Skip to content
Draft
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
21 changes: 14 additions & 7 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Run tests with Node.js ${{ matrix.node-version }}
run: npm run test-node
test-karma:
run: npm run test:node
test-browser:
runs-on: ubuntu-latest
timeout-minutes: 10
# three browser engines plus a cold browser install
timeout-minutes: 20
strategy:
matrix:
node-version: [24.x]
Expand All @@ -58,11 +59,14 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- name: Run karma tests
run: npm run test-karma
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox webkit
- name: Run browser tests
run: npm run test:browser
coverage:
runs-on: ubuntu-latest
timeout-minutes: 10
# the browser project runs here too, across three engines
timeout-minutes: 20
strategy:
matrix:
node-version: [24.x]
Expand All @@ -75,8 +79,11 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
# coverage runs the browser project too, so the browsers are required
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox webkit
- name: Generate coverage report
run: npm run coverage-ci
run: npm run coverage:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
*.sw[nop]
*~
.cache
.vitest
.nyc_output
.project
.settings
.vscode
TAGS
coverage
dist
__screenshots__
node_modules
reports
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
- Keep `create()` headers given as a `Headers` instance. They were dropped.
- Support `ky`'s `replaceOption()` for `headers` in `create()` and
`extend()`. In `create()` it replaces the default headers.
- Detect a possible CORS error in Firefox and WebKit, not just Chromium. The
`Failed to fetch "<url>". Possible CORS error.` message was produced by
matching Chromium's network-error text, so other engines fell through to a
generic error. Firefox and WebKit wording is now recognized as well, as are
the Chromium and WebKit variants with a host suffix, such as
`Failed to fetch (example.com)`.

### Changed
- **BREAKING**: Revert CJS related workarounds from v3.0.0.
Expand Down Expand Up @@ -44,6 +50,25 @@
- 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 via `playwright` instead of `karma`, in Chromium,
Firefox, and WebKit rather than Chromium alone.
- `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 scripts use `group:variant` names: `test:node`, `test:browser`,
`test:watch`, and `coverage:ci`.
- `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 `istanbul` and `c8` count executable lines differently.
The `istanbul` provider is used rather than `v8` because v8 coverage is
gathered over CDP, which only Chromium supports.

### Removed
- **BREAKING**: Remove CJS support.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ const client = httpClient.extend({headers});
import {Agent} from 'https';
import {httpClient} from '@digitalbazaar/http-client';

const agent = new https.Agent({rejectUnauthorized: false});
const client = httpClient.extend({headers, agent});
const agent = new Agent({rejectUnauthorized: false});
const client = httpClient.extend({agent});

// subsequent http calls will use the provided https Agent
```
Expand Down
109 changes: 0 additions & 109 deletions karma.conf.cjs

This file was deleted.

123 changes: 56 additions & 67 deletions lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/*!
* Copyright (c) 2020-2026 Digital Bazaar, Inc.
*/
import ky, {replaceOption} from 'ky';
import {convertAgent} from './agentCompatibility.js';
import ky from 'ky';

export {ky};

export const DEFAULT_HEADERS = {
Accept: 'application/ld+json, application/json'
};

// every client extends this instance (or a client derived from it), so `ky`'s
// own merge applies header overrides case-insensitively, deletes a header
// given as `undefined`, and honors `replaceOption()`
const defaultKy = ky.create({headers: DEFAULT_HEADERS});

/*
Methods to proxy from `ky`. This must mirror `ky`'s own helper registry
(`requestMethods` in its `core/constants.js`), because proxying indexes into
Expand All @@ -24,82 +29,50 @@ const PROXY_METHODS = new Set([
'get', 'post', 'put', 'patch', 'head', 'delete', 'query'
]);

/*
Browsers reject a blocked or failed `fetch` with a `TypeError` whose message
is engine-specific. A cross-origin block is deliberately indistinguishable
from any other network failure -- the response is opaque -- which is why the
message below says "Possible". Node.js rejects with `fetch failed`, which is
intentionally absent here: there is no CORS in Node.js, so the hint would be
misleading. Each entry is exercised by the browser test matrix.

Chromium and WebKit may also append the host, as in
`Failed to fetch (example.com)`; those variants follow `is-network-error`,
which `ky` inlines to detect network errors in every runtime. `ky`'s own
`isNetworkError` is not used here because it also matches Node.js and other
non-browser runtimes.
*/
const BROWSER_NETWORK_ERRORS = new Set([
// Chromium
'Failed to fetch',
// Firefox
'NetworkError when attempting to fetch resource.',
// WebKit
'Load failed'
]);
// messages that may carry a ` (<host>)` suffix
const HOST_SUFFIXED_NETWORK_ERRORS = ['Failed to fetch', 'Load failed'];

/**
* 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 instance to inherit from.
* @param {object} [options.headers={}] - Default header overrides.
* @param {object} [options.parent] - The ky instance to inherit from;
* defaults to one with `DEFAULT_HEADERS`.
* @param {object|Headers} [options.headers={}] - Default header overrides.
* @param {object} [options.params] - Other default overrides.
*
* @returns {Function} Custom httpClient instance.
*/
export function createInstance({
parent = ky, headers = {}, ...params
parent = defaultKy, headers = {}, ...params
} = {}) {
// convert legacy agent options
params = convertAgent(params);

// headers are always passed to ky as 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`)
const {replace, value} = _unwrapReplaceOption(headers);

