Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
8 changes: 0 additions & 8 deletions .claude/settings.json

This file was deleted.

1 change: 0 additions & 1 deletion .claude/skills/vite-plus

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
node: ['22', '24', '25']
node: ['22', '24', '26']

name: Test (${{ matrix.os }}, ${{ matrix.node }})
runs-on: ${{ matrix.os }}
Expand Down
14 changes: 14 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Patch coverage stays strict (new/changed lines must be covered).
# Project coverage tolerates small fluctuations from external-network tests
# and undici behaviour changes (e.g. an error branch reachable only on a
# different protocol path).
coverage:
status:
project:
default:
target: auto
threshold: 1%
patch:
default:
target: auto
threshold: 0%
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"mime-types": "^2.1.35",
"qs": "^6.15.0",
"type-fest": "^4.41.0",
"undici": "^7.24.0",
"undici": "^8.4.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor allowH2:false in FetchFactory options

With this upgrade, omitting allowH2 now means HTTP/2 is enabled by default, but the same ClientOptions type is also used by FetchFactory.setClientOptions(): when callers pass only { allowH2: false }, src/fetch.ts still checks clientOptions?.allowH2 truthily and constructs a BaseAgent without the option, so undici v8 negotiates HTTP/2 anyway. This leaves fetch users without the documented HTTP/1.1 opt-out unless they also provide connect/lookup or a custom dispatcher.

Useful? React with 👍 / 👎.

