mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
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)
This commit is contained in:
@@ -5,6 +5,7 @@ use axum::{
|
|||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
|
|
||||||
static CLIENT_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/client");
|
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 {
|
pub async fn serve_embedded_asset(uri: Uri) -> Response {
|
||||||
let path = uri.path();
|
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()
|
(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())
|
||||||
|
}
|
||||||
|
|||||||
+73
@@ -300,6 +300,57 @@ async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
|
|||||||
api_404_handler(uri).await
|
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<Arc<AppState>>) -> impl IntoResponse {
|
async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
let healthy = state.health_checker.check().await;
|
let healthy = state.health_checker.check().await;
|
||||||
|
|
||||||
@@ -465,6 +516,11 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
|
|
||||||
if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
|
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");
|
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();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
axum::http::header::ALLOW,
|
axum::http::header::ALLOW,
|
||||||
@@ -488,6 +544,11 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
// Block internal routes from external access
|
// Block internal routes from external access
|
||||||
if path.starts_with("/internal/") {
|
if path.starts_with("/internal/") {
|
||||||
tracing::warn!(path = %path, "Attempted access to internal route");
|
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();
|
return (StatusCode::NOT_FOUND, "Not found").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +612,12 @@ async fn isr_handler(State(state): State<Arc<AppState>>, 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 {
|
if is_head {
|
||||||
(status, headers).into_response()
|
(status, headers).into_response()
|
||||||
} else {
|
} else {
|
||||||
@@ -565,6 +632,12 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
|||||||
duration_ms,
|
duration_ms,
|
||||||
"Failed to proxy to Bun"
|
"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,
|
StatusCode::BAD_GATEWAY,
|
||||||
format!("Failed to render page: {err}"),
|
format!("Failed to render page: {err}"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+13
-12
@@ -16,18 +16,19 @@ export type OGImageSpec =
|
|||||||
* @returns Full URL to the R2-hosted image
|
* @returns Full URL to the R2-hosted image
|
||||||
*/
|
*/
|
||||||
export function getOGImageUrl(spec: OGImageSpec): string {
|
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) {
|
if (!R2_BASE) {
|
||||||
throw new Error("VITE_OG_R2_BASE_URL environment variable is not set");
|
// During prerendering or development, use a fallback placeholder
|
||||||
}
|
return "/og/placeholder.png";
|
||||||
|
}
|
||||||
|
|
||||||
switch (spec.type) {
|
switch (spec.type) {
|
||||||
case "index":
|
case "index":
|
||||||
return `${R2_BASE}/og/index.png`;
|
return `${R2_BASE}/og/index.png`;
|
||||||
case "projects":
|
case "projects":
|
||||||
return `${R2_BASE}/og/projects.png`;
|
return `${R2_BASE}/og/projects.png`;
|
||||||
case "project":
|
case "project":
|
||||||
return `${R2_BASE}/og/project/${spec.id}.png`;
|
return `${R2_BASE}/og/project/${spec.id}.png`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AppWrapper from "$lib/components/AppWrapper.svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
|
const status = $derived($page.status);
|
||||||
|
|
||||||
|
const messages: Record<number, string> = {
|
||||||
|
404: "Page not found",
|
||||||
|
405: "Method not allowed",
|
||||||
|
500: "Something went wrong",
|
||||||
|
502: "Service temporarily unavailable",
|
||||||
|
503: "Service temporarily unavailable",
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = $derived(messages[status] || "An error occurred");
|
||||||
|
const showHomeLink = $derived(![502, 503].includes(status));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{status} - {message}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AppWrapper>
|
||||||
|
<div class="flex min-h-screen items-center justify-center">
|
||||||
|
<div class="mx-4 max-w-2xl text-center">
|
||||||
|
<h1 class="mb-4 font-hanken text-8xl text-zinc-200">{status}</h1>
|
||||||
|
<p class="mb-8 text-2xl text-zinc-400">{message}</p>
|
||||||
|
{#if showHomeLink}
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-block rounded-sm bg-zinc-900 px-4 py-2 text-zinc-100 transition-colors hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Return home
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppWrapper>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Reset layout chain - no server-side data loading for error pages
|
||||||
|
export const prerender = true;
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from "$app/paths";
|
||||||
|
import AppWrapper from "$components/AppWrapper.svelte";
|
||||||
|
import Dots from "$lib/components/Dots.svelte";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
`${data.code} - ${data.message.charAt(0).toUpperCase() + data.message.slice(1)}`,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{title}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AppWrapper>
|
||||||
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="mx-4 max-w-3xl text-center">
|
||||||
|
<h1 class="text-6xl sm:text-9xl font-hanken font-black text-zinc-200">
|
||||||
|
{data.code}
|
||||||
|
</h1>
|
||||||
|
<p class="text-2xl sm:text-3xl text-zinc-400 mb-8 capitalize">
|
||||||
|
{data.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Only show "Return home" for non-transient errors -->
|
||||||
|
{#if !data.transient}
|
||||||
|
<a
|
||||||
|
href={resolve("/")}
|
||||||
|
class="inline-block py-2 px-4 bg-zinc-900 text-zinc-50 no-underline rounded-sm transition-colors hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Return home
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppWrapper>
|
||||||
@@ -8,16 +8,19 @@ const config = {
|
|||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
out: "build",
|
out: "build",
|
||||||
precompress: {
|
precompress: true,
|
||||||
brotli: true,
|
|
||||||
gzip: true,
|
|
||||||
files: ["html", "js", "json", "css", "svg", "xml", "wasm"],
|
|
||||||
},
|
|
||||||
serveAssets: false,
|
serveAssets: false,
|
||||||
}),
|
}),
|
||||||
alias: {
|
alias: {
|
||||||
$components: "src/lib/components",
|
$components: "src/lib/components",
|
||||||
},
|
},
|
||||||
|
prerender: {
|
||||||
|
handleHttpError: ({ path, referrer, message }) => {
|
||||||
|
console.log(
|
||||||
|
`Prerender error for ${path} (from ${referrer}): ${message}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user