@specific.dev/spectest 0.89.0 → 0.89.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -51,6 +51,7 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
51
51
  import { encodeRegistry } from "./harness/names-registry.js";
52
52
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
53
53
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
54
+ import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
54
55
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
55
56
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
56
57
  import { startTlsTerminator } from "./harness/tls-terminator.js";
@@ -1977,8 +1978,8 @@ async function unbindRuntimeTls(hostname) {
1977
1978
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
1978
1979
  * dispatches every request to the matching Route by Host header.
1979
1980
  *
1980
- * Shared by the HTTP and HTTPS branches. Also exports a `websocket`
1981
- * handler so reverse-proxy targets can transparently bridge WS
1981
+ * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
1982
+ * handler set so reverse-proxy targets can transparently bridge WS
1982
1983
  * upgrades through to their upstream service.
1983
1984
  */
1984
1985
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2016,81 +2017,9 @@ serve = { proto: "http" }) {
2016
2017
  idleTimeout: 0,
2017
2018
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2018
2019
  fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
2019
- websocket: {
2020
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2021
- async open(ws) {
2022
- const data = ws.data;
2023
- try {
2024
- const upstream = new WebSocket(data.upstreamUrl);
2025
- // ArrayBuffer so binary frames can be ws.send()'d to the
2026
- // downstream client verbatim — Blob would need an extra
2027
- // .arrayBuffer() round-trip on every message.
2028
- upstream.binaryType = "arraybuffer";
2029
- data.upstream = upstream;
2030
- upstream.addEventListener("open", () => {
2031
- for (const m of data.pending)
2032
- upstream.send(m);
2033
- data.pending = [];
2034
- });
2035
- upstream.addEventListener("message", (ev) => {
2036
- try {
2037
- ws.send(ev.data);
2038
- }
2039
- catch {
2040
- /* client gone */
2041
- }
2042
- });
2043
- upstream.addEventListener("close", (ev) => {
2044
- try {
2045
- ws.close(ev.code, ev.reason);
2046
- }
2047
- catch {
2048
- /* already closed */
2049
- }
2050
- });
2051
- upstream.addEventListener("error", () => {
2052
- try {
2053
- ws.close(1011, "upstream error");
2054
- }
2055
- catch {
2056
- /* already closed */
2057
- }
2058
- });
2059
- }
2060
- catch (err) {
2061
- // eslint-disable-next-line no-console
2062
- console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
2063
- try {
2064
- ws.close(1011, "upstream open failed");
2065
- }
2066
- catch {
2067
- /* ignore */
2068
- }
2069
- }
2070
- },
2071
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2072
- message(ws, message) {
2073
- const data = ws.data;
2074
- const payload = typeof message === "string" ? message : new Uint8Array(message);
2075
- if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
2076
- data.upstream.send(payload);
2077
- }
2078
- else {
2079
- // Buffer until the upstream finishes its handshake.
2080
- data.pending.push(payload);
2081
- }
2082
- },
2083
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2084
- close(ws, code, reason) {
2085
- const data = ws.data;
2086
- try {
2087
- data.upstream?.close(code, reason);
2088
- }
2089
- catch {
2090
- /* ignore */
2091
- }
2092
- },
2093
- },
2020
+ // The frame relay for bridged WebSocket upgrades; `proxyToService`
2021
+ // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
2022
+ websocket: WS_BRIDGE_HANDLERS,
2094
2023
  };
2095
2024
  return Bun.serve(opts);
2096
2025
  }
