aboutsummaryrefslogtreecommitdiff
path: root/src/daemon/protocol.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/daemon/protocol.ts')
-rw-r--r--src/daemon/protocol.ts162
1 files changed, 162 insertions, 0 deletions
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;