Files
WRNexusJS/packages/core/src/errors.ts
T
2026-07-12 15:55:18 +05:30

221 lines
9.1 KiB
TypeScript

/**
* Error + status pages. Every page here is a self-contained HTML document —
* inline CSS only, no external stylesheet, no JavaScript (so it renders under the
* strict CSP, even when the app's assets are what failed). Theme-aware via
* `prefers-color-scheme`, styled in the WrNexus design language (ink-navy,
* azure, a faint blueprint grid + glow). Development shows the stack trace;
* production never leaks internal paths.
*/
import { escapeHtml } from "./security.ts";
export type Mode = "development" | "production";
interface ErrorPageOptions {
status: number;
/** Big display code, e.g. "404" / "500". */
code: string;
title: string;
message: string;
/** Monospace eyebrow, e.g. "ERROR 404". */
eyebrow?: string;
/** Optional dev-only detail (error name + stack), rendered in a code panel. */
detail?: { heading: string; body: string };
/** Show a "Back home" action (default true). */
home?: boolean;
}
/** Shared, self-contained, theme-aware error document. */
function errorDocument(o: ErrorPageOptions): string {
const eyebrow = escapeHtml(o.eyebrow ?? `ERROR ${o.status}`);
const title = escapeHtml(o.title);
const message = escapeHtml(o.message);
const detail = o.detail
? `
<section class="detail">
<div class="detail-head">${escapeHtml(o.detail.heading)}</div>
<pre class="detail-body">${escapeHtml(o.detail.body)}</pre>
</section>`
: "";
const home = o.home === false ? "" : `<a class="btn btn-primary" href="/">Back to home</a>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>${title}</title>
<style>
:root {
--bg: #0a0e17; --bg2: #070a12; --text: #e7ecf5; --muted: #93a1b8;
--brand: #6ea0ff; --brand-2: #3f7dff; --border: rgba(255,255,255,.10);
--card: rgba(255,255,255,.03); --grid: rgba(110,160,255,.10);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f9fc; --bg2: #eef2f8; --text: #0f172a; --muted: #5a6b85;
--brand: #2b62f0; --brand-2: #2b62f0; --border: rgba(15,23,42,.10);
--card: rgba(15,23,42,.02); --grid: rgba(43,98,240,.09);
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
display: grid; place-items: center; min-height: 100%;
padding: clamp(1.5rem, 5vw, 4rem); position: relative; overflow-x: hidden;
}
/* Blueprint grid + radial glow backdrop. */
body::before {
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(to right, var(--grid) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid) 1px, transparent 1px);
background-size: 56px 56px;
-webkit-mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
mask-image: radial-gradient(ellipse 75% 60% at 50% 30%, #000 10%, transparent 72%);
}
body::after {
content: ""; position: fixed; left: 50%; top: -10%; z-index: 0; pointer-events: none;
width: min(680px, 90vw); height: 420px; transform: translateX(-50%);
background: radial-gradient(circle at center, color-mix(in oklab, var(--brand-2) 34%, transparent), transparent 68%);
filter: blur(8px); opacity: .55;
}
main {
position: relative; z-index: 1; width: 100%; max-width: 640px; text-align: center;
animation: rise .6s cubic-bezier(.16,1,.3,1) both;
}
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }
.eyebrow {
font: 600 .72rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
letter-spacing: .22em; color: var(--brand); text-transform: uppercase;
}
.code {
margin: .5rem 0 0; font-weight: 800; line-height: .9;
font-size: clamp(5rem, 22vw, 11rem); letter-spacing: -.04em;
background: linear-gradient(180deg, var(--text), color-mix(in oklab, var(--brand) 60%, var(--text)));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
h1 { margin: .25rem 0 0; font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 700; letter-spacing: -.02em; }
.msg { margin: .9rem auto 0; max-width: 30rem; color: var(--muted); line-height: 1.65; font-size: 1rem; }
.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: .75rem; justify-content: center; }
.btn {
display: inline-flex; align-items: center; gap: .5rem; text-decoration: none;
padding: .7rem 1.25rem; border-radius: 10px; font-weight: 600; font-size: .9rem;
transition: transform .12s ease, filter .12s ease, border-color .12s ease;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
color: #fff; background: linear-gradient(180deg, var(--brand), var(--brand-2));
box-shadow: 0 8px 24px -10px color-mix(in oklab, var(--brand-2) 80%, transparent);
}
.btn-primary:hover { filter: brightness(1.08); }
.detail {
margin: 2.25rem auto 0; text-align: left; max-width: 100%;
border: 1px solid var(--border); border-radius: 12px; background: var(--card); overflow: hidden;
}
.detail-head {
padding: .7rem 1rem; font: 600 .75rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace;
color: var(--brand); border-bottom: 1px solid var(--border);
white-space: pre-wrap; word-break: break-word;
}
.detail-body {
margin: 0; padding: 1rem; max-height: 40vh; overflow: auto;
font: .8rem/1.6 ui-monospace, "SFMono-Regular", Menlo, monospace;
color: var(--muted); white-space: pre-wrap; word-break: break-word;
}
</style>
</head>
<body>
<main>
<p class="eyebrow">${eyebrow}</p>
<div class="code" aria-hidden="true">${escapeHtml(o.code)}</div>
<h1>${title}</h1>
<p class="msg">${message}</p>
<div class="actions">${home}</div>
${detail}
</main>
</body>
</html>`;
}
/** Common status → friendly copy, for a generic HTML status page. */
const STATUS_COPY: Record<number, { title: string; message: string }> = {
400: {
title: "Bad request",
message: "The request couldn't be understood. Check the URL and try again.",
},
401: { title: "Sign in required", message: "You need to be signed in to view this page." },
403: { title: "Access denied", message: "You don't have permission to view this page." },
404: {
title: "Page not found",
message: "The page you're looking for doesn't exist or has moved.",
},
413: { title: "Too large", message: "The request was larger than the server allows." },
429: {
title: "Slow down",
message: "You've made too many requests. Please wait a moment and try again.",
},
500: {
title: "Something went wrong",
message: "The server hit an unexpected error. Please try again in a moment.",
},
502: {
title: "Bad gateway",
message: "We couldn't reach an upstream service. Please try again shortly.",
},
503: {
title: "Temporarily unavailable",
message: "The service is down for a moment. Please try again shortly.",
},
};
/** A beautiful, self-contained HTML page for any 4xx/5xx status. */
export function renderStatusPage(status: number): Response {
const copy = STATUS_COPY[status] ?? {
title: status >= 500 ? "Something went wrong" : "Something's not right",
message: "An unexpected response was returned. Please try again.",
};
return new Response(
errorDocument({ status, code: String(status), title: copy.title, message: copy.message }),
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
/** Readable, styled development error page — includes the stack trace. */
export function renderDevError(err: unknown, status = 500): Response {
const error = err instanceof Error ? err : new Error(String(err));
const name = error.name || "Error";
const message = error.message || "Unknown error";
return new Response(
errorDocument({
status,
code: String(status),
eyebrow: "DEVELOPMENT ERROR",
title: name,
message,
detail: { heading: `${name}: ${message}`, body: error.stack || "(no stack available)" },
}),
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
/** Generic production error page — no stack, no file paths. */
export function renderProdError(status = 500): Response {
return renderStatusPage(status);
}
/** Pick the right error page for the current mode. */
export function renderError(err: unknown, mode: Mode): Response {
return mode === "development" ? renderDevError(err) : renderProdError();
}
/** Beautiful 404 page. */
export function renderNotFound(): Response {
return renderStatusPage(404);
}