inspector/scripts/verify-bundle-externals.mjs
cliffhall 9a084f49e5 fix(proxy): proxy /api/fetch, and detect entry-inlined externals
Addresses the Copilot review on #2099.

- `/api/fetch` used the bare global `fetch`. That route is the browser's only
  way out to the network — the web client's `environment.fetch` is
  `createRemoteFetch()`, which forwards OAuth discovery and token requests
  there — so a corporate-proxy user could connect to a server but never
  authorize against it, and the PR's "OAuth is proxied now" claim held for the
  CLI and TUI but not for web. It now uses the same proxy-aware base fetch, with
  an integration test driving a real forwarding proxy (confirmed failing without
  the fix).

- `verify:bundle-externals` only recognized an inlining that produced a
  `<pkg>-HASH.js` chunk. A *statically* imported package is folded into
  `index.js` and emits no chunk, so it would have passed. Detection now keys off
  esbuild's `// <path>` module banners, covering both shapes. Because that
  depends on unminified output, a bundle with no banners now fails as such
  rather than reporting clean — enabling `minify` cannot silently retire the
  check. Tests cover the entry-inlined case, the same-prefix case
  (`undici-types` must not read as `undici`), and the no-banner case.

- The pre-push pipeline summaries in AGENTS.md, README.md, and
  .github/copilot-instructions.md still described `verify:build-gate → smoke`
  and omitted the new gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 21:33:31 -04:00

192 lines
7.2 KiB
JavaScript

/**
* Guards the mirror of the "must be bundled" rule: a dependency a client
* declares **external** must still be external in the built bundle.
*
* AGENTS.md has a rule for dependencies that MUST be inlined — the
* React-rendering ones (#1952) — and nothing at all for the opposite direction,
* which is how #2067 shipped. `undici` was declared only in the root and
* `clients/cli` manifests, and tsup auto-externalizes just what the *nearest*
* manifest declares, so esbuild inlined 1.05 MB of it into the web and TUI
* bundles. Inlining a CommonJS package into an ESM bundle is not merely wasteful:
* esbuild rewrites `import("undici")` to a relative chunk whose `require("assert")`
* hits its `__require` shim and throws `Dynamic require of "assert" is not
* supported`. Because the specifier was rewritten at build time, no user-side
* install can ever satisfy it — the package is unloadable by construction, and
* every proxied connection failed with a message telling users to install a
* package that was already there.
*
* The check is deliberately on the **built output**, not the config: the config
* is what a reviewer reads, but the emitted chunk is what ships, and the two
* disagreed for four releases. It is also derived from each client's own
* `external` list rather than a list maintained here, so a newly externalized
* package is covered without editing this file.
*
* Nothing else catches this class. The unit and integration tests run against
* source with a real `undici` on the resolution path; the smokes run the built
* tree but never set a proxy env var, so the lazy `import()` never executes.
*/
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/**
* Clients that ship a tsup bundle, with the config to read `external` from and
* the build directory to inspect. `clients/launcher` is plain `tsc` — it emits
* no bundle and inlines nothing — so it has nothing to check.
*/
export const BUNDLED_CLIENTS = [
{
name: "web",
config: "clients/web/tsup.runner.config.ts",
build: "clients/web/build",
},
{
name: "cli",
config: "clients/cli/tsup.config.ts",
build: "clients/cli/build",
},
{
name: "tui",
config: "clients/tui/tsup.config.ts",
build: "clients/tui/build",
},
];
/**
* Extracts the string literals of a tsup config's `external: [...]` array.
*
* Reading the config as *text* rather than importing it keeps this guard free of
* the client's own toolchain (tsup, esbuild, and — for the TUI — a plugin that
* reads `ink-form` off disk at module load). Only bare string entries are
* collected; a regex entry such as `/^@inspector\/core/` is a `noExternal`
* pattern in practice and has no package name to look for.
*/
export function parseExternals(source) {
const start = source.indexOf("external: [");
if (start === -1) return [];
let depth = 0;
let end = -1;
for (let i = source.indexOf("[", start); i < source.length; i++) {
if (source[i] === "[") depth++;
else if (source[i] === "]") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) return [];
const body = source.slice(start, end);
// Strip comments first: the entries are documented at length, and a package
// name quoted inside prose would otherwise read as an entry.
const withoutComments = body
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "");
return [...withoutComments.matchAll(/"([^"]+)"|'([^']+)'/g)]
.map((m) => m[1] ?? m[2])
.filter((name) => name !== "external: [");
}
/**
* Every module esbuild inlines is emitted under a `// <path>` banner naming its
* source file, so a package that was bundled despite being declared external
* leaves `// ../../node_modules/<pkg>/…` lines behind. Matching on those covers
* **both** shapes this can take: a separate `<pkg>-HASH.js` chunk (what a
* dynamically `import()`ed CommonJS package produces, which is what #2067 was)
* and a statically-imported package folded straight into `index.js`, which
* emits no chunk at all and would otherwise pass unnoticed.
*
* Reading banners means the guard depends on unminified output. That is true of
* every client bundle today and is asserted rather than assumed: a build with no
* banners at all fails as `noBanners` instead of silently reporting a clean
* result, so turning on `minify` surfaces here rather than quietly retiring the
* check.
*/
export function findInlinedExternals({ externals, files }) {
const banners = files.flatMap((file) =>
[...file.source.matchAll(/^\/\/ (\S+)$/gm)].map((m) => ({
file: file.name,
path: m[1],
})),
);
if (banners.length === 0) {
return { noBanners: true, violations: [] };
}
const violations = [];
for (const pkg of externals) {
// The trailing slash is load-bearing: without it `undici` matches
// `undici-types`, a @types transitive that is legitimately present.
const needle = `node_modules/${pkg}/`;
const hit = banners.find((b) => b.path.includes(needle));
if (hit) {
violations.push({
pkg,
reason: `inlined into ${hit.file} (e.g. ${hit.path})`,
});
}
}
return { noBanners: false, violations };
}
function main() {
const failures = [];
for (const client of BUNDLED_CLIENTS) {
const buildDir = join(repoRoot, client.build);
const entry = join(buildDir, "index.js");
if (!existsSync(entry)) {
failures.push(
`${client.name}: ${client.build}/index.js is missing — run \`npm run build\` first.`,
);
continue;
}
const externals = parseExternals(
readFileSync(join(repoRoot, client.config), "utf8"),
);
const files = readdirSync(buildDir)
.filter((f) => f.endsWith(".js"))
.map((name) => ({
name,
source: readFileSync(join(buildDir, name), "utf8"),
}));
const { noBanners, violations } = findInlinedExternals({
externals,
files,
});
if (noBanners) {
failures.push(
`${client.name}: ${client.build} has no esbuild module banners, so this guard ` +
`cannot see what was inlined. Has minification been enabled for this bundle?`,
);
continue;
}
for (const v of violations) {
failures.push(
`${client.name}: "${v.pkg}" is declared external but was inlined (${v.reason}).`,
);
}
}
if (failures.length > 0) {
console.error("verify:bundle-externals FAILED\n");
for (const f of failures) console.error(` - ${f}`);
console.error(
"\nAn externalized package must resolve from the consumer's install. If it was\n" +
"inlined, esbuild rewrote its specifier to a relative chunk that no user-side\n" +
"install can satisfy — and a CommonJS package inlined into an ESM bundle throws\n" +
'`Dynamic require of "..." is not supported` on first use (#2067).\n' +
"Add the package to that client's tsup `external` list.",
);
process.exit(1);
}
console.log(
`verify:bundle-externals OK — ${BUNDLED_CLIENTS.length} bundles, no externalized package inlined.`,
);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();