@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/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
@@ -0,0 +1,2 @@
1
+ /** Single source of truth for the @mochi.js/core package version string. */
2
+ export const VERSION = "0.0.1" as const;