#!/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 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.`) }