import { spawn } from "node:child_process"; import type { TraceEnvelope, TraceSubsystemStatus, ViewerReadyMessage } from "./types.ts"; import { IpcQueue, type IpcSink } from "./ipc-queue.ts"; export interface ViewerWritable extends IpcSink { end(): void; } export interface ViewerReadable { on(event: "data", listener: (chunk: Buffer | string) => void): unknown; on(event: "error", listener: (error: Error) => void): unknown; } export interface ViewerChild { stdin: ViewerWritable; stdout: ViewerReadable; stderr: ViewerReadable; on(event: "error", listener: (error: Error) => void): unknown; on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown; kill(): boolean; } export type ViewerLauncher = (command: string, args: readonly string[]) => ViewerChild; export interface ViewerManagerOptions { bootstrapPath: string; tracePath?: string; sessionId: string; token: string; port: number; maxQueueBytes: number; autoOpen: boolean; startupTimeoutMs: number; launcher?: ViewerLauncher; openBrowser?: (url: string) => Promise; onStatus: (status: TraceSubsystemStatus, url?: string) => void; onWarning: (message: string) => void; } function launchViewer(command: string, args: readonly string[]): ViewerChild { return spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] }); } function openBrowserProcess(url: string): Promise { return new Promise((resolve, reject) => { const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; const child = spawn(command, args, { detached: true, stdio: "ignore" }); child.once("error", reject); child.once("spawn", () => { child.unref(); resolve(); }); }); } function isReadyMessage(value: unknown): value is ViewerReadyMessage { if (typeof value !== "object" || value === null) return false; const record = value as Record; return record.type === "ready" && typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0 && record.port <= 65535; } export class ViewerManager { private readonly options: ViewerManagerOptions; private readonly launcher: ViewerLauncher; private readonly openBrowser: (url: string) => Promise; private child: ViewerChild | undefined; private queue: IpcQueue | undefined; private startupTimer: NodeJS.Timeout | undefined; private attempts = 0; private stopping = false; private stdoutBuffer = ""; private viewerUrl: string | undefined; constructor(options: ViewerManagerOptions) { this.options = options; this.launcher = options.launcher ?? launchViewer; this.openBrowser = options.openBrowser ?? openBrowserProcess; } start(): void { if (this.child !== undefined || this.stopping) return; this.spawnAttempt(); } enqueue(event: TraceEnvelope, line: string): boolean { return this.queue?.enqueue(event, line) ?? false; } open(): void { if (this.viewerUrl === undefined) { this.options.onWarning("Viewer is not ready"); return; } this.openCurrentUrl(); } async shutdown(timeoutMs: number): Promise { this.stopping = true; if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); const child = this.child; if (child === undefined) return; await new Promise((resolve) => { let settled = false; const finish = (): void => { if (settled) return; settled = true; clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { try { child.kill(); } catch { /* best effort */ } finish(); }, Math.max(0, timeoutMs)); child.on("exit", finish); try { child.stdin.end(); } catch { finish(); } }); } private spawnAttempt(): void { this.attempts += 1; this.options.onStatus("starting"); const executable = process.release.name === "node" ? process.execPath : "node"; const args = [this.options.bootstrapPath]; if (this.options.tracePath !== undefined) args.push("--trace-path", this.options.tracePath); args.push("--token", this.options.token, "--port", String(this.options.port)); let child: ViewerChild; try { child = this.launcher(executable, args); } catch (error) { this.handleFailure(`Viewer launch failed: ${error instanceof Error ? error.message : String(error)}`); return; } this.child = child; this.stdoutBuffer = ""; this.queue = new IpcQueue(child.stdin, this.options.maxQueueBytes, (reason) => this.handleFailure(reason, child)); child.on("error", (error) => this.handleFailure(`Viewer process failed: ${error.message}`, child)); child.on("exit", (code, signal) => { if (this.stopping || child !== this.child) return; this.handleFailure(`Viewer exited (${code ?? signal ?? "unknown"})`, child); }); child.stdout.on("error", (error) => this.handleFailure(`Viewer stdout failed: ${error.message}`, child)); child.stderr.on("error", (error) => this.options.onWarning(`Viewer stderr failed: ${error.message}`)); child.stdout.on("data", (chunk) => this.handleStdout(String(chunk), child)); child.stderr.on("data", (chunk) => { const message = String(chunk).trim(); if (message.length > 0) this.options.onWarning(message); }); this.startupTimer = setTimeout(() => this.handleFailure("Viewer startup timed out", child), this.options.startupTimeoutMs); } private handleStdout(chunk: string, child: ViewerChild): void { if (child !== this.child) return; this.stdoutBuffer += chunk; for (;;) { const newline = this.stdoutBuffer.indexOf("\n"); if (newline < 0) return; const line = this.stdoutBuffer.slice(0, newline); this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); let parsed: unknown; try { parsed = JSON.parse(line); } catch { continue; } if (!isReadyMessage(parsed)) continue; if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); this.viewerUrl = `http://127.0.0.1:${parsed.port}/?session_id=${encodeURIComponent(this.options.sessionId)}&token=${encodeURIComponent(this.options.token)}`; this.options.onStatus("active", this.viewerUrl); if (this.options.autoOpen) this.openCurrentUrl(); } } private openCurrentUrl(): void { if (this.viewerUrl === undefined) return; void this.openBrowser(this.viewerUrl).catch((error: unknown) => { this.options.onWarning(`Could not open Viewer browser: ${error instanceof Error ? error.message : String(error)}`); }); } private handleFailure(reason: string, child?: ViewerChild): void { if (this.stopping || (child !== undefined && child !== this.child)) return; if (this.startupTimer !== undefined) clearTimeout(this.startupTimer); const failedChild = this.child; this.child = undefined; this.queue = undefined; this.viewerUrl = undefined; this.options.onWarning(reason); try { failedChild?.kill(); } catch { /* best effort */ } if (this.attempts < 2) this.spawnAttempt(); else this.options.onStatus("failed"); } }