diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 40079ca..03191b3 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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b3077..9f4a747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,28 @@ ## 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. +- **BREAKING**: Update proxied method list. + - Remove `push`. + - Add `query`, `options`, and `trace` to align with `ky@2`. +- **BREAKING**: Update dependencies: + - `ky@2`. + - 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. + - Test on Node.js >=22. + - Update `engines.node` to `>=22`. + - Update README requirements section. + +### Removed +- **BREAKING**: Remove CJS support. ## 4.3.0 - 2026-01-15 diff --git a/README.md b/README.md index 2ed472b..df07eee 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,48 @@ -# 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 + +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) + ```js import {httpClient} from '@digitalbazaar/http-client'; ``` #### Import and initialize a custom Bearer Token client + ```js import {httpClient} from '@digitalbazaar/http-client'; @@ -21,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'; @@ -32,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'); @@ -45,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 @@ -61,6 +98,7 @@ try { ``` #### GET HTML by overriding default headers + ```js const headers = {Accept: 'text/html'}; try { @@ -76,6 +114,7 @@ try { ``` #### POST a JSON payload + ```js try { const response = await httpClient.post('http://httpbin.org/json', { @@ -92,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 @@ -110,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 diff --git a/karma.conf.cjs b/karma.conf.cjs index b9377fc..1bc4f80 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,23 @@ 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', + // 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 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; 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..30f74a8 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' @@ -13,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' ]); /** @@ -21,40 +20,40 @@ 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({ + // 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: new Headers(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 +66,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 +75,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; } }); @@ -128,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 @@ -144,11 +144,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; } 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/package.json b/package.json index 2047bbe..d64cde5 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,21 +23,20 @@ "lint": "eslint" }, "files": [ - "lib/*", - "dist/*" + "lib/*" ], "dependencies": { - "ky": "^1.14.2", - "undici": "^6.23.0" + "ky": "^2.0.2", + "undici": "^6.28.0" }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "c8": "^10.1.3", + "c8": "^12.0.0", "chai": "^4.5.0", "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", @@ -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": { @@ -80,7 +67,7 @@ }, "homepage": "https://github.com/digitalbazaar/http-client", "engines": { - "node": ">=18.0" + "node": ">=22" }, "c8": { "reporter": [ 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..49e316d 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,13 +1,438 @@ /*! - * Copyright (c) 2020-2022 Digital Bazaar, Inc. All rights reserved. + * 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 {test} from './10-client-api.spec.common.cjs'; -import utils from './utils.cjs'; -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() + ]); + }); + + it('has proper exports', async () => { + 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); + }); + + 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; + 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('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') || + // 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'; + + 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/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'); - }); -}); - 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 53% rename from tests/utils-browser.cjs rename to tests/utils-browser.js index 582bf90..87692ef 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,11 @@ api.startServers = async () => { 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/tests/utils.cjs b/tests/utils.js similarity index 76% rename from tests/utils.cjs rename to tests/utils.js index c988f1e..a83430e 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(); @@ -99,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