feat: wire CMS redirects plugin into hooks.server.ts
Deploy / verify (push) Successful in 51s
Deploy / deploy (push) Successful in 1m16s

New $lib/redirects.server.ts: lazy-loads GET /api/redirects, exact-match
index with trailing-slash normalisation, 60s TTL with background refresh
(cold start blocks once, subsequent requests get stale cache instantly).

hooks.server.ts: before legacy-redirect fallback, runs lookupRedirect for
GET requests. Editor-defined rules win over hardcoded /blog→/posts etc.
External targets (http://…) drop query; internal targets keep ?search.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 11:51:45 +02:00
parent 0aa3898e5b
commit c00eb26689
2 changed files with 120 additions and 0 deletions
+19
View File
@@ -4,6 +4,7 @@ import { maintenanceResponse } from '$lib/maintenance.server';
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
import { env as envPublic } from '$env/dynamic/public';
import { logWarn } from '$lib/log.server';
import { lookupRedirect } from '$lib/redirects.server';
import type { Handle } from '@sveltejs/kit';
/**
@@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => {
return maintenanceResponse(200);
}
// CMS-verwaltete Redirects (Admin UI → /admin/plugins/redirects).
// Editor-Regeln gewinnen gegen hardcoded Legacy-Patterns; nur GET, sonst
// brechen POST-Forms beim Pfadwechsel.
if (event.request.method === 'GET') {
const cmsRedirect = await lookupRedirect(event.url.pathname);
if (cmsRedirect) {
const search = event.url.search;
const dest =
cmsRedirect.to.startsWith('http') || !search
? cmsRedirect.to
: `${cmsRedirect.to}${search}`;
return new Response(null, {
status: cmsRedirect.status,
headers: { location: dest },
});
}
}
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
// SvelteKit-Routing/CMS-Calls dafür unnötig.
const redirectTo = legacyRedirectTarget(event.url.pathname);
+101
View File
@@ -0,0 +1,101 @@
/**
* CMS-backed redirect store.
*
* Loads the public `/api/redirects` list (enabled only) and serves it as an
* exact-match lookup. Cache is in-memory with a TTL; a background refresh
* runs after the TTL expires so requests don't pay the network cost.
*
* Editors manage the list in the admin UI (`/admin/plugins/redirects`) —
* webhook-based invalidation is not wired up yet; the 60 s TTL is the
* eventual-consistency bound.
*/
import { getCmsBaseUrl } from './cms-source.server';
import { logWarn } from './log.server';
type Redirect = {
id?: string;
from: string;
to: string;
status: 301 | 302;
enabled?: boolean;
};
type Index = Map<string, { to: string; status: 301 | 302 }>;
const ENV_NAME = 'windwiderstand';
const TTL_MS = 60_000;
let cache: Index = new Map();
let loadedAt = 0;
let inflight: Promise<void> | null = null;
function normalize(path: string): string {
// Match leading-slash-required paths; strip trailing slash so /foo
// and /foo/ resolve to the same rule (the CMS stores exact paths).
if (!path.startsWith('/')) return path;
if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
return path;
}
async function fetchRedirects(): Promise<Redirect[]> {
const url = `${getCmsBaseUrl()}/api/redirects?_environment=${encodeURIComponent(ENV_NAME)}`;
const r = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as unknown;
if (!Array.isArray(data)) return [];
return data.filter(
(x): x is Redirect =>
typeof x === 'object' &&
x !== null &&
typeof (x as Redirect).from === 'string' &&
typeof (x as Redirect).to === 'string',
);
}
function rebuildIndex(list: Redirect[]): Index {
const idx: Index = new Map();
for (const r of list) {
if (r.enabled === false) continue;
const status = r.status === 302 ? 302 : 301;
idx.set(normalize(r.from), { to: r.to, status });
}
return idx;
}
async function refresh(): Promise<void> {
try {
const list = await fetchRedirects();
cache = rebuildIndex(list);
loadedAt = Date.now();
} catch (err) {
logWarn('redirects.refresh', err);
// Keep old cache on failure — better stale than broken.
loadedAt = Date.now();
}
}
function ensureFresh(): Promise<void> | void {
const stale = Date.now() - loadedAt > TTL_MS;
if (!stale) return;
// First load (cold start) blocks the request; subsequent refreshes run in
// the background so requests get the stale cache instantly.
if (loadedAt === 0) {
inflight ??= refresh().finally(() => {
inflight = null;
});
return inflight;
}
if (!inflight) {
inflight = refresh().finally(() => {
inflight = null;
});
}
}
export async function lookupRedirect(
pathname: string,
): Promise<{ to: string; status: 301 | 302 } | null> {
const maybe = ensureFresh();
if (maybe) await maybe;
return cache.get(normalize(pathname)) ?? null;
}