// Small CDP client for the installed Chromium. No application dependencies. import { spawn } from 'node:child_process'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; export async function browser() { const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-')); const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [ '--headless', '--no-first-run', '--no-default-browser-check', '--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); let id = 0, buffer = ''; const pending = new Map(); child.stdio[4].on('data', chunk => { buffer += chunk.toString(); let end; while ((end = buffer.indexOf('\0')) !== -1) { const message = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1); if (pending.has(message.id)) { const { resolve, reject, timeout } = pending.get(message.id); clearTimeout(timeout); pending.delete(message.id); message.error ? reject(new Error(JSON.stringify(message.error))) : resolve(message.result); } } }); function send(method, params = {}, sessionId) { return new Promise((resolve, reject) => { const key = ++id; const timeout = setTimeout(() => { pending.delete(key); reject(new Error(`CDP timeout: ${method}`)); }, 25000); pending.set(key, { resolve, reject, timeout }); child.stdio[3].write(JSON.stringify({ id: key, method, params, ...(sessionId ? {sessionId} : {}) }) + '\0'); }); } const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); const call = (method, params) => send(method, params, sessionId); await call('Page.enable'); await call('Runtime.enable'); const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text + ': ' + r.result.description); return r.result.value; }; return { call, evaluate, async viewport(width, height = 1000) { await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); }, async navigate(url) { const result = await call('Page.navigate', { url }); if (result.errorText) throw new Error(result.errorText); // Bounded page-load check, not an agent wake/watch loop. for (let n = 0; n < 50; n++) { if (await evaluate('document.readyState === "complete"')) break; await new Promise(r => setTimeout(r, 100)); } await evaluate('document.fonts.ready.then(() => true)'); return result; }, async screenshot(path) { const { data } = await call('Page.captureScreenshot', { format: 'png' }); await writeFile(path, Buffer.from(data, 'base64')); }, async key(key, code = key, modifiers = 0) { const virtual = { Tab: 9, Enter: 13, Escape: 27, ArrowDown: 40, ArrowRight: 39, ' ': 32 }[key]; await call('Input.dispatchKeyEvent', { type: 'keyDown', key, code, modifiers, windowsVirtualKeyCode: virtual, text: key === 'Enter' ? '\r' : key.length === 1 ? key : '' }); await call('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers, windowsVirtualKeyCode: virtual }); }, async close() { await send('Browser.close').catch(() => {}); if (child.exitCode === null) await new Promise(resolve => child.once('exit', resolve)); await rm(profile, { recursive: true, force: true }); }, }; }