// create new ky instance
let _ky;
if(parent === ky) {
// ensure default headers, allow overrides; `replaceOption()` replaces
// the defaults instead
_ky = parent.create({
headers: _mergeHeaders(replace ? undefined : DEFAULT_HEADERS, value),
...params
});
} else {
// extend parent; an `undefined` value is left for ky's merge, which
// deletes that header from the parent's
_ky = parent.extend({
headers: replace ?
replaceOption(_mergeHeaders(undefined, value)) : new Headers(value),
...params
});
}

return _createHttpClient(_ky);
}

// ky marks a `replaceOption()` value with a private symbol; recover it from
// a marked value so a marked `headers` option can be recognized
const [REPLACE_OPTION] = Object.getOwnPropertySymbols(replaceOption());

function _unwrapReplaceOption(value) {
if(value?.[REPLACE_OPTION]) {
return {replace: true, value: value.value};
}
return {replace: false, value};
}

/**
* Sets `headers` over `base` case-insensitively.
*
* @param {object|Headers} [base] - Base headers.
* @param {object|Headers} [headers] - Headers to set over `base`.
*
* @returns {Headers} The merged headers.
*/
function _mergeHeaders(base, headers) {
const result = new Headers(base);
for(const [name, value] of new Headers(headers)) {
// `Headers` converts an `undefined` value to 'undefined'; like ky, treat
// it as a deletion
if(value === 'undefined') {
result.delete(name);
} else {
result.set(name, value);
}
}
return result;
return _createHttpClient(parent.extend({headers, ...params}));
}

function _createHttpClient(instance) {
Expand Down Expand Up @@ -163,6 +136,22 @@ async function _handleResponse(target, thisArg, args) {
return response;
}

/**
* Checks whether an error message is a browser's `fetch` network error.
*
* @param {string} [message] - Error message.
*
* @returns {boolean} True for a browser network error message.
*/
function _isBrowserNetworkError(message) {
if(typeof message !== 'string') {
return false;
}
return BROWSER_NETWORK_ERRORS.has(message) ||
HOST_SUFFIXED_NETWORK_ERRORS.some(prefix =>
message.startsWith(`${prefix} (`) && message.endsWith(')'));
}

/**
* @param {object} options - Options hashmap.
* @param {Error} options.error - Error thrown during http operation.
Expand All @@ -175,10 +164,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' ||
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`
if(_isBrowserNetworkError(error.message) ||
_isBrowserNetworkError(error.cause?.message)) {
// ky@2 wraps the browser's underlying `TypeError` in its own
// `NetworkError`, with the original error as `cause`
error.message = `Failed to fetch "${url}". Possible CORS error.`;
}
// ky's TimeoutError class
Expand Down
Loading
Loading