Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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.

5 changes: 4 additions & 1 deletion src/HttpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ export class HttpAgent extends BaseAgent {
};
super({
...baseOpts,
connect: { ...options.connect, lookup: lookupFunction, allowH2: options.allowH2 },
// Keep allowH2 at the top level only. undici builds the connector as
// `buildConnector({ allowH2, ...connect })`, so an allowH2 inside `connect`
// would shadow the top-level/per-request value and defeat `allowH2: false`.
connect: { ...options.connect, lookup: lookupFunction },
Comment on lines +69 to +72
});
this.#checkAddress = options.checkAddress;
}
Expand Down
103 changes: 76 additions & 27 deletions src/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { createGunzip, createBrotliDecompress, gunzipSync, brotliDecompressSync
import FormStream from 'formstream';
import mime from 'mime-types';
import qs from 'qs';
import { request as undiciRequest, Dispatcher, Agent, getGlobalDispatcher, Pool } from 'undici';
import { request as undiciRequest, Dispatcher, Agent, getGlobalDispatcher, MockAgent, Pool } from 'undici';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import undiciSymbols from 'undici/lib/core/symbols.js';
Expand All @@ -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,59 @@ export interface PoolStat {
size: number;
}

// undici@8 keys HTTP/1.1-only pools (dispatched with allowH2: false) by suffixing the origin.
const HTTP1_ONLY_POOL_KEY_SUFFIX = '#http1-only';

// Expose http1-only pools under their plain origin so callers can look up stats by URL.
// MockAgent may use RegExp/function origin matchers, so keys are not always strings.
export function normalizePoolStatsKey(key: unknown): string {
if (typeof key !== 'string') {
return String(key);
}
const index = key.indexOf(HTTP1_ONLY_POOL_KEY_SUFFIX);
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. undici ClientStats
// (Agent with connections: 1) omit free/queued, so treat absent counters as 0.
export function mergePoolStat(existing: PoolStat | undefined, stats: Partial<PoolStat>): PoolStat {
return {
connected: (existing?.connected ?? 0) + (stats.connected ?? 0),
free: (existing?.free ?? 0) + (stats.free ?? 0),
pending: (existing?.pending ?? 0) + (stats.pending ?? 0),
queued: (existing?.queued ?? 0) + (stats.queued ?? 0),
running: (existing?.running ?? 0) + (stats.running ?? 0),
size: (existing?.size ?? 0) + (stats.size ?? 0),
};
}

// Collect undici pool stats for a dispatcher, collapsing undici@8's http1-only
// pools back onto their origin so HttpClient and FetchFactory share one implementation.
export function buildPoolStats(agent: Dispatcher): Record<string, PoolStat> {
// origin => Pool Instance
const clients: Map<string, WeakRef<Pool>> | undefined = Reflect.get(agent, undiciSymbols.kClients);
const poolStatsMap: Record<string, PoolStat> = {};
if (!clients) {
return poolStatsMap;
}
for (const [key, ref] of clients) {
const pool = (typeof ref.deref === 'function' ? ref.deref() : ref) as unknown as Pool & { dispatcher: Pool };
// NOTE: pool become to { dispatcher: Pool } in undici@v7
const stats = pool?.stats ?? pool?.dispatcher?.stats;
if (!stats) continue;
const origin = normalizePoolStatsKey(key);
poolStatsMap[origin] = mergePoolStat(poolStatsMap[origin], stats);
}
return poolStatsMap;
}

// `instanceof` misses a MockAgent from a duplicate undici install, so also match
// by constructor name as a cheap cross-realm fallback.
function isMockAgent(dispatcher: Dispatcher | undefined): boolean {
return dispatcher instanceof MockAgent || dispatcher?.constructor?.name === 'MockAgent';
}

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
const RedirectStatusCodes = [
301, // Moved Permanently
Expand All @@ -189,10 +246,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 +267,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 All @@ -223,29 +285,7 @@ export class HttpClient extends EventEmitter {
}

getDispatcherPoolStats(): Record<string, PoolStat> {
const agent = this.getDispatcher();
// origin => Pool Instance
const clients: Map<string, WeakRef<Pool>> | undefined = Reflect.get(agent, undiciSymbols.kClients);
const poolStatsMap: Record<string, PoolStat> = {};
if (!clients) {
return poolStatsMap;
}
for (const [key, ref] of clients) {
const pool = (typeof ref.deref === 'function' ? ref.deref() : ref) as unknown as Pool & { dispatcher: Pool };
// NOTE: pool become to { dispatcher: Pool } in undici@v7
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;
}
return poolStatsMap;
return buildPoolStats(this.getDispatcher());
}

async request<T = any>(url: RequestURL, options?: RequestOptions): Promise<HttpClientResponse<T>> {
Expand Down Expand Up @@ -441,6 +481,15 @@ export class HttpClient extends EventEmitter {
signal: args.signal,
reset: false,
};
// 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. Skip it for MockAgent: it keys clients as `${origin}#http1-only` and
// would miss interceptors registered on the plain origin (protocol negotiation
// is moot when mocking anyway).
const allowH2 = args.allowH2 ?? this.#allowH2;
if (typeof allowH2 === 'boolean' && !isMockAgent(requestOptions.dispatcher ?? getGlobalDispatcher())) {
requestOptions.allowH2 = allowH2;
Comment thread
fengmk2 marked this conversation as resolved.
}
if (typeof args.highWaterMark === 'number') {
requestOptions.highWaterMark = args.highWaterMark;
}
Expand Down
14 changes: 11 additions & 3 deletions src/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export type RequestURL = string | URL;
export type FixJSONCtlCharsHandler = (data: string) => string;
export type FixJSONCtlChars = boolean | FixJSONCtlCharsHandler;

type AbortSignal = unknown;

export type RequestOptions = {
/** Request method, defaults to GET. Could be GET, POST, DELETE or PUT. Alias 'type'. */
method?: HttpMethod | Lowercase<HttpMethod>;
Expand Down Expand Up @@ -141,13 +139,23 @@ 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: false` is applied per request and is honored by Agent-based dispatchers (the default global agent and
* `ProxyAgent`). It cannot downgrade a raw `Pool`/`Client` passed as `dispatcher`, which builds its connector at
* construction time, construct those with `allowH2: false` instead. It is also not applied to `MockAgent` (protocol
* negotiation is moot when mocking), so mock passthrough to the real network follows that agent's own default.
*/
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 */
reset?: boolean;
/** Default: `64 KiB` */
highWaterMark?: number;
signal?: AbortSignal | EventEmitter;
signal?: globalThis.AbortSignal | EventEmitter;
};

export type RequestMeta = {
Expand Down
Loading
Loading