@specific.dev/spectest 0.101.0 → 0.103.0

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/src/daemon.ts CHANGED
@@ -91,6 +91,7 @@ import {
91
91
  unionDockerignore,
92
92
  } from "./harness/build-context.js";
93
93
  import { startServices } from "./harness/service-graph.js";
94
+ import { dispatchFake } from "./harness/fake-websocket.js";
94
95
  import {
95
96
  casesMetadata as catalogueCases,
96
97
  groupsMetadata as catalogueGroups,
@@ -131,7 +132,6 @@ import {
131
132
  upstreamRequestHeaders,
132
133
  } from "./harness/http-proxy.js";
133
134
  import {
134
- WS_BRIDGE_HANDLERS,
135
135
  openUpstreamWebSocket,
136
136
  parseSubprotocols,
137
137
  upstreamHandshakeHeaders,
@@ -157,6 +157,7 @@ import {
157
157
  unbindRoute,
158
158
  type Route as IngressRoute,
159
159
  } from "./harness/ingress-table.js";
160
+ import { startIngressServer, type IngressServer } from "./harness/ingress-server.js";
160
161
  import { startHttp2Proxy } from "./harness/http2-proxy.js";
161
162
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
162
163
  import { runContainerArgs } from "./harness/container-run.js";
@@ -1920,12 +1921,11 @@ let LOWERED: LoweredIngress = {
1920
1921
  aliasesByService: {},
1921
1922
  wildcards: [],
1922
1923
  };
1923
- /** Running HTTP servers per port (Bun.Server). Rebuilt on /load. */
1924
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1925
- const INGRESS_HTTP_SERVERS = new Map<number, any>();
1924
+ /** Running HTTP servers per port. Rebuilt on /load. */
1925
+ const INGRESS_HTTP_SERVERS = new Map<number, IngressServer>();
1926
+ const INGRESS_HTTP_STARTING = new Map<number, Promise<void>>();
1926
1927
  /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1927
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1928
- const INGRESS_HTTPS_SERVERS = new Map<number, any>();
1928
+ const INGRESS_HTTPS_SERVERS = new Map<number, IngressServer>();
1929
1929
  /**
1930
1930
  * The :443 TLS terminator, once per harness process.
1931
1931
  *
@@ -1989,6 +1989,7 @@ function stopIngressServers(): void {
1989
1989
  }
1990
1990
  }
1991
1991
  INGRESS_HTTP_SERVERS.clear();
1992
+ INGRESS_HTTP_STARTING.clear();
1992
1993
  for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
1993
1994
  try {
1994
1995
  srv.stop?.(true);
@@ -2129,7 +2130,7 @@ async function startIngress(): Promise<void> {
2129
2130
  );
2130
2131
  }
2131
2132
 
2132
- const Bun = requireBun();
2133
+ requireBun();
2133
2134
 
2134
2135
  // Resolve every ingress hostname to its handler into the live per-port
2135
2136
  // route tables (module scope, so runtime `tls` can extend them later).
@@ -2168,12 +2169,12 @@ async function startIngress(): Promise<void> {
2168
2169
  // ── HTTP listeners (one per non-443 port).
2169
2170
  for (const [port, byHost] of INGRESS.routesByPort) {
2170
2171
  if (port === INGRESS_HTTPS_PORT) continue;
2171
- INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
2172
+ await ensureHttpIngress(port, byHost);
2172
2173
  // eslint-disable-next-line no-console
2173
2174
  console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
2174
2175
  }
2175
2176
  // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
2176
- if (INGRESS.certByHost.size > 0) await ensureHttpsIngress(Bun);
2177
+ if (INGRESS.certByHost.size > 0) await ensureHttpsIngress();
2177
2178
 
2178
2179
  // Seed the resolver's names registry: ingress hostnames (fakes, TLS
2179
2180
  // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
@@ -2188,7 +2189,7 @@ function requireBun(): any {
2188
2189
  const Bun = (globalThis as any).Bun;
2189
2190
  if (!Bun?.serve) {
2190
2191
  throw new Error(
2191
- "ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)",
2192
+ "ingress requires Bun; the daemon must run under Bun (it does in-VM)",
2192
2193
  );
2193
2194
  }
2194
2195
  return Bun;
@@ -2198,10 +2199,10 @@ function requireBun(): any {
2198
2199
  /**
2199
2200
  * Bring :443 up, once.
2200
2201
  *
2201
- * Two pieces: a plaintext `Bun.serve` on an ephemeral loopback port that
2202
+ * Two pieces: a plaintext HTTP server on an ephemeral loopback port that
2202
2203
  * carries the HTTPS route table (and therefore all the routing, fake
2203
- * dispatch, interception, reverse-proxying and `server.upgrade()`
2204
- * WebSocket bridging that :80 already gets, unchanged), and the TLS
2204
+ * dispatch, interception, reverse-proxying and WebSocket
2205
+ * bridging that :80 already gets, unchanged), and the TLS
2205
2206
  * terminator in front of it on :443.
2206
2207
  *
2207
2208
  * **This is idempotent, and that is the feature.** Adding a certificate
@@ -2222,27 +2223,25 @@ function requireBun(): any {
2222
2223
  * container can reach (containers arrive at the bridge gateway), so the
2223
2224
  * only way in is through the terminator.
2224
2225
  */
2225
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2226
- async function ensureHttpsIngress(Bun: any): Promise<void> {
2226
+ async function ensureHttpsIngress(): Promise<void> {
2227
2227
  if (INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) return;
2228
2228
  if (!HTTPS_INGRESS_STARTING) {
2229
2229
  // Cleared on both outcomes: once bound, the check above short-circuits
2230
2230
  // every later caller, and a failed bind must be retryable rather than
2231
2231
  // remembered as a rejection for the life of the project.
2232
- HTTPS_INGRESS_STARTING = startHttpsIngress(Bun).finally(() => {
2232
+ HTTPS_INGRESS_STARTING = startHttpsIngress().finally(() => {
2233
2233
  HTTPS_INGRESS_STARTING = undefined;
2234
2234
  });
2235
2235
  }
2236
2236
  return HTTPS_INGRESS_STARTING;
2237
2237
  }
2238
2238
 
2239
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2240
- async function startHttpsIngress(Bun: any): Promise<void> {
2239
+ async function startHttpsIngress(): Promise<void> {
2241
2240
  const routes = routesFor(INGRESS, INGRESS_HTTPS_PORT);
2242
2241
  // `proto` is passed rather than inferred: this listener speaks plain
2243
2242
  // HTTP, but everything reaching it arrived over TLS, so X-Forwarded-Proto
2244
2243
  // must say https.
2245
- const plain = bindIngressServer(Bun, 0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2244
+ const plain = await bindIngressServer(0, routes, `https :${INGRESS_HTTPS_PORT}`, {
2246
2245
  proto: "https",
2247
2246
  hostname: "127.0.0.1",
2248
2247
  });
@@ -2327,7 +2326,7 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2327
2326
  `runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`,
2328
2327
  );
2329
2328
  }
2330
- const Bun = requireBun();
2329
+ requireBun();
2331
2330
  const route: Route = { kind: "proxy", service, port, http2, cors };
2332
2331
 
2333
2332
  // Decide the cost before mutating anything: an already-covered hostname
@@ -2344,24 +2343,14 @@ async function bindRuntimeTls(hostname: string, service: string, port: number, h
2344
2343
  // the route is only reachable once the hostname resolves, which is the
2345
2344
  // registry write at the end.
2346
2345
  bindRoute(INGRESS, host, route);
2347
- if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
2348
- INGRESS_HTTP_SERVERS.set(
2349
- INGRESS_HTTP_PORT,
2350
- bindIngressServer(
2351
- Bun,
2352
- INGRESS_HTTP_PORT,
2353
- routesFor(INGRESS, INGRESS_HTTP_PORT),
2354
- `port ${INGRESS_HTTP_PORT}`,
2355
- ),
2356
- );
2357
- }
2346
+ await ensureHttpIngress(INGRESS_HTTP_PORT, routesFor(INGRESS, INGRESS_HTTP_PORT));
2358
2347
  if (plan.needsCert) {
2359
2348
  // The terminator reads this table on every handshake, so the leaf is
2360
2349
  // live the moment it lands — no listener is touched, and no connection
2361
2350
  // already on :443 notices.
2362
2351
  INGRESS.certByHost.set(host, await generateHostCert(host, [host]));
2363
2352
  }
2364
- if (plan.needsHttpsListener) await ensureHttpsIngress(Bun);
2353
+ if (plan.needsHttpsListener) await ensureHttpsIngress();
2365
2354
 
2366
2355
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
2367
2356
  // A wildcard can only live in the resolver's suffix table.
@@ -2398,63 +2387,32 @@ async function unbindRuntimeTls(hostname: string): Promise<void> {
2398
2387
  }
2399
2388
  }
2400
2389
 
2401
- /**
2402
- * Spin up one Bun.serve listener bound to (port, optional TLS) that
2403
- * dispatches every request to the matching Route by Host header.
2404
- *
2405
- * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
2406
- * handler set so reverse-proxy targets can transparently bridge WS
2407
- * upgrades through to their upstream service.
2408
- */
2409
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2410
- function bindIngressServer(
2411
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2412
- Bun: any,
2390
+ /** Concurrent runtime routes must share the asynchronous first bind. */
2391
+ async function ensureHttpIngress(port: number, byHost: Map<string, Route>): Promise<void> {
2392
+ if (INGRESS_HTTP_SERVERS.has(port)) return;
2393
+ let starting = INGRESS_HTTP_STARTING.get(port);
2394
+ if (!starting) {
2395
+ starting = bindIngressServer(port, byHost, `port ${port}`).then(server => {
2396
+ INGRESS_HTTP_SERVERS.set(port, server);
2397
+ }).finally(() => INGRESS_HTTP_STARTING.delete(port));
2398
+ INGRESS_HTTP_STARTING.set(port, starting);
2399
+ }
2400
+ await starting;
2401
+ }
2402
+
2403
+ /** Bind the shared HTTP/1 ingress handler, including the listener behind TLS. */
2404
+ async function bindIngressServer(
2413
2405
  port: number,
2414
2406
  byHost: Map<string, Route>,
2415
2407
  listenerLabel: string,
2416
- // Every ingress listener speaks plain HTTP now — the one behind :443
2417
- // sits under the TLS terminator. So the scheme a client actually used
2418
- // can no longer be inferred from the socket and is declared instead; it
2419
- // stamps X-Forwarded-Proto, which upstreams that build absolute URLs or
2420
- // redirect depend on.
2421
2408
  serve: { proto: "http" | "https"; hostname?: string } = { proto: "http" },
2422
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2423
- ): any {
2424
- const proto = serve.proto;
2425
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2426
- const opts: Record<string, any> = {
2409
+ ): Promise<IngressServer> {
2410
+ return startIngressServer({
2427
2411
  port,
2428
2412
  hostname: serve.hostname ?? "0.0.0.0",
2429
- // SO_REUSEPORT on every ingress listener. It was introduced so a
2430
- // replacement :443 could overlap the original during a certificate
2431
- // swap; the terminator removed that swap, and this stays for the
2432
- // remaining case — the `/load` teardown force-closes and rebinds, and
2433
- // a socket the kernel has not finished releasing would otherwise fail
2434
- // the next bind with "Is port 443 in use?". Note the flip side, which
2435
- // {@link stopIngressServers} depends on: a half-alive listener shares
2436
- // the port silently instead of colliding loudly.
2437
2413
  reusePort: true,
2438
- // OCI layer uploads can exceed Bun's default 128 MiB request limit.
2439
- // Large proxy bodies stream through; this limit does not allocate a buffer.
2440
- maxRequestBodySize: 1024 ** 4,
2441
- // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
2442
- // request whose upstream takes >10s to produce bytes — under parallel
2443
- // test load that surfaced as "fetch failed"/"other side closed" on
2444
- // deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
2445
- // ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
2446
- // arbitrarily slow app endpoints (deploys can legitimately take
2447
- // minutes), so disable the idle timeout entirely; forked test VMs are
2448
- // short-lived, leaked-connection risk is bounded by the fork.
2449
- idleTimeout: 0,
2450
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2451
- fetch: (req: Request, server: any): Response | Promise<Response> =>
2452
- dispatchIngress(req, server, byHost, listenerLabel, proto),
2453
- // The frame relay for bridged WebSocket upgrades; `proxyToService`
2454
- // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
2455
- websocket: WS_BRIDGE_HANDLERS,
2456
- };
2457
- return Bun.serve(opts);
2414
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, serve.proto),
2415
+ });
2458
2416
  }
2459
2417
 
2460
2418
  /**
@@ -2501,17 +2459,7 @@ async function dispatchIngress(
2501
2459
  // The real answer — a fake handled in-process, or the proxied container.
2502
2460
  const upstream = async (current: Request): Promise<Response> => {
2503
2461
  if (route.kind === "fake") {
2504
- try {
2505
- return await runUninstrumented(() =>
2506
- route.fake.def.handler(current, route.fake.state, FAKE_CTX),
2507
- );
2508
- } catch (err) {
2509
- const e = err as Error;
2510
- return new Response(
2511
- `spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`,
2512
- { status: 500, headers: { "content-type": "text/plain" } },
2513
- );
2514
- }
2462
+ return dispatchFake(current, server, route.fake.def, route.fake.state, FAKE_CTX);
2515
2463
  }
2516
2464
  return proxyToService(current, server, route.service, route.port, listenerLabel, proto);
2517
2465
  };
@@ -2610,7 +2558,7 @@ async function proxyToService(
2610
2558
  headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2611
2559
  });
2612
2560
  if (ok) {
2613
- // Bun has already taken over the response — return a stub.
2561
+ // The WebSocket has already taken over the response — return a stub.
2614
2562
  return new Response(null, { status: 101 });
2615
2563
  }
2616
2564
  try {
@@ -2676,6 +2624,7 @@ async function proxyToService(
2676
2624
  headers: fwdHeaders,
2677
2625
  body: hasBody ? (bufferedBody ?? req.body) : undefined,
2678
2626
  redirect: "manual",
2627
+ signal: req.signal,
2679
2628
  });
2680
2629
  // decompress:false → forward the encoded body untouched (see fn doc).
2681
2630
  // keepalive:false → fresh connection per request (see fwdHeaders above).
@@ -2685,7 +2634,7 @@ async function proxyToService(
2685
2634
  keepalive: false,
2686
2635
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2687
2636
  } as any);
2688
- // Strip hop-by-hop response headers; let Bun set content-length / TE.
2637
+ // Strip connection framing while preserving the unchanged body's length.
2689
2638
  const respHeaders = new Headers();
2690
2639
  for (const [k, v] of upstreamRes.headers) {
2691
2640
  if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
@@ -0,0 +1,17 @@
1
+ /** A connection accepted by a fake. Text and binary messages retain their type. */
2
+ export interface FakeWebSocket {
3
+ readonly readyState: number;
4
+ send(message: string | Uint8Array | ArrayBuffer): void;
5
+ close(code?: number, reason?: string): void;
6
+ }
7
+
8
+ /** Returned by a fake's websocket handshake callback to accept a connection.
9
+ * Callbacks run in order per connection, including async callbacks. Capture
10
+ * fake state and connection-local state in the handshake callback's closure. */
11
+ export interface FakeWebSocketHandlers {
12
+ /** An offered Sec-WebSocket-Protocol to select, if the client requires one. */
13
+ protocol?: string;
14
+ open?(socket: FakeWebSocket): void | Promise<void>;
15
+ message?(socket: FakeWebSocket, message: string | Uint8Array): void | Promise<void>;
16
+ close?(socket: FakeWebSocket, code: number, reason: string): void | Promise<void>;
17
+ }
@@ -0,0 +1,54 @@
1
+ import type WebSocket from "ws";
2
+ import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
3
+
4
+ const MAX_BUFFER = 16 * 1024 * 1024;
5
+ const MAX_PENDING = 1024;
6
+
7
+ /** Serialize async fake callbacks without allowing a slow handler or peer to
8
+ * retain unbounded audio. A handler failure closes just this connection. */
9
+ export function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void {
10
+ let tail = Promise.resolve();
11
+ let pending = 0;
12
+ let bytes = 0;
13
+ let failed = false;
14
+ const socket: FakeWebSocket = {
15
+ get readyState() { return ws.readyState; },
16
+ send(message) {
17
+ if (ws.readyState !== 1) throw new Error("WebSocket is not open");
18
+ const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
19
+ if (ws.bufferedAmount + size > MAX_BUFFER) {
20
+ failed = true;
21
+ ws.close(1009, "fake output buffer exceeded");
22
+ throw new Error("fake WebSocket output buffer exceeded");
23
+ }
24
+ ws.send(message);
25
+ },
26
+ close: (code, reason) => ws.close(code, reason),
27
+ };
28
+ const fail = (err: unknown) => {
29
+ failed = true;
30
+ console.error("[spectest] fake WebSocket callback failed:", err);
31
+ try { ws.close(1011, "fake handler failed"); } catch { ws.terminate(); }
32
+ };
33
+ const enqueue = (fn: () => void | Promise<void>, size = 0, closing = false) => {
34
+ if (failed && !closing) return;
35
+ if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
36
+ failed = true;
37
+ ws.close(1009, "fake input buffer exceeded");
38
+ return;
39
+ }
40
+ pending++;
41
+ bytes += size;
42
+ tail = tail.then(async () => {
43
+ if (!failed || closing) await fn();
44
+ }).catch(fail).finally(() => { pending--; bytes -= size; });
45
+ };
46
+ ws.on("message", (data, binary) => {
47
+ const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
48
+ const message = binary ? new Uint8Array(buffer) : buffer.toString();
49
+ enqueue(() => handlers.message?.(socket, message), buffer.length);
50
+ });
51
+ ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
52
+ ws.on("error", fail);
53
+ enqueue(() => handlers.open?.(socket));
54
+ }
@@ -0,0 +1,219 @@
1
+ import { afterEach, expect, test, spyOn } from "bun:test";
2
+ import { once } from "node:events";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { execFileSync } from "node:child_process";
8
+ import { defineFake, type FakeContext, type FakeDefinition } from "../index.js";
9
+ import { dispatchFake } from "./fake-websocket.js";
10
+ import { startIngressServer, type IngressServer } from "./ingress-server.js";
11
+ import { currentInstrumentationScope } from "./instrumentation-scope.js";
12
+ import { startTlsTerminator } from "./tls-terminator.js";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const WS = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
16
+ const servers: IngressServer[] = [];
17
+ afterEach(() => { for (const s of servers.splice(0)) s.stop(true); });
18
+
19
+ async function serve<S>(def: FakeDefinition<S, any>, state: S) {
20
+ const server = await startIngressServer({ port: 0, hostname: "127.0.0.1",
21
+ fetch: (req, server) => dispatchFake(req, server, def, state, {} as FakeContext),
22
+ });
23
+ servers.push(server);
24
+ return `127.0.0.1:${server.port}`;
25
+ }
26
+
27
+ function client(url: string, protocols?: string[], options?: import("ws").ClientOptions) {
28
+ const ws = new WS(url.startsWith("wss://") ? url : `ws://${url}`, protocols, options);
29
+ const messages: { data: Buffer; binary: boolean }[] = [];
30
+ const waiting: ((value: { data: Buffer; binary: boolean }) => void)[] = [];
31
+ ws.on("message", (data, binary) => {
32
+ const value = { data: Buffer.from(data as Buffer), binary };
33
+ const next = waiting.shift();
34
+ if (next) next(value); else messages.push(value);
35
+ });
36
+ const open = once(ws, "open");
37
+ const closed = once(ws, "close");
38
+ return { ws, open, closed, next: () => messages.length ? Promise.resolve(messages.shift()!) :
39
+ new Promise<{ data: Buffer; binary: boolean }>(resolve => waiting.push(resolve)) };
40
+ }
41
+
42
+ test("HTTP and WebSocket share state, helpers, ordered async callbacks and binary frames", async () => {
43
+ const state = { events: [] as string[], closed: "" };
44
+ const def = defineFake({
45
+ name: "speech", hostnames: ["speech.test"],
46
+ state: () => state,
47
+ handler: (_req, state) => Response.json(state.events),
48
+ helpers: ({ state }) => ({ events: () => [...state.events] }),
49
+ websocket: async (req, state) => {
50
+ expect(new URL(req.url).pathname).toBe("/realtime");
51
+ expect(currentInstrumentationScope()).toBeNull();
52
+ return {
53
+ protocol: "audio",
54
+ async open(ws) {
55
+ await Bun.sleep(10);
56
+ state.events.push("open");
57
+ ws.send("ready");
58
+ },
59
+ async message(ws, message) {
60
+ await Bun.sleep(5);
61
+ expect(currentInstrumentationScope()).toBeNull();
62
+ state.events.push(typeof message === "string" ? message : "binary");
63
+ ws.send(message);
64
+ },
65
+ close(_ws, code, reason) { state.closed = `${code}:${reason}`; },
66
+ };
67
+ },
68
+ });
69
+ const addr = await serve(def, state);
70
+ const c = client(`${addr}/realtime`, ["other", "audio"]);
71
+ await c.open;
72
+ expect(c.ws.protocol).toBe("audio");
73
+ c.ws.send("first");
74
+ c.ws.send(new Uint8Array([0, 128, 255]));
75
+ expect((await c.next()).data.toString()).toBe("ready");
76
+ expect((await c.next()).data.toString()).toBe("first");
77
+ expect(await c.next()).toEqual({ data: Buffer.from([0, 128, 255]), binary: true });
78
+ expect(await (await fetch(`http://${addr}/events`)).json()).toEqual(["open", "first", "binary"]);
79
+ const helpers = await def.helpers!({ name: "speech", state, ctx: {} as FakeContext });
80
+ expect(helpers.events()).toEqual(["open", "first", "binary"]);
81
+ c.ws.close(4001, "finished");
82
+ await c.closed;
83
+ for (let i = 0; !state.closed && i < 50; i++) await Bun.sleep(2);
84
+ expect(state.closed).toBe("4001:finished");
85
+ });
86
+
87
+ test("handshake can reject before upgrade with an HTTP status and body", async () => {
88
+ const addr = await serve(defineFake({ name: "auth", hostnames: ["auth.test"],
89
+ handler: () => new Response("http"),
90
+ websocket: req => req.headers.get("authorization") === "Bearer valid"
91
+ ? {} : new Response("token required", { status: 401 }),
92
+ }), undefined);
93
+ const ws = new WS(`ws://${addr}/realtime`);
94
+ const [req, response] = await once(ws, "unexpected-response");
95
+ const chunks: Buffer[] = [];
96
+ for await (const chunk of response) chunks.push(Buffer.from(chunk));
97
+ expect(response.statusCode).toBe(401);
98
+ expect(Buffer.concat(chunks).toString()).toBe("token required");
99
+ req.destroy();
100
+ });
101
+
102
+ test("an unoffered protocol fails the handshake", async () => {
103
+ const addr = await serve(defineFake({ name: "bad", hostnames: ["bad.test"],
104
+ handler: () => new Response("http"), websocket: () => ({ protocol: "unoffered" }),
105
+ }), undefined);
106
+ const ws = new WS(`ws://${addr}`, ["offered"]);
107
+ const [req, response] = await once(ws, "unexpected-response");
108
+ expect(response.statusCode).toBe(500);
109
+ response.resume();
110
+ req.destroy();
111
+ });
112
+
113
+ test("a throwing async callback closes its connection and later connections still work", async () => {
114
+ const errors = spyOn(console, "error").mockImplementation(() => {});
115
+ try {
116
+ const addr = await serve(defineFake({ name: "errors", hostnames: ["errors.test"],
117
+ handler: () => new Response("healthy"),
118
+ websocket: () => ({ async message(ws, message) {
119
+ if (message === "fail") throw new Error("intentional failure");
120
+ ws.send(message);
121
+ } }),
122
+ }), undefined);
123
+ const c = client(addr);
124
+ await c.open;
125
+ c.ws.send("fail");
126
+ const [code] = await c.closed;
127
+ expect(code).toBe(1011);
128
+ const next = client(addr);
129
+ await next.open;
130
+ next.ws.send("works");
131
+ expect((await next.next()).data.toString()).toBe("works");
132
+ next.ws.close();
133
+ await next.closed;
134
+ expect(errors).toHaveBeenCalled();
135
+ } finally { errors.mockRestore(); }
136
+ });
137
+
138
+ test("HTTP-only fakes retain their existing handling of upgrade requests", async () => {
139
+ const addr = await serve(defineFake({ name: "http", hostnames: ["http.test"],
140
+ handler: () => new Response("no realtime", { status: 404 }),
141
+ }), undefined);
142
+ const ws = new WS(`ws://${addr}`);
143
+ const [req, response] = await once(ws, "unexpected-response");
144
+ expect(response.statusCode).toBe(404);
145
+ response.resume();
146
+ req.destroy();
147
+ });
148
+
149
+ test("WSS uses the shared TLS terminator with real certificate verification", async () => {
150
+ const dir = mkdtempSync(join(tmpdir(), "spectest-fake-wss-"));
151
+ let tls: Awaited<ReturnType<typeof startTlsTerminator>> | undefined;
152
+ try {
153
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
154
+ "-subj", "/CN=speech.test", "-addext", "subjectAltName=DNS:speech.test",
155
+ "-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
156
+ const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
157
+ const addr = await serve(defineFake({ name: "tls", hostnames: ["speech.test"],
158
+ handler: () => new Response("http"),
159
+ websocket: req => {
160
+ expect(req.headers.get("host")).toBe("speech.test");
161
+ expect(req.headers.get("cookie")).toBe("session=valid");
162
+ return { open(ws) { ws.send("secure"); }, message(ws, message) { ws.send(message); } };
163
+ },
164
+ }), undefined);
165
+ tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: Number(addr.split(":")[1]), certFor: () => leaf });
166
+ const c = client(`wss://127.0.0.1:${tls.port}`, undefined, {
167
+ servername: "speech.test", ca: leaf.cert, headers: { host: "speech.test", cookie: "session=valid" },
168
+ });
169
+ await c.open;
170
+ expect((await c.next()).data.toString()).toBe("secure");
171
+ c.ws.send(new Uint8Array([1, 2, 3]));
172
+ expect(await c.next()).toEqual({ data: Buffer.from([1, 2, 3]), binary: true });
173
+ c.ws.close(); await c.closed;
174
+ } finally { tls?.close(); rmSync(dir, { recursive: true, force: true }); }
175
+ });
176
+
177
+ test("each handshake owns separate connection-local state", async () => {
178
+ const addr = await serve(defineFake({ name: "sessions", hostnames: ["speech.test"],
179
+ handler: () => new Response("http"), websocket: () => {
180
+ let sequence = 0;
181
+ return { message(ws) { ws.send(String(++sequence)); } };
182
+ },
183
+ }), undefined);
184
+ const a = client(addr), b = client(addr);
185
+ await Promise.all([a.open, b.open]);
186
+ a.ws.send("next"); a.ws.send("next"); b.ws.send("next");
187
+ expect((await a.next()).data.toString()).toBe("1");
188
+ expect((await a.next()).data.toString()).toBe("2");
189
+ expect((await b.next()).data.toString()).toBe("1");
190
+ a.ws.close(); b.ws.close(); await Promise.all([a.closed, b.closed]);
191
+ });
192
+
193
+ test("oversized fake output closes with 1009 instead of buffering it", async () => {
194
+ const errors = spyOn(console, "error").mockImplementation(() => {});
195
+ try {
196
+ const addr = await serve(defineFake({ name: "bounded", hostnames: ["bounded.test"],
197
+ handler: () => new Response("http"),
198
+ websocket: () => ({ message(ws) { ws.send(new Uint8Array(16 * 1024 * 1024 + 1)); } }),
199
+ }), undefined);
200
+ const c = client(addr);
201
+ await c.open;
202
+ c.ws.send("start");
203
+ expect((await c.closed)[0]).toBe(1009);
204
+ } finally { errors.mockRestore(); }
205
+ });
206
+
207
+ test("a slow callback cannot accumulate unlimited pending messages", async () => {
208
+ let release!: () => void;
209
+ const blocked = new Promise<void>(resolve => { release = resolve; });
210
+ try {
211
+ const addr = await serve(defineFake({ name: "slow", hostnames: ["slow.test"],
212
+ handler: () => new Response("http"), websocket: () => ({ open: () => blocked }),
213
+ }), undefined);
214
+ const c = client(addr);
215
+ await c.open;
216
+ for (let i = 0; i < 1100; i++) c.ws.send("queued");
217
+ expect((await c.closed)[0]).toBe(1009);
218
+ } finally { release(); }
219
+ });
@@ -0,0 +1,39 @@
1
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
+ import type { FakeContext, FakeDefinition } from "../index.js";
3
+ import type { IngressServer } from "./ingress-server.js";
4
+ import { runUninstrumented } from "./instrumentation-scope.js";
5
+ import { parseSubprotocols } from "./ws-bridge.js";
6
+
7
+ /** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
8
+ export async function dispatchFake<S>(
9
+ req: Request,
10
+ server: IngressServer,
11
+ def: FakeDefinition<S, any>,
12
+ state: S,
13
+ ctx: FakeContext,
14
+ ): Promise<Response> {
15
+ try {
16
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
17
+ return await runUninstrumented(() => def.handler(req, state, ctx));
18
+ }
19
+ const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
20
+ if (accepted instanceof Response) return accepted;
21
+ if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
22
+ if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
23
+ throw new Error("websocket selected a protocol the client did not offer");
24
+ }
25
+ const handlers: FakeWebSocketHandlers = {
26
+ open: socket => runUninstrumented(() => accepted.open?.(socket)),
27
+ message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
28
+ close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
29
+ };
30
+ return server.upgrade(req, {
31
+ handlers,
32
+ headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
33
+ }) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
34
+ } catch (err) {
35
+ return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
36
+ status: 500, headers: { "content-type": "text/plain" },
37
+ });
38
+ }
39
+ }
@@ -3,6 +3,7 @@ import { defineEnvironment, defineFake, lowerIngress, provides, proxy, type Cors
3
3
  import { supabase } from "../components/supabase.js";
4
4
  import { withIngressCors, upstreamRequestHeaders } from "./http-proxy.js";
5
5
  import { InterceptRegistry, runChain } from "./intercept.js";
6
+ import { startIngressServer } from "./ingress-server.js";
6
7
 
7
8
  const allowed = "https://app.tangent.test";
8
9
  const denied = "https://untrusted.test";
@@ -56,12 +57,12 @@ for (const kind of ["fake", "service"] as const) {
56
57
  method: req.method, headers: upstreamRequestHeaders(req.headers, { proto: "https" }),
57
58
  body: req.body, redirect: "manual",
58
59
  });
59
- const ingress = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
+ const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: req =>
60
61
  withIngressCors(req, mode, current => runChain(
61
62
  interceptors.chainFor("bucket.test", new URL(current.url).pathname), current, forward)),
62
63
  });
63
64
  try {
64
- const send = (path: string, init: RequestInit) => fetch(new URL(path, ingress.url), init);
65
+ const send = (path: string, init: RequestInit) => fetch(new URL(path, `http://127.0.0.1:${ingress.port}`), init);
65
66
  const ok = await send("/probe", preflight(allowed));
66
67
  expect(ok.status).toBe(204);
67
68
  expect(ok.headers.get("access-control-allow-origin")).toBe(allowed);