diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-06-21 00:46:44 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-21 00:46:44 +0530 |
| commit | da5d31af999b728c31ab0169373531cab4543f0c (patch) | |
| tree | 5d350ad1428ff332372a9c724ac46c964d8ca477 /src/daemon | |
| parent | 8db684d43366d335760e106eae39a74376bd7640 (diff) | |
release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init (#6)
* feat: Unix socket daemon with auto-reconnect for persistent Chrome CDP
Eliminates the 'Allow Remote Debugging' Chrome prompt that appeared on
every pi session by multiplexing pi instances through a single persistent
daemon process.
Key additions:
- Unix socket IPC (net.createServer + readline JSON framing)
- CdpBridge with ID multiplexer + event router
- DaemonTransport implementing CdpTransport interface
- Auto-spawn with ensureDaemon() (lazy, checks socket)
Fixes since initial implementation:
- Bridge auto-reconnects to Chrome with exponential backoff (1s-60s).
Previously the bridge tried once at startup and permanently failed
if Chrome wasn't ready — causing 'Chrome not connected' on every
browser tool call.
- Daemon spawn uses project-local tsx binary instead of global --import
flag, fixing silent spawn failure (ERR_MODULE_NOT_FOUND).
- Stale ctx crash: persistState in onOwnershipChange wrapped in
try/catch to prevent CDP event consumer crashes.
49 test assertions across 4 test files. Zero TypeScript errors.
* feat: on-demand browser initialization
Browser daemon no longer auto-starts on session_start. Instead:
- session_start creates the client lazily but does NOT connect or spawn
the daemon. Zero Chrome prompts for non-browser pi sessions.
- Browser tool wrapper checks for daemon socket before each call.
Socket missing → clear error: 'Run /browser-setup first'.
Socket exists → proceeds silently (daemon already running).
- /browser-setup is the explicit, one-time init point. It spawns
the daemon and connects to Chrome only when the user opts in.
- SKILL.md updated with Connection Setup instructions telling agents
to direct users to /browser-setup when socket is missing.
* fix: checkChromeRunning on macOS — match by substring not exact
ps -A -o comm= returns full executable paths on modern macOS
(e.g. /Applications/Google Chrome.app/Contents/MacOS/Google Chrome),
not the basename. The old exact-match check (browserProcesses.includes(l))
never matched, causing false 'No browser instance running' errors.
Changed to substring includes() with helper/renderer/crashpad filtering.
* feat: browser_setup as agent-callable tool
The agent can now self-recover from 'Browser harness not initialized'
errors by calling browser_setup directly — no user mediation needed.
- Extracted shared performSetup() from the slash command into setup.ts
- Created src/domains/setup.ts with ensureAlive:false to bypass the
socket guard (this tool IS the setup, can't require itself)
- Registered browser_setup in the tool registry
- Updated SKILL.md: agent calls browser_setup directly, not ask user
- Removed stale test/manual/spawn-test.ts
* release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init
Diffstat (limited to 'src/daemon')
| -rw-r--r-- | src/daemon/bridge.ts | 411 | ||||
| -rw-r--r-- | src/daemon/index.ts | 114 | ||||
| -rw-r--r-- | src/daemon/protocol.ts | 162 | ||||
| -rw-r--r-- | src/daemon/server.ts | 245 | ||||
| -rw-r--r-- | src/daemon/spawn.ts | 85 |
5 files changed, 1017 insertions, 0 deletions
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts new file mode 100644 index 0000000..c2c1355 --- /dev/null +++ b/src/daemon/bridge.ts @@ -0,0 +1,411 @@ +/** + * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing. + * + * A single long-lived process connection to Chrome that proxies CDP traffic + * for multiple pi clients connected via the IpcServer. One Allow dialog ever. + * + * References: + * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals + * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions + * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/ + */ + +import WebSocket from "ws"; +import { discoverWsUrl } from "../cdp/discovery"; +import type { CdpRawMessage } from "../cdp/types"; +import type { WireRequest, WireResponse, WireEvent } from "./protocol"; +import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void; + +export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void; + +export type CloseHandler = () => void; + +export type CdpBridge = { + /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop + * are initialized (not when Chrome is connected — that may take retries). */ + start(): Promise<void>; + stop(): Promise<void>; + handleRequest(req: WireRequest, clientId: string, send: SendToClient): void; + /** True when the Chrome WebSocket is open and ready. */ + isAlive(): boolean; + recordSession(clientId: string, sessionId: string): void; + releaseSession(sessionId: string): void; + getSessionOwner(sessionId: string): string | undefined; + removeClient(clientId: string): void; + onEvent(handler: EventHandler): void; + onClose(handler: CloseHandler): void; +}; + +// ── ID Multiplexer ──────────────────────────────────────────────────────────── + +interface PendingEntry { + clientId: string; + localId: number; +} + +class IdMultiplexer { + private nextId = 1; + private localToRemote = new Map<string, Map<number, number>>(); + private pending = new Map<number, PendingEntry>(); + + allocate(clientId: string, localId: number): number { + const daemonId = this.nextId++; + let m = this.localToRemote.get(clientId); + if (!m) { m = new Map(); this.localToRemote.set(clientId, m); } + m.set(localId, daemonId); + this.pending.set(daemonId, { clientId, localId }); + return daemonId; + } + + resolve(daemonId: number): PendingEntry | null { + const e = this.pending.get(daemonId); + if (!e) return null; + this.pending.delete(daemonId); + const m = this.localToRemote.get(e.clientId); + if (m) m.delete(e.localId); + return e; + } + + clearClient(clientId: string): void { + const m = this.localToRemote.get(clientId); + if (m) { for (const [, did] of m) this.pending.delete(did); } + this.localToRemote.delete(clientId); + } +} + +// ── Event Router ─────────────────────────────────────────────────────────────── + +class EventRouter { + private owners = new Map<string, string>(); // sessionId → clientId + private clientSessions = new Map<string, Set<string>>(); // clientId → sessions + + record(clientId: string, sessionId: string): void { + const prev = this.owners.get(sessionId); + if (prev && prev !== clientId) { + this.clientSessions.get(prev)?.delete(sessionId); + } + this.owners.set(sessionId, clientId); + let s = this.clientSessions.get(clientId); + if (!s) { s = new Set(); this.clientSessions.set(clientId, s); } + s.add(sessionId); + } + + release(sessionId: string): void { + const owner = this.owners.get(sessionId); + if (owner) this.clientSessions.get(owner)?.delete(sessionId); + this.owners.delete(sessionId); + } + + getOwner(sessionId: string): string | undefined { + return this.owners.get(sessionId); + } + + removeClient(clientId: string): void { + const sessions = this.clientSessions.get(clientId); + if (sessions) { for (const sid of sessions) this.owners.delete(sid); } + this.clientSessions.delete(clientId); + } + + /** + * Route a CDP event to client(s). + * Session-scoped → only the owning client. + * Browser-level (no sessionId) → all connected clients. + */ + route(event: WireEvent): string[] { + if (event.sessionId) { + const owner = this.owners.get(event.sessionId); + return owner ? [owner] : []; + } + return [...this.clientSessions.keys()]; + } + + allClients(): string[] { + return [...this.clientSessions.keys()]; + } +} + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createCdpBridge = (): CdpBridge => { + let ws: WebSocket | null = null; + let wsUrl: string | null = null; + const mux = new IdMultiplexer(); + const router = new EventRouter(); + let eventHandler: EventHandler | null = null; + let closeHandler: CloseHandler | null = null; + + // daemonId → { send, timer, isAttach } + const callbacks = new Map<number, { + send: SendToClient; + clientId: string; + localId: number; + timer: ReturnType<typeof setTimeout>; + isAttach: boolean; + }>(); + + // ── Handle raw CDP messages from Chrome ───────────────────────────────── + + const onChromeMessage = (raw: string): void => { + let msg: CdpRawMessage; + try { msg = JSON.parse(raw); } catch { return; } + + // ---- Response (has id) ---- + if (msg.id !== undefined) { + const cb = callbacks.get(msg.id); + if (!cb) { + // Stale response — client already timed out or disconnected. + // Still clean up the multiplexer mapping. + mux.resolve(msg.id); + return; + } + clearTimeout(cb.timer); + callbacks.delete(msg.id); + + const localId = cb.localId; + + if (msg.error) { + cb.send(cb.clientId, { + type: "response", + id: localId, + error: { code: msg.error.code ?? -1, message: msg.error.message }, + }); + } else { + // Track session ownership for attach responses + if (cb.isAttach && msg.result) { + const result = msg.result as { sessionId?: string }; + if (result.sessionId) { + router.record(cb.clientId, result.sessionId); + } + } + cb.send(cb.clientId, { type: "response", id: localId, result: msg.result }); + } + + mux.resolve(msg.id); + return; + } + + // ---- Event (no id, has method) ---- + if (!msg.method) return; + + // Inspector.detached — release session ownership + if (msg.method === "Inspector.detached" && msg.sessionId) { + router.release(msg.sessionId); + } + + const wireEvent: WireEvent = { + type: "event", + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), + }; + + // Route through the event handler for the IpcServer to broadcast + if (eventHandler) { + const targets = router.route(wireEvent); + if (targets.length > 0) { + eventHandler(wireEvent, targets); + } + } + }; + + // ── Connect to Chrome ─────────────────────────────────────────────────── + + // ── Auto-reconnecting Chrome connection ─────────────────────────────── + + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let reconnectAttempt = 0; + let stopped = false; + + /** + * Try to discover Chrome and open a WebSocket. On failure, schedule + * an exponential backoff retry (capped at 60s). When Chrome finally + * becomes available, the bridge connects automatically — no manual + * daemon restart needed. + */ + const tryConnect = (): void => { + if (stopped) return; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + + const attempt = async (): Promise<void> => { + // Already connected — nothing to do. + if (ws && ws.readyState === WebSocket.OPEN) return; + + // Resolve URL (discover if not cached, or clear cache on retry) + let url = wsUrl; + if (!url || reconnectAttempt > 0) { + const d = await discoverWsUrl(); + if (!d.success) { scheduleRetry(); return; } + url = d.data; + wsUrl = url; + } + + const settledPromise = new Promise<void>((settle) => { + let settled = false; + const settleOnce = (): void => { + if (settled) return; + settled = true; + settle(); + }; + + let sock: WebSocket; + try { + sock = new WebSocket(url!, { perMessageDeflate: false }); + } catch { + scheduleRetry(); + return; + } + + const timer = setTimeout(() => { + sock.close(); + settleOnce(); + }, CDP_CONNECT_TIMEOUT_MS); + + sock.on("open", () => { + clearTimeout(timer); + ws = sock; + reconnectAttempt = 0; + // Enable target discovery so we see page events + ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } })); + console.log("[pi-browser-daemon] Connected to Chrome ✓"); + settleOnce(); + }); + + sock.on("message", (data: WebSocket.Data) => { + onChromeMessage(typeof data === "string" ? data : data.toString()); + }); + + sock.on("error", () => { + clearTimeout(timer); + settleOnce(); + }); + + sock.on("close", () => { + clearTimeout(timer); + ws = null; + // Reject all pending callbacks + for (const [daemonId, cb] of callbacks) { + clearTimeout(cb.timer); + cb.send(cb.clientId, { + type: "response", + id: cb.localId, + error: { code: -32000, message: "Chrome disconnected" }, + }); + callbacks.delete(daemonId); + mux.resolve(daemonId); + } + closeHandler?.(); + // Auto-reconnect on unexpected close + scheduleRetry(); + }); + }); + + await settledPromise; + // If connection didn't succeed, schedule a retry + if (!ws || ws.readyState !== WebSocket.OPEN) { + scheduleRetry(); + } + }; + + attempt().catch(() => scheduleRetry()); + }; + + /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */ + const scheduleRetry = (): void => { + if (stopped) return; + const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000); + reconnectAttempt++; + wsUrl = null; // force re-discovery on next attempt + reconnectTimer = setTimeout(tryConnect, delay); + }; + + const start = async (): Promise<void> => { + stopped = false; + // Don't await — the retry loop runs in the background. + // The daemon is "started" when the IPC server is listening, not when + // Chrome is connected. + tryConnect(); + }; + + const stop = async (): Promise<void> => { + stopped = true; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + for (const [, cb] of callbacks) clearTimeout(cb.timer); + callbacks.clear(); + if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; } + wsUrl = null; + }; + + const handleRequest = ( + req: WireRequest, + clientId: string, + send: SendToClient, + ): void => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: "Chrome not connected" }, + }); + return; + } + + const daemonId = mux.allocate(clientId, req.id); + + // Store callback for the response + const timer = setTimeout(() => { + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` }, + }); + }, CDP_COMMAND_TIMEOUT_MS); + + const isAttach = req.method === "Target.attachToTarget"; + const isDetach = req.method === "Target.detachFromTarget"; + + if (isDetach && req.sessionId) { + router.release(req.sessionId); + } + + callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach }); + + const payload: Record<string, unknown> = { + id: daemonId, + method: req.method, + params: req.params ?? {}, + }; + if (req.sessionId) payload["sessionId"] = req.sessionId; + + try { + ws.send(JSON.stringify(payload)); + } catch (e) { + clearTimeout(timer); + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: e instanceof Error ? e.message : String(e) }, + }); + } + }; + + return { + start, + stop, + handleRequest, + isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN, + recordSession: (cid, sid) => router.record(cid, sid), + releaseSession: (sid) => router.release(sid), + getSessionOwner: (sid) => router.getOwner(sid), + removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); }, + onEvent: (h) => { eventHandler = h; }, + onClose: (h) => { closeHandler = h; }, + }; +}; diff --git a/src/daemon/index.ts b/src/daemon/index.ts new file mode 100644 index 0000000..d2c43a8 --- /dev/null +++ b/src/daemon/index.ts @@ -0,0 +1,114 @@ +/** + * Browser daemon entry point — standalone long-lived process. + * + * Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket + * for pi instances to connect through. One Allow dialog ever (per Chrome + * lifetime), zero repeated prompts. + * + * Run: npx tsx src/daemon/index.ts + */ + +import { createIpcServer } from "./server"; +import { createCdpBridge, type SendToClient } from "./bridge"; +import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol"; + +async function main() { + console.log("[pi-browser-daemon] Starting..."); + + const ipcServer = createIpcServer(); + const cdpBridge = createCdpBridge(); + + // ── Wire IpcServer → CdpBridge ───────────────────────────────────────── + + // When a pi client sends a CDP request, proxy it through the bridge to Chrome. + ipcServer.onMessage((msg, client) => { + if (msg.type !== "request") return; + + const send: SendToClient = (cid, resp) => { + ipcServer.send(cid, resp); + }; + + cdpBridge.handleRequest(msg, client.id, send); + }); + + // When Chrome sends an event, route it to the correct pi client(s). + cdpBridge.onEvent((event, targetClientIds) => { + for (const cid of targetClientIds) { + ipcServer.send(cid, event); + } + }); + + // When a client disconnects, clean up its CDP state. + ipcServer.onDisconnect((client) => { + cdpBridge.removeClient(client.id); + }); + + // When Chrome disconnects, notify all clients. + cdpBridge.onClose(() => { + console.log("[pi-browser-daemon] Chrome disconnected"); + ipcServer.broadcast({ + type: "control", + action: "shutdown", + reason: "chrome_disconnected", + }); + }); + + // ── Idle timeout ─────────────────────────────────────────────────────── + + // The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients. + // This prevents zombie processes when pi sessions end and never reconnect. + // The timeout resets whenever a client connects. + let idleTimer: ReturnType<typeof setTimeout> | null = null; + + const resetIdleTimer = (): void => { + if (idleTimer) clearTimeout(idleTimer); + if (ipcServer.clientCount() === 0) { + idleTimer = setTimeout(() => { + console.log("[pi-browser-daemon] Idle timeout — no clients for " + + `${DAEMON_IDLE_TIMEOUT_MS / 60000} minutes. Shutting down.`); + shutdown(); + }, DAEMON_IDLE_TIMEOUT_MS); + } + }; + + const cancelIdleTimer = (): void => { + if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } + }; + + ipcServer.onConnect(() => { cancelIdleTimer(); }); + ipcServer.onDisconnect(() => { resetIdleTimer(); }); + + // ── Start ────────────────────────────────────────────────────────────── + + try { + await ipcServer.start(); + console.log("[pi-browser-daemon] IPC server listening"); + } catch (e) { + console.error("[pi-browser-daemon] Failed to start IPC server:", e); + process.exit(1); + } + + // Connect to Chrome (auto-retries with backoff in the background). + // The daemon is ready immediately — Chrome may connect later. + await cdpBridge.start(); + + // Start the idle timer (no clients yet at startup). + resetIdleTimer(); + + // ── Graceful shutdown ────────────────────────────────────────────────── + + const shutdown = async () => { + console.log("[pi-browser-daemon] Shutting down..."); + await cdpBridge.stop(); + await ipcServer.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((e) => { + console.error("[pi-browser-daemon] Fatal:", e); + process.exit(1); +}); diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts new file mode 100644 index 0000000..9d777bc --- /dev/null +++ b/src/daemon/protocol.ts @@ -0,0 +1,162 @@ +/** + * Unix socket daemon protocol — wire message types, type guards, and constants. + * + * The daemon speaks JSON-line protocol over a Unix domain socket. + * One JSON object per newline. Four message types: request, response, event, control. + * + * References: + * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals + * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback + * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line + */ + +// ── Message types ────────────────────────────────────────────────────────────── + +/** A CDP command sent from a pi client to the daemon. */ +export type WireRequest = { + readonly type: "request"; + /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */ + readonly id: number; + readonly method: string; + readonly params?: Record<string, unknown>; + /** Optional CDP session id. Omitted for browser-level commands. */ + readonly sessionId?: string; +}; + +/** A CDP response (result or error) sent from the daemon back to the pi client. */ +export type WireResponse = { + readonly type: "response"; + /** Matches the id from the corresponding WireRequest. */ + readonly id: number; + readonly result?: unknown; + readonly error?: { + readonly code: number; + readonly message: string; + readonly data?: string; + }; +}; + +/** A CDP event forwarded from Chrome through the daemon to pi client(s). */ +export type WireEvent = { + readonly type: "event"; + readonly method: string; + readonly params?: Record<string, unknown>; + /** Session-scoped events carry this; browser-level events omit it. */ + readonly sessionId?: string; +}; + +/** Daemon lifecycle control messages. Bidirectional. */ +export type WireControl = { + readonly type: "control"; + readonly action: "register" | "registered" | "deregister" | "shutdown"; + /** pi client namespace (required for register/registered/deregister). */ + readonly clientId?: string; + /** Human-readable reason for shutdown. */ + readonly reason?: string; +}; + +/** All messages that may appear on the Unix socket. */ +export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl; + +// ── Type guard ───────────────────────────────────────────────────────────────── + +const isObject = (v: unknown): v is Readonly<Record<string, unknown>> => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Validates that an unknown parsed-JSON value is a well-formed WireMessage. + * Does NOT validate the full CDP semantics — only structural shape. + */ +export const isWireMessage = (v: unknown): v is WireMessage => { + if (!isObject(v)) return false; + const t = v["type"]; + if (typeof t !== "string") return false; + + switch (t) { + case "request": { + if (typeof v["id"] !== "number") return false; + if (typeof v["method"] !== "string") return false; + const sid = v["sessionId"]; + if (sid !== undefined && typeof sid !== "string") return false; + return true; + } + case "response": { + if (typeof v["id"] !== "number") return false; + const errVal = v["error"]; + if (errVal !== undefined) { + if (!isObject(errVal)) return false; + if (typeof errVal["message"] !== "string") return false; + if (typeof errVal["code"] !== "number") return false; + } + return true; + } + case "event": { + if (typeof v["method"] !== "string") return false; + const sid = v["sessionId"]; + if (sid !== undefined && typeof sid !== "string") return false; + return true; + } + case "control": { + const action = v["action"]; + if (typeof action !== "string") return false; + if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false; + const cid = v["clientId"]; + if ( + (action === "register" || action === "registered" || action === "deregister") && + typeof cid !== "string" + ) { + return false; + } + return true; + } + default: + return false; + } +}; + +// ── Wire message constructors ────────────────────────────────────────────────── + +/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */ +export const serialize = (msg: WireMessage): string => JSON.stringify(msg); + +/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */ +export const deserialize = (line: string): WireMessage | null => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isWireMessage(parsed)) return null; + return parsed; +}; + +// ── Constants ────────────────────────────────────────────────────────────────── + +/** Filesystem path for the daemon's Unix domain socket. */ +export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock"; + +/** + * How long the daemon stays alive with zero connected clients before exiting + * (milliseconds). After this period, the daemon cleans up and shuts down. + */ +export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** + * Hard cap on concurrent pi client connections. Extra connections are rejected + * immediately to prevent resource exhaustion. + */ +export const DAEMON_MAX_CLIENTS = 16; + +/** + * If true, the daemon unlinks (deletes) any stale socket file from a previous + * daemon crash before calling server.listen(). Without this, a crashed daemon + * leaves a socket inode that blocks the next listen() call with EADDRINUSE. + */ +export const DAEMON_STALE_SOCKET_CLEANUP = true; + +/** CDP WebSocket connection timeout (ms). */ +export const CDP_CONNECT_TIMEOUT_MS = 10_000; + +/** CDP command timeout when proxying through daemon (ms). */ +export const CDP_COMMAND_TIMEOUT_MS = 10_000; diff --git a/src/daemon/server.ts b/src/daemon/server.ts new file mode 100644 index 0000000..fea9bb7 --- /dev/null +++ b/src/daemon/server.ts @@ -0,0 +1,245 @@ +/** + * IpcServer — Unix socket server for daemon ↔ pi client communication. + * + * Accepts multiple concurrent pi client connections over a Unix domain + * stream socket. Uses newline-delimited JSON framing (one WireMessage per + * line). Clients are anonymous until they send a control.register message. + * + * References: + * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener + * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback + * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions + */ + +import { createServer, type Server, type Socket } from "node:net"; +import { createInterface, type Interface } from "node:readline"; +import { unlinkSync } from "node:fs"; +import { + type WireMessage, + type WireControl, + isWireMessage, + DAEMON_SOCKET_PATH, + DAEMON_MAX_CLIENTS, + DAEMON_STALE_SOCKET_CLEANUP, +} from "./protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +/** A connected pi client with its socket and readline interface. */ +export type ClientSocket = { + /** Client namespace (set after registration). */ + id: string; + readonly socket: Socket; + readonly rl: Interface; + /** True once a valid control.register message has been received. */ + registered: boolean; +}; + +/** Handler called for every valid WireMessage from any client. */ +export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void; + +/** Handler called when a client connects or disconnects. */ +export type ConnectionHandler = (client: ClientSocket) => void; + +export type IpcServer = { + /** Start listening on the Unix socket. Resolves when the server is ready. */ + start(): Promise<void>; + /** Close the server and all client connections. Resolves when cleaned up. */ + stop(): Promise<void>; + /** Register a handler for incoming WireMessages from any client. */ + onMessage(handler: MessageHandler): void; + /** Register a handler for client connect events. */ + onConnect(handler: ConnectionHandler): void; + /** Register a handler for client disconnect events. */ + onDisconnect(handler: ConnectionHandler): void; + /** Send a WireMessage to a specific client by clientId. No-op if client not found. */ + send(clientId: string, msg: WireMessage): void; + /** Send a WireMessage to all connected (registered) clients. */ + broadcast(msg: WireMessage): void; + /** Force-disconnect a client by clientId. */ + disconnectClient(clientId: string): void; + /** All currently connected clients (by clientId). */ + clients(): ReadonlyMap<string, ClientSocket>; + /** Number of connected clients. */ + clientCount(): number; +}; + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createIpcServer = (): IpcServer => { + let server: Server | null = null; + // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration. + const sockets = new Map<string, ClientSocket>(); + // clientId → ClientSocket (only registered clients). + const clients = new Map<string, ClientSocket>(); + let messageHandler: MessageHandler | null = null; + let connectHandler: ConnectionHandler | null = null; + let disconnectHandler: ConnectionHandler | null = null; + let nextTempId = 1; + + const start = (): Promise<void> => + new Promise((resolve, reject) => { + // Clean up stale socket file from a previous crashed daemon. + if (DAEMON_STALE_SOCKET_CLEANUP) { + try { + unlinkSync(DAEMON_SOCKET_PATH); + } catch { + // ENOENT is expected — no stale socket to clean. + } + } + + server = createServer({ pauseOnConnect: false }, (socket: Socket) => { + // Reject if at capacity (check registered clients only). + if (clients.size >= DAEMON_MAX_CLIENTS) { + socket.destroy(); + return; + } + + const tempId = `anon-${nextTempId++}`; + const rl = createInterface({ input: socket, crlfDelay: Infinity }); + let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false }; + + sockets.set(tempId, currentClient); + + // Buffer for partial lines. readline handles line splitting, but + // we store a reference in case we need it for diagnostics. + let lineCount = 0; + + rl.on("line", (line: string) => { + lineCount++; + let msg: WireMessage | null; + try { + const parsed: unknown = JSON.parse(line); + if (!isWireMessage(parsed)) return; + msg = parsed; + } catch { + // Malformed JSON — ignore silently, don't crash the daemon. + return; + } + + // Handle registration before forwarding to the general handler. + if (msg.type === "control" && msg.action === "register" && !currentClient.registered) { + const clientId = msg.clientId; + if (!clientId) return; + + // If a client with this id is already connected, reject. + if (clients.has(clientId)) { + const reply: WireControl = { + type: "control", + action: "shutdown", + reason: `clientId ${clientId} is already connected`, + }; + socket.write(JSON.stringify(reply) + "\n"); + socket.destroy(); + return; + } + + // Promote from anonymous to registered — mutate in place so the + // closure captures the updated client reference. + sockets.delete(tempId); + currentClient.id = clientId; + currentClient.registered = true; + clients.set(clientId, currentClient); + + // Acknowledge registration. + const ack: WireControl = { type: "control", action: "registered", clientId }; + socket.write(JSON.stringify(ack) + "\n"); + + connectHandler?.(currentClient); + return; + } + + // Require registration for non-control messages. + if (!currentClient.registered) return; + + // Forward to the general handler. + messageHandler?.(msg, currentClient); + }); + + socket.on("error", () => { + // Socket errors (ECONNRESET, etc.) are handled by the 'close' event. + }); + + socket.on("close", () => { + // Clean up whichever map the client is in. + const found = clients.get(currentClient.id) ?? sockets.get(tempId); + if (found) { + clients.delete(found.id); + sockets.delete(tempId); + // Notify if this was a registered client. + if (found.registered) { + disconnectHandler?.(found); + } + } + // Clean up readline to prevent memory leaks. + rl.close(); + }); + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + reject(err); + }); + + server.listen(DAEMON_SOCKET_PATH, () => { + resolve(); + }); + }); + + const stop = (): Promise<void> => + new Promise((resolve) => { + // Close all client sockets. + for (const [, client] of clients) { + try { client.socket.destroy(); } catch { /* best-effort */ } + } + for (const [, client] of sockets) { + try { client.socket.destroy(); } catch { /* best-effort */ } + } + clients.clear(); + sockets.clear(); + + if (server) { + server.close(() => { + // Clean up the socket file. + try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ } + resolve(); + }); + } else { + resolve(); + } + }); + + return { + start, + stop, + onMessage(handler) { + messageHandler = handler; + }, + onConnect(handler) { + connectHandler = handler; + }, + onDisconnect(handler) { + disconnectHandler = handler; + }, + send(clientId, msg) { + const client = clients.get(clientId); + if (!client) return; + try { + client.socket.write(JSON.stringify(msg) + "\n"); + } catch { + // Socket write error — the close handler will clean up. + } + }, + broadcast(msg) { + for (const [clientId] of clients) { + this.send(clientId, msg); + } + }, + disconnectClient(clientId) { + const client = clients.get(clientId); + if (!client) return; + client.socket.destroy(); + }, + clients: (): ReadonlyMap<string, ClientSocket> => clients, + clientCount: (): number => clients.size, + }; +}; diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts new file mode 100644 index 0000000..e9f4d51 --- /dev/null +++ b/src/daemon/spawn.ts @@ -0,0 +1,85 @@ +/** + * Daemon lifecycle helpers — spawn detection and lazy launch. + * + * The daemon is a standalone long-lived process that owns the single WebSocket + * to Chrome. pi instances connect to it via a Unix domain socket. This module + * provides the plumbing to detect a running daemon and spawn one if needed. + * + * Reference: + * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { access } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { DAEMON_SOCKET_PATH } from "./protocol"; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); + +/** + * Check whether the daemon is running by testing if its Unix socket exists. + * The socket file is created by the daemon on startup and removed on shutdown. + */ +export const isDaemonRunning = async (): Promise<boolean> => { + try { + await access(DAEMON_SOCKET_PATH); + return true; + } catch { + return false; + } +}; + +/** + * Spawn the daemon as a detached child process. The daemon will continue + * running after the parent pi process exits. + * + * The daemon entry point is resolved relative to this module. When running + * from source (tsx), the entry is src/daemon/index.ts. When running compiled, + * it's dist/daemon/index.js. + * + * Returns the child process handle (caller should call child.unref()). + */ +export const spawnDaemon = (): ChildProcess | null => { + // Resolve the daemon entry point and tsx binary. + const daemonScript = join(moduleDir, "index.ts"); + const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx"); + + // Use tsx to run the daemon TypeScript source directly. + // The spawed process inherits the parent's env so module resolution works. + // Fall back to `npx tsx` if the local binary isn't available. + const cmd = existsSync(tsxBin) ? tsxBin : "npx"; + const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript]; + + const child = spawn(cmd, args, { + detached: true, + stdio: "ignore", + env: { ...process.env }, + }); + + child.unref(); + return child; +}; + +/** + * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs) + * for the socket file to appear. + * + * Returns true if the daemon is running (or was successfully spawned). + */ +export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => { + if (await isDaemonRunning()) return true; + + const child = spawnDaemon(); + if (!child) return false; + + // Poll for the socket file to appear + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isDaemonRunning()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + + return false; +}; |
