Files
CashuMints.space/web/src/lib/nostr-build.ts
T
michilisandClaude Opus 5 1eade490c8 Order the latest-reviews strip by created_at alone.
The home page carousel floated reviews whose author had a kind 0 ahead of
everything else, capped at 90 days old, so a named review from two months ago
sat between two reviews from this week. Under a heading that says "Latest
reviews", with each card's foot printing the very timestamp being overruled,
that reads as broken rather than as curation.

Strict recency now, newest first, as the last thing that happens to the list,
with the event id breaking ties so two builds of the same events agree. Names
and avatars are still resolved and still shown; they no longer decide the
order.

The candidate scan stops at the limit rather than gathering twelve times it:
the scan already runs newest first, so nothing further down can outrank what
it has, and the build resolves ten profiles per locale instead of a hundred
and twenty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:23:22 +02:00

382 lines
13 KiB
TypeScript

/**
* 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.
*/
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<typeof setTimeout> | 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<T>(fn: (pool: SimplePool) => Promise<T>): Promise<T> {
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<NostrEvent[]> } | null = null;
/** Recent review events, from the relays at most once every `READ_TTL_MS`. */
function readReviewEvents(): Promise<NostrEvent[]> {
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<BuildReview[]> {
try {
const events = await readReviewEvents();
const index = buildFeedIndex(targets);
const candidates: BuildReview[] = [];
const seenAuthors = new Set<string>();
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,
});
// The scan runs newest first, so the first `limit` survivors are the latest
// `limit` reviews. Nothing further down the list can outrank them.
if (candidates.length >= limit) break;
}
const profiles = await fetchProfiles(candidates.map((c) => c.pubkey));
for (const review of candidates) review.profile = profiles.get(review.pubkey) ?? null;
/*
* Strict recency, newest first, as the last thing that happens to this list.
*
* The heading says "Latest reviews", so the order under it is the event
* `created_at` and nothing else. This used to float reviews whose author had a
* kind 0 to the front, which read as broken on the page: a named review from two
* months ago sat between two reviews from this week, and the card feet said so,
* because the "2mo ago" label is formatted from the very same `created_at` this
* sorts on. Author names are still resolved and still shown — they just no longer
* decide the order.
*
* Sorted here rather than left to the scan above, so that the invariant holds
* whatever the gathering loop does later, and `id` breaks ties between two events
* that share a second so two builds of the same events agree.
*/
candidates.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
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<BuildReview[]> {
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<string, BuildReview>();
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<string, Profile>();
const asked = new Set<string>();
/**
* 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<Map<string, Profile>> {
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<string, NostrEvent>();
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<string, Profile>();
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;
}
}