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/LICENSE b/LICENSE index 8569ca8..1206dc1 100644 --- a/LICENSE +++ b/LICENSE @@ -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: diff --git a/karma.conf.cjs b/karma.conf.cjs index b9377fc..3686c1a 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -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'); diff --git a/lib/agentCompatibility-browser.js b/lib/agentCompatibility-browser.js index 9367263..20b9eb5 100644 --- a/lib/agentCompatibility-browser.js +++ b/lib/agentCompatibility-browser.js @@ -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) { diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index edf24c1..e05b4a3 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -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{ + return false; + } +})(); + // converts `agent`/`httpsAgent` option to a dispatcher option export function convertAgent(options) { if(!canConvert) { @@ -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; + 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 = 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}); }; } diff --git a/lib/httpClient.js b/lib/httpClient.js index 37608a3..63d8d7b 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -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'; diff --git a/lib/index.js b/lib/index.js index 6a8cee2..7f71cc0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import { createInstance, 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.cjs b/tests/10-client-api.spec.cjs index 01c30c1..9ea3151 100644 --- a/tests/10-client-api.spec.cjs +++ b/tests/10-client-api.spec.cjs @@ -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'); diff --git a/tests/10-client-api.spec.common.cjs b/tests/10-client-api.spec.common.cjs index 02b4ebc..3487cb5 100644 --- a/tests/10-client-api.spec.common.cjs +++ b/tests/10-client-api.spec.common.cjs @@ -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({ @@ -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/10-client-api.spec.js b/tests/10-client-api.spec.js index d9a97a5..5b4a932 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import { DEFAULT_HEADERS, diff --git a/tests/utils-browser.cjs b/tests/utils-browser.cjs index 582bf90..beee0ce 100644 --- a/tests/utils-browser.cjs +++ b/tests/utils-browser.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ 'use strict'; diff --git a/tests/utils.cjs b/tests/utils.cjs index c988f1e..08d35bf 100644 --- a/tests/utils.cjs +++ b/tests/utils.cjs @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2018-2026 Digital Bazaar, Inc. */ 'use strict'; @@ -111,5 +111,11 @@ function createApp() { }); }); + app.post('/echo', cors(), express.json(), (req, res) => { + res.json({ + echo: req.body + }); + }); + return app; }