"ylru": "^2.0.0"
},
"devDependencies": {
Expand Down Expand Up @@ -110,7 +110,7 @@
}
},
"engines": {
"node": ">= 22.0.0"
"node": ">= 22.19.0"
},
"packageManager": "pnpm@11.6.0"
}
32 changes: 16 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 53 additions & 11 deletions src/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ import { parseJSON, digestAuthHeader, globalId, performanceTime, isReadable, upd
type Exists<T> = T extends undefined ? never : T;
type UndiciRequestOption = Exists<Parameters<typeof undiciRequest>[1]>;
type PropertyShouldBe<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
type IUndiciRequestOption = PropertyShouldBe<UndiciRequestOption, 'headers', IncomingHttpHeaders>;
// undici reads `allowH2` per dispatch (Agent picks an http1-only pool when false),
// but it is not part of the public request options type, so add it explicitly.
type IUndiciRequestOption = PropertyShouldBe<UndiciRequestOption, 'headers', IncomingHttpHeaders> & {
allowH2?: boolean;
};

export const PROTO_RE: RegExp = /^https?:\/\//i;

Expand Down Expand Up @@ -74,7 +78,7 @@ const debug = debuglog('urllib:HttpClient');

export type ClientOptions = {
defaultArgs?: RequestOptions;
/** Allow to use HTTP2 first. Default is `false` */
/** Negotiate HTTP/2 with capable servers via ALPN. Enabled by default since undici@8; set `false` to force HTTP/1.1. */
allowH2?: boolean;
/** Custom DNS lookup function, default is `dns.lookup`. */
lookup?: LookupFunction;
Expand Down Expand Up @@ -171,6 +175,36 @@ export interface PoolStat {
size: number;
}

// undici@8 keys http1-only pools (allowH2: false) as `${origin}#http1-only`;
// expose them under their plain origin so callers can look up stats by URL.
export function normalizePoolStatsKey(key: string): string {
const index = key.indexOf('#http1-only');
Comment thread
fengmk2 marked this conversation as resolved.
Outdated
return index === -1 ? key : key.slice(0, index);
}

// When both an HTTP/2 and an http1-only pool exist for the same origin, sum
// their stats so a single origin entry reflects every client.
export function mergePoolStat(existing: PoolStat | undefined, stats: PoolStat): PoolStat {
if (!existing) {
return {
connected: stats.connected,
free: stats.free,
pending: stats.pending,
queued: stats.queued,
running: stats.running,
size: stats.size,
};
}
return {
connected: existing.connected + stats.connected,
free: existing.free + stats.free,
pending: existing.pending + stats.pending,
queued: existing.queued + stats.queued,
Comment thread
fengmk2 marked this conversation as resolved.
Outdated
running: existing.running + stats.running,
size: existing.size + stats.size,
};
}

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
const RedirectStatusCodes = [
301, // Moved Permanently
Expand All @@ -189,10 +223,14 @@ const CrossOriginSensitiveHeaders = new Set(['authorization', 'cookie', 'proxy-a
export class HttpClient extends EventEmitter {
#defaultArgs?: RequestOptions;
#dispatcher?: Dispatcher;
#allowH2?: boolean;

constructor(clientOptions?: ClientOptions) {
super();
this.#defaultArgs = clientOptions?.defaultArgs;
// Remember the client-level protocol preference so it can be applied per
// request (mainly for `allowH2: false`, which has no dedicated agent).
this.#allowH2 = clientOptions?.allowH2;
if (clientOptions?.lookup || clientOptions?.checkAddress) {
this.#dispatcher = new HttpAgent({
lookup: clientOptions.lookup,
Expand All @@ -206,7 +244,8 @@ export class HttpClient extends EventEmitter {
allowH2: clientOptions.allowH2,
});
} else if (clientOptions?.allowH2) {
// Support HTTP2
// Support HTTP/2 with a dedicated agent. `allowH2: false` is handled
// per request instead, so it does not bypass the active dispatcher.
this.#dispatcher = new Agent({
allowH2: clientOptions.allowH2,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize stats keys for HTTP/1-only agents

Passing allowH2: false here makes undici v8 store the internal client under a key like https://host#http1-only; getDispatcherPoolStats() exposes those internal kClients keys as documented origins, so callers using new HttpClient({ allowH2: false }) or request(..., { allowH2: false }) can no longer look up stats by the origin URL and get undefined. Consider normalizing that suffix (or using the dispatcher origin) when building the stats map.

Useful? React with 👍 / 👎.

});
Expand Down Expand Up @@ -236,14 +275,10 @@ export class HttpClient extends EventEmitter {
const stats = pool?.stats ?? pool?.dispatcher?.stats;
if (!stats) continue;

poolStatsMap[key] = {
connected: stats.connected,
free: stats.free,
pending: stats.pending,
queued: stats.queued,
running: stats.running,
size: stats.size,
} satisfies PoolStat;
// undici@8 keys http1-only pools (allowH2: false) as `${origin}#http1-only`,
// expose them by their origin so callers can look up stats by URL.
const origin = normalizePoolStatsKey(key);
poolStatsMap[origin] = mergePoolStat(poolStatsMap[origin], stats);
}
return poolStatsMap;
}
Expand Down Expand Up @@ -441,6 +476,13 @@ export class HttpClient extends EventEmitter {
signal: args.signal,
reset: false,
};
const allowH2 = args.allowH2 ?? this.#allowH2;
if (typeof allowH2 === 'boolean') {
// Apply the protocol preference per request so the active dispatcher
// (global, proxy, ...) is honored instead of being bypassed by a
// dedicated HTTP/1.1-only agent.
requestOptions.allowH2 = allowH2;
Comment thread
fengmk2 marked this conversation as resolved.
}
if (typeof args.highWaterMark === 'number') {
requestOptions.highWaterMark = args.highWaterMark;
}
Expand Down
7 changes: 6 additions & 1 deletion src/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type RequestURL = string | URL;
export type FixJSONCtlCharsHandler = (data: string) => string;
export type FixJSONCtlChars = boolean | FixJSONCtlCharsHandler;

type AbortSignal = unknown;
type AbortSignal = globalThis.AbortSignal;

export type RequestOptions = {
/** Request method, defaults to GET. Could be GET, POST, DELETE or PUT. Alias 'type'. */
Expand Down Expand Up @@ -141,6 +141,11 @@ export type RequestOptions = {
ctx?: unknown;
/** Request dispatcher, default is getGlobalDispatcher() */
dispatcher?: Dispatcher;
/**
* Negotiate HTTP/2 with capable servers via ALPN. Enabled by default since undici@8; set `false` to force HTTP/1.1
* for this request without bypassing the active dispatcher.
*/
allowH2?: boolean;
/** Unix domain socket file path */
socketPath?: string | null;
/** Whether the request should stablish a keep-alive or not. Default `false`, try to keep alive by default */
Expand Down
19 changes: 8 additions & 11 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { initDiagnosticsChannel } from './diagnosticsChannel.js';
import type { FetchOpaque } from './FetchOpaqueInterceptor.js';
import { HttpAgent } from './HttpAgent.js';
import type { HttpAgentOptions } from './HttpAgent.js';
import { channels } from './HttpClient.js';
import { channels, mergePoolStat, normalizePoolStatsKey } from './HttpClient.js';
import type {
ClientOptions,
PoolStat,
Expand Down Expand Up @@ -77,8 +77,9 @@ export class FetchFactory {
allowH2: clientOptions.allowH2,
} as HttpAgentOptions;
dispatcherClazz = BaseAgent;
} else if (clientOptions?.allowH2) {
// Support HTTP2
} else if (clientOptions?.allowH2 !== undefined) {
// Pin the protocol when allowH2 is set explicitly: `true` enables HTTP/2,
// `false` forces HTTP/1.1 instead of following undici@8's HTTP/2 default.
dispatcherOption = {
...dispatcherOption,
allowH2: clientOptions.allowH2,
Expand Down Expand Up @@ -111,14 +112,10 @@ export class FetchFactory {
const stats = pool?.stats ?? pool?.dispatcher?.stats;
if (!stats) continue;

poolStatsMap[key] = {
connected: stats.connected,
free: stats.free,
pending: stats.pending,
queued: stats.queued,
running: stats.running,
size: stats.size,
} satisfies PoolStat;
// undici@8 keys http1-only pools (allowH2: false) as `${origin}#http1-only`,
// expose them by their origin so callers can look up stats by URL.
const origin = normalizePoolStatsKey(key);
poolStatsMap[origin] = mergePoolStat(poolStatsMap[origin], stats as PoolStat);
}
return poolStatsMap;
}
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ let allowUnauthorizedHttpClient: HttpClient;
let allowH2AndUnauthorizedHttpClient: HttpClient;
const domainSocketHttpClients = new LRU(50);

// Only `allowH2: true` needs a dedicated agent; `allowH2: false` is applied per
// request (see HttpClient#request) so it forces HTTP/1.1 through the active
// dispatcher (e.g. a global ProxyAgent) instead of bypassing it.
export function getDefaultHttpClient(rejectUnauthorized?: boolean, allowH2?: boolean): HttpClient {
Comment thread
fengmk2 marked this conversation as resolved.
Outdated
if (rejectUnauthorized === false) {
if (allowH2) {
Expand Down Expand Up @@ -55,7 +58,7 @@ interface UrllibRequestOptions extends RequestOptions {
* verification fails. Default: `true`
*/
rejectUnauthorized?: boolean;
/** Allow to use HTTP2 first. Default is `false` */
/** Negotiate HTTP/2 with capable servers via ALPN. Enabled by default since undici@8; set `false` to force HTTP/1.1. */
allowH2?: boolean;
}

Expand Down
Loading
Loading