diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 40079ca..926cc02 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,7 +27,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [18.x, 20.x, 22.x, 24.x, 26.x] steps: - uses: actions/checkout@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f07a15e..15d36b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # @digitalbazaar/http-client ChangeLog +## 4.3.1 - 2026-07-15 + +### Fixed +- Keep native `fetch` on the agent path when the runtime undici and the + bundled undici share a major version, handing the dispatcher to `ky` to + forward. Only fall back to the bundled undici's own `fetch` when the majors + differ (e.g. node 26's built-in undici 8 rejecting the bundled undici 6 + dispatcher). Fixes the agent path on node 26 without regressing the native + `fetch` performance on compatible runtimes. + ## 4.3.0 - 2026-01-15 ### Changed diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index edf24c1..a2067df 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -1,7 +1,8 @@ /*! * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ -import {Agent} from 'undici'; +import {Agent, fetch as undiciFetch, Request as UndiciRequest} from 'undici'; +import undiciPkg from 'undici/package.json' with {type: 'json'}; import {versions} from 'node:process'; // as long as an agent has a reference to it, its associated dispatcher will @@ -12,6 +13,37 @@ const AGENT_CACHE = new WeakMap(); const [major, minor] = versions.node.split('.').map(v => parseInt(v, 10)); const canConvert = (major > 18) || (major === 18 && minor >= 2); +// A dispatcher built from the bundled undici's `Agent` shares a handler +// contract with the runtime's `fetch` only when their undici majors match. +// This package installs undici 6; node's own bundled undici major varies by +// release line and does not necessarily match that -- today node 22 bundles +// undici 6 (matches), while node 24 bundles undici 7 and node 26 bundles +// undici 8 (both mismatch, so both already take the fallback path below, +// not just node 26). The contract that breaks (the dispatcher handler's +// `onError`) changed across those majors, so a mismatched pairing rejects +// the bundled v6 dispatcher with "invalid onError method". When they match +// we hand the dispatcher to `ky`, which forwards it to the runtime fetch (ky +// deliberately keeps `dispatcher` out of its request-option registry so it +// reaches fetch). When they differ we call the bundled undici's own fetch, +// which cannot consume the runtime's `Request` class directly, so it is +// rebuilt as the bundled undici's own `Request` first (see `createFetch` +// below). This skew only exists because node does not +// expose its built-in undici (`node:undici`); see +// digitalbazaar/http-client#43. +// The version read is guarded: if a future undici hides `package.json` behind +// an `exports` map, or `process.versions.undici` is absent, default to the +// bundled undici's own fetch (the always-safe path) rather than throwing at +// module load and breaking `import` for every consumer. +const nativeFetchCompatible = (() => { + try { + const bundledMajor = parseInt(undiciPkg.version, 10); + const runtimeMajor = parseInt(versions.undici, 10); + return runtimeMajor === bundledMajor; + } catch{ + return false; + } +})(); + // converts `agent`/`httpsAgent` option to a dispatcher option export function convertAgent(options) { if(!canConvert) { @@ -29,24 +61,51 @@ export function convertAgent(options) { return options; } - // use custom fetch if agent has already been converted - let fetch = AGENT_CACHE.get(agent); + // reuse the dispatcher built for this agent + let dispatcher = AGENT_CACHE.get(agent); + if(!dispatcher) { + dispatcher = new Agent({connect: agent.options}); + AGENT_CACHE.set(agent, dispatcher); + } + + // drop the converted legacy options so they are not forwarded to `fetch` + const rest = {...options}; + delete rest.agent; + delete rest.httpsAgent; + + // compatible runtime: let `ky` forward the dispatcher to the native `fetch`, + // which consumes the runtime `Request` natively — no wrapper, native perf + if(nativeFetchCompatible) { + return {...rest, dispatcher}; + } + + // incompatible runtime `fetch` that rejects this dispatcher, so route + // through the bundled undici's own fetch via an override + let fetch = AGENT_CACHE.get(dispatcher); if(!fetch) { - const dispatcher = new Agent({connect: agent.options}); fetch = createFetch(dispatcher); fetch._httpClientCustomFetch = true; - AGENT_CACHE.set(agent, fetch); + AGENT_CACHE.set(dispatcher, fetch); } - - return {...options, fetch}; + return {...rest, fetch}; } -// create fetch override uses custom `dispatcher`; since `ky` does not pass -// the dispatcher option through to `fetch`, we must use this override -function createFetch(dispatcher) { - return function fetch(...args) { - dispatcher = (args[1] && args[1].dispatcher) || dispatcher; - args[1] = {...args[1], dispatcher}; - return globalThis.fetch(...args); +// create fetch override uses custom `dispatcher`; on an incompatible runtime +// `ky`'s runtime `Request` cannot be consumed by the bundled undici's fetch +// directly, so it is rebuilt as the bundled undici's own `Request` here. +// Passing the runtime `Request` as undici's `Request` *init* (its second +// constructor argument) works because undici's own `Request` constructor +// performs its own `RequestInit` dictionary conversion -- it reads exactly +// the fields its own implementation understands directly off the object it's +// given, duck-typed rather than `instanceof`-checked, so it stays correct +// automatically as undici's own supported fields evolve. No manual +// allow/deny-list of `RequestInit` fields is needed or maintained here. +function createFetch(defaultDispatcher) { + return function fetch(input, init) { + const dispatcher = init?.dispatcher || defaultDispatcher; + if(input && typeof input === 'object' && typeof input.url === 'string') { + input = new UndiciRequest(input.url, input); + } + return undiciFetch(input, {...init, dispatcher}); }; } diff --git a/tests/10-client-api.spec.common.cjs b/tests/10-client-api.spec.common.cjs index 02b4ebc..090b0ed 100644 --- a/tests/10-client-api.spec.common.cjs +++ b/tests/10-client-api.spec.common.cjs @@ -91,6 +91,30 @@ describe('http-client API', () => { should.exist(response.data); response.status.should.equal(200); }); + + // exercises the agent path with a request body: on an incompatible + // runtime the body + headers must survive the Request -> (url, init) + // decomposition, on a compatible one it rides the native dispatcher path + it('can POST a body over an HTTPS agent', async () => { + let err; + let response; + const url = `https://${httpsHost}/echo`; + const payload = {hello: 'world', n: 42, nested: {ok: true}}; + try { + const agent = utils.makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.post(url, {agent, json: payload}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.echo); + response.data.echo.should.deep.equal(payload); + }); } it('handles a get not found error', async () => { diff --git a/tests/utils.cjs b/tests/utils.cjs index c988f1e..334c633 100644 --- a/tests/utils.cjs +++ b/tests/utils.cjs @@ -111,5 +111,11 @@ function createApp() { }); }); + app.post('/echo', cors(), express.json(), (req, res) => { + res.json({ + echo: req.body + }); + }); + return app; }