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.
180 lines
8.6 KiB
Markdown
180 lines
8.6 KiB
Markdown
# 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.
|