feat: wire CMS redirects plugin into hooks.server.ts
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user