Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.

59 changes: 55 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,50 @@ 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);
// Ensure origin-form per RFC 7230 §5.3.1 (leading '/').
if (requestPath.isEmpty()) {
resource = makeString('/', m_url.queryWithLeadingQuestionMark());
} else if (requestPath.startsWith('/')) {
resource = makeString(requestPath, m_url.queryWithLeadingQuestionMark());
} else {
resource = makeString('/', requestPath, m_url.queryWithLeadingQuestionMark());
}
}
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 +633,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 +713,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 +726,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
44 changes: 44 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 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
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 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
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,44 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type {
}
}

// 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) {
// SNI uses the URL host (defaulted to "localhost" in
// C++ when absent), mirroring the TCP path below. A
// user-supplied Host header does NOT affect SNI; use
// `tls: { checkServerIdentity }` or put the hostname
// in the URL (wss+unix://name/path) to verify against
// a specific certificate name.
if (host_slice.slice().len > 0 and !strings.isIPAddress(host_slice.slice())) {
client.hostname = bun.default_allocator.dupeZ(u8, host_slice.slice()) catch "";
}
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
211 changes: 211 additions & 0 deletions test/js/web/websocket/websocket-unix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tls as tlsCert } from "harness";
import { tmpdir } from "node:os";
import { join } from "node:path";

// 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: "inherit",
});

const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout.trim()).toBe("pong:from-child");
expect(exitCode).toBe(0);
});
});
Loading