diff --git a/app/api/cache/warm/route.ts b/app/api/cache/warm/route.ts index 7caf7cb..3c99df2 100644 --- a/app/api/cache/warm/route.ts +++ b/app/api/cache/warm/route.ts @@ -10,6 +10,7 @@ import { getProjectRegistryState, refreshProjectRedirectMap, registryEntryForProject, + syncProjectRegistry, } from "@/lib/projectRegistry"; /** @@ -22,6 +23,14 @@ import { * Upstash key. Expiring tags here would force a re-render every five minutes * for no freshness gain. * + * It DOES run the registry sync: `/api/nostr/refresh`'s `after()` hook is the + * only other trigger, and clients call that route only when they spot an event + * NEWER than the server snapshot — which this cron keeps fresh, starving that + * trigger. Without the sync here, new projects never get slugs (the registry + * once sat unpublished for days). `syncProjectRegistry` is fully self-guarded + * (env gates, 5-min throttle, write lock, append-only fresh-read checks) and + * no-ops when there is nothing to register. + * * Scheduled from `vercel.json`. Vercel Cron sends `Authorization: Bearer * $CRON_SECRET` automatically once CRON_SECRET is set on the project. Hobby * projects only get daily crons — on that plan, drive this from an Upstash @@ -78,11 +87,16 @@ export async function GET(req: NextRequest) { // middleware 308s legacy `/projects/` URLs even without a registration. await refreshProjectRedirectMap(); + // Register any new projects (assign slugs + republish the registry event). + // Self-guarded and swallow-on-error — a failed attempt retries next tick. + await syncProjectRegistry(); + return NextResponse.json({ ok: true, warmed: snapshot.projects.length > 0, projects: snapshot.projects.length, durableWrites, + registrySyncAttempted: true, generatedAt: snapshot.generatedAt, elapsedMs: Date.now() - startedAt, }); diff --git a/app/dashboard/projects/UserProjectsClient.tsx b/app/dashboard/projects/UserProjectsClient.tsx index de529af..338d4f1 100644 --- a/app/dashboard/projects/UserProjectsClient.tsx +++ b/app/dashboard/projects/UserProjectsClient.tsx @@ -36,6 +36,7 @@ import { fetchUserProjects, getCachedUserProjects, publishUserProject, + refreshNostrServerCache, removeCachedCommunityProject, upsertCachedCommunityProject, type ProjectsDoc, @@ -441,6 +442,21 @@ export default function UserProjectsClient() { upsertCachedCommunityProject( communityProjectFromSignedEvent(project, result.signed), ); + // Read-your-writes: warm the server snapshot / per-id caches with the + // event we just published (its `after()` hook also triggers the registry + // sync), so the public `/projects/` URL resolves immediately instead + // of waiting for the next cron scan. Fire-and-forget — the relays + // already accepted the event, so a refresh hiccup must not fail the save. + refreshNostrServerCache({ + scopes: ["projects"], + projectId: project.id, + author: result.signed.pubkey, + candidateEventId: result.signed.id, + candidateCreatedAt: result.signed.created_at, + blocking: true, + }).catch((e) => { + console.warn("[labs] post-publish server refresh failed", e); + }); setTimeout(() => setPublishProgress(null), 3500); return result; } catch (e) { diff --git a/app/projects/[slug]/NostrProjectPageClient.tsx b/app/projects/[slug]/NostrProjectPageClient.tsx index 6bb8f52..d7bd2a4 100644 --- a/app/projects/[slug]/NostrProjectPageClient.tsx +++ b/app/projects/[slug]/NostrProjectPageClient.tsx @@ -490,7 +490,7 @@ function FeedbackCard({ * narrows relay lookups when the caller knows it (registry-backed URLs). */ export default function NostrProjectPage({ - projectId, + projectId: projectIdProp, author, canonicalSlug, initialProject, @@ -500,6 +500,27 @@ export default function NostrProjectPage({ canonicalSlug?: string; initialProject?: CommunityProject; }) { + // The build-time fallback shell of `/projects/[slug]` is prerendered with the + // literal route placeholder as the param, so a stale cached shell arrives + // here with projectId === "[slug]". The URL is the only trustworthy source in + // that case — and it must be window.location, not useParams(), because the + // client router hydrates from the same placeholder RSC tree. Start from the + // server-passed prop (keeps hydration consistent) and swap in the real path + // segment after mount. + const [projectId, setProjectId] = useState(projectIdProp); + useEffect(() => { + if (projectIdProp !== "[slug]") { + setProjectId(projectIdProp); + return; + } + const last = window.location.pathname.split("/").filter(Boolean).pop(); + if (!last) return; + try { + setProjectId(decodeURIComponent(last)); + } catch { + /* malformed escape — keep the placeholder */ + } + }, [projectIdProp]); const [project, setProject] = useState( initialProject ?? undefined, ); @@ -527,6 +548,11 @@ export default function NostrProjectPage({ }, [auth]); useEffect(() => { + // Placeholder param from a stale fallback shell — the rescue effect above + // swaps in the real URL segment on the next render; scanning for the + // literal "[slug]" would only ever end in "Proyecto no encontrado". + if (projectId === "[slug]") return; + let cancelled = false; const snapshotAbort = new AbortController(); diff --git a/app/projects/[slug]/page.tsx b/app/projects/[slug]/page.tsx index c133254..6856fd8 100644 --- a/app/projects/[slug]/page.tsx +++ b/app/projects/[slug]/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Suspense } from "react"; +import { connection } from "next/server"; import { permanentRedirect } from "next/navigation"; import { getCanonicalProjectRefs, @@ -119,6 +120,15 @@ async function PageContent({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + if (slug === "[slug]") { + // Fallback-shell prerender: the build renders this route once with the + // literal template placeholder as the param. Left alone it would bake a + // fully-resolved "unknown project" client scan into the static shell — + // which is then served (and can get pinned) for params that have no cached + // render yet. Postpone instead, so the shell suspends at + // and real params always resolve at request time. + await connection(); + } const resolved = await resolveProjectParam(slug); if (resolved.kind === "pubkey") { diff --git a/lib/nostrRelayConfig.ts b/lib/nostrRelayConfig.ts index ab6a61c..690a829 100644 --- a/lib/nostrRelayConfig.ts +++ b/lib/nostrRelayConfig.ts @@ -16,6 +16,10 @@ export const LACRYPTA_DEFAULT_RELAYS = [ ] as const; export const LACRYPTA_FAST_USER_RELAYS = [ + // La Crypta's own relay must always hold community events — the server-side + // snapshot/registry reads include it, and a publish that lands only on the + // third-party relays leaves the event thinly propagated. + "wss://relay.lacrypta.ar", "wss://relay.masize.com", "wss://relay.damus.io", "wss://relay.primal.net", diff --git a/lib/projectResolver.ts b/lib/projectResolver.ts index 6b7243e..46ffd38 100644 --- a/lib/projectResolver.ts +++ b/lib/projectResolver.ts @@ -25,8 +25,10 @@ import { getNostrSubmissionsSnapshot, getProjectWithDurableFallback, type CachedNostrProject, + type CachedNostrSubmissionsSnapshot, } from "./nostrCache"; import { + buildRegistryState, getProjectRegistryState, registryEntryForProject, type ProjectRegistryState, @@ -40,6 +42,25 @@ const HEX64_RE = /^[0-9a-f]{64}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +/** Resolve to `fallback` if `promise` hasn't settled within `ms`. */ +async function withResolverDeadline( + promise: Promise, + ms: number, + fallback: T, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise.catch(() => fallback), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + export type ResolvedProject = { kind: "project"; canonicalSlug: string; @@ -211,10 +232,19 @@ export async function resolveProjectParam( if (pubkey) return { kind: "pubkey", pubkey }; } - const [registry, snapshot] = await Promise.all([ - getProjectRegistryState(), - getNostrSubmissionsSnapshot(), - ]); + // The cached round-trips are internally time-capped, but this render path + // must NEVER hang: a page stream that stalls here is what pins Vercel's + // prerender cache on a stale fallback shell (revalidation only replaces an + // entry after a *successful* render). Degrade to empty state — the page then + // falls through to the client-scan shell instead of hanging. + const [registry, snapshot] = await withResolverDeadline( + Promise.all([getProjectRegistryState(), getNostrSubmissionsSnapshot()]), + 10_000, + [ + buildRegistryState([]), + { projects: [], generatedAt: "", relays: [] }, + ] as [ProjectRegistryState, CachedNostrSubmissionsSnapshot], + ); // 1. Registry slug (the canonical namespace). The matched slug may be an // alias (a project's earlier slug, kept as a redirect after the owner changed diff --git a/lib/upstashCache.ts b/lib/upstashCache.ts index 5627af2..1a97a81 100644 --- a/lib/upstashCache.ts +++ b/lib/upstashCache.ts @@ -130,12 +130,45 @@ function buildClient(): Redis | null { if (!url || !token) return null; if (process.env.UPSTASH_CACHE_DISABLED === "1") return null; try { - return new Redis({ url, token }); + // The default client retries 5× with backoff and has no request timeout; + // paired with the per-call deadline below, a struggling endpoint must + // degrade to a cache miss, never stall a render. + return new Redis({ + url, + token, + retry: { + retries: 2, + backoff: (retryCount) => Math.min(1000, 100 * 2 ** retryCount), + }, + }); } catch { return null; } } +/** + * Hard per-call deadline. Every render on the site funnels through this module + * (snapshot, registry, redirect map, durable copies), so an unbounded Upstash + * call would hang page streams and ISR revalidations fleet-wide — the timeout + * makes a slow endpoint indistinguishable from an unconfigured one. + */ +const READ_TIMEOUT_MS = 2000; +const WRITE_TIMEOUT_MS = 3000; + +async function withDeadline(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("upstash deadline")), ms); + }), + ]); + } finally { + clearTimeout(timer); + } +} + /** True when reads/writes will actually reach Upstash. Useful for warm routes. */ export function isUpstashEnabled(): boolean { return getRedis() !== null; @@ -170,7 +203,7 @@ export async function upstashReadThrough( const fullKey = namespacedKey(key); try { - const hit = await redis.get(fullKey); + const hit = await withDeadline(redis.get(fullKey), READ_TIMEOUT_MS); if (hit !== null && hit !== undefined) return hit; } catch { return producer(); @@ -181,7 +214,10 @@ export async function upstashReadThrough( options?.shouldCache ?? ((v: T) => v !== null && v !== undefined); if (shouldCache(value)) { try { - await redis.set(fullKey, value, { ex: ttlSeconds }); + await withDeadline( + redis.set(fullKey, value, { ex: ttlSeconds }), + WRITE_TIMEOUT_MS, + ); } catch { /* best-effort write; the value is already on its way to the caller */ } @@ -199,7 +235,10 @@ export async function upstashGet(key: string): Promise { const redis = getRedis(); if (!redis) return null; try { - const hit = await redis.get(namespacedKey(key)); + const hit = await withDeadline( + redis.get(namespacedKey(key)), + READ_TIMEOUT_MS, + ); return hit ?? null; } catch { return null; @@ -219,7 +258,10 @@ export async function upstashSet( const redis = getRedis(); if (!redis) return; try { - await redis.set(namespacedKey(key), value, { ex: ttlSeconds }); + await withDeadline( + redis.set(namespacedKey(key), value, { ex: ttlSeconds }), + WRITE_TIMEOUT_MS, + ); } catch { /* best-effort */ } @@ -243,10 +285,10 @@ export async function upstashAcquireLock( const redis = getRedis(); if (!redis) return true; try { - const res = await redis.set(namespacedKey(key), "1", { - nx: true, - ex: ttlSeconds, - }); + const res = await withDeadline( + redis.set(namespacedKey(key), "1", { nx: true, ex: ttlSeconds }), + WRITE_TIMEOUT_MS, + ); return res === "OK"; } catch { return true; @@ -263,7 +305,7 @@ export async function upstashDelete(...keys: string[]): Promise { const redis = getRedis(); if (!redis || keys.length === 0) return; try { - await redis.del(...keys.map(namespacedKey)); + await withDeadline(redis.del(...keys.map(namespacedKey)), WRITE_TIMEOUT_MS); } catch { /* best-effort */ }