/** * 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; const ENV_NAME = 'windwiderstand'; const TTL_MS = 60_000; let cache: Index = new Map(); let loadedAt = 0; let inflight: Promise | 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 { 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 { 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 { 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; }