@@ -2181,8 +2110,12 @@ function ingressClientIp(server, req, proto) {
2181
2110
  * Reverse-proxy a request to `http://<service>:<port>` on
2182
2111
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
2183
2112
  *
2184
- * - WS upgrade requests get routed through `server.upgrade()`, with
2185
- * the upstream URL stashed on `ws.data`. The shared `websocket`
2113
+ * - WS upgrade requests open the upstream WebSocket first, with the
2114
+ * client's full `Sec-WebSocket-Protocol` list and its forwardable
2115
+ * headers (cookies, authorization, origin, x-forwarded-*). Only after
2116
+ * that handshake succeeds is the client upgraded, with the protocol
2117
+ * the upstream selected; a refused upstream handshake is a 502, not a
2118
+ * 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
2186
2119
  * handler opens the upstream and bridges frames both ways.
2187
2120
  * - Plain requests pass through via `fetch()` with hop-by-hop
2188
2121
  * headers stripped; the response body is a ReadableStream returned
@@ -2205,16 +2138,38 @@ server, service, port, listenerLabel, proto) {
2205
2138
  const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
2206
2139
  if (upgrade === "websocket") {
2207
2140
  const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
2208
- const wsData = {
2209
- upstreamUrl,
2210
- upstream: null,
2211
- pending: [],
2212
- };
2213
- const ok = server.upgrade(req, { data: wsData });
2141
+ // The upstream handshake carries what the client's did: the whole
2142
+ // subprotocol list (a console ticket often rides as the second entry)
2143
+ // and the forwardable headers. Same provenance stamps as the HTTP path.
2144
+ const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2145
+ const headers = upstreamHandshakeHeaders(req.headers, {
2146
+ service,
2147
+ port,
2148
+ proto,
2149
+ clientIp: ingressClientIp(server, req, proto),
2150
+ });
2151
+ const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
2152
+ if (!opened.ok) {
2153
+ return new Response(`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`, { status: 502, headers: { "content-type": "text/plain" } });
2154
+ }
2155
+ const wsData = { upstreamUrl, upstream: opened.socket };
2156
+ // Echo the protocol the upstream selected; Bun would otherwise pick
2157
+ // the client's first entry by itself, which can differ.
2158
+ const selected = opened.socket.protocol;
2159
+ const ok = server.upgrade(req, {
2160
+ data: wsData,
2161
+ headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2162
+ });
2214
2163
  if (ok) {
2215
2164
  // Bun has already taken over the response — return a stub.
2216
2165
  return new Response(null, { status: 101 });
2217
2166
  }
2167
+ try {
2168
+ opened.socket.close(1011, "client upgrade refused");
2169
+ }
2170
+ catch {
2171
+ /* ignore */
2172
+ }
2218
2173
  return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
2219
2174
  }
2220
2175
  const fwdHeaders = new Headers();
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The ingress WebSocket bridge: one client WebSocket on an ingress
3
+ * listener, one upstream WebSocket to the service, frames copied in both
4
+ * directions.
5
+ *
6
+ * The handshake is the part that matters. A client that sends
7
+ * `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
8
+ * the full list, and expects to get back the one the upstream selected.
9
+ * The first version of this bridge opened the upstream with no protocols
10
+ * and no headers, so a ticket or a cookie that rode on the handshake never
11
+ * reached the service, and the service refused the connection after the
12
+ * client already saw a 101. So the bridge now opens the upstream FIRST,
13
+ * with the client's protocol list and forwardable headers, waits for the
14
+ * upstream handshake, and only then upgrades the client with the protocol
15
+ * the upstream selected. An upstream that refuses the handshake becomes a
16
+ * 502 to the client, the same as the HTTP proxy path.
17
+ *
18
+ * `daemon.ts` owns the listener and the route lookup; this module owns
19
+ * the handshake and the frame relay so the two can be tested on loopback
20
+ * without a VM.
21
+ */
22
+ /** Per-bridge context stored on `ws.data` of the client socket. */
23
+ export interface WsBridgeData {
24
+ upstreamUrl: string;
25
+ /** Open by the time the client socket exists: the handshake awaited it. */
26
+ upstream: WebSocket;
27
+ }
28
+ /** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
29
+ * trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
30
+ * the server pick from the list, so order is the client's preference). */
31
+ export declare function parseSubprotocols(header: string | null | undefined): string[];
32
+ /**
33
+ * The headers the upstream handshake carries: everything forwardable from
34
+ * the client's request (cookies, authorization, origin, user-agent …),
35
+ * minus the client's own handshake fields, plus the proxy provenance
36
+ * headers and a Host that names the service.
37
+ */
38
+ export declare function upstreamHandshakeHeaders(req: Headers, opts: {
39
+ service: string;
40
+ port: number;
41
+ proto: string;
42
+ clientIp?: string;
43
+ }): Record<string, string>;
44
+ export type UpstreamOpen = {
45
+ ok: true;
46
+ socket: WebSocket;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ };
51
+ /**
52
+ * Open the upstream WebSocket and wait for its handshake. Resolves once
53
+ * the socket is open, or once it failed; the handshake listeners are
54
+ * removed either way so a later close does not fire them again.
55
+ */
56
+ export declare function openUpstreamWebSocket(url: string, opts: {
57
+ protocols: string[];
58
+ headers: Record<string, string>;
59
+ timeoutMs?: number;
60
+ }): Promise<UpstreamOpen>;
61
+ /** The minimum of Bun's ServerWebSocket that the relay uses. */
62
+ interface ClientSocket {
63
+ data: WsBridgeData;
64
+ send(data: string | ArrayBuffer | Uint8Array): unknown;
65
+ close(code?: number, reason?: string): unknown;
66
+ }
67
+ /**
68
+ * The `websocket` handler set for `Bun.serve`. The upstream is already
69
+ * open when `open()` runs, so frames go straight through in both
70
+ * directions, and a close on either side closes the other with the same
71
+ * code and reason.
72
+ */
73
+ export declare const WS_BRIDGE_HANDLERS: {
74
+ open(ws: ClientSocket): void;
75
+ message(ws: ClientSocket, message: string | Buffer): void;
76
+ close(ws: ClientSocket, code: number, reason: string): void;
77
+ };
78
+ export {};
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The ingress WebSocket bridge: one client WebSocket on an ingress
3
+ * listener, one upstream WebSocket to the service, frames copied in both
4
+ * directions.
5
+ *
6
+ * The handshake is the part that matters. A client that sends
7
+ * `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
8
+ * the full list, and expects to get back the one the upstream selected.
9
+ * The first version of this bridge opened the upstream with no protocols
10
+ * and no headers, so a ticket or a cookie that rode on the handshake never
11
+ * reached the service, and the service refused the connection after the
12
+ * client already saw a 101. So the bridge now opens the upstream FIRST,
13
+ * with the client's protocol list and forwardable headers, waits for the
14
+ * upstream handshake, and only then upgrades the client with the protocol
15
+ * the upstream selected. An upstream that refuses the handshake becomes a
16
+ * 502 to the client, the same as the HTTP proxy path.
17
+ *
18
+ * `daemon.ts` owns the listener and the route lookup; this module owns
19
+ * the handshake and the frame relay so the two can be tested on loopback
20
+ * without a VM.
21
+ */
22
+ import { forwardableHeaders } from "./http-proxy.js";
23
+ /** Headers that belong to the client's own WebSocket handshake. Bun's
24
+ * WebSocket client mints its own key and version, and the protocol list
25
+ * goes through the constructor option, so these never travel as plain
26
+ * headers. */
27
+ const HANDSHAKE_HEADERS = new Set([
28
+ "sec-websocket-key",
29
+ "sec-websocket-version",
30
+ "sec-websocket-protocol",
31
+ "sec-websocket-extensions",
32
+ "sec-websocket-accept",
33
+ ]);
34
+ /** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
35
+ * trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
36
+ * the server pick from the list, so order is the client's preference). */
37
+ export function parseSubprotocols(header) {
38
+ if (!header)
39
+ return [];
40
+ return header
41
+ .split(",")
42
+ .map((s) => s.trim())
43
+ .filter((s) => s.length > 0);
44
+ }
45
+ /**
46
+ * The headers the upstream handshake carries: everything forwardable from
47
+ * the client's request (cookies, authorization, origin, user-agent …),
48
+ * minus the client's own handshake fields, plus the proxy provenance
49
+ * headers and a Host that names the service.
50
+ */
51
+ export function upstreamHandshakeHeaders(req, opts) {
52
+ const out = {};
53
+ forwardableHeaders(req).forEach((value, key) => {
54
+ if (HANDSHAKE_HEADERS.has(key))
55
+ return;
56
+ out[key] = value;
57
+ });
58
+ const priorXff = req.get("x-forwarded-for");
59
+ const xff = opts.clientIp
60
+ ? priorXff
61
+ ? `${priorXff}, ${opts.clientIp}`
62
+ : opts.clientIp
63
+ : priorXff;
64
+ if (xff)
65
+ out["x-forwarded-for"] = xff;
66
+ out["x-forwarded-proto"] = opts.proto;
67
+ const publicHost = req.get("host");
68
+ if (publicHost)
69
+ out["x-forwarded-host"] = publicHost;
70
+ out["host"] = `${opts.service}:${opts.port}`;
71
+ return out;
72
+ }
73
+ /**
74
+ * Open the upstream WebSocket and wait for its handshake. Resolves once
75
+ * the socket is open, or once it failed; the handshake listeners are
76
+ * removed either way so a later close does not fire them again.
77
+ */
78
+ export function openUpstreamWebSocket(url, opts) {
79
+ const Ctor = WebSocket;
80
+ let socket;
81
+ try {
82
+ socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
83
+ }
84
+ catch (err) {
85
+ return Promise.resolve({ ok: false, reason: String(err) });
86
+ }
87
+ // ArrayBuffer so binary frames can be sent to the client verbatim.
88
+ socket.binaryType = "arraybuffer";
89
+ return new Promise((resolve) => {
90
+ let settled = false;
91
+ const timeoutMs = opts.timeoutMs ?? 30_000;
92
+ const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
93
+ const onOpen = () => finish({ ok: true, socket });
94
+ const onError = (ev) => {
95
+ const msg = ev.message;
96
+ finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
97
+ };
98
+ const onClose = (ev) => finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
99
+ const finish = (result) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ socket.removeEventListener("open", onOpen);
105
+ socket.removeEventListener("error", onError);
106
+ socket.removeEventListener("close", onClose);
107
+ if (!result.ok) {
108
+ try {
109
+ socket.close();
110
+ }
111
+ catch {
112
+ /* already closed */
113
+ }
114
+ }
115
+ resolve(result);
116
+ };
117
+ socket.addEventListener("open", onOpen);
118
+ socket.addEventListener("error", onError);
119
+ socket.addEventListener("close", onClose);
120
+ });
121
+ }
122
+ /**
123
+ * The `websocket` handler set for `Bun.serve`. The upstream is already
124
+ * open when `open()` runs, so frames go straight through in both
125
+ * directions, and a close on either side closes the other with the same
126
+ * code and reason.
127
+ */
128
+ export const WS_BRIDGE_HANDLERS = {
129
+ open(ws) {
130
+ const { upstream } = ws.data;
131
+ upstream.addEventListener("message", (ev) => {
132
+ try {
133
+ ws.send(ev.data);
134
+ }
135
+ catch {
136
+ /* client gone */
137
+ }
138
+ });
139
+ upstream.addEventListener("close", (ev) => {
140
+ try {
141
+ ws.close(ev.code, ev.reason);
142
+ }
143
+ catch {
144
+ /* already closed */
145
+ }
146
+ });
147
+ upstream.addEventListener("error", () => {
148
+ try {
149
+ ws.close(1011, "upstream error");
150
+ }
151
+ catch {
152
+ /* already closed */
153
+ }
154
+ });
155
+ // The upstream can have closed between the handshake and this
156
+ // callback; the close listener above was not attached yet then.
157
+ if (upstream.readyState !== WebSocket.OPEN) {
158
+ try {
159
+ ws.close(1011, "upstream closed");
160
+ }
161
+ catch {
162
+ /* already closed */
163
+ }
164
+ }
165
+ },
166
+ message(ws, message) {
167
+ const { upstream } = ws.data;
168
+ const payload = typeof message === "string" ? message : new Uint8Array(message);
169
+ if (upstream.readyState === WebSocket.OPEN)
170
+ upstream.send(payload);
171
+ },
172
+ close(ws, code, reason) {
173
+ try {
174
+ ws.data.upstream.close(code, reason);
175
+ }
176
+ catch {
177
+ /* ignore */
178
+ }
179
+ },
180
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.89.0",
3
+ "version": "0.89.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -124,6 +124,13 @@ import {
124
124
  corsPreflightResponse,
125
125
  isCorsPreflight,
126
126
  } from "./harness/http-proxy.js";
