/** * HTTP Server for the CDP-direct daemon. * * Endpoints: * POST /command — receive Request, dispatch via CDP, return Response * GET /status — daemon health + per-tab stats * POST /shutdown — graceful shutdown * * Bearer token authentication (optional, but enforced when token is set). * Two-phase startup: HTTP server starts immediately, CDP connects async. * Commands received before CDP is ready queue and wait. */ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import type { Request } from "@bb-browser/shared"; import { COMMAND_TIMEOUT, DAEMON_PORT } from "@bb-browser/shared"; import { CdpConnection } from "./cdp-connection.js"; import { dispatchRequest } from "./command-dispatch.js"; export interface HttpServerOptions { host?: string; port?: number; token?: string; cdp: CdpConnection; onShutdown?: () => void; } export class HttpServer { private server: Server | null = null; private readonly host: string; private readonly port: number; private readonly token: string | null; private readonly cdp: CdpConnection; private readonly onShutdown?: () => void; private startTime = 0; constructor(options: HttpServerOptions) { this.host = options.host ?? "127.0.0.1"; this.port = options.port ?? DAEMON_PORT; this.token = options.token ?? null; this.cdp = options.cdp; this.onShutdown = options.onShutdown; } // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- async start(): Promise { return new Promise((resolve, reject) => { this.server = createServer((req, res) => { this.handleRequest(req, res); }); this.server.on("error", reject); this.server.listen(this.port, this.host, () => { this.startTime = Date.now(); resolve(); }); }); } async stop(): Promise { if (this.server) { return new Promise((resolve) => { this.server!.close(() => resolve()); }); } } get uptime(): number { if (this.startTime === 0) return 0; return Math.floor((Date.now() - this.startTime) / 1000); } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- private checkAuth(req: IncomingMessage, res: ServerResponse): boolean { if (!this.token) return true; const auth = req.headers.authorization ?? ""; if (auth === `Bearer ${this.token}`) return true; this.sendJson(res, 401, { error: "Unauthorized" }); return false; } // --------------------------------------------------------------------------- // Routing // --------------------------------------------------------------------------- private handleRequest(req: IncomingMessage, res: ServerResponse): void { // CORS res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } if (!this.checkAuth(req, res)) return; const url = req.url ?? "/"; if (req.method === "POST" && url === "/command") { this.handleCommand(req, res); } else if (req.method === "GET" && url === "/status") { this.handleStatus(req, res); } else if (req.method === "POST" && url === "/shutdown") { this.handleShutdown(req, res); } else { this.sendJson(res, 404, { error: "Not found" }); } } // --------------------------------------------------------------------------- // POST /command // --------------------------------------------------------------------------- private async handleCommand(req: IncomingMessage, res: ServerResponse): Promise { try { const body = await this.readBody(req); const request = JSON.parse(body) as Request; // Wait for CDP to be ready (two-phase startup) if (!this.cdp.connected) { try { await Promise.race([ this.cdp.waitUntilReady(), new Promise((_, reject) => setTimeout(() => reject(new Error("CDP connection timeout")), COMMAND_TIMEOUT), ), ]); } catch { const cdpTarget = `${this.cdp.host}:${this.cdp.port}`; const reason = this.cdp.lastError || "unknown"; this.sendJson(res, 503, { id: request.id, success: false, error: `Chrome not connected (CDP at ${cdpTarget})`, reason, hint: "Make sure Chrome is running. Try: bb-browser daemon shutdown && bb-browser tab list", }); return; } } // Dispatch with timeout const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Command timeout")), COMMAND_TIMEOUT), ); const response = await Promise.race([ dispatchRequest(this.cdp, request), timeout, ]); this.sendJson(res, 200, response); } catch (error) { this.sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : "Invalid request", }); } } // --------------------------------------------------------------------------- // GET /status // --------------------------------------------------------------------------- private handleStatus(_req: IncomingMessage, res: ServerResponse): void { const tabs = this.cdp.tabManager.allTabs().map((tab) => ({ shortId: tab.shortId, targetId: tab.targetId, networkRequests: tab.networkRequests.size, consoleMessages: tab.consoleMessages.size, jsErrors: tab.jsErrors.size, lastActionSeq: tab.lastActionSeq, })); this.sendJson(res, 200, { running: true, cdpConnected: this.cdp.connected, uptime: this.uptime, currentSeq: this.cdp.tabManager.currentSeq(), currentTargetId: this.cdp.currentTargetId, tabs, }); } // --------------------------------------------------------------------------- // POST /shutdown // --------------------------------------------------------------------------- private handleShutdown(_req: IncomingMessage, res: ServerResponse): void { this.sendJson(res, 200, { code: 0, message: "Shutting down" }); setTimeout(() => { if (this.onShutdown) { this.onShutdown(); } }, 100); } // --------------------------------------------------------------------------- // Utility // --------------------------------------------------------------------------- private readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); req.on("error", reject); }); } private sendJson(res: ServerResponse, status: number, data: unknown): void { const body = JSON.stringify(data); res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), }); res.end(body); } }