@@ -0,0 +1,680 @@
|
||||
# cashumints.space
|
||||
|
||||
A Cashu mint explorer and review site. Lists every mint discoverable on the Nostr network,
|
||||
shows each mint's live metadata from its own `/v1/info`, and surfaces community reviews
|
||||
published as NIP-87 events.
|
||||
|
||||
Made by [Azzamo](https://azzamo.net).
|
||||
|
||||
```
|
||||
Nostr relays Cashu mints
|
||||
(NIP-87 events) (/v1/info)
|
||||
| |
|
||||
v discovery (hourly) v probe (10 min)
|
||||
+----------------------------------------------+
|
||||
| api/ Node + Hono + SQLite |
|
||||
| last-known-good metadata, 4 REST endpoints |
|
||||
+----------------------------------------------+
|
||||
| |
|
||||
build-time fetch runtime fetch (islands)
|
||||
v v
|
||||
+----------------------------------------------+
|
||||
| web/ Astro, static, prerendered |
|
||||
+----------------------------------------------+
|
||||
|
|
||||
browser <-> relays (review text, signing via NIP-07 or NIP-46)
|
||||
```
|
||||
|
||||
The backend is a memory layer, not an authority. Its job is to remember what a mint looked
|
||||
like when it was last reachable, so an offline mint still has a page people can read and
|
||||
review. Review content is never stored server-side.
|
||||
|
||||
Identity is the same story: logging in is a browser-side affair the API never sees. A
|
||||
session is a pubkey plus, for a remote signer, the NIP-46 connection details, kept in
|
||||
localStorage. No private key is ever asked for, accepted or stored, by any code path in
|
||||
this repository.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
| --------- | -------------------------------------------------------------- |
|
||||
| `api/` | Indexer and REST API. Hono, better-sqlite3, nostr-tools. |
|
||||
| `web/` | Astro static site with vanilla TypeScript islands. |
|
||||
| `shared/` | Types, NIP-87 constants, URL normalization, scoring, NUT names. |
|
||||
|
||||
The site is in English, Spanish and Dutch. `web/src/i18n/` holds the message catalogs
|
||||
and the locale table; `web/src/i18n/GLOSSARY.md` holds the term decisions a translator
|
||||
needs before touching either. See [Languages](#languages).
|
||||
|
||||
`NOTES.md` records what was found in the previous codebase: event kinds, tags, relays, the
|
||||
rating encoding, and the bugs this rebuild fixes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node 20 or newer (uses `--experimental-strip-types`, so no build step for the API)
|
||||
- pnpm 9 or newer
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm --filter ./shared build
|
||||
```
|
||||
|
||||
`shared/` compiles to `dist/`, which both other packages import. Run it once after install
|
||||
and again whenever you change something in `shared/src`.
|
||||
|
||||
## Seed the database
|
||||
|
||||
Ingests the initial mint list, probes every mint, runs a full discovery backfill against
|
||||
the relays, and prints a summary table. Takes about a minute against the live network.
|
||||
|
||||
```bash
|
||||
pnpm seed
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Runs the API on `:8787` and the Astro dev server on `:4321`. Both ports come from
|
||||
`.env` — see [Configuration](#configuration).
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Run them separately if you prefer:
|
||||
|
||||
```bash
|
||||
pnpm dev:api
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm dev:web
|
||||
```
|
||||
|
||||
The web app reads the API at build time, so the API must be running before you build or
|
||||
start the dev server.
|
||||
|
||||
## Skeleton loading
|
||||
|
||||
Two regions fetch their content at runtime and get a skeleton while they wait: the reviews
|
||||
panel body on a mint page (reviews come from Nostr relays) and the prerender miss summary
|
||||
on `/mint/{host}` for a mint added since the last build. Nothing prerendered is ever
|
||||
covered by a skeleton, so the mint header, verdict strip, sidebar, the reviews panel's own
|
||||
header and filter counts, the pulse ticker and every grid stay on screen as they are.
|
||||
|
||||
The bones are captured from the real rendered components by
|
||||
[Boneyard](https://github.com/0xGF/boneyard) and committed as
|
||||
`web/src/bones/*.bones.json`, so they ship with the island and draw immediately without a
|
||||
network round trip.
|
||||
|
||||
Regenerate them with the dev server running:
|
||||
|
||||
```bash
|
||||
pnpm bones
|
||||
```
|
||||
|
||||
That visits a real mint page and a deliberately unprerendered `/mint/` URL at 360, 768 and
|
||||
1280px wide, and rewrites both bone files. Both islands render fixture content
|
||||
(`web/src/lib/skeleton-fixtures.ts`) while the capture browser is looking, so the result
|
||||
does not depend on a relay answering. The fixtures load only during a capture and are not
|
||||
part of the bundle a visitor downloads.
|
||||
|
||||
**Re-run `pnpm bones` after changing the layout of a review card or of the prerender miss
|
||||
summary**, including CSS-only changes. Stale bones are not a build error, they just stop
|
||||
matching the content that replaces them.
|
||||
|
||||
Capturing needs a Chromium for Playwright, once per machine:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./web exec playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
## Static assets
|
||||
|
||||
`web/public/` ships as-is: the fonts, the social card and the icon set.
|
||||
|
||||
**Fonts** are self-hosted from `web/src/assets/fonts/` — Space Grotesk, Inter and
|
||||
JetBrains Mono, as the `latin` and `latin-ext` woff2 subsets Google Fonts serves, with
|
||||
the same `unicode-range` declarations, so rendering is identical to the CDN it replaced.
|
||||
All three are variable fonts, so six files cover all fourteen faces. The site makes no
|
||||
third-party request for them, which is both one less thing in the critical path and one
|
||||
less entry in the list on `/privacy`. They are SIL Open Font License 1.1; the notice
|
||||
ships at `/fonts/OFL.txt`.
|
||||
|
||||
They sit in `src/assets/` rather than `public/` so the build fingerprints them into
|
||||
`/_astro/`, which is what makes `Cache-Control: immutable` honest and puts them under a
|
||||
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
|
||||
every client-side navigation — the router re-inserts their preload links on each swap —
|
||||
and the typefaces visibly reload from page to page.
|
||||
|
||||
**The social card and icons** (`og.png`, `favicon.ico`, `favicon.svg`,
|
||||
`apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) are generated once and committed:
|
||||
|
||||
```bash
|
||||
node web/scripts/make-assets.mjs
|
||||
```
|
||||
|
||||
That draws the 1200x630 card in a headless Chromium using the site's own fonts and
|
||||
tokens, then downsamples one icon master into the rest. Re-run it after a brand change.
|
||||
It is deliberately not part of `pnpm build`: the site has to build on a machine with no
|
||||
browser, and these files change roughly never. It needs the same Chromium `pnpm bones`
|
||||
does.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit checks over URL normalization, rating parsing, scoring and the review-dedupe SQL:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./api test
|
||||
```
|
||||
|
||||
The warning banners. Fixture-driven checks over `getMintWarnings`, the one function that
|
||||
decides whether a mint page shows "melt only", "withdrawals disabled", "mint frozen" or
|
||||
one of the three offline tiers. Fixtures are real `/v1/info` payloads (or a real one with
|
||||
a single flag flipped, noted in the file) under `shared/fixtures/warnings`:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./api test:warnings
|
||||
```
|
||||
|
||||
The offline acceptance check. Copies the seeded database, points a healthy mint at a dead
|
||||
URL, probes it until it is marked offline, and asserts the mint page still serves its full
|
||||
cached metadata and reviews:
|
||||
|
||||
```bash
|
||||
pnpm --filter ./api test:offline
|
||||
```
|
||||
|
||||
Type checking across the workspace:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
```
|
||||
|
||||
## Build the site
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Output lands in `web/dist/`. 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,
|
||||
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`
|
||||
alternates, and `robots.txt` sits beside it.
|
||||
|
||||
### What is indexed, and what is not
|
||||
|
||||
One rule, in `web/src/lib/seo.ts`, decides it: a mint page is left out of the index when
|
||||
the site has **never once reached** that mint **and** nobody has reviewed it. Such a page
|
||||
has no name, no description, no version and no reviews, because all of those come from a
|
||||
mint that answered or a person who wrote something — so every one of them is the same
|
||||
page as the next. It stays listed on `/mints`, stays linked, stays searchable on the site
|
||||
and stays reviewable; it just carries `noindex, follow` and is absent from the sitemap,
|
||||
until the mint answers once or someone reviews it.
|
||||
|
||||
The mint page and the sitemap import that one predicate rather than each testing for it,
|
||||
and `pnpm check:hreflang` verifies from the built output that they still agree — a page
|
||||
saying `noindex` while the sitemap advertises it is a contradiction, and it fails the
|
||||
build.
|
||||
|
||||
Structured data is assembled in `web/src/layouts/Base.astro` and nowhere else, from the
|
||||
builders in `web/src/lib/schema.ts`, so a document cannot end up with two `ld+json`
|
||||
blocks or two conflicting `@id`s. Every page carries `Organization`, `WebSite` and
|
||||
`WebPage`; mint pages add `BreadcrumbList` and a `Service` node whose `aggregateRating`
|
||||
appears only when reviews actually carried ratings; `/mints` and `/wallets` add an
|
||||
`ItemList`. Mint names and descriptions are written by mint operators, so
|
||||
`serializeGraph` escapes them for the one context that matters — nothing that could
|
||||
close or reopen a script element survives it.
|
||||
|
||||
Check the build for broken internal links, links to routes that were never emitted, and
|
||||
anchors pointing at ids no page has:
|
||||
|
||||
```bash
|
||||
pnpm check:links
|
||||
```
|
||||
|
||||
It exits non-zero on a problem, so it can gate a deploy. `LIST_TARGETS=1` prints every
|
||||
internal target and which pages link to it. `NOTES-PAGES.md` holds the last full audit.
|
||||
|
||||
Check the i18n and indexing side of the build: `<html lang>` against the URL's locale, a
|
||||
self-referencing canonical on every page, a complete and reciprocal hreflang set
|
||||
including `x-default`, and a sitemap that lists every indexable page with its alternates
|
||||
and no page that asked not to be indexed:
|
||||
|
||||
```bash
|
||||
pnpm check:hreflang
|
||||
```
|
||||
|
||||
The catalogs are checked before every build, and separately with:
|
||||
|
||||
```bash
|
||||
pnpm check:i18n
|
||||
```
|
||||
|
||||
Both gate a deploy. See [Languages](#languages) for what they look for.
|
||||
|
||||
## Languages
|
||||
|
||||
English, Spanish and Dutch. English is the site as it was; the other two are the same
|
||||
site, prerendered again.
|
||||
|
||||
### Routing
|
||||
|
||||
Path-prefix locales, with English at the root:
|
||||
|
||||
| Language | Home | A mint page |
|
||||
| ---------- | ------- | ------------------------ |
|
||||
| English | `/` | `/mint/kashu.me` |
|
||||
| Spanish | `/es` | `/es/mint/kashu.me` |
|
||||
| Dutch | `/nl` | `/nl/mint/kashu.me` |
|
||||
|
||||
Every existing English URL stays exactly where it was, which matters more here than
|
||||
tidiness: those URLs are indexed, linked, and pasted into wallets.
|
||||
|
||||
**Route slugs stay English in every language**: `/es/mints`, never `/es/mentas`. This is
|
||||
a deliberate trade. Translated slugs read marginally better in an address bar and cost a
|
||||
permanent, growing table of slug-per-route-per-language that every internal link, the
|
||||
sitemap, the link checker and the switcher have to agree on forever. Stable URLs are
|
||||
worth more here, and the hreflang set is what actually tells a crawler these are the same
|
||||
page.
|
||||
|
||||
**Nothing redirects by language.** No `Accept-Language` sniffing, no IP geolocation. A
|
||||
crawler asking for `/mint/kashu.me` gets `/mint/kashu.me`, and a reader who deliberately
|
||||
opened the English page is not overruled by a browser setting they configured years ago.
|
||||
The language control in the header is how you change language, and it lands on the page
|
||||
you were already reading.
|
||||
|
||||
One file per route emits all three: pages live under `web/src/pages/[...locale]/`, and
|
||||
`localePaths()` in `web/src/i18n/paths.ts` is their `getStaticPaths`. There is one copy
|
||||
of each page's markup, translated by `t()`, rather than three copies to keep in step.
|
||||
|
||||
### What is and is not translated
|
||||
|
||||
Translated: every piece of site copy. Navigation, buttons, labels, filters, empty
|
||||
states, error states, form copy, tooltips, `aria-label`s, the static pages, the warning
|
||||
banners, page titles and meta descriptions, and the plain-language name beside a NUT
|
||||
number.
|
||||
|
||||
Never translated, because it is data rather than copy:
|
||||
|
||||
- review text, and the name, NIP-05 and npub of whoever wrote it
|
||||
- mint names, mint descriptions and MOTDs, in whatever language the operator wrote them
|
||||
- URLs, hosts, npubs, pubkeys, version strings
|
||||
- protocol terms: `NUT-04`, `NIP-87`, `bunker://`, Nostr, Lightning, ecash, sat
|
||||
|
||||
A mint's own `description` is the first sentence of its page's meta description, verbatim.
|
||||
Only when a mint has published none does the site write that sentence itself.
|
||||
|
||||
### Adding a fourth language
|
||||
|
||||
Four steps, and the build tells you if you miss one.
|
||||
|
||||
1. **`web/src/i18n/GLOSSARY.md`** — add the column and decide the terms first. This is
|
||||
the step people skip, and it is the one that costs later: a reader who meets two words
|
||||
for "review" has to work out whether they are the same thing.
|
||||
|
||||
2. **`web/src/i18n/xx.json`** — copy `en.json` and translate it. Flat, dotted keys.
|
||||
A key with a count uses `.one` / `.other`; a language with more plural categories may
|
||||
add `.few`, `.many`, `.zero`, and `Intl.PluralRules` picks between them. Nothing else
|
||||
in the codebase has to know.
|
||||
|
||||
3. **`web/src/i18n/config.ts`** — add the entry to `LOCALES`:
|
||||
|
||||
```ts
|
||||
{ code: 'pt', label: 'Português', intl: 'pt-BR', og: 'pt_BR' },
|
||||
```
|
||||
|
||||
`label` is the language's own name for itself, and is what the switcher shows. `intl`
|
||||
is the tag `Intl` gets, region included, because number and date formatting differ by
|
||||
region even when the language does not.
|
||||
|
||||
4. **`web/src/i18n/locales.mjs`** — add the code to `LOCALE_CODES`. This is the same list
|
||||
in plain JavaScript, for `astro.config.mjs` and the checker, both of which run before
|
||||
TypeScript exists. `check-i18n` compares the two lists and fails if they disagree, so
|
||||
forgetting this is a build error rather than a mystery.
|
||||
|
||||
That is the whole change. The Astro i18n config, the routes, the switcher, the hreflang
|
||||
sets, the `og:locale:alternate` list and the sitemap all read from `LOCALES` and pick the
|
||||
new language up on the next build.
|
||||
|
||||
Two things you do not have to do: dates, times and numbers come from `Intl` and are right
|
||||
for the new locale immediately, and relative times fall back to
|
||||
`Intl.RelativeTimeFormat` for any unit the new catalog does not tighten with its own
|
||||
`time.ago.*` string.
|
||||
|
||||
### What the build checks
|
||||
|
||||
`pnpm check:i18n` runs before every build (from `astro.config.mjs`) and on its own. Four
|
||||
questions:
|
||||
|
||||
| | | |
|
||||
| --- | --- | --- |
|
||||
| `missing` | a key English has and this locale does not | **warning**, listed by name |
|
||||
| `unknown` | a key a locale has and English does not | error |
|
||||
| `undefined` | a `t('...')` in the source no catalog defines | error |
|
||||
| `client` | an island reaching for a key outside `CLIENT_NAMESPACES` | error |
|
||||
|
||||
Missing keys are a warning on purpose: they fall back to English, and half a language is
|
||||
better than no language. Everything else is an error, because each one puts a wrong
|
||||
string in front of a reader. A key that resolves nowhere at all renders as humanised
|
||||
words rather than as `mint.warnings.meltOnly`, so even the unreachable case is not a raw
|
||||
key on screen.
|
||||
|
||||
It also diffs the warning-banner copy against `shared/src/warnings.ts`. The decision
|
||||
about which banner a mint gets lives in `shared/` and has to stay language-free, so the
|
||||
English sentences live there too (the API's fixture tests assert on them) and the
|
||||
catalogs carry the same keys for translation. Two copies of safety copy is exactly the
|
||||
sort of thing that drifts, so the two are compared on every build.
|
||||
|
||||
`pnpm check:hreflang` runs over `dist/` after a build: `<html lang>` against the URL,
|
||||
self-referencing canonicals, complete and reciprocal alternate sets, `x-default` pointing
|
||||
at English, every alternate resolving to a page that was actually emitted, and a sitemap
|
||||
entry with alternates for each. The 404 pages are the one exception and are checked for
|
||||
the opposite: no canonical, no alternates, and a `noindex`.
|
||||
|
||||
### How the strings reach an island
|
||||
|
||||
The prerendered half of the site is translated at build time and costs a visitor
|
||||
nothing. The islands need their strings in the browser, and the requirement is that a
|
||||
Spanish page ships Spanish and nothing else.
|
||||
|
||||
`Base.astro` inlines the current locale's client-facing keys as one
|
||||
`<script type="application/json" data-i18n>` in the body, already resolved against
|
||||
English. Islands read it through `useI18n()` in `web/src/i18n/client.ts`.
|
||||
|
||||
The alternative, importing `es.json` from an island, does not work here: island chunks
|
||||
are shared across every locale, so anything imported into one is downloaded by all of
|
||||
them. A dynamic `import()` keyed on locale would avoid that but costs a round trip on the
|
||||
critical path of every island and leaves all three catalogs sitting in `dist/_astro/`.
|
||||
Inlining travels in HTML the page was sending anyway.
|
||||
|
||||
It is in the body rather than the head because the view transition router replaces the
|
||||
body on every navigation. An island reading it after a swap reads the page it is actually
|
||||
on; a head script would be kept and would keep answering in the language the reader
|
||||
arrived in.
|
||||
|
||||
`CLIENT_NAMESPACES` in `config.ts` decides which namespaces are inlined, and
|
||||
`check-i18n` fails the build if an island reaches outside that list.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every variable has a working default, so nothing has to be set to run the site locally.
|
||||
To change any of them, copy the example file and edit it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
One `.env` at the repo root serves all three packages. The API loads it through node's
|
||||
own `--env-file-if-exists`, and the web side through `web/scripts/load-env.mjs`, imported
|
||||
at the top of `astro.config.mjs`. In both, a real environment variable wins over the file,
|
||||
so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still overrides it.
|
||||
`.env` is gitignored; `.env.example` is the documented copy.
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| ------------------------ | --------------------------- | ---------------------------------------------------- |
|
||||
| `PORT` | `8787` | HTTP port |
|
||||
| `DB_PATH` | `api/data/cashumints.db` | SQLite file |
|
||||
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
|
||||
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
|
||||
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
|
||||
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
|
||||
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
|
||||
| `PROBE_TIMEOUT_MS` | `5000` | Per-mint request timeout |
|
||||
| `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. |
|
||||
|
||||
### Web (`web/`)
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| ---------------- | -------------------------- | --------------------------------------------- |
|
||||
| `WEB_PORT` | `4321` | Dev server and preview port |
|
||||
| `API_URL` | `http://127.0.0.1:8787` | API origin used at build time |
|
||||
| `PUBLIC_API_URL` | empty (same origin) | API origin baked into markup and islands |
|
||||
| `SITE_URL` | `https://cashumints.space` | Canonical origin for meta tags |
|
||||
| `RELAYS` | see `shared/src/nostr.ts` | Relays for the build-time review fetch |
|
||||
| `BONES_URL` | `http://localhost:$WEB_PORT` | Dev server `pnpm bones` captures against |
|
||||
|
||||
`PORT` and `API_URL` have to agree: the build and the dev proxy reach the API at
|
||||
`API_URL`, so moving the API off `8787` means changing both.
|
||||
|
||||
`PUBLIC_API_URL` deliberately does **not** fall back to `API_URL`: that is a build-machine
|
||||
address, and a visitor's browser cannot reach `127.0.0.1`. Left empty, icon `src`
|
||||
attributes and island fetches are same-origin paths (`/icons/...`, `/api/...`), which the
|
||||
dev server proxies to `API_URL` and nginx forwards in production — see "Static site behind
|
||||
nginx" below.
|
||||
|
||||
Set it only when the API answers on its own origin:
|
||||
|
||||
```bash
|
||||
API_URL=http://127.0.0.1:8787 PUBLIC_API_URL=https://api.cashumints.space pnpm build
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Four endpoints, CORS open, no auth.
|
||||
|
||||
| Endpoint | Notes |
|
||||
| -------------------- | ------------------------------------------------------------------ |
|
||||
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. |
|
||||
| `GET /api/stats` | Network counters, memoized 60s in process. |
|
||||
| `GET /api/mints` | All mints, online first then score descending. Optional `?limit=`. |
|
||||
| `GET /api/mints/:host` | One mint plus info, NUTs, distribution, uptime and probe history. |
|
||||
|
||||
`/icons/*` serves the cached mint icons.
|
||||
|
||||
### Ranking
|
||||
|
||||
Bayesian weighted rating:
|
||||
|
||||
```
|
||||
score = (v / (v + m)) * R + (m / (v + m)) * C
|
||||
```
|
||||
|
||||
`v` is the review count, `R` the mint's mean rating, `m` = 5, and `C` the prior mean.
|
||||
Offline mints are multiplied by 0.5 and always sorted below every online mint; mints with
|
||||
no review in 180 days are multiplied by 0.9.
|
||||
|
||||
**`C` defaults to 3, not the observed global mean.** BACKEND.md specifies the global mean,
|
||||
but almost every Cashu review is five stars, so that mean sits near 4.7. Shrinking two
|
||||
above-prior means toward a prior that high cannot reorder them, it only compresses them,
|
||||
and the result is that a single five star review outranks 39 considered ones. The neutral
|
||||
prior is what makes the formula behave as intended. Set `SCORE_PRIOR_MEAN=global` for the
|
||||
literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
|
||||
`pnpm --filter ./api test` asserts both cases.
|
||||
|
||||
## Deployment
|
||||
|
||||
### API as a systemd unit
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cashumints-api.service
|
||||
[Unit]
|
||||
Description=cashumints.space indexer and API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cashumints
|
||||
WorkingDirectory=/srv/cashumints/api
|
||||
ExecStart=/usr/bin/node --experimental-strip-types src/index.ts
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=8787
|
||||
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
|
||||
Environment=ICON_DIR=/var/lib/cashumints/icons
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/cashumints
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now cashumints-api
|
||||
```
|
||||
|
||||
### Static site behind nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cashumints.space;
|
||||
|
||||
root /srv/cashumints/web;
|
||||
index index.html;
|
||||
|
||||
# Astro emits directory-style routes, so try the directory index before 404.
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /404.html;
|
||||
}
|
||||
|
||||
# A miss under a language prefix answers in that language. Without these two blocks
|
||||
# every 404 is the English one, including the ones a Spanish reader reaches by
|
||||
# following a Spanish link, and the language control on it would take them to a page
|
||||
# they were not on.
|
||||
location /es/ {
|
||||
try_files $uri $uri/ $uri.html /es/404/index.html;
|
||||
}
|
||||
|
||||
location /nl/ {
|
||||
try_files $uri $uri/ $uri.html /nl/404/index.html;
|
||||
}
|
||||
|
||||
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
|
||||
# webfonts, which are all emitted here with a content hash in the name. Serving the
|
||||
# fonts without it is visible, not theoretical: the view transition router re-inserts
|
||||
# the font preload links on every navigation, so an uncached font is re-fetched on
|
||||
# each page change and the typefaces flicker as they reload.
|
||||
location /_astro/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# The social card, the icons and the manifest. Not fingerprinted, since their names
|
||||
# are referenced from outside the site, so a week with revalidation rather than a
|
||||
# year of immutability.
|
||||
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
|
||||
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
expires 1d;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
}
|
||||
```
|
||||
|
||||
Add a `location` block per language when you add one. The 404 is also the client-side
|
||||
fallback for a mint discovered since the last build, and it reads the locale off its own
|
||||
URL, so `/es/mint/some-new-mint` resolves that mint and renders its summary in Spanish.
|
||||
|
||||
### API behind nginx, with a micro-cache
|
||||
|
||||
`/api/mints` and `/api/stats` change at most every few minutes but can be requested by
|
||||
every visitor at once. A short micro-cache absorbs that without making the data stale.
|
||||
`/api/health` is deliberately excluded: it is the endpoint you page on.
|
||||
|
||||
```nginx
|
||||
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
|
||||
max_size=256m inactive=10m use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.cashumints.space;
|
||||
|
||||
location /api/health {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location ~ ^/api/(mints|stats) {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
|
||||
proxy_cache cashumints;
|
||||
proxy_cache_valid 200 30s;
|
||||
proxy_cache_valid 404 10s;
|
||||
# Serve the previous response while one request refreshes it, so a slow
|
||||
# backend never becomes a slow page.
|
||||
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
|
||||
proxy_cache_background_update on;
|
||||
proxy_cache_lock on;
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_cache cashumints;
|
||||
proxy_cache_valid 200 1d;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rebuilds
|
||||
|
||||
Mint pages are prerendered, so new mints and new review counts appear at the next build.
|
||||
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 client-side
|
||||
fallback on the 404 page.
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cashumints-build.service
|
||||
[Unit]
|
||||
Description=Rebuild the cashumints.space static site
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=cashumints
|
||||
WorkingDirectory=/srv/cashumints
|
||||
Environment=API_URL=http://127.0.0.1:8787
|
||||
Environment=PUBLIC_API_URL=https://api.cashumints.space
|
||||
Environment=SITE_URL=https://cashumints.space
|
||||
ExecStart=/usr/bin/pnpm build
|
||||
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
|
||||
```
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/cashumints-build.timer
|
||||
[Unit]
|
||||
Description=Nightly cashumints.space rebuild
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now cashumints-build.timer
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `LICENSE`.
|
||||
Reference in New Issue
Block a user