221 lines
9.1 KiB
TypeScript
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);
|
|
}
|