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>
137 lines
6.9 KiB
JavaScript
137 lines
6.9 KiB
JavaScript
// ── The client half's library build ────────────────────────────────────────
|
|
//
|
|
// Produces `dist/entry.js`: one prebuilt ES module that core injects as a
|
|
// same-origin `<script type="module" src>` before `</body>`. The operator never
|
|
// builds anything (MODULE_SYSTEM.md §1.14), so this config is not a developer
|
|
// convenience — it is how the artifact that ships is made, and CI runs it.
|
|
//
|
|
// The normative contract is MODULE_API.md §3.6. Three mechanical details in here
|
|
// were each found the hard way and are worth reading before changing anything.
|
|
//
|
|
// **1. `resolve.alias` uses the ARRAY form with anchored regexes.** Vite's object
|
|
// form does PREFIX matching, so a `react` key also rewrites `react/jsx-runtime`
|
|
// — silently, to the wrong shim, and the chunk then fails at its first element
|
|
// with a message about `jsx` not being a function. `^react$` and
|
|
// `^react/jsx-runtime$` cannot collide.
|
|
//
|
|
// **2. The aliases replace `external`; they do not accompany it.** §3.6 shows
|
|
// both, and they do not compose: Rollup asks `external` BEFORE Vite's alias
|
|
// resolver runs, so a specifier listed there is marked external and never
|
|
// aliased. The chunk then ships bare `import 'react'` specifiers, which the
|
|
// browser cannot resolve without an import map — and core's `script-src 'self'`
|
|
// forbids the inline script an import map has to be. (`output.globals` would
|
|
// have covered iife/umd and does nothing for an ES module.) Slice 0 shipped with
|
|
// both, built cleanly, and emitted exactly that chunk; `scripts/checkExternals.js`
|
|
// is what caught it. So: alias only, and nothing in `external`.
|
|
//
|
|
// **3. What `external` was there to guard is guarded by `assertSharedNotBundled`
|
|
// below.** The risk it was covering 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. A resolution-time assertion catches
|
|
// that precisely, at build time, instead of by looking for fingerprints in
|
|
// minified output afterwards.
|
|
|
|
import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const shim = (name) => fileURLToPath(new URL(`./src/shim/${name}.js`, import.meta.url))
|
|
|
|
// The shared dependencies, in one place: what a module must never bundle, and
|
|
// the shim it is aliased to instead. Adding to this list means adding to
|
|
// `window.__rg` in core, which is a MODULE_API minor bump — not a decision this
|
|
// file can make on its own.
|
|
export const SHARED = [
|
|
{ specifier: 'react', shim: 'react' },
|
|
{ specifier: 'react/jsx-runtime', shim: 'jsx-runtime' },
|
|
// A production `vite build` emits the non-dev runtime, but the plugin picks
|
|
// per mode and a `--mode development` build would reach for this one. Aliased
|
|
// rather than left to chance: the shim re-exports `jsxDEV` too.
|
|
{ specifier: 'react/jsx-dev-runtime', shim: 'jsx-runtime' },
|
|
{ specifier: 'react-dom', shim: 'react-dom' },
|
|
{ specifier: 'react-dom/client', shim: 'react-dom' },
|
|
{ specifier: 'react-router-dom', shim: 'react-router-dom' },
|
|
]
|
|
|
|
// The packages whose real source must never end up in the chunk.
|
|
//
|
|
// Stated independently of SHARED, and that is the whole point — an earlier
|
|
// version derived this from the alias list "so the two cannot disagree", which
|
|
// meant deleting an alias also deleted the guard against the thing that alias
|
|
// prevented. The guard then reported nothing on a chunk with react-router welded
|
|
// into it. What may not be bundled is a fact about core's `window.__rg`, not a
|
|
// function of what this config happens to alias; `test/build.test.js` asserts
|
|
// every SHARED specifier is covered here, which is the direction the dependency
|
|
// belongs in.
|
|
//
|
|
// `react-router` and `@remix-run/router` are react-router-dom's own internals.
|
|
// They cannot appear while the alias holds — nothing resolves through to them —
|
|
// so naming them costs nothing and closes the case where a module imports one
|
|
// directly and gets a second navigation context in a page that otherwise works.
|
|
export const SHARED_PACKAGES = ['react', 'react-dom', 'react-router-dom', 'react-router', '@remix-run/router']
|
|
|
|
/**
|
|
* Fail the build if a shared dependency's real source is about to be bundled.
|
|
*
|
|
* This is the safety net, and it is a resolution-time one on purpose. The
|
|
* alternative — grepping the built chunk for a fingerprint — has to guess at
|
|
* strings that survive minification, and guesses at that are how a check ends up
|
|
* passing on a chunk that carries a second React. Here there is nothing to
|
|
* guess: if a module id resolved into `node_modules/react`, an alias missed, and
|
|
* the alias that missed is named in the error.
|
|
*
|
|
* It hooks `transform` rather than `load`, and that is not interchangeable:
|
|
* `load` is FIRST-WINS, so an earlier plugin returning the module's contents
|
|
* means this hook is never called for it. Written against `load` this guard sat
|
|
* in the build doing nothing, and a deliberately-broken alias produced a 24 kB
|
|
* chunk with react-router welded into it and a green build — which is the exact
|
|
* failure it exists to prevent. `transform` runs for every module, every time.
|
|
*/
|
|
function assertSharedNotBundled() {
|
|
return {
|
|
name: 'module-uo:assert-shared-not-bundled',
|
|
enforce: 'post',
|
|
transform(code, id) {
|
|
const normalised = id.split('\\').join('/')
|
|
const hit = SHARED_PACKAGES.find((pkg) => normalised.includes(`/node_modules/${pkg}/`))
|
|
if (hit) {
|
|
this.error(
|
|
`"${hit}" resolved into node_modules (${normalised}). It must be aliased to a shim that ` +
|
|
're-exports from window.__rg — there is exactly one React in the page and core owns it ' +
|
|
'(MODULE_API.md §3.2, §3.6). Check resolve.alias in vite.config.js.',
|
|
)
|
|
}
|
|
return null
|
|
},
|
|
}
|
|
}
|
|
|
|
export default defineConfig({
|
|
plugins: [react(), assertSharedNotBundled()],
|
|
resolve: {
|
|
alias: SHARED.map(({ specifier, shim: name }) => ({
|
|
find: new RegExp(`^${specifier.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}$`),
|
|
replacement: shim(name),
|
|
})),
|
|
},
|
|
build: {
|
|
lib: {
|
|
entry: fileURLToPath(new URL('./src/entry.jsx', import.meta.url)),
|
|
formats: ['es'],
|
|
// Unhashed, deliberately: `module.json` names this file, and a hashed name
|
|
// would have to be discovered at runtime. Core answers the cache question
|
|
// instead, serving it `no-cache` so a revalidation catches a new build
|
|
// (MODULE_API.md §3.1).
|
|
fileName: () => 'entry.js',
|
|
},
|
|
outDir: 'dist',
|
|
emptyOutDir: true,
|
|
// No inline bootstrap, for the same reason core disables it: an inline
|
|
// script is refused under `script-src 'self'`, and the failure is a chunk
|
|
// that never evaluates with a CSP report as the only clue.
|
|
modulePreload: { polyfill: false },
|
|
// `rollupOptions.external` is deliberately EMPTY — see note 2 at the top.
|
|
rollupOptions: { external: [] },
|
|
},
|
|
})
|