/** * Phase 18.α — Dev Error Overlay injector. * * Provides two surfaces: * * 1. `buildOverlayHeadTag(config)` — returns the `` + `` ); } /** * Thin wrapper called from `runtime/ssr.ts`. The function name matches * the spec ("maybeInjectDevOverlay"). Returning a string keeps ssr.ts' * template-literal interpolation ergonomic. */ export function maybeInjectDevOverlay(config: DevOverlayInjectorConfig): string { return buildOverlayHeadTag(config); } /** * Parse a raw `error.stack` string into frames on the server, mirroring * the client-side parser. Server-side preparation means the overlay * renders the structured list immediately — no regex cost on the main * thread at page load. * * Deliberately simple: handles Chrome / V8 (` at fn (file:line:col)`) * and Firefox (`fn@file:line:col`). Anything else becomes an * `` frame carrying the raw line text, so information is * never lost. */ export function parseStackFrames(stack: string | undefined | null): DevErrorStackFrame[] { if (!stack) return []; const lines = stack.split("\n"); const out: DevErrorStackFrame[] = []; for (const raw of lines) { const t = raw.trim(); if (!t) continue; // Skip the first line if it's just "Error: message" (the header). if (/^[A-Z][a-zA-Z]*(?:Error)?:/.test(t) && out.length === 0) continue; let m = t.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/); if (m) { out.push({ fn: m[1], file: m[2], line: Number.parseInt(m[3], 10), column: Number.parseInt(m[4], 10), raw, }); continue; } m = t.match(/^at\s+(.+):(\d+):(\d+)$/); if (m) { out.push({ fn: "", file: m[1], line: Number.parseInt(m[2], 10), column: Number.parseInt(m[3], 10), raw, }); continue; } m = t.match(/^(.*?)@(.+):(\d+):(\d+)$/); if (m) { out.push({ fn: m[1] || "", file: m[2], line: Number.parseInt(m[3], 10), column: Number.parseInt(m[4], 10), raw, }); continue; } out.push({ fn: "", file: t, line: null, column: null, raw }); } return out; } /** * Build a `DevErrorPayload` from a caught `Error`-like value. Safe * against non-Error throws (strings, plain objects, undefined). */ export function buildPayloadFromError( err: unknown, extra: { kind?: DevErrorPayload["kind"]; routeId?: string; url?: string } = {}, ): DevErrorPayload { let name = "Error"; let message = ""; let stack = ""; if (err && typeof err === "object") { const rec = err as { name?: unknown; message?: unknown; stack?: unknown }; name = typeof rec.name === "string" ? rec.name : "Error"; message = typeof rec.message === "string" ? rec.message : ""; stack = typeof rec.stack === "string" ? rec.stack : ""; } else if (typeof err === "string") { message = err; } else if (err !== null && err !== undefined) { try { message = String(err); } catch { message = ""; } } return { name, message, frames: parseStackFrames(stack), stack, kind: extra.kind ?? "ssr", timestamp: Date.now(), routeId: extra.routeId, url: extra.url, }; } /** * Build the body-end embed tags for an SSR 500 response. The first tag * carries the payload as JSON (the IIFE picks it up on * DOMContentLoaded); the second tag is a defensive re-dispatch for * pages that already fired DOMContentLoaded by the time the overlay * mounts (possible when HMR re-runs the client bundle). * * Both tags are safe to splice verbatim — `escapeJsonForInlineScript` * neutralises any `` in the payload. */ export function buildOverlayErrorEmbed(payload: DevErrorPayload): string { const json = escapeJsonForInlineScript(JSON.stringify(payload)); const dispatch = `(function(){try{var d=${json};window.dispatchEvent(new CustomEvent(${JSON.stringify( OVERLAY_CUSTOM_EVENT, )},{detail:d}));}catch(_){}})();`; return ( `` + `` ); } /** * Produce a minimal HTML document for the SSR 500 surface in dev. The * overlay client IIFE is inlined so the overlay renders even when the * React tree never emitted a root — which is exactly when `createSSRErrorResponse` * fires. Returns a complete `` string; the server wraps * it in a 500 `Response`. */ export function buildOverlayErrorHtml(payload: DevErrorPayload): string { return ( `` + `Mandu — ${escapeTitle(payload.name)}` + buildOverlayHeadTag({ isDev: true }) + `
` + buildOverlayErrorEmbed(payload) + `` ); } function escapeTitle(s: string): string { return s.replace(/[<>&"']/g, (c) => c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === "\"" ? """ : "'", ); }