@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/README.md +46 -0
- package/dist/browser.d.ts +6 -0
- package/dist/browser.js +81 -28
- package/dist/daemon.js +42 -79
- package/dist/fake-websocket.d.ts +16 -0
- package/dist/fake-websocket.js +1 -0
- package/dist/harness/fake-websocket-connection.d.ts +5 -0
- package/dist/harness/fake-websocket-connection.js +58 -0
- package/dist/harness/fake-websocket.d.ts +4 -0
- package/dist/harness/fake-websocket.js +32 -0
- package/dist/harness/ingress-server.d.ts +30 -0
- package/dist/harness/ingress-server.js +179 -0
- package/dist/harness/microphone-fixture.d.ts +13 -0
- package/dist/harness/microphone-fixture.js +67 -0
- package/dist/harness/tls-terminator.js +5 -9
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
- package/src/browser.ts +69 -26
- package/src/daemon.ts +44 -95
- package/src/fake-websocket.ts +17 -0
- package/src/harness/fake-websocket-connection.ts +54 -0
- package/src/harness/fake-websocket.test.ts +219 -0
- package/src/harness/fake-websocket.ts +39 -0
- package/src/harness/ingress-cors.test.ts +3 -2
- package/src/harness/ingress-server.test.ts +250 -0
- package/src/harness/ingress-server.ts +188 -0
- package/src/harness/microphone-fixture.ts +71 -0
- package/src/harness/tls-terminator.test.ts +2 -2
- package/src/harness/tls-terminator.ts +5 -9
- package/src/harness/ws-bridge.test.ts +4 -5
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +143 -0
|
@@ -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,188 @@
|
|
|
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
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
9
|
+
import { attachFakeWebSocket } from "./fake-websocket-connection.js";
|
|
10
|
+
|
|
11
|
+
// Load the installed implementation, not Bun's built-in `ws` replacement.
|
|
12
|
+
// On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
|
|
13
|
+
// asynchronous upstream handshake. The package uses the ordinary socket API.
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
16
|
+
|
|
17
|
+
export interface IngressServer {
|
|
18
|
+
port: number;
|
|
19
|
+
stop(force?: boolean): void;
|
|
20
|
+
requestIP(req: Request): { address: string; port: number } | undefined;
|
|
21
|
+
upgrade(req: Request, options: ({ data: WsBridgeData } | { handlers: FakeWebSocketHandlers }) & { headers?: Record<string, string> }): boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Serve the ingress's Fetch handlers through node:http. Bun.serve discards
|
|
26
|
+
* Content-Length on a still-streaming Response, even when its bytes are
|
|
27
|
+
* unchanged. ServerResponse preserves the declared length while pipeline
|
|
28
|
+
* propagates backpressure and cancellation without buffering the whole body.
|
|
29
|
+
* This is also the HTTP/1 listener behind the TLS terminator.
|
|
30
|
+
*/
|
|
31
|
+
export async function startIngressServer(options: {
|
|
32
|
+
port: number;
|
|
33
|
+
hostname?: string;
|
|
34
|
+
reusePort?: boolean;
|
|
35
|
+
fetch(req: Request, server: IngressServer): Response | undefined | Promise<Response | undefined>;
|
|
36
|
+
}): Promise<IngressServer> {
|
|
37
|
+
const peers = new WeakMap<Request, Socket>();
|
|
38
|
+
const upgrades = new WeakMap<Request, { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean }>();
|
|
39
|
+
const protocols = new WeakMap<IncomingMessage, string | undefined>();
|
|
40
|
+
const sockets = new Set<Socket>();
|
|
41
|
+
const websockets = new WebSocketServer({
|
|
42
|
+
noServer: true,
|
|
43
|
+
// The upstream handshake has already selected a protocol. Never select
|
|
44
|
+
// the client's first offer on its behalf.
|
|
45
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
46
|
+
});
|
|
47
|
+
const fakeWebsockets = new WebSocketServer({
|
|
48
|
+
noServer: true, maxPayload: 16 * 1024 * 1024,
|
|
49
|
+
handleProtocols: (_offered, req) => protocols.get(req) || false,
|
|
50
|
+
});
|
|
51
|
+
const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
|
|
52
|
+
void dispatch(req, res);
|
|
53
|
+
});
|
|
54
|
+
server.on("connection", socket => {
|
|
55
|
+
sockets.add(socket);
|
|
56
|
+
socket.once("close", () => sockets.delete(socket));
|
|
57
|
+
});
|
|
58
|
+
server.on("upgrade", (req, socket, head) => {
|
|
59
|
+
void dispatch(req, undefined, { req, socket: socket as Socket, head, upgraded: false });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const facade: IngressServer = {
|
|
63
|
+
port: 0,
|
|
64
|
+
stop(force = false) {
|
|
65
|
+
if (force) {
|
|
66
|
+
for (const ws of websockets.clients) ws.terminate();
|
|
67
|
+
for (const ws of fakeWebsockets.clients) ws.terminate();
|
|
68
|
+
for (const socket of sockets) socket.destroy();
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
}
|
|
71
|
+
websockets.close();
|
|
72
|
+
fakeWebsockets.close();
|
|
73
|
+
server.close();
|
|
74
|
+
},
|
|
75
|
+
requestIP(req) {
|
|
76
|
+
const socket = peers.get(req);
|
|
77
|
+
return socket?.remoteAddress && socket.remotePort
|
|
78
|
+
? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
|
|
79
|
+
},
|
|
80
|
+
upgrade(req, opts) {
|
|
81
|
+
const pending = upgrades.get(req);
|
|
82
|
+
if (!pending || pending.upgraded || pending.socket.destroyed) return false;
|
|
83
|
+
protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
|
|
84
|
+
const target = "handlers" in opts ? fakeWebsockets : websockets;
|
|
85
|
+
target.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
|
|
86
|
+
pending.upgraded = true;
|
|
87
|
+
if ("handlers" in opts) {
|
|
88
|
+
attachFakeWebSocket(ws, opts.handlers);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const client = {
|
|
92
|
+
data: opts.data,
|
|
93
|
+
send: (data: string | ArrayBuffer | Uint8Array) => ws.send(data),
|
|
94
|
+
close: (code?: number, reason?: string) => ws.close(code, reason),
|
|
95
|
+
};
|
|
96
|
+
ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client,
|
|
97
|
+
binary ? Buffer.from(data as Buffer) : data.toString()));
|
|
98
|
+
ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
|
|
99
|
+
ws.on("error", () => opts.data.upstream.close());
|
|
100
|
+
WS_BRIDGE_HANDLERS.open(client);
|
|
101
|
+
});
|
|
102
|
+
return pending.upgraded;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
async function dispatch(
|
|
107
|
+
incoming: IncomingMessage,
|
|
108
|
+
outgoing: ServerResponse | undefined,
|
|
109
|
+
upgrade?: { req: IncomingMessage; socket: Socket; head: Buffer; upgraded: boolean },
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const abort = new AbortController();
|
|
112
|
+
const onClose = () => {
|
|
113
|
+
if (!outgoing?.writableFinished && !upgrade?.upgraded) abort.abort();
|
|
114
|
+
};
|
|
115
|
+
const connection = outgoing ?? upgrade!.socket;
|
|
116
|
+
connection.once("close", onClose);
|
|
117
|
+
const responseWriter = () => {
|
|
118
|
+
if (!outgoing) {
|
|
119
|
+
// Only attach an HTTP response after a refused upgrade. Attaching
|
|
120
|
+
// one before handleUpgrade conflicts with Bun's native handshake.
|
|
121
|
+
outgoing = new http.ServerResponse(incoming);
|
|
122
|
+
outgoing.assignSocket(upgrade!.socket);
|
|
123
|
+
outgoing.once("finish", () => upgrade!.socket.end());
|
|
124
|
+
}
|
|
125
|
+
return outgoing;
|
|
126
|
+
};
|
|
127
|
+
let response: Response | undefined;
|
|
128
|
+
try {
|
|
129
|
+
const headers = new Headers();
|
|
130
|
+
for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
|
|
131
|
+
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
132
|
+
}
|
|
133
|
+
const method = incoming.method ?? "GET";
|
|
134
|
+
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
135
|
+
method, headers, signal: abort.signal,
|
|
136
|
+
body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
|
|
137
|
+
duplex: "half",
|
|
138
|
+
} as RequestInit);
|
|
139
|
+
peers.set(req, incoming.socket);
|
|
140
|
+
if (upgrade) upgrades.set(req, upgrade);
|
|
141
|
+
response = await options.fetch(req, facade);
|
|
142
|
+
if (upgrade?.upgraded) return;
|
|
143
|
+
if (!response) throw new Error("Ingress handler returned no response");
|
|
144
|
+
if (abort.signal.aborted) {
|
|
145
|
+
await response.body?.cancel();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const writer = responseWriter();
|
|
149
|
+
writer.statusCode = response.status;
|
|
150
|
+
writer.statusMessage = response.statusText;
|
|
151
|
+
response.headers.forEach((value, key) => {
|
|
152
|
+
if (key !== "set-cookie") writer.setHeader(key, value);
|
|
153
|
+
});
|
|
154
|
+
const cookies = response.headers.getSetCookie();
|
|
155
|
+
if (cookies.length) writer.setHeader("set-cookie", cookies);
|
|
156
|
+
// Flush even for a body already buffered by fetch. An upstream with
|
|
157
|
+
// no length must not acquire an invented Content-Length from end().
|
|
158
|
+
writer.flushHeaders();
|
|
159
|
+
if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
|
|
160
|
+
await response.body?.cancel();
|
|
161
|
+
writer.end();
|
|
162
|
+
} else {
|
|
163
|
+
await pipeline(Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream), writer);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (upgrade?.upgraded) return;
|
|
167
|
+
outgoing = responseWriter();
|
|
168
|
+
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
169
|
+
outgoing.writeHead(500, { "content-type": "text/plain" });
|
|
170
|
+
outgoing.end("spectest-daemon: ingress response failed\n");
|
|
171
|
+
} else {
|
|
172
|
+
outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
connection.off("close", onClose);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await new Promise<void>((resolve, reject) => {
|
|
180
|
+
server.once("error", reject);
|
|
181
|
+
server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
|
|
182
|
+
server.off("error", reject);
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
facade.port = (server.address() as { port: number }).port;
|
|
187
|
+
return facade;
|
|
188
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveExistingProjectPath } from "../project-files.js";
|
|
5
|
+
|
|
6
|
+
export interface MicrophoneFixture {
|
|
7
|
+
/** WAV file relative to the repository root (absolute paths also work).
|
|
8
|
+
* Use 16-bit PCM mono or stereo audio. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** Repeat the recording. Default false: play once, then supply silence. */
|
|
11
|
+
loop?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Stage immutable bytes under a safe filename: Chromium interprets a `%`
|
|
15
|
+
* suffix as playback options, so a project filename cannot be passed verbatim. */
|
|
16
|
+
export async function prepareMicrophoneFixture(fixture: MicrophoneFixture): Promise<{
|
|
17
|
+
arg: string;
|
|
18
|
+
cleanup(): Promise<void>;
|
|
19
|
+
}> {
|
|
20
|
+
if (!fixture || typeof fixture.path !== "string" || !fixture.path.trim()) {
|
|
21
|
+
throw new Error("microphone.path must name a WAV fixture");
|
|
22
|
+
}
|
|
23
|
+
if (fixture.loop !== undefined && typeof fixture.loop !== "boolean") throw new Error("microphone.loop must be a boolean");
|
|
24
|
+
const source = resolveExistingProjectPath(fixture.path, "microphone fixture");
|
|
25
|
+
const bytes = await readFile(source);
|
|
26
|
+
let pcm = false;
|
|
27
|
+
let audio = false;
|
|
28
|
+
let dataAt = 0;
|
|
29
|
+
let dataSize = 0;
|
|
30
|
+
let bytesPerFrame = 0;
|
|
31
|
+
let sampleRate = 0;
|
|
32
|
+
if (bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE") {
|
|
33
|
+
for (let at = 12; at + 8 <= bytes.length;) {
|
|
34
|
+
const size = bytes.readUInt32LE(at + 4);
|
|
35
|
+
const end = at + 8 + size;
|
|
36
|
+
if (end > bytes.length) break;
|
|
37
|
+
const chunk = bytes.toString("ascii", at, at + 4);
|
|
38
|
+
if (chunk === "fmt " && size >= 16) {
|
|
39
|
+
sampleRate = bytes.readUInt32LE(at + 12);
|
|
40
|
+
bytesPerFrame = bytes.readUInt16LE(at + 10) * 2;
|
|
41
|
+
pcm = bytes.readUInt16LE(at + 8) === 1 && [1, 2].includes(bytes.readUInt16LE(at + 10)) &&
|
|
42
|
+
sampleRate >= 8000 && sampleRate <= 192000 && bytes.readUInt16LE(at + 22) === 16 &&
|
|
43
|
+
bytes.readUInt16LE(at + 20) === bytesPerFrame;
|
|
44
|
+
}
|
|
45
|
+
if (chunk === "data" && size > 0) { audio = true; dataAt = at; dataSize = size; }
|
|
46
|
+
at = end + (size % 2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!pcm || !audio || dataSize % bytesPerFrame !== 0) throw new Error(`microphone fixture "${fixture.path}" must be a non-empty 16-bit PCM mono or stereo WAV (8–192 kHz)`);
|
|
50
|
+
// Chromium's FileSource returns zero at EOF without clearing the previous
|
|
51
|
+
// capture buffer. Finish with silence so single playback cannot repeat the
|
|
52
|
+
// final speech fragment indefinitely. Leave looping recordings untouched.
|
|
53
|
+
let staged = bytes;
|
|
54
|
+
if (!fixture.loop) {
|
|
55
|
+
const silence = Buffer.alloc(Math.ceil(sampleRate / 4) * bytesPerFrame);
|
|
56
|
+
const end = dataAt + 8 + dataSize;
|
|
57
|
+
staged = Buffer.concat([bytes.subarray(0, end), silence, bytes.subarray(end)]);
|
|
58
|
+
staged.writeUInt32LE(staged.length - 8, 4);
|
|
59
|
+
staged.writeUInt32LE(dataSize + silence.length, dataAt + 4);
|
|
60
|
+
}
|
|
61
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-microphone-"));
|
|
62
|
+
const cleanup = () => rm(dir, { recursive: true, force: true });
|
|
63
|
+
try {
|
|
64
|
+
const file = join(dir, "audio.wav");
|
|
65
|
+
await writeFile(file, staged);
|
|
66
|
+
return { arg: `--use-file-for-fake-audio-capture=${file}${fixture.loop ? "" : "%noloop"}`, cleanup };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
await cleanup();
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -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 =
|
|
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
|
|
37
|
-
* bound on loopback. Opted-in HTTP/2 routes select a separate stream
|
|
38
|
-
* by ALPN; HTTP/1.1 and WebSockets
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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:
|
|
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 =
|
|
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
|
|
package/src/index.ts
CHANGED
|
@@ -78,6 +78,9 @@ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js
|
|
|
78
78
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
79
79
|
|
|
80
80
|
export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
|
|
81
|
+
export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
|
|
82
|
+
export type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
|
|
83
|
+
import type { FakeWebSocketHandlers } from "./fake-websocket.js";
|
|
81
84
|
|
|
82
85
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
83
86
|
|
|
@@ -2171,6 +2174,11 @@ export interface FakeDefinition<
|
|
|
2171
2174
|
* also applies to state/helper factories and helper implementations.
|
|
2172
2175
|
*/
|
|
2173
2176
|
handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
|
|
2177
|
+
/** Optional WebSocket handshake on the same hostnames as HTTP. Return a
|
|
2178
|
+
* Response to reject, or connection callbacks to accept. Callbacks share
|
|
2179
|
+
* this fake's state and run uninstrumented, in order per connection. */
|
|
2180
|
+
websocket?: (req: Request, state: S, ctx: FakeContext) =>
|
|
2181
|
+
FakeWebSocketHandlers | Response | Promise<FakeWebSocketHandlers | Response>;
|
|
2174
2182
|
/**
|
|
2175
2183
|
* Build the helpers exposed at `ctx.fakes.<name>` — a record of
|
|
2176
2184
|
* **functions** that read or mutate the fake's `state` (received here)
|
|
@@ -2239,6 +2247,9 @@ export function defineFake<
|
|
|
2239
2247
|
if (typeof opts.handler !== "function") {
|
|
2240
2248
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
|
2241
2249
|
}
|
|
2250
|
+
if (opts.websocket !== undefined && typeof opts.websocket !== "function") {
|
|
2251
|
+
throw new Error(`defineFake(${opts.name}): \`websocket\` must be a function`);
|
|
2252
|
+
}
|
|
2242
2253
|
return opts;
|
|
2243
2254
|
}
|
|
2244
2255
|
|