Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 60 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand All @@ -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');
Expand All @@ -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
Expand All @@ -61,6 +98,7 @@ try {
```

#### GET HTML by overriding default headers

```js
const headers = {Accept: 'text/html'};
try {
Expand All @@ -76,6 +114,7 @@ try {
```

#### POST a JSON payload

```js
try {
const response = await httpClient.post('http://httpbin.org/json', {
Expand All @@ -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
Expand All @@ -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
22 changes: 19 additions & 3 deletions karma.conf.cjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 0 additions & 9 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 0 additions & 18 deletions lib/deferred.js

This file was deleted.

62 changes: 30 additions & 32 deletions lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,59 @@
/*!
* 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'
};

// 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'
]);

/**
* Returns a custom httpClient instance. Used to specify default headers and
* 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);
Expand All @@ -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);
};
}
Expand All @@ -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;
}
});
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
6 changes: 3 additions & 3 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -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();
Loading