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>
This commit is contained in:
parent
d90a955164
commit
9a084f49e5
2 changed files with 203 additions and 77 deletions
|
|
@ -92,38 +92,45 @@ export function parseExternals(source) {
|
|||
}
|
||||
|
||||
/**
|
||||
* The chunk esbuild emits for an inlined package is named after the package's
|
||||
* last path segment plus a hash — `undici-HXPKCIY3.js`,
|
||||
* `@scope/name` → `name-XXXXXXXX.js` — and the rewritten specifier in the entry
|
||||
* is the matching relative path. Either one present means the package was
|
||||
* inlined despite being declared 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, buildFiles, entrySource }) {
|
||||
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) {
|
||||
const base = pkg.split("/").pop();
|
||||
const chunk = buildFiles.find((f) =>
|
||||
new RegExp(`^${escapeRegExp(base)}-[A-Za-z0-9_]+\\.js$`).test(f),
|
||||
);
|
||||
if (chunk) {
|
||||
violations.push({ pkg, reason: `emitted chunk ${chunk}` });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entrySource.includes(`"./${base}-`) ||
|
||||
entrySource.includes(`'./${base}-`)
|
||||
) {
|
||||
// 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: "entry imports a rewritten relative chunk",
|
||||
reason: `inlined into ${hit.file} (e.g. ${hit.path})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return { noBanners: false, violations };
|
||||
}
|
||||
|
||||
function main() {
|
||||
|
|
@ -140,11 +147,23 @@ function main() {
|
|||
const externals = parseExternals(
|
||||
readFileSync(join(repoRoot, client.config), "utf8"),
|
||||
);
|
||||
const violations = findInlinedExternals({
|
||||
const files = readdirSync(buildDir)
|
||||
.filter((f) => f.endsWith(".js"))
|
||||
.map((name) => ({
|
||||
name,
|
||||
source: readFileSync(join(buildDir, name), "utf8"),
|
||||
}));
|
||||
const { noBanners, violations } = findInlinedExternals({
|
||||
externals,
|
||||
buildFiles: readdirSync(buildDir),
|
||||
entrySource: readFileSync(entry, "utf8"),
|
||||
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}).`,
|
||||
|
|
|
|||
|
|
@ -51,61 +51,104 @@ describe("parseExternals", () => {
|
|||
});
|
||||
|
||||
describe("findInlinedExternals", () => {
|
||||
test("flags an emitted chunk named after the package", () => {
|
||||
// The exact shape #2067 shipped: `undici-HXPKCIY3.js` beside index.js.
|
||||
const v = findInlinedExternals({
|
||||
const banner = (p) => `// ${p}\nvar x = 1;\n`;
|
||||
|
||||
test("flags a package inlined as its own chunk", () => {
|
||||
// The exact shape #2067 shipped: a dynamically import()ed CommonJS package
|
||||
// emitted as `undici-HXPKCIY3.js` beside index.js.
|
||||
const { violations } = findInlinedExternals({
|
||||
externals: ["undici"],
|
||||
buildFiles: ["index.js", "undici-HXPKCIY3.js"],
|
||||
entrySource: "",
|
||||
files: [
|
||||
{
|
||||
name: "index.js",
|
||||
source: banner("../../core/mcp/node/transport.ts"),
|
||||
},
|
||||
{
|
||||
name: "undici-HXPKCIY3.js",
|
||||
source: banner("../../node_modules/undici/lib/core/symbols.js"),
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].pkg, "undici");
|
||||
assert.match(v[0].reason, /undici-HXPKCIY3\.js/);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].pkg, "undici");
|
||||
assert.match(violations[0].reason, /undici-HXPKCIY3\.js/);
|
||||
});
|
||||
|
||||
test("flags a rewritten relative specifier in the entry", () => {
|
||||
// Belt to the chunk-name brace: if esbuild ever names the chunk differently,
|
||||
// the rewritten import in the entry still gives the inlining away.
|
||||
const v = findInlinedExternals({
|
||||
externals: ["undici"],
|
||||
buildFiles: ["index.js"],
|
||||
entrySource: 'await import("./undici-ABC123.js")',
|
||||
test("flags a package inlined straight into the entry, with no chunk", () => {
|
||||
// A *statically* imported package is folded into index.js and emits no
|
||||
// chunk at all, so a chunk-name check would miss it entirely. The banner is
|
||||
// what gives it away.
|
||||
const { violations } = findInlinedExternals({
|
||||
externals: ["pino"],
|
||||
files: [
|
||||
{
|
||||
name: "index.js",
|
||||
source:
|
||||
banner("../../core/mcp/index.ts") +
|
||||
banner("../../node_modules/pino/pino.js"),
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(v.length, 1);
|
||||
assert.match(v[0].reason, /rewritten relative chunk/);
|
||||
});
|
||||
|
||||
test("matches a scoped package on its last segment", () => {
|
||||
const v = findInlinedExternals({
|
||||
externals: ["@napi-rs/keyring"],
|
||||
buildFiles: ["index.js", "keyring-QQ11.js"],
|
||||
entrySource: "",
|
||||
});
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].pkg, "@napi-rs/keyring");
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].pkg, "pino");
|
||||
assert.match(violations[0].reason, /index\.js/);
|
||||
});
|
||||
|
||||
test("passes a genuinely external package", () => {
|
||||
assert.deepEqual(
|
||||
findInlinedExternals({
|
||||
externals: ["undici", "pino"],
|
||||
buildFiles: ["index.js"],
|
||||
entrySource: 'await import("undici"); import "pino";',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { noBanners, violations } = findInlinedExternals({
|
||||
externals: ["undici", "pino"],
|
||||
files: [
|
||||
{
|
||||
name: "index.js",
|
||||
source:
|
||||
banner("../../core/mcp/node/proxyFetch.ts") +
|
||||
'await import("undici");\nimport "pino";\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(noBanners, false);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("does not mistake an unrelated chunk for the package", () => {
|
||||
// `undici-types` is a @types transitive; a substring match would flag it.
|
||||
assert.deepEqual(
|
||||
findInlinedExternals({
|
||||
externals: ["undici"],
|
||||
buildFiles: ["index.js", "undici-types-AA22.js"],
|
||||
entrySource: "",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
test("does not mistake a same-prefix package for the external", () => {
|
||||
// `undici-types` is a @types transitive and is legitimately inlined; the
|
||||
// trailing slash in the needle is what keeps it from reading as `undici`.
|
||||
const { violations } = findInlinedExternals({
|
||||
externals: ["undici"],
|
||||
files: [
|
||||
{
|
||||
name: "index.js",
|
||||
source: banner("../../node_modules/undici-types/index.js"),
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test("matches a scoped package on its full name", () => {
|
||||
const { violations } = findInlinedExternals({
|
||||
externals: ["@napi-rs/keyring"],
|
||||
files: [
|
||||
{
|
||||
name: "index.js",
|
||||
source: banner("../../node_modules/@napi-rs/keyring/index.js"),
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].pkg, "@napi-rs/keyring");
|
||||
});
|
||||
|
||||
test("reports noBanners rather than a clean pass when banners are absent", () => {
|
||||
// The guard reads esbuild's `// <path>` module banners, so minified output
|
||||
// would make every bundle look clean. That must fail loudly instead —
|
||||
// otherwise enabling `minify` silently retires the check.
|
||||
const { noBanners, violations } = findInlinedExternals({
|
||||
externals: ["undici"],
|
||||
files: [{ name: "index.js", source: "var a=1;var b=2;" }],
|
||||
});
|
||||
assert.equal(noBanners, true);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue