/** * Build-time Nostr reads. Runs in Node during `astro build`, never in the browser. * * Two consumers: the home page "latest reviews" strip (three cards, junk filtered, * one per author) and the /reviews feed (the newest written reviews across every * mint, prerendered so the page has real content for a crawler before the island * takes over with live relay data). */ import { SimplePool } from 'nostr-tools/pool'; import type { Event as NostrEvent } from 'nostr-tools'; import { nip19 } from 'nostr-tools'; import { DEFAULT_RELAYS, KIND_PROFILE, KIND_REVIEW, PROFILE_RELAYS, cleanReviewContent, parseProfileContent, parseRating, WRITTEN_MIN_CHARS, type Profile, } from '@cashumints/shared'; import { buildFeedIndex, resolveFeedTarget, type FeedTarget } from './feed-resolve'; const list = (value: string | undefined): string[] | null => { const parsed = value?.split(',').map((relay) => relay.trim()).filter(Boolean); return parsed && parsed.length > 0 ? parsed : null; }; const RELAYS = list(process.env['RELAYS']) ?? [...DEFAULT_RELAYS]; /** * Profiles come from a wider pool than reviews do. The review relays hold few kind 0 * events, which is why this strip used to render npubs where it had names available * one relay over. */ const PROFILE_POOL = list(process.env['PROFILE_RELAYS']) ?? [...PROFILE_RELAYS]; export interface BuildReview { id: string; pubkey: string; npub: string; created_at: number; rating: number | null; content: string; mint_url: string; mint_host: string; /** `cashu` or `fedimint`: which route the card links to, and which filter shows it. */ mint_type: string; profile: Profile | null; } /** * Junk filter for the home page strip only, per FRONTEND.md: reviews shorter than 8 * characters or matching a common test string do not qualify. They still appear in * full on the mint page, nothing is hidden, this strip is just a shop window. */ /** How many candidates to gather per card shown, so the ordering has a real choice. */ const POOL_FACTOR = 12; /** * How recent a review must still be to be promoted for having a named author. * * The ordering below prefers reviews whose author has a kind 0, and this is the leash * on that preference. Without it the strip would headline a named review from two * years ago under a heading that says "Latest reviews"; with it, a named review from * within the season can lead and anything older cannot. */ const PROMOTE_MAX_AGE_S = 90 * 24 * 60 * 60; const TEST_STRINGS = new Set([ 'test', 'testing', 'test test', 'hello', 'hi', 'ok', 'okay', 'good', 'nice', 'great', 'cool', 'gm', 'a', '.', '..', '...', 'asdf', 'qwerty', '123', ]); function qualifies(content: string): boolean { const text = content.trim(); if (text.length < 8) return false; if (TEST_STRINGS.has(text.toLowerCase().replace(/[.!?]+$/, ''))) return false; // A body that is only punctuation or emoji says nothing about the mint. if (!/[a-z]{3}/i.test(text)) return false; return true; } /* ---------- the shared pool ---------- */ /** * How long the pool waits with nothing to do before closing its sockets. * * One pool spans every read here rather than one per call: the review query and the * profile query that follows it used to re-handshake five relays between them, and a * dev server rendering three locales did it again for each. Long enough to cover that * run of work, short enough that it is gone by the time anything looks. * * It cannot simply stay open forever. An open websocket keeps Node alive, so `astro * build` would finish the site and then sit there with nothing to do. */ const IDLE_CLOSE_MS = 2000; let pool: SimplePool | null = null; let idle: ReturnType | null = null; let inFlight = 0; function closePool(): void { const open = pool; pool = null; idle = null; if (!open) return; try { open.close([...new Set([...RELAYS, ...PROFILE_POOL])]); } catch { // A relay that already dropped throws on close. Harmless at build time. } } /** * Run `fn` against the shared pool, and close the pool once nothing else is using it. * * The close is armed on the way out rather than on the way in, so a query that takes * its full ten seconds is never closed out from under itself, and two reads running * together both finish before either arms it. */ async function withPool(fn: (pool: SimplePool) => Promise): Promise { pool ??= new SimplePool(); if (idle) { clearTimeout(idle); idle = null; } inFlight += 1; try { return await fn(pool); } finally { inFlight -= 1; if (inFlight === 0) { idle = setTimeout(closePool, IDLE_CLOSE_MS); // The timer must not be the thing holding the process open either. idle.unref?.(); } } } /* ---------- the shared read ---------- */ /** * How many events to ask the relays for. * * Both readers below want the same thing: recent kind 38000, unfiltered. They differ * only in how they narrow it afterwards, so they share one query at the larger of the * two limits they used to ask for separately. */ const EVENT_LIMIT = 500; /** * How long that one read stays good. * * `astro build` calls both readers once per locale — six identical ten-second-bounded * queries for one answer. A build never runs long enough for this to expire, so it * works out to exactly one relay read for the whole build. * * The TTL is there for `astro dev`, which renders a page per request: without it the * full relay round trip sits in front of every single click on `/` and `/reviews`, * which is what made those two pages take a second and a half while every other page * on the site answered instantly. Five minutes is short enough that a review * published while the dev server is up still turns up without restarting it. */ const READ_TTL_MS = 5 * 60 * 1000; let cached: { at: number; events: Promise } | null = null; /** Recent review events, from the relays at most once every `READ_TTL_MS`. */ function readReviewEvents(): Promise { const at = Date.now(); if (cached && at - cached.at < READ_TTL_MS) return cached.events; const events = withPool((p) => p.querySync(RELAYS, { kinds: [KIND_REVIEW], limit: EVENT_LIMIT }, { maxWait: 10_000 }), ).catch((error: unknown) => { // A failed read must not sit in the cache: one bad moment would otherwise leave // every page built in the next five minutes with no reviews on it. if (cached?.at === at) cached = null; throw error; }); cached = { at, events }; return events; } /** * Newest review events that are worth showing, resolved against everything the API * knows about — mints and federations both, since the strip's heading is "latest * reviews" and not "latest mint reviews". In practice federations rarely reach it: the * junk filter above wants a written body, and almost every 38173 review on the network * is a bare `[5/5]`. */ export async function fetchLatestReviews( targets: readonly FeedTarget[], limit: number, ): Promise { try { const events = await readReviewEvents(); const index = buildFeedIndex(targets); const candidates: BuildReview[] = []; const seenAuthors = new Set(); for (const event of [...events].sort((a, b) => b.created_at - a.created_at)) { const content = cleanReviewContent(event.content); if (!qualifies(content)) continue; const target = resolveFeedTarget(event, index); if (!target) continue; // One card per author, so the strip is not three posts from the same npub. if (seenAuthors.has(event.pubkey)) continue; seenAuthors.add(event.pubkey); candidates.push({ id: event.id, pubkey: event.pubkey, npub: safeNpub(event.pubkey), created_at: event.created_at, rating: parseRating(event), content, mint_url: target.url, mint_host: target.host, mint_type: target.type, profile: null, }); if (candidates.length >= limit * POOL_FACTOR) break; } const profiles = await fetchProfiles(candidates.map((c) => c.pubkey)); for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null; /* * Recent reviews with a named author come first; everything else stays newest * first behind them. * * Most review keys have published exactly one event in their life: the review * itself. Nothing can be fetched for them, so a strict recency order fills this * strip with anonymous npubs while a named review sits a few rows below the * cut. This is the home page shop window, which already drops junk bodies, so * it prefers a review a reader can attach a person to. Nothing is hidden: * every review is on its mint page, and PROMOTE_MAX_AGE_S keeps "latest" * meaning latest. */ const cutoff = Math.floor(Date.now() / 1000) - PROMOTE_MAX_AGE_S; const promoted = (review: BuildReview): number => review.profile?.found && review.created_at >= cutoff ? 1 : 0; candidates.sort((a, b) => promoted(b) - promoted(a) || b.created_at - a.created_at); return candidates.slice(0, limit); } catch { // Relays down at build time must not fail the build: the section renders its // empty state and the site still ships. return []; } } /** * The global feed: newest written reviews across every mint and federation. * * Same source and same parsing as the home page strip, three differences: * every author may appear more than once (once per mint they reviewed), the junk * filter is not applied (a short body is still a review, and this page is the record * rather than a shop window), and only reviews with a body are returned because the * feed renders cards. Ratings without a comment are a per mint thing and stay on the * mint page. * * Whatever this returns is a snapshot at build time. The island re-queries the relays * on load and replaces it, so a review published a minute ago is not missing for a * visitor, only for a crawler. */ export async function fetchReviewFeed( targets: readonly FeedTarget[], limit: number, ): Promise { try { const events = await readReviewEvents(); const index = buildFeedIndex(targets); /* * Kind 38000 is addressable: a later event from the same author about the same * subject replaces the earlier one, exactly as a subject page treats it. Keyed on * author plus subject, so one person reviewing three mints and a federation keeps * four reviews. */ const newest = new Map(); for (const event of events) { const content = cleanReviewContent(event.content); if (content.length < WRITTEN_MIN_CHARS) continue; const target = resolveFeedTarget(event, index); if (!target) continue; const key = `${event.pubkey}:${target.host}`; const existing = newest.get(key); if (existing && existing.created_at >= event.created_at) continue; newest.set(key, { id: event.id, pubkey: event.pubkey, npub: safeNpub(event.pubkey), created_at: event.created_at, rating: parseRating(event), content, mint_url: target.url, mint_host: target.host, mint_type: target.type, profile: null, }); } const feed = [...newest.values()] .sort((a, b) => b.created_at - a.created_at) .slice(0, limit); const profiles = await fetchProfiles(feed.map((review) => review.pubkey)); for (const review of feed) review.profile = profiles.get(review.pubkey) ?? null; return feed; } catch { // Relays down at build time must not fail the build: the page ships with its // loading state and the island fills it in. return []; } } /* ---------- profiles ---------- */ /** Names resolved so far, and every key already asked about, hit or miss. */ const resolved = new Map(); const asked = new Set(); /** * Names and avatars for `pubkeys`, asking the relays only about keys not seen before. * * The two readers above draw from the same events, so the feed's authors are largely * the home strip's authors, and all of it repeats per locale. Without this the build * would put the same hundred keys to eight relays six times over. In practice only * the first call asks anything. * * A key is marked asked only when a query actually came back, so relays being down * once does not cache a permanent miss for the rest of the build. */ async function fetchProfiles(pubkeys: string[]): Promise> { const wanted = [...new Set(pubkeys)]; const missing = wanted.filter((pubkey) => !asked.has(pubkey)); if (missing.length > 0) { try { const events = await withPool((p) => p.querySync(PROFILE_POOL, { kinds: [KIND_PROFILE], authors: missing }, { maxWait: 6000 }), ); // Keep only the newest kind-0 per author. const newest = new Map(); for (const event of events) { const existing = newest.get(event.pubkey); if (!existing || event.created_at > existing.created_at) newest.set(event.pubkey, event); } // Same parse and sanitize rules the browser island uses, so a name renders the // same way whether it was resolved at build time or on the page. for (const [pubkey, event] of newest) { const profile = parseProfileContent(pubkey, event.content); if (profile.found) resolved.set(pubkey, profile); } for (const pubkey of missing) asked.add(pubkey); } catch { // No profiles is fine: cards fall back to a short npub. } } const out = new Map(); for (const pubkey of wanted) { const profile = resolved.get(pubkey); if (profile) out.set(pubkey, profile); } return out; } function safeNpub(pubkey: string): string { try { return nip19.npubEncode(pubkey); } catch { return pubkey; } }