From fe59f8fe5c074b785ad70cb86b9170c7cd94a080 Mon Sep 17 00:00:00 2001 From: Qingyu Wang <40660121+colinaaa@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:21:47 +0800 Subject: [PATCH 01/22] fix(websocket/stream): only enqueue parsed messages in WebSocketStream (#4959) (cherry picked from commit 49ded6dc1b81e8d04360f9a3bf0a2cf2e399dd5e) --- lib/web/websocket/stream/websocketstream.js | 6 -- test/websocket/stream/issue-4958.js | 61 +++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 test/websocket/stream/issue-4958.js diff --git a/lib/web/websocket/stream/websocketstream.js b/lib/web/websocket/stream/websocketstream.js index ce3be84fc3d..ca40ad08dae 100644 --- a/lib/web/websocket/stream/websocketstream.js +++ b/lib/web/websocket/stream/websocketstream.js @@ -284,12 +284,6 @@ class WebSocketStream { start: (controller) => { this.#readableStreamController = controller }, - pull (controller) { - let chunk - while (controller.desiredSize > 0 && (chunk = response.socket.read()) !== null) { - controller.enqueue(chunk) - } - }, cancel: (reason) => this.#cancel(reason) }) diff --git a/test/websocket/stream/issue-4958.js b/test/websocket/stream/issue-4958.js new file mode 100644 index 00000000000..4bc447f9c1b --- /dev/null +++ b/test/websocket/stream/issue-4958.js @@ -0,0 +1,61 @@ +'use strict' + +const { test } = require('node:test') +const { WebSocketServer } = require('ws') + +const { WebSocketStream } = require('../../..') + +// Repro for: opened.readable may include raw socket bytes instead of only message payloads. +test('websocketstream opened.readable should expose text message payloads only', async (t) => { + const server = new WebSocketServer({ + port: 0, + path: '/', + perMessageDeflate: false + }) + + t.after(() => { + for (const client of server.clients) { + client.terminate() + } + + server.close() + }) + + server.on('connection', (socket) => { + socket.send(JSON.stringify({ event: 'Initialize', data: 1010 })) + socket.send(JSON.stringify({ event: 'Ready', data: { id: 1010 } })) + }) + + const url = `ws://127.0.0.1:${server.address().port}/` + + for (let run = 1; run <= 100; run++) { + const wss = new WebSocketStream(url) + const { readable } = await wss.opened + const reader = readable.getReader() + + try { + for (let index = 1; index <= 2; index++) { + const { done, value } = await reader.read() + + if (done) { + break + } + + t.assert.strictEqual( + typeof value, + 'string', + `run ${run}, chunk ${index}: expected string payload but got ${value?.constructor?.name ?? typeof value}` + ) + + t.assert.doesNotThrow( + () => JSON.parse(value), + `run ${run}, chunk ${index}: expected valid JSON text payload` + ) + } + } finally { + reader.releaseLock() + wss.close() + await wss.closed.catch(() => {}) + } + } +}) From 483a4a75687d66f728b9e39d3fa5179f253b5d02 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:54:07 -0700 Subject: [PATCH 02/22] fix: preserve connect option in H2CClient (#5000) Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> (cherry picked from commit 5334fa6a2887a9149d302d5de67095b700dfe1fb) --- lib/dispatcher/h2c-client.js | 2 +- test/h2c-client.js | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/dispatcher/h2c-client.js b/lib/dispatcher/h2c-client.js index bd385225ea1..5fe7e778ba4 100644 --- a/lib/dispatcher/h2c-client.js +++ b/lib/dispatcher/h2c-client.js @@ -15,7 +15,7 @@ class H2CClient extends Client { ) } - const { connect, maxConcurrentStreams, pipelining, ...opts } = + const { maxConcurrentStreams, pipelining, ...opts } = clientOpts ?? {} let defaultMaxConcurrentStreams = 100 let defaultPipelining = 100 diff --git a/test/h2c-client.js b/test/h2c-client.js index fd551a94343..193589863c4 100644 --- a/test/h2c-client.js +++ b/test/h2c-client.js @@ -140,6 +140,32 @@ test('Connect to h2c server over a unix domain socket', { skip: process.platform }) }) +test('Should pass custom connect function to Client', async t => { + const planner = tspl(t, { plan: 3 }) + + const connectError = new Error('custom connect error') + const socketPath = '/var/run/test.sock' + const client = new H2CClient('http://localhost', { + socketPath, + connect (opts, cb) { + planner.strictEqual(opts.socketPath, socketPath) + planner.strictEqual(opts.allowH2, true) + cb(connectError, null) + } + }) + + t.after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + planner.strictEqual(err, connectError) + }) + + await planner.completed +}) + test('Should throw if bad useH2c has been passed', async t => { t = tspl(t, { plan: 1 }) From a3995963376ff4b9ce9fa717ec6762aaf99f9ef1 Mon Sep 17 00:00:00 2001 From: Ran <8403607+eddieran@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:33:02 +0800 Subject: [PATCH 03/22] fix: prevent cache dedup key collision via unescaped delimiters (#5012) (#5013) (cherry picked from commit 30e9f98c22c5918bea25ce964ae89b1b3202b371) --- lib/util/cache.js | 13 +++---- test/interceptors/deduplicate.js | 61 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/lib/util/cache.js b/lib/util/cache.js index 5968f4efe5f..2da2c044dbd 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -374,9 +374,11 @@ function assertCacheMethods (methods, name = 'CacheMethods') { * @returns {string} */ function makeDeduplicationKey (cacheKey, excludeHeaders) { - // Create a deterministic string key from the cache key - // Include origin, method, path, and sorted headers - let key = `${cacheKey.origin}:${cacheKey.method}:${cacheKey.path}` + // Use JSON.stringify to produce a collision-resistant key. + // Previous format used `:` and `=` delimiters without escaping, which + // allowed different header sets to produce identical keys (e.g. + // {a:"x:b=y"} vs {a:"x", b:"y"}). See: https://github.com/nodejs/undici/issues/5012 + const headers = {} if (cacheKey.headers) { const sortedHeaders = Object.keys(cacheKey.headers).sort() @@ -385,12 +387,11 @@ function makeDeduplicationKey (cacheKey, excludeHeaders) { if (excludeHeaders?.has(header.toLowerCase())) { continue } - const value = cacheKey.headers[header] - key += `:${header}=${Array.isArray(value) ? value.join(',') : value}` + headers[header] = cacheKey.headers[header] } } - return key + return JSON.stringify([cacheKey.origin, cacheKey.method, cacheKey.path, headers]) } module.exports = { diff --git a/test/interceptors/deduplicate.js b/test/interceptors/deduplicate.js index 458bd80bc08..43fb2b6b7aa 100644 --- a/test/interceptors/deduplicate.js +++ b/test/interceptors/deduplicate.js @@ -3,10 +3,11 @@ const { createServer } = require('node:http') const { describe, test, after } = require('node:test') const { once } = require('node:events') -const { strictEqual } = require('node:assert') +const { strictEqual, notStrictEqual } = require('node:assert') const { setTimeout: sleep } = require('node:timers/promises') const diagnosticsChannel = require('node:diagnostics_channel') const { Client, interceptors } = require('../../index') +const { makeDeduplicationKey } = require('../../lib/util/cache') describe('Deduplicate Interceptor', () => { test('deduplicates concurrent requests for the same resource', async () => { @@ -1291,4 +1292,62 @@ describe('Deduplicate Interceptor', () => { message: 'expected opts.excludeHeaderNames to be an array, got string' }) }) + + test('makeDeduplicationKey does not collide when header values contain delimiters', () => { + // Regression test for https://github.com/nodejs/undici/issues/5012 + // Previously, headers {a:"x:b=y"} and {a:"x", b:"y"} produced the same key + const key1 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { a: 'x:b=y' } + }) + + const key2 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { a: 'x', b: 'y' } + }) + + notStrictEqual(key1, key2) + }) + + test('makeDeduplicationKey produces same key for identical headers', () => { + const key1 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { b: '2', a: '1' } + }) + + const key2 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { a: '1', b: '2' } + }) + + strictEqual(key1, key2) + }) + + test('makeDeduplicationKey respects excludeHeaders', () => { + const excludeHeaders = new Set(['x-request-id']) + + const key1 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { accept: 'text/html', 'x-request-id': '111' } + }, excludeHeaders) + + const key2 = makeDeduplicationKey({ + origin: 'https://example.com', + method: 'GET', + path: '/', + headers: { accept: 'text/html', 'x-request-id': '222' } + }, excludeHeaders) + + strictEqual(key1, key2) + }) }) From 5aab0bacbb6227b2f1400a0cd2e2a86c14c1e5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=88=E9=B8=BF=E9=A3=9E?= Date: Tue, 14 Apr 2026 21:43:46 +0800 Subject: [PATCH 04/22] fix: fix the logic for the UNDICI_NO_WASM_SIMD environment variable (#5026) (cherry picked from commit 0860142dbd6fd5f36e941e4c790416924d6d10ad) --- lib/dispatcher/client-h1.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js index 939e0814bcd..00863928de6 100644 --- a/lib/dispatcher/client-h1.js +++ b/lib/dispatcher/client-h1.js @@ -69,9 +69,9 @@ function lazyllhttp () { let useWasmSIMD = process.arch !== 'ppc64' // The Env Variable UNDICI_NO_WASM_SIMD allows explicitly overriding the default behavior if (process.env.UNDICI_NO_WASM_SIMD === '1') { - useWasmSIMD = true - } else if (process.env.UNDICI_NO_WASM_SIMD === '0') { useWasmSIMD = false + } else if (process.env.UNDICI_NO_WASM_SIMD === '0') { + useWasmSIMD = true } if (useWasmSIMD) { From ee2b89e26fda86c9577a323176aa554ad2b4fe07 Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:56:48 +0300 Subject: [PATCH 05/22] fix(fetch): correct 'navigator' typo to 'navigate' in fetchFinale (#5044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The condition in step 7 of fetchFinale checked `fetchParams.request.mode !== 'navigator'`, but 'navigator' is not a valid request mode — the spec value is 'navigate'. Because the typo never matched, the guarded block always ran, setting responseStatus and extracting the MIME type even for navigate-mode responses with cross-origin redirects, which the spec requires to be skipped. Signed-off-by: Nikita Skovoroda Co-authored-by: Claude Co-authored-by: Nikita Skovoroda (cherry picked from commit a6f8644f30280646583dcdff5eb6e169498434e4) --- lib/web/fetch/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index c48ed041cc6..68f183df048 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -1030,7 +1030,7 @@ function fetchFinale (fetchParams, response) { let responseStatus = 0 // 7. If fetchParams’s request’s mode is not "navigate" or response’s has-cross-origin-redirects is false: - if (fetchParams.request.mode !== 'navigator' || !response.hasCrossOriginRedirects) { + if (fetchParams.request.mode !== 'navigate' || !response.hasCrossOriginRedirects) { // 1. Set responseStatus to response’s status. responseStatus = response.status From 805493dcc9d6a48f9302b8a436e1620b76b8b013 Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:59:56 +0300 Subject: [PATCH 06/22] fix(webidl): correct signed integer bounds in ConvertToInt (#5038) The signed integer lower bound was computed as `Math.pow(-2, bitLength) - 1`, which evaluates to `(-2)^bitLength - 1` instead of the spec-required `-2^(bitLength - 1)`. The step 11 overflow threshold similarly used `2^bitLength - 1` instead of `2^(bitLength - 1)`, causing values in `[2^(bitLength-1), 2^bitLength - 2]` to skip the signed wrap. Fix both expressions and update the surrounding comments to match the WebIDL spec. Signed-off-by: Nikita Skovoroda Co-authored-by: Claude Co-authored-by: Nikita Skovoroda (cherry picked from commit d2e8178693ff6e395a57f6e9eb4a5dedb31881cf) --- lib/web/webidl/index.js | 10 +++++----- test/webidl/util.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/web/webidl/index.js b/lib/web/webidl/index.js index 5518b495906..58664859dc9 100644 --- a/lib/web/webidl/index.js +++ b/lib/web/webidl/index.js @@ -190,10 +190,10 @@ webidl.util.ConvertToInt = function (V, bitLength, signedness, flags) { } else { // 3. Otherwise: - // 1. Let lowerBound be -2^bitLength − 1. - lowerBound = Math.pow(-2, bitLength) - 1 + // 1. Let lowerBound be -2^(bitLength − 1). + lowerBound = -Math.pow(2, bitLength - 1) - // 2. Let upperBound be 2^bitLength − 1 − 1. + // 2. Let upperBound be 2^(bitLength − 1) − 1. upperBound = Math.pow(2, bitLength - 1) - 1 } @@ -272,9 +272,9 @@ webidl.util.ConvertToInt = function (V, bitLength, signedness, flags) { // 10. Set x to x modulo 2^bitLength. x = x % Math.pow(2, bitLength) - // 11. If signedness is "signed" and x ≥ 2^bitLength − 1, + // 11. If signedness is "signed" and x ≥ 2^(bitLength − 1), // then return x − 2^bitLength. - if (signedness === 'signed' && x >= Math.pow(2, bitLength) - 1) { + if (signedness === 'signed' && x >= Math.pow(2, bitLength - 1)) { return x - Math.pow(2, bitLength) } diff --git a/test/webidl/util.js b/test/webidl/util.js index 3860154d35d..30b01a0a745 100644 --- a/test/webidl/util.js +++ b/test/webidl/util.js @@ -125,4 +125,23 @@ test('webidl.util.ConvertToInt(V)', () => { } assert.equal(ConvertToInt(111, 2, 'signed'), -1) + + // https://webidl.spec.whatwg.org/#abstract-opdef-converttoint + // For N-bit signed integers, lowerBound is -2^(N-1) and the modulo wrap + // threshold (step 11) is 2^(N-1). + assert.equal(ConvertToInt(128, 8, 'signed'), -128, '8-bit signed wrap at 128') + assert.equal(ConvertToInt(200, 8, 'signed'), -56, '8-bit signed wrap at 200') + assert.equal( + ConvertToInt(-128, 8, 'signed', webidl.attributes.EnforceRange), + -128, + '8-bit signed EnforceRange lower bound' + ) + assert.throws(() => { + ConvertToInt(-129, 8, 'signed', webidl.attributes.EnforceRange) + }, TypeError, '8-bit signed EnforceRange below lower bound throws') + assert.equal( + ConvertToInt(-(2 ** 31), 32, 'signed', webidl.attributes.EnforceRange), + -(2 ** 31), + '32-bit signed EnforceRange lower bound' + ) }) From 1eaa2430f2971e80650cbd5e5ecd363dd4fb3f1f Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:52:58 +0300 Subject: [PATCH 07/22] fix(fetch): use || for CRLF check in multipart formdata-parser (#5049) (cherry picked from commit ece771c7689c94e259a1592855d6bf184978072e) --- lib/web/fetch/formdata-parser.js | 6 +++--- test/fetch/formdata.js | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index b65848998f7..7723b5fe309 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -383,8 +383,8 @@ function parseMultipartFormDataHeaders (input, position) { // Parse attributes recursively until CRLF while ( position.position < input.length && - input[position.position] !== 0x0d && - input[position.position + 1] !== 0x0a + (input[position.position] !== 0x0d || + input[position.position + 1] !== 0x0a) ) { const attribute = parseContentDispositionAttribute(input, position) @@ -448,7 +448,7 @@ function parseMultipartFormDataHeaders (input, position) { // 2.9. If position does not point to a sequence of bytes starting with 0x0D 0x0A // (CR LF), return failure. Otherwise, advance position by 2 (past the newline). - if (input[position.position] !== 0x0d && input[position.position + 1] !== 0x0a) { + if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) { throw parsingError('expected CRLF') } else { position.position += 2 diff --git a/test/fetch/formdata.js b/test/fetch/formdata.js index 5c0b721d281..3ebfea5d6de 100644 --- a/test/fetch/formdata.js +++ b/test/fetch/formdata.js @@ -383,3 +383,26 @@ test('.formData() with multipart/form-data body that ends with --\r\n', async (t await request.formData() }) + +test('.formData() rejects malformed multipart header line ending with bare CR', async (t) => { + const boundary = '----formdata-undici-bare-cr-0000000000' + const body = Buffer.concat([ + Buffer.from('--' + boundary + '\r\n'), + Buffer.from('Content-Disposition: form-data; name="x"'), + Buffer.from([0x0d]), // bare CR (no LF) + Buffer.from('Content-Type: text/plain\r\n'), + Buffer.from('\r\n'), + Buffer.from('hello\r\n'), + Buffer.from('--' + boundary + '--\r\n') + ]) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=' + boundary + }, + body + }) + + await t.assert.rejects(request.formData(), TypeError) +}) From 39cd29ae6a3ec44922aa4b56eadcde8d161c0ae2 Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:54:09 +0300 Subject: [PATCH 08/22] fix(websocket): correct argument order in WebSocketStream UTF-8 failure (#5050) (cherry picked from commit 759602e8d12c3d2b9d99be47896dfd9502a6fd47) --- lib/web/websocket/stream/websocketstream.js | 2 +- .../stream/invalid-utf8-text-frame.js | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 test/websocket/stream/invalid-utf8-text-frame.js diff --git a/lib/web/websocket/stream/websocketstream.js b/lib/web/websocket/stream/websocketstream.js index ca40ad08dae..c248625e843 100644 --- a/lib/web/websocket/stream/websocketstream.js +++ b/lib/web/websocket/stream/websocketstream.js @@ -332,7 +332,7 @@ class WebSocketStream { try { chunk = utf8Decode(data) } catch { - failWebsocketConnection(this.#handler, 'Received invalid UTF-8 in text frame.') + failWebsocketConnection(this.#handler, 1007, 'Received invalid UTF-8 in text frame.') return } } else if (type === opcodes.BINARY) { diff --git a/test/websocket/stream/invalid-utf8-text-frame.js b/test/websocket/stream/invalid-utf8-text-frame.js new file mode 100644 index 00000000000..91605ec026e --- /dev/null +++ b/test/websocket/stream/invalid-utf8-text-frame.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('node:test') +const { once } = require('node:events') +const { WebSocketServer } = require('ws') +const { WebSocketStream } = require('../../..') + +test('WebSocketStream sends close code 1007 when receiving invalid UTF-8 in a text frame', async (t) => { + const server = new WebSocketServer({ port: 0 }) + + t.after(() => server.close()) + + const connection = new Promise((resolve) => { + server.on('connection', (ws) => { + // Send a text frame with invalid UTF-8 payload (unmasked, server->client). + // 0x81 = FIN + text opcode, 0x02 = length 2, then 0xFF 0xFE (invalid UTF-8). + ws._socket.write(Buffer.from([0x81, 0x02, 0xFF, 0xFE])) + resolve(ws) + }) + }) + + const wss = new WebSocketStream(`ws://127.0.0.1:${server.address().port}`) + // Swallow the expected unclean-close rejection on the client side. + wss.closed.catch(() => {}) + + await wss.opened + + const ws = await connection + const [code] = await once(ws, 'close') + + t.assert.strictEqual(code, 1007) +}) From 171d64dd8c9f6353d3ff77487eab507c0ac4df8c Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:01:24 +0300 Subject: [PATCH 09/22] fix(cache): evict oldest entries first in SqliteCacheStore prune (#5039) The `#deleteOldValuesQuery` used `ORDER BY cachedAt DESC` which deleted the most recently cached entries instead of the oldest ones, inverting the intended LRU-style eviction policy. Switch to `ASC` so that pruning removes the oldest entries as expected. Signed-off-by: Nikita Skovoroda Co-authored-by: Claude Co-authored-by: Nikita Skovoroda (cherry picked from commit e0e61d31714de9f9b7e9c4f4d4cf4af1fd9fc7cd) --- lib/cache/sqlite-cache-store.js | 2 +- .../sqlite-cache-store-tests.js | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/cache/sqlite-cache-store.js b/lib/cache/sqlite-cache-store.js index 7cb4aa7e246..75d7e253ec8 100644 --- a/lib/cache/sqlite-cache-store.js +++ b/lib/cache/sqlite-cache-store.js @@ -216,7 +216,7 @@ module.exports = class SqliteCacheStore { SELECT id FROM cacheInterceptorV${VERSION} - ORDER BY cachedAt DESC + ORDER BY cachedAt ASC LIMIT ? ) `) diff --git a/test/cache-interceptor/sqlite-cache-store-tests.js b/test/cache-interceptor/sqlite-cache-store-tests.js index 47cf5651687..bb9d3e2d4c9 100644 --- a/test/cache-interceptor/sqlite-cache-store-tests.js +++ b/test/cache-interceptor/sqlite-cache-store-tests.js @@ -156,6 +156,44 @@ test('SqliteCacheStore two writes', { skip: runtimeFeatures.has('sqlite') === fa } }) +test('SqliteCacheStore prune evicts oldest entries first', { skip: runtimeFeatures.has('sqlite') === false }, async () => { + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + + const maxCount = 10 + const store = new SqliteCacheStore({ maxCount }) + + const baseTime = Date.now() + + for (let i = 0; i < 20; i++) { + const key = { + origin: 'localhost', + path: '/' + i, + method: 'GET', + headers: {} + } + + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cachedAt: baseTime + i * 1000, + staleAt: baseTime + i * 1000 + 60_000, + deleteAt: baseTime + i * 1000 + 120_000, + body: Buffer.from('x') + } + + store.set(key, value) + } + + // The most recently cached entry must still be present; + // the oldest entry must have been evicted. + const newest = store.get({ origin: 'localhost', path: '/19', method: 'GET', headers: {} }) + notEqual(newest, undefined) + + const oldest = store.get({ origin: 'localhost', path: '/0', method: 'GET', headers: {} }) + strictEqual(oldest, undefined) +}) + test('SqliteCacheStore write & read', { skip: runtimeFeatures.has('sqlite') === false }, async () => { const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') From 5a6f52f01981c7ed32626aa3962a9c83db8b526e Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:02:08 +0300 Subject: [PATCH 10/22] fix(socks5): correctly expand IPv6 '::' compressed notation (#5046) The previous implementation used `doubleColonIndex / 3` to map the character offset of '::' to a parts-array index, which only works when every group before '::' is exactly three characters wide. For typical addresses like `2001:db8::1` or `fe80::1` the zero-fill gap was never applied, producing a wrong 16-byte buffer and, in a SOCKS5 proxy context, connections to unintended destinations. Rewrite parseIPv6 to split around '::' and write the trailing groups at their correct offsets from the end of the buffer. Signed-off-by: Nikita Skovoroda Co-authored-by: Claude Co-authored-by: Nikita Skovoroda (cherry picked from commit 838053dfb375ac48defdf281a65d679bc1bf7f05) --- lib/core/socks5-utils.js | 39 +++++++++++++++++---------------------- test/socks5-utils.js | 16 ++++++++++++++-- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/lib/core/socks5-utils.js b/lib/core/socks5-utils.js index 2b5a3662bf5..cff6763c846 100644 --- a/lib/core/socks5-utils.js +++ b/lib/core/socks5-utils.js @@ -46,34 +46,29 @@ function parseAddress (address) { */ function parseIPv6 (address) { const buffer = Buffer.alloc(16) - const parts = address.split(':') - let partIndex = 0 - let bufferIndex = 0 // Handle compressed notation (::) const doubleColonIndex = address.indexOf('::') if (doubleColonIndex !== -1) { - // Count non-empty parts - const nonEmptyParts = parts.filter(p => p.length > 0).length - const skipParts = 8 - nonEmptyParts - - for (let i = 0; i < parts.length; i++) { - if (parts[i] === '' && i === doubleColonIndex / 3) { - // Skip empty parts for :: - bufferIndex += skipParts * 2 - } else if (parts[i] !== '') { - const value = parseInt(parts[i], 16) - buffer.writeUInt16BE(value, bufferIndex) - bufferIndex += 2 - } + const before = address.slice(0, doubleColonIndex) + const after = address.slice(doubleColonIndex + 2) + const beforeParts = before === '' ? [] : before.split(':') + const afterParts = after === '' ? [] : after.split(':') + + let bufferIndex = 0 + for (const part of beforeParts) { + buffer.writeUInt16BE(parseInt(part, 16), bufferIndex) + bufferIndex += 2 + } + bufferIndex = 16 - afterParts.length * 2 + for (const part of afterParts) { + buffer.writeUInt16BE(parseInt(part, 16), bufferIndex) + bufferIndex += 2 } } else { - // No compression, parse normally - for (const part of parts) { - if (part === '') continue - const value = parseInt(part, 16) - buffer.writeUInt16BE(value, partIndex * 2) - partIndex++ + const parts = address.split(':') + for (let i = 0; i < parts.length; i++) { + buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2) } } diff --git a/test/socks5-utils.js b/test/socks5-utils.js index 8dd551313b4..c01070e573a 100644 --- a/test/socks5-utils.js +++ b/test/socks5-utils.js @@ -48,19 +48,31 @@ test('parseAddress - Domain', async (t) => { }) test('parseIPv6', async (t) => { - const p = tspl(t, { plan: 3 }) + const p = tspl(t, { plan: 9 }) // Test full IPv6 const buffer1 = parseIPv6('2001:0db8:0000:0042:0000:8a2e:0370:7334') p.equal(buffer1.length, 16, 'should return 16-byte buffer') + p.equal(buffer1.toString('hex'), '20010db80000004200008a2e03707334', 'should parse full IPv6 correctly') - // Test compressed IPv6 + // Test compressed IPv6 (zero-fill gap must land in the middle, not after `db8`) const buffer2 = parseIPv6('2001:db8::1') p.equal(buffer2.length, 16, 'should return 16-byte buffer for compressed') + p.equal(buffer2.toString('hex'), '20010db8000000000000000000000001', 'should expand :: correctly for 2001:db8::1') // Test loopback const buffer3 = parseIPv6('::1') p.equal(buffer3.length, 16, 'should return 16-byte buffer for loopback') + p.equal(buffer3.toString('hex'), '00000000000000000000000000000001', 'should expand ::1 correctly') + + // Test :: in the middle with short groups on both sides + p.equal(parseIPv6('1::2').toString('hex'), '00010000000000000000000000000002', 'should expand 1::2 correctly') + + // Test link-local with single group after :: + p.equal(parseIPv6('fe80::1').toString('hex'), 'fe800000000000000000000000000001', 'should expand fe80::1 correctly') + + // Test trailing :: + p.equal(parseIPv6('fe80::').toString('hex'), 'fe800000000000000000000000000000', 'should expand fe80:: correctly') await p.completed }) From 2111c96e6196851197aee1e0dc9a0ea5d4c93358 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:49:37 -0700 Subject: [PATCH 11/22] fix: reject malformed content-length request headers (#5060) (cherry picked from commit c8d50b4a820002f7f47795e5c6b5606ad82bdde7) --- lib/core/request.js | 19 +++++++++++++++++-- lib/web/fetch/index.js | 5 ++++- test/fetch/content-length.js | 20 ++++++++++++++++++++ test/invalid-headers.js | 32 +++++++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/lib/core/request.js b/lib/core/request.js index 829da6f8fc1..326d58fb43f 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -27,6 +27,21 @@ const { headerNameLowerCasedRecord } = require('./constants') // Verifies that a given path is valid does not contain control chars \x00 to \x20 const invalidPathRegex = /[^\u0021-\u00ff]/ +function isValidContentLengthHeaderValue (val) { + if (typeof val !== 'string' || val.length === 0) { + return false + } + + for (let i = 0; i < val.length; i++) { + const charCode = val.charCodeAt(i) + if (charCode < 48 || charCode > 57) { + return false + } + } + + return true +} + const kHandler = Symbol('handler') class Request { @@ -402,10 +417,10 @@ function processHeader (request, key, val) { if (request.contentLength !== null) { throw new InvalidArgumentError('duplicate content-length header') } - request.contentLength = parseInt(val, 10) - if (!Number.isFinite(request.contentLength)) { + if (!isValidContentLengthHeaderValue(val)) { throw new InvalidArgumentError('invalid content-length header') } + request.contentLength = parseInt(val, 10) } else if (request.contentType === null && headerName === 'content-type') { request.contentType = val request.headers.push(key, val) diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js index 68f183df048..8aa86ff60ee 100644 --- a/lib/web/fetch/index.js +++ b/lib/web/fetch/index.js @@ -1433,7 +1433,10 @@ async function httpNetworkOrCacheFetch ( // 8. If contentLengthHeaderValue is non-null, then append // `Content-Length`/contentLengthHeaderValue to httpRequest’s header // list. - if (contentLengthHeaderValue != null) { + if ( + contentLengthHeaderValue != null && + !httpRequest.headersList.contains('content-length', true) + ) { httpRequest.headersList.append('content-length', contentLengthHeaderValue, true) } diff --git a/test/fetch/content-length.js b/test/fetch/content-length.js index 7c430b9578d..807e0529cce 100644 --- a/test/fetch/content-length.js +++ b/test/fetch/content-length.js @@ -27,3 +27,23 @@ test('Content-Length is set when using a FormData body with fetch', async (t) => body: fd }) }) + +test('Content-Length is not duplicated when provided explicitly', async (t) => { + const body = 'a+b+c' + + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + t.assert.strictEqual(req.headers['content-length'], `${Buffer.byteLength(body)}`) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(closeServerAsPromise(server)) + + await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + body, + headers: { + 'content-length': Buffer.byteLength(body) + } + }) +}) diff --git a/test/invalid-headers.js b/test/invalid-headers.js index c7801949343..d9ecb91f0c5 100644 --- a/test/invalid-headers.js +++ b/test/invalid-headers.js @@ -5,7 +5,7 @@ const { test, after } = require('node:test') const { Client, errors } = require('..') test('invalid headers', (t) => { - t = tspl(t, { plan: 10 }) + t = tspl(t, { plan: 13 }) const client = new Client('http://localhost:3000') after(() => client.close()) @@ -19,6 +19,36 @@ test('invalid headers', (t) => { t.ok(err instanceof errors.InvalidArgumentError) }) + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': '1.1' + } + }, (err, data) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': '10abc' + } + }, (err, data) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': '-1' + } + }, (err, data) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + client.request({ path: '/', method: 'GET', From f5f3828795c184fd578bafcc450948bfdb3167d9 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:50:31 -0700 Subject: [PATCH 12/22] fix(request): reject NaN highWaterMark during option validation (#5062) (cherry picked from commit e1211a527780583f0a380732c57d0e92c1fc1c65) --- lib/api/api-request.js | 2 +- test/node-test/client-errors.js | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/api/api-request.js b/lib/api/api-request.js index f6d15f75b0e..a6341af531b 100644 --- a/lib/api/api-request.js +++ b/lib/api/api-request.js @@ -21,7 +21,7 @@ class RequestHandler extends AsyncResource { throw new InvalidArgumentError('invalid callback') } - if (highWaterMark && (typeof highWaterMark !== 'number' || highWaterMark < 0)) { + if (highWaterMark != null && (!Number.isFinite(highWaterMark) || highWaterMark < 0)) { throw new InvalidArgumentError('invalid highWaterMark') } diff --git a/test/node-test/client-errors.js b/test/node-test/client-errors.js index 4021bb37955..d260f628b85 100644 --- a/test/node-test/client-errors.js +++ b/test/node-test/client-errors.js @@ -1161,7 +1161,7 @@ test('retry idempotent inflight', async (t) => { }) test('invalid opts', async (t) => { - const p = tspl(t, { plan: 5 }) + const p = tspl(t, { plan: 7 }) const client = new Client('http://localhost:5000') client.request(null, (err) => { @@ -1186,6 +1186,14 @@ test('invalid opts', async (t) => { p.ok(err instanceof errors.InvalidArgumentError) p.strictEqual(err.message, 'invalid highWaterMark') }) + client.request({ + path: '/', + method: 'GET', + highWaterMark: Number.NaN + }, (err) => { + p.ok(err instanceof errors.InvalidArgumentError) + p.strictEqual(err.message, 'invalid highWaterMark') + }) await p.completed }) From a79a3a4d51aba16d0f3f771db3424addf8d75766 Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:08:53 +0530 Subject: [PATCH 13/22] fix(fetch): prefer filename* over filename in multipart form-data (#5068) (cherry picked from commit f6c5ddab02e888729c3141e4457be4e5224d71ab) --- lib/web/fetch/formdata-parser.js | 17 +++++- test/busboy/issue-4661.js | 102 +++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 test/busboy/issue-4661.js diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js index 7723b5fe309..a4622ae10a3 100644 --- a/lib/web/fetch/formdata-parser.js +++ b/lib/web/fetch/formdata-parser.js @@ -204,7 +204,7 @@ function multipartFormDataParser (input, mimeType) { * Parses content-disposition attributes (e.g., name="value" or filename*=utf-8''encoded) * @param {Buffer} input * @param {{ position: number }} position - * @returns {{ name: string, value: string }} + * @returns {{ name: string, value: string, extended: boolean } | null} */ function parseContentDispositionAttribute (input, position) { // Skip leading semicolon and whitespace @@ -304,7 +304,7 @@ function parseContentDispositionAttribute (input, position) { value = decoder.decode(tokenValue) } - return { name: attrNameStr, value } + return { name: attrNameStr, value, extended: isExtended } } /** @@ -368,6 +368,9 @@ function parseMultipartFormDataHeaders (input, position) { switch (bufferToLowerCasedHeaderName(headerName)) { case 'content-disposition': { name = filename = null + // Track whether filename was set from the extended (RFC 5987) form so + // a subsequent legacy `filename` attribute does not override it. + let filenameIsExtended = false // Collect the disposition type (should be "form-data") const dispositionType = collectASequenceOfBytes( @@ -395,7 +398,15 @@ function parseMultipartFormDataHeaders (input, position) { if (attribute.name === 'name') { name = attribute.value } else if (attribute.name === 'filename') { - filename = attribute.value + // Per RFC 5987 §4.1, when both legacy and extended forms of the + // same parameter are present, the extended (filename*) form takes + // precedence regardless of the order they appear in. + if (attribute.extended) { + filename = attribute.value + filenameIsExtended = true + } else if (!filenameIsExtended) { + filename = attribute.value + } } } diff --git a/test/busboy/issue-4661.js b/test/busboy/issue-4661.js new file mode 100644 index 00000000000..112edcb79c4 --- /dev/null +++ b/test/busboy/issue-4661.js @@ -0,0 +1,102 @@ +'use strict' + +const { test } = require('node:test') +const { Request } = require('../..') + +const boundary = '1df6c75e-c5a7-486c-af47-67b632b19522' +const contentType = `multipart/form-data; boundary=${boundary}` + +// https://github.com/nodejs/undici/issues/4661 +test('content-disposition allows both filename and filename* parameters', async (t) => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="Abc"; filename="E 1962 029 1342-0003.JPG"; filename*=utf-8\'\'E%201962%20029%201342-0003.JPG\r\n' + + '\r\n' + + 'Hello\r\n' + + `--${boundary}--` + }) + + const fd = await request.formData() + const file = fd.get('Abc') + t.assert.ok(file instanceof File) + t.assert.strictEqual(file.name, 'E 1962 029 1342-0003.JPG') + t.assert.strictEqual(await file.text(), 'Hello') +}) + +test('filename* (RFC 5987) without legacy filename is parsed correctly', async (t) => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="Abc"; filename*=utf-8\'\'only-extended.txt\r\n' + + '\r\n' + + 'Hello\r\n' + + `--${boundary}--` + }) + + const fd = await request.formData() + const file = fd.get('Abc') + t.assert.ok(file instanceof File) + t.assert.strictEqual(file.name, 'only-extended.txt') +}) + +test('filename* takes precedence over filename when both are present', async (t) => { + // Per RFC 5987 §4.1, when both extended and non-extended forms of the + // same parameter appear, the extended form is preferred regardless of order. + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="Abc"; filename*=utf-8\'\'extended.txt; filename="legacy.txt"\r\n' + + '\r\n' + + 'Hello\r\n' + + `--${boundary}--` + }) + + const fd = await request.formData() + const file = fd.get('Abc') + t.assert.ok(file instanceof File) + t.assert.strictEqual(file.name, 'extended.txt') +}) + +test('filename* takes precedence when filename appears first', async (t) => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="Abc"; filename="legacy.txt"; filename*=utf-8\'\'extended.txt\r\n' + + '\r\n' + + 'Hello\r\n' + + `--${boundary}--` + }) + + const fd = await request.formData() + const file = fd.get('Abc') + t.assert.ok(file instanceof File) + t.assert.strictEqual(file.name, 'extended.txt') +}) + +test('filename* with percent-encoded UTF-8 bytes is decoded', async (t) => { + // %E2%82%AC = U+20AC (€), %20 = space + const request = new Request('http://localhost', { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="Abc"; filename*=UTF-8\'\'%E2%82%AC%20rates.txt\r\n' + + '\r\n' + + 'Hello\r\n' + + `--${boundary}--` + }) + + const fd = await request.formData() + const file = fd.get('Abc') + t.assert.ok(file instanceof File) + t.assert.strictEqual(file.name, '\u20AC rates.txt') +}) From 731ba87ad4f1eeada33086169bb9f08c14c95db0 Mon Sep 17 00:00:00 2001 From: Maruthan G <113752568+maruthang@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:21:15 +0530 Subject: [PATCH 14/22] fix(cache): include query in cache key when opts.path is undefined (#5081) makeCacheKey used pathHasQueryOrFragment(opts.path) to decide whether to merge opts.query into the cached path, but opts.path can be undefined when a caller supplies only origin+query at dispatch time. In that case the check threw TypeError ("Cannot read properties of undefined (reading 'includes')"), so the query was never folded into the cache key and requests with differing queries could collide or the key construction itself could fail. Check the already-defaulted fullPath ('/' when opts.path is missing) instead, so the query is consistently serialised into key.path and different query strings get separate cache entries regardless of how path is supplied. Refs #4209 Signed-off-by: Maruthan G (cherry picked from commit 1f57375c4a69564abc20f05a8df2603c3e7d0146) --- lib/util/cache.js | 2 +- test/cache-interceptor/issue-4209.js | 144 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/cache-interceptor/issue-4209.js diff --git a/lib/util/cache.js b/lib/util/cache.js index 2da2c044dbd..e5abe0f0de0 100644 --- a/lib/util/cache.js +++ b/lib/util/cache.js @@ -18,7 +18,7 @@ function makeCacheKey (opts) { let fullPath = opts.path || '/' - if (opts.query && !pathHasQueryOrFragment(opts.path)) { + if (opts.query && !pathHasQueryOrFragment(fullPath)) { fullPath = serializePathWithQuery(fullPath, opts.query) } diff --git a/test/cache-interceptor/issue-4209.js b/test/cache-interceptor/issue-4209.js new file mode 100644 index 00000000000..e207a0525be --- /dev/null +++ b/test/cache-interceptor/issue-4209.js @@ -0,0 +1,144 @@ +'use strict' + +const { test, after } = require('node:test') +const { equal, notEqual, deepStrictEqual } = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { request, Agent, interceptors } = require('../../') +const { makeCacheKey } = require('../../lib/util/cache') + +// Regression test for https://github.com/nodejs/undici/issues/4209 +// The cache interceptor must treat requests with different `query` params +// (or query strings) as distinct cache entries, and must not throw when +// `opts.path` is absent while `opts.query` is provided. + +test('issue #4209 - makeCacheKey includes query in key.path', () => { + const k1 = makeCacheKey({ + origin: 'http://example.com', + method: 'GET', + path: '/', + query: { i: 1 }, + headers: {} + }) + const k2 = makeCacheKey({ + origin: 'http://example.com', + method: 'GET', + path: '/', + query: { i: 2 }, + headers: {} + }) + equal(k1.path, '/?i=1') + equal(k2.path, '/?i=2') + notEqual(k1.path, k2.path) +}) + +test('issue #4209 - makeCacheKey does not throw when opts.path is undefined', () => { + // Previously `pathHasQueryOrFragment(opts.path)` threw when opts.path was + // undefined, because undefined has no `.includes`. The cache key must be + // derivable even when only `query` is supplied. + const key = makeCacheKey({ + origin: 'http://example.com', + method: 'GET', + query: { i: 42 }, + headers: {} + }) + deepStrictEqual(key, { + origin: 'http://example.com', + method: 'GET', + path: '/?i=42', + headers: {} + }) +}) + +test('issue #4209 - different query strings do not collide in the cache', async () => { + let requestCount = 0 + const server = createServer((req, res) => { + requestCount++ + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Cache-Control': 'public, max-age=100' + }) + // Each response embeds the received URL and a per-request counter so + // that a cache collision is immediately observable: a cached reply for + // a different query would carry the wrong URL/counter. + res.end(`req=${requestCount};url=${req.url}`) + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Agent().compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const origin = `http://localhost:${server.address().port}` + + // First request with ?i=0 – hits origin + const r0 = await request(`${origin}/?i=0`, { dispatcher }) + const b0 = await r0.body.text() + equal(requestCount, 1, 'first distinct query hits the origin') + equal(b0, 'req=1;url=/?i=0') + + // Same query repeated – must be served from cache + const r0b = await request(`${origin}/?i=0`, { dispatcher }) + const b0b = await r0b.body.text() + equal(requestCount, 1, 'repeated query is served from cache') + equal(b0b, b0, 'cached body matches original') + + // Different query – must NOT collide with the previous cache entry + const r1 = await request(`${origin}/?i=1`, { dispatcher }) + const b1 = await r1.body.text() + equal(requestCount, 2, 'different query goes to origin, not the cache') + equal(b1, 'req=2;url=/?i=1') + notEqual(b1, b0, 'different query must produce a different response') + + // And the first one still comes from cache. + const r0c = await request(`${origin}/?i=0`, { dispatcher }) + const b0c = await r0c.body.text() + equal(requestCount, 2, 'original query still cached after the new one') + equal(b0c, b0) +}) + +test('issue #4209 - query object form also keyed distinctly', async () => { + let requestCount = 0 + const server = createServer((req, res) => { + requestCount++ + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Cache-Control': 'public, max-age=100' + }) + res.end(`req=${requestCount};url=${req.url}`) + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Agent().compose(interceptors.cache()) + + after(async () => { + server.close() + await dispatcher.close() + }) + + const origin = `http://localhost:${server.address().port}` + + const r0 = await request(origin, { dispatcher, query: { i: 0 } }) + const b0 = await r0.body.text() + equal(requestCount, 1) + equal(b0, 'req=1;url=/?i=0') + + const r1 = await request(origin, { dispatcher, query: { i: 1 } }) + const b1 = await r1.body.text() + equal(requestCount, 2, 'different query option must bypass cache') + equal(b1, 'req=2;url=/?i=1') + notEqual(b0, b1) + + // Re-request first query – served from cache, counter unchanged. + const r0b = await request(origin, { dispatcher, query: { i: 0 } }) + const b0b = await r0b.body.text() + equal(requestCount, 2, 'first query still served from cache') + equal(b0b, b0) +}) From a166822241b0f76a28d664b1ab480a9e3d8bfab1 Mon Sep 17 00:00:00 2001 From: DeepView Autofix <276251120+deepview-autofix@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:54:39 +0300 Subject: [PATCH 15/22] fix(mock): make filterCalls AND operator actually intersect results (#5045) `makeFilterCalls` returned an arrow function whose `this` cannot be rebound by `.call()`, so `handler.call({ logs: store }, criteria)` in `handleFilterCallsWithOptions` always filtered against the full `this.logs` instead of the narrowed `store`, causing AND to behave like OR. Pass the source logs explicitly to the filter helpers and seed the store with `this.logs` for AND so each criterion narrows the previous result. Signed-off-by: Nikita Skovoroda Co-authored-by: Claude Co-authored-by: Nikita Skovoroda (cherry picked from commit 2a6f9c7dbd1d37b36e2269bb0d13194de8fe615b) --- lib/mock/mock-call-history.js | 30 +++++++++++++++--------------- test/mock-call-history.js | 19 ++++++++++++++++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/lib/mock/mock-call-history.js b/lib/mock/mock-call-history.js index d4a92b2b24b..74de68247c7 100644 --- a/lib/mock/mock-call-history.js +++ b/lib/mock/mock-call-history.js @@ -3,14 +3,14 @@ const { kMockCallHistoryAddLog } = require('./mock-symbols') const { InvalidArgumentError } = require('../core/errors') -function handleFilterCallsWithOptions (criteria, options, handler, store) { +function handleFilterCallsWithOptions (criteria, options, handler, store, allLogs) { switch (options.operator) { case 'OR': - store.push(...handler(criteria)) + store.push(...handler(criteria, allLogs)) return store case 'AND': - return handler.call({ logs: store }, criteria) + return handler(criteria, store) default: // guard -- should never happens because buildAndValidateFilterCallsOptions is called before throw new InvalidArgumentError('options.operator must to be a case insensitive string equal to \'OR\' or \'AND\'') @@ -35,14 +35,14 @@ function buildAndValidateFilterCallsOptions (options = {}) { } function makeFilterCalls (parameterName) { - return (parameterValue) => { + return (parameterValue, logs) => { if (typeof parameterValue === 'string' || parameterValue == null) { - return this.logs.filter((log) => { + return logs.filter((log) => { return log[parameterName] === parameterValue }) } if (parameterValue instanceof RegExp) { - return this.logs.filter((log) => { + return logs.filter((log) => { return parameterValue.test(log[parameterName]) }) } @@ -175,30 +175,30 @@ class MockCallHistory { const finalOptions = { operator: 'OR', ...buildAndValidateFilterCallsOptions(options) } - let maybeDuplicatedLogsFiltered = [] + let maybeDuplicatedLogsFiltered = finalOptions.operator === 'AND' ? this.logs : [] if ('protocol' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.protocol, finalOptions, this.filterCallsByProtocol, maybeDuplicatedLogsFiltered, this.logs) } if ('host' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.host, finalOptions, this.filterCallsByHost, maybeDuplicatedLogsFiltered, this.logs) } if ('port' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.port, finalOptions, this.filterCallsByPort, maybeDuplicatedLogsFiltered, this.logs) } if ('origin' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.origin, finalOptions, this.filterCallsByOrigin, maybeDuplicatedLogsFiltered, this.logs) } if ('path' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.path, finalOptions, this.filterCallsByPath, maybeDuplicatedLogsFiltered, this.logs) } if ('hash' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.hash, finalOptions, this.filterCallsByHash, maybeDuplicatedLogsFiltered, this.logs) } if ('fullUrl' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.fullUrl, finalOptions, this.filterCallsByFullUrl, maybeDuplicatedLogsFiltered, this.logs) } if ('method' in criteria) { - maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered) + maybeDuplicatedLogsFiltered = handleFilterCallsWithOptions(criteria.method, finalOptions, this.filterCallsByMethod, maybeDuplicatedLogsFiltered, this.logs) } const uniqLogsFiltered = [...new Set(maybeDuplicatedLogsFiltered)] diff --git a/test/mock-call-history.js b/test/mock-call-history.js index 6e05f21f9fa..e074ddf3b5f 100644 --- a/test/mock-call-history.js +++ b/test/mock-call-history.js @@ -409,7 +409,24 @@ describe('MockCallHistory - filterCalls with options', () => { const filtered = mockCallHistoryHello.filterCalls({ path: '/', port: '4000' }, { operator: 'AND' }) - t.assert.strictEqual(filtered.length, 2) + t.assert.strictEqual(filtered.length, 1) + }) + + test('should use "AND" operator narrowing through every criterion', t => { + t.plan(2) + + const mockCallHistoryHello = new MockCallHistory('hello') + + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000', method: 'GET' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:4000', method: 'POST' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/', origin: 'http://localhost:5000', method: 'GET' }) + mockCallHistoryHello[kMockCallHistoryAddLog]({ path: '/foo', origin: 'http://localhost:4000', method: 'GET' }) + + const andFiltered = mockCallHistoryHello.filterCalls({ path: '/', port: '4000', method: 'GET' }, { operator: 'AND' }) + t.assert.strictEqual(andFiltered.length, 1) + + const orFiltered = mockCallHistoryHello.filterCalls({ path: '/', port: '4000', method: 'GET' }, { operator: 'OR' }) + t.assert.strictEqual(orFiltered.length, 4) }) test('should use "AND" operator with a lot of filters', t => { From 325932c42cbbb5ecacd5d23ba78ba013c5ff768a Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:21:28 -0700 Subject: [PATCH 16/22] fix(cache): skip expired sqlite vary entries during lookup (#5095) (cherry picked from commit 67ca48c90f085910e6745f9d0eafda096ec59355) --- lib/cache/sqlite-cache-store.js | 2 +- .../sqlite-cache-store-tests.js | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lib/cache/sqlite-cache-store.js b/lib/cache/sqlite-cache-store.js index 75d7e253ec8..35182c5c994 100644 --- a/lib/cache/sqlite-cache-store.js +++ b/lib/cache/sqlite-cache-store.js @@ -409,7 +409,7 @@ module.exports = class SqliteCacheStore { const now = Date.now() for (const value of values) { if (now >= value.deleteAt && !canBeExpired) { - return undefined + continue } let matches = true diff --git a/test/cache-interceptor/sqlite-cache-store-tests.js b/test/cache-interceptor/sqlite-cache-store-tests.js index bb9d3e2d4c9..853b4fc9b7d 100644 --- a/test/cache-interceptor/sqlite-cache-store-tests.js +++ b/test/cache-interceptor/sqlite-cache-store-tests.js @@ -231,3 +231,59 @@ test('SqliteCacheStore write & read', { skip: runtimeFeatures.has('sqlite') === deepStrictEqual(store.get(key), value) }) + +test('SqliteCacheStore ignores expired Vary variants when a later one is still valid', { skip: runtimeFeatures.has('sqlite') === false }, () => { + const store = new SqliteCacheStore() + const now = Date.now() + const baseKey = { + origin: 'localhost', + path: '/', + method: 'GET' + } + + store.set({ + ...baseKey, + headers: { + 'accept-encoding': 'gzip' + } + }, { + statusCode: 200, + statusMessage: '', + headers: {}, + vary: { + 'accept-encoding': 'gzip' + }, + cachedAt: now - 2000, + staleAt: now - 1000, + deleteAt: now - 1, + body: Buffer.from('expired gzip') + }) + + store.set({ + ...baseKey, + headers: { + 'accept-encoding': 'br' + } + }, { + statusCode: 200, + statusMessage: '', + headers: {}, + vary: { + 'accept-encoding': 'br' + }, + cachedAt: now, + staleAt: now + 1000, + deleteAt: now + 2000, + body: Buffer.from('valid br') + }) + + const result = store.get({ + ...baseKey, + headers: { + 'accept-encoding': 'br' + } + }) + + notEqual(result, undefined) + strictEqual(result.body.toString(), 'valid br') +}) From a82ece9b1749c366e2458f3842b22393d2c19760 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:51:35 -0700 Subject: [PATCH 17/22] fix: enforce maxCachedSessions in TLS session cache (#5102) Assisted-by: openai:gpt-5.4 Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> (cherry picked from commit 754a3d389cc4ed4c269c9c1fd64970f36afe5bd9) --- lib/core/connect.js | 16 +++++++++ test/tls-session-reuse.js | 72 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/lib/core/connect.js b/lib/core/connect.js index a49af91486e..89a20c740a7 100644 --- a/lib/core/connect.js +++ b/lib/core/connect.js @@ -38,6 +38,22 @@ const SessionCache = class WeakSessionCache { return } + if (this._sessionCache.has(sessionKey)) { + this._sessionCache.delete(sessionKey) + } else if (this._sessionCache.size >= this._maxCachedSessions) { + for (const [key, ref] of this._sessionCache) { + if (ref.deref() === undefined) { + this._sessionCache.delete(key) + return + } + } + + const oldest = this._sessionCache.keys().next() + if (!oldest.done) { + this._sessionCache.delete(oldest.value) + } + } + this._sessionCache.set(sessionKey, new WeakRef(session)) this._sessionRegistry.register(session, sessionKey) } diff --git a/test/tls-session-reuse.js b/test/tls-session-reuse.js index 2ebd25dbd2a..d63dd3a808c 100644 --- a/test/tls-session-reuse.js +++ b/test/tls-session-reuse.js @@ -6,7 +6,7 @@ const { readFileSync } = require('node:fs') const { join } = require('node:path') const https = require('node:https') const crypto = require('node:crypto') -const { Client, Pool } = require('..') +const { Client, Pool, buildConnector } = require('..') const { kSocket } = require('../lib/core/symbols') const options = { @@ -179,3 +179,73 @@ describe('A pool should be able to reuse TLS sessions between clients', () => { await t.completed }) }) + +describe('A connector should enforce maxCachedSessions across hostnames', () => { + test('Prepare request', async t => { + t = tspl(t, { plan: 4 }) + + const tlsOptions = { + ...options, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.2' + } + + const serverA = https.createServer(tlsOptions, (req, res) => { + res.end('a') + }) + const serverB = https.createServer(tlsOptions, (req, res) => { + res.end('b') + }) + + await Promise.all([ + new Promise((resolve) => serverA.listen(0, resolve)), + new Promise((resolve) => serverB.listen(0, resolve)) + ]) + + after(() => { + serverA.close() + serverB.close() + }) + + const connector = buildConnector({ + ca, + lookup (hostname, options, callback) { + if (options?.all) { + callback(null, [{ address: '127.0.0.1', family: 4 }]) + } else { + callback(null, '127.0.0.1', 4) + } + }, + maxCachedSessions: 1, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false + }) + + async function connectOnce ({ hostname, port }) { + const { promise, resolve, reject } = Promise.withResolvers() + + connector({ hostname, protocol: 'https:', port }, (err, socket) => { + if (err) { + reject(err) + return + } + + const reused = socket.isSessionReused() + + socket.on('error', reject) + socket.on('end', () => resolve(reused)) + socket.resume() + socket.write(`GET / HTTP/1.1\r\nHost: ${hostname}\r\nConnection: close\r\n\r\n`) + }) + + return promise + } + + t.strictEqual(await connectOnce({ hostname: 'a.test', port: serverA.address().port }), false) + t.strictEqual(await connectOnce({ hostname: 'a.test', port: serverA.address().port }), true) + t.strictEqual(await connectOnce({ hostname: 'b.test', port: serverB.address().port }), false) + t.strictEqual(await connectOnce({ hostname: 'a.test', port: serverA.address().port }), false) + await t.completed + }) +}) From 5c98517ec24a7b35aa87a958c78e0baf73b25c50 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:33:17 -0700 Subject: [PATCH 18/22] fix: reuse parser WeakRef for timeout callbacks (#5125) (cherry picked from commit ead8a6b4aff84bf76f6f722f266c7f0d47a9ae4c) --- lib/dispatcher/client-h1.js | 5 ++-- test/client-timeout.js | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js index 00863928de6..605edf30b0b 100644 --- a/lib/dispatcher/client-h1.js +++ b/lib/dispatcher/client-h1.js @@ -216,6 +216,7 @@ class Parser { */ this.socket = socket this.timeout = null + this.timeoutWeakRef = new WeakRef(this) this.timeoutValue = null this.timeoutType = null this.statusCode = 0 @@ -253,9 +254,9 @@ class Parser { if (delay) { if (type & USE_FAST_TIMER) { - this.timeout = timers.setFastTimeout(onParserTimeout, delay, new WeakRef(this)) + this.timeout = timers.setFastTimeout(onParserTimeout, delay, this.timeoutWeakRef) } else { - this.timeout = setTimeout(onParserTimeout, delay, new WeakRef(this)) + this.timeout = setTimeout(onParserTimeout, delay, this.timeoutWeakRef) this.timeout?.unref() } } diff --git a/test/client-timeout.js b/test/client-timeout.js index a2c18cd8fd0..7ec8fdec6ee 100644 --- a/test/client-timeout.js +++ b/test/client-timeout.js @@ -3,10 +3,67 @@ const { tspl } = require('@matteo.collina/tspl') const { test, after } = require('node:test') const { Client, errors } = require('..') +const EventEmitter = require('node:events') const { createServer } = require('node:http') const { Readable } = require('node:stream') const FakeTimers = require('@sinonjs/fake-timers') const timers = require('../lib/util/timers') +const connectH1 = require('../lib/dispatcher/client-h1') +const { + kMaxHeadersSize, + kMaxResponseSize, + kParser, + kQueue, + kRunningIdx +} = require('../lib/core/symbols') + +class DummySocket extends EventEmitter { + constructor () { + super() + this.destroyed = false + this.errored = null + } + + read () { + return null + } +} + +test('parser reuses WeakRef when replacing timeout callbacks', async (t) => { + const OriginalWeakRef = global.WeakRef + t.after(() => { + global.WeakRef = OriginalWeakRef + }) + + t = tspl(t, { plan: 1 }) + + let weakRefCount = 0 + + global.WeakRef = class CountingWeakRef extends OriginalWeakRef { + constructor (target) { + weakRefCount++ + super(target) + } + } + + const socket = new DummySocket() + const client = { + [kMaxHeadersSize]: 1024, + [kMaxResponseSize]: 1024, + [kQueue]: [], + [kRunningIdx]: 0 + } + + await connectH1(client, socket) + const parser = socket[kParser] + + parser.setTimeout(200, 0) + parser.setTimeout(300, 0) + parser.setTimeout(400, 1) + parser.destroy() + + t.strictEqual(weakRefCount, 1) +}) test('refresh timeout on pause', async (t) => { t = tspl(t, { plan: 1 }) From f53a334c66d8fdfb7b85c47b4942723fffbdca07 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:51:14 -0700 Subject: [PATCH 19/22] fix: stop buffering data after SOCKS5 connect (#5118) (cherry picked from commit 74e87061eeab0d8104db1c3aecdc27c782e3c017) --- lib/core/socks5-client.js | 15 ++++++++++----- test/socks5-client.js | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lib/core/socks5-client.js b/lib/core/socks5-client.js index 5d175d70366..9f3ed88b5aa 100644 --- a/lib/core/socks5-client.js +++ b/lib/core/socks5-client.js @@ -7,6 +7,7 @@ const { debuglog } = require('node:util') const { parseAddress } = require('./socks5-utils') const debug = debuglog('undici:socks5') +const EMPTY_BUFFER = Buffer.alloc(0) // SOCKS5 constants const SOCKS_VERSION = 0x05 @@ -72,7 +73,10 @@ class Socks5Client extends EventEmitter { this.socket = socket this.options = options this.state = STATES.INITIAL - this.buffer = Buffer.alloc(0) + this.buffer = EMPTY_BUFFER + this.onSocketData = this.onData.bind(this) + this.onSocketError = this.onError.bind(this) + this.onSocketClose = this.onClose.bind(this) // Authentication settings this.authMethods = [] @@ -82,9 +86,9 @@ class Socks5Client extends EventEmitter { this.authMethods.push(AUTH_METHODS.NO_AUTH) // Socket event handlers - this.socket.on('data', this.onData.bind(this)) - this.socket.on('error', this.onError.bind(this)) - this.socket.on('close', this.onClose.bind(this)) + this.socket.on('data', this.onSocketData) + this.socket.on('error', this.onSocketError) + this.socket.on('close', this.onSocketClose) } /** @@ -363,8 +367,9 @@ class Socks5Client extends EventEmitter { const boundPort = this.buffer.readUInt16BE(offset) - this.buffer = this.buffer.subarray(responseLength) + this.buffer = EMPTY_BUFFER this.state = STATES.CONNECTED + this.socket.removeListener('data', this.onSocketData) debug('connected, bound address:', boundAddress, 'port:', boundPort) this.emit('connected', { address: boundAddress, port: boundPort }) diff --git a/test/socks5-client.js b/test/socks5-client.js index 2c927f63217..045eb7aa20a 100644 --- a/test/socks5-client.js +++ b/test/socks5-client.js @@ -144,7 +144,7 @@ test('Socks5Client - username/password authentication', async (t) => { }) test('Socks5Client - connect command', async (t) => { - const p = tspl(t, { plan: 8 }) + const p = tspl(t, { plan: 10 }) const targetHost = 'example.com' const targetPort = 80 @@ -206,6 +206,8 @@ test('Socks5Client - connect command', async (t) => { client.on('connected', (info) => { p.equal(info.address, '127.0.0.1', 'should return bound address') p.equal(info.port, 80, 'should return bound port') + p.equal(client.buffer.length, 0, 'should clear SOCKS5 protocol buffer after connect') + p.equal(socket.listenerCount('data'), 0, 'should stop parsing socket data after connect') }) await client.handshake() From 7b7b88b28f23b49ede5690fe6523a345f4038366 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:03:15 -0700 Subject: [PATCH 20/22] fix(cache): enforce sqlite maxCount after insert (#5112) (cherry picked from commit 2eb9ddc01e1f0a6e518e90d2ea1a8f0d850ddc68) --- lib/cache/sqlite-cache-store.js | 2 +- .../sqlite-cache-store-tests.js | 43 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/cache/sqlite-cache-store.js b/lib/cache/sqlite-cache-store.js index 35182c5c994..7a5fc999f47 100644 --- a/lib/cache/sqlite-cache-store.js +++ b/lib/cache/sqlite-cache-store.js @@ -283,7 +283,6 @@ module.exports = class SqliteCacheStore { existingValue.id ) } else { - this.#prune() // New response, let's insert it this.#insertValueQuery.run( url, @@ -299,6 +298,7 @@ module.exports = class SqliteCacheStore { value.cachedAt, value.staleAt ) + this.#prune() } } diff --git a/test/cache-interceptor/sqlite-cache-store-tests.js b/test/cache-interceptor/sqlite-cache-store-tests.js index 853b4fc9b7d..f5eb18d0116 100644 --- a/test/cache-interceptor/sqlite-cache-store-tests.js +++ b/test/cache-interceptor/sqlite-cache-store-tests.js @@ -75,9 +75,8 @@ test('SqliteCacheStore works nicely with multiple stores', { skip: runtimeFeatur test('SqliteCacheStore maxEntries', { skip: runtimeFeatures.has('sqlite') === false }, async () => { const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') - const store = new SqliteCacheStore({ - maxCount: 10 - }) + const maxCount = 10 + const store = new SqliteCacheStore({ maxCount }) for (let i = 0; i < 20; i++) { /** @@ -109,7 +108,43 @@ test('SqliteCacheStore maxEntries', { skip: runtimeFeatures.has('sqlite') === fa writeBody(writable, body) } - strictEqual(store.size <= 11, true) + strictEqual(store.size <= maxCount, true) +}) + +test('SqliteCacheStore enforces maxCount after insert', { skip: runtimeFeatures.has('sqlite') === false }, () => { + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + + const store = new SqliteCacheStore({ maxCount: 1 }) + + store.set( + { origin: 'localhost', path: '/a', method: 'GET', headers: {} }, + { + statusCode: 200, + statusMessage: '', + headers: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 10_000, + deleteAt: Date.now() + 20_000, + body: Buffer.from('a') + } + ) + + store.set( + { origin: 'localhost', path: '/b', method: 'GET', headers: {} }, + { + statusCode: 200, + statusMessage: '', + headers: {}, + cachedAt: Date.now() + 1, + staleAt: Date.now() + 10_001, + deleteAt: Date.now() + 20_001, + body: Buffer.from('b') + } + ) + + strictEqual(store.size, 1) + strictEqual(store.get({ origin: 'localhost', path: '/a', method: 'GET', headers: {} }), undefined) + notEqual(store.get({ origin: 'localhost', path: '/b', method: 'GET', headers: {} }), undefined) }) test('SqliteCacheStore two writes', { skip: runtimeFeatures.has('sqlite') === false }, async () => { From 7ba7a259dc24d049f66a74eb2b60963c38ec0076 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 28 Apr 2026 09:53:58 +0200 Subject: [PATCH 21/22] fix: preserve allowH2 when wrapping custom connect Signed-off-by: Matteo Collina --- lib/dispatcher/client.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index f4feff1bbe0..f40fbb2c75b 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -235,9 +235,13 @@ class Client extends DispatcherBase { ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), ...connect }) - } else if (socketPath != null) { + } else { const customConnect = connect - connect = (opts, callback) => customConnect({ ...opts, socketPath }, callback) + connect = (opts, callback) => customConnect({ + ...opts, + ...(socketPath != null ? { socketPath } : null), + ...(allowH2 != null ? { allowH2 } : null) + }, callback) } this[kUrl] = util.parseOrigin(url) From b8c42ded73794645c92438be554d857912981106 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 28 Apr 2026 13:01:53 +0200 Subject: [PATCH 22/22] test: avoid Promise.withResolvers in tls session reuse test Signed-off-by: Matteo Collina --- test/tls-session-reuse.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/tls-session-reuse.js b/test/tls-session-reuse.js index d63dd3a808c..3128f7b5864 100644 --- a/test/tls-session-reuse.js +++ b/test/tls-session-reuse.js @@ -8,6 +8,7 @@ const https = require('node:https') const crypto = require('node:crypto') const { Client, Pool, buildConnector } = require('..') const { kSocket } = require('../lib/core/symbols') +const { createDeferredPromise } = require('../lib/util/promise') const options = { key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), @@ -223,7 +224,7 @@ describe('A connector should enforce maxCachedSessions across hostnames', () => }) async function connectOnce ({ hostname, port }) { - const { promise, resolve, reject } = Promise.withResolvers() + const { promise, resolve, reject } = Promise.withResolvers?.() ?? createDeferredPromise() connector({ hostname, protocol: 'https:', port }, (err, socket) => { if (err) {