PLAN.md §30 as approved, plus D119/D120 from the build. Server: - rust_map_images (one row per server: picture as MEDIUMBLOB, geometry, monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair. - mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and asks map.info; a new key or hash from the free Rust+ cache (or a render kept on disk) is fetched in slices, checked against its SHA-256 and stored in one statement. One fetch per server, a backoff on failure, `stale` abandons a fetch that straddles a map change. Render now (D109) is admin-only and watched to completion. - mapLive.js: D111. One map.live per server per 5 s whoever asks; positions are held in memory only. - model/map: four layers (world, events public; players, bases staff), a fleet default plus per-server override (D114), the players layer capped by presence (D113), own dot and online first-party clan mates for a linked viewer (D115, D117, D118). A layer the viewer may not see is absent from the answer, never sent and hidden. - Routes: public /servers/:id/map, /map/image (immutable under its hash), /map/live; admin /servers/:id/map/fetch and /render; the Map card on the visibility PUT. Swagger fragment and frozen manifest regenerated. Client: - A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid (labels only when a cell is wide enough to hold one), a legend that lists hidden layers with who can see them, polled every 10 s while visible. - D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml copies every dist/*.js; checkExternals and build.test.js hold both ends. - The Map card on Admin -> Rust visibility, with Fetch again and Render now. Capability `map` declared for the Android app (phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
214 lines
9.5 KiB
JavaScript
214 lines
9.5 KiB
JavaScript
#!/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
|
|
// specifier is a chunk that was split — since D120 there is one, Leaflet's,
|
|
// which the Map tab imports with `import()` — and is `relativeImports`'s
|
|
// concern below; a bare name is this check's.
|
|
//
|
|
// **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]
|
|
}
|
|
|
|
/**
|
|
* Every relative specifier the chunk imports — its split chunks (D120).
|
|
*
|
|
* Each one is a file the browser will ask for beside `entry.js`, and core
|
|
* serves that directory, so it works in development. Whether it SHIPS is
|
|
* `release.yml`'s business, which is why the script below also asserts every
|
|
* one of them exists in `dist/`, and `test/build.test.js` asserts the release
|
|
* copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the
|
|
* release forgot is a Map tab that spins for ever on an operator's site while
|
|
* every check here passes.
|
|
*/
|
|
export function relativeImports(chunk) {
|
|
const masked = stringMask(chunk)
|
|
const found = new Set()
|
|
for (const match of chunk.matchAll(IMPORTS)) {
|
|
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
|
if (masked[keywordAt]) continue
|
|
if (match[1].startsWith('./')) found.add(match[1].slice(2))
|
|
}
|
|
return [...found]
|
|
}
|
|
|
|
// 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 dist = path.dirname(CHUNK)
|
|
const entry = fs.readFileSync(CHUNK, 'utf8')
|
|
const problems = []
|
|
|
|
// Every chunk, not only the entry: a split chunk that bundled a second React
|
|
// would load on the tab that imports it and fail there, and nowhere else.
|
|
for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) {
|
|
for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`)
|
|
}
|
|
for (const name of relativeImports(entry)) {
|
|
if (!fs.existsSync(path.join(dist, name))) {
|
|
problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`)
|
|
}
|
|
}
|
|
|
|
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)
|
|
const split = relativeImports(entry)
|
|
console.log(
|
|
`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` +
|
|
(split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'),
|
|
)
|
|
}
|