Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/bun.js/bindings/headers.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 48 additions & 4 deletions src/bun.js/bindings/webcore/WebSocket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,10 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength()) };
}

bool is_secure = m_url.protocolIs("wss"_s) || m_url.protocolIs("https"_s);
bool is_unix = m_url.protocolIs("ws+unix"_s) || m_url.protocolIs("wss+unix"_s);
bool is_secure = m_url.protocolIs("wss"_s) || m_url.protocolIs("https"_s) || m_url.protocolIs("wss+unix"_s);

if (!m_url.protocolIs("http"_s) && !m_url.protocolIs("ws"_s) && !is_secure) {
if (!m_url.protocolIs("http"_s) && !m_url.protocolIs("ws"_s) && !is_secure && !is_unix) {
// context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, );
m_state = CLOSED;
updateHasPendingActivity();
Expand Down Expand Up @@ -550,8 +551,43 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
// tag and corrupt the HTTP upgrade request build in Zig.
String hostString = m_url.host().toString();
auto resource = resourceName(m_url);
String unixSocketPathString;
if (is_unix) {
// ws+unix:///path/to/sock.sock[:/request/path][?query]
// The URL pathname is "/path/to/sock.sock:/request/path". Split on the
// first ':' into the socket path and the HTTP request path, matching
// the npm `ws` package's ws+unix: handling. Anything after the first
// colon becomes the request path; if there is no colon the request
// path is "/" (plus any query string).
auto pathname = m_url.path();
if (pathname.isEmpty()) {
m_state = CLOSED;
updateHasPendingActivity();
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) };
}
size_t colon = pathname.find(':');
if (colon == notFound) {
unixSocketPathString = pathname.toString();
resource = makeString('/', m_url.queryWithLeadingQuestionMark());
} else {
unixSocketPathString = pathname.left(colon).toString();
auto requestPath = pathname.substring(colon + 1);
resource = makeString(requestPath.isEmpty() ? "/"_s : requestPath, m_url.queryWithLeadingQuestionMark());
Comment thread
robobun marked this conversation as resolved.
Outdated
}
if (unixSocketPathString.isEmpty()) {
m_state = CLOSED;
updateHasPendingActivity();
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) };
}
// Host header defaults to "localhost" over a unix socket, matching
// Node's http.request({ socketPath }) and the npm `ws` package.
if (hostString.isEmpty()) {
hostString = "localhost"_s;
}
}
BunString host = Bun::toString(hostString);
BunString path = Bun::toString(resource);
BunString unixSocketPath = Bun::toString(unixSocketPathString);
BunString clientProtocolString = Bun::toString(protocolString);
uint16_t port = is_secure ? 443 : 80;
if (auto userPort = m_url.port()) {
Expand Down Expand Up @@ -590,6 +626,12 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
// Determine connection type based on proxy usage and TLS requirements
bool hasProxy = proxyConfig.has_value();

// Unix domain sockets are local; proxies do not apply.
if (is_unix) {
proxyConfig = std::nullopt;
hasProxy = false;
}

// Check NO_PROXY even for explicitly-provided proxies
if (hasProxy) {
auto hostStr = m_url.host().toString();
Expand Down Expand Up @@ -664,7 +706,8 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
sslConfig, is_secure,
targetAuthorization.isEmpty() ? nullptr : &targetAuth);
targetAuthorization.isEmpty() ? nullptr : &targetAuth,
is_unix ? &unixSocketPath : nullptr);
} else {
us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext<false>();
RELEASE_ASSERT(ctx);
Expand All @@ -676,7 +719,8 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
sslConfig, is_secure,
targetAuthorization.isEmpty() ? nullptr : &targetAuth);
targetAuthorization.isEmpty() ? nullptr : &targetAuth,
is_unix ? &unixSocketPath : nullptr);
}

