@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 +6 -5
- package/src/__tests__/proxy-auth.test.ts +2 -2
- package/src/index.ts +1 -0
- package/src/launch.ts +46 -0
- package/src/page.ts +49 -0
- package/src/proxy-auth.ts +36 -19
- package/src/session.ts +57 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mochi.js/core",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
53
|
-
"@mochi.js/
|
|
54
|
-
"@mochi.js/
|
|
55
|
-
"@mochi.js/
|
|
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
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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: [
|
|
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
|
|
153
|
-
*
|
|
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
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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
|
|
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.
|