http2: add support for raw header arrays in h2Stream.respond()

PR-URL: https://github.com/nodejs/node/pull/59455
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
Reviewed-By: Gerhard Stöbich <deb2001-github@yahoo.de>
This commit is contained in:
Tim Perry 2025-08-21 12:18:10 +01:00 committed by GitHub
parent 44d9e6d8ad
commit b5e8247339
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 204 additions and 35 deletions

View File

@ -1082,6 +1082,11 @@ changes:
pr-url: https://github.com/nodejs/node/pull/58313
description: Following the deprecation of priority signaling as of RFC 9113,
`weight` option is deprecated.
- version:
- v24.0.0
- v22.17.0
pr-url: https://github.com/nodejs/node/pull/57917
description: Allow passing headers in raw array format.
-->
* `headers` {HTTP/2 Headers Object|Array}
@ -1856,6 +1861,10 @@ and will throw an error.
<!-- YAML
added: v8.4.0
changes:
- version:
- REPLACEME
pr-url: https://github.com/nodejs/node/pull/59455
description: Allow passing headers in raw array format.
- version:
- v14.5.0
- v12.19.0
@ -1863,7 +1872,7 @@ changes:
description: Allow explicitly setting date headers.
-->
* `headers` {HTTP/2 Headers Object}
* `headers` {HTTP/2 Headers Object|Array}
* `options` {Object}
* `endStream` {boolean} Set to `true` to indicate that the response will not
include payload data.

View File

