Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
394 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|