mirror of https://github.com/garrytan/gstack.git
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:
parent
e3d7f49c74
commit
3bc13adabc
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue