mirror of
https://github.com/dsh-market/dsh-market.git
synced 2026-09-28 05:03:07 +08:00
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:
+29
-13
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user