Compare commits
5
Commits
dev
..
8373853971
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8373853971 | ||
|
|
c1f4565bfe | ||
|
|
f138066ee6 | ||
|
|
59f9af119a | ||
|
|
87901c56d2 |
@@ -96,19 +96,6 @@ SEO_PRODUCT_JSONLD=1
|
||||
# so reviews the old site published to snort/primal were invisible to it.
|
||||
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
|
||||
|
||||
# How many events a backfill has to read before it counts as having read anything.
|
||||
#
|
||||
# A backfill asks every relay above for the whole history of four kinds; on a working
|
||||
# relay list that is thousands of events. Under this floor, discovery logs
|
||||
# `ERROR discovery starvation suspected` and /api/health answers 503 with
|
||||
# `discovery_starved: true` until the next backfill clears it.
|
||||
#
|
||||
# This exists because a RELAYS list missing the relay that carries the announcement
|
||||
# archive returned about thirty events per backfill for a year, reported ok=true every
|
||||
# time, and left the index at eight mints with every health signal green. Lower it only
|
||||
# for a private or test relay that genuinely holds less; 1 disables the check.
|
||||
#BACKFILL_MIN_EVENTS=200
|
||||
|
||||
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
|
||||
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
|
||||
# 0 at all and snort/primal hold almost none, so the two aggregators below are what
|
||||
|
||||
+1
-2
@@ -4,9 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "node --env-file-if-exists=../.env --watch src/index.ts",
|
||||
"start": "node --env-file-if-exists=../.env dist/index.js",
|
||||
"start": "node --env-file-if-exists=../.env src/index.ts",
|
||||
"seed": "node --env-file-if-exists=../.env src/seed.ts",
|
||||
"migrate": "node --env-file-if-exists=../.env src/migrate.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
|
||||
@@ -112,20 +112,6 @@ export const config = {
|
||||
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
|
||||
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
|
||||
[...DEFAULT_RELAYS]) as string[],
|
||||
/**
|
||||
* The floor a backfill cycle has to clear before it counts as a real read.
|
||||
*
|
||||
* For about a year this deployment's RELAYS list did not include the relay carrying
|
||||
* the kind 38000/38172 archive. Every backfill returned about thirty events, wrote
|
||||
* them, reported ok=true, and the index sat at eight mints while every health signal
|
||||
* stayed green. A backfill asks five relays for the whole history of four kinds; on a
|
||||
* working relay set it comes back with thousands. Anything under this is not a quiet
|
||||
* network, it is a misconfigured one, and it says so in the log and on /api/health.
|
||||
*
|
||||
* Raise it on a deployment that genuinely has more history, lower it for a local
|
||||
* test relay. It is deliberately not zero-able: set it to 1 if you mean "off".
|
||||
*/
|
||||
backfillMinEvents: int('BACKFILL_MIN_EVENTS', 200),
|
||||
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
|
||||
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
|
||||
probeConcurrency: int('PROBE_CONCURRENCY', 8),
|
||||
|
||||
+14
-276
@@ -19,10 +19,9 @@ import {
|
||||
type FedimintAnnouncement,
|
||||
type LnurlAnnouncement,
|
||||
type LnurlFields,
|
||||
type RelayHealth,
|
||||
} from '@cashumints/shared';
|
||||
import { config } from './config.ts';
|
||||
import { getDb, setState, getState, getStateNumber } from './db.ts';
|
||||
import { getDb, setState, getStateNumber } from './db.ts';
|
||||
import type { Sql } from './db-driver.ts';
|
||||
import { log } from './log.ts';
|
||||
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
|
||||
@@ -40,118 +39,8 @@ export interface DiscoveryResult {
|
||||
newMints: string[];
|
||||
newReviews: number;
|
||||
ok: boolean;
|
||||
/** What each configured relay actually did, in `config.relays` order. */
|
||||
relays: RelayHealth[];
|
||||
/** A backfill that came in under `config.backfillMinEvents`. */
|
||||
starved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the last cycle did, kept so /api/health can answer for it.
|
||||
*
|
||||
* Written to the `state` table rather than held in memory, because the question it
|
||||
* answers — "is discovery actually reading anything?" — has to survive the restart that
|
||||
* would otherwise reset it to "no cycle yet, nothing to report". A process that crash
|
||||
* loops would clear an in-memory flag on every attempt.
|
||||
*/
|
||||
export interface DiscoveryReport {
|
||||
at: number;
|
||||
mode: 'backfill' | 'incremental';
|
||||
events: number;
|
||||
ok: boolean;
|
||||
relays: RelayHealth[];
|
||||
starved: boolean;
|
||||
}
|
||||
|
||||
/** `state` key holding the JSON of the above. */
|
||||
const REPORT_KEY = 'last_discovery_report';
|
||||
|
||||
/**
|
||||
* The last cycle's report, or null before any cycle has run.
|
||||
*
|
||||
* A row that will not parse reads as null — the same as no cycle — because the caller
|
||||
* is a health endpoint and "I cannot tell you" must not be dressed up as "fine".
|
||||
*/
|
||||
export async function lastDiscoveryReport(): Promise<DiscoveryReport | null> {
|
||||
const raw = await getState(REPORT_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as DiscoveryReport;
|
||||
return Array.isArray(parsed.relays) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-relay bookkeeping for one cycle.
|
||||
*
|
||||
* Every relay in `config.relays` gets a row up front, including the ones that are never
|
||||
* reached, because a relay that produced no row at all is exactly the one worth naming:
|
||||
* the year-long starvation was a relay list that connected cleanly and simply did not
|
||||
* hold the archive, and the only field that would have shown it is a zero here.
|
||||
*
|
||||
* `events` counts what a relay sent *before* cross-relay deduplication, so five relays
|
||||
* carrying the same 400 events report 400 each rather than 400 once and 0 four times.
|
||||
* Attribution is the whole point; the deduplicated total is reported separately.
|
||||
*/
|
||||
class RelayTally {
|
||||
private readonly rows = new Map<string, { events: number; subs: number; eoses: number; connected: boolean }>();
|
||||
|
||||
constructor(urls: readonly string[]) {
|
||||
for (const url of urls) {
|
||||
this.rows.set(url, { events: 0, subs: 0, eoses: 0, connected: false });
|
||||
}
|
||||
}
|
||||
|
||||
private row(url: string) {
|
||||
let found = this.rows.get(url);
|
||||
if (!found) {
|
||||
found = { events: 0, subs: 0, eoses: 0, connected: false };
|
||||
this.rows.set(url, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
connected(url: string): void {
|
||||
this.row(url).connected = true;
|
||||
}
|
||||
|
||||
subscribed(url: string): void {
|
||||
this.row(url).subs++;
|
||||
}
|
||||
|
||||
event(url: string): void {
|
||||
this.row(url).events++;
|
||||
}
|
||||
|
||||
eose(url: string): void {
|
||||
this.row(url).eoses++;
|
||||
}
|
||||
|
||||
/** One row per configured relay, in configuration order. */
|
||||
list(): RelayHealth[] {
|
||||
return [...this.rows.entries()].map(([url, row]) => ({
|
||||
url,
|
||||
connected: row.connected,
|
||||
events: row.events,
|
||||
// A cycle asks a relay many questions. It only counts as having reached the end
|
||||
// of the stream if it reached the end of every one of them.
|
||||
eose: row.subs > 0 && row.eoses === row.subs,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long enough that the relay's own EOSE timer never wins.
|
||||
*
|
||||
* `Subscription` fires `oneose` both when an EOSE frame arrives and when its internal
|
||||
* timer expires, so the two are indistinguishable from the callback. Pushing that timer
|
||||
* out of reach and running the deadline here instead is what makes `eose` in the report
|
||||
* mean "the relay said it was done" rather than "something gave up".
|
||||
*/
|
||||
const NEVER_EOSE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let pool: SimplePool | null = null;
|
||||
|
||||
/**
|
||||
@@ -177,100 +66,12 @@ export function closePool(): void {
|
||||
pool = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every configured relay one filter, and record what each of them did.
|
||||
*
|
||||
* This replaces `pool.querySync(config.relays, …)`, which answers the same question and
|
||||
* throws the attribution away: it merges five relays into one deduplicated array, so a
|
||||
* relay list where four relays are empty and one carries everything is indistinguishable
|
||||
* from five healthy ones. That indistinguishability is the bug this whole file is being
|
||||
* changed for — a year of ~31-event backfills, `ok=true` every time.
|
||||
*
|
||||
* What it keeps from `querySync`, deliberately:
|
||||
*
|
||||
* - One subscription per relay over the pool's existing sockets, so this is the same
|
||||
* number of connections as before.
|
||||
* - A single `alreadyHaveEvent` shared across all five. `AbstractRelay._onmessage`
|
||||
* consults it *before* `JSON.parse` and signature verification, so an event five
|
||||
* relays all carry is still verified once. Per-relay `querySync` calls would have
|
||||
* verified it five times, which at 500 events a page is real CPU.
|
||||
* - `receivedEvent`, which fires on the way past that check, so the per-relay count is
|
||||
* what the relay sent rather than what was new because of it.
|
||||
*
|
||||
* What it changes: the deadline is run here rather than by each `Subscription`'s own
|
||||
* EOSE timer, so `oneose` firing means an EOSE frame actually arrived. See NEVER_EOSE_MS.
|
||||
*
|
||||
* Never throws. A relay that will not connect is a fact to record, not a reason to
|
||||
* abandon the four that did.
|
||||
*/
|
||||
async function queryRelays(filter: Filter, tally: RelayTally | null): Promise<NostrEvent[]> {
|
||||
const events: NostrEvent[] = [];
|
||||
const known = new Set<string>();
|
||||
const alreadyHaveEvent = (id: string): boolean => {
|
||||
if (known.has(id)) return true;
|
||||
known.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
config.relays.map(async (url) => {
|
||||
let relay;
|
||||
try {
|
||||
// The same connection budget subscribeMap would have used for this maxWait.
|
||||
relay = await getPool().ensureRelay(url, {
|
||||
connectionTimeout: Math.max(MAX_WAIT_MS * 0.8, MAX_WAIT_MS - 1000),
|
||||
});
|
||||
} catch {
|
||||
// Left as connected=false in the tally, which is the whole report this needs.
|
||||
return;
|
||||
}
|
||||
tally?.connected(url);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (deadline !== undefined) clearTimeout(deadline);
|
||||
resolve();
|
||||
};
|
||||
|
||||
try {
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent: (event) => events.push(event),
|
||||
alreadyHaveEvent,
|
||||
receivedEvent: () => tally?.event(url),
|
||||
oneose: () => {
|
||||
tally?.eose(url);
|
||||
sub.close('closed automatically on eose');
|
||||
},
|
||||
onclose: finish,
|
||||
eoseTimeout: NEVER_EOSE_MS,
|
||||
});
|
||||
tally?.subscribed(url);
|
||||
deadline = setTimeout(() => sub.close('closed on maxWait'), MAX_WAIT_MS);
|
||||
} catch {
|
||||
// The socket went away between ensureRelay and the REQ.
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query one kind, paging backwards with `until` until a page yields nothing new.
|
||||
* Relays cap `limit` independently, so paging is the only way a fresh database
|
||||
* converges to the complete history.
|
||||
*/
|
||||
async function fetchKind(
|
||||
kind: number,
|
||||
since: number | null,
|
||||
tally: RelayTally | null,
|
||||
): Promise<NostrEvent[]> {
|
||||
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
|
||||
const seen = new Map<string, NostrEvent>();
|
||||
let until: number | undefined;
|
||||
|
||||
@@ -281,7 +82,7 @@ async function fetchKind(
|
||||
|
||||
let batch: NostrEvent[];
|
||||
try {
|
||||
batch = await queryRelays(filter, tally);
|
||||
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
|
||||
} catch (err) {
|
||||
log.warn('relay query failed', {
|
||||
kind,
|
||||
@@ -322,7 +123,6 @@ async function fetchKind(
|
||||
async function fetchReviewsForMint(
|
||||
target: ReviewTarget,
|
||||
since: number | null,
|
||||
tally: RelayTally | null,
|
||||
): Promise<NostrEvent[]> {
|
||||
const filters: Filter[] = [];
|
||||
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
|
||||
@@ -378,7 +178,11 @@ async function fetchReviewsForMint(
|
||||
}
|
||||
|
||||
const batches = await Promise.all(
|
||||
filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
|
||||
filters.map((filter) =>
|
||||
getPool()
|
||||
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
|
||||
.catch(() => [] as NostrEvent[]),
|
||||
),
|
||||
);
|
||||
|
||||
return batches.flat();
|
||||
@@ -792,7 +596,6 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
|
||||
|
||||
const newMints = new Set<string>();
|
||||
const tally = new RelayTally(config.relays);
|
||||
let newReviews = 0;
|
||||
let events = 0;
|
||||
let ok = true;
|
||||
@@ -807,7 +610,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
const announcementsByType = new Map<string, NostrEvent[]>();
|
||||
await Promise.all(
|
||||
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
|
||||
announcementsByType.set(type, await fetchKind(kind, since, tally));
|
||||
announcementsByType.set(type, await fetchKind(kind, since));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -822,10 +625,10 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
|
||||
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
||||
// so reviews feed discovery too.
|
||||
const reviews = await fetchKind(KIND_REVIEW, since, tally);
|
||||
const reviews = await fetchKind(KIND_REVIEW, since);
|
||||
// The recent window catches anything a relay dropped from the unbounded query.
|
||||
const recent =
|
||||
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
|
||||
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
|
||||
|
||||
const byId = new Map<string, NostrEvent>();
|
||||
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
|
||||
@@ -886,7 +689,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
while (cursor < targets.length) {
|
||||
const target = targets[cursor++];
|
||||
if (!target) continue;
|
||||
const found = await fetchReviewsForMint(target, since, tally);
|
||||
const found = await fetchReviewsForMint(target, since);
|
||||
if (found.length > 0) {
|
||||
events += found.length;
|
||||
newReviews += await ingestReviews(found, index);
|
||||
@@ -904,79 +707,14 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
||||
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
|
||||
const mode = backfill ? 'backfill' : 'incremental';
|
||||
const relays = tally.list();
|
||||
|
||||
/*
|
||||
* Name the relay, every time, one line each.
|
||||
*
|
||||
* A relay that would not connect is worth saying on any cycle: the address is wrong,
|
||||
* or it is down, and neither gets better by itself. A relay that connected and sent
|
||||
* nothing is only news on a backfill — an incremental cycle asking for the last hour
|
||||
* of four kinds legitimately comes back empty, and warning about that hourly would
|
||||
* train everyone to skip the line that eventually matters.
|
||||
*/
|
||||
for (const relay of relays) {
|
||||
if (!relay.connected) {
|
||||
log.warn('discovery relay unreachable', { relay: relay.url, mode });
|
||||
continue;
|
||||
}
|
||||
if (backfill && relay.events === 0) {
|
||||
log.warn('discovery relay returned no events', { relay: relay.url, mode });
|
||||
} else if (!relay.eose) {
|
||||
log.warn('discovery relay never reached EOSE', {
|
||||
relay: relay.url,
|
||||
mode,
|
||||
events: relay.events,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The floor, and the flag the health endpoint reads.
|
||||
*
|
||||
* Only a backfill is measured against it. A backfill asks for the entire history of
|
||||
* every announcement kind and every review, so on a working relay set it is thousands
|
||||
* of events; an incremental cycle asks for one interval and is supposed to be small.
|
||||
*
|
||||
* The flag is sticky across incremental cycles: an hourly cycle that finds four
|
||||
* events must not clear a starvation a backfill diagnosed, so a non-backfill carries
|
||||
* forward whatever the last backfill concluded.
|
||||
*/
|
||||
let starved: boolean;
|
||||
if (backfill) {
|
||||
starved = events < config.backfillMinEvents;
|
||||
if (starved) {
|
||||
log.error('discovery starvation suspected', {
|
||||
events,
|
||||
floor: config.backfillMinEvents,
|
||||
relays: relays.length,
|
||||
silent: relays.filter((r) => r.events === 0).length,
|
||||
unreachable: relays.filter((r) => !r.connected).length,
|
||||
hint: 'check RELAYS: a relay list missing the announcement archive looks exactly like this',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No backfill has ever run in this deployment: nothing has confirmed the relay set
|
||||
// reads anything, and saying "fine" would be the whole original bug.
|
||||
starved = (await lastDiscoveryReport())?.starved ?? true;
|
||||
}
|
||||
|
||||
const report: DiscoveryReport = { at: now, mode, events, ok, relays, starved };
|
||||
// A report that cannot be written is not worth failing a cycle over; the cycle's own
|
||||
// work is already committed, and health degrades on the stale timestamp instead.
|
||||
await setState(REPORT_KEY, JSON.stringify(report)).catch(() => undefined);
|
||||
|
||||
log.info('discovery cycle', {
|
||||
mode,
|
||||
mode: backfill ? 'backfill' : 'incremental',
|
||||
events,
|
||||
new_mints: newMints.size,
|
||||
new_reviews: newReviews,
|
||||
ok,
|
||||
starved,
|
||||
relays: relays.map((r) => `${r.url}=${r.connected ? r.events : 'down'}`).join(' '),
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
|
||||
return { events, newMints: [...newMints], newReviews, ok, relays, starved };
|
||||
return { events, newMints: [...newMints], newReviews, ok };
|
||||
}
|
||||
|
||||
+13
-85
@@ -3,7 +3,6 @@ import {
|
||||
compareMints,
|
||||
NEUTRAL_PRIOR_MEAN,
|
||||
parseNuts,
|
||||
readCapabilities,
|
||||
type Health,
|
||||
type MintDetail,
|
||||
type MintInfo,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from '@cashumints/shared';
|
||||
import { config, startedAt } from './config.ts';
|
||||
import { getDb, getStateNumber, getState } from './db.ts';
|
||||
import { lastDiscoveryReport } from './discovery.ts';
|
||||
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
|
||||
|
||||
/**
|
||||
@@ -107,39 +105,6 @@ function round1(n: number | null): number | null {
|
||||
return n === null ? null : Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NUT numbers a row publishes.
|
||||
*
|
||||
* `nuts_json` is what the prober wrote and wins; `info.nuts` is the raw NUT-06 object it
|
||||
* was derived from, kept as a fallback for rows written before that column existed. One
|
||||
* function so a list card and a detail page can never read a different answer off the
|
||||
* same row.
|
||||
*/
|
||||
function rowNuts(row: MintRow, info: MintInfo | null): string[] {
|
||||
if (row.nuts_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.nuts_json) as string[];
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {
|
||||
// Fall through to the info object below.
|
||||
}
|
||||
}
|
||||
return info ? parseNuts(info.nuts) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* One list item.
|
||||
*
|
||||
* The chip fields at the bottom are why this now parses `info_json`. The alternative was
|
||||
* what /mints and /lnurl-mints used to do: fetch `GET /api/mints/:host` once per mint to
|
||||
* read two booleans off each one. That is an acceptable price for a build machine
|
||||
* rendering fifty-five cards once a night and an unacceptable one for every browser that
|
||||
* opens the page, which is what the list has to survive now that it hydrates.
|
||||
*
|
||||
* Facts, not sentences. `capabilities` is two booleans and `mintChip` turns them into
|
||||
* "Melt only" in the reader's language, wherever the card is being drawn. Rendering the
|
||||
* label here would ship one language to twenty-four locales.
|
||||
*/
|
||||
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
|
||||
const base = {
|
||||
review_count: agg?.review_count ?? 0,
|
||||
@@ -148,15 +113,6 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
||||
last_review_at: agg?.last_review_at ?? null,
|
||||
};
|
||||
|
||||
const info = parseInfo(row.info_json);
|
||||
// A federation and an LNURL mint have no `info_json` and so get null, which is the
|
||||
// honest value: not "both NUTs are enabled", but "there is nothing here to read".
|
||||
const capabilities = info ? readCapabilities(info.nuts) : null;
|
||||
|
||||
// Only the two facts the LNURL chip is drawn from, not the whole ecosystem blob: this
|
||||
// payload is fetched by every visitor on three pages.
|
||||
const lnurl = row.type === 'lnurl' ? parseEcosystem<LnurlFields>(row) : null;
|
||||
|
||||
return {
|
||||
url: row.url,
|
||||
host: row.host,
|
||||
@@ -170,14 +126,6 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
||||
score: bayesianScore(base, mean, now),
|
||||
last_review_at: base.last_review_at,
|
||||
version: row.version,
|
||||
nuts: rowNuts(row, info),
|
||||
capabilities,
|
||||
...(lnurl
|
||||
? {
|
||||
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
|
||||
funding_available: lnurl.funding_available ?? null,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,9 +232,16 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
|
||||
|
||||
const item = toListItem(row, agg.get(row.url), mean, now);
|
||||
const info = parseInfo(row.info_json);
|
||||
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
|
||||
// time here with a subtly different fallback rule; one function now answers for both.
|
||||
const nuts = item.nuts;
|
||||
|
||||
let nuts: string[] = [];
|
||||
if (row.nuts_json) {
|
||||
try {
|
||||
nuts = JSON.parse(row.nuts_json) as string[];
|
||||
} catch {
|
||||
nuts = [];
|
||||
}
|
||||
}
|
||||
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
|
||||
|
||||
/*
|
||||
* Type-specific columns are spread across the payload rather than nested under a key.
|
||||
@@ -424,55 +379,28 @@ export function resetStatsCache(): void {
|
||||
statsCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health bypasses the stats cache: it is the endpoint you page on.
|
||||
*
|
||||
* Three things can degrade it, and they are three different failures:
|
||||
*
|
||||
* probeStale nothing has checked a mint in three intervals
|
||||
* !discoveryOk the last discovery cycle threw
|
||||
* report.starved the last backfill read less than BACKFILL_MIN_EVENTS
|
||||
*
|
||||
* The third is the one added after the postmortem, and it is the only one that would
|
||||
* have caught a year of the index sitting at eight mints: the cycles were completing,
|
||||
* `ok` was true, the probes were fresh, and the relay list simply did not contain the
|
||||
* relay holding the archive. "Ran without throwing" is not the same claim as "read
|
||||
* anything", and only the second one is worth a green light.
|
||||
*
|
||||
* A deployment with an empty database reports degraded until its first backfill lands,
|
||||
* because until then nothing has confirmed the relay set reads anything at all. That is
|
||||
* intended: it holds `cashumints-web.service` at its health gate rather than letting it
|
||||
* publish a site built from nothing.
|
||||
*/
|
||||
/** Health bypasses the stats cache: it is the endpoint you page on. */
|
||||
export async function getHealth(): Promise<Health> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const db = await getDb();
|
||||
|
||||
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([
|
||||
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([
|
||||
getStateNumber('last_probe_at'),
|
||||
getStateNumber('last_discovery_at'),
|
||||
getState('last_discovery_ok'),
|
||||
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
|
||||
lastDiscoveryReport(),
|
||||
]);
|
||||
|
||||
const discoveryOk = discoveryOkRaw !== '0';
|
||||
const staleAfter = config.probeIntervalMin * 60 * 3;
|
||||
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
|
||||
// No report at all is starvation by default: see the note above.
|
||||
const starved = report?.starved ?? true;
|
||||
|
||||
return {
|
||||
status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok',
|
||||
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
|
||||
uptime_s: now - startedAt,
|
||||
last_probe_at: lastProbe,
|
||||
last_discovery_at: lastDiscovery,
|
||||
mints_tracked: tracked?.n ?? 0,
|
||||
updated_at: now,
|
||||
discovery_relays: report?.relays ?? [],
|
||||
last_discovery_events: report?.events ?? null,
|
||||
last_discovery_mode: report?.mode ?? null,
|
||||
discovery_starved: starved,
|
||||
backfill_min_events: config.backfillMinEvents,
|
||||
};
|
||||
}
|
||||
|
||||
+1
-16
@@ -7,22 +7,7 @@
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
/*
|
||||
* This project is compiled now, rather than run straight off `src/*.ts`.
|
||||
*
|
||||
* The reason is a fifteen-hour crash loop: the unit's ExecStart named `src/index.ts`,
|
||||
* the host's `/usr/bin/node` was 20, and native type stripping is 22.18 and newer, so
|
||||
* every start died on ERR_UNKNOWN_FILE_EXTENSION and systemd restarted it 464 times
|
||||
* without anything going red. Emitting plain `.js` removes the host's Node version
|
||||
* from the set of things that can break a deploy.
|
||||
*
|
||||
* `rewriteRelativeImportExtensions` is what lets the source keep its explicit `.ts`
|
||||
* specifiers — which is what makes `node --watch src/index.ts` work in development —
|
||||
* while the emitted files import `./config.js` and run anywhere.
|
||||
*/
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# /etc/cashumints/alert.env
|
||||
#
|
||||
# Read by cashumints-alert@.service, which systemd starts when any of the three units
|
||||
# fails. Everything here is optional: with the file absent or both values empty, an
|
||||
# alert is still written to the journal at ERROR priority and is readable with
|
||||
#
|
||||
# journalctl -p err -t cashumints-alert
|
||||
#
|
||||
# Set one or both to have failures leave the machine.
|
||||
#
|
||||
# Install it root-owned and not world-readable — a webhook URL is a capability:
|
||||
# sudo install -d -m 0755 /etc/cashumints
|
||||
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
|
||||
# sudo systemctl daemon-reload
|
||||
|
||||
# An ntfy topic URL. Free and public at ntfy.sh; pick a topic name nobody will guess,
|
||||
# because anyone who knows it can read and post to it.
|
||||
#NTFY_URL=https://ntfy.sh/cashumints-alerts-CHANGE-ME
|
||||
|
||||
# Anything that accepts a JSON POST. The body carries `unit`, `host`, `at`, `text` and
|
||||
# `content` — the last of which is what Discord and most Slack-compatible endpoints read,
|
||||
# so one payload fits all three.
|
||||
#WEBHOOK_URL=https://discord.com/api/webhooks/…
|
||||
@@ -1,101 +0,0 @@
|
||||
# /etc/systemd/system/cashumints-alert@.service
|
||||
#
|
||||
# The unit that makes a failure audible.
|
||||
#
|
||||
# The other three units each carry `OnFailure=cashumints-alert@%n.service`, so systemd
|
||||
# starts one of these with the failed unit's name as the instance — `%i` below is
|
||||
# literally `cashumints.service`, `cashumints-web.service` or `cashumints-site.service`.
|
||||
#
|
||||
# Why it exists: the API once crash looped 464 times over fifteen hours and nothing said
|
||||
# so. `Restart=on-failure` with no start limit is an infinite loop that never reaches a
|
||||
# `failed` state, so the journal filled with identical lines nobody was reading and
|
||||
# every signal stayed green. The other half of the fix is StartLimitBurst= in each unit,
|
||||
# which turns the loop into a failure; this is what carries that failure off the machine.
|
||||
#
|
||||
# Install:
|
||||
# sudo install -m 0644 deploy/cashumints-alert@.service /etc/systemd/system/
|
||||
# sudo install -d -m 0755 /etc/cashumints
|
||||
# sudo install -m 0640 -o root -g root deploy/alert.env.example /etc/cashumints/alert.env
|
||||
# sudo systemctl daemon-reload
|
||||
#
|
||||
# No [Install] section and never enabled: OnFailure= starts it, and a unit that also
|
||||
# started at boot would page on every reboot.
|
||||
|
||||
[Unit]
|
||||
Description=Notify that %i failed
|
||||
# No OnFailure= here. An alerter that alerts about its own failure is a loop, and this
|
||||
# one is written so its worst case is a journal line rather than a retry.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
# The one file an operator edits, and the only reason this unit is configurable at all.
|
||||
# Absent is a supported state — the leading `-` says so — and then the ExecStart below
|
||||
# still writes to the journal at ERROR, which is what `systemctl status` and
|
||||
# `journalctl -p err` read. See alert.env.example.
|
||||
EnvironmentFile=-/etc/cashumints/alert.env
|
||||
|
||||
# So `journalctl -t cashumints-alert` finds every alert, whichever unit triggered it.
|
||||
SyslogIdentifier=cashumints-alert
|
||||
|
||||
# Everything is inside one shell so the "nothing configured" branch is reachable without
|
||||
# a second unit. The pieces, in order:
|
||||
#
|
||||
# - `printf '<3>…'` on stdout. systemd reads that syslog prefix off a journal stream
|
||||
# and files the line at priority 3, ERROR, so `journalctl -p err` is a complete
|
||||
# history of failures on a host with no webhook configured at all. `<4>` is warning.
|
||||
# A prefix rather than systemd-cat, so the unit needs nothing from the filesystem it
|
||||
# has just sandboxed itself away from.
|
||||
# - NTFY_URL is a topic URL (https://ntfy.sh/your-topic). It gets a plain-text body
|
||||
# naming the failed unit, plus the header names ntfy understands.
|
||||
# - WEBHOOK_URL gets a JSON POST instead, for Discord, Slack or anything that speaks
|
||||
# `{"content": …}` — every key is sent, so one payload fits all of them.
|
||||
# - `--max-time 10` and a `||` fallback on each: an alert that hangs would hold the
|
||||
# failed unit's job open, and an alert that fails must not itself become a second
|
||||
# failed unit for somebody to notice. The shell ends in `true` for the same reason.
|
||||
#
|
||||
# `%i` is the failed unit's name, passed as an argument rather than interpolated into
|
||||
# the shell text: systemd expands specifiers before /bin/sh ever sees the line, and a
|
||||
# unit name is not a thing to trust to quoting.
|
||||
ExecStart=/bin/sh -c '\
|
||||
UNIT="$1"; \
|
||||
HOST="$(hostname)"; \
|
||||
WHEN="$(date -Is)"; \
|
||||
TEXT="$UNIT failed on $HOST at $WHEN"; \
|
||||
printf "<3>%s\\n" "$TEXT"; \
|
||||
if [ -n "$NTFY_URL" ]; then \
|
||||
/usr/bin/curl -fsS --max-time 10 \
|
||||
-H "Title: cashumints: $UNIT failed" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: rotating_light" \
|
||||
-d "$TEXT" "$NTFY_URL" >/dev/null \
|
||||
|| printf "<3>%s\\n" "alert: POST to NTFY_URL failed"; \
|
||||
fi; \
|
||||
if [ -n "$WEBHOOK_URL" ]; then \
|
||||
/usr/bin/curl -fsS --max-time 10 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\\"unit\\":\\"$UNIT\\",\\"host\\":\\"$HOST\\",\\"at\\":\\"$WHEN\\",\\"text\\":\\"$TEXT\\",\\"content\\":\\"$TEXT\\"}" \
|
||||
"$WEBHOOK_URL" >/dev/null \
|
||||
|| printf "<3>%s\\n" "alert: POST to WEBHOOK_URL failed"; \
|
||||
fi; \
|
||||
if [ -z "$NTFY_URL" ] && [ -z "$WEBHOOK_URL" ]; then \
|
||||
printf "<4>%s\\n" "alert: no NTFY_URL or WEBHOOK_URL in /etc/cashumints/alert.env, journal only"; \
|
||||
fi; \
|
||||
true' _ %i
|
||||
|
||||
# It sends one HTTP request and writes one line. It needs no identity of its own, and
|
||||
# DynamicUser gives it a throwaway one rather than sharing `nobody` with everything else
|
||||
# on the host that also could not be bothered to make a user.
|
||||
DynamicUser=yes
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
# An alert that cannot reach the network in ten seconds is not worth a stuck job.
|
||||
TimeoutStartSec=30
|
||||
@@ -1,67 +0,0 @@
|
||||
# /etc/systemd/system/cashumints-site.service
|
||||
#
|
||||
# Serves the built site on loopback. nginx proxies to it and never opens a file itself,
|
||||
# which is the point: when nginx held a `root` inside /home/cashumints, every directory
|
||||
# down to dist had to be traversable by www-data, and the one that was not took the
|
||||
# whole site down as a blanket 404 with nothing in the error log naming the cause.
|
||||
#
|
||||
# This is a long-running daemon, unlike cashumints-web.service next to it — that one is
|
||||
# the oneshot that produces what this one serves.
|
||||
|
||||
[Unit]
|
||||
Description=cashumints.space static site server
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
# Give up after five failures in two minutes instead of restarting forever. A process
|
||||
# that cannot start will not start on the 4000th attempt either, and `failed` in
|
||||
# `systemctl status` is a far louder signal than a journal scrolling past. The window
|
||||
# matches cashumints.service; see the note there for why it is 120s and not 60s. These
|
||||
# two are [Unit] keys; systemd ignores them under [Service] with only a warning.
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=5
|
||||
|
||||
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
|
||||
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
|
||||
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
|
||||
OnFailure=cashumints-alert@%n.service
|
||||
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
|
||||
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cashumints
|
||||
Group=cashumints
|
||||
WorkingDirectory=/home/cashumints/CashuMints.space/web
|
||||
|
||||
# The tree comes from cashumints-web.service, which rsyncs it here after a build.
|
||||
# Serving web/dist directly would mean a rebuild empties the site for the length of it.
|
||||
StateDirectory=cashumints
|
||||
Environment=NODE_ENV=production
|
||||
Environment=SITE_PORT=8789
|
||||
Environment=SITE_HOST=127.0.0.1
|
||||
Environment=WEB_ROOT=/var/lib/cashumints/web
|
||||
ExecStart=/usr/bin/node server.mjs
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
KillSignal=SIGTERM
|
||||
# In-flight responses finish; idle keep-alive connections are closed at once.
|
||||
TimeoutStopSec=15s
|
||||
UMask=0027
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
# Read-only rather than absent: server.mjs itself lives under /home/cashumints.
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/lib/cashumints
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,141 +0,0 @@
|
||||
# /etc/systemd/system/cashumints-web.service
|
||||
#
|
||||
# The frontend is static: `output: 'static'` in astro.config.mjs, and the whole site is
|
||||
# produced ahead of time. There is no frontend build to keep alive, so this unit is a
|
||||
# build rather than a daemon — one shot of `pnpm build`, which compiles shared/, renders
|
||||
# a social card per mint and prerenders every page from the live API. The daemon that
|
||||
# hands the result out is cashumints-site.service.
|
||||
#
|
||||
# Run it after a deploy, and only after a deploy:
|
||||
# sudo systemctl start cashumints-web
|
||||
#
|
||||
# There used to be a cashumints-web.timer firing this at 03:30 every night, because the
|
||||
# mint list was a snapshot of whatever the API held when the build ran and a nightly
|
||||
# rebuild was the only way it ever changed. The list hydrates from the API after paint
|
||||
# now, so a new mint, a new review count and a changed status all reach the page within
|
||||
# a second of load, and rebuilding 2,000 pages at 03:30 to refresh numbers that refresh
|
||||
# themselves is 20 minutes of CPU for nothing.
|
||||
#
|
||||
# What a build still produces, and therefore what a deploy is still for: the prerendered
|
||||
# HTML a crawler reads, the social card per mint, the sitemap, and a `/mint/{host}` page
|
||||
# for every mint known at build time. A mint indexed since the last deploy has no page of
|
||||
# its own until the next one; the 404 fallback resolves it against the live API, so it is
|
||||
# readable and reviewable in the meantime. That was already true between nightly builds.
|
||||
#
|
||||
# There is deliberately no [Install] section — this belongs to a deploy, not to a boot.
|
||||
|
||||
[Unit]
|
||||
Description=Rebuild the cashumints.space static site
|
||||
# Every page's data comes from the API over loopback, so the API has to be up.
|
||||
# Requires= rather than Wants=: a dead API should abort the build, not replace a good
|
||||
# site with an empty one.
|
||||
Requires=cashumints.service
|
||||
After=cashumints.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
|
||||
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
|
||||
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
|
||||
OnFailure=cashumints-alert@%n.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=cashumints
|
||||
Group=cashumints
|
||||
WorkingDirectory=/home/cashumints/CashuMints.space
|
||||
|
||||
# Where the published copy lands. Shared with the API and the site server, and created
|
||||
# by systemd with this unit's ownership if it is not there yet.
|
||||
StateDirectory=cashumints
|
||||
|
||||
Environment=NODE_ENV=production
|
||||
# Where the build reaches the API. Must match PORT= in cashumints.service.
|
||||
Environment=API_URL=http://127.0.0.1:8788
|
||||
Environment=SITE_URL=https://cashumints.space
|
||||
# Browser-facing origin. Empty means same origin: islands fetch /api/... and nginx
|
||||
# forwards it. Set this only if the API ever moves to its own hostname. Declared here
|
||||
# even though it is empty, because systemd's environment wins over .env — so what a
|
||||
# production build emits cannot drift with an edit to that file.
|
||||
Environment=PUBLIC_API_URL=
|
||||
|
||||
# After= orders the start; it does not wait for the port to accept connections. At boot
|
||||
# the API is still opening its database and probing, so block until it reports healthy
|
||||
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
|
||||
# it is genuinely ready, and curl -f treats that as a failure, so the loop keeps waiting.
|
||||
ExecStartPre=/usr/bin/timeout 90 /bin/sh -c 'until curl -sf -o /dev/null http://127.0.0.1:8788/api/health; do sleep 1; done'
|
||||
|
||||
# Then: does the API actually have an index to build a site out of?
|
||||
#
|
||||
# Health answering 200 says the process is up and its last backfill read something. It
|
||||
# does not say how many mints are in the table, and those are different questions — the
|
||||
# year of ~31-event backfills had a healthy API serving a real, complete, correct list of
|
||||
# eight mints. A build against that succeeds, prerenders eight cards, and rsync happily
|
||||
# replaces fifty-five with eight.
|
||||
#
|
||||
# So count the list before spending twenty minutes building from it. Below the floor
|
||||
# this exits non-zero, systemd abandons the unit at ExecStartPre, and — because publishing
|
||||
# is ExecStartPost, after the build — the previously published site is never touched. The
|
||||
# site stays exactly as it was and the OnFailure alert says why.
|
||||
#
|
||||
# Counted by the `"host":` key, one per item, rather than by counting `{`: the list
|
||||
# payload carries a nested object per mint (its NUT capability switches), so brace
|
||||
# counting would report roughly double. No jq: it is not installed on this host and a
|
||||
# build gate should not add a dependency to run.
|
||||
#
|
||||
# `Q` is a double-quote character, built with printf rather than written literally,
|
||||
# because this whole command is already inside systemd's single quotes and a quote of
|
||||
# either kind in the grep pattern would end the argument early.
|
||||
#
|
||||
# A curl that fails for any reason leaves `n` empty, `$${n:-0}` reads that as zero, and
|
||||
# zero is below every floor — so an API that fell over between the health check above and
|
||||
# this line refuses the build rather than sailing through it.
|
||||
Environment=MIN_MINTS_FOR_BUILD=20
|
||||
ExecStartPre=/bin/sh -c 'Q=$$(printf "\\042"); \
|
||||
n=$$(curl -sf --max-time 30 http://127.0.0.1:8788/api/mints | grep -o "$${Q}host$${Q}:" | wc -l); \
|
||||
if [ "$${n:-0}" -lt "$$MIN_MINTS_FOR_BUILD" ]; then \
|
||||
printf "<3>%s\\n" "refusing to build: /api/mints returned $${n:-0} mints, floor is $$MIN_MINTS_FOR_BUILD. Previous site left untouched."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
printf "%s\\n" "build gate: $$n mints, floor $$MIN_MINTS_FOR_BUILD"'
|
||||
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
|
||||
# and systemd's PATH does not include it.
|
||||
ExecStart=/usr/bin/pnpm build
|
||||
|
||||
# Publish, as a separate step from building.
|
||||
#
|
||||
# `astro build` empties dist before it writes, so the site server cannot read dist
|
||||
# directly — a rebuild would be a minute of 404s. It serves this copy instead, and the
|
||||
# copy is only touched once a build has succeeded: a failed build leaves the previous
|
||||
# site up rather than replacing it with a half-written one, which is the same reason
|
||||
# Requires=cashumints.service is above and the same reason the mint-count gate is an
|
||||
# ExecStartPre rather than a check after the fact.
|
||||
#
|
||||
# --delay-updates stages the changed files and renames them in at the end, so the window
|
||||
# where the tree is a mix of two builds is a rename rather than a whole transfer, and
|
||||
# --delete-after keeps removals from landing before their replacements. Unchanged files
|
||||
# — every hashed asset and card, which is nearly all of it — are not touched at all.
|
||||
ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/lib/cashumints/web/
|
||||
|
||||
# ~200 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
|
||||
# TimeoutStartSec is what bounds a Type=oneshot.
|
||||
TimeoutStartSec=1800
|
||||
# A build should not starve the API it is reading from.
|
||||
Nice=10
|
||||
|
||||
# The site server runs as cashumints and reads its own files, so this no longer has to
|
||||
# be world-readable — it was 0022 for nginx, back when nginx opened the files as
|
||||
# www-data. Kept at 0022 anyway: rsync preserves these modes into the published copy,
|
||||
# and a readable static site is easier to inspect than one that needs sudo.
|
||||
UMask=0022
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
# ProtectHome is deliberately absent, unlike in cashumints.service: this unit writes
|
||||
# inside /home/cashumints — web/dist, web/public/og, web/src/generated and the pnpm
|
||||
# store are all under it.
|
||||
ProtectSystem=full
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
@@ -1,70 +0,0 @@
|
||||
# /etc/systemd/system/cashumints.service
|
||||
[Unit]
|
||||
Description=cashumints.space indexer and API
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
# Stop after five failures in two minutes rather than restarting forever.
|
||||
#
|
||||
# The window is 120s and not 60s because RestartSec=5s below means five attempts take
|
||||
# a little over twenty seconds of restarts plus however long each attempt lives before
|
||||
# it dies. A process that fails *slowly* — a database that times out, a port that takes
|
||||
# four seconds to refuse — can spread five failures past a sixty second window and reset
|
||||
# the counter forever, which is the loop this is supposed to stop. 120s covers that.
|
||||
#
|
||||
# The failure this exists for: ExecStart named a .ts file, /usr/bin/node was 20, and
|
||||
# every start died in under a second. 464 restarts over fifteen hours, and because
|
||||
# Restart=on-failure without a start limit never reaches a `failed` state, nothing
|
||||
# anywhere went red. Both keys belong to [Unit] — under [Service] systemd only warns and
|
||||
# ignores them.
|
||||
StartLimitIntervalSec=120
|
||||
StartLimitBurst=5
|
||||
|
||||
# Carry a failure off the machine. `%n` is this unit's own name, so the alert says
|
||||
# which one died. cashumints-alert@.service writes to the journal at ERROR always and
|
||||
# curls NTFY_URL or WEBHOOK_URL from /etc/cashumints/alert.env when either is set.
|
||||
OnFailure=cashumints-alert@%n.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cashumints
|
||||
Group=cashumints
|
||||
WorkingDirectory=/home/cashumints/CashuMints.space/api
|
||||
|
||||
# StateDirectory creates /var/lib/cashumints with the service user's ownership.
|
||||
StateDirectory=cashumints
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=8788
|
||||
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
|
||||
Environment=ICON_DIR=/var/lib/cashumints/icons
|
||||
|
||||
# Compiled JavaScript, run by the distribution's own node.
|
||||
#
|
||||
# This line used to read `src/index.ts`, which made every start depend on the host
|
||||
# having Node 22.18 or newer for native type stripping. A host with Node 20 answered
|
||||
# that with ERR_UNKNOWN_FILE_EXTENSION in under a second, 464 times over fifteen hours,
|
||||
# and nothing anywhere went red. `pnpm build` now emits api/dist, so what runs here is
|
||||
# ordinary ESM and any Node from 20.18 up will start it.
|
||||
#
|
||||
# Deliberately /usr/bin/node and nothing else: an nvm or fnm path is invisible to this
|
||||
# unit's ProtectHome and breaks silently at the next version bump.
|
||||
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30s
|
||||
UMask=0027
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/lib/cashumints
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,160 +0,0 @@
|
||||
# /etc/nginx/sites-available/cashumints.space
|
||||
#
|
||||
# nginx terminates TLS and proxies. It opens no file belonging to this project — not the
|
||||
# built site, not an icon — and that is deliberate.
|
||||
#
|
||||
# It used to point a `root` at web/dist. Because nginx runs as www-data and everything
|
||||
# this project owns runs as cashumints, that arrangement required every directory from /
|
||||
# down to dist to be traversable by a user with no other business in the tree. A home
|
||||
# directory at its default 0700 anywhere in that chain broke the entire site, and it
|
||||
# broke it invisibly: `try_files` treats a permission error as a plain miss, so the
|
||||
# symptom was a blanket 404, or an internal-redirect loop that ended in a 500 with the
|
||||
# real cause named nowhere.
|
||||
#
|
||||
# Two upstreams now, both on loopback, both owned by the same user that built what they
|
||||
# serve:
|
||||
#
|
||||
# 127.0.0.1:8789 cashumints-site.service the prerendered site
|
||||
# 127.0.0.1:8788 cashumints.service /api/* and /icons/*
|
||||
#
|
||||
# Routing that used to live here lives with the thing that owns it. The locale 404 rule
|
||||
# in particular was a hand-maintained alternation of 23 codes in a file that is not in
|
||||
# the repository; adding a language meant remembering to edit it, and forgetting was
|
||||
# silent. web/server.mjs resolves those from the built tree.
|
||||
|
||||
proxy_cache_path /var/cache/nginx/cashumints
|
||||
levels=1:2
|
||||
keys_zone=cashumints:10m
|
||||
max_size=256m
|
||||
inactive=10m
|
||||
use_temp_path=off;
|
||||
|
||||
# Keep a few connections open to each upstream rather than reconnecting per request.
|
||||
# Both processes hold idle sockets longer than nginx does, so nginx is always the side
|
||||
# that closes and there is no window where it reuses a socket the upstream just dropped
|
||||
# — that race is what produces sporadic 502s under load.
|
||||
upstream cashumints_site {
|
||||
server 127.0.0.1:8789;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream cashumints_api {
|
||||
server 127.0.0.1:8788;
|
||||
keepalive 8;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name cashumints.space;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://cashumints.space$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name cashumints.space;
|
||||
|
||||
# nginx 1.25 and later want `http2 on;` on its own line and warn about the form above.
|
||||
# Left as is because it is the form that works on both, and Debian 12 ships 1.22.
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cashumints.space/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cashumints.space/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Set here rather than upstream: this is the only part of the stack that knows a
|
||||
# request arrived over TLS. Add `preload` only once you are content never to serve
|
||||
# this name over plain HTTP again.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# The upstreams send no Content-Encoding, so compression is nginx's to do. gzip_proxied
|
||||
# any is required — without it nginx refuses to compress a proxied response at all.
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json
|
||||
application/manifest+json application/xml image/svg+xml;
|
||||
|
||||
# Nothing here accepts an upload. The API's largest body is an 8 KB JSON submission,
|
||||
# and rejecting the oversized ones at the edge keeps them off the Node process.
|
||||
client_max_body_size 16k;
|
||||
|
||||
# Both upstreams are a process on this machine. A slow response is a bug, not a
|
||||
# network condition, and failing fast beats holding a worker for a minute.
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
|
||||
# HTTP/1.1 with an empty Connection header is what makes the keepalive pools above
|
||||
# work; the default 1.0 opens a new socket per request.
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Every proxied location includes Debian's /etc/nginx/proxy_params, which sets Host,
|
||||
# X-Real-IP, X-Forwarded-For and X-Forwarded-Proto. That include is load-bearing, not
|
||||
# decorative: the API's rate limiter reads the last hop of X-Forwarded-For to tell two
|
||||
# visitors apart, and without it every request arrives from the loopback peer and
|
||||
# shares one bucket. On a distro that ships no proxy_params, set those four by hand.
|
||||
# Do not also set X-Forwarded-For alongside the include — declaring it twice is what
|
||||
# produces nginx's "could not build optimal proxy_headers_hash" warning.
|
||||
|
||||
# The prerendered site. Cache-Control comes from server.mjs — a year and immutable for
|
||||
# anything with a content hash in its name, revalidate-every-time for markup — so
|
||||
# there is nothing to restate here.
|
||||
location / {
|
||||
proxy_pass http://cashumints_site;
|
||||
include proxy_params;
|
||||
}
|
||||
|
||||
# Health must always reflect the live process.
|
||||
location = /api/health {
|
||||
proxy_pass http://cashumints_api;
|
||||
proxy_cache off;
|
||||
# add_header replaces rather than merges: declaring one here drops every add_header
|
||||
# inherited from the server block, so HSTS has to be restated alongside it.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Cache-Control "no-store" always;
|
||||
include proxy_params;
|
||||
}
|
||||
|
||||
# Briefly cache the read-heavy endpoints.
|
||||
location ~ ^/api/(mints|stats)(?:/|$|\?) {
|
||||
proxy_pass http://cashumints_api;
|
||||
proxy_cache cashumints;
|
||||
proxy_cache_valid 200 30s;
|
||||
proxy_cache_valid 404 10s;
|
||||
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_lock on;
|
||||
# Restated for the same reason as in /api/health above.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
include proxy_params;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://cashumints_api;
|
||||
include proxy_params;
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://cashumints_api;
|
||||
proxy_cache cashumints;
|
||||
proxy_cache_valid 200 1d;
|
||||
include proxy_params;
|
||||
}
|
||||
|
||||
# No error_page and no try_files. A miss is the site server's 404 page, in the right
|
||||
# language and with a 404 status; an nginx error page here would replace it with a
|
||||
# blank one and hide which upstream failed.
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
# Dynamic mint data: analysis
|
||||
|
||||
**Status:** analysis only. No code or config was changed.
|
||||
|
||||
**Goal:** new and updated mints, stats, and reviews should appear on the list (and related list surfaces) immediately, without waiting for the nightly static rebuild.
|
||||
|
||||
**Current architecture (important correction):** nginx does **not** serve `web/dist` as files. Production is:
|
||||
|
||||
```
|
||||
browser → nginx :443 → cashumints-site (web/server.mjs :8789) → published copy of dist
|
||||
→ cashumints_api (Hono :8788) → /api/*, /icons/*
|
||||
```
|
||||
|
||||
`cashumints-web.timer` rebuilds at **03:30** daily, then `rsync`s `web/dist/` to `WEB_ROOT` (`/var/lib/cashumints/web`). Markup is a snapshot of whatever the API returned at that build.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data flow audit
|
||||
|
||||
There are **no Astro content collections**. Every dynamic page either fetches the Hono API at **build time** (`web/src/lib/api.ts`, `API_URL`, default `http://127.0.0.1:8787` / prod `8788`) or hydrates in the browser from `/api/…` and/or Nostr.
|
||||
|
||||
Astro “islands” here are **not** React/Vue/Svelte with `client:load`. There is **zero** `client:*` usage. Runtime behaviour is vanilla `<script>` modules in `.astro` files, bundled by Vite, re-run on view transitions via `onReady()`.
|
||||
|
||||
### 1.1 Every route under `web/src/pages`
|
||||
|
||||
| Route | `getStaticPaths` | Build-time data | Client-side at runtime |
|
||||
|---|---|---|---|
|
||||
| `[...locale]/index.astro` (`/`, `/es`, …) | `localePaths` (one HTML page per locale) | `fetchMints`, `fetchFedimints`, `fetchLnurlMints`, `fetchStats`, `fetchHealth`; N+1 `fetchMint` / `fetchFedimint` / `fetchLnurlMint` for the top 6 of each; `fetchLatestReviews()` from relays via `nostr-build.ts` | **PulseTicker** refreshes `/api/stats` + `/api/health`. Search form is GET to `/mints`. Review carousel is **not** re-queried. ColorBends is WebGL only. |
|
||||
| `[...locale]/mints.astro` | `localePaths` | `fetchMints()` then **one `fetchMint(host)` per mint** for NUT chips | Search / sort / hide-offline only rearrange **already-rendered** cards. **No live refetch.** |
|
||||
| `[...locale]/fedimints.astro` | `localePaths` | `fetchFedimints()` (no N+1; no NUT chips) | Same client filter/sort as `/mints`. **No live refetch.** |
|
||||
| `[...locale]/lnurl-mints.astro` | `localePaths` | `fetchLnurlMints()` + N+1 `fetchLnurlMint` for chips | Same. **No live refetch.** |
|
||||
| `[...locale]/mint/[host].astro` | `localePathsFor` over **every cashu mint known at build** | `fetchMints()` + `fetchMint(host)` per mint, shared across locales | **MintLive** → `GET /api/mints/:host`. **Reviews** → Nostr via `nostr-tools` `SimplePool`. |
|
||||
| `[...locale]/fedimint/[id].astro` | same pattern, `fetchFedimints` | federation detail | Same two islands. |
|
||||
| `[...locale]/lnurl-mint/[host].astro` | same pattern, `fetchLnurlMints` | LNURL detail | Same two islands. |
|
||||
| `[...locale]/reviews.astro` | `localePaths` | `fetchAllListings()`, `fetchStats()`, `fetchReviewFeed(…, 30)` from relays | **Re-queries relays** (`feed-client.ts` `loadFeed`), paginates, filters. This is the working “live list” pattern. |
|
||||
| `[...locale]/wallets.astro` | `localePaths` | Hardcoded wallet array in the page | None |
|
||||
| `[...locale]/about.astro` | `localePaths` | `fetchStats()` for counts in copy | None |
|
||||
| `[...locale]/privacy.astro`, `terms.astro`, `disclaimer.astro` | `localePaths` | i18n catalogs only | None |
|
||||
| `[...locale]/404.astro` + `pages/404.astro` | prefixed locales / English `/404` | None (static 404 shell) | **NotFound resolver:** `GET /api/mints/:host`, on miss `POST /api/index`, then reviews panel |
|
||||
| `sitemap.xml.ts` | n/a (endpoint at build) | `fetchMints` / `fetchFedimints` / `fetchLnurlMints`; `isIndexableMint` filter | n/a |
|
||||
| `robots.txt.ts` | n/a | `SITE_URL` only; `Disallow: /api/`, `/icons/` | n/a |
|
||||
|
||||
24 locales (`en` plus 23 prefixes). List pages are ~24 HTML files each. Mint detail pages are `mints × locales` (README ballpark: ~55 cashu mints × 24 ≈ 1,300+ pages, plus federations and LNURL).
|
||||
|
||||
### 1.2 Why `/mints` shows only a subset, with stale stats and reviews
|
||||
|
||||
**Not pagination.** `fetchMints()` calls `GET /api/mints?type=cashu` with **no `limit`**. `listMints()` in the API returns every matching row, sorted by score. The optional `?limit=` query is unused by the site.
|
||||
|
||||
**Not an `isIndexableMint` filter on the grid.** Unreachable, unreviewed mints still render as cards. That predicate only drops them from JSON-LD `ItemList` and the sitemap.
|
||||
|
||||
**The list is a frozen HTML snapshot.** `mints.astro` does:
|
||||
|
||||
```ts
|
||||
const mints = await fetchMints();
|
||||
const capabilities = await Promise.all(
|
||||
mints.map((mint) => fetchMint(mint.host).then(…).catch(() => null)),
|
||||
);
|
||||
```
|
||||
|
||||
then maps every item to `<MintCard>`. After publish, those cards do not change until the next `cashumints-web.timer` run.
|
||||
|
||||
What that freezes:
|
||||
|
||||
| Card field | Source at build | Live counterpart |
|
||||
|---|---|---|
|
||||
| Presence of the mint | API `mints` table at 03:30 | Discovery + `POST /api/index` write new rows immediately |
|
||||
| `review_count`, `rating_avg`, `score`, `last_review_at` | SQL aggregates of **ingested** Nostr reviews | Relays (and the API, once discovery has ingested) |
|
||||
| `status`, `last_online` | last probe stored in the DB | `GET /api/mints/:host` (what MintLive already uses) |
|
||||
| Melt-only / frozen chip | N+1 detail fetch of cached `/v1/info` | same detail payload |
|
||||
|
||||
**Why a mint can exist “on the site” but not on the list:** the 404 resolver (`NotFound.astro`) looks up `/mint/{host}` against the **live** API and, if missing, calls `POST /api/index`. That mint becomes reviewable at once. It does **not** get a card on `/mints` until rebuild. Same for Review-by-URL. The README states this as intended:
|
||||
|
||||
> Mint pages are prerendered, so new mints and new review counts appear at the next build. … an unbuilt mint still resolves through the client-side fallback on the 404 page.
|
||||
|
||||
**Why reviews look wrong on the list and right on the detail page:** `/mints` never talks to Nostr. It prints `MintListItem.review_count` / `rating_avg` from the API snapshot. The detail page’s `Reviews.astro` calls `loadReviews(subject)` against relays and replaces the panel. A review written today is on the detail page as soon as relays answer; the list card still shows yesterday’s count.
|
||||
|
||||
**Home page extra subset:** `mints.slice(0, 6)` (and the same for federations / LNURL). “Latest reviews” is a **build-time** relay read (`fetchLatestReviews(…, 10)`), unlike `/reviews`, which re-queries on load.
|
||||
|
||||
### 1.3 How the mint detail page fetches Nostr (the pattern that works)
|
||||
|
||||
**Component:** `web/src/components/Reviews.astro`
|
||||
Root: `[data-reviews-panel]` with `data-subject={JSON.stringify(subject)}`.
|
||||
Script imports `loadReviews` / `loadProfiles` from `web/src/lib/reviews-client.ts`.
|
||||
|
||||
**Library:** [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools) **v2.10.4** — `SimplePool` from `nostr-tools/pool`, plus `nip19`. **Not NDK.**
|
||||
|
||||
**Relays** (`DEFAULT_RELAYS` in `shared/src/nostr.ts`, overridable with `PUBLIC_REVIEW_RELAYS`):
|
||||
|
||||
- `wss://relay.cashumints.space`
|
||||
- `wss://nos.lol`
|
||||
- `wss://relay.azzamo.net`
|
||||
- `wss://relay.snort.social`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
Profiles add `wss://purplepag.es` and `wss://relay.nostr.net`.
|
||||
|
||||
**Query:** kind `38000` (addressable reviews), two OR’d filters, `limit: 500`, `maxWait: 8000` ms:
|
||||
|
||||
1. `#d` = mint pubkey (plus LNURL alternate ids) and `#k` = announcement kind
|
||||
2. `#u` = URL spellings (legacy Cashu reviews; federations skip this)
|
||||
|
||||
Newest event per author wins. In-tab cache 5 minutes. Empty result is treated as a relay error if the API had counted reviews.
|
||||
|
||||
**Live status (API, not Nostr):** `MintLive.astro` is a hidden `[data-mint-live={host}]` marker. Its script `fetch(`${apiBase}/api/mints/${host}`)` and patches the status chip, banner, limits, and NUT/module/feature rows. `apiBase` is `import.meta.env.PUBLIC_API_URL`, empty in production → same-origin `/api/…`.
|
||||
|
||||
**Unbuilt mint URLs:** `server.mjs` serves the locale 404. `NotFound.astro` derives the address from the path, `GET /api/mints/:host`, then `POST /api/index` on miss, then dispatches `cashumints:subject` so the same Reviews island starts.
|
||||
|
||||
---
|
||||
|
||||
## 2. API coverage
|
||||
|
||||
All routes are in `api/src/server.ts`. CORS is enabled on `/api/*` and `/icons/*` (`hono/cors`, default allow-all). Production uses **same origin** (`PUBLIC_API_URL=` empty; nginx proxies `/api/` and `/icons/`), so browsers never hit a cross-origin API unless that env is set.
|
||||
|
||||
### 2.1 Existing endpoints
|
||||
|
||||
#### `GET /api/mints`
|
||||
|
||||
- Query: `?type=cashu|fedimint|lnurl` (unknown type → empty array); optional `?limit=N`
|
||||
- No type filter → **entire index**, each item tagged `type`
|
||||
- **No default limit.** Full list.
|
||||
- Response: `MintListItem[]`
|
||||
|
||||
```ts
|
||||
{
|
||||
url: string;
|
||||
host: string;
|
||||
name: string | null;
|
||||
icon: string | null; // "/icons/…" or null
|
||||
type: 'cashu' | 'fedimint' | 'lnurl' | string;
|
||||
status: 'online' | 'degraded' | 'offline' | 'unknown' | 'announced';
|
||||
last_online: number | null; // unix
|
||||
review_count: number; // ingested, one-per-author
|
||||
rating_avg: number | null; // 1 decimal
|
||||
score: number; // Bayesian, from those aggregates
|
||||
last_review_at: number | null;
|
||||
version: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
**This is enough to rebuild the `/mints` grid** (name, icon, rating, count, status, sort keys). It is **not** enough for melt-only / frozen chips (`nuts` / capabilities) or for true sentiment bars (`rating_distribution`). Those currently require N+1 `GET /api/mints/:host`.
|
||||
|
||||
#### `GET /api/mints/:host`
|
||||
|
||||
- 404 `{ error, message }` if unknown
|
||||
- Response: `MintDetail` = list item plus:
|
||||
|
||||
```ts
|
||||
{
|
||||
description, pubkey, info /* NUT-06 */, nuts: string[],
|
||||
first_seen, last_probe, updated_at,
|
||||
rating_distribution: { '1'..'5': number },
|
||||
reviews_90d, uptime_30d, probes_recent: { ts, ok, latency_ms }[],
|
||||
// fedimint extras when type=fedimint: federation_id, invite_codes, modules, …
|
||||
// lnurl extras when type=lnurl: features, funding_available, withdraw bounds, …
|
||||
}
|
||||
```
|
||||
|
||||
Used by MintLive and the 404 resolver. **No review bodies.**
|
||||
|
||||
#### `GET /api/stats`
|
||||
|
||||
In-process memo **60s**. Cashu `mints_*` counts are cashu-only.
|
||||
|
||||
```ts
|
||||
{
|
||||
mints_total, mints_online, mints_offline, mints_degraded,
|
||||
reviews_total, last_review_at, updated_at,
|
||||
cashu_total, // same as mints_total
|
||||
fedimint_total, fedimint_online, fedimint_offline, fedimint_announced, fedimint_reviews,
|
||||
lnurl_total, lnurl_online, lnurl_offline, lnurl_degraded_funding, lnurl_reviews
|
||||
}
|
||||
```
|
||||
|
||||
PulseTicker already consumes this live.
|
||||
|
||||
#### `GET /api/health`
|
||||
|
||||
`Cache-Control: no-store`. `{ status, uptime_s, last_probe_at, last_discovery_at, mints_tracked, updated_at }`. 503 when degraded.
|
||||
|
||||
#### `POST /api/index`
|
||||
|
||||
The only write. Body `{ type, input }`. Rate limit **10/hour/IP** (`INDEX_RATE_LIMIT`). Success 200/201: `MintDetail & { existing, indexed_from? }`. Failures: `bad_input`, `blocked_host`, `wrong_type`, `unverifiable`, `rate_limited`, etc.
|
||||
|
||||
#### `GET /icons/:file`
|
||||
|
||||
Static files, `Cache-Control: public, max-age=86400`.
|
||||
|
||||
### 2.2 Gaps
|
||||
|
||||
| Need | Exists? | Notes |
|
||||
|---|---|---|
|
||||
| Full mint / federation / LNURL lists | **Yes** | `/api/mints?type=…` |
|
||||
| Per-mint stats (status, uptime, distribution, probes) | **Yes** | `/api/mints/:host` |
|
||||
| Review **bodies** / recent review text | **No HTTP endpoint** | Live only via Nostr (`reviews-client.ts`, `feed-client.ts`). API stores ratings for aggregates, not content for the UI. |
|
||||
| List payload with NUT chips / LNURL chips | **No** | List item has no `nuts`, no `rating_distribution`. Today: N+1 at **build**. Client-side N+1 per visitor would be wasteful. |
|
||||
| Global review feed JSON | **No** | `/reviews` talks to relays, not the API. |
|
||||
| Snapshot JSON in `dist` | **None** | No checked-in mint dump. Stale data **is** the prerendered HTML. |
|
||||
|
||||
Discovery itself is not the list cap (`QUERY_LIMIT` 500 × `MAX_PAGES` 20). The visible cap is **whatever was in the DB when `astro build` ran**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Option analysis
|
||||
|
||||
### 3a. Client-side hydration (keep static build)
|
||||
|
||||
Keep `output: 'static'`, `server.mjs`, nginx, and the timer. On `/mints` (and twins), fetch the live API after load and rebuild the grid — the same idea as Reviews / PulseTicker / MintLive.
|
||||
|
||||
**What already exists to copy**
|
||||
|
||||
- PulseTicker: prerender numbers, then `fetch(`${apiBase}/api/stats`)`.
|
||||
- `/reviews`: prerender ~30 cards, then `loadFeed()` replaces them.
|
||||
- MintLive: patch in place from `/api/mints/:host`.
|
||||
- `/mints` script already sorts/filters on `data-*` (`data-score`, `data-rating`, `data-reviews`, `data-last-review`, `data-status`, …). New cards only need those attributes.
|
||||
|
||||
**There are no `client:load` islands to add.** Work is extra `<script>` in the list pages (or a shared module) plus an HTML builder for a card, because `MintCard.astro` cannot run in the browser.
|
||||
|
||||
Concrete changes:
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `web/src/pages/[...locale]/mints.astro` | After paint, `fetch('/api/mints?type=cashu')`, rebuild `[data-mint-grid]`, update `[data-result-count]`. Keep prerendered cards as noscript / first-paint fallback. |
|
||||
| `web/src/pages/[...locale]/fedimints.astro` | Same, `?type=fedimint`. |
|
||||
| `web/src/pages/[...locale]/lnurl-mints.astro` | Same, `?type=lnurl`. |
|
||||
| `web/src/lib/mint-cards.ts` (**new**) | Browser HTML for one card, mirroring `MintCard.astro` (same pattern as `review-cards.ts`). |
|
||||
| `web/src/lib/api.ts` | Optional: a tiny browser `fetch` helper using `apiBase` from `client.ts` (build-time `API_URL` must **not** ship to the browser). |
|
||||
| `web/src/pages/[...locale]/index.astro` | Optional but needed for “immediately”: refresh top-6 grids from the same list endpoint; optionally reuse `/reviews`’s `loadFeed` for the carousel (today that strip is build-only). |
|
||||
| `web/src/components/MintCard.astro` | Untouched if the JS builder is a parallel renderer; or extract shared markup helpers. |
|
||||
|
||||
**Chips:** either (i) leave prerendered chips stale, (ii) add `nuts` / chip flags to `MintListItem` + `toListItem()` in `api/src/queries.ts` (small, one-time API change), or (iii) N+1 detail fetches from every browser (do not do this).
|
||||
|
||||
**New mint click path:** hydrated list → `/mint/{newhost}` → 404 HTML → NotFound resolver → live page. Already designed. No SSR required for that URL to work.
|
||||
|
||||
**Dependencies:** none new (`nostr-tools` already in `web`). No adapter. No nginx / systemd change for the site server.
|
||||
|
||||
**Config:** none if `PUBLIC_API_URL` stays empty.
|
||||
|
||||
### 3b. Hybrid SSR (`@astrojs/node`, `prerender = false`)
|
||||
|
||||
Astro 5 dropped `output: 'hybrid'`. Pattern: install an adapter, keep `output: 'static'`, set `export const prerender = false` on routes that must run per request.
|
||||
|
||||
Concrete changes:
|
||||
|
||||
| File / unit | Change |
|
||||
|---|---|
|
||||
| `web/package.json` | Add `@astrojs/node`. `start` would become something like `node dist/server/entry.mjs` instead of `node server.mjs`. |
|
||||
| `web/astro.config.mjs` | `import node from '@astrojs/node'`; `adapter: node({ mode: 'standalone' })`. `output` can stay `'static'`. |
|
||||
| `web/src/pages/[...locale]/mints.astro` (and fedimints / lnurl-mints; maybe `index.astro`) | `export const prerender = false`. Drop `getStaticPaths` **or** keep it only if those routes stay static. SSR pages fetch `fetchMints()` **per request**. |
|
||||
| `web/server.mjs` | **Cannot stay as the only server.** Node adapter emits `dist/server/entry.mjs` plus `dist/client/` for assets. Locale 404s, ETag/cache policy, path jail, and “refuse empty root” all live in `server.mjs` today and would need reimplementation or a wrapper. |
|
||||
| `cashumints-site.service` | `ExecStart` → adapter entry; `WEB_ROOT` layout changes (`client/` vs flat dist). |
|
||||
| `cashumints-web.service` | `rsync` of `web/dist/` is wrong for `client/` + `server/`. Publish + restart model changes. A failed SSR process takes down **HTML**, not only islands. |
|
||||
| nginx | Still reverse-proxy; upstream may stay `:8789` if the adapter listens there. `proxy_read_timeout` 30s is fine for API-backed SSR. |
|
||||
|
||||
SSR **does not** by itself refresh review **bodies** on the list (there are none). It **does** make list HTML match the API on every request, including for crawlers, and can emit real 200s for new `/mint/{host}` if that route is also `prerender = false` (today new hosts are **404** until rebuild).
|
||||
|
||||
Per-request cost: `/mints` already does N+1 detail fetches for chips × 24 locales if you SSR all locales naively. Needs a cache or list-payload chips.
|
||||
|
||||
### 3c. Caching layers
|
||||
|
||||
**In-repo**
|
||||
|
||||
| Layer | TTL | Affects |
|
||||
|---|---|---|
|
||||
| `getStats()` memo (`api/src/queries.ts`) | 60s | `/api/stats` |
|
||||
| Review / feed `SimplePool` caches in the tab | 5 min | detail reviews, `/reviews` |
|
||||
| Icons | 1 day (`max-age=86400`) | `/icons/*` |
|
||||
| `GET /api/mints`, `/api/stats`, `/api/mints/:host` | **no** `Cache-Control` from Hono (except health `no-store`) | nginx fills the gap |
|
||||
|
||||
**nginx** (`README.md` sample; not in this repo)
|
||||
|
||||
```nginx
|
||||
proxy_cache_path … keys_zone=cashumints:10m max_size=256m inactive=10m;
|
||||
|
||||
location ~ ^/api/(mints|stats)(?:/|$|\?) {
|
||||
proxy_cache cashumints;
|
||||
proxy_cache_valid 200 30s;
|
||||
proxy_cache_valid 404 10s;
|
||||
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_lock on;
|
||||
}
|
||||
location /icons/ { proxy_cache …; proxy_cache_valid 200 1d; }
|
||||
location = /api/health { proxy_cache off; }
|
||||
```
|
||||
|
||||
**HTML** from `server.mjs`: `Cache-Control: public, max-age=0, must-revalidate` + ETag. Browsers revalidate; they do not keep a 24h stale `/mints`.
|
||||
|
||||
**Implications**
|
||||
|
||||
- Client-side list fetch: **30s** nginx micro-cache is desirable (thundering herd). Not the 24h bug.
|
||||
- After `POST /api/index`, a new mint can be missing from `GET /api/mints` for up to 30s (404 cache 10s on unknown host). Acceptable; optional `Cache-Control: s-maxage=30` on the API to make it explicit.
|
||||
- SSR `/mints` would still sit behind that 30s API cache unless the SSR process talks to the API on loopback **bypassing nginx** (`API_URL=http://127.0.0.1:8788`), which the **build** already does. An SSR Node process on the same machine should keep using loopback, not `https://cashumints.space/api`.
|
||||
- No purge/invalidation API exists. Not needed for 30s TTL.
|
||||
|
||||
**Rate limits:** only `POST /api/index`. `GET /api/mints` is unbounded. A list page per visitor is one small JSON payload (~tens of mints), not a risk.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
**Do 3a (client-side hydration of the list from `GET /api/mints`).** Least infrastructure change that fixes: full live mint list, live card stats, live review **counts** on the list.
|
||||
|
||||
Do **not** move `/mints` to SSR for this goal. That replaces `server.mjs`, the publish path, and systemd, to solve a problem the API + existing island style already solve. Use SSR later only if you need **200 + HTML** for mint URLs that do not exist at build time (SEO for brand-new hosts). Those URLs already **work** for humans via the 404 resolver.
|
||||
|
||||
### Why 3a is enough
|
||||
|
||||
1. The API already returns the full cashu list with scores and review aggregates.
|
||||
2. Same-origin `/api` is already proxied; PulseTicker proves browser → API works in production.
|
||||
3. New mints already resolve on click (404 → `POST /api/index`). Hydrating the list is the missing half.
|
||||
4. Review **text** on `/mints` was never a list feature; card counts come from the API. Once the list refetches, counts match what MintLive/stats use (discovery-ingested). Bodies stay on the detail page via Nostr, as now.
|
||||
5. `/reviews` already has live bodies.
|
||||
|
||||
### Effort
|
||||
|
||||
**Small–medium, ~1–2 days** for `/mints` + fedimints + lnurl-mints + a shared card renderer. Another half day if the home top grids and “latest reviews” strip should match.
|
||||
|
||||
Optional extra (half day): add chip fields to `GET /api/mints` so the client does not N+1.
|
||||
|
||||
### Files that would be touched (3a)
|
||||
|
||||
**Required for the list pages**
|
||||
|
||||
- `web/src/pages/[...locale]/mints.astro`
|
||||
- `web/src/pages/[...locale]/fedimints.astro`
|
||||
- `web/src/pages/[...locale]/lnurl-mints.astro`
|
||||
- `web/src/lib/mint-cards.ts` (new; browser card HTML + `fetch` against `apiBase`)
|
||||
|
||||
**Likely**
|
||||
|
||||
- `web/src/pages/[...locale]/index.astro` (top-6 + “all N mints” count; otherwise home still lies)
|
||||
- `web/src/components/MintCard.astro` only if extracting shared helpers rather than duplicating markup
|
||||
|
||||
**Optional API (chips)**
|
||||
|
||||
- `shared/src/types.ts` (`MintListItem`)
|
||||
- `api/src/queries.ts` (`toListItem`)
|
||||
|
||||
**Not required:** `astro.config.mjs`, `web/package.json` (unless you add a test), `web/server.mjs`, nginx, systemd units, `@astrojs/node`.
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| **SEO:** crawlers see the prerendered subset | Medium if you **replace** HTML with an empty grid. Low if you **keep** build-time cards and enhance. Google does not run the same JS as users. | Keep prerendered cards. Hydration only adds/updates. `ItemList` JSON-LD stays a snapshot; acceptable. |
|
||||
| **New mint URLs return HTTP 404** until rebuild | Low for UX (resolver fills in). Medium for SEO of a mint indexed today. | Out of scope for list freshness. SSR on `mint/[host].astro` is the fix if needed. |
|
||||
| **Nostr relay latency** | Does not apply to the **list** if you use the API. Applies to `/reviews` and detail panels already (8–9s timeout, bones, retry). | Do not query relays on `/mints`. |
|
||||
| **API rate limits** | None on GET. | — |
|
||||
| **CORS** | None while `PUBLIC_API_URL` is empty (same origin). Hono already sends CORS if the API is split later. | Do not bake `API_URL` (`127.0.0.1`) into browser code. |
|
||||
| **nginx 30s cache** | Cards can lag indexing by 30s | Fine. Loopback SSR would skip it; client fetch will not. |
|
||||
| **Card markup drift** | JS builder vs `MintCard.astro` | One HTML helper, or accept a short duplication like `review-cards.ts` vs the Astro card. |
|
||||
| **View transitions** | Cards use `data-vt-*` names applied on click | Preserve those attributes in the builder. |
|
||||
| **i18n** | Card strings must use `useI18n()` in the island, not build-time `t` | Same as Reviews / PulseTicker. |
|
||||
| **Home “latest reviews”** | Still nightly unless you reuse `loadFeed` | Separate follow-up; `/reviews` is already live. |
|
||||
|
||||
### Suggested sequence (when implementing)
|
||||
|
||||
1. Hydrate `/mints` from `GET /api/mints?type=cashu`; keep prerendered fallback.
|
||||
2. Repeat for `/fedimints` and `/lnurl-mints`.
|
||||
3. Hydrate the home top grids (and the “all N” link) from the same endpoints.
|
||||
4. Only if chips go stale in a way users notice: extend the list payload.
|
||||
5. Revisit SSR only for mint-detail **status codes** / OG / sitemap freshness — not for the list.
|
||||
+2
-3
@@ -4,7 +4,7 @@
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.18"
|
||||
"node": ">=22.18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
|
||||
@@ -12,8 +12,7 @@
|
||||
"dev:web": "pnpm --filter ./web dev",
|
||||
"seed": "pnpm --filter ./api seed",
|
||||
"bones": "pnpm --filter ./web bones",
|
||||
"build": "pnpm --filter ./shared build && pnpm --filter ./api build && pnpm --filter ./web build",
|
||||
"start": "pnpm --filter ./web start",
|
||||
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
|
||||
"check:links": "pnpm --filter ./web check:links",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"check:i18n": "pnpm --filter ./web check:i18n",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/** Shapes returned by the API. The web app builds against these. */
|
||||
|
||||
import type { MintCapabilities } from './warnings.js';
|
||||
|
||||
/**
|
||||
* Statuses a listed thing can be in.
|
||||
*
|
||||
@@ -37,40 +35,6 @@ export interface MintListItem {
|
||||
score: number;
|
||||
last_review_at: number | null;
|
||||
version: string | null;
|
||||
|
||||
/* ---- card chips ----
|
||||
*
|
||||
* The three fields below exist so a card can be drawn from the list payload alone.
|
||||
* Before them, /mints fetched `GET /api/mints/:host` once per mint at build time just
|
||||
* to read two booleans off each one, which is fine for fifty-five mints on one build
|
||||
* machine and is not fine for every visitor's browser once the list hydrates. They are
|
||||
* facts, never rendered strings: the label a chip prints is decided by `mintChip` in
|
||||
* the reader's own language, on whichever side is drawing the card.
|
||||
*
|
||||
* A federation has no counterpart and needs none — it publishes no capability list, so
|
||||
* `mintChip` returns null for one and always will. See the Fedimint branch of
|
||||
* `getMintWarnings`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* NUT numbers this mint publishes, as strings: `["4", "5", "17"]`. Cashu only; empty
|
||||
* for the other ecosystems and for a mint whose `/v1/info` has never been read.
|
||||
*/
|
||||
nuts: string[];
|
||||
/**
|
||||
* NUT-04 and NUT-05 switches, the Cashu chip's only input. null means nothing is
|
||||
* cached for this mint, which is a different fact from "both are on" — see
|
||||
* `readCapabilities`.
|
||||
*/
|
||||
capabilities: MintCapabilities | null;
|
||||
/**
|
||||
* LNURL: the advertised withdraw ceiling, millisatoshi. Optional rather than
|
||||
* `| null`, so this stays exactly what `Partial<LnurlFields>` declares on `MintDetail`
|
||||
* and the two do not have to be kept identical by hand.
|
||||
*/
|
||||
max_withdrawable_msat?: number | null;
|
||||
/** LNURL: whether the last probe reached the mint's funding node. */
|
||||
funding_available?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ProbeSample {
|
||||
@@ -272,29 +236,6 @@ export interface Stats {
|
||||
lnurl_reviews: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How one configured relay answered during the last discovery cycle.
|
||||
*
|
||||
* The three facts are deliberately separate, because the failure this exists to catch
|
||||
* had all three looking different from each other: the relays in `RELAYS` connected
|
||||
* fine, reached EOSE fine, and simply did not carry the archive, so `events` was the
|
||||
* only field that would have said anything. A relay that is down and a relay that is
|
||||
* up and empty are different problems with different fixes.
|
||||
*/
|
||||
export interface RelayHealth {
|
||||
url: string;
|
||||
/** A socket was opened to it. false means the address is wrong or the relay is down. */
|
||||
connected: boolean;
|
||||
/**
|
||||
* Events it sent, counted before cross-relay deduplication — so this is what *this*
|
||||
* relay contributed, not what was new because of it. Zero on a connected relay is
|
||||
* the interesting number.
|
||||
*/
|
||||
events: number;
|
||||
/** Every query it was asked ended in a real EOSE rather than in a timeout. */
|
||||
eose: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/health`. */
|
||||
export interface Health {
|
||||
status: 'ok' | 'degraded';
|
||||
@@ -303,26 +244,6 @@ export interface Health {
|
||||
last_discovery_at: number | null;
|
||||
mints_tracked: number;
|
||||
updated_at: number;
|
||||
/**
|
||||
* Per-relay outcome of the last discovery cycle. Empty until one has run — including
|
||||
* on a fresh database, which is why a brand new deployment reports degraded until its
|
||||
* first backfill finishes.
|
||||
*/
|
||||
discovery_relays: RelayHealth[];
|
||||
/** Unique events the last discovery cycle received. null before the first one. */
|
||||
last_discovery_events: number | null;
|
||||
/** Which kind of cycle those numbers describe. */
|
||||
last_discovery_mode: 'backfill' | 'incremental' | null;
|
||||
/**
|
||||
* The last backfill came back under `backfill_min_events`, or none has run yet.
|
||||
*
|
||||
* This is the flag that would have caught a year of ~31-event backfills against a
|
||||
* relay list missing the archive. It forces `status` to degraded, and /api/health to
|
||||
* 503, which is what the build gate and the site's own health checks read.
|
||||
*/
|
||||
discovery_starved: boolean;
|
||||
/** `BACKFILL_MIN_EVENTS`, echoed so a reader of this payload can see the threshold. */
|
||||
backfill_min_events: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"og": "node scripts/og/build-og.mjs",
|
||||
"og:fixtures": "node scripts/og/build-og.mjs --fixtures",
|
||||
"build": "node scripts/og/build-og.mjs && astro build",
|
||||
"start": "node server.mjs",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "astro check",
|
||||
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
|
||||
|
||||
@@ -268,20 +268,6 @@ for (const file of files) {
|
||||
// A .ts file under lib/ or scripts/ is island code wholesale.
|
||||
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
||||
|
||||
/*
|
||||
* A page can inline one extra namespace for its own islands, through Base.astro's
|
||||
* `clientNamespaces` prop. The home page does: its grids hydrate from the API and
|
||||
* rewrite their own "All 56 mints" links, whose strings live under `home.` — a
|
||||
* namespace not worth inlining on 1,300 mint pages that never read it.
|
||||
*
|
||||
* Read out of the page rather than listed here, so the prop and this check cannot
|
||||
* disagree. A namespace a page does not actually pass is still a leak.
|
||||
*/
|
||||
const extraNamespaces = new Set(
|
||||
[...(/clientNamespaces=\{\[([^\]]*)\]\}/.exec(source)?.[1] ?? '').matchAll(/'([\w-]+)'/g)]
|
||||
.map((m) => m[1]),
|
||||
);
|
||||
|
||||
for (const match of source.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
used.add(key);
|
||||
@@ -294,7 +280,7 @@ for (const file of files) {
|
||||
for (const match of clientSource.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
const namespace = key.split('.')[0];
|
||||
if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
|
||||
if (!clientNamespaces.has(namespace)) {
|
||||
clientLeaks.push({ key, file: relative, namespace });
|
||||
}
|
||||
}
|
||||
|
||||
-386
@@ -1,386 +0,0 @@
|
||||
/**
|
||||
* The production web server.
|
||||
*
|
||||
* The site is `output: 'static'`: `pnpm build` prerenders every page and this process
|
||||
* only hands the result out over HTTP. nginx sits in front of it and proxies, rather
|
||||
* than pointing a `root` at the built tree, so that nothing outside this file decides
|
||||
* what is readable. That is not a stylistic preference — it removes two whole classes
|
||||
* of failure the file-serving arrangement kept producing:
|
||||
*
|
||||
* traversal permissions nginx runs as www-data and the build runs as cashumints,
|
||||
* so every directory from / down to dist had to be traversable
|
||||
* by a user with no other business in it. One 0700 home
|
||||
* directory anywhere in the chain took the site down, and
|
||||
* `try_files` reports a permission error as a plain miss, so
|
||||
* the symptom was a blanket 404 or an internal-redirect loop
|
||||
* ending in 500 — never the actual cause.
|
||||
*
|
||||
* duplicated routing the locale 404 rule lived in nginx as a hand-maintained
|
||||
* alternation of 23 codes. Adding a language meant editing a
|
||||
* file that is not in this repository, and forgetting to was
|
||||
* silent. Locale 404s are resolved by looking in the built
|
||||
* tree now, so the list cannot drift.
|
||||
*
|
||||
* Reading files is all it does. There is no template, no database handle and no route
|
||||
* table: `/api/*` and `/icons/*` belong to the API on its own port and nginx forwards
|
||||
* them there directly.
|
||||
*
|
||||
* Env:
|
||||
* SITE_PORT 8789 port to listen on
|
||||
* SITE_HOST 127.0.0.1 interface to bind; loopback because nginx terminates TLS
|
||||
* WEB_ROOT ./dist the tree to serve
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function int(raw, fallback) {
|
||||
const n = Number.parseInt(raw ?? '', 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree to serve, absolute.
|
||||
*
|
||||
* Defaults to the build output next to this file, which is what `pnpm start` and the
|
||||
* tests use. In production it points at a copy outside the checkout: `pnpm build`
|
||||
* empties dist before it writes, so serving dist directly means a rebuild takes the
|
||||
* whole site down for the length of the build. See cashumints-web.service.
|
||||
*/
|
||||
export const ROOT = path.resolve(process.env.WEB_ROOT ?? path.join(here, 'dist'));
|
||||
|
||||
const PORT = int(process.env.SITE_PORT, 8789);
|
||||
const HOST = process.env.SITE_HOST ?? '127.0.0.1';
|
||||
|
||||
/** One line per event, key=value after the message. Matches the API's log format. */
|
||||
function log(level, msg, fields = {}) {
|
||||
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
|
||||
}
|
||||
const line = parts.join(' ');
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
const TYPES = new Map(Object.entries({
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
'.wasm': 'application/wasm',
|
||||
}));
|
||||
|
||||
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
||||
const WEEK = 'public, max-age=604800';
|
||||
const HOUR = 'public, max-age=3600';
|
||||
/** Zero lifetime but cacheable: the client keeps the body and revalidates into a 304. */
|
||||
const REVALIDATE = 'public, max-age=0, must-revalidate';
|
||||
|
||||
/**
|
||||
* How long a response may be reused.
|
||||
*
|
||||
* Everything Vite and the card builder emit carries a content hash in its filename, so
|
||||
* those are immutable for a year — a changed file is a changed URL. Markup is the
|
||||
* opposite: the URLs are permanent and the bytes change on every rebuild, so it
|
||||
* revalidates every time and the ETag below turns that into a 304 in the usual case.
|
||||
*
|
||||
* `/og/default.png` is the one unhashed card, served for pages that have no mint of
|
||||
* their own, so it gets an hour rather than a year.
|
||||
*/
|
||||
export function cacheControl(urlPath, ext) {
|
||||
if (ext === '.html') return REVALIDATE;
|
||||
if (urlPath === '/og/default.png') return HOUR;
|
||||
if (urlPath.startsWith('/_astro/') || urlPath.startsWith('/og/')) return IMMUTABLE;
|
||||
if (urlPath === '/robots.txt' || urlPath === '/sitemap.xml') return HOUR;
|
||||
return WEEK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a request path into a path inside ROOT, or null if it escapes or is malformed.
|
||||
*
|
||||
* Returns the *relative* path so the caller can join it against ROOT; every segment is
|
||||
* checked rather than trusting `path.join` to have swallowed the `..`. Dotfiles are
|
||||
* refused outright: a static build emits none, so a request for one is either a probe
|
||||
* or a mistake, and neither should be answered with bytes.
|
||||
*/
|
||||
export function safePath(urlPath) {
|
||||
if (urlPath.includes('\0')) return null;
|
||||
|
||||
const normalized = path.posix.normalize(urlPath);
|
||||
if (!normalized.startsWith('/')) return null;
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
if (segment === '..' || segment.startsWith('.')) return null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function statFile(file) {
|
||||
try {
|
||||
const stats = await fsp.stat(file);
|
||||
return stats.isFile() ? stats : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates for one request path, in order.
|
||||
*
|
||||
* Astro emits directory-style routes — /mints is dist/mints/index.html — and
|
||||
* `trailingSlash: 'ignore'` means /mints and /mints/ are both the page. Trying the
|
||||
* literal path first keeps assets a single stat; the `.html` candidate covers the flat
|
||||
* files at the root, /404.html among them.
|
||||
*/
|
||||
export function candidates(segments) {
|
||||
const rel = segments.join('/');
|
||||
if (rel === '') return ['index.html'];
|
||||
return [rel, `${rel}/index.html`, `${rel}.html`];
|
||||
}
|
||||
|
||||
/**
|
||||
* The 404 body for a path, and it is not always the English one.
|
||||
*
|
||||
* A miss under /es keeps the visitor on the Spanish 404 rather than bouncing them into
|
||||
* English, which is the same reason `redirectToDefaultLocale` is false in the Astro
|
||||
* config. Which prefixes count is decided by what the build actually emitted: if
|
||||
* <prefix>/404/index.html exists, the prefix is a locale. Nothing to keep in sync.
|
||||
*/
|
||||
async function notFoundBody(segments) {
|
||||
const first = segments[0];
|
||||
if (first) {
|
||||
const localized = path.join(ROOT, first, '404', 'index.html');
|
||||
const stats = await statFile(localized);
|
||||
if (stats) return { file: localized, stats };
|
||||
}
|
||||
const fallback = path.join(ROOT, '404.html');
|
||||
const stats = await statFile(fallback);
|
||||
return stats ? { file: fallback, stats } : null;
|
||||
}
|
||||
|
||||
/** nginx's ETag format: hex mtime and hex size, strong. Cheap, and changes on rebuild. */
|
||||
function etagFor(stats) {
|
||||
return `"${Math.floor(stats.mtimeMs / 1000).toString(16)}-${stats.size.toString(16)}"`;
|
||||
}
|
||||
|
||||
/** RFC 9110: a list of entity tags, or `*`. Weak comparison is right for GET. */
|
||||
function etagMatches(header, etag) {
|
||||
if (!header) return false;
|
||||
if (header.trim() === '*') return true;
|
||||
const bare = etag.replace(/^W\//, '');
|
||||
return header
|
||||
.split(',')
|
||||
.map((candidate) => candidate.trim().replace(/^W\//, ''))
|
||||
.includes(bare);
|
||||
}
|
||||
|
||||
function notModified(req, etag, lastModified) {
|
||||
if (etagMatches(req.headers['if-none-match'], etag)) return true;
|
||||
|
||||
// Only consulted when the client sent no ETag, per RFC 9110 §13.1.3.
|
||||
if (req.headers['if-none-match']) return false;
|
||||
const since = Date.parse(req.headers['if-modified-since'] ?? '');
|
||||
return Number.isFinite(since) && Math.floor(lastModified / 1000) * 1000 <= since;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers every response carries.
|
||||
*
|
||||
* No CSP here on purpose. The site loads an analytics script from another origin, the
|
||||
* review islands open websockets to whatever relays are configured and the status
|
||||
* islands fetch whatever mint URLs the index holds, so a policy tight enough to be
|
||||
* worth having has to be derived from those lists rather than guessed at — and a wrong
|
||||
* one fails as a silently broken island. HSTS belongs to nginx, which is what actually
|
||||
* terminates TLS.
|
||||
*/
|
||||
function baseHeaders() {
|
||||
return {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Frame-Options': 'DENY',
|
||||
};
|
||||
}
|
||||
|
||||
function send(res, status, headers, body) {
|
||||
res.writeHead(status, { ...baseHeaders(), ...headers });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
/** Stream a file, or just its headers for HEAD. */
|
||||
function sendFile(req, res, status, file, stats, urlPath) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const etag = etagFor(stats);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': TYPES.get(ext) ?? 'application/octet-stream',
|
||||
'Content-Length': stats.size,
|
||||
'Last-Modified': new Date(stats.mtimeMs).toUTCString(),
|
||||
ETag: etag,
|
||||
'Cache-Control': cacheControl(urlPath, ext),
|
||||
...baseHeaders(),
|
||||
};
|
||||
|
||||
// A 304 must not carry a body or a Content-Length describing one.
|
||||
if (status === 200 && notModified(req, etag, stats.mtimeMs)) {
|
||||
delete headers['Content-Length'];
|
||||
delete headers['Content-Type'];
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(status, headers);
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(file);
|
||||
stream.on('error', (err) => {
|
||||
log('error', 'read failed', { file, err: err.message });
|
||||
res.destroy();
|
||||
});
|
||||
// Kill the read when the client hangs up mid-transfer rather than draining the file.
|
||||
res.on('close', () => stream.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
send(res, 405, { Allow: 'GET, HEAD', 'Content-Type': 'text/plain; charset=utf-8' }, 'Method Not Allowed\n');
|
||||
return;
|
||||
}
|
||||
|
||||
let urlPath;
|
||||
try {
|
||||
urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
} catch {
|
||||
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = safePath(urlPath);
|
||||
if (!segments) {
|
||||
send(res, 400, { 'Content-Type': 'text/plain; charset=utf-8' }, 'Bad Request\n');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of candidates(segments)) {
|
||||
const file = path.join(ROOT, candidate);
|
||||
// Belt and braces: safePath already refused `..`, this refuses anything that still
|
||||
// resolved outside the tree, a symlink in the build output included.
|
||||
if (file !== ROOT && !file.startsWith(ROOT + path.sep)) break;
|
||||
|
||||
const stats = await statFile(file);
|
||||
if (stats) {
|
||||
sendFile(req, res, 200, file, stats, urlPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const miss = await notFoundBody(segments);
|
||||
if (!miss) {
|
||||
send(res, 404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': REVALIDATE }, 'Not Found\n');
|
||||
return;
|
||||
}
|
||||
// Served as a 404, not a 200 with a 404-shaped body: a soft 404 gets every typo'd
|
||||
// URL indexed as a real page.
|
||||
sendFile(req, res, 404, miss.file, miss.stats, urlPath);
|
||||
}
|
||||
|
||||
export function createServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
log('error', 'request failed', { path: req.url, err: err.message });
|
||||
if (!res.headersSent) {
|
||||
send(res, 500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, 'Internal Server Error\n');
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* Longer than nginx's upstream keepalive, and headersTimeout longer still.
|
||||
*
|
||||
* If this end closes an idle connection at the same moment nginx reuses it, nginx has
|
||||
* nothing to retry and reports 502. Outlasting the proxy makes the proxy always the
|
||||
* one to close, which is the race-free direction.
|
||||
*/
|
||||
server.keepAliveTimeout = 65_000;
|
||||
server.headersTimeout = 66_000;
|
||||
|
||||
// A malformed request line should not take the process with it.
|
||||
server.on('clientError', (err, socket) => {
|
||||
if (err.code === 'ECONNRESET' || !socket.writable) return;
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to start on an empty or unreadable root.
|
||||
*
|
||||
* The failure this prevents is the one that is hardest to see: a process that starts
|
||||
* cleanly, answers every request with a 404 and looks healthy to anything watching the
|
||||
* port. Exiting non-zero puts the reason in `systemctl status` instead.
|
||||
*/
|
||||
async function checkRoot() {
|
||||
const index = path.join(ROOT, 'index.html');
|
||||
if (await statFile(index)) return;
|
||||
log('error', 'web root has no index.html', {
|
||||
root: ROOT,
|
||||
hint: 'a manual `pnpm build` only writes web/dist; `systemctl start cashumints-web` builds and publishes to WEB_ROOT',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Only when run directly, so the tests can import the pieces above. */
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await checkRoot();
|
||||
|
||||
const server = createServer();
|
||||
server.listen(PORT, HOST, () => {
|
||||
log('info', 'site listening', { host: HOST, port: PORT, root: ROOT });
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log('info', 'shutting down', { signal });
|
||||
|
||||
server.close(() => process.exit(0));
|
||||
// Idle keep-alive connections would otherwise hold the close open for a minute.
|
||||
server.closeIdleConnections();
|
||||
setTimeout(() => {
|
||||
server.closeAllConnections();
|
||||
process.exit(0);
|
||||
}, 10_000).unref();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -394,28 +394,14 @@ function escapeText(value: string): string {
|
||||
let pendingPaint = 0;
|
||||
/** Set for one paint after publishing, to mark the new card as it goes in. */
|
||||
let markNewest = false;
|
||||
/** Reviews published in this session, shown at the top until relays echo them back. */
|
||||
/**
|
||||
* Reviews published in this session, shown at the top until relays echo them back.
|
||||
*
|
||||
* Keyed by event id, holding how many relays took it: a review that only two of
|
||||
* four relays accepted says so on its card, because that is a thing the author
|
||||
* may want to act on.
|
||||
*/
|
||||
const optimistic = new Map<string, { accepted: number; total: number }>();
|
||||
/** Optimistic cards whose brief publishing note is still visible. */
|
||||
const publishingNotes = new Set<string>();
|
||||
const PUBLISHING_NOTE_MS = 4_000;
|
||||
const PUBLISHING_NOTE_FADE_MS = 220;
|
||||
|
||||
function dismissPublishingNote(id: string): void {
|
||||
publishingNotes.delete(id);
|
||||
const note = document.querySelector<HTMLElement>(
|
||||
`[id="review-${id.toLowerCase()}"] .propagating`,
|
||||
);
|
||||
if (!note) return;
|
||||
note.classList.add('is-out');
|
||||
window.setTimeout(() => note.remove(), PUBLISHING_NOTE_FADE_MS);
|
||||
}
|
||||
|
||||
function markOptimistic(id: string, accepted: number, total: number): void {
|
||||
optimistic.set(id, { accepted, total });
|
||||
publishingNotes.add(id);
|
||||
window.setTimeout(() => dismissPublishingNote(id), PUBLISHING_NOTE_MS);
|
||||
}
|
||||
|
||||
/* ---------- rendering ---------- */
|
||||
|
||||
@@ -490,7 +476,8 @@ function escapeText(value: string): string {
|
||||
target.innerHTML = slice
|
||||
.map((review) =>
|
||||
reviewHtml(review, f, {
|
||||
propagating: publishingNotes.has(review.id),
|
||||
propagating: optimistic.has(review.id),
|
||||
publishedTo: optimistic.get(review.id) ?? null,
|
||||
permalink,
|
||||
}),
|
||||
)
|
||||
@@ -680,7 +667,10 @@ function escapeText(value: string): string {
|
||||
* that they would have seen had they written it on this page.
|
||||
*/
|
||||
if (handedForward && !all.some((review) => review.id === handedForward.id)) {
|
||||
markOptimistic(handedForward.id, handedForward.accepted, handedForward.total);
|
||||
optimistic.set(handedForward.id, {
|
||||
accepted: handedForward.accepted,
|
||||
total: handedForward.total,
|
||||
});
|
||||
all.unshift({
|
||||
id: handedForward.id,
|
||||
pubkey: handedForward.pubkey,
|
||||
@@ -820,7 +810,7 @@ function escapeText(value: string): string {
|
||||
},
|
||||
onPublished: (published) => {
|
||||
// Optimistic insert at the top. The relays will echo it back on next load.
|
||||
markOptimistic(published.id, published.accepted, published.total);
|
||||
optimistic.set(published.id, { accepted: published.accepted, total: published.total });
|
||||
all.unshift({
|
||||
id: published.id,
|
||||
pubkey: published.pubkey,
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "دي أول مراجعة من الـnpub ده، ومفيش نشاط تاني ليه",
|
||||
"reviews.published": "نُشرت",
|
||||
"reviews.publishedPartial": "نُشرت إلى {accepted} من {total} إعادة شحن.",
|
||||
"reviews.propagating": "بننشر على موزّعات Nostr",
|
||||
"reviews.propagating": "البروغات، قد يستغرق الأمر لحظة للظهور في مكان آخر.",
|
||||
"reviews.summary.line.one": "{count} تصنيف دون تعليق: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} تصنيف دون تعليق: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} تصنيف دون تعليق على {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "أول تقييم من هذا npub، لم يتم العثور على أي نشاط آخر",
|
||||
"reviews.published": "تم النشر.",
|
||||
"reviews.publishedPartial": "تم النشر إلى {accepted} من أصل {total} مرحل.",
|
||||
"reviews.propagating": "جارٍ النشر إلى مرحلات Nostr",
|
||||
"reviews.propagating": "أثناء النشر، قد يستغرق الأمر لحظة حتى يظهر في مكان آخر.",
|
||||
"reviews.summary.line.one": "{count} تقييم بدون تعليق: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} تقييمًا بدون تعليق: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "تقييم {count} بدون تعليق على {when}: {breakdown}",
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
"reviews.anonNote": "První přezkum z tohoto npub, žádná jiná činnost nenalezena",
|
||||
"reviews.published": "Publikováno.",
|
||||
"reviews.publishedPartial": "Zveřejněno{accepted}z{total}relé.",
|
||||
"reviews.propagating": "Publikování na Nostr relayích",
|
||||
"reviews.propagating": "Propagatování může chvíli trvat, než se objeví jinde.",
|
||||
"reviews.summary.line.one": "{count}hodnocení bez komentáře:{breakdown}",
|
||||
"reviews.summary.line.other": "{count}ratingy bez komentáře:{breakdown}",
|
||||
"reviews.summary.line.few": "{count}ratingy bez komentáře:{breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Første anmeldelse af denne npub; der er ikke fundet andre aktiviteter",
|
||||
"reviews.published": "Udgivet.",
|
||||
"reviews.publishedPartial": "Udgivet til {accepted} blandt {total}-relæerne.",
|
||||
"reviews.propagating": "Udgiver til Nostr-relays",
|
||||
"reviews.propagating": "Når indholdet opdateres, kan det tage et øjeblik, før det vises andre steder.",
|
||||
"reviews.summary.line.one": "{count}-vurdering uden kommentar: {breakdown}",
|
||||
"reviews.summary.line.other": "{count}-vurderinger uden kommentar: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count}-vurdering uden kommentar til {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Erste Rezension von diesem Verlag, keine weiteren Aktivitäten gefunden",
|
||||
"reviews.published": "Veröffentlicht.",
|
||||
"reviews.publishedPartial": "Veröffentlicht auf {accepted} der {total}-Relais.",
|
||||
"reviews.propagating": "Veröffentlichen auf Nostr-Relays",
|
||||
"reviews.propagating": "Die Übertragung kann einen Moment dauern, bis sie an anderer Stelle angezeigt wird.",
|
||||
"reviews.summary.line.one": "{count}-Bewertung ohne Kommentar: {breakdown}",
|
||||
"reviews.summary.line.other": "{count}-Bewertungen ohne Kommentar: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count}-Bewertung ohne Kommentar auf {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Πρώτη κριτική από αυτό το npub, δεν βρέθηκε άλλη δραστηριότητα",
|
||||
"reviews.published": "Δημοσίευση.",
|
||||
"reviews.publishedPartial": "Δημοσίευση στο{accepted} του{total} ⁇ λεμάν.",
|
||||
"reviews.propagating": "Δημοσίευση στα relays του Nostr",
|
||||
"reviews.propagating": "Διαδοχικά, μπορεί να πάρει μια στιγμή για να εμφανιστεί αλλού.",
|
||||
"reviews.summary.line.one": "{count} αξιολόγηση χωρίς σχόλιο:{breakdown}",
|
||||
"reviews.summary.line.other": "{count} αξιολογήσεις χωρίς σχόλιο:{breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} αξιολόγηση χωρίς σχόλιο σχετικά με{when}:{breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "First review from this npub, no other activity found",
|
||||
"reviews.published": "Published.",
|
||||
"reviews.publishedPartial": "Published to {accepted} of {total} relays.",
|
||||
"reviews.propagating": "Publishing to Nostr relays",
|
||||
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.",
|
||||
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
|
||||
"reviews.published": "Publicada.",
|
||||
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
|
||||
"reviews.propagating": "Publicando en relés Nostr",
|
||||
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.",
|
||||
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Premier avis sur ce npub, aucune autre activité trouvée",
|
||||
"reviews.published": "Publié.",
|
||||
"reviews.publishedPartial": "Publié sur {accepted} des relais {total}.",
|
||||
"reviews.propagating": "Publication sur les relais Nostr",
|
||||
"reviews.propagating": "En se propageant, cela peut prendre un moment pour apparaître ailleurs.",
|
||||
"reviews.summary.line.one": "Note {count} sans commentaire : {breakdown}",
|
||||
"reviews.summary.line.other": "Notes {count} sans commentaire : {breakdown}",
|
||||
"reviews.summary.lineDay.one": "Note {count} sans commentaire sur {when} : {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "इस npub से पहली समीक्षा, कोई अन्य गतिविधि नहीं मिली।",
|
||||
"reviews.published": "प्रकाशित",
|
||||
"reviews.publishedPartial": "{total} रिलेज़ में से {accepted} पर प्रकाशित।",
|
||||
"reviews.propagating": "Nostr रिले पर प्रकाशित किया जा रहा है",
|
||||
"reviews.propagating": "प्रसारित करते समय, कहीं और प्रकट होने में थोड़ा समय लग सकता है।",
|
||||
"reviews.summary.line.one": "बिना टिप्पणी के {count} रेटिंग: {breakdown}",
|
||||
"reviews.summary.line.other": "टिप्पणी के बिना रेटिंग: {count}; रेटिंग: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} रेटिंग, {when} पर बिना टिप्पणी के: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Ulasan pertama dari npub ini, tidak ada aktivitas lain ditemukan",
|
||||
"reviews.published": "Diterbitkan.",
|
||||
"reviews.publishedPartial": "Diterbitkan ke {accepted} dari {total} relay.",
|
||||
"reviews.propagating": "Mempublikasikan ke relay Nostr",
|
||||
"reviews.propagating": "Sedang disebarkan; mungkin perlu waktu untuk muncul di tempat lain.",
|
||||
"reviews.summary.line.one": "Rating {count} tanpa komentar: {breakdown}",
|
||||
"reviews.summary.line.other": "Rating {count} tanpa komentar: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} rating tanpa komentar di {when}: {breakdown}",
|
||||
|
||||
+2
-11
@@ -121,22 +121,13 @@ export function missingKeys(): Record<string, string[]> {
|
||||
* key that survives is resolved: a key this locale is missing arrives already filled
|
||||
* with the English string, so the browser needs no fallback catalog and ships exactly
|
||||
* one language.
|
||||
*
|
||||
* `extra` is for a namespace exactly one page's islands need. The home page's grids
|
||||
* hydrate and have to rewrite their own "All 56 mints →" links, which live under
|
||||
* `home.` — a namespace worth about 2KB that every other page, including 1,300 mint
|
||||
* pages, has no use for. Passed per page through `Base.astro`'s `clientNamespaces`
|
||||
* prop, it is inlined where it is read and nowhere else. `check-i18n.mjs` does not know
|
||||
* about this, so a key reached this way must still be in a namespace the checker
|
||||
* accepts, or listed in `CLIENT_NAMESPACES` — see the note there.
|
||||
*/
|
||||
export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
|
||||
export function clientCatalog(locale: Locale): Catalog {
|
||||
const catalog = catalogFor(locale);
|
||||
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
|
||||
const out: Catalog = {};
|
||||
for (const key of Object.keys(BASE_CATALOG)) {
|
||||
const namespace = key.split('.')[0] ?? '';
|
||||
if (!allowed.has(namespace)) continue;
|
||||
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
|
||||
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Prima recensione da questo npub, nessun'altra attività trovata",
|
||||
"reviews.published": "Pubblicato.",
|
||||
"reviews.publishedPartial": "Pubblicato in {accepted} di {total} relè.",
|
||||
"reviews.propagating": "Pubblicazione sui relay Nostr",
|
||||
"reviews.propagating": "Propagando, può richiedere un momento per apparire altrove.",
|
||||
"reviews.summary.line.one": "{count} valutazione senza un commento: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} valutazioni senza un commento: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} valutazione senza un commento su {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "このnpubからの最初のレビュー、他の活動が見つかりません",
|
||||
"reviews.published": "掲載情報",
|
||||
"reviews.publishedPartial": "に公開 {accepted} の {total} リレー.",
|
||||
"reviews.propagating": "Nostrリレーに公開中",
|
||||
"reviews.propagating": "伝播は、他の場所で出現する瞬間を取るかもしれません。",
|
||||
"reviews.summary.line.one": "{count} コメントのない評価: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} コメントのない評価: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} 評価なし にコメント {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
|
||||
"reviews.published": "Gepubliceerd.",
|
||||
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
|
||||
"reviews.propagating": "Publiceren naar Nostr-relays",
|
||||
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.",
|
||||
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
"reviews.anonNote": "Pierwsza recenzja z tego npubu, nie znaleziono żadnej innej aktywności",
|
||||
"reviews.published": "Opublikowano.",
|
||||
"reviews.publishedPartial": "Opublikowano dla {accepted} sztafetyi {total}.",
|
||||
"reviews.propagating": "Publikowanie na relayach Nostr",
|
||||
"reviews.propagating": "Rozmnażając, może potrzebować chwili, by pojawić się gdzie indziej.",
|
||||
"reviews.summary.line.one": "{count} ocena bez komentarza: {breakdown}",
|
||||
"reviews.summary.line.few": "{count} oceny bez komentarza: {breakdown}",
|
||||
"reviews.summary.line.many": "{count} ocen bez komentarza: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Primeira revisão deste npub, nenhuma outra atividade encontrada",
|
||||
"reviews.published": "Publicado.",
|
||||
"reviews.publishedPartial": "Publicado em {accepted} dos relés {total}.",
|
||||
"reviews.propagating": "Publicando nos relays Nostr",
|
||||
"reviews.propagating": "Propagando, pode demorar um pouco para aparecer em outro lugar.",
|
||||
"reviews.summary.line.one": "Classificação {count} sem comentários: {breakdown}",
|
||||
"reviews.summary.line.other": "Avaliações {count} sem comentários: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "Classificação {count} sem comentários em {when}: {breakdown}",
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
"reviews.anonNote": "Prima revizuire a acestei npub-uri, nicio altă activitate nu a fost găsită",
|
||||
"reviews.published": "Publicat.",
|
||||
"reviews.publishedPartial": "Publicată în{accepted} din{total} relee.",
|
||||
"reviews.propagating": "Se publică pe relayurile Nostr",
|
||||
"reviews.propagating": "Propaganda, poate dura un moment să apară în altă parte.",
|
||||
"reviews.summary.line.one": "{count}rating fără comentarii:{breakdown}",
|
||||
"reviews.summary.line.other": "{count} ratinguri fără comentarii:{breakdown}",
|
||||
"reviews.summary.line.few": "{count} ratinguri fără comentarii:{breakdown}",
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
"reviews.anonNote": "Первый отзыв с этого npub, другой активности не найдено",
|
||||
"reviews.published": "Опубликовано.",
|
||||
"reviews.publishedPartial": "Опубликовано на {accepted} из {total} relays.",
|
||||
"reviews.propagating": "Публикация на relays Nostr",
|
||||
"reviews.propagating": "Распространяется по сети, в других местах отзыв может появиться не сразу.",
|
||||
"reviews.summary.line.one": "Рейтинг «{count}» без комментариев: {breakdown}",
|
||||
"reviews.summary.line.other": "Рейтинги «{count}» без комментариев: {breakdown}",
|
||||
"reviews.summary.line.few": "Рейтинги «{count}» без комментариев: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Första recensionen av denna npub, inga andra aktiviteter har hittats",
|
||||
"reviews.published": "Publicerad.",
|
||||
"reviews.publishedPartial": "Publicerad till reläet {accepted} i serien {total}.",
|
||||
"reviews.propagating": "Publicerar till Nostr-relayer",
|
||||
"reviews.propagating": "När innehållet sprids kan det ta en stund innan det visas på andra ställen.",
|
||||
"reviews.summary.line.one": "{count}-betyg utan kommentar: {breakdown}",
|
||||
"reviews.summary.line.other": "{count}-betyg utan kommentar: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "Betyg för {count} utan kommentar om {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Bu npub'dan ilk inceleme, başka bir faaliyet bulunmadı",
|
||||
"reviews.published": "Yayınlandı.",
|
||||
"reviews.publishedPartial": "{accepted} {total} rölesine yayımlandı.",
|
||||
"reviews.propagating": "Nostr relay'lerine yayınlanıyor",
|
||||
"reviews.propagating": "Yayılırken başka bir yerde ortaya çıkması biraz zaman alabilir.",
|
||||
"reviews.summary.line.one": "{count} yorumsuz puan: {breakdown}",
|
||||
"reviews.summary.line.other": "{count} yorumsuz puanlar: {breakdown}",
|
||||
"reviews.summary.lineDay.one": "{when} yorumsuz {count} puanı: {breakdown}",
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
"reviews.anonNote": "Перший відгук від цього npub, іншої активності не знайдено",
|
||||
"reviews.published": "Опубліковано.",
|
||||
"reviews.publishedPartial": "Опубліковано на {accepted} із {total} relays.",
|
||||
"reviews.propagating": "Публікація на relays Nostr",
|
||||
"reviews.propagating": "Поширюється мережею, поява в інших місцях може тривати деякий час.",
|
||||
"reviews.summary.line.one": "{count} рейтинг без коментаря: {breakdown}",
|
||||
"reviews.summary.line.few": "{count} оцінки без коментарів: {breakdown}",
|
||||
"reviews.summary.line.many": "{count} оцінок без коментарів: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "اس ناول سے پہلا جائزہ، کوئی اور سرگرمی نہیں پائی گئی",
|
||||
"reviews.published": "شائع ہوا۔",
|
||||
"reviews.publishedPartial": "شائع شدہ{accepted}کا مطلب{total}ریلویز.",
|
||||
"reviews.propagating": "Nostr ریلے پر شائع ہو رہا ہے",
|
||||
"reviews.propagating": "اِس لئے شاید آپ کسی اَور ملک میں جا کر اُس کے بارے میں بات کریں ۔",
|
||||
"reviews.summary.line.one": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
|
||||
"reviews.summary.line.other": "{count}بغیر تبصرے کے شرحیں:{breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count}بغیر تبصرے کے خواندگی{when}:{breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "Xem lại lần đầu từ npb này, không tìm thấy hoạt động nào khác",
|
||||
"reviews.published": "Xuất bản.",
|
||||
"reviews.publishedPartial": "Do {accepted} của {total} rơle.",
|
||||
"reviews.propagating": "Đang đăng lên các relay Nostr",
|
||||
"reviews.propagating": "Tuyên truyền, có thể phải mất một lúc mới xuất hiện.",
|
||||
"reviews.summary.line.one": "{count} đánh giá mà không bình luận: {breakdown}",
|
||||
"reviews.summary.line.other": "{breakdown} {count} đánh giá mà không bình luận: 918274242",
|
||||
"reviews.summary.lineDay.one": "{count} đánh giá mà không bình luận gì về {when}: {breakdown}",
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"reviews.anonNote": "这是关于这款npub的首条评论,未发现其他相关活动",
|
||||
"reviews.published": "已发布。",
|
||||
"reviews.publishedPartial": "已发布至 {total} 继电器中的 {accepted}。",
|
||||
"reviews.propagating": "正在发布到 Nostr 中继",
|
||||
"reviews.propagating": "正在同步,内容可能需要片刻时间才会显示在其他地方。",
|
||||
"reviews.summary.line.one": "{count} 的评分(无评论):{breakdown}",
|
||||
"reviews.summary.line.other": "{count} 的评分(无评论):{breakdown}",
|
||||
"reviews.summary.lineDay.one": "{count}的评分,未对{when}发表评论:{breakdown}",
|
||||
|
||||
@@ -25,14 +25,6 @@ interface Props {
|
||||
description: string;
|
||||
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
||||
mintCount?: number;
|
||||
/**
|
||||
* Catalog namespaces this page's islands need on top of `CLIENT_NAMESPACES`.
|
||||
*
|
||||
* The home page passes `['home']`: its three grids refresh from the API and rewrite
|
||||
* their own "All 56 mints →" links, so those strings have to reach the browser. They
|
||||
* are inlined on the one page that reads them rather than on all 1,300.
|
||||
*/
|
||||
clientNamespaces?: readonly string[];
|
||||
ogType?: string;
|
||||
/**
|
||||
* A real page that should not be in the index.
|
||||
@@ -78,7 +70,7 @@ interface Props {
|
||||
const {
|
||||
title, description, current, mintCount, ogType = 'website',
|
||||
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
||||
imageAlt, clientNamespaces = [],
|
||||
imageAlt,
|
||||
} = Astro.props;
|
||||
|
||||
/*
|
||||
@@ -126,7 +118,7 @@ const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
|
||||
* travels in the HTML the page was sending anyway, costs no extra request, and is on
|
||||
* screen before the first island has finished downloading.
|
||||
*/
|
||||
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
|
||||
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
|
||||
|
||||
/**
|
||||
* The social card, absolute.
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* The mint card, as HTML strings, and the hydration that puts them on a page.
|
||||
*
|
||||
* `MintCard.astro` renders the same card at build time and cannot run in the browser, so
|
||||
* this is its parallel renderer — the same relationship `review-cards.ts` has with the
|
||||
* reviews panel. The two must agree on every class name and every `data-*` attribute,
|
||||
* because the sort, the filter, the rank chips and the shared-element view transitions
|
||||
* on the three index pages all read the DOM rather than any model:
|
||||
*
|
||||
* data-mint-card what the sort collects and the transition arms
|
||||
* data-name / data-domain what the search box matches
|
||||
* data-status "hide offline", and the online/offline counts
|
||||
* data-score / -rating / -reviews the sort keys
|
||||
* data-last-review / -last-online the other two sort keys
|
||||
* data-vt-icon / data-vt-name shared-element names, applied on click
|
||||
*
|
||||
* Why this exists at all: /mints was a snapshot of whatever the API held when `astro
|
||||
* build` ran, and stayed that until the next build. A mint indexed at noon was reviewable
|
||||
* immediately — the 404 resolver saw to that — and simply had no card until the nightly
|
||||
* rebuild. The list now refetches after paint. The prerendered cards stay exactly as they
|
||||
* were: they are the first paint, they are what a crawler and a reader with no JavaScript
|
||||
* get, and this only ever replaces them with something newer.
|
||||
*
|
||||
* Nothing here talks to a relay. Card counts come from the API's ingested aggregates,
|
||||
* which is what they always were; review *bodies* remain a detail-page and /reviews
|
||||
* concern. See docs/dynamic-mint-data.md.
|
||||
*
|
||||
* Everything interpolated goes through `escapeHtml`. A mint's name comes from its own
|
||||
* `/v1/info` — a string an operator controls — and this builds markup with strings.
|
||||
*/
|
||||
import {
|
||||
baseUrlFromKey,
|
||||
federationIdFromSlug,
|
||||
getMintWarnings,
|
||||
mintChip,
|
||||
type MintListItem,
|
||||
} from '@cashumints/shared';
|
||||
import { apiBase, escapeHtml, iconGradient } from './client';
|
||||
import { targetPath } from './feed-resolve';
|
||||
import { displayDomain, initials, transitionName } from './format';
|
||||
import { localePath, splitLocale } from '../i18n/routing';
|
||||
import type { Locale } from '../i18n/config';
|
||||
import { useI18n } from '../i18n/client';
|
||||
import { formatters, starString, type Formatters } from '../i18n/format';
|
||||
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
|
||||
import { initReveal } from '../scripts/reveal';
|
||||
|
||||
export interface CardOptions {
|
||||
/** 1-based position in the default order. Omitted, the card has no rank chip. */
|
||||
rank?: number;
|
||||
/** Entrance delay in ms, for a grid of known size that arrives as one gesture. */
|
||||
revealDelay?: number;
|
||||
/**
|
||||
* Render already revealed, with no entrance.
|
||||
*
|
||||
* Set for a card replacing one the reader is already looking at. `[data-reveal]` is
|
||||
* `opacity: 0` until `.in` lands, so without this a hydration would fade the whole
|
||||
* visible grid back in a second after load — an animation that says "something
|
||||
* changed" about forty cards where at most one did.
|
||||
*/
|
||||
revealed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card's markup.
|
||||
*
|
||||
* `f` carries both the translator and the locale's number and date formatting, and
|
||||
* `locale` is what prefixes the href. Both are passed in rather than read here, because
|
||||
* a grid renders dozens of these and rebuilding the formatter per card would be the
|
||||
* expensive part of the whole hydration.
|
||||
*/
|
||||
export function mintCardHtml(
|
||||
mint: MintListItem,
|
||||
locale: Locale,
|
||||
f: Formatters,
|
||||
options: CardOptions = {},
|
||||
): string {
|
||||
const t = f.t;
|
||||
const { rank, revealDelay, revealed } = options;
|
||||
|
||||
/*
|
||||
* One card, all three ecosystems — the same reasoning as MintCard.astro.
|
||||
*
|
||||
* A federation has no URL, so its second line is a shortened federation id rather than
|
||||
* `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an
|
||||
* `lnurl:` scheme in front of its URL, so the domain comes out of the key.
|
||||
*/
|
||||
const fedimint = mint.type === 'fedimint';
|
||||
const lnurlBase = baseUrlFromKey(mint.url);
|
||||
const domain = fedimint
|
||||
? federationIdFromSlug(mint.host)
|
||||
: displayDomain(lnurlBase ?? mint.url);
|
||||
const name = fedimint
|
||||
? mint.name ?? t('card.unnamedFederation')
|
||||
: mint.name ?? domain.split('/')[0] ?? domain;
|
||||
|
||||
// Never `API_URL`: that is a build-machine address and a visitor's browser cannot
|
||||
// reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons
|
||||
// against whatever origin is serving the page.
|
||||
const icon = mint.icon ? `${apiBase}${mint.icon}` : null;
|
||||
const offline = mint.status === 'offline';
|
||||
const announced = mint.status === 'announced';
|
||||
const href = localePath(targetPath(mint), locale);
|
||||
|
||||
/*
|
||||
* The chip, from facts the list payload now carries.
|
||||
*
|
||||
* It used to take one `GET /api/mints/:host` per mint to read these — fine for a build
|
||||
* machine rendering the grid once a night, ruinous as an N+1 in every visitor's
|
||||
* browser. `capabilities` and the two LNURL fields were added to `MintListItem` for
|
||||
* exactly this. A federation has no chip and never will: `mintChip` returns null for
|
||||
* one, because a federation publishes no capability list to draw a claim from.
|
||||
*/
|
||||
const chipSource =
|
||||
mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined
|
||||
? {
|
||||
type: mint.type,
|
||||
status: mint.status,
|
||||
last_online: mint.last_online,
|
||||
capabilities: mint.capabilities ?? null,
|
||||
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
|
||||
funding_available: mint.funding_available ?? null,
|
||||
}
|
||||
: null;
|
||||
const chip = chipSource
|
||||
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
|
||||
: null;
|
||||
|
||||
// Falls back to the rating split when there is no distribution, exactly as the build
|
||||
// does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says.
|
||||
const pos =
|
||||
mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100);
|
||||
const neg = mint.rating_avg === null ? 0 : 100 - pos;
|
||||
|
||||
const vtIcon = escapeHtml(transitionName('icon', mint.host));
|
||||
const vtName = escapeHtml(transitionName('name', mint.host));
|
||||
|
||||
const iconHtml = icon
|
||||
? `<img class="mc-icon" src="${escapeHtml(icon)}" alt="" width="42" height="42" ` +
|
||||
`loading="lazy" decoding="async" data-vt-icon="${vtIcon}">`
|
||||
: `<span class="mc-icon" style="background:${escapeHtml(iconGradient(domain))}" ` +
|
||||
`aria-hidden="true" data-vt-icon="${vtIcon}">${escapeHtml(initials(name))}</span>`;
|
||||
|
||||
const statsHtml =
|
||||
mint.rating_avg === null
|
||||
? `<span class="mc-none">${escapeHtml(t('card.noRatings'))}</span>`
|
||||
: `<span class="mc-rating">` +
|
||||
`<span class="mc-score">${escapeHtml(f.decimal(mint.rating_avg))}</span>` +
|
||||
`<span class="mc-stars" aria-hidden="true">${starString(mint.rating_avg)}</span>` +
|
||||
`</span>`;
|
||||
|
||||
/*
|
||||
* "Announced" has no "last seen" to print, and printing one anyway is exactly the fake
|
||||
* status this site refuses to show. What it has instead is the fact that nothing has
|
||||
* checked it, said in as many words.
|
||||
*/
|
||||
const last = announced
|
||||
? t('card.notChecked')
|
||||
: offline
|
||||
? mint.last_online
|
||||
? t('card.lastSeen', { when: f.relative(mint.last_online) })
|
||||
: t('card.neverSeen')
|
||||
: mint.last_review_at
|
||||
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
|
||||
: t('card.noReviews');
|
||||
|
||||
const classes = ['mint-card'];
|
||||
if (offline) classes.push('is-offline');
|
||||
if (revealed) classes.push('in');
|
||||
|
||||
return (
|
||||
`<a class="${classes.join(' ')}" href="${escapeHtml(href)}" data-reveal` +
|
||||
(revealDelay === undefined ? '' : ` data-reveal-delay="${revealDelay}"`) +
|
||||
` data-mint-card` +
|
||||
` data-name="${escapeHtml(name.toLowerCase())}"` +
|
||||
` data-domain="${escapeHtml(domain.toLowerCase())}"` +
|
||||
` data-status="${escapeHtml(mint.status)}"` +
|
||||
` data-score="${mint.score}"` +
|
||||
` data-rating="${mint.rating_avg ?? 0}"` +
|
||||
` data-reviews="${mint.review_count}"` +
|
||||
` data-last-review="${mint.last_review_at ?? 0}"` +
|
||||
` data-last-online="${mint.last_online ?? 0}">` +
|
||||
`<div class="mc-top">` +
|
||||
iconHtml +
|
||||
`<span class="mc-id">` +
|
||||
`<span class="mc-name" data-vt-name="${vtName}">${escapeHtml(name)}</span>` +
|
||||
`<span class="mc-domain${fedimint ? ' mono' : ''}">${escapeHtml(domain)}</span>` +
|
||||
`</span>` +
|
||||
(rank === undefined
|
||||
? ''
|
||||
: `<span class="mc-rank${rank === 1 ? ' gold' : ''}">#${rank}</span>`) +
|
||||
`</div>` +
|
||||
`<div class="mc-stats">` +
|
||||
statsHtml +
|
||||
`<span class="mc-reviews">${escapeHtml(t('card.reviews', { n: mint.review_count }))}</span>` +
|
||||
`</div>` +
|
||||
`<div class="mc-bar" aria-hidden="true"><span class="mc-bar-fill">` +
|
||||
(pos > 0 ? `<span class="pos" style="width:${pos}%"></span>` : '') +
|
||||
(neg > 0 ? `<span class="neg" style="width:${neg}%"></span>` : '') +
|
||||
`</span></div>` +
|
||||
`<div class="mc-foot">` +
|
||||
`<span class="mc-dot ${escapeHtml(mint.status)}"></span>` +
|
||||
`<span class="mc-status">${escapeHtml(statusLabel(mint.status, t))}</span>` +
|
||||
(chip ? `<span class="mc-chip ${escapeHtml(chip.severity)}">${escapeHtml(chip.label)}</span>` : '') +
|
||||
`<span class="last">${escapeHtml(last)}</span>` +
|
||||
`</div>` +
|
||||
`</a>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One ecosystem's full listing, or null.
|
||||
*
|
||||
* Null on anything at all going wrong, and the caller's job is then to do nothing: the
|
||||
* prerendered grid is already on screen and correct as of the last build, so a failed
|
||||
* refresh should be invisible rather than an error message about a list the reader can
|
||||
* see. This is the same rule the reviews panel and the pulse ticker follow.
|
||||
*/
|
||||
export async function fetchListing(type: string): Promise<MintListItem[] | null> {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const items = (await res.json()) as MintListItem[];
|
||||
// A well-formed empty answer is still not a reason to empty a grid that has cards in
|
||||
// it. An API serving nothing is the failure the build gate exists to catch, and a
|
||||
// page that renders it as "no mints" would be this bug wearing a different hat.
|
||||
return Array.isArray(items) && items.length > 0 ? items : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a grid's cards with freshly rendered ones.
|
||||
*
|
||||
* Cards whose host was already on screen and revealed are rendered revealed, so the
|
||||
* common case — the list is the same list, with newer numbers — is a silent swap rather
|
||||
* than forty cards fading in again. Genuinely new hosts get the ordinary entrance from
|
||||
* `initReveal`, which is also what reveals anything below the fold on scroll.
|
||||
*
|
||||
* Returns the new card elements, in DOM order, for the caller to re-apply its sort and
|
||||
* filter to.
|
||||
*/
|
||||
export function renderMintGrid(
|
||||
grid: HTMLElement,
|
||||
items: MintListItem[],
|
||||
options: { ranked?: boolean; revealDelayStep?: number } = {},
|
||||
): HTMLElement[] {
|
||||
const { ranked = true, revealDelayStep } = options;
|
||||
const t = useI18n();
|
||||
const f = formatters(t);
|
||||
const { locale } = splitLocale(window.location.pathname);
|
||||
|
||||
// Which hosts the reader can already see. Keyed by host rather than by index: the list
|
||||
// may have grown, shrunk or reordered, and the question is per mint.
|
||||
const revealed = new Set<string>();
|
||||
for (const card of grid.querySelectorAll<HTMLAnchorElement>('[data-mint-card]')) {
|
||||
if (card.classList.contains('in')) {
|
||||
const host = card.getAttribute('href')?.split('/').pop();
|
||||
if (host) revealed.add(decodeURIComponent(host));
|
||||
}
|
||||
}
|
||||
|
||||
grid.innerHTML = items
|
||||
.map((mint, i) =>
|
||||
mintCardHtml(mint, locale, f, {
|
||||
...(ranked ? { rank: i + 1 } : {}),
|
||||
...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }),
|
||||
revealed: revealed.has(mint.host),
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
|
||||
initReveal(grid);
|
||||
return [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
}
|
||||
|
||||
export interface HydrateOptions {
|
||||
/** `cashu`, `fedimint` or `lnurl`. */
|
||||
type: string;
|
||||
/** The grid to rebuild. */
|
||||
grid: HTMLElement;
|
||||
/** Keep only the first N, for the home page's top-six strips. */
|
||||
limit?: number;
|
||||
ranked?: boolean;
|
||||
revealDelayStep?: number;
|
||||
/** Called with the new cards and the payload they were built from, on success only. */
|
||||
onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one ecosystem and rebuild its grid, after paint.
|
||||
*
|
||||
* Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on
|
||||
* success: the API is the newer of the two by construction, since the prerendered grid
|
||||
* is a copy of what this same endpoint said at build time.
|
||||
*/
|
||||
export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
|
||||
const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options;
|
||||
|
||||
const all = await fetchListing(type);
|
||||
if (!all) return;
|
||||
// The reader may have navigated away while the fetch was in flight. The router has
|
||||
// already swapped this grid out of the document, so rendering into it (and telling the
|
||||
// caller to re-apply filters against cards nobody can see) is wasted work.
|
||||
if (!grid.isConnected) return;
|
||||
const items = limit === undefined ? all : all.slice(0, limit);
|
||||
|
||||
const cards = renderMintGrid(grid, items, {
|
||||
...(ranked === undefined ? {} : { ranked }),
|
||||
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
|
||||
});
|
||||
onReplaced?.(cards, all);
|
||||
}
|
||||
+28
-16
@@ -55,6 +55,19 @@ export interface BuildReview {
|
||||
* 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',
|
||||
@@ -214,30 +227,29 @@ export async function fetchLatestReviews(
|
||||
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;
|
||||
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;
|
||||
|
||||
/*
|
||||
* Strict recency, newest first, as the last thing that happens to this list.
|
||||
* Recent reviews with a named author come first; everything else stays newest
|
||||
* first behind them.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
candidates.sort((a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id));
|
||||
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 {
|
||||
|
||||
@@ -37,8 +37,16 @@ export interface MintRef {
|
||||
}
|
||||
|
||||
export interface CardOptions {
|
||||
/** Adds the transient publishing note for a review published in this session. */
|
||||
/** Adds the "propagating to relays" note, for a review published in this session. */
|
||||
propagating?: boolean;
|
||||
/**
|
||||
* How many relays took it, when the review was published in this session.
|
||||
*
|
||||
* Only said out loud when some relay refused: "published to 2 of 4" is a fact the
|
||||
* author can act on (try again later, and it is already on the network), where a
|
||||
* clean 4 of 4 is just noise on the card.
|
||||
*/
|
||||
publishedTo?: { accepted: number; total: number } | null;
|
||||
/** Adds a link to the mint being reviewed. */
|
||||
mint?: MintRef | null;
|
||||
/**
|
||||
@@ -295,9 +303,15 @@ function actionsHtml(review: LoadedReview, f: Formatters, options: CardOptions):
|
||||
/* ---------- the card ---------- */
|
||||
|
||||
export function reviewHtml(review: LoadedReview, f: Formatters, options: CardOptions = {}): string {
|
||||
// The note pulses gently for a few seconds after a review is published.
|
||||
// The note pulses gently while the review is still in flight. It stops the moment
|
||||
// the relays echo the review back, because then the card is redrawn without it.
|
||||
const relays = options.publishedTo;
|
||||
const partial =
|
||||
relays && relays.accepted < relays.total
|
||||
? f.t('reviews.publishedPartial', { accepted: relays.accepted, total: relays.total })
|
||||
: f.t('reviews.published');
|
||||
const propagating = options.propagating
|
||||
? `<p class="rev-note propagating">${escapeHtml(f.t('reviews.propagating'))}</p>`
|
||||
? `<p class="rev-note propagating">${escapeHtml(partial)} ${escapeHtml(f.t('reviews.propagating'))}</p>`
|
||||
: '';
|
||||
|
||||
// The anchor id only for real event ids, so `#review-{id}` deep links resolve and
|
||||
|
||||
@@ -98,9 +98,6 @@ export const FIXTURE_MINT: MintDetail = {
|
||||
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
||||
info: null,
|
||||
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
|
||||
// Both NUTs published and neither switched off, so this fixture draws no card chip —
|
||||
// which is what a representative healthy mint should look like.
|
||||
capabilities: { mintDisabled: false, meltDisabled: false, mintPublished: true, meltPublished: true },
|
||||
first_seen: daysAgo(420),
|
||||
last_probe: daysAgo(0),
|
||||
updated_at: daysAgo(0),
|
||||
|
||||
@@ -138,7 +138,7 @@ const schema = [
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mint-grid" data-mint-grid="fedimint">
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{federations.map((federation, i) => <MintCard mint={federation} rank={i + 1} />)}
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,6 @@ const schema = [
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
@@ -259,7 +258,7 @@ const schema = [
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid="fedimint"]');
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
@@ -268,15 +267,7 @@ const schema = [
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
/*
|
||||
* Re-readable, not captured once.
|
||||
*
|
||||
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||
* sorting and filtering elements that are no longer in the document — the search box
|
||||
* would appear to do nothing at all.
|
||||
*/
|
||||
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
@@ -424,37 +415,6 @@ const schema = [
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
/*
|
||||
* The live list, one fetch after paint.
|
||||
*
|
||||
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||
* A mint indexed since the last build gets its card here; every card's rating,
|
||||
* review count and status arrive current rather than as of 03:30.
|
||||
*
|
||||
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||
* far better answer to a flaky network than an error about a list already on screen.
|
||||
*
|
||||
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||
* refresh cannot undo an interaction that happened before it landed.
|
||||
*/
|
||||
const hydrate = (): void => {
|
||||
void hydrateMintGrid({
|
||||
type: 'fedimint',
|
||||
grid: grid!,
|
||||
onReplaced: (fresh) => {
|
||||
cards = fresh;
|
||||
apply();
|
||||
},
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => hydrate());
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
|
||||
@@ -100,18 +100,7 @@ const signedBody = t('home.why.signed.body', {
|
||||
});
|
||||
---
|
||||
|
||||
{/*
|
||||
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
|
||||
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
|
||||
links, so those strings have to reach the browser; 1,300 mint pages have no use for
|
||||
them. See `clientCatalog` in src/i18n/index.ts.
|
||||
*/}
|
||||
<Base
|
||||
title={t('home.title')}
|
||||
description={description}
|
||||
current="mints"
|
||||
clientNamespaces={['home']}
|
||||
>
|
||||
<Base title={t('home.title')} description={description} current="mints">
|
||||
{/*
|
||||
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
||||
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
||||
@@ -191,11 +180,9 @@ const signedBody = t('home.why.signed.body', {
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
||||
<span class="sec-sub">{t('home.top.sub')}</span>
|
||||
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
|
||||
{t('home.top.all', { n: mints.length })}
|
||||
</a>
|
||||
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
|
||||
</div>
|
||||
<div class="mint-grid" data-home-grid="cashu">
|
||||
<div class="mint-grid">
|
||||
{top.map((mint, i) => (
|
||||
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
||||
))}
|
||||
@@ -213,11 +200,11 @@ const signedBody = t('home.why.signed.body', {
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
||||
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
|
||||
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
|
||||
<a class="sec-link" href={localePath('/fedimints', locale)}>
|
||||
{t('home.topFedimints.all', { n: federations.length })}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mint-grid" data-home-grid="fedimint">
|
||||
<div class="mint-grid">
|
||||
{topFederations.map((federation, i) => (
|
||||
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
||||
))}
|
||||
@@ -238,11 +225,11 @@ const signedBody = t('home.why.signed.body', {
|
||||
<div class="sec-head" data-reveal>
|
||||
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
||||
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
|
||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
|
||||
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mint-grid" data-home-grid="lnurl">
|
||||
<div class="mint-grid">
|
||||
{topLnurl.map((mint, i) => (
|
||||
<MintCard
|
||||
mint={mint}
|
||||
@@ -628,67 +615,14 @@ const signedBody = t('home.why.signed.body', {
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
|
||||
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
||||
|
||||
/** One card every six seconds, until the reader touches the track. */
|
||||
const AUTO_MS = 6000;
|
||||
/** Matches the track's CSS gap. */
|
||||
const GAP = 16;
|
||||
|
||||
/**
|
||||
* The three top-six strips, and the "All N" link over each of them.
|
||||
*
|
||||
* The home page prerenders six cards per ecosystem and a count beside them, from the
|
||||
* same API the index pages read. Between builds the strips went stale in two ways at
|
||||
* once: a new mint could not appear in the top six however good it was, and the count
|
||||
* beside the link said how many mints existed at 03:30. Both are one fetch away.
|
||||
*
|
||||
* The key is the catalog string for that link, which is why this page inlines the
|
||||
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
|
||||
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
|
||||
*
|
||||
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
|
||||
* cards get real rating distributions from a per-mint detail fetch at build time, and
|
||||
* the list payload has no distribution in it. A refreshed card falls back to the
|
||||
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
|
||||
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
|
||||
*/
|
||||
const STRIPS = [
|
||||
{ type: 'cashu', key: 'home.top.all' },
|
||||
{ type: 'fedimint', key: 'home.topFedimints.all' },
|
||||
{ type: 'lnurl', key: 'home.topLnurl.all' },
|
||||
] as const;
|
||||
|
||||
const hydrateStrips = (): void => {
|
||||
const t = useI18n();
|
||||
|
||||
for (const strip of STRIPS) {
|
||||
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
|
||||
// A section with nothing in it is not rendered at all, and a strip that was empty
|
||||
// at build time stays empty until the next one: there is no heading to hang cards
|
||||
// under. Rare, and not worth building a section in JavaScript for.
|
||||
if (!grid) continue;
|
||||
|
||||
void hydrateMintGrid({
|
||||
type: strip.type,
|
||||
grid,
|
||||
limit: 6,
|
||||
// The reveal delay the build gives these six, so a refreshed strip arrives the
|
||||
// same way the prerendered one did.
|
||||
revealDelayStep: 50,
|
||||
onReplaced: (_cards, all) => {
|
||||
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
|
||||
if (link) swapText(link, t(strip.key, { n: all.length }));
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setup = (): void => {
|
||||
requestAnimationFrame(() => hydrateStrips());
|
||||
|
||||
// No track at all means the relays gave the build nothing, and the section is
|
||||
// showing its empty state instead.
|
||||
const found = {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import { fetchLnurlMints } from '../../lib/api';
|
||||
import type { LnurlDetail } from '@cashumints/shared';
|
||||
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { isIndexableMint } from '../../lib/seo';
|
||||
@@ -31,18 +32,26 @@ const { locale } = Astro.props;
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
One request, no N+1.
|
||||
|
||||
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
|
||||
purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
|
||||
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
|
||||
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
|
||||
chips come off the same payload as everything else — which is what makes the hydration
|
||||
below possible without an N+1 in every visitor's browser.
|
||||
*/
|
||||
const mints = await fetchLnurlMints();
|
||||
|
||||
/*
|
||||
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
|
||||
chips need each mint's detail. One fetch per mint, at build time, against the API on
|
||||
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
|
||||
cannot be read simply gets no chip.
|
||||
*/
|
||||
const details = await Promise.all(
|
||||
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
|
||||
);
|
||||
const chipSources = details.map((detail: LnurlDetail | null) =>
|
||||
detail
|
||||
? {
|
||||
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
|
||||
funding_available: detail.funding_available ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
|
||||
@@ -142,17 +151,8 @@ const schema = [
|
||||
{t('lnurlMints.showing', { total: mints.length, online, offline })}
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid="lnurl">
|
||||
{mints.map((mint, i) => (
|
||||
<MintCard
|
||||
mint={mint}
|
||||
rank={i + 1}
|
||||
lnurl={{
|
||||
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
|
||||
funding_available: mint.funding_available ?? null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
@@ -258,7 +258,6 @@ const schema = [
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
@@ -272,7 +271,7 @@ const schema = [
|
||||
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid="lnurl"]');
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
@@ -281,15 +280,7 @@ const schema = [
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
/*
|
||||
* Re-readable, not captured once.
|
||||
*
|
||||
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||
* sorting and filtering elements that are no longer in the document — the search box
|
||||
* would appear to do nothing at all.
|
||||
*/
|
||||
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
@@ -440,37 +431,6 @@ const schema = [
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
/*
|
||||
* The live list, one fetch after paint.
|
||||
*
|
||||
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||
* A mint indexed since the last build gets its card here; every card's rating,
|
||||
* review count and status arrive current rather than as of 03:30.
|
||||
*
|
||||
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||
* far better answer to a flaky network than an error about a list already on screen.
|
||||
*
|
||||
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||
* refresh cannot undo an interaction that happened before it landed.
|
||||
*/
|
||||
const hydrate = (): void => {
|
||||
void hydrateMintGrid({
|
||||
type: 'lnurl',
|
||||
grid: grid!,
|
||||
onReplaced: (fresh) => {
|
||||
cards = fresh;
|
||||
apply();
|
||||
},
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => hydrate());
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import { fetchMints } from '../../lib/api';
|
||||
import { readCapabilities } from '@cashumints/shared';
|
||||
import { fetchMint, fetchMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { isIndexableMint } from '../../lib/seo';
|
||||
@@ -19,18 +20,19 @@ const { locale } = Astro.props;
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
/*
|
||||
One request, no N+1.
|
||||
|
||||
This used to be `fetchMints()` followed by one `fetchMint(host)` per mint, purely to
|
||||
read the two NUT switches behind the "melt only" and "frozen" chips. `MintListItem`
|
||||
carries `capabilities` now, so the chips come off the same payload as everything else.
|
||||
That was worth doing for the build — fifty-six requests down to one — and it was
|
||||
necessary for the hydration below, which does the same read in every visitor's browser
|
||||
and could not have done it fifty-six times.
|
||||
*/
|
||||
const mints = await fetchMints();
|
||||
|
||||
/*
|
||||
The list payload carries no NUT information, so the "melt only" and "frozen" chips
|
||||
need each mint's cached info. One detail fetch per mint, at build time, against the
|
||||
API on the same machine: the mint pages already do exactly this in getStaticPaths.
|
||||
A mint whose detail cannot be read simply gets no chip.
|
||||
*/
|
||||
const capabilities = await Promise.all(
|
||||
mints.map((mint) =>
|
||||
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
|
||||
),
|
||||
);
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
|
||||
@@ -125,8 +127,8 @@ const schema = [
|
||||
{t('mints.showing', { total: mints.length, online, offline })}
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid="cashu">
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
|
||||
</div>
|
||||
|
||||
<p class="no-results" data-no-results hidden>
|
||||
@@ -230,7 +232,6 @@ const schema = [
|
||||
|
||||
<script>
|
||||
import { wireCopyableIds } from '../../lib/client';
|
||||
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||
import { useI18n } from '../../i18n/client';
|
||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||
|
||||
@@ -244,7 +245,7 @@ const schema = [
|
||||
// `t` formats its own numbers, so the grouping separator follows the locale too.
|
||||
const t = useI18n();
|
||||
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid="cashu"]');
|
||||
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
|
||||
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
|
||||
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
|
||||
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
|
||||
@@ -253,15 +254,7 @@ const schema = [
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
/*
|
||||
* Re-readable, not captured once.
|
||||
*
|
||||
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
|
||||
* block), so a `const cards` snapshot taken at setup would leave every control
|
||||
* sorting and filtering elements that are no longer in the document — the search box
|
||||
* would appear to do nothing at all.
|
||||
*/
|
||||
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||
|
||||
@@ -412,37 +405,6 @@ const schema = [
|
||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||
if (q || sort) apply();
|
||||
|
||||
/*
|
||||
* The live list, one fetch after paint.
|
||||
*
|
||||
* Everything above this line operates on the prerendered grid, which is a copy of
|
||||
* what this same endpoint returned when `astro build` ran. That copy is the first
|
||||
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
|
||||
* JavaScript — so it stays, and this only ever replaces it with something newer.
|
||||
* A mint indexed since the last build gets its card here; every card's rating,
|
||||
* review count and status arrive current rather than as of 03:30.
|
||||
*
|
||||
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
|
||||
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
|
||||
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
|
||||
* far better answer to a flaky network than an error about a list already on screen.
|
||||
*
|
||||
* `apply()` afterwards re-applies whatever the reader had already set — a search
|
||||
* they typed, a sort they picked, "hide offline" — against the new cards, so the
|
||||
* refresh cannot undo an interaction that happened before it landed.
|
||||
*/
|
||||
const hydrate = (): void => {
|
||||
void hydrateMintGrid({
|
||||
type: 'cashu',
|
||||
grid: grid!,
|
||||
onReplaced: (fresh) => {
|
||||
cards = fresh;
|
||||
apply();
|
||||
},
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(() => hydrate());
|
||||
|
||||
onLeave(() => {
|
||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||
leaving.clear();
|
||||
|
||||
@@ -208,15 +208,6 @@ export function armSharedTransitions(): void {
|
||||
* only does its work at module scope is dead after the first navigation. It does fire
|
||||
* `astro:page-load` on every arrival, including the first, but on the first it waits
|
||||
* for `window.load`, which is far too late to be the only trigger. Hence both.
|
||||
*
|
||||
* The listener is never removed, on purpose: a layout island (the language switcher,
|
||||
* the login dialog) has DOM on every page, and this is what keeps it alive. The flip
|
||||
* side is that `setup` runs on *every* page the router swaps in, not only pages that
|
||||
* include the script. A page-level script must therefore look its DOM up by a marker
|
||||
* unique to that page — `data-mint-grid="cashu"`, `data-home-grid="fedimint"` — and do
|
||||
* nothing when the marker is absent. A marker shared between pages means a stale page's
|
||||
* setup finds the current page's DOM and rewrites it: the listing pages once flashed
|
||||
* back to the previous ecosystem's mints for exactly that reason.
|
||||
*/
|
||||
export function onReady(setup: () => void): void {
|
||||
let done = false;
|
||||
|
||||
@@ -1268,13 +1268,8 @@ main { flex: 1; }
|
||||
0%, 25% { opacity: .85; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
/* Visible for a few seconds after a review is published from this tab. */
|
||||
/* Still travelling. Stops when the relays echo the review back on the next load. */
|
||||
.rev-note.propagating { animation: soft-pulse 2s var(--ease-in-out) infinite; }
|
||||
.rev-note.propagating.is-out {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
|
||||
/* ---------- status dot ---------- */
|
||||
.status-dot { position: relative; }
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* The three listing pages must each own their grid.
|
||||
*
|
||||
* `<ClientRouter />` swaps pages without reloading, and `onReady` in `scripts/reveal.ts`
|
||||
* re-runs every page's setup on every arrival — deliberately, since that is what keeps
|
||||
* the layout islands alive. So once `/mints` has been visited, its setup also runs on
|
||||
* `/fedimints`. If both pages mark their grid the same way, the stale cashu setup finds
|
||||
* the fedimint grid, fetches `?type=cashu` and rewrites it: the page shows fedimints for
|
||||
* a moment, then flashes back to cashu mints. That happened.
|
||||
*
|
||||
* The guard is that each page's marker carries its ecosystem, and its script only looks
|
||||
* for its own. This checks the template, the query and the hydrate call agree, and that
|
||||
* no bare `data-mint-grid` has crept back in. Plain node, source read as text, like
|
||||
* `i18n-wiring.test.mjs`.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src/pages/[...locale]');
|
||||
const read = (file) => readFileSync(path.join(pagesDir, file), 'utf8');
|
||||
|
||||
const LISTINGS = [
|
||||
['mints.astro', 'cashu'],
|
||||
['fedimints.astro', 'fedimint'],
|
||||
['lnurl-mints.astro', 'lnurl'],
|
||||
];
|
||||
|
||||
for (const [file, type] of LISTINGS) {
|
||||
test(`${file} marks, queries and hydrates its grid as ${type}`, () => {
|
||||
const source = read(file);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
new RegExp(`<div class="mint-grid" data-mint-grid="${type}">`),
|
||||
`${file}: template grid is not marked data-mint-grid="${type}"`,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
new RegExp(`querySelector<HTMLElement>\\('\\[data-mint-grid="${type}"\\]'\\)`),
|
||||
`${file}: script does not query [data-mint-grid="${type}"]`,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
new RegExp(`type: '${type}',`),
|
||||
`${file}: hydrateMintGrid is not called with type '${type}'`,
|
||||
);
|
||||
|
||||
// A bare marker, in either the template or the query, is the bug coming back.
|
||||
assert.doesNotMatch(source, /data-mint-grid[>\s]/, `${file}: bare data-mint-grid in template`);
|
||||
assert.doesNotMatch(source, /\[data-mint-grid\]/, `${file}: bare [data-mint-grid] selector`);
|
||||
});
|
||||
}
|
||||
|
||||
test('the three listings use three different markers', () => {
|
||||
const types = new Set(LISTINGS.map(([, type]) => type));
|
||||
assert.equal(types.size, LISTINGS.length);
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* What the production server has to get right that a static file server does not.
|
||||
*
|
||||
* The site was served by nginx pointing a `root` at dist until this process took over,
|
||||
* and the rules that lived in that config are the ones worth pinning down here: a miss
|
||||
* has to answer 404 rather than 200 with a 404-shaped page, a miss under a locale has
|
||||
* to stay in that locale, hashed assets have to be immutable and markup must not be,
|
||||
* and nothing outside the root may be readable however the path is spelled.
|
||||
*
|
||||
* A fixture tree rather than dist/: these are assertions about the server, and running
|
||||
* them should not require a build.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cashumints-web-'));
|
||||
|
||||
const write = async (rel, body) => {
|
||||
await fs.mkdir(path.join(root, path.dirname(rel)), { recursive: true });
|
||||
await fs.writeFile(path.join(root, rel), body);
|
||||
};
|
||||
|
||||
await write('index.html', '<!doctype html><html lang="en">home</html>');
|
||||
await write('404.html', '<!doctype html><html lang="en">missing</html>');
|
||||
await write('es/404/index.html', '<!doctype html><html lang="es">no encontrado</html>');
|
||||
await write('es/mints/index.html', '<!doctype html><html lang="es">casas</html>');
|
||||
await write('mints/index.html', '<!doctype html><html lang="en">mints</html>');
|
||||
await write('robots.txt', 'User-agent: *\n');
|
||||
await write('_astro/app.abc123.js', 'export default 1;\n');
|
||||
await write('og/default.png', 'not really a png');
|
||||
await write('og/mint.abc123.png', 'not really a png either');
|
||||
|
||||
// ROOT is read from the environment once, at import.
|
||||
process.env.WEB_ROOT = root;
|
||||
const { createServer, safePath, candidates, cacheControl } = await import('../server.mjs');
|
||||
|
||||
const server = createServer();
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
test.after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const get = (p, init) => fetch(`${base}${p}`, init);
|
||||
|
||||
test('serves the index at the root', async () => {
|
||||
const res = await get('/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'text/html; charset=utf-8');
|
||||
assert.match(await res.text(), /home/);
|
||||
});
|
||||
|
||||
test('resolves directory routes with and without a trailing slash', async () => {
|
||||
for (const p of ['/mints', '/mints/']) {
|
||||
const res = await get(p);
|
||||
assert.equal(res.status, 200, p);
|
||||
assert.match(await res.text(), /mints/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a miss is a 404, not a 200 carrying the 404 page', async () => {
|
||||
const res = await get('/nope');
|
||||
assert.equal(res.status, 404);
|
||||
assert.match(await res.text(), /missing/);
|
||||
});
|
||||
|
||||
test('a miss under a locale stays in that locale', async () => {
|
||||
const res = await get('/es/nope');
|
||||
assert.equal(res.status, 404);
|
||||
assert.match(await res.text(), /no encontrado/);
|
||||
});
|
||||
|
||||
test('a miss under a prefix that is not a locale falls back to English', async () => {
|
||||
const res = await get('/mint/does-not-exist');
|
||||
assert.equal(res.status, 404);
|
||||
assert.match(await res.text(), /missing/);
|
||||
});
|
||||
|
||||
test('hashed assets are immutable and markup is not', async () => {
|
||||
const asset = await get('/_astro/app.abc123.js');
|
||||
assert.equal(asset.headers.get('cache-control'), 'public, max-age=31536000, immutable');
|
||||
|
||||
const page = await get('/');
|
||||
assert.equal(page.headers.get('cache-control'), 'public, max-age=0, must-revalidate');
|
||||
});
|
||||
|
||||
test('the one unhashed card is not cached for a year', async () => {
|
||||
const shared = await get('/og/default.png');
|
||||
assert.equal(shared.headers.get('cache-control'), 'public, max-age=3600');
|
||||
|
||||
const hashed = await get('/og/mint.abc123.png');
|
||||
assert.equal(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
|
||||
});
|
||||
|
||||
test('a matching ETag revalidates into a bodiless 304', async () => {
|
||||
const first = await get('/');
|
||||
const etag = first.headers.get('etag');
|
||||
assert.ok(etag);
|
||||
|
||||
const second = await get('/', { headers: { 'If-None-Match': etag } });
|
||||
assert.equal(second.status, 304);
|
||||
assert.equal(await second.text(), '');
|
||||
});
|
||||
|
||||
test('HEAD answers with the headers and no body', async () => {
|
||||
const res = await get('/', { method: 'HEAD' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-length'), String((await fs.stat(path.join(root, 'index.html'))).size));
|
||||
assert.equal(await res.text(), '');
|
||||
});
|
||||
|
||||
test('anything but GET and HEAD is refused', async () => {
|
||||
const res = await get('/', { method: 'POST' });
|
||||
assert.equal(res.status, 405);
|
||||
assert.equal(res.headers.get('allow'), 'GET, HEAD');
|
||||
});
|
||||
|
||||
test('nothing outside the root is readable', async () => {
|
||||
await fs.writeFile(path.join(root, '..', 'cashumints-secret.txt'), 'secret');
|
||||
|
||||
for (const p of ['/../cashumints-secret.txt', '/%2e%2e/cashumints-secret.txt', '/mints/../../cashumints-secret.txt']) {
|
||||
const res = await get(p);
|
||||
assert.equal(res.status, 404, p);
|
||||
assert.doesNotMatch(await res.text(), /secret/, p);
|
||||
}
|
||||
await fs.rm(path.join(root, '..', 'cashumints-secret.txt'), { force: true });
|
||||
});
|
||||
|
||||
test('dotfiles are refused rather than looked up', async () => {
|
||||
const res = await get('/.env');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('every response carries the baseline security headers', async () => {
|
||||
const res = await get('/');
|
||||
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
||||
assert.equal(res.headers.get('referrer-policy'), 'strict-origin-when-cross-origin');
|
||||
assert.equal(res.headers.get('x-frame-options'), 'DENY');
|
||||
});
|
||||
|
||||
test('safePath refuses what it should and keeps what it should', () => {
|
||||
assert.deepEqual(safePath('/mints/'), ['mints']);
|
||||
assert.deepEqual(safePath('/'), []);
|
||||
assert.equal(safePath('/a\0b'), null);
|
||||
assert.equal(safePath('/.git/config'), null);
|
||||
// Normalised away rather than escaping: the lookup stays inside the root.
|
||||
assert.deepEqual(safePath('/../../etc/passwd'), ['etc', 'passwd']);
|
||||
});
|
||||
|
||||
test('candidates cover the shapes a static build emits', () => {
|
||||
assert.deepEqual(candidates([]), ['index.html']);
|
||||
assert.deepEqual(candidates(['mints']), ['mints', 'mints/index.html', 'mints.html']);
|
||||
});
|
||||
|
||||
test('cacheControl is decided by path and extension', () => {
|
||||
assert.match(cacheControl('/mints', '.html'), /must-revalidate/);
|
||||
assert.match(cacheControl('/_astro/x.js', '.js'), /immutable/);
|
||||
assert.match(cacheControl('/robots.txt', '.txt'), /max-age=3600/);
|
||||
assert.match(cacheControl('/favicon.ico', '.ico'), /max-age=604800/);
|
||||
});
|
||||
Reference in New Issue
Block a user