From c7b0e774c885710268b3aba64bc5476d5e2b2403 Mon Sep 17 00:00:00 2001 From: Joel Mangin Date: Wed, 15 Jul 2026 13:24:20 -0400 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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}); }; From 67a4028990eb5c22f36a8616199fc4f69d0b0e14 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 22:10:28 +0000 Subject: [PATCH 07/15] Clarify installed vs built-in undici terminology. The code used "bundled" for the npm-installed undici and "runtime"/"native" for the one built into node, which reads backwards and made `bundledMajor === runtimeMajor` hard to follow. Standardize on "installed" and "built-in" throughout. Co-Authored-By: Claude Opus 5 (1M context) --- lib/agentCompatibility.js | 60 +++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index a2067df..d46055f 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -13,32 +13,31 @@ 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 +// A dispatcher built from the installed undici's `Agent` shares a handler +// contract with node's built-in `fetch` only when their undici majors match. +// This package installs undici 6; the undici built into node varies by +// release line and does not necessarily match that -- today node 22 has +// undici 6 built in (matches), while node 24 has 7 and node 26 has 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 installed v6 +// dispatcher with "invalid onError method". When they match we hand the +// dispatcher to `ky`, which forwards it to the built-in 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. +// reaches fetch). When they differ we call the installed undici's own fetch, +// which cannot consume node's built-in `Request` class directly, so it is +// rebuilt as the installed 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 +// installed undici's own fetch (the always-safe path) rather than throwing at // module load and breaking `import` for every consumer. -const nativeFetchCompatible = (() => { +const builtinFetchCompatible = (() => { try { - const bundledMajor = parseInt(undiciPkg.version, 10); - const runtimeMajor = parseInt(versions.undici, 10); - return runtimeMajor === bundledMajor; + const installedMajor = parseInt(undiciPkg.version, 10); + const builtinMajor = parseInt(versions.undici, 10); + return builtinMajor === installedMajor; } catch{ return false; } @@ -73,14 +72,14 @@ export function convertAgent(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) { + // compatible: let `ky` forward the dispatcher to node's built-in `fetch`, + // which consumes its own `Request` natively — no wrapper, native perf + if(builtinFetchCompatible) { return {...rest, dispatcher}; } - // incompatible runtime `fetch` that rejects this dispatcher, so route - // through the bundled undici's own fetch via an override + // incompatible built-in `fetch` that rejects this dispatcher, so route + // through the installed undici's own fetch via an override let fetch = AGENT_CACHE.get(dispatcher); if(!fetch) { fetch = createFetch(dispatcher); @@ -90,10 +89,11 @@ export function convertAgent(options) { return {...rest, fetch}; } -// 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 +// create fetch override uses custom `dispatcher`; when incompatible, the +// built-in `Request` that `ky` creates cannot be consumed by the installed +// undici's fetch directly, so it is rebuilt as the installed undici's own +// `Request` here. +// Passing the built-in `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 From 8e748b7d490692f1cdbe345c7f557e9a1ac32896 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 23:19:24 +0000 Subject: [PATCH 08/15] Split the undici compatibility comment by concern. The comment above `builtinFetchCompatible` had grown to explain the background, the constant, both branches, and the guarded version reads all at once. Hoist the background to a module-level note, leave the constant with only its own meaning, and move the note about `ky` forwarding `dispatcher` to the branch that relies on it. Drop the `Request` rebuild explanation, which `createFetch` already documents. Co-Authored-By: Claude Opus 5 (1M context) --- lib/agentCompatibility.js | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index d46055f..faf6f31 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -5,6 +5,16 @@ import {Agent, fetch as undiciFetch, Request as UndiciRequest} from 'undici'; import undiciPkg from 'undici/package.json' with {type: 'json'}; import {versions} from 'node:process'; +// Background: node has its own copy of undici built in but does not expose +// it (there is no `node:undici`), so this package installs its own. A +// dispatcher only works with the undici that created it -- the handler +// contract changed across majors, so handing an installed v6 dispatcher to +// a built-in v7 or v8 `fetch` fails with "invalid onError method". Which +// major node has built in varies by release line (node 22 has 6, node 24 +// has 7, node 26 has 8), so no single installed version matches every +// supported runtime -- with undici 6 installed, both node 24 and node 26 +// take the fallback path below. See digitalbazaar/http-client#43. + // as long as an agent has a reference to it, its associated dispatcher will // be kept in this cache for reuse const AGENT_CACHE = new WeakMap(); @@ -13,25 +23,11 @@ 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 installed undici's `Agent` shares a handler -// contract with node's built-in `fetch` only when their undici majors match. -// This package installs undici 6; the undici built into node varies by -// release line and does not necessarily match that -- today node 22 has -// undici 6 built in (matches), while node 24 has 7 and node 26 has 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 installed v6 -// dispatcher with "invalid onError method". When they match we hand the -// dispatcher to `ky`, which forwards it to the built-in fetch (ky -// deliberately keeps `dispatcher` out of its request-option registry so it -// reaches fetch). When they differ we call the installed undici's own fetch, -// which cannot consume node's built-in `Request` class directly, so it is -// rebuilt as the installed 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 -// installed undici's own fetch (the always-safe path) rather than throwing at +// true when the installed and built-in undici majors match, meaning their +// dispatchers are interchangeable. Both reads are guarded: a future undici +// could hide `package.json` behind an `exports` map, and `versions.undici` +// may be absent. Either way fall back to `false` and use the installed +// undici's own fetch -- the always-safe path -- rather than throwing at // module load and breaking `import` for every consumer. const builtinFetchCompatible = (() => { try { @@ -72,8 +68,9 @@ export function convertAgent(options) { delete rest.agent; delete rest.httpsAgent; - // compatible: let `ky` forward the dispatcher to node's built-in `fetch`, - // which consumes its own `Request` natively — no wrapper, native perf + // majors match: hand the dispatcher to `ky`, which forwards it to the + // built-in `fetch` (`ky` deliberately keeps `dispatcher` out of its + // request-option registry so it reaches fetch) -- no wrapper needed if(builtinFetchCompatible) { return {...rest, dispatcher}; } From 68b237ca46b45be42c4554e936c330aa40280677 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 30 Jul 2026 20:06:26 +0000 Subject: [PATCH 09/15] Use "platform" instead of "built-in" for node's own undici. Rename `builtinFetchCompatible` to `platformFetchCompatible` and `builtinMajor` to `platformMajor`, and update the comments to match. Also update the 4.3.1 changelog entry, which still used the older "bundled" and "runtime" wording for the same two things. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +++++----- lib/agentCompatibility.js | 37 +++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d36b0..e08464e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## 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 +- Keep the platform `fetch` on the agent path when the platform undici and + the installed undici share a major version, handing the dispatcher to `ky` + to forward. Only fall back to the installed undici's own `fetch` when the + majors differ (e.g. node 26's platform undici 8 rejecting an installed + undici 6 dispatcher). Fixes the agent path on node 26 without regressing `fetch` performance on compatible runtimes. ## 4.3.0 - 2026-01-15 diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index faf6f31..fb339cc 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -5,15 +5,16 @@ import {Agent, fetch as undiciFetch, Request as UndiciRequest} from 'undici'; import undiciPkg from 'undici/package.json' with {type: 'json'}; import {versions} from 'node:process'; -// Background: node has its own copy of undici built in but does not expose -// it (there is no `node:undici`), so this package installs its own. A -// dispatcher only works with the undici that created it -- the handler -// contract changed across majors, so handing an installed v6 dispatcher to -// a built-in v7 or v8 `fetch` fails with "invalid onError method". Which -// major node has built in varies by release line (node 22 has 6, node 24 -// has 7, node 26 has 8), so no single installed version matches every -// supported runtime -- with undici 6 installed, both node 24 and node 26 -// take the fallback path below. See digitalbazaar/http-client#43. +// Background: node ships its own copy of undici in the platform but does +// not expose it (there is no `node:undici`), so this package installs its +// own. A dispatcher only works with the undici that created it -- the +// handler contract changed across majors, so handing an installed v6 +// dispatcher to a platform v7 or v8 `fetch` fails with "invalid onError +// method". Which major the platform provides varies by release line (node +// 22 has 6, node 24 has 7, node 26 has 8), so no single installed version +// matches every supported runtime -- with undici 6 installed, both node 24 +// and node 26 take the fallback path below. See +// digitalbazaar/http-client#43. // as long as an agent has a reference to it, its associated dispatcher will // be kept in this cache for reuse @@ -23,17 +24,17 @@ const AGENT_CACHE = new WeakMap(); const [major, minor] = versions.node.split('.').map(v => parseInt(v, 10)); const canConvert = (major > 18) || (major === 18 && minor >= 2); -// true when the installed and built-in undici majors match, meaning their +// true when the installed and platform undici majors match, meaning their // dispatchers are interchangeable. Both reads are guarded: a future undici // could hide `package.json` behind an `exports` map, and `versions.undici` // may be absent. Either way fall back to `false` and use the installed // undici's own fetch -- the always-safe path -- rather than throwing at // module load and breaking `import` for every consumer. -const builtinFetchCompatible = (() => { +const platformFetchCompatible = (() => { try { const installedMajor = parseInt(undiciPkg.version, 10); - const builtinMajor = parseInt(versions.undici, 10); - return builtinMajor === installedMajor; + const platformMajor = parseInt(versions.undici, 10); + return platformMajor === installedMajor; } catch{ return false; } @@ -69,13 +70,13 @@ export function convertAgent(options) { delete rest.httpsAgent; // majors match: hand the dispatcher to `ky`, which forwards it to the - // built-in `fetch` (`ky` deliberately keeps `dispatcher` out of its + // platform `fetch` (`ky` deliberately keeps `dispatcher` out of its // request-option registry so it reaches fetch) -- no wrapper needed - if(builtinFetchCompatible) { + if(platformFetchCompatible) { return {...rest, dispatcher}; } - // incompatible built-in `fetch` that rejects this dispatcher, so route + // incompatible platform `fetch` that rejects this dispatcher, so route // through the installed undici's own fetch via an override let fetch = AGENT_CACHE.get(dispatcher); if(!fetch) { @@ -87,10 +88,10 @@ export function convertAgent(options) { } // create fetch override uses custom `dispatcher`; when incompatible, the -// built-in `Request` that `ky` creates cannot be consumed by the installed +// platform `Request` that `ky` creates cannot be consumed by the installed // undici's fetch directly, so it is rebuilt as the installed undici's own // `Request` here. -// Passing the built-in `Request` as undici's `Request` *init* (its second +// Passing the platform `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 From eb04772b65b875cf98bfb11836e24acc70905d08 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 30 Jul 2026 17:12:06 -0400 Subject: [PATCH 10/15] Change pending release version. - Using a minor bump due to nature of the undici fix. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08464e..22b9855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # @digitalbazaar/http-client ChangeLog -## 4.3.1 - 2026-07-15 +## 4.4.0 - 2026-07-xx ### Fixed - Keep the platform `fetch` on the agent path when the platform undici and From 0b50fa7e160ebce83ecf5c843e96c55ef83bc9c4 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 30 Jul 2026 17:37:46 -0400 Subject: [PATCH 11/15] Update minor dependencies. --- CHANGELOG.md | 6 ++++++ package.json | 18 +++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b9855..7981edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 4.4.0 - 2026-07-xx +### Changed +- Update minor dependencies. + - `ky@1.14.3`. + - `undici@6.28.0`. + - All dev dependencies. + ### Fixed - Keep the platform `fetch` on the agent path when the platform undici and the installed undici share a major version, handing the dispatcher to `ky` diff --git a/package.json b/package.json index 8ea6b09..007cbd2 100644 --- a/package.json +++ b/package.json @@ -37,17 +37,17 @@ "dist/*" ], "dependencies": { - "ky": "^1.14.2", - "undici": "^6.23.0" + "ky": "^1.14.3", + "undici": "^6.28.0" }, "devDependencies": { - "@digitalbazaar/eslint-config": "^7.0.0", + "@digitalbazaar/eslint-config": "^7.0.1", "c8": "^10.1.3", "chai": "^4.5.0", - "cors": "^2.8.5", + "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", - "eslint": "^9.39.2", + "eslint": "^9.39.5", "express": "^5.2.1", "karma": "^6.4.4", "karma-chai": "^0.1.0", @@ -56,10 +56,10 @@ "karma-mocha-reporter": "^2.2.5", "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", - "mocha": "^11.7.5", - "rimraf": "^6.1.2", - "rollup": "^4.55.1", - "webpack": "^5.104.1" + "mocha": "^11.7.6", + "rimraf": "^6.1.3", + "rollup": "^4.62.3", + "webpack": "^5.109.2" }, "repository": { "type": "git", From 4e235fa32d11dabf62901c94b376d23dbeb0de9b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 31 Jul 2026 17:10:25 -0400 Subject: [PATCH 12/15] Update copyright lines. - Update years. - Removed "All rights reserved.". --- LICENSE | 3 +-- karma.conf.cjs | 2 +- lib/agentCompatibility-browser.js | 2 +- lib/agentCompatibility.js | 2 +- lib/httpClient.js | 2 +- lib/index.js | 2 +- tests/10-client-api.spec.cjs | 2 +- tests/10-client-api.spec.common.cjs | 2 +- tests/10-client-api.spec.js | 2 +- tests/utils-browser.cjs | 2 +- tests/utils.cjs | 2 +- 11 files changed, 11 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index 8569ca8..1206dc1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,4 @@ -Copyright (c) 2020, Digital Bazaar, Inc. -All rights reserved. +Copyright (c) 2020-2026, Digital Bazaar, Inc. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/karma.conf.cjs b/karma.conf.cjs index b9377fc..3686c1a 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ const {startServers} = require('./tests/utils.cjs'); diff --git a/lib/agentCompatibility-browser.js b/lib/agentCompatibility-browser.js index 9367263..20b9eb5 100644 --- a/lib/agentCompatibility-browser.js +++ b/lib/agentCompatibility-browser.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2022-2026 Digital Bazaar, Inc. */ // no-op for browsers export function convertAgent(options) { diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index fb339cc..6955ac0 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2022-2026 Digital Bazaar, Inc. */ import {Agent, fetch as undiciFetch, Request as UndiciRequest} from 'undici'; import undiciPkg from 'undici/package.json' with {type: 'json'}; diff --git a/lib/httpClient.js b/lib/httpClient.js index 37608a3..63d8d7b 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import {convertAgent} from './agentCompatibility.js'; import {deferred} from './deferred.js'; diff --git a/lib/index.js b/lib/index.js index 6a8cee2..7f71cc0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import { createInstance, diff --git a/tests/10-client-api.spec.cjs b/tests/10-client-api.spec.cjs index 01c30c1..9ea3151 100644 --- a/tests/10-client-api.spec.cjs +++ b/tests/10-client-api.spec.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ const {kyPromise, httpClient, DEFAULT_HEADERS} = require('..'); const isNode = require('detect-node'); diff --git a/tests/10-client-api.spec.common.cjs b/tests/10-client-api.spec.common.cjs index 090b0ed..3487cb5 100644 --- a/tests/10-client-api.spec.common.cjs +++ b/tests/10-client-api.spec.common.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ // common test for ESM and CommonJS exports.test = function({ diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index d9a97a5..5b4a932 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import { DEFAULT_HEADERS, diff --git a/tests/utils-browser.cjs b/tests/utils-browser.cjs index 582bf90..beee0ce 100644 --- a/tests/utils-browser.cjs +++ b/tests/utils-browser.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ 'use strict'; diff --git a/tests/utils.cjs b/tests/utils.cjs index 334c633..08d35bf 100644 --- a/tests/utils.cjs +++ b/tests/utils.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2018-2026 Digital Bazaar, Inc. */ 'use strict'; From 4bdd127b8ee811bff451f2a97e6d2466ec21ed4a Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 31 Jul 2026 21:42:13 +0000 Subject: [PATCH 13/15] Use block comments for the long explanation blocks. Convert the three multi-line explanatory `//` blocks in `lib/agentCompatibility.js` to `/* ... */`, matching the leading-asterisk style already used by the copyright and JSDoc headers. Short blocks, single-line comments, and commented-out code are left as `//`. Co-Authored-By: Claude Opus 5 (1M context) --- lib/agentCompatibility.js | 59 +++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 6955ac0..749f9a4 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -5,16 +5,17 @@ import {Agent, fetch as undiciFetch, Request as UndiciRequest} from 'undici'; import undiciPkg from 'undici/package.json' with {type: 'json'}; import {versions} from 'node:process'; -// Background: node ships its own copy of undici in the platform but does -// not expose it (there is no `node:undici`), so this package installs its -// own. A dispatcher only works with the undici that created it -- the -// handler contract changed across majors, so handing an installed v6 -// dispatcher to a platform v7 or v8 `fetch` fails with "invalid onError -// method". Which major the platform provides varies by release line (node -// 22 has 6, node 24 has 7, node 26 has 8), so no single installed version -// matches every supported runtime -- with undici 6 installed, both node 24 -// and node 26 take the fallback path below. See -// digitalbazaar/http-client#43. +/* +Background: node ships its own copy of undici in the platform but does not +expose it (there is no `node:undici`), so this package installs its own. A +dispatcher only works with the undici that created it -- the handler contract +changed across majors, so handing an installed v6 dispatcher to a platform v7 +or v8 `fetch` fails with "invalid onError method". Which major the platform +provides varies by release line (node 22 has 6, node 24 has 7, node 26 has 8), +so no single installed version matches every supported runtime -- with undici 6 +installed, both node 24 and node 26 take the fallback path below. See +digitalbazaar/http-client#43. +*/ // as long as an agent has a reference to it, its associated dispatcher will // be kept in this cache for reuse @@ -24,12 +25,14 @@ const AGENT_CACHE = new WeakMap(); const [major, minor] = versions.node.split('.').map(v => parseInt(v, 10)); const canConvert = (major > 18) || (major === 18 && minor >= 2); -// true when the installed and platform undici majors match, meaning their -// dispatchers are interchangeable. Both reads are guarded: a future undici -// could hide `package.json` behind an `exports` map, and `versions.undici` -// may be absent. Either way fall back to `false` and use the installed -// undici's own fetch -- the always-safe path -- rather than throwing at -// module load and breaking `import` for every consumer. +/* +true when the installed and platform undici majors match, meaning their +dispatchers are interchangeable. Both reads are guarded: a future undici could +hide `package.json` behind an `exports` map, and `versions.undici` may be +absent. Either way fall back to `false` and use the installed undici's own +fetch -- the always-safe path -- rather than throwing at module load and +breaking `import` for every consumer. +*/ const platformFetchCompatible = (() => { try { const installedMajor = parseInt(undiciPkg.version, 10); @@ -87,17 +90,19 @@ export function convertAgent(options) { return {...rest, fetch}; } -// create fetch override uses custom `dispatcher`; when incompatible, the -// platform `Request` that `ky` creates cannot be consumed by the installed -// undici's fetch directly, so it is rebuilt as the installed undici's own -// `Request` here. -// Passing the platform `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. +/* +create fetch override uses custom `dispatcher`; when incompatible, the platform +`Request` that `ky` creates cannot be consumed by the installed undici's fetch +directly, so it is rebuilt as the installed undici's own `Request` here. + +Passing the platform `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; From b3da80320e11e3236c2d465ff96cee610bd0ebec Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 31 Jul 2026 21:51:35 +0000 Subject: [PATCH 14/15] Use separate caches for dispatchers and fetch overrides. `AGENT_CACHE` had come to hold two kinds of entry: agents keyed to their dispatchers, and dispatchers keyed to their `fetch` overrides. Split it into `DISPATCHER_CACHE` and `FETCH_CACHE` so each map is homogeneous and its contents are clear when inspected. Lifetimes are unchanged: a dispatcher is strongly held by `DISPATCHER_CACHE` while its agent is reachable, so its `FETCH_CACHE` entry lives exactly as long as the agent. Co-Authored-By: Claude Opus 5 (1M context) --- lib/agentCompatibility.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 749f9a4..4416485 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -19,7 +19,12 @@ digitalbazaar/http-client#43. // as long as an agent has a reference to it, its associated dispatcher will // be kept in this cache for reuse -const AGENT_CACHE = new WeakMap(); +const DISPATCHER_CACHE = new WeakMap(); + +// on the fallback path, the `fetch` override built for a dispatcher is kept +// here for reuse; the dispatcher is held by DISPATCHER_CACHE for as long as +// its agent lives, so the override has the same lifetime as the agent +const FETCH_CACHE = new WeakMap(); // can only convert agent to dispatcher option on node 18.2+ const [major, minor] = versions.node.split('.').map(v => parseInt(v, 10)); @@ -61,10 +66,10 @@ export function convertAgent(options) { } // reuse the dispatcher built for this agent - let dispatcher = AGENT_CACHE.get(agent); + let dispatcher = DISPATCHER_CACHE.get(agent); if(!dispatcher) { dispatcher = new Agent({connect: agent.options}); - AGENT_CACHE.set(agent, dispatcher); + DISPATCHER_CACHE.set(agent, dispatcher); } // drop the converted legacy options so they are not forwarded to `fetch` @@ -81,11 +86,11 @@ export function convertAgent(options) { // incompatible platform `fetch` that rejects this dispatcher, so route // through the installed undici's own fetch via an override - let fetch = AGENT_CACHE.get(dispatcher); + let fetch = FETCH_CACHE.get(dispatcher); if(!fetch) { fetch = createFetch(dispatcher); fetch._httpClientCustomFetch = true; - AGENT_CACHE.set(dispatcher, fetch); + FETCH_CACHE.set(dispatcher, fetch); } return {...rest, fetch}; } From ee48613bd392eebac5ee83801e66c5cf5a5cbe3b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 31 Jul 2026 18:49:07 -0400 Subject: [PATCH 15/15] Fix style. 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 4416485..e05b4a3 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -31,7 +31,7 @@ const [major, minor] = versions.node.split('.').map(v => parseInt(v, 10)); const canConvert = (major > 18) || (major === 18 && minor >= 2); /* -true when the installed and platform undici majors match, meaning their +True when the installed and platform undici majors match, meaning their dispatchers are interchangeable. Both reads are guarded: a future undici could hide `package.json` behind an `exports` map, and `versions.undici` may be absent. Either way fall back to `false` and use the installed undici's own @@ -96,7 +96,7 @@ export function convertAgent(options) { } /* -create fetch override uses custom `dispatcher`; when incompatible, the platform +Create fetch override uses custom `dispatcher`; when incompatible, the platform `Request` that `ky` creates cannot be consumed by the installed undici's fetch directly, so it is rebuilt as the installed undici's own `Request` here.