Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
87 changes: 73 additions & 14 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/*!
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
*/
import {Agent} 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';

// as long as an agent has a reference to it, its associated dispatcher will
Expand All @@ -12,6 +13,37 @@ 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this text block be simplified? I find it hard to follow. Not sure the "today" wording makes sense.

// 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
// module load and breaking `import` for every consumer.
const nativeFetchCompatible = (() => {
try {
const bundledMajor = parseInt(undiciPkg.version, 10);
const runtimeMajor = parseInt(versions.undici, 10);
Comment on lines +39 to +40

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This naming is a bit confusing. Maybe "installed" and "builtin" would be better?

return runtimeMajor === bundledMajor;
} catch{
return false;
}
})();

// converts `agent`/`httpsAgent` option to a dispatcher option
export function convertAgent(options) {
if(!canConvert) {
Expand All @@ -29,24 +61,51 @@ 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};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't done previously (fetch had to be passed) ... but maybe this works now?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude had a long winded run of tests and explanation summarized as "Confirmed — works now. On node 22 convertAgent returns only {dispatcher} and the self-signed HTTPS tests pass; without the dispatcher the same request fails with DEPTH_ZERO_SELF_SIGNED_CERT, so the option is definitely reaching fetch. The custom fetch is only needed on the major-mismatch path."

}

// 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) {
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
// 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') {
input = new UndiciRequest(input.url, input);
}
return undiciFetch(input, {...init, dispatcher});
};
}
24 changes: 24 additions & 0 deletions tests/10-client-api.spec.common.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 6 additions & 0 deletions tests/utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,11 @@ function createApp() {
});
});

app.post('/echo', cors(), express.json(), (req, res) => {
res.json({
echo: req.body
});
});

return app;
}