From 512a1a60e58d05144d7db8c6d13428ebf5a52b77 Mon Sep 17 00:00:00 2001 From: Agustin Kassis Date: Sun, 7 Jun 2026 17:14:17 -0300 Subject: [PATCH] Add Nostr cache reconciliation --- app/api/nostr-projects/route.ts | 11 +- app/api/nostr/badges/route.ts | 11 + app/api/nostr/profiles/route.ts | 26 ++ app/api/nostr/projects/route.ts | 18 ++ app/api/nostr/refresh/route.ts | 104 ++++++++ app/api/nostr/relay-lists/route.ts | 26 ++ app/api/nostr/reports/route.ts | 22 ++ app/api/revalidate-nostr/route.ts | 15 +- app/hackathons/[id]/HackathonProjectsList.tsx | 183 ++++++++++++-- .../[projectId]/NostrProjectPageClient.tsx | 74 +++++- .../[id]/[projectId]/NostrProjectServer.tsx | 1 + app/hackathons/[id]/page.tsx | 2 + app/projects/ProjectsGrid.tsx | 113 ++++++++- .../[pubkey]/[id]/StandaloneProjectPage.tsx | 124 ++++++++-- app/projects/[pubkey]/[id]/page.tsx | 35 +-- components/Navbar.tsx | 2 +- components/sections/GamingHackathonBanner.tsx | 4 +- lib/nostrBadges.ts | 40 ++- lib/nostrBadgesCache.ts | 227 ++++++++++++++++++ lib/nostrCache.ts | 26 +- lib/nostrCacheTags.ts | 18 ++ lib/nostrProfile.ts | 47 +++- lib/nostrProfileCache.ts | 3 +- lib/nostrRelayListCache.ts | 111 +++++++++ lib/nostrRelays.ts | 22 +- lib/nostrReports.ts | 42 +++- lib/nostrReportsCache.ts | 175 ++++++++++++++ lib/scrollMemory.ts | 83 +++++++ lib/soldiers.ts | 7 +- lib/userProjects.ts | 95 +++++++- 30 files changed, 1557 insertions(+), 110 deletions(-) create mode 100644 app/api/nostr/badges/route.ts create mode 100644 app/api/nostr/profiles/route.ts create mode 100644 app/api/nostr/projects/route.ts create mode 100644 app/api/nostr/refresh/route.ts create mode 100644 app/api/nostr/relay-lists/route.ts create mode 100644 app/api/nostr/reports/route.ts create mode 100644 lib/nostrBadgesCache.ts create mode 100644 lib/nostrCacheTags.ts create mode 100644 lib/nostrRelayListCache.ts create mode 100644 lib/nostrReportsCache.ts create mode 100644 lib/scrollMemory.ts diff --git a/app/api/nostr-projects/route.ts b/app/api/nostr-projects/route.ts index 1aa0abc..3ef7f67 100644 --- a/app/api/nostr-projects/route.ts +++ b/app/api/nostr-projects/route.ts @@ -2,6 +2,7 @@ import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { getNostrSubmissionsSnapshot, + NOSTR_PROJECTS_TAG, NOSTR_SUBMISSIONS_TAG, } from "@/lib/nostrCache"; @@ -11,6 +12,12 @@ export async function GET() { } export async function POST() { - revalidateTag(NOSTR_SUBMISSIONS_TAG, "max"); - return NextResponse.json({ ok: true, revalidated: NOSTR_SUBMISSIONS_TAG }); + revalidateTag(NOSTR_PROJECTS_TAG, { expire: 0 }); + revalidateTag(NOSTR_SUBMISSIONS_TAG, { expire: 0 }); + const snapshot = await getNostrSubmissionsSnapshot(); + return NextResponse.json({ + ok: true, + revalidated: [NOSTR_PROJECTS_TAG, NOSTR_SUBMISSIONS_TAG], + snapshot, + }); } diff --git a/app/api/nostr/badges/route.ts b/app/api/nostr/badges/route.ts new file mode 100644 index 0000000..7815de8 --- /dev/null +++ b/app/api/nostr/badges/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCachedBadgesSnapshot } from "@/lib/nostrBadgesCache"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const pubkey = (searchParams.get("pubkey") ?? "").trim(); + if (!/^[0-9a-f]{64}$/i.test(pubkey)) { + return NextResponse.json({ error: "Falta pubkey." }, { status: 400 }); + } + return NextResponse.json(await getCachedBadgesSnapshot(pubkey)); +} diff --git a/app/api/nostr/profiles/route.ts b/app/api/nostr/profiles/route.ts new file mode 100644 index 0000000..4de76dd --- /dev/null +++ b/app/api/nostr/profiles/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; + +function pubkeysFromRequest(req: NextRequest) { + const { searchParams } = new URL(req.url); + const raw = searchParams.get("pubkeys") ?? searchParams.get("pubkey") ?? ""; + return raw + .split(",") + .map((p) => p.trim()) + .filter((p) => /^[0-9a-f]{64}$/i.test(p)) + .slice(0, 50); +} + +export async function GET(req: NextRequest) { + const pubkeys = pubkeysFromRequest(req); + if (pubkeys.length === 0) { + return NextResponse.json({ error: "Falta pubkey." }, { status: 400 }); + } + const entries = await Promise.all( + pubkeys.map(async (pubkey) => [pubkey, await getCachedNostrProfile(pubkey)]), + ); + return NextResponse.json({ + profiles: Object.fromEntries(entries), + generatedAt: new Date().toISOString(), + }); +} diff --git a/app/api/nostr/projects/route.ts b/app/api/nostr/projects/route.ts new file mode 100644 index 0000000..d42d0e9 --- /dev/null +++ b/app/api/nostr/projects/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getNostrSubmissionsSnapshot } from "@/lib/nostrCache"; +import { projectMatchesIdentifier } from "@/lib/projectIdentity"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const hackathonId = searchParams.get("hackathonId"); + const projectId = searchParams.get("projectId"); + const author = searchParams.get("author"); + const snapshot = await getNostrSubmissionsSnapshot(); + const projects = snapshot.projects.filter((project) => { + if (hackathonId && project.hackathon !== hackathonId) return false; + if (author && project.author !== author) return false; + if (projectId && !projectMatchesIdentifier(project, projectId)) return false; + return true; + }); + return NextResponse.json({ ...snapshot, projects }); +} diff --git a/app/api/nostr/refresh/route.ts b/app/api/nostr/refresh/route.ts new file mode 100644 index 0000000..0a75728 --- /dev/null +++ b/app/api/nostr/refresh/route.ts @@ -0,0 +1,104 @@ +import { revalidateTag } from "next/cache"; +import { NextRequest, NextResponse } from "next/server"; +import { + getFreshNostrSubmissionsSnapshot, + getNostrSubmissionsSnapshot, +} from "@/lib/nostrCache"; +import { + NOSTR_LEGACY_SUBMISSIONS_TAG, + NOSTR_PROJECTS_TAG, + nostrBadgesTag, + nostrProfileTag, + nostrRelayListTag, + nostrReportsTag, +} from "@/lib/nostrCacheTags"; +import { projectMatchesIdentifier } from "@/lib/projectIdentity"; + +type RefreshScope = + | "projects" + | "profile" + | "relay-list" + | "badges" + | "reports" + | "results"; + +type RefreshBody = { + scopes?: RefreshScope[]; + hackathonId?: string; + projectId?: string; + author?: string; + pubkey?: string; + candidateEventId?: string; + candidateCreatedAt?: number; + blocking?: boolean; +}; + +function expireTag(tag: string) { + revalidateTag(tag, { expire: 0 }); +} + +export async function POST(req: NextRequest) { + let body: RefreshBody; + try { + body = (await req.json()) as RefreshBody; + } catch { + return NextResponse.json({ error: "JSON invalido." }, { status: 400 }); + } + + const scopes = Array.isArray(body.scopes) && body.scopes.length > 0 + ? body.scopes + : (["projects"] satisfies RefreshScope[]); + const refreshed: Record = {}; + const expiredTags: string[] = []; + + const expire = (tag: string) => { + expireTag(tag); + expiredTags.push(tag); + }; + + if (scopes.includes("projects")) { + expire(NOSTR_PROJECTS_TAG); + expire(NOSTR_LEGACY_SUBMISSIONS_TAG); + const snapshot = body.blocking === false + ? await getFreshNostrSubmissionsSnapshot() + : await getNostrSubmissionsSnapshot(); + refreshed.projects = { + ...snapshot, + projects: snapshot.projects.filter((project) => { + if (body.hackathonId && project.hackathon !== body.hackathonId) { + return false; + } + if (body.author && project.author !== body.author) return false; + if ( + body.projectId && + !projectMatchesIdentifier(project, body.projectId) + ) { + return false; + } + return true; + }), + }; + } + + if (body.pubkey) { + if (scopes.includes("profile")) expire(nostrProfileTag(body.pubkey)); + if (scopes.includes("relay-list")) expire(nostrRelayListTag(body.pubkey)); + if (scopes.includes("badges")) expire(nostrBadgesTag(body.pubkey)); + } + + if (body.hackathonId) { + if (scopes.includes("reports") || scopes.includes("results")) { + expire(nostrReportsTag(body.hackathonId)); + } + } + + return NextResponse.json({ + ok: true, + expiredTags, + refreshed, + candidate: { + eventId: body.candidateEventId ?? null, + createdAt: body.candidateCreatedAt ?? null, + }, + }); +} diff --git a/app/api/nostr/relay-lists/route.ts b/app/api/nostr/relay-lists/route.ts new file mode 100644 index 0000000..638820b --- /dev/null +++ b/app/api/nostr/relay-lists/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCachedRelayList } from "@/lib/nostrRelayListCache"; + +function pubkeysFromRequest(req: NextRequest) { + const { searchParams } = new URL(req.url); + const raw = searchParams.get("pubkeys") ?? searchParams.get("pubkey") ?? ""; + return raw + .split(",") + .map((p) => p.trim()) + .filter((p) => /^[0-9a-f]{64}$/i.test(p)) + .slice(0, 50); +} + +export async function GET(req: NextRequest) { + const pubkeys = pubkeysFromRequest(req); + if (pubkeys.length === 0) { + return NextResponse.json({ error: "Falta pubkey." }, { status: 400 }); + } + const entries = await Promise.all( + pubkeys.map(async (pubkey) => [pubkey, await getCachedRelayList(pubkey)]), + ); + return NextResponse.json({ + relayLists: Object.fromEntries(entries), + generatedAt: new Date().toISOString(), + }); +} diff --git a/app/api/nostr/reports/route.ts b/app/api/nostr/reports/route.ts new file mode 100644 index 0000000..c63c1ea --- /dev/null +++ b/app/api/nostr/reports/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCachedHackathonReportsSnapshot } from "@/lib/nostrReportsCache"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const hackathonId = (searchParams.get("hackathonId") ?? "").trim(); + const projectId = (searchParams.get("projectId") ?? "").trim(); + if (!hackathonId) { + return NextResponse.json({ error: "Falta hackathonId." }, { status: 400 }); + } + const snapshot = await getCachedHackathonReportsSnapshot(hackathonId); + if (projectId) { + return NextResponse.json({ + hackathonId, + projectId, + report: snapshot.reports[projectId] ?? null, + generatedAt: snapshot.generatedAt, + relays: snapshot.relays, + }); + } + return NextResponse.json(snapshot); +} diff --git a/app/api/revalidate-nostr/route.ts b/app/api/revalidate-nostr/route.ts index 7698a44..6a32f55 100644 --- a/app/api/revalidate-nostr/route.ts +++ b/app/api/revalidate-nostr/route.ts @@ -1,5 +1,9 @@ import { revalidateTag } from "next/cache"; import { type NextRequest, NextResponse } from "next/server"; +import { + NOSTR_LEGACY_SUBMISSIONS_TAG, + NOSTR_PROJECTS_TAG, +} from "@/lib/nostrCacheTags"; /** * Secret-gated revalidation hook for Nostr-sourced cached data. @@ -9,9 +13,7 @@ import { type NextRequest, NextResponse } from "next/server"; * -H "x-revalidate-secret: $REVALIDATE_SECRET" \ * -d '{"tag":"nostr:hackathon-submissions"}' * - * Defaults to the global submissions tag when no body is provided. - * Stale-while-revalidate ('max') keeps perceived speed while a fresh - * relay round-trip completes in the background. + * Defaults to the global projects tag when no body is provided. */ export async function POST(req: NextRequest) { const secret = req.headers.get("x-revalidate-secret"); @@ -20,7 +22,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); } - let tag = "nostr:hackathon-submissions"; + let tag = NOSTR_PROJECTS_TAG; try { const body = (await req.json()) as { tag?: unknown }; if (typeof body.tag === "string" && body.tag.length > 0) { @@ -30,6 +32,9 @@ export async function POST(req: NextRequest) { /* no body, use default */ } - revalidateTag(tag, "max"); + revalidateTag(tag, { expire: 0 }); + if (tag === NOSTR_PROJECTS_TAG) { + revalidateTag(NOSTR_LEGACY_SUBMISSIONS_TAG, { expire: 0 }); + } return NextResponse.json({ ok: true, tag }); } diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx index 00bb959..2a98df6 100644 --- a/app/hackathons/[id]/HackathonProjectsList.tsx +++ b/app/hackathons/[id]/HackathonProjectsList.tsx @@ -1,7 +1,15 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type MouseEvent, +} from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { ArrowRight, CircleDashed, @@ -25,7 +33,12 @@ import { fetchAuthorPictures, fetchCommunityProjectsSnapshot, getCachedCommunityProjects, + communityProjectKey, + mergeCommunityProjects, + newerCommunityProjects, + refreshNostrServerCache, refreshCommunityProjectsFromRelays, + sortCommunityProjects, TOP10_RELAYS, type CommunityProject, } from "@/lib/userProjects"; @@ -34,6 +47,10 @@ import { useHackathonResults, type WinnerEntry } from "@/lib/nostrReports"; import { GithubIcon } from "@/components/BrandIcons"; import { cn } from "@/lib/cn"; import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks"; +import { + rememberScrollPosition, + restoreScrollPosition, +} from "@/lib/scrollMemory"; function medal(position: number | null | undefined) { if (position === 1) return "๐Ÿฅ‡"; @@ -60,6 +77,30 @@ function fromCommunity(np: CommunityProject): HackathonSubmission { }; } +function communityFromSubmission( + submission: HackathonSubmission, +): CommunityProject | null { + if ( + !submission.nostrAuthor || + !submission.nostrEventId || + !submission.nostrCreatedAt + ) { + return null; + } + const withRuntime = submission as HackathonSubmission & { + createdAt?: number; + updatedAt?: number; + }; + return { + ...submission, + author: submission.nostrAuthor, + eventId: submission.nostrEventId, + eventCreatedAt: submission.nostrCreatedAt, + createdAt: withRuntime.createdAt ?? submission.nostrCreatedAt, + updatedAt: withRuntime.updatedAt ?? submission.nostrCreatedAt, + }; +} + export default function HackathonProjectsList({ hackathon, initialNostrSubmissions = [], @@ -67,15 +108,27 @@ export default function HackathonProjectsList({ hackathon: Hackathon; initialNostrSubmissions?: HackathonSubmission[]; }) { + const router = useRouter(); const [nostrSubmissions, setNostrSubmissions] = useState< HackathonSubmission[] >(initialNostrSubmissions); const [scanning, setScanning] = useState(false); + const [syncingCache, setSyncingCache] = useState(false); + const [cachePending, setCachePending] = useState(false); const [authorPictures, setAuthorPictures] = useState>( () => picturesFromSubmissions(initialNostrSubmissions), ); const sectionRef = useRef(null); const relayAbortRef = useRef(null); + const communityRef = useRef( + initialNostrSubmissions + .map(communityFromSubmission) + .filter((project): project is CommunityProject => project !== null), + ); + + useLayoutEffect(() => { + return restoreScrollPosition(`/hackathons/${hackathon.id}`); + }, [hackathon.id]); const awards = useMemo( () => prizedProjects(hackathon.id), @@ -100,14 +153,25 @@ export default function HackathonProjectsList({ [hackathon.id, nostrSubmissions], ); - function applyCommunityProjects(projects: CommunityProject[]) { - const filtered = projects.filter((p) => p.hackathon === hackathon.id); - setNostrSubmissions(filtered.map(fromCommunity)); - const seeded = picturesFromCommunityProjects(filtered); + function applyCommunityProjects( + projects: CommunityProject[], + opts?: { merge?: boolean }, + ) { + const filtered = sortCommunityProjects( + projects.filter((p) => p.hackathon === hackathon.id), + ); + const next = opts?.merge + ? mergeCommunityProjects(communityRef.current, filtered).filter( + (p) => p.hackathon === hackathon.id, + ) + : filtered; + communityRef.current = next; + setNostrSubmissions(next.map(fromCommunity)); + const seeded = picturesFromCommunityProjects(next); if (seeded.size > 0) { setAuthorPictures((prev) => new Map([...prev, ...seeded])); } - const pubkeys = [...new Set(filtered.map((p) => p.author))]; + const pubkeys = [...new Set(next.map((p) => p.author))]; if (pubkeys.length > 0) { fetchAuthorPictures(pubkeys, TOP10_RELAYS).then(setAuthorPictures); } @@ -115,6 +179,9 @@ export default function HackathonProjectsList({ function upsertCommunityProject(project: CommunityProject) { if (project.hackathon !== hackathon.id) return; + communityRef.current = mergeCommunityProjects(communityRef.current, [ + project, + ]).filter((p) => p.hackathon === hackathon.id); const next = fromCommunity(project); setNostrSubmissions((prev) => { const idx = prev.findIndex( @@ -136,10 +203,10 @@ export default function HackathonProjectsList({ async function syncServerSnapshot(signal?: AbortSignal) { const snapshot = await fetchCommunityProjectsSnapshot({ signal }); - applyCommunityProjects(snapshot.projects); + applyCommunityProjects(snapshot.projects, { merge: true }); } - async function refreshFromRelays() { + async function refreshFromRelays(opts?: { manual?: boolean }) { if (scanning) return; setScanning(true); relayAbortRef.current?.abort(); @@ -149,7 +216,60 @@ export default function HackathonProjectsList({ const snapshot = await refreshCommunityProjectsFromRelays({ signal: abort.signal, }); - applyCommunityProjects(snapshot.projects); + const visible = snapshot.projects.filter( + (p) => p.hackathon === hackathon.id, + ); + const newer = newerCommunityProjects(visible, communityRef.current); + if (newer.length > 0) { + applyCommunityProjects(snapshot.projects, { merge: true }); + const newest = newer.reduce((best, project) => + project.eventCreatedAt > best.eventCreatedAt ? project : best, + ); + setSyncingCache(true); + try { + const serverSnapshot = await refreshNostrServerCache({ + scopes: ["projects"], + hackathonId: hackathon.id, + candidateEventId: newest.eventId, + candidateCreatedAt: newest.eventCreatedAt, + blocking: true, + signal: abort.signal, + }); + if (serverSnapshot) { + applyCommunityProjects(serverSnapshot.projects, { merge: true }); + setCachePending( + newer.some( + (candidate) => + !serverSnapshot.projects.some( + (serverProject) => + communityProjectKey(serverProject) === + communityProjectKey(candidate) && + serverProject.eventCreatedAt >= candidate.eventCreatedAt, + ), + ), + ); + router.refresh(); + } + } catch (e) { + if (!(e instanceof DOMException && e.name === "AbortError")) { + console.warn("[labs] hackathon cache refresh failed", e); + } + } finally { + setSyncingCache(false); + } + } else if (opts?.manual) { + setCachePending(false); + const serverSnapshot = await refreshNostrServerCache({ + scopes: ["projects"], + hackathonId: hackathon.id, + blocking: true, + signal: abort.signal, + }); + if (serverSnapshot) { + applyCommunityProjects(serverSnapshot.projects, { merge: true }); + router.refresh(); + } + } } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; console.warn("[labs] hackathon scan failed", e); @@ -167,6 +287,7 @@ export default function HackathonProjectsList({ if (cached) { const filtered = cached.filter((p) => p.hackathon === hackathon.id); if (filtered.length > 0) { + communityRef.current = filtered; setNostrSubmissions(filtered.map(fromCommunity)); const pics = picturesFromCommunityProjects(filtered); if (pics.size > 0) setAuthorPictures(pics); @@ -179,7 +300,22 @@ export default function HackathonProjectsList({ }) .finally(() => { if (!snapshotAbort.signal.aborted) { - refreshFromRelays(); + const idle = window.requestIdleCallback?.( + () => refreshFromRelays(), + { timeout: 1200 }, + ); + const fallback = + idle === undefined + ? window.setTimeout(() => refreshFromRelays(), 250) + : null; + snapshotAbort.signal.addEventListener( + "abort", + () => { + if (idle !== undefined) window.cancelIdleCallback(idle); + if (fallback !== null) window.clearTimeout(fallback); + }, + { once: true }, + ); } }); return () => { @@ -203,7 +339,7 @@ export default function HackathonProjectsList({ block: "start", }); if (project) upsertCommunityProject(project); - refreshFromRelays(); + refreshFromRelays({ manual: true }); } window.addEventListener("labs:project-published", onPublished); return () => @@ -229,10 +365,12 @@ export default function HackathonProjectsList({
{headerLabel} - {scanning && ( + {(scanning || syncingCache || cachePending) && ( - sincronizando + {syncingCache || cachePending + ? "sincronizando cache" + : "sincronizando"} )}
@@ -251,7 +389,7 @@ export default function HackathonProjectsList({ )}