Files
ulicraft-server-v1/plan/21-generators.md
T
oier 00e9815307 docs(generators): an empty 200 is not a healthy service
Observed while staging generators. on cochi: with the nginx vhost installed
but the container not yet deployed, caddy answers an unmatched Host with an
EMPTY 200. Both / and the mirror path returned "HTTP 200, 0 bytes" while
nothing at all was running.

So the guidance to probe a mirror path instead of / was necessary but not
sufficient — a status-code-only check reports green on a completely absent
service. Assert the body.

Same failure family as the filestash green-monitor: the server answering
is not the app working.
2026-07-21 19:37:28 +02:00

180 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 21 — Generators (`generators.${BASE_DOMAIN}`)
> Minecraft data pack generators — a pinned, offline-only fork of
> [misode.github.io](https://github.com/misode/misode.github.io), served with
> its own data mirror so it works with **zero internet** at the LAN party.
## Why self-host at all
Upstream is public, free and CDN-hosted, so a plain mirror would buy nothing.
Three things justify this:
1. **Offline.** At the party the machine is present but the network is not.
2. **Version pin.** The generators can never drift ahead of a 1.21.1 server.
3. **Modded schemas.** `public/mcdoc/` is the extension point for mods (see
*C-lite / C-full* below).
## The one fact that drives every decision
**The app is 100% client-side.** Every data fetch comes from the *player's
browser*, not from cochi. So:
- "cochi has no WAN at the party" protects nothing — a leftover upstream URL
just fails in the browser, at the venue, with no internet to debug it.
- Blocking the container's egress proves nothing either.
- The **only** things that make it offline-capable are (a) which origins the
bundle refers to, and (b) the mirror we serve them from.
That is why the CSP is enforcement, not decoration — see *Verification*.
## Architecture
```
host nginx (TLS, LE cert for generators.) ─► caddy ─► generators:80
│ (caddy in-container)
caddy/conf.d/70-generators.caddy ── sets Content-Security-Policy
│
generators container: /srv/app ← built Preact/Vite bundle (from the fork)
/srv/data ← ~109M mirror, bind-mounted :ro
```
One origin serves both the app and its data. No CORS, no second cert, no second
DNS name.
| Piece | Where |
|---|---|
| Fork | `git.fosil.eu/oier/ulicraft-generators`, branch `ulicraft`, `GENERATORS_REPO` in `.env` |
| Dockerfile + Caddyfile | **in the fork** (it is unpublished upstream, so unlike `nmsr` it cannot clone a pinned ref) |
| Mirror sync | `tooling/sync-generators-data.sh` → `GENERATORS_DATA_DIR` (default `./generators-data`, gitignored) |
| Stack vhost | `caddy/conf.d/70-generators.caddy` |
| TLS vhost | `nginx/ulicraft-caddy.conf.tmpl` |
## The mirror (~109M)
mcmeta publishes per-version snapshots as **tags** (~4000 of them), not branches.
| Source | Ref | Size |
|---|---|---|
| `misode/mcmeta` | tag `1.21.1-summary` | 31M |
| `misode/mcmeta` | tag `1.21.1-data` | 34M |
| `misode/mcmeta` | tag `1.21.1-assets-tiny` → served **as** `1.21.1-assets` | 36M |
| `misode/mcmeta` | tag `1.21.1-atlas` | 3.5M |
| `misode/mcmeta` | branch `summary`, `versions/data.min.json` only | 172K |
| `SpyglassMC/vanilla-mcdoc` | branch `generated`, `symbols.json` | 2.4M |
Three non-obvious points:
- **`assets-tiny` is assets minus the `.ogg` sounds** (hundreds of MB). It is
served at the `-assets` path because that is what the app requests. Sound
*playback* would 404, which is why the fork deletes the sounds generator.
- **`summary/versions/data.min.json` comes from the branch head, not a tag**
(`DataFetcher.ts:205`), and it runs during Spyglass init. Miss it and the app
dead-ends offline while looking perfectly healthy online.
- The **directory layout mirrors upstream's URL shape** (`/mcmeta/<ref>-<type>/…`).
That is what keeps the fork patch to a couple of constants.
The sync script verifies a **sentinel file per source** rather than trusting a
clean `git clone` exit — a renamed upstream path would otherwise "sync
successfully" and die in the browser at the party.
## Fork diff (pinned at upstream `2bfe5522`)
| Change | File |
|---|---|
| `mcmetaUrl` → `/mcmeta`, `vanillaMcdocUrl` → `/vanilla-mcdoc` | `services/DataFetcher.ts` |
| Removed jsdelivr fallback | `services/DataFetcher.ts` |
| Removed hardcoded immersive_weathering raw.githubusercontent URL | `services/DataFetcher.ts` |
| Version locked to `1.21` | `services/Versions.ts` (`MIRRORED_VERSION`) |
| Switcher filters the **view**, not the data | `components/VersionSwitcher.tsx` |
| Routes `/sounds`, `/changelog`, `/versions`, `/whats-new` deleted | `App.tsx`, `pages/Home.tsx` |
| Share disabled (write API, unmirrorable) | `services/Sharing.ts` |
| gtag → no-op, EthicalAds/Giscus/contributors removed, footer + `SOURCE_REPO_URL` | `index.html`, `Home.tsx`, `Footer.tsx`, `Utils.ts` |
### Version lock — the trap
`checkVersion()` compares versions **by index** (`config.versions.findIndex`).
Deleting entries from `config.versions` / `VersionIds` makes a removed
`minVersion` resolve to `-1`, so `-1 <= version` passes and generators that do
**not** exist in 1.21 silently render. **Filter the view, never the data.**
### Upstream policy: pin and freeze
Do not chase `master`. Upstream ships 26.x while this server sits at 1.21.1, and
every merge risks reintroducing an external origin. Merges are a rare,
deliberate chore — and the CSP is what catches a regression.
## C-lite vs C-full (modded generators)
**Currently C-lite.** `public/mcdoc/create.mcdoc` and `neoforge.mcdoc` ship
upstream and cover the pack's two biggest datapack surfaces. `.mcdoc` files are
fetched **same-origin** from `/mcdoc/<dep>.mcdoc`, so adding a mod = drop a file
in `public/mcdoc/` + add entries to `src/config.json`.
**C-full** (autocomplete for the pack's own items/blocks) additionally needs:
- a registry dump from the live server — KubeJS is already in the pack and
already mounted, so a startup script can dump every registry;
- a patch to `fetchRegistries`, which currently **hardcodes the `minecraft:`
namespace** (`data[id].map(e => 'minecraft:' + e)`), so modded ids cannot
enter autocomplete from data alone;
- modded item icons would still be missing (the atlas is vanilla-only).
mcdoc is hand-written per mod (upstream's range: 19–1389 lines), so C-full needs
a **named shortlist**, never "the pack".
## Deploy
Prerequisites on cochi, in this order — **the first one can take the whole stack
down if skipped**:
1. **`GENERATORS_REPO` must be set in cochi's `.env`.** compose uses
`${GENERATORS_REPO:?…}`, and an unset required variable makes
`docker compose up` fail to parse the file — **every service, including
Minecraft**, not just this one.
2. Clone the fork on cochi at that path (branch `ulicraft`).
3. `tooling/sync-generators-data.sh` on cochi (~109M, **needs WAN**).
4. `generators` in `LE_SUBDOMAINS` → `tooling/issue-letsencrypt.sh`.
5. **Only then** `tooling/render-nginx.sh --install` (nginx fails its config
test if the cert file does not exist).
6. DNS `A generators.${BASE_DOMAIN}` — and the **party DNS** too.
Then the normal deploy (`deploy` skill): git pull + `docker compose up -d --build`.
## Verification
**A 200 on `/` proves the server is up, not that the app works.** This is an SPA
whose failure mode is a healthy-looking shell over dead data.
- `curl /mcmeta/1.21.1-summary/registries/data.min.json` — proves the mirror is
actually mounted. An Uptime Kuma monitor should probe **this**, not `/`.
- **A status code is not enough — assert the BODY.** Observed on 2026-07-21,
with the nginx vhost installed but the container not yet deployed: caddy
answers an unmatched `Host` with an **empty `200`**, so *both* `/` and the
mirror path above returned `HTTP 200, 0 bytes` while literally nothing was
running. Any check that looks only at the status code reports green on a
completely absent service. Match on content (or a minimum body size):
```
curl -s https://generators.${BASE_DOMAIN}/mcmeta/1.21.1-summary/registries/data.min.json \
| head -c 40 # must be JSON, ~536 KB total — not empty
```
- **Offline acceptance test**: a real browser with every non-origin request
blocked, walking the generators you intend to use. `tooling/`-adjacent harness
lives in the scratchpad; the check is: zero off-origin attempts, zero
same-origin failures, zero console errors, schemas actually rendered.
- The **CSP** (`connect-src 'self'`) is the standing enforcement: an external
origin that sneaks back in breaks loudly in prod, on an ordinary day, instead
of silently at the party.
`script-src`/`style-src` are deliberately permissive for now — Spyglass/deepslate
wasm+eval needs were not characterised under deadline. Tightening them is a
post-party task; loosening `connect-src` is not.
## Party-day notes
- HSTS is `max-age=31536000; includeSubDomains` on every vhost, so **there is no
HTTP fallback and no cert-warning click-through**. The cert must be valid.
- LE certs bind **names, not IPs**, so a party DNS override pointing
`generators.${BASE_DOMAIN}` at the LAN IP validates fine — provided the cert
is unexpired and was issued while WAN existed.