diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 40079ca..926cc02 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index f07a15e..7981edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index edf24c1..fb339cc 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -1,9 +1,21 @@ /*! * 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'; +// 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(); @@ -12,6 +24,22 @@ const AGENT_CACHE = new WeakMap(); 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{ + return false; + } +})(); + // converts `agent`/`httpsAgent` option to a dispatcher option export function convertAgent(options) { if(!canConvert) { @@ -29,24 +57,53 @@ 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; + + // 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 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) { - 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`; 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}); }; } diff --git a/package.json b/package.json index 8ea6b09..007cbd2 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/tests/10-client-api.spec.common.cjs b/tests/10-client-api.spec.common.cjs index 02b4ebc..090b0ed 100644 --- a/tests/10-client-api.spec.common.cjs +++ b/tests/10-client-api.spec.common.cjs @@ -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 () => { diff --git a/tests/utils.cjs b/tests/utils.cjs index c988f1e..334c633 100644 --- a/tests/utils.cjs +++ b/tests/utils.cjs @@ -111,5 +111,11 @@ function createApp() { }); }); + app.post('/echo', cors(), express.json(), (req, res) => { + res.json({ + echo: req.body + }); + }); + return app; }