@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingress WebSocket bridge: one client WebSocket on an ingress
|
|
3
|
+
* listener, one upstream WebSocket to the service, frames copied in both
|
|
4
|
+
* directions.
|
|
5
|
+
*
|
|
6
|
+
* The handshake is the part that matters. A client that sends
|
|
7
|
+
* `Sec-WebSocket-Protocol: console, <ticket>` expects the upstream to see
|
|
8
|
+
* the full list, and expects to get back the one the upstream selected.
|
|
9
|
+
* The first version of this bridge opened the upstream with no protocols
|
|
10
|
+
* and no headers, so a ticket or a cookie that rode on the handshake never
|
|
11
|
+
* reached the service, and the service refused the connection after the
|
|
12
|
+
* client already saw a 101. So the bridge now opens the upstream FIRST,
|
|
13
|
+
* with the client's protocol list and forwardable headers, waits for the
|
|
14
|
+
* upstream handshake, and only then upgrades the client with the protocol
|
|
15
|
+
* the upstream selected. An upstream that refuses the handshake becomes a
|
|
16
|
+
* 502 to the client, the same as the HTTP proxy path.
|
|
17
|
+
*
|
|
18
|
+
* `daemon.ts` owns the listener and the route lookup; this module owns
|
|
19
|
+
* the handshake and the frame relay so the two can be tested on loopback
|
|
20
|
+
* without a VM.
|
|
21
|
+
*/
|
|
22
|
+
import { forwardableHeaders } from "./http-proxy.js";
|
|
23
|
+
/** Headers that belong to the client's own WebSocket handshake. Bun's
|
|
24
|
+
* WebSocket client mints its own key and version, and the protocol list
|
|
25
|
+
* goes through the constructor option, so these never travel as plain
|
|
26
|
+
* headers. */
|
|
27
|
+
const HANDSHAKE_HEADERS = new Set([
|
|
28
|
+
"sec-websocket-key",
|
|
29
|
+
"sec-websocket-version",
|
|
30
|
+
"sec-websocket-protocol",
|
|
31
|
+
"sec-websocket-extensions",
|
|
32
|
+
"sec-websocket-accept",
|
|
33
|
+
]);
|
|
34
|
+
/** `Sec-WebSocket-Protocol` is a comma-separated list; each token is
|
|
35
|
+
* trimmed, empty tokens are dropped, order is kept (RFC 6455 §4.1 lets
|
|
36
|
+
* the server pick from the list, so order is the client's preference). */
|
|
37
|
+
export function parseSubprotocols(header) {
|
|
38
|
+
if (!header)
|
|
39
|
+
return [];
|
|
40
|
+
return header
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter((s) => s.length > 0);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The headers the upstream handshake carries: everything forwardable from
|
|
47
|
+
* the client's request (cookies, authorization, origin, user-agent …),
|
|
48
|
+
* minus the client's own handshake fields, plus the proxy provenance
|
|
49
|
+
* headers and a Host that names the service.
|
|
50
|
+
*/
|
|
51
|
+
export function upstreamHandshakeHeaders(req, opts) {
|
|
52
|
+
const out = {};
|
|
53
|
+
forwardableHeaders(req).forEach((value, key) => {
|
|
54
|
+
if (HANDSHAKE_HEADERS.has(key))
|
|
55
|
+
return;
|
|
56
|
+
out[key] = value;
|
|
57
|
+
});
|
|
58
|
+
const priorXff = req.get("x-forwarded-for");
|
|
59
|
+
const xff = opts.clientIp
|
|
60
|
+
? priorXff
|
|
61
|
+
? `${priorXff}, ${opts.clientIp}`
|
|
62
|
+
: opts.clientIp
|
|
63
|
+
: priorXff;
|
|
64
|
+
if (xff)
|
|
65
|
+
out["x-forwarded-for"] = xff;
|
|
66
|
+
out["x-forwarded-proto"] = opts.proto;
|
|
67
|
+
const publicHost = req.get("host");
|
|
68
|
+
if (publicHost)
|
|
69
|
+
out["x-forwarded-host"] = publicHost;
|
|
70
|
+
out["host"] = `${opts.service}:${opts.port}`;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Open the upstream WebSocket and wait for its handshake. Resolves once
|
|
75
|
+
* the socket is open, or once it failed; the handshake listeners are
|
|
76
|
+
* removed either way so a later close does not fire them again.
|
|
77
|
+
*/
|
|
78
|
+
export function openUpstreamWebSocket(url, opts) {
|
|
79
|
+
const Ctor = WebSocket;
|
|
80
|
+
let socket;
|
|
81
|
+
try {
|
|
82
|
+
socket = new Ctor(url, { protocols: opts.protocols, headers: opts.headers });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return Promise.resolve({ ok: false, reason: String(err) });
|
|
86
|
+
}
|
|
87
|
+
// ArrayBuffer so binary frames can be sent to the client verbatim.
|
|
88
|
+
socket.binaryType = "arraybuffer";
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
92
|
+
const timer = setTimeout(() => finish({ ok: false, reason: "upstream handshake timed out" }), timeoutMs);
|
|
93
|
+
const onOpen = () => finish({ ok: true, socket });
|
|
94
|
+
const onError = (ev) => {
|
|
95
|
+
const msg = ev.message;
|
|
96
|
+
finish({ ok: false, reason: msg ? `upstream handshake failed: ${msg}` : "upstream handshake failed" });
|
|
97
|
+
};
|
|
98
|
+
const onClose = (ev) => finish({ ok: false, reason: `upstream closed during handshake (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})` });
|
|
99
|
+
const finish = (result) => {
|
|
100
|
+
if (settled)
|
|
101
|
+
return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
socket.removeEventListener("open", onOpen);
|
|
105
|
+
socket.removeEventListener("error", onError);
|
|
106
|
+
socket.removeEventListener("close", onClose);
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
try {
|
|
109
|
+
socket.close();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* already closed */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
resolve(result);
|
|
116
|
+
};
|
|
117
|
+
socket.addEventListener("open", onOpen);
|
|
118
|
+
socket.addEventListener("error", onError);
|
|
119
|
+
socket.addEventListener("close", onClose);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The `websocket` handler set for `Bun.serve`. The upstream is already
|
|
124
|
+
* open when `open()` runs, so frames go straight through in both
|
|
125
|
+
* directions, and a close on either side closes the other with the same
|
|
126
|
+
* code and reason.
|
|
127
|
+
*/
|
|
128
|
+
export const WS_BRIDGE_HANDLERS = {
|
|
129
|
+
open(ws) {
|
|
130
|
+
const { upstream } = ws.data;
|
|
131
|
+
upstream.addEventListener("message", (ev) => {
|
|
132
|
+
try {
|
|
133
|
+
ws.send(ev.data);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* client gone */
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
upstream.addEventListener("close", (ev) => {
|
|
140
|
+
try {
|
|
141
|
+
ws.close(ev.code, ev.reason);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* already closed */
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
upstream.addEventListener("error", () => {
|
|
148
|
+
try {
|
|
149
|
+
ws.close(1011, "upstream error");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* already closed */
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// The upstream can have closed between the handshake and this
|
|
156
|
+
// callback; the close listener above was not attached yet then.
|
|
157
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
158
|
+
try {
|
|
159
|
+
ws.close(1011, "upstream closed");
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* already closed */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
message(ws, message) {
|
|
167
|
+
const { upstream } = ws.data;
|
|
168
|
+
const payload = typeof message === "string" ? message : new Uint8Array(message);
|
|
169
|
+
if (upstream.readyState === WebSocket.OPEN)
|
|
170
|
+
upstream.send(payload);
|
|
171
|
+
},
|
|
172
|
+
close(ws, code, reason) {
|
|
173
|
+
try {
|
|
174
|
+
ws.data.upstream.close(code, reason);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { strict as nodeAssert } from "node:assert";
|
|
2
|
-
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedResponse, Provenanced, SpectestFetch, Unwrap, } from "./inspect.js";
|
|
2
|
+
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
3
3
|
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
4
4
|
export { field } from "./inspect.js";
|
|
5
5
|
export { annotate } from "./annotate.js";
|
|
@@ -300,11 +300,15 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
300
300
|
/**
|
|
301
301
|
* Instrumented `fetch`. In a test each call is recorded on the timeline
|
|
302
302
|
* and resolves to a {@link WrappedResponse}: reads carry provenance so
|
|
303
|
-
* `expect(res.status)
|
|
303
|
+
* `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
|
|
304
|
+
* `expect(await res.json())` nest under the HTTP
|
|
304
305
|
* call. Because the status-line accessors are {@link Carrier}s, a raw
|
|
305
306
|
* `res.status === 200` is a *type error* — use `res.status.unwrap()` /
|
|
306
307
|
* `res.unwrap().status`, or assert via `expect`. Outside a test the
|
|
307
308
|
* result is wrapped the same way, just with no timeline to link to.
|
|
309
|
+
* Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
|
|
310
|
+
* and stream reader values also retain provenance. Use `captureBody: false`
|
|
311
|
+
* for open-ended streams; SSE responses skip recorder body capture automatically.
|
|
308
312
|
*
|
|
309
313
|
* (The plain global `fetch` is wrapped the same way at runtime but keeps
|
|
310
314
|
* the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
|
package/dist/inspect.d.ts
CHANGED
|
@@ -163,7 +163,7 @@ export type Provenanced = {
|
|
|
163
163
|
* recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
|
|
164
164
|
* it includes `null | undefined`.
|
|
165
165
|
*/
|
|
166
|
-
export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
|
|
166
|
+
export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends ArrayBuffer | SharedArrayBuffer ? WrappedBuffer<T> : T extends ArrayBufferView ? WrappedBinaryView<T> : T extends File ? WrappedFile : T extends Blob ? WrappedBlob : T extends FormData ? WrappedFormData : T extends ReadableStream<infer C> ? WrappedStream<C> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
|
|
167
167
|
/** A wrapped object: every own property is itself {@link Wrapped}, plus an
|
|
168
168
|
* `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
|
|
169
169
|
export type WrappedObject<T> = {
|
|
@@ -214,6 +214,69 @@ export interface WrappedArray<U> {
|
|
|
214
214
|
/** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
|
|
215
215
|
[Symbol.iterator](): IterableIterator<U>;
|
|
216
216
|
}
|
|
217
|
+
/** Native objects need their own views: getters and methods must receive the
|
|
218
|
+
* real native receiver, and method results must be wrapped after they resolve.
|
|
219
|
+
* Iteration, callbacks, and control-flow flags on stream readers remain raw.
|
|
220
|
+
*/
|
|
221
|
+
type NativeResult<T> = T extends Promise<infer R> ? Promise<Wrapped<R>> : Wrapped<T>;
|
|
222
|
+
type NativeView<T, K extends keyof T> = Omit<T, K> & {
|
|
223
|
+
readonly [P in K]: T[P] extends (...args: infer A) => infer R ? (...args: A) => NativeResult<R> : Wrapped<T[P]>;
|
|
224
|
+
} & {
|
|
225
|
+
unwrap(): T;
|
|
226
|
+
transform<R>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
227
|
+
};
|
|
228
|
+
export type WrappedBuffer<T extends ArrayBuffer | SharedArrayBuffer = ArrayBuffer> = NativeView<T, Extract<keyof T, "byteLength" | "maxByteLength" | "resizable" | "growable" | "detached" | "slice" | "transfer" | "transferToFixedLength">>;
|
|
229
|
+
declare const BINARY_METHOD_NAMES: readonly ["at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast", "findIndex", "findLastIndex", "some", "every", "filter", "map", "join", "fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64", "getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32", "getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64"];
|
|
230
|
+
/** Numeric indexes carry provenance. Like WrappedArray, length and iteration
|
|
231
|
+
* stay raw; callbacks receive native values. Unwrap for native API inputs. */
|
|
232
|
+
export type WrappedBinaryView<T extends ArrayBufferView> = NativeView<T, Extract<keyof T, number | "buffer" | "byteLength" | "byteOffset" | typeof BINARY_METHOD_NAMES[number]>>;
|
|
233
|
+
export type WrappedBytes = WrappedBinaryView<Uint8Array>;
|
|
234
|
+
export type WrappedBlob = NativeView<Blob, "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
235
|
+
export type WrappedFile = NativeView<File, "name" | "lastModified" | "webkitRelativePath" | "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
236
|
+
export type WrappedFormData = NativeView<FormData, "get" | "getAll" | "has">;
|
|
237
|
+
export type WrappedReadResult<T> = {
|
|
238
|
+
done: false;
|
|
239
|
+
value: Wrapped<T>;
|
|
240
|
+
} | {
|
|
241
|
+
done: true;
|
|
242
|
+
value: Wrapped<T> | undefined;
|
|
243
|
+
};
|
|
244
|
+
export interface WrappedReader<T> extends Omit<ReadableStreamDefaultReader<T>, "read"> {
|
|
245
|
+
read(): Promise<WrappedReadResult<T>>;
|
|
246
|
+
unwrap(): ReadableStreamDefaultReader<T>;
|
|
247
|
+
}
|
|
248
|
+
export interface WrappedBYOBReader extends Omit<ReadableStreamBYOBReader, "read"> {
|
|
249
|
+
read<T extends ArrayBufferView>(view: T, options?: {
|
|
250
|
+
min?: number;
|
|
251
|
+
}): Promise<WrappedReadResult<T>>;
|
|
252
|
+
unwrap(): ReadableStreamBYOBReader;
|
|
253
|
+
}
|
|
254
|
+
export interface WrappedStream<T> extends Omit<ReadableStream<T>, "getReader" | "tee" | "pipeThrough"> {
|
|
255
|
+
getReader(): WrappedReader<T>;
|
|
256
|
+
getReader(options: {
|
|
257
|
+
mode: "byob";
|
|
258
|
+
}): WrappedBYOBReader;
|
|
259
|
+
getReader(options: ReadableStreamGetReaderOptions): WrappedReader<T> | WrappedBYOBReader;
|
|
260
|
+
tee(): [WrappedStream<T>, WrappedStream<T>];
|
|
261
|
+
pipeThrough<R>(transform: ReadableWritablePair<R, any>, options?: StreamPipeOptions): WrappedStream<R>;
|
|
262
|
+
unwrap(): ReadableStream<T>;
|
|
263
|
+
transform<R>(label: string, fn: (raw: ReadableStream<T>) => R): Wrapped<R>;
|
|
264
|
+
}
|
|
265
|
+
/** Response headers whose lookups retain the originating HTTP call's provenance.
|
|
266
|
+
* Missing headers remain `null`; `expect(headers.get(name)).toBe(null)` still
|
|
267
|
+
* links to the call. Iterators and other native methods return raw values.
|
|
268
|
+
*/
|
|
269
|
+
export interface WrappedHeaders extends Omit<Headers, "get" | "has" | "getSetCookie" | "getAll" | "toJSON" | "count"> {
|
|
270
|
+
get(name: string): Carrier<string> | null;
|
|
271
|
+
/** Unwrap before using the result as a condition. */
|
|
272
|
+
has(name: string): Carrier<boolean>;
|
|
273
|
+
getSetCookie(): WrappedArray<string>;
|
|
274
|
+
getAll(name: "set-cookie" | "Set-Cookie"): WrappedArray<string>;
|
|
275
|
+
toJSON(): Wrapped<Record<string, string | string[]>>;
|
|
276
|
+
readonly count: Carrier<number>;
|
|
277
|
+
unwrap(): Headers;
|
|
278
|
+
transform<R = unknown>(label: string, fn: (raw: Headers) => R): Wrapped<R>;
|
|
279
|
+
}
|
|
217
280
|
/**
|
|
218
281
|
* The view `ctx.fetch` resolves to in every context (a spectest op wraps
|
|
219
282
|
* unconditionally): a {@link Response} whose status-line accessors are
|
|
@@ -222,8 +285,9 @@ export interface WrappedArray<U> {
|
|
|
222
285
|
* used to silently always be false. Compare `res.status.unwrap() === 200`
|
|
223
286
|
* or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
|
|
224
287
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
288
|
+
* All body readers return provenance-carrying values, including binary bodies,
|
|
289
|
+
* blobs and form data. `.unwrap()` recovers the native response or body value.
|
|
290
|
+
* Stream reader `done` and stream `locked` remain raw control-flow booleans.
|
|
227
291
|
*/
|
|
228
292
|
export interface WrappedResponse {
|
|
229
293
|
readonly status: Carrier<number>;
|
|
@@ -232,13 +296,15 @@ export interface WrappedResponse {
|
|
|
232
296
|
readonly url: Carrier<string>;
|
|
233
297
|
readonly redirected: Carrier<boolean>;
|
|
234
298
|
readonly type: Carrier<string>;
|
|
235
|
-
readonly headers:
|
|
236
|
-
readonly bodyUsed: boolean
|
|
299
|
+
readonly headers: WrappedHeaders;
|
|
300
|
+
readonly bodyUsed: Carrier<boolean>;
|
|
301
|
+
readonly body: WrappedStream<Uint8Array> | null;
|
|
237
302
|
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
238
303
|
text(): Promise<Carrier<string>>;
|
|
239
|
-
arrayBuffer(): Promise<
|
|
240
|
-
|
|
241
|
-
|
|
304
|
+
arrayBuffer(): Promise<WrappedBuffer>;
|
|
305
|
+
bytes(): Promise<WrappedBytes>;
|
|
306
|
+
blob(): Promise<WrappedBlob>;
|
|
307
|
+
formData(): Promise<WrappedFormData>;
|
|
242
308
|
clone(): WrappedResponse;
|
|
243
309
|
/** Recover the underlying raw {@link Response} (a real `number` status,
|
|
244
310
|
* an unwrapped body, etc.). */
|
|
@@ -246,12 +312,19 @@ export interface WrappedResponse {
|
|
|
246
312
|
}
|
|
247
313
|
/** The signature of `ctx.fetch`: a `fetch` that resolves to a
|
|
248
314
|
* {@link WrappedResponse} so reads carry provenance into assertions. */
|
|
249
|
-
export type SpectestFetch = (input: RequestInfo | URL, init?:
|
|
315
|
+
export type SpectestFetch = (input: RequestInfo | URL, init?: SpectestRequestInit) => Promise<WrappedResponse>;
|
|
316
|
+
export interface SpectestRequestInit extends RequestInit {
|
|
317
|
+
/** Skip the recorder's body capture for open-ended streams. SSE skips it
|
|
318
|
+
* automatically. The response and assertions remain recorded. */
|
|
319
|
+
captureBody?: boolean;
|
|
320
|
+
}
|
|
250
321
|
/**
|
|
251
322
|
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
252
323
|
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
253
|
-
* to `sourceSeq`.
|
|
254
|
-
*
|
|
255
|
-
*
|
|
324
|
+
* to `sourceSeq`. Every body-reading method returns its resolved value
|
|
325
|
+
* wrapped under `path: ["body"]`. Header lookups carry paths
|
|
326
|
+
* under `headers`, and clones retain the same provenance. Everything else
|
|
327
|
+
* passes through bound to the real Response.
|
|
256
328
|
*/
|
|
257
329
|
export declare function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse;
|
|
330
|
+
export {};
|
package/dist/inspect.js
CHANGED
|
@@ -210,6 +210,20 @@ export function wrap(raw, sourceSeq, path = []) {
|
|
|
210
210
|
return raw;
|
|
211
211
|
const t = typeof raw;
|
|
212
212
|
if (t === "object") {
|
|
213
|
+
const native = readRaw(raw);
|
|
214
|
+
if (native instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && native instanceof SharedArrayBuffer)) {
|
|
215
|
+
return wrapNative(native, sourceSeq, path, new Set(["byteLength", "maxByteLength", "resizable", "growable", "detached"]), new Set(["slice", "transfer", "transferToFixedLength"]));
|
|
216
|
+
}
|
|
217
|
+
if (ArrayBuffer.isView(native)) {
|
|
218
|
+
return wrapNative(native, sourceSeq, path, new Set(["buffer", "byteLength", "byteOffset"]), BINARY_METHODS, true);
|
|
219
|
+
}
|
|
220
|
+
if (native instanceof Blob) {
|
|
221
|
+
return wrapNative(native, sourceSeq, path, new Set(["size", "type", "name", "lastModified", "webkitRelativePath"]), new Set(["slice", "text", "bytes", "arrayBuffer", "stream"]));
|
|
222
|
+
}
|
|
223
|
+
if (native instanceof FormData)
|
|
224
|
+
return wrapFormData(native, sourceSeq, path);
|
|
225
|
+
if (native instanceof ReadableStream)
|
|
226
|
+
return wrapStream(native, sourceSeq, path);
|
|
213
227
|
return wrapObject(raw, sourceSeq, path);
|
|
214
228
|
}
|
|
215
229
|
if (t === "function")
|
|
@@ -437,15 +451,140 @@ export function field(value, ...path) {
|
|
|
437
451
|
return cur;
|
|
438
452
|
return retag(cur, tag.sourceSeq, [...tag.path, ...path.map(String)]);
|
|
439
453
|
}
|
|
454
|
+
const BINARY_METHOD_NAMES = [
|
|
455
|
+
"at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast",
|
|
456
|
+
"findIndex", "findLastIndex", "some", "every", "filter", "map", "join",
|
|
457
|
+
"fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64",
|
|
458
|
+
"getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32",
|
|
459
|
+
"getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64",
|
|
460
|
+
];
|
|
461
|
+
const BINARY_METHODS = new Set(BINARY_METHOD_NAMES);
|
|
462
|
+
function wrapNative(raw, sourceSeq, path, properties, methods, indexes = false) {
|
|
463
|
+
const tag = { sourceSeq, path };
|
|
464
|
+
return new Proxy(raw, {
|
|
465
|
+
get(target, prop) {
|
|
466
|
+
if (prop === OP_TAG)
|
|
467
|
+
return tag;
|
|
468
|
+
if (prop === UNWRAP)
|
|
469
|
+
return target;
|
|
470
|
+
if (prop === "unwrap")
|
|
471
|
+
return () => target;
|
|
472
|
+
if (prop === "transform")
|
|
473
|
+
return makeTransform(target, sourceSeq, path);
|
|
474
|
+
const value = Reflect.get(target, prop, target);
|
|
475
|
+
if (typeof prop === "string") {
|
|
476
|
+
if (properties.has(prop) || (indexes && /^(0|[1-9]\d*)$/.test(prop))) {
|
|
477
|
+
return wrapChild(value, sourceSeq, [...path, prop]);
|
|
478
|
+
}
|
|
479
|
+
if (methods.has(prop) && typeof value === "function") {
|
|
480
|
+
return (...args) => {
|
|
481
|
+
const result = value.apply(target, args);
|
|
482
|
+
const resultPath = [...path, `<${prop}>`];
|
|
483
|
+
return result instanceof Promise
|
|
484
|
+
? result.then((v) => wrapChild(v, sourceSeq, resultPath))
|
|
485
|
+
: wrapChild(result, sourceSeq, resultPath);
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
490
|
+
},
|
|
491
|
+
has(target, prop) {
|
|
492
|
+
return prop === OP_TAG || prop === UNWRAP || Reflect.has(target, prop);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function wrapFormData(raw, sourceSeq, path) {
|
|
497
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set());
|
|
498
|
+
return new Proxy(base, {
|
|
499
|
+
get(target, prop) {
|
|
500
|
+
if (prop === "get" || prop === "getAll" || prop === "has") {
|
|
501
|
+
return (name) => wrapChild(raw[prop](name), sourceSeq, prop === "get" ? [...path, name] : [...path, name, `<${prop}>`]);
|
|
502
|
+
}
|
|
503
|
+
return Reflect.get(target, prop);
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function wrapStream(raw, sourceSeq, path) {
|
|
508
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set(["pipeThrough"]));
|
|
509
|
+
return new Proxy(base, {
|
|
510
|
+
get(target, prop) {
|
|
511
|
+
if (prop === "tee")
|
|
512
|
+
return () => raw.tee().map((s, i) => wrapStream(s, sourceSeq, [...path, `<tee>`, String(i)]));
|
|
513
|
+
if (prop === "getReader")
|
|
514
|
+
return (options) => {
|
|
515
|
+
const reader = raw.getReader(options);
|
|
516
|
+
let index = 0;
|
|
517
|
+
return new Proxy(reader, {
|
|
518
|
+
get(r, key) {
|
|
519
|
+
if (key === "unwrap" || key === UNWRAP)
|
|
520
|
+
return key === "unwrap" ? () => r : r;
|
|
521
|
+
if (key === OP_TAG)
|
|
522
|
+
return { sourceSeq, path };
|
|
523
|
+
if (key === "read")
|
|
524
|
+
return async (...args) => {
|
|
525
|
+
const read = r.read;
|
|
526
|
+
const result = await read.apply(r, args);
|
|
527
|
+
const valuePath = [...path, "<read>", String(index++)];
|
|
528
|
+
// done stays a real boolean so normal reader loops terminate.
|
|
529
|
+
return { done: result.done, get value() { return wrapChild(result.value, sourceSeq, valuePath); } };
|
|
530
|
+
};
|
|
531
|
+
const value = Reflect.get(r, key, r);
|
|
532
|
+
return typeof value === "function" ? value.bind(r) : value;
|
|
533
|
+
},
|
|
534
|
+
has(r, key) { return key === UNWRAP || key === OP_TAG || Reflect.has(r, key); },
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
return Reflect.get(target, prop);
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function wrapHeaders(headers, sourceSeq) {
|
|
542
|
+
const path = ["headers"];
|
|
543
|
+
const tag = { sourceSeq, path };
|
|
544
|
+
return new Proxy(headers, {
|
|
545
|
+
get(target, prop) {
|
|
546
|
+
if (prop === OP_TAG)
|
|
547
|
+
return tag;
|
|
548
|
+
if (prop === UNWRAP)
|
|
549
|
+
return target;
|
|
550
|
+
if (prop === "unwrap")
|
|
551
|
+
return () => target;
|
|
552
|
+
if (prop === "transform")
|
|
553
|
+
return makeTransform(target, sourceSeq, path);
|
|
554
|
+
if (prop === "get" || prop === "has") {
|
|
555
|
+
return (name) => wrapChild(target[prop](name), sourceSeq, prop === "get" ? [...path, name.toLowerCase()] : [...path, name.toLowerCase(), "<has>"]);
|
|
556
|
+
}
|
|
557
|
+
if (prop === "count")
|
|
558
|
+
return wrap(target.count, sourceSeq, [...path, "count"]);
|
|
559
|
+
if (prop === "getAll")
|
|
560
|
+
return (name) => wrap(target.getAll(name), sourceSeq, [...path, name.toLowerCase()]);
|
|
561
|
+
if (prop === "toJSON")
|
|
562
|
+
return () => wrap(target.toJSON(), sourceSeq, path);
|
|
563
|
+
if (prop === "getSetCookie") {
|
|
564
|
+
return () => wrap(target.getSetCookie(), sourceSeq, [...path, "set-cookie"]);
|
|
565
|
+
}
|
|
566
|
+
const value = Reflect.get(target, prop, target);
|
|
567
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
568
|
+
},
|
|
569
|
+
has(target, prop) {
|
|
570
|
+
if (prop === OP_TAG || prop === UNWRAP)
|
|
571
|
+
return true;
|
|
572
|
+
return Reflect.has(target, prop);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
440
576
|
/**
|
|
441
577
|
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
442
578
|
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
443
|
-
* to `sourceSeq`.
|
|
444
|
-
*
|
|
445
|
-
*
|
|
579
|
+
* to `sourceSeq`. Every body-reading method returns its resolved value
|
|
580
|
+
* wrapped under `path: ["body"]`. Header lookups carry paths
|
|
581
|
+
* under `headers`, and clones retain the same provenance. Everything else
|
|
582
|
+
* passes through bound to the real Response.
|
|
446
583
|
*/
|
|
447
584
|
export function wrapResponse(res, sourceSeq) {
|
|
448
585
|
const tag = { sourceSeq, path: [] };
|
|
586
|
+
let headers;
|
|
587
|
+
let body;
|
|
449
588
|
const carrierProps = new Set([
|
|
450
589
|
"status",
|
|
451
590
|
"ok",
|
|
@@ -453,8 +592,9 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
453
592
|
"url",
|
|
454
593
|
"redirected",
|
|
455
594
|
"type",
|
|
595
|
+
"bodyUsed",
|
|
456
596
|
]);
|
|
457
|
-
const bodyMethods = new Set(["json", "text"]);
|
|
597
|
+
const bodyMethods = new Set(["json", "text", "bytes", "arrayBuffer", "blob", "formData"]);
|
|
458
598
|
const handler = {
|
|
459
599
|
get(target, prop) {
|
|
460
600
|
if (prop === OP_TAG)
|
|
@@ -466,6 +606,15 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
466
606
|
return () => readRaw(target);
|
|
467
607
|
if (prop === "then")
|
|
468
608
|
return undefined;
|
|
609
|
+
if (prop === "headers")
|
|
610
|
+
return headers ??= wrapHeaders(target.headers, sourceSeq);
|
|
611
|
+
if (prop === "body") {
|
|
612
|
+
if (target.body === null)
|
|
613
|
+
return wrapChild(null, sourceSeq, ["body"]);
|
|
614
|
+
return body ??= wrapStream(target.body, sourceSeq, ["body"]);
|
|
615
|
+
}
|
|
616
|
+
if (prop === "clone")
|
|
617
|
+
return () => wrapResponse(target.clone(), sourceSeq);
|
|
469
618
|
if (typeof prop === "string" && carrierProps.has(prop)) {
|
|
470
619
|
const value = target[prop];
|
|
471
620
|
return wrap(value, sourceSeq, [prop]);
|
|
@@ -476,7 +625,7 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
476
625
|
return fn;
|
|
477
626
|
return async (...args) => {
|
|
478
627
|
const value = await fn.apply(target, args);
|
|
479
|
-
return
|
|
628
|
+
return wrapChild(value, sourceSeq, ["body"]);
|
|
480
629
|
};
|
|
481
630
|
}
|
|
482
631
|
const value = target[prop];
|
package/package.json
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
|
+
// Trust for the environment CA, applied per session. Chromium on Linux
|
|
2
|
+
// reads its own root store (NSS), never the container's mounted bundle, so
|
|
3
|
+
// the renderer rejects every certificate the environment mints while Node
|
|
4
|
+
// in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
|
|
5
|
+
// procedure accepts exactly the chains that CA issued for the requested
|
|
6
|
+
// host, and defers to Chromium's own verdict for everything else. An app
|
|
7
|
+
// that installs its own procedure later replaces it, as in production.
|
|
8
|
+
// Kept free of Electron so the unit test can run it under Bun.
|
|
9
|
+
export const ELECTRON_CA_TRUST = String.raw`
|
|
10
|
+
const { X509Certificate } = require('node:crypto');
|
|
11
|
+
function loadEnvironmentCa(path) {
|
|
12
|
+
try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
// request: { hostname, errorCode, certificate: { data, issuerCert? } }.
|
|
16
|
+
// Returns true only for a chain that ends at the CA and names the host.
|
|
17
|
+
function issuedByEnvironmentCa(ca, request) {
|
|
18
|
+
try {
|
|
19
|
+
const leaf = new X509Certificate(request.certificate.data);
|
|
20
|
+
const host = request.hostname;
|
|
21
|
+
const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
|
|
22
|
+
if (!named) return false;
|
|
23
|
+
const chain = [leaf];
|
|
24
|
+
for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
|
|
25
|
+
chain.push(new X509Certificate(c.issuerCert.data));
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < chain.length; i++) {
|
|
28
|
+
const cert = chain[i];
|
|
29
|
+
if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
|
|
30
|
+
const issuer = chain[i + 1] ?? ca;
|
|
31
|
+
if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
1
38
|
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
39
|
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
40
|
export const ELECTRON_CHROME_HOOK = String.raw`
|
|
4
41
|
const electron = require('electron');
|
|
42
|
+
${ELECTRON_CA_TRUST}
|
|
43
|
+
const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
|
|
44
|
+
if (environmentCa) {
|
|
45
|
+
// Every session, including the default one and app-created partitions.
|
|
46
|
+
electron.app.on('session-created', session => {
|
|
47
|
+
session.setCertificateVerifyProc((request, callback) => {
|
|
48
|
+
if (request.errorCode === 0) return callback(0);
|
|
49
|
+
callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
5
53
|
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
54
|
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
55
|
construct(Target, args, NewTarget) {
|