The entry point becomes real: five mount prefixes, the admin.users.detail extension slot, the shard push catalog, the town-crier announce leg and both lifecycle hooks. module.json declares all of it and the loader checks the declaration against what register() actually registers, in both directions. The URLs are byte-identical to the ones core served before the extraction. That is the whole point of moving the code and not the paths: the shipped Android app calls POST /api/v1/admin/shard/kick and the Discord bot reads /api/v1/public/shard/*, and neither knows a module answers now. Require order is load-bearing and the requires are inside register() because of it. Every ported file reaches core through ./core, whose members resolve ctx when called -- but a router does `const express = core.express` at ITS file scope, which runs the moment it is required. Hoisting these to the top of the file breaks the module with an error about ctx being missing, from a file that never mentions it. boot.js takes the eight UO call sites out of core's server.js. One behavioural change, deliberate: uoLinkSocket.start() and the sidecar health probe used to run AFTER the listener bound and now run before it, because onBoot does. start() returns as soon as the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is diagnostics; being up is not a precondition for serving a page. router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit, core's factory. The policy is the module's -- only the module knows what its endpoints cost -- and the plumbing is core's, so there is one express-rate-limit in the process and one place a breach is logged. Co-Authored-By: Claude <noreply@anthropic.com>
module-uo — the Ultima Online module for Runic Gateway
The Runic Gateway website is becoming game-agnostic: core keeps accounts, sessions, the wiki, posts, branding, theming and the admin panel, and everything that knows what a shard is moves out into an installable module. This repo is that module — the first one, and the reference for every module that follows.
RunicGateway/website (core — game-agnostic)
│ loads modules at boot, synchronously, from the filesystem
▼
┌───────────────────────────────────────────┐
│ module-uo (>>> HERE <<<) │
│ shard status · spawn atlas · marketplace │
│ governors · cliloc · town crier · uo-link│
└───────────────────────────────────────────┘
│ server half: routers, models, schema fragment
│ client half: prebuilt ESM chunk, SPA routes + nav
▼
the shard bridge (RunicGateway/link → RunicGateway/servuo-plugins)
The module's id is uo — that is what appears in module.json, in the installed_modules
table, in the modules/<id>/ path on disk and in the URL segment (/uo/*, /admin/uo/*,
/player/uo/*). Module-uo is the repository; module-uo is the module and its release artifact.
Status: the bundle skeleton exists; the extraction has started
The design of record is
website/MODULE_SYSTEM.md
and the normative contract is
website/MODULE_API.md
in the docs repo — read them before opening a PR here. Where the two differ, the contract wins.
| Phase | Where it happens | State |
|---|---|---|
0 — CI trigger fix, cut website edge, bootstrap this repo |
website, here |
✅ done |
1 — module API contract (docs/website/MODULE_API.md) + the atlas spike |
docs, website |
✅ done |
2 — core scaffolding: loader, installed_modules, registries, client registry |
website |
✅ done |
| 3 — extract the UO half of the site into this repo | website, here |
🟡 in progress |
| 4 — delivery: the admin Modules screen + the Docker path | website |
⬜ |
Phase 3 moves the UO half of website/ here in ten slices (MODULE_SYSTEM.md §2.7.1), server-first
and then client. Each slice is one PR here that adds, and one PR in website that deletes — this one
merging first, so website's edge branch serves the feature from core right up to the moment core
drops it.
Slice 0 is the bundle skeleton, and it registers nothing on purpose. What it proves is the
delivery path itself: core discovers the module, validates module.json, calls register(), serves
the client chunk, injects it, and reports the module started — and the chunk resolves React, the
renderer and the router from core's window.__rg rather than bundling its own. Every slice after
this one adds registrations to server/index.js and client/src/entry.jsx.
Working on it
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
The two check:* scripts are the contract's acceptance criteria rather than this module's own tests:
no import may escape the module root (MODULE_API.md §5.1), and no bare specifier may survive into
the built chunk (§3.6). The matching failure — a shared dependency being bundled — fails the build
itself, from a guard inside vite.config.js.
Running it against a real core means checking this repo out as website/modules/uo, building the
client half, and booting core. The four-step browser smoke in MODULE_API.md §7.7 is the only thing
that proves the client half works: its real failure modes are timing and module resolution, and
neither has a shape a DOM-less test runner can see.
What it contains
One repo, one bundle: the server half and the client half live side by side and version together, so a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
module.json ✅ id, version, coreApi range, mounts, extensions
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
server/test/ ✅ node --test, with a fake ctx standing in for core
server/ routers, controllers, models, utils
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
server/db/purge.sql destructive; only ever run by an explicit purge
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
client/src/ route components, nav registrations, feature provider
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
Release artifact: module-uo-<version>.tar.gz, plus a manifest carrying its sha256.
How it reaches an operator
An operator never builds anything. Installing a module is the WordPress-plugin experience: an
admin-panel action, or a directory mounted into the Docker container — never a build step, because
production runs a prebuilt, pull-only image. That constraint is why the client half ships as a
prebuilt ESM chunk that resolves React from a window.__rg global rather than an import map (an
import map must be inline, and the site's CSP is script-src 'self').
The installer is not the delivery path. It deploys the shard side — the plugin overlay and the uo-link sidecar — and never contacts the website. Module delivery is website-side only.
Compatibility
module.json declares a coreApi semver range, checked at boot against core's MODULE_API_VERSION.
A mismatch fails loudly — the module is marked startup_failed and the site comes up without it,
rather than mis-loading. This is a separate number from PROTOCOL_VERSION, which versions the shard
wire protocol and says nothing about a website module.
A module that fails to load must never take the site down.
Related repos
| Repo | What |
|---|---|
this — RunicGateway/Module-uo |
The UO module: the game-specific half of the website. |
| RunicGateway/website | Core — the site, admin panel and API that loads this module. |
| RunicGateway/link | The uo-link sidecar — the network-facing half of the game bridge this module talks to. |
| RunicGateway/servuo-plugins | The C# ServUO plugin that feeds the sidecar. |
| RunicGateway/installer | Deploys the shard side. Not the module delivery path. |
| RunicGateway/docs | All project documentation, including the module system design and this module's docs under modules/uo/. |
Contributing
See CONTRIBUTING.md. Contributions are welcome, AI assistance must be disclosed, and security problems go to SECURITY.md rather than a public issue.
License
GNU General Public License v3.0 or later — see LICENSE.md.