From f44235333f9e3bc199d2eddce32175e7ccdb9537 Mon Sep 17 00:00:00 2001 From: Hallison Melo Date: Tue, 26 May 2026 15:40:36 -0300 Subject: [PATCH] quic: convert incoming :status header to number --- doc/api/quic.md | 5 +- lib/internal/quic/quic.js | 15 +++-- .../parallel/test-quic-h3-callback-errors.mjs | 4 +- test/parallel/test-quic-h3-close-behavior.mjs | 4 +- .../test-quic-h3-concurrent-requests.mjs | 2 +- test/parallel/test-quic-h3-datagram.mjs | 4 +- test/parallel/test-quic-h3-error-codes.mjs | 4 +- test/parallel/test-quic-h3-goaway.mjs | 2 +- .../test-quic-h3-header-validation.mjs | 4 +- .../test-quic-h3-informational-headers.mjs | 8 +-- test/parallel/test-quic-h3-origin.mjs | 4 +- test/parallel/test-quic-h3-pending-stream.mjs | 2 +- .../parallel/test-quic-h3-post-filehandle.mjs | 2 +- test/parallel/test-quic-h3-post-request.mjs | 2 +- test/parallel/test-quic-h3-priority.mjs | 10 +-- test/parallel/test-quic-h3-qpack-settings.mjs | 2 +- .../test-quic-h3-request-response.mjs | 4 +- test/parallel/test-quic-h3-settings.mjs | 6 +- .../test-quic-h3-status-code-type.mjs | 67 +++++++++++++++++++ .../test-quic-h3-trailing-headers.mjs | 4 +- ...est-quic-h3-zero-rtt-rejected-settings.mjs | 2 +- test/parallel/test-quic-h3-zero-rtt.mjs | 4 +- 22 files changed, 117 insertions(+), 44 deletions(-) create mode 100644 test/parallel/test-quic-h3-status-code-type.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 5ecac022a3c83b..e3e2ae19b2a056 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -3825,8 +3825,9 @@ A few things to note: the request is `HEADERS` followed by `END_STREAM`. * The `onheaders` callback receives the response pseudo-headers and regular headers in a single object with lowercase string keys. - After the callback returns, the same object is also accessible - via [`stream.headers`][]. + For incoming headers, the `:status` pseudo-header is converted to + a `number`, matching HTTP/2 behavior. After the callback returns, + the same object is also accessible via [`stream.headers`][]. * Reading `for await (const chunks of stream)` consumes the response body. Each iteration yields a `Uint8Array[]` batch of chunks. * HTTP semantic helpers (URL parsing, method/status validation, diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index fe9e223bdce4d5..c1d3b4b226b549 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -1203,14 +1203,19 @@ function parseHeaderPairs(pairs) { assert(pairs.length % 2 === 0); const block = { __proto__: null }; for (let n = 0; n + 1 < pairs.length; n += 2) { - if (block[pairs[n]] !== undefined) { - if (ArrayIsArray(block[pairs[n]])) { - ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); + const name = pairs[n]; + let value = pairs[n + 1]; + // Match HTTP/2 behavior: incoming :status is exposed as a number. + if (name === ':status') + value |= 0; + if (block[name] !== undefined) { + if (ArrayIsArray(block[name])) { + ArrayPrototypePush(block[name], value); } else { - block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; + block[name] = [block[name], value]; } } else { - block[pairs[n]] = pairs[n + 1]; + block[name] = value; } } return block; diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index 226a3f6b96fbd5..8bf40bdd07cf27 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -154,7 +154,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), ontrailers: mustCall(function() { throw new Error('ontrailers sync error'); @@ -268,7 +268,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index 02b34945087267..725b29257b9557 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -65,7 +65,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -77,7 +77,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index 6f0aa50b7f02ae..5c519c99958955 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -75,7 +75,7 @@ const requests = paths.map(mustCall(async (path) => { ':authority': 'localhost', }, onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); headersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index ea00cec42bc8f4..fbf40a7a8608ce 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -90,7 +90,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -154,7 +154,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index f9aebadc85cfd2..17f33b48129269 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -58,7 +58,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -109,7 +109,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index 7542849f35eeed..00c8aa6e415a3e 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -81,7 +81,7 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { await clientSession.opened; const onClientHeaders = mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); if (++clientHeaderCount === 2) { bothHeadersReceived.resolve(); } diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 43673e0cc00f1e..97f0fe7a41e6db 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -94,7 +94,7 @@ const decoder = new TextDecoder(); }, onheaders: mustCall(function(headers) { // Client should also receive lowercased response header names. - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); strictEqual(headers['content-type'], 'text/html'); strictEqual(headers['x-response-header'], 'ResponseValue'); @@ -151,7 +151,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '204'); + strictEqual(headers[':status'], 204); }), }); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 6fa950b7bccbd6..bbeaf63effd0b8 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -37,7 +37,7 @@ dc.subscribe('quic.stream.info', mustCall((msg) => { ok(msg.stream, 'stream.info should include stream'); ok(msg.session, 'stream.info should include session'); ok(msg.headers, 'stream.info should include headers'); - strictEqual(msg.headers[':status'], '103'); + strictEqual(msg.headers[':status'], 103); })); // quic.stream.headers also fires for the final response headers. @@ -92,12 +92,12 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, oninfo: mustCall(function(headers) { - strictEqual(headers[':status'], '103'); + strictEqual(headers[':status'], 103); strictEqual(headers.link, '; rel=preload; as=style'); clientInfoReceived.resolve(); }), onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -110,7 +110,7 @@ const body = await bytes(stream); strictEqual(decoder.decode(body), responseBody); // stream.headers should return the final (initial) headers, not 1xx. -strictEqual(stream.headers[':status'], '200'); +strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 4f1fdf50e58d6d..07464e8e8dc876 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -80,7 +80,7 @@ const decoder = new TextDecoder(); ':authority': 'example.com', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -176,7 +176,7 @@ const decoder = new TextDecoder(); ':authority': 'custom-port.example.com', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index fd269e64e0543a..506082f8707e01 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -67,7 +67,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: true, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index 46e30d8376d6cd..9ba0f0b2307b1e 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -79,7 +79,7 @@ writeFileSync(testFile, testContent); }, body: fh, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index a12458ef10df30..32334e4b3e4b69 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -87,7 +87,7 @@ const stream = await clientSession.createBidirectionalStream({ }, body: encoder.encode(requestBody), onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), }); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index fc7ca231f0d63a..fa53a7204d666d 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -70,7 +70,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: false, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -88,7 +88,7 @@ const decoder = new TextDecoder(); priority: 'low', incremental: true, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); deepStrictEqual(stream2.priority, { level: 'low', incremental: true }); @@ -102,7 +102,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); deepStrictEqual(stream3.priority, { level: 'default', incremental: false }); @@ -116,7 +116,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); // Default priority initially. @@ -218,7 +218,7 @@ const decoder = new TextDecoder(); }, body: encoder.encode('signal'), onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); deepStrictEqual(stream.priority, { level: 'default', incremental: false }); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 6ca5671ef5b91f..d64b01ff375ab5 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -38,7 +38,7 @@ async function makeRequest(clientSession, path) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); const body = await bytes(stream); diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index 1610f8deec1d41..0006dc10d7f69f 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -96,7 +96,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -109,7 +109,7 @@ const body = await bytes(stream); strictEqual(decoder.decode(body), responseBody); // stream.headers should return the buffered response headers. -strictEqual(stream.headers[':status'], '200'); +strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index 363aa985c23a82..ff4358751582aa 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -74,7 +74,7 @@ const decoder = new TextDecoder(); 'x-second': 'two', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -132,7 +132,7 @@ const decoder = new TextDecoder(); 'x-long': longValue, }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); @@ -179,7 +179,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-status-code-type.mjs b/test/parallel/test-quic-h3-status-code-type.mjs new file mode 100644 index 00000000000000..b0c6cfb30ff7e2 --- /dev/null +++ b/test/parallel/test-quic-h3-status-code-type.mjs @@ -0,0 +1,67 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Verify incoming :status is exposed as a number, matching HTTP/2 behavior. +// See https://github.com/nodejs/node/issues/63557 + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const codes = [200, 204, 404]; +let serverResponses = 0; +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = mustCall(() => { + if (++serverResponses === codes.length) { + ss.close(); + serverDone.resolve(); + } + }, codes.length); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + onheaders: mustCall(function() { + const status = codes[serverResponses - 1]; + this.sendHeaders({ ':status': String(status) }, { terminal: true }); + this.writer.endSync(); + }, codes.length), +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +await clientSession.opened; + +for (const expected of codes) { + const stream = await clientSession.createBidirectionalStream({ + headers: { + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, + onheaders: mustCall(function(headers) { + strictEqual(typeof headers[':status'], 'number'); + strictEqual(headers[':status'], expected); + }), + }); + await stream.closed; +} + +await serverDone.promise; +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index 436cb243b3dd99..4b8b732ec3ed62 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -97,7 +97,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), ontrailers: mustCall(function(trailers) { @@ -117,7 +117,7 @@ strictEqual(decoder.decode(body), responseBody); await clientTrailersReceived.promise; // stream.headers should still be the initial headers, not trailers. -strictEqual(stream.headers[':status'], '200'); +strictEqual(stream.headers[':status'], 200); await Promise.all([stream.closed, serverDone.promise]); await clientSession.close(); diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index 41f77a63a1f980..8025620a477bab 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -77,7 +77,7 @@ async function getTicket(endpointOptions) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); const body = await bytes(s); diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index 4e51958d7c864a..1464b71d401d69 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -86,7 +86,7 @@ const s1 = await cs1.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), }); const body1 = await bytes(s1); @@ -114,7 +114,7 @@ const s2 = await cs2.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); + strictEqual(headers[':status'], 200); }), });