/** * Server-side frontend log ring — the data source behind GET /__bloby/control/logs/frontend * (the agent's "tail frontend / devtools logs") and the friendly "Copy error" flow. * * Two independent producers feed ONE in-memory ring, so the tail is never empty regardless of * how the frontend broke: * 1. The Vite dev server's customLogger (supervisor/vite-dev.ts) — COMPILE/transform errors, * captured even when the browser never ran a line of JS (hard compile failure / blank page). * 2. The browser (supervisor/workspace-guard.js) POSTing window.onerror / unhandledrejection / * console.error / console.warn / Vite-overlay text to POST /__bloby/control/fe-log — RUNTIME * errors, which Vite never sees. * * Memory-only by design: the agent reads it over the loopback endpoint (no workspace file to grow * unbounded, pollute the dir, or self-trigger Vite's watcher). It is the current session's frontend * error trail; a supervisor restart clears it (frontend errors are transient by nature). */ export type FrontendLogKind = | 'error' | 'unhandledrejection' | 'console.error' | 'console.warn' | 'vite-error' | 'vite-warn' | 'vite-overlay'; export interface FrontendLogEntry { t: number; kind: FrontendLogKind; text: string; stack?: string; } const RING_MAX = 500; const TEXT_CAP = 4000; // per-field clamp so one giant stack can't blow the ring's memory const ring: FrontendLogEntry[] = []; // Collapse the same message arriving repeatedly in a short window. The guard re-evaluates the Vite // overlay on a 1.5s tick, and a crash loop can spam identical errors — without this the ring fills // with one repeated line and pushes out the useful history. let lastKey = ''; let lastAt = 0; /** Append one frontend log entry to the ring. Best-effort, never throws, drops empty text. * text is newline-stripped: the browser-facing POST /__bloby/control/fe-log endpoint is * unauthenticated, and tailFrontendLog renders one entry per line — an embedded newline would let a * remote caller forge a fake ` [kind] ...` line that the (Bash-capable) agent reads as genuine. * Collapsing newlines to a marker keeps each entry to exactly one line. (stack keeps its newlines: * the renderer indents every stack line, so it can't masquerade as an un-indented log header.) */ export function appendFrontendLog(kind: FrontendLogKind, text: string, stack?: string): void { const clean = (text == null ? '' : String(text)).slice(0, TEXT_CAP).replace(/[\r\n]+/g, ' ⏎ ').trim(); if (!clean) return; const stk = stack ? String(stack).slice(0, TEXT_CAP) : undefined; const key = kind + '|' + clean; const now = Date.now(); if (key === lastKey && now - lastAt < 4000) { lastAt = now; return; } lastKey = key; lastAt = now; ring.push({ t: now, kind, text: clean, stack: stk }); while (ring.length > RING_MAX) ring.shift(); } /** Render the last `maxLines` ring lines as text (newest last). Each entry is one header line * (` [kind] text`) plus optional indented stack lines. */ export function tailFrontendLog(maxLines = 100): string { const lines: string[] = []; for (const e of ring) { lines.push(`${new Date(e.t).toISOString()} [${e.kind}] ${e.text}`); if (e.stack) lines.push(' ' + e.stack.replace(/\n/g, '\n ')); } return lines.slice(-Math.max(0, maxLines)).join('\n'); } /** Number of entries currently buffered (surfaced as `clients`-independent count). */ export function getFrontendLogCount(): number { return ring.length; }