fix(proxy): route through undici's own fetch and keep undici external
Corporate-proxy users could not connect to any remote MCP server from v2. Two independent defects, both reported in #2067. 1. The web and TUI bundles inlined undici. It was declared in the root and `clients/cli` manifests but named in no client's tsup `external` list, and tsup auto-externalizes only what the *nearest* manifest declares — so esbuild inlined 1.05MB of it into `clients/{web,tui}/build/`. undici is CommonJS, so the emitted chunk's `require("assert")` hit esbuild's ESM `__require` shim and threw `Dynamic require of "assert" is not supported`. The rejected `import()` surfaced as "the `undici` package is not available", telling users to install a package that was already present — and could not have helped, because the specifier had been rewritten to a relative chunk at build time. 2. `withProxyDispatcher` injected a *userland* undici `EnvHttpProxyAgent` into Node's *built-in* fetch. That couples two different copies of undici at the dispatcher handler interface, which is not stable across majors: Node 22 embeds undici 6, Node 24 embeds 7, Node 26 embeds 8. A userland undici 8 agent handed a built-in undici 7 handler is rejected with `fetch failed: invalid onRequestStart method`, and the request never leaves the process. So fixing the bundling alone would only have converted the web/TUI failure into the CLI's. Both halves of the pair now come from userland undici — its `fetch` and its `EnvHttpProxyAgent` — which keeps the handler inside one copy and works unchanged from the Node 22.19 engine floor through Node 26. Deleting the wrapper in favor of Node's native `NODE_USE_ENV_PROXY` was not an option: measured, it is unsupported on 22.19 (the floor) and only lands mid-22.x. Because undici's `Response` is a different class from `globalThis.Response`, the proxied fetch re-wraps each response as a genuine global `Response`, streaming preserved. Without that, `res instanceof Response` is false for callers that test it — including the SDK's OAuth error formatter, which would degrade every message to `Raw body: [object Response]`. The proxy now sits at the bottom of the fetch stack (`environment.fetch` for the Node clients, and the `createTransportNode` default for the web backend, its one caller that supplies no `fetchFn`) rather than wrapping the transport's fetch. Proxying means *substituting* the fetch, not decorating one, so wrapping a supplied `fetchFn` would discard it; installing it underneath lets InspectorClient's own wrappers compose over it. A side effect worth having: OAuth discovery and token requests go through `environment.fetch` too, and were never proxied before. Guarding it: - `undici` is added to all three tsup `external` lists and removed from `clients/cli/package.json`, so it is root-declared only, per the placement rule in AGENTS.md. The two installs had already drifted (root 8.5.0 vs cli 8.9.0). - New `npm run verify:bundle-externals` fails if any package a client declares external was inlined into that client's bundle anyway. It reads the *built output*, not the config — those two disagreed for four releases — and derives what to check from each client's own `external` array, so a newly externalized package is covered without editing the guard. Verified against the real pre-fix artifact, which it flags. - The proxy tests now drive a real forwarding proxy rather than a mock. That matters: defect 2 was a runtime interface mismatch, which no mock can catch because no mock ever dispatches. AGENTS.md's must-not-bundle rule (added by #2082) is extended with why undici slipped past it, and notes the `node -e` trap — probing the bundled chunk with `node -e` falsely succeeds, because it exposes a global `require` that satisfies esbuild's guard. Closes #2067 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
fd1fa06cf5
commit
d90a955164
1 changed files with 758 additions and 133 deletions
122
scripts/verify-bundle-externals.test.mjs
Normal file
122
scripts/verify-bundle-externals.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Table-driven tests for the pure parsers of `verify-bundle-externals.mjs`.
|
||||
*
|
||||
* One case per rule the guard encodes. The guard itself reads real build output,
|
||||
* which no unit test can produce cheaply — so what is pinned here is the
|
||||
* *decision* logic: which entries count as externals, and which emitted shapes
|
||||
* count as "inlined anyway".
|
||||
*/
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BUNDLED_CLIENTS,
|
||||
parseExternals,
|
||||
findInlinedExternals,
|
||||
} from "./verify-bundle-externals.mjs";
|
||||
|
||||
describe("parseExternals", () => {
|
||||
test("collects bare string entries", () => {
|
||||
assert.deepEqual(
|
||||
parseExternals(`export default { external: ["undici", "pino"] }`),
|
||||
["undici", "pino"],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores package names that appear only inside comments", () => {
|
||||
// The real configs document each entry at length, and #2067's entry names
|
||||
// `undici` in its own prose — a comment-blind scan would double-count it and,
|
||||
// worse, invent entries for packages merely discussed.
|
||||
const src = `export default { external: [
|
||||
// "ghost" is explained here but is NOT an entry.
|
||||
/* neither is "phantom" */
|
||||
"undici",
|
||||
] }`;
|
||||
assert.deepEqual(parseExternals(src), ["undici"]);
|
||||
});
|
||||
|
||||
test("handles single quotes and nested arrays", () => {
|
||||
assert.deepEqual(
|
||||
parseExternals(
|
||||
`export default { external: ['a', 'b'], noExternal: ["c"] }`,
|
||||
),
|
||||
["a", "b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns an empty list when there is no external array", () => {
|
||||
assert.deepEqual(parseExternals(`export default { entry: ["x.ts"] }`), []);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
externals: ["undici"],
|
||||
buildFiles: ["index.js", "undici-HXPKCIY3.js"],
|
||||
entrySource: "",
|
||||
});
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].pkg, "undici");
|
||||
assert.match(v[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")',
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
test("passes a genuinely external package", () => {
|
||||
assert.deepEqual(
|
||||
findInlinedExternals({
|
||||
externals: ["undici", "pino"],
|
||||
buildFiles: ["index.js"],
|
||||
entrySource: 'await import("undici"); import "pino";',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
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: "",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BUNDLED_CLIENTS", () => {
|
||||
test("covers every tsup-bundled client", () => {
|
||||
// `clients/launcher` is plain tsc and emits no bundle, so it is deliberately
|
||||
// absent. If a fourth bundled client appears, it must be enrolled here.
|
||||
assert.deepEqual(BUNDLED_CLIENTS.map((c) => c.name).sort(), [
|
||||
"cli",
|
||||
"tui",
|
||||
"web",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue