feat(modules): the filesystem module loader (phase 2, PR 2) #129

Merged
whitlocktech merged 1 commits from feat/modules-loader into edge 2026-08-10 21:31:26 +00:00
Member

Phase 2 PR 2 of docs/website/MODULE_SYSTEM.md §2.7. Docs half: RunicGateway/docs#127.

Adds server/src/modules/{loader,semver,version}.js — the synchronous scan of MODULES_DIR, manifest validation, prefix and table-name collision rejection, per-module try/catch across the load path, and the mount into the three tier routers behind the MODULE_API.md §4.5 dispatch guard.

The two decisions the contract left open

1. The load trigger is one explicit call, not a lazy scan (API §7.6). app.js calls modules.load({ public, admin, player }) once; every accessor throws until it has run, because "no modules installed" is a real answer a caller must not be handed by accident — the spike's silent empty list cost one confusing test failure. Scanning at require time was the alternative and was rejected twice over: the loader now needs the tier routers handed to it, which a require-time side effect cannot receive, and a require's position enforces an ordering constraint invisibly.

2. Core's prefix ownership is asked of the live tier routers, via express's own layer.match(), skipping root-mounted (fast_slash) layers — the spike hardcoded ~24 prefixes and the table was already stale when written. Deriving it means the check cannot drift the first time core adds a capability router, and needs no second declaration of the mount table.

One thing found while building it

Mounting is a second pass, after every module is validated. Mounting inside the scan loop puts the first module's layers on the tier router while the second is still being validated, indistinguishable from core's: the second module claiming a taken prefix was told it collided with core, and the module-versus-module check below it was unreachable. Caught by a test that expected the other message.

Scope

registerExtension / registerNotificationStreams / registerAnnounceLeg and onBoot / onShutdown throw not available until phase 2 PR 4/5 rather than no-op — an accepting stub would let a module believe it had registered something and fail silently at the far end. No schema replay (PR 3), no boot dispatch or installed_modules reconcile (PR 5); until then a record's state is in memory only, moved by the one narrow setState(id, state, reason) those two PRs will call.

Verification

  • 842 server tests pass, 28 of them new and all on the failure paths — the property under test throughout is that the failing module fails alone.
  • routes.manifest.json diff is zero lines, 229 routes — the Phase 2 exit criterion. routes.guards.json likewise.
  • swagger-output.json regenerates byte-identical; the new require and call in app.js do not disturb swagger-autogen's static analysis (the §6.1 trap).
  • No module ships on the volume, so nothing an operator or a client can see changes.

  • AI-assisted: written with Claude Code (Opus 5), reviewed before opening.
Phase 2 PR 2 of [`docs/website/MODULE_SYSTEM.md` §2.7](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md). Docs half: RunicGateway/docs#127. Adds `server/src/modules/{loader,semver,version}.js` — the synchronous scan of `MODULES_DIR`, manifest validation, prefix and table-name collision rejection, per-module try/catch across the load path, and the mount into the three tier routers behind the `MODULE_API.md` §4.5 dispatch guard. ## The two decisions the contract left open **1. The load trigger is one explicit call, not a lazy scan** (API §7.6). `app.js` calls `modules.load({ public, admin, player })` once; every accessor throws until it has run, because "no modules installed" is a real answer a caller must not be handed by accident — the spike's silent empty list cost one confusing test failure. Scanning at require time was the alternative and was rejected twice over: the loader now needs the tier routers *handed to it*, which a require-time side effect cannot receive, and a `require`'s position enforces an ordering constraint invisibly. **2. Core's prefix ownership is asked of the live tier routers**, via express's own `layer.match()`, skipping root-mounted (`fast_slash`) layers — the spike hardcoded ~24 prefixes and the table was already stale when written. Deriving it means the check cannot drift the first time core adds a capability router, and needs no second declaration of the mount table. ## One thing found while building it **Mounting is a second pass**, after every module is validated. Mounting inside the scan loop puts the first module's layers on the tier router while the second is still being validated, indistinguishable from core's: the second module claiming a taken prefix was told it collided with *core*, and the module-versus-module check below it was unreachable. Caught by a test that expected the other message. ## Scope `registerExtension` / `registerNotificationStreams` / `registerAnnounceLeg` and `onBoot` / `onShutdown` throw `not available until phase 2 PR 4/5` rather than no-op — an accepting stub would let a module believe it had registered something and fail silently at the far end. No schema replay (PR 3), no boot dispatch or `installed_modules` reconcile (PR 5); until then a record's state is in memory only, moved by the one narrow `setState(id, state, reason)` those two PRs will call. ## Verification - **842 server tests pass**, 28 of them new and all on the failure paths — the property under test throughout is that *the failing module fails alone*. - **`routes.manifest.json` diff is zero lines**, 229 routes — the Phase 2 exit criterion. `routes.guards.json` likewise. - **`swagger-output.json` regenerates byte-identical**; the new require and call in `app.js` do not disturb swagger-autogen's static analysis (the §6.1 trap). - No module ships on the volume, so nothing an operator or a client can see changes. --- - [x] AI-assisted: written with Claude Code (Opus 5), reviewed before opening.
wtclaude added 1 commit 2026-08-10 19:36:49 +00:00
feat(modules): the filesystem module loader
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 10m4s
ec1ca7e794
Phase 2 PR 2 of docs/website/MODULE_SYSTEM.md 2.7. Adds
server/src/modules/{loader,semver,version}.js: the synchronous scan of
MODULES_DIR, manifest validation, prefix and table-name collision
rejection, per-module try/catch and the mount into the three tier
routers behind the MODULE_API.md 4.5 dispatch guard.

Two decisions the contract left open, both now written up there:

- The load trigger is one explicit modules.load(tierRouters) call in
  app.js, not a lazy scan (API 7.6). Accessors throw until it has run,
  because "no modules installed" is a real answer a caller must not be
  handed by accident.
- Whether core owns a prefix is asked of the live tier routers via
  express's own layer.match(), skipping root-mounted layers, rather than
  a hardcoded table -- the spike's was already stale when written
  (API 4.3).

Mounting is a second pass after every module is validated. Doing it
inside the scan loop makes the first module's layers indistinguishable
from core's, so the second module claiming a taken prefix is told it
collided with core and the module-versus-module check is unreachable.

registerExtension/NotificationStreams/AnnounceLeg and onBoot/onShutdown
throw "not available until phase 2 PR 4/5" rather than no-op; an
accepting stub would let a module believe it had registered something.
No schema replay, no boot dispatch, no installed_modules reconcile --
those are PRs 3 and 5, and until PR 5 a record's state is in memory only.

No module ships on the volume, so nothing an operator or client can see
changes: 842 tests pass, routes.manifest.json is unchanged at 229 routes
and swagger-output.json regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 7780fb033b into edge 2026-08-10 21:31:26 +00:00
whitlocktech deleted branch feat/modules-loader 2026-08-10 21:31:27 +00:00
Sign in to join this conversation.
No description provided.