From 3c6f61c4e40fc9f7e557145d3b40a96e8a0bd112 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 6 Jan 2026 00:43:00 -0600 Subject: [PATCH] feat: add prerendered error pages with Rust integration - Prerender 20+ HTTP error codes (4xx/5xx) at build time - Embed error pages in binary and serve via Axum - Intercept error responses for HTML requests - Add transient flag for retry-worthy errors (502/503/504) --- src/assets.rs | 17 +++++ src/main.rs | 73 +++++++++++++++++++ web/src/lib/error-codes.ts | 41 +++++++++++ web/src/lib/og-types.ts | 25 ++++--- web/src/routes/+error.svelte | 38 ++++++++++ .../routes/errors/[code]/+layout.server.ts | 2 + web/src/routes/errors/[code]/+page.server.ts | 25 +++++++ web/src/routes/errors/[code]/+page.svelte | 38 ++++++++++ web/svelte.config.js | 13 ++-- 9 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/error-codes.ts create mode 100644 web/src/routes/+error.svelte create mode 100644 web/src/routes/errors/[code]/+layout.server.ts create mode 100644 web/src/routes/errors/[code]/+page.server.ts create mode 100644 web/src/routes/errors/[code]/+page.svelte diff --git a/src/assets.rs b/src/assets.rs index edd08ea..c917fb5 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -5,6 +5,7 @@ use axum::{ use include_dir::{Dir, include_dir}; static CLIENT_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/client"); +static ERROR_PAGES: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/prerendered/errors"); pub async fn serve_embedded_asset(uri: Uri) -> Response { let path = uri.path(); @@ -43,3 +44,19 @@ pub async fn serve_embedded_asset(uri: Uri) -> Response { (StatusCode::NOT_FOUND, "Asset not found").into_response() } } + +/// Get prerendered error page HTML for a given status code. +/// +/// Error pages are prerendered by SvelteKit and embedded at compile time. +/// The list of available error codes is defined in web/src/lib/error-codes.ts. +/// +/// # Arguments +/// * `status_code` - HTTP status code (e.g., 404, 500) +/// +/// # Returns +/// * `Some(&[u8])` - HTML content if error page exists +/// * `None` - If no prerendered page exists for this code +pub fn get_error_page(status_code: u16) -> Option<&'static [u8]> { + let filename = format!("{}.html", status_code); + ERROR_PAGES.get_file(&filename).map(|f| f.contents()) +} diff --git a/src/main.rs b/src/main.rs index 15b46fe..538210f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -300,6 +300,57 @@ async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse { api_404_handler(uri).await } +fn accepts_html(headers: &HeaderMap) -> bool { + if let Some(accept) = headers.get(axum::http::header::ACCEPT) { + if let Ok(accept_str) = accept.to_str() { + return accept_str.contains("text/html") || accept_str.contains("*/*"); + } + } + // Default to true for requests without Accept header (browsers typically send it) + true +} + +fn serve_error_page(status: StatusCode) -> Response { + let status_code = status.as_u16(); + + if let Some(html) = assets::get_error_page(status_code) { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); + + (status, headers, html).into_response() + } else { + // Fallback for undefined error codes (500 generic page) + tracing::warn!( + status_code, + "No prerendered error page found for status code - using fallback" + ); + + if let Some(fallback_html) = assets::get_error_page(500) { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"), + ); + + (status, headers, fallback_html).into_response() + } else { + // Last resort: plaintext (should never happen if 500.html exists) + (status, format!("Error {}", status_code)).into_response() + } + } +} + async fn health_handler(State(state): State>) -> impl IntoResponse { let healthy = state.health_checker.check().await; @@ -465,6 +516,11 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon if method != axum::http::Method::GET && method != axum::http::Method::HEAD { tracing::warn!(method = %method, path = %path, "Non-GET/HEAD request to non-API route"); + + if accepts_html(req.headers()) { + return serve_error_page(StatusCode::METHOD_NOT_ALLOWED); + } + let mut headers = HeaderMap::new(); headers.insert( axum::http::header::ALLOW, @@ -488,6 +544,11 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon // Block internal routes from external access if path.starts_with("/internal/") { tracing::warn!(path = %path, "Attempted access to internal route"); + + if accepts_html(req.headers()) { + return serve_error_page(StatusCode::NOT_FOUND); + } + return (StatusCode::NOT_FOUND, "Not found").into_response(); } @@ -551,6 +612,12 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon } } + // Intercept error responses for HTML requests + if (status.is_client_error() || status.is_server_error()) && accepts_html(req.headers()) + { + return serve_error_page(status); + } + if is_head { (status, headers).into_response() } else { @@ -565,6 +632,12 @@ async fn isr_handler(State(state): State>, req: Request) -> Respon duration_ms, "Failed to proxy to Bun" ); + + // Serve 502 error page instead of plaintext + if accepts_html(req.headers()) { + return serve_error_page(StatusCode::BAD_GATEWAY); + } + ( StatusCode::BAD_GATEWAY, format!("Failed to render page: {err}"), diff --git a/web/src/lib/error-codes.ts b/web/src/lib/error-codes.ts new file mode 100644 index 0000000..2beb9ee --- /dev/null +++ b/web/src/lib/error-codes.ts @@ -0,0 +1,41 @@ +/** + * Single source of truth for all HTTP error codes. + * Used by: + * - SvelteKit EntryGenerator (prerendering) + * - Error page components (rendering) + * - Rust assets.rs (validation only) + */ +export const ERROR_CODES = { + // 4xx Client Errors + 400: { message: "Bad request", transient: false }, + 401: { message: "Unauthorized", transient: false }, + 403: { message: "Forbidden", transient: false }, + 404: { message: "Page not found", transient: false }, + 405: { message: "Method not allowed", transient: false }, + 406: { message: "Not acceptable", transient: false }, + 408: { message: "Request timeout", transient: true }, + 409: { message: "Conflict", transient: false }, + 410: { message: "Gone", transient: false }, + 413: { message: "Payload too large", transient: false }, + 414: { message: "URI too long", transient: false }, + 415: { message: "Unsupported media type", transient: false }, + 418: { message: "I'm a teapot", transient: false }, // RFC 2324 Easter egg + 422: { message: "Unprocessable entity", transient: false }, + 429: { message: "Too many requests", transient: true }, + 451: { message: "Unavailable for legal reasons", transient: false }, + + // 5xx Server Errors + 500: { message: "Internal server error", transient: false }, + 501: { message: "Not implemented", transient: false }, + 502: { message: "Bad gateway", transient: true }, + 503: { message: "Service unavailable", transient: true }, + 504: { message: "Gateway timeout", transient: true }, + 505: { message: "HTTP version not supported", transient: false }, +} as const; + +export type ErrorCode = keyof typeof ERROR_CODES; + +// Helper to check if error code is defined +export function isDefinedErrorCode(code: number): code is ErrorCode { + return code in ERROR_CODES; +} diff --git a/web/src/lib/og-types.ts b/web/src/lib/og-types.ts index 10bcad4..4a3a7f3 100644 --- a/web/src/lib/og-types.ts +++ b/web/src/lib/og-types.ts @@ -16,18 +16,19 @@ export type OGImageSpec = * @returns Full URL to the R2-hosted image */ export function getOGImageUrl(spec: OGImageSpec): string { - const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL; + const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL; - if (!R2_BASE) { - throw new Error("VITE_OG_R2_BASE_URL environment variable is not set"); - } + if (!R2_BASE) { + // During prerendering or development, use a fallback placeholder + return "/og/placeholder.png"; + } - switch (spec.type) { - case "index": - return `${R2_BASE}/og/index.png`; - case "projects": - return `${R2_BASE}/og/projects.png`; - case "project": - return `${R2_BASE}/og/project/${spec.id}.png`; - } + switch (spec.type) { + case "index": + return `${R2_BASE}/og/index.png`; + case "projects": + return `${R2_BASE}/og/projects.png`; + case "project": + return `${R2_BASE}/og/project/${spec.id}.png`; + } } diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte new file mode 100644 index 0000000..658ed6f --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,38 @@ + + + + {status} - {message} + + + +
+
+