@ -2541,8 +2541,31 @@ function callStreamClose(stream) {
stream.close();
}
function processHeaders(oldHeaders, options) {
assertIsObject(oldHeaders, 'headers');
function prepareResponseHeaders(stream, headersParam, options) {
let headers;
let statusCode;
if (ArrayIsArray(headersParam)) {
({
headers,
statusCode,
} = prepareResponseHeadersArray(headersParam, options));
stream[kRawHeaders] = headers;
} else {
({
headers,
statusCode,
} = prepareResponseHeadersObject(headersParam, options));
stream[kSentHeaders] = headers;
}
const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse);
return { headers, headersList, statusCode };
}
function prepareResponseHeadersObject(oldHeaders, options) {
assertIsObject(oldHeaders, 'headers', ['Object', 'Array']);
const headers = { __proto__: null };
if (oldHeaders !== null && oldHeaders !== undefined) {
@ -2563,6 +2586,44 @@ function processHeaders(oldHeaders, options) {
headers[HTTP2_HEADER_DATE] ??= utcDate();
}
validatePreparedResponseHeaders(headers, statusCode);
return {
headers,
statusCode: headers[HTTP2_HEADER_STATUS],
};
}
function prepareResponseHeadersArray(headers, options) {
let statusCode;
let isDateSet = false;
for (let i = 0; i < headers.length; i += 2) {
const header = headers[i].toLowerCase();
const value = headers[i + 1];
if (header === HTTP2_HEADER_STATUS) {
statusCode = value | 0;
} else if (header === HTTP2_HEADER_DATE) {
isDateSet = true;
}
}
if (!statusCode) {
statusCode = HTTP_STATUS_OK;
headers.unshift(HTTP2_HEADER_STATUS, statusCode);
}
if (!isDateSet && (options.sendDate == null || options.sendDate)) {
headers.push(HTTP2_HEADER_DATE, utcDate());
}
validatePreparedResponseHeaders(headers, statusCode);
return { headers, statusCode };
}
function validatePreparedResponseHeaders(headers, statusCode) {
// This is intentionally stricter than the HTTP/1 implementation, which
// allows values between 100 and 999 (inclusive) in order to allow for
// backwards compatibility with non-spec compliant code. With HTTP/2,
@ -2570,16 +2631,13 @@ function processHeaders(oldHeaders, options) {
// This will have an impact on the compatibility layer for anyone using
// non-standard, non-compliant status codes.
if (statusCode < 200 || statusCode > 599)
throw new ERR_HTTP2_STATUS_INVALID(headers[HTTP2_HEADER_STATUS]);
throw new ERR_HTTP2_STATUS_INVALID(statusCode);
const neverIndex = headers[kSensitiveHeaders];
if (neverIndex !== undefined && !ArrayIsArray(neverIndex))
throw new ERR_INVALID_ARG_VALUE('headers[http2.neverIndex]', neverIndex);
return headers;
}
function onFileUnpipe() {
const stream = this.sink[kOwner];
if (stream.ownsFd)
@ -2882,7 +2940,7 @@ class ServerHttp2Stream extends Http2Stream {
}
// Initiate a response on this Http2Stream
respond(headers, options) {
respond(headersParam, options) {
if (this.destroyed || this.closed)
throw new ERR_HTTP2_INVALID_STREAM();
if (this.headersSent)
@ -2907,15 +2965,16 @@ class ServerHttp2Stream extends Http2Stream {
state.flags |= STREAM_FLAGS_HAS_TRAILERS;
}
headers = processHeaders(headers, options);
const headersList = buildNgHeaderString(headers, assertValidPseudoHeaderResponse);
this[kSentHeaders] = headers;
const {
headers,
headersList,
statusCode,
} = prepareResponseHeaders(this, headersParam, options);
state.flags |= STREAM_FLAGS_HEADERS_SENT;
// Close the writable side if the endStream option is set or status
// is one of known codes with no payload, or it's a head request
const statusCode = headers[HTTP2_HEADER_STATUS] | 0;
if (!!options.endStream ||
statusCode === HTTP_STATUS_NO_CONTENT ||
statusCode === HTTP_STATUS_RESET_CONTENT ||
@ -2945,7 +3004,7 @@ class ServerHttp2Stream extends Http2Stream {
// regular file, here the fd is passed directly. If the underlying
// mechanism is not able to read from the fd, then the stream will be
// reset with an error code.
respondWithFD(fd, headers, options) {
respondWithFD(fd, headersParam, options) {
if (this.destroyed || this.closed)
throw new ERR_HTTP2_INVALID_STREAM();
if (this.headersSent)
@ -2982,8 +3041,11 @@ class ServerHttp2Stream extends Http2Stream {
this[kUpdateTimer]();
this.ownsFd = false;
headers = processHeaders(headers, options);
const statusCode = headers[HTTP2_HEADER_STATUS] |= 0;
const {
headers,
statusCode,
} = prepareResponseHeadersObject(headersParam, options);
// Payload/DATA frames are not permitted in these cases
if (statusCode === HTTP_STATUS_NO_CONTENT ||
statusCode === HTTP_STATUS_RESET_CONTENT ||
@ -3011,7 +3073,7 @@ class ServerHttp2Stream extends Http2Stream {
// giving the user an opportunity to verify the details and set additional
// headers. If statCheck returns false, the operation is aborted and no
// file details are sent.
respondWithFile(path, headers, options) {
respondWithFile(path, headersParam, options) {
if (this.destroyed || this.closed)
throw new ERR_HTTP2_INVALID_STREAM();
if (this.headersSent)
@ -3042,8 +3104,11 @@ class ServerHttp2Stream extends Http2Stream {
this[kUpdateTimer]();
this.ownsFd = true;
headers = processHeaders(headers, options);
const statusCode = headers[HTTP2_HEADER_STATUS] |= 0;
const {
headers,
statusCode,
} = prepareResponseHeadersObject(headersParam, options);
// Payload/DATA frames are not permitted in these cases
if (statusCode === HTTP_STATUS_NO_CONTENT ||
statusCode === HTTP_STATUS_RESET_CONTENT ||

View File

@ -690,7 +690,6 @@ function prepareRequestHeadersArray(headers, session) {
const headersList = buildNgHeaderString(
rawHeaders,
assertValidPseudoHeader,
headers[kSensitiveHeaders],
);
return {
@ -755,14 +754,14 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE);
* @returns {[string, number]}
*/
function buildNgHeaderString(arrayOrMap,
assertValuePseudoHeader = assertValidPseudoHeader,
sensitiveHeaders = arrayOrMap[kSensitiveHeaders]) {
assertValuePseudoHeader = assertValidPseudoHeader) {
let headers = '';
let pseudoHeaders = '';
let count = 0;
const singles = new SafeSet();
const neverIndex = (sensitiveHeaders || emptyArray).map((v) => v.toLowerCase());
const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray;
const neverIndex = sensitiveHeaders.map((v) => v.toLowerCase());
function processHeader(key, value) {
key = key.toLowerCase();

View File

@ -0,0 +1,76 @@
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
const assert = require('assert');
const http2 = require('http2');
{
const server = http2.createServer();
server.on('stream', common.mustCall((stream, _headers, _flags, rawHeaders) => {
assert.deepStrictEqual(rawHeaders, [
':method', 'GET',
':authority', `localhost:${server.address().port}`,
':scheme', 'http',
':path', '/',
'a', 'b',
'x-foo', 'bar', // Lowercased as required for HTTP/2
'a', 'c', // Duplicate header order preserved
]);
stream.respond([
'x', '1',
'x-FOO', 'bar',
'x', '2',
]);
assert.partialDeepStrictEqual(stream.sentHeaders, {
'__proto__': null,
':status': 200,
'x': [ '1', '2' ],
'x-FOO': 'bar',
});
assert.strictEqual(typeof stream.sentHeaders.date, 'string');
stream.end();
}));
server.listen(0, common.mustCall(() => {
const port = server.address().port;
const client = http2.connect(`http://localhost:${port}`);
const req = client.request([
'a', 'b',
'x-FOO', 'bar',
'a', 'c',
]).end();
assert.deepStrictEqual(req.sentHeaders, {
'__proto__': null,
':path': '/',
':scheme': 'http',
':authority': `localhost:${server.address().port}`,
':method': 'GET',
'a': [ 'b', 'c' ],
'x-FOO': 'bar',
});
req.on('response', common.mustCall((_headers, _flags, rawHeaders) => {
assert.strictEqual(rawHeaders.length, 10);
assert.deepStrictEqual(rawHeaders.slice(0, 8), [
':status', '200',
'x', '1',
'x-foo', 'bar', // Lowercased as required for HTTP/2
'x', '2', // Duplicate header order preserved
]);
assert.strictEqual(rawHeaders[8], 'date');
assert.strictEqual(typeof rawHeaders[9], 'string');
client.close();
server.close();
}));
}));
}

View File

@ -8,19 +8,33 @@ const http2 = require('http2');
{
const server = http2.createServer();
server.on('stream', common.mustCall((stream, headers, flags, rawHeaders) => {
server.on('stream', common.mustCall((stream, _headers, _flags, rawHeaders) => {
assert.deepStrictEqual(rawHeaders, [
':path', '/foobar',
':scheme', 'http',
':authority', `localhost:${server.address().port}`,
':method', 'GET',
':authority', `test.invalid:${server.address().port}`,
':method', 'POST',
'a', 'b',
'x-foo', 'bar',
'a', 'c',
'x-foo', 'bar', // Lowercased as required for HTTP/2
'a', 'c', // Duplicate header order preserved
]);
stream.respond({
':status': 200
stream.respond([
':status', '404',
'x', '1',
'x-FOO', 'bar',
'x', '2',
'DATE', '0000',
]);
assert.deepStrictEqual(stream.sentHeaders, {
'__proto__': null,
':status': '404',
'x': [ '1', '2' ],
'x-FOO': 'bar',
'DATE': '0000',
});
stream.end();
}));
@ -32,8 +46,8 @@ const http2 = require('http2');
const req = client.request([
':path', '/foobar',
':scheme', 'http',
':authority', `localhost:${server.address().port}`,
':method', 'GET',
':authority', `test.invalid:${server.address().port}`,
':method', 'POST',
'a', 'b',
'x-FOO', 'bar',
'a', 'c',
@ -43,14 +57,20 @@ const http2 = require('http2');
'__proto__': null,
':path': '/foobar',
':scheme': 'http',
':authority': `localhost:${server.address().port}`,
':method': 'GET',
':authority': `test.invalid:${server.address().port}`,
':method': 'POST',
'a': [ 'b', 'c' ],
'x-FOO': 'bar',
});
req.on('response', common.mustCall((headers) => {
assert.strictEqual(headers[':status'], 200);
req.on('response', common.mustCall((_headers, _flags, rawHeaders) => {
assert.deepStrictEqual(rawHeaders, [
':status', '404',
'x', '1',
'x-foo', 'bar', // Lowercased as required for HTTP/2
'x', '2', // Duplicate header order preserved
'date', '0000', // Server doesn't automatically set its own value
]);
client.close();
server.close();
}));