@specific.dev/spectest 0.88.3 → 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/README.md +42 -2
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/daemon.js +40 -85
- package/dist/harness/fetch.js +7 -3
- package/dist/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/daemon.ts +48 -86
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
|
@@ -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
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,21 @@ export type {
|
|
|
17
17
|
Wrapped,
|
|
18
18
|
WrappedObject,
|
|
19
19
|
WrappedArray,
|
|
20
|
+
WrappedHeaders,
|
|
21
|
+
WrappedBuffer,
|
|
22
|
+
WrappedBinaryView,
|
|
23
|
+
WrappedBytes,
|
|
24
|
+
WrappedBlob,
|
|
25
|
+
WrappedFile,
|
|
26
|
+
WrappedFormData,
|
|
27
|
+
WrappedStream,
|
|
28
|
+
WrappedReader,
|
|
29
|
+
WrappedBYOBReader,
|
|
30
|
+
WrappedReadResult,
|
|
20
31
|
WrappedResponse,
|
|
21
32
|
Provenanced,
|
|
22
33
|
SpectestFetch,
|
|
34
|
+
SpectestRequestInit,
|
|
23
35
|
Unwrap,
|
|
24
36
|
} from "./inspect.js";
|
|
25
37
|
import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
@@ -475,11 +487,15 @@ export interface SpectestContext<
|
|
|
475
487
|
/**
|
|
476
488
|
* Instrumented `fetch`. In a test each call is recorded on the timeline
|
|
477
489
|
* and resolves to a {@link WrappedResponse}: reads carry provenance so
|
|
478
|
-
* `expect(res.status)
|
|
490
|
+
* `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
|
|
491
|
+
* `expect(await res.json())` nest under the HTTP
|
|
479
492
|
* call. Because the status-line accessors are {@link Carrier}s, a raw
|
|
480
493
|
* `res.status === 200` is a *type error* — use `res.status.unwrap()` /
|
|
481
494
|
* `res.unwrap().status`, or assert via `expect`. Outside a test the
|
|
482
495
|
* result is wrapped the same way, just with no timeline to link to.
|
|
496
|
+
* Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
|
|
497
|
+
* and stream reader values also retain provenance. Use `captureBody: false`
|
|
498
|
+
* for open-ended streams; SSE responses skip recorder body capture automatically.
|
|
483
499
|
*
|
|
484
500
|
* (The plain global `fetch` is wrapped the same way at runtime but keeps
|
|
485
501
|
* the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { expect as assertValue } from "./index.js";
|
|
3
|
+
import { readRaw, readTag, wrap, wrapResponse } from "./inspect.js";
|
|
4
|
+
import { recordHttp, startRecording, stopRecording, type AssertionEvent } from "./recorder.js";
|
|
5
|
+
|
|
6
|
+
let seq: number;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
startRecording();
|
|
9
|
+
seq = recordHttp({ method: "GET", url: "http://test/", status: 200, durationMs: 0 })!;
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => { stopRecording(); });
|
|
12
|
+
|
|
13
|
+
function paths() {
|
|
14
|
+
const assertions = stopRecording().filter((e): e is AssertionEvent => e.kind === "assertion");
|
|
15
|
+
for (const assertion of assertions) expect(assertion.sourceSeq).toBe(seq);
|
|
16
|
+
return assertions.map((e) => e.path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("fetch body provenance", () => {
|
|
20
|
+
test("bytes retain indexes, sizes, derived values and native escape hatches", async () => {
|
|
21
|
+
const raw = new Uint8Array([0x89, 0x50, 0, 0xff]);
|
|
22
|
+
const res = wrapResponse(new Response(raw), seq);
|
|
23
|
+
assertValue(res.bodyUsed).toBe(false);
|
|
24
|
+
const bytes = await res.bytes();
|
|
25
|
+
assertValue(res.bodyUsed).toBe(true);
|
|
26
|
+
assertValue(bytes).toEqual(raw);
|
|
27
|
+
assertValue(bytes).toHaveLength(4);
|
|
28
|
+
assertValue(bytes.byteLength).toBe(4);
|
|
29
|
+
assertValue(bytes[0]).toBe(0x89);
|
|
30
|
+
assertValue(bytes.slice(1, 3)).toEqual(new Uint8Array([0x50, 0]));
|
|
31
|
+
assertValue(bytes.subarray(1).at(0)).toBe(0x50);
|
|
32
|
+
assertValue(bytes.includes(0xff)).toBe(true);
|
|
33
|
+
assertValue(bytes.filter((v) => v === 0)).toHaveLength(1);
|
|
34
|
+
assertValue(bytes.map((v) => v + 1)[2]).toBe(1);
|
|
35
|
+
assertValue(bytes.at(99)).toBe(undefined);
|
|
36
|
+
expect(bytes.length).toBe(4);
|
|
37
|
+
expect([...bytes]).toEqual([...raw]);
|
|
38
|
+
expect(bytes.unwrap()).toBeInstanceOf(Uint8Array);
|
|
39
|
+
expect(readRaw<unknown>(bytes)).toBe(bytes.unwrap());
|
|
40
|
+
expect(new TextDecoder().decode(bytes.unwrap())).toBe(new TextDecoder().decode(raw));
|
|
41
|
+
expect(paths()).toContainEqual(["body", "<subarray>", "<at>"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("array buffers and derived DataViews retain provenance", async () => {
|
|
45
|
+
const res = wrapResponse(new Response(new Uint8Array([1, 2, 3])), seq);
|
|
46
|
+
const buffer = await res.arrayBuffer();
|
|
47
|
+
assertValue(buffer.byteLength).toBe(3);
|
|
48
|
+
assertValue(buffer.slice(1).byteLength).toBe(2);
|
|
49
|
+
const bytes = buffer.transform("bytes", (b) => new Uint8Array(b));
|
|
50
|
+
assertValue(bytes.buffer.byteLength).toBe(3);
|
|
51
|
+
const view = buffer.transform("view", (b) => new DataView(b));
|
|
52
|
+
assertValue(view.getUint16(0)).toBe(258);
|
|
53
|
+
expect(new Uint8Array(buffer.unwrap())).toEqual(new Uint8Array([1, 2, 3]));
|
|
54
|
+
expect(paths()).toEqual([
|
|
55
|
+
["body", "byteLength"], ["body", "<slice>", "byteLength"],
|
|
56
|
+
["body", "<bytes>", "buffer", "byteLength"], ["body", "<view>", "<getUint16>"],
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("blobs wrap metadata, slices, decoded bodies and streams", async () => {
|
|
61
|
+
const res = wrapResponse(new Response("hello", { headers: { "content-type": "text/plain" } }), seq);
|
|
62
|
+
const blob = await res.blob();
|
|
63
|
+
assertValue(blob.size).toBe(5);
|
|
64
|
+
assertValue(blob.type).toBe(blob.unwrap().type);
|
|
65
|
+
assertValue(await blob.slice(1, 4).text()).toBe("ell");
|
|
66
|
+
assertValue((await blob.bytes())[0]).toBe(104);
|
|
67
|
+
assertValue((await blob.arrayBuffer()).byteLength).toBe(5);
|
|
68
|
+
const reader = blob.stream().getReader();
|
|
69
|
+
try {
|
|
70
|
+
const chunk = await reader.read();
|
|
71
|
+
expect(chunk.done).toBe(false);
|
|
72
|
+
assertValue(chunk.value).toEqual(new TextEncoder().encode("hello"));
|
|
73
|
+
} finally { await reader.cancel(); reader.releaseLock(); }
|
|
74
|
+
expect(blob.unwrap()).toBeInstanceOf(Blob);
|
|
75
|
+
expect(paths()).toContainEqual(["body", "<stream>", "<read>", "0"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("form data wraps missing and repeated fields and uploaded files", async () => {
|
|
79
|
+
const form = new FormData();
|
|
80
|
+
form.append("tag", "one");
|
|
81
|
+
form.append("tag", "two");
|
|
82
|
+
form.append("file", new File(["hello"], "hello.txt", { type: "text/plain", lastModified: 123 }));
|
|
83
|
+
const res = wrapResponse(new Response(form), seq);
|
|
84
|
+
const parsed = await res.formData();
|
|
85
|
+
assertValue(parsed.get("tag")).toBe("one");
|
|
86
|
+
assertValue(parsed.getAll("tag")).toEqual(["one", "two"]);
|
|
87
|
+
assertValue(parsed.getAll("tag")[1]).toBe("two");
|
|
88
|
+
assertValue(parsed.has("tag")).toBe(true);
|
|
89
|
+
assertValue(parsed.has("missing")).toBe(false);
|
|
90
|
+
assertValue(parsed.get("missing")).toBe(null);
|
|
91
|
+
const file = parsed.get("file");
|
|
92
|
+
if (!file || !("name" in file)) throw new Error("Expected a file");
|
|
93
|
+
assertValue(file.name).toBe("hello.txt");
|
|
94
|
+
assertValue(file.type).toBe(file.unwrap().type);
|
|
95
|
+
assertValue(file.size).toBe(5);
|
|
96
|
+
assertValue(file.lastModified).toBe(file.unwrap().lastModified);
|
|
97
|
+
assertValue(await file.text()).toBe("hello");
|
|
98
|
+
assertValue((await file.bytes())[0]).toBe(104);
|
|
99
|
+
expect([...parsed][0]).toEqual(["tag", "one"]);
|
|
100
|
+
expect(parsed.unwrap()).toBeInstanceOf(FormData);
|
|
101
|
+
expect(paths()).toContainEqual(["body", "file", "<text>"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("top-level JSON null and absent bodies retain provenance", async () => {
|
|
105
|
+
const json = wrapResponse(new Response("null"), seq);
|
|
106
|
+
assertValue(await json.json<null>()).toBe(null);
|
|
107
|
+
const empty = wrapResponse(new Response(null, { status: 204 }), seq);
|
|
108
|
+
assertValue(empty.body).toBe(null);
|
|
109
|
+
expect(paths()).toEqual([["body"], ["body"]]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("Bun header helpers are wrapped and serialization stays native", () => {
|
|
113
|
+
const res = wrapResponse(new Response(null, { headers: [
|
|
114
|
+
["x-test", "yes"], ["set-cookie", "a=1"], ["set-cookie", "b=2"],
|
|
115
|
+
] }), seq);
|
|
116
|
+
assertValue(res.headers.getAll("Set-Cookie")).toEqual(["a=1", "b=2"]);
|
|
117
|
+
assertValue(res.headers.toJSON()["x-test"]).toBe("yes");
|
|
118
|
+
assertValue(res.headers.count).toBe(res.unwrap().headers.count);
|
|
119
|
+
expect(JSON.stringify(res.headers)).toBe(JSON.stringify(res.unwrap().headers));
|
|
120
|
+
expect(paths()).toEqual([["headers", "set-cookie"], ["headers", "x-test"], ["headers", "count"]]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reader values are wrapped but done and locked remain control-flow booleans", async () => {
|
|
124
|
+
const res = wrapResponse(new Response("hello"), seq);
|
|
125
|
+
const body = res.body!;
|
|
126
|
+
expect(res.body).toBe(body);
|
|
127
|
+
const reader = body.getReader();
|
|
128
|
+
expect(body.locked).toBe(true);
|
|
129
|
+
expect(readRaw<unknown>(reader)).toBe(reader.unwrap());
|
|
130
|
+
let chunks = 0;
|
|
131
|
+
while (true) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done) break;
|
|
134
|
+
assertValue(value.byteLength).toBe(5);
|
|
135
|
+
chunks++;
|
|
136
|
+
}
|
|
137
|
+
expect(chunks).toBe(1);
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
expect(body.locked).toBe(false);
|
|
140
|
+
expect(paths()).toEqual([["body", "<read>", "0", "byteLength"]]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("BYOB readers wrap the returned view while preserving buffer semantics", async () => {
|
|
144
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
145
|
+
type: "bytes",
|
|
146
|
+
pull(controller) {
|
|
147
|
+
controller.enqueue(new Uint8Array([1, 2, 3]));
|
|
148
|
+
controller.close();
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const res = wrapResponse(new Response(stream), seq);
|
|
152
|
+
const reader = res.body!.getReader({ mode: "byob" });
|
|
153
|
+
const chunk = await reader.read(new Uint8Array(8));
|
|
154
|
+
expect(chunk.done).toBe(false);
|
|
155
|
+
assertValue(chunk.value).toEqual(new Uint8Array([1, 2, 3]));
|
|
156
|
+
expect((await reader.read(new Uint8Array(8))).done).toBe(true);
|
|
157
|
+
reader.releaseLock();
|
|
158
|
+
expect(paths()).toEqual([["body", "<read>", "0"]]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("tee and pipeThrough preserve provenance and pipeTo consumes native chunks", async () => {
|
|
162
|
+
const res = wrapResponse(new Response("hi"), seq);
|
|
163
|
+
const [left, right] = res.body!.tee();
|
|
164
|
+
const decoded = left.pipeThrough(new TextDecoderStream());
|
|
165
|
+
const reader = decoded.getReader();
|
|
166
|
+
assertValue((await reader.read()).value).toBe("hi");
|
|
167
|
+
reader.releaseLock();
|
|
168
|
+
const chunks: Uint8Array[] = [];
|
|
169
|
+
await right.pipeTo(new WritableStream({ write(chunk) { chunks.push(chunk); } }));
|
|
170
|
+
expect(chunks).toEqual([new TextEncoder().encode("hi")]);
|
|
171
|
+
expect(paths()).toEqual([["body", "<tee>", "0", "<pipeThrough>", "<read>", "0"]]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("native reader errors and body consumption errors propagate", async () => {
|
|
175
|
+
const res = wrapResponse(new Response("once"), seq);
|
|
176
|
+
await res.bytes();
|
|
177
|
+
await expect(res.text()).rejects.toThrow();
|
|
178
|
+
const failure = new Error("stream failed");
|
|
179
|
+
const broken = wrapResponse(new Response(new ReadableStream({ start(c) { c.error(failure); } })), seq);
|
|
180
|
+
const reader = broken.body!.getReader();
|
|
181
|
+
await expect(reader.read()).rejects.toBe(failure);
|
|
182
|
+
reader.releaseLock();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("native body wrappers are available without a recorded event", async () => {
|
|
186
|
+
const bytes = await wrapResponse(new Response("hi"), undefined).bytes();
|
|
187
|
+
expect(bytes.byteLength.unwrap()).toBe(2);
|
|
188
|
+
expect(readTag(bytes[0])).toEqual({ sourceSeq: undefined, path: ["body", "0"] });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("retagging native values unwraps fully and replaces their provenance", async () => {
|
|
192
|
+
const bytes = await wrapResponse(new Response("hi"), seq).bytes();
|
|
193
|
+
const retagged = wrap(bytes, 99, ["poll"]);
|
|
194
|
+
expect(retagged.unwrap()).toBe(bytes.unwrap());
|
|
195
|
+
expect(readTag(retagged.byteLength)).toEqual({ sourceSeq: 99, path: ["poll", "byteLength"] });
|
|
196
|
+
expect(retagged.byteLength.unwrap()).toBe(2);
|
|
197
|
+
const blob = await wrapResponse(new Response("hi"), seq).blob();
|
|
198
|
+
const retaggedBlob = wrap(blob, 99, ["poll"]);
|
|
199
|
+
expect(retaggedBlob.unwrap()).toBe(blob.unwrap());
|
|
200
|
+
expect((await retaggedBlob.text()).unwrap()).toBe("hi");
|
|
201
|
+
});
|
|
202
|
+
});
|