docs(website): correct the library build, and record slice 0

Slice 0 built the module bundle skeleton against the contract and found that
§3.6 does not work as written. It shows Rollup's `external` alongside the
resolve aliases, and the two 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 emits 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.

§3.6 is corrected: alias only, `external` empty, with the alias table shown in
full because the anchoring is what stops `react` also capturing
`react/jsx-runtime`. What `external` was guarding -- a missed alias welding a
second React into the chunk -- moves to a resolution-time build plugin, and two
properties of that plugin are now contract because both were wrong first: it
hooks `transform` rather than `load` (first-wins, so it never ran), and its
forbidden-package list is stated rather than derived from the alias list
(deriving it means deleting an alias also deletes the guard).

Also records slice 0's outcome in §2.7.1, including the finding that generalises
past this repo: the boundary check failed on its own documentation, because the
comments describing what it catches are written in the syntax it catches. Slice
8's §5.2 grep has the same problem waiting for it. And the loader skips a
SYMLINKED module directory silently, which is the first thing to check when a
module fails to appear locally.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 01:33:38 -05:00
committed by Claude
parent 749233d378
commit 7548c20820
2 changed files with 80 additions and 13 deletions

View File

@@ -663,27 +663,33 @@ owns the paths it calls, which is correct: it owns the routes at the other end.
### 3.6 Vite library-mode build
The module's `vite.config.js`, and the four externals are the whole contract:
The module's `vite.config.js`, and the shared-dependency aliases are the whole contract:
```js
export default defineConfig({
plugins: [react()],
plugins: [react(), assertSharedNotBundled()],
resolve: {
// ARRAY form with ANCHORED regexes. The object form does PREFIX matching, so
// a `react` key silently also rewrites `react/jsx-runtime`.
alias: [
{ find: /^react$/, replacement: shim('react') },
{ find: /^react\/jsx-runtime$/, replacement: shim('jsx-runtime') },
{ find: /^react\/jsx-dev-runtime$/, replacement: shim('jsx-runtime') },
{ find: /^react-dom$/, replacement: shim('react-dom') },
{ find: /^react-dom\/client$/, replacement: shim('react-dom') },
{ find: /^react-router-dom$/, replacement: shim('react-router-dom') },
],
},
build: {
lib: { entry: 'src/entry.jsx', formats: ['es'], fileName: () => 'entry.js' },
outDir: 'dist',
modulePreload: { polyfill: false }, // same reason as core: no inline bootstrap under CSP
rollupOptions: {
external: ['react', 'react-dom', 'react-dom/client', 'react-router-dom'],
output: { paths: { /* rewritten to window.__rg by the shim below */ } },
},
rollupOptions: { external: [] }, // deliberately empty — see below
},
})
```
Rollup's `external` alone emits bare `import 'react'` specifiers, which the browser cannot resolve
without an import map — and CSP forbids the inline `<script type="importmap">` that would provide
one (`MODULE_SYSTEM.md` §1.14). The module therefore ships a two-line shim module that re-exports
from the global, and aliases the four externals to it:
Each aliased specifier resolves to a two-line shim that re-exports from the global:
```js
// src/shim/react.js
@@ -691,9 +697,35 @@ export default window.__rg.react
export const { useState, useEffect, useMemo, useCallback, useRef, createElement, Fragment } = window.__rg.react
```
**This is the highest-risk mechanical detail in the whole plan and it is exactly what the Phase 1
spike exists to prove.** If it does not hold, §2.6 of the design of record is wrong and the client
half needs rethinking before Phase 2 builds on it.
**Corrected 2026-08-11, Phase 3 slice 0: `external` and the aliases do not compose, and this section
used to show both.** Rollup asks `external` *before* Vite's alias resolver runs, so a specifier
listed there is marked external and never aliased. The chunk then emits bare `import 'react'`
specifiers, which the browser cannot resolve without an import map — and CSP forbids the inline
`<script type="importmap">` that would provide one (`MODULE_SYSTEM.md` §1.14). Slice 0 shipped with
both, built cleanly, and emitted exactly that chunk. So: **alias only, and `external` empty.**
What `external` was there to guard is real — an alias that misses means a second React welded into
the chunk, which loads fine and then throws about an invalid hook call somewhere unrelated. That is
guarded instead by a **resolution-time plugin that fails the build if a shared dependency resolves
into `node_modules`**. Two things about it are contract, because both were wrong first:
- **It hooks `transform`, not `load`.** `load` is first-wins, so an earlier plugin returning the
module's contents means the guard is never called for it. Written against `load` it sat in the
build doing nothing, and a deliberately-broken alias produced a 24 kB chunk with react-router
bundled and a green build.
- **Its forbidden-package list is stated, not derived from the alias list.** Deriving it "so the two
cannot disagree" means deleting an alias also deletes the guard against what that alias prevented
— which is exactly when it is needed. What may not be bundled is a fact about `window.__rg`; a
test asserts the aliases stay inside it.
The module's own boundary checks are `scripts/checkImports.js` (§5.1) and `scripts/checkExternals.js`,
which asks the **built chunk** whether any bare specifier survived. That question cannot be asked of
source: `import { useState } from 'react'` is correct in every file, and which React it becomes is
decided here.
**This is the highest-risk mechanical detail in the whole plan.** The Phase 1 spike proved the
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`,
by checking each imported binding is **identity-equal** to the one core published.
---

View File

@@ -801,6 +801,41 @@ rather than core adding an eighth member: the kit is closed on purpose, and a fu
props and no layout cannot drift the way a component can. The same is not true of `PublicLayout`,
which is why that one is in the kit.
#### Slice 0 — the bundle skeleton (Module-uo#2, 2026-08-11)
`module.json`, an entry point taking `(ctx, api)`, the Vite library build, four shims, and both
boundary checks. It **registers nothing**, and core is untouched — what it proves is the delivery
path itself, before a single UO file moves into it. 29 server tests and 9 client tests, both new.
Verified against a real core rather than asserted: the module loads, mounts its zero routes, reaches
`started` and is published by `/api/v1/public/modules`; its chunk serves from the entry's directory
with `Cache-Control: no-cache` while its server source, `module.json` and `package.json` all 404;
and in Chrome, under the enforced `script-src 'self'`, the chunk reports every shared dependency
**identity-equal** to core's, with zero CSP reports.
**Three findings, each of which had produced a green build that was wrong.** The first amends the
contract and is written up at [API §3.6](MODULE_API.md#36-vite-library-mode-build): `external` and
the aliases do not compose, so `external` is now empty and a resolution-time build guard replaces
it. The second is that guard's own two failures — hooking `load` (first-wins, so it never ran) and
deriving its forbidden list from the alias list (so deleting an alias deleted the guard). Both were
found by breaking an alias on purpose and checking the build actually went red, which is the only
way a guard's absence is visible.
The third is about the boundary check itself and generalises past this repo. **`checkImports.js`
failed on its own documentation** — the comment naming `require("../../etc/passwd")` as an example
of what to catch, and the entry point's comment explaining why a module must never
`require('express')`. A check that cannot survive being described is one people stop writing
comments around, so it strips comments and template literals with a character walk rather than a
regexp (a URL in a string contains a comment opener; a comment contains quotes) and carries its own
test suite. The same applies to slice 8's §5.2 grep, which will be read by a codebase that discusses
modules constantly.
**One thing to know before running a module locally: the loader skips a *symlinked* module directory
silently.** `readdirSync(…, { withFileTypes: true }).filter(e => e.isDirectory())` reports a Windows
junction as a symlink, so a module linked rather than copied into `modules/` is simply not there,
with nothing logged. Not a defect for a real install — `modules/` is a bind mount of real
directories (§2.5) — but it is the first thing to check when a module fails to appear.
**Phase 4 — Delivery.** The admin-panel Modules screen (install, enable, disable, retry, purge,
`startup_failed` with its recorded reason) and the Docker-environment path from §2.5. Deliberately
last, so loader, packaging, schema and chunk-loading problems are not all being debugged at once.