diff --git a/package.json b/package.json index 55b7f279..74d1fc16 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "mime-types": "^2.1.35", "qs": "^6.15.0", "type-fest": "^4.41.0", - "undici": "^7.24.0", + "undici": "^8.2.0", "ylru": "^2.0.0" }, "devDependencies": { @@ -109,7 +109,7 @@ } }, "engines": { - "node": ">= 22.0.0" + "node": ">= 22.19.0" }, "packageManager": "pnpm@11.0.8", "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 503b9ac2..cd762a92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^4.41.0 version: 4.41.0 undici: - specifier: ^7.24.0 - version: 7.25.0 + specifier: ^8.2.0 + version: 8.2.0 ylru: specifier: ^2.0.0 version: 2.0.0 @@ -1558,9 +1558,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.25.0: - resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} - engines: {node: '>=20.18.1'} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} @@ -2862,7 +2862,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.25.0: {} + undici@8.2.0: {} unicode-emoji-modifier-base@1.0.0: {} diff --git a/src/BaseAgent.ts b/src/BaseAgent.ts index 826207e2..f5907284 100644 --- a/src/BaseAgent.ts +++ b/src/BaseAgent.ts @@ -12,7 +12,10 @@ export class BaseAgent extends Agent { #opaqueLocalStorage?: AsyncLocalStorage; constructor(options: BaseAgentOptions) { - super(options); + super({ + ...options, + allowH2: options.allowH2 ?? false, + }); this.#opaqueLocalStorage = options.opaqueLocalStorage; } diff --git a/src/HttpAgent.ts b/src/HttpAgent.ts index 9be55aaa..73ad9203 100644 --- a/src/HttpAgent.ts +++ b/src/HttpAgent.ts @@ -66,7 +66,7 @@ export class HttpAgent extends BaseAgent { }; super({ ...baseOpts, - connect: { ...options.connect, lookup: lookupFunction, allowH2: options.allowH2 }, + connect: { ...options.connect, lookup: lookupFunction, allowH2: options.allowH2 ?? false }, }); this.#checkAddress = options.checkAddress; } diff --git a/src/HttpClient.ts b/src/HttpClient.ts index df901b11..a846bfa8 100644 --- a/src/HttpClient.ts +++ b/src/HttpClient.ts @@ -41,6 +41,30 @@ type PropertyShouldBe = Omit & { [P in K]: V }; type IUndiciRequestOption = PropertyShouldBe; export const PROTO_RE: RegExp = /^https?:\/\//i; +let initialGlobalDispatcher: Dispatcher | undefined; +let defaultHttp1Dispatcher: Dispatcher; + +/** + * Use an explicitly overridden global undici dispatcher when present; otherwise fall back to urllib's shared + * HTTP/1.1-safe default agent. + */ +function getDefaultDispatcher(): Dispatcher { + const globalDispatcher = getGlobalDispatcher(); + if (!initialGlobalDispatcher) { + initialGlobalDispatcher = globalDispatcher; + } + + if (globalDispatcher !== initialGlobalDispatcher) { + return globalDispatcher; + } + + if (!defaultHttp1Dispatcher) { + defaultHttp1Dispatcher = new Agent({ + allowH2: false, + }); + } + return defaultHttp1Dispatcher; +} export interface UndiciTimingInfo { startTime: number; @@ -187,29 +211,31 @@ export class HttpClient extends EventEmitter { constructor(clientOptions?: ClientOptions) { super(); this.#defaultArgs = clientOptions?.defaultArgs; + const allowH2 = clientOptions?.allowH2 ?? false; if (clientOptions?.lookup || clientOptions?.checkAddress) { this.#dispatcher = new HttpAgent({ lookup: clientOptions.lookup, checkAddress: clientOptions.checkAddress, connect: clientOptions.connect, - allowH2: clientOptions.allowH2, + allowH2, }); } else if (clientOptions?.connect) { this.#dispatcher = new Agent({ connect: clientOptions.connect, - allowH2: clientOptions.allowH2, + allowH2, }); - } else if (clientOptions?.allowH2) { - // Support HTTP2 + } else if (clientOptions?.allowH2 !== undefined) { + // Any explicit allowH2 value should pin protocol preference for this client + // instead of following later global dispatcher overrides. this.#dispatcher = new Agent({ - allowH2: clientOptions.allowH2, + allowH2, }); } initDiagnosticsChannel(); } getDispatcher(): Dispatcher { - return this.#dispatcher ?? getGlobalDispatcher(); + return this.#dispatcher ?? getDefaultDispatcher(); } setDispatcher(dispatcher: Dispatcher): void { diff --git a/src/Request.ts b/src/Request.ts index 330dd330..b916d96e 100644 --- a/src/Request.ts +++ b/src/Request.ts @@ -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'. */ diff --git a/src/fetch.ts b/src/fetch.ts index e9f0acb5..4af40325 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -58,6 +58,7 @@ export class FetchFactory { setClientOptions(clientOptions: ClientOptions): void { let dispatcherOption: BaseAgentOptions = { + allowH2: clientOptions.allowH2 ?? false, opaqueLocalStorage: this.#opaqueLocalStorage, }; let dispatcherClazz: new (options: BaseAgentOptions) => BaseAgent = BaseAgent; diff --git a/test/HttpClient.test.ts b/test/HttpClient.test.ts index f33bf4bb..dce88794 100644 --- a/test/HttpClient.test.ts +++ b/test/HttpClient.test.ts @@ -125,6 +125,49 @@ describe('HttpClient.test.ts', () => { assert(httpClient.getDispatcherPoolStats()[_url.substring(0, _url.length - 1)].connected > 1); }); + it('should keep HTTP/1.1 by default', async () => { + const server = createSecureServer({ + allowHTTP1: true, + key: pems.private, + cert: pems.cert, + }); + + let lastHttpVersion = ''; + server.on('request', (req, res) => { + lastHttpVersion = req.httpVersion; + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + }); + res.end(`hello http/${req.httpVersion}!`); + }); + + server.listen(0); + await once(server, 'listening'); + + const httpClient = new HttpClient({ + connect: { + rejectUnauthorized: false, + }, + }); + + const url = `https://localhost:${(server.address() as AddressInfo).port}`; + try { + const response = await httpClient.request(url, { + dataType: 'text', + }); + assert.equal(response.status, 200); + assert.equal(response.data, 'hello http/1.1!'); + assert.equal(lastHttpVersion, '1.1'); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); + it('should not exit after other side closed error', async () => { const server = createSecureServer({ key: pems.private, diff --git a/test/fetch.test.ts b/test/fetch.test.ts index e39a1207..f7fcaa51 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import diagnosticsChannel from 'node:diagnostics_channel'; +import { once } from 'node:events'; +import { createSecureServer } from 'node:http2'; +import type { AddressInfo } from 'node:net'; import { setTimeout as sleep } from 'node:timers/promises'; +import selfsigned from 'selfsigned'; import { Request } from 'undici'; import { describe, it, beforeAll, afterAll } from 'vite-plus/test'; @@ -113,6 +117,51 @@ describe('fetch.test.ts', () => { assert(Object.keys(stats).length > 0, `dispatcher pool stats: ${JSON.stringify(stats)}`); }); + it('fetch should keep HTTP/1.1 by default', async () => { + const pem = selfsigned.generate([], { + keySize: 2048, + }); + const server = createSecureServer({ + allowHTTP1: true, + key: pem.private, + cert: pem.cert, + }); + + let lastHttpVersion = ''; + server.on('request', (req, res) => { + lastHttpVersion = req.httpVersion; + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + }); + res.end(`hello http/${req.httpVersion}!`); + }); + + server.listen(0); + await once(server, 'listening'); + + const factory = new FetchFactory(); + factory.setClientOptions({ + connect: { + rejectUnauthorized: false, + }, + }); + + const url = `https://localhost:${(server.address() as AddressInfo).port}`; + try { + const response = await factory.fetch(url); + assert.equal(response.status, 200); + assert.equal(await response.text(), 'hello http/1.1!'); + assert.equal(lastHttpVersion, '1.1'); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); + it('fetch request with post should work', async () => { await assert.doesNotReject(async () => { const request = new Request(_url, { diff --git a/test/options.dispatcher.test.ts b/test/options.dispatcher.test.ts index e737afc0..7751fccd 100644 --- a/test/options.dispatcher.test.ts +++ b/test/options.dispatcher.test.ts @@ -11,6 +11,7 @@ describe('options.dispatcher.test.ts', () => { let _url: string; let proxyServer: any; let proxyServerUrl: string; + const proxyAgents: ProxyAgent[] = []; beforeAll(async () => { const { closeServer, url } = await startServer(); close = closeServer; @@ -27,34 +28,47 @@ describe('options.dispatcher.test.ts', () => { afterAll(async () => { await close(); + await Promise.all(proxyAgents.map((proxyAgent) => proxyAgent.close())); await new Promise((resolve) => { proxyServer.close(resolve); }); }); it('should work with proxyAgent dispatcher', async () => { - const proxyAgent = new ProxyAgent(proxyServerUrl); - const response = await request(`${_url}html`, { - dispatcher: proxyAgent, - dataType: 'text', - timing: true, - }); - assert.equal(response.status, 200); - assert.equal(response.data, '