proxyHeaderValues.clear();
Expand Down
41 changes: 41 additions & 0 deletions src/http/websocket_client/WebSocketUpgradeClient.zig
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@
target_is_secure: bool,
// Target URL authorization (Basic auth from ws://user:pass@host)
target_authorization: ?*const bun.String,
// Unix domain socket path for ws+unix:// / wss+unix:// (null for TCP)
unix_socket_path: ?*const bun.String,
) callconv(.c) ?*HTTPClient {
const vm = global.bunVM();

Expand Down Expand Up @@ -153,6 +155,10 @@
defer if (target_authorization_slice) |s| s.deinit();
if (target_authorization) |ta| target_authorization_slice = ta.toUTF8(allocator);

var unix_socket_path_slice: ?jsc.ZigString.Slice = null;
defer if (unix_socket_path_slice) |s| s.deinit();
if (unix_socket_path) |usp| unix_socket_path_slice = usp.toUTF8(allocator);

const using_proxy = proxy_host != null;

// Check if user provided a custom protocol for subprotocols validation
Expand Down Expand Up @@ -307,6 +313,41 @@
}
}

// Unix domain socket path (ws+unix:// / wss+unix://)
if (unix_socket_path_slice) |usp| {
if (Socket.connectUnixAnon(
usp.slice(),
connect_ctx,
client,
false,
)) |socket| {
client.tcp = socket;
if (client.state == .failed) {
client.deref();
return null;
Comment thread
robobun marked this conversation as resolved.
}
bun.analytics.Features.WebSocket += 1;

if (comptime ssl) {
// For wss+unix://, the URL host (or a user-supplied Host
// header) drives SNI; there is no network hostname to fall
// back on so skip SNI if the host is empty or an IP.
if (host_slice.slice().len > 0 and !strings.isIPAddress(host_slice.slice())) {
client.hostname = bun.default_allocator.dupeZ(u8, host_slice.slice()) catch "";
}

Check failure on line 337 in src/http/websocket_client/WebSocketUpgradeClient.zig

View check run for this annotation

Claude / Claude Code Review

Misleading SNI comment: user Host header does not drive SNI for wss+unix://

The comment at WebSocketUpgradeClient.zig:332-334 claims that a user-supplied Host header drives SNI for wss+unix://, but the implementation only uses host_slice (the URL host, defaulting to "localhost") for client.hostname. Users who follow the comment's implied contract—providing a Host header to control SNI—will get TLS handshake failures when rejectUnauthorized is true and the server cert is issued for the Host header value rather than localhost.
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}

client.tcp.timeout(120);
client.state = .reading;
// +1 for cpp_websocket
client.ref();
return client;
} else |_| {
client.deref();
}
return null;
}

