@specific.dev/spectest 0.100.0 → 0.102.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.
@@ -0,0 +1,250 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import { once } from "node:events";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { gzipSync } from "node:zlib";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { execFileSync } from "node:child_process";
11
+ import { forwardableHeaders, withIngressCors } from "./http-proxy";
12
+ import { startIngressServer, type IngressServer } from "./ingress-server";
13
+ import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
14
+
15
+ const object = randomBytes(65536);
16
+ const compressed = gzipSync(object);
17
+ const digest = (body: Uint8Array) => createHash("sha256").update(body).digest("hex");
18
+ let upstream: http.Server;
19
+ let upstreamPort: number;
20
+ let ingress: IngressServer;
21
+ let tls: TlsTerminator;
22
+ let dir: string;
23
+ let release: (() => void) | undefined;
24
+ let upstreamClosed: Promise<unknown>;
25
+
26
+ beforeAll(async () => {
27
+ upstream = http.createServer((req, res) => {
28
+ const path = req.url!;
29
+ let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
30
+ res.setHeader("content-type", "application/octet-stream");
31
+ res.setHeader("etag", '"original"');
32
+ res.setHeader("set-cookie", ["a=1", "b=2"]);
33
+ if (path === "/empty") body = Buffer.alloc(0);
34
+ if (path === "/gzip") res.setHeader("content-encoding", "gzip");
35
+ if (req.headers.range) {
36
+ body = body.subarray(512, 8704);
37
+ res.statusCode = 206;
38
+ res.setHeader("content-range", `bytes 512-8703/${object.length}`);
39
+ }
40
+ if (path !== "/unknown" && path !== "/unknown-small") res.setHeader("content-length", body.length);
41
+ if (path === "/large") res.setHeader("content-length", 1024 ** 3);
42
+ if (path === "/not-modified") res.statusCode = 304;
43
+ if (path === "/no-content") { res.statusCode = 204; res.removeHeader("content-length"); }
44
+ if (req.method === "HEAD" || [204, 304].includes(res.statusCode) || body.length === 0) { res.end(); return; }
45
+ if (path === "/unknown-small") { res.write("small"); res.end(); return; }
46
+ // Force multiple reads by sending the tail after headers + first chunk.
47
+ res.write(body.subarray(0, 1024));
48
+ if (path === "/gated" || path === "/large" || path === "/unknown") {
49
+ upstreamClosed = once(res, "close");
50
+ release = () => res.end(body.subarray(1024));
51
+ } else {
52
+ setTimeout(() => res.end(body.subarray(1024)), 5);
53
+ }
54
+ });
55
+ upstream.listen(0, "127.0.0.1");
56
+ await once(upstream, "listening");
57
+ upstreamPort = (upstream.address() as { port: number }).port;
58
+ ingress = await startIngressServer({
59
+ port: 0, hostname: "127.0.0.1",
60
+ fetch: req => withIngressCors(req, "permissive", async current => {
61
+ const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
62
+ method: current.method, headers: current.headers, signal: current.signal,
63
+ decompress: false, keepalive: false,
64
+ });
65
+ return new Response(res.body, {
66
+ status: res.status, statusText: res.statusText, headers: forwardableHeaders(res.headers),
67
+ });
68
+ }),
69
+ });
70
+ dir = mkdtempSync(join(tmpdir(), "spectest-length-"));
71
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2",
72
+ "-subj", "/CN=bucket.test", "-keyout", join(dir, "key.pem"), "-out", join(dir, "cert.pem")], { stdio: "ignore" });
73
+ const leaf = { key: readFileSync(join(dir, "key.pem"), "utf8"), cert: readFileSync(join(dir, "cert.pem"), "utf8") };
74
+ tls = await startTlsTerminator({ port: 0, hostname: "127.0.0.1", upstreamPort: ingress.port, certFor: () => leaf });
75
+ });
76
+
77
+ afterAll(() => {
78
+ release?.();
79
+ tls?.close();
80
+ ingress?.stop(true);
81
+ upstream?.closeAllConnections();
82
+ upstream?.close();
83
+ if (dir) rmSync(dir, { recursive: true, force: true });
84
+ });
85
+
86
+ function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string> } = {}) {
87
+ const boundary = options.boundary ?? "https";
88
+ return new Promise<http.IncomingMessage>((resolve, reject) => {
89
+ const req = (boundary === "https" ? https : http).request({
90
+ host: "127.0.0.1", port: boundary === "https" ? tls.port : boundary === "http" ? ingress.port : upstreamPort,
91
+ path, method: options.method ?? "GET", headers: { host: "bucket.test", ...options.headers },
92
+ agent: false, rejectUnauthorized: false,
93
+ }, resolve);
94
+ req.once("error", reject);
95
+ req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
96
+ req.end();
97
+ });
98
+ }
99
+
100
+ async function bytes(res: http.IncomingMessage) {
101
+ const chunks: Buffer[] = [];
102
+ for await (const chunk of res) chunks.push(Buffer.from(chunk));
103
+ return Buffer.concat(chunks);
104
+ }
105
+
106
+ describe("ingress response framing", () => {
107
+ for (const boundary of ["storage", "http", "https"] as const) {
108
+ test(`${boundary}: repeated multi-chunk GETs keep length and digest`, async () => {
109
+ for (const size of [8960, 65536]) {
110
+ for (let n = 0; n < 20; n++) {
111
+ const res = await request(`/${size}`, { boundary });
112
+ const body = await bytes(res);
113
+ expect(res.statusCode).toBe(200);
114
+ expect(res.headers["content-length"]).toBe(String(size));
115
+ expect(res.headers["transfer-encoding"]).toBeUndefined();
116
+ expect(body.length).toBe(size);
117
+ expect(digest(body)).toBe(digest(object.subarray(0, size)));
118
+ }
119
+ }
120
+ });
121
+ }
122
+
123
+ test("HEAD, empty objects, ranges, cookies and CORS preserve their metadata", async () => {
124
+ const head = await request("/65536", { method: "HEAD" });
125
+ expect(head.headers["content-length"]).toBe("65536");
126
+ expect((await bytes(head)).length).toBe(0);
127
+ const empty = await request("/empty");
128
+ expect(empty.headers["content-length"]).toBe("0");
129
+ expect((await bytes(empty)).length).toBe(0);
130
+ const range = await request("/65536", { headers: { range: "bytes=512-8703", origin: "https://app.test" } });
131
+ expect(range.statusCode).toBe(206);
132
+ expect(range.headers["content-length"]).toBe("8192");
133
+ expect(range.headers["content-range"]).toBe("bytes 512-8703/65536");
134
+ expect(range.headers.etag).toBe('"original"');
135
+ expect(range.headers["set-cookie"]).toEqual(["a=1", "b=2"]);
136
+ expect(range.headers["access-control-allow-origin"]).toBe("https://app.test");
137
+ expect(digest(await bytes(range))).toBe(digest(object.subarray(512, 8704)));
138
+ });
139
+
140
+ test("compressed bytes and their encoded length remain unchanged", async () => {
141
+ const res = await request("/gzip", { headers: { "accept-encoding": "gzip" } });
142
+ expect(res.headers["content-length"]).toBe(String(compressed.length));
143
+ expect(res.headers["content-encoding"]).toBe("gzip");
144
+ expect(digest(await bytes(res))).toBe(digest(compressed));
145
+ });
146
+
147
+ test("no length is invented for an already-complete unknown-length body", async () => {
148
+ const res = await request("/unknown-small");
149
+ expect(res.headers["content-length"]).toBeUndefined();
150
+ expect((await bytes(res)).toString()).toBe("small");
151
+ });
152
+
153
+ test("bodyless statuses stay bodyless", async () => {
154
+ for (const [path, status] of [["/not-modified", 304], ["/no-content", 204]] as const) {
155
+ const res = await request(path);
156
+ expect(res.statusCode).toBe(status);
157
+ expect(res.headers["transfer-encoding"]).toBeUndefined();
158
+ if (status === 304) expect(res.headers["content-length"]).toBe("65536");
159
+ expect((await bytes(res)).length).toBe(0);
160
+ }
161
+ });
162
+
163
+ for (const path of ["/gated", "/unknown", "/large"]) {
164
+ test(`${path}: first bytes arrive before upstream finishes; disconnect cancels`, async () => {
165
+ // The source cannot finish until the test releases it. This fails if
166
+ // ingress waits for an entire body, regardless of any buffering cap.
167
+ const res = await request(path);
168
+ const first = await once(res, "data");
169
+ expect(Buffer.from(first[0])).toEqual(object.subarray(0, 1024));
170
+ expect(res.headers["content-length"]).toBe(path === "/unknown" ? undefined : path === "/large" ? String(1024 ** 3) : "65536");
171
+ if (path === "/large") {
172
+ res.destroy();
173
+ await upstreamClosed;
174
+ } else {
175
+ const rest = bytes(res);
176
+ release!();
177
+ expect(digest(Buffer.concat([first[0], await rest]))).toBe(digest(object));
178
+ }
179
+ });
180
+ }
181
+ });
182
+
183
+ describe("ingress request lifecycle", () => {
184
+ test("streams uploads and preserves the request URL, headers and peer", async () => {
185
+ const server = await startIngressServer({
186
+ port: 0, hostname: "127.0.0.1",
187
+ async fetch(req, ingress) {
188
+ const body = await req.arrayBuffer();
189
+ return Response.json({ method: req.method, url: req.url, digest: digest(new Uint8Array(body)),
190
+ authorization: req.headers.get("authorization"), peer: ingress.requestIP(req)?.address });
191
+ },
192
+ });
193
+ try {
194
+ for (const chunked of [false, true]) {
195
+ const result = new Promise<http.IncomingMessage>((resolve, reject) => {
196
+ const req = http.request({ host: "127.0.0.1", port: server.port, path: "/upload?part=2", method: "PUT",
197
+ headers: { host: "bucket.test:8443", authorization: "signed", ...(chunked ? {} : { "content-length": object.length }) },
198
+ }, resolve);
199
+ req.on("error", reject);
200
+ req.write(object.subarray(0, 1024));
201
+ setTimeout(() => req.end(object.subarray(1024)), 5);
202
+ });
203
+ expect(JSON.parse((await bytes(await result)).toString())).toEqual({ method: "PUT",
204
+ url: "http://bucket.test:8443/upload?part=2", digest: digest(object), authorization: "signed", peer: "127.0.0.1" });
205
+ }
206
+ } finally { server.stop(true); }
207
+ });
208
+
209
+ test("disconnect before response headers aborts the handler", async () => {
210
+ let entered!: () => void;
211
+ const started = new Promise<void>(resolve => { entered = resolve; });
212
+ let cancelled!: () => void;
213
+ const aborted = new Promise<void>(resolve => { cancelled = resolve; });
214
+ const server = await startIngressServer({
215
+ port: 0, hostname: "127.0.0.1",
216
+ async fetch(req) {
217
+ req.signal.addEventListener("abort", cancelled, { once: true });
218
+ entered();
219
+ await aborted;
220
+ return new Response("cancelled");
221
+ },
222
+ });
223
+ try {
224
+ const req = http.get(`http://127.0.0.1:${server.port}/`);
225
+ req.on("error", () => {});
226
+ await started;
227
+ req.destroy();
228
+ await aborted;
229
+ } finally { server.stop(true); }
230
+ });
231
+
232
+ test("an error after headers closes the stream instead of appending a success or error body", async () => {
233
+ const server = await startIngressServer({
234
+ port: 0, hostname: "127.0.0.1",
235
+ fetch: () => new Response(new ReadableStream({
236
+ start(controller) {
237
+ controller.enqueue(object.subarray(0, 1024));
238
+ setTimeout(() => controller.error(new Error("upstream truncated")), 20);
239
+ },
240
+ }), { headers: { "content-length": String(object.length) } }),
241
+ });
242
+ try {
243
+ const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
244
+ http.get(`http://127.0.0.1:${server.port}/`, resolve).on("error", reject);
245
+ });
246
+ expect(res.headers["content-length"]).toBe(String(object.length));
247
+ await expect(bytes(res)).rejects.toThrow();
248
+ } finally { server.stop(true); }
249
+ });
250
+ });
@@ -0,0 +1,175 @@
1
+ import http, { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import type { Socket } from "node:net";
5
+ import { createRequire } from "node:module";
6
+ import { dirname, join } from "node:path";
7
+ import { WS_BRIDGE_HANDLERS, type WsBridgeData } from "./ws-bridge.js";
8
+
9
+ // Load the installed implementation, not Bun's built-in `ws` replacement.
10
+ // On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
11
+ // asynchronous upstream handshake. The package uses the ordinary socket API.
12
+ const require = createRequire(import.meta.url);
13
+ const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
14
+
15
+ export interface IngressServer {
16
+ port: number;
17
+ stop(force?: boolean): void;
18
+ requestIP(req: Request): { address: string; port: number } | undefined;
19
+ upgrade(req: Request, options: { data: WsBridgeData; headers?: Record<string, string> }): boolean;
20
+ }
21
+
22
+ /**
23
+ * Serve the ingress's Fetch handlers through node:http. Bun.serve discards
24
+ * Content-Length on a still-streaming Response, even when its bytes are
25
+ * unchanged. ServerResponse preserves the declared length while pipeline
26
+ * propagates backpressure and cancellation without buffering the whole body.
27
+ * This is also the HTTP/1 listener behind the TLS terminator.
28
+ */
29
+ export async function startIngressServer(options: {
30
+ port: number;
31
+ hostname?: string;
32
+ reusePort?: boolean;
33
+ fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
34
+ }): Promise<IngressServer> {
35
+ const peers = new WeakMap<Request, Socket>();
36
+ const upgrades = new WeakMap<Request, { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean }>();
37
+ const protocols = new WeakMap<IncomingMessage, string | undefined>();
38
+ const sockets = new Set<Socket>();
39
+ const websockets = new WebSocketServer({
40
+ noServer: true,
41
+ // The upstream handshake has already selected a protocol. Never select
42
+ // the client's first offer on its behalf.
43
+ handleProtocols: (_offered, req) => protocols.get(req) || false,
44
+ });
45
+ const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
46
+ void dispatch(req, res);
47
+ });
48
+ server.on("connection", socket => {
49
+ sockets.add(socket);
50
+ socket.once("close", () => sockets.delete(socket));
51
+ });
52
+ server.on("upgrade", (req, socket, head) => {
53
+ void dispatch(req, undefined, { req, socket: socket as Socket, head, upgraded: false });
54
+ });
55
+
56
+ const facade: IngressServer = {
57
+ port: 0,
58
+ stop(force = false) {
59
+ if (force) {
60
+ for (const ws of websockets.clients) ws.terminate();
61
+ for (const socket of sockets) socket.destroy();
62
+ server.closeAllConnections();
63
+ }
64
+ websockets.close();
65
+ server.close();
66
+ },
67
+ requestIP(req) {
68
+ const socket = peers.get(req);
69
+ return socket?.remoteAddress && socket.remotePort
70
+ ? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
71
+ },
72
+ upgrade(req, opts) {
73
+ const pending = upgrades.get(req);
74
+ if (!pending || pending.upgraded || pending.socket.destroyed) return false;
75
+ protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
76
+ websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
77
+ pending.upgraded = true;
78
+ const client = {
79
+ data: opts.data,
80
+ send: (data: string | ArrayBuffer | Uint8Array) => ws.send(data),
81
+ close: (code?: number, reason?: string) => ws.close(code, reason),
82
+ };
83
+ ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client,
84
+ binary ? Buffer.from(data as Buffer) : data.toString()));
85
+ ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
86
+ ws.on("error", () => opts.data.upstream.close());
87
+ WS_BRIDGE_HANDLERS.open(client);
88
+ });
89
+ return pending.upgraded;
90
+ },
91
+ };
92
+
93
+ async function dispatch(
94
+ incoming: IncomingMessage,
95
+ outgoing: ServerResponse | undefined,
96
+ upgrade?: { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean },
97
+ ): Promise<void> {
98
+ const abort = new AbortController();
99
+ const onClose = () => {
100
+ if (!outgoing?.writableFinished && !upgrade?.upgraded) abort.abort();
101
+ };
102
+ const connection = outgoing ?? upgrade!.socket;
103
+ connection.once("close", onClose);
104
+ const responseWriter = () => {
105
+ if (!outgoing) {
106
+ // Only attach an HTTP response after a refused upgrade. Attaching
107
+ // one before handleUpgrade conflicts with Bun's native handshake.
108
+ outgoing = new http.ServerResponse(incoming);
109
+ outgoing.assignSocket(upgrade!.socket);
110
+ outgoing.once("finish", () => upgrade!.socket.end());
111
+ }
112
+ return outgoing;
113
+ };
114
+ let response: Response | undefined;
115
+ try {
116
+ const headers = new Headers();
117
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
118
+ headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
119
+ }
120
+ const method = incoming.method ?? "GET";
121
+ const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
122
+ method, headers, signal: abort.signal,
123
+ body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
124
+ duplex: "half",
125
+ } as RequestInit);
126
+ peers.set(req, incoming.socket);
127
+ if (upgrade) upgrades.set(req, upgrade);
128
+ response = await options.fetch(req, facade);
129
+ if (upgrade?.upgraded) return;
130
+ if (!response) throw new Error("Ingress handler returned no response");
131
+ if (abort.signal.aborted) {
132
+ await response.body?.cancel();
133
+ return;
134
+ }
135
+ const writer = responseWriter();
136
+ writer.statusCode = response.status;
137
+ writer.statusMessage = response.statusText;
138
+ response.headers.forEach((value, key) => {
139
+ if (key !== "set-cookie") writer.setHeader(key, value);
140
+ });
141
+ const cookies = response.headers.getSetCookie();
142
+ if (cookies.length) writer.setHeader("set-cookie", cookies);
143
+ // Flush even for a body already buffered by fetch. An upstream with
144
+ // no length must not acquire an invented Content-Length from end().
145
+ writer.flushHeaders();
146
+ if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
147
+ await response.body?.cancel();
148
+ writer.end();
149
+ } else {
150
+ await pipeline(Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream), writer);
151
+ }
152
+ } catch (error) {
153
+ if (upgrade?.upgraded) return;
154
+ outgoing = responseWriter();
155
+ if (!outgoing.headersSent && !outgoing.destroyed) {
156
+ outgoing.writeHead(500, { "content-type": "text/plain" });
157
+ outgoing.end("spectest-daemon: ingress response failed\n");
158
+ } else {
159
+ outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
160
+ }
161
+ } finally {
162
+ connection.off("close", onClose);
163
+ }
164
+ }
165
+
166
+ await new Promise<void>((resolve, reject) => {
167
+ server.once("error", reject);
168
+ server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
169
+ server.off("error", reject);
170
+ resolve();
171
+ });
172
+ });
173
+ facade.port = (server.address() as { port: number }).port;
174
+ return facade;
175
+ }
@@ -28,6 +28,7 @@ import tls from "node:tls";
28
28
  import type { Leaf } from "./ingress-table";
