@mochi.js/core 0.3.0 → 0.8.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 +19 -10
- package/package.json +5 -6
- package/src/__tests__/cookies-jar.test.ts +360 -0
- package/src/__tests__/default-profile.test.ts +179 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +244 -0
- package/src/__tests__/geo-consistency.test.ts +0 -1
- package/src/__tests__/geo-probe.test.ts +13 -13
- package/src/__tests__/init-injector.e2e.test.ts +143 -0
- package/src/__tests__/init-injector.test.ts +248 -0
- package/src/__tests__/inject.test.ts +80 -165
- package/src/__tests__/page-dx-cluster.test.ts +291 -0
- package/src/__tests__/piercing.test.ts +1 -1
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proc.test.ts +3 -3
- package/src/__tests__/proxy-auth.test.ts +22 -56
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/__tests__/window-size.e2e.test.ts +0 -1
- package/src/cdp/init-injector.ts +644 -0
- package/src/cdp/types.ts +0 -1
- package/src/default-profile.ts +110 -0
- package/src/geo-consistency.ts +0 -1
- package/src/geo-probe.ts +37 -32
- package/src/index.ts +33 -1
- package/src/launch.ts +225 -50
- package/src/linux-server.ts +157 -0
- package/src/page/element-handle.ts +0 -1
- package/src/page/piercing.ts +0 -1
- package/src/page/selector.ts +0 -1
- package/src/page.ts +429 -10
- package/src/proc.ts +52 -10
- package/src/proxy-auth.ts +25 -108
- package/src/session.ts +846 -182
- package/src/version.ts +1 -1
package/src/session.ts
CHANGED
|
@@ -20,34 +20,49 @@ import {
|
|
|
20
20
|
import type { MatrixV1 } from "@mochi.js/consistency";
|
|
21
21
|
import { buildPayload, type PayloadResult } from "@mochi.js/inject";
|
|
22
22
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
} from "@mochi.js/net";
|
|
23
|
+
type InitInjectorHandle,
|
|
24
|
+
installInitInjector,
|
|
25
|
+
wrapSelfRemovingPayload,
|
|
26
|
+
} from "./cdp/init-injector";
|
|
28
27
|
import { MessageRouter } from "./cdp/router";
|
|
29
28
|
import type { AttachedToTargetEvent } from "./cdp/types";
|
|
30
29
|
import { Page } from "./page";
|
|
31
30
|
import type { ChromiumProcess } from "./proc";
|
|
32
|
-
import { installProxyAuth, type ProxyAuthHandle } from "./proxy-auth";
|
|
33
31
|
import { VERSION } from "./version";
|
|
34
32
|
|
|
35
33
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* Per-call timeout for the worker idOnly inject roundtrip. 5s, not the
|
|
35
|
+
* router's 30s default — workers spawned by sites like sannysoft,
|
|
36
|
+
* bot.incolumitas, fingerprintjs probes routinely die between
|
|
37
|
+
* `Target.attachedToTarget` and our reply. Without a tight cap, every
|
|
38
|
+
* orphan worker stalls the route loop for the full 30s. Real workers
|
|
39
|
+
* resolve in single-digit ms; 5s is generous.
|
|
39
40
|
*
|
|
40
|
-
*
|
|
41
|
+
* If you ever see a legitimate worker fail at 5s, raise this — but the
|
|
42
|
+
* symptom would be a missing inject on a long-running worker, which is
|
|
43
|
+
* separate from the orphan-worker race we're sizing for.
|
|
41
44
|
*/
|
|
42
|
-
|
|
43
|
-
openCtx(spec: { preset: string; proxy?: string }): NetCtx;
|
|
44
|
-
requestOnCtx(ctx: NetCtx, url: string, init: NetFetchInit): Response;
|
|
45
|
-
}
|
|
45
|
+
const WORKER_INJECT_TIMEOUT_MS = 5_000;
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Predicate: is this an "expected" failure from the worker idOnly inject
|
|
49
|
+
* race (worker died between attach and our roundtrip)? Recognized:
|
|
50
|
+
* - `CdpTimeoutError` — router gave up after WORKER_INJECT_TIMEOUT_MS
|
|
51
|
+
* because the target stopped responding. Most common path.
|
|
52
|
+
* - CDP `Session with given id not found` — target detached mid-call.
|
|
53
|
+
* - CDP `Target closed` — same race, different message variant.
|
|
54
|
+
*
|
|
55
|
+
* All three are routine and silent. A genuine bug (bad contextId,
|
|
56
|
+
* wrong serialization, schema drift) surfaces as anything else and
|
|
57
|
+
* still warns through the console.
|
|
58
|
+
*/
|
|
59
|
+
function isTransientWorkerError(err: unknown): boolean {
|
|
60
|
+
if (err === null || typeof err !== "object") return false;
|
|
61
|
+
const name = (err as { name?: string }).name;
|
|
62
|
+
if (name === "CdpTimeoutError") return true;
|
|
63
|
+
const msg = (err as { message?: string }).message ?? "";
|
|
64
|
+
return msg.includes("Session with given id not found") || msg.includes("Target closed");
|
|
65
|
+
}
|
|
51
66
|
|
|
52
67
|
export interface SessionInit {
|
|
53
68
|
proc: ChromiumProcess;
|
|
@@ -56,19 +71,12 @@ export interface SessionInit {
|
|
|
56
71
|
/** Optional overrides for the underlying message-router timeout. */
|
|
57
72
|
defaultTimeoutMs?: number;
|
|
58
73
|
/**
|
|
59
|
-
* When true, skip {@link buildPayload} AND skip
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
62
|
-
*
|
|
74
|
+
* When true, skip {@link buildPayload} AND skip the init-injector install
|
|
75
|
+
* (no `Fetch.fulfillRequest` body splice on documents); worker targets
|
|
76
|
+
* receive no inject either. Intended for `mochi capture` and similar
|
|
77
|
+
* baseline-collection flows. PLAN.md §12.1,
|
|
63
78
|
*/
|
|
64
79
|
bypassInject?: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Optional outbound proxy URL forwarded to the network FFI for
|
|
67
|
-
* `Session.fetch` requests. Out-of-band requests honour this independently
|
|
68
|
-
* of the browser's `--proxy-server` flag (which already sees the proxy via
|
|
69
|
-
* the CDP launch path).
|
|
70
|
-
*/
|
|
71
|
-
netProxy?: string;
|
|
72
80
|
/**
|
|
73
81
|
* Optional proxy credentials. When set, the Session attaches a CDP
|
|
74
82
|
* `Fetch.authRequired` listener so HTTP / SOCKS5 proxy auth challenges
|
|
@@ -79,14 +87,6 @@ export interface SessionInit {
|
|
|
79
87
|
* @see proxy-auth.ts for the §8.2 invariant rationale.
|
|
80
88
|
*/
|
|
81
89
|
proxyAuth?: { username: string; password: string };
|
|
82
|
-
/**
|
|
83
|
-
* Network adapter override — tests inject a stub here to exercise the
|
|
84
|
-
* `Session.fetch` wiring without loading the cdylib. Production code does
|
|
85
|
-
* not pass this; the default uses `@mochi.js/net`.
|
|
86
|
-
*
|
|
87
|
-
* @internal
|
|
88
|
-
*/
|
|
89
|
-
netAdapter?: NetAdapter;
|
|
90
90
|
/**
|
|
91
91
|
* Convenience layer toggles surfaced via
|
|
92
92
|
* `LaunchOptions.challenges`. When `challenges.turnstile.autoClick` is
|
|
@@ -118,6 +118,85 @@ export interface StorageSnapshot {
|
|
|
118
118
|
sessionStorage: Record<string, Record<string, string>>;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// ---- cookie-jar persistence -------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Current on-disk cookie-file format version. Bumped on incompatible header
|
|
125
|
+
* changes. The reader refuses unknown majors with a precise diagnostic so a
|
|
126
|
+
* stale jar doesn't silently load with the wrong shape.
|
|
127
|
+
*/
|
|
128
|
+
export const COOKIE_JAR_FORMAT_VERSION = 1 as const;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* On-disk shape for {@link Session.cookies.save}. The `cookies` array is the
|
|
132
|
+
* verbatim `Storage.getCookies` payload — every shipped Chromium revision
|
|
133
|
+
* agrees on this shape, so loading on a newer Chromium round-trips losslessly.
|
|
134
|
+
*
|
|
135
|
+
* @see https://chromedevtools.github.io/devtools-protocol/tot/Storage/#method-getCookies
|
|
136
|
+
*/
|
|
137
|
+
export interface CookieJarFile {
|
|
138
|
+
/** Format version (currently `1`). */
|
|
139
|
+
version: typeof COOKIE_JAR_FORMAT_VERSION;
|
|
140
|
+
/** ISO-8601 UTC timestamp of `save()` (ends in `Z`). */
|
|
141
|
+
savedAt: string;
|
|
142
|
+
/** Mochi core version that produced the file. */
|
|
143
|
+
mochiVersion: string;
|
|
144
|
+
/** The regex source that filtered the saved set (default `".*"`). */
|
|
145
|
+
pattern: string;
|
|
146
|
+
/** Number of cookies in the `cookies` array — redundant with `cookies.length`, kept for trace logs. */
|
|
147
|
+
count: number;
|
|
148
|
+
/** Raw `Storage.getCookies` cookies, optionally filtered by `pattern`. */
|
|
149
|
+
cookies: import("./page").Cookie[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Options shared by `cookies.save` / `cookies.load`. */
|
|
153
|
+
export interface CookieJarOptions {
|
|
154
|
+
/**
|
|
155
|
+
* Optional regex matched against each cookie's `domain`. Default `.*`
|
|
156
|
+
* (everything). Cookies failing the match are skipped on save AND on load
|
|
157
|
+
* (so a saved-with-everything jar can be partially restored).
|
|
158
|
+
*/
|
|
159
|
+
pattern?: RegExp;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `Session.cookies` namespace — exposes the read/write/persist surface for the
|
|
164
|
+
* session's cookie jar. The legacy `Session.cookies(filter)` and
|
|
165
|
+
* `Session.setCookies(...)` shapes are gone; callers go through this object.
|
|
166
|
+
*
|
|
167
|
+
* The whole namespace is bound to a Session instance via the `Session.cookies`
|
|
168
|
+
* getter — every method routes through `Storage.getCookies` /
|
|
169
|
+
* `Storage.setCookies` on the root browser target (the only domain that
|
|
170
|
+
* exposes a global cookie reader without a per-page Network domain).
|
|
171
|
+
*/
|
|
172
|
+
export interface CookieJar {
|
|
173
|
+
/**
|
|
174
|
+
* All cookies the browser is aware of, optionally filtered by url. The url
|
|
175
|
+
* filter is a coarse hostname match (no path / secure / sameSite handling) —
|
|
176
|
+
* sufficient for "scope down to a session" use cases.
|
|
177
|
+
*/
|
|
178
|
+
get(filter?: { url?: string }): Promise<import("./page").Cookie[]>;
|
|
179
|
+
/** Set cookies via the root-target Storage domain. */
|
|
180
|
+
set(cookies: import("./page").Cookie[]): Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* Persist cookies to a JSON file at `path`. Cookies whose `domain` does NOT
|
|
183
|
+
* match `opts.pattern` (default: every domain) are skipped. The file format
|
|
184
|
+
* is {@link CookieJarFile}.
|
|
185
|
+
*/
|
|
186
|
+
save(path: string, opts?: CookieJarOptions): Promise<void>;
|
|
187
|
+
/**
|
|
188
|
+
* Read a JSON file written by {@link save} and replay every cookie back into
|
|
189
|
+
* the browser via `Storage.setCookies`. Cookies whose `domain` does NOT
|
|
190
|
+
* match `opts.pattern` (default: everything) are skipped — useful when one
|
|
191
|
+
* jar holds multi-domain state but only a slice should be re-installed for
|
|
192
|
+
* the current run.
|
|
193
|
+
*
|
|
194
|
+
* Throws on missing/corrupt files or version mismatch with a diagnostic that
|
|
195
|
+
* pins the exact failure point.
|
|
196
|
+
*/
|
|
197
|
+
load(path: string, opts?: CookieJarOptions): Promise<void>;
|
|
198
|
+
}
|
|
199
|
+
|
|
121
200
|
export class Session {
|
|
122
201
|
/**
|
|
123
202
|
* The resolved Matrix for this session — a relationally-locked snapshot
|
|
@@ -131,24 +210,30 @@ export class Session {
|
|
|
131
210
|
private readonly _pages: Page[] = [];
|
|
132
211
|
private closed = false;
|
|
133
212
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
*
|
|
141
|
-
* client pool inside the Rust crate handles connection reuse for repeated
|
|
142
|
-
* calls. Closed on `Session.close`.
|
|
213
|
+
* Lazily-created scratch frame used by {@link fetch} to satisfy the
|
|
214
|
+
* `frameId` requirement of `Network.loadNetworkResource` AND to host the
|
|
215
|
+
* `page.evaluate("fetch(...)")` path for non-GET calls. The frame
|
|
216
|
+
* navigates `about:blank` once and is reused across every `Session.fetch`
|
|
217
|
+
* call. Closed on {@link close}.
|
|
218
|
+
*
|
|
219
|
+
* @internal
|
|
143
220
|
*/
|
|
144
|
-
private
|
|
221
|
+
private scratchFrame: { targetId: string; sessionId: string; frameId: string } | undefined;
|
|
145
222
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
223
|
+
* Mutex for {@link ensureScratchFrame} — without it, two concurrent
|
|
224
|
+
* `Session.fetch` calls race on `Target.createTarget` and produce two
|
|
225
|
+
* scratch frames (only one tracked). The promise resolves once the first
|
|
226
|
+
* caller has finished setup; subsequent callers reuse the cached frame.
|
|
148
227
|
*
|
|
149
228
|
* @internal
|
|
150
229
|
*/
|
|
151
|
-
private
|
|
230
|
+
private scratchFramePromise:
|
|
231
|
+
| Promise<{
|
|
232
|
+
targetId: string;
|
|
233
|
+
sessionId: string;
|
|
234
|
+
frameId: string;
|
|
235
|
+
}>
|
|
236
|
+
| undefined;
|
|
152
237
|
/**
|
|
153
238
|
* The compiled inject payload for this session. Built once at construction
|
|
154
239
|
* from the resolved {@link MatrixV1}; reused across every new page and
|
|
@@ -163,18 +248,22 @@ export class Session {
|
|
|
163
248
|
private readonly _payload: PayloadResult | null;
|
|
164
249
|
/**
|
|
165
250
|
* Whether this session bypasses the inject pipeline (no `buildPayload`,
|
|
166
|
-
* no `
|
|
167
|
-
*
|
|
251
|
+
* no body splice via `Fetch.fulfillRequest`, no worker injection). Set
|
|
252
|
+
* from {@link SessionInit.bypassInject}. PLAN.md §12.1,
|
|
168
253
|
*
|
|
169
254
|
* @internal
|
|
170
255
|
*/
|
|
171
256
|
private readonly bypassInject: boolean;
|
|
172
257
|
/**
|
|
173
|
-
* Live handle for the
|
|
174
|
-
*
|
|
175
|
-
*
|
|
258
|
+
* Live handle for the unified `Fetch` domain owner — installs once on
|
|
259
|
+
* construction and tears down on `Session.close`. Owns BOTH the
|
|
260
|
+
* Document-body splice (init-script delivery, task 0266) AND the
|
|
261
|
+
* `Fetch.authRequired` listener for proxy creds. Undefined when neither
|
|
262
|
+
* inject nor proxy auth is in play (capture-with-no-proxy short-circuit).
|
|
263
|
+
*
|
|
264
|
+
* @see PLAN.md §8.4, tasks/0266-fetch-fulfill-init-script.md
|
|
176
265
|
*/
|
|
177
|
-
private
|
|
266
|
+
private initInjectorHandle: InitInjectorHandle | undefined;
|
|
178
267
|
/**
|
|
179
268
|
* Snapshot of the `challenges` launch option, retained so
|
|
180
269
|
* {@link newPage} can install the per-page auto-click handler. Undefined
|
|
@@ -196,14 +285,19 @@ export class Session {
|
|
|
196
285
|
* @internal
|
|
197
286
|
*/
|
|
198
287
|
private readonly workerExecutionContextIds = new Map<string, number>();
|
|
288
|
+
/**
|
|
289
|
+
* The `CookieJar` instance returned by the {@link cookies} getter. Created
|
|
290
|
+
* once at construction and bound to this Session — every call routes
|
|
291
|
+
* through `Storage.getCookies` / `Storage.setCookies` on the root browser
|
|
292
|
+
* target. See {@link CookieJar} for the surface contract.
|
|
293
|
+
*/
|
|
294
|
+
private readonly cookieJar: CookieJar;
|
|
199
295
|
|
|
200
296
|
constructor(init: SessionInit) {
|
|
201
297
|
this.proc = init.proc;
|
|
202
298
|
this.profile = init.matrix;
|
|
203
299
|
this.seed = init.seed;
|
|
204
300
|
this.bypassInject = init.bypassInject === true;
|
|
205
|
-
this.netProxy = init.netProxy;
|
|
206
|
-
this.netAdapter = init.netAdapter ?? defaultNetAdapter;
|
|
207
301
|
this.challengesOpts = init.challenges;
|
|
208
302
|
// Skip payload compilation entirely when bypassed — capture flows must
|
|
209
303
|
// not pay the build cost AND must not see the matrix-derived bytes.
|
|
@@ -212,26 +306,40 @@ export class Session {
|
|
|
212
306
|
defaultTimeoutMs: init.defaultTimeoutMs,
|
|
213
307
|
});
|
|
214
308
|
this.router.start();
|
|
309
|
+
this.cookieJar = createCookieJar(this);
|
|
215
310
|
this.installAutoAttach();
|
|
216
311
|
this.installCrashGuard();
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
312
|
+
// Task 0266: unified Fetch.enable owner — handles both Document-body
|
|
313
|
+
// splice (init-script delivery via Fetch.fulfillRequest, replacing
|
|
314
|
+
// Page.addScriptToEvaluateOnNewDocument) AND the proxy-auth handler
|
|
315
|
+
// when credentials are supplied. Single Fetch.enable per session.
|
|
316
|
+
//
|
|
317
|
+
// The injector skips Fetch.enable entirely when both are inactive
|
|
318
|
+
// (capture flow with no proxy) so we keep the §8.2-clean
|
|
319
|
+
// "no extra protocol surface" property of the v0.1 baseline for that
|
|
320
|
+
// narrow case.
|
|
321
|
+
const payloadCode = this._payload?.code ?? null;
|
|
322
|
+
const auth = init.proxyAuth;
|
|
323
|
+
if (payloadCode !== null || auth !== undefined) {
|
|
221
324
|
// Fire-and-forget: surface failures via console.warn but don't reject
|
|
222
|
-
// the constructor
|
|
223
|
-
//
|
|
224
|
-
|
|
325
|
+
// the constructor. The init-script path means a failure to install
|
|
326
|
+
// breaks inject delivery (the page still loads with the bare
|
|
327
|
+
// browser fingerprint), so we log loudly to keep the failure
|
|
328
|
+
// visible.
|
|
329
|
+
void installInitInjector(this.router, {
|
|
330
|
+
payloadCode,
|
|
331
|
+
...(auth !== undefined ? { auth } : {}),
|
|
332
|
+
})
|
|
225
333
|
.then((handle) => {
|
|
226
334
|
if (this.closed) {
|
|
227
335
|
void handle.dispose();
|
|
228
336
|
return;
|
|
229
337
|
}
|
|
230
|
-
this.
|
|
338
|
+
this.initInjectorHandle = handle;
|
|
231
339
|
})
|
|
232
340
|
.catch((err: unknown) => {
|
|
233
341
|
if (!this.closed) {
|
|
234
|
-
console.warn("[mochi]
|
|
342
|
+
console.warn("[mochi] init-injector installation failed:", err);
|
|
235
343
|
}
|
|
236
344
|
});
|
|
237
345
|
}
|
|
@@ -242,12 +350,15 @@ export class Session {
|
|
|
242
350
|
* 1. `Target.createTarget` opens a new browser tab.
|
|
243
351
|
* 2. `Target.attachToTarget({ flatten: true })` returns a flat-mode session
|
|
244
352
|
* id we'll use to address page-level CDP methods.
|
|
245
|
-
* 3.
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
353
|
+
* 3. The inject payload is delivered NOT via
|
|
354
|
+
* `Page.addScriptToEvaluateOnNewDocument` but via the always-on
|
|
355
|
+
* `Fetch` domain handler installed once at session-construction time
|
|
356
|
+
* (`installInitInjector`). When this page navigates, the document
|
|
357
|
+
* response is intercepted, its CSP rewritten, and the payload
|
|
358
|
+
* spliced as an inline `<script>` at end-of-`<head>` before the
|
|
359
|
+
* first non-comment `<script>`. See PLAN.md §8.4 / task 0266 for
|
|
360
|
+
* the rationale (closes the source-attribution leak that
|
|
361
|
+
* `addScriptToEvaluateOnNewDocument` otherwise carries).
|
|
251
362
|
*
|
|
252
363
|
* `flatten: true` is critical — without it, page CDP messages would need to
|
|
253
364
|
* be wrapped in `Target.sendMessageToTarget` envelopes. Flat mode lets us
|
|
@@ -333,18 +444,44 @@ export class Session {
|
|
|
333
444
|
{ sessionId: attached.sessionId },
|
|
334
445
|
);
|
|
335
446
|
}
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
447
|
+
// Task 0266: the inject payload is delivered via a TWO-MECHANISM strategy:
|
|
448
|
+
//
|
|
449
|
+
// 1. Session-level `installInitInjector` (constructor) — listens on
|
|
450
|
+
// `Fetch.requestPaused`, splices the wrapped payload into every
|
|
451
|
+
// HTTP/HTTPS Document response. This is the load-bearing path for
|
|
452
|
+
// real navigations: closes the `addScriptToEvaluateOnNewDocument`
|
|
453
|
+
// source-attribution leak.
|
|
454
|
+
//
|
|
455
|
+
// 2. Per-page `Page.addScriptToEvaluateOnNewDocument` (this block) —
|
|
456
|
+
// registers the SAME wrapped payload as a fallback for URL schemes
|
|
457
|
+
// that the Fetch domain does NOT intercept: `about:blank`,
|
|
458
|
+
// `data:`, `blob:`. Without this, an `await page.goto("about:blank")`
|
|
459
|
+
// followed by an inject-dependent assertion (e.g. `navigator.
|
|
460
|
+
// webdriver` patched via R-022) would fail because the inject
|
|
461
|
+
// never fired.
|
|
462
|
+
//
|
|
463
|
+
// The wrapper sets `__mochi_inject_marker = true` on globalThis and
|
|
464
|
+
// checks for it at entry, so when both paths fire on the same realm
|
|
465
|
+
// (a normal HTTP nav has Fetch splice + new-document fire), the second
|
|
466
|
+
// invocation early-returns before any side effect. PLAN.md §8.4
|
|
467
|
+
// documents this dual-mechanism design and the trade-off it accepts:
|
|
468
|
+
// the source-attribution leak is closed for every URL scheme that
|
|
469
|
+
// matters (HTTP/HTTPS — i.e. every fingerprinter-relevant page) but
|
|
470
|
+
// remains for transitional URLs (about:blank/data:/blob:) where no
|
|
471
|
+
// fingerprinter typically reads.
|
|
339
472
|
let injectScriptIdentifier: string | undefined;
|
|
340
473
|
if (!this.bypassInject && this._payload !== null) {
|
|
474
|
+
const wrapped = wrapSelfRemovingPayload(this._payload.code);
|
|
341
475
|
const installed = await this.router.send<{ identifier: string }>(
|
|
342
476
|
"Page.addScriptToEvaluateOnNewDocument",
|
|
343
477
|
{
|
|
344
|
-
source:
|
|
478
|
+
source: wrapped,
|
|
479
|
+
// Run before the first script in the document — same timing the
|
|
480
|
+
// Fetch.fulfillRequest splice achieves on HTTP nav.
|
|
345
481
|
runImmediately: true,
|
|
482
|
+
// Empty `worldName` MUST be the literal empty string — naming any
|
|
483
|
+
// world creates a fingerprintable isolated world (PLAN.md §8.4).
|
|
346
484
|
worldName: "",
|
|
347
|
-
// includeCommandLineAPI defaults to false; we don't set it.
|
|
348
485
|
},
|
|
349
486
|
{ sessionId: attached.sessionId },
|
|
350
487
|
);
|
|
@@ -394,117 +531,367 @@ export class Session {
|
|
|
394
531
|
}
|
|
395
532
|
|
|
396
533
|
/**
|
|
397
|
-
*
|
|
534
|
+
* Cookie-jar surface: `get`, `set`, `save`, `load`. See {@link CookieJar}.
|
|
535
|
+
*
|
|
536
|
+
* All four methods route through `Storage.getCookies` /
|
|
537
|
+
* `Storage.setCookies` on the *root* browser target — the only domain that
|
|
538
|
+
* exposes a global cookie reader/writer without a per-page Network domain.
|
|
398
539
|
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
540
|
+
* The persistence layer (`save`/`load`) is JSON, NOT pickle (per audit:
|
|
541
|
+
* `docs/audits/nodriver.md` LOW finding 2 — Bun-native code uses JSON).
|
|
542
|
+
* Format pinned by {@link CookieJarFile}; a small header (`version`,
|
|
543
|
+
* `savedAt`, `mochiVersion`, `pattern`, `count`) lets a future incompatible
|
|
544
|
+
* change be detected before any cookie touches the browser.
|
|
401
545
|
*/
|
|
402
|
-
|
|
403
|
-
this.
|
|
404
|
-
const result = await this.router.send<{ cookies: import("./page").Cookie[] }>(
|
|
405
|
-
"Storage.getCookies",
|
|
406
|
-
);
|
|
407
|
-
if (filter.url === undefined) return result.cookies;
|
|
408
|
-
// v0.1 only supports a coarse host-string filter — full URL matching with
|
|
409
|
-
// path, secure, etc. is out of scope per the brief.
|
|
410
|
-
let host: string;
|
|
411
|
-
try {
|
|
412
|
-
host = new URL(filter.url).hostname;
|
|
413
|
-
} catch {
|
|
414
|
-
return [];
|
|
415
|
-
}
|
|
416
|
-
return result.cookies.filter((c) => c.domain.endsWith(host) || host.endsWith(c.domain));
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** Set cookies via the root-target Storage domain. */
|
|
420
|
-
async setCookies(cookies: import("./page").Cookie[]): Promise<void> {
|
|
421
|
-
this.assertOpen();
|
|
422
|
-
await this.router.send("Storage.setCookies", { cookies });
|
|
546
|
+
get cookies(): CookieJar {
|
|
547
|
+
return this.cookieJar;
|
|
423
548
|
}
|
|
424
549
|
|
|
425
550
|
/** Storage snapshot. v0.1: cookies only. localStorage/sessionStorage are empty placeholders pending phase 0.7. */
|
|
426
551
|
async storage(): Promise<StorageSnapshot> {
|
|
427
552
|
this.assertOpen();
|
|
428
|
-
const c = await this.
|
|
553
|
+
const c = await this.cookieJar.get();
|
|
429
554
|
return { cookies: c, localStorage: {}, sessionStorage: {} };
|
|
430
555
|
}
|
|
431
556
|
|
|
432
557
|
/**
|
|
433
|
-
* Out-of-band fetch —
|
|
434
|
-
*
|
|
435
|
-
* own navigation/XHR/fetch are unaffected (they use Chromium's native
|
|
436
|
-
* TLS, which already matches a Chrome profile). Returns a standard Web
|
|
437
|
-
* `Response`. PLAN.md §5.4 / §10.
|
|
558
|
+
* Out-of-band fetch — routes through Chromium itself so JA4/JA3/H2 are
|
|
559
|
+
* real Chrome by definition. Returns a standard Web `Response`.
|
|
438
560
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
561
|
+
* ### Dual-mechanism routing
|
|
562
|
+
*
|
|
563
|
+
* The implementation picks one of two CDP paths based on the call shape.
|
|
564
|
+
* Both paths run inside the browser, so both inherit the session's
|
|
565
|
+
* cookie jar, proxy (`--proxy-server`), and TLS stack — the bytes a
|
|
566
|
+
* server observes are byte-identical to what Chromium sends on its own
|
|
567
|
+
* navigation.
|
|
568
|
+
*
|
|
569
|
+
* - **Mechanism A — `Network.loadNetworkResource`.** Used when the call
|
|
570
|
+
* is a simple GET (no `init.method` other than `"GET"`, no
|
|
571
|
+
* `init.headers`, no `init.body`). The CDP method bypasses the
|
|
572
|
+
* same-origin policy at the network layer — there is no CORS preflight
|
|
573
|
+
* and no `Origin` header is sent. Body is returned as an
|
|
574
|
+
* {@link IO.StreamHandle} which we drain via `IO.read` until EOF and
|
|
575
|
+
* then close. Requires a `frameId`; we lazily allocate an
|
|
576
|
+
* `about:blank` scratch frame and reuse it across calls.
|
|
577
|
+
*
|
|
578
|
+
* - **Mechanism B — `page.evaluate("fetch(url, init).then(...)")`.** Used
|
|
579
|
+
* for everything else (POST/PUT/DELETE, custom headers, request body).
|
|
580
|
+
* Full {@link RequestInit} semantics pass through: cookies inherit
|
|
581
|
+
* from the page's origin (the scratch frame is `about:blank`), CORS
|
|
582
|
+
* applies same as a real user's browser, redirects follow per
|
|
583
|
+
* `init.redirect`. Bodies are forwarded as `string` /
|
|
584
|
+
* `ArrayBuffer` / `URLSearchParams`; `Blob` / `FormData` /
|
|
585
|
+
* `ReadableStream` are not yet supported (rejected with a clear
|
|
586
|
+
* diagnostic). The response is reconstructed from a base64-encoded
|
|
587
|
+
* ArrayBuffer + a status / headers tuple.
|
|
588
|
+
*
|
|
589
|
+
* ### Cookie semantics (breaking change vs. 0.6)
|
|
590
|
+
*
|
|
591
|
+
* Both mechanisms share the browser's cookie jar. A cookie set via
|
|
592
|
+
* `Page.goto` or `session.cookies.set` is sent on the next
|
|
593
|
+
* `session.fetch` call to the same origin — no manual `Cookie` header
|
|
594
|
+
* propagation. The pre-0.7 wreq-routed `Session.fetch` was cookieless.
|
|
595
|
+
*
|
|
596
|
+
* ### What changed vs. 0.6
|
|
597
|
+
*
|
|
598
|
+
* - **No more Rust FFI.** The `@mochi.js/net` and `@mochi.js/net-rs`
|
|
599
|
+
* packages are gone; there is no cdylib to install or trust.
|
|
600
|
+
* - **Cookies inherit** (above).
|
|
601
|
+
* - **Non-GET respects CORS.** Mechanism B is a real `fetch` from the
|
|
602
|
+
* page's main world; cross-origin POSTs without `Access-Control-Allow-Origin`
|
|
603
|
+
* fail the same way they would for a user.
|
|
604
|
+
*
|
|
605
|
+
* @see PLAN.md §5.4 / §7
|
|
442
606
|
*/
|
|
443
607
|
async fetch(url: string, init?: RequestInit): Promise<Response> {
|
|
444
608
|
this.assertOpen();
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
609
|
+
const isSimpleGet =
|
|
610
|
+
init === undefined ||
|
|
611
|
+
((init.method === undefined || init.method.toUpperCase() === "GET") &&
|
|
612
|
+
init.headers === undefined &&
|
|
613
|
+
init.body === undefined);
|
|
614
|
+
if (isSimpleGet) return this.fetchViaLoadNetworkResource(url);
|
|
615
|
+
// Mechanism B: serialize the init eagerly so unsupported body shapes
|
|
616
|
+
// (FormData / Blob / ReadableStream) throw BEFORE we allocate any CDP
|
|
617
|
+
// resources — a no-op on the wire if the call would have failed
|
|
618
|
+
// anyway.
|
|
619
|
+
const initSerialized = serializeRequestInitForFetch(init as RequestInit);
|
|
620
|
+
return this.fetchViaPageEvaluate(url, initSerialized);
|
|
455
621
|
}
|
|
456
622
|
|
|
457
|
-
/**
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
623
|
+
/**
|
|
624
|
+
* Mechanism A: drive `Network.loadNetworkResource` against the scratch
|
|
625
|
+
* frame, then drain the resulting `IO.StreamHandle` until EOF.
|
|
626
|
+
*
|
|
627
|
+
* `Network.loadNetworkResource` is exposed by the browser-side network
|
|
628
|
+
* handler and runs against the host's StoragePartition rather than the
|
|
629
|
+
* per-target `NetworkAgent`'s request observer. It does NOT require
|
|
630
|
+
* `Network.enable` (the contract test
|
|
631
|
+
* `tests/contract/session-fetch-no-network-enable.contract.test.ts`
|
|
632
|
+
* pins this empirically — if Chromium ever changes its mind, the test
|
|
633
|
+
* fails loudly and we fall back to mechanism B exclusively).
|
|
634
|
+
*
|
|
635
|
+
* Returned options are intentionally narrow: the CDP method only takes
|
|
636
|
+
* `disableCache` and `includeCredentials`. We default
|
|
637
|
+
* `includeCredentials: true` so cookies inherit (the whole point of a
|
|
638
|
+
* shared-identity fetch).
|
|
639
|
+
*
|
|
640
|
+
* @internal
|
|
641
|
+
*/
|
|
642
|
+
private async fetchViaLoadNetworkResource(url: string): Promise<Response> {
|
|
643
|
+
const { frameId } = await this.ensureScratchFrame();
|
|
644
|
+
const res = await this.router.send<{ resource: LoadNetworkResourcePageResult }>(
|
|
645
|
+
"Network.loadNetworkResource",
|
|
646
|
+
{
|
|
647
|
+
frameId,
|
|
648
|
+
url,
|
|
649
|
+
options: { disableCache: false, includeCredentials: true },
|
|
650
|
+
},
|
|
651
|
+
);
|
|
652
|
+
if (!res.resource.success) {
|
|
653
|
+
const name = res.resource.netErrorName ?? "fetch failed";
|
|
654
|
+
const httpStatus =
|
|
655
|
+
res.resource.httpStatusCode !== undefined
|
|
656
|
+
? ` (httpStatus=${res.resource.httpStatusCode})`
|
|
657
|
+
: "";
|
|
658
|
+
throw new Error(`[mochi] Session.fetch: ${name}${httpStatus}`);
|
|
659
|
+
}
|
|
660
|
+
const status =
|
|
661
|
+
typeof res.resource.httpStatusCode === "number" && res.resource.httpStatusCode > 0
|
|
662
|
+
? res.resource.httpStatusCode
|
|
663
|
+
: 200;
|
|
664
|
+
const headers = new Headers();
|
|
665
|
+
if (res.resource.headers !== undefined) {
|
|
666
|
+
for (const [k, v] of Object.entries(res.resource.headers)) {
|
|
667
|
+
try {
|
|
668
|
+
headers.append(k, String(v));
|
|
669
|
+
} catch {
|
|
670
|
+
// ignore unmappable header names
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (res.resource.stream === undefined) {
|
|
675
|
+
// Empty body — no stream allocated. Common for 204 / HEAD-style
|
|
676
|
+
// responses though `loadNetworkResource` is GET-only.
|
|
677
|
+
return new Response(uint8ToArrayBuffer(new Uint8Array(0)), { status, headers });
|
|
464
678
|
}
|
|
465
|
-
|
|
679
|
+
const body = await this.readIoStream(res.resource.stream);
|
|
680
|
+
return new Response(uint8ToArrayBuffer(body), { status, headers });
|
|
466
681
|
}
|
|
467
682
|
|
|
468
|
-
/**
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
683
|
+
/**
|
|
684
|
+
* Drain an `IO.StreamHandle` produced by `Network.loadNetworkResource`.
|
|
685
|
+
*
|
|
686
|
+
* The CDP `IO.read` method returns chunks tagged with a `base64Encoded`
|
|
687
|
+
* boolean — text bodies arrive verbatim, binary bodies arrive base64-
|
|
688
|
+
* decoded. We accumulate raw bytes (decoding base64 when needed) and
|
|
689
|
+
* close the handle on EOF. `IO.close` is best-effort: a failure to
|
|
690
|
+
* close doesn't prevent the response from being returned.
|
|
691
|
+
*
|
|
692
|
+
* Chunk size: 64 KiB — the same window the DevTools frontend uses.
|
|
693
|
+
*
|
|
694
|
+
* @internal
|
|
695
|
+
*/
|
|
696
|
+
private async readIoStream(handle: string): Promise<Uint8Array> {
|
|
697
|
+
const chunks: Uint8Array[] = [];
|
|
698
|
+
let totalLen = 0;
|
|
699
|
+
// 64 KiB per chunk — DevTools frontend uses the same window. Larger
|
|
700
|
+
// values risk fragmenting the CDP frame; smaller values triple the
|
|
701
|
+
// round-trip count for a realistic JSON body.
|
|
702
|
+
const READ_SIZE = 64 * 1024;
|
|
703
|
+
for (;;) {
|
|
704
|
+
const r = await this.router.send<{ data: string; eof: boolean; base64Encoded?: boolean }>(
|
|
705
|
+
"IO.read",
|
|
706
|
+
{ handle, size: READ_SIZE },
|
|
707
|
+
);
|
|
708
|
+
if (r.data.length > 0) {
|
|
709
|
+
const bytes =
|
|
710
|
+
r.base64Encoded === true ? base64ToBytes(r.data) : new TextEncoder().encode(r.data);
|
|
711
|
+
chunks.push(bytes);
|
|
712
|
+
totalLen += bytes.byteLength;
|
|
484
713
|
}
|
|
485
|
-
|
|
714
|
+
if (r.eof) break;
|
|
486
715
|
}
|
|
487
|
-
|
|
716
|
+
try {
|
|
717
|
+
await this.router.send("IO.close", { handle });
|
|
718
|
+
} catch {
|
|
719
|
+
// best-effort — handle may have auto-released on EOF
|
|
720
|
+
}
|
|
721
|
+
if (chunks.length === 0) return new Uint8Array(0);
|
|
722
|
+
if (chunks.length === 1) return chunks[0] as Uint8Array;
|
|
723
|
+
const out = new Uint8Array(totalLen);
|
|
724
|
+
let offset = 0;
|
|
725
|
+
for (const c of chunks) {
|
|
726
|
+
out.set(c, offset);
|
|
727
|
+
offset += c.byteLength;
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
488
730
|
}
|
|
489
731
|
|
|
490
732
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
733
|
+
* Mechanism B: forward the call into the page's main-world `fetch` via
|
|
734
|
+
* `Runtime.callFunctionOn`. The function returns
|
|
735
|
+
* `{ status, headers, bodyB64 }`; the body round-trips as base64 so
|
|
736
|
+
* binary responses survive intact.
|
|
737
|
+
*
|
|
738
|
+
* Cookies inherit from the scratch page's origin (`about:blank`), which
|
|
739
|
+
* means cookies set via `Page.goto` (any origin) plus
|
|
740
|
+
* `Storage.setCookies` reach the call exactly as if a user typed `fetch`
|
|
741
|
+
* into the browser console. CORS applies — cross-origin POSTs without
|
|
742
|
+
* the right ACAO header fail the same way they would for a user.
|
|
743
|
+
*
|
|
744
|
+
* @internal
|
|
494
745
|
*/
|
|
495
|
-
private
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
746
|
+
private async fetchViaPageEvaluate(url: string, initSerialized: string): Promise<Response> {
|
|
747
|
+
const { sessionId } = await this.ensureScratchFrame();
|
|
748
|
+
const documentObjectId = await this.scratchDocumentObjectId(sessionId);
|
|
749
|
+
// The function source is small and self-contained. We avoid any
|
|
750
|
+
// `Runtime.evaluate` (per §8.2 / `Runtime.enable` is forbidden, plus
|
|
751
|
+
// we want a deterministic context) and bind to the document objectId
|
|
752
|
+
// so the call lands in the page's main world.
|
|
753
|
+
const fnDeclaration = `async function(urlArg, initJson) {
|
|
754
|
+
const init = JSON.parse(initJson);
|
|
755
|
+
let bodyOut = init.__body;
|
|
756
|
+
if (init.__bodyB64 !== undefined) {
|
|
757
|
+
const bin = atob(init.__bodyB64);
|
|
758
|
+
const bytes = new Uint8Array(bin.length);
|
|
759
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
760
|
+
bodyOut = bytes;
|
|
761
|
+
}
|
|
762
|
+
delete init.__body;
|
|
763
|
+
delete init.__bodyB64;
|
|
764
|
+
if (bodyOut !== undefined) init.body = bodyOut;
|
|
765
|
+
const r = await fetch(urlArg, init);
|
|
766
|
+
const buf = await r.arrayBuffer();
|
|
767
|
+
let b64 = "";
|
|
768
|
+
const view = new Uint8Array(buf);
|
|
769
|
+
// Chunked btoa to dodge call-stack overflow on big bodies.
|
|
770
|
+
const CHUNK = 0x8000;
|
|
771
|
+
for (let i = 0; i < view.length; i += CHUNK) {
|
|
772
|
+
let s = "";
|
|
773
|
+
const end = Math.min(i + CHUNK, view.length);
|
|
774
|
+
for (let j = i; j < end; j++) s += String.fromCharCode(view[j]);
|
|
775
|
+
b64 += btoa(s);
|
|
776
|
+
}
|
|
777
|
+
const headers = {};
|
|
778
|
+
r.headers.forEach((v, k) => { headers[k] = v; });
|
|
779
|
+
return { status: r.status, headers, bodyB64: b64 };
|
|
780
|
+
}`;
|
|
781
|
+
const callRes = await this.router.send<{
|
|
782
|
+
result: {
|
|
783
|
+
value?: { status: number; headers: Record<string, string>; bodyB64: string };
|
|
784
|
+
type: string;
|
|
785
|
+
};
|
|
786
|
+
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
|
787
|
+
}>(
|
|
788
|
+
"Runtime.callFunctionOn",
|
|
789
|
+
{
|
|
790
|
+
functionDeclaration: fnDeclaration,
|
|
791
|
+
objectId: documentObjectId,
|
|
792
|
+
arguments: [{ value: url }, { value: initSerialized }],
|
|
793
|
+
returnByValue: true,
|
|
794
|
+
awaitPromise: true,
|
|
795
|
+
},
|
|
796
|
+
{ sessionId },
|
|
797
|
+
);
|
|
798
|
+
if (callRes.exceptionDetails !== undefined) {
|
|
799
|
+
const desc =
|
|
800
|
+
callRes.exceptionDetails.exception?.description ??
|
|
801
|
+
callRes.exceptionDetails.text ??
|
|
802
|
+
"page-evaluate fetch threw";
|
|
803
|
+
throw new Error(`[mochi] Session.fetch: ${desc}`);
|
|
804
|
+
}
|
|
805
|
+
const out = callRes.result.value;
|
|
806
|
+
if (out === undefined) {
|
|
807
|
+
throw new Error("[mochi] Session.fetch: page-evaluate fetch returned undefined");
|
|
808
|
+
}
|
|
809
|
+
const headers = new Headers();
|
|
810
|
+
for (const [k, v] of Object.entries(out.headers)) {
|
|
811
|
+
try {
|
|
812
|
+
headers.append(k, v);
|
|
813
|
+
} catch {
|
|
814
|
+
// ignore unmappable header names
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const body = base64ToBytes(out.bodyB64);
|
|
818
|
+
return new Response(uint8ToArrayBuffer(body), { status: out.status, headers });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Lazily create the scratch frame used by {@link fetch}. The first call
|
|
823
|
+
* spawns an `about:blank` page (kept off the public {@link pages} list),
|
|
824
|
+
* attaches a flat-mode session, enables `Page` (for the `frameNavigated`
|
|
825
|
+
* event), records the main-frame id, and caches the result. Subsequent
|
|
826
|
+
* calls reuse the cache. Closed on {@link close}.
|
|
827
|
+
*
|
|
828
|
+
* Concurrent first-callers share the same in-flight promise so we don't
|
|
829
|
+
* race on `Target.createTarget`.
|
|
830
|
+
*
|
|
831
|
+
* @internal
|
|
832
|
+
*/
|
|
833
|
+
private async ensureScratchFrame(): Promise<{
|
|
834
|
+
targetId: string;
|
|
835
|
+
sessionId: string;
|
|
836
|
+
frameId: string;
|
|
837
|
+
}> {
|
|
838
|
+
if (this.scratchFrame !== undefined) return this.scratchFrame;
|
|
839
|
+
if (this.scratchFramePromise !== undefined) return this.scratchFramePromise;
|
|
840
|
+
this.scratchFramePromise = (async () => {
|
|
841
|
+
const created = await this.router.send<{ targetId: string }>("Target.createTarget", {
|
|
842
|
+
url: "about:blank",
|
|
843
|
+
});
|
|
844
|
+
const attached = await this.router.send<{ sessionId: string }>("Target.attachToTarget", {
|
|
845
|
+
targetId: created.targetId,
|
|
846
|
+
flatten: true,
|
|
847
|
+
});
|
|
848
|
+
// Page.enable surfaces `Page.frameNavigated`; we need it to capture
|
|
849
|
+
// the main-frame id deterministically (`Page.getFrameTree` is also
|
|
850
|
+
// an option but adds a CDP round-trip).
|
|
851
|
+
await this.router.send("Page.enable", undefined, { sessionId: attached.sessionId });
|
|
852
|
+
const tree = await this.router.send<{ frameTree: { frame: { id: string } } }>(
|
|
853
|
+
"Page.getFrameTree",
|
|
854
|
+
undefined,
|
|
855
|
+
{ sessionId: attached.sessionId },
|
|
856
|
+
);
|
|
857
|
+
this.scratchFrame = {
|
|
858
|
+
targetId: created.targetId,
|
|
859
|
+
sessionId: attached.sessionId,
|
|
860
|
+
frameId: tree.frameTree.frame.id,
|
|
861
|
+
};
|
|
862
|
+
return this.scratchFrame;
|
|
863
|
+
})();
|
|
864
|
+
try {
|
|
865
|
+
const frame = await this.scratchFramePromise;
|
|
866
|
+
return frame;
|
|
867
|
+
} finally {
|
|
868
|
+
this.scratchFramePromise = undefined;
|
|
502
869
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Resolve the scratch page's `document` objectId for `Runtime.callFunctionOn`.
|
|
874
|
+
* `DOM.getDocument` is the canonical "give me a fresh root NodeId"
|
|
875
|
+
* method; `DOM.resolveNode` then returns its `objectId`. Both are §8.2-
|
|
876
|
+
* clean (no `Runtime.enable`, no isolated worlds).
|
|
877
|
+
*
|
|
878
|
+
* @internal
|
|
879
|
+
*/
|
|
880
|
+
private async scratchDocumentObjectId(sessionId: string): Promise<string> {
|
|
881
|
+
const doc = await this.router.send<{ root: { nodeId: number } }>(
|
|
882
|
+
"DOM.getDocument",
|
|
883
|
+
{ depth: 0 },
|
|
884
|
+
{ sessionId },
|
|
507
885
|
);
|
|
886
|
+
const resolved = await this.router.send<{ object: { objectId?: string } }>(
|
|
887
|
+
"DOM.resolveNode",
|
|
888
|
+
{ nodeId: doc.root.nodeId },
|
|
889
|
+
{ sessionId },
|
|
890
|
+
);
|
|
891
|
+
if (resolved.object.objectId === undefined) {
|
|
892
|
+
throw new Error("[mochi] Session.fetch: scratch document objectId unresolved");
|
|
893
|
+
}
|
|
894
|
+
return resolved.object.objectId;
|
|
508
895
|
}
|
|
509
896
|
|
|
510
897
|
/**
|
|
@@ -533,26 +920,28 @@ export class Session {
|
|
|
533
920
|
// ignore — best-effort
|
|
534
921
|
}
|
|
535
922
|
}
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
if (this.
|
|
923
|
+
// Close the scratch frame used by Session.fetch (mechanisms A + B).
|
|
924
|
+
// `Target.closeTarget` is idempotent server-side; we only call when
|
|
925
|
+
// a scratch frame was actually opened.
|
|
926
|
+
if (this.scratchFrame !== undefined) {
|
|
927
|
+
const targetId = this.scratchFrame.targetId;
|
|
928
|
+
this.scratchFrame = undefined;
|
|
540
929
|
try {
|
|
541
|
-
this.
|
|
930
|
+
await this.router.send("Target.closeTarget", { targetId });
|
|
542
931
|
} catch (err) {
|
|
543
|
-
console.warn("[mochi]
|
|
932
|
+
if (!this.closed) console.warn("[mochi] scratch frame close failed:", err);
|
|
544
933
|
}
|
|
545
|
-
this.netCtx = undefined;
|
|
546
934
|
}
|
|
547
|
-
// Drop the
|
|
548
|
-
// the router so the disable round-trip can still
|
|
549
|
-
|
|
935
|
+
// Drop the unified init-injector subscription (and its `Fetch.disable`)
|
|
936
|
+
// BEFORE we tear down the router so the disable round-trip can still
|
|
937
|
+
// complete on the live transport.
|
|
938
|
+
if (this.initInjectorHandle !== undefined) {
|
|
550
939
|
try {
|
|
551
|
-
await this.
|
|
940
|
+
await this.initInjectorHandle.dispose();
|
|
552
941
|
} catch (err) {
|
|
553
|
-
console.warn("[mochi]
|
|
942
|
+
console.warn("[mochi] init-injector dispose failed:", err);
|
|
554
943
|
}
|
|
555
|
-
this.
|
|
944
|
+
this.initInjectorHandle = undefined;
|
|
556
945
|
}
|
|
557
946
|
await this.router.close();
|
|
558
947
|
await this.proc.close();
|
|
@@ -610,6 +999,25 @@ export class Session {
|
|
|
610
999
|
*/
|
|
611
1000
|
static readonly VERSION = VERSION;
|
|
612
1001
|
|
|
1002
|
+
/**
|
|
1003
|
+
* Module-private accessor used by {@link createCookieJar}. The cookie-jar
|
|
1004
|
+
* factory lives in module scope (so callers can subclass via the public
|
|
1005
|
+
* {@link CookieJar} interface without touching the Session internals); this
|
|
1006
|
+
* accessor lets the factory reach the router + the open-state guard while
|
|
1007
|
+
* keeping both genuinely private to user code.
|
|
1008
|
+
*
|
|
1009
|
+
* @internal
|
|
1010
|
+
*/
|
|
1011
|
+
_internalCookieJarPlumbing(): {
|
|
1012
|
+
router: MessageRouter;
|
|
1013
|
+
assertOpen: () => void;
|
|
1014
|
+
} {
|
|
1015
|
+
return {
|
|
1016
|
+
router: this.router,
|
|
1017
|
+
assertOpen: () => this.assertOpen(),
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
613
1021
|
// ---- internals --------------------------------------------------------------
|
|
614
1022
|
|
|
615
1023
|
private installAutoAttach(): void {
|
|
@@ -643,7 +1051,7 @@ export class Session {
|
|
|
643
1051
|
* (no Page domain). PLAN.md §8.4 calls out that the worker target accepts
|
|
644
1052
|
* `Runtime.evaluate` even though `Runtime.enable` is forbidden by §8.2.
|
|
645
1053
|
*
|
|
646
|
-
* The Patchright-cited bootstrap
|
|
1054
|
+
* The Patchright-cited bootstrap (— `crServiceWorkerPatch.ts:32-43`,
|
|
647
1055
|
* `crPagePatch.ts:404-417`) tightens the inject race window:
|
|
648
1056
|
* 1. `Runtime.evaluate("globalThis", { serialization: "idOnly" })` —
|
|
649
1057
|
* returns a `RemoteObject` whose `objectId` carries the worker's
|
|
@@ -687,6 +1095,14 @@ export class Session {
|
|
|
687
1095
|
// so the call binds to the worker's own context, not whatever
|
|
688
1096
|
// `Runtime.evaluate` happens to resolve. The payload IIFE is wrapped
|
|
689
1097
|
// as a function declaration so `callFunctionOn` accepts it.
|
|
1098
|
+
//
|
|
1099
|
+
// Timeout: 5s, not the 30s default. Transient workers (sannysoft,
|
|
1100
|
+
// bot.incolumitas, etc. spawn brief workers that die between attach
|
|
1101
|
+
// and inject) WILL silently disappear; without a per-call cap the
|
|
1102
|
+
// route loop blocks for 30s waiting on a reply that's never coming,
|
|
1103
|
+
// adding 30s × N orphan workers per test run. 5s is plenty for a
|
|
1104
|
+
// real worker (callFunctionOn against a live context returns in
|
|
1105
|
+
// single-digit ms); anything past that, the target is dead.
|
|
690
1106
|
await this.router.send(
|
|
691
1107
|
"Runtime.callFunctionOn",
|
|
692
1108
|
{
|
|
@@ -696,11 +1112,26 @@ export class Session {
|
|
|
696
1112
|
awaitPromise: false,
|
|
697
1113
|
// includeCommandLineAPI must remain false (§8.2).
|
|
698
1114
|
},
|
|
699
|
-
{ sessionId: childSessionId },
|
|
1115
|
+
{ sessionId: childSessionId, timeoutMs: WORKER_INJECT_TIMEOUT_MS },
|
|
700
1116
|
);
|
|
701
1117
|
} catch (err: unknown) {
|
|
702
1118
|
if (!this.closed) {
|
|
703
|
-
|
|
1119
|
+
// Downgrade to debug for the expected race (worker died before
|
|
1120
|
+
// inject completed). The two error fingerprints are: our own
|
|
1121
|
+
// CdpTimeoutError (router gave up), or CDP's own "Session with
|
|
1122
|
+
// given id not found" / "Target closed" (target detached
|
|
1123
|
+
// mid-roundtrip). Both are routine on real-world pages with
|
|
1124
|
+
// short-lived workers; warning on every one is just noise. A
|
|
1125
|
+
// genuine bug (e.g. the idOnly extraction returning a bad
|
|
1126
|
+
// contextId) is anything else and still warns.
|
|
1127
|
+
if (isTransientWorkerError(err)) {
|
|
1128
|
+
// best-effort: silent. The worker is gone; nothing to do.
|
|
1129
|
+
} else {
|
|
1130
|
+
console.warn(
|
|
1131
|
+
`[mochi] payload inject into worker ${ev.targetInfo.targetId} failed:`,
|
|
1132
|
+
err,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
704
1135
|
}
|
|
705
1136
|
}
|
|
706
1137
|
}
|
|
@@ -709,13 +1140,18 @@ export class Session {
|
|
|
709
1140
|
try {
|
|
710
1141
|
await this.router.send("Runtime.runIfWaitingForDebugger", undefined, {
|
|
711
1142
|
sessionId: childSessionId,
|
|
1143
|
+
timeoutMs: WORKER_INJECT_TIMEOUT_MS,
|
|
712
1144
|
});
|
|
713
1145
|
} catch (err: unknown) {
|
|
714
1146
|
if (!this.closed) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1147
|
+
if (isTransientWorkerError(err)) {
|
|
1148
|
+
// best-effort: silent. Same race as the inject path above.
|
|
1149
|
+
} else {
|
|
1150
|
+
console.warn(
|
|
1151
|
+
`[mochi] Runtime.runIfWaitingForDebugger on target ${ev.targetInfo.targetId} failed:`,
|
|
1152
|
+
err,
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
719
1155
|
}
|
|
720
1156
|
}
|
|
721
1157
|
}
|
|
@@ -808,7 +1244,7 @@ export class Session {
|
|
|
808
1244
|
}
|
|
809
1245
|
}
|
|
810
1246
|
|
|
811
|
-
// ---- UA-CH metadata helpers
|
|
1247
|
+
// ---- UA-CH metadata helpers -------------------------------------
|
|
812
1248
|
|
|
813
1249
|
/**
|
|
814
1250
|
* Single brand entry as accepted by `Network.setUserAgentOverride`'s
|
|
@@ -982,3 +1418,231 @@ export function buildUserAgentMetadata(matrix: MatrixV1): {
|
|
|
982
1418
|
wow64: false,
|
|
983
1419
|
};
|
|
984
1420
|
}
|
|
1421
|
+
|
|
1422
|
+
// ---- cookie-jar factory -----------------------------------------
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Build the {@link CookieJar} returned by `Session.cookies`. Bound to one
|
|
1426
|
+
* Session instance via {@link Session._internalCookieJarPlumbing}. Module-
|
|
1427
|
+
* private; the public surface is the interface — instances are only created
|
|
1428
|
+
* by the Session constructor.
|
|
1429
|
+
*
|
|
1430
|
+
* `save`/`load` use Bun's filesystem APIs (`Bun.file`, `Bun.write`) — Bun is
|
|
1431
|
+
* the only supported runtime per PLAN.md I-3 so there's no Node fallback.
|
|
1432
|
+
*
|
|
1433
|
+
* @internal
|
|
1434
|
+
*/
|
|
1435
|
+
function createCookieJar(session: Session): CookieJar {
|
|
1436
|
+
const { router, assertOpen } = session._internalCookieJarPlumbing();
|
|
1437
|
+
return {
|
|
1438
|
+
async get(filter: { url?: string } = {}) {
|
|
1439
|
+
assertOpen();
|
|
1440
|
+
const result = await router.send<{ cookies: import("./page").Cookie[] }>(
|
|
1441
|
+
"Storage.getCookies",
|
|
1442
|
+
);
|
|
1443
|
+
if (filter.url === undefined) return result.cookies;
|
|
1444
|
+
// Coarse host-string filter — full URL matching with path / secure /
|
|
1445
|
+
// sameSite is out of scope per the brief. Mirrors the pre-0257
|
|
1446
|
+
// behaviour of the legacy `Session.cookies(filter)` method.
|
|
1447
|
+
let host: string;
|
|
1448
|
+
try {
|
|
1449
|
+
host = new URL(filter.url).hostname;
|
|
1450
|
+
} catch {
|
|
1451
|
+
return [];
|
|
1452
|
+
}
|
|
1453
|
+
return result.cookies.filter((c) => c.domain.endsWith(host) || host.endsWith(c.domain));
|
|
1454
|
+
},
|
|
1455
|
+
async set(cookies: import("./page").Cookie[]) {
|
|
1456
|
+
assertOpen();
|
|
1457
|
+
await router.send("Storage.setCookies", { cookies });
|
|
1458
|
+
},
|
|
1459
|
+
async save(path: string, opts: CookieJarOptions = {}) {
|
|
1460
|
+
assertOpen();
|
|
1461
|
+
const pattern = opts.pattern ?? /.*/;
|
|
1462
|
+
const all = await router.send<{ cookies: import("./page").Cookie[] }>("Storage.getCookies");
|
|
1463
|
+
const filtered = all.cookies.filter((c) => pattern.test(c.domain));
|
|
1464
|
+
const file: CookieJarFile = {
|
|
1465
|
+
version: COOKIE_JAR_FORMAT_VERSION,
|
|
1466
|
+
savedAt: new Date().toISOString(),
|
|
1467
|
+
mochiVersion: VERSION,
|
|
1468
|
+
pattern: pattern.source,
|
|
1469
|
+
count: filtered.length,
|
|
1470
|
+
cookies: filtered,
|
|
1471
|
+
};
|
|
1472
|
+
// Pretty-print with 2-space indent: jars are committed by some users
|
|
1473
|
+
// alongside fixtures (per nodriver's `pickle` use case); pretty JSON
|
|
1474
|
+
// diffs cleanly. Negligible size impact for a few-kB cookie set.
|
|
1475
|
+
await Bun.write(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
1476
|
+
},
|
|
1477
|
+
async load(path: string, opts: CookieJarOptions = {}) {
|
|
1478
|
+
assertOpen();
|
|
1479
|
+
const pattern = opts.pattern ?? /.*/;
|
|
1480
|
+
const file = Bun.file(path);
|
|
1481
|
+
const exists = await file.exists();
|
|
1482
|
+
if (!exists) {
|
|
1483
|
+
throw new Error(`[mochi] cookies.load: file not found at ${path}`);
|
|
1484
|
+
}
|
|
1485
|
+
let parsed: unknown;
|
|
1486
|
+
try {
|
|
1487
|
+
const text = await file.text();
|
|
1488
|
+
parsed = JSON.parse(text);
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
throw new Error(`[mochi] cookies.load: ${path} is not valid JSON: ${String(err)}`);
|
|
1491
|
+
}
|
|
1492
|
+
const jar = parsed as Partial<CookieJarFile>;
|
|
1493
|
+
if (typeof jar !== "object" || jar === null) {
|
|
1494
|
+
throw new Error(`[mochi] cookies.load: ${path} is not a JSON object`);
|
|
1495
|
+
}
|
|
1496
|
+
if (jar.version !== COOKIE_JAR_FORMAT_VERSION) {
|
|
1497
|
+
throw new Error(
|
|
1498
|
+
`[mochi] cookies.load: ${path} version ${String(jar.version)} is not supported (expected ${COOKIE_JAR_FORMAT_VERSION})`,
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
if (!Array.isArray(jar.cookies)) {
|
|
1502
|
+
throw new Error(`[mochi] cookies.load: ${path} has no \`cookies\` array`);
|
|
1503
|
+
}
|
|
1504
|
+
// Filter on load too: a single saved-with-everything jar can be sliced
|
|
1505
|
+
// domain-wise without re-saving.
|
|
1506
|
+
const toLoad = jar.cookies.filter((c) => pattern.test(c.domain));
|
|
1507
|
+
if (toLoad.length === 0) return;
|
|
1508
|
+
await router.send("Storage.setCookies", { cookies: toLoad });
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ---- Session.fetch helpers --------------------------------------
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Shape of the `Network.loadNetworkResource` reply per the CDP `tot`
|
|
1517
|
+
* spec. The `stream` handle, when present, is an {@link IO.StreamHandle}
|
|
1518
|
+
* that must be drained via `IO.read` until EOF and then `IO.close`d.
|
|
1519
|
+
*
|
|
1520
|
+
* @internal
|
|
1521
|
+
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-loadNetworkResource
|
|
1522
|
+
*/
|
|
1523
|
+
interface LoadNetworkResourcePageResult {
|
|
1524
|
+
success: boolean;
|
|
1525
|
+
netError?: number;
|
|
1526
|
+
netErrorName?: string;
|
|
1527
|
+
httpStatusCode?: number;
|
|
1528
|
+
/** `IO.StreamHandle` — drain via `IO.read` until EOF. Undefined on empty body. */
|
|
1529
|
+
stream?: string;
|
|
1530
|
+
headers?: Record<string, string>;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Convert a `Uint8Array` to a fresh `ArrayBuffer` slice — TS's lib.dom
|
|
1535
|
+
* `BodyInit` rejects `Uint8Array<ArrayBufferLike>` in some configurations
|
|
1536
|
+
* (Bun ships its own DOM types here), so we hand `Response` an ArrayBuffer
|
|
1537
|
+
* directly. Zero-copy when possible (the underlying buffer is already a
|
|
1538
|
+
* plain `ArrayBuffer`); falls back to a copy slice otherwise.
|
|
1539
|
+
*
|
|
1540
|
+
* @internal
|
|
1541
|
+
*/
|
|
1542
|
+
function uint8ToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
1543
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Decode a base64-encoded string into a `Uint8Array`. Used by
|
|
1548
|
+
* {@link Session.fetch}'s mechanisms A (when `IO.read` returns
|
|
1549
|
+
* `base64Encoded: true`) and B (the page-evaluate path always returns
|
|
1550
|
+
* base64 so binary responses round-trip intact).
|
|
1551
|
+
*
|
|
1552
|
+
* Bun ships `atob` natively; we use it for the chunked decode.
|
|
1553
|
+
*
|
|
1554
|
+
* @internal
|
|
1555
|
+
*/
|
|
1556
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
1557
|
+
if (b64.length === 0) return new Uint8Array(0);
|
|
1558
|
+
const bin = atob(b64);
|
|
1559
|
+
const out = new Uint8Array(bin.length);
|
|
1560
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1561
|
+
return out;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Serialize a {@link RequestInit} into a JSON-safe shape the page-evaluate
|
|
1566
|
+
* fetch path can consume. Headers / method / redirect / mode / credentials
|
|
1567
|
+
* pass through unchanged. The body is the tricky part:
|
|
1568
|
+
*
|
|
1569
|
+
* - `string` / `URLSearchParams` → forwarded as the `__body` string field.
|
|
1570
|
+
* - `ArrayBuffer` / typed array → base64-encoded into `__bodyB64` so
|
|
1571
|
+
* binary survives the JSON-only round-trip; the page-side glue
|
|
1572
|
+
* decodes back to a Uint8Array before passing to `fetch`.
|
|
1573
|
+
* - `null` / `undefined` → no body field.
|
|
1574
|
+
* - `Blob` / `FormData` / `ReadableStream` → throws with a clear
|
|
1575
|
+
* diagnostic. Future work; needs a separate channel because they're
|
|
1576
|
+
* not JSON-serializable.
|
|
1577
|
+
*
|
|
1578
|
+
* @internal
|
|
1579
|
+
*/
|
|
1580
|
+
function serializeRequestInitForFetch(init: RequestInit): string {
|
|
1581
|
+
const out: Record<string, unknown> = {};
|
|
1582
|
+
if (init.method !== undefined) out.method = init.method;
|
|
1583
|
+
if (init.headers !== undefined) out.headers = headersInitToRecord(init.headers);
|
|
1584
|
+
if (init.redirect !== undefined) out.redirect = init.redirect;
|
|
1585
|
+
if (init.mode !== undefined) out.mode = init.mode;
|
|
1586
|
+
if (init.credentials !== undefined) out.credentials = init.credentials;
|
|
1587
|
+
if (init.referrer !== undefined) out.referrer = init.referrer;
|
|
1588
|
+
if (init.referrerPolicy !== undefined) out.referrerPolicy = init.referrerPolicy;
|
|
1589
|
+
if (init.cache !== undefined) out.cache = init.cache;
|
|
1590
|
+
if (init.integrity !== undefined) out.integrity = init.integrity;
|
|
1591
|
+
if (init.keepalive !== undefined) out.keepalive = init.keepalive;
|
|
1592
|
+
const b = init.body;
|
|
1593
|
+
if (b !== undefined && b !== null) {
|
|
1594
|
+
if (typeof b === "string") {
|
|
1595
|
+
out.__body = b;
|
|
1596
|
+
} else if (b instanceof URLSearchParams) {
|
|
1597
|
+
out.__body = b.toString();
|
|
1598
|
+
} else if (b instanceof ArrayBuffer) {
|
|
1599
|
+
out.__bodyB64 = bytesToBase64(new Uint8Array(b));
|
|
1600
|
+
} else if (ArrayBuffer.isView(b)) {
|
|
1601
|
+
const view = b as ArrayBufferView;
|
|
1602
|
+
out.__bodyB64 = bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
1603
|
+
} else {
|
|
1604
|
+
// Blob / FormData / ReadableStream — would need a separate transport
|
|
1605
|
+
// (multipart / streaming) that the JSON-only page-evaluate seam can't
|
|
1606
|
+
// express today. The brief explicitly defers these to a follow-up.
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
"[mochi] Session.fetch: Blob, FormData, and ReadableStream bodies are not yet supported — " +
|
|
1609
|
+
"use string / ArrayBuffer / URLSearchParams or wait for the streaming-body PR.",
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return JSON.stringify(out);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** Coerce a Web `Headers` / record / array-pair shape into a plain record. */
|
|
1617
|
+
function headersInitToRecord(h: HeadersInit): Record<string, string> {
|
|
1618
|
+
if (h instanceof Headers) {
|
|
1619
|
+
const out: Record<string, string> = {};
|
|
1620
|
+
h.forEach((v, k) => {
|
|
1621
|
+
out[k] = v;
|
|
1622
|
+
});
|
|
1623
|
+
return out;
|
|
1624
|
+
}
|
|
1625
|
+
if (Array.isArray(h)) {
|
|
1626
|
+
const out: Record<string, string> = {};
|
|
1627
|
+
for (const pair of h) {
|
|
1628
|
+
const k = pair[0];
|
|
1629
|
+
const v = pair[1];
|
|
1630
|
+
if (typeof k === "string" && typeof v === "string") out[k] = v;
|
|
1631
|
+
}
|
|
1632
|
+
return out;
|
|
1633
|
+
}
|
|
1634
|
+
return { ...(h as Record<string, string>) };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** Encode a `Uint8Array` to base64. Chunked to dodge call-stack overflow. */
|
|
1638
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
1639
|
+
let out = "";
|
|
1640
|
+
const CHUNK = 0x8000;
|
|
1641
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
1642
|
+
let s = "";
|
|
1643
|
+
const end = Math.min(i + CHUNK, bytes.length);
|
|
1644
|
+
for (let j = i; j < end; j++) s += String.fromCharCode(bytes[j] as number);
|
|
1645
|
+
out += btoa(s);
|
|
1646
|
+
}
|
|
1647
|
+
return out;
|
|
1648
|
+
}
|