first commit
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user