29
29
  import { selectCertName } from "./hostmatch";
30
30
  import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
31
+ import { startIngressServer } from "./ingress-server";
31
32
 
32
33
  // ── a throwaway CA and leaves, so the client can verify for real ──────────
33
34
  let dir: string;
@@ -80,10 +81,9 @@ let seenClientIp: string | undefined;
80
81
 
81
82
  beforeAll(async () => {
82
83
  certByHost = new Map([["app.test", leaves.get("app.test")!]]);
83
- plain = Bun.serve({
84
+ plain = await startIngressServer({
84
85
  port: 0,
85
86
  hostname: "127.0.0.1",
86
- idleTimeout: 0,
87
87
  fetch(req, server) {
88
88
  const peer = server.requestIP(req);
89
89
  seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
@@ -33,15 +33,11 @@
33
33
  * ## The shape, and why it is a byte pipe
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
- * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
- * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
39
- * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
40
- * bridging — is the same `Bun.serve` code that already serves :80, run
41
- * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
42
- * would have meant reimplementing the WebSocket bridge against a
43
- * different API, which is exactly the kind of trade this change exists to
44
- * avoid: one flake removed, another introduced somewhere subtler.
36
+ * leaf by SNI, and pipes the decrypted stream to the plaintext ingress
37
+ * server bound on loopback. Opted-in HTTP/2 routes select a separate stream
38
+ * router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
39
+ * Routing, fakes, interceptors and reverse-proxying stay in that listener;
40
+ * this layer never parses or rewrites HTTP response framing.
45
41
  *
46
42
  * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
47
43
  * 50 MB bodies): ~11 % fewer small requests per second, download
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { startIngressServer, type IngressServer } from "./ingress-server";
2
3
 
3
4
  import {
4
- WS_BRIDGE_HANDLERS,
5
5
  openUpstreamWebSocket,
6
6
  parseSubprotocols,
7
7
  upstreamHandshakeHeaders,
@@ -65,11 +65,11 @@ describe("upstreamHandshakeHeaders", () => {
65
65
  */
66
66
  describe("the bridge on loopback", () => {
67
67
  let upstream: ReturnType<typeof Bun.serve>;
68
- let bridge: ReturnType<typeof Bun.serve>;
68
+ let bridge: IngressServer;
69
69
  /** What the upstream saw on its last handshake. */
70
70
  let seen: { protocols: string[]; cookie: string | null; host: string | null; xfh: string | null } | null = null;
71
71
 
72
- beforeAll(() => {
72
+ beforeAll(async () => {
73
73
  upstream = Bun.serve({
74
74
  port: 0,
75
75
  fetch(req, server) {
@@ -102,7 +102,7 @@ describe("the bridge on loopback", () => {
102
102
  },
103
103
  });
104
104
 
105
- bridge = Bun.serve({
105
+ bridge = await startIngressServer({
106
106
  port: 0,
107
107
  async fetch(req, server) {
108
108
  const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
@@ -128,7 +128,6 @@ describe("the bridge on loopback", () => {
128
128
  }
129
129
  return undefined;
130
130
  },
131
- websocket: WS_BRIDGE_HANDLERS,
132
131
  });
133
132
  });
134
133