@mochi.js/core 0.0.1 → 0.1.2
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 +3 -3
- package/package.json +11 -4
- package/src/__tests__/behavioral.e2e.test.ts +200 -0
- package/src/__tests__/binary.test.ts +89 -0
- package/src/__tests__/forbidden.test.ts +80 -0
- package/src/__tests__/framer.test.ts +92 -0
- package/src/__tests__/inject.e2e.test.ts +253 -0
- package/src/__tests__/inject.test.ts +276 -0
- package/src/__tests__/integration.e2e.test.ts +60 -0
- package/src/__tests__/proxy-auth.test.ts +253 -0
- package/src/__tests__/router.test.ts +193 -0
- package/src/__tests__/smoke.test.ts +11 -5
- package/src/binary.ts +129 -0
- package/src/cdp/forbidden.ts +102 -0
- package/src/cdp/framer.ts +79 -0
- package/src/cdp/router.ts +240 -0
- package/src/cdp/transport.ts +167 -0
- package/src/cdp/types.ts +152 -0
- package/src/errors.ts +23 -0
- package/src/index.ts +46 -39
- package/src/launch.ts +282 -0
- package/src/page.ts +979 -0
- package/src/proc.ts +213 -0
- package/src/proxy-auth.ts +252 -0
- package/src/session.ts +638 -0
- package/src/version.ts +2 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Session` — the per-(profile, seed) browser lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Owns one Chromium process, one CDP transport+router, and one or more
|
|
5
|
+
* `Page` objects. Closing the session kills the browser and removes the
|
|
6
|
+
* ephemeral user-data-dir. PLAN.md §5.1 / §7.
|
|
7
|
+
*
|
|
8
|
+
* v0.2 exposes a real, relationally-locked `MatrixV1` derived by
|
|
9
|
+
* `@mochi.js/consistency.deriveMatrix(profile, seed)`. The Matrix is
|
|
10
|
+
* deterministic per `(profile, seed)` (excluding `derivedAt`).
|
|
11
|
+
*
|
|
12
|
+
* @see PLAN.md §7
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type Disposable as ChallengeHandle,
|
|
17
|
+
installTurnstileAutoClick,
|
|
18
|
+
type TurnstileEscalationReason,
|
|
19
|
+
} from "@mochi.js/challenges";
|
|
20
|
+
import type { MatrixV1 } from "@mochi.js/consistency";
|
|
21
|
+
import { buildPayload, type PayloadResult } from "@mochi.js/inject";
|
|
22
|
+
import {
|
|
23
|
+
openCtx as defaultOpenCtx,
|
|
24
|
+
requestOnCtx as defaultRequestOnCtx,
|
|
25
|
+
type NetCtx,
|
|
26
|
+
type NetFetchInit,
|
|
27
|
+
} from "@mochi.js/net";
|
|
28
|
+
import { MessageRouter } from "./cdp/router";
|
|
29
|
+
import type { AttachedToTargetEvent } from "./cdp/types";
|
|
30
|
+
import { Page } from "./page";
|
|
31
|
+
import type { ChromiumProcess } from "./proc";
|
|
32
|
+
import { installProxyAuth, type ProxyAuthHandle } from "./proxy-auth";
|
|
33
|
+
import { VERSION } from "./version";
|
|
34
|
+
|
|
35
|
+
/**
|
|
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`.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export interface NetAdapter {
|
|
43
|
+
openCtx(spec: { preset: string; proxy?: string }): NetCtx;
|
|
44
|
+
requestOnCtx(ctx: NetCtx, url: string, init: NetFetchInit): Response;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const defaultNetAdapter: NetAdapter = {
|
|
48
|
+
openCtx: defaultOpenCtx,
|
|
49
|
+
requestOnCtx: defaultRequestOnCtx,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface SessionInit {
|
|
53
|
+
proc: ChromiumProcess;
|
|
54
|
+
matrix: MatrixV1;
|
|
55
|
+
seed: string;
|
|
56
|
+
/** Optional overrides for the underlying message-router timeout. */
|
|
57
|
+
defaultTimeoutMs?: number;
|
|
58
|
+
/**
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
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
|
+
/**
|
|
73
|
+
* Optional proxy credentials. When set, the Session attaches a CDP
|
|
74
|
+
* `Fetch.authRequired` listener so HTTP / SOCKS5 proxy auth challenges
|
|
75
|
+
* are answered transparently. Undefined when no proxy is configured or
|
|
76
|
+
* the proxy doesn't require auth — in that case `Fetch.enable` is never
|
|
77
|
+
* sent and the protocol surface stays untouched.
|
|
78
|
+
*
|
|
79
|
+
* @see proxy-auth.ts for the §8.2 invariant rationale.
|
|
80
|
+
*/
|
|
81
|
+
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
|
+
/**
|
|
91
|
+
* Convenience layer toggles surfaced via
|
|
92
|
+
* `LaunchOptions.challenges`. When `challenges.turnstile.autoClick` is
|
|
93
|
+
* `true`, every page returned by `Session.newPage` has
|
|
94
|
+
* `installTurnstileAutoClick(page, opts)` wired automatically.
|
|
95
|
+
* See `@mochi.js/challenges`.
|
|
96
|
+
*/
|
|
97
|
+
challenges?: {
|
|
98
|
+
turnstile?: {
|
|
99
|
+
autoClick?: boolean;
|
|
100
|
+
timeout?: number;
|
|
101
|
+
humanize?: boolean;
|
|
102
|
+
onSolved?: (token: string) => void;
|
|
103
|
+
onEscalation?: (reason: TurnstileEscalationReason) => void;
|
|
104
|
+
pollIntervalMs?: number;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Public Cookie shape (re-exported from page.ts). */
|
|
110
|
+
export type { Cookie } from "./page";
|
|
111
|
+
|
|
112
|
+
/** Storage snapshot — placeholder shape; full surface lands later. */
|
|
113
|
+
export interface StorageSnapshot {
|
|
114
|
+
cookies: import("./page").Cookie[];
|
|
115
|
+
/** localStorage entries, keyed by origin. v0.1: empty placeholder. */
|
|
116
|
+
localStorage: Record<string, Record<string, string>>;
|
|
117
|
+
/** sessionStorage entries, keyed by origin. v0.1: empty placeholder. */
|
|
118
|
+
sessionStorage: Record<string, Record<string, string>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class Session {
|
|
122
|
+
/**
|
|
123
|
+
* The resolved Matrix for this session — a relationally-locked snapshot
|
|
124
|
+
* of `(profile, seed)` produced by `@mochi.js/consistency.deriveMatrix`.
|
|
125
|
+
*/
|
|
126
|
+
readonly profile: MatrixV1;
|
|
127
|
+
readonly seed: string;
|
|
128
|
+
|
|
129
|
+
private readonly proc: ChromiumProcess;
|
|
130
|
+
private readonly router: MessageRouter;
|
|
131
|
+
private readonly _pages: Page[] = [];
|
|
132
|
+
private closed = false;
|
|
133
|
+
/**
|
|
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`.
|
|
143
|
+
*/
|
|
144
|
+
private netCtx: NetCtx | undefined;
|
|
145
|
+
/**
|
|
146
|
+
* Pluggable seam for the network FFI. Defaults to `@mochi.js/net`.
|
|
147
|
+
* Tests inject a stub here.
|
|
148
|
+
*
|
|
149
|
+
* @internal
|
|
150
|
+
*/
|
|
151
|
+
private readonly netAdapter: NetAdapter;
|
|
152
|
+
/**
|
|
153
|
+
* The compiled inject payload for this session. Built once at construction
|
|
154
|
+
* from the resolved {@link MatrixV1}; reused across every new page and
|
|
155
|
+
* every auto-attached worker target. PLAN.md §5.3 / §8.4.
|
|
156
|
+
*
|
|
157
|
+
* `null` when {@link SessionInit.bypassInject} is `true` (PLAN.md §12.1):
|
|
158
|
+
* the capture flow needs the bare browser fingerprint, so we skip both
|
|
159
|
+
* the build and the per-page install.
|
|
160
|
+
*
|
|
161
|
+
* @internal — exposed via {@link _internalPayload} for tests/diagnostics.
|
|
162
|
+
*/
|
|
163
|
+
private readonly _payload: PayloadResult | null;
|
|
164
|
+
/**
|
|
165
|
+
* 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.
|
|
168
|
+
*
|
|
169
|
+
* @internal
|
|
170
|
+
*/
|
|
171
|
+
private readonly bypassInject: boolean;
|
|
172
|
+
/**
|
|
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.
|
|
176
|
+
*/
|
|
177
|
+
private proxyAuthHandle: ProxyAuthHandle | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* Snapshot of the `challenges` launch option, retained so
|
|
180
|
+
* {@link newPage} can install the per-page auto-click handler. Undefined
|
|
181
|
+
* when no challenge convenience layer is enabled. Each page gets its
|
|
182
|
+
* own {@link ChallengeHandle} tracked here for disposal on
|
|
183
|
+
* {@link close}.
|
|
184
|
+
*/
|
|
185
|
+
private readonly challengesOpts: SessionInit["challenges"] | undefined;
|
|
186
|
+
private readonly challengeHandles: ChallengeHandle[] = [];
|
|
187
|
+
|
|
188
|
+
constructor(init: SessionInit) {
|
|
189
|
+
this.proc = init.proc;
|
|
190
|
+
this.profile = init.matrix;
|
|
191
|
+
this.seed = init.seed;
|
|
192
|
+
this.bypassInject = init.bypassInject === true;
|
|
193
|
+
this.netProxy = init.netProxy;
|
|
194
|
+
this.netAdapter = init.netAdapter ?? defaultNetAdapter;
|
|
195
|
+
this.challengesOpts = init.challenges;
|
|
196
|
+
// Skip payload compilation entirely when bypassed — capture flows must
|
|
197
|
+
// not pay the build cost AND must not see the matrix-derived bytes.
|
|
198
|
+
this._payload = this.bypassInject ? null : buildPayload(init.matrix);
|
|
199
|
+
this.router = new MessageRouter(this.proc.reader, this.proc.writer, {
|
|
200
|
+
defaultTimeoutMs: init.defaultTimeoutMs,
|
|
201
|
+
});
|
|
202
|
+
this.router.start();
|
|
203
|
+
this.installAutoAttach();
|
|
204
|
+
this.installCrashGuard();
|
|
205
|
+
// Wire CDP-driven proxy auth only when credentials were supplied. The
|
|
206
|
+
// no-auth path skips Fetch.enable entirely so we don't pay the
|
|
207
|
+
// protocol-attach cost or surface any extra CDP traffic.
|
|
208
|
+
if (init.proxyAuth !== undefined) {
|
|
209
|
+
// Fire-and-forget: surface failures via console.warn but don't reject
|
|
210
|
+
// the constructor — pages still launch and unauthenticated traffic
|
|
211
|
+
// will simply 407, giving callers a recoverable signal.
|
|
212
|
+
void installProxyAuth(this.router, init.proxyAuth)
|
|
213
|
+
.then((handle) => {
|
|
214
|
+
if (this.closed) {
|
|
215
|
+
void handle.dispose();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.proxyAuthHandle = handle;
|
|
219
|
+
})
|
|
220
|
+
.catch((err: unknown) => {
|
|
221
|
+
if (!this.closed) {
|
|
222
|
+
console.warn("[mochi] proxy-auth installation failed:", err);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Open a new page. Internally:
|
|
230
|
+
* 1. `Target.createTarget` opens a new browser tab.
|
|
231
|
+
* 2. `Target.attachToTarget({ flatten: true })` returns a flat-mode session
|
|
232
|
+
* id we'll use to address page-level CDP methods.
|
|
233
|
+
* 3. `Page.addScriptToEvaluateOnNewDocument({ source, runImmediately: true,
|
|
234
|
+
* worldName: "" })` installs the inject payload to run main-world,
|
|
235
|
+
* before any page script, on every navigation. The returned identifier
|
|
236
|
+
* is tracked on the {@link Page} so it can be removed on close.
|
|
237
|
+
* Critical: `worldName: ""` — any non-empty string creates an isolated
|
|
238
|
+
* world (PLAN.md §8.4) which is detectable.
|
|
239
|
+
*
|
|
240
|
+
* `flatten: true` is critical — without it, page CDP messages would need to
|
|
241
|
+
* be wrapped in `Target.sendMessageToTarget` envelopes. Flat mode lets us
|
|
242
|
+
* just attach `sessionId` to the request.
|
|
243
|
+
*/
|
|
244
|
+
async newPage(): Promise<Page> {
|
|
245
|
+
this.assertOpen();
|
|
246
|
+
const created = await this.router.send<{ targetId: string }>("Target.createTarget", {
|
|
247
|
+
url: "about:blank",
|
|
248
|
+
});
|
|
249
|
+
const attached = await this.router.send<{ sessionId: string }>("Target.attachToTarget", {
|
|
250
|
+
targetId: created.targetId,
|
|
251
|
+
flatten: true,
|
|
252
|
+
});
|
|
253
|
+
// Page.enable is required for lifecycle events but does NOT trip §8.2
|
|
254
|
+
// (only Runtime.enable is forbidden). We enable here so subsequent
|
|
255
|
+
// addScriptToEvaluateOnNewDocument is honoured by the page domain.
|
|
256
|
+
await this.router.send("Page.enable", undefined, { sessionId: attached.sessionId });
|
|
257
|
+
// PLAN.md §12.1 / task 0040 — capture flow short-circuits inject so the
|
|
258
|
+
// browser reports its bare fingerprint. Otherwise install the payload
|
|
259
|
+
// main-world via §8.4. worldName MUST be the empty string.
|
|
260
|
+
let injectScriptIdentifier: string | undefined;
|
|
261
|
+
if (!this.bypassInject && this._payload !== null) {
|
|
262
|
+
const installed = await this.router.send<{ identifier: string }>(
|
|
263
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
264
|
+
{
|
|
265
|
+
source: this._payload.code,
|
|
266
|
+
runImmediately: true,
|
|
267
|
+
worldName: "",
|
|
268
|
+
// includeCommandLineAPI defaults to false; we don't set it.
|
|
269
|
+
},
|
|
270
|
+
{ sessionId: attached.sessionId },
|
|
271
|
+
);
|
|
272
|
+
injectScriptIdentifier = installed.identifier;
|
|
273
|
+
}
|
|
274
|
+
const page = new Page({
|
|
275
|
+
router: this.router,
|
|
276
|
+
targetId: created.targetId,
|
|
277
|
+
sessionId: attached.sessionId,
|
|
278
|
+
initialUrl: "about:blank",
|
|
279
|
+
...(injectScriptIdentifier !== undefined ? { injectScriptIdentifier } : {}),
|
|
280
|
+
// PLAN.md I-5: behavior comes from MatrixV1.behavior (the matrix is
|
|
281
|
+
// the single source of truth — `Session.profile` is the resolved
|
|
282
|
+
// MatrixV1). Per-call opts may override individual fields.
|
|
283
|
+
behavior: this.profile.behavior,
|
|
284
|
+
seed: this.seed,
|
|
285
|
+
// Initial cursor at the display center — a real human's pointer is
|
|
286
|
+
// never at (0, 0). The matrix's display dimensions are the canonical
|
|
287
|
+
// source (PLAN.md I-5).
|
|
288
|
+
initialCursor: {
|
|
289
|
+
x: Math.floor(this.profile.display.width / 2),
|
|
290
|
+
y: Math.floor(this.profile.display.height / 2),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
this._pages.push(page);
|
|
294
|
+
// Wire the Turnstile auto-click convenience layer if the session was
|
|
295
|
+
// launched with `challenges.turnstile.autoClick: true`. The handle is
|
|
296
|
+
// tracked on the Session so it disposes on close (and the page-close
|
|
297
|
+
// path also cleans up via the disposable's idempotent dispose).
|
|
298
|
+
const ts = this.challengesOpts?.turnstile;
|
|
299
|
+
if (ts !== undefined && ts.autoClick === true) {
|
|
300
|
+
const tsOpts: Parameters<typeof installTurnstileAutoClick>[1] = {};
|
|
301
|
+
if (ts.timeout !== undefined) tsOpts.timeout = ts.timeout;
|
|
302
|
+
if (ts.humanize !== undefined) tsOpts.humanize = ts.humanize;
|
|
303
|
+
if (ts.onSolved !== undefined) tsOpts.onSolved = ts.onSolved;
|
|
304
|
+
if (ts.onEscalation !== undefined) tsOpts.onEscalation = ts.onEscalation;
|
|
305
|
+
if (ts.pollIntervalMs !== undefined) tsOpts.pollIntervalMs = ts.pollIntervalMs;
|
|
306
|
+
const handle = installTurnstileAutoClick(page, tsOpts);
|
|
307
|
+
this.challengeHandles.push(handle);
|
|
308
|
+
}
|
|
309
|
+
return page;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Snapshot of currently open pages. */
|
|
313
|
+
pages(): Page[] {
|
|
314
|
+
return [...this._pages];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* All cookies the browser is aware of, optionally filtered by url.
|
|
319
|
+
*
|
|
320
|
+
* Uses `Storage.getCookies` on the *root* browser target (the only domain
|
|
321
|
+
* that exposes a global cookie reader without a per-page Network domain).
|
|
322
|
+
*/
|
|
323
|
+
async cookies(filter: { url?: string } = {}): Promise<import("./page").Cookie[]> {
|
|
324
|
+
this.assertOpen();
|
|
325
|
+
const result = await this.router.send<{ cookies: import("./page").Cookie[] }>(
|
|
326
|
+
"Storage.getCookies",
|
|
327
|
+
);
|
|
328
|
+
if (filter.url === undefined) return result.cookies;
|
|
329
|
+
// v0.1 only supports a coarse host-string filter — full URL matching with
|
|
330
|
+
// path, secure, etc. is out of scope per the brief.
|
|
331
|
+
let host: string;
|
|
332
|
+
try {
|
|
333
|
+
host = new URL(filter.url).hostname;
|
|
334
|
+
} catch {
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
return result.cookies.filter((c) => c.domain.endsWith(host) || host.endsWith(c.domain));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Set cookies via the root-target Storage domain. */
|
|
341
|
+
async setCookies(cookies: import("./page").Cookie[]): Promise<void> {
|
|
342
|
+
this.assertOpen();
|
|
343
|
+
await this.router.send("Storage.setCookies", { cookies });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Storage snapshot. v0.1: cookies only. localStorage/sessionStorage are empty placeholders pending phase 0.7. */
|
|
347
|
+
async storage(): Promise<StorageSnapshot> {
|
|
348
|
+
this.assertOpen();
|
|
349
|
+
const c = await this.cookies();
|
|
350
|
+
return { cookies: c, localStorage: {}, sessionStorage: {} };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Out-of-band fetch — issues a request via the Rust `wreq` cdylib so the
|
|
355
|
+
* wire fingerprint matches the session's profile preset. The browser's
|
|
356
|
+
* own navigation/XHR/fetch are unaffected (they use Chromium's native
|
|
357
|
+
* TLS, which already matches a Chrome profile). Returns a standard Web
|
|
358
|
+
* `Response`. PLAN.md §5.4 / §10.
|
|
359
|
+
*
|
|
360
|
+
* Lazy: the per-Session `NetCtx` (Tokio runtime + wreq Client) is created
|
|
361
|
+
* on the first call and reused for subsequent calls. Closed on
|
|
362
|
+
* {@link close}.
|
|
363
|
+
*/
|
|
364
|
+
async fetch(url: string, init?: RequestInit): Promise<Response> {
|
|
365
|
+
this.assertOpen();
|
|
366
|
+
const ctx = this.ensureNetCtx();
|
|
367
|
+
const headers = this.headersToRecord(init?.headers);
|
|
368
|
+
const body = this.bodyToString(init?.body);
|
|
369
|
+
return this.netAdapter.requestOnCtx(ctx, url, {
|
|
370
|
+
method: init?.method ?? "GET",
|
|
371
|
+
headers,
|
|
372
|
+
body,
|
|
373
|
+
preset: this.profile.wreqPreset,
|
|
374
|
+
...(this.netProxy !== undefined ? { proxy: this.netProxy } : {}),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Lazy-create the per-Session Net Ctx (one Tokio runtime + wreq client). */
|
|
379
|
+
private ensureNetCtx(): NetCtx {
|
|
380
|
+
if (this.netCtx === undefined) {
|
|
381
|
+
this.netCtx = this.netAdapter.openCtx({
|
|
382
|
+
preset: this.profile.wreqPreset,
|
|
383
|
+
...(this.netProxy !== undefined ? { proxy: this.netProxy } : {}),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return this.netCtx;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Coerce a Web `Headers` / record / array-pair shape into a plain record. */
|
|
390
|
+
private headersToRecord(h: HeadersInit | undefined): Record<string, string> {
|
|
391
|
+
if (h === undefined) return {};
|
|
392
|
+
if (h instanceof Headers) {
|
|
393
|
+
const out: Record<string, string> = {};
|
|
394
|
+
h.forEach((v, k) => {
|
|
395
|
+
out[k] = v;
|
|
396
|
+
});
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
if (Array.isArray(h)) {
|
|
400
|
+
const out: Record<string, string> = {};
|
|
401
|
+
for (const pair of h) {
|
|
402
|
+
const k = pair[0];
|
|
403
|
+
const v = pair[1];
|
|
404
|
+
if (typeof k === "string" && typeof v === "string") out[k] = v;
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
return { ...(h as Record<string, string>) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Coerce a `RequestInit.body` to a UTF-8 string (the only shape the v0.6
|
|
413
|
+
* FFI surface accepts). `null`/`undefined` map to `null`. ArrayBuffer-style
|
|
414
|
+
* inputs are decoded as UTF-8; binary bodies are deferred per task brief.
|
|
415
|
+
*/
|
|
416
|
+
private bodyToString(b: BodyInit | null | undefined): string | null {
|
|
417
|
+
if (b === undefined || b === null) return null;
|
|
418
|
+
if (typeof b === "string") return b;
|
|
419
|
+
if (b instanceof ArrayBuffer) return new TextDecoder().decode(b);
|
|
420
|
+
if (ArrayBuffer.isView(b)) {
|
|
421
|
+
// Includes Uint8Array, Buffer, etc.
|
|
422
|
+
return new TextDecoder().decode(b as ArrayBufferView);
|
|
423
|
+
}
|
|
424
|
+
if (b instanceof URLSearchParams) return b.toString();
|
|
425
|
+
// Blob / FormData / ReadableStream — out of v0.6 scope.
|
|
426
|
+
throw new Error(
|
|
427
|
+
"[mochi] Session.fetch: only string, ArrayBuffer/View, and URLSearchParams bodies are supported in v0.6",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Close the session: tear down the router, kill Chromium (SIGTERM → 2s
|
|
433
|
+
* grace → SIGKILL), remove the user-data-dir. Idempotent.
|
|
434
|
+
*/
|
|
435
|
+
async close(): Promise<void> {
|
|
436
|
+
if (this.closed) return;
|
|
437
|
+
this.closed = true;
|
|
438
|
+
// Dispose any challenge convenience-layer handles first so background
|
|
439
|
+
// pollers stop before pages tear down their CDP sessions.
|
|
440
|
+
for (const h of this.challengeHandles) {
|
|
441
|
+
try {
|
|
442
|
+
h.dispose();
|
|
443
|
+
} catch {
|
|
444
|
+
// ignore — best-effort
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.challengeHandles.length = 0;
|
|
448
|
+
// Mark all pages as closed (they'll error on further use).
|
|
449
|
+
for (const p of this._pages) {
|
|
450
|
+
// close() is idempotent on Page.
|
|
451
|
+
try {
|
|
452
|
+
await p.close();
|
|
453
|
+
} catch {
|
|
454
|
+
// ignore — best-effort
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Tear down the per-Session Net Ctx if one was opened. `close()` is
|
|
458
|
+
// idempotent on the Net Ctx as well; calling on never-opened sessions
|
|
459
|
+
// is a no-op since `netCtx` stays undefined.
|
|
460
|
+
if (this.netCtx !== undefined) {
|
|
461
|
+
try {
|
|
462
|
+
this.netCtx.close();
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.warn("[mochi] net ctx close failed:", err);
|
|
465
|
+
}
|
|
466
|
+
this.netCtx = undefined;
|
|
467
|
+
}
|
|
468
|
+
// Drop the proxy-auth subscription + Fetch.disable BEFORE we tear down
|
|
469
|
+
// the router so the disable round-trip can still complete.
|
|
470
|
+
if (this.proxyAuthHandle !== undefined) {
|
|
471
|
+
try {
|
|
472
|
+
await this.proxyAuthHandle.dispose();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
console.warn("[mochi] proxy-auth dispose failed:", err);
|
|
475
|
+
}
|
|
476
|
+
this.proxyAuthHandle = undefined;
|
|
477
|
+
}
|
|
478
|
+
await this.router.close();
|
|
479
|
+
await this.proc.close();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Internal access to the router for tests (e.g. forbidden-method contract
|
|
484
|
+
* test). Not part of the public API surface.
|
|
485
|
+
*
|
|
486
|
+
* @internal
|
|
487
|
+
*/
|
|
488
|
+
_internalRouter(): MessageRouter {
|
|
489
|
+
return this.router;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Internal access to the user-data-dir path (for E2E cleanup verification).
|
|
494
|
+
* Not part of the public API surface.
|
|
495
|
+
*
|
|
496
|
+
* @internal
|
|
497
|
+
*/
|
|
498
|
+
_internalUserDataDir(): string {
|
|
499
|
+
return this.proc.userDataDir;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Internal access to the compiled inject payload (sha256 + code).
|
|
504
|
+
* Used by the contract test to pin the payload bytes per matrix.
|
|
505
|
+
*
|
|
506
|
+
* Returns `null` when the session was constructed with
|
|
507
|
+
* `bypassInject: true` — capture-style sessions never compile a payload
|
|
508
|
+
* (PLAN.md §12.1, task 0040).
|
|
509
|
+
*
|
|
510
|
+
* @internal
|
|
511
|
+
*/
|
|
512
|
+
_internalPayload(): PayloadResult | null {
|
|
513
|
+
return this._payload;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Whether this session has the inject pipeline disabled. True when
|
|
518
|
+
* constructed with `bypassInject: true` (e.g. `mochi capture`).
|
|
519
|
+
*
|
|
520
|
+
* @internal
|
|
521
|
+
*/
|
|
522
|
+
_internalBypassInject(): boolean {
|
|
523
|
+
return this.bypassInject;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The package version that produced this session — useful for diagnostics
|
|
528
|
+
* and for the stub MatrixV1 fields.
|
|
529
|
+
*
|
|
530
|
+
* @internal
|
|
531
|
+
*/
|
|
532
|
+
static readonly VERSION = VERSION;
|
|
533
|
+
|
|
534
|
+
// ---- internals --------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
private installAutoAttach(): void {
|
|
537
|
+
// PLAN.md §8.3: Target.setAutoAttach picks up workers/service-workers/
|
|
538
|
+
// audio-worklets/etc. We use waitForDebuggerOnStart so we can inject
|
|
539
|
+
// the payload BEFORE any worker script runs.
|
|
540
|
+
this.router
|
|
541
|
+
.send("Target.setAutoAttach", {
|
|
542
|
+
autoAttach: true,
|
|
543
|
+
waitForDebuggerOnStart: true,
|
|
544
|
+
flatten: true,
|
|
545
|
+
})
|
|
546
|
+
.catch((err: unknown) => {
|
|
547
|
+
// Suppress the noisy post-close race; surface real failures.
|
|
548
|
+
if (this.closed) return;
|
|
549
|
+
console.warn("[mochi] Target.setAutoAttach failed:", err);
|
|
550
|
+
});
|
|
551
|
+
this.router.on("Target.attachedToTarget", (params, sessionId) => {
|
|
552
|
+
const ev = params as AttachedToTargetEvent;
|
|
553
|
+
const childSessionId = sessionId ?? ev.sessionId;
|
|
554
|
+
void this.handleAttachedTarget(ev, childSessionId);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Inject the payload into a freshly-attached target if it's a worker-
|
|
560
|
+
* style target (dedicated worker, shared worker, service worker, audio
|
|
561
|
+
* worklet, etc.), then resume it.
|
|
562
|
+
*
|
|
563
|
+
* Worker targets do NOT support `Page.addScriptToEvaluateOnNewDocument`
|
|
564
|
+
* (no Page domain). PLAN.md §8.4 calls out that we use `Runtime.evaluate`
|
|
565
|
+
* against the paused worker session before issuing
|
|
566
|
+
* `Runtime.runIfWaitingForDebugger`. The §8.2 forbidden-method assertion
|
|
567
|
+
* does NOT trip because we never send `Runtime.enable` — only
|
|
568
|
+
* `Runtime.evaluate` against an already-paused worker target.
|
|
569
|
+
*
|
|
570
|
+
* Caveat: worker injection has a smaller stealth ceiling than main-
|
|
571
|
+
* world Page injection. Documented in `docs/limits.md`.
|
|
572
|
+
*/
|
|
573
|
+
private async handleAttachedTarget(
|
|
574
|
+
ev: AttachedToTargetEvent,
|
|
575
|
+
childSessionId: string,
|
|
576
|
+
): Promise<void> {
|
|
577
|
+
const targetType = ev.targetInfo.type;
|
|
578
|
+
const isWorkerLike =
|
|
579
|
+
targetType === "worker" ||
|
|
580
|
+
targetType === "service_worker" ||
|
|
581
|
+
targetType === "shared_worker" ||
|
|
582
|
+
targetType === "audio_worklet";
|
|
583
|
+
|
|
584
|
+
// PLAN.md §12.1 / task 0040 — capture flow skips worker injection too.
|
|
585
|
+
if (isWorkerLike && !this.bypassInject && this._payload !== null) {
|
|
586
|
+
try {
|
|
587
|
+
await this.router.send(
|
|
588
|
+
"Runtime.evaluate",
|
|
589
|
+
{
|
|
590
|
+
expression: this._payload.code,
|
|
591
|
+
awaitPromise: false,
|
|
592
|
+
returnByValue: false,
|
|
593
|
+
// includeCommandLineAPI must remain false (§8.2).
|
|
594
|
+
},
|
|
595
|
+
{ sessionId: childSessionId },
|
|
596
|
+
);
|
|
597
|
+
} catch (err: unknown) {
|
|
598
|
+
if (!this.closed) {
|
|
599
|
+
console.warn(`[mochi] payload inject into worker ${ev.targetInfo.targetId} failed:`, err);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (ev.waitingForDebugger) {
|
|
605
|
+
try {
|
|
606
|
+
await this.router.send("Runtime.runIfWaitingForDebugger", undefined, {
|
|
607
|
+
sessionId: childSessionId,
|
|
608
|
+
});
|
|
609
|
+
} catch (err: unknown) {
|
|
610
|
+
if (!this.closed) {
|
|
611
|
+
console.warn(
|
|
612
|
+
`[mochi] Runtime.runIfWaitingForDebugger on target ${ev.targetInfo.targetId} failed:`,
|
|
613
|
+
err,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private installCrashGuard(): void {
|
|
621
|
+
// If Chromium dies unexpectedly, we want to mark the session closed so
|
|
622
|
+
// pending and future calls reject cleanly.
|
|
623
|
+
this.proc.exited
|
|
624
|
+
.then(() => {
|
|
625
|
+
// Fire-and-forget — close() is idempotent.
|
|
626
|
+
void this.close();
|
|
627
|
+
})
|
|
628
|
+
.catch(() => {
|
|
629
|
+
void this.close();
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private assertOpen(): void {
|
|
634
|
+
if (this.closed) {
|
|
635
|
+
throw new Error("[mochi] session is closed");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
package/src/version.ts
ADDED