@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.
@@ -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
+ }