127
+ import {
128
+ WS_BRIDGE_HANDLERS,
129
+ openUpstreamWebSocket,
130
+ parseSubprotocols,
131
+ upstreamHandshakeHeaders,
132
+ type WsBridgeData,
133
+ } from "./harness/ws-bridge.js";
127
134
  import {
128
135
  certCovers as hostmatchCertCovers,
129
136
  hostWithoutPort,
@@ -2112,14 +2119,6 @@ async function bridgeGatewayIp(): Promise<string> {
2112
2119
  * The shape is `harness/ingress-table.ts`'s, generic over that record. */
2113
2120
  type Route = IngressRoute<FakeRuntime>;
2114
2121
 
2115
- /** Per-WS-upgrade context: the upstream URL we'll open in `open()` and
2116
- * keep the bridge in `message()`/`close()`. Stored on `ws.data`. */
2117
- interface WsBridgeData {
2118
- upstreamUrl: string;
2119
- upstream: WebSocket | null;
2120
- pending: Array<string | ArrayBuffer | Uint8Array>;
2121
- }
2122
-
2123
2122
  /**
2124
2123
  * Pristine `fetch` captured at module load, before any test-scoped
2125
2124
  * fetch wrapper can monkey-patch `globalThis.fetch`. The reverse-proxy
@@ -2440,8 +2439,8 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
2440
2439
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
2441
2440
  * dispatches every request to the matching Route by Host header.
2442
2441
  *
2443
- * Shared by the HTTP and HTTPS branches. Also exports a `websocket`
2444
- * handler so reverse-proxy targets can transparently bridge WS
2442
+ * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
2443
+ * handler set so reverse-proxy targets can transparently bridge WS
2445
2444
  * upgrades through to their upstream service.
2446
2445
  */
2447
2446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2485,74 +2484,9 @@ function bindIngressServer(
2485
2484
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2486
2485
  fetch: (req: Request, server: any): Response | Promise<Response> =>
2487
2486
  dispatchIngress(req, server, byHost, listenerLabel, proto),
2488
- websocket: {
2489
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2490
- async open(ws: any) {
2491
- const data = ws.data as WsBridgeData;
2492
- try {
2493
- const upstream = new WebSocket(data.upstreamUrl);
2494
- // ArrayBuffer so binary frames can be ws.send()'d to the
2495
- // downstream client verbatim — Blob would need an extra
2496
- // .arrayBuffer() round-trip on every message.
2497
- upstream.binaryType = "arraybuffer";
2498
- data.upstream = upstream;
2499
- upstream.addEventListener("open", () => {
2500
- for (const m of data.pending) upstream.send(m);
2501
- data.pending = [];
2502
- });
2503
- upstream.addEventListener("message", (ev: MessageEvent) => {
2504
- try {
2505
- ws.send(ev.data);
2506
- } catch {
2507
- /* client gone */
2508
- }
2509
- });
2510
- upstream.addEventListener("close", (ev: CloseEvent) => {
2511
- try {
2512
- ws.close(ev.code, ev.reason);
2513
- } catch {
2514
- /* already closed */
2515
- }
2516
- });
2517
- upstream.addEventListener("error", () => {
2518
- try {
2519
- ws.close(1011, "upstream error");
2520
- } catch {
2521
- /* already closed */
2522
- }
2523
- });
2524
- } catch (err) {
2525
- // eslint-disable-next-line no-console
2526
- console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
2527
- try {
2528
- ws.close(1011, "upstream open failed");
2529
- } catch {
2530
- /* ignore */
2531
- }
2532
- }
2533
- },
2534
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2535
- message(ws: any, message: string | Buffer) {
2536
- const data = ws.data as WsBridgeData;
2537
- const payload =
2538
- typeof message === "string" ? message : new Uint8Array(message);
2539
- if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
2540
- data.upstream.send(payload);
2541
- } else {
2542
- // Buffer until the upstream finishes its handshake.
2543
- data.pending.push(payload);
2544
- }
2545
- },
2546
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2547
- close(ws: any, code: number, reason: string) {
2548
- const data = ws.data as WsBridgeData;
2549
- try {
2550
- data.upstream?.close(code, reason);
2551
- } catch {
2552
- /* ignore */
2553
- }
2554
- },
2555
- },
2487
+ // The frame relay for bridged WebSocket upgrades; `proxyToService`
2488
+ // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
2489
+ websocket: WS_BRIDGE_HANDLERS,
2556
2490
  };
2557
2491
  return Bun.serve(opts);
2558
2492
  }
