Skip to content

Commit b18f268

Browse files
WebSocket client: support ws+unix:// and wss+unix:// (#29203)
## What does this PR do? Adds Unix domain socket support to the WebSocket client via the `ws+unix://` and `wss+unix://` URL schemes. ```js // Plain const ws = new WebSocket("ws+unix:///tmp/app.sock"); // With a request path (split on first ':', same as the npm `ws` package) const ws = new WebSocket("ws+unix:///tmp/app.sock:/api/stream?x=1"); // TLS over a unix socket const ws = new WebSocket("wss+unix:///tmp/app.sock", { tls: { rejectUnauthorized: false }, }); ``` - `Host` header defaults to `localhost`, matching Node's `http.request({ socketPath })` and `ws`. - Proxies are ignored for unix URLs (the socket is local). - `wss+unix://` selects the TLS socket context and runs the normal handshake over the domain socket. ## How did you verify your code works? New test file `test/js/web/websocket/websocket-unix.test.ts` covers: - echo over `Bun.serve({ unix })` - `:path` + query string parsing and `Host` header value - binary frames - connect failure when the socket file does not exist - `SyntaxError` on missing socket path - `wss+unix://` against `Bun.serve({ unix, tls })` - round-trip from a spawned subprocess ``` 7 pass 0 fail ``` Existing `websocket-client.test.ts` and `websocket-custom-headers.test.ts` continue to pass. ## Implementation - `WebSocket.cpp`: recognise `ws+unix:` / `wss+unix:`, split pathname on the first `:` into socket path + request path, default host to `localhost`, skip proxy, pass the socket path through to Zig. - `WebSocketUpgradeClient.zig`: new `unix_socket_path` arg; when set, dial via `Socket.connectUnixAnon` on the existing per-SSL `us_socket_context` instead of the TCP `connectPtr` path. All downstream state (upgrade, adopt, deflate, custom SSL ctx) is unchanged. - `headers.h`: add the parameter to both `Bun__WebSocketHTTP{,S}Client__connect` externs. Fixes #4423 --------- Co-authored-by: robobun <robobun@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 4d78bd1 commit b18f268

4 files changed

Lines changed: 314 additions & 6 deletions

File tree

src/bun.js/bindings/headers.h

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/bun.js/bindings/webcore/WebSocket.cpp

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,10 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
462462
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength()) };
463463
}
464464

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

