@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* 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,
|
||||
isCashuMintReview,
|
||||
normalizeMintUrl,
|
||||
mintUrlsFromEvent,
|
||||
parseProfileContent,
|
||||
parseRating,
|
||||
WRITTEN_MIN_CHARS,
|
||||
type Profile,
|
||||
} from '@cashumints/shared';
|
||||
|
||||
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;
|
||||
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 the mints the API
|
||||
* knows about. `hostByUrl` maps normalized mint URL to routing slug.
|
||||
*/
|
||||
export async function fetchLatestReviews(
|
||||
hostByUrl: Map<string, string>,
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
|
||||
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;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) 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: normalized.url,
|
||||
mint_host: host,
|
||||
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.
|
||||
*
|
||||
* 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(
|
||||
hostByUrl: Map<string, string>,
|
||||
limit: number,
|
||||
): Promise<BuildReview[]> {
|
||||
try {
|
||||
const events = await readReviewEvents();
|
||||
|
||||
/*
|
||||
* Kind 38000 is addressable: a later event from the same author about the same
|
||||
* mint replaces the earlier one, exactly as the mint page treats it. Keyed on
|
||||
* author plus mint, so one person reviewing three mints keeps three reviews.
|
||||
*/
|
||||
const newest = new Map<string, BuildReview>();
|
||||
|
||||
for (const event of events) {
|
||||
const content = cleanReviewContent(event.content);
|
||||
if (content.length < WRITTEN_MIN_CHARS) continue;
|
||||
if (!isCashuMintReview(event) && mintUrlsFromEvent(event).length === 0) continue;
|
||||
|
||||
const raw = mintUrlsFromEvent(event)[0];
|
||||
if (!raw) continue;
|
||||
const normalized = normalizeMintUrl(raw);
|
||||
if (!normalized) continue;
|
||||
|
||||
const host = hostByUrl.get(normalized.url);
|
||||
if (!host) continue;
|
||||
|
||||
const key = `${event.pubkey}:${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: normalized.url,
|
||||
mint_host: host,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user