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.
8.6 KiB
21 — Generators (generators.${BASE_DOMAIN})
Minecraft data pack generators — a pinned, offline-only fork of 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:
- Offline. At the party the machine is present but the network is not.
- Version pin. The generators can never drift ahead of a 1.21.1 server.
- 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-tinyis assets minus the.oggsounds (hundreds of MB). It is served at the-assetspath because that is what the app requests. Sound playback would 404, which is why the fork deletes the sounds generator.summary/versions/data.min.jsoncomes 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 theminecraft: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:
GENERATORS_REPOmust be set in cochi's.env. compose uses${GENERATORS_REPO:?…}, and an unset required variable makesdocker compose upfail to parse the file — every service, including Minecraft, not just this one.- Clone the fork on cochi at that path (branch
ulicraft). tooling/sync-generators-data.shon cochi (~109M, needs WAN).generatorsinLE_SUBDOMAINS→tooling/issue-letsencrypt.sh.- Only then
tooling/render-nginx.sh --install(nginx fails its config test if the cert file does not exist). - 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
Hostwith an empty200, so both/and the mirror path above returnedHTTP 200, 0 byteswhile 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; includeSubDomainson 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.