Closes Phase 2. Modules live on a mount, never in the image — that is what lets an operator add one to a pull-only deployment without building anything. `./modules` is a bind mount rather than a named volume: placing a module directory by hand is a supported install (MODULE_SYSTEM.md §2.5), and that has to be doable from the host rather than through `docker cp`. Read-write, because the admin panel's install/uninstall unpacks and removes directories there. The directory is tracked via its README so it exists in the checkout with the operator's own ownership — Docker recreates a missing bind-mount source as root:root, which the container user could not then write. `.dockerignore` excludes it so a module in the builder's working tree can never ship inside an image. Also corrects the route-manifest generator's list of filesystem-conditional mounts, which never picked up `/modules` when PR 7 added it. Comment only; the generator filters on an allowlist, so its behaviour was already right. Verified against a real container, not just a parsed compose file: image carries an empty node-owned /app/modules despite a module in the build context; a module on the bind mount loads, mounts, replays and reaches `started`; `/api/v1/public/modules` lists it; the chunk serves from the entry's directory only (server source and module.json 404) with `no-cache`; the injected tag follows core's bundle; and in Chrome the page renders on first paint inside core's PublicLayout with its nav row interleaved into core's public nav, under enforced `script-src 'self'` with zero CSP reports and no console errors. Removing the directory by hand reconciles the row to `startup_failed`/`require` and leaves core healthy with no injection. 933 server + 160 client tests pass, manifest unchanged at 230 routes, swagger regenerates byte-identical. Co-Authored-By: Claude <noreply@anthropic.com>
3.0 KiB
Installed modules
This directory is bind-mounted into the container at /app/modules (see
docker-compose.yml). It is where installed modules live — the game-specific
routes, tables, screens and nav that are not part of core. Design of record:
docs/website/MODULE_SYSTEM.md; the
normative contract module authors build against is
docs/website/MODULE_API.md.
Core ships no module. This directory is empty in a fresh checkout, and the
site runs cleanly that way — an empty modules/ is the normal state for bare
core, not a misconfiguration. Everything below is ignored by git except this
README, which exists so the directory itself is tracked: docker-compose.yml
bind-mounts it, and Docker recreates a missing bind-mount source as a
root-owned directory the container user cannot write.
Layout
One directory per module, named for its id, each holding a prebuilt bundle:
modules/
uo/
module.json # the manifest the loader reads
server/index.js # registers routes, streams, hooks
server/db/schema.sql # tables, replayed every boot
server/db/purge.sql # only ever run by an explicit purge
client/dist/entry.js # prebuilt ESM chunk, served at /modules/uo/
Nothing here is compiled by the operator. A module arrives already built — that is the whole point of the design. There is no install step that runs a bundler, and none that needs one.
Installing a module
Two supported paths, both writing the same installed_modules row:
- The admin panel downloads the bundle from the module's release, verifies it
against its
sha256, and unpacks it here. - By hand, for a compose-managed host: unpack the bundle into a directory
named for the module id, e.g.
tar -xf uo-1.0.0.tgz -C ./modules.
Either way, adding or removing a module takes a restart. The loader scans
this directory synchronously at startup (MODULE_API.md §4.1); nothing placed
here is picked up by a running server.
docker compose restart app
On boot each module is validated, mounted, its schema fragment replayed and its
onBoot hook run — reaching started, or startup_failed with the stage and
reason recorded. A module that fails to start does not stop the site: core, and
every other module, carry on without it.
Uninstalling
Removing a directory and restarting is enough to stop a module serving. Note that
this is not the same as an uninstall through the admin panel, which also marks
the row disabled — a directory that simply vanishes leaves a row claiming to be
enabled, which the loader records as startup_failed.
A module's tables and data are retained in both cases. Dropping them is a separate, explicit, destructive purge; it is never bundled into an uninstall.
Ownership
The container runs as uid 1000 (node) and the admin panel writes here, so the
app must be able to write this directory. It is created by your checkout, with
your ownership. If they differ:
chown -R 1000:1000 modules