diff --git a/doc/api/quic.md b/doc/api/quic.md index 8f3834dccf9c..7a52e4f72c1f 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -3990,8 +3990,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 666e1f7b013b..f645998e628d 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -1285,14 +1285,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 f4a9477ca873..be8ed391b8d5 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -151,7 +151,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), ontrailers: mustCall(function() { throw new Error('ontrailers sync error'); @@ -265,7 +265,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 d25cd50e4ef5..e3b73ce9b0b9 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -62,7 +62,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -74,7 +74,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.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 c81403bf1362..5bd5635008ca 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -72,7 +72,7 @@ const requests = paths.map(mustCall(async (path) => { ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.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 4d081a9f1bce..38aeb971c8fe 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -87,7 +87,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -151,7 +151,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '200'); + assert.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 cd5c9ff0a25a..3a91a2e8f056 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -55,7 +55,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -106,7 +106,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index c3b6e3ae246a..bb0bf8e966c1 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -78,7 +78,7 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { await clientSession.opened; const onClientHeaders = mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 873991a89864..a75a884c39b9 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -91,7 +91,7 @@ const decoder = new TextDecoder(); }, onheaders: mustCall(function(headers) { // Client should also receive lowercased response header names. - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/html'); assert.strictEqual(headers['x-response-header'], 'ResponseValue'); @@ -148,7 +148,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall((headers) => { - assert.strictEqual(headers[':status'], '204'); + assert.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 1bab5b26d436..357b507ae0db 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -34,7 +34,7 @@ dc.subscribe('quic.stream.info', mustCall((msg) => { assert.ok(msg.stream, 'stream.info should include stream'); assert.ok(msg.session, 'stream.info should include session'); assert.ok(msg.headers, 'stream.info should include headers'); - assert.strictEqual(msg.headers[':status'], '103'); + assert.strictEqual(msg.headers[':status'], 103); })); // quic.stream.headers also fires for the final response headers. @@ -89,12 +89,12 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, oninfo: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '103'); + assert.strictEqual(headers[':status'], 103); assert.strictEqual(headers.link, '; rel=preload; as=style'); clientInfoReceived.resolve(); }), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -107,7 +107,7 @@ const body = await bytes(stream); assert.strictEqual(decoder.decode(body), responseBody); // stream.headers should return the final (initial) headers, not 1xx. -assert.strictEqual(stream.headers[':status'], '200'); +assert.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 05e7d166585e..9f80449b6b65 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -77,7 +77,7 @@ const decoder = new TextDecoder(); ':authority': 'example.com', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -173,7 +173,7 @@ const decoder = new TextDecoder(); ':authority': 'custom-port.example.com', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 836c032e2b99..a6e9c8cfd912 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -64,7 +64,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: true, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 ce6bec75c57a..a4c583463d24 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -76,7 +76,7 @@ writeFileSync(testFile, testContent); }, body: fh, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 adf874a0e8aa..c5d9635a640c 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -84,7 +84,7 @@ const stream = await clientSession.createBidirectionalStream({ }, body: encoder.encode(requestBody), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 fcf7210b703c..10be3d6f216e 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -67,7 +67,7 @@ const decoder = new TextDecoder(); priority: 'high', incremental: false, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -85,7 +85,7 @@ const decoder = new TextDecoder(); priority: 'low', incremental: true, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.deepStrictEqual(stream2.priority, { level: 'low', incremental: true }); @@ -99,7 +99,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.deepStrictEqual(stream3.priority, { level: 'default', incremental: false }); @@ -113,7 +113,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); // Default priority initially. @@ -215,7 +215,7 @@ const decoder = new TextDecoder(); }, body: encoder.encode('signal'), onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); assert.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 e56730531c0f..f30b7163cf6b 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -35,7 +35,7 @@ async function makeRequest(clientSession, path) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 e359e492f753..cde16684d7e9 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -93,7 +93,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); assert.strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); }), @@ -106,7 +106,7 @@ const body = await bytes(stream); assert.strictEqual(decoder.decode(body), responseBody); // stream.headers should return the buffered response headers. -assert.strictEqual(stream.headers[':status'], '200'); +assert.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 3a2bd9387f57..733d3865e872 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -71,7 +71,7 @@ const decoder = new TextDecoder(); 'x-second': 'two', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -129,7 +129,7 @@ const decoder = new TextDecoder(); 'x-long': longValue, }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); @@ -185,7 +185,7 @@ const decoder = new TextDecoder(); ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 000000000000..a1bc7178e17a --- /dev/null +++ b/test/parallel/test-quic-h3-status-code-type.mjs @@ -0,0 +1,64 @@ +// 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'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.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) { + assert.strictEqual(typeof headers[':status'], 'number'); + assert.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 e19886fa5bad..f4ffc223d4d5 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -94,7 +94,7 @@ const stream = await clientSession.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); clientHeadersReceived.resolve(); }), ontrailers: mustCall(function(trailers) { @@ -114,7 +114,7 @@ assert.strictEqual(decoder.decode(body), responseBody); await clientTrailersReceived.promise; // stream.headers should still be the initial headers, not trailers. -assert.strictEqual(stream.headers[':status'], '200'); +assert.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 c17f2ad3994c..755bde188e0b 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -74,7 +74,7 @@ async function getTicket(endpointOptions) { ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.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 ef51c63ee8aa..f836caa1ec4f 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -83,7 +83,7 @@ const s1 = await cs1.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), }); const body1 = await bytes(s1); @@ -111,7 +111,7 @@ const s2 = await cs2.createBidirectionalStream({ ':authority': 'localhost', }, onheaders: mustCall(function(headers) { - assert.strictEqual(headers[':status'], '200'); + assert.strictEqual(headers[':status'], 200); }), });