From 8155cf5eeea42dfa47a3127de834c18df7fb9ffc Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:15:51 -0400 Subject: [PATCH 01/21] 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 | 435 ------------------ tests/10-client-api.spec.js | 429 ++++++++++++++++- tests/test-mocha.cjs | 2 - tests/{utils-browser.cjs => utils-browser.js} | 11 +- tests/{utils.cjs => utils.js} | 37 +- 9 files changed, 454 insertions(+), 521 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} (71%) rename tests/{utils.cjs => utils.js} (77%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b3077..cb27f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - Update dev dependencies. +### Removed +- **BREAKING**: Remove CJS support. + ## 4.3.0 - 2026-01-15 ### Changed diff --git a/package.json b/package.json index 2047bbe..d40b3bc 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.2", @@ -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.7.6", - "rimraf": "^6.1.3", - "rollup": "^4.62.2", "webpack": "^5.109.0" }, "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 01c30c1..0000000 --- a/tests/10-client-api.spec.cjs +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved. - */ -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 02b4ebc..0000000 --- a/tests/10-client-api.spec.common.cjs +++ /dev/null @@ -1,435 +0,0 @@ -/*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. - */ -// 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); - }); - } - - 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 d9a97a5..8d55a70 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,5 +1,5 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import { DEFAULT_HEADERS, @@ -7,7 +7,428 @@ 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); + }); + } + + 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 71% rename from tests/utils-browser.cjs rename to tests/utils-browser.js index 582bf90..498f606 100644 --- a/tests/utils-browser.cjs +++ b/tests/utils-browser.js @@ -1,12 +1,7 @@ /*! - * Copyright (c) 2023 Digital Bazaar, Inc. All rights reserved. + * 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 77% rename from tests/utils.cjs rename to tests/utils.js index c988f1e..09a33c6 100644 --- a/tests/utils.cjs +++ b/tests/utils.js @@ -1,20 +1,15 @@ /*! - * Copyright (c) 2018-2023 Digital Bazaar, Inc. All rights reserved. + * 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 10acef29ace47930a99152b0a80daf0c7080e68f Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:24:04 -0400 Subject: [PATCH 02/21] 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 40079ca..946fb4e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -27,7 +27,7 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: [18.x, 20.x, 22.x, 24.x] + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index cb27f6f..85f0572 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 d40b3bc..ebab1e5 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 39ec776e7abe9f27735faf2fbce22ecf442ec32e Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:37:53 -0400 Subject: [PATCH 03/21] 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 8dba37d5a25f8263c350cf2115a394dc4627f626 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:36:45 -0400 Subject: [PATCH 04/21] 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 | 47 +++++++++++++++------------------- lib/index.js | 6 ++--- tests/10-client-api.spec.js | 6 ++--- tests/deferred.spec.js | 50 ------------------------------------- 6 files changed, 29 insertions(+), 102 deletions(-) delete mode 100644 lib/deferred.js delete mode 100644 tests/deferred.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f0572..fccf755 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 37608a3..9805403 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,11 +1,10 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import {convertAgent} from './agentCompatibility.js'; -import {deferred} from './deferred.js'; +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 6a8cee2..3df0d75 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,12 +1,12 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ 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 8d55a70..434c8ad 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 a3b2d9434e6dcdbe7d840b593a273a263735d85e Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 15:55:37 -0400 Subject: [PATCH 05/21] 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 fccf755..50a7b76 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 ebab1e5..ea0419a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lib/*" ], "dependencies": { - "ky": "^1.14.2", + "ky": "^2.0.2", "undici": "^6.23.0" }, "devDependencies": { From ec998b2d8d31be5ed3ee1208570c1f0a144d5388 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:23:50 -0400 Subject: [PATCH 06/21] 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 434c8ad..57c39d5 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -381,7 +381,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 f92874057295d1199d3c412ae36eafabb78c48b9 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 16:25:04 -0400 Subject: [PATCH 07/21] 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 43cd17b6f83df6c34baa0bf01a4c6c8b85f38950 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 17:06:51 -0400 Subject: [PATCH 08/21] 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 50a7b76..50f658f 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 fb1df870e5d098e903f656ec04f3f5c90c7909b7 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:13:48 +0000 Subject: [PATCH 09/21] 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 c552694b9dd8297f8c015869683352fa8f27e4b1 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:40:17 -0400 Subject: [PATCH 10/21] 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 946fb4e..03191b3 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 9f004512f5bd864664c5b2d1da02844178cc6137 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:41:21 -0400 Subject: [PATCH 11/21] Fix karma config. - Fix import. - Use suggested ChromeHeadless options for CI. --- karma.conf.cjs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/karma.conf.cjs b/karma.conf.cjs index b9377fc..6d0f883 100644 --- a/karma.conf.cjs +++ b/karma.conf.cjs @@ -1,8 +1,8 @@ /* - * Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved. + * 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 7efd6a4019f4d32f5a2c3dbeb7c96602fa8332f9 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:58:41 -0400 Subject: [PATCH 12/21] 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 57c39d5..61224c9 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -156,7 +156,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 38adf8d8236ec8f844f4c0ba9f723acf68013f50 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 20:59:07 -0400 Subject: [PATCH 13/21] 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 ea0419a..3652006 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 dd9c9d960ce2e146d2af82f78183ef7b9d6fd2d7 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:34 +0000 Subject: [PATCH 14/21] 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 bba093ea62161de40b228757e4f5bd046ccec776 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:39 +0000 Subject: [PATCH 15/21] 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 09a33c6..a83430e 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 6173f147474722bf4dc9ad168d8ca80d574a37f8 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:00:44 +0000 Subject: [PATCH 16/21] 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 1a25ea872d7389f79c51c98555dc1419a652464a Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 21:09:07 -0400 Subject: [PATCH 17/21] Improve changelog notes. --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f658f..9f4a747 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 45b8791a8082dfab80f742685bc42ebf4d60aa70 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 01:27:57 +0000 Subject: [PATCH 18/21] 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 61224c9..49e316d 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,9 +67,33 @@ 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; }); } + // 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 39bd0596346b52165bd0fb23b49feca76868d00b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 22:12:01 -0400 Subject: [PATCH 19/21] Update eslint. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3652006..64a059f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "express": "^5.2.1", "karma": "^6.4.4", "karma-chai": "^0.1.0", From 95a9c968778774f87191fb5704492639b308820f Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Fri, 24 Jul 2026 22:12:25 -0400 Subject: [PATCH 20/21] Update undici. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 64a059f..d64cde5 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ ], "dependencies": { "ky": "^2.0.2", - "undici": "^6.23.0" + "undici": "^6.28.0" }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", From 80807f3481ed908e3315fcd2d7512336586b039b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 25 Jul 2026 02:45:32 +0000 Subject: [PATCH 21/21] 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 | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/agentCompatibility.js b/lib/agentCompatibility.js index edf24c1..cd1b93c 100644 --- a/lib/agentCompatibility.js +++ b/lib/agentCompatibility.js @@ -2,22 +2,13 @@ * Copyright (c) 2022 Digital Bazaar, Inc. All rights reserved. */ import {Agent} from 'undici'; -import {versions} from 'node:process'; // as long as an agent has a reference to it, its associated dispatcher will // be kept in this cache for reuse const AGENT_CACHE = new WeakMap(); -// 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); - // 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;