From 27d8bb1593e401d8a80da0c11b04bd96d101fd40 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 18:16:12 -0400 Subject: [PATCH 01/39] Update eslint dependencies. --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- tests/.eslintrc.cjs | 8 -------- 3 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 tests/.eslintrc.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a76c1eb..92cb547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # @digitalbazaar/http-client ChangeLog +## 5.0.0 - 2026-xx-xx + +### Changed +- Update dev dependencies. + ## 4.4.0 - 2026-08-06 ### Changed diff --git a/package.json b/package.json index 0214abc..0353f96 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,13 @@ "undici": "^6.28.0" }, "devDependencies": { - "@digitalbazaar/eslint-config": "^7.0.1", + "@digitalbazaar/eslint-config": "^9.0.0", "c8": "^10.1.3", "chai": "^4.5.0", "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", - "eslint": "^9.39.5", + "eslint": "^10.8.1", "express": "^5.2.1", "karma": "^6.4.4", "karma-chai": "^0.1.0", diff --git a/tests/.eslintrc.cjs b/tests/.eslintrc.cjs deleted file mode 100644 index f76d0f3..0000000 --- a/tests/.eslintrc.cjs +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - env: { - mocha: true - }, - globals: { - should: true - } -}; From f04db822237f32bc9f952ec3f77aa846a7a4c021 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 6 Aug 2026 19:01:10 -0400 Subject: [PATCH 02/39] Update dev dependencies. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0353f96..1803a3c 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,9 @@ "karma-mocha-reporter": "^2.2.5", "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", - "mocha": "^11.7.6", + "mocha": "^11.8.0", "rimraf": "^6.1.3", - "rollup": "^4.62.3", + "rollup": "^4.62.4", "webpack": "^5.109.2" }, "repository": { From db9969245882c4f95bc4db4a6f08f7d596cddb15 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 6 Aug 2026 19:04:16 -0400 Subject: [PATCH 03/39] Fix lint issue. --- lib/agentCompatibility.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index e05b4a3..8864064 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -43,7 +43,7 @@ const platformFetchCompatible = (() => { const installedMajor = parseInt(undiciPkg.version, 10); const platformMajor = parseInt(versions.undici, 10); return platformMajor === installedMajor; - } catch{ + } catch { return false; } })(); From 9e1a1b1a3689e5a402093cf918cd328e9f6a3a59 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:15:51 -0400 Subject: [PATCH 04/39] Remove CJS support. --- CHANGELOG.md | 3 + package.json | 25 +- rollup.config.js | 24 - tests/10-client-api.spec.cjs | 9 - tests/10-client-api.spec.common.cjs | 459 ------------------ tests/10-client-api.spec.js | 451 ++++++++++++++++- tests/test-mocha.cjs | 2 - tests/{utils-browser.cjs => utils-browser.js} | 9 +- tests/{utils.cjs => utils.js} | 35 +- 9 files changed, 475 insertions(+), 542 deletions(-) delete mode 100644 rollup.config.js delete mode 100644 tests/10-client-api.spec.cjs delete mode 100644 tests/10-client-api.spec.common.cjs delete mode 100644 tests/test-mocha.cjs rename tests/{utils-browser.cjs => utils-browser.js} (82%) rename tests/{utils.cjs => utils.js} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cb547..a53a778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - Update dev dependencies. +### Removed +- **BREAKING**: Remove CJS support. + ## 4.4.0 - 2026-08-06 ### Changed diff --git a/package.json b/package.json index 1803a3c..dd98d31 100644 --- a/package.json +++ b/package.json @@ -4,27 +4,17 @@ "description": "An opinionated, isomorphic HTTP client.", "license": "BSD-3-Clause", "type": "module", - "main": "./dist/cjs/index.cjs", - "exports": { - "require": "./dist/cjs/index.cjs", - "import": "./lib/index.js" - }, + "main": "./lib/index.js", "browser": { "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js", - "./tests/utils.cjs": "./tests/utils-browser.cjs" + "./tests/utils.js": "./tests/utils-browser.js" }, "react-native": { "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js" }, "scripts": { - "rollup": "rollup -c rollup.config.js", - "build": "npm run clear && npm run rollup", - "clear": "rimraf dist/ && mkdir dist", - "prepare": "npm run build", - "rebuild": "npm run clear && npm run build", - "test": "npm run test-node && npm run test-node-cjs", + "test": "npm run test-node", "test-node": "cross-env NODE_ENV=test mocha --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.js tests/*.spec.js", - "test-node-cjs": "cross-env NODE_ENV=test mocha --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.cjs tests/*.spec.cjs", "test-karma": "karma start karma.conf.cjs", "test-watch": "cross-env NODE_ENV=test mocha --watch --parallel --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.js tests/*.spec.js", "coverage": "cross-env NODE_ENV=test c8 npm run test-node", @@ -33,8 +23,7 @@ "lint": "eslint" }, "files": [ - "lib/*", - "dist/*" + "lib/*" ], "dependencies": { "ky": "^1.14.3", @@ -42,8 +31,8 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "c8": "^10.1.3", - "chai": "^4.5.0", + "c8": "^12.0.0", + "chai": "^6.2.2", "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", @@ -57,8 +46,6 @@ "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", "mocha": "^11.8.0", - "rimraf": "^6.1.3", - "rollup": "^4.62.4", "webpack": "^5.109.2" }, "repository": { diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index 366fe25..0000000 --- a/rollup.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import pkg from './package.json' with {type: 'json'}; - -function preserveDynamicImportPlugin() { - return { - name: 'preserve-dynamic-import', - renderDynamicImport() { - return {left: 'import(', right: ')'}; - } - }; -} - -export default [ - { - input: './lib/index.js', - output: [ - { - file: 'dist/cjs/index.cjs', - format: 'cjs' - } - ], - plugins: [preserveDynamicImportPlugin()], - external: Object.keys(pkg.dependencies).concat(['crypto', 'util']) - } -]; diff --git a/tests/10-client-api.spec.cjs b/tests/10-client-api.spec.cjs deleted file mode 100644 index 9ea3151..0000000 --- a/tests/10-client-api.spec.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Copyright (c) 2020-2026 Digital Bazaar, Inc. - */ -const {kyPromise, httpClient, DEFAULT_HEADERS} = require('..'); -const isNode = require('detect-node'); -const {test} = require('./10-client-api.spec.common.cjs'); -const utils = require('./utils.cjs'); - -test({kyPromise, httpClient, DEFAULT_HEADERS, isNode, utils}); diff --git a/tests/10-client-api.spec.common.cjs b/tests/10-client-api.spec.common.cjs deleted file mode 100644 index 3487cb5..0000000 --- a/tests/10-client-api.spec.common.cjs +++ /dev/null @@ -1,459 +0,0 @@ -/*! - * Copyright (c) 2020-2026 Digital Bazaar, Inc. - */ -// common test for ESM and CommonJS -exports.test = function({ - kyPromise, httpClient, DEFAULT_HEADERS, isNode, utils -}) { - -/* eslint-disable @stylistic/indent */ -/* global after, before, describe, it, should */ -describe('http-client API', () => { - // start/close local test server - let serverInfo; - let httpHost; - let httpsHost; - before(async () => { - serverInfo = await utils.startServers(); - httpHost = serverInfo.httpHost; - httpsHost = serverInfo.httpsHost; - }); - after(async () => { - await Promise.all([ - serverInfo.httpServer.close(), - serverInfo.httpsServer.close() - ]); - }); - - let ky; - it('has proper exports', async () => { - ky = await kyPromise; - should.exist(ky); - DEFAULT_HEADERS.should.have.keys(['Accept']); - httpClient.should.be.a('function'); - ky.should.be.a('function'); - }); - - it('can ping HTTP test server', async () => { - let err; - let response; - const url = `http://${httpHost}/ping`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - }); - - // test HTTPS on github.com on node and browsers - // NOTE: might get rate limited - it('can use HTTPS on github.com', async () => { - let err; - let response; - const url = 'https://github.com/'; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - const ct = response.headers.get('content-type'); - should.exist(ct); - ct.includes('application/json').should.be.true; - }); - - if(isNode) { - // test local self-signed cert in node only - it('can ping HTTPS test server', async () => { - let err; - let response; - const url = `https://${httpsHost}/ping`; - try { - const agent = utils.makeAgent({ - rejectUnauthorized: false - }); - response = await httpClient.get(url, {agent}); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - 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 () => { - let err; - let response; - const url = `http://${httpHost}/status/404`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.toUpperCase().should.contain('NOT FOUND'); - should.exist(err.response); - should.exist(err.response.status); - should.exist(err.requestUrl); - err.requestUrl.should.equal(url); - err.response.status.should.equal(404); - }); - - it('handles a connection refused error', async () => { - let err; - let response; - // the intention here is to use an unused http port - // the port cannot be higher than 65535 (which is invalid) - const nonExistentResource = 'https://localhost:65535'; - const expectedErrorCode = 'ECONNREFUSED'; - // replace the default Accept with text/plain to get around - // possibly sending a CORS pre-flight - const headers = {Accept: 'text/plain'}; - try { - response = await httpClient.get(nonExistentResource, {headers}); - } catch(e) { - err = e; - } - should.not.exist( - response, 'Expected nonExistentResource to not return a response.'); - should.exist( - err, 'Expected nonExistentResource to error.'); - should.not.exist( - err.response, - 'Expected nonExistentResource "err.response" to not exist.' - ); - should.exist( - err.requestUrl, - 'Expected nonExistentResource "err.requestUrl" to exist.' - ); - err.requestUrl.should.equal( - nonExistentResource, - `Expected nonExistentResource "err.requestUrl" to be ` + - `${nonExistentResource}` - ); - // in node 18 global fetch places the error code in err.cause - const cause = err.cause || err; - // chrome's fetch errors don't contain a code at all - if(cause.code) { - cause.code.should.equal( - expectedErrorCode, - `Expected nonExistentResource "err.code" to be ${expectedErrorCode}.` - ); - } - }); - - if(!isNode) { - // browser check for endpoint without CORS - it.only('handles a CORS error', async () => { - let err; - let response; - const url = `http://${httpHost}/nocors`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.equal( - `Failed to fetch "${url}". Possible CORS error.`); - should.not.exist(err.response); - should.exist(err.requestUrl); - err.requestUrl.should.equal(url); - }); - } - - it('handles a TimeoutError error', async () => { - let err; - let response; - const url = `http://${httpHost}/delay/2`; - try { - response = await httpClient.get(url, { - timeout: 1000 - }); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.equal( - `Request to "${url}" timed out.`); - should.not.exist(err.response); - should.exist(err.requestUrl); - err.requestUrl.should.equal(url); - }); - - it('successfully makes request with default json headers', async () => { - let err; - let response; - const url = `http://${httpHost}/headers`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - should.exist(response.data.headers); - response.status.should.equal(200); - const {accept} = response.data.headers; - accept.should.equal('application/ld+json, application/json'); - }); - - it('successfully makes request with header that is overridden', async () => { - let err; - let response; - const url = `http://${httpHost}/headers`; - try { - response = await httpClient.get(url, { - headers: { - accept: 'text/html' - } - }); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - should.exist(response.data.headers); - response.status.should.equal(200); - const {accept} = response.data.headers; - accept.should.equal('text/html'); - }); - - it('can use create() to provide default headers', async () => { - let err; - let response; - const url = `http://${httpHost}/headers`; - try { - response = await httpClient.get(url, { - headers: { - accept: 'text/html' - } - }); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - should.exist(response.data.headers); - response.status.should.equal(200); - const {accept} = response.data.headers; - accept.should.equal('text/html'); - }); - - it('handles a successful get with JSON data', async () => { - let err; - let response; - const url = `http://${httpHost}/json`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - const ct = response.headers.get('content-type'); - should.exist(ct); - ct.includes('application/json').should.be.true; - }); - - it('handles a successful get with HTML data', async () => { - let err; - let response; - const url = `http://${httpHost}/html`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.not.exist(response.data); - should.exist(await response.text()); - response.status.should.equal(200); - const ct = response.headers.get('content-type'); - should.exist(ct); - ct.includes('text/html').should.be.true; - }); - - it('handles a successful direct get', async () => { - let err; - let response; - const url = `http://${httpHost}/json`; - try { - response = await httpClient(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - }); - - it('handles a get not found error with JSON data', async () => { - let err; - let response; - const url = `http://${httpHost}/404`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.contain('404 Not Found'); - should.exist(err.response); - should.exist(err.response.status); - should.exist(err.status); - err.status.should.equal(404); - should.exist(err.data); - err.data.should.be.an('object'); - // these are API specific from the JSON body of the response - err.data.should.have.keys(['code', 'description']); - err.data.code.should.equal(404); - err.data.description.should.equal('Not Found'); - }); - - it('handles a direct get not found error with JSON data', async () => { - let err; - let response; - const url = `http://${httpHost}/404`; - try { - response = await httpClient(url); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.contain('404 Not Found'); - should.exist(err.response); - should.exist(err.response.status); - should.exist(err.status); - err.status.should.equal(404); - should.exist(err.data); - err.data.should.be.an('object'); - // these are API specific from the JSON body of the response - err.data.should.have.keys(['code', 'description']); - err.data.code.should.equal(404); - err.data.description.should.equal('Not Found'); - }); - - if(isNode) { - describe('Nodejs execution context', () => { - it('handles a network error', async () => { - let err; - let response; - try { - response = await httpClient.get( - 'http://localhost:9876/does-not-exist'); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.satisfy(m => - m.includes( - 'request to http://localhost:9876/does-not-exist failed, reason: ' + - 'connect ECONNREFUSED 127.0.0.1:9876') || - // node 18.x + - m.includes('fetch failed')); - }); - }); - } else { - describe('Browser execution context', () => { - it('should give a meaningful CORS error', async () => { - let err; - let response; - try { - response = await httpClient.get('https://example.com'); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - // failed to fetch may commonly be due to an issue with CORS - err.message.should - .equal('Failed to fetch "https://example.com". Possible CORS error.'); - }); - }); - } - - describe('extend (custom client)', () => { - it('adds an Authorization header to all requests', async () => { - const accessToken = '12345'; - - const client = httpClient.extend({ - headers: {Authorization: `Bearer ${accessToken}`} - }); - - let err; - let response; - const url = `http://${httpHost}/headers`; - try { - response = await client.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - should.exist(response.data.headers); - response.status.should.equal(200); - const {authorization: authzHeader} = response.data.headers; - authzHeader.should.equal('Bearer 12345'); - }); - }); -}); - -}; diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 5b4a932..ed78885 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -7,7 +7,452 @@ import { kyPromise } from '../lib/index.js'; import isNode from 'detect-node'; -import {test} from './10-client-api.spec.common.cjs'; -import utils from './utils.cjs'; +import * as utils from './utils.js'; -test({kyPromise, httpClient, DEFAULT_HEADERS, isNode, utils}); +describe('http-client API', () => { + // start/close local test server + let serverInfo; + let httpHost; + let httpsHost; + before(async () => { + serverInfo = await utils.startServers(); + httpHost = serverInfo.httpHost; + httpsHost = serverInfo.httpsHost; + }); + after(async () => { + await Promise.all([ + serverInfo.httpServer.close(), + serverInfo.httpsServer.close() + ]); + }); + + let ky; + it('has proper exports', async () => { + ky = await kyPromise; + should.exist(ky); + DEFAULT_HEADERS.should.have.keys(['Accept']); + httpClient.should.be.a('function'); + ky.should.be.a('function'); + }); + + it('can ping HTTP test server', async () => { + let err; + let response; + const url = `http://${httpHost}/ping`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + }); + + // test HTTPS on github.com on node and browsers + // NOTE: might get rate limited + it('can use HTTPS on github.com', async () => { + let err; + let response; + const url = 'https://github.com/'; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + const ct = response.headers.get('content-type'); + should.exist(ct); + ct.includes('application/json').should.be.true; + }); + + if(isNode) { + // test local self-signed cert in node only + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/ping`; + try { + const agent = utils.makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.get(url, {agent}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + 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 () => { + let err; + let response; + const url = `http://${httpHost}/status/404`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.toUpperCase().should.contain('NOT FOUND'); + should.exist(err.response); + should.exist(err.response.status); + should.exist(err.requestUrl); + err.requestUrl.should.equal(url); + err.response.status.should.equal(404); + }); + + it('handles a connection refused error', async () => { + let err; + let response; + // the intention here is to use an unused http port + // the port cannot be higher than 65535 (which is invalid) + const nonExistentResource = 'https://localhost:65535'; + const expectedErrorCode = 'ECONNREFUSED'; + // replace the default Accept with text/plain to get around + // possibly sending a CORS pre-flight + const headers = {Accept: 'text/plain'}; + try { + response = await httpClient.get(nonExistentResource, {headers}); + } catch(e) { + err = e; + } + should.not.exist( + response, 'Expected nonExistentResource to not return a response.'); + should.exist( + err, 'Expected nonExistentResource to error.'); + should.not.exist( + err.response, + 'Expected nonExistentResource "err.response" to not exist.' + ); + should.exist( + err.requestUrl, + 'Expected nonExistentResource "err.requestUrl" to exist.' + ); + err.requestUrl.should.equal( + nonExistentResource, + `Expected nonExistentResource "err.requestUrl" to be ` + + `${nonExistentResource}` + ); + // in node 18 global fetch places the error code in err.cause + const cause = err.cause || err; + // chrome's fetch errors don't contain a code at all + if(cause.code) { + cause.code.should.equal( + expectedErrorCode, + `Expected nonExistentResource "err.code" to be ${expectedErrorCode}.` + ); + } + }); + + if(!isNode) { + // browser check for endpoint without CORS + it.only('handles a CORS error', async () => { + let err; + let response; + const url = `http://${httpHost}/nocors`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.equal( + `Failed to fetch "${url}". Possible CORS error.`); + should.not.exist(err.response); + should.exist(err.requestUrl); + err.requestUrl.should.equal(url); + }); + } + + it('handles a TimeoutError error', async () => { + let err; + let response; + const url = `http://${httpHost}/delay/2`; + try { + response = await httpClient.get(url, { + timeout: 1000 + }); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.equal( + `Request to "${url}" timed out.`); + should.not.exist(err.response); + should.exist(err.requestUrl); + err.requestUrl.should.equal(url); + }); + + it('successfully makes request with default json headers', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + should.exist(response.data.headers); + response.status.should.equal(200); + const {accept} = response.data.headers; + accept.should.equal('application/ld+json, application/json'); + }); + + it('successfully makes request with header that is overridden', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await httpClient.get(url, { + headers: { + accept: 'text/html' + } + }); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + should.exist(response.data.headers); + response.status.should.equal(200); + const {accept} = response.data.headers; + accept.should.equal('text/html'); + }); + + it('can use create() to provide default headers', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await httpClient.get(url, { + headers: { + accept: 'text/html' + } + }); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + should.exist(response.data.headers); + response.status.should.equal(200); + const {accept} = response.data.headers; + accept.should.equal('text/html'); + }); + + it('handles a successful get with JSON data', async () => { + let err; + let response; + const url = `http://${httpHost}/json`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + const ct = response.headers.get('content-type'); + should.exist(ct); + ct.includes('application/json').should.be.true; + }); + + it('handles a successful get with HTML data', async () => { + let err; + let response; + const url = `http://${httpHost}/html`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.not.exist(response.data); + should.exist(await response.text()); + response.status.should.equal(200); + const ct = response.headers.get('content-type'); + should.exist(ct); + ct.includes('text/html').should.be.true; + }); + + it('handles a successful direct get', async () => { + let err; + let response; + const url = `http://${httpHost}/json`; + try { + response = await httpClient(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + }); + + it('handles a get not found error with JSON data', async () => { + let err; + let response; + const url = `http://${httpHost}/404`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.contain('404 Not Found'); + should.exist(err.response); + should.exist(err.response.status); + should.exist(err.status); + err.status.should.equal(404); + should.exist(err.data); + err.data.should.be.an('object'); + // these are API specific from the JSON body of the response + err.data.should.have.keys(['code', 'description']); + err.data.code.should.equal(404); + err.data.description.should.equal('Not Found'); + }); + + it('handles a direct get not found error with JSON data', async () => { + let err; + let response; + const url = `http://${httpHost}/404`; + try { + response = await httpClient(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.contain('404 Not Found'); + should.exist(err.response); + should.exist(err.response.status); + should.exist(err.status); + err.status.should.equal(404); + should.exist(err.data); + err.data.should.be.an('object'); + // these are API specific from the JSON body of the response + err.data.should.have.keys(['code', 'description']); + err.data.code.should.equal(404); + err.data.description.should.equal('Not Found'); + }); + + if(isNode) { + describe('Nodejs execution context', () => { + it('handles a network error', async () => { + let err; + let response; + try { + response = await httpClient.get( + 'http://localhost:9876/does-not-exist'); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.satisfy(m => + m.includes( + 'request to http://localhost:9876/does-not-exist failed, reason: ' + + 'connect ECONNREFUSED 127.0.0.1:9876') || + // node 18.x + + m.includes('fetch failed')); + }); + }); + } else { + describe('Browser execution context', () => { + it('should give a meaningful CORS error', async () => { + let err; + let response; + try { + response = await httpClient.get('https://example.com'); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + // failed to fetch may commonly be due to an issue with CORS + err.message.should + .equal('Failed to fetch "https://example.com". Possible CORS error.'); + }); + }); + } + + describe('extend (custom client)', () => { + it('adds an Authorization header to all requests', async () => { + const accessToken = '12345'; + + const client = httpClient.extend({ + headers: {Authorization: `Bearer ${accessToken}`} + }); + + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await client.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + should.exist(response.data.headers); + response.status.should.equal(200); + const {authorization: authzHeader} = response.data.headers; + authzHeader.should.equal('Bearer 12345'); + }); + }); +}); diff --git a/tests/test-mocha.cjs b/tests/test-mocha.cjs deleted file mode 100644 index db87e62..0000000 --- a/tests/test-mocha.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const {should} = require('chai'); -global.should = should(); diff --git a/tests/utils-browser.cjs b/tests/utils-browser.js similarity index 82% rename from tests/utils-browser.cjs rename to tests/utils-browser.js index beee0ce..498f606 100644 --- a/tests/utils-browser.cjs +++ b/tests/utils-browser.js @@ -1,12 +1,7 @@ /*! * Copyright (c) 2023-2026 Digital Bazaar, Inc. */ -'use strict'; - -const api = {}; -module.exports = api; - -api.startServers = async () => { +export async function startServers() { return { // mock server // karma will startup real server @@ -22,4 +17,4 @@ api.startServers = async () => { httpHost: process.env.TEST_HTTP_HOST, httpsHost: process.env.TEST_HTTPS_HOST }; -}; +} diff --git a/tests/utils.cjs b/tests/utils.js similarity index 80% rename from tests/utils.cjs rename to tests/utils.js index 08d35bf..589e342 100644 --- a/tests/utils.cjs +++ b/tests/utils.js @@ -1,20 +1,15 @@ /*! * Copyright (c) 2018-2026 Digital Bazaar, Inc. */ -'use strict'; - -const {setTimeout} = require('node:timers/promises'); -const cors = require('cors'); -const express = require('express'); -const fs = require('node:fs').promises; -const http = require('node:http'); -const https = require('node:https'); -const path = require('node:path'); - -const api = {}; -module.exports = api; - -api.startServers = async () => { +import cors from 'cors'; +import express from 'express'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import {setTimeout} from 'node:timers/promises'; + +export async function startServers() { let _httpResolve; let _httpsResolve; const _httpStarted = new Promise(resolve => { @@ -23,8 +18,10 @@ api.startServers = async () => { const _httpsStarted = new Promise(resolve => { _httpsResolve = resolve; }); - const key = await fs.readFile(path.join(__dirname, './test-server.key')); - const cert = await fs.readFile(path.join(__dirname, './test-server.crt')); + const key = + await fs.readFile(path.join(import.meta.dirname, './test-server.key')); + const cert = + await fs.readFile(path.join(import.meta.dirname, './test-server.crt')); const app = createApp(); const httpServer = http.createServer(app).listen({ host: '0.0.0.0', @@ -53,11 +50,11 @@ api.startServers = async () => { httpHost, httpsHost }; -}; +} -api.makeAgent = options => { +export function makeAgent(options) { return https.Agent(options); -}; +} function createApp() { const app = express(); From 6c3f2d0aad4a55d7f0bd35c066ccab9ce3a26e21 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:24:04 -0400 Subject: [PATCH 05/39] Update supported versions. - Test on Node.js >=22. - Update `engines.node` to `>=22`. - Update README requirements section. --- .github/workflows/main.yaml | 2 +- CHANGELOG.md | 5 +++++ README.md | 23 +++++++++++++++++++++++ package.json | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 926cc02..7756952 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, 26.x] + node-version: [22.x, 24.x, 26.x] steps: - uses: actions/checkout@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index a53a778..920b877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Changed - Update dev dependencies. +- Update README.md. +- **NOTE**: Update supported platforms. + - Test on Node.js >=22. + - Update `engines.node` to `>=22`. + - Update README requirements section. ### Removed - **BREAKING**: Remove CJS support. diff --git a/README.md b/README.md index 2ed472b..2d019b5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,29 @@ # http-client An opinionated, isomorphic HTTP client for Node.js, browsers, and React Native. +## Install + +This software requires and supports maintained recent versions of Node.js and +browsers. Updates may remove support for older unmaintained platform versions. +Please use dependency version lock files and testing to ensure compatibility +with this software. + +To install from NPM: + +https://www.npmjs.com/package/@digitalbazaar/http-client + +```sh +npm install @digitalbazaar/http-client +``` + +To install locally (for development): + +```sh +git clone https://github.com/digitalbazaar/http-client.git +cd http-client +npm install +``` + ### Usage #### Import httpClient (Node.js, browsers, or React Native) diff --git a/package.json b/package.json index dd98d31..66e6fb8 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ }, "homepage": "https://github.com/digitalbazaar/http-client", "engines": { - "node": ">=18.0" + "node": ">=22" }, "c8": { "reporter": [ From cde63c635fba0405645d502b6c43cbb3bcd5c2a3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:37:53 -0400 Subject: [PATCH 06/39] Update README. - Improve formatting. - Add more common README sections. --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d019b5..df07eee 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ -# http-client -An opinionated, isomorphic HTTP client for Node.js, browsers, and React Native. +# http-client _(@digitalbazaar/http-client)_ + +> An opinionated, isomorphic HTTP client for Node.js, browsers, and React Native. + +## Table of Contents + +- [Install](#install) +- [Usage](#usage) +- [Contribute](#contribute) +- [Commercial Support](#commercial-support) +- [License](#license) ## Install @@ -27,11 +36,13 @@ npm install ### Usage #### Import httpClient (Node.js, browsers, or React Native) + ```js import {httpClient} from '@digitalbazaar/http-client'; ``` #### Import and initialize a custom Bearer Token client + ```js import {httpClient} from '@digitalbazaar/http-client'; @@ -44,6 +55,7 @@ const client = httpClient.extend({headers}); ``` #### Disable self-signed TLS/SSL cert checks for development purposes only + ```js import {Agent} from 'https'; import {httpClient} from '@digitalbazaar/http-client'; @@ -55,6 +67,7 @@ const client = httpClient.extend({headers, agent}); ``` #### GET a JSON response in the browser + ```js try { const response = await httpClient.get('http://httpbin.org/json'); @@ -68,6 +81,7 @@ try { ``` #### GET a JSON response in Node with an HTTP Agent + ```js import https from 'https'; // use an agent to avoid self-signed certificate errors @@ -84,6 +98,7 @@ try { ``` #### GET HTML by overriding default headers + ```js const headers = {Accept: 'text/html'}; try { @@ -99,6 +114,7 @@ try { ``` #### POST a JSON payload + ```js try { const response = await httpClient.post('http://httpbin.org/json', { @@ -115,6 +131,7 @@ try { ``` #### POST a JSON payload in Node with an HTTP Agent + ```js import https from 'https'; // use an agent to avoid self-signed certificate errors @@ -133,3 +150,21 @@ try { throw e; } ``` + +## Contribute + +See [the contribute file](https://github.com/digitalbazaar/bedrock/blob/master/CONTRIBUTING.md)! + +PRs accepted. + +If editing the Readme, please conform to the +[standard-readme](https://github.com/RichardLitt/standard-readme) specification. + +## Commercial Support + +Commercial support for this library is available upon request from +Digital Bazaar: support@digitalbazaar.com + +## License + +[New BSD License (3-clause)](LICENSE) © 2026 Digital Bazaar From 5a465d90cb983e5867f2d2de4201893076f83d8a Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:36:45 -0400 Subject: [PATCH 07/39] Revert CJS related workarounds from v3.0.0. - `kyOriginalPromise` no longer exported. - `ky` is again exported. - Change from using `ky` promises to regular instances. --- CHANGELOG.md | 4 +++ lib/deferred.js | 18 ------------- lib/httpClient.js | 45 ++++++++++++++------------------- lib/index.js | 4 +-- tests/10-client-api.spec.js | 6 ++--- tests/deferred.spec.js | 50 ------------------------------------- 6 files changed, 27 insertions(+), 100 deletions(-) delete mode 100644 lib/deferred.js delete mode 100644 tests/deferred.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 920b877..2efe7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## 5.0.0 - 2026-xx-xx ### Changed +- **BREAKING**: Revert CJS related workarounds from v3.0.0. + - `kyPromise` no longer exported. + - `ky` is again exported. + - Change from using `ky` promises to regular instances. - Update dev dependencies. - Update README.md. - **NOTE**: Update supported platforms. diff --git a/lib/deferred.js b/lib/deferred.js deleted file mode 100644 index 3a41152..0000000 --- a/lib/deferred.js +++ /dev/null @@ -1,18 +0,0 @@ -export function deferred(f) { - let promise; - - return { - then( - onfulfilled, - onrejected - ) { - // Use logical OR assignment when Node.js 14.x support is dropped - //promise ||= new Promise(resolve => resolve(f())); - promise || (promise = new Promise(resolve => resolve(f()))); - return promise.then( - onfulfilled, - onrejected - ); - } - }; -} diff --git a/lib/httpClient.js b/lib/httpClient.js index 63d8d7b..9805403 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -2,10 +2,9 @@ * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import {convertAgent} from './agentCompatibility.js'; -import {deferred} from './deferred.js'; +import ky from 'ky'; -export const kyOriginalPromise = deferred(() => import('ky') - .then(({default: ky}) => ky)); +export {ky}; export const DEFAULT_HEADERS = { Accept: 'application/ld+json, application/json' @@ -21,40 +20,36 @@ const PROXY_METHODS = new Set([ * other default overrides. * * @param {object} [options={}] - Options hashmap. - * @param {object} [options.parent] - The ky promise to inherit from. + * @param {object} [options.parent] - The ky instance to inherit from. * @param {object} [options.headers={}] - Default header overrides. * @param {object} [options.params] - Other default overrides. * * @returns {Function} Custom httpClient instance. */ export function createInstance({ - parent = kyOriginalPromise, headers = {}, ...params + parent = ky, headers = {}, ...params } = {}) { // convert legacy agent options params = convertAgent(params); - // create new ky instance that will asynchronously resolve - const kyPromise = deferred(() => parent.then(kyBase => { - let ky; - if(parent === kyOriginalPromise) { - // ensure default headers, allow overrides - ky = kyBase.create({ - headers: {...DEFAULT_HEADERS, ...headers}, - ...params - }); - } else { - // extend parent - ky = kyBase.extend({headers, ...params}); - } - return ky; - })); + // create new ky instance + let _ky; + if(parent === ky) { + // ensure default headers, allow overrides + _ky = parent.create({ + headers: {...DEFAULT_HEADERS, ...headers}, + ...params + }); + } else { + // extend parent + _ky = parent.extend({headers, ...params}); + } - return _createHttpClient(kyPromise); + return _createHttpClient(_ky); } -function _createHttpClient(kyPromise) { +function _createHttpClient(ky) { async function httpClient(...args) { - const ky = await kyPromise; const method = ((args[1] && args[1].method) || 'get').toLowerCase(); if(PROXY_METHODS.has(method)) { return httpClient[method].apply(ky[method], args); @@ -67,7 +62,6 @@ function _createHttpClient(kyPromise) { for(const method of PROXY_METHODS) { httpClient[method] = async function(...args) { - const ky = await kyPromise; return _handleResponse(ky[method], ky, args); }; } @@ -77,13 +71,12 @@ function _createHttpClient(kyPromise) { }; httpClient.extend = function({headers = {}, ...params}) { - return createInstance({parent: kyPromise, headers, ...params}); + return createInstance({parent: ky, headers, ...params}); }; // default async `stop` signal getter Object.defineProperty(httpClient, 'stop', { async get() { - const ky = await kyPromise; return ky.stop; } }); diff --git a/lib/index.js b/lib/index.js index 7f71cc0..3df0d75 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,9 +4,9 @@ import { createInstance, DEFAULT_HEADERS, - kyOriginalPromise + ky } from './httpClient.js'; -export {kyOriginalPromise as kyPromise, DEFAULT_HEADERS}; +export {ky, DEFAULT_HEADERS}; export const httpClient = createInstance(); diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index ed78885..7cb7df5 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,13 +1,13 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ +import * as utils from './utils.js'; import { DEFAULT_HEADERS, httpClient, - kyPromise + ky } from '../lib/index.js'; import isNode from 'detect-node'; -import * as utils from './utils.js'; describe('http-client API', () => { // start/close local test server @@ -26,9 +26,7 @@ describe('http-client API', () => { ]); }); - let ky; it('has proper exports', async () => { - ky = await kyPromise; should.exist(ky); DEFAULT_HEADERS.should.have.keys(['Accept']); httpClient.should.be.a('function'); diff --git a/tests/deferred.spec.js b/tests/deferred.spec.js deleted file mode 100644 index 04e6adc..0000000 --- a/tests/deferred.spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import {deferred} from '../lib/deferred.js'; - -describe('deferred()', () => { - it('resolves to the return value of its function', async () => { - const d = deferred(() => { - return 'return value'; - }); - - const ret = await d; - ret.should.equal('return value'); - }); - - it('defers execution until awaited', async () => { - let executionCount = 0; - executionCount.should.equal(0); - - const d = deferred(() => { - executionCount++; - return 'return value'; - }); - - executionCount.should.equal(0); - await d; - executionCount.should.equal(1); - }); - - it('only executes once', async () => { - let executionCount = 0; - executionCount.should.equal(0); - - const d = deferred(() => { - executionCount++; - return 'return value'; - }); - - await d; - await d; - executionCount.should.equal(1); - }); - - it('unwraps returned promises', async () => { - const d = deferred(() => { - return Promise.resolve('return value'); - }); - - const ret = await d; - ret.should.equal('return value'); - }); -}); - From 46f541c446df153ab7dbbe22afb38bb5aad2133d Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:55:37 -0400 Subject: [PATCH 08/39] Update to `ky@2`. --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efe7f6..54e9ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - `kyPromise` no longer exported. - `ky` is again exported. - Change from using `ky` promises to regular instances. +- Update dependencies: + - `ky@2`. + - **BREAKING**: See `ky` docs for exported `ky` API changes. For most use + cases the wrapped API is expected to be the same. - Update dev dependencies. - Update README.md. - **NOTE**: Update supported platforms. diff --git a/package.json b/package.json index 66e6fb8..e601ae5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lib/*" ], "dependencies": { - "ky": "^1.14.3", + "ky": "^2.0.2", "undici": "^6.28.0" }, "devDependencies": { From a18c11e5faf6470a1e4afa824c5a3c20432b8f63 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:23:50 -0400 Subject: [PATCH 09/39] Update checked error messages for test. --- tests/10-client-api.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 7cb7df5..1c94446 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -405,7 +405,11 @@ describe('http-client API', () => { 'request to http://localhost:9876/does-not-exist failed, reason: ' + 'connect ECONNREFUSED 127.0.0.1:9876') || // node 18.x + - m.includes('fetch failed')); + m.includes('fetch failed') || + // node 22+ / ky@2 + m.includes( + 'Request failed due to a network error: ' + + 'GET http://localhost:9876/does-not-exist')); }); }); } else { From 2e4cec6ffcb5e9a6ad26f92c3893dea9a688e4ea Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:25:04 -0400 Subject: [PATCH 10/39] Fix error handling. - After `ky@2` update the error body is already available in `error.data` and trying to get JSON again will fail. --- lib/httpClient.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index 9805403..05a57b6 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -137,11 +137,9 @@ async function _handleError({error, url}) { const contentType = error.response.headers.get('content-type'); if(contentType && contentType.includes('json')) { - const errorBody = await error.response.json(); // the HTTPError received from ky has a generic message based on status // use that if the JSON body does not include a message - error.message = errorBody.message || error.message; - error.data = errorBody; + error.message = error.data?.message || error.message; } throw error; } From 8998287446a02461eb647684b0b630d367e74916 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 17:06:51 -0400 Subject: [PATCH 11/39] Update proxied method list. Remove `push`; it was never an HTTP method. The list mirrors the helpers `ky` implements, so add a test that it cannot drift from `ky`'s registry, and one covering a non-proxied method via `httpClient(url, {method})`. --- CHANGELOG.md | 1 + lib/httpClient.js | 14 +++++++++++-- tests/10-client-api.spec.js | 41 +++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e9ca4..eefae0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `kyPromise` no longer exported. - `ky` is again exported. - Change from using `ky` promises to regular instances. +- **BREAKING**: Remove `push` from the proxied method list. - Update dependencies: - `ky@2`. - **BREAKING**: See `ky` docs for exported `ky` API changes. For most use diff --git a/lib/httpClient.js b/lib/httpClient.js index 05a57b6..2a13654 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -10,9 +10,19 @@ export const DEFAULT_HEADERS = { Accept: 'application/ld+json, application/json' }; -// methods to proxy from ky +/* +Methods to proxy from `ky`. This must mirror `ky`'s own helper registry +(`requestMethods` in its `core/constants.js`), because proxying indexes into +`ky[method]` -- a name `ky` does not implement would throw on first call. + +`ky` accepts more methods than it exposes helpers for: `options` and `trace` +are in its `HttpMethod` type and its retry defaults, and `query` is on `ky`'s +main branch but unreleased as of `ky@2.0.2`. Those are reached through +`httpClient(url, {method})`, which falls through to `ky` directly, rather +than by being listed here. +*/ const PROXY_METHODS = new Set([ - 'get', 'post', 'put', 'push', 'patch', 'head', 'delete' + 'get', 'post', 'put', 'patch', 'head', 'delete' ]); /** diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 1c94446..b36387c 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -33,6 +33,47 @@ describe('http-client API', () => { ky.should.be.a('function'); }); + // guards against the proxied set drifting ahead of `ky`'s helper registry: + // proxying indexes into `ky[method]`, so a name `ky` does not implement + // would throw on first call rather than fail here + it('proxies only methods that `ky` implements', async () => { + const proxied = [ + 'get', 'post', 'put', 'patch', 'head', 'delete' + ]; + for(const method of proxied) { + ky[method].should.be.a('function', `ky.${method} is missing`); + httpClient[method].should.be.a( + 'function', `httpClient.${method} is missing`); + } + // methods `ky` has no helper for must not be proxied + for(const method of ['query', 'options', 'trace']) { + should.not.exist(ky[method], `ky.${method} unexpectedly exists`); + should.not.exist( + httpClient[method], `httpClient.${method} must not be proxied`); + } + }); + + if(isNode) { + // `ky` supports `options` as a `method` value but exposes no helper for + // it, so it has to reach `ky` through the direct-call fall-through. + // Node only: in a browser this needs the server to list OPTIONS in its + // CORS `Access-Control-Allow-Methods`, which the default `cors()` used by + // the test server does not. + it('supports a non-proxied method via the `method` option', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await httpClient(url, {method: 'options'}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(204); + }); + } + it('can ping HTTP test server', async () => { let err; let response; From 014ff19f319e42d25e05209a1c40a58d0a181a87 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:13:48 +0000 Subject: [PATCH 12/39] Fix case-insensitive header merging. ky@2 merges header options via a plain object spread when both sides are still plain objects, which does not dedupe names that differ only by case (e.g. `Accept` vs `accept`), causing values to be appended instead of overridden. Use a `Headers` instance instead. --- lib/httpClient.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index 2a13654..bb4afde 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -47,12 +47,16 @@ export function createInstance({ if(parent === ky) { // ensure default headers, allow overrides _ky = parent.create({ - headers: {...DEFAULT_HEADERS, ...headers}, + // use a `Headers` instance (instead of a plain object) so ky merges + // per-request headers case-insensitively; ky's plain-object merge + // path does a `{...a, ...b}` spread, which does not dedupe header + // names that differ only by case (e.g. `Accept` vs `accept`) + headers: new Headers({...DEFAULT_HEADERS, ...headers}), ...params }); } else { // extend parent - _ky = parent.extend({headers, ...params}); + _ky = parent.extend({headers: new Headers(headers), ...params}); } return _createHttpClient(_ky); From 6c3b445663da1590620514ee98d04bbc88b5620b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:40:17 -0400 Subject: [PATCH 13/39] Disable CJS CI tests. --- .github/workflows/main.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 7756952..96d5d7d 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -41,10 +41,8 @@ jobs: uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - - name: Run ESM test with Node.js ${{ matrix.node-version }} + - name: Run tests with Node.js ${{ matrix.node-version }} run: npm run test-node - - name: Run CJS test with Node.js ${{ matrix.node-version }} - run: npm run test-node-cjs test-karma: runs-on: ubuntu-latest timeout-minutes: 10 From ed16df9e8c14eafd72a8678fd712155bf320ce4c Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:41:21 -0400 Subject: [PATCH 14/39] Fix karma config. - Fix import. - Use suggested ChromeHeadless options for CI. --- karma.conf.cjs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/karma.conf.cjs b/karma.conf.cjs index 3686c1a..6d0f883 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -2,7 +2,7 @@ * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -const {startServers} = require('./tests/utils.cjs'); +const {startServers} = require('./tests/utils.js'); const webpack = require('webpack'); module.exports = async function(config) { @@ -70,7 +70,21 @@ module.exports = async function(config) { // start these browsers // browser launchers: https://npmjs.org/browse/keyword/karma-launcher //browsers: ['ChromeHeadless', 'Chrome', 'Firefox', 'Safari'], - browsers: ['ChromeHeadless'], + browsers: ['ChromeHeadlessNoSandbox'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: [ + // Essential: Bypasses container namespace errors + '--no-sandbox', + // Prevents extra privilege-dropping failures + '--disable-setuid-sandbox', + // Speeds up headless execution in CI environments + '--disable-gpu', + '--disable-software-rasterizer' + ] + } + }, // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits From 95a3388d678e4f8aa0d0c9b3b9e58daa9cd95dcd Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:58:41 -0400 Subject: [PATCH 15/39] Run all tests in karma. --- tests/10-client-api.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index b36387c..3101e89 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -221,7 +221,7 @@ describe('http-client API', () => { if(!isNode) { // browser check for endpoint without CORS - it.only('handles a CORS error', async () => { + it('handles a CORS error', async () => { let err; let response; const url = `http://${httpHost}/nocors`; From fd23c6d2eca94c326e82bd64051209b0ece0180b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:59:07 -0400 Subject: [PATCH 16/39] Revert to `chai@4` for karma testing. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e601ae5..7981c9e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", "c8": "^12.0.0", - "chai": "^6.2.2", + "chai": "^4.5.0", "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", From 2a8d5749828ae3e8b563747a183e4972660f3be5 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:34 +0000 Subject: [PATCH 17/39] Add missing browser `makeAgent` export. Webpack's browser field remaps `tests/utils.js` to `tests/utils-browser.js`, but the browser stub never defined `makeAgent`. The namespace import in the shared spec file only references it inside an `isNode` guard, but webpack still statically validates the export, breaking the karma build. --- tests/utils-browser.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/utils-browser.js b/tests/utils-browser.js index 498f606..87692ef 100644 --- a/tests/utils-browser.js +++ b/tests/utils-browser.js @@ -18,3 +18,10 @@ export async function startServers() { httpsHost: process.env.TEST_HTTPS_HOST }; } + +// unused in the browser; the test that calls this is guarded by `isNode`, +// but it must still exist so webpack's static export check on the +// `import * as utils` namespace succeeds +export function makeAgent() { + return undefined; +} From 83ed66cd38e652e86fb6ad1e087160c2683d42a5 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:39 +0000 Subject: [PATCH 18/39] Handle CORS preflight for `/headers` test route. Adding a non-simple header (e.g. `Authorization`) triggers a browser CORS preflight `OPTIONS` request, which this route never answered, causing the actual request to be blocked. --- tests/utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/utils.js b/tests/utils.js index 589e342..dbb4ed6 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -96,6 +96,8 @@ function createApp() { res.status(200).send(); }); + // handle CORS preflight for non-simple request headers (e.g. Authorization) + app.options('/headers', cors()); app.get('/headers', cors(), (req, res) => { res.json({ headers: req.headers From 8ab4c1df41bf68b0a1a5882c5d3e17d850460a98 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:44 +0000 Subject: [PATCH 19/39] Fix CORS error detection for `ky@2`. `ky@2` wraps the browser's `TypeError: Failed to fetch` in its own `NetworkError`, with the original error moved to `cause`. Check both locations so the friendly CORS message still gets applied. --- lib/httpClient.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index bb4afde..fd4b3dc 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -135,7 +135,10 @@ async function _handleError({error, url}) { // handle network errors and system errors that do not have a response if(!error.response) { - if(error.message === 'Failed to fetch') { + if(error.message === 'Failed to fetch' || + error.cause?.message === 'Failed to fetch') { + // ky@2 wraps the browser's underlying `TypeError: Failed to fetch` + // in its own `NetworkError`, with the original error as `cause` error.message = `Failed to fetch "${url}". Possible CORS error.`; } // ky's TimeoutError class From 47017b48d82fedb49977379374c133e928a11b1d Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:09:07 -0400 Subject: [PATCH 20/39] Improve changelog notes. --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eefae0c..cdbb197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ - `ky` is again exported. - Change from using `ky` promises to regular instances. - **BREAKING**: Remove `push` from the proxied method list. -- Update dependencies: +- **BREAKING**: Update dependencies: - `ky@2`. - - **BREAKING**: See `ky` docs for exported `ky` API changes. For most use - cases the wrapped API is expected to be the same. + - For most use cases the wrapped API is expected to be the same. + - See `ky` docs for exported `ky` API changes. + - Note that some errors can now have `cause` property chains and may use a + `NetworkError`. - Update dev dependencies. - Update README.md. - **NOTE**: Update supported platforms. From 54f4ebac95e88217dc115ba417ae8ca6fdbaded8 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:27:57 +0000 Subject: [PATCH 21/39] Run HTTPS tests in karma. Launch the browser with `--ignore-certificate-errors` so it accepts the self-signed cert, letting the local HTTPS test server test run in both node and browsers. This covers TLS in the browser without depending on an external site. Restrict the github.com test to node. The site sends no CORS headers, so a browser blocks the request before it is sent. Keeping it node-only also halves how often it runs, reducing rate limit exposure. --- karma.conf.cjs | 4 ++- tests/10-client-api.spec.js | 58 +++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/karma.conf.cjs b/karma.conf.cjs index 6d0f883..1bc4f80 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -81,7 +81,9 @@ module.exports = async function(config) { '--disable-setuid-sandbox', // Speeds up headless execution in CI environments '--disable-gpu', - '--disable-software-rasterizer' + '--disable-software-rasterizer', + // Accept the self-signed cert used by the local HTTPS test server + '--ignore-certificate-errors' ] } }, diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 3101e89..d4daedd 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -90,38 +90,16 @@ describe('http-client API', () => { response.status.should.equal(200); }); - // test HTTPS on github.com on node and browsers - // NOTE: might get rate limited - it('can use HTTPS on github.com', async () => { - let err; - let response; - const url = 'https://github.com/'; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - const ct = response.headers.get('content-type'); - should.exist(ct); - ct.includes('application/json').should.be.true; - }); - if(isNode) { - // test local self-signed cert in node only - it('can ping HTTPS test server', async () => { + // test HTTPS against a real external site; node only, since the site + // sends no CORS headers and a browser would block the request + // NOTE: might get rate limited + it('can use HTTPS on github.com', async () => { let err; let response; - const url = `https://${httpsHost}/ping`; + const url = 'https://github.com/'; try { - const agent = utils.makeAgent({ - rejectUnauthorized: false - }); - response = await httpClient.get(url, {agent}); + response = await httpClient.get(url); } catch(e) { err = e; } @@ -130,6 +108,9 @@ describe('http-client API', () => { should.exist(response.status); should.exist(response.data); response.status.should.equal(200); + const ct = response.headers.get('content-type'); + should.exist(ct); + ct.includes('application/json').should.be.true; }); // exercises the agent path with a request body: on an incompatible @@ -157,6 +138,27 @@ describe('http-client API', () => { }); } + // test local self-signed cert; node uses an agent to accept it, karma + // launches the browser with `--ignore-certificate-errors` + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/ping`; + try { + const agent = utils.makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.get(url, {agent}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + }); + it('handles a get not found error', async () => { let err; let response; From 77f71545572663d3d4039ff515560e97079a2841 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 02:45:32 +0000 Subject: [PATCH 22/39] Remove dead node version check. `engines` requires node >=22, so the node 18.2+ guard on agent conversion is always true. --- lib/agentCompatibility.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 8864064..961f8fd 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -26,10 +26,6 @@ const DISPATCHER_CACHE = new WeakMap(); // 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 @@ -50,10 +46,6 @@ const platformFetchCompatible = (() => { // converts `agent`/`httpsAgent` option to a dispatcher option export function convertAgent(options) { - if(!canConvert) { - return options; - } - // do not override custom fetch function from another lib if(options?.fetch && !options.fetch._httpClientCustomFetch) { return options; From d5bd6c8502388c3fe1a1115dff120b6b908b46a3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 11 Aug 2026 01:42:11 +0000 Subject: [PATCH 23/39] Update to `undici@7`. Aligns the installed undici with the one built into the current Node.js LTS release, per the policy of optimizing for current LTS. undici 7 is the transition release between the old (v6 `onError`) and new (v8 `onRequestStart`) dispatcher handler dialects, shipping both `wrap-handler` and `unwrap-handler` to translate in either direction. A v7 dispatcher is therefore usable by the `fetch` built into Node.js 22, 24, and 26 alike, so the legacy `agent`/`httpsAgent` options now use the platform `fetch` on every supported release and responses are platform `Response` instances again. Replace the strict installed-equals-platform major check with an explicit table of the platform majors each installed major can drive. An installed major with no entry falls back to requiring an exact match, so a missing or stale entry costs only the fallback path rather than correctness. The fallback is kept rather than removed: a future undici bump is expected to need it again, since a v8 dispatcher cannot be driven by the v7 `fetch` in Node.js 24. --- CHANGELOG.md | 5 +++++ lib/agentCompatibility.js | 45 ++++++++++++++++++++++++++------------- package.json | 2 +- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdbb197..4147ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ - See `ky` docs for exported `ky` API changes. - Note that some errors can now have `cause` property chains and may use a `NetworkError`. + - `undici@7`. + - Aligns with the undici built into the current Node.js LTS release. + - A v7 dispatcher is usable by the `fetch` built into Node.js 22, 24, and + 26, so the legacy `agent`/`httpsAgent` options now use the platform + `fetch` on every supported release rather than an internal override. - Update dev dependencies. - Update README.md. - **NOTE**: Update supported platforms. diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 961f8fd..1358567 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -8,13 +8,11 @@ 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. +dispatcher is only usable by an undici that speaks its handler dialect -- +that contract changed across majors, so a mismatched pairing fails with +"invalid onError method" or "invalid onRequestStart method". Which major the +platform provides varies by release line (node 22 has 6, node 24 has 7, node +26 has 8). See digitalbazaar/http-client#43. */ // as long as an agent has a reference to it, its associated dispatcher will @@ -27,18 +25,35 @@ const DISPATCHER_CACHE = new WeakMap(); const FETCH_CACHE = new WeakMap(); /* -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. +Platform undici majors that each installed undici major's dispatcher can be +driven by. undici 7 is a transition release: it ships both `wrap-handler` and +`unwrap-handler` and translates between the old (v6 `onError`) and new (v8 +`onRequestStart`) handler dialects in both directions, so a v7 dispatcher +works with platform undici 6, 7, and 8 -- every node this package supports. + +Revisit when bumping undici. An installed major that is not listed falls back +to requiring an exact match, so a missing or stale entry only costs the +fallback path below -- still correct, just not the platform `fetch`. +*/ +const COMPATIBLE_PLATFORM_MAJORS = { + 7: [6, 7, 8] +}; + +/* +True when the installed undici's dispatcher can be handed to the platform +`fetch`. 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; + const compatible = + COMPATIBLE_PLATFORM_MAJORS[installedMajor] ?? [installedMajor]; + return compatible.includes(platformMajor); } catch { return false; } @@ -69,7 +84,7 @@ export function convertAgent(options) { delete rest.agent; delete rest.httpsAgent; - // majors match: hand the dispatcher to `ky`, which forwards it to the + // compatible: 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) { diff --git a/package.json b/package.json index 7981c9e..cb753d2 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "dependencies": { "ky": "^2.0.2", - "undici": "^6.28.0" + "undici": "^7.29.0" }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", From 18b3c4d29afc306748ef065b699067aefa07e877 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 14 Aug 2026 00:25:44 +0000 Subject: [PATCH 24/39] Do not report undici compatibility on an unreadable version. The guard fell open when both version reads failed. `parseInt` returns `NaN` rather than throwing, so neither read reached the `catch`; the table lookup fell back to `[NaN]`, and `includes` matches `NaN` to `NaN` under SameValueZero. The result was `true` -- handing the dispatcher to a platform `fetch` that may reject it, rather than the always-safe installed `fetch` the comment promises. Require both majors to be integers before comparing. --- lib/agentCompatibility.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index 1358567..bb1df1b 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -46,11 +46,20 @@ 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. + +The explicit integer check matters: `parseInt` returns `NaN` instead of +throwing, so an unreadable version never reaches the `catch`. If both reads +were unreadable the lookup would fall back to `[NaN]`, and `includes` matches +`NaN` to `NaN` under SameValueZero -- reporting compatible, the opposite of +the safe default. */ const platformFetchCompatible = (() => { try { const installedMajor = parseInt(undiciPkg.version, 10); const platformMajor = parseInt(versions.undici, 10); + if(!Number.isInteger(installedMajor) || !Number.isInteger(platformMajor)) { + return false; + } const compatible = COMPATIBLE_PLATFORM_MAJORS[installedMajor] ?? [installedMajor]; return compatible.includes(platformMajor); From ae9dc47fa332faef6e15dd7318c1afa285c22d76 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 14 Aug 2026 00:25:44 +0000 Subject: [PATCH 25/39] Document the `error.data` breaking change. `ky@2` buffers the error body regardless of content type, so `error.data` is now set for any error response -- an object for JSON, a string otherwise -- where v4 left it `undefined` unless the content type included `json`. Code using `if(error.data)` as a "the server sent JSON" test needs updating. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4147ca7..8884d33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ - `ky` is again exported. - Change from using `ky` promises to regular instances. - **BREAKING**: Remove `push` from the proxied method list. +- **BREAKING**: `error.data` is now set for any error response body, not only + a JSON one. + - `ky@2` buffers the error body regardless of content type, so `.data` is + an object for JSON and a string otherwise. Under v4 it was left + `undefined` unless the content type included `json`. + - Code using `if(error.data)` as a "the server sent JSON" test needs + updating; an HTML error page from a proxy now makes it truthy. - **BREAKING**: Update dependencies: - `ky@2`. - For most use cases the wrapped API is expected to be the same. From 4d0a36f29b7d64e2740e80dd3815295bf5ded190 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 00:17:01 +0000 Subject: [PATCH 26/39] Update dependencies. - `ky@2.1`. - `undici@7.30`. - Dev: `eslint@10.11`, `mocha@12`, `webpack@5.111`. --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index cb753d2..deefb65 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "lib/*" ], "dependencies": { - "ky": "^2.0.2", - "undici": "^7.29.0" + "ky": "^2.1.0", + "undici": "^7.30.0" }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", @@ -36,7 +36,7 @@ "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", - "eslint": "^10.8.1", + "eslint": "^10.11.0", "express": "^5.2.1", "karma": "^6.4.4", "karma-chai": "^0.1.0", @@ -45,8 +45,8 @@ "karma-mocha-reporter": "^2.2.5", "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", - "mocha": "^11.8.0", - "webpack": "^5.109.2" + "mocha": "^12.0.2", + "webpack": "^5.111.1" }, "repository": { "type": "git", From 0279c3a85306480638e314cb120e2374b838bad9 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 00:17:01 +0000 Subject: [PATCH 27/39] Proxy the `query` method. `ky@2.1` adds `query` to its helper registry and exposes `ky.query`, so add it to the proxied set. `httpClient.query` and `{method: 'query'}` now go through response handling, setting `.data` and normalizing errors like the other helpers, rather than falling through to `ky` directly. Add a QUERY echo route to the test server. The default `cors()` methods omit QUERY, so the route lists it for the browser preflight. --- CHANGELOG.md | 5 ++++- lib/httpClient.js | 9 ++++----- tests/10-client-api.spec.js | 40 +++++++++++++++++++++++++++++++++++-- tests/utils.js | 9 +++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8884d33..9d8db19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 5.0.0 - 2026-xx-xx +### Added +- Proxy the `query` method (`httpClient.query`), added in `ky@2.1`. + ### Changed - **BREAKING**: Revert CJS related workarounds from v3.0.0. - `kyPromise` no longer exported. @@ -16,7 +19,7 @@ - Code using `if(error.data)` as a "the server sent JSON" test needs updating; an HTML error page from a proxy now makes it truthy. - **BREAKING**: Update dependencies: - - `ky@2`. + - `ky@2.1`. - For most use cases the wrapped API is expected to be the same. - See `ky` docs for exported `ky` API changes. - Note that some errors can now have `cause` property chains and may use a diff --git a/lib/httpClient.js b/lib/httpClient.js index fd4b3dc..2fa9981 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -16,13 +16,12 @@ Methods to proxy from `ky`. This must mirror `ky`'s own helper registry `ky[method]` -- a name `ky` does not implement would throw on first call. `ky` accepts more methods than it exposes helpers for: `options` and `trace` -are in its `HttpMethod` type and its retry defaults, and `query` is on `ky`'s -main branch but unreleased as of `ky@2.0.2`. Those are reached through -`httpClient(url, {method})`, which falls through to `ky` directly, rather -than by being listed here. +are in its `HttpMethod` type and its retry defaults but have no helpers. +Those are reached through `httpClient(url, {method})`, which falls through to +`ky` directly, rather than by being listed here. */ const PROXY_METHODS = new Set([ - 'get', 'post', 'put', 'patch', 'head', 'delete' + 'get', 'post', 'put', 'patch', 'head', 'delete', 'query' ]); /** diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index d4daedd..bf44623 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -38,7 +38,7 @@ describe('http-client API', () => { // would throw on first call rather than fail here it('proxies only methods that `ky` implements', async () => { const proxied = [ - 'get', 'post', 'put', 'patch', 'head', 'delete' + 'get', 'post', 'put', 'patch', 'head', 'delete', 'query' ]; for(const method of proxied) { ky[method].should.be.a('function', `ky.${method} is missing`); @@ -46,13 +46,49 @@ describe('http-client API', () => { 'function', `httpClient.${method} is missing`); } // methods `ky` has no helper for must not be proxied - for(const method of ['query', 'options', 'trace']) { + for(const method of ['options', 'trace']) { should.not.exist(ky[method], `ky.${method} unexpectedly exists`); should.not.exist( httpClient[method], `httpClient.${method} must not be proxied`); } }); + it('supports the proxied `query` method', async () => { + let err; + let response; + const url = `http://${httpHost}/query`; + const payload = {hello: 'world', n: 42, nested: {ok: true}}; + try { + response = await httpClient.query(url, {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('routes a `query` method option through the proxy', async () => { + let err; + let response; + const url = `http://${httpHost}/query`; + const payload = {hello: 'world'}; + try { + response = await httpClient(url, {method: 'query', json: payload}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + // `data` is only set by the proxied path + should.exist(response.data); + response.data.echo.should.deep.equal(payload); + }); + if(isNode) { // `ky` supports `options` as a `method` value but exposes no helper for // it, so it has to reach `ky` through the direct-call fall-through. diff --git a/tests/utils.js b/tests/utils.js index dbb4ed6..8fb4d21 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -116,5 +116,14 @@ function createApp() { }); }); + // the default `cors()` methods do not include QUERY, so list it for the + // preflight a browser sends before a QUERY request + app.options('/query', cors({methods: 'QUERY'})); + app.query('/query', cors(), express.json(), (req, res) => { + res.json({ + echo: req.body + }); + }); + return app; } From d7735eb785f40b5ad6d81520bb8ff2abcebd4069 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:07:34 +0000 Subject: [PATCH 28/39] Fix `create()` header merging. `create()` spread the overrides into `DEFAULT_HEADERS` as a plain object before converting to `Headers`. Plain object keys are case-sensitive, so an `accept` override survived alongside the default `Accept`, and the `Headers` constructor combined them into one value. The same spread silently dropped overrides given as a `Headers` instance. Set the overrides onto a `Headers` built from the defaults instead, treating an `undefined` value as a deletion like ky does. Wrapping headers in `new Headers()` also threw on ky's `replaceOption()` marker. Recognize the marker and honor it: `extend()` replaces the parent's headers and `create()` replaces the defaults. The `create()` test never called `create()`, which hid the case bug. Make it do so, and cover `Headers` instances and `replaceOption()`. --- CHANGELOG.md | 7 +++++ lib/httpClient.js | 59 +++++++++++++++++++++++++++++++------ tests/10-client-api.spec.js | 54 ++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8db19..7e93291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ ### Added - Proxy the `query` method (`httpClient.query`), added in `ky@2.1`. +### Fixed +- Merge `create()` header overrides case-insensitively. An `accept` override + was combined with the default `Accept` instead of replacing it. +- Keep `create()` headers given as a `Headers` instance. They were dropped. +- Support `ky`'s `replaceOption()` for `headers` in `create()` and + `extend()`. In `create()` it replaces the default headers. + ### Changed - **BREAKING**: Revert CJS related workarounds from v3.0.0. - `kyPromise` no longer exported. diff --git a/lib/httpClient.js b/lib/httpClient.js index 2fa9981..33b6df1 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,8 +1,8 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ +import ky, {replaceOption} from 'ky'; import {convertAgent} from './agentCompatibility.js'; -import ky from 'ky'; export {ky}; @@ -41,26 +41,67 @@ export function createInstance({ // convert legacy agent options params = convertAgent(params); + // headers are always passed to ky as a `Headers` instance (instead of a + // plain object) so ky merges per-request headers case-insensitively; ky's + // plain-object merge path does a `{...a, ...b}` spread, which does not + // dedupe header names that differ only by case (e.g. `Accept` vs `accept`) + const {replace, value} = _unwrapReplaceOption(headers); + // create new ky instance let _ky; if(parent === ky) { - // ensure default headers, allow overrides + // ensure default headers, allow overrides; `replaceOption()` replaces + // the defaults instead _ky = parent.create({ - // use a `Headers` instance (instead of a plain object) so ky merges - // per-request headers case-insensitively; ky's plain-object merge - // path does a `{...a, ...b}` spread, which does not dedupe header - // names that differ only by case (e.g. `Accept` vs `accept`) - headers: new Headers({...DEFAULT_HEADERS, ...headers}), + headers: _mergeHeaders(replace ? undefined : DEFAULT_HEADERS, value), ...params }); } else { - // extend parent - _ky = parent.extend({headers: new Headers(headers), ...params}); + // extend parent; an `undefined` value is left for ky's merge, which + // deletes that header from the parent's + _ky = parent.extend({ + headers: replace ? + replaceOption(_mergeHeaders(undefined, value)) : new Headers(value), + ...params + }); } return _createHttpClient(_ky); } +// ky marks a `replaceOption()` value with a private symbol; recover it from +// a marked value so a marked `headers` option can be recognized +const [REPLACE_OPTION] = Object.getOwnPropertySymbols(replaceOption()); + +function _unwrapReplaceOption(value) { + if(value?.[REPLACE_OPTION]) { + return {replace: true, value: value.value}; + } + return {replace: false, value}; +} + +/** + * Sets `headers` over `base` case-insensitively. + * + * @param {object|Headers} [base] - Base headers. + * @param {object|Headers} [headers] - Headers to set over `base`. + * + * @returns {Headers} The merged headers. + */ +function _mergeHeaders(base, headers) { + const result = new Headers(base); + for(const [name, value] of new Headers(headers)) { + // `Headers` converts an `undefined` value to 'undefined'; like ky, treat + // it as a deletion + if(value === 'undefined') { + result.delete(name); + } else { + result.set(name, value); + } + } + return result; +} + function _createHttpClient(ky) { async function httpClient(...args) { const method = ((args[1] && args[1].method) || 'get').toLowerCase(); diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index bf44623..53735f1 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -8,6 +8,7 @@ import { ky } from '../lib/index.js'; import isNode from 'detect-node'; +import {replaceOption} from 'ky'; describe('http-client API', () => { // start/close local test server @@ -340,16 +341,19 @@ describe('http-client API', () => { accept.should.equal('text/html'); }); + // a lowercase name must replace the default `Accept`, not be combined + // with it it('can use create() to provide default headers', async () => { let err; let response; const url = `http://${httpHost}/headers`; try { - response = await httpClient.get(url, { + const client = httpClient.create({ headers: { accept: 'text/html' } }); + response = await client.get(url); } catch(e) { err = e; } @@ -363,6 +367,28 @@ describe('http-client API', () => { accept.should.equal('text/html'); }); + it('can use create() with a `Headers` instance', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + const client = httpClient.create({ + headers: new Headers({Authorization: 'Bearer 12345'}) + }); + response = await client.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.headers); + const {accept, authorization} = response.data.headers; + accept.should.equal('application/ld+json, application/json'); + authorization.should.equal('Bearer 12345'); + }); + it('handles a successful get with JSON data', async () => { let err; let response; @@ -535,5 +561,31 @@ describe('http-client API', () => { const {authorization: authzHeader} = response.data.headers; authzHeader.should.equal('Bearer 12345'); }); + + it('replaces parent headers with `replaceOption()`', async () => { + const parent = httpClient.extend({ + headers: {Authorization: 'Bearer 12345'} + }); + const client = parent.extend({ + headers: replaceOption({accept: 'text/plain'}) + }); + + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await client.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.headers); + const {accept, authorization} = response.data.headers; + accept.should.equal('text/plain'); + should.not.exist(authorization); + }); }); }); From 4df73c2da5b0c54134c299cf69a0f3210e9eabbb Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:07:34 +0000 Subject: [PATCH 29/39] Fix connection refused test for `ky@2` errors. ky wraps fetch's error in a `NetworkError`, which moves node's system error code from `err.cause` to `err.cause.cause`. The `if(cause.code)` guard then skipped the assertion on every runtime. Walk the `cause` chain for the code, and require it on node. --- tests/10-client-api.spec.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 53735f1..1850d88 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -247,10 +247,15 @@ describe('http-client API', () => { `Expected nonExistentResource "err.requestUrl" to be ` + `${nonExistentResource}` ); - // in node 18 global fetch places the error code in err.cause - const cause = err.cause || err; - // chrome's fetch errors don't contain a code at all - if(cause.code) { + // ky wraps fetch's error in a `NetworkError`, and node's fetch places the + // system error in its own error's `cause`; chrome's fetch errors don't + // contain a code at all + if(isNode) { + let cause = err; + while(cause && !cause.code) { + cause = cause.cause; + } + should.exist(cause, 'Expected an error code in the "cause" chain.'); cause.code.should.equal( expectedErrorCode, `Expected nonExistentResource "err.code" to be ${expectedErrorCode}.` From 3062edcd7859626a278655994a9f34a0a8387b05 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:07:34 +0000 Subject: [PATCH 30/39] Document that error response bodies are consumed. `ky@2` reads the error response body to fill `error.data`, so reading it again from `error.response` throws "Body has already been read". --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e93291..7cb9fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ `undefined` unless the content type included `json`. - Code using `if(error.data)` as a "the server sent JSON" test needs updating; an HTML error page from a proxy now makes it truthy. + - Reading the body from `error.response` (`.json()`, `.text()`, etc.) now + throws, since `ky@2` has already consumed it. Use `error.data` instead. - **BREAKING**: Update dependencies: - `ky@2.1`. - For most use cases the wrapped API is expected to be the same. From 54eeac2344d1908303fd61e608f0dd8ac017a45d Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:42:36 +0000 Subject: [PATCH 31/39] Check the proxied methods against all of `ky`'s helpers. The proxy test only checked a fixed list of names, so a helper added by a `ky` upgrade went unnoticed unless it was one of the names checked for, as `query` happened to be. Compare the proxied list with every request helper on `ky`, and with the methods `httpClient` actually has, so an addition or removal on either side fails the test. The list stays hand-maintained: a new helper is new public API and should be added deliberately. --- tests/10-client-api.spec.js | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 1850d88..3c2754d 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -34,24 +34,26 @@ describe('http-client API', () => { ky.should.be.a('function'); }); - // guards against the proxied set drifting ahead of `ky`'s helper registry: - // proxying indexes into `ky[method]`, so a name `ky` does not implement - // would throw on first call rather than fail here - it('proxies only methods that `ky` implements', async () => { + // guards against the proxied set drifting from `ky`'s helper registry in + // either direction: proxying indexes into `ky[method]`, so a name `ky` does + // not implement would throw on first call, and a helper `ky` adds should be + // proxied deliberately (it is new public API) rather than go unnoticed + it('proxies exactly the methods that `ky` implements', async () => { const proxied = [ 'get', 'post', 'put', 'patch', 'head', 'delete', 'query' ]; - for(const method of proxied) { - ky[method].should.be.a('function', `ky.${method} is missing`); - httpClient[method].should.be.a( - 'function', `httpClient.${method} is missing`); - } - // methods `ky` has no helper for must not be proxied - for(const method of ['options', 'trace']) { - should.not.exist(ky[method], `ky.${method} unexpectedly exists`); - should.not.exist( - httpClient[method], `httpClient.${method} must not be proxied`); - } + Object.keys(httpClient) + .filter(key => !['create', 'extend'].includes(key)) + .sort().should.deep.equal( + [...proxied].sort(), + '`httpClient` methods differ from the proxied methods'); + // `ky` functions that are not request helpers + const notHelpers = ['create', 'extend', 'retry']; + const helpers = Object.keys(ky).filter( + key => typeof ky[key] === 'function' && !notHelpers.includes(key)); + helpers.sort().should.deep.equal( + [...proxied].sort(), + '`ky` request helpers differ from the proxied methods'); }); it('supports the proxied `query` method', async () => { From b895f0a9359cd527e53a5ae39482c3ae326ee33e Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:43:17 +0000 Subject: [PATCH 32/39] Rename `_createHttpClient`'s `ky` parameter to `instance`. The parameter shadowed the imported base `ky`, which `createInstance` relies on for its `parent === ky` check. Inside `_createHttpClient` the name meant the derived instance instead, e.g. `extend` passing `parent: ky`, so an edit expecting the base would quietly get the wrong one. --- lib/httpClient.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index 33b6df1..d9ea649 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -102,21 +102,21 @@ function _mergeHeaders(base, headers) { return result; } -function _createHttpClient(ky) { +function _createHttpClient(instance) { async function httpClient(...args) { const method = ((args[1] && args[1].method) || 'get').toLowerCase(); if(PROXY_METHODS.has(method)) { - return httpClient[method].apply(ky[method], args); + return httpClient[method].apply(instance[method], args); } // convert legacy agent options args[1] = convertAgent(args[1]); - return ky.apply(ky, args); + return instance.apply(instance, args); } for(const method of PROXY_METHODS) { httpClient[method] = async function(...args) { - return _handleResponse(ky[method], ky, args); + return _handleResponse(instance[method], instance, args); }; } @@ -125,13 +125,13 @@ function _createHttpClient(ky) { }; httpClient.extend = function({headers = {}, ...params}) { - return createInstance({parent: ky, headers, ...params}); + return createInstance({parent: instance, headers, ...params}); }; // default async `stop` signal getter Object.defineProperty(httpClient, 'stop', { async get() { - return ky.stop; + return instance.stop; } }); From acc3caf29541e8122b37ec334f5b8cc03ef2c295 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:43:51 +0000 Subject: [PATCH 33/39] Drop redundant content-type check in error handling. `ky@2` already parses `error.data` by content type, and a text body is a string with no `message`, so reading `error.data?.message` needs no content-type guard of its own. Dropping it also keeps the message logic in step with ky's JSON detection, including a custom `parseJson`. Add a test for the JSON `message` path, which had no coverage. --- lib/httpClient.js | 10 ++++------ tests/10-client-api.spec.js | 15 +++++++++++++++ tests/utils.js | 6 ++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index d9ea649..42b8d0d 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -192,11 +192,9 @@ async function _handleError({error, url}) { // always move status up to the root of error error.status = error.response.status; - const contentType = error.response.headers.get('content-type'); - if(contentType && contentType.includes('json')) { - // the HTTPError received from ky has a generic message based on status - // use that if the JSON body does not include a message - error.message = error.data?.message || error.message; - } + // the HTTPError received from ky has a generic message based on status; + // use a JSON body's message instead when it has one (ky has already parsed + // `error.data` by content type, and a text body has no `message`) + error.message = error.data?.message || error.message; throw error; } diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 3c2754d..c89f7dc 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -475,6 +475,21 @@ describe('http-client API', () => { err.data.description.should.equal('Not Found'); }); + it('uses the message from a JSON error body', async () => { + let err; + let response; + const url = `http://${httpHost}/error/message`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.status.should.equal(400); + err.message.should.equal('Invalid widget.'); + }); + it('handles a direct get not found error with JSON data', async () => { let err; let response; diff --git a/tests/utils.js b/tests/utils.js index 8fb4d21..dd641f8 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -83,6 +83,12 @@ function createApp() { res.status(404).send('NOT FOUND'); }); + app.get('/error/message', cors(), (req, res) => { + res.status(400).json({ + message: 'Invalid widget.' + }); + }); + // emulate https://httpstat.us/404 app.get('/404', cors(), (req, res) => { res.status(404).json({ From 2cea20bcccde5cd588fd1c983054359a4d0c7498 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:54:30 +0000 Subject: [PATCH 34/39] Test the async `stop` getter. The wrapper API is deliberately all async, a holdover from keeping the CJS and ESM builds consistent that is kept to avoid breaking callers. Pin down that `stop` resolves to `ky.stop` rather than returning it directly. --- tests/10-client-api.spec.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index c89f7dc..25f2f7d 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -34,6 +34,15 @@ describe('http-client API', () => { ky.should.be.a('function'); }); + // the wrapper API is deliberately all async, including this getter; it + // dates from keeping the CJS and ESM builds consistent and is kept so + // calling code does not have to change + it('resolves `stop` to `ky.stop` from an async getter', async () => { + const stop = httpClient.stop; + stop.should.be.an.instanceof(Promise); + (await stop).should.equal(ky.stop); + }); + // guards against the proxied set drifting from `ky`'s helper registry in // either direction: proxying indexes into `ky[method]`, so a name `ky` does // not implement would throw on first call, and a helper `ky` adds should be From 842367f0344d2c259bf2aecd7ca1d7eb2856dc5e Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:54:43 +0000 Subject: [PATCH 35/39] Test removing a default header in `create()`. Cover the `undefined` value path in `_mergeHeaders`: the header is deleted, like in ky, instead of being sent as the string 'undefined'. --- tests/10-client-api.spec.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 25f2f7d..4083bd6 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -405,6 +405,31 @@ describe('http-client API', () => { authorization.should.equal('Bearer 12345'); }); + // like ky, an `undefined` value deletes the header rather than sending + // the string 'undefined' + it('can use create() to remove a default header', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + const client = httpClient.create({ + headers: { + Accept: undefined + } + }); + response = await client.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.headers); + // fetch sends its own default when no `Accept` is set + response.data.headers.accept.should.equal('*/*'); + }); + it('handles a successful get with JSON data', async () => { let err; let response; From 5fdc05fa4ddd70498f4df30a7c9ec0bd5b8b55e3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:54:57 +0000 Subject: [PATCH 36/39] Test the possible CORS error message in node. Only the browser run reached the "Possible CORS error" message, since it needs a real CORS failure. Simulate the browser's bare `TypeError: Failed to fetch` with a `fetch` option so every run covers it. --- tests/10-client-api.spec.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 4083bd6..d816c88 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -295,6 +295,29 @@ describe('http-client API', () => { }); } + // a browser's `fetch` rejects a CORS failure with a bare + // `TypeError: Failed to fetch`; simulate it so node covers the message too + it('reports a possible CORS error for a failed fetch', async () => { + let err; + let response; + const url = `http://${httpHost}/ping`; + try { + response = await httpClient.get(url, { + fetch: async () => { + throw new TypeError('Failed to fetch'); + }, + // skip ky's retry backoff for a network error + retry: 0 + }); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.equal(`Failed to fetch "${url}". Possible CORS error.`); + err.requestUrl.should.equal(url); + }); + it('handles a TimeoutError error', async () => { let err; let response; From 58a3f773798cadad3f1ad5fb1a58f0c137fa64a5 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:55:26 +0000 Subject: [PATCH 37/39] Test that a custom `fetch` survives agent conversion. `convertAgent` leaves options alone when they already carry a `fetch` it did not create, so a `fetch` supplied by another library is neither replaced nor handed a `dispatcher` built from the agent. Cover that path, checking for the `dispatcher`: on a compatible platform the custom `fetch` would still be called without the guard. --- tests/10-client-api.spec.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index d816c88..54bbc09 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -186,6 +186,38 @@ describe('http-client API', () => { }); } + if(isNode) { + // a custom `fetch` (e.g. from another library) is used as given; agent + // conversion must neither replace it nor slip it a `dispatcher` + it('keeps a custom `fetch` when an agent is given', async () => { + let err; + let response; + let called = false; + let dispatcher; + const url = `http://${httpHost}/ping`; + try { + const agent = utils.makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.get(url, { + agent, + fetch: async (input, init) => { + called = true; + dispatcher = init?.dispatcher; + return globalThis.fetch(input, init); + } + }); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + called.should.be.true; + should.not.exist(dispatcher); + }); + } + // test local self-signed cert; node uses an agent to accept it, karma // launches the browser with `--ignore-certificate-errors` it('can ping HTTPS test server', async () => { From f4eb8aef0a1b0bdd0fa3eec2df27faa561465f53 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:56:22 +0000 Subject: [PATCH 38/39] Test that an agent's conversion is reused. `convertAgent` caches the dispatcher (and, on an incompatible platform, the `fetch` override) per agent so connections are pooled across requests. Check the same agent gets the same conversion and a different agent does not. --- tests/10-client-api.spec.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 54bbc09..601c2d6 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -7,6 +7,7 @@ import { httpClient, ky } from '../lib/index.js'; +import {convertAgent} from '../lib/agentCompatibility.js'; import isNode from 'detect-node'; import {replaceOption} from 'ky'; @@ -216,6 +217,19 @@ describe('http-client API', () => { called.should.be.true; should.not.exist(dispatcher); }); + + // an agent is converted once and reused, so its connections are pooled + // across requests instead of each request building a new dispatcher + it('reuses the conversion of the same agent', async () => { + const agent = utils.makeAgent({rejectUnauthorized: false}); + const otherAgent = utils.makeAgent({rejectUnauthorized: false}); + // a compatible platform gets a `dispatcher`, others a `fetch` override + const converted = options => options.dispatcher ?? options.fetch; + const first = converted(convertAgent({agent})); + should.exist(first); + converted(convertAgent({agent})).should.equal(first); + converted(convertAgent({agent: otherAgent})).should.not.equal(first); + }); } // test local self-signed cert; node uses an agent to accept it, karma From 54ea253da21b01ec5ba5149df0f82236a40b8075 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 02:58:33 +0000 Subject: [PATCH 39/39] Test replacing the default headers in `create()`. `replaceOption()` headers in `create()` replace `DEFAULT_HEADERS` instead of being merged into them. Only `extend()` had a `replaceOption()` test. --- tests/10-client-api.spec.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 601c2d6..c57a69d 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -499,6 +499,29 @@ describe('http-client API', () => { response.data.headers.accept.should.equal('*/*'); }); + it('can use create() to replace the default headers', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + const client = httpClient.create({ + headers: replaceOption({Authorization: 'Bearer 12345'}) + }); + response = await client.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.headers); + const {accept, authorization} = response.data.headers; + // fetch sends its own default when no `Accept` is set + accept.should.equal('*/*'); + authorization.should.equal('Bearer 12345'); + }); + it('handles a successful get with JSON data', async () => { let err; let response;