From a62a5751597bd907783ce146368b5e64534e4880 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 18:16:12 -0400 Subject: [PATCH 01/24] Update eslint dependencies. --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- tests/.eslintrc.cjs | 8 -------- 3 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 tests/.eslintrc.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f07a15e..72b3077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # @digitalbazaar/http-client ChangeLog +## 5.0.0 - 2026-xx-xx + +### Changed +- Update dev dependencies. + ## 4.3.0 - 2026-01-15 ### Changed diff --git a/package.json b/package.json index 8ea6b09..4fc092e 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,13 @@ "undici": "^6.23.0" }, "devDependencies": { - "@digitalbazaar/eslint-config": "^7.0.0", + "@digitalbazaar/eslint-config": "^9.0.0", "c8": "^10.1.3", "chai": "^4.5.0", "cors": "^2.8.5", "cross-env": "^10.1.0", "detect-node": "^2.1.0", - "eslint": "^9.39.2", + "eslint": "^10.7.0", "express": "^5.2.1", "karma": "^6.4.4", "karma-chai": "^0.1.0", diff --git a/tests/.eslintrc.cjs b/tests/.eslintrc.cjs deleted file mode 100644 index f76d0f3..0000000 --- a/tests/.eslintrc.cjs +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - env: { - mocha: true - }, - globals: { - should: true - } -}; From cf8e6f036191236e2e8241d545bfa2bc73655192 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 18:54:27 -0400 Subject: [PATCH 02/24] Update dev dependencies. --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4fc092e..2047bbe 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@digitalbazaar/eslint-config": "^9.0.0", "c8": "^10.1.3", "chai": "^4.5.0", - "cors": "^2.8.5", + "cors": "^2.8.6", "cross-env": "^10.1.0", "detect-node": "^2.1.0", "eslint": "^10.7.0", @@ -56,10 +56,10 @@ "karma-mocha-reporter": "^2.2.5", "karma-sourcemap-loader": "^0.4.0", "karma-webpack": "^5.0.1", - "mocha": "^11.7.5", - "rimraf": "^6.1.2", - "rollup": "^4.55.1", - "webpack": "^5.104.1" + "mocha": "^11.7.6", + "rimraf": "^6.1.3", + "rollup": "^4.62.2", + "webpack": "^5.109.0" }, "repository": { "type": "git", From 8155cf5eeea42dfa47a3127de834c18df7fb9ffc Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Thu, 23 Jul 2026 19:15:51 -0400 Subject: [PATCH 03/24] 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 04/24] 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 05/24] 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 06/24] 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 07/24] 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 08/24] 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 09/24] 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 10/24] 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 11/24] 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 12/24] 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 13/24] 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 14/24] 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 15/24] 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 16/24] 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 17/24] 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 18/24] 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 19/24] 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 20/24] 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 21/24] 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 22/24] 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 23/24] 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; From d141bf5e5d07708556c3243475ba65b8ca2f6886 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 03:00:26 +0000 Subject: [PATCH 24/24] Switch testing from mocha/chai/karma/c8 to vitest. Karma is unmaintained. Vitest replaces the runner, the browser test harness, and the coverage tool with a single dependency and config. - Add `vitest.config.js` with `node` and `browser` projects. The browser project runs Chromium through `@vitest/browser-playwright`, replacing karma and karma-webpack. - Keep the existing `should`-style assertions unchanged. `tests/setup.js` installs the global `should` from vitest's re-exported chai, so `chai` is no longer a dependency. - Start the HTTP/HTTPS test servers in `tests/globalSetup.js` and pass their ephemeral hosts to tests with `inject()`. This replaces starting them in the karma config and injecting the hosts via webpack's `DefinePlugin`, and lets `tests/utils-browser.js` be removed. - Split the suite by environment into `10-client-api.spec.js` (shared), `20-node.spec.js`, and `30-browser.spec.js` instead of branching on `isNode` at runtime. This keeps node-only modules out of the browser project and drops the `detect-node` dependency. Test bodies are unchanged; both projects still run 17 tests. - Report coverage with `@vitest/coverage-v8` across both projects, so browser-only code paths are now covered. Reported totals shift slightly because vitest and c8 count executable lines differently. - Replace the `test-karma` CI job with `test-browser`, and install and cache Playwright's Chromium in the browser and coverage jobs. Add an `exports` field with a `browser` condition for `agentCompatibility` and import it via a self-reference. Vite does not apply the top-level `browser` field to package-internal relative imports, so this is also a fix for browser bundlers, which would otherwise pull `undici` into their builds. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/main.yaml | 21 +++++- CHANGELOG.md | 20 ++++++ karma.conf.cjs | 109 ----------------------------- lib/httpClient.js | 2 +- package.json | 49 ++++++------- tests/10-client-api.spec.js | 132 ++---------------------------------- tests/20-node.spec.js | 80 ++++++++++++++++++++++ tests/30-browser.spec.js | 66 ++++++++++++++++++ tests/globalSetup.js | 19 ++++++ tests/setup.js | 8 +++ tests/test-mocha.js | 2 - tests/utils-browser.js | 27 -------- vitest.config.js | 52 ++++++++++++++ 13 files changed, 289 insertions(+), 298 deletions(-) delete mode 100644 karma.conf.cjs create mode 100644 tests/20-node.spec.js create mode 100644 tests/30-browser.spec.js create mode 100644 tests/globalSetup.js create mode 100644 tests/setup.js delete mode 100644 tests/test-mocha.js delete mode 100644 tests/utils-browser.js create mode 100644 vitest.config.js diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 03191b3..20fb329 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -43,7 +43,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Run tests with Node.js ${{ matrix.node-version }} run: npm run test-node - test-karma: + test-browser: runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -58,8 +58,15 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Run karma tests - run: npm run test-karma + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium + - name: Run browser tests + run: npm run test-browser coverage: runs-on: ubuntu-latest timeout-minutes: 10 @@ -75,6 +82,14 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + # coverage runs the browser project too, so a browser is required + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium - name: Generate coverage report run: npm run coverage-ci - name: Upload coverage to Codecov diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4a747..d13e5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,26 @@ - Test on Node.js >=22. - Update `engines.node` to `>=22`. - Update README requirements section. +- **BREAKING**: Add an `exports` field. + - Only `.`, `./agentCompatibility.js`, and `./package.json` are importable; + other deep imports into the package are no longer reachable. +- Switch testing from `mocha`/`chai`/`karma`/`c8` to `vitest`. + - `karma` is unmaintained; `vitest` covers Node.js tests, browser tests, and + coverage with a single tool and config. + - Browser tests now run in Chromium via `playwright` instead of `karma`. + - `npm test` now runs both the Node.js and browser suites; use + `npm run test-node` or `npm run test-browser` for one of them. + - `npm run test-karma` is replaced by `npm run test-browser`. + - `npm run coverage-report` is removed; use + `npm run coverage -- --coverage.reporter=html`. + - Coverage now includes the browser suite, and reported totals shift + slightly because `vitest` and `c8` count executable lines differently. + +### Fixed +- Resolve `agentCompatibility` through an `exports` `browser` condition rather + than only the top-level `browser` field. Bundlers that do not apply the + `browser` field to package-internal relative imports (such as Vite) no longer + pull `undici` into browser builds. ### Removed - **BREAKING**: Remove CJS support. diff --git a/karma.conf.cjs b/karma.conf.cjs deleted file mode 100644 index 1bc4f80..0000000 --- a/karma.conf.cjs +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2020-2026 Digital Bazaar, Inc. - */ - -const {startServers} = require('./tests/utils.js'); -const webpack = require('webpack'); - -module.exports = async function(config) { - const { - //httpServer, - //httpsServer, - httpHost: testHttpHost, - httpsHost: testHttpsHost - } = await startServers(); - - config.set({ - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha', 'chai'], - - // list of files / patterns to load in the browser - files: [ - 'tests/*.spec.js' - ], - - // list of files to exclude - exclude: [], - - // preprocess matching files before serving them to the browser - // preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - 'tests/*.js': ['webpack', 'sourcemap'] - }, - - webpack: { - //mode: 'production', - mode: 'development', - devtool: 'inline-source-map', - plugins: [ - new webpack.DefinePlugin({ - 'process.env.TEST_HTTP_HOST': JSON.stringify(testHttpHost), - 'process.env.TEST_HTTPS_HOST': JSON.stringify(testHttpsHost) - }) - ] - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - //reporters: ['progress'], - reporters: ['mocha'], - - // web server port - port: 9876, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || - // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - // enable / disable watching file and executing tests whenever any - // file changes - autoWatch: false, - - // start these browsers - // browser launchers: https://npmjs.org/browse/keyword/karma-launcher - //browsers: ['ChromeHeadless', 'Chrome', 'Firefox', 'Safari'], - 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', - // Accept the self-signed cert used by the local HTTPS test server - '--ignore-certificate-errors' - ] - } - }, - - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: true, - - // Concurrency level - // how many browser should be started simultaneous - concurrency: Infinity, - - // Mocha - client: { - mocha: { - // increase from default 2s - timeout: 10000, - reporter: 'html' - //delay: true - } - } - }); -}; diff --git a/lib/httpClient.js b/lib/httpClient.js index 30f74a8..5fd843a 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import {convertAgent} from './agentCompatibility.js'; +import {convertAgent} from '@digitalbazaar/http-client/agentCompatibility.js'; import ky from 'ky'; export {ky}; diff --git a/package.json b/package.json index d64cde5..a2f746e 100644 --- a/package.json +++ b/package.json @@ -5,21 +5,28 @@ "license": "BSD-3-Clause", "type": "module", "main": "./lib/index.js", + "exports": { + ".": "./lib/index.js", + "./agentCompatibility.js": { + "react-native": "./lib/agentCompatibility-browser.js", + "browser": "./lib/agentCompatibility-browser.js", + "default": "./lib/agentCompatibility.js" + }, + "./package.json": "./package.json" + }, "browser": { - "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js", - "./tests/utils.js": "./tests/utils-browser.js" + "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js" }, "react-native": { "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js" }, "scripts": { - "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-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", - "coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly --reporter=text-summary --reporter=text npm run test-node", - "coverage-report": "c8 report", + "test": "vitest run", + "test-node": "vitest run --project node", + "test-browser": "vitest run --project browser", + "test-watch": "vitest", + "coverage": "vitest run --coverage", + "coverage-ci": "vitest run --coverage --coverage.reporter=lcovonly --coverage.reporter=text-summary --coverage.reporter=text", "lint": "eslint" }, "files": [ @@ -31,22 +38,13 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "c8": "^12.0.0", - "chai": "^4.5.0", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", "cors": "^2.8.6", - "cross-env": "^10.1.0", - "detect-node": "^2.1.0", "eslint": "^10.8.0", "express": "^5.2.1", - "karma": "^6.4.4", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^3.2.0", - "karma-mocha": "^2.0.1", - "karma-mocha-reporter": "^2.2.5", - "karma-sourcemap-loader": "^0.4.0", - "karma-webpack": "^5.0.1", - "mocha": "^11.7.6", - "webpack": "^5.109.0" + "playwright": "^1.62.0", + "vitest": "^4.1.10" }, "repository": { "type": "git", @@ -68,12 +66,5 @@ "homepage": "https://github.com/digitalbazaar/http-client", "engines": { "node": ">=22" - }, - "c8": { - "reporter": [ - "lcov", - "text-summary", - "text" - ] } } diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 49e316d..c622fb5 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,30 +1,18 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import * as utils from './utils.js'; import { DEFAULT_HEADERS, httpClient, ky } from '../lib/index.js'; -import isNode from 'detect-node'; +import {describe, inject, it} from 'vitest'; +// tests shared by the `node` and `browser` projects; environment-specific +// tests live in `20-node.spec.js` and `30-browser.spec.js` 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() - ]); - }); + // local test servers are started once by `tests/globalSetup.js` + const httpHost = inject('httpHost'); it('has proper exports', async () => { should.exist(ky); @@ -49,51 +37,6 @@ describe('http-client API', () => { response.status.should.equal(200); }); - if(isNode) { - // 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://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; - }); - } - - // 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; @@ -156,27 +99,6 @@ describe('http-client API', () => { } }); - if(!isNode) { - // browser check for endpoint without CORS - it('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; @@ -365,50 +287,6 @@ describe('http-client API', () => { 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') || - // node 22+ / ky@2 - m.includes( - 'Request failed due to a network error: ' + - 'GET http://localhost:9876/does-not-exist')); - }); - }); - } 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'; diff --git a/tests/20-node.spec.js b/tests/20-node.spec.js new file mode 100644 index 0000000..2af8ff9 --- /dev/null +++ b/tests/20-node.spec.js @@ -0,0 +1,80 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import {describe, inject, it} from 'vitest'; +import {httpClient} from '../lib/index.js'; +import {makeAgent} from './utils.js'; + +// tests that only run in the `node` project; this file is free to import +// node-only modules, which is why it is kept out of `10-client-api.spec.js` +describe('http-client API', () => { + // local test servers are started once by `tests/globalSetup.js` + const httpsHost = inject('httpsHost'); + + // 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://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; + }); + + // test local self-signed cert; node needs an agent to accept it + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/ping`; + try { + const agent = 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); + }); + + 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') || + // node 22+ / ky@2 + m.includes( + 'Request failed due to a network error: ' + + 'GET http://localhost:9876/does-not-exist')); + }); + }); +}); diff --git a/tests/30-browser.spec.js b/tests/30-browser.spec.js new file mode 100644 index 0000000..ffb9b4c --- /dev/null +++ b/tests/30-browser.spec.js @@ -0,0 +1,66 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import {describe, inject, it} from 'vitest'; +import {httpClient} from '../lib/index.js'; + +// tests that only run in the `browser` project +describe('http-client API', () => { + // local test servers are started once by `tests/globalSetup.js` + const httpHost = inject('httpHost'); + const httpsHost = inject('httpsHost'); + + // test local self-signed cert; no agent is needed because the playwright + // provider always runs the browser context with `ignoreHTTPSErrors` + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/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); + }); + + // browser check for endpoint without CORS + it('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); + }); + + 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.'); + }); + }); +}); diff --git a/tests/globalSetup.js b/tests/globalSetup.js new file mode 100644 index 0000000..b5aefcd --- /dev/null +++ b/tests/globalSetup.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +// starts the local HTTP/HTTPS test servers once per project and hands their +// ephemeral hosts to the tests via `inject()`; the servers must start here, +// on the Node.js side, because the browser project cannot run them itself +import {startServers} from './utils.js'; + +export default async function setup(project) { + const {httpServer, httpsServer, httpHost, httpsHost} = await startServers(); + + project.provide('httpHost', httpHost); + project.provide('httpsHost', httpsHost); + + return async () => { + httpServer.close(); + httpsServer.close(); + }; +} diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..1e7a596 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,8 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +// vitest re-exports chai's `should` interface; expose it as a global so the +// existing `should`-style assertions work unchanged +import {should} from 'vitest'; + +globalThis.should = should(); diff --git a/tests/test-mocha.js b/tests/test-mocha.js deleted file mode 100644 index 8c4930e..0000000 --- a/tests/test-mocha.js +++ /dev/null @@ -1,2 +0,0 @@ -import {should} from 'chai'; -global.should = should(); diff --git a/tests/utils-browser.js b/tests/utils-browser.js deleted file mode 100644 index 87692ef..0000000 --- a/tests/utils-browser.js +++ /dev/null @@ -1,27 +0,0 @@ -/*! - * Copyright (c) 2023-2026 Digital Bazaar, Inc. - */ -export async function startServers() { - return { - // mock server - // karma will startup real server - httpServer: { - close: async () => {} - }, - // mock server - // karma will startup real server - httpsServer: { - close: async () => {} - }, - // get host string from server karma started - httpHost: process.env.TEST_HTTP_HOST, - 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; -} diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..c62be84 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,52 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {defineConfig} from 'vitest/config'; +import {playwright} from '@vitest/browser-playwright'; + +export default defineConfig({ + test: { + // tests make real network requests, so the 5s default is too low + testTimeout: 30000, + hookTimeout: 30000, + // `coverage` is process-wide: it can only be set at the root, never + // inside a project, and applies across every project in the run + coverage: { + provider: 'v8', + reporter: ['lcov', 'text-summary', 'text'], + include: ['lib/**/*.js'] + }, + projects: [ + { + extends: true, + test: { + name: 'node', + environment: 'node', + globalSetup: ['./tests/globalSetup.js'], + setupFiles: ['./tests/setup.js'], + include: ['tests/10-client-api.spec.js', 'tests/20-node.spec.js'] + } + }, + { + extends: true, + test: { + name: 'browser', + globalSetup: ['./tests/globalSetup.js'], + setupFiles: ['./tests/setup.js'], + include: ['tests/10-client-api.spec.js', 'tests/30-browser.spec.js'], + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: { + // required to launch Chromium in CI containers + args: ['--no-sandbox', '--disable-setuid-sandbox'] + } + }), + instances: [{browser: 'chromium'}] + } + } + } + ] + } +});