c00eb26689
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>
102 lines
2.9 KiB
TypeScript
102 lines
2.9 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|