if (Socket.connectPtr(
display_host,
connect_port,
Expand Down
212 changes: 212 additions & 0 deletions test/js/web/websocket/websocket-unix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, test, expect } from "bun:test";
import { bunEnv, bunExe, isWindows, tls as tlsCert } from "harness";
import { join } from "node:path";
import { tmpdir } from "node:os";

// Unix domain sockets are not supported on Windows via ws+unix://
// (uSockets uses AF_UNIX which has limited support there).
describe.skipIf(isWindows)("WebSocket over unix domain socket", () => {
function sockPath(name: string) {
// Keep it short to stay under sun_path limits on macOS/Linux.
return join(tmpdir(), `bun.ws.${name}.${process.pid}.${Date.now().toString(36)}.sock`);
}

test("ws+unix:// echoes through Bun.serve({ unix })", async () => {
const unix = sockPath("echo");
await using server = Bun.serve({
unix,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("not a websocket", { status: 400 });
},
websocket: {
open(ws) {
ws.send("hello from server");
},
message(ws, message) {
ws.send(message);
},
},
});

const ws = new WebSocket(`ws+unix://${unix}`);
const received: string[] = [];
const { promise, resolve, reject } = Promise.withResolvers<void>();

ws.onerror = e => reject(e);
ws.onopen = () => {
ws.send("ping over unix");
};
ws.onmessage = e => {
received.push(String(e.data));
if (received.length === 2) {
ws.close();
}
};
ws.onclose = e => {
resolve();
};

await promise;

expect(received).toEqual(["hello from server", "ping over unix"]);
expect(ws.url).toBe(`ws+unix://${unix}`);
});

test("ws+unix:// with ':path' after socket path", async () => {
const unix = sockPath("path");
let seenUrl = "";
let seenHost = "";
await using server = Bun.serve({
unix,
fetch(req, server) {
seenUrl = new URL(req.url).pathname + new URL(req.url).search;
seenHost = req.headers.get("host") ?? "";
if (server.upgrade(req)) return;
return new Response("not a websocket", { status: 400 });
},
websocket: {
message(ws, message) {
ws.send(`echo:${message}`);
},
},
});

const ws = new WebSocket(`ws+unix://${unix}:/api/v1/stream?x=1`);
const { promise, resolve, reject } = Promise.withResolvers<string>();
ws.onerror = e => reject(e);
ws.onopen = () => ws.send("hi");
ws.onmessage = e => {
resolve(String(e.data));
ws.close();
};

const got = await promise;
expect(got).toBe("echo:hi");
expect(seenUrl).toBe("/api/v1/stream?x=1");
// Host header defaults to "localhost" over a unix socket, matching Node.
expect(seenHost).toBe("localhost");
});

test("ws+unix:// sends binary data", async () => {
const unix = sockPath("bin");
await using server = Bun.serve({
unix,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("no", { status: 400 });
},
websocket: {
message(ws, message) {
ws.sendBinary(message as Uint8Array);
},
},
});

const ws = new WebSocket(`ws+unix://${unix}`);
ws.binaryType = "arraybuffer";
const payload = new Uint8Array([1, 2, 3, 4, 5, 255]);
const { promise, resolve, reject } = Promise.withResolvers<ArrayBuffer>();
ws.onerror = e => reject(e);
ws.onopen = () => ws.send(payload);
ws.onmessage = e => {
resolve(e.data);
ws.close();
};
const got = new Uint8Array(await promise);
expect([...got]).toEqual([...payload]);
});

test("ws+unix:// connection failure emits close when socket file does not exist", async () => {
const unix = sockPath("missing");
const ws = new WebSocket(`ws+unix://${unix}`);
const { promise, resolve } = Promise.withResolvers<{ code: number; gotError: boolean }>();
let gotError = false;
ws.onerror = () => {
gotError = true;
};
ws.onclose = e => resolve({ code: e.code, gotError });
const { code, gotError: sawError } = await promise;
expect(sawError).toBe(true);
expect(code).toBe(1006);
});

test("ws+unix:// without a socket path throws SyntaxError", () => {
expect(() => new WebSocket("ws+unix://")).toThrow(SyntaxError);
});

test("wss+unix:// connects to a TLS server over a unix socket", async () => {
const unix = sockPath("tls");
await using server = Bun.serve({
unix,
tls: tlsCert,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("no", { status: 400 });
},
websocket: {
message(ws, message) {
ws.send(`secure:${message}`);
},
},
});

const ws = new WebSocket(`wss+unix://${unix}`, {
// @ts-expect-error bun extension
tls: { rejectUnauthorized: false },
});
const { promise, resolve, reject } = Promise.withResolvers<string>();
ws.onerror = e => reject(e);
ws.onopen = () => ws.send("hello");
ws.onmessage = e => {
resolve(String(e.data));
ws.close();
};
const got = await promise;
expect(got).toBe("secure:hello");
});

test("works from a subprocess", async () => {
const unix = sockPath("sp");
await using server = Bun.serve({
unix,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("no", { status: 400 });
},
websocket: {
message(ws, message) {
ws.send(`pong:${message}`);
},
},
});

await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const ws = new WebSocket(process.argv[1]);
ws.onopen = () => ws.send("from-child");
ws.onmessage = e => {
console.log(String(e.data));
ws.close();
};
ws.onerror = e => {
console.error("error", e && e.message);
process.exit(1);
};
`,
`ws+unix://${unix}`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("pong:from-child");
expect(stderr).not.toContain("error");
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
});
Loading