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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# @digitalbazaar/http-client ChangeLog

## 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`
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

### Changed
Expand Down
3 changes: 1 addition & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion karma.conf.cjs
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
2 changes: 1 addition & 1 deletion lib/agentCompatibility-browser.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
99 changes: 83 additions & 16 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,53 @@
/*!
* Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2022-2026 Digital Bazaar, Inc.
*/
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';

/*
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();
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));
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.
*/
const platformFetchCompatible = (() => {
try {
const installedMajor = parseInt(undiciPkg.version, 10);
const platformMajor = parseInt(versions.undici, 10);
return platformMajor === installedMajor;
} catch{
Comment thread
dlongley marked this conversation as resolved.
return false;
}
})();

// converts `agent`/`httpsAgent` option to a dispatcher option
export function convertAgent(options) {
if(!canConvert) {
Expand All @@ -29,24 +65,55 @@ 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 = DISPATCHER_CACHE.get(agent);
if(!dispatcher) {
dispatcher = new Agent({connect: agent.options});
DISPATCHER_CACHE.set(agent, dispatcher);
}

// drop the converted legacy options so they are not forwarded to `fetch`
const rest = {...options};
delete rest.agent;
Comment thread
dlongley marked this conversation as resolved.
delete rest.httpsAgent;

// 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};
Comment thread
dlongley marked this conversation as resolved.
}

// incompatible platform `fetch` that rejects this dispatcher, so route
// through the installed undici's own fetch via an override
let fetch = FETCH_CACHE.get(dispatcher);
if(!fetch) {
const dispatcher = new Agent({connect: agent.options});
fetch = createFetch(dispatcher);
fetch._httpClientCustomFetch = true;
AGENT_CACHE.set(agent, fetch);
FETCH_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`; 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;
if(input && typeof input === 'object' && typeof input.url === 'string') {
input = new UndiciRequest(input.url, input);
}
return undiciFetch(input, {...init, dispatcher});
};
}
2 changes: 1 addition & 1 deletion lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2026 Digital Bazaar, Inc.
*/
import {
createInstance,
Expand Down
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/10-client-api.spec.cjs
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
26 changes: 25 additions & 1 deletion tests/10-client-api.spec.common.cjs
Original file line number Diff line number Diff line change
@@ -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({
Expand Down 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
2 changes: 1 addition & 1 deletion tests/10-client-api.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2026 Digital Bazaar, Inc.
*/
import {
DEFAULT_HEADERS,
Expand Down
2 changes: 1 addition & 1 deletion tests/utils-browser.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2023-2026 Digital Bazaar, Inc.
*/
'use strict';

Expand Down
8 changes: 7 additions & 1 deletion tests/utils.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*!
* Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2018-2026 Digital Bazaar, Inc.
*/
'use strict';

Expand Down Expand Up @@ -111,5 +111,11 @@ function createApp() {
});
});

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

return app;
}