@mochi.js/core 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mochi.js/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Bun-native browser automation framework — relational fingerprint locking, zero-jitter injection, behavioral playback. The primary entry point.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,10 +49,11 @@
49
49
  "build": "echo 'no build step yet — Bun consumes src/ directly'"
50
50
  },
51
51
  "dependencies": {
52
- "@mochi.js/behavioral": "workspace:*",
53
- "@mochi.js/consistency": "workspace:*",
54
- "@mochi.js/inject": "workspace:*",
55
- "@mochi.js/net": "workspace:*"
52
+ "@mochi.js/behavioral": "^0.1.1",
53
+ "@mochi.js/challenges": "^0.2.0",
54
+ "@mochi.js/consistency": "^0.1.0",
55
+ "@mochi.js/inject": "^0.1.1",
56
+ "@mochi.js/net": "^0.1.1"
56
57
  },
57
58
  "publishConfig": {
58
59
  "access": "public"
@@ -6,7 +6,7 @@
6
6
  * creds, empty password).
7
7
  *
8
8
  * `installProxyAuth`: drives a fake CDP router. Verifies:
9
- * - `Fetch.enable` is sent with `handleAuthRequests: true, patterns: []`.
9
+ * - `Fetch.enable` is sent with `handleAuthRequests: true, patterns: [{ urlPattern: "*" }]`.
10
10
  * - `Fetch.authRequired` events trigger `Fetch.continueWithAuth` carrying
11
11
  * the configured creds.
12
12
  * - The defensive `Fetch.requestPaused` handler issues `Fetch.continueRequest`.
@@ -193,7 +193,7 @@ describe("installProxyAuth", () => {
193
193
  const handle = await installProxyAuth(f.router, { username: "u", password: "p" });
194
194
  const enable = f.written.find((c) => c.method === "Fetch.enable");
195
195
  expect(enable).toBeDefined();
196
- expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [] });
196
+ expect(enable?.params).toEqual({ handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
197
197
  await handle.dispose();
198
198
  await f.router.close();
199
199
  });
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  export { NotImplementedError } from "./errors";
24
24
  // Public surface — exported here so users only need `@mochi.js/core`.
25
25
  export {
26
+ type ChallengeLaunchOptions,
26
27
  type LaunchOptions,
27
28
  launch,
28
29
  type Mochi,
package/src/launch.ts CHANGED
@@ -27,6 +27,38 @@ export interface ProxyConfig {
27
27
  password?: string;
28
28
  }
29
29
 
30
+ /**
31
+ * Per-challenge convenience options surfaced via `LaunchOptions.challenges`.
32
+ *
33
+ * v0.2 implements `turnstile.autoClick` only. Other entries (hCaptcha,
34
+ * reCAPTCHA, etc.) are reserved for v0.3+ — see `@mochi.js/challenges`
35
+ * README.
36
+ *
37
+ * When `turnstile.autoClick: true`, the `Session` calls
38
+ * `installTurnstileAutoClick(page)` on every page returned by `newPage`.
39
+ * The handle is disposed automatically on page close.
40
+ *
41
+ * The full `TurnstileOptions` (timeout / humanize / onSolved / onEscalation)
42
+ * are passed through unchanged. See
43
+ * `@mochi.js/challenges#TurnstileOptions`.
44
+ */
45
+ export interface ChallengeLaunchOptions {
46
+ turnstile?: {
47
+ /** When `true`, auto-install Turnstile detection + click on every newPage. */
48
+ autoClick?: boolean;
49
+ /** Override the per-widget post-click timeout (ms). Default 30_000. */
50
+ timeout?: number;
51
+ /** When `false`, use a fast non-humanized click path. Default `true`. */
52
+ humanize?: boolean;
53
+ /** Fired when a widget reports a token. */
54
+ onSolved?: (token: string) => void;
55
+ /** Fired on image-challenge / managed-variant / timeout. */
56
+ onEscalation?: (reason: "image-challenge" | "managed" | "timeout") => void;
57
+ /** Override the DOM-poll cadence (ms). Default 500. */
58
+ pollIntervalMs?: number;
59
+ };
60
+ }
61
+
30
62
  /**
31
63
  * Options accepted by `mochi.launch`.
32
64
  *
@@ -75,6 +107,19 @@ export interface LaunchOptions {
75
107
  * Chromium); task 0040.
76
108
  */
77
109
  bypassInject?: boolean;
110
+ /**
111
+ * Convenience layer toggles for common bot-defense widgets. When
112
+ * `challenges.turnstile.autoClick` is `true`, every page returned by
113
+ * `Session.newPage` has `installTurnstileAutoClick(page, opts)` wired
114
+ * automatically — the Bezier+Fitts behavioral synth handles the click,
115
+ * an optional `onSolved` callback fires when the response token appears,
116
+ * and `onEscalation` fires on image-challenge / managed-variant / timeout.
117
+ *
118
+ * See `@mochi.js/challenges` for the full surface and the limits page
119
+ * for the v0.2 scope (visible-checkbox variants only — image/audio
120
+ * solving is v0.3+).
121
+ */
122
+ challenges?: ChallengeLaunchOptions;
78
123
  }
