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 a2067df..fb339cc 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -5,6 +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. + // 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,32 +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); -// 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 +// 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 nativeFetchCompatible = (() => { +const platformFetchCompatible = (() => { try { - const bundledMajor = parseInt(undiciPkg.version, 10); - const runtimeMajor = parseInt(versions.undici, 10); - return runtimeMajor === bundledMajor; + const installedMajor = parseInt(undiciPkg.version, 10); + const platformMajor = parseInt(versions.undici, 10); + return platformMajor === installedMajor; } catch{ return false; } @@ -73,14 +69,15 @@ 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) { + // majors match: hand the dispatcher to `ky`, which forwards it to the + // platform `fetch` (`ky` deliberately keeps `dispatcher` out of its + // request-option registry so it reaches fetch) -- no wrapper needed + if(platformFetchCompatible) { return {...rest, dispatcher}; } - // incompatible runtime `fetch` that rejects this dispatcher, so route - // through the bundled undici's own fetch via an override + // 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) { fetch = createFetch(dispatcher); @@ -90,10 +87,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 +// 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