Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/api/cache/warm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "@/lib/upstashCache";
import {
getProjectRegistryState,
refreshProjectRedirectMap,
registryEntryForProject,
} from "@/lib/projectRegistry";

Expand Down Expand Up @@ -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/<id>` URLs even without a registration.
await refreshProjectRedirectMap();

return NextResponse.json({
ok: true,
warmed: snapshot.projects.length > 0,
Expand Down
91 changes: 91 additions & 0 deletions lib/projectRedirectMap.ts
Original file line number Diff line number Diff line change
@@ -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/<uuid>` 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:<env>:…`) 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<string, string> {
// Canonical slug per id = latest `registeredAt` wins (matches buildRegistryState).
const canonicalById = new Map<string, string>();
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<string, string> = {};
for (const [idLc, slug] of canonicalById) {
if (idLc !== slug) map[idLc] = slug; // /projects/<id> → /projects/<slug>
}
for (const e of entries) {
const canonical = canonicalById.get(e.id.toLowerCase());
if (canonical && e.slug !== canonical) {
map[e.slug] = canonical; // /projects/<old-slug> → /projects/<new-slug>
}
}
return map;
}

/* ─────────────────────────── edge read path ────────────────────────────── */

// Per-instance cache so middleware doesn't hit Upstash on every project request.
let cache: { at: number; map: Record<string, string> } | null = null;
const CACHE_TTL_MS = 60_000;

async function getRedirectMapCached(): Promise<Record<string, string>> {
const now = Date.now();
if (cache && now - cache.at < CACHE_TTL_MS) return cache.map;
const map =
(await upstashGet<Record<string, string>>(PROJECT_REDIRECT_MAP_KEY)) ?? {};
cache = { at: now, map };
return map;
}

/**
* Resolve `/projects/<param>` 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<string | null> {
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
}
}
38 changes: 37 additions & 1 deletion lib/projectRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<id>` 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<void> {
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<void> {
const state = await getProjectRegistryState();
await writeRedirectMap(state.entries);
}

let lastSyncAttempt = 0;
const SYNC_THROTTLE_MS = 5 * 60 * 1000;

Expand Down Expand Up @@ -610,6 +643,7 @@ export async function syncProjectRegistry(): Promise<void> {

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)
Expand Down Expand Up @@ -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/<id>` 308s to the new slug.
await writeRedirectMap(entries);

return { status: "ok", event: signed, slug: input.slug, changed };
} catch (error) {
Expand Down
42 changes: 42 additions & 0 deletions proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveProjectRedirect } from "@/lib/projectRedirectMap";

/**
* Canonicalize single-segment `/projects/<id-or-old-slug>` 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/<slug>`; its legacy
* `/projects/<uuid>` (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<NextResponse> {
// Single segment only: `/projects/<x>` — never `/projects/<x>/<y>` (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();
}