Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 31 additions & 33 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down