/** * Socket client for a raw `browser-cli serve` endpoint. * * One command per connection: connect, read the challenge frame, send the * authenticated (and PQ-encrypted) request frame, read the response frame, * close. The crypto and payload shapes live in `protocol.ts`. */ import { connect as netConnect, isIP } from 'node:net'; import { connect as tlsConnect } from 'node:tls'; import type { Socket } from 'node:net'; import { randomUUID } from 'node:crypto'; import { buildAuthPayload, decodeResponse, frame, type Challenge } from './protocol'; /** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT * (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this * client implements. */ const CLIENT_VERSION = '0.15.4'; const USER_AGENT = `browser-cli/${CLIENT_VERSION}`; // Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed. const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] }; const MAX_MSG_BYTES = 32 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 30_000; export interface ServeConnectOptions { host: string; port: number; /** Wrap the TCP connection in TLS (for a serve behind a TLS-terminating proxy). */ tls?: boolean; /** Reject self-signed / invalid certs when `tls` is on. */ rejectUnauthorized?: boolean; /** Ed25519 PKCS8 PEM private key, or null/empty for a `--no-auth` endpoint. */ privateKeyPem?: string | null; /** Optional `_route` target for a multi-browser serve. */ route?: string | null; timeoutMs?: number; } /** Reassembles browser-cli's 4-byte length-prefixed frames from a socket. */ class FrameReader { private buffer = Buffer.alloc(0); private waiters: Array<(frame: Buffer) => void> = []; private failed: Error | null = null; constructor(socket: Socket) { socket.on('data', (chunk: Buffer) => this.onData(chunk)); } private onData(chunk: Buffer): void { this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 4) { const length = this.buffer.readUInt32LE(0); if (length > MAX_MSG_BYTES) { this.fail(new Error(`serve frame too large (${length} bytes)`)); return; } if (this.buffer.length < 4 + length) break; const payload = this.buffer.subarray(4, 4 + length); this.buffer = this.buffer.subarray(4 + length); const waiter = this.waiters.shift(); if (waiter) waiter(Buffer.from(payload)); } } fail(error: Error): void { this.failed = error; } /** Resolve with the next complete frame, or reject on error/EOF/timeout. */ next(): Promise { if (this.failed) return Promise.reject(this.failed); return new Promise((resolve) => this.waiters.push(resolve)); } } function openSocket(opts: ServeConnectOptions): Socket { if (opts.tls) { return tlsConnect({ host: opts.host, port: opts.port, rejectUnauthorized: opts.rejectUnauthorized !== false, // SNI must be a hostname; Node rejects an IP literal as servername. ...(isIP(opts.host) ? {} : { servername: opts.host }), }); } return netConnect({ host: opts.host, port: opts.port }); } /** * Run a single browser-cli command against a `serve` endpoint and return the * server's response object: `{id, success, data}` or `{id, success:false, error}`. */ export async function sendServeCommand( opts: ServeConnectOptions, command: string, args: Record, ): Promise { const socket = openSocket(opts); socket.setNoDelay(true); const reader = new FrameReader(socket); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); fn(); }; const timer = setTimeout( () => finish(() => reject(new Error(`serve request to ${opts.host}:${opts.port} timed out after ${timeoutMs}ms`))), timeoutMs, ); socket.on('error', (err) => finish(() => reject(err))); socket.on('close', () => finish(() => reject(new Error('serve connection closed before a response was received')))); const run = async () => { const challengeRaw = await reader.next(); const challenge = JSON.parse(challengeRaw.toString('utf8')) as Challenge; const baseMsg: Record = { id: randomUUID(), command, args: args ?? {}, user_agent: USER_AGENT, accept_encoding: ACCEPT_ENCODING, }; if (opts.route) baseMsg._route = opts.route; const { payload, pqSecret } = await buildAuthPayload(baseMsg, challenge, opts.privateKeyPem || null); socket.write(frame(Buffer.from(JSON.stringify(payload), 'utf8'))); const responseRaw = await reader.next(); const response = decodeResponse(responseRaw, pqSecret); finish(() => resolve(response)); }; // A TLS socket is ready only after 'secureConnect'; a plain socket on 'connect'. socket.once(opts.tls ? 'secureConnect' : 'connect', () => { run().catch((err) => finish(() => reject(err))); }); }); }