/** * Browser console -> dev terminal bridge (development only). * * Why this exists * --------------- * PulsePoint reports every runtime problem through `console.error` with a * `[PP-ERROR]` / `[PP-WARN]` prefix. Those never reached the `npm run dev` * terminal, so a broken route looked identical to a working one unless someone * happened to have DevTools open. An agent editing templates had no feedback * signal at all. * * This wires the browser back to the terminal that is already running: * BrowserSync proxies every request to the PHP server, so a middleware on * `POST /__pp-devlog` costs no extra port. The ``; const SETTINGS_DIR = dirname(fileURLToPath(import.meta.url)); /** * Session-scoped browser event log. * * Lives in `.pp/` because `settings/project-name.ts` deletes that directory at * the start of every `npm run dev`, so the log is truncated per dev session with * no cleanup code of its own -- and `.pp/` is already gitignored. */ export const BROWSER_LOG_FILE = join(SETTINGS_DIR, "..", ".pp", "browser-log.jsonl"); /** * Backstop cap. Compaction on every source change is what normally keeps the * file small; this only catches a pathological burst between two compactions. * Deliberately modest: at ~4 bytes per token, 256 KB is still ~64k tokens if * anything ever reads the raw file, and nothing should need more than that. */ const MAX_LOG_BYTES = 256 * 1024; /** Events kept when trimming (the session header is always preserved). */ const TRIM_KEEP_EVENTS = 2000; /** * An error reported within this long of its page load is treated as mount-phase. * * The distinction decides whether a clean reload is evidence of a fix. Mount * errors re-fire on every load, so a reload genuinely re-tests them. An error * from a click handler fires only when someone clicks, so a reload proves * nothing about it -- and reporting that route as CLEAN is a lie that sends * whoever reads it away from a live bug. */ const MOUNT_PHASE_MS = 2000; /** Cap a single forwarded payload so a runaway logger cannot flood the terminal. */ const MAX_BODY_BYTES = 64 * 1024; /** Identical repeated messages inside this window print once with a count. */ const DEDUPE_WINDOW_MS = 1000; type ClientLogLevel = "error" | "warn" | "log"; type ClientLogEntry = { /** `load` marks a page render; anything else is forwarded console output. */ type?: "load" | "console"; level?: ClientLogLevel; message?: string; url?: string; stack?: string; /** Per-page-load id, so an error can be tied to the load that produced it. */ page?: string; }; /** One line of `.pp/browser-log.jsonl`. Keep in sync with `browser-log.ts`. */ type LogEvent = { t: string; type: "session" | "session-end" | "load" | "error" | "warn" | "resolved" | "restart"; route?: string; page?: string; message?: string; stack?: string[]; pid?: number; port?: number; /** `resolved` only: how many earlier errors this clean load supersedes. */ supersedes?: number; /** `session` only: tells anyone reading the raw file how to read it. */ readme?: string; /** `error` only: whether a reload can re-test this. See MOUNT_PHASE_MS. */ phase?: "mount" | "interaction" | "unknown"; /** `error` only: milliseconds between the page load and the report. */ afterMs?: number; /** Survived a compaction: it predates the current code and was not re-tested. */ carried?: boolean; }; const recentMessages = new Map(); /** * Errors that are still standing, per route. * * This is the working set compaction preserves, so it must hold whole events * rather than counts. A load removes the route's mount-phase errors (that load * re-tested them); interaction errors stay, because nothing about a reload * exercises a click handler. * * In-memory state is safe here precisely because this process and the log file * have the same lifetime: PHP re-executes per request and holds no state, while * this BrowserSync process lives for the whole dev session and is the only * writer of the log. */ const openErrors = new Map(); /** Load time per page id, so an error can be placed in mount or interaction phase. */ const pageLoads = new Map(); /** The session line, replayed as the header every time the log is compacted. */ let sessionHeader: LogEvent | null = null; let logFileUsable = true; /** Drop old events once the file grows past the cap, keeping the session header. */ function trimLogFile(): void { try { const lines = readFileSync(BROWSER_LOG_FILE, "utf-8").split("\n").filter(Boolean); const header = lines.find((line) => line.includes('"type":"session"')); const tail = lines.slice(-TRIM_KEEP_EVENTS); const kept = header && !tail.includes(header) ? [header, ...tail] : tail; writeFileSync(BROWSER_LOG_FILE, kept.join("\n") + "\n", "utf-8"); } catch { // A trim failure is not worth taking the dev server down for. } } /** * Append one event to the session log. * * Synchronous on purpose: `appendFileSync` cannot interleave partial lines the * way concurrent async writes could, and dev-time volume is trivial. A logging * failure must never break the dev server, so every error is swallowed once and * the sink then disables itself. */ function appendEvent(event: LogEvent): void { if (!logFileUsable) return; try { let size = 0; try { size = statSync(BROWSER_LOG_FILE).size; } catch { mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true }); } if (size > MAX_LOG_BYTES) trimLogFile(); appendFileSync(BROWSER_LOG_FILE, JSON.stringify(event) + "\n", "utf-8"); } catch { logFileUsable = false; } } /** * Open a new session log. Called by `bs-config.ts` once BrowserSync is up. * * The header lets a reader decide whether the log is live or a leftover from a * dev server that has since exited -- the difference between "the app is clean" * and "nothing has been observed", which an agent must not confuse. */ export function startBrowserLogSession(port: number): void { openErrors.clear(); pageLoads.clear(); sessionHeader = { t: new Date().toISOString(), type: "session", pid: process.pid, port, // A reader who opens this file directly (an AI agent will) has no way to tell // a fixed error from a live one. Say so on line one rather than relying on // everyone knowing the format. readme: "History, not current state. An error is superseded by a later 'load' or " + "'resolved' for the same route -- except an error with phase 'interaction', " + "which a reload cannot re-test. Run `npm run logs` for current status.", }; appendEvent(sessionHeader); } /** * Rewrite the log down to what is still open, and mark the code boundary. * * Called on every source change. Two problems it solves at once: the file would * otherwise grow for the whole life of a dev session that is never restarted, * and errors produced by code that has since been edited would keep reading as * current. * * What survives is deliberately narrow -- the session header, a `restart` marker, * and errors nobody has re-tested. Resolved history is dropped outright; it has * already done its job. Anything carried once is dropped on the next compaction, * so a stale interaction error cannot haunt the log forever. */ export function compactBrowserLog(reason: string): void { if (!logFileUsable || !sessionHeader) return; const carried: LogEvent[] = []; for (const [route, events] of [...openErrors]) { // Already carried once: the code has changed twice since, so stop reporting it. const survivors = events.filter((event) => !event.carried); if (survivors.length === 0) { openErrors.delete(route); continue; } const marked = survivors.map((event) => ({ ...event, carried: true })); openErrors.set(route, marked); carried.push(...marked); } // Page ids no longer resolve to a load event in the file; carried errors are // read by route instead, so drop the lookup table with them. pageLoads.clear(); const restart: LogEvent = { t: new Date().toISOString(), type: "restart", message: `Source changed (${reason}); log compacted. Errors below predate the current code.`, supersedes: carried.length, }; try { mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true }); const lines = [sessionHeader, restart, ...carried] .map((event) => JSON.stringify(event)) .join("\n"); writeFileSync(BROWSER_LOG_FILE, lines + "\n", "utf-8"); } catch { logFileUsable = false; } } /** Mark a clean shutdown so a reader knows the log is finished, not abandoned. */ export function endBrowserLogSession(): void { appendEvent({ t: new Date().toISOString(), type: "session-end" }); } function shouldPrint(signature: string): { print: boolean; repeated: number } { const now = Date.now(); const previous = recentMessages.get(signature); if (previous && now - previous.at < DEDUPE_WINDOW_MS) { previous.count += 1; previous.at = now; return { print: false, repeated: previous.count }; } const repeated = previous?.count ?? 0; recentMessages.set(signature, { at: now, count: 1 }); // Keep the map from growing without bound during a long dev session. if (recentMessages.size > 200) { const cutoff = now - DEDUPE_WINDOW_MS * 10; for (const [key, value] of recentMessages) { if (value.at < cutoff) recentMessages.delete(key); } } return { print: true, repeated }; } function formatRoute(rawUrl: string | undefined): string { if (!rawUrl) return ""; try { const parsed = new URL(rawUrl); return parsed.pathname + parsed.search; } catch { return rawUrl; } } /** Keep the frames that name the failing template, not the whole runtime stack. */ function topFrames(stack: string | undefined, limit: number): string[] { if (!stack) return []; return stack .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("at ")) .slice(0, limit); } function handleEntry(entry: ClientLogEntry): void { const route = formatRoute(entry.url); // A page load is a log-only event: it is the "this route rendered" marker the // reader needs to date errors against, but printing one per navigation would // bury the errors it exists to contextualise. if (entry.type === "load") { const page = entry.page ?? ""; appendEvent({ t: new Date().toISOString(), type: "load", route, page }); pageLoads.set(page, Date.now()); if (pageLoads.size > 200) { // Long sessions accumulate page ids; the oldest can no longer receive // reports, so drop them rather than growing without bound. for (const key of [...pageLoads.keys()].slice(0, 100)) pageLoads.delete(key); } // This load re-ran mount, so any mount error from an earlier page has been // retested. Interaction errors have not: nothing here clicked anything. const standing = openErrors.get(route); if (standing) { const retested = standing.filter((event) => event.page !== page && event.phase !== "interaction"); const remaining = standing.filter((event) => !retested.includes(event)); if (retested.length > 0) { if (remaining.length > 0) openErrors.set(route, remaining); else openErrors.delete(route); // A clean reload would otherwise write nothing, leaving the error as the // last word on this route in the raw file. State the supersession. appendEvent({ t: new Date().toISOString(), type: "resolved", route, page, supersedes: retested.length, message: `Route reloaded; ${retested.length} earlier mount error(s) on this route are historical.`, }); } } return; } const level: ClientLogLevel = entry.level ?? "error"; const message = (entry.message ?? "").trim(); if (!message) return; // The file keeps every occurrence; only the terminal is deduplicated, so a // burst stays readable there without the log losing the true error count. const event: LogEvent = { t: new Date().toISOString(), type: level === "warn" ? "warn" : "error", route, page: entry.page, message, stack: topFrames(entry.stack, 5), }; if (level === "error") { // Timing is the only signal available for whether a reload can re-test this: // mount work finishes in well under a second, so a report arriving much later // came from something a person did. const loadedAt = entry.page ? pageLoads.get(entry.page) : undefined; if (loadedAt === undefined) { event.phase = "unknown"; } else { event.afterMs = Date.now() - loadedAt; event.phase = event.afterMs <= MOUNT_PHASE_MS ? "mount" : "interaction"; } openErrors.set(route, [...(openErrors.get(route) ?? []), event]); } if (level === "error" || level === "warn") { appendEvent(event); } printEntry(entry, level, message, route); } function printEntry( entry: ClientLogEntry, level: ClientLogLevel, message: string, route: string, ): void { const { print, repeated } = shouldPrint(`${level}:${message}`); if (!print) return; const badge = level === "error" ? chalk.bgRed.black.bold(" BROWSER ERROR ") : level === "warn" ? chalk.bgYellow.black.bold(" BROWSER WARN ") : chalk.bgBlue.black.bold(" BROWSER LOG "); const suppressed = repeated > 1 ? chalk.gray(` (${repeated} identical suppressed)`) : ""; console.log(""); console.log(`${badge} ${route ? chalk.cyan(route) : ""}${suppressed}`); for (const line of message.split("\n")) { console.log(` ${level === "error" ? chalk.red(line) : chalk.yellow(line)}`); } // The first frames are the runtime's own internals; the useful location is // the compiled template expression, which the message already names. for (const frame of topFrames(entry.stack, 3)) { console.log(chalk.gray(` ${frame}`)); } console.log(""); } function readBody(req: IncomingMessage): Promise { return new Promise((resolve) => { let size = 0; const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY_BYTES) { req.destroy(); resolve(""); return; } chunks.push(chunk); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); req.on("error", () => resolve("")); }); } /** * Client script served at `/__pp-devlog.js`. * * Forwards every `console.error` / `console.warn` from page code (PulsePoint * `[PP-ERROR]` / `[PP-WARN]` diagnostics included) plus genuine uncaught errors * and unhandled rejections, so an app-authored `console.error` in a route * script is enough to flag the route. `console.log` stays in the browser where * it belongs, keeping ordinary debugging out of the terminal. */ const CLIENT_SCRIPT = `(() => { if (window.__ppDevLogInstalled) return; window.__ppDevLogInstalled = true; var ENDPOINT = ${JSON.stringify(DEV_LOG_PATH)}; // Identifies this page load. Two POSTs can arrive out of order, so the reader // groups an error with its load by id rather than by arrival time -- that is // what makes "did this route reload clean?" answerable. var PAGE_ID = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); function post(payload) { try { payload.url = location.href; payload.page = PAGE_ID; var body = JSON.stringify(payload); // keepalive lets the report survive a navigation triggered by the error. fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: body, keepalive: true }).catch(function () {}); } catch (e) {} } function send(level, message, stack) { post({ type: "console", level: level, message: String(message).slice(0, 8000), stack: stack ? String(stack).slice(0, 4000) : undefined }); } function format(args) { return Array.prototype.map .call(args, function (arg) { if (arg instanceof Error) return arg.message; if (typeof arg === "string") return arg; try { return JSON.stringify(arg); } catch (e) { return String(arg); } }) .join(" "); } function hook(name, level) { var original = console[name]; console[name] = function () { var text = format(arguments); if (text) { var stack; for (var i = 0; i < arguments.length; i++) { if (arguments[i] instanceof Error) { stack = arguments[i].stack; break; } } send(level, text, stack); } return original.apply(console, arguments); }; } hook("error", "error"); hook("warn", "warn"); // Announce the load before anything can fail, so even a mount-time error has // a page record to attach to. post({ type: "load" }); window.addEventListener("error", function (event) { if (!event) return; var error = event.error; send( "error", "Uncaught " + (error && error.message ? error.message : event.message), error && error.stack ); }); window.addEventListener("unhandledrejection", function (event) { var reason = event && event.reason; send( "error", "Unhandled promise rejection: " + (reason && reason.message ? reason.message : String(reason)), reason && reason.stack ); }); })();`; /** * Inject the client hook into a proxied HTML document. * * Called from the response interceptor in `bs-config.ts`. Injected as a classic * script in `` so it runs during parse, before the deferred module that * boots PulsePoint (`/js/main.js` is `type="module"`) -- otherwise the first * mount errors, the ones worth seeing, would fire before the hook exists. * Fragments and non-document responses have no ``, so they pass through. */ export function injectDevLogScript(html: string): string { if (!html.includes("") || html.includes(DEV_LOG_CLIENT_PATH)) { return html; } return html.replace("", `${DEV_LOG_SCRIPT_TAG}`); } /** * BrowserSync middleware pair: serves the client hook and receives its reports. * * Returned as a connect-style middleware; anything that is not one of the two * dev-log paths falls straight through to the proxy. */ export function devLogMiddleware( req: IncomingMessage, res: ServerResponse, next: () => void, ): void { const url = (req.url || "").split("?")[0]; if (url === DEV_LOG_CLIENT_PATH) { res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-store", }); res.end(CLIENT_SCRIPT); return; } if (url === DEV_LOG_PATH && req.method === "POST") { void readBody(req).then((raw) => { try { if (raw) handleEntry(JSON.parse(raw) as ClientLogEntry); } catch { // A malformed report must never take the dev server down. } res.writeHead(204).end(); }); return; } next(); }