hello

'); + const { closeServer: closeHttpsServer, url: httpsUrl } = await startServer({ https: true }); + try { + const proxyAgent = new ProxyAgent({ + uri: proxyServerUrl, + requestTls: { + rejectUnauthorized: false, + }, + }); + proxyAgents.push(proxyAgent); + const response = await request(`${_url}html`, { + dispatcher: proxyAgent, + dataType: 'text', + timing: true, + }); + assert.equal(response.status, 200); + assert.equal(response.data, '

hello

'); - const response2 = await request('https://registry.npmmirror.com/urllib/latest', { - dispatcher: proxyAgent, - dataType: 'json', - timing: true, - }); - // console.log(response2.status, response2.headers); - assert.equal(response2.status, 200); - assert.equal(response2.data.name, 'urllib'); + const response2 = await request(httpsUrl, { + dispatcher: proxyAgent, + dataType: 'json', + timing: true, + }); + assert.equal(response2.status, 200); + assert.equal(response2.data.method, 'GET'); + assert.equal(response2.data.headers.host, new URL(httpsUrl).host); + } finally { + await closeHttpsServer(); + } }); it('should work with getGlobalDispatcher() dispatcher', async () => { const agent = getGlobalDispatcher(); const proxyAgent = new ProxyAgent(proxyServerUrl); + proxyAgents.push(proxyAgent); setGlobalDispatcher(proxyAgent); const response = await request(`${_url}html`, { dataType: 'text',