Files
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

8.6 KiB
Raw Permalink Blame History

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:

  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.