79
124
 
80
125
  /**
@@ -112,6 +157,7 @@ export async function launch(opts: LaunchOptions): Promise<Session> {
112
157
  // `@mochi.js/net` (wreq) accepts the full `user:pass@host` URL form.
113
158
  ...(normalized !== undefined ? { netProxy: normalized.netProxy } : {}),
114
159
  ...(normalized?.auth !== undefined ? { proxyAuth: normalized.auth } : {}),
160
+ ...(opts.challenges !== undefined ? { challenges: opts.challenges } : {}),
115
161
  });
116
162
  return session;
117
163
  }
package/src/page.ts CHANGED
@@ -352,6 +352,55 @@ export class Page {
352
352
  return result.cookies;
353
353
  }
354
354
 
355
+ /**
356
+ * Install an additional main-world script that runs on every new document
357
+ * via `Page.addScriptToEvaluateOnNewDocument({ runImmediately: true,
358
+ * worldName: "" })`. Returns the CDP identifier so callers can later
359
+ * remove it via {@link removeInitScript}.
360
+ *
361
+ * `worldName: ""` is critical — any non-empty string creates an isolated
362
+ * world (PLAN.md §8.4) which is detectable. `runImmediately: true` ensures
363
+ * the script also runs against the current document if one already exists,
364
+ * not just on the next navigation.
365
+ *
366
+ * Use cases:
367
+ * - The `@mochi.js/challenges` Turnstile detector (mounts a
368
+ * `MutationObserver` + Symbol-keyed reader on `document` in the page's
369
+ * main world, before any page script runs).
370
+ * - Any future per-page convenience layer that needs main-world
371
+ * mutation observation.
372
+ *
373
+ * The session-level inject payload is installed separately on every
374
+ * `newPage()` and is NOT routed through this method — convenience-layer
375
+ * scripts compose on top of it.
376
+ */
377
+ async addInitScript(source: string): Promise<string> {
378
+ this.assertOpen();
379
+ const result = await this.send<{ identifier: string }>(
380
+ "Page.addScriptToEvaluateOnNewDocument",
381
+ {
382
+ source,
383
+ runImmediately: true,
384
+ worldName: "",
385
+ },
386
+ );
387
+ return result.identifier;
388
+ }
389
+
390
+ /**
391
+ * Remove a previously-installed init script by its identifier (returned
392
+ * from {@link addInitScript}). Best-effort — silently ignores failures
393
+ * (e.g. the target was already closed).
394
+ */
395
+ async removeInitScript(identifier: string): Promise<void> {
396
+ if (this.closed) return;
397
+ try {
398
+ await this.send("Page.removeScriptToEvaluateOnNewDocument", { identifier });
399
+ } catch {
400
+ // Ignore — target might already be gone.
401
+ }
402
+ }
403
+
355
404
  /** Tear down the page. Does not close the session's other pages. */
