feat(module): the bundle skeleton (phase 3, slice 0) #2

Merged
whitlocktech merged 2 commits from feature/module-bundle-skeleton into main 2026-08-11 06:43:39 +00:00
Member

The first real module. It registers nothing, deliberately — what slice 0 proves is the delivery path itself, end to end, before a single UO file moves into it. Plan: MODULE_SYSTEM.md §2.7.1 (docs#135); contract: MODULE_API.md.

What landed

Server halfmodule.json; an entry point taking (ctx, api) that registers nothing; a test suite built on a fake ctx (the contract holding is what makes the module testable without core at all); and scripts/checkImports.js, the §5.1 boundary check.

Client half — the Vite library build; four shims re-exporting react, react-dom/client, react-router-dom and react/jsx-runtime from window.__rg; an entry that verifies each is identity-equal to core's copy; and scripts/checkExternals.js.

29 server tests, 9 client tests, both new. CI's conditional "planning phase" gates are now armed and the two check:* scripts run in it.

Verified against a real core

Copied in as website/modules/uo on edge, core booted against the dev database:

  • module loads, mounts its zero routes, runs to started, and /api/v1/public/modules publishes it
  • chunk serves from the entry's directory as application/javascript with Cache-Control: no-cache; the module's server/index.js, module.json and server/package.json all 404, as does /modules/nope/entry.js and a .. traversal
  • htmlShell injects <script type="module" src="/modules/uo/entry.js">
  • in Chrome, under the enforced script-src 'self': [module-uo] loaded against core API 1.0.0; shared dependencies OK — zero CSP reports, no console errors, site renders normally

Core's working tree is untouched by this PR, so its manifest and OpenAPI spec are unchanged by construction.

Three findings, each of which had produced a green build that was wrong

1. external and the aliases do not compose. §3.6 shows both. Rollup asks external before Vite's alias resolver runs, so a specifier in both is marked external and never aliased — the chunk then ships bare import "react", which no browser can resolve without an import map, and CSP forbids the inline script an import map has to be. It built cleanly and emitted exactly that; checkExternals caught it. Now: alias only, external empty, and vite.config.js grows a resolution-time guard that fails the build if a shared dependency resolves into node_modules. That is a better net than the fingerprint-grep alternative, which has to guess at strings that survive minification.

2. That guard was wrong twice before it worked. Written against Rollup's load hook it never ran — load is first-wins and an earlier plugin had already claimed the module — so a deliberately-broken alias produced a 24 kB chunk with react-router welded into it and a green build. Then its forbidden-package list was derived from the alias list "so the two cannot disagree", which meant deleting an alias also deleted the guard against what that alias prevented. It states the contract independently now, and a test asserts the aliases stay inside it. Both failure modes were found by breaking an alias on purpose and checking the build actually goes red.

3. checkImports failed on its own documentation. The comment naming require("../../etc/passwd") as an example of what to catch, and index.js explaining why the module must never require("express"). A boundary check that cannot survive being described is one people stop writing comments around. It now strips comments and template literals with a character walk rather than a regexp — a URL in a string contains a comment opener, and a comment contains quotes — and it has its own 13-test suite, because a check never shown to fail is a check nobody knows the state of.

Two smaller notes

  • The loader skips a symlinked module directory silently. readdirSync(…, {withFileTypes:true}).filter(e => e.isDirectory()) reports a Windows junction as a symlink, so the first smoke run found no module and said nothing. Not a defect for a real install — modules/ is a bind mount of real directories — but it cost twenty minutes here, so it is written down. No core change proposed.
  • server/ ships zero dependencies: everything arrives on ctx. express and express-validator are devDependencies only, because test/_fakes.js builds a real express router — a fake Router would test the fake. checkImports allows devDependencies in test/ and scripts/ and forbids any bare specifier in shipped code.

  • AI-assisted (Claude Code / Claude Opus 5)
The first real module. It **registers nothing, deliberately** — what slice 0 proves is the delivery path itself, end to end, before a single UO file moves into it. Plan: [`MODULE_SYSTEM.md` §2.7.1](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/135) (docs#135); contract: `MODULE_API.md`. ## What landed **Server half** — `module.json`; an entry point taking `(ctx, api)` that registers nothing; a test suite built on a fake `ctx` (the contract holding is what makes the module testable without core at all); and `scripts/checkImports.js`, the §5.1 boundary check. **Client half** — the Vite library build; four shims re-exporting `react`, `react-dom/client`, `react-router-dom` and `react/jsx-runtime` from `window.__rg`; an entry that verifies each is **identity-equal** to core's copy; and `scripts/checkExternals.js`. 29 server tests, 9 client tests, both new. CI's conditional "planning phase" gates are now armed and the two `check:*` scripts run in it. ## Verified against a real core Copied in as `website/modules/uo` on `edge`, core booted against the dev database: - module loads, mounts its zero routes, runs to `started`, and `/api/v1/public/modules` publishes it - chunk serves from the entry's directory as `application/javascript` with `Cache-Control: no-cache`; the module's `server/index.js`, `module.json` and `server/package.json` all **404**, as does `/modules/nope/entry.js` and a `..` traversal - `htmlShell` injects `<script type="module" src="/modules/uo/entry.js">` - in Chrome, under the **enforced** `script-src 'self'`: `[module-uo] loaded against core API 1.0.0; shared dependencies OK` — zero CSP reports, no console errors, site renders normally Core's working tree is untouched by this PR, so its manifest and OpenAPI spec are unchanged by construction. ## Three findings, each of which had produced a green build that was wrong **1. `external` and the aliases do not compose.** §3.6 shows both. Rollup asks `external` *before* Vite's alias resolver runs, so a specifier in both is marked external and never aliased — the chunk then ships bare `import "react"`, which no browser can resolve without an import map, and CSP forbids the inline script an import map has to be. It built cleanly and emitted exactly that; `checkExternals` caught it. Now: **alias only, `external` empty**, and `vite.config.js` grows a resolution-time guard that fails the build if a shared dependency resolves into `node_modules`. That is a better net than the fingerprint-grep alternative, which has to guess at strings that survive minification. **2. That guard was wrong twice before it worked.** Written against Rollup's `load` hook it never ran — `load` is **first-wins** and an earlier plugin had already claimed the module — so a deliberately-broken alias produced a 24 kB chunk with react-router welded into it and a green build. Then its forbidden-package list was *derived from the alias list* "so the two cannot disagree", which meant deleting an alias also deleted the guard against what that alias prevented. It states the contract independently now, and a test asserts the aliases stay inside it. Both failure modes were found by breaking an alias on purpose and checking the build actually goes red. **3. `checkImports` failed on its own documentation.** The comment naming `require("../../etc/passwd")` as an example of what to catch, and `index.js` explaining why the module must never `require("express")`. A boundary check that cannot survive being described is one people stop writing comments around. It now strips comments and template literals with a character walk rather than a regexp — a URL in a string contains a comment opener, and a comment contains quotes — and it has its own 13-test suite, because a check never shown to fail is a check nobody knows the state of. ## Two smaller notes - **The loader skips a symlinked module directory silently.** `readdirSync(…, {withFileTypes:true}).filter(e => e.isDirectory())` reports a Windows junction as a symlink, so the first smoke run found no module and said nothing. Not a defect for a real install — `modules/` is a bind mount of real directories — but it cost twenty minutes here, so it is written down. No core change proposed. - `server/` ships **zero dependencies**: everything arrives on `ctx`. `express` and `express-validator` are devDependencies only, because `test/_fakes.js` builds a *real* express router — a fake Router would test the fake. `checkImports` allows devDependencies in `test/` and `scripts/` and forbids any bare specifier in shipped code. --- - [x] AI-assisted (Claude Code / Claude Opus 5)
wtclaude added 1 commit 2026-08-11 06:32:19 +00:00
feat(module): the bundle skeleton (phase 3, slice 0)
Some checks failed
PR Checks / server-tests (pull_request) Failing after 10s
PR Checks / client-build (pull_request) Successful in 8m45s
5d7668d5ea
The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.

Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.

Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.

Three findings, each of which had produced a green build that was wrong.

MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier in both is marked external and never aliased -- the chunk then ships
bare `import "react"`, which no browser can resolve without an import map, and
CSP forbids one. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.

That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.

checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.

Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 1 commit 2026-08-11 06:37:03 +00:00
fix(module): ask Node whether a specifier is a builtin
All checks were successful
PR Checks / server-tests (pull_request) Successful in 12s
PR Checks / client-build (pull_request) Successful in 15s
47809854ef
The first CI run failed on `node:test`, in every test file, reported as the
module boundary being broken. It was not: `builtinModules` omits `test` on
Node 20 (CI) and includes it on Node 24 (local), so a list rebuilt from it
disagrees with itself across versions.

Use `isBuiltin`, which is Node's own answer, and treat the `node:` prefix as
sufficient on its own -- a prefixed specifier can never resolve to a package,
whatever the running version enumerates. Test covers both forms.

Also corrects this file's header: the client half's guard is no longer
`external` (it never worked), it is the Vite build's resolution-time check plus
checkExternals.js on the built chunk.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit a183634f4e into main 2026-08-11 06:43:39 +00:00
whitlocktech deleted branch feature/module-bundle-skeleton 2026-08-11 06:43:40 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#2
No description provided.