feat(template): a module that builds and loads — Phase 5 slice 1
The kit's `template/`: a complete, minimal Runic Gateway module a reader copies,
renames, and runs before reading a chapter. Slice 0 landed the workflow that runs
it; this is the tree that workflow was written against, so the `template` job
arms itself with no edit to the guard.
Installed into a real core it adds one public page at `/examplegame/status`, a nav
row pointing at it, one API route described in an OpenAPI fragment core merges,
one table created by an idempotent schema fragment and dropped by a purge file,
and both lifecycle hooks. That is deliberately less than a real module does; what
it is complete about is the shape — every seam used once, with the reasoning next
to it.
Four decisions, settled with the org lead:
1. **Public tier only, plus the lifecycle hooks.** §2.11.1 d1's "one public route",
plus enough to show the whole vertical seam once. Admin and player tiers become
worked examples quoted from module-uo in chapter 2 rather than two thirds of a
tree the reader deletes on day one.
2. **The release workflow ships as a file, in BOTH flavours** — `.gitea/` and
`.github/`. Neither runs where it sits (a workflow is only read from a
repository root) and each arms itself when the reader's copy is its own repo.
Packaging is the part of a module that cannot be guessed at, and the kit's
audience is outside this org, so assuming Gitea would have been assuming our
own deployment. Core installs from a URL and does not care where the release
lives — only that the host is on the operator's `MODULE_SOURCE_HOSTS`.
3. **A rename checklist that CI verifies**, not a rename script. `template/README.md`
carries the table; `scripts/checkRenameSites.js` holds it against the tree in
both directions — an unlisted file that still carries the placeholder fails, and
so does a listed file that no longer does. The second half is the one usually
left out and the more valuable: a row that has stopped matching reads as
instructions to edit something that is not there. Same rule core's identifier
check follows about its own exemptions. It has its own ten-test suite, run by
CI as `node --test`, because a check that has never been shown to fail is a
check nobody knows the state of.
4. **A neutral invented game.** One deviation from the literal answer, forced by
decision 3: the id is `examplegame`, not `example`. The checklist check is a
text search, and `example` occurs in ordinary English ("for example") all over
prose that is not a rename site — a placeholder that cannot occur by accident is
what makes the check answerable instead of a source of false alarms someone
learns to ignore.
**The pin moves to the 1.4.0 bump** (website `edge` 1b692bf), which is what
`template/module.json` declares as `coreApi`. Slice 0 pinned its parent, before
1.4.0 existed, so `checkCoreApi.js` arms for the first time here — it asserts
EQUALITY, and its failing on the next contract bump is the system working.
Also in CI: the client tests now run AFTER the build (two of them read the built
chunk and skip without one — run first, the job reports green while asking nothing
about the artifact that ships), and `check:swagger` verifies the committed
fragment is current.
## The finding: an UPDATE that changes nothing does not touch ON UPDATE CURRENT_TIMESTAMP
Every suite passed, both guards passed, the chunk built, the module loaded into a
real core and the page rendered correctly. Two hours later the same page said the
world was offline, and it was wrong.
`updated_at` was declared `ON UPDATE CURRENT_TIMESTAMP`, and MariaDB fires that
only when an UPDATE actually CHANGES a value. The boot refresh writes the same
numbers every thirty seconds — which is exactly what a quiet game looks like — so
the timestamp froze at the first write, the row crossed the freshness window, and
the model correctly reported a stale row as offline. Verified against the live
database: two hours of refreshes, `updated_at` still the boot timestamp.
No test in this repo could see it. The model takes its clock as an argument, and
nothing in a suite runs the same UPDATE twice against a real database. It is only
visible as a page that was right when you looked at it and wrong an hour later.
The writer now sets `updated_at = CURRENT_TIMESTAMP` explicitly and the column
drops the clause that was not doing what it looked like it was doing; both carry
the reasoning. Re-verified end to end: the timestamp advances every interval and
the API reports fresh.
Falling out of the fix, the schema fragment gained the rule the reader hits next:
**changing a table is an ALTER, never an edit to its CREATE** — `CREATE TABLE IF
NOT EXISTS` does nothing when the table exists, so an edited column definition
takes effect on a fresh install and on no existing one, which is the worst
possible split because your development database is usually the fresh one.
## Verified
- 29 server tests, 18 client tests, 10 kit-script tests; `check:imports`,
`check:externals`, `check:swagger` and `checkCoreApi` all green, run in CI's own
order from a clean `npm ci`.
- Browser smoke (MODULE_API.md §7.7) against a real core built from the pinned
ref: module `started`, published on `/api/v1/public/modules`, chunk served
`no-cache` with the right MIME from the entry's directory while `module.json`
and the server source 404, script tag injected after core's bundle, the page
rendering inside core's own chrome, the nav row interleaved into the public
header between Wiki and About, SPA navigation into it from another page, the
module's path and schema and tag merged into `/api/docs.json`, and
`[examplegame] registered against core API 1.4.0` in the console with no CSP
report and no React error.
Refs: MODULE_SYSTEM.md §2.11.1 (slice 1), MODULE_API.md §2.x, §3.x, §5.1, §7.7.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1792
template/client/package-lock.json
generated
Normal file
1792
template/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
template/client/package.json
Normal file
24
template/client/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "examplegame-module-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Client half of the Example Game module — a prebuilt ESM chunk core injects into its own SPA",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "node --test",
|
||||
"check:externals": "node scripts/checkExternals.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
172
template/client/scripts/checkExternals.js
Normal file
172
template/client/scripts/checkExternals.js
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.1's client half — what stayed a bare import in the built chunk ──────
|
||||
//
|
||||
// The server half's boundary check reads source. The client half's has to read
|
||||
// the BUILD OUTPUT, because the failure it exists to catch is invisible in
|
||||
// source: `import { useState } from 'react'` is correct in every file, and
|
||||
// whether it ends up as core's React or as a second copy welded into the chunk
|
||||
// is decided by vite.config.js's aliases. A missed alias changes nothing you can
|
||||
// see until a hook throws in the browser.
|
||||
//
|
||||
// So: build, then ask the artifact two questions.
|
||||
//
|
||||
// 1. **Is there a bare import left?** There must not be. Aliased shims are
|
||||
// bundled, so a surviving bare specifier means an alias missed and
|
||||
// `external` caught it — the loud failure the config prefers, but still a
|
||||
// failure, and better found here than by a browser refusing to load.
|
||||
// 2. **Did a shared dependency get bundled?** React's own source has
|
||||
// fingerprints that no module of ours would contain by accident. Finding
|
||||
// one means the chunk carries a second React, which is the silent version
|
||||
// of the same mistake and the one worth the fingerprint check.
|
||||
//
|
||||
// Run after `npm run build`, in CI, on the artifact that ships.
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||
|
||||
/**
|
||||
* Which characters of the chunk are inside a string, template or comment.
|
||||
*
|
||||
* **A check that reads code with a regexp fails on code that talks about
|
||||
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||
* because a button reading "Approve and import" put the token `import`
|
||||
* immediately before a quote and the pattern could not tell that from a
|
||||
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||
* anything.
|
||||
*
|
||||
* The server half hit the same wall from the other side and answered it the same
|
||||
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||
* inside a string, because that distinction is a property of the parse.
|
||||
*
|
||||
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||
* would take the answer with the noise.
|
||||
*/
|
||||
export function stringMask(src) {
|
||||
const inString = new Uint8Array(src.length)
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
const end = nl === -1 ? src.length : nl
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (two === '/*') {
|
||||
const close = src.indexOf('*/', i + 2)
|
||||
const end = close === -1 ? src.length : close + 2
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (c === '"' || c === "'" || c === '`') {
|
||||
// The opening quote itself stays unmasked: a specifier is read starting
|
||||
// at its quote, and the regexp below anchors on that.
|
||||
i += 1
|
||||
while (i < src.length && src[i] !== c) {
|
||||
// A backslash escapes the next character, including the closing quote.
|
||||
const step = src[i] === '\\' ? 2 : 1
|
||||
inString.fill(1, i, Math.min(i + step, src.length))
|
||||
i += step
|
||||
}
|
||||
i += 1
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return inString
|
||||
}
|
||||
|
||||
// Static and dynamic imports that survived into the output. A relative or
|
||||
// absolute specifier is a chunk that was split, which this build does not do —
|
||||
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||
//
|
||||
// **This pattern used to require whitespace after `import`, and so could not see
|
||||
// the one shape the build actually emits.** Minified Rollup output is
|
||||
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||
// import — the most likely way for an alias to miss — would have passed this
|
||||
// check silently. It was found by writing the test for the false POSITIVE above
|
||||
// it, which is the argument for testing a check against both answers.
|
||||
//
|
||||
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||
// import's parenthesis.
|
||||
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||
|
||||
/** Every bare specifier the chunk still imports at runtime. */
|
||||
export function bareImports(chunk) {
|
||||
const masked = stringMask(chunk)
|
||||
const bare = new Set()
|
||||
for (const match of chunk.matchAll(IMPORTS)) {
|
||||
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||
// unless the match began at position 0.
|
||||
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||
const specifier = match[1]
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
return [...bare]
|
||||
}
|
||||
|
||||
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||
// packages ship and this module has no other reason to contain.
|
||||
//
|
||||
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||
// library's source arrives as code AND as its own error-message strings, and
|
||||
// masking would discard half the evidence. The direction of the risk is opposite
|
||||
// to the import check's — here a false positive is a fingerprint too generic,
|
||||
// which is a fixable choice of probe, not a property of the parse.
|
||||
const BUNDLED = [
|
||||
{ what: 'react', probe: 'react.development.js' },
|
||||
{ what: 'react', probe: 'Invalid hook call' },
|
||||
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||
]
|
||||
|
||||
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||
export function problemsWith(chunk) {
|
||||
const problems = []
|
||||
const bare = bareImports(chunk)
|
||||
if (bare.length) {
|
||||
problems.push(
|
||||
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Only when run as a script. Importing this from a test must not read a chunk
|
||||
// that may not have been built, and must not call process.exit.
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
}
|
||||
34
template/client/src/api.js
Normal file
34
template/client/src/api.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// ── This module's own API bindings ────────────────────────────────────────
|
||||
//
|
||||
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
|
||||
// `ApiError` thrown on any non-2xx. The paths are yours, because the routes at
|
||||
// the other end are yours — `server/router/**` in this repo serves them.
|
||||
//
|
||||
// **Do not build your own fetch wrapper.** The primitive is what carries the
|
||||
// session cookie, the CSRF handling and the error shape core's `ErrorState`
|
||||
// knows how to render. A module that calls `fetch` directly gets none of that
|
||||
// and finds out one page at a time.
|
||||
//
|
||||
// Keeping the bindings in one file, ordered the way the routers are, is
|
||||
// convention rather than contract — but the two halves of every call live in
|
||||
// different directories and nothing checks them against each other, so anything
|
||||
// that makes a mismatch easy to see is worth doing.
|
||||
|
||||
import rg from './core.js'
|
||||
|
||||
const { request: req, BASE } = rg.api
|
||||
|
||||
// ── public ────────────────────────────────────────────────────────────────
|
||||
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
|
||||
// `/api/v1/public/world/status` — the route `server/router/public/world.router.js`
|
||||
// registers under the `/world` prefix `module.json` declares.
|
||||
export const world = {
|
||||
status: () => req('/public/world/status'),
|
||||
}
|
||||
|
||||
// Exported for the rare caller that needs the base itself — an `<img src>`, a
|
||||
// download link, an EventSource. Reach for `request` first.
|
||||
export { BASE }
|
||||
|
||||
export default { world, BASE }
|
||||
77
template/client/src/core.js
Normal file
77
template/client/src/core.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// ── What core hands this module, on the client side ────────────────────────
|
||||
//
|
||||
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||
// Every page imports its layout, its state components and its hooks from here,
|
||||
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
|
||||
// §3.4.
|
||||
//
|
||||
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||
// would capture `undefined` at file scope. There is no such gap here.
|
||||
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||
// and every module chunk is a deferred script the server injects *after* that
|
||||
// bundle's tag, so by the time the first line of this file executes the global
|
||||
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
|
||||
// than being wrapped in an accessor that would cost it its identity.
|
||||
//
|
||||
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||
// also goes through — the shims touch the global before this file does, so a
|
||||
// check here would be unreachable.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { rg as shared } from './shim/rg.js'
|
||||
|
||||
const rg = shared()
|
||||
|
||||
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||
//
|
||||
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
|
||||
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
|
||||
// finished artifact — and both reason about the chunk in isolation. Neither can
|
||||
// see the one failure that only exists once the chunk meets a core: a
|
||||
// `window.__rg` whose React is not the React that rendered the page.
|
||||
//
|
||||
// Identity is the only question worth asking. A second React satisfies every
|
||||
// type check, renders its first element happily, and then throws about an invalid
|
||||
// hook call somewhere unrelated — in a component that has nothing to do with it.
|
||||
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||
console.error(
|
||||
'[examplegame] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
|
||||
// three data-page states, the fetch hook, and read-only access to the session and
|
||||
// the site's settings. Anything else your pages need — tables, tabs, an editor —
|
||||
// you bundle yourself, in a `components/` directory of your own.
|
||||
//
|
||||
// Closed is a real constraint and it is the price of the boundary being worth
|
||||
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing a
|
||||
// kit component's props is a major one. Use them, though. A module page that
|
||||
// ships its own layout is a page that stops looking like the site it is installed
|
||||
// in, and drifts further every time core changes.
|
||||
export const {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
export const registry = rg.registry
|
||||
|
||||
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check, only something to report.
|
||||
export const coreApiVersion = rg.version
|
||||
|
||||
export default rg
|
||||
79
template/client/src/entry.jsx
Normal file
79
template/client/src/entry.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── The client entry point ────────────────────────────────────────────────
|
||||
//
|
||||
// Core serves `dist/entry.js` from your module's directory and injects it into
|
||||
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
|
||||
// This file registers what the module has; core renders it. Normative:
|
||||
// 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 core's first render. There is no subscription and no
|
||||
// late registration: a module that registered asynchronously would register after
|
||||
// the route table had been read, and the symptom is a page that redirects home
|
||||
// with nothing logged anywhere.
|
||||
//
|
||||
// So everything below is a plain top-level call and every page is a STATIC
|
||||
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
|
||||
// and it is the one thing this seam cannot have.
|
||||
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
|
||||
import WorldStatus from './routes/public/WorldStatus.jsx'
|
||||
|
||||
// Your module id, exactly as `module.json` spells it. Core keys the registry by
|
||||
// it and prefixes every route path with it.
|
||||
const ID = 'examplegame'
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Paths are relative to your module's namespace and core prefixes them. Whatever
|
||||
// you write here, a public route lands at `/<id>/<path>`, an admin route at
|
||||
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. You cannot
|
||||
// write the segment your routes hang under, which is the point: two modules
|
||||
// installed side by side cannot collide, and an operator can see from a URL which
|
||||
// module served it.
|
||||
//
|
||||
// So this one page is at `/examplegame/status`.
|
||||
//
|
||||
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
|
||||
// available and core applies it as its own `RoleGate`; supplying your own is not
|
||||
// possible, because the sidebar and the route table have to agree about who may
|
||||
// see what, and they only do if one thing decides.
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: 'status', element: <WorldStatus /> },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Nav ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A registered row is an ORDINARY row from here on. It interleaves into core's
|
||||
// own navigation, and an operator can reorder it, relabel it or hide it from the
|
||||
// admin nav editor exactly as they can core's — because the interleave happens
|
||||
// before the override merge, and the override layer is keyed by `to`.
|
||||
//
|
||||
// Three fields worth knowing before you need them:
|
||||
//
|
||||
// • `order` places the row among core's, which are keyed by their index. A row
|
||||
// with NO order appends after them, rather than defaulting to 0 — otherwise
|
||||
// "I didn't ask for a position" would mean "put me first".
|
||||
// • `group` (admin sidebar) names an existing core group; an unknown name
|
||||
// appends a new group at the end rather than dropping the row.
|
||||
// • `icon` is a component, and core supplies no fallback. Public header rows
|
||||
// carry no icons, so there is none here — but an admin or player row without
|
||||
// one is the only row in its sidebar with no glyph, which reads as breakage.
|
||||
// Match the nav you are landing in: the admin sidebar draws at 18px with a
|
||||
// 1.6 stroke, the player portal at 16px with a 2.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'World', to: '/examplegame/status' },
|
||||
],
|
||||
})
|
||||
|
||||
// `module.json`'s `coreApi` range was checked by the loader before this file was
|
||||
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
|
||||
// between the core that validated your manifest and the core that published this
|
||||
// global is otherwise invisible from the browser, which is where the client half
|
||||
// actually fails.
|
||||
console.info(`[${ID}] registered against core API ${coreApiVersion}`)
|
||||
65
template/client/src/routes/public/WorldStatus.jsx
Normal file
65
template/client/src/routes/public/WorldStatus.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── The one page ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// An ordinary React component. Nothing about being inside a module changes how
|
||||
// you write one — the only differences are where React comes from (core, via the
|
||||
// aliases in vite.config.js, so the import below looks completely normal and is
|
||||
// not) and where the chrome comes from (`../../core.js`, the seven-member kit).
|
||||
//
|
||||
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
|
||||
// maintenance gate and nothing else, so a page that omits the layout renders
|
||||
// bare — no header, no footer, no site chrome — which looks like a bug and is
|
||||
// the contract (§3.3). Admin and player routes are the other way round: core
|
||||
// wraps those in their layouts for you.
|
||||
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
|
||||
// is in every browser core supports, and one fewer dependency in the chunk is
|
||||
// one fewer thing an operator ships.
|
||||
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function ago(iso) {
|
||||
if (!iso) return 'never'
|
||||
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
|
||||
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
|
||||
return RELATIVE.format(Math.round(seconds / size), unit)
|
||||
}
|
||||
|
||||
export default function WorldStatus() {
|
||||
// `useAsync` is core's fetch/loading/error hook, and the three components
|
||||
// below are its three states. Using them rather than rolling your own is what
|
||||
// makes a module page indistinguishable from a core one while it loads and
|
||||
// while it fails.
|
||||
const { data, loading, error } = useAsync(() => api.world.status(), [])
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PageHeader
|
||||
title="World status"
|
||||
subtitle="What the game server last told us about itself"
|
||||
/>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState error={error} />}
|
||||
|
||||
{data && (
|
||||
<div style={{ display: 'grid', gap: '0.75rem', maxWidth: '32rem' }}>
|
||||
<p>
|
||||
<strong>{data.worldName || 'The world'}</strong> is{' '}
|
||||
{data.online ? 'online' : 'offline'}
|
||||
{data.online && data.players > 0 ? ` with ${data.players} playing` : ''}.
|
||||
</p>
|
||||
<p style={{ opacity: 0.7 }}>
|
||||
Last reported {ago(data.updatedAt)}
|
||||
{/* `stale` is a first-class part of the answer rather than something
|
||||
the page infers from a timestamp. The server decides what counts
|
||||
as stale, because the server is what knows how often the game is
|
||||
supposed to check in. */}
|
||||
{data.stale ? ' — this is out of date, so the world is shown as offline.' : '.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
16
template/client/src/shim/jsx-runtime.js
Normal file
16
template/client/src/shim/jsx-runtime.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// `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.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const jsxRuntime = rg().jsxRuntime
|
||||
|
||||
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||
|
||||
export default jsxRuntime.default ?? jsxRuntime
|
||||
14
template/client/src/shim/react-dom.js
vendored
Normal file
14
template/client/src/shim/react-dom.js
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// `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.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const reactDom = rg().reactDom
|
||||
|
||||
export default reactDom.default ?? reactDom
|
||||
|
||||
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom
|
||||
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
32
template/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
// `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.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const router = 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
|
||||
50
template/client/src/shim/react.js
vendored
Normal file
50
template/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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.
|
||||
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const react = 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
|
||||
29
template/client/src/shim/rg.js
Normal file
29
template/client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// The one place this module reads `window.__rg`, and the one place that says
|
||||
// something useful when it is not there.
|
||||
//
|
||||
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||
// modules evaluate dependencies in the source order of their import statements,
|
||||
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||
// that survives exactly until someone sorts the imports. Whichever module the
|
||||
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||
//
|
||||
// A missing global means core did not publish its shared dependencies before
|
||||
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||
// called react.js, which reads like the module bundled React wrong — the
|
||||
// opposite of what happened.
|
||||
export function rg() {
|
||||
const shared = window.__rg
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
'[examplegame] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||
'fault in this module.',
|
||||
)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
export default rg
|
||||
154
template/client/test/build.test.js
Normal file
154
template/client/test/build.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// What can be checked about the client half without a browser.
|
||||
//
|
||||
// Not much, and being honest about that is the point: the client half's real
|
||||
// failures are timing and resolution, and neither has a shape a DOM-less test
|
||||
// runner can see. MODULE_API.md §7.7's four-step browser smoke is what actually
|
||||
// proves this half works, and it is re-run whenever this seam changes.
|
||||
//
|
||||
// What IS testable here is the configuration that decides resolution — and one
|
||||
// of these tests exists because the trap it guards cost this project real time: Vite's object-form `resolve.alias` does PREFIX matching, so a `react`
|
||||
// key silently also rewrites `react/jsx-runtime`. An anchored regexp in the
|
||||
// array form cannot. That is a property of the config, and a test can hold it.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT = path.resolve(HERE, '..')
|
||||
|
||||
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||
const configModule = await import('../vite.config.js')
|
||||
const config = configModule.default
|
||||
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||
|
||||
test('every alias is an anchored regexp, never a bare prefix string', () => {
|
||||
const aliases = config.resolve.alias
|
||||
assert.ok(Array.isArray(aliases), 'alias must use the ARRAY form — the object form prefix-matches')
|
||||
for (const { find } of aliases) {
|
||||
assert.ok(find instanceof RegExp, `alias "${find}" is a string; a string prefix-matches`)
|
||||
assert.ok(find.source.startsWith('^') && find.source.endsWith('$'), `alias ${find} is not anchored`)
|
||||
}
|
||||
})
|
||||
|
||||
test('react and react/jsx-runtime resolve to different shims', () => {
|
||||
// The exact collision the object form causes. Asserted on the outcome rather
|
||||
// than on the config's shape, so it keeps holding however the config is
|
||||
// rewritten.
|
||||
const resolve = (specifier) =>
|
||||
config.resolve.alias.find(({ find }) => find.test(specifier))?.replacement
|
||||
assert.ok(resolve('react'))
|
||||
assert.ok(resolve('react/jsx-runtime'))
|
||||
assert.notStrictEqual(resolve('react'), resolve('react/jsx-runtime'))
|
||||
})
|
||||
|
||||
test('every shared dependency is aliased', () => {
|
||||
for (const specifier of ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'react-router-dom']) {
|
||||
assert.ok(
|
||||
config.resolve.alias.some(({ find }) => find.test(specifier)),
|
||||
`${specifier} is not aliased — it would be bundled, giving the page a second copy`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('rollup external stays empty — it preempts the aliases rather than backing them up', () => {
|
||||
// Rollup asks `external` BEFORE Vite's alias resolver runs, so a specifier
|
||||
// listed 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. §3.6 shows both; they do not compose.
|
||||
assert.deepStrictEqual(config.build.rollupOptions.external, [])
|
||||
})
|
||||
|
||||
test('the not-bundled guard covers every shared specifier and is not derived from them', () => {
|
||||
// The direction of this dependency is the finding. Deriving the forbidden
|
||||
// package list FROM the alias list means deleting an alias also deletes the
|
||||
// guard against what that alias prevented — which is precisely when the guard
|
||||
// is needed. So the guard states the contract, and this asserts the aliases
|
||||
// stay inside it.
|
||||
const packages = new Set(guardedPackages)
|
||||
for (const { specifier } of SHARED) {
|
||||
const pkg = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]
|
||||
assert.ok(packages.has(pkg), `${pkg} is aliased but not guarded against being bundled`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every alias points at a shim file that exists', () => {
|
||||
for (const { find, replacement } of config.resolve.alias) {
|
||||
assert.ok(fs.existsSync(replacement), `alias ${find} points at a missing file: ${replacement}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the build emits one unhashed entry.js, which is what module.json names', () => {
|
||||
assert.deepStrictEqual(config.build.lib.formats, ['es'])
|
||||
assert.strictEqual(config.build.lib.fileName(), 'entry.js')
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(CLIENT, '..', 'module.json'), 'utf8'))
|
||||
assert.strictEqual(manifest.client.entry, 'client/dist/entry.js')
|
||||
assert.strictEqual(config.build.outDir, 'dist')
|
||||
})
|
||||
|
||||
test('modulePreload polyfilling stays off — an inline bootstrap is refused under CSP', () => {
|
||||
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
||||
})
|
||||
|
||||
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
||||
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
||||
// makes the "core did not publish its dependencies" message reachable. The
|
||||
// shims touch the global before anything else in the chunk does, so a check
|
||||
// placed in the first-imported file is a guarantee that lasts until someone
|
||||
// sorts the imports.
|
||||
const dir = path.join(CLIENT, 'src', 'shim')
|
||||
const shims = fs.readdirSync(dir)
|
||||
assert.ok(shims.length >= 5)
|
||||
for (const file of shims) {
|
||||
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
||||
if (file === 'rg.js') {
|
||||
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
||||
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
||||
continue
|
||||
}
|
||||
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
||||
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
||||
// A shim may import its sibling helper and nothing else — anything further
|
||||
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
||||
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
||||
// The artifact check itself, over the artifact that ships. Skipped rather than
|
||||
// failed when there is no build: `npm test` must be runnable before `npm run
|
||||
// build`, and CI runs them in order.
|
||||
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
||||
if (!fs.existsSync(chunk)) return
|
||||
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
||||
})
|
||||
|
||||
test('an import inside a string is not an import — the check reads code, not text', () => {
|
||||
// The regression that made this necessary: the first chunk with real content
|
||||
// in it had a button labelled "Approve and import" put the token
|
||||
// immediately before a quote. The check rejected the whole build, naming a
|
||||
// fragment of minified JSX as the offending specifier.
|
||||
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
||||
assert.deepStrictEqual(bareImports(uiCopy), [])
|
||||
|
||||
// Neither is one in a comment, or in a template literal.
|
||||
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
||||
|
||||
// And a real one still is, in each form the build could emit.
|
||||
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
||||
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
||||
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
||||
|
||||
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
||||
// string must not end it early and leave the tail looking like code.
|
||||
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||
})
|
||||
177
template/client/test/registration.test.js
Normal file
177
template/client/test/registration.test.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// ── What the chunk registers, checked without a browser ───────────────────
|
||||
//
|
||||
// `build.test.js` says the honest thing about this half: its real failures are
|
||||
// timing and resolution, and a DOM-less runner cannot see either. MODULE_API.md
|
||||
// §7.7's browser smoke is what proves the client half works, and nothing here
|
||||
// replaces it.
|
||||
//
|
||||
// What a test CAN do is read back what the chunk asked for. Registration is the
|
||||
// one thing the chunk does at evaluation time, and it does it through an object
|
||||
// core hands it — so: stand up a fake `window.__rg` with a recording registry and
|
||||
// the real React behind it, import the BUILT artifact, and inspect the result. No
|
||||
// DOM is needed because nothing renders; `<WorldStatus />` is `jsx(WorldStatus)`,
|
||||
// an object, and the route table is full of them by design.
|
||||
//
|
||||
// It catches a page that silently stops being routed, a nav row whose `to` drifts
|
||||
// from its route's path, and the whole registration surface disappearing because
|
||||
// something threw halfway down entry.jsx.
|
||||
//
|
||||
// **It runs against `dist/entry.js`, so build before you test.** The skip below
|
||||
// is deliberate — `npm test` has to be runnable before `npm run build` — which
|
||||
// means a CI job that tests without building is a job asking nothing at all. Ours
|
||||
// builds first, on purpose.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import * as react from 'react'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
// A component, as far as the registry cares. The kit's real members are core's;
|
||||
// nothing renders here, so a named stub is enough to be imported and passed on.
|
||||
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||
|
||||
function fakeRg() {
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
return {
|
||||
version: manifest.coreApi.replace(/^\D+/, ''),
|
||||
react,
|
||||
jsxRuntime,
|
||||
router,
|
||||
// `react-dom/client` is imported for the identity check in core.js and never
|
||||
// called — `createRoot` in a DOM-less process would throw. The shim reads
|
||||
// this object, so the check compares against whatever is here.
|
||||
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
|
||||
ui: Object.fromEntries(
|
||||
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
|
||||
.map((n) => [n, stub(n)]),
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
},
|
||||
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
|
||||
registerExtension(id, slot, Component) {
|
||||
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||
extensions.set(slot, { id, Component })
|
||||
},
|
||||
routesFor: (area) => routes[area],
|
||||
navFor: (area) => nav[area],
|
||||
},
|
||||
_read: () => ({ routes, nav, providers, extensions }),
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded once: an ES module is evaluated a single time per process however many
|
||||
// times it is imported, so every test below reads the same registration pass —
|
||||
// which is also how it behaves in a browser.
|
||||
let registered = null
|
||||
let skip = false
|
||||
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
skip = true
|
||||
} else {
|
||||
const rg = fakeRg()
|
||||
globalThis.window = { __rg: rg }
|
||||
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
|
||||
registered = rg._read()
|
||||
}
|
||||
|
||||
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
|
||||
|
||||
it('registers at least one route, namespaced under the module id', () => {
|
||||
const all = Object.values(registered.routes).flat()
|
||||
assert.ok(all.length > 0, 'the chunk registered no routes at all')
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
for (const r of list) {
|
||||
assert.ok(r.path.startsWith(`${manifest.id}/`), `${area} route "${r.path}" is not under the namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every route path is distinct within its area', () => {
|
||||
// Two routes on one path is a page that can never be reached, and React
|
||||
// renders the first one without complaint.
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
const paths = list.map((r) => r.path)
|
||||
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('every nav row points at a route this module actually registered', () => {
|
||||
// The agreement that matters, and the one that rots quietly: a row survives a
|
||||
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
|
||||
// the FULL rendered path (`/examplegame/status`); routes carry the namespaced
|
||||
// one (`examplegame/status`). Reconciling the two is the whole test.
|
||||
const rendered = {
|
||||
public: (p) => `/${p}`,
|
||||
admin: (p) => `/admin/${p}`,
|
||||
player: (p) => `/player/${p}`,
|
||||
}
|
||||
for (const [area, rows] of Object.entries(registered.nav)) {
|
||||
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
|
||||
for (const row of rows) {
|
||||
assert.ok(reachable.has(row.to), `${area} nav row "${row.label}" links to ${row.to}, which no route serves`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every admin and player nav row carries an icon', () => {
|
||||
// Both of those navs draw a glyph on every core row, so a row without one reads
|
||||
// as breakage rather than as a design — and core's player portal used to render
|
||||
// `<n.icon />` unguarded, which blanked the entire portal with React error #130
|
||||
// the first time a module registered a row without one. Core guards it now; a
|
||||
// missing icon there is still a visible defect and this is the cheap place to
|
||||
// catch it. The PUBLIC header is text buttons and is deliberately excluded.
|
||||
for (const area of ['admin', 'player']) {
|
||||
for (const row of registered.nav[area]) {
|
||||
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('a nav row that gates on a feature has a provider to resolve it', () => {
|
||||
// Resolution is by the REGISTERING module (§3.3), and every unknown fails OPEN.
|
||||
// So a row carrying a `feature` from a module that registered no provider is a
|
||||
// row that always shows — which re-advertises a surface an operator hid.
|
||||
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
|
||||
if (gated.length === 0) return
|
||||
assert.ok(registered.providers.size > 0, 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('every slot module.json declares is one the chunk fills', () => {
|
||||
// `module.json` declares SERVER slots, and the loader validates those before
|
||||
// the chunk is ever served. Client slots cannot be declared there — the server
|
||||
// knows nothing about them — so this is the one place the two halves meet.
|
||||
for (const slot of manifest.extensions || []) {
|
||||
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers under exactly one module id, matching the manifest', () => {
|
||||
const owners = new Set([
|
||||
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||
...Object.values(registered.nav).flat().map((r) => r.moduleId),
|
||||
...[...registered.extensions.values()].map((e) => e.id),
|
||||
...[...registered.providers.values()].map((p) => p.id),
|
||||
])
|
||||
assert.deepEqual([...owners], [manifest.id])
|
||||
})
|
||||
137
template/client/vite.config.js
Normal file
137
template/client/vite.config.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── 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.) The first real module
|
||||
// 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: 'examplegame: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: [] },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user