@@ -2657,8 +2591,12 @@ function ingressClientIp(server: any, req: Request, proto: string): string | und
2657
2591
  * Reverse-proxy a request to `http://<service>:<port>` on
2658
2592
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
2659
2593
  *
2660
- * - WS upgrade requests get routed through `server.upgrade()`, with
2661
- * the upstream URL stashed on `ws.data`. The shared `websocket`
2594
+ * - WS upgrade requests open the upstream WebSocket first, with the
2595
+ * client's full `Sec-WebSocket-Protocol` list and its forwardable
2596
+ * headers (cookies, authorization, origin, x-forwarded-*). Only after
2597
+ * that handshake succeeds is the client upgraded, with the protocol
2598
+ * the upstream selected; a refused upstream handshake is a 502, not a
2599
+ * 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
2662
2600
  * handler opens the upstream and bridges frames both ways.
2663
2601
  * - Plain requests pass through via `fetch()` with hop-by-hop
2664
2602
  * headers stripped; the response body is a ReadableStream returned
@@ -2688,16 +2626,40 @@ async function proxyToService(
2688
2626
  const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
2689
2627
  if (upgrade === "websocket") {
2690
2628
  const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
2691
- const wsData: WsBridgeData = {
2692
- upstreamUrl,
2693
- upstream: null,
2694
- pending: [],
2695
- };
2696
- const ok = server.upgrade(req, { data: wsData });
2629
+ // The upstream handshake carries what the client's did: the whole
2630
+ // subprotocol list (a console ticket often rides as the second entry)
2631
+ // and the forwardable headers. Same provenance stamps as the HTTP path.
2632
+ const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2633
+ const headers = upstreamHandshakeHeaders(req.headers, {
2634
+ service,
2635
+ port,
2636
+ proto,
2637
+ clientIp: ingressClientIp(server, req, proto),
2638
+ });
2639
+ const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
2640
+ if (!opened.ok) {
2641
+ return new Response(
2642
+ `spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`,
2643
+ { status: 502, headers: { "content-type": "text/plain" } },
2644
+ );
2645
+ }
2646
+ const wsData: WsBridgeData = { upstreamUrl, upstream: opened.socket };
2647
+ // Echo the protocol the upstream selected; Bun would otherwise pick
2648
+ // the client's first entry by itself, which can differ.
2649
+ const selected = opened.socket.protocol;
2650
+ const ok = server.upgrade(req, {
2651
+ data: wsData,
2652
+ headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2653
+ });
2697
2654
  if (ok) {
2698
2655
  // Bun has already taken over the response — return a stub.
2699
2656
  return new Response(null, { status: 101 });
2700
2657
  }
2658
+ try {
2659
+ opened.socket.close(1011, "client upgrade refused");
2660
+ } catch {
2661
+ /* ignore */
2662
+ }
2701
2663
  return new Response(
2702
2664
  `spectest-daemon: ws upgrade refused on ${listenerLabel}\n`,
2703
2665
  { status: 426, headers: { "content-type": "text/plain" } },
@@ -0,0 +1,224 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ WS_BRIDGE_HANDLERS,
5
+ openUpstreamWebSocket,
6
+ parseSubprotocols,
7
+ upstreamHandshakeHeaders,
8
+ type WsBridgeData,
9
+ } from "./ws-bridge";
10
+
11
+ describe("parseSubprotocols", () => {
12
+ test("splits, trims, drops empty tokens, keeps order", () => {
13
+ expect(parseSubprotocols("console, ticket-abc")).toEqual(["console", "ticket-abc"]);
14
+ expect(parseSubprotocols(" a ,, b ,")).toEqual(["a", "b"]);
15
+ expect(parseSubprotocols(null)).toEqual([]);
16
+ expect(parseSubprotocols("")).toEqual([]);
17
+ });
18
+ });
19
+
20
+ describe("upstreamHandshakeHeaders", () => {
21
+ test("forwards cookies and origin, drops the client's handshake fields, stamps provenance", () => {
22
+ const req = new Headers({
23
+ host: "api.prod.specific.dev",
24
+ connection: "Upgrade",
25
+ upgrade: "websocket",
26
+ "sec-websocket-key": "abc==",
27
+ "sec-websocket-version": "13",
28
+ "sec-websocket-protocol": "console, ticket",
29
+ "sec-websocket-extensions": "permessage-deflate",
30
+ cookie: "sid=1",
31
+ origin: "https://app.test",
32
+ authorization: "Bearer t",
33
+ });
34
+ const out = upstreamHandshakeHeaders(req, {
35
+ service: "api",
36
+ port: 4000,
37
+ proto: "https",
38
+ clientIp: "10.0.0.7",
39
+ });
40
+ expect(out["cookie"]).toBe("sid=1");
41
+ expect(out["origin"]).toBe("https://app.test");
42
+ expect(out["authorization"]).toBe("Bearer t");
43
+ expect(out["host"]).toBe("api:4000");
44
+ expect(out["x-forwarded-host"]).toBe("api.prod.specific.dev");
45
+ expect(out["x-forwarded-proto"]).toBe("https");
46
+ expect(out["x-forwarded-for"]).toBe("10.0.0.7");
47
+ for (const k of Object.keys(out)) {
48
+ expect(k.startsWith("sec-websocket-")).toBe(false);
49
+ expect(["connection", "upgrade"]).not.toContain(k);
50
+ }
51
+ });
52
+
53
+ test("appends the client to an existing x-forwarded-for", () => {
54
+ const out = upstreamHandshakeHeaders(new Headers({ "x-forwarded-for": "1.2.3.4" }), {
55
+ service: "api",
56
+ port: 1,
57
+ proto: "http",
58
+ clientIp: "10.0.0.7",
59
+ });
60
+ expect(out["x-forwarded-for"]).toBe("1.2.3.4, 10.0.0.7");
61
+ });
62
+ });
63
+
64
+ /**
65
+ * A loopback copy of the daemon's arrangement: an upstream that admits a
66
+ * connection only when the SECOND subprotocol is the ticket (the shape of
67
+ * Specific's console API), a bridge listener in front of it that does what
68
+ * `proxyToService` does, and a client through the bridge.
69
+ */
70
+ describe("the bridge on loopback", () => {
71
+ let upstream: ReturnType<typeof Bun.serve>;
72
+ let bridge: ReturnType<typeof Bun.serve>;
73
+ /** What the upstream saw on its last handshake. */
74
+ let seen: { protocols: string[]; cookie: string | null; xfh: string | null } | null = null;
75
+
76
+ beforeAll(() => {
77
+ upstream = Bun.serve({
78
+ port: 0,
79
+ fetch(req, server) {
80
+ const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
81
+ seen = {
82
+ protocols,
83
+ cookie: req.headers.get("cookie"),
84
+ xfh: req.headers.get("x-forwarded-host"),
85
+ };
86
+ if (protocols[1] !== "ticket-ok") return new Response("Unauthorized", { status: 401 });
87
+ const ok = server.upgrade(req, {
88
+ headers: { "sec-websocket-protocol": protocols[0] },
89
+ data: { admitted: protocols[1] },
90
+ });
91
+ return ok ? undefined : new Response("refused", { status: 426 });
92
+ },
93
+ websocket: {
94
+ open(ws) {
95
+ ws.send("ready");
96
+ },
97
+ message(ws, m) {
98
+ if (typeof m === "string") {
99
+ if (m === "close-me") ws.close(4402, "app said so");
100
+ else ws.send(`echo:${m}`);
101
+ } else {
102
+ ws.send(new Uint8Array(m).reverse());
103
+ }
104
+ },
105
+ },
106
+ });
107
+
108
+ bridge = Bun.serve({
109
+ port: 0,
110
+ async fetch(req, server) {
111
+ const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
112
+ const headers = upstreamHandshakeHeaders(req.headers, {
113
+ service: "api",
114
+ port: upstream.port!,
115
+ proto: "https",
116
+ });
117
+ const url = new URL(req.url);
118
+ const opened = await openUpstreamWebSocket(
119
+ `ws://127.0.0.1:${upstream.port}${url.pathname}${url.search}`,
120
+ { protocols, headers },
121
+ );
122
+ if (!opened.ok) return new Response(opened.reason, { status: 502 });
123
+ const data: WsBridgeData = { upstreamUrl: "", upstream: opened.socket };
124
+ const ok = server.upgrade(req, {
125
+ headers: opened.socket.protocol
126
+ ? { "sec-websocket-protocol": opened.socket.protocol }
127
+ : undefined,
128
+ data,
129
+ });
130
+ if (!ok) {
131
+ opened.socket.close();
132
+ return new Response("refused", { status: 426 });
133
+ }
134
+ return undefined;
135
+ },
136
+ websocket: WS_BRIDGE_HANDLERS,
137
+ });
138
+ });
139
+
140
+ afterAll(() => {
141
+ upstream.stop(true);
142
+ bridge.stop(true);
143
+ });
144
+
145
+ function connect(protocols: string[]): Promise<{
146
+ ws: WebSocket;
147
+ events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }>;
148
+ done: Promise<void>;
149
+ }> {
150
+ const Ctor = WebSocket as unknown as new (
151
+ url: string,
152
+ opts: { protocols?: string[]; headers?: Record<string, string> },
153
+ ) => WebSocket;
154
+ const ws = new Ctor(`ws://127.0.0.1:${bridge.port}/console?x=1`, {
155
+ protocols,
156
+ headers: { cookie: "sid=abc", host: "api.prod.specific.dev" },
157
+ });
158
+ ws.binaryType = "arraybuffer";
159
+ const events: Array<{ kind: "open" | "message" | "close" | "error"; value?: unknown }> = [];
160
+ const done = new Promise<void>((resolve) => {
161
+ ws.addEventListener("close", (ev) => {
162
+ events.push({ kind: "close", value: { code: ev.code, reason: ev.reason } });
163
+ resolve();
164
+ });
165
+ });
166
+ ws.addEventListener("error", (ev) => events.push({ kind: "error", value: (ev as { message?: string }).message }));
167
+ ws.addEventListener("message", (ev) => events.push({ kind: "message", value: ev.data }));
168
+ return new Promise((resolve, reject) => {
169
+ ws.addEventListener("open", () => {
170
+ events.push({ kind: "open" });
171
+ resolve({ ws, events, done });
172
+ });
173
+ done.then(() => reject(new Error(`closed before open: ${JSON.stringify(events)}`)));
174
+ });
175
+ }
176
+
177
+ test("the full protocol list and the headers reach the upstream; the selected protocol comes back", async () => {
178
+ const { ws, events, done } = await connect(["console", "ticket-ok"]);
179
+ expect(ws.protocol).toBe("console");
180
+ expect(seen).toEqual({
181
+ protocols: ["console", "ticket-ok"],
182
+ cookie: "sid=abc",
183
+ xfh: "api.prod.specific.dev",
184
+ });
185
+ await Bun.sleep(20);
186
+ ws.send("hi");
187
+ ws.send(new Uint8Array([1, 2, 3]));
188
+ await Bun.sleep(50);
189
+ ws.close(1000, "bye");
190
+ await done;
191
+ const messages = events.filter((e) => e.kind === "message").map((e) => e.value);
192
+ expect(messages[0]).toBe("ready");
193
+ expect(messages[1]).toBe("echo:hi");
194
+ expect(Array.from(new Uint8Array(messages[2] as ArrayBuffer))).toEqual([3, 2, 1]);
195
+ });
196
+
197
+ test("an upstream close code after the handshake is relayed to the client", async () => {
198
+ const { ws, events, done } = await connect(["console", "ticket-ok"]);
199
+ await Bun.sleep(20);
200
+ ws.send("close-me");
201
+ await done;
202
+ const close = events.find((e) => e.kind === "close")?.value as { code: number; reason: string };
203
+ expect(close.code).toBe(4402);
204
+ expect(close.reason).toBe("app said so");
205
+ });
206
+
207
+ test("an upstream that refuses the handshake never gives the client a 101", async () => {
208
+ // A plain fetch with the upgrade headers shows the status the bridge
209
+ // answers with; a WebSocket client only reports a failed handshake.
210
+ const res = await fetch(`http://127.0.0.1:${bridge.port}/console`, {
211
+ headers: {
212
+ connection: "Upgrade",
213
+ upgrade: "websocket",
214
+ "sec-websocket-version": "13",
215
+ "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==",
216
+ "sec-websocket-protocol": "console, ticket-bad",
217
+ },
218
+ });
219
+ expect(res.status).toBe(502);
220
+ expect(await res.text()).toContain("upstream handshake failed");
221
+ expect(seen?.protocols).toEqual(["console", "ticket-bad"]);
222
+ await expect(connect(["console", "ticket-bad"])).rejects.toThrow(/closed before open/);
223
+ });
224
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The ingress WebSocket bridge: one client WebSocket on an ingress
3
+ * listener, one upstream WebSocket to the service, frames copied in both
4
+ * directions.
5
+ *
6
+ * The handshake is the part that matters. A client that sends
7
+ * `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
8
+ * the full list, and expects to get back the one the upstream selected.
9
+ * The first version of this bridge opened the upstream with no protocols
10
+ * and no headers, so a ticket or a cookie that rode on the handshake never
11
+ * reached the service, and the service refused the connection after the
12
+ * client already saw a 101. So the bridge now opens the upstream FIRST,
13
+ * with the client's protocol list and forwardable headers, waits for the
14
+ * upstream handshake, and only then upgrades the client with the protocol
15
+ * the upstream selected. An upstream that refuses the handshake becomes a
16
+ * 502 to the client, the same as the HTTP proxy path.
17
+ *
18
+ * `daemon.ts` owns the listener and the route lookup; this module owns
19
+ * the handshake and the frame relay so the two can be tested on loopback
20
+ * without a VM.
21
+ */
22
+
23
+ import { forwardableHeaders } from "./http-proxy.js";
24
+
25
+ /** Headers that belong to the client's own WebSocket handshake. Bun's
26
+ * WebSocket client mints its own key and version, and the protocol list
27
+ * goes through the constructor option, so these never travel as plain
28
+ * headers. */
29
+ const HANDSHAKE_HEADERS: ReadonlySet<string> = new Set([
30
+ "sec-websocket-key",
31
+ "sec-websocket-version",
32
+ "sec-websocket-protocol",
33
+ "sec-websocket-extensions",
34
+ "sec-websocket-accept",
35
+ ]);
36
+
37
+ /** Per-bridge context stored on `ws.data` of the client socket. */
38
+ export interface WsBridgeData {
39
+ upstreamUrl: string;
40
+ /** Open by the time the client socket exists: the handshake awaited it. */
41
+ upstream: WebSocket;
42
+ }
43
+
44
+ /** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
45
+ * trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
46
+ * the server pick from the list, so order is the client's preference). */
47
+ export function parseSubprotocols(header: string | null | undefined): string[] {
48
+ if (!header) return [];
49
+ return header
50
+ .split(",")
51
+ .map((s) => s.trim())
52
+ .filter((s) => s.length > 0);
53
+ }
54
+
55
+ /**
56
+ * The headers the upstream handshake carries: everything forwardable from
57
+ * the client's request (cookies, authorization, origin, user-agent …),
58
+ * minus the client's own handshake fields, plus the proxy provenance
59
+ * headers and a Host that names the service.
60
+ */
61
+ export function upstreamHandshakeHeaders(
62
+ req: Headers,
63
+ opts: {
64
+ service: string;
65
+ port: number;
66
+ proto: string;
67
+ clientIp?: string;
68
+ },
69
+ ): Record<string, string> {
70
+ const out: Record<string, string> = {};
71
+ forwardableHeaders(req).forEach((value, key) => {
72
+ if (HANDSHAKE_HEADERS.has(key)) return;
73
+ out[key] = value;
74
+ });
75
+ const priorXff = req.get("x-forwarded-for");
76
+ const xff = opts.clientIp
77
+ ? priorXff
78
+ ? `${priorXff}, ${opts.clientIp}`
79
+ : opts.clientIp
80
+ : priorXff;
81
+ if (xff) out["x-forwarded-for"] = xff;
82
+ out["x-forwarded-proto"] = opts.proto;
83
+ const publicHost = req.get("host");
84
+ if (publicHost) out["x-forwarded-host"] = publicHost;
85
+ out["host"] = `${opts.service}:${opts.port}`;
86
+ return out;
87
+ }
88
+
89
+ /** Bun's WebSocket constructor takes an options object with `headers`;
90
+ * the bundled types only declare the WHATWG `protocols` form. */
91
+ type BunWebSocketCtor = new (
92
+ url: string,
93
+ opts: { protocols?: string[]; headers?: Record<string, string> },
94
+ ) => WebSocket;
95
+
96
+ export type UpstreamOpen =
97
+ | { ok: true; socket: WebSocket }
98
+ | { ok: false; reason: string };
99
+
100
+ /**
101
+ * Open the upstream WebSocket and wait for its handshake. Resolves once
102
+ * the socket is open, or once it failed; the handshake listeners are
103
+ * removed either way so a later close does not fire them again.
104
+ */
105
+ export function openUpstreamWebSocket(
106
+ url: string,
107
+ opts: { protocols: string[]; headers: Record<string, string>; timeoutMs?: number },
108
+ ): Promise<UpstreamOpen> {
109
+ const Ctor = WebSocket as unknown as BunWebSocketCtor;
110
+ let socket: WebSocket;
111
+ try {
112
+ socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
113
+ } catch (err) {
114
+ return Promise.resolve({ ok: false, reason: String(err) });
115
+ }
116
+ // ArrayBuffer so binary frames can be sent to the client verbatim.
117
+ socket.binaryType = "arraybuffer";
118
+ return new Promise<UpstreamOpen>((resolve) => {
119
+ let settled = false;
120
+ const timeoutMs = opts.timeoutMs ?? 30_000;
121
+ const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
122
+ const onOpen = (): void => finish({ ok: true, socket });
123
+ const onError = (ev: Event): void => {
124
+ const msg = (ev as { message?: string }).message;
125
+ finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
126
+ };
127
+ const onClose = (ev: CloseEvent): void =>
128
+ finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
129
+ const finish = (result: UpstreamOpen): void => {
130
+ if (settled) return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ socket.removeEventListener("open", onOpen);
134
+ socket.removeEventListener("error", onError);
135
+ socket.removeEventListener("close", onClose);
136
+ if (!result.ok) {
137
+ try {
138
+ socket.close();
139
+ } catch {
140
+ /* already closed */
141
+ }
142
+ }
143
+ resolve(result);
144
+ };
145
+ socket.addEventListener("open", onOpen);
146
+ socket.addEventListener("error", onError);
147
+ socket.addEventListener("close", onClose);
148
+ });
149
+ }
150
+
151
+ /** The minimum of Bun's ServerWebSocket that the relay uses. */
152
+ interface ClientSocket {
153
+ data: WsBridgeData;
154
+ send(data: string | ArrayBuffer | Uint8Array): unknown;
155
+ close(code?: number, reason?: string): unknown;
156
+ }
157
+
158
+ /**
159
+ * The `websocket` handler set for `Bun.serve`. The upstream is already
160
+ * open when `open()` runs, so frames go straight through in both
161
+ * directions, and a close on either side closes the other with the same
162
+ * code and reason.
163
+ */
164
+ export const WS_BRIDGE_HANDLERS = {
165
+ open(ws: ClientSocket): void {
166
+ const { upstream } = ws.data;
167
+ upstream.addEventListener("message", (ev: MessageEvent) => {
168
+ try {
169
+ ws.send(ev.data);
170
+ } catch {
171
+ /* client gone */
172
+ }
173
+ });
174
+ upstream.addEventListener("close", (ev: CloseEvent) => {
175
+ try {
176
+ ws.close(ev.code, ev.reason);
177
+ } catch {
178
+ /* already closed */
179
+ }
180
+ });
181
+ upstream.addEventListener("error", () => {
182
+ try {
183
+ ws.close(1011, "upstream error");
184
+ } catch {
185
+ /* already closed */
186
+ }
187
+ });
188
+ // The upstream can have closed between the handshake and this
189
+ // callback; the close listener above was not attached yet then.
190
+ if (upstream.readyState !== WebSocket.OPEN) {
191
+ try {
192
+ ws.close(1011, "upstream closed");
193
+ } catch {
194
+ /* already closed */
195
+ }
196
+ }
197
+ },
198
+ message(ws: ClientSocket, message: string | Buffer): void {
199
+ const { upstream } = ws.data;
200
+ const payload = typeof message === "string" ? message : new Uint8Array(message);
201
+ if (upstream.readyState === WebSocket.OPEN) upstream.send(payload);
202
+ },
203
+ close(ws: ClientSocket, code: number, reason: string): void {
204
+ try {
205
+ ws.data.upstream.close(code, reason);
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ },
210
+ };