356
405
  async close(): Promise<void> {
357
406
  if (this.closed) return;
package/src/proxy-auth.ts CHANGED
@@ -12,9 +12,14 @@
12
12
  * stealth invariants.
13
13
  *
14
14
  * The CDP path is invariant-clean: enable `Fetch` with `handleAuthRequests`
15
- * and *empty* request patterns. Chromium fires `Fetch.authRequired` ONLY for
16
- * proxy auth challenges; regular request flow is unaffected (no
17
- * `Fetch.requestPaused` events when patterns is `[]`). We answer with
15
+ * AND a wildcard pattern. Chromium rejects `patterns: []` when
16
+ * `handleAuthRequests: true` (`-32602 Can't specify empty patterns with
17
+ * handleAuth set`, verified on CfT linux ~2026-05) the original 0160
18
+ * design assumed empty patterns would only fire `Fetch.authRequired`
19
+ * events, but modern Chromium requires at least one URL pattern when auth
20
+ * handling is on. We use `[{ urlPattern: "*" }]` and forward every paused
21
+ * request immediately via `Fetch.continueRequest`. The auth challenges
22
+ * separately fire `Fetch.authRequired`; we answer those with
18
23
  * `Fetch.continueWithAuth` carrying the parsed credentials.
19
24
  *
20
25
  * PLAN.md §8.2 invariant check
@@ -25,9 +30,11 @@
25
30
  * isolated world) are forbidden. `Fetch.enable` operates at the network
26
31
  * layer below page script — it does not produce execution-context-creation
27
32
  * events, does not surface a `chrome.devtools` global, and is not
28
- * detectable from page JavaScript. The defensive `Fetch.requestPaused`
29
- * handler below is unreachable when `patterns: []` is set, but registered
30
- * as belt-and-braces in case a Chromium quirk triggers a pause.
33
+ * detectable from page JavaScript. With `patterns: [{urlPattern: "*"}]`
34
+ * every request pauses for one CDP round-trip before continuing that's
35
+ * a measurable but bounded overhead (sub-ms per request on modern
36
+ * hardware) and only active on sessions with proxy auth credentials
37
+ * (the function early-returns when `auth` is undefined).
31
38
  *
32
39
  * Protocols
33
40
  * ---------
@@ -146,17 +153,20 @@ export interface ProxyAuthHandle {
146
153
  * any protocol surface for sessions that don't need it.
147
154
  *
148
155
  * Behavior:
149
- * - Sends `Fetch.enable { handleAuthRequests: true, patterns: [] }` once.
156
+ * - Sends `Fetch.enable { handleAuthRequests: true, patterns: [{
157
+ * urlPattern: "*" }] }` once.
150
158
  * - On `Fetch.authRequired`, replies with `Fetch.continueWithAuth` and
151
159
  * the parsed creds.
152
- * - On `Fetch.requestPaused` (defensive — should never fire with empty
153
- * patterns), forwards `Fetch.continueRequest` so we don't hang.
160
+ * - On `Fetch.requestPaused`, forwards `Fetch.continueRequest`
161
+ * immediately so the network model stays unchanged (every request
162
+ * still flows; we just take one CDP round-trip to wave it through).
154
163
  *
155
- * The empty `patterns` array is critical: any non-empty patterns turn
156
- * Chromium into an interception proxy for matching requests, which tanks
157
- * page perf and changes the network model. Empty patterns +
158
- * `handleAuthRequests: true` is the documented contract for "auth-only
159
- * interception".
164
+ * Why wildcard patterns instead of empty: modern Chromium (CfT linux
165
+ * ~2026-05) rejects `patterns: []` when `handleAuthRequests: true` is set
166
+ * with `-32602 Can't specify empty patterns with handleAuth set`. The
167
+ * wildcard plus an immediate-continue handler is the equivalent of
168
+ * "auth-only interception" with one extra round-trip per request — only
169
+ * active on proxy-authed sessions.
160
170
  */
161
171
  export async function installProxyAuth(
162
172
  router: MessageRouter,
@@ -186,22 +196,29 @@ export async function installProxyAuth(
186
196
  });
187
197
  });
