/**
* 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 === "\"" ? """
: "'",
);
}