{status}

+

{message}

+ {#if showHomeLink} + + Return home + + {/if} +
+
+
diff --git a/web/src/routes/errors/[code]/+layout.server.ts b/web/src/routes/errors/[code]/+layout.server.ts new file mode 100644 index 0000000..314e2e0 --- /dev/null +++ b/web/src/routes/errors/[code]/+layout.server.ts @@ -0,0 +1,2 @@ +// Reset layout chain - no server-side data loading for error pages +export const prerender = true; diff --git a/web/src/routes/errors/[code]/+page.server.ts b/web/src/routes/errors/[code]/+page.server.ts new file mode 100644 index 0000000..6d13242 --- /dev/null +++ b/web/src/routes/errors/[code]/+page.server.ts @@ -0,0 +1,25 @@ +import { ERROR_CODES } from "$lib/error-codes"; +import type { EntryGenerator, PageServerLoad } from "./$types"; + +/** + * Tell SvelteKit to prerender all defined error codes. + * This generates static HTML files at build time. + */ +export const entries: EntryGenerator = () => { + return Object.keys(ERROR_CODES).map((code) => ({ code })); +}; + +export const prerender = true; + +/** + * Load error metadata for the page. + * This runs during prerendering to generate static HTML. + */ +export const load: PageServerLoad = ({ params }) => { + const code = parseInt(params.code, 10) as keyof typeof ERROR_CODES; + + return { + code, + ...ERROR_CODES[code], + }; +}; diff --git a/web/src/routes/errors/[code]/+page.svelte b/web/src/routes/errors/[code]/+page.svelte new file mode 100644 index 0000000..6d0a44c --- /dev/null +++ b/web/src/routes/errors/[code]/+page.svelte @@ -0,0 +1,38 @@ + + + + {title} + + + +
+
+

+ {data.code} +

+

+ {data.message} +

+ + + {#if !data.transient} + + Return home + + {/if} +
+
+
diff --git a/web/svelte.config.js b/web/svelte.config.js index 08441ee..86bd51d 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -8,16 +8,19 @@ const config = { kit: { adapter: adapter({ out: "build", - precompress: { - brotli: true, - gzip: true, - files: ["html", "js", "json", "css", "svg", "xml", "wasm"], - }, + precompress: true, serveAssets: false, }), alias: { $components: "src/lib/components", }, + prerender: { + handleHttpError: ({ path, referrer, message }) => { + console.log( + `Prerender error for ${path} (from ${referrer}): ${message}`, + ); + }, + }, }, };