feat(browse): add CdpPipeTransport for --remote-debugging-pipe CDP

Thin wrapper around two Node streams (Writable + Readable) that speaks
CDP with NUL-byte-delimited JSON framing. Handles id-based request/
response correlation and error/close propagation. Not wired into
anything yet; follow-up commits swap cookie-import's TCP CDP path
to use this.

Refs #1136.
This commit is contained in:
Nicolas Gomes Ferreira Dos Santos 2026-04-23 20:46:32 -07:00
parent e3d7f49c74
commit 3bc13adabc
2 changed files with 291 additions and 0 deletions

View File

@ -40,6 +40,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { Writable as NodeWritable, Readable as NodeReadable } from 'node:stream';
import { TEMP_DIR } from './platform';
// ─── Types ──────────────────────────────────────────────────────
@ -1020,6 +1021,136 @@ function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' {
}
}
// ─── Internal: CDP-over-pipe transport ─────────────────────────
// Chrome's --remote-debugging-pipe exposes CDP over anonymous stdio pipes
// rather than a TCP socket. Framing is simple: UTF-8 JSON payloads delimited
// by single NUL bytes (0x00), in both directions. No length prefix, no
// WebSocket envelope.
//
// The transport accepts Node-style streams (Writable + Readable) so it can
// be unit-tested with PassThrough without spawning a real subprocess. In
// production, the streams come from `child.stdio[3]` (net.Socket, writable —
// parent→Chrome) and `child.stdio[4]` (net.Socket, readable — Chrome→parent)
// of a `node:child_process.spawn` child.
//
// Why node:child_process and not Bun.spawn: Bun.spawn exposes fds ≥ 3 as
// raw numeric file descriptors rather than stream objects, and the Node
// Writable.toWeb / Readable.toWeb bridges hang in Bun. node:child_process
// gives us net.Socket endpoints that Just Work.
type CdpPending = {
resolve: (value: any) => void;
reject: (err: Error) => void;
};
export class CdpPipeTransport {
private nextId = 1;
private pending = new Map<number, CdpPending>();
private closed = false;
private writeStream: NodeWritable;
private readStream: NodeReadable;
private readBuffer = new Uint8Array(0);
private decoder = new TextDecoder();
constructor(writeStream: NodeWritable, readStream: NodeReadable) {
this.writeStream = writeStream;
this.readStream = readStream;
this.readStream.on('data', (chunk: Buffer | string) => {
const bytes = typeof chunk === 'string'
? new TextEncoder().encode(chunk)
: new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
this.ingest(bytes);
});
this.readStream.on('end', () => {
if (!this.closed) {
this.abortAll(new CookieImportError('CDP read stream ended unexpectedly', 'cdp_error'));
}
});
this.readStream.on('error', (err: Error) => this.abortAll(err));
this.writeStream.on('error', (err: Error) => this.abortAll(err));
}
send(method: string, params?: object, sessionId?: string): Promise<any> {
if (this.closed) {
return Promise.reject(new CookieImportError('CDP transport closed', 'cdp_error'));
}
const id = this.nextId++;
const frame: Record<string, unknown> = { id, method };
if (params !== undefined) frame.params = params;
if (sessionId !== undefined) frame.sessionId = sessionId;
const promise = new Promise<any>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
// Write is fire-and-forget; errors bubble via the 'error' listener above.
this.writeStream.write(JSON.stringify(frame) + '\x00');
return promise;
}
close(): void {
if (this.closed) return;
this.closed = true;
this.abortAll(new CookieImportError('CDP transport closed', 'cdp_error'));
try { this.writeStream.end(); } catch {}
}
private ingest(chunk: Uint8Array): void {
// Append to accumulated buffer.
const combined = new Uint8Array(this.readBuffer.length + chunk.length);
combined.set(this.readBuffer, 0);
combined.set(chunk, this.readBuffer.length);
this.readBuffer = combined;
// Split on NUL; last piece (no trailing NUL) becomes the new buffer.
let start = 0;
for (let i = 0; i < this.readBuffer.length; i++) {
if (this.readBuffer[i] === 0x00) {
const frame = this.readBuffer.slice(start, i);
start = i + 1;
this.handleFrame(this.decoder.decode(frame));
}
}
this.readBuffer = this.readBuffer.slice(start);
}
private handleFrame(text: string): void {
if (text.length === 0) return;
let msg: any;
try {
msg = JSON.parse(text);
} catch {
// Malformed frame — ignore. Real CDP does not send malformed frames;
// if we see one, it's a bug, but crashing the transport punishes the
// caller for Chrome's bug.
return;
}
// Responses have an id. Events (no id) are ignored — we don't subscribe.
if (typeof msg.id !== 'number') return;
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
if (msg.error) {
pending.reject(new CookieImportError(
`CDP error: ${msg.error.message || 'unknown'}`,
'cdp_error',
));
} else {
pending.resolve(msg.result ?? {});
}
}
private abortAll(err: Error): void {
const wrapped = err instanceof CookieImportError
? err
: new CookieImportError(`CDP transport error: ${err.message}`, 'cdp_error');
for (const pending of this.pending.values()) pending.reject(wrapped);
this.pending.clear();
}
}
/**
* Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies.
* Quick check reads a small sample, no decryption attempted.

View File

@ -19,6 +19,8 @@ import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { PassThrough } from 'node:stream';
import { CdpPipeTransport } from '../src/cookie-import-browser';
// ─── Test Constants ─────────────────────────────────────────────
@ -517,3 +519,161 @@ describe('Cookie Import Browser', () => {
});
});
});
// ─── CdpPipeTransport ────────────────────────────────────────────
// Helper: create a controllable pair of PassThrough streams mimicking Chrome's
// pipe ends. `writeToChild` is the stream the transport writes TO (Chrome
// would read). `readFromChild` is the stream the transport reads FROM (Chrome
// would write). PassThrough is structurally equivalent to the net.Socket ends
// that node:child_process.spawn returns at stdio[3]/[4] for this purpose.
function makeMockPipes() {
const writeToChild = new PassThrough();
const readFromChild = new PassThrough();
const writtenChunks: Buffer[] = [];
writeToChild.on('data', (chunk: Buffer) => writtenChunks.push(chunk));
return {
writeToChild,
readFromChild,
getWritten: () => Buffer.concat(writtenChunks).toString('utf-8'),
pushFromChild: (s: string) => readFromChild.write(s),
closeFromChild: () => readFromChild.end(),
errorFromChild: (err: Error) => readFromChild.destroy(err),
};
}
// Flush pending I/O ticks so writes reach the PassThrough 'data' listener.
const flush = async () => {
for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r));
};
describe('CdpPipeTransport', () => {
test('send writes a NUL-terminated JSON frame with monotonic id', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
// Attach catch handlers — we never await these, but close() will reject them.
t.send('Network.enable').catch(() => {});
t.send('Network.getAllCookies').catch(() => {});
await flush();
const written = pipes.getWritten();
expect(written).toBe(
'{"id":1,"method":"Network.enable"}\x00' +
'{"id":2,"method":"Network.getAllCookies"}\x00'
);
t.close();
});
test('send resolves when matching id arrives as a single frame', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const pending = t.send('Network.enable');
pipes.pushFromChild('{"id":1,"result":{}}\x00');
const result = await pending;
expect(result).toEqual({});
t.close();
});
test('send resolves when response arrives split across two reads at the NUL boundary', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const pending = t.send('Network.getAllCookies');
pipes.pushFromChild('{"id":1,"result":{"coo');
pipes.pushFromChild('kies":[]}}\x00');
const result = await pending;
expect(result).toEqual({ cookies: [] });
t.close();
});
test('ingest handles multiple frames arriving in one chunk', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const p1 = t.send('Network.enable');
const p2 = t.send('Network.getAllCookies');
pipes.pushFromChild('{"id":1,"result":{}}\x00{"id":2,"result":{"cookies":[{"name":"x"}]}}\x00');
expect(await p1).toEqual({});
expect(await p2).toEqual({ cookies: [{ name: 'x' }] });
t.close();
});
test('error frame rejects the matching pending promise with cdp_error', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const pending = t.send('Network.getAllCookies');
pipes.pushFromChild('{"id":1,"error":{"code":-32000,"message":"nope"}}\x00');
let caught: any = null;
try { await pending; } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(CookieImportError);
expect(caught!.code).toBe('cdp_error');
expect(caught!.message).toContain('nope');
t.close();
});
test('close rejects pending promises with cdp_error', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const pending = t.send('Network.enable');
t.close();
let caught: any = null;
try { await pending; } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(CookieImportError);
expect(caught!.code).toBe('cdp_error');
expect(caught!.message.toLowerCase()).toContain('closed');
});
test('unexpected read-stream end rejects pending with cdp_error', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
const pending = t.send('Network.enable');
pipes.closeFromChild();
let caught: any = null;
try { await pending; } catch (e) { caught = e; }
expect(caught).toBeInstanceOf(CookieImportError);
expect(caught!.code).toBe('cdp_error');
});
test('send with explicit sessionId includes it in the frame', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
t.send('Network.enable', undefined, 'SESSION123').catch(() => {});
await flush();
const written = pipes.getWritten();
expect(written).toBe('{"id":1,"method":"Network.enable","sessionId":"SESSION123"}\x00');
t.close();
});
test('send with params serializes them into the frame', async () => {
const pipes = makeMockPipes();
const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild);
t.send('Target.attachToTarget', { targetId: 'T1', flatten: true }).catch(() => {});
await flush();
const written = pipes.getWritten();
expect(written).toBe(
'{"id":1,"method":"Target.attachToTarget","params":{"targetId":"T1","flatten":true}}\x00'
);
t.close();
});
});