Files
CashuMints.space/web/src/lib/nostr-build.ts
T
michilis 6f17b572b1 Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
2026-08-21 02:10:48 +02:00

394 lines
14 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.
*/
/** 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<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,
});
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<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;
}
}