import { spawn, spawnSync } from 'node:child_process'; import { createServer } from 'node:http'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { extname, join, resolve, sep } from 'node:path'; const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); export const CHROME_PATH = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'].find( (name) => spawnSync('which', [name], { stdio: 'ignore' }).status === 0, ) ?? null; const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css' }; // Serves a directory over http://127.0.0.1 so a page can import ES modules from it. export function startStaticServer(root) { const base = resolve(root); const server = createServer((req, res) => { const path = resolve(join(base, decodeURIComponent(new URL(req.url, 'http://localhost').pathname))); let isFile = false; try { isFile = path.startsWith(base + sep) && statSync(path).isFile(); } catch { // not found } if (!isFile) { res.writeHead(404).end('not found'); return; } res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' }); res.end(readFileSync(path)); }); return new Promise((done) => { server.listen(0, '127.0.0.1', () => { done({ origin: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((closed) => server.close(closed)) }); }); }); } export async function launchBrowser() { const profile = mkdtempSync(join(tmpdir(), 'outlier-saver-chrome-')); // Chrome puts a unix socket in TMPDIR and dies with "Socket path too long" if that path is long. const { TMPDIR: _ignored, ...env } = process.env; const proc = spawn( CHROME_PATH, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', 'about:blank'], { stdio: 'ignore', env }, ); const portFile = join(profile, 'DevToolsActivePort'); let port = null; for (let attempt = 0; attempt < 80 && port === null; attempt += 1) { if (existsSync(portFile)) port = Number(readFileSync(portFile, 'utf8').split('\n')[0]); else await sleep(250); } if (port === null) { proc.kill(); throw new Error('Chrome did not start'); } return { async openPage(url) { const target = await (await fetch(`http://127.0.0.1:${port}/json/new?${url}`, { method: 'PUT' })).json(); const ws = new WebSocket(target.webSocketDebuggerUrl); await new Promise((opened, failed) => { ws.onopen = opened; ws.onerror = failed; }); let nextId = 0; const pending = new Map(); ws.onmessage = ({ data }) => { const message = JSON.parse(data); const waiter = pending.get(message.id); if (!waiter) return; pending.delete(message.id); if (message.error) waiter.fail(new Error(message.error.message)); else waiter.done(message.result); }; const send = (method, params = {}) => new Promise((done, fail) => { nextId += 1; pending.set(nextId, { done, fail }); ws.send(JSON.stringify({ id: nextId, method, params })); }); const evaluate = async (expression) => { const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); } return result.result.value; }; // A fresh tab is "complete" on about:blank before it navigates, so wait for the real page. for (let attempt = 0; attempt < 80; attempt += 1) { const state = await evaluate('JSON.stringify([location.href, document.readyState])'); const [href, readyState] = JSON.parse(state); if (href !== 'about:blank' && readyState === 'complete') break; await sleep(100); } return { evaluate, close: () => ws.close() }; }, async close() { proc.kill('SIGTERM'); await sleep(300); rmSync(profile, { recursive: true, force: true, maxRetries: 3 }); }, }; }