feat(module): the bundle skeleton (phase 3, slice 0)
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>
This commit is contained in:
82
client/src/entry.jsx
Normal file
82
client/src/entry.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// ── module-uo's client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// This file is the whole of the chunk's top-level behaviour: core injects
|
||||
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
|
||||
// registers what it has, and core renders it. The normative contract is
|
||||
// MODULE_API.md §3.3.
|
||||
//
|
||||
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||
// is published — and before DOMContentLoaded, which is what core waits for
|
||||
// before its first render. There is no subscription and no late registration: a
|
||||
// module that registered asynchronously would register after the routes had been
|
||||
// read, and the symptom is a page that redirects home with nothing logged. That
|
||||
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
||||
// can see it, which is why §7.7's browser smoke exists.
|
||||
//
|
||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
|
||||
// on purpose. What it proves is the delivery path itself, and the imports below
|
||||
// are how it proves the hardest part of it.
|
||||
|
||||
// These four specifiers are the whole shared-dependency contract, written the
|
||||
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
|
||||
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
|
||||
// React, core's renderer and core's router, and no second copy of any of them.
|
||||
// A module author writes these imports exactly as they would in any app.
|
||||
//
|
||||
// They are here in slice 0 rather than arriving with the first page because an
|
||||
// unexercised alias is an unproven one: with nothing importing `react`, the
|
||||
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
|
||||
// this whole slice exists to prove has not been touched.
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const rg = window.__rg
|
||||
|
||||
// A module that cannot see the global is a module core did not load — which
|
||||
// means the injection or the ordering broke, not the module. Say so, once,
|
||||
// rather than throwing a TypeError about a property of undefined three frames
|
||||
// deep in a component.
|
||||
if (!rg) {
|
||||
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
|
||||
} else {
|
||||
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
|
||||
// easiest of the four to get wrong and the hardest to notice: Vite's
|
||||
// object-form alias prefix-matches, so a `react` key silently captures
|
||||
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
|
||||
// function` in whichever component happens to render first.
|
||||
const probe = <span>module-uo</span>
|
||||
|
||||
// The self-check: are the bindings this chunk imported the SAME objects core
|
||||
// published? Identity is the only question worth asking. A bundled second
|
||||
// React satisfies every type check, renders its first element happily, and
|
||||
// then throws about an invalid hook call somewhere unrelated.
|
||||
const shared = [
|
||||
['react', createElement === rg.react.createElement],
|
||||
['react/jsx-runtime', isValidElement(probe)],
|
||||
['react-dom/client', createRoot === rg.reactDom.createRoot],
|
||||
['react-router-dom', Link === rg.router.Link],
|
||||
]
|
||||
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
|
||||
|
||||
if (bundled.length) {
|
||||
console.error(
|
||||
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
|
||||
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
} else {
|
||||
// Registrations land here, slice by slice:
|
||||
//
|
||||
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
|
||||
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
|
||||
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
|
||||
//
|
||||
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
|
||||
// before this file is ever served, so there is nothing to re-check here. It
|
||||
// is logged because a mismatch between the core that validated the manifest
|
||||
// and the core that published this global would otherwise be invisible from
|
||||
// the browser, which is where the client half actually fails.
|
||||
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
|
||||
}
|
||||
}
|
||||
14
client/src/shim/jsx-runtime.js
Normal file
14
client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// `react/jsx-runtime`, from core.
|
||||
//
|
||||
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
|
||||
// under the automatic runtime, which is the default the tooling assumes. Those
|
||||
// have to resolve to CORE's React like every other import — a second jsx runtime
|
||||
// bound to a second React is the same one-React violation as bundling `react`
|
||||
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||
// a component that looks fine.
|
||||
|
||||
const jsxRuntime = window.__rg.jsxRuntime
|
||||
|
||||
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||
|
||||
export default jsxRuntime.default ?? jsxRuntime
|
||||
12
client/src/shim/react-dom.js
vendored
Normal file
12
client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// `react-dom/client`, from core.
|
||||
//
|
||||
// A module never calls `createRoot` — core owns the root and the module renders
|
||||
// inside it. This exists because a transitive import can still reach for
|
||||
// react-dom, and one that resolved to a bundled copy would put a second
|
||||
// renderer in the page.
|
||||
|
||||
const reactDom = window.__rg.reactDom
|
||||
|
||||
export default reactDom.default ?? reactDom
|
||||
|
||||
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||
30
client/src/shim/react-router-dom.js
vendored
Normal file
30
client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// `react-router-dom`, from core.
|
||||
//
|
||||
// The sharpest of the four, because router state is not just a library — it is
|
||||
// one live navigation context. A module with its own copy would get a router
|
||||
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||
|
||||
const router = window.__rg.router
|
||||
|
||||
export default router.default ?? router
|
||||
|
||||
export const {
|
||||
BrowserRouter,
|
||||
Link,
|
||||
NavLink,
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
createSearchParams,
|
||||
generatePath,
|
||||
matchPath,
|
||||
useLocation,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useResolvedPath,
|
||||
useSearchParams,
|
||||
} = router
|
||||
48
client/src/shim/react.js
vendored
Normal file
48
client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// The shared React, taken from core rather than bundled.
|
||||
//
|
||||
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
|
||||
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
|
||||
// chunk, which the browser cannot resolve without an import map — and an import
|
||||
// map has to be an inline `<script type="importmap">`, which core's
|
||||
// `script-src 'self'` forbids. `output.globals` does not help either; it is
|
||||
// iife/umd only, and this is an ES module. So each shared dependency is aliased
|
||||
// to a two-line module that re-exports from the global core published before any
|
||||
// module chunk evaluated.
|
||||
//
|
||||
// The named re-exports are not decoration: `import { useState } from 'react'`
|
||||
// compiles to a named import, and a module with only a default export would fail
|
||||
// at link time in the browser with a message about the binding, not about this.
|
||||
|
||||
const react = window.__rg.react
|
||||
|
||||
export default react.default ?? react
|
||||
|
||||
export const {
|
||||
Children,
|
||||
Component,
|
||||
Fragment,
|
||||
StrictMode,
|
||||
Suspense,
|
||||
cloneElement,
|
||||
createContext,
|
||||
createElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
lazy,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useDebugValue,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useInsertionEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
useTransition,
|
||||
} = react
|
||||
Reference in New Issue
Block a user