From cad77c02edf20becbb17434de3964d98d59efd34 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 11 Oct 2026 19:58:46 -0400 Subject: [PATCH] http: speed up rendering of object headers Iterate the headers object passed to writeHead() with ObjectKeys() instead of for...in plus ObjectHasOwn(). Both yield the same own enumerable string keys in the same order, but an object with many headers is in dictionary mode, where for...in re-checks every key with extra hash lookups. matchHeader() now returns early unless a header name has the length and case-folded first letter of a field that affects message framing, which skips the toLowerCase() call for most other names. Names that reach it are validated tokens, so this cannot miss a match. Assisted-by: Grok Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 39 ++- .../test-http-write-head-object-headers.js | 222 ++++++++++++++++++ 2 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-http-write-head-object-headers.js diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 2ca455eb5566..115b641eae9c 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -26,7 +26,6 @@ const { ArrayIsArray, MathFloor, ObjectDefineProperty, - ObjectHasOwn, ObjectKeys, ObjectSetPrototypeOf, ObjectValues, @@ -534,10 +533,14 @@ function _storeHeader(firstLine, headers) { } } } else { - for (const key in headers) { - if (ObjectHasOwn(headers, key)) { - processHeader(this, state, key, headers[key], true, lenient); - } + // ObjectKeys() yields the same own enumerable keys, in the same order, + // as for...in filtered by ObjectHasOwn(). Objects with many headers are + // in dictionary mode, where for...in re-checks every key with extra + // hash lookups. + const keys = ObjectKeys(headers); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + processHeader(this, state, key, headers[key], true, lenient); } } } @@ -713,8 +716,30 @@ function lowerOutgoingHeaderName(field) { } function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) - return; + // Header names are validated tokens (ASCII), so a name can only match one + // of the fields below if its length and case-folded first letter do. This + // skips the toLowerCase() allocation for most other headers. + switch (field.length) { + case 4: // Date + case 6: // Expect + case 7: // Trailer + case 10: // Connection, Keep-Alive + case 14: // Content-Length + case 17: // Transfer-Encoding + break; + default: + return; + } + switch (field.charCodeAt(0) | 0x20) { + case 0x63: // C + case 0x64: // D + case 0x65: // E + case 0x6b: // K + case 0x74: // T + break; + default: + return; + } field = lowerOutgoingHeaderName(field); switch (field) { case 'connection': diff --git a/test/parallel/test-http-write-head-object-headers.js b/test/parallel/test-http-write-head-object-headers.js new file mode 100644 index 000000000000..07cc1ef84549 --- /dev/null +++ b/test/parallel/test-http-write-head-object-headers.js @@ -0,0 +1,222 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// Verify how writeHead() renders a plain headers object, and that the fields +// that drive message framing are recognized in any casing while other names +// are emitted verbatim. + +function createResponse({ sendDate = false, keepAliveTimeout = 0 } = {}) { + const res = new http.ServerResponse({ + method: 'GET', + httpVersionMajor: 1, + httpVersionMinor: 1, + }); + res.sendDate = sendDate; + res._keepAliveTimeout = keepAliveTimeout; + return res; +} + +function render(headers, options) { + const res = createResponse(options); + res.writeHead(200, headers); + return res; +} + +function headerLines(res) { + const lines = res._header.split('\r\n'); + assert.strictEqual(lines.shift(), 'HTTP/1.1 200 OK'); + // The header block ends with an empty line. + assert.deepStrictEqual(lines.splice(-2), ['', '']); + return lines; +} + +// Only own enumerable string keys are emitted, in Object.keys() order. +{ + const headers = { __proto__: { inherited: 'no' } }; + headers['x-b'] = '1'; + headers['x-a'] = '2'; + headers[42] = '3'; + Object.defineProperty(headers, 'hidden', { value: 'no', enumerable: false }); + headers[Symbol('symbol')] = 'no'; + + assert.deepStrictEqual(headerLines(render(headers)), [ + '42: 3', + 'x-b: 1', + 'x-a: 2', + 'Connection: keep-alive', + 'Transfer-Encoding: chunked', + ]); +} + +// Null-prototype objects work, and accessors are read exactly once. +{ + let reads = 0; + const headers = { __proto__: null }; + headers['x-a'] = '1'; + Object.defineProperty(headers, 'x-getter', { + enumerable: true, + get() { + reads++; + return 'v'; + }, + }); + + assert.deepStrictEqual(headerLines(render(headers)), [ + 'x-a: 1', + 'x-getter: v', + 'Connection: keep-alive', + 'Transfer-Encoding: chunked', + ]); + assert.strictEqual(reads, 1); +} + +// An object with many headers keeps their order, and a framing header among +// them is still honored. +{ + const headers = {}; + const expected = []; + for (let i = 0; i < 600; i++) { + if (i === 300) { + headers['Content-Length'] = '0'; + expected.push('Content-Length: 0'); + } + headers[`x-h${i}`] = `v${i}`; + expected.push(`x-h${i}: v${i}`); + } + + const res = render(headers); + assert.deepStrictEqual(headerLines(res), + [...expected, 'Connection: keep-alive']); + assert.strictEqual(res.chunkedEncoding, false); +} + +// Framing fields are recognized in any casing. +{ + const res = render({ 'CONTENT-LENGTH': '5' }); + assert.deepStrictEqual(headerLines(res), + ['CONTENT-LENGTH: 5', 'Connection: keep-alive']); + assert.strictEqual(res.chunkedEncoding, false); +} +{ + const res = render({ 'tRaNsFeR-eNcOdInG': 'chunked' }); + assert.deepStrictEqual(headerLines(res), + ['tRaNsFeR-eNcOdInG: chunked', 'Connection: keep-alive']); + assert.strictEqual(res.chunkedEncoding, true); +} +{ + const res = render({ 'cOnNeCtIoN': 'close' }); + assert.deepStrictEqual(headerLines(res), + ['cOnNeCtIoN: close', 'Transfer-Encoding: chunked']); + assert.strictEqual(res._last, true); +} +{ + // A user supplied Date suppresses the automatic one. + const res = render({ 'DATE': 'x' }, { sendDate: true }); + assert.deepStrictEqual(headerLines(res), [ + 'DATE: x', + 'Connection: keep-alive', + 'Transfer-Encoding: chunked', + ]); +} +{ + // A user supplied Keep-Alive suppresses the automatic one. + const res = render({ 'kEeP-aLiVe': 'timeout=1' }, { keepAliveTimeout: 5000 }); + assert.deepStrictEqual(headerLines(res), [ + 'kEeP-aLiVe: timeout=1', + 'Connection: keep-alive', + 'Transfer-Encoding: chunked', + ]); +} +{ + // Trailers require chunked encoding. + assert.throws(() => render({ 'TRAILER': 'x', 'Content-Length': '1' }), { + code: 'ERR_HTTP_TRAILER_INVALID', + }); +} +{ + // Expect flushes the header right away. There is no socket here, so it is + // queued on the response. + assert.strictEqual(render({ 'eXpEcT': '100-continue' }).outputData.length, 1); +} + +// Names that share a framing field's length and first letter, or only its +// length, are emitted verbatim and do not affect framing. +{ + const res = render({ + 'Content-Lengtx': '5', + 'Xontent-Length': '5', + 'Connectiom': 'close', + 'Transfer-Encodinx': 'identity', + 'Dat3': 'x', + 'Expecx': '100-continue', + 'Trailes': 'x', + 'Keep-Alivx': 'timeout=1', + }, { sendDate: true, keepAliveTimeout: 5000 }); + + const lines = headerLines(res); + assert.deepStrictEqual(lines.slice(0, 8), [ + 'Content-Lengtx: 5', + 'Xontent-Length: 5', + 'Connectiom: close', + 'Transfer-Encodinx: identity', + 'Dat3: x', + 'Expecx: 100-continue', + 'Trailes: x', + 'Keep-Alivx: timeout=1', + ]); + assert.match(lines[8], /^Date: .+ GMT$/); + assert.deepStrictEqual(lines.slice(9), [ + 'Connection: keep-alive', + 'Keep-Alive: timeout=5', + 'Transfer-Encoding: chunked', + ]); + assert.strictEqual(res.chunkedEncoding, true); + assert.strictEqual(res._last, false); + assert.strictEqual(res.outputData.length, 0); +} + +// The same rules apply to headers set through setHeader(). +{ + const res = createResponse(); + res.setHeader('CONTENT-LENGTH', '0'); + res.setHeader('Content-Lengtx', '1'); + res.writeHead(200); + assert.deepStrictEqual(headerLines(res), [ + 'CONTENT-LENGTH: 0', + 'Content-Lengtx: 1', + 'Connection: keep-alive', + ]); + assert.strictEqual(res.chunkedEncoding, false); +} + +// End to end: a response with many headers and an upper-case Content-Length. +{ + const headers = {}; + for (let i = 0; i < 600; i++) { + headers[`x-h${i}`] = `v${i}`; + } + headers['CONTENT-LENGTH'] = '5'; + + const server = http.createServer(common.mustCall((req, res) => { + res.writeHead(200, headers); + res.end('hello'); + })); + + server.listen(0, common.mustCall(() => { + http.get({ port: server.address().port }, common.mustCall((res) => { + assert.strictEqual(res.headers['x-h0'], 'v0'); + assert.strictEqual(res.headers['x-h599'], 'v599'); + assert.strictEqual(res.headers['content-length'], '5'); + assert.strictEqual(res.headers['transfer-encoding'], undefined); + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => body += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(body, 'hello'); + server.close(); + })); + })); + })); +}