From 9780778b2652023d9a0e6ace427247c680818542 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Fri, 17 Jul 2026 18:24:50 -0300 Subject: [PATCH] fix(projects): 308 legacy /projects/ URLs to canonical slug via proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered projects' legacy id/UUID URLs (e.g. /projects/913449a2-…) were stuck serving a cached soft-404 ("Proyecto no encontrado") instead of redirecting to their canonical /projects/. Root cause: the single-segment page issues its id→slug redirect with `permanentRedirect` INSIDE a Suspense boundary under cacheComponents/PPR — that redirect degrades to a streamed meta tag and can't overwrite a full-route cache entry previously cached as a 200 (the pre-registration soft-404), so the URL is pinned. The legacy two-segment URLs already dodge this via route handlers (real 308); the single-segment case is a page. Fix: emit the redirect from a Next 16 `proxy.ts` (the renamed "middleware" convention) — it runs BEFORE the full-route cache, so a real 308 can't be pinned, and it uses only a small Upstash `id/old-slug → canonical-slug` map (never the flaky relay/snapshot scans), making the redirect deterministic. - lib/projectRedirectMap.ts (new): `buildRedirectMap(entries)` (pure) + an edge-safe cached reader `resolveProjectRedirect(param)` (via upstashGet). Keys = project id (lowercased) + old slugs; value = current canonical slug; canonical-slug URLs are absent → pass through. - proxy.ts (new): matches single-segment /projects/:param, 308s when the map has a target; no-ops (NextResponse.next) for canonical slugs, unknown ids, or when Upstash is unconfigured. - lib/projectRegistry.ts: write the map on syncProjectRegistry + registerUserProjectSlug; export refreshProjectRedirectMap(). - app/api/cache/warm/route.ts: refresh the map every cron so it stays warm. Verified: tsc + build clean (proxy is edge-safe, no deprecation warning), buildRedirectMap unit-tested, proxy no-ops safely without Upstash (next start: /projects/* → 200). The Upstash-backed 308 + PPR cache-immunity verify on Vercel (preview/prod have Upstash + PPR). Co-Authored-By: Claude Opus 4.8 --- app/api/cache/warm/route.ts | 5 ++ lib/projectRedirectMap.ts | 91 +++++++++++++++++++++++++++++++++++++ lib/projectRegistry.ts | 38 +++++++++++++++- proxy.ts | 42 +++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 lib/projectRedirectMap.ts create mode 100644 proxy.ts diff --git a/app/api/cache/warm/route.ts b/app/api/cache/warm/route.ts index 81dd2de..7caf7cb 100644 --- a/app/api/cache/warm/route.ts +++ b/app/api/cache/warm/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/upstashCache"; import { getProjectRegistryState, + refreshProjectRedirectMap, registryEntryForProject, } from "@/lib/projectRegistry"; @@ -73,6 +74,10 @@ export async function GET(req: NextRequest) { } } + // Keep the edge redirect map (id/old-slug → canonical slug) fresh so + // middleware 308s legacy `/projects/` URLs even without a registration. + await refreshProjectRedirectMap(); + return NextResponse.json({ ok: true, warmed: snapshot.projects.length > 0, diff --git a/lib/projectRedirectMap.ts b/lib/projectRedirectMap.ts new file mode 100644 index 0000000..a900c49 --- /dev/null +++ b/lib/projectRedirectMap.ts @@ -0,0 +1,91 @@ +/** + * Edge-readable `id/old-slug → canonical-slug` redirect map. + * + * Why this exists: the canonical `/projects/[slug]` page issues its id→slug + * redirect with `permanentRedirect` INSIDE a Suspense boundary under + * `cacheComponents`/PPR. That redirect degrades to a streamed meta tag and, + * worse, cannot overwrite a full-route cache entry that was previously cached + * as a 200 (a pre-registration soft-404) — so a registered project's legacy + * `/projects/` URL gets pinned to "Proyecto no encontrado" and never + * redirects. The legacy two-segment URLs already dodge this by redirecting from + * a route handler (a real 308); the single-segment case is a page, so we handle + * it in `proxy.ts` instead — a real 308 emitted BEFORE the full-route + * cache, driven only by this small map (never the flaky relay/snapshot scans). + * + * The map is a plain JSON object in Upstash, written by the registry layer + * (`lib/projectRegistry.ts`) on sync/registration and by the warm cron. Both + * the Node writer and the edge reader go through `upstashGet`/`upstashSet`, so + * the key namespacing (`lacrypta::…`) stays consistent. + */ + +import type { ProjectRegistryEntry } from "./projectRegistryContract"; +import { upstashGet } from "./upstashCache"; + +/** Logical Upstash key (namespaced by `upstashGet`/`upstashSet`). */ +export const PROJECT_REDIRECT_MAP_KEY = "nostr:registry-redirects:v1"; +/** Long TTL — the map is rewritten on every sync/registration/warm, and even a + * stale copy is safe (append-only registry; a missing entry just means the + * page handles that redirect, as before). */ +export const PROJECT_REDIRECT_MAP_TTL = 60 * 60 * 24 * 30; + +/** + * Build the compact redirect map from registry entries. Keys are a project's + * `id` (lowercased) and any of its OLD slugs; the value is the current + * canonical slug. Only entries that actually need a redirect are included + * (`key !== canonical slug`), so canonical-slug URLs are absent → pass through. + */ +export function buildRedirectMap( + entries: ProjectRegistryEntry[], +): Record { + // Canonical slug per id = latest `registeredAt` wins (matches buildRegistryState). + const canonicalById = new Map(); + const ordered = [...entries].sort( + (a, b) => (a.registeredAt || 0) - (b.registeredAt || 0), + ); + for (const e of ordered) canonicalById.set(e.id.toLowerCase(), e.slug); + + const map: Record = {}; + for (const [idLc, slug] of canonicalById) { + if (idLc !== slug) map[idLc] = slug; // /projects/ → /projects/ + } + for (const e of entries) { + const canonical = canonicalById.get(e.id.toLowerCase()); + if (canonical && e.slug !== canonical) { + map[e.slug] = canonical; // /projects/ → /projects/ + } + } + return map; +} + +/* ─────────────────────────── edge read path ────────────────────────────── */ + +// Per-instance cache so middleware doesn't hit Upstash on every project request. +let cache: { at: number; map: Record } | null = null; +const CACHE_TTL_MS = 60_000; + +async function getRedirectMapCached(): Promise> { + const now = Date.now(); + if (cache && now - cache.at < CACHE_TTL_MS) return cache.map; + const map = + (await upstashGet>(PROJECT_REDIRECT_MAP_KEY)) ?? {}; + cache = { at: now, map }; + return map; +} + +/** + * Resolve `/projects/` to its canonical slug, or `null` if `param` is + * already canonical / unknown / the cache is unavailable. Used by middleware. + */ +export async function resolveProjectRedirect( + param: string, +): Promise { + const key = param.trim().toLowerCase(); + if (!key) return null; + try { + const map = await getRedirectMapCached(); + const target = map[key]; + return target && target !== key ? target : null; + } catch { + return null; // never break navigation on a cache hiccup + } +} diff --git a/lib/projectRegistry.ts b/lib/projectRegistry.ts index a733435..b8c7aa4 100644 --- a/lib/projectRegistry.ts +++ b/lib/projectRegistry.ts @@ -21,7 +21,16 @@ import { cacheLife, cacheTag } from "next/cache"; import { DEFAULT_RELAYS } from "./nostrRelayConfig"; import { NOSTR_PROJECT_REGISTRY_TAG } from "./nostrCacheTags"; import { expireNostrTag } from "./nostrRevalidate"; -import { upstashAcquireLock, upstashReleaseLock } from "./upstashCache"; +import { + upstashAcquireLock, + upstashReleaseLock, + upstashSet, +} from "./upstashCache"; +import { + PROJECT_REDIRECT_MAP_KEY, + PROJECT_REDIRECT_MAP_TTL, + buildRedirectMap, +} from "./projectRedirectMap"; import type { SignedEvent } from "./nostrSigner"; import { PROJECT_REGISTRY_D_TAG, @@ -469,6 +478,30 @@ async function buildAndSignRegistryEvent( ) as unknown as SignedEvent; } +/** + * Write the edge redirect map (`id/old-slug → canonical-slug`) to Upstash so + * `proxy.ts` can 308 legacy `/projects/` URLs. Best-effort — a failure + * only means the proxy falls back to the page's redirect for a bit. + */ +async function writeRedirectMap(entries: ProjectRegistryEntry[]): Promise { + try { + await upstashSet( + PROJECT_REDIRECT_MAP_KEY, + buildRedirectMap(entries), + PROJECT_REDIRECT_MAP_TTL, + ); + } catch { + /* best-effort */ + } +} + +/** Rebuild + persist the redirect map from the current registry. Called by the + * warm cron so the map stays fresh even without new registrations. */ +export async function refreshProjectRedirectMap(): Promise { + const state = await getProjectRegistryState(); + await writeRedirectMap(state.entries); +} + let lastSyncAttempt = 0; const SYNC_THROTTLE_MS = 5 * 60 * 1000; @@ -610,6 +643,7 @@ export async function syncProjectRegistry(): Promise { const { revalidateTag } = await import("next/cache"); revalidateTag(NOSTR_PROJECT_REGISTRY_TAG, "max"); + await writeRedirectMap(entries); console.log( `[projectRegistry] registered ${additions.length} project(s): ${additions .map((a) => a.slug) @@ -797,6 +831,8 @@ export async function registerUserProjectSlug(input: { // Drop the registry cache so the resolver re-reads the new slug at once. // The registry is not Upstash-backed, so this is a Next-tier expiry only. await expireNostrTag(NOSTR_PROJECT_REGISTRY_TAG); + // Refresh the edge redirect map so `/projects/` 308s to the new slug. + await writeRedirectMap(entries); return { status: "ok", event: signed, slug: input.slug, changed }; } catch (error) { diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..be082b6 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveProjectRedirect } from "@/lib/projectRedirectMap"; + +/** + * Canonicalize single-segment `/projects/` URLs with a REAL + * 308 emitted before the full-route cache. (Next 16 "proxy" = the former + * "middleware" file convention.) + * + * A registered project's canonical URL is `/projects/`; its legacy + * `/projects/` (and any old slug) must redirect there. Doing that in the + * page (`permanentRedirect` under cacheComponents/PPR) can't overwrite a + * previously-cached 200, so those URLs get pinned to a stale soft-404. The proxy + * runs ahead of the cache and uses only the small Upstash redirect map (never + * the relay/snapshot scans), so the redirect is deterministic and can't be + * pinned. Unmapped params (canonical slugs, unknown ids, or when Upstash is + * unconfigured) fall through to the page unchanged. + */ +export const config = { + matcher: ["/projects/:param"], +}; + +export async function proxy(req: NextRequest): Promise { + // Single segment only: `/projects/` — never `/projects//` (legacy + // two-segment redirects are their own route handlers) nor `/projects`. + const match = req.nextUrl.pathname.match(/^\/projects\/([^/]+)$/); + if (!match) return NextResponse.next(); + + let param = match[1]; + try { + param = decodeURIComponent(param); + } catch { + /* keep raw on malformed escapes */ + } + + const target = await resolveProjectRedirect(param); + if (target) { + const url = req.nextUrl.clone(); + url.pathname = `/projects/${target}`; + return NextResponse.redirect(url, 308); + } + return NextResponse.next(); +}