Dev #8
@@ -96,6 +96,19 @@ SEO_PRODUCT_JSONLD=1
|
|||||||
# so reviews the old site published to snort/primal were invisible to it.
|
# 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
|
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
|
# 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
|
# 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
|
# 0 at all and snort/primal hold almost none, so the two aggregators below are what
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.astro/
|
.astro/
|
||||||
api/data/
|
api/data/
|
||||||
deploy/
|
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -52,9 +52,15 @@ rating encoding, and the bugs this rebuild fixes.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node 22.18 or newer (native TypeScript type stripping, so no build step for the API)
|
- Node 20.18 or newer to build and to run what a build produces
|
||||||
|
- Node 22.18 or newer to *develop*: `pnpm dev`, `pnpm seed` and the `api` test scripts
|
||||||
|
run `src/*.ts` through node directly, which needs native type stripping
|
||||||
- pnpm 9 or newer
|
- pnpm 9 or newer
|
||||||
|
|
||||||
|
`engines.node` is the first of those, not the second, on purpose: it is the floor a
|
||||||
|
deployment has to clear, and a production host should never be told it needs a newer
|
||||||
|
Node than the compiled service actually runs on.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -286,7 +292,56 @@ pnpm typecheck
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
Output lands in `web/dist/`. Every page is prerendered once per language, so ~55 mints
|
Three packages in order, and the order is a dependency chain rather than a habit:
|
||||||
|
`shared` emits the types and the warning copy both other packages import, `api` compiles
|
||||||
|
`api/src` to `api/dist`, and `web` prerenders against a running API.
|
||||||
|
|
||||||
|
Output lands in `api/dist/` and `web/dist/`.
|
||||||
|
|
||||||
|
### Live lists
|
||||||
|
|
||||||
|
The prerendered mint list is a snapshot of what `GET /api/mints` said when the build ran.
|
||||||
|
It used to stay that until the next build, which is why there was a nightly timer: a mint
|
||||||
|
indexed at noon was reviewable at once — the 404 resolver saw to that — and had no card on
|
||||||
|
`/mints` until 03:30, beside cards whose ratings and statuses were equally old.
|
||||||
|
|
||||||
|
`/mints`, `/fedimints`, `/lnurl-mints` and the home page's three top-six strips now refetch
|
||||||
|
that endpoint once, after paint, and rebuild their grids from the answer. One request per
|
||||||
|
page, no relays involved: card counts have always come from the API's ingested review
|
||||||
|
aggregates, and review *bodies* remain a mint page and `/reviews` concern.
|
||||||
|
|
||||||
|
**The prerendered cards stay.** They are the first paint, they are what a crawler indexes,
|
||||||
|
and they are the whole page for a reader with no JavaScript — `<noscript>` already unhides
|
||||||
|
them, and the search, sort and filter controls act on whatever is in the DOM. Hydration
|
||||||
|
only ever replaces them with something newer, and never with nothing:
|
||||||
|
|
||||||
|
- a failed fetch does nothing at all, silently — a grid that is correct as of the last
|
||||||
|
build is a far better answer to a flaky network than an error about a list already on
|
||||||
|
screen;
|
||||||
|
- an API answering `[]` also does nothing. Serving an empty index is the failure the
|
||||||
|
[build gate](#rebuilds) exists to catch, and a page that rendered it as "no mints" would
|
||||||
|
be that bug wearing a different hat;
|
||||||
|
- whatever the reader had already set — a search they typed, a sort they picked, "hide
|
||||||
|
offline" — is re-applied to the new cards, so a refresh landing mid-interaction cannot
|
||||||
|
undo it.
|
||||||
|
|
||||||
|
Two pieces make it work. `web/src/lib/mint-cards.ts` is `MintCard.astro`'s browser twin,
|
||||||
|
the same relationship `review-cards.ts` has with the reviews panel: identical classes and
|
||||||
|
identical `data-*` attributes, because the sort keys, the search fields, the rank chips and
|
||||||
|
the shared-element view transitions are all read off the DOM. And `MintListItem` carries
|
||||||
|
the facts a chip is drawn from — `nuts`, `capabilities`, and the LNURL withdraw ceiling and
|
||||||
|
funding flag. Those replaced an N+1: `/mints` and `/lnurl-mints` each used to fetch
|
||||||
|
`GET /api/mints/:host` once per mint at build time to read two booleans off it, which is
|
||||||
|
tolerable on a build machine and unthinkable in every visitor's browser.
|
||||||
|
|
||||||
|
Strings come from the page's own inlined catalog, so a hydrated Spanish card says "En
|
||||||
|
línea", "54 reseñas" and "4,9", and its link is `/es/mint/…`. The one thing hydration
|
||||||
|
cannot improve is the home page's sentiment bars: the build gives those six cards real
|
||||||
|
rating distributions from a per-mint detail fetch, and the list payload has no
|
||||||
|
distribution in it, so a refreshed card falls back to the rating proxy the index pages
|
||||||
|
have always used.
|
||||||
|
|
||||||
|
Every page is prerendered once per language, so ~55 mints
|
||||||
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
|
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
|
||||||
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
|
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
|
||||||
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
|
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
|
||||||
@@ -575,6 +630,7 @@ so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still ove
|
|||||||
| `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. |
|
| `DB_POOL_MAX` | `10` | Postgres connections held open. Unused by SQLite. |
|
||||||
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
|
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
|
||||||
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
|
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
|
||||||
|
| `BACKFILL_MIN_EVENTS` | `200` | Events a backfill has to read before it counts as one. Under it, discovery logs `ERROR discovery starvation suspected` and health goes 503. See [Starvation](#discovery-starvation). |
|
||||||
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
|
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
|
||||||
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
|
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
|
||||||
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
|
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
|
||||||
@@ -707,7 +763,7 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
|
|||||||
|
|
||||||
| Endpoint | Notes |
|
| Endpoint | Notes |
|
||||||
| -------------------- | ------------------------------------------------------------------ |
|
| -------------------- | ------------------------------------------------------------------ |
|
||||||
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. |
|
| `GET /api/health` | Never cached. 503 when probes are stale, discovery failed, or discovery is starved. Carries the last cycle's per-relay outcome. |
|
||||||
| `GET /api/stats` | Network counters, memoized 60s in process. |
|
| `GET /api/stats` | Network counters, memoized 60s in process. |
|
||||||
| `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. |
|
| `GET /api/mints` | Everything listed, online first then score descending. `?limit=`, `?type=`. |
|
||||||
| `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
|
| `GET /api/mints/:host` | One listing plus its ecosystem's own fields, distribution, uptime and probe history. |
|
||||||
@@ -715,6 +771,82 @@ Five endpoints, CORS open, no auth. Four read; the fifth writes.
|
|||||||
|
|
||||||
`/icons/*` serves the cached mint icons.
|
`/icons/*` serves the cached mint icons.
|
||||||
|
|
||||||
|
### Discovery starvation
|
||||||
|
|
||||||
|
For about a year, `GET /api/health` said `ok`, every discovery cycle reported
|
||||||
|
`ok=true`, and the index sat at eight mints. The production `RELAYS` list did not
|
||||||
|
include the relay carrying the kind 38000/38172 archive, so each backfill read about
|
||||||
|
thirty events, wrote them faithfully, and the nightly build republished the result.
|
||||||
|
Nothing was broken in a way anything measured.
|
||||||
|
|
||||||
|
What was missing is that "the cycle completed" and "the cycle read anything" are
|
||||||
|
different claims, and only the first one was being made. Three things now make the
|
||||||
|
second one:
|
||||||
|
|
||||||
|
**Per-relay attribution.** A cycle records, for every relay in `RELAYS`, whether a
|
||||||
|
socket opened, how many events it sent, and whether it ended in a real EOSE. Counts are
|
||||||
|
taken before cross-relay deduplication, so they say what each relay contributed rather
|
||||||
|
than what happened to be new because of it. The one-line cycle log carries the lot:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO discovery cycle mode=backfill events=1528 … starved=false \
|
||||||
|
relays=wss://relay.cashumints.space=12 wss://nos.lol=1566 wss://relay.azzamo.net=10 \
|
||||||
|
wss://relay.snort.social=18 wss://relay.primal.net=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Read that line before changing `RELAYS`. It is also how you find out that most of this
|
||||||
|
network's archive currently sits behind one relay.
|
||||||
|
|
||||||
|
**A WARN per relay, naming it.** A relay that would not connect is warned about on every
|
||||||
|
cycle. A relay that connected and sent nothing is warned about on backfills only — an
|
||||||
|
incremental cycle asking for one interval is *supposed* to come back empty, and an
|
||||||
|
hourly warning about that would train everyone to skip the line that eventually matters.
|
||||||
|
|
||||||
|
```
|
||||||
|
WARN discovery relay unreachable relay=wss://relay.example.invalid mode=backfill
|
||||||
|
WARN discovery relay returned no events relay=wss://relay.azzamo.net mode=backfill
|
||||||
|
```
|
||||||
|
|
||||||
|
**A floor.** `BACKFILL_MIN_EVENTS`, 200 by default. A backfill asks five relays for the
|
||||||
|
entire history of four kinds; on a working relay set that is thousands of events. Under
|
||||||
|
the floor:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR discovery starvation suspected events=31 floor=200 relays=5 silent=4 unreachable=0
|
||||||
|
```
|
||||||
|
|
||||||
|
and a flag is set that `GET /api/health` reports as `discovery_starved`, which forces
|
||||||
|
`status` to `degraded` and the response to **503**. The flag is sticky across
|
||||||
|
incremental cycles: an hourly cycle finding four events must not clear a starvation a
|
||||||
|
backfill diagnosed. Only the next backfill clears it.
|
||||||
|
|
||||||
|
`GET /api/health` grew four fields for this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "degraded",
|
||||||
|
"discovery_relays": [
|
||||||
|
{ "url": "wss://relay.cashumints.space", "connected": true, "events": 12, "eose": true },
|
||||||
|
{ "url": "wss://relay.example.invalid", "connected": false, "events": 0, "eose": false }
|
||||||
|
],
|
||||||
|
"last_discovery_events": 31,
|
||||||
|
"last_discovery_mode": "backfill",
|
||||||
|
"discovery_starved": true,
|
||||||
|
"backfill_min_events": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A fresh database reports 503 until its first backfill finishes, and that is correct.**
|
||||||
|
Before any backfill has run, nothing has confirmed that this deployment's relay list
|
||||||
|
reads anything at all, and answering `ok` would be the original bug in miniature. In
|
||||||
|
practice it holds `cashumints-web.service` at its health gate — which is the point: a
|
||||||
|
first deploy should not publish a site built from an empty index. The state is stored in
|
||||||
|
the database rather than in memory for the same reason, so a restart cannot launder a
|
||||||
|
starvation into "no cycle yet".
|
||||||
|
|
||||||
|
To silence it deliberately on a deployment that genuinely has less history than this —
|
||||||
|
a private relay, a test rig — set `BACKFILL_MIN_EVENTS=1`.
|
||||||
|
|
||||||
### Indexing on demand
|
### Indexing on demand
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -782,6 +914,9 @@ literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
The unit files and the nginx block quoted below are checked in under `deploy/`. Those
|
||||||
|
are the copies to edit; what is quoted here is the same text, for reading in context.
|
||||||
|
|
||||||
Three units and an nginx block. Everything this project runs listens on loopback and
|
Three units and an nginx block. Everything this project runs listens on loopback and
|
||||||
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
|
runs as the same unprivileged user; nginx terminates TLS and proxies to it, and opens no
|
||||||
file belonging to the project.
|
file belonging to the project.
|
||||||
@@ -806,19 +941,34 @@ is the one that built them.
|
|||||||
|
|
||||||
### Node
|
### Node
|
||||||
|
|
||||||
The API and the site server both run TypeScript and ESM directly, with no build step, so
|
**Node 20.18 or newer is enough.** Nothing systemd starts reads a `.ts` file: `pnpm
|
||||||
**systemd's node must be 22.18 or newer** — that is the release where native type
|
build` compiles `api/src` to `api/dist`, the site server is plain `.mjs`, and both units
|
||||||
stripping stopped needing a flag. This is not the same question as `node -v` in your
|
run `/usr/bin/node` against ordinary JavaScript. 20.18 rather than 20.0 only because
|
||||||
shell: a version manager puts its node on the interactive `PATH` only, while systemd
|
both `ExecStart` lines pass `--env-file-if-exists`, which landed there.
|
||||||
resolves the absolute path in `ExecStart`. Check the one that matters:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/usr/bin/node --version
|
/usr/bin/node --version
|
||||||
```
|
```
|
||||||
|
|
||||||
On Node 20 the API exits immediately with `ERR_UNKNOWN_FILE_EXTENSION` for `.ts` and
|
That is the version that matters, and it is not the same question as `node -v` in your
|
||||||
restarts forever. Install Node system-wide rather than pointing `ExecStart` at a version
|
shell: a version manager puts its node on the interactive `PATH` only, while systemd
|
||||||
manager's path, which breaks at the next upgrade and is invisible to `ProtectHome`.
|
resolves the absolute path in `ExecStart`. Install Node system-wide rather than pointing
|
||||||
|
`ExecStart` at a version manager's path, which breaks at the next upgrade and is
|
||||||
|
invisible to `ProtectHome`.
|
||||||
|
|
||||||
|
**Why this section used to say 22.18.** The API ran `src/index.ts` directly, on native
|
||||||
|
type stripping, so the host's Node version was a runtime dependency of the service. A
|
||||||
|
deploy onto a host with Node 20 met `ERR_UNKNOWN_FILE_EXTENSION`, exited in under a
|
||||||
|
second, and was restarted by systemd 464 times over fifteen hours. Every dashboard was
|
||||||
|
green throughout, because there was no dashboard: `Restart=on-failure` with no start
|
||||||
|
limit is an infinite loop that never reports a failure. Two things changed. The service
|
||||||
|
is compiled, so the host's Node version cannot break it in that way again; and the units
|
||||||
|
now stop after five failures in two minutes and run an `OnFailure=` alert, so if
|
||||||
|
something else breaks it in some other way, the machine says so. See "Failing loudly".
|
||||||
|
|
||||||
|
The version floor that is still 22.18 is the *development* one — `pnpm dev`, `pnpm seed`,
|
||||||
|
`pnpm migrate` and the `api` `test:*` scripts all hand `src/*.ts` to node. That is a
|
||||||
|
laptop requirement, not a server one.
|
||||||
|
|
||||||
### The API
|
### The API
|
||||||
|
|
||||||
@@ -828,12 +978,15 @@ manager's path, which breaks at the next upgrade and is invisible to `ProtectHom
|
|||||||
Description=cashumints.space indexer and API
|
Description=cashumints.space indexer and API
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
# Stop after five failures in a minute rather than restarting forever: a process that
|
# Stop after five failures in two minutes rather than restarting forever: a process
|
||||||
# cannot start will not start on the 4000th attempt either, and `failed` in
|
# that cannot start will not start on the 4000th attempt either, and `failed` in
|
||||||
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
|
# `systemctl status` is a louder signal than a journal scrolling past. Both keys belong
|
||||||
# to [Unit] — under [Service] systemd only warns and ignores them.
|
# to [Unit] — under [Service] systemd only warns and ignores them. See "Failing loudly"
|
||||||
StartLimitIntervalSec=60
|
# for why the window is 120s and not 60s.
|
||||||
|
StartLimitIntervalSec=120
|
||||||
StartLimitBurst=5
|
StartLimitBurst=5
|
||||||
|
# And carry that `failed` off the machine. %n is this unit's own name.
|
||||||
|
OnFailure=cashumints-alert@%n.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
@@ -849,7 +1002,10 @@ Environment=DB_PATH=/var/lib/cashumints/cashumints.db
|
|||||||
Environment=ICON_DIR=/var/lib/cashumints/icons
|
Environment=ICON_DIR=/var/lib/cashumints/icons
|
||||||
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
|
# For Postgres, replace DB_PATH with DATABASE_URL and add After=postgresql.service.
|
||||||
# Keep ICON_DIR either way: cached icons are files, not rows.
|
# Keep ICON_DIR either way: cached icons are files, not rows.
|
||||||
ExecStart=/usr/bin/node --env-file-if-exists=../.env src/index.ts
|
|
||||||
|
# Compiled JavaScript, run by the distribution's own node. See "Node" above for why
|
||||||
|
# this is not src/index.ts any more.
|
||||||
|
ExecStart=/usr/bin/node --env-file-if-exists=../.env dist/index.js
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
@@ -901,8 +1057,9 @@ Two behaviours are worth knowing about because they are load-bearing:
|
|||||||
Description=cashumints.space static site server
|
Description=cashumints.space static site server
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
StartLimitIntervalSec=60
|
StartLimitIntervalSec=120
|
||||||
StartLimitBurst=5
|
StartLimitBurst=5
|
||||||
|
OnFailure=cashumints-alert@%n.service
|
||||||
# Not Requires=cashumints.service: the pages are prerendered, so the site keeps serving
|
# 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.
|
# a correct-as-of-last-build copy while the API is down. Only the islands go quiet.
|
||||||
|
|
||||||
@@ -1067,17 +1224,238 @@ nginx 1.25 and later want `http2 on;` on its own line and warn about the `listen
|
|||||||
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
|
form above; Debian 12 ships 1.22, where the newer form is an unknown directive. The form
|
||||||
above is the one that works on both.
|
above is the one that works on both.
|
||||||
|
|
||||||
|
### Failing loudly
|
||||||
|
|
||||||
|
Two separate silences produced today's incident, and they need separate fixes.
|
||||||
|
|
||||||
|
The first is a **crash loop that never reports a failure**. `Restart=on-failure` with no
|
||||||
|
start limit is an infinite loop by definition: systemd restarts, the process dies,
|
||||||
|
systemd restarts. The unit never reaches `failed`, so `systemctl status` stays `active
|
||||||
|
(auto-restart)`, nothing sends anything anywhere, and the only evidence is a journal
|
||||||
|
scrolling past at four lines a second. The API did this 464 times over fifteen hours.
|
||||||
|
|
||||||
|
All three units now carry:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
StartLimitIntervalSec=120
|
||||||
|
StartLimitBurst=5
|
||||||
|
OnFailure=cashumints-alert@%n.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why 120 and not 60.** `RestartSec=5s` means five attempts cost a little over twenty
|
||||||
|
seconds of waiting, plus however long each attempt survives before dying. A process that
|
||||||
|
fails *slowly* — a database connection that times out, a port that takes four seconds to
|
||||||
|
refuse — spreads five failures past a sixty second window, resets the counter, and loops
|
||||||
|
forever anyway. 120s covers the slow case. Both keys belong under `[Unit]`; systemd
|
||||||
|
takes them under `[Service]` with only a warning and then ignores them.
|
||||||
|
|
||||||
|
**Why `OnFailure=` at all.** `StartLimitBurst` turns the loop into a `failed` state,
|
||||||
|
which is much better, and is still a state somebody has to go and look at. `OnFailure=`
|
||||||
|
is what makes the machine speak first. `%n` expands to the failed unit's own name, which
|
||||||
|
arrives as the template instance in `%i`.
|
||||||
|
|
||||||
|
The second silence is a **build that publishes an index it should have refused**; that
|
||||||
|
one is the `MIN_MINTS_FOR_BUILD` gate under "Rebuilds", and the `discovery_starved` flag
|
||||||
|
under "Discovery starvation" is what feeds it.
|
||||||
|
|
||||||
|
#### The alert unit
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /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
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is one optional file. With it absent, or with both values empty, a failure
|
||||||
|
still lands in the journal at `ERROR` and is readable with `journalctl -p err -t
|
||||||
|
cashumints-alert`; the unit is written so that its worst case is a log line rather than
|
||||||
|
a second thing to debug.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /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/…
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it without breaking anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start 'cashumints-alert@test.service'
|
||||||
|
journalctl -t cashumints-alert -n 5 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
### Rebuilds
|
### Rebuilds
|
||||||
|
|
||||||
Mint pages are prerendered, so new mints and new review counts appear at the next build.
|
**Builds happen on deploy. There is no timer.**
|
||||||
A nightly rebuild is enough; the site stays correct in between because the islands
|
|
||||||
refresh status and reviews at runtime, and an unbuilt mint still resolves through the
|
```bash
|
||||||
client-side fallback on the 404 page.
|
sudo systemctl start cashumints-web # build, then publish
|
||||||
|
```
|
||||||
|
|
||||||
|
There used to be a `cashumints-web.timer` firing at 03:30 nightly, and it was load-bearing:
|
||||||
|
the mint list was a snapshot of whatever the API held when `astro build` ran, so a
|
||||||
|
rebuild was the only way a new mint, a new review count or a changed status ever reached
|
||||||
|
`/mints`. The list hydrates now — one `GET /api/mints` after paint, see
|
||||||
|
[Live lists](#live-lists) — so all three reach the page within a second of load, in every
|
||||||
|
language, and rebuilding 2,000 pages overnight to refresh numbers that refresh themselves
|
||||||
|
is twenty minutes of CPU for nothing.
|
||||||
|
|
||||||
|
What a build still produces, and therefore what a deploy is still for:
|
||||||
|
|
||||||
|
| Still built | Still stale between deploys |
|
||||||
|
| --- | --- |
|
||||||
|
| The prerendered HTML a crawler reads | The `ItemList` JSON-LD on the index pages |
|
||||||
|
| A social card per mint | The card for a mint indexed since the deploy |
|
||||||
|
| `sitemap.xml` and the hreflang set | A `/mint/{host}` page for a mint indexed since the deploy |
|
||||||
|
|
||||||
|
That last row is the one to know about. A mint indexed today has no prerendered page of
|
||||||
|
its own until the next deploy: `/mint/newhost` returns **404**, and the 404 page's
|
||||||
|
resolver looks the address up against the live API and renders it — readable, reviewable,
|
||||||
|
linkable, with a `noindex` on it until the deploy gives it a real page. That was already
|
||||||
|
true between nightly builds; dropping the timer only lengthens the window.
|
||||||
|
|
||||||
|
Deploy when you ship code, or when enough new mints have accumulated that their pages are
|
||||||
|
worth prerendering. Nothing breaks if you do not.
|
||||||
|
|
||||||
Publishing is a separate step from building, and the separation is the point: the copy
|
Publishing is a separate step from building, and the separation is the point: the copy
|
||||||
the site server reads is only touched once a build has succeeded, so a failed build
|
the site server reads is only touched once a build has succeeded, so a failed build
|
||||||
leaves the previous site up rather than replacing it with a half-written one.
|
leaves the previous site up rather than replacing it with a half-written one.
|
||||||
|
|
||||||
|
**Two gates before the build starts.** The first waits for `/api/health` to answer 200,
|
||||||
|
because `After=` orders a start and does not wait for a port. The second counts
|
||||||
|
`/api/mints` and refuses to build below `MIN_MINTS_FOR_BUILD`, default 20.
|
||||||
|
|
||||||
|
The second exists because health answering 200 and the index being complete are
|
||||||
|
different claims. A year of ~31-event backfills left a perfectly healthy API serving a
|
||||||
|
real, correct, complete list of eight mints; a build against that succeeds, prerenders
|
||||||
|
eight cards, and `rsync --delete-after` replaces fifty-five with eight. The gate runs as
|
||||||
|
an `ExecStartPre`, so a refusal aborts the unit before `pnpm build` — and publishing is
|
||||||
|
`ExecStartPost`, after the build — which means the previously published site is never
|
||||||
|
touched. The `OnFailure=` alert says why.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Raise or lower it for this host without editing the unit:
|
||||||
|
sudo systemctl edit cashumints-web # [Service] / Environment=MIN_MINTS_FOR_BUILD=40
|
||||||
|
```
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# /etc/systemd/system/cashumints-web.service
|
# /etc/systemd/system/cashumints-web.service
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -1088,6 +1466,7 @@ Description=Rebuild the cashumints.space static site
|
|||||||
Requires=cashumints.service
|
Requires=cashumints.service
|
||||||
After=cashumints.service network-online.target
|
After=cashumints.service network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
OnFailure=cashumints-alert@%n.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
@@ -1110,6 +1489,40 @@ Environment=PUBLIC_API_URL=
|
|||||||
# rather than letting the first fetch die on ECONNREFUSED. /api/health answers 503 until
|
# 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.
|
# 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'
|
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,
|
# Check `which pnpm` on the host: a corepack or pnpm-home install sits outside /usr/bin,
|
||||||
# and systemd's PATH does not include it.
|
# and systemd's PATH does not include it.
|
||||||
ExecStart=/usr/bin/pnpm build
|
ExecStart=/usr/bin/pnpm build
|
||||||
@@ -1122,7 +1535,7 @@ ExecStartPost=/usr/bin/rsync -a --delete-after --delay-updates web/dist/ /var/li
|
|||||||
# ~500 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
|
# ~500 prerendered pages plus a card per mint. Minutes, not seconds, on a small VPS, and
|
||||||
# TimeoutStartSec is what bounds a Type=oneshot.
|
# TimeoutStartSec is what bounds a Type=oneshot.
|
||||||
TimeoutStartSec=1800
|
TimeoutStartSec=1800
|
||||||
# A nightly rebuild should not starve the API it is reading from.
|
# A build should not starve the API it is reading from.
|
||||||
Nice=10
|
Nice=10
|
||||||
UMask=0022
|
UMask=0022
|
||||||
|
|
||||||
@@ -1139,33 +1552,33 @@ ProtectControlGroups=true
|
|||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
```
|
```
|
||||||
|
|
||||||
There is deliberately no `[Install]` section: a rebuild should be scheduled, not fired on
|
There is deliberately no `[Install]` section: this belongs to a deploy, not to a boot.
|
||||||
every boot.
|
|
||||||
|
|
||||||
```ini
|
#### Removing the timer
|
||||||
# /etc/systemd/system/cashumints-web.timer
|
|
||||||
[Unit]
|
|
||||||
Description=Nightly cashumints.space rebuild
|
|
||||||
|
|
||||||
[Timer]
|
On a host that still has the nightly timer installed, once:
|
||||||
OnCalendar=*-*-* 03:30:00
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
```bash
|
||||||
WantedBy=timers.target
|
sudo systemctl disable --now cashumints-web.timer
|
||||||
|
sudo rm -f /etc/systemd/system/cashumints-web.timer
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
systemctl list-timers --all | grep cashumints # expect nothing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`disable --now` both stops the pending job and removes the `timers.target` symlink;
|
||||||
|
without the `rm` and the `daemon-reload`, systemd keeps a unit it can still be asked to
|
||||||
|
start by name.
|
||||||
|
|
||||||
### First deploy
|
### First deploy
|
||||||
|
|
||||||
Order matters once: the site server refuses to start against a root that has no
|
Order matters once: the site server refuses to start against a root that has no
|
||||||
`index.html`, so the build has to publish before it comes up.
|
`index.html`, so the build has to publish before it comes up.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/usr/bin/node --version # 22.18 or newer, or the API will not run
|
/usr/bin/node --version # 20.18 or newer
|
||||||
sudo systemctl enable --now cashumints # API first: the build reads from it
|
sudo systemctl enable --now cashumints # API first: the build reads from it
|
||||||
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
|
sudo systemctl start cashumints-web # build, then publish to /var/lib/cashumints/web
|
||||||
sudo systemctl enable --now cashumints-site # now it has something to serve
|
sudo systemctl enable --now cashumints-site # now it has something to serve
|
||||||
sudo systemctl enable --now cashumints-web.timer
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -4,8 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "node --env-file-if-exists=../.env --watch src/index.ts",
|
"dev": "node --env-file-if-exists=../.env --watch src/index.ts",
|
||||||
"start": "node --env-file-if-exists=../.env src/index.ts",
|
"start": "node --env-file-if-exists=../.env dist/index.js",
|
||||||
"seed": "node --env-file-if-exists=../.env src/seed.ts",
|
"seed": "node --env-file-if-exists=../.env src/seed.ts",
|
||||||
"migrate": "node --env-file-if-exists=../.env src/migrate.ts",
|
"migrate": "node --env-file-if-exists=../.env src/migrate.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ export const config = {
|
|||||||
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
|
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
|
||||||
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
|
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
|
||||||
[...DEFAULT_RELAYS]) as string[],
|
[...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),
|
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
|
||||||
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
|
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
|
||||||
probeConcurrency: int('PROBE_CONCURRENCY', 8),
|
probeConcurrency: int('PROBE_CONCURRENCY', 8),
|
||||||
|
|||||||
+276
-14
@@ -19,9 +19,10 @@ import {
|
|||||||
type FedimintAnnouncement,
|
type FedimintAnnouncement,
|
||||||
type LnurlAnnouncement,
|
type LnurlAnnouncement,
|
||||||
type LnurlFields,
|
type LnurlFields,
|
||||||
|
type RelayHealth,
|
||||||
} from '@cashumints/shared';
|
} from '@cashumints/shared';
|
||||||
import { config } from './config.ts';
|
import { config } from './config.ts';
|
||||||
import { getDb, setState, getStateNumber } from './db.ts';
|
import { getDb, setState, getState, getStateNumber } from './db.ts';
|
||||||
import type { Sql } from './db-driver.ts';
|
import type { Sql } from './db-driver.ts';
|
||||||
import { log } from './log.ts';
|
import { log } from './log.ts';
|
||||||
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
|
import { insertMintIfNew, upsertFedimint, upsertLnurl } from './mints.ts';
|
||||||
@@ -39,8 +40,118 @@ export interface DiscoveryResult {
|
|||||||
newMints: string[];
|
newMints: string[];
|
||||||
newReviews: number;
|
newReviews: number;
|
||||||
ok: boolean;
|
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;
|
let pool: SimplePool | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,12 +177,100 @@ export function closePool(): void {
|
|||||||
pool = null;
|
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.
|
* 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
|
* Relays cap `limit` independently, so paging is the only way a fresh database
|
||||||
* converges to the complete history.
|
* converges to the complete history.
|
||||||
*/
|
*/
|
||||||
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
|
async function fetchKind(
|
||||||
|
kind: number,
|
||||||
|
since: number | null,
|
||||||
|
tally: RelayTally | null,
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
const seen = new Map<string, NostrEvent>();
|
const seen = new Map<string, NostrEvent>();
|
||||||
let until: number | undefined;
|
let until: number | undefined;
|
||||||
|
|
||||||
@@ -82,7 +281,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
|
|||||||
|
|
||||||
let batch: NostrEvent[];
|
let batch: NostrEvent[];
|
||||||
try {
|
try {
|
||||||
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
|
batch = await queryRelays(filter, tally);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('relay query failed', {
|
log.warn('relay query failed', {
|
||||||
kind,
|
kind,
|
||||||
@@ -123,6 +322,7 @@ async function fetchKind(kind: number, since: number | null): Promise<NostrEvent
|
|||||||
async function fetchReviewsForMint(
|
async function fetchReviewsForMint(
|
||||||
target: ReviewTarget,
|
target: ReviewTarget,
|
||||||
since: number | null,
|
since: number | null,
|
||||||
|
tally: RelayTally | null,
|
||||||
): Promise<NostrEvent[]> {
|
): Promise<NostrEvent[]> {
|
||||||
const filters: Filter[] = [];
|
const filters: Filter[] = [];
|
||||||
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
|
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
|
||||||
@@ -178,11 +378,7 @@ async function fetchReviewsForMint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const batches = await Promise.all(
|
const batches = await Promise.all(
|
||||||
filters.map((filter) =>
|
filters.map((filter) => queryRelays(filter, tally).catch(() => [] as NostrEvent[])),
|
||||||
getPool()
|
|
||||||
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
|
|
||||||
.catch(() => [] as NostrEvent[]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return batches.flat();
|
return batches.flat();
|
||||||
@@ -596,6 +792,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
|||||||
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
|
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
|
||||||
|
|
||||||
const newMints = new Set<string>();
|
const newMints = new Set<string>();
|
||||||
|
const tally = new RelayTally(config.relays);
|
||||||
let newReviews = 0;
|
let newReviews = 0;
|
||||||
let events = 0;
|
let events = 0;
|
||||||
let ok = true;
|
let ok = true;
|
||||||
@@ -610,7 +807,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
|||||||
const announcementsByType = new Map<string, NostrEvent[]>();
|
const announcementsByType = new Map<string, NostrEvent[]>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
|
Object.entries(ANNOUNCEMENT_KINDS).map(async ([type, kind]) => {
|
||||||
announcementsByType.set(type, await fetchKind(kind, since));
|
announcementsByType.set(type, await fetchKind(kind, since, tally));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -625,10 +822,10 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
|||||||
|
|
||||||
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
// Announcements alone miss mints that only ever appear in a review's `u` tag,
|
||||||
// so reviews feed discovery too.
|
// so reviews feed discovery too.
|
||||||
const reviews = await fetchKind(KIND_REVIEW, since);
|
const reviews = await fetchKind(KIND_REVIEW, since, tally);
|
||||||
// The recent window catches anything a relay dropped from the unbounded query.
|
// The recent window catches anything a relay dropped from the unbounded query.
|
||||||
const recent =
|
const recent =
|
||||||
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
|
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S, tally) : [];
|
||||||
|
|
||||||
const byId = new Map<string, NostrEvent>();
|
const byId = new Map<string, NostrEvent>();
|
||||||
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
|
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
|
||||||
@@ -689,7 +886,7 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
|||||||
while (cursor < targets.length) {
|
while (cursor < targets.length) {
|
||||||
const target = targets[cursor++];
|
const target = targets[cursor++];
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const found = await fetchReviewsForMint(target, since);
|
const found = await fetchReviewsForMint(target, since, tally);
|
||||||
if (found.length > 0) {
|
if (found.length > 0) {
|
||||||
events += found.length;
|
events += found.length;
|
||||||
newReviews += await ingestReviews(found, index);
|
newReviews += await ingestReviews(found, index);
|
||||||
@@ -707,14 +904,79 @@ export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult>
|
|||||||
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
|
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', {
|
log.info('discovery cycle', {
|
||||||
mode: backfill ? 'backfill' : 'incremental',
|
mode,
|
||||||
events,
|
events,
|
||||||
new_mints: newMints.size,
|
new_mints: newMints.size,
|
||||||
new_reviews: newReviews,
|
new_reviews: newReviews,
|
||||||
ok,
|
ok,
|
||||||
|
starved,
|
||||||
|
relays: relays.map((r) => `${r.url}=${r.connected ? r.events : 'down'}`).join(' '),
|
||||||
ms: Date.now() - started,
|
ms: Date.now() - started,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { events, newMints: [...newMints], newReviews, ok };
|
return { events, newMints: [...newMints], newReviews, ok, relays, starved };
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-13
@@ -3,6 +3,7 @@ import {
|
|||||||
compareMints,
|
compareMints,
|
||||||
NEUTRAL_PRIOR_MEAN,
|
NEUTRAL_PRIOR_MEAN,
|
||||||
parseNuts,
|
parseNuts,
|
||||||
|
readCapabilities,
|
||||||
type Health,
|
type Health,
|
||||||
type MintDetail,
|
type MintDetail,
|
||||||
type MintInfo,
|
type MintInfo,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from '@cashumints/shared';
|
} from '@cashumints/shared';
|
||||||
import { config, startedAt } from './config.ts';
|
import { config, startedAt } from './config.ts';
|
||||||
import { getDb, getStateNumber, getState } from './db.ts';
|
import { getDb, getStateNumber, getState } from './db.ts';
|
||||||
|
import { lastDiscoveryReport } from './discovery.ts';
|
||||||
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
|
import { mintByHost, parseEcosystem, type MintRow } from './mints.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +107,39 @@ function round1(n: number | null): number | null {
|
|||||||
return n === null ? null : Math.round(n * 10) / 10;
|
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 {
|
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
|
||||||
const base = {
|
const base = {
|
||||||
review_count: agg?.review_count ?? 0,
|
review_count: agg?.review_count ?? 0,
|
||||||
@@ -113,6 +148,15 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
|||||||
last_review_at: agg?.last_review_at ?? null,
|
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 {
|
return {
|
||||||
url: row.url,
|
url: row.url,
|
||||||
host: row.host,
|
host: row.host,
|
||||||
@@ -126,6 +170,14 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
|
|||||||
score: bayesianScore(base, mean, now),
|
score: bayesianScore(base, mean, now),
|
||||||
last_review_at: base.last_review_at,
|
last_review_at: base.last_review_at,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
|
nuts: rowNuts(row, info),
|
||||||
|
capabilities,
|
||||||
|
...(lnurl
|
||||||
|
? {
|
||||||
|
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
|
||||||
|
funding_available: lnurl.funding_available ?? null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,16 +284,9 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
|
|||||||
|
|
||||||
const item = toListItem(row, agg.get(row.url), mean, now);
|
const item = toListItem(row, agg.get(row.url), mean, now);
|
||||||
const info = parseInfo(row.info_json);
|
const info = parseInfo(row.info_json);
|
||||||
|
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
|
||||||
let nuts: string[] = [];
|
// time here with a subtly different fallback rule; one function now answers for both.
|
||||||
if (row.nuts_json) {
|
const nuts = item.nuts;
|
||||||
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.
|
* Type-specific columns are spread across the payload rather than nested under a key.
|
||||||
@@ -379,28 +424,55 @@ export function resetStatsCache(): void {
|
|||||||
statsCache = null;
|
statsCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Health bypasses the stats cache: it is the endpoint you page on. */
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
export async function getHealth(): Promise<Health> {
|
export async function getHealth(): Promise<Health> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked] = await Promise.all([
|
const [lastProbe, lastDiscovery, discoveryOkRaw, tracked, report] = await Promise.all([
|
||||||
getStateNumber('last_probe_at'),
|
getStateNumber('last_probe_at'),
|
||||||
getStateNumber('last_discovery_at'),
|
getStateNumber('last_discovery_at'),
|
||||||
getState('last_discovery_ok'),
|
getState('last_discovery_ok'),
|
||||||
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
|
db.get<{ n: number }>('SELECT COUNT(*) AS n FROM mints'),
|
||||||
|
lastDiscoveryReport(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const discoveryOk = discoveryOkRaw !== '0';
|
const discoveryOk = discoveryOkRaw !== '0';
|
||||||
const staleAfter = config.probeIntervalMin * 60 * 3;
|
const staleAfter = config.probeIntervalMin * 60 * 3;
|
||||||
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
|
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 {
|
return {
|
||||||
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
|
status: probeStale || !discoveryOk || starved ? 'degraded' : 'ok',
|
||||||
uptime_s: now - startedAt,
|
uptime_s: now - startedAt,
|
||||||
last_probe_at: lastProbe,
|
last_probe_at: lastProbe,
|
||||||
last_discovery_at: lastDiscovery,
|
last_discovery_at: lastDiscovery,
|
||||||
mints_tracked: tracked?.n ?? 0,
|
mints_tracked: tracked?.n ?? 0,
|
||||||
updated_at: now,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -7,7 +7,22 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noEmit": 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,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"rewriteRelativeImportExtensions": true,
|
"rewriteRelativeImportExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# /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/…
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# /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
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# /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.
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# 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
-2
@@ -4,7 +4,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.18"
|
"node": ">=20.18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
|
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"dev:web": "pnpm --filter ./web dev",
|
"dev:web": "pnpm --filter ./web dev",
|
||||||
"seed": "pnpm --filter ./api seed",
|
"seed": "pnpm --filter ./api seed",
|
||||||
"bones": "pnpm --filter ./web bones",
|
"bones": "pnpm --filter ./web bones",
|
||||||
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
|
"build": "pnpm --filter ./shared build && pnpm --filter ./api build && pnpm --filter ./web build",
|
||||||
"start": "pnpm --filter ./web start",
|
"start": "pnpm --filter ./web start",
|
||||||
"check:links": "pnpm --filter ./web check:links",
|
"check:links": "pnpm --filter ./web check:links",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** Shapes returned by the API. The web app builds against these. */
|
/** Shapes returned by the API. The web app builds against these. */
|
||||||
|
|
||||||
|
import type { MintCapabilities } from './warnings.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuses a listed thing can be in.
|
* Statuses a listed thing can be in.
|
||||||
*
|
*
|
||||||
@@ -35,6 +37,40 @@ export interface MintListItem {
|
|||||||
score: number;
|
score: number;
|
||||||
last_review_at: number | null;
|
last_review_at: number | null;
|
||||||
version: string | 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 {
|
export interface ProbeSample {
|
||||||
@@ -236,6 +272,29 @@ export interface Stats {
|
|||||||
lnurl_reviews: number;
|
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`. */
|
/** `GET /api/health`. */
|
||||||
export interface Health {
|
export interface Health {
|
||||||
status: 'ok' | 'degraded';
|
status: 'ok' | 'degraded';
|
||||||
@@ -244,6 +303,26 @@ export interface Health {
|
|||||||
last_discovery_at: number | null;
|
last_discovery_at: number | null;
|
||||||
mints_tracked: number;
|
mints_tracked: number;
|
||||||
updated_at: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -268,6 +268,20 @@ for (const file of files) {
|
|||||||
// A .ts file under lib/ or scripts/ is island code wholesale.
|
// A .ts file under lib/ or scripts/ is island code wholesale.
|
||||||
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
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)) {
|
for (const match of source.matchAll(T_CALL)) {
|
||||||
const key = match[2];
|
const key = match[2];
|
||||||
used.add(key);
|
used.add(key);
|
||||||
@@ -280,7 +294,7 @@ for (const file of files) {
|
|||||||
for (const match of clientSource.matchAll(T_CALL)) {
|
for (const match of clientSource.matchAll(T_CALL)) {
|
||||||
const key = match[2];
|
const key = match[2];
|
||||||
const namespace = key.split('.')[0];
|
const namespace = key.split('.')[0];
|
||||||
if (!clientNamespaces.has(namespace)) {
|
if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
|
||||||
clientLeaks.push({ key, file: relative, namespace });
|
clientLeaks.push({ key, file: relative, namespace });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -121,13 +121,22 @@ export function missingKeys(): Record<string, string[]> {
|
|||||||
* key that survives is resolved: a key this locale is missing arrives already filled
|
* 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
|
* with the English string, so the browser needs no fallback catalog and ships exactly
|
||||||
* one language.
|
* 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): Catalog {
|
export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
|
||||||
const catalog = catalogFor(locale);
|
const catalog = catalogFor(locale);
|
||||||
|
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
|
||||||
const out: Catalog = {};
|
const out: Catalog = {};
|
||||||
for (const key of Object.keys(BASE_CATALOG)) {
|
for (const key of Object.keys(BASE_CATALOG)) {
|
||||||
const namespace = key.split('.')[0] ?? '';
|
const namespace = key.split('.')[0] ?? '';
|
||||||
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
|
if (!allowed.has(namespace)) continue;
|
||||||
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ interface Props {
|
|||||||
description: string;
|
description: string;
|
||||||
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
|
||||||
mintCount?: number;
|
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;
|
ogType?: string;
|
||||||
/**
|
/**
|
||||||
* A real page that should not be in the index.
|
* A real page that should not be in the index.
|
||||||
@@ -70,7 +78,7 @@ interface Props {
|
|||||||
const {
|
const {
|
||||||
title, description, current, mintCount, ogType = 'website',
|
title, description, current, mintCount, ogType = 'website',
|
||||||
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
|
||||||
imageAlt,
|
imageAlt, clientNamespaces = [],
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -118,7 +126,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
|
* travels in the HTML the page was sending anyway, costs no extra request, and is on
|
||||||
* screen before the first island has finished downloading.
|
* screen before the first island has finished downloading.
|
||||||
*/
|
*/
|
||||||
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
|
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The social card, absolute.
|
* The social card, absolute.
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
const items = limit === undefined ? all : all.slice(0, limit);
|
||||||
|
|
||||||
|
const cards = renderMintGrid(grid, items, {
|
||||||
|
...(ranked === undefined ? {} : { ranked }),
|
||||||
|
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
|
||||||
|
});
|
||||||
|
onReplaced?.(cards, all);
|
||||||
|
}
|
||||||
@@ -98,6 +98,9 @@ export const FIXTURE_MINT: MintDetail = {
|
|||||||
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
|
||||||
info: null,
|
info: null,
|
||||||
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
|
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),
|
first_seen: daysAgo(420),
|
||||||
last_probe: daysAgo(0),
|
last_probe: daysAgo(0),
|
||||||
updated_at: daysAgo(0),
|
updated_at: daysAgo(0),
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -267,7 +268,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* 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 num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -415,6 +424,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
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(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
@@ -100,7 +100,18 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
});
|
});
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base title={t('home.title')} description={description} current="mints">
|
{/*
|
||||||
|
`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']}
|
||||||
|
>
|
||||||
{/*
|
{/*
|
||||||
The hero sits on an animated colour field: ColorBends from React Bits, ported to
|
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
|
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
|
||||||
@@ -180,9 +191,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.top.sub')}</span>
|
<span class="sec-sub">{t('home.top.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
|
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
|
||||||
|
{t('home.top.all', { n: mints.length })}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="cashu">
|
||||||
{top.map((mint, i) => (
|
{top.map((mint, i) => (
|
||||||
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
|
||||||
))}
|
))}
|
||||||
@@ -200,11 +213,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
|
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/fedimints', locale)}>
|
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
|
||||||
{t('home.topFedimints.all', { n: federations.length })}
|
{t('home.topFedimints.all', { n: federations.length })}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="fedimint">
|
||||||
{topFederations.map((federation, i) => (
|
{topFederations.map((federation, i) => (
|
||||||
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
|
||||||
))}
|
))}
|
||||||
@@ -225,11 +238,11 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
<div class="sec-head" data-reveal>
|
<div class="sec-head" data-reveal>
|
||||||
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
|
||||||
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
|
||||||
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
|
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
|
||||||
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
{t('home.topLnurl.all', { n: stats.lnurl_total })}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mint-grid">
|
<div class="mint-grid" data-home-grid="lnurl">
|
||||||
{topLnurl.map((mint, i) => (
|
{topLnurl.map((mint, i) => (
|
||||||
<MintCard
|
<MintCard
|
||||||
mint={mint}
|
mint={mint}
|
||||||
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
|
import { useI18n } from '../../i18n/client';
|
||||||
|
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
/** One card every six seconds, until the reader touches the track. */
|
/** One card every six seconds, until the reader touches the track. */
|
||||||
const AUTO_MS = 6000;
|
const AUTO_MS = 6000;
|
||||||
/** Matches the track's CSS gap. */
|
/** Matches the track's CSS gap. */
|
||||||
const GAP = 16;
|
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 => {
|
const setup = (): void => {
|
||||||
|
requestAnimationFrame(() => hydrateStrips());
|
||||||
|
|
||||||
// No track at all means the relays gave the build nothing, and the section is
|
// No track at all means the relays gave the build nothing, and the section is
|
||||||
// showing its empty state instead.
|
// showing its empty state instead.
|
||||||
const found = {
|
const found = {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import MintCard from '../../components/MintCard.astro';
|
import MintCard from '../../components/MintCard.astro';
|
||||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||||
import type { LnurlDetail } from '@cashumints/shared';
|
import { fetchLnurlMints } from '../../lib/api';
|
||||||
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
|
|
||||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||||
import { itemListNode } from '../../lib/schema';
|
import { itemListNode } from '../../lib/schema';
|
||||||
import { isIndexableMint } from '../../lib/seo';
|
import { isIndexableMint } from '../../lib/seo';
|
||||||
@@ -32,25 +31,17 @@ const { locale } = Astro.props;
|
|||||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||||
const t = useI18n(locale);
|
const t = useI18n(locale);
|
||||||
|
|
||||||
const mints = await fetchLnurlMints();
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
|
One request, no N+1.
|
||||||
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
|
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
|
||||||
cannot be read simply gets no chip.
|
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 details = await Promise.all(
|
const mints = await fetchLnurlMints();
|
||||||
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 online = mints.filter((m) => m.status === 'online').length;
|
||||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||||
@@ -152,7 +143,16 @@ const schema = [
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mint-grid" data-mint-grid>
|
<div class="mint-grid" data-mint-grid>
|
||||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
|
{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>
|
</div>
|
||||||
|
|
||||||
<p class="no-results" data-no-results hidden>
|
<p class="no-results" data-no-results hidden>
|
||||||
@@ -258,6 +258,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -280,7 +281,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* 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 num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -431,6 +440,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
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(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import MintCard from '../../components/MintCard.astro';
|
import MintCard from '../../components/MintCard.astro';
|
||||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||||
import { readCapabilities } from '@cashumints/shared';
|
import { fetchMints } from '../../lib/api';
|
||||||
import { fetchMint, fetchMints } from '../../lib/api';
|
|
||||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||||
import { itemListNode } from '../../lib/schema';
|
import { itemListNode } from '../../lib/schema';
|
||||||
import { isIndexableMint } from '../../lib/seo';
|
import { isIndexableMint } from '../../lib/seo';
|
||||||
@@ -20,19 +19,18 @@ const { locale } = Astro.props;
|
|||||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||||
const t = useI18n(locale);
|
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();
|
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 online = mints.filter((m) => m.status === 'online').length;
|
||||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||||
|
|
||||||
@@ -128,7 +126,7 @@ const schema = [
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mint-grid" data-mint-grid>
|
<div class="mint-grid" data-mint-grid>
|
||||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
|
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="no-results" data-no-results hidden>
|
<p class="no-results" data-no-results hidden>
|
||||||
@@ -232,6 +230,7 @@ const schema = [
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { wireCopyableIds } from '../../lib/client';
|
import { wireCopyableIds } from '../../lib/client';
|
||||||
|
import { hydrateMintGrid } from '../../lib/mint-cards';
|
||||||
import { useI18n } from '../../i18n/client';
|
import { useI18n } from '../../i18n/client';
|
||||||
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
|
||||||
|
|
||||||
@@ -254,7 +253,15 @@ const schema = [
|
|||||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||||
|
|
||||||
if (grid && searchInput && sortSelect && hideOffline) {
|
if (grid && searchInput && sortSelect && hideOffline) {
|
||||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
/*
|
||||||
|
* 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 num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
|
||||||
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
|
||||||
|
|
||||||
@@ -405,6 +412,37 @@ const schema = [
|
|||||||
if (sort && sort in SORTS) sortSelect.value = sort;
|
if (sort && sort in SORTS) sortSelect.value = sort;
|
||||||
if (q || sort) apply();
|
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(() => {
|
onLeave(() => {
|
||||||
for (const timer of leaving.values()) window.clearTimeout(timer);
|
for (const timer of leaving.values()) window.clearTimeout(timer);
|
||||||
leaving.clear();
|
leaving.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user