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
31 changes: 23 additions & 8 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,12 @@ 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:
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 @@ -60,11 +59,19 @@ 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 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 @@ -77,6 +84,14 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
# coverage runs the browser project too, so the browsers are required
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }}
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox webkit
- name: Generate coverage report
run: npm run coverage-ci
- name: Upload coverage to Codecov
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-attachments
.nyc_output
.project
.settings
.vscode
TAGS
coverage
dist
__screenshots__
node_modules
reports
47 changes: 44 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,57 @@
- `ky` is again exported.
- Change from using `ky` promises to regular instances.
- **BREAKING**: Remove `push` from the proxied method list.
- Update dependencies:
- **BREAKING**: `error.data` is now set for any error response body, not only
a JSON one.
- `ky@2` buffers the error body regardless of content type, so `.data` is
an object for JSON and a string otherwise. Under v4 it was left
`undefined` unless the content type included `json`.
- Code using `if(error.data)` as a "the server sent JSON" test needs
updating; an HTML error page from a proxy now makes it truthy.
- **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`.
- `undici@7`.
- Aligns with the undici built into the current Node.js LTS release.
- A v7 dispatcher is usable by the `fetch` built into Node.js 22, 24, and
26, so the legacy `agent`/`httpsAgent` options now use the platform
`fetch` on every supported release rather than an internal override.
- Update dev dependencies.
- Update README.md.
- **NOTE**: Update supported platforms.
- 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 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.

### Fixed
- 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.
- 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.
Expand Down
93 changes: 0 additions & 93 deletions karma.conf.cjs

This file was deleted.

60 changes: 38 additions & 22 deletions lib/agentCompatibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ import {versions} from 'node:process';
/*
Background: node ships its own copy of undici in the platform but does not
expose it (there is no `node:undici`), so this package installs its own. A
dispatcher only works with the undici that created it -- the handler contract
changed across majors, so handing an installed v6 dispatcher to a platform v7
or v8 `fetch` fails with "invalid onError method". Which major the platform
provides varies by release line (node 22 has 6, node 24 has 7, node 26 has 8),
so no single installed version matches every supported runtime -- with undici 6
installed, both node 24 and node 26 take the fallback path below. See
digitalbazaar/http-client#43.
dispatcher is only usable by an undici that speaks its handler dialect --
that contract changed across majors, so a mismatched pairing fails with
"invalid onError method" or "invalid onRequestStart method". Which major the
platform provides varies by release line (node 22 has 6, node 24 has 7, node
26 has 8). See digitalbazaar/http-client#43.
*/

// as long as an agent has a reference to it, its associated dispatcher will
Expand All @@ -26,34 +24,52 @@ const DISPATCHER_CACHE = new WeakMap();
// its agent lives, so the override has the same lifetime as the agent
const FETCH_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);
/*
Platform undici majors that each installed undici major's dispatcher can be
driven by. undici 7 is a transition release: it ships both `wrap-handler` and
`unwrap-handler` and translates between the old (v6 `onError`) and new (v8
`onRequestStart`) handler dialects in both directions, so a v7 dispatcher
works with platform undici 6, 7, and 8 -- every node this package supports.

Revisit when bumping undici. An installed major that is not listed falls back
to requiring an exact match, so a missing or stale entry only costs the
fallback path below -- still correct, just not the platform `fetch`.
*/
const COMPATIBLE_PLATFORM_MAJORS = {
7: [6, 7, 8]
};

/*
True when the installed and platform undici majors match, meaning their
dispatchers are interchangeable. Both reads are guarded: a future undici could
hide `package.json` behind an `exports` map, and `versions.undici` may be
absent. Either way fall back to `false` and use the installed undici's own
fetch -- the always-safe path -- rather than throwing at module load and
breaking `import` for every consumer.
True when the installed undici's dispatcher can be handed to the platform
`fetch`. Both reads are guarded: a future undici could hide `package.json`
behind an `exports` map, and `versions.undici` may be absent. Either way fall
back to `false` and use the installed undici's own fetch -- the always-safe
path -- rather than throwing at module load and breaking `import` for every
consumer.

The explicit integer check matters: `parseInt` returns `NaN` instead of
throwing, so an unreadable version never reaches the `catch`. If both reads
were unreadable the lookup would fall back to `[NaN]`, and `includes` matches
`NaN` to `NaN` under SameValueZero -- reporting compatible, the opposite of
the safe default.
*/
const platformFetchCompatible = (() => {
try {
const installedMajor = parseInt(undiciPkg.version, 10);
const platformMajor = parseInt(versions.undici, 10);
return platformMajor === installedMajor;
if(!Number.isInteger(installedMajor) || !Number.isInteger(platformMajor)) {
return false;
}
const compatible =
COMPATIBLE_PLATFORM_MAJORS[installedMajor] ?? [installedMajor];
return compatible.includes(platformMajor);
} catch {
return false;
}
})();

// 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 All @@ -77,7 +93,7 @@ export function convertAgent(options) {
delete rest.agent;
delete rest.httpsAgent;

// majors match: hand the dispatcher to `ky`, which forwards it to the
// compatible: hand the dispatcher to `ky`, which forwards it to the
// platform `fetch` (`ky` deliberately keeps `dispatcher` out of its
// request-option registry so it reaches fetch) -- no wrapper needed
if(platformFetchCompatible) {
Expand Down
32 changes: 28 additions & 4 deletions lib/httpClient.js
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -25,6 +25,23 @@ const PROXY_METHODS = new Set([
'get', 'post', 'put', 'patch', 'head', 'delete'
]);

/*
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.
*/
const BROWSER_NETWORK_ERRORS = new Set([
// Chromium
'Failed to fetch',
// Firefox
'NetworkError when attempting to fetch resource.',
// WebKit
'Load failed'
]);

/**
* Returns a custom httpClient instance. Used to specify default headers and
* other default overrides.
Expand All @@ -47,12 +64,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);
Expand Down Expand Up @@ -131,7 +152,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(BROWSER_NETWORK_ERRORS.has(error.message) ||
BROWSER_NETWORK_ERRORS.has(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