fix(net): carry our own dispatcher on a direct fetch (#742)

Node 22's global fetch reads the legacy undici dispatcher. The host's
first undici 8 import (web_fetch) replaces it, and gzip catalog bodies
then fail JSON.parse for the rest of the process. A proxied request
already passed a dispatcher; a direct one now does the same.
This commit is contained in:
Liu Wenjie
2026-09-27 13:45:18 +08:00
parent 9bc6120ba5
commit a7e3bf9622
6 changed files with 88 additions and 32 deletions
+29 -13
View File
@@ -18,17 +18,28 @@
* So the market calls undici's fetch with an explicit dispatcher. The scope
* is deliberate: only requests made by this module change, and the host's
* own networking is left exactly as the host configured it.
*
* The same explicit dispatcher is required when no proxy is configured
* (#742). Node 22's global fetch reads `Symbol.for('undici.globalDispatcher.1')`.
* The first import of undici 8 in the host process (the `web_fetch` tool)
* finds `.2` empty, installs its own dispatcher, and writes a
* `Dispatcher1Wrapper` onto `.1`. After that, global fetch returns gzip
* bodies with null headers, and `JSON.parse` fails on the catalog. undici 7's
* own fetch reads `.1` too, so swapping the function is not enough: the
* request has to carry a dispatcher this module created. A proxy request
* already did. A direct request now does the same, with a plain `Agent`.
*/
import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'
/**
* The proxy this process would use for the catalog, if any.
*
* The standard variables mirror `EnvHttpProxyAgent`'s own resolution
* deliberately, rather than picking the order that reads best, because the
* same answer does two jobs: it decides whether to route through undici at
* all, and it is what the failure message CLAIMS was tried. A helper that
* same answer does two jobs: it decides whether the request goes through
* the proxy agent or the direct one, and it is what the failure message
* CLAIMS was tried. A helper that
* named a proxy undici would not have used would put a false statement in
* every bug report. `npm_config_*` is an additional source on top of that:
* npm holds its proxy in its own config namespace (a machine set up with
@@ -75,32 +86,37 @@ function proxyFromEnv(): { http: string | null; https: string | null } {
}
/**
* Built once and reused: an agent per request would drop connection reuse,
* and this one reads NO_PROXY as well, so a host that excludes its own
* Built once and reused: an agent per request would drop connection reuse.
* The proxy agent also reads NO_PROXY, so a host that excludes its own
* registry mirror keeps being excluded.
*/
let agent: EnvHttpProxyAgent | null = null
let proxyAgent: EnvHttpProxyAgent | null = null
let directAgent: Agent | null = null
/**
* Fetch through the proxy this machine is configured to use.
* Fetch through the proxy this machine is configured to use, or directly
* through this module's own agent when it has none.
*
* Falls back to the global fetch when no proxy is set, which keeps the
* ordinary case on the runtime's own path rather than routing it through a
* second HTTP stack for no reason.
* Both paths pass a dispatcher. Without one, a direct call would use the
* global fetch, and that fetch stays broken for the rest of the process
* after the host imports undici 8 (#742).
*/
export async function marketFetch(
url: string,
init?: { signal?: AbortSignal; headers?: Record<string, string> },
): Promise<Response> {
const { http, https } = proxyFromEnv()
if (http === null && https === null) return await fetch(url, init)
if (http === null && https === null) {
directAgent ??= new Agent()
return await undiciFetch(url, { ...init, dispatcher: directAgent }) as unknown as Response
}
// Pass the resolved proxies explicitly. EnvHttpProxyAgent itself reads
// only http(s)_proxy out of the environment, so a proxy that lives in
// npm_config_* must be handed over directly — otherwise the agent would
// silently go direct while configuredProxy() claims a proxy was used.
agent ??= new EnvHttpProxyAgent({
proxyAgent ??= new EnvHttpProxyAgent({
httpProxy: http ?? undefined,
httpsProxy: https ?? undefined,
})
return await undiciFetch(url, { ...init, dispatcher: agent }) as unknown as Response
return await undiciFetch(url, { ...init, dispatcher: proxyAgent }) as unknown as Response
}
+32 -9
View File
@@ -17,23 +17,32 @@ import { describeFetchFailure, forgetCatalog, loadRegistry } from '../src/regist
import { configuredProxy, marketFetch } from '../src/net.ts'
/**
* undici stands in for the real outbound path. `marketFetch` routes through
* EnvHttpProxyAgent only when a proxy is configured, and the assertion that
* matters is exactly which proxy URLs the agent was built with —
* npm_config_* is invisible to EnvHttpProxyAgent, so the explicit handoff
* is what makes the npm fallback real instead of a name the failure message
* claims was tried.
* undici stands in for the real outbound path. Every `marketFetch` goes
* through it with an explicit dispatcher (#742): `EnvHttpProxyAgent` when a
* proxy is configured, a plain `Agent` otherwise. The assertion that matters
* for a proxy is exactly which URLs the agent was built with — npm_config_*
* is invisible to EnvHttpProxyAgent, so the explicit handoff is what makes
* the npm fallback real instead of a name the failure message claims was
* tried. `fetch` forwards to the global stub when one is installed, so the
* catalog script below still observes the request.
*/
const undici = vi.hoisted(() => ({
fetch: vi.fn(async () => new Response('ok', { status: 200 })),
fetch: vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (vi.isMockFunction(globalThis.fetch)) return globalThis.fetch(input, init)
return new Response('ok', { status: 200 })
}),
EnvHttpProxyAgent: vi.fn(function (this: unknown, opts?: unknown) {
return { opts }
}),
Agent: vi.fn(function (this: unknown) {
return { direct: true }
}),
}))
vi.mock('undici', () => ({
fetch: undici.fetch,
EnvHttpProxyAgent: undici.EnvHttpProxyAgent,
Agent: undici.Agent,
}))
const CATALOG = {
@@ -479,6 +488,7 @@ describe('marketFetch', () => {
beforeEach(() => {
undici.fetch.mockClear()
undici.EnvHttpProxyAgent.mockClear()
undici.Agent.mockClear()
})
it('hands npm-config proxies to the agent explicitly — EnvHttpProxyAgent cannot see them', async () => {
@@ -497,9 +507,22 @@ describe('marketFetch', () => {
)
})
it('stays on the global fetch when there is no proxy anywhere', async () => {
it('uses its own dispatcher when no proxy is configured (#742)', async () => {
// Node 22's global fetch shares a dispatcher symbol with undici 8. After
// the host's first web_fetch import, that symbol points at a wrapper the
// builtin fetch cannot decompress, so a direct catalog read comes back
// as gzip. Carrying an Agent this module created is what keeps the body
// decoded; calling global fetch, or undici's fetch with no dispatcher,
// both fail. The agent is reused, so a second call does not build another.
vi.stubGlobal('fetch', vi.fn(async () => new Response('ok', { status: 200 })))
await marketFetch('https://catalog.example/plugins.json')
expect(undici.fetch).not.toHaveBeenCalled()
await marketFetch('https://catalog.example/plugins.json')
expect(undici.EnvHttpProxyAgent).not.toHaveBeenCalled()
const calls = undici.fetch.mock.calls
expect(calls).toHaveLength(2)
const first = calls[0]?.[1] as { dispatcher?: unknown } | undefined
const second = calls[1]?.[1] as { dispatcher?: unknown } | undefined
expect(first?.dispatcher).toEqual({ direct: true })
expect(second?.dispatcher).toBe(first?.dispatcher)
})
})
+7 -8
View File
@@ -1,12 +1,11 @@
// The unit lane never reaches the network: every request a spec cares about
// is answered by a stub on the global fetch. A proxy in the developer's
// environment defeats that — `marketFetch` (src/net.ts) goes through undici
// with a proxy agent whenever http(s)_proxy is set, and the stub never sees
// the request — so on a machine with HTTPS_PROXY exported 63 of the suite's
// tests failed for reasons that had nothing to do with the code under test,
// and the #148 proxy spec read the machine's lowercase `https_proxy` in place
// of the value it had set. Drop the variables before any spec runs; a spec
// that wants a proxy sets one itself (tests/dsh-cli.spec.ts does).
// is answered by a stub on the global fetch. `marketFetch` (src/net.ts) always
// calls undici, and undici-fetch.ts forwards that call to the global stub.
// A proxy in the developer's environment still changes which agent is built
// and what `configuredProxy()` reports, so on a machine with HTTPS_PROXY
// exported the #148 proxy spec read the machine's lowercase `https_proxy` in
// place of the value it had set. Drop the variables before any spec runs; a
// spec that wants a proxy sets one itself (tests/dsh-cli.spec.ts does).
//
// Only the unit lane: the compat and web lanes drive real pnpm and a real
// browser, which may need the proxy to reach anything at all.
+17
View File
@@ -0,0 +1,17 @@
import { vi } from 'vitest'
// `marketFetch` calls undici's fetch with its own dispatcher (#742). The
// unit lane stubs the global fetch, not the package. Forward the package
// fetch onto that stub and drop the dispatcher, so a stub written against
// `init.headers` / `init.signal` still sees the call it was written for.
vi.mock('undici', async (importOriginal) => {
const actual = await importOriginal<typeof import('undici')>()
return {
...actual,
fetch: (input: RequestInfo | URL, init?: RequestInit & { dispatcher?: unknown }) => {
if (init === undefined) return globalThis.fetch(input)
const { dispatcher: _dispatcher, ...rest } = init
return globalThis.fetch(input, rest)
},
}
})
+2 -1
View File
@@ -260,7 +260,8 @@ describe('checkUpdates — private git hosts (#525)', () => {
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'dshm-giteahome-'))
// marketFetch uses undici when a proxy is set, which bypasses stubGlobal('fetch').
// A proxy in the environment selects EnvHttpProxyAgent inside marketFetch.
// These cases want the direct agent, so the machine's proxy must not leak in.
for (const key of proxyKeys) {
savedProxy[key] = process.env[key]
delete process.env[key]
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
test: {
// Strips the developer's proxy variables first: with one exported, the
// fetch stubs below marketFetch never see a request (tests/setup/no-proxy.ts).
setupFiles: ['tests/setup/no-proxy.ts'],
setupFiles: ['tests/setup/no-proxy.ts', 'tests/setup/undici-fetch.ts'],
include: ['tests/**/*.spec.ts', 'tests/**/*.spec.tsx'],
exclude: ['tests/**/*.compat.spec.ts', '**/node_modules/**'],
pool: 'forks',