Advertisement

← vibesdk

scripts/dev-browser-sidecar.ts

3.8 KB Download file
#!/usr/bin/env node
/**
 * Local browser sidecar — drives Chromium with `puppeteer` and exposes
 * an HTTP API the worker calls in dev. Shares `runCapture` with the
 * in-worker `@cloudflare/puppeteer` path so behaviour is identical
 * across dev and prod.
 *
 * Start with `npm run dev:browser`. Binds to 127.0.0.1 only; never
 * exposed to the network. If the worker can't reach this sidecar in
 * dev, the tool returns a structured warning rather than throwing.
 */

import http from 'node:http';
import type { Browser, Page } from 'puppeteer';
import puppeteer from 'puppeteer';

import { runCapture } from '../worker/services/browser-capture/capture-core';
import type {
	CapturePage,
	CapturePayload,
} from '../worker/services/browser-capture/types';

const PORT = Number(process.env.PORT ?? 9223);
const HOST = '127.0.0.1';

let browserPromise: Promise<Browser> | undefined;

async function getBrowser(): Promise<Browser> {
	if (!browserPromise) {
		browserPromise = puppeteer.launch({
			headless: true,
			args: ['--no-sandbox', '--disable-setuid-sandbox'],
		});
		browserPromise.then(
			(b) => {
				b.on('disconnected', () => {
					console.warn('[dev-browser-sidecar] Chromium disconnected; will relaunch on next request');
					browserPromise = undefined;
				});
			},
			(e) => {
				console.error('[dev-browser-sidecar] failed to launch Chromium', e);
				browserPromise = undefined;
			},
		);
	}
	return browserPromise;
}

async function handleCapture(payload: CapturePayload) {
	const browser = await getBrowser();
	const page: Page = await browser.newPage();
	// puppeteer's Page satisfies the CapturePage structural type.
	return runCapture(page as unknown as CapturePage, payload);
}

function readBody(req: http.IncomingMessage): Promise<string> {
	return new Promise((resolve, reject) => {
		const chunks: Buffer[] = [];
		req.on('data', (c: Buffer) => chunks.push(c));
		req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
		req.on('error', reject);
	});
}

function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
	res.statusCode = status;
	res.setHeader('content-type', 'application/json');
	res.end(JSON.stringify(body));
}

const server = http.createServer(async (req, res) => {
	try {
		if (req.method === 'GET' && req.url === '/health') {
			return sendJson(res, 200, { ok: true, version: 1 });
		}
		if (req.method === 'POST' && req.url === '/capture-console-logs') {
			const raw = await readBody(req);
			let payload: CapturePayload;
			try {
				payload = JSON.parse(raw) as CapturePayload;
			} catch (e) {
				return sendJson(res, 400, {
					error: `invalid JSON body: ${e instanceof Error ? e.message : String(e)}`,
				});
			}
			if (!payload?.url) {
				return sendJson(res, 400, { error: 'missing required field: url' });
			}
			const result = await handleCapture(payload);
			return sendJson(res, 200, result);
		}
		res.statusCode = 404;
		res.end();
	} catch (e) {
		console.error('[dev-browser-sidecar] request failed', e);
		sendJson(res, 500, {
			error: e instanceof Error ? e.message : 'capture failed',
		});
	}
});

server.listen(PORT, HOST, () => {
	console.log(`[dev-browser-sidecar] listening on http://${HOST}:${PORT}`);
	console.log('[dev-browser-sidecar] endpoints: GET /health, POST /capture-console-logs');
});

async function shutdown(signal: string) {
	console.log(`[dev-browser-sidecar] received ${signal}, shutting down`);
	try {
		const b = await browserPromise?.catch(() => undefined);
		await b?.close();
	} catch {
		// best effort
	}
	server.close(() => process.exit(0));
	setTimeout(() => process.exit(1), 5000).unref();
}

process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));