@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/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
- openCtx as defaultOpenCtx,
24
- requestOnCtx as defaultRequestOnCtx,
25
- type NetCtx,
26
- type NetFetchInit,
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
- * Injection seam for the network FFI. Session uses this internally so tests
37
- * can stub the FFI layer without spinning up the cdylib. Production code
38
- * defaults to `@mochi.js/net`.
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
- * @internal
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
- export interface NetAdapter {
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
- const defaultNetAdapter: NetAdapter = {
48
- openCtx: defaultOpenCtx,
49
- requestOnCtx: defaultRequestOnCtx,
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
- * `Page.addScriptToEvaluateOnNewDocument` on every new page; worker
61
- * targets receive no inject either. Intended for `mochi capture` and
62
- * similar baseline-collection flows. PLAN.md §12.1, task 0040.
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
- * Proxy URL forwarded to `@mochi.js/net` for out-of-band fetches. Mirrors
135
- * the launch-time `proxy` option but is held here because the net Ctx is
136
- * created lazily on first `fetch`.
137
- */
138
- private readonly netProxy: string | undefined;
139
- /**
140
- * Lazily-opened Net Ctx for `Session.fetch`. One per Session — wreq's
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 netCtx: NetCtx | undefined;
221
+ private scratchFrame: { targetId: string; sessionId: string; frameId: string } | undefined;
145
222
  /**
146
- * Pluggable seam for the network FFI. Defaults to `@mochi.js/net`.
147
- * Tests inject a stub here.
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 readonly netAdapter: NetAdapter;
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 `Page.addScriptToEvaluateOnNewDocument`, no worker injection).
167
- * Set from {@link SessionInit.bypassInject}. PLAN.md §12.1, task 0040.
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 CDP `Fetch.authRequired` subscription. Created
174
- * lazily on construction when `init.proxyAuth` is set; disposed on
175
- * `Session.close`. Undefined when the session has no proxy auth.
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 proxyAuthHandle: ProxyAuthHandle | undefined;
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
- // Wire CDP-driven proxy auth only when credentials were supplied. The
218
- // no-auth path skips Fetch.enable entirely so we don't pay the
219
- // protocol-attach cost or surface any extra CDP traffic.
220
- if (init.proxyAuth !== undefined) {
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 pages still launch and unauthenticated traffic
223
- // will simply 407, giving callers a recoverable signal.
224
- void installProxyAuth(this.router, init.proxyAuth)
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.proxyAuthHandle = handle;
338
+ this.initInjectorHandle = handle;
231
339
  })
232
340
  .catch((err: unknown) => {
233
341
  if (!this.closed) {
234
- console.warn("[mochi] proxy-auth installation failed:", err);
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. `Page.addScriptToEvaluateOnNewDocument({ source, runImmediately: true,
246
- * worldName: "" })` installs the inject payload to run main-world,
247
- * before any page script, on every navigation. The returned identifier
248
- * is tracked on the {@link Page} so it can be removed on close.
249
- * Critical: `worldName: ""` any non-empty string creates an isolated
250
- * world (PLAN.md §8.4) which is detectable.
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
- // PLAN.md §12.1 / task 0040 capture flow short-circuits inject so the
337
- // browser reports its bare fingerprint. Otherwise install the payload
338
- // main-world via §8.4. worldName MUST be the empty string.
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: this._payload.code,
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
- * All cookies the browser is aware of, optionally filtered by url.
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
- * Uses `Storage.getCookies` on the *root* browser target (the only domain
400
- * that exposes a global cookie reader without a per-page Network domain).
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
- async cookies(filter: { url?: string } = {}): Promise<import("./page").Cookie[]> {
403
- this.assertOpen();
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.cookies();
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 — issues a request via the Rust `wreq` cdylib so the
434
- * wire fingerprint matches the session's profile preset. The browser's
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
- * Lazy: the per-Session `NetCtx` (Tokio runtime + wreq Client) is created
440
- * on the first call and reused for subsequent calls. Closed on
441
- * {@link close}.
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 ctx = this.ensureNetCtx();
446
- const headers = this.headersToRecord(init?.headers);
447
- const body = this.bodyToString(init?.body);
448
- return this.netAdapter.requestOnCtx(ctx, url, {
449
- method: init?.method ?? "GET",
450
- headers,
451
- body,
452
- preset: this.profile.wreqPreset,
453
- ...(this.netProxy !== undefined ? { proxy: this.netProxy } : {}),
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
- /** Lazy-create the per-Session Net Ctx (one Tokio runtime + wreq client). */
458
- private ensureNetCtx(): NetCtx {
459
- if (this.netCtx === undefined) {
460
- this.netCtx = this.netAdapter.openCtx({
461
- preset: this.profile.wreqPreset,
462
- ...(this.netProxy !== undefined ? { proxy: this.netProxy } : {}),
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
- return this.netCtx;
679
+ const body = await this.readIoStream(res.resource.stream);
680
+ return new Response(uint8ToArrayBuffer(body), { status, headers });
466
681
  }
467
682
 
468
- /** Coerce a Web `Headers` / record / array-pair shape into a plain record. */
469
- private headersToRecord(h: HeadersInit | undefined): Record<string, string> {
470
- if (h === undefined) return {};
471
- if (h instanceof Headers) {
472
- const out: Record<string, string> = {};
473
- h.forEach((v, k) => {
474
- out[k] = v;
475
- });
476
- return out;
477
- }
478
- if (Array.isArray(h)) {
479
- const out: Record<string, string> = {};
480
- for (const pair of h) {
481
- const k = pair[0];
482
- const v = pair[1];
483
- if (typeof k === "string" && typeof v === "string") out[k] = v;
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
- return out;
714
+ if (r.eof) break;
486
715
  }
487
- return { ...(h as Record<string, string>) };
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
- * Coerce a `RequestInit.body` to a UTF-8 string (the only shape the v0.6
492
- * FFI surface accepts). `null`/`undefined` map to `null`. ArrayBuffer-style
493
- * inputs are decoded as UTF-8; binary bodies are deferred per task brief.
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 bodyToString(b: BodyInit | null | undefined): string | null {
496
- if (b === undefined || b === null) return null;
497
- if (typeof b === "string") return b;
498
- if (b instanceof ArrayBuffer) return new TextDecoder().decode(b);
499
- if (ArrayBuffer.isView(b)) {
500
- // Includes Uint8Array, Buffer, etc.
501
- return new TextDecoder().decode(b as ArrayBufferView);
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
- if (b instanceof URLSearchParams) return b.toString();
504
- // Blob / FormData / ReadableStream — out of v0.6 scope.
505
- throw new Error(
506
- "[mochi] Session.fetch: only string, ArrayBuffer/View, and URLSearchParams bodies are supported in v0.6",
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
- // Tear down the per-Session Net Ctx if one was opened. `close()` is
537
- // idempotent on the Net Ctx as well; calling on never-opened sessions
538
- // is a no-op since `netCtx` stays undefined.
539
- if (this.netCtx !== undefined) {
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.netCtx.close();
930
+ await this.router.send("Target.closeTarget", { targetId });
542
931
  } catch (err) {
543
- console.warn("[mochi] net ctx close failed:", err);
932
+ if (!this.closed) console.warn("[mochi] scratch frame close failed:", err);
544
933
  }
545
- this.netCtx = undefined;
546
934
  }
547
- // Drop the proxy-auth subscription + Fetch.disable BEFORE we tear down
548
- // the router so the disable round-trip can still complete.
549
- if (this.proxyAuthHandle !== undefined) {
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.proxyAuthHandle.dispose();
940
+ await this.initInjectorHandle.dispose();
552
941
  } catch (err) {
553
- console.warn("[mochi] proxy-auth dispose failed:", err);
942
+ console.warn("[mochi] init-injector dispose failed:", err);
554
943
  }
555
- this.proxyAuthHandle = undefined;
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 (task 0254 — `crServiceWorkerPatch.ts:32-43`,
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
- console.warn(`[mochi] payload inject into worker ${ev.targetInfo.targetId} failed:`, err);
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
- console.warn(
716
- `[mochi] Runtime.runIfWaitingForDebugger on target ${ev.targetInfo.targetId} failed:`,
717
- err,
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 (task 0261) -------------------------------------
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
+ }