Skip to content
Open
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
63 changes: 30 additions & 33 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -13,32 +23,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 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 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;
}
Expand Down Expand Up @@ -73,14 +68,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
// 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};
}

// 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);
Expand All @@ -90,10 +86,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
Expand Down