188
198
 
189
- // Defensive `patterns: []` means this event should never fire, but
190
- // some Chromium builds may pause requests adjacent to auth challenges.
191
- // If it ever fires, immediately continue so we don't hang the request.
199
+ // Pattern is REQUIRED with handleAuthRequests: true. Modern Chromium
200
+ // rejects an empty `patterns` array with `-32602 Can't specify empty
201
+ // patterns with handleAuth set` (verified on CfT linux ~2026-05). Use
202
+ // a wildcard pattern so every request paus es, then immediately
203
+ // forward in the requestPaused handler below — that gets us auth
204
+ // challenge interception without altering the user-visible network
205
+ // model. The per-request CDP round-trip is real overhead but only
206
+ // active when the session has proxy auth credentials (this whole
207
+ // function early-returns when `auth` is undefined), so non-proxied
208
+ // sessions pay zero cost.
192
209
  const offPaused: Unsubscribe = router.on("Fetch.requestPaused", (params) => {
193
210
  const requestId = (params as { requestId?: string } | null)?.requestId;
194
211
  if (typeof requestId !== "string") return;
195
212
  router.send("Fetch.continueRequest", { requestId }).catch((err: unknown) => {
196
213
  if (!isClosedError(err)) {
197
- console.warn("[mochi] Fetch.continueRequest (defensive) failed:", err);
214
+ console.warn("[mochi] Fetch.continueRequest failed:", err);
198
215
  }
199
216
  });
200
217
  });
201
218
 
202
219
  await router.send("Fetch.enable", {
203
220
  handleAuthRequests: true,
204
- patterns: [],
221
+ patterns: [{ urlPattern: "*" }],
205
222
  });
206
223
 
207
224
  let disposed = false;
package/src/session.ts CHANGED
@@ -12,6 +12,11 @@
12
12
  * @see PLAN.md §7
13
13
  */
14
14
 
15
+ import {
16
+ type Disposable as ChallengeHandle,
17
+ installTurnstileAutoClick,
18
+ type TurnstileEscalationReason,
19
+ } from "@mochi.js/challenges";
15
20
  import type { MatrixV1 } from "@mochi.js/consistency";
16
21
  import { buildPayload, type PayloadResult } from "@mochi.js/inject";
17
22
  import {
@@ -82,6 +87,23 @@ export interface SessionInit {
82
87
  * @internal
83
88
  */
84
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
+ };
85
107
  }
86
108
 
87
109
  /** Public Cookie shape (re-exported from page.ts). */
@@ -153,6 +175,15 @@ export class Session {
153
175
  * `Session.close`. Undefined when the session has no proxy auth.
154
176
  */
155
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[] = [];
156
187
 
157
188
  constructor(init: SessionInit) {
158
189
  this.proc = init.proc;
@@ -161,6 +192,7 @@ export class Session {
161
192
  this.bypassInject = init.bypassInject === true;
162
193
  this.netProxy = init.netProxy;
163
194
  this.netAdapter = init.netAdapter ?? defaultNetAdapter;
195
+ this.challengesOpts = init.challenges;
164
196
  // Skip payload compilation entirely when bypassed — capture flows must
165
197
  // not pay the build cost AND must not see the matrix-derived bytes.
166
198
  this._payload = this.bypassInject ? null : buildPayload(init.matrix);
@@ -259,6 +291,21 @@ export class Session {
259
291
  },
260
292
  });
261
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
+ }
262
309
  return page;
263
310
  }
264
311
 
@@ -388,6 +435,16 @@ export class Session {
388
435
  async close(): Promise<void> {
389
436
  if (this.closed) return;
390
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;
391
448
  // Mark all pages as closed (they'll error on further use).
392
449
  for (const p of this._pages) {
393
450
  // close() is idempotent on Page.