It was untracked in the working tree. web/src/lib/mint-cards.ts cites it for the reasoning behind hydrating rather than moving the list to SSR, and a comment pointing at a file nobody else has is worse than no comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
22 KiB
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 rsyncs 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:
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 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.spacewss://nos.lolwss://relay.azzamo.netwss://relay.snort.socialwss://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:
#d= mint pubkey (plus LNURL alternate ids) and#k= announcement kind#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[]
{
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:
{
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.
{
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, thenloadFeed()replaces them.- MintLive: patch in place from
/api/mints/:host. /mintsscript already sorts/filters ondata-*(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)
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 fromGET /api/mintsfor up to 30s (404 cache 10s on unknown host). Acceptable; optionalCache-Control: s-maxage=30on the API to make it explicit. - SSR
/mintswould 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, nothttps://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
- The API already returns the full cashu list with scores and review aggregates.
- Same-origin
/apiis already proxied; PulseTicker proves browser → API works in production. - New mints already resolve on click (404 →
POST /api/index). Hydrating the list is the missing half. - Review text on
/mintswas 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. /reviewsalready 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.astroweb/src/pages/[...locale]/fedimints.astroweb/src/pages/[...locale]/lnurl-mints.astroweb/src/lib/mint-cards.ts(new; browser card HTML +fetchagainstapiBase)
Likely
web/src/pages/[...locale]/index.astro(top-6 + “all N mints” count; otherwise home still lies)web/src/components/MintCard.astroonly 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)
- Hydrate
/mintsfromGET /api/mints?type=cashu; keep prerendered fallback. - Repeat for
/fedimintsand/lnurl-mints. - Hydrate the home top grids (and the “all N” link) from the same endpoints.
- Only if chips go stale in a way users notice: extend the list payload.
- Revisit SSR only for mint-detail status codes / OG / sitemap freshness — not for the list.