467-
if (!m_url.protocolIs("http"_s) && !m_url.protocolIs("ws"_s) && !is_secure) {
468+
if (!m_url.protocolIs("http"_s) && !m_url.protocolIs("ws"_s) && !is_secure && !is_unix) {
468469
// context.addConsoleMessage(MessageSource::JS, MessageLevel::Error, );
469470
m_state = CLOSED;
470471
updateHasPendingActivity();
@@ -550,8 +551,50 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
550551
// tag and corrupt the HTTP upgrade request build in Zig.
551552
String hostString = m_url.host().toString();
552553
auto resource = resourceName(m_url);
554+
String unixSocketPathString;
555+
if (is_unix) {
556+
// ws+unix:///path/to/sock.sock[:/request/path][?query]
557+
// The URL pathname is "/path/to/sock.sock:/request/path". Split on the
558+
// first ':' into the socket path and the HTTP request path, matching
559+
// the npm `ws` package's ws+unix: handling. Anything after the first
560+
// colon becomes the request path; if there is no colon the request
561+
// path is "/" (plus any query string).
562+
auto pathname = m_url.path();
563+
if (pathname.isEmpty()) {
564+
m_state = CLOSED;
565+
updateHasPendingActivity();
566+
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) };
567+
}
568+
size_t colon = pathname.find(':');
569+
if (colon == notFound) {
570+
unixSocketPathString = pathname.toString();
571+
resource = makeString('/', m_url.queryWithLeadingQuestionMark());
572+
} else {
573+
unixSocketPathString = pathname.left(colon).toString();
574+
auto requestPath = pathname.substring(colon + 1);
575+
// Ensure origin-form per RFC 7230 §5.3.1 (leading '/').
576+
if (requestPath.isEmpty()) {
577+
resource = makeString('/', m_url.queryWithLeadingQuestionMark());
578+
} else if (requestPath.startsWith('/')) {
579+
resource = makeString(requestPath, m_url.queryWithLeadingQuestionMark());
580+
} else {
581+
resource = makeString('/', requestPath, m_url.queryWithLeadingQuestionMark());
582+
}
583+
}
584+
if (unixSocketPathString.isEmpty()) {
585+
m_state = CLOSED;
586+
updateHasPendingActivity();
587+
return Exception { SyntaxError, makeString("Invalid url for WebSocket "_s, m_url.stringCenterEllipsizedToLength(), " (missing unix socket path)"_s) };
588+
}
589+
// Host header defaults to "localhost" over a unix socket, matching
590+
// Node's http.request({ socketPath }) and the npm `ws` package.
591+
if (hostString.isEmpty()) {
592+
hostString = "localhost"_s;
593+
}
594+
}
553595
BunString host = Bun::toString(hostString);
554596
BunString path = Bun::toString(resource);
597+
BunString unixSocketPath = Bun::toString(unixSocketPathString);
555598
BunString clientProtocolString = Bun::toString(protocolString);
556599
uint16_t port = is_secure ? 443 : 80;
557600
if (auto userPort = m_url.port()) {
@@ -590,6 +633,12 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
590633
// Determine connection type based on proxy usage and TLS requirements
591634
bool hasProxy = proxyConfig.has_value();
592635

636+
// Unix domain sockets are local; proxies do not apply.
637+
if (is_unix) {
638+
proxyConfig = std::nullopt;
639+
hasProxy = false;
640+
}
641+
593642
// Check NO_PROXY even for explicitly-provided proxies
594643
if (hasProxy) {
595644
auto hostStr = m_url.host().toString();
@@ -664,7 +713,8 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
664713
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
665714
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
666715
sslConfig, is_secure,
667-
targetAuthorization.isEmpty() ? nullptr : &targetAuth);
716+
targetAuthorization.isEmpty() ? nullptr : &targetAuth,
717+
is_unix ? &unixSocketPath : nullptr);
668718
} else {
669719
us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext<false>();
670720
RELEASE_ASSERT(ctx);
@@ -676,7 +726,8 @@ ExceptionOr<void> WebSocket::connect(const String& url, const Vector<String>& pr
676726
(hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr,
677727
proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(),
678728
sslConfig, is_secure,
679-
targetAuthorization.isEmpty() ? nullptr : &targetAuth);
729+
targetAuthorization.isEmpty() ? nullptr : &targetAuth,
730+
is_unix ? &unixSocketPath : nullptr);
680731
}
681732

682733
proxyHeaderValues.clear();

src/http/websocket_client/WebSocketUpgradeClient.zig

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
120120
target_is_secure: bool,
121121
// Target URL authorization (Basic auth from ws://user:pass@host)
122122
target_authorization: ?*const bun.String,
123+
// Unix domain socket path for ws+unix:// / wss+unix:// (null for TCP)
124+
unix_socket_path: ?*const bun.String,
123125
) callconv(.c) ?*HTTPClient {
124126
const vm = global.bunVM();
125127

@@ -153,6 +155,10 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
153155
defer if (target_authorization_slice) |s| s.deinit();
154156
if (target_authorization) |ta| target_authorization_slice = ta.toUTF8(allocator);
155157

158+
var unix_socket_path_slice: ?jsc.ZigString.Slice = null;
159+
defer if (unix_socket_path_slice) |s| s.deinit();
160+
if (unix_socket_path) |usp| unix_socket_path_slice = usp.toUTF8(allocator);
161+
156162
const using_proxy = proxy_host != null;
157163

158164
// Check if user provided a custom protocol for subprotocols validation
@@ -307,6 +313,44 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
307313
}
308314
}
309315

316+
// Unix domain socket path (ws+unix:// / wss+unix://)
317+
if (unix_socket_path_slice) |usp| {
318+
if (Socket.connectUnixAnon(
319+
usp.slice(),
320+
connect_ctx,
321+
client,
322+
false,
323+
)) |socket| {
324+
client.tcp = socket;
325+
if (client.state == .failed) {
326+
client.deref();
327+
return null;
328+
}
329+
bun.analytics.Features.WebSocket += 1;
330+
331+
if (comptime ssl) {
332+
// SNI uses the URL host (defaulted to "localhost" in
333+
// C++ when absent), mirroring the TCP path below. A
334+
// user-supplied Host header does NOT affect SNI; use
335+
// `tls: { checkServerIdentity }` or put the hostname
336+
// in the URL (wss+unix://name/path) to verify against
337+
// a specific certificate name.
338+
if (host_slice.slice().len > 0 and !strings.isIPAddress(host_slice.slice())) {
339+
client.hostname = bun.default_allocator.dupeZ(u8, host_slice.slice()) catch "";
340+
}
341+
}
342+
343+
client.tcp.timeout(120);
344+
client.state = .reading;
345+
// +1 for cpp_websocket
346+
client.ref();
347+
return client;
348+
} else |_| {
349+
client.deref();
350+
}
351+
return null;
352+
}
353+
310354
if (Socket.connectPtr(
311355
display_host,
312356
connect_port,
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { bunEnv, bunExe, isWindows, tls as tlsCert } from "harness";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
// Unix domain sockets are not supported on Windows via ws+unix://
7+
// (uSockets uses AF_UNIX which has limited support there).
8+
describe.skipIf(isWindows)("WebSocket over unix domain socket", () => {
9+
function sockPath(name: string) {
10+
// Keep it short to stay under sun_path limits on macOS/Linux.
11+
return join(tmpdir(), `bun.ws.${name}.${process.pid}.${Date.now().toString(36)}.sock`);
12+
}
13+
14+
test("ws+unix:// echoes through Bun.serve({ unix })", async () => {
15+
const unix = sockPath("echo");
16+
await using server = Bun.serve({
17+
unix,
18+
fetch(req, server) {
19+
if (server.upgrade(req)) return;
20+
return new Response("not a websocket", { status: 400 });
21+
},
22+
websocket: {
23+
open(ws) {
24+
ws.send("hello from server");
25+
},
26+
message(ws, message) {
27+
ws.send(message);
28+
},
29+
},
30+
});
31+
32+
const ws = new WebSocket(`ws+unix://${unix}`);
33+
const received: string[] = [];
34+
const { promise, resolve, reject } = Promise.withResolvers<void>();
35+
36+
ws.onerror = e => reject(e);
37+
ws.onopen = () => {
38+
ws.send("ping over unix");
39+
};
40+
ws.onmessage = e => {
41+
received.push(String(e.data));
42+
if (received.length === 2) {
43+
ws.close();
44+
}
45+
};
46+
ws.onclose = e => {
47+
resolve();
48+
};
49+
50+
await promise;
51+
52+
expect(received).toEqual(["hello from server", "ping over unix"]);
53+
expect(ws.url).toBe(`ws+unix://${unix}`);
54+
});
55+
56+
test("ws+unix:// with ':path' after socket path", async () => {
57+
const unix = sockPath("path");
58+
let seenUrl = "";
59+
let seenHost = "";
60+
await using server = Bun.serve({
61+
unix,
62+
fetch(req, server) {
63+
seenUrl = new URL(req.url).pathname + new URL(req.url).search;
64+
seenHost = req.headers.get("host") ?? "";
65+
if (server.upgrade(req)) return;
66+
return new Response("not a websocket", { status: 400 });
67+
},
68+
websocket: {
69+
message(ws, message) {
70+
ws.send(`echo:${message}`);
71+
},
72+
},
73+
});
74+
75+
const ws = new WebSocket(`ws+unix://${unix}:/api/v1/stream?x=1`);
76+
const { promise, resolve, reject } = Promise.withResolvers<string>();
77+
ws.onerror = e => reject(e);
78+
ws.onopen = () => ws.send("hi");
79+
ws.onmessage = e => {
80+
resolve(String(e.data));
81+
ws.close();
82+
};
83+
84+
const got = await promise;
85+
expect(got).toBe("echo:hi");
86+
expect(seenUrl).toBe("/api/v1/stream?x=1");
87+
// Host header defaults to "localhost" over a unix socket, matching Node.
88+
expect(seenHost).toBe("localhost");
89+
});
90+
91+
test("ws+unix:// sends binary data", async () => {
92+
const unix = sockPath("bin");
93+
await using server = Bun.serve({
94+
unix,
95+
fetch(req, server) {
96+
if (server.upgrade(req)) return;
97+
return new Response("no", { status: 400 });
98+
},
99+
websocket: {
100+
message(ws, message) {
101+
ws.sendBinary(message as Uint8Array);
102+
},
103+
},
104+
});
105+
106+
const ws = new WebSocket(`ws+unix://${unix}`);
107+
ws.binaryType = "arraybuffer";
108+
const payload = new Uint8Array([1, 2, 3, 4, 5, 255]);
109+
const { promise, resolve, reject } = Promise.withResolvers<ArrayBuffer>();
110+
ws.onerror = e => reject(e);
111+
ws.onopen = () => ws.send(payload);
112+
ws.onmessage = e => {
113+
resolve(e.data);
114+
ws.close();
115+
};
116+
const got = new Uint8Array(await promise);
117+
expect([...got]).toEqual([...payload]);
118+
});
119+
120+
test("ws+unix:// connection failure emits close when socket file does not exist", async () => {
121+
const unix = sockPath("missing");
122+
const ws = new WebSocket(`ws+unix://${unix}`);
123+
const { promise, resolve } = Promise.withResolvers<{ code: number; gotError: boolean }>();
124+
let gotError = false;
125+
ws.onerror = () => {
126+
gotError = true;
127+
};
128+
ws.onclose = e => resolve({ code: e.code, gotError });
129+
const { code, gotError: sawError } = await promise;
130+
expect(sawError).toBe(true);
131+
expect(code).toBe(1006);
132+
});
133+
134+
test("ws+unix:// without a socket path throws SyntaxError", () => {
135+
expect(() => new WebSocket("ws+unix://")).toThrow(SyntaxError);
136+
});
137+
138+
test("wss+unix:// connects to a TLS server over a unix socket", async () => {
139+
const unix = sockPath("tls");
140+
await using server = Bun.serve({
141+
unix,
142+
tls: tlsCert,
143+
fetch(req, server) {
144+
if (server.upgrade(req)) return;
145+
return new Response("no", { status: 400 });
146+
},
147+
websocket: {
148+
message(ws, message) {
149+
ws.send(`secure:${message}`);
150+
},
151+
},
152+
});
153+
154+
const ws = new WebSocket(`wss+unix://${unix}`, {
155+
// @ts-expect-error bun extension
156+
tls: { rejectUnauthorized: false },
157+
});
158+
const { promise, resolve, reject } = Promise.withResolvers<string>();
159+
ws.onerror = e => reject(e);
160+
ws.onopen = () => ws.send("hello");
161+
ws.onmessage = e => {
162+
resolve(String(e.data));
163+
ws.close();
164+
};
165+
const got = await promise;
166+
expect(got).toBe("secure:hello");
167+
});
168+
169+
test("works from a subprocess", async () => {
170+
const unix = sockPath("sp");
171+
await using server = Bun.serve({
172+
unix,
173+
fetch(req, server) {
174+
if (server.upgrade(req)) return;
175+
return new Response("no", { status: 400 });
176+
},
177+
websocket: {
178+
message(ws, message) {
179+
ws.send(`pong:${message}`);
180+
},
181+
},
182+
});
183+
184+
await using proc = Bun.spawn({
185+
cmd: [
186+
bunExe(),
187+
"-e",
188+
`
189+
const ws = new WebSocket(process.argv[1]);
190+
ws.onopen = () => ws.send("from-child");
191+
ws.onmessage = e => {
192+
console.log(String(e.data));
193+
ws.close();
194+
};
195+
ws.onerror = e => {
196+
console.error("error", e && e.message);
197+
process.exit(1);
198+
};
199+
`,
200+
`ws+unix://${unix}`,
201+
],
202+
env: bunEnv,
203+
stdout: "pipe",
204+
stderr: "inherit",
205+
});
206+
207+
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
208+
expect(stdout.trim()).toBe("pong:from-child");
209+
expect(exitCode).toBe(0);
210+
});
211+
});

0 commit comments

Comments
 (0)