@mochi.js/core 0.3.0 → 0.6.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 +4 -4
- package/src/__tests__/cookies-jar.test.ts +361 -0
- package/src/__tests__/default-profile.test.ts +181 -0
- package/src/__tests__/dx-cluster.e2e.test.ts +245 -0
- package/src/__tests__/init-injector.e2e.test.ts +144 -0
- package/src/__tests__/init-injector.test.ts +249 -0
- package/src/__tests__/inject.test.ts +80 -164
- package/src/__tests__/page-dx-cluster.test.ts +292 -0
- package/src/__tests__/proc-linux-server.test.ts +243 -0
- package/src/__tests__/proxy-auth.test.ts +22 -55
- package/src/__tests__/screenshot.e2e.test.ts +126 -0
- package/src/__tests__/screenshot.test.ts +363 -0
- package/src/cdp/init-injector.ts +644 -0
- package/src/default-profile.ts +112 -0
- package/src/index.ts +33 -1
- package/src/launch.ts +199 -10
- package/src/linux-server.ts +157 -0
- package/src/page.ts +410 -8
- package/src/proc.ts +48 -5
- package/src/proxy-auth.ts +26 -107
- package/src/session.ts +367 -68
package/src/session.ts
CHANGED
|
@@ -25,11 +25,15 @@ import {
|
|
|
25
25
|
type NetCtx,
|
|
26
26
|
type NetFetchInit,
|
|
27
27
|
} from "@mochi.js/net";
|
|
28
|
+
import {
|
|
29
|
+
type InitInjectorHandle,
|
|
30
|
+
installInitInjector,
|
|
31
|
+
wrapSelfRemovingPayload,
|
|
32
|
+
} from "./cdp/init-injector";
|
|
28
33
|
import { MessageRouter } from "./cdp/router";
|
|
29
34
|
import type { AttachedToTargetEvent } from "./cdp/types";
|
|
30
35
|
import { Page } from "./page";
|
|
31
36
|
import type { ChromiumProcess } from "./proc";
|
|
32
|
-
import { installProxyAuth, type ProxyAuthHandle } from "./proxy-auth";
|
|
33
37
|
import { VERSION } from "./version";
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -49,6 +53,40 @@ const defaultNetAdapter: NetAdapter = {
|
|
|
49
53
|
requestOnCtx: defaultRequestOnCtx,
|
|
50
54
|
};
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Per-call timeout for the worker idOnly inject roundtrip. 5s, not the
|
|
58
|
+
* router's 30s default — workers spawned by sites like sannysoft,
|
|
59
|
+
* bot.incolumitas, fingerprintjs probes routinely die between
|
|
60
|
+
* `Target.attachedToTarget` and our reply. Without a tight cap, every
|
|
61
|
+
* orphan worker stalls the route loop for the full 30s. Real workers
|
|
62
|
+
* resolve in single-digit ms; 5s is generous.
|
|
63
|
+
*
|
|
64
|
+
* If you ever see a legitimate worker fail at 5s, raise this — but the
|
|
65
|
+
* symptom would be a missing inject on a long-running worker, which is
|
|
66
|
+
* separate from the orphan-worker race we're sizing for.
|
|
67
|
+
*/
|
|
68
|
+
const WORKER_INJECT_TIMEOUT_MS = 5_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Predicate: is this an "expected" failure from the worker idOnly inject
|
|
72
|
+
* race (worker died between attach and our roundtrip)? Recognized:
|
|
73
|
+
* - `CdpTimeoutError` — router gave up after WORKER_INJECT_TIMEOUT_MS
|
|
74
|
+
* because the target stopped responding. Most common path.
|
|
75
|
+
* - CDP `Session with given id not found` — target detached mid-call.
|
|
76
|
+
* - CDP `Target closed` — same race, different message variant.
|
|
77
|
+
*
|
|
78
|
+
* All three are routine and silent. A genuine bug (bad contextId,
|
|
79
|
+
* wrong serialization, schema drift) surfaces as anything else and
|
|
80
|
+
* still warns through the console.
|
|
81
|
+
*/
|
|
82
|
+
function isTransientWorkerError(err: unknown): boolean {
|
|
83
|
+
if (err === null || typeof err !== "object") return false;
|
|
84
|
+
const name = (err as { name?: string }).name;
|
|
85
|
+
if (name === "CdpTimeoutError") return true;
|
|
86
|
+
const msg = (err as { message?: string }).message ?? "";
|
|
87
|
+
return msg.includes("Session with given id not found") || msg.includes("Target closed");
|
|
88
|
+
}
|
|
89
|
+
|
|
52
90
|
export interface SessionInit {
|
|
53
91
|
proc: ChromiumProcess;
|
|
54
92
|
matrix: MatrixV1;
|
|
@@ -56,10 +94,10 @@ export interface SessionInit {
|
|
|
56
94
|
/** Optional overrides for the underlying message-router timeout. */
|
|
57
95
|
defaultTimeoutMs?: number;
|
|
58
96
|
/**
|
|
59
|
-
* When true, skip {@link buildPayload} AND skip
|
|
60
|
-
* `
|
|
61
|
-
*
|
|
62
|
-
*
|
|
97
|
+
* When true, skip {@link buildPayload} AND skip the init-injector install
|
|
98
|
+
* (no `Fetch.fulfillRequest` body splice on documents); worker targets
|
|
99
|
+
* receive no inject either. Intended for `mochi capture` and similar
|
|
100
|
+
* baseline-collection flows. PLAN.md §12.1, task 0040.
|
|
63
101
|
*/
|
|
64
102
|
bypassInject?: boolean;
|
|
65
103
|
/**
|
|
@@ -118,6 +156,86 @@ export interface StorageSnapshot {
|
|
|
118
156
|
sessionStorage: Record<string, Record<string, string>>;
|
|
119
157
|
}
|
|
120
158
|
|
|
159
|
+
// ---- cookie-jar persistence (task 0257) -------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Current on-disk cookie-file format version. Bumped on incompatible header
|
|
163
|
+
* changes. The reader refuses unknown majors with a precise diagnostic so a
|
|
164
|
+
* stale jar doesn't silently load with the wrong shape.
|
|
165
|
+
*/
|
|
166
|
+
export const COOKIE_JAR_FORMAT_VERSION = 1 as const;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* On-disk shape for {@link Session.cookies.save}. The `cookies` array is the
|
|
170
|
+
* verbatim `Storage.getCookies` payload — every shipped Chromium revision
|
|
171
|
+
* agrees on this shape, so loading on a newer Chromium round-trips losslessly.
|
|
172
|
+
*
|
|
173
|
+
* @see tasks/0257-dx-cluster-cookies-storage-permissions.md (success criteria)
|
|
174
|
+
* @see https://chromedevtools.github.io/devtools-protocol/tot/Storage/#method-getCookies
|
|
175
|
+
*/
|
|
176
|
+
export interface CookieJarFile {
|
|
177
|
+
/** Format version (currently `1`). */
|
|
178
|
+
version: typeof COOKIE_JAR_FORMAT_VERSION;
|
|
179
|
+
/** ISO-8601 UTC timestamp of `save()` (ends in `Z`). */
|
|
180
|
+
savedAt: string;
|
|
181
|
+
/** Mochi core version that produced the file. */
|
|
182
|
+
mochiVersion: string;
|
|
183
|
+
/** The regex source that filtered the saved set (default `".*"`). */
|
|
184
|
+
pattern: string;
|
|
185
|
+
/** Number of cookies in the `cookies` array — redundant with `cookies.length`, kept for trace logs. */
|
|
186
|
+
count: number;
|
|
187
|
+
/** Raw `Storage.getCookies` cookies, optionally filtered by `pattern`. */
|
|
188
|
+
cookies: import("./page").Cookie[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Options shared by `cookies.save` / `cookies.load`. */
|
|
192
|
+
export interface CookieJarOptions {
|
|
193
|
+
/**
|
|
194
|
+
* Optional regex matched against each cookie's `domain`. Default `.*`
|
|
195
|
+
* (everything). Cookies failing the match are skipped on save AND on load
|
|
196
|
+
* (so a saved-with-everything jar can be partially restored).
|
|
197
|
+
*/
|
|
198
|
+
pattern?: RegExp;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `Session.cookies` namespace — exposes the read/write/persist surface for the
|
|
203
|
+
* session's cookie jar. The legacy `Session.cookies(filter)` and
|
|
204
|
+
* `Session.setCookies(...)` shapes are gone; callers go through this object.
|
|
205
|
+
*
|
|
206
|
+
* The whole namespace is bound to a Session instance via the `Session.cookies`
|
|
207
|
+
* getter — every method routes through `Storage.getCookies` /
|
|
208
|
+
* `Storage.setCookies` on the root browser target (the only domain that
|
|
209
|
+
* exposes a global cookie reader without a per-page Network domain).
|
|
210
|
+
*/
|
|
211
|
+
export interface CookieJar {
|
|
212
|
+
/**
|
|
213
|
+
* All cookies the browser is aware of, optionally filtered by url. The url
|
|
214
|
+
* filter is a coarse hostname match (no path / secure / sameSite handling) —
|
|
215
|
+
* sufficient for "scope down to a session" use cases.
|
|
216
|
+
*/
|
|
217
|
+
get(filter?: { url?: string }): Promise<import("./page").Cookie[]>;
|
|
218
|
+
/** Set cookies via the root-target Storage domain. */
|
|
219
|
+
set(cookies: import("./page").Cookie[]): Promise<void>;
|
|
220
|
+
/**
|
|
221
|
+
* Persist cookies to a JSON file at `path`. Cookies whose `domain` does NOT
|
|
222
|
+
* match `opts.pattern` (default: every domain) are skipped. The file format
|
|
223
|
+
* is {@link CookieJarFile}.
|
|
224
|
+
*/
|
|
225
|
+
save(path: string, opts?: CookieJarOptions): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* Read a JSON file written by {@link save} and replay every cookie back into
|
|
228
|
+
* the browser via `Storage.setCookies`. Cookies whose `domain` does NOT
|
|
229
|
+
* match `opts.pattern` (default: everything) are skipped — useful when one
|
|
230
|
+
* jar holds multi-domain state but only a slice should be re-installed for
|
|
231
|
+
* the current run.
|
|
232
|
+
*
|
|
233
|
+
* Throws on missing/corrupt files or version mismatch with a diagnostic that
|
|
234
|
+
* pins the exact failure point.
|
|
235
|
+
*/
|
|
236
|
+
load(path: string, opts?: CookieJarOptions): Promise<void>;
|
|
237
|
+
}
|
|
238
|
+
|
|
121
239
|
export class Session {
|
|
122
240
|
/**
|
|
123
241
|
* The resolved Matrix for this session — a relationally-locked snapshot
|
|
@@ -163,18 +281,22 @@ export class Session {
|
|
|
163
281
|
private readonly _payload: PayloadResult | null;
|
|
164
282
|
/**
|
|
165
283
|
* Whether this session bypasses the inject pipeline (no `buildPayload`,
|
|
166
|
-
* no `
|
|
167
|
-
*
|
|
284
|
+
* no body splice via `Fetch.fulfillRequest`, no worker injection). Set
|
|
285
|
+
* from {@link SessionInit.bypassInject}. PLAN.md §12.1, task 0040.
|
|
168
286
|
*
|
|
169
287
|
* @internal
|
|
170
288
|
*/
|
|
171
289
|
private readonly bypassInject: boolean;
|
|
172
290
|
/**
|
|
173
|
-
* Live handle for the
|
|
174
|
-
*
|
|
175
|
-
*
|
|
291
|
+
* Live handle for the unified `Fetch` domain owner — installs once on
|
|
292
|
+
* construction and tears down on `Session.close`. Owns BOTH the
|
|
293
|
+
* Document-body splice (init-script delivery, task 0266) AND the
|
|
294
|
+
* `Fetch.authRequired` listener for proxy creds. Undefined when neither
|
|
295
|
+
* inject nor proxy auth is in play (capture-with-no-proxy short-circuit).
|
|
296
|
+
*
|
|
297
|
+
* @see PLAN.md §8.4, tasks/0266-fetch-fulfill-init-script.md
|
|
176
298
|
*/
|
|
177
|
-
private
|
|
299
|
+
private initInjectorHandle: InitInjectorHandle | undefined;
|
|
178
300
|
/**
|
|
179
301
|
* Snapshot of the `challenges` launch option, retained so
|
|
180
302
|
* {@link newPage} can install the per-page auto-click handler. Undefined
|
|
@@ -196,6 +318,13 @@ export class Session {
|
|
|
196
318
|
* @internal
|
|
197
319
|
*/
|
|
198
320
|
private readonly workerExecutionContextIds = new Map<string, number>();
|
|
321
|
+
/**
|
|
322
|
+
* The `CookieJar` instance returned by the {@link cookies} getter. Created
|
|
323
|
+
* once at construction and bound to this Session — every call routes
|
|
324
|
+
* through `Storage.getCookies` / `Storage.setCookies` on the root browser
|
|
325
|
+
* target. See {@link CookieJar} for the surface contract.
|
|
326
|
+
*/
|
|
327
|
+
private readonly cookieJar: CookieJar;
|
|
199
328
|
|
|
200
329
|
constructor(init: SessionInit) {
|
|
201
330
|
this.proc = init.proc;
|
|
@@ -212,26 +341,40 @@ export class Session {
|
|
|
212
341
|
defaultTimeoutMs: init.defaultTimeoutMs,
|
|
213
342
|
});
|
|
214
343
|
this.router.start();
|
|
344
|
+
this.cookieJar = createCookieJar(this);
|
|
215
345
|
this.installAutoAttach();
|
|
216
346
|
this.installCrashGuard();
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
347
|
+
// Task 0266: unified Fetch.enable owner — handles both Document-body
|
|
348
|
+
// splice (init-script delivery via Fetch.fulfillRequest, replacing
|
|
349
|
+
// Page.addScriptToEvaluateOnNewDocument) AND the proxy-auth handler
|
|
350
|
+
// when credentials are supplied. Single Fetch.enable per session.
|
|
351
|
+
//
|
|
352
|
+
// The injector skips Fetch.enable entirely when both are inactive
|
|
353
|
+
// (capture flow with no proxy) so we keep the §8.2-clean
|
|
354
|
+
// "no extra protocol surface" property of the v0.1 baseline for that
|
|
355
|
+
// narrow case.
|
|
356
|
+
const payloadCode = this._payload?.code ?? null;
|
|
357
|
+
const auth = init.proxyAuth;
|
|
358
|
+
if (payloadCode !== null || auth !== undefined) {
|
|
221
359
|
// Fire-and-forget: surface failures via console.warn but don't reject
|
|
222
|
-
// the constructor
|
|
223
|
-
//
|
|
224
|
-
|
|
360
|
+
// the constructor. The init-script path means a failure to install
|
|
361
|
+
// breaks inject delivery (the page still loads with the bare
|
|
362
|
+
// browser fingerprint), so we log loudly to keep the failure
|
|
363
|
+
// visible.
|
|
364
|
+
void installInitInjector(this.router, {
|
|
365
|
+
payloadCode,
|
|
366
|
+
...(auth !== undefined ? { auth } : {}),
|
|
367
|
+
})
|
|
225
368
|
.then((handle) => {
|
|
226
369
|
if (this.closed) {
|
|
227
370
|
void handle.dispose();
|
|
228
371
|
return;
|
|
229
372
|
}
|
|
230
|
-
this.
|
|
373
|
+
this.initInjectorHandle = handle;
|
|
231
374
|
})
|
|
232
375
|
.catch((err: unknown) => {
|
|
233
376
|
if (!this.closed) {
|
|
234
|
-
console.warn("[mochi]
|
|
377
|
+
console.warn("[mochi] init-injector installation failed:", err);
|
|
235
378
|
}
|
|
236
379
|
});
|
|
237
380
|
}
|
|
@@ -242,12 +385,15 @@ export class Session {
|
|
|
242
385
|
* 1. `Target.createTarget` opens a new browser tab.
|
|
243
386
|
* 2. `Target.attachToTarget({ flatten: true })` returns a flat-mode session
|
|
244
387
|
* id we'll use to address page-level CDP methods.
|
|
245
|
-
* 3.
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
388
|
+
* 3. The inject payload is delivered NOT via
|
|
389
|
+
* `Page.addScriptToEvaluateOnNewDocument` but via the always-on
|
|
390
|
+
* `Fetch` domain handler installed once at session-construction time
|
|
391
|
+
* (`installInitInjector`). When this page navigates, the document
|
|
392
|
+
* response is intercepted, its CSP rewritten, and the payload
|
|
393
|
+
* spliced as an inline `<script>` at end-of-`<head>` before the
|
|
394
|
+
* first non-comment `<script>`. See PLAN.md §8.4 / task 0266 for
|
|
395
|
+
* the rationale (closes the source-attribution leak that
|
|
396
|
+
* `addScriptToEvaluateOnNewDocument` otherwise carries).
|
|
251
397
|
*
|
|
252
398
|
* `flatten: true` is critical — without it, page CDP messages would need to
|
|
253
399
|
* be wrapped in `Target.sendMessageToTarget` envelopes. Flat mode lets us
|
|
@@ -333,18 +479,44 @@ export class Session {
|
|
|
333
479
|
{ sessionId: attached.sessionId },
|
|
334
480
|
);
|
|
335
481
|
}
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
482
|
+
// Task 0266: the inject payload is delivered via a TWO-MECHANISM strategy:
|
|
483
|
+
//
|
|
484
|
+
// 1. Session-level `installInitInjector` (constructor) — listens on
|
|
485
|
+
// `Fetch.requestPaused`, splices the wrapped payload into every
|
|
486
|
+
// HTTP/HTTPS Document response. This is the load-bearing path for
|
|
487
|
+
// real navigations: closes the `addScriptToEvaluateOnNewDocument`
|
|
488
|
+
// source-attribution leak.
|
|
489
|
+
//
|
|
490
|
+
// 2. Per-page `Page.addScriptToEvaluateOnNewDocument` (this block) —
|
|
491
|
+
// registers the SAME wrapped payload as a fallback for URL schemes
|
|
492
|
+
// that the Fetch domain does NOT intercept: `about:blank`,
|
|
493
|
+
// `data:`, `blob:`. Without this, an `await page.goto("about:blank")`
|
|
494
|
+
// followed by an inject-dependent assertion (e.g. `navigator.
|
|
495
|
+
// webdriver` patched via R-022) would fail because the inject
|
|
496
|
+
// never fired.
|
|
497
|
+
//
|
|
498
|
+
// The wrapper sets `__mochi_inject_marker = true` on globalThis and
|
|
499
|
+
// checks for it at entry, so when both paths fire on the same realm
|
|
500
|
+
// (a normal HTTP nav has Fetch splice + new-document fire), the second
|
|
501
|
+
// invocation early-returns before any side effect. PLAN.md §8.4
|
|
502
|
+
// documents this dual-mechanism design and the trade-off it accepts:
|
|
503
|
+
// the source-attribution leak is closed for every URL scheme that
|
|
504
|
+
// matters (HTTP/HTTPS — i.e. every fingerprinter-relevant page) but
|
|
505
|
+
// remains for transitional URLs (about:blank/data:/blob:) where no
|
|
506
|
+
// fingerprinter typically reads.
|
|
339
507
|
let injectScriptIdentifier: string | undefined;
|
|
340
508
|
if (!this.bypassInject && this._payload !== null) {
|
|
509
|
+
const wrapped = wrapSelfRemovingPayload(this._payload.code);
|
|
341
510
|
const installed = await this.router.send<{ identifier: string }>(
|
|
342
511
|
"Page.addScriptToEvaluateOnNewDocument",
|
|
343
512
|
{
|
|
344
|
-
source:
|
|
513
|
+
source: wrapped,
|
|
514
|
+
// Run before the first script in the document — same timing the
|
|
515
|
+
// Fetch.fulfillRequest splice achieves on HTTP nav.
|
|
345
516
|
runImmediately: true,
|
|
517
|
+
// Empty `worldName` MUST be the literal empty string — naming any
|
|
518
|
+
// world creates a fingerprintable isolated world (PLAN.md §8.4).
|
|
346
519
|
worldName: "",
|
|
347
|
-
// includeCommandLineAPI defaults to false; we don't set it.
|
|
348
520
|
},
|
|
349
521
|
{ sessionId: attached.sessionId },
|
|
350
522
|
);
|
|
@@ -394,38 +566,26 @@ export class Session {
|
|
|
394
566
|
}
|
|
395
567
|
|
|
396
568
|
/**
|
|
397
|
-
*
|
|
569
|
+
* Cookie-jar surface: `get`, `set`, `save`, `load`. See {@link CookieJar}.
|
|
570
|
+
*
|
|
571
|
+
* All four methods route through `Storage.getCookies` /
|
|
572
|
+
* `Storage.setCookies` on the *root* browser target — the only domain that
|
|
573
|
+
* exposes a global cookie reader/writer without a per-page Network domain.
|
|
398
574
|
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
575
|
+
* The persistence layer (`save`/`load`) is JSON, NOT pickle (per audit:
|
|
576
|
+
* `docs/audits/nodriver.md` LOW finding 2 — Bun-native code uses JSON).
|
|
577
|
+
* Format pinned by {@link CookieJarFile}; a small header (`version`,
|
|
578
|
+
* `savedAt`, `mochiVersion`, `pattern`, `count`) lets a future incompatible
|
|
579
|
+
* change be detected before any cookie touches the browser.
|
|
401
580
|
*/
|
|
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 });
|
|
581
|
+
get cookies(): CookieJar {
|
|
582
|
+
return this.cookieJar;
|
|
423
583
|
}
|
|
424
584
|
|
|
425
585
|
/** Storage snapshot. v0.1: cookies only. localStorage/sessionStorage are empty placeholders pending phase 0.7. */
|
|
426
586
|
async storage(): Promise<StorageSnapshot> {
|
|
427
587
|
this.assertOpen();
|
|
428
|
-
const c = await this.
|
|
588
|
+
const c = await this.cookieJar.get();
|
|
429
589
|
return { cookies: c, localStorage: {}, sessionStorage: {} };
|
|
430
590
|
}
|
|
431
591
|
|
|
@@ -544,15 +704,16 @@ export class Session {
|
|
|
544
704
|
}
|
|
545
705
|
this.netCtx = undefined;
|
|
546
706
|
}
|
|
547
|
-
// Drop the
|
|
548
|
-
// the router so the disable round-trip can still
|
|
549
|
-
|
|
707
|
+
// Drop the unified init-injector subscription (and its `Fetch.disable`)
|
|
708
|
+
// BEFORE we tear down the router so the disable round-trip can still
|
|
709
|
+
// complete on the live transport.
|
|
710
|
+
if (this.initInjectorHandle !== undefined) {
|
|
550
711
|
try {
|
|
551
|
-
await this.
|
|
712
|
+
await this.initInjectorHandle.dispose();
|
|
552
713
|
} catch (err) {
|
|
553
|
-
console.warn("[mochi]
|
|
714
|
+
console.warn("[mochi] init-injector dispose failed:", err);
|
|
554
715
|
}
|
|
555
|
-
this.
|
|
716
|
+
this.initInjectorHandle = undefined;
|
|
556
717
|
}
|
|
557
718
|
await this.router.close();
|
|
558
719
|
await this.proc.close();
|
|
@@ -610,6 +771,25 @@ export class Session {
|
|
|
610
771
|
*/
|
|
611
772
|
static readonly VERSION = VERSION;
|
|
612
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Module-private accessor used by {@link createCookieJar}. The cookie-jar
|
|
776
|
+
* factory lives in module scope (so callers can subclass via the public
|
|
777
|
+
* {@link CookieJar} interface without touching the Session internals); this
|
|
778
|
+
* accessor lets the factory reach the router + the open-state guard while
|
|
779
|
+
* keeping both genuinely private to user code.
|
|
780
|
+
*
|
|
781
|
+
* @internal
|
|
782
|
+
*/
|
|
783
|
+
_internalCookieJarPlumbing(): {
|
|
784
|
+
router: MessageRouter;
|
|
785
|
+
assertOpen: () => void;
|
|
786
|
+
} {
|
|
787
|
+
return {
|
|
788
|
+
router: this.router,
|
|
789
|
+
assertOpen: () => this.assertOpen(),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
613
793
|
// ---- internals --------------------------------------------------------------
|
|
614
794
|
|
|
615
795
|
private installAutoAttach(): void {
|
|
@@ -687,6 +867,14 @@ export class Session {
|
|
|
687
867
|
// so the call binds to the worker's own context, not whatever
|
|
688
868
|
// `Runtime.evaluate` happens to resolve. The payload IIFE is wrapped
|
|
689
869
|
// as a function declaration so `callFunctionOn` accepts it.
|
|
870
|
+
//
|
|
871
|
+
// Timeout: 5s, not the 30s default. Transient workers (sannysoft,
|
|
872
|
+
// bot.incolumitas, etc. spawn brief workers that die between attach
|
|
873
|
+
// and inject) WILL silently disappear; without a per-call cap the
|
|
874
|
+
// route loop blocks for 30s waiting on a reply that's never coming,
|
|
875
|
+
// adding 30s × N orphan workers per test run. 5s is plenty for a
|
|
876
|
+
// real worker (callFunctionOn against a live context returns in
|
|
877
|
+
// single-digit ms); anything past that, the target is dead.
|
|
690
878
|
await this.router.send(
|
|
691
879
|
"Runtime.callFunctionOn",
|
|
692
880
|
{
|
|
@@ -696,11 +884,26 @@ export class Session {
|
|
|
696
884
|
awaitPromise: false,
|
|
697
885
|
// includeCommandLineAPI must remain false (§8.2).
|
|
698
886
|
},
|
|
699
|
-
{ sessionId: childSessionId },
|
|
887
|
+
{ sessionId: childSessionId, timeoutMs: WORKER_INJECT_TIMEOUT_MS },
|
|
700
888
|
);
|
|
701
889
|
} catch (err: unknown) {
|
|
702
890
|
if (!this.closed) {
|
|
703
|
-
|
|
891
|
+
// Downgrade to debug for the expected race (worker died before
|
|
892
|
+
// inject completed). The two error fingerprints are: our own
|
|
893
|
+
// CdpTimeoutError (router gave up), or CDP's own "Session with
|
|
894
|
+
// given id not found" / "Target closed" (target detached
|
|
895
|
+
// mid-roundtrip). Both are routine on real-world pages with
|
|
896
|
+
// short-lived workers; warning on every one is just noise. A
|
|
897
|
+
// genuine bug (e.g. the idOnly extraction returning a bad
|
|
898
|
+
// contextId) is anything else and still warns.
|
|
899
|
+
if (isTransientWorkerError(err)) {
|
|
900
|
+
// best-effort: silent. The worker is gone; nothing to do.
|
|
901
|
+
} else {
|
|
902
|
+
console.warn(
|
|
903
|
+
`[mochi] payload inject into worker ${ev.targetInfo.targetId} failed:`,
|
|
904
|
+
err,
|
|
905
|
+
);
|
|
906
|
+
}
|
|
704
907
|
}
|
|
705
908
|
}
|
|
706
909
|
}
|
|
@@ -709,13 +912,18 @@ export class Session {
|
|
|
709
912
|
try {
|
|
710
913
|
await this.router.send("Runtime.runIfWaitingForDebugger", undefined, {
|
|
711
914
|
sessionId: childSessionId,
|
|
915
|
+
timeoutMs: WORKER_INJECT_TIMEOUT_MS,
|
|
712
916
|
});
|
|
713
917
|
} catch (err: unknown) {
|
|
714
918
|
if (!this.closed) {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
919
|
+
if (isTransientWorkerError(err)) {
|
|
920
|
+
// best-effort: silent. Same race as the inject path above.
|
|
921
|
+
} else {
|
|
922
|
+
console.warn(
|
|
923
|
+
`[mochi] Runtime.runIfWaitingForDebugger on target ${ev.targetInfo.targetId} failed:`,
|
|
924
|
+
err,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
719
927
|
}
|
|
720
928
|
}
|
|
721
929
|
}
|
|
@@ -982,3 +1190,94 @@ export function buildUserAgentMetadata(matrix: MatrixV1): {
|
|
|
982
1190
|
wow64: false,
|
|
983
1191
|
};
|
|
984
1192
|
}
|
|
1193
|
+
|
|
1194
|
+
// ---- cookie-jar factory (task 0257) -----------------------------------------
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Build the {@link CookieJar} returned by `Session.cookies`. Bound to one
|
|
1198
|
+
* Session instance via {@link Session._internalCookieJarPlumbing}. Module-
|
|
1199
|
+
* private; the public surface is the interface — instances are only created
|
|
1200
|
+
* by the Session constructor.
|
|
1201
|
+
*
|
|
1202
|
+
* `save`/`load` use Bun's filesystem APIs (`Bun.file`, `Bun.write`) — Bun is
|
|
1203
|
+
* the only supported runtime per PLAN.md I-3 so there's no Node fallback.
|
|
1204
|
+
*
|
|
1205
|
+
* @internal
|
|
1206
|
+
*/
|
|
1207
|
+
function createCookieJar(session: Session): CookieJar {
|
|
1208
|
+
const { router, assertOpen } = session._internalCookieJarPlumbing();
|
|
1209
|
+
return {
|
|
1210
|
+
async get(filter: { url?: string } = {}) {
|
|
1211
|
+
assertOpen();
|
|
1212
|
+
const result = await router.send<{ cookies: import("./page").Cookie[] }>(
|
|
1213
|
+
"Storage.getCookies",
|
|
1214
|
+
);
|
|
1215
|
+
if (filter.url === undefined) return result.cookies;
|
|
1216
|
+
// Coarse host-string filter — full URL matching with path / secure /
|
|
1217
|
+
// sameSite is out of scope per the brief. Mirrors the pre-0257
|
|
1218
|
+
// behaviour of the legacy `Session.cookies(filter)` method.
|
|
1219
|
+
let host: string;
|
|
1220
|
+
try {
|
|
1221
|
+
host = new URL(filter.url).hostname;
|
|
1222
|
+
} catch {
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
return result.cookies.filter((c) => c.domain.endsWith(host) || host.endsWith(c.domain));
|
|
1226
|
+
},
|
|
1227
|
+
async set(cookies: import("./page").Cookie[]) {
|
|
1228
|
+
assertOpen();
|
|
1229
|
+
await router.send("Storage.setCookies", { cookies });
|
|
1230
|
+
},
|
|
1231
|
+
async save(path: string, opts: CookieJarOptions = {}) {
|
|
1232
|
+
assertOpen();
|
|
1233
|
+
const pattern = opts.pattern ?? /.*/;
|
|
1234
|
+
const all = await router.send<{ cookies: import("./page").Cookie[] }>("Storage.getCookies");
|
|
1235
|
+
const filtered = all.cookies.filter((c) => pattern.test(c.domain));
|
|
1236
|
+
const file: CookieJarFile = {
|
|
1237
|
+
version: COOKIE_JAR_FORMAT_VERSION,
|
|
1238
|
+
savedAt: new Date().toISOString(),
|
|
1239
|
+
mochiVersion: VERSION,
|
|
1240
|
+
pattern: pattern.source,
|
|
1241
|
+
count: filtered.length,
|
|
1242
|
+
cookies: filtered,
|
|
1243
|
+
};
|
|
1244
|
+
// Pretty-print with 2-space indent: jars are committed by some users
|
|
1245
|
+
// alongside fixtures (per nodriver's `pickle` use case); pretty JSON
|
|
1246
|
+
// diffs cleanly. Negligible size impact for a few-kB cookie set.
|
|
1247
|
+
await Bun.write(path, `${JSON.stringify(file, null, 2)}\n`);
|
|
1248
|
+
},
|
|
1249
|
+
async load(path: string, opts: CookieJarOptions = {}) {
|
|
1250
|
+
assertOpen();
|
|
1251
|
+
const pattern = opts.pattern ?? /.*/;
|
|
1252
|
+
const file = Bun.file(path);
|
|
1253
|
+
const exists = await file.exists();
|
|
1254
|
+
if (!exists) {
|
|
1255
|
+
throw new Error(`[mochi] cookies.load: file not found at ${path}`);
|
|
1256
|
+
}
|
|
1257
|
+
let parsed: unknown;
|
|
1258
|
+
try {
|
|
1259
|
+
const text = await file.text();
|
|
1260
|
+
parsed = JSON.parse(text);
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
throw new Error(`[mochi] cookies.load: ${path} is not valid JSON: ${String(err)}`);
|
|
1263
|
+
}
|
|
1264
|
+
const jar = parsed as Partial<CookieJarFile>;
|
|
1265
|
+
if (typeof jar !== "object" || jar === null) {
|
|
1266
|
+
throw new Error(`[mochi] cookies.load: ${path} is not a JSON object`);
|
|
1267
|
+
}
|
|
1268
|
+
if (jar.version !== COOKIE_JAR_FORMAT_VERSION) {
|
|
1269
|
+
throw new Error(
|
|
1270
|
+
`[mochi] cookies.load: ${path} version ${String(jar.version)} is not supported (expected ${COOKIE_JAR_FORMAT_VERSION})`,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (!Array.isArray(jar.cookies)) {
|
|
1274
|
+
throw new Error(`[mochi] cookies.load: ${path} has no \`cookies\` array`);
|
|
1275
|
+
}
|
|
1276
|
+
// Filter on load too: a single saved-with-everything jar can be sliced
|
|
1277
|
+
// domain-wise without re-saving.
|
|
1278
|
+
const toLoad = jar.cookies.filter((c) => pattern.test(c.domain));
|
|
1279
|
+
if (toLoad.length === 0) return;
|
|
1280
|
+
await router.send("Storage.setCookies", { cookies: toLoad });
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
}
|