From 6f01c4c87b5a0a44c51108eefed610ae62115138 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:15:51 -0400 Subject: [PATCH 01/19] 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 97e6be7407101f89a3fdc728b2c55b520a6b9d48 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:24:04 -0400 Subject: [PATCH 02/19] 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 e6c25b29db7678e1ae29ab39172e307a8185efdb Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:37:53 -0400 Subject: [PATCH 03/19] 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 a6ae4691caf908866db306df9c6f78e207893bb3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:36:45 -0400 Subject: [PATCH 04/19] 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..86f4479 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. + - `kyOriginalPromise` 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 cdd2d87e9e09290065b86641c1ec352deb3bd5c2 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:55:37 -0400 Subject: [PATCH 05/19] 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 86f4479..c7f756c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - `kyOriginalPromise` 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 1d9df16c33a2ff6fed95b2e3dbd7585863a7daeb Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:23:50 -0400 Subject: [PATCH 06/19] 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 6f0a48977f3a1c4c126e3c610239ffc293eab779 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:25:04 -0400 Subject: [PATCH 07/19] 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 c697dfb46237081dfa4fc1307c1427c84ca2996c Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 17:06:51 -0400 Subject: [PATCH 08/19] Update proxied method list. - Remove `push`. - Add `query`, `options`, and `trace` to align with `ky@2`. --- CHANGELOG.md | 3 +++ lib/httpClient.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f756c..1f595b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - `kyOriginalPromise` no longer exported. - `ky` is again exported. - Change from using `ky` promises to regular instances. +- **BREAKING**: Update proxied method list. + - Remove `push`. + - Add `query`, `options`, and `trace` to align with `ky@2`. - 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..9b40b8f 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -12,7 +12,7 @@ export const DEFAULT_HEADERS = { // methods to proxy from ky const PROXY_METHODS = new Set([ - 'get', 'post', 'put', 'push', 'patch', 'head', 'delete' + 'get', 'post', 'put', 'patch', 'head', 'delete', 'query', 'options', 'trace' ]); /** From 99b931cecb5ad3f1f8bd2ab4ac36450f980f48ff Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:13:48 +0000 Subject: [PATCH 09/19] 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. Co-Authored-By: Claude Sonnet 5 --- lib/httpClient.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index 9b40b8f..abc2816 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -37,12 +37,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 2a017c377d1d5e3bbf5e59b5dea11e55ddf3bf00 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:40:17 -0400 Subject: [PATCH 10/19] 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 4495ee6b8387bb9d80ce950fffd2087566d123a7 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:41:21 -0400 Subject: [PATCH 11/19] 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 6abbcd160762593f07ebe1d7f5c93eb83f974df3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:58:41 -0400 Subject: [PATCH 12/19] 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 1c94446..ec52bbb 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -180,7 +180,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 937968bb18057bbec1e4fb7fca23da7d41d3a8fa Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:59:07 -0400 Subject: [PATCH 13/19] 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 c68bde01f80d1724b151701603deacc2932ed824 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:34 +0000 Subject: [PATCH 14/19] 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. Co-Authored-By: Claude Sonnet 5 --- 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 af9140bba73d93aec984002cb200b2a3c062e4b3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:39 +0000 Subject: [PATCH 15/19] 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. Co-Authored-By: Claude Sonnet 5 --- 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 d74969e87a7b63850eadcf14686a2e8da30dafd2 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:44 +0000 Subject: [PATCH 16/19] 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. Co-Authored-By: Claude Sonnet 5 --- lib/httpClient.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index abc2816..30f74a8 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -125,7 +125,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 b08ba57a7139355a48207ed8dca7d47e598b0f47 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:09:07 -0400 Subject: [PATCH 17/19] Improve changelog notes. --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f595b9..8e3654c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ - **BREAKING**: Update proxied method list. - Remove `push`. - Add `query`, `options`, and `trace` to align with `ky@2`. -- 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 9d8e12ea60ac20d1d965a50d542e13b010d7c945 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:27:57 +0000 Subject: [PATCH 18/19] 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. Co-Authored-By: Claude Sonnet 5 --- 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 ec52bbb..a0978fe 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -49,38 +49,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; } @@ -89,6 +67,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 @@ -116,6 +97,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 cc1f63d5e2a4c770389d2f2e49808f88a52e9fd3 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 02:45:32 +0000 Subject: [PATCH 19/19] Remove dead node version check. `engines` requires node >=22, so the node 18.2+ guard on agent conversion is always true. Co-Authored-By: Claude Opus 5 (1M context) --- 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;