From c7b0e774c885710268b3aba64bc5476d5e2b2403 Mon Sep 17 00:00:00 2001 From: Joel Mangin Date: Wed, 15 Jul 2026 13:24:20 -0400 Subject: [PATCH 1/6] Use native fetch when the runtime undici is compatible. convertAgent builds a dispatcher from the bundled undici's Agent. When the runtime fetch and the bundled undici share a major version their handler contracts match, so the dispatcher can be handed to ky, which forwards it to the native fetch (ky keeps dispatcher out of its request-option registry so it reaches fetch); this preserves the native fetch and its performance. Only when the majors differ -- e.g. node 26's built-in undici 8 rejecting the bundled undici 6 dispatcher with "invalid onError method" -- fall back to the bundled undici's own fetch, decomposing ky's runtime Request to url + init because that fetch cannot consume a foreign Request class. Guard the version read so a future undici that hides package.json behind an exports map (or a missing process.versions.undici) defaults to the bundled fetch rather than throwing at module load and breaking import for every consumer. Strip the converted agent/httpsAgent options so they are not forwarded on to fetch. The previous approach always routed through the bundled undici's fetch, regressing runtimes whose native fetch was already compatible. This skew only exists because node does not expose its built-in undici. Add a POST-with-body agent test so the request body and headers are exercised across both the native and the bundled-fetch paths. Related to #43. Co-authored-by: Dave Longley --- CHANGELOG.md | 10 +++ lib/agentCompatibility.js | 97 ++++++++++++++++++++++++----- tests/10-client-api.spec.common.cjs | 24 +++++++ tests/utils.cjs | 6 ++ 4 files changed, 123 insertions(+), 14 deletions(-) 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..3644caf 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} from 'undici'; +import {createRequire} from 'node:module'; import {versions} from 'node:process'; // as long as an agent has a reference to it, its associated dispatcher will @@ -12,6 +13,33 @@ 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. The +// contract that breaks (the dispatcher handler's `onError`) changed between +// undici 6 and 8, so node<=24 (built-in undici 6) accepts the bundled v6 +// dispatcher while node 26 (built-in undici 8) rejects it 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 and so needs it decomposed to (url, init). 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 require = createRequire(import.meta.url); + const bundledMajor = parseInt(require('undici/package.json').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 +57,65 @@ 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 (e.g. node 26): the runtime fetch 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, so +// it is decomposed to url + init here. A `Request`'s fields are prototype +// getters with no own-enumerable properties, so it cannot be spread-copied — +// the reads must be explicit, and they mirror the RequestInit fields `ky` +// applies to the Request so no request semantics are dropped. +function createFetch(defaultDispatcher) { + return function fetch(input, init) { + const dispatcher = init?.dispatcher || defaultDispatcher; + if(input && typeof input === 'object' && typeof input.url === 'string') { + const req = input; + const reqInit = { + method: req.method, + headers: req.headers, + mode: req.mode, + credentials: req.credentials, + cache: req.cache, + redirect: req.redirect, + referrer: req.referrer, + referrerPolicy: req.referrerPolicy, + integrity: req.integrity, + keepalive: req.keepalive, + signal: req.signal + }; + if(req.body) { + reqInit.body = req.body; + reqInit.duplex = 'half'; + } + return undiciFetch(req.url, {...reqInit, ...init, dispatcher}); + } + 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; } From 44223a7e9ef8a6abd66f8caf2b6a623c11122d30 Mon Sep 17 00:00:00 2001 From: Joel Mangin Date: Sat, 25 Jul 2026 10:13:32 -0400 Subject: [PATCH 2/6] Test the undici-compatibility fix on Node.js 26 in CI. The existing agent tests already exercise the fallback path (bundled undici's own fetch) on Node 24, since its built-in undici (7.x) does not match this package's installed undici (6.x) either -- only Node 22's built-in undici happens to match today. Adding 26.x to the test matrix is what actually proves the fix on the one runtime it was written for, rather than relying on the local repro scripts alone. --- .github/workflows/main.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 03f2b260f87d10036d29f4546fa8ed8e45755e89 Mon Sep 17 00:00:00 2001 From: Joel Mangin Date: Sat, 25 Jul 2026 10:19:29 -0400 Subject: [PATCH 3/6] Read undici's package.json via an import attribute; fix stale comment. Replaces the createRequire(...) require('undici/package.json') dance with a native ESM JSON import attribute -- same value, no CJS interop needed now that this is read at module scope with top-level await unnecessary either way. Also corrects the comment above it, which stated node 24 bundles undici 6 (matching this package's installed undici) when it actually bundles undici 7 -- only node 22 matches today. The runtime check itself was never affected (it compares versions dynamically rather than hardcoding them), but the comment was actively misleading. --- lib/agentCompatibility.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 3644caf..234100f 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -2,7 +2,7 @@ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ import {Agent, fetch as undiciFetch} from 'undici'; -import {createRequire} from 'node:module'; +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 @@ -14,16 +14,20 @@ 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. The -// contract that breaks (the dispatcher handler's `onError`) changed between -// undici 6 and 8, so node<=24 (built-in undici 6) accepts the bundled v6 -// dispatcher while node 26 (built-in undici 8) rejects it 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 and so needs it decomposed to (url, init). This skew only -// exists because node does not expose its built-in undici (`node:undici`); see +// 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 and so needs it +// decomposed to (url, init). 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 @@ -31,8 +35,7 @@ const canConvert = (major > 18) || (major === 18 && minor >= 2); // module load and breaking `import` for every consumer. const nativeFetchCompatible = (() => { try { - const require = createRequire(import.meta.url); - const bundledMajor = parseInt(require('undici/package.json').version, 10); + const bundledMajor = parseInt(undiciPkg.version, 10); const runtimeMajor = parseInt(versions.undici, 10); return runtimeMajor === bundledMajor; } catch{ From 949ad099bfa4eb4aa98e0f5a357154b0be313502 Mon Sep 17 00:00:00 2001 From: Joel Mangin Date: Sat, 25 Jul 2026 17:24:38 -0400 Subject: [PATCH 4/6] Delegate Request-to-init conversion to undici's own Request constructor. Dave Longley flagged the previous field-by-field RequestInit copy (and a denylist/reflection-based version of the same idea, tried in between) as overly complex and not future-proofed -- any manual list of fields drifts out of sync with the Fetch spec as it gains new RequestInit members over time. `new UndiciRequest(input.url, input)` sidesteps the whole problem: undici's own `Request` constructor performs its own RequestInit dictionary conversion, reading exactly the fields its own implementation understands directly off the object it's given -- duck-typed, not `instanceof`-checked -- so passing the runtime's native `Request` as that init "just works," and stays correct automatically as undici's own supported fields evolve. No allow-list, deny-list, or prototype reflection needed or maintained here. Verified via the existing test suite (test-node: 22/22, test-node-cjs: 18/18, both including the HTTPS-agent POST test that exercises this exact path) plus manual checks of GET/HEAD/POST, a streaming body with a non-empty init override, and field-by-field confirmation that method/headers/mode/credentials/cache/redirect/referrer/ referrerPolicy/integrity/keepalive/signal/url all survive the conversion without the source request's body being consumed early. --- lib/agentCompatibility.js | 42 ++++++++++++++------------------------- 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 234100f..8730a9f 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ -import {Agent, fetch as undiciFetch} 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'; @@ -25,8 +25,9 @@ const canConvert = (major > 18) || (major === 18 && minor >= 2); // 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 and so needs it -// decomposed to (url, init). This skew only exists because node does not +// 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 @@ -90,34 +91,21 @@ export function convertAgent(options) { } // create fetch override uses custom `dispatcher`; on an incompatible runtime -// `ky`'s runtime `Request` cannot be consumed by the bundled undici's fetch, so -// it is decomposed to url + init here. A `Request`'s fields are prototype -// getters with no own-enumerable properties, so it cannot be spread-copied — -// the reads must be explicit, and they mirror the RequestInit fields `ky` -// applies to the Request so no request semantics are dropped. +// `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') { - const req = input; - const reqInit = { - method: req.method, - headers: req.headers, - mode: req.mode, - credentials: req.credentials, - cache: req.cache, - redirect: req.redirect, - referrer: req.referrer, - referrerPolicy: req.referrerPolicy, - integrity: req.integrity, - keepalive: req.keepalive, - signal: req.signal - }; - if(req.body) { - reqInit.body = req.body; - reqInit.duplex = 'half'; - } - return undiciFetch(req.url, {...reqInit, ...init, dispatcher}); + const request = new UndiciRequest(input.url, input); + return undiciFetch(request, {...init, dispatcher}); } return undiciFetch(input, {...init, dispatcher}); }; From 62cd8e9751b8f6ada72ad0915d60d4c81ab6143b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 18:37:33 -0400 Subject: [PATCH 5/6] Use version independent text in comment. Co-authored-by: Dave Longley --- lib/agentCompatibility.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 8730a9f..47b18fd 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -79,8 +79,8 @@ export function convertAgent(options) { return {...rest, dispatcher}; } - // incompatible runtime (e.g. node 26): the runtime fetch rejects this - // dispatcher, so route through the bundled undici's own fetch via an override + // 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) { fetch = createFetch(dispatcher); From f4656062a1cb3667663366d44eb8cd24617c6eee Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 18:39:14 -0400 Subject: [PATCH 6/6] Simplify code flow. Co-authored-by: Dave Longley --- lib/agentCompatibility.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 47b18fd..a2067df 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -104,8 +104,7 @@ function createFetch(defaultDispatcher) { return function fetch(input, init) { const dispatcher = init?.dispatcher || defaultDispatcher; if(input && typeof input === 'object' && typeof input.url === 'string') { - const request = new UndiciRequest(input.url, input); - return undiciFetch(request, {...init, dispatcher}); + input = new UndiciRequest(input.url, input); } return undiciFetch(input, {...init, dispatcher}); };