feat(maintenance): static fallback when the CMS is unreachable
Deploy / verify (push) Successful in 43s
Deploy / deploy (push) Successful in 1m4s

Adds a self-contained maintenance page that the SvelteKit handle hook
serves before the first CMS call when `isCmsAvailable()` returns
false. The probe hits `/api-docs/openapi.json` with a short timeout
and caches the result (10s healthy, 3s unhealthy) so we don't pay a
round-trip per request. `/maintenance` and `?cms_down=1` simulate
the page without taking the CMS down — useful for previewing the
copy.

The maintenance HTML is fully inline (no CMS calls, no layout, no
external CSS) so it survives even when every dependency is down.
Returned with 503 + Retry-After:60 + noindex headers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-04 14:47:15 +02:00
parent 1e9ef44b52
commit 56084ac542
3 changed files with 155 additions and 0 deletions
+34
View File
@@ -1,4 +1,6 @@
import { getTranslationBundleBySlug } from '$lib/cms';
import { isCmsAvailable } from '$lib/cms-health.server';
import { maintenanceResponse } from '$lib/maintenance.server';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
import type { Handle } from '@sveltejs/kit';
@@ -9,6 +11,8 @@ import type { Handle } from '@sveltejs/kit';
const PAGE_CACHE_CONTROL =
'public, max-age=0, s-maxage=30, stale-while-revalidate=300';
const ASSET_EXT_RE = /\.(ico|png|jpe?g|webp|gif|svg|css|js|mjs|map|woff2?|ttf|otf|txt|xml|json|pdf)$/i;
function isCacheablePath(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
if (pathname.startsWith('/cms-images/')) return false;
@@ -16,7 +20,37 @@ function isCacheablePath(pathname: string): boolean {
return true;
}
/** Pfade auf denen wir vor dem Resolve einen CMS-Health-Check machen. */
function shouldHealthCheck(pathname: string): boolean {
if (pathname.startsWith('/api/')) return false;
if (pathname.startsWith('/_app/')) return false;
if (pathname.startsWith('/cms-images/')) return false;
if (pathname.startsWith('/cms-files/')) return false;
if (pathname === '/sitemap.xml' || pathname === '/robots.txt') return false;
if (ASSET_EXT_RE.test(pathname)) return false;
return true;
}
function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boolean {
if (pathname === '/maintenance' || pathname === '/maintenance/') return true;
if (search.has('cms_down')) return true;
return false;
}
export const handle: Handle = async ({ event, resolve }) => {
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
// CMS abzuschalten. /maintenance oder ?cms_down=1.
if (isMaintenanceSimulation(event.url.pathname, event.url.searchParams)) {
return maintenanceResponse(200);
}
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
// Cached, daher kein Roundtrip pro Request.
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
const ok = await isCmsAvailable();
if (!ok) return maintenanceResponse(503);
}
try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, { locale: 'de' });
if (bundle?.strings && typeof bundle.strings === 'object') {
+60
View File
@@ -0,0 +1,60 @@
/**
* Health-Probe für die RustyCMS API. Ergebnisse werden kurz gecacht, damit nicht
* jeder Request einen extra Roundtrip auslöst. Bei Negativ-Status kürzere TTL,
* damit das Frontend zügig wieder hochkommt sobald das CMS antwortet.
*/
import { env } from '$env/dynamic/public';
const HEALTHY_TTL_MS = 10_000;
const UNHEALTHY_TTL_MS = 3_000;
const PROBE_TIMEOUT_MS = 1_500;
type CacheEntry = { healthy: boolean; expires: number };
let cached: CacheEntry | null = null;
let inflight: Promise<boolean> | null = null;
function getBaseUrl(): string {
const url = env.PUBLIC_CMS_URL || 'http://localhost:3000';
return url.replace(/\/$/, '');
}
async function probe(): Promise<boolean> {
const base = getBaseUrl();
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(`${base}/api-docs/openapi.json`, {
method: 'GET',
signal: ctrl.signal,
});
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
export async function isCmsAvailable(): Promise<boolean> {
const now = Date.now();
if (cached && cached.expires > now) return cached.healthy;
if (inflight) return inflight;
const p = (async () => {
const healthy = await probe();
cached = {
healthy,
expires: Date.now() + (healthy ? HEALTHY_TTL_MS : UNHEALTHY_TTL_MS),
};
return healthy;
})();
inflight = p;
p.finally(() => {
if (inflight === p) inflight = null;
});
return p;
}
export function resetCmsHealthCache(): void {
cached = null;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Statisches Maintenance-HTML für CMS-Ausfälle.
* Selbsttragend: keine CMS-Calls, kein Layout, inline CSS.
*/
const MAINTENANCE_HTML = `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Wartungsarbeiten — windwiderstand.de</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: light; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: #f8f7f3;
color: #18181b;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 1.5rem;
}
.card { max-width: 36rem; text-align: center; }
.badge {
display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px;
background: #e7f2ea; color: #2d7a45;
font-size: 0.875rem; font-weight: 600; letter-spacing: 0.025em;
margin-bottom: 1.25rem;
}
h1 {
font-size: clamp(1.75rem, 4vw, 2.5rem);
margin: 0 0 0.75rem; color: #18181b; font-weight: 700;
}
p {
font-size: 1.0625rem; line-height: 1.6; color: #52525b;
margin: 0 0 0.75rem;
}
.small { font-size: 0.875rem; color: #71717a; margin-top: 1.5rem; }
a { color: #2d7a45; }
</style>
</head>
<body>
<main class="card" role="main">
<span class="badge">Wartungsmodus</span>
<h1>Wir sind gleich zurück.</h1>
<p>Diese Seite wird gerade aktualisiert oder ist vorübergehend nicht erreichbar. Bitte versuche es in wenigen Minuten erneut.</p>
<p class="small">Vielen Dank für deine Geduld.</p>
</main>
</body>
</html>
`;
export function maintenanceResponse(status: 200 | 503 = 503): Response {
const headers: Record<string, string> = {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'x-robots-tag': 'noindex, nofollow',
};
if (status === 503) headers['retry-after'] = '60';
return new Response(MAINTENANCE_HTML, { status, headers });
}