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 { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
||||||
import { env as envPublic } from '$env/dynamic/public';
|
import { env as envPublic } from '$env/dynamic/public';
|
||||||
import { logWarn } from '$lib/log.server';
|
import { logWarn } from '$lib/log.server';
|
||||||
|
import { lookupRedirect } from '$lib/redirects.server';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +63,24 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return maintenanceResponse(200);
|
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
|
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
||||||
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
||||||
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
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