@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-coded list of CDP methods (and method+param combinations) that mochi
|
|
3
|
+
* MUST NEVER send to a Chromium target. These constraints are non-negotiable
|
|
4
|
+
* stealth invariants and the CDP transport enforces them with a synchronous
|
|
5
|
+
* assertion at `send()` time.
|
|
6
|
+
*
|
|
7
|
+
* @see PLAN.md §8.2 — "What we do NOT send"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Thrown synchronously by the CDP transport when calling code attempts to send
|
|
12
|
+
* a method that is on the forbidden list. The error references the PLAN.md §8.2
|
|
13
|
+
* line for the specific constraint so reviewers can trace the rule back to the
|
|
14
|
+
* design doc.
|
|
15
|
+
*/
|
|
16
|
+
export class ForbiddenCdpMethodError extends Error {
|
|
17
|
+
/** The CDP method name that was rejected. */
|
|
18
|
+
readonly method: string;
|
|
19
|
+
/** Human-readable rationale citing PLAN.md §8.2. */
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
|
|
22
|
+
constructor(method: string, reason: string) {
|
|
23
|
+
super(
|
|
24
|
+
`[mochi] forbidden CDP method "${method}" — ${reason} ` +
|
|
25
|
+
"(see PLAN.md §8.2). This is a stealth invariant; do not bypass.",
|
|
26
|
+
);
|
|
27
|
+
this.name = "ForbiddenCdpMethodError";
|
|
28
|
+
this.method = method;
|
|
29
|
+
this.reason = reason;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Methods that are forbidden unconditionally, regardless of params or target.
|
|
35
|
+
* @see PLAN.md §8.2
|
|
36
|
+
*/
|
|
37
|
+
const FORBIDDEN_METHODS: ReadonlyMap<string, string> = new Map([
|
|
38
|
+
[
|
|
39
|
+
"Runtime.enable",
|
|
40
|
+
"PLAN.md §8.2 line 1: 'Runtime.enable (any target). Detectable by error.stack lookup tricks.'",
|
|
41
|
+
],
|
|
42
|
+
[
|
|
43
|
+
"Page.createIsolatedWorld",
|
|
44
|
+
"PLAN.md §8.2 line 2: 'Page.createIsolatedWorld. Also detectable.' Use main-world (worldName: '') injection only.",
|
|
45
|
+
],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Methods that are forbidden only when called with specific param shapes. The
|
|
50
|
+
* predicate returns the violation reason (string) when params are forbidden, or
|
|
51
|
+
* `null` when the call is acceptable.
|
|
52
|
+
* @see PLAN.md §8.2 line 3
|
|
53
|
+
*/
|
|
54
|
+
const PARAM_FORBIDDEN: ReadonlyMap<string, (params: unknown) => string | null> = new Map([
|
|
55
|
+
[
|
|
56
|
+
"Runtime.evaluate",
|
|
57
|
+
(params: unknown): string | null => {
|
|
58
|
+
if (
|
|
59
|
+
params !== null &&
|
|
60
|
+
typeof params === "object" &&
|
|
61
|
+
"includeCommandLineAPI" in params &&
|
|
62
|
+
(params as { includeCommandLineAPI?: unknown }).includeCommandLineAPI === true
|
|
63
|
+
) {
|
|
64
|
+
return (
|
|
65
|
+
"PLAN.md §8.2 line 3: 'Runtime.evaluate with includeCommandLineAPI: true' " +
|
|
66
|
+
"(leaks $x, $_, etc. devtools globals to page script)."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Inspect a CDP request and throw `ForbiddenCdpMethodError` if it violates
|
|
76
|
+
* any §8.2 invariant. Called synchronously by the transport before serializing
|
|
77
|
+
* and before any I/O.
|
|
78
|
+
*
|
|
79
|
+
* Exported separately from the transport so tests and contract tests can
|
|
80
|
+
* exercise the invariant directly without spawning Chromium.
|
|
81
|
+
*/
|
|
82
|
+
export function assertNotForbidden(method: string, params?: unknown): void {
|
|
83
|
+
const flatReason = FORBIDDEN_METHODS.get(method);
|
|
84
|
+
if (flatReason !== undefined) {
|
|
85
|
+
throw new ForbiddenCdpMethodError(method, flatReason);
|
|
86
|
+
}
|
|
87
|
+
const paramCheck = PARAM_FORBIDDEN.get(method);
|
|
88
|
+
if (paramCheck !== undefined) {
|
|
89
|
+
const reason = paramCheck(params);
|
|
90
|
+
if (reason !== null) {
|
|
91
|
+
throw new ForbiddenCdpMethodError(method, reason);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The list of unconditionally-forbidden method names. Exported for tests and
|
|
98
|
+
* documentation tooling. Do not consume from product code.
|
|
99
|
+
*
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
export const FORBIDDEN_METHOD_NAMES: readonly string[] = Array.from(FORBIDDEN_METHODS.keys());
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-mode CDP framing.
|
|
3
|
+
*
|
|
4
|
+
* Chromium's `--remote-debugging-pipe` writes one CDP JSON message per record,
|
|
5
|
+
* each terminated by a single NUL byte (`\0`). The reader can deliver any
|
|
6
|
+
* chunk size — partial messages, multiple messages in one chunk, or a single
|
|
7
|
+
* message split across chunks. This framer buffers bytes until it sees a NUL,
|
|
8
|
+
* yields the bytes-before-the-NUL as one frame, and continues with the
|
|
9
|
+
* remainder.
|
|
10
|
+
*
|
|
11
|
+
* @see PLAN.md §8.1 (Transport)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const NUL = 0x00;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stateful streaming framer for NUL-delimited CDP records. Push raw byte
|
|
18
|
+
* chunks; receive complete frames (without the delimiter) in order.
|
|
19
|
+
*
|
|
20
|
+
* The buffer is unbounded — Chromium will not produce frames that exceed the
|
|
21
|
+
* caller's available memory in normal operation; if it does, the higher-level
|
|
22
|
+
* router timeout will surface the issue.
|
|
23
|
+
*/
|
|
24
|
+
export class CdpFramer {
|
|
25
|
+
private buffer: Uint8Array = new Uint8Array(0);
|
|
26
|
+
private decoder = new TextDecoder("utf-8", { fatal: false });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Append a chunk of bytes to the internal buffer and return any complete
|
|
30
|
+
* frames that became available. Frames are returned as decoded UTF-8 strings
|
|
31
|
+
* (CDP guarantees JSON; the framer does not validate).
|
|
32
|
+
*/
|
|
33
|
+
push(chunk: Uint8Array): string[] {
|
|
34
|
+
if (chunk.length === 0) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
// Concatenate. We could optimize with a ring buffer, but JSON-RPC over
|
|
38
|
+
// pipe never approaches the throughput where that matters.
|
|
39
|
+
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
40
|
+
next.set(this.buffer, 0);
|
|
41
|
+
next.set(chunk, this.buffer.length);
|
|
42
|
+
this.buffer = next;
|
|
43
|
+
|
|
44
|
+
const frames: string[] = [];
|
|
45
|
+
let start = 0;
|
|
46
|
+
for (let i = 0; i < this.buffer.length; i++) {
|
|
47
|
+
if (this.buffer[i] === NUL) {
|
|
48
|
+
const frameBytes = this.buffer.subarray(start, i);
|
|
49
|
+
if (frameBytes.length > 0) {
|
|
50
|
+
frames.push(this.decoder.decode(frameBytes));
|
|
51
|
+
}
|
|
52
|
+
start = i + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (start > 0) {
|
|
56
|
+
this.buffer = this.buffer.subarray(start);
|
|
57
|
+
}
|
|
58
|
+
return frames;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True iff the framer has no buffered bytes (i.e., no partial frame is in
|
|
63
|
+
* flight). Useful for graceful-shutdown assertions.
|
|
64
|
+
*/
|
|
65
|
+
get isEmpty(): boolean {
|
|
66
|
+
return this.buffer.length === 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Encode a CDP JSON-RPC string to its on-the-wire bytes (UTF-8 + trailing NUL).
|
|
72
|
+
*/
|
|
73
|
+
export function encodeFrame(json: string): Uint8Array {
|
|
74
|
+
const utf8 = new TextEncoder().encode(json);
|
|
75
|
+
const out = new Uint8Array(utf8.length + 1);
|
|
76
|
+
out.set(utf8, 0);
|
|
77
|
+
out[utf8.length] = NUL;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP message router.
|
|
3
|
+
*
|
|
4
|
+
* Sits between {@link CdpTransport} (raw frames) and the higher-level
|
|
5
|
+
* Session/Page code (typed `send` + event subscriptions). Owns:
|
|
6
|
+
*
|
|
7
|
+
* - request-id correlation: every `send(method, params)` returns a Promise
|
|
8
|
+
* that resolves with `result` or rejects with the CDP error.
|
|
9
|
+
* - per-method event dispatch: `on(method, handler)` / `off(method, handler)`.
|
|
10
|
+
* - per-call timeout: default 30s, override per request.
|
|
11
|
+
* - shutdown: rejects all pending requests with a stable error.
|
|
12
|
+
*
|
|
13
|
+
* The router does NOT spawn the browser, parse flags, or know about pages.
|
|
14
|
+
* It's a pure JSON-RPC dispatcher with a §8.2-aware transport underneath.
|
|
15
|
+
*
|
|
16
|
+
* @see PLAN.md §8
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { CdpTransport, type PipeReader, type PipeWriter } from "./transport";
|
|
20
|
+
import type { CdpRequest, CdpResponse, CdpSessionId } from "./types";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
/** A handler invoked when a CDP event arrives. */
|
|
25
|
+
export type CdpEventHandler = (params: unknown, sessionId?: CdpSessionId) => void;
|
|
26
|
+
|
|
27
|
+
/** A subscription token returned by `on()`; call to unsubscribe. */
|
|
28
|
+
export type Unsubscribe = () => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generic CDP error surfaced when a method returns `{error: ...}`.
|
|
32
|
+
*/
|
|
33
|
+
export class CdpRemoteError extends Error {
|
|
34
|
+
readonly method: string;
|
|
35
|
+
readonly code: number;
|
|
36
|
+
readonly data: unknown;
|
|
37
|
+
constructor(method: string, code: number, message: string, data?: unknown) {
|
|
38
|
+
super(`[mochi] CDP error from ${method} (${code}): ${message}`);
|
|
39
|
+
this.name = "CdpRemoteError";
|
|
40
|
+
this.method = method;
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.data = data;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown when the underlying child process exits unexpectedly while a CDP
|
|
48
|
+
* request is in flight, or when the pipe terminates abnormally.
|
|
49
|
+
*/
|
|
50
|
+
export class BrowserCrashedError extends Error {
|
|
51
|
+
override readonly cause?: Error;
|
|
52
|
+
constructor(message = "browser process exited or pipe closed unexpectedly", cause?: Error) {
|
|
53
|
+
super(`[mochi] ${message}`);
|
|
54
|
+
this.name = "BrowserCrashedError";
|
|
55
|
+
if (cause !== undefined) {
|
|
56
|
+
this.cause = cause;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Thrown when a CDP request exceeds its per-call timeout. */
|
|
62
|
+
export class CdpTimeoutError extends Error {
|
|
63
|
+
readonly method: string;
|
|
64
|
+
readonly timeoutMs: number;
|
|
65
|
+
constructor(method: string, timeoutMs: number) {
|
|
66
|
+
super(`[mochi] CDP method ${method} timed out after ${timeoutMs}ms`);
|
|
67
|
+
this.name = "CdpTimeoutError";
|
|
68
|
+
this.method = method;
|
|
69
|
+
this.timeoutMs = timeoutMs;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface PendingCall {
|
|
74
|
+
method: string;
|
|
75
|
+
resolve: (result: unknown) => void;
|
|
76
|
+
reject: (err: Error) => void;
|
|
77
|
+
timer: ReturnType<typeof setTimeout>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Optional knobs for `MessageRouter.send()`. */
|
|
81
|
+
export interface SendOptions {
|
|
82
|
+
/** Override the default 30s timeout. */
|
|
83
|
+
timeoutMs?: number;
|
|
84
|
+
/** Route to a sub-target session (worker, OOPIF). */
|
|
85
|
+
sessionId?: CdpSessionId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Couples a transport with request/response correlation + event dispatch.
|
|
90
|
+
*/
|
|
91
|
+
export class MessageRouter {
|
|
92
|
+
readonly transport: CdpTransport;
|
|
93
|
+
private readonly pending = new Map<number, PendingCall>();
|
|
94
|
+
private readonly handlers = new Map<string, Set<CdpEventHandler>>();
|
|
95
|
+
private readonly defaultTimeoutMs: number;
|
|
96
|
+
private closeCause: Error | undefined;
|
|
97
|
+
|
|
98
|
+
constructor(reader: PipeReader, writer: PipeWriter, opts: { defaultTimeoutMs?: number } = {}) {
|
|
99
|
+
this.defaultTimeoutMs = opts.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
100
|
+
this.transport = new CdpTransport(reader, writer, {
|
|
101
|
+
onFrame: (json) => this.dispatch(json),
|
|
102
|
+
onClose: (reason) => this.onTransportClosed(reason),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Begin reading from the underlying transport. */
|
|
107
|
+
start(): void {
|
|
108
|
+
this.transport.start();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Send a CDP method and resolve with its `result` payload. Rejects with
|
|
113
|
+
* {@link CdpRemoteError}, {@link CdpTimeoutError}, or
|
|
114
|
+
* {@link BrowserCrashedError} on failure modes.
|
|
115
|
+
*
|
|
116
|
+
* Calls {@link assertNotForbidden} via the transport before any I/O — for
|
|
117
|
+
* forbidden methods this rejects synchronously (well, on next microtask)
|
|
118
|
+
* with {@link ForbiddenCdpMethodError}.
|
|
119
|
+
*/
|
|
120
|
+
send<T = unknown>(method: string, params?: unknown, opts: SendOptions = {}): Promise<T> {
|
|
121
|
+
if (this.transport.isClosed) {
|
|
122
|
+
return Promise.reject(this.closeCause ?? new BrowserCrashedError("transport already closed"));
|
|
123
|
+
}
|
|
124
|
+
const id = this.transport.nextRequestId();
|
|
125
|
+
const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs;
|
|
126
|
+
const request: CdpRequest = { id, method, params };
|
|
127
|
+
if (opts.sessionId !== undefined) {
|
|
128
|
+
request.sessionId = opts.sessionId;
|
|
129
|
+
}
|
|
130
|
+
return new Promise<T>((resolve, reject) => {
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
const entry = this.pending.get(id);
|
|
133
|
+
if (entry !== undefined) {
|
|
134
|
+
this.pending.delete(id);
|
|
135
|
+
entry.reject(new CdpTimeoutError(method, timeoutMs));
|
|
136
|
+
}
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
this.pending.set(id, {
|
|
139
|
+
method,
|
|
140
|
+
resolve: resolve as (v: unknown) => void,
|
|
141
|
+
reject,
|
|
142
|
+
timer,
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
this.transport.send(request);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Synchronous rejection (e.g. ForbiddenCdpMethodError, closed pipe).
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this.pending.delete(id);
|
|
150
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Subscribe to CDP events of a given method name (e.g. `Page.frameNavigated`).
|
|
157
|
+
* Returns an unsubscribe function. Multiple handlers per method are supported.
|
|
158
|
+
*/
|
|
159
|
+
on(method: string, handler: CdpEventHandler): Unsubscribe {
|
|
160
|
+
let set = this.handlers.get(method);
|
|
161
|
+
if (set === undefined) {
|
|
162
|
+
set = new Set();
|
|
163
|
+
this.handlers.set(method, set);
|
|
164
|
+
}
|
|
165
|
+
set.add(handler);
|
|
166
|
+
return () => {
|
|
167
|
+
const current = this.handlers.get(method);
|
|
168
|
+
if (current !== undefined) {
|
|
169
|
+
current.delete(handler);
|
|
170
|
+
if (current.size === 0) this.handlers.delete(method);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Subscribe once; auto-unsubscribes after the first event. */
|
|
176
|
+
once(method: string, handler: CdpEventHandler): Unsubscribe {
|
|
177
|
+
const wrapped: CdpEventHandler = (params, sessionId) => {
|
|
178
|
+
unsubscribe();
|
|
179
|
+
handler(params, sessionId);
|
|
180
|
+
};
|
|
181
|
+
const unsubscribe = this.on(method, wrapped);
|
|
182
|
+
return unsubscribe;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Tear down. Pending calls reject with `BrowserCrashedError`. Idempotent. */
|
|
186
|
+
async close(reason?: Error): Promise<void> {
|
|
187
|
+
if (this.transport.isClosed) return;
|
|
188
|
+
await this.transport.close(reason);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Decode an incoming JSON frame and dispatch to pending caller or event listeners. */
|
|
192
|
+
private dispatch(json: string): void {
|
|
193
|
+
let msg: CdpResponse;
|
|
194
|
+
try {
|
|
195
|
+
msg = JSON.parse(json) as CdpResponse;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error("[mochi] failed to parse CDP frame:", err, json.slice(0, 200));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (typeof msg.id === "number") {
|
|
201
|
+
const entry = this.pending.get(msg.id);
|
|
202
|
+
if (entry === undefined) {
|
|
203
|
+
// Stale response (e.g. timed-out call). Drop silently.
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.pending.delete(msg.id);
|
|
207
|
+
clearTimeout(entry.timer);
|
|
208
|
+
if (msg.error !== undefined) {
|
|
209
|
+
entry.reject(
|
|
210
|
+
new CdpRemoteError(entry.method, msg.error.code, msg.error.message, msg.error.data),
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
entry.resolve(msg.result);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (typeof msg.method === "string") {
|
|
218
|
+
const set = this.handlers.get(msg.method);
|
|
219
|
+
if (set === undefined) return;
|
|
220
|
+
for (const handler of set) {
|
|
221
|
+
try {
|
|
222
|
+
handler(msg.params, msg.sessionId);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error(`[mochi] CDP event handler for ${msg.method} threw:`, err);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private onTransportClosed(reason?: Error): void {
|
|
231
|
+
const cause = reason ?? new BrowserCrashedError();
|
|
232
|
+
this.closeCause = cause;
|
|
233
|
+
for (const [, entry] of this.pending) {
|
|
234
|
+
clearTimeout(entry.timer);
|
|
235
|
+
entry.reject(cause);
|
|
236
|
+
}
|
|
237
|
+
this.pending.clear();
|
|
238
|
+
// Event handlers stay registered — a future re-attach is a v2 concern.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-mode CDP transport.
|
|
3
|
+
*
|
|
4
|
+
* Owns the read loop on FD 3 (browser → us) and the writer on FD 4 (us →
|
|
5
|
+
* browser). Decodes frames via {@link CdpFramer}, hands them to a callback
|
|
6
|
+
* supplied by the {@link MessageRouter}. Writes via {@link encodeFrame}.
|
|
7
|
+
*
|
|
8
|
+
* The transport itself is intentionally I/O-only — it does not understand
|
|
9
|
+
* request/response correlation, events, or sessions. The router does that.
|
|
10
|
+
* What the transport DOES do is enforce the §8.2 forbidden-method invariants
|
|
11
|
+
* via {@link assertNotForbidden} on every send, before any I/O.
|
|
12
|
+
*
|
|
13
|
+
* @see PLAN.md §8.1 / §8.2
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { assertNotForbidden } from "./forbidden";
|
|
17
|
+
import { CdpFramer, encodeFrame } from "./framer";
|
|
18
|
+
import type { CdpRequest } from "./types";
|
|
19
|
+
|
|
20
|
+
/** Minimal duplex pipe surface. Bun's `proc.stdio[3]` is a `ReadableStream` and
|
|
21
|
+
* `proc.stdio[4]` is a `FileSink`. We type against the actual shapes we use so
|
|
22
|
+
* the transport is unit-testable with mocks. */
|
|
23
|
+
export interface PipeReader {
|
|
24
|
+
/**
|
|
25
|
+
* Returns a `ReadableStream<Uint8Array>` reader. The transport will pull
|
|
26
|
+
* chunks until the stream ends (browser exit) or `close()` is called.
|
|
27
|
+
*/
|
|
28
|
+
getReader(): ReadableStreamDefaultReader<Uint8Array>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PipeWriter {
|
|
32
|
+
/** Write a chunk; returns void or a promise (Bun's FileSink returns number). */
|
|
33
|
+
write(chunk: Uint8Array): unknown;
|
|
34
|
+
/** Flush any buffered bytes. */
|
|
35
|
+
flush?(): unknown;
|
|
36
|
+
/** Close the underlying FD. */
|
|
37
|
+
end?(): unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Callback the router supplies to receive complete JSON frames. */
|
|
41
|
+
export type FrameHandler = (json: string) => void;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Transport-level events surfaced to the router. The transport never throws
|
|
45
|
+
* asynchronously; it reports lifecycle changes through this channel.
|
|
46
|
+
*/
|
|
47
|
+
export interface TransportListener {
|
|
48
|
+
/** Called for every complete JSON frame from the browser. */
|
|
49
|
+
onFrame: FrameHandler;
|
|
50
|
+
/** Called once when the pipe closes (browser exit, manual close, or read error). */
|
|
51
|
+
onClose: (reason?: Error) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The core transport. Construct with already-opened pipe handles plus a
|
|
56
|
+
* listener; call `start()` to begin the read loop, `send()` to write a CDP
|
|
57
|
+
* request, `close()` to release resources.
|
|
58
|
+
*/
|
|
59
|
+
export class CdpTransport {
|
|
60
|
+
private readonly framer = new CdpFramer();
|
|
61
|
+
private readonly reader: PipeReader;
|
|
62
|
+
private readonly writer: PipeWriter;
|
|
63
|
+
private readonly listener: TransportListener;
|
|
64
|
+
private nextId = 1;
|
|
65
|
+
private closed = false;
|
|
66
|
+
private readLoopPromise: Promise<void> | null = null;
|
|
67
|
+
private currentReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(reader: PipeReader, writer: PipeWriter, listener: TransportListener) {
|
|
70
|
+
this.reader = reader;
|
|
71
|
+
this.writer = writer;
|
|
72
|
+
this.listener = listener;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True after `close()` has been called or the read loop has terminated. */
|
|
76
|
+
get isClosed(): boolean {
|
|
77
|
+
return this.closed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Mint the next monotonic CDP request id. */
|
|
81
|
+
nextRequestId(): number {
|
|
82
|
+
return this.nextId++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Start the async read loop. Idempotent. */
|
|
86
|
+
start(): void {
|
|
87
|
+
if (this.readLoopPromise !== null) return;
|
|
88
|
+
this.readLoopPromise = this.runReadLoop();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Synchronously enforce §8.2 invariants, then serialize and write the
|
|
93
|
+
* request to the browser pipe.
|
|
94
|
+
*
|
|
95
|
+
* Throws {@link ForbiddenCdpMethodError} *before* any I/O for any §8.2
|
|
96
|
+
* violation.
|
|
97
|
+
*/
|
|
98
|
+
send(request: CdpRequest): void {
|
|
99
|
+
assertNotForbidden(request.method, request.params);
|
|
100
|
+
if (this.closed) {
|
|
101
|
+
throw new Error(`[mochi] cannot send CDP method ${request.method}: transport is closed`);
|
|
102
|
+
}
|
|
103
|
+
const json = JSON.stringify(request);
|
|
104
|
+
const bytes = encodeFrame(json);
|
|
105
|
+
this.writer.write(bytes);
|
|
106
|
+
if (typeof this.writer.flush === "function") {
|
|
107
|
+
// Bun's FileSink returns a promise we don't need to await; failures
|
|
108
|
+
// surface on the next write or via the close listener.
|
|
109
|
+
void this.writer.flush();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tear down the transport. Idempotent. Cancels the read loop, closes the
|
|
115
|
+
* writer, and notifies the listener exactly once.
|
|
116
|
+
*/
|
|
117
|
+
async close(reason?: Error): Promise<void> {
|
|
118
|
+
if (this.closed) return;
|
|
119
|
+
this.closed = true;
|
|
120
|
+
try {
|
|
121
|
+
if (this.currentReader !== null) {
|
|
122
|
+
await this.currentReader.cancel().catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore cancel failures
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (typeof this.writer.end === "function") {
|
|
129
|
+
await this.writer.end();
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore writer-close failures
|
|
133
|
+
}
|
|
134
|
+
this.listener.onClose(reason);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async runReadLoop(): Promise<void> {
|
|
138
|
+
let closeReason: Error | undefined;
|
|
139
|
+
try {
|
|
140
|
+
const reader = this.reader.getReader();
|
|
141
|
+
this.currentReader = reader;
|
|
142
|
+
while (!this.closed) {
|
|
143
|
+
const { value, done } = await reader.read();
|
|
144
|
+
if (done) break;
|
|
145
|
+
if (value !== undefined && value.length > 0) {
|
|
146
|
+
const frames = this.framer.push(value);
|
|
147
|
+
for (const frame of frames) {
|
|
148
|
+
try {
|
|
149
|
+
this.listener.onFrame(frame);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// A handler bug should not kill the read loop.
|
|
152
|
+
// Surface to listener.onClose only on truly fatal conditions.
|
|
153
|
+
console.error("[mochi] CDP frame handler threw:", err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
closeReason = err instanceof Error ? err : new Error(String(err));
|
|
160
|
+
} finally {
|
|
161
|
+
if (!this.closed) {
|
|
162
|
+
this.closed = true;
|
|
163
|
+
this.listener.onClose(closeReason);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|