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

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 @@ 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,41 @@ 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) {
// 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 "";
}
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, 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: "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