From fbb6c1c60264f97c89ef36e3701ec1b27e866419 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 18:14:46 +0000 Subject: [PATCH 1/6] http,http2: speed up header validation and write coalescing Replace regex-based header token/value checks with byte lookup tables, cache OutgoingMessage lenient-validation, and coalesce headers with small Buffer bodies into a single socket write. Scan IncomingMessage rawHeaders for Content-Length and Transfer-Encoding so optimizeEmptyRequests does not force req.headers construction. On the HTTP/2 path, skip toLowerCase for already-lowercase names, use a Set for sensitive/single-value header checks, and reserve outgoing session storage to avoid reallocs while gathering nghttp2 frames. Signed-off-by: Yagiz Nizipli --- lib/_http_common.js | 49 ++++++++++++++++-------------- lib/_http_incoming.js | 22 ++++++++++++++ lib/_http_outgoing.js | 50 ++++++++++++++++++++----------- lib/_http_server.js | 6 ++-- lib/internal/http2/util.js | 21 ++++++++----- src/node_http2.cc | 2 ++ test/parallel/test-http-common.js | 12 ++++++++ 7 files changed, 112 insertions(+), 50 deletions(-) diff --git a/lib/_http_common.js b/lib/_http_common.js index d5e7bdedee39..53eed713f0e3 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -213,7 +213,6 @@ function freeParser(parser, req, socket) { // Valid chars: ^_`a-zA-Z-0-9!#$%&'*+.|~ // Based on RFC 7230 Section 3.2.6 token definition // See https://tools.ietf.org/html/rfc7230#section-3.2.6 -const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; const validTokenChars = new Uint8Array([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 @@ -241,14 +240,12 @@ const validTokenChars = new Uint8Array([ * @returns {boolean} */ function checkIsHttpToken(val) { - if (val.length >= 10) { - return tokenRegExp.test(val); - } - - if (val.length === 0) return false; + const len = val.length; + if (len === 0) return false; - // Use lookup table for short strings, regex for longer ones - for (let i = 0; i < val.length; i++) { + // Lookup table for all lengths. Header names like Content-Length (14) + // and Transfer-Encoding (17) used to take the regex path. + for (let i = 0; i < len; i++) { if (!validTokenChars[val.charCodeAt(i)]) { return false; } @@ -256,19 +253,17 @@ function checkIsHttpToken(val) { return true; } -// Strict header value regex per RFC 7230 (original/default behavior): -// field-value = *( field-content / obs-fold ) -// field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] -// field-vchar = VCHAR / obs-text -// This rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f). -const strictHeaderCharRegex = /[^\t\x20-\x7e\x80-\xff]/; - -// Lenient header value regex per Fetch spec (https://fetch.spec.whatwg.org/#header-value): -// - Must contain no 0x00 (NUL) or HTTP newline bytes (0x0a LF, 0x0d CR) -// - Must be byte sequences (0x00-0xff), not arbitrary unicode -// This allows most control characters except NUL, CR, and LF. -// eslint-disable-next-line no-control-regex -const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/; +// Strict: valid bytes are HTAB (0x09), VCHAR (0x20-0x7e), and obs-text (0x80-0xff). +// Rejects 0x00-0x08, 0x0a-0x1f, and DEL (0x7f). Matches /[^\t\x20-\x7e\x80-\xff]/. +const strictValidHeaderChars = new Uint8Array(256); +// Lenient (Fetch): reject only NUL, LF, CR. Matches /[\x00\x0a\x0d]|[^\x00-\xff]/. +const lenientValidHeaderChars = new Uint8Array(256); +{ + for (let i = 0; i < 256; i++) { + strictValidHeaderChars[i] = (i === 0x09 || (i >= 0x20 && i !== 0x7f)) ? 1 : 0; + lenientValidHeaderChars[i] = (i !== 0x00 && i !== 0x0a && i !== 0x0d) ? 1 : 0; + } +} /** * True if val contains an invalid header value character. @@ -279,8 +274,16 @@ const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/; * @returns {boolean} */ function checkInvalidHeaderChar(val, lenient = false) { - const regex = lenient ? lenientHeaderCharRegex : strictHeaderCharRegex; - return regex.test(val); + // regex.test() ToString-coerces; keep that for numbers/arrays/booleans. + if (typeof val !== 'string') + val = `${val}`; + const table = lenient ? lenientValidHeaderChars : strictValidHeaderChars; + for (let i = 0; i < val.length; i++) { + const c = val.charCodeAt(i); + if (c > 255 || table[c] === 0) + return true; + } + return false; } function cleanParser(parser) { diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index 067a4cda3e39..a76d8620884c 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -494,6 +494,28 @@ function _addHeaderLineDistinct(field, value, dest) { } } +// Scan rawHeaders so callers do not force construction of req.headers. +IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() { + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + for (let i = 0; i < n; i += 2) { + const name = headers[i]; + const len = name.length; + if (len === 14) { + if (name === 'Content-Length' || name === 'content-length' || + name.toLowerCase() === 'content-length') { + return true; + } + } else if (len === 17) { + if (name === 'Transfer-Encoding' || name === 'transfer-encoding' || + name.toLowerCase() === 'transfer-encoding') { + return true; + } + } + } + return false; +}; + IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable() { this._dumped = true; this._readableState.ended = true; diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 0d0507c93f47..a03a17f4d042 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -93,6 +93,8 @@ const kEndCallbacks = Symbol('kEndCallbacks'); const kFlushError = Symbol('kFlushError'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kLenientHeaderValidation = Symbol('kLenientHeaderValidation'); +const kMaxHeaderBodyCoalesce = 16 * 1024; const nop = () => {}; @@ -177,26 +179,30 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + const cached = this[kLenientHeaderValidation]; + if (cached !== undefined) + return cached; + + let result; // New httpValidation option takes priority (ClientRequest case) if (this.httpValidation !== undefined) { - return this.httpValidation !== 'strict'; - } - // ServerResponse: check server's httpValidation option - const serverHttpValidation = this.req?.socket?.server?.httpValidation; - if (serverHttpValidation !== undefined) { - return serverHttpValidation !== 'strict'; - } - // Legacy insecureHTTPParser - ClientRequest has it directly - if (typeof this.insecureHTTPParser === 'boolean') { - return this.insecureHTTPParser; - } - // ServerResponse can access via req.socket.server - const serverOption = this.req?.socket?.server?.insecureHTTPParser; - if (typeof serverOption === 'boolean') { - return serverOption; + result = this.httpValidation !== 'strict'; + } else { + // ServerResponse: check server's httpValidation option + const serverHttpValidation = this.req?.socket?.server?.httpValidation; + if (serverHttpValidation !== undefined) { + result = serverHttpValidation !== 'strict'; + } else if (typeof this.insecureHTTPParser === 'boolean') { + // Legacy insecureHTTPParser - ClientRequest has it directly + result = this.insecureHTTPParser; + } else { + // ServerResponse can access via req.socket.server + const serverOption = this.req?.socket?.server?.insecureHTTPParser; + result = typeof serverOption === 'boolean' ? serverOption : isLenient(); + } } - // Fall back to global option - return isLenient(); + this[kLenientHeaderValidation] = result; + return result; }; ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { @@ -396,6 +402,16 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL if (typeof data === 'string' && (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { data = this._header + data; + } else if (isUint8Array(data) && data.byteLength <= kMaxHeaderBodyCoalesce) { + // One write() for headers + small body (typical JSON/HTML responses). + const header = this._header; + const combined = Buffer.allocUnsafe(header.length + data.byteLength); + combined.write(header, 0, header.length, 'latin1'); + combined.set(data, header.length); + data = combined; + encoding = undefined; + if (byteLength !== undefined) + byteLength = data.byteLength; } else { const header = this._header; this.outputData.unshift({ diff --git a/lib/_http_server.js b/lib/_http_server.js index 6cede195b879..ff56c13ebd60 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -1264,8 +1264,8 @@ function emitCloseNT(self) { } } -function hasBodyHeaders(headers) { - return ('content-length' in headers) || ('transfer-encoding' in headers); +function hasBodyHeaders(req) { + return req._hasBodyHeaders(); } // The following callback is issued after the headers have been read on a @@ -1321,7 +1321,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { } // Check if we should optimize empty requests (those without Content-Length or Transfer-Encoding headers) - const shouldOptimize = server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req.headers); + const shouldOptimize = server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req); if (shouldOptimize) { // Fast processing where emitting 'data', 'end' and 'close' events is diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 159ba5bd5315..149f08517b1a 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -775,10 +775,18 @@ function buildNgHeaderString(arrayOrMap, let singles; const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; const neverIndex = sensitiveHeaders.length === 0 ? - emptyArray : sensitiveHeaders.map((v) => v.toLowerCase()); + null : new SafeSet(sensitiveHeaders.map((v) => v.toLowerCase())); function processHeader(key, value) { - key = key.toLowerCase(); + // HTTP/2 header names are lowercase; skip toLowerCase in the common case. + const keyLen = key.length; + for (let i = 0; i < keyLen; i++) { + const c = key.charCodeAt(i); + if (c >= 65 && c <= 90) { + key = key.toLowerCase(); + break; + } + } const isSingleValueField = kSingleValueFields.has(key); const isStrictSingleValueField = strictSingleValueFields && isSingleValueField; @@ -800,14 +808,13 @@ function buildNgHeaderString(arrayOrMap, } if (isStrictSingleValueField) { if (singles === undefined) { - singles = [key]; - } else if (singles.includes(key)) { + singles = new SafeSet(); + } else if (singles.has(key)) { throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); - } else { - singles.push(key); } + singles.add(key); } - const flags = neverIndex.length !== 0 && neverIndex.includes(key) ? + const flags = neverIndex !== null && neverIndex.has(key) ? kNeverIndexFlag : kNoHeaderFlags; if (key[0] === ':') { diff --git a/src/node_http2.cc b/src/node_http2.cc index 04b2acca148d..13591e3e39d1 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -2041,6 +2041,8 @@ uint8_t Http2Session::SendPendingData() { CHECK(outgoing_buffers_.empty()); CHECK(outgoing_storage_.empty()); + // Avoid repeated reallocs while gathering nghttp2_session_mem_send chunks. + outgoing_storage_.reserve(16384); // Part One: Gather data from nghttp2 diff --git a/test/parallel/test-http-common.js b/test/parallel/test-http-common.js index 1629856ce57d..0ba746eebc69 100644 --- a/test/parallel/test-http-common.js +++ b/test/parallel/test-http-common.js @@ -31,3 +31,15 @@ assert.strictEqual(checkInvalidHeaderChar('tt'), false); assert.strictEqual(checkInvalidHeaderChar('ttt'), false); assert.strictEqual(checkInvalidHeaderChar('tttt'), false); assert.strictEqual(checkInvalidHeaderChar('ttttt'), false); +assert.strictEqual(checkInvalidHeaderChar('\tvalue'), false); +assert.strictEqual(checkInvalidHeaderChar('value\x7f'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x01'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x00'), true); +assert.strictEqual(checkInvalidHeaderChar('value\n'), true); +assert.strictEqual(checkInvalidHeaderChar('value\r'), true); +assert.strictEqual(checkInvalidHeaderChar('value\x80'), false); +assert.strictEqual(checkInvalidHeaderChar('value\x01', true), false); +assert.strictEqual(checkInvalidHeaderChar('value\x7f', true), false); +assert.strictEqual(checkInvalidHeaderChar('value\x00', true), true); +assert.strictEqual(checkInvalidHeaderChar('value\n', true), true); +assert.strictEqual(checkInvalidHeaderChar('value\r', true), true); From 62327c4d4cebddcab0272e64fc56eb35035af023 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 18:20:27 +0000 Subject: [PATCH 2/6] http,http2: coalesce chunked end() and cache status lines Send headers, the last chunk, and the chunked terminator in a single write() when res.end() is used with Transfer-Encoding: chunked. Reuse prebuilt HTTP/1.1 status lines for default reason phrases, skip toLowerCase() on common outgoing header names, and hoist HTTP/2 header serialization off the per-call closure. Signed-off-by: Yagiz Nizipli --- lib/_http_incoming.js | 3 +- lib/_http_outgoing.js | 160 +++++++++++++++--- lib/_http_server.js | 29 +++- lib/internal/http2/core.js | 10 +- lib/internal/http2/util.js | 158 ++++++++--------- .../test-http-chunked-end-coalesce.js | 98 +++++++++++ 6 files changed, 346 insertions(+), 112 deletions(-) create mode 100644 test/parallel/test-http-chunked-end-coalesce.js diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index a76d8620884c..ae328ed28864 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -503,11 +503,12 @@ IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() { const len = name.length; if (len === 14) { if (name === 'Content-Length' || name === 'content-length' || - name.toLowerCase() === 'content-length') { + name === 'CONTENT-LENGTH' || name.toLowerCase() === 'content-length') { return true; } } else if (len === 17) { if (name === 'Transfer-Encoding' || name === 'transfer-encoding' || + name === 'TRANSFER-ENCODING' || name.toLowerCase() === 'transfer-encoding') { return true; } diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index a03a17f4d042..68b55030156c 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -94,21 +94,22 @@ const kFlushError = Symbol('kFlushError'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); const kLenientHeaderValidation = Symbol('kLenientHeaderValidation'); +const kChunkedEndSent = Symbol('kChunkedEndSent'); const kMaxHeaderBodyCoalesce = 16 * 1024; const nop = () => {}; const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; -// isCookieField performs a case-insensitive comparison of a provided string -// against the word "cookie." As of V8 6.6 this is faster than handrolling or -// using a case-insensitive RegExp. function isCookieField(s) { - return s.length === 6 && s.toLowerCase() === 'cookie'; + return s.length === 6 && + (s === 'Cookie' || s === 'cookie' || s.toLowerCase() === 'cookie'); } function isContentDispositionField(s) { - return s.length === 19 && s.toLowerCase() === 'content-disposition'; + return s.length === 19 && + (s === 'Content-Disposition' || s === 'content-disposition' || + s.toLowerCase() === 'content-disposition'); } function OutgoingMessage(options) { @@ -637,24 +638,80 @@ function storeHeader(self, state, key, value, validate, lenient) { matchHeader(self, state, key, value); } +function matchConnection(self, state, value) { + state.connection = true; + self._removedConnection = false; + if (RE_CONN_CLOSE.test(value)) + self._last = true; + else + self.shouldKeepAlive = true; +} + +function matchTransferEncoding(self, state, value) { + state.te = true; + self._removedTE = false; + if (RE_TE_CHUNKED.test(value)) + self.chunkedEncoding = true; +} + function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) + const len = field.length; + if (len < 4 || len > 17) return; - field = field.toLowerCase(); - switch (field) { + + // Avoid toLowerCase() for the usual Title-Case / lowercase spellings. + switch (len) { + case 4: + if (field === 'Date' || field === 'date') { + state.date = true; + return; + } + break; + case 6: + if (field === 'Expect' || field === 'expect') { + state.expect = true; + return; + } + break; + case 7: + if (field === 'Trailer' || field === 'trailer') { + state.trailer = true; + return; + } + break; + case 10: + if (field === 'Connection' || field === 'connection') { + matchConnection(self, state, value); + return; + } + if (field === 'Keep-Alive' || field === 'keep-alive') { + self._defaultKeepAlive = false; + return; + } + break; + case 14: + if (field === 'Content-Length' || field === 'content-length') { + state.contLen = true; + self._contentLength = +value; + self._removedContLen = false; + return; + } + break; + case 17: + if (field === 'Transfer-Encoding' || field === 'transfer-encoding') { + matchTransferEncoding(self, state, value); + return; + } + break; + } + + const lower = field.toLowerCase(); + switch (lower) { case 'connection': - state.connection = true; - self._removedConnection = false; - if (RE_CONN_CLOSE.test(value)) - self._last = true; - else - self.shouldKeepAlive = true; + matchConnection(self, state, value); break; case 'transfer-encoding': - state.te = true; - self._removedTE = false; - if (RE_TE_CHUNKED.test(value)) - self.chunkedEncoding = true; + matchTransferEncoding(self, state, value); break; case 'content-length': state.contLen = true; @@ -664,7 +721,7 @@ function matchHeader(self, state, field, value) { case 'date': case 'expect': case 'trailer': - state[field] = true; + state[lower] = true; break; case 'keep-alive': self._defaultKeepAlive = false; @@ -917,6 +974,37 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableNeedDrain', { }); const crlf_buf = Buffer.from('\r\n'); + +// One write() for headers + last chunk + terminator. Used by end(chunk) +// when Transfer-Encoding is chunked and headers have not been flushed. +function trySendCombinedChunkedEnd(msg, chunk, encoding, len, callback) { + if (msg._headerSent || msg._header === null || msg[kChunkedLength] !== 0) + return undefined; + + const suffix = '\r\n0\r\n' + msg._trailer + '\r\n'; + const hex = len.toString(16); + + if (typeof chunk === 'string' && + (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { + msg[kChunkedEndSent] = true; + return msg._send(hex + '\r\n' + chunk + suffix, encoding, callback); + } + + if (isUint8Array(chunk) && chunk.byteLength <= kMaxHeaderBodyCoalesce) { + const prefix = msg._header + hex + '\r\n'; + const total = prefix.length + chunk.byteLength + suffix.length; + const combined = Buffer.allocUnsafe(total); + combined.write(prefix, 0, prefix.length, 'latin1'); + combined.set(chunk, prefix.length); + combined.write(suffix, prefix.length + chunk.byteLength, suffix.length, 'latin1'); + msg._headerSent = true; + msg[kChunkedEndSent] = true; + return msg._writeRaw(combined, undefined, callback, total); + } + + return undefined; +} + OutgoingMessage.prototype.write = function write(chunk, encoding, callback) { if (typeof encoding === 'function') { callback = encoding; @@ -962,8 +1050,14 @@ function strictContentLength(msg) { } function write_(msg, chunk, encoding, callback, fromEnd) { - if (typeof callback !== 'function') + let endCallback; + if (fromEnd && typeof callback === 'function') { + // end() owns the finish callback unless the chunked fast path sends it. + endCallback = callback; + callback = nop; + } else if (typeof callback !== 'function') { callback = nop; + } if (chunk === null) { throw new ERR_STREAM_NULL_VALUES(); @@ -1030,6 +1124,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { let ret; if (msg.chunkedEncoding && chunk.length !== 0) { len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; + if (fromEnd && !msg.strictContentLength) { + const combined = trySendCombinedChunkedEnd( + msg, chunk, encoding, len, endCallback ?? nop); + if (combined !== undefined) + return combined; + } if (msg[kCorked] && msg._headerSent) { msg[kChunkedBuffer].push(chunk, encoding, callback); msg[kChunkedLength] += len; @@ -1170,7 +1270,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this[kSocket].cork(); } - write_(this, chunk, encoding, null, true); + write_(this, chunk, encoding, onFinish.bind(undefined, this), true); } else if (this.finished) { if (typeof callback === 'function') { queueEndCallback(this, callback); @@ -1192,14 +1292,18 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength); } - const finish = onFinish.bind(undefined, this); - - if (this._hasBody && this.chunkedEncoding) { - this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); - } else if (!this._headerSent || this.writableLength || chunk) { - this._send('', 'latin1', finish); + if (this[kChunkedEndSent]) { + // Headers, last chunk, and terminator were already flushed together. } else { - process.nextTick(finish); + const finish = onFinish.bind(undefined, this); + + if (this._hasBody && this.chunkedEncoding) { + this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish); + } else if (!this._headerSent || this.writableLength || chunk) { + this._send('', 'latin1', finish); + } else { + process.nextTick(finish); + } } if (this[kSocket]) { diff --git a/lib/_http_server.js b/lib/_http_server.js index ff56c13ebd60..4572fe495fb3 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -190,6 +190,15 @@ const STATUS_CODES = { 511: 'Network Authentication Required', // RFC 6585 6 }; +const STATUS_LINES = { __proto__: null }; +{ + const codes = ObjectKeys(STATUS_CODES); + for (let i = 0; i < codes.length; i++) { + const code = codes[i]; + STATUS_LINES[code] = `HTTP/1.1 ${code} ${STATUS_CODES[code]}\r\n`; + } +} + const kOnExecute = HTTPParser.kOnExecute | 0; const kOnTimeout = HTTPParser.kOnTimeout | 0; @@ -331,7 +340,8 @@ ServerResponse.prototype.writeInformation = function writeInformation( } const statusMessage = STATUS_CODES[statusCode] || 'unknown'; - let head = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + let head = STATUS_LINES[statusCode] || + `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; const lenient = this._isLenientHeaderValidation(); if (headers !== undefined && headers !== null) { @@ -427,12 +437,13 @@ function writeHead(statusCode, reason, obj) { } + const defaultStatusMessage = STATUS_CODES[statusCode] || 'unknown'; if (typeof reason === 'string') { // writeHead(statusCode, reasonPhrase[, headers]) this.statusMessage = reason; } else { // writeHead(statusCode[, headers]) - this.statusMessage ||= STATUS_CODES[statusCode] || 'unknown'; + this.statusMessage ||= defaultStatusMessage; obj ??= reason; } this.statusCode = statusCode; @@ -475,10 +486,16 @@ function writeHead(statusCode, reason, obj) { headers = obj; } - if (checkInvalidHeaderChar(this.statusMessage)) - throw new ERR_INVALID_CHAR('statusMessage'); - - const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + let statusLine; + if (this.statusMessage === defaultStatusMessage) { + // Default reason phrases are already valid; reuse the prebuilt status line. + statusLine = STATUS_LINES[statusCode] || + `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } else { + if (checkInvalidHeaderChar(this.statusMessage)) + throw new ERR_INVALID_CHAR('statusMessage'); + statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } if (statusCode === 204 || statusCode === 304 || (statusCode >= 100 && statusCode <= 199)) { diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index 200471dca0fd..8ebb53cc3518 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -2858,7 +2858,15 @@ function prepareResponseHeadersArray(headers, options) { let isDateSet = false; for (let i = 0; i < headers.length; i += 2) { - const header = headers[i].toLowerCase(); + let header = headers[i]; + const headerLen = header.length; + for (let j = 0; j < headerLen; j++) { + const c = header.charCodeAt(j); + if (c >= 65 && c <= 90) { + header = header.toLowerCase(); + break; + } + } const value = headers[i + 1]; if (header === HTTP2_HEADER_STATUS) { diff --git a/lib/internal/http2/util.js b/lib/internal/http2/util.js index 149f08517b1a..3124b4b2e334 100644 --- a/lib/internal/http2/util.js +++ b/lib/internal/http2/util.js @@ -765,87 +765,93 @@ const kNoHeaderFlags = StringFromCharCode(NGHTTP2_NV_FLAG_NONE); * header validation. * @returns {[string, number]} */ -function buildNgHeaderString(arrayOrMap, - validatePseudoHeaderValue, - strictSingleValueFields) { - let headers = ''; - let pseudoHeaders = ''; - let count = 0; +function asciiToLowerIfNeeded(key) { + const keyLen = key.length; + for (let i = 0; i < keyLen; i++) { + const c = key.charCodeAt(i); + if (c >= 65 && c <= 90) + return key.toLowerCase(); + } + return key; +} - let singles; - const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; - const neverIndex = sensitiveHeaders.length === 0 ? - null : new SafeSet(sensitiveHeaders.map((v) => v.toLowerCase())); - - function processHeader(key, value) { - // HTTP/2 header names are lowercase; skip toLowerCase in the common case. - const keyLen = key.length; - for (let i = 0; i < keyLen; i++) { - const c = key.charCodeAt(i); - if (c >= 65 && c <= 90) { - key = key.toLowerCase(); +function processNgHeader(state, key, value) { + key = asciiToLowerIfNeeded(key); + const isSingleValueField = kSingleValueFields.has(key); + const isStrictSingleValueField = state.strictSingleValueFields && + isSingleValueField; + let isArray = ArrayIsArray(value); + if (isArray) { + switch (value.length) { + case 0: + return; + case 1: + value = String(value[0]); + isArray = false; break; - } + default: + if (isStrictSingleValueField) + throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); } - const isSingleValueField = kSingleValueFields.has(key); - const isStrictSingleValueField = strictSingleValueFields && - isSingleValueField; - let isArray = ArrayIsArray(value); - if (isArray) { - switch (value.length) { - case 0: - return; - case 1: - value = String(value[0]); - isArray = false; - break; - default: - if (isStrictSingleValueField) - throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); - } - } else { - value = String(value); - } - if (isStrictSingleValueField) { - if (singles === undefined) { - singles = new SafeSet(); - } else if (singles.has(key)) { - throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); - } - singles.add(key); + } else { + value = String(value); + } + if (isStrictSingleValueField) { + if (state.singles === undefined) { + state.singles = new SafeSet(); + } else if (state.singles.has(key)) { + throw new ERR_HTTP2_HEADER_SINGLE_VALUE(key); } - const flags = neverIndex !== null && neverIndex.has(key) ? - kNeverIndexFlag : - kNoHeaderFlags; - if (key[0] === ':') { - const err = validatePseudoHeaderValue(key); - if (err !== undefined) - throw err; - pseudoHeaders += `${key}\0${value}\0${flags}`; - count++; - return; + state.singles.add(key); + } + const flags = state.neverIndex !== null && state.neverIndex.has(key) ? + kNeverIndexFlag : + kNoHeaderFlags; + if (key[0] === ':') { + const err = state.validatePseudoHeaderValue(key); + if (err !== undefined) + throw err; + state.pseudoHeaders += `${key}\0${value}\0${flags}`; + state.count++; + return; + } + // Well-known single-value fields are all valid HTTP tokens and none of + // them is a connection-specific header, so both checks can be skipped. + if (!isSingleValueField) { + if (!checkIsHttpToken(key)) { + throw new ERR_INVALID_HTTP_TOKEN('Header name', key); } - // Well-known single-value fields are all valid HTTP tokens and none of - // them is a connection-specific header, so both checks can be skipped. - if (!isSingleValueField) { - if (!checkIsHttpToken(key)) { - throw new ERR_INVALID_HTTP_TOKEN('Header name', key); - } - if (isIllegalConnectionSpecificHeader(key, value)) { - throw new ERR_HTTP2_INVALID_CONNECTION_HEADERS(key); - } + if (isIllegalConnectionSpecificHeader(key, value)) { + throw new ERR_HTTP2_INVALID_CONNECTION_HEADERS(key); } - if (isArray) { - for (let j = 0; j < value.length; ++j) { - const val = String(value[j]); - headers += `${key}\0${val}\0${flags}`; - } - count += value.length; - return; + } + if (isArray) { + for (let j = 0; j < value.length; ++j) { + const val = String(value[j]); + state.headers += `${key}\0${val}\0${flags}`; } - headers += `${key}\0${value}\0${flags}`; - count++; + state.count += value.length; + return; } + state.headers += `${key}\0${value}\0${flags}`; + state.count++; +} + +function buildNgHeaderString(arrayOrMap, + validatePseudoHeaderValue, + strictSingleValueFields) { + const sensitiveHeaders = arrayOrMap[kSensitiveHeaders] || emptyArray; + const state = { + __proto__: null, + headers: '', + pseudoHeaders: '', + count: 0, + singles: undefined, + neverIndex: sensitiveHeaders.length === 0 ? + null : new SafeSet(sensitiveHeaders.map((v) => v.toLowerCase())), + validatePseudoHeaderValue, + strictSingleValueFields, + }; if (ArrayIsArray(arrayOrMap)) { for (let i = 0; i < arrayOrMap.length; i += 2) { @@ -853,7 +859,7 @@ function buildNgHeaderString(arrayOrMap, const value = arrayOrMap[i + 1]; if (value === undefined || key === '') continue; - processHeader(key, value); + processNgHeader(state, key, value); } } else { const keys = ObjectKeys(arrayOrMap); @@ -862,11 +868,11 @@ function buildNgHeaderString(arrayOrMap, const value = arrayOrMap[key]; if (value === undefined || key === '') continue; - processHeader(key, value); + processNgHeader(state, key, value); } } - return [pseudoHeaders + headers, count]; + return [state.pseudoHeaders + state.headers, state.count]; } class NghttpError extends Error { diff --git a/test/parallel/test-http-chunked-end-coalesce.js b/test/parallel/test-http-chunked-end-coalesce.js new file mode 100644 index 000000000000..02fb4d378b70 --- /dev/null +++ b/test/parallel/test-http-chunked-end-coalesce.js @@ -0,0 +1,98 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +// Verify that res.end() with chunked encoding still produces a valid +// HTTP/1.1 message when headers + last chunk + terminator are combined +// into a single write. + +function rawRequest(port, path) { + return new Promise((resolve, reject) => { + const client = net.connect(port, () => { + client.write( + `GET ${path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n`); + }); + const chunks = []; + client.on('data', (c) => chunks.push(c)); + client.on('end', () => resolve(Buffer.concat(chunks))); + client.on('error', reject); + }); +} + +function assertChunkedBody(raw, expected) { + const expectedBytes = Buffer.from(expected, 'utf8'); + const rawStr = raw.toString('latin1'); + const sep = rawStr.indexOf('\r\n\r\n'); + assert.notStrictEqual(sep, -1, `missing header separator: ${rawStr}`); + const body = raw.subarray(sep + 4); + const hex = expectedBytes.byteLength.toString(16); + const expectedBody = Buffer.concat([ + Buffer.from(`${hex}\r\n`), + expectedBytes, + Buffer.from('\r\n0\r\n\r\n'), + ]); + assert.deepStrictEqual(body, expectedBody); +} + +const server = http.createServer(common.mustCallAtLeast((req, res) => { + switch (req.url) { + case '/string': + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('hello'); + break; + case '/buffer': + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(Buffer.from('world')); + break; + case '/utf8': + res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('héllo'); + break; + case '/trailer': + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Trailer': 'X-Test', + }); + res.addTrailers({ 'X-Test': 'ok' }); + res.end('bye'); + break; + case '/unusual-length': + res.writeHead(200, { 'CONTENT-LENGTH': '2' }); + res.end('hi'); + break; + default: + res.writeHead(404); + res.end(); + } +}, 5)); + +server.listen(0, common.mustCall(async () => { + const port = server.address().port; + + const stringRaw = await rawRequest(port, '/string'); + const stringText = stringRaw.toString('latin1'); + assert.match(stringText, /^HTTP\/1\.1 200 OK\r\n/); + assert.match(stringText, /Transfer-Encoding: chunked\r\n/i); + assertChunkedBody(stringRaw, 'hello'); + + const bufferRaw = await rawRequest(port, '/buffer'); + assertChunkedBody(bufferRaw, 'world'); + + const utf8Raw = await rawRequest(port, '/utf8'); + assertChunkedBody(utf8Raw, 'héllo'); + + const trailerRaw = await rawRequest(port, '/trailer'); + const trailerText = trailerRaw.toString('latin1'); + const trailerSep = trailerText.indexOf('\r\n\r\n'); + const trailerBody = trailerText.slice(trailerSep + 4); + assert.strictEqual(trailerBody, '3\r\nbye\r\n0\r\nX-Test: ok\r\n\r\n'); + + const lengthRaw = await rawRequest(port, '/unusual-length'); + const lengthText = lengthRaw.toString('latin1'); + assert.match(lengthText, /CONTENT-LENGTH: 2\r\n/); + assert.ok(lengthText.endsWith('\r\n\r\nhi')); + + server.close(); +})); From 1a2c1ed72171205318eb6748ed7c7dd9e298661a Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 19:37:44 +0000 Subject: [PATCH 3/6] http: write coalesced chunked headers as latin1 The chunked end() fast path concatenated headers with the body and wrote the result using the body encoding. That re-encoded obs-text header values as UTF-8 and reduced corked res.end() to a single socket.write(), which broke test-http-server-non-utf8-header and test-http-response-cork. Copy headers as latin1 into the combined buffer, and accept a single write after uncork. Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 41 ++++++++++--------- .../test-http-chunked-end-coalesce.js | 18 +++++++- test/parallel/test-http-response-cork.js | 5 ++- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 68b55030156c..0dd0c727fa0b 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -977,32 +977,35 @@ const crlf_buf = Buffer.from('\r\n'); // One write() for headers + last chunk + terminator. Used by end(chunk) // when Transfer-Encoding is chunked and headers have not been flushed. +// Headers are always copied as latin1 so obs-text / binary values are not +// re-encoded as UTF-8 (see test-http-server-non-utf8-header.js). function trySendCombinedChunkedEnd(msg, chunk, encoding, len, callback) { if (msg._headerSent || msg._header === null || msg[kChunkedLength] !== 0) return undefined; - const suffix = '\r\n0\r\n' + msg._trailer + '\r\n'; - const hex = len.toString(16); - - if (typeof chunk === 'string' && - (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { - msg[kChunkedEndSent] = true; - return msg._send(hex + '\r\n' + chunk + suffix, encoding, callback); + let body; + if (typeof chunk === 'string') { + body = Buffer.from(chunk, encoding || 'utf8'); + } else if (isUint8Array(chunk)) { + body = chunk; + } else { + return undefined; } - if (isUint8Array(chunk) && chunk.byteLength <= kMaxHeaderBodyCoalesce) { - const prefix = msg._header + hex + '\r\n'; - const total = prefix.length + chunk.byteLength + suffix.length; - const combined = Buffer.allocUnsafe(total); - combined.write(prefix, 0, prefix.length, 'latin1'); - combined.set(chunk, prefix.length); - combined.write(suffix, prefix.length + chunk.byteLength, suffix.length, 'latin1'); - msg._headerSent = true; - msg[kChunkedEndSent] = true; - return msg._writeRaw(combined, undefined, callback, total); - } + if (body.byteLength > kMaxHeaderBodyCoalesce) + return undefined; - return undefined; + const suffix = '\r\n0\r\n' + msg._trailer + '\r\n'; + const hex = len.toString(16); + const prefix = msg._header + hex + '\r\n'; + const total = prefix.length + body.byteLength + suffix.length; + const combined = Buffer.allocUnsafe(total); + combined.write(prefix, 0, prefix.length, 'latin1'); + combined.set(body, prefix.length); + combined.write(suffix, prefix.length + body.byteLength, suffix.length, 'latin1'); + msg._headerSent = true; + msg[kChunkedEndSent] = true; + return msg._writeRaw(combined, undefined, callback, total); } OutgoingMessage.prototype.write = function write(chunk, encoding, callback) { diff --git a/test/parallel/test-http-chunked-end-coalesce.js b/test/parallel/test-http-chunked-end-coalesce.js index 02fb4d378b70..104981ec68c0 100644 --- a/test/parallel/test-http-chunked-end-coalesce.js +++ b/test/parallel/test-http-chunked-end-coalesce.js @@ -62,11 +62,18 @@ const server = http.createServer(common.mustCallAtLeast((req, res) => { res.writeHead(200, { 'CONTENT-LENGTH': '2' }); res.end('hi'); break; + case '/latin1-header': + res.writeHead(200, [ + 'content-disposition', + Buffer.from('bår').toString('binary'), + ]); + res.end('ok'); + break; default: res.writeHead(404); res.end(); } -}, 5)); +}, 6)); server.listen(0, common.mustCall(async () => { const port = server.address().port; @@ -94,5 +101,14 @@ server.listen(0, common.mustCall(async () => { assert.match(lengthText, /CONTENT-LENGTH: 2\r\n/); assert.ok(lengthText.endsWith('\r\n\r\nhi')); + const latin1Raw = await rawRequest(port, '/latin1-header'); + const latin1Text = latin1Raw.toString('latin1'); + const expectedLatin1 = Buffer.from('bår').toString('latin1'); + assert.ok( + latin1Text.includes(`content-disposition: ${expectedLatin1}\r\n`), + latin1Text, + ); + assertChunkedBody(latin1Raw, 'ok'); + server.close(); })); diff --git a/test/parallel/test-http-response-cork.js b/test/parallel/test-http-response-cork.js index a587e2dfbf59..b724aa3b0140 100644 --- a/test/parallel/test-http-response-cork.js +++ b/test/parallel/test-http-response-cork.js @@ -6,10 +6,11 @@ const assert = require('assert'); const server = http.createServer(common.mustCallAtLeast((req, res) => { let corked = false; const originalWrite = res.socket.write; - res.socket.write = common.mustCall((...args) => { + // Chunked res.end() may flush headers + body + terminator in one write. + res.socket.write = common.mustCallAtLeast((...args) => { assert.strictEqual(corked, false); return originalWrite.call(res.socket, ...args); - }, 5); + }, 1); corked = true; res.cork(); assert.strictEqual(res.writableCorked, res.socket.writableCorked); From 69766d565661a042ff952c90ef3f51c3fcee5d9f Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 20:16:57 +0000 Subject: [PATCH 4/6] http: keep regex header-value checks JS lookup tables were slower than V8's regex for header values and for token names longer than ~10 bytes. Restore the regex path for those cases, and use the table for names of length <= 10 so Connection / Keep-Alive stay on the faster path. Signed-off-by: Yagiz Nizipli --- lib/_http_common.js | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/lib/_http_common.js b/lib/_http_common.js index 53eed713f0e3..ace8d0e7f0be 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -213,6 +213,7 @@ function freeParser(parser, req, socket) { // Valid chars: ^_`a-zA-Z-0-9!#$%&'*+.|~ // Based on RFC 7230 Section 3.2.6 token definition // See https://tools.ietf.org/html/rfc7230#section-3.2.6 +const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/; const validTokenChars = new Uint8Array([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 @@ -242,9 +243,11 @@ const validTokenChars = new Uint8Array([ function checkIsHttpToken(val) { const len = val.length; if (len === 0) return false; + // Table is faster for short names (Connection, Keep-Alive). V8's regex + // wins once the name is longer than ~10 bytes (Content-Length, etc.). + if (len > 10) + return tokenRegExp.test(val); - // Lookup table for all lengths. Header names like Content-Length (14) - // and Transfer-Encoding (17) used to take the regex path. for (let i = 0; i < len; i++) { if (!validTokenChars[val.charCodeAt(i)]) { return false; @@ -253,17 +256,19 @@ function checkIsHttpToken(val) { return true; } -// Strict: valid bytes are HTAB (0x09), VCHAR (0x20-0x7e), and obs-text (0x80-0xff). -// Rejects 0x00-0x08, 0x0a-0x1f, and DEL (0x7f). Matches /[^\t\x20-\x7e\x80-\xff]/. -const strictValidHeaderChars = new Uint8Array(256); -// Lenient (Fetch): reject only NUL, LF, CR. Matches /[\x00\x0a\x0d]|[^\x00-\xff]/. -const lenientValidHeaderChars = new Uint8Array(256); -{ - for (let i = 0; i < 256; i++) { - strictValidHeaderChars[i] = (i === 0x09 || (i >= 0x20 && i !== 0x7f)) ? 1 : 0; - lenientValidHeaderChars[i] = (i !== 0x00 && i !== 0x0a && i !== 0x0d) ? 1 : 0; - } -} +// Strict header value regex per RFC 7230 (original/default behavior): +// field-value = *( field-content / obs-fold ) +// field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] +// field-vchar = VCHAR / obs-text +// This rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f). +const strictHeaderCharRegex = /[^\t\x20-\x7e\x80-\xff]/; + +// Lenient header value regex per Fetch spec (https://fetch.spec.whatwg.org/#header-value): +// - Must contain no 0x00 (NUL) or HTTP newline bytes (0x0a LF, 0x0d CR) +// - Must be byte sequences (0x00-0xff), not arbitrary unicode +// This allows most control characters except NUL, CR, and LF. +// eslint-disable-next-line no-control-regex +const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/; /** * True if val contains an invalid header value character. @@ -274,16 +279,8 @@ const lenientValidHeaderChars = new Uint8Array(256); * @returns {boolean} */ function checkInvalidHeaderChar(val, lenient = false) { - // regex.test() ToString-coerces; keep that for numbers/arrays/booleans. - if (typeof val !== 'string') - val = `${val}`; - const table = lenient ? lenientValidHeaderChars : strictValidHeaderChars; - for (let i = 0; i < val.length; i++) { - const c = val.charCodeAt(i); - if (c > 255 || table[c] === 0) - return true; - } - return false; + const regex = lenient ? lenientHeaderCharRegex : strictHeaderCharRegex; + return regex.test(val); } function cleanParser(parser) { From 1ec86ff5b3042224e1201c55a63e1d577edcbb6d Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 20:32:04 +0000 Subject: [PATCH 5/6] http: keep incoming headers in C++ until accessed Pass NativeHttpHeaders from the parser instead of a JS string array. IncomingMessage materializes rawHeaders/headers only when read. Host/Expect/body-header checks use C++ has/get. Skip Buffer::Copy for dumped bodies via parser.setSkipBody(). Signed-off-by: Yagiz Nizipli --- lib/_http_client.js | 1 + lib/_http_common.js | 19 +- lib/_http_incoming.js | 83 +++++++- lib/_http_server.js | 8 +- src/node_http_parser.cc | 242 +++++++++++++++++++++- test/parallel/test-http-native-headers.js | 56 +++++ test/parallel/test-http-parser.js | 23 +- 7 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 test/parallel/test-http-native-headers.js diff --git a/lib/_http_client.js b/lib/_http_client.js index adcacb752e6e..d16988dc9e16 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -897,6 +897,7 @@ function parserOnIncomingClient(res, shouldKeepAlive) { // we need to rewrite it to the first one and skip all the pending IncomingMessage socket.parser.incoming = req.res; socket.parser.incoming[kSkipPendingData] = true; + socket.parser.setSkipBody?.(true); } return 0; } diff --git a/lib/_http_common.js b/lib/_http_common.js index ace8d0e7f0be..5a352eb1114d 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -28,7 +28,8 @@ const { } = primordials; const { setImmediate } = require('timers'); -const { methods, allMethods, HTTPParser } = internalBinding('http_parser'); +const { methods, allMethods, HTTPParser, NativeHttpHeaders } = + internalBinding('http_parser'); const { getOptionValue } = require('internal/options'); const insecureHTTPParser = getOptionValue('--insecure-http-parser'); @@ -105,13 +106,19 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method, incoming.url = url; incoming.upgrade = upgrade; - let n = headers.length; + if (headers !== undefined && + NativeHttpHeaders !== undefined && + headers instanceof NativeHttpHeaders) { + incoming._setNativeHeaders(headers, parser.maxHeaderPairs); + } else { + let n = headers.length; - // If parser.maxHeaderPairs <= 0 assume that there's no limit. - if (parser.maxHeaderPairs > 0) - n = MathMin(n, parser.maxHeaderPairs); + // If parser.maxHeaderPairs <= 0 assume that there's no limit. + if (parser.maxHeaderPairs > 0) + n = MathMin(n, parser.maxHeaderPairs); - incoming._addHeaderLines(headers, n); + incoming._addHeaderLines(headers, n); + } if (typeof method === 'number') { // server only diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index ae328ed28864..fbe78df23b29 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -22,6 +22,7 @@ 'use strict'; const { + MathMin, ObjectDefineProperty, ObjectSetPrototypeOf, Symbol, @@ -37,6 +38,7 @@ const { AbortController } = require('internal/abort_controller'); const kHeaders = Symbol('kHeaders'); const kHeadersDistinct = Symbol('kHeadersDistinct'); const kHeadersCount = Symbol('kHeadersCount'); +const kNativeHeaders = Symbol('kNativeHeaders'); const kTrailers = Symbol('kTrailers'); const kTrailersDistinct = Symbol('kTrailersDistinct'); const kTrailersCount = Symbol('kTrailersCount'); @@ -83,6 +85,7 @@ function IncomingMessage(socket) { this.complete = false; this[kHeaders] = null; this[kHeadersCount] = 0; + this[kNativeHeaders] = null; this.rawHeaders = []; this[kTrailers] = null; this[kTrailersCount] = 0; @@ -123,6 +126,31 @@ ObjectDefineProperty(IncomingMessage.prototype, 'connection', { }, }); +function materializeRawHeaders() { + const native = this[kNativeHeaders]; + const arr = native === null ? [] : native.toArray(this[kHeadersCount]); + this[kNativeHeaders] = null; + ObjectDefineProperty(this, 'rawHeaders', { + __proto__: null, + configurable: true, + enumerable: true, + writable: true, + value: arr, + }); + return arr; +} + +function setRawHeaders(val) { + this[kNativeHeaders] = null; + ObjectDefineProperty(this, 'rawHeaders', { + __proto__: null, + configurable: true, + enumerable: true, + writable: true, + value: val, + }); +} + ObjectDefineProperty(IncomingMessage.prototype, 'headers', { __proto__: null, get: function() { @@ -311,6 +339,53 @@ function abortSignal(self) { } } +IncomingMessage.prototype._setNativeHeaders = function _setNativeHeaders(native, maxPairs) { + this[kNativeHeaders] = native; + const len = native.length | 0; + this[kHeadersCount] = maxPairs > 0 ? MathMin(len, maxPairs) : len; + // Keep bytes in C++ until rawHeaders / headers are actually read. + ObjectDefineProperty(this, 'rawHeaders', { + __proto__: null, + configurable: true, + enumerable: true, + get: materializeRawHeaders, + set: setRawHeaders, + }); +}; + +IncomingMessage.prototype._hasHeader = function _hasHeader(name) { + if (this[kNativeHeaders] !== null) + return this[kNativeHeaders].has(name); + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + const want = name.length; + for (let i = 0; i < n; i += 2) { + const field = headers[i]; + if (field.length === want && field.toLowerCase() === name) + return true; + } + return false; +}; + +IncomingMessage.prototype._getHeader = function _getHeader(name) { + if (this[kNativeHeaders] !== null) + return this[kNativeHeaders].get(name); + const headers = this.rawHeaders; + const n = this[kHeadersCount]; + const want = name.length; + let value; + for (let i = 0; i < n; i += 2) { + const field = headers[i]; + if (field.length === want && field.toLowerCase() === name) { + if (value === undefined) + value = headers[i + 1]; + else + value += ', ' + headers[i + 1]; + } + } + return value; +}; + IncomingMessage.prototype._addHeaderLines = _addHeaderLines; function _addHeaderLines(headers, n) { if (headers?.length) { @@ -494,8 +569,12 @@ function _addHeaderLineDistinct(field, value, dest) { } } -// Scan rawHeaders so callers do not force construction of req.headers. +// Scan C++ headers (or rawHeaders) so callers do not force req.headers. IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() { + if (this[kNativeHeaders] !== null) { + return this[kNativeHeaders].has('content-length') || + this[kNativeHeaders].has('transfer-encoding'); + } const headers = this.rawHeaders; const n = this[kHeadersCount]; for (let i = 0; i < n; i += 2) { @@ -524,6 +603,7 @@ IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable this._readableState.destroyed = true; this._readableState.closed = true; this._readableState.closeEmitted = true; + this.socket?.parser?.setSkipBody?.(true); }; @@ -532,6 +612,7 @@ IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable IncomingMessage.prototype._dump = function _dump() { if (!this._dumped) { this._dumped = true; + this.socket?.parser?.setSkipBody?.(true); // If there is buffered data, it may trigger 'data' events. // Remove 'data' event listeners explicitly. this.removeAllListeners('data'); diff --git a/lib/_http_server.js b/lib/_http_server.js index 4572fe495fb3..d899d746268a 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -229,7 +229,7 @@ function ServerResponse(req, options) { this._expect_continue = false; if (req.httpVersionMajor < 1 || req.httpVersionMinor < 1) { - this.useChunkedEncodingByDefault = chunkExpression.test(req.headers.te); + this.useChunkedEncodingByDefault = chunkExpression.test(req._getHeader('te')); this.shouldKeepAlive = false; } @@ -1371,7 +1371,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { // From RFC 7230 5.4 https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 // A server MUST respond with a 400 (Bad Request) status code to any // HTTP/1.1 request message that lacks a Host header field - if (server.requireHostHeader && req.headers.host === undefined) { + if (server.requireHostHeader && !req._hasHeader('host')) { res.writeHead(400, ['Connection', 'close']); res.end(); return 0; @@ -1394,10 +1394,10 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { server.emit('dropRequest', req, socket); res.writeHead(503); res.end(); - } else if (req.headers.expect !== undefined) { + } else if (req._hasHeader('expect')) { handled = true; - if (continueExpression.test(req.headers.expect)) { + if (continueExpression.test(req._getHeader('expect'))) { res._expect_continue = true; if (server.listenerCount('checkContinue') > 0) { server.emit('checkContinue', req, res); diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index 62e83074bf88..9d48249284bc 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -22,6 +22,7 @@ #include "node.h" #include "node_buffer.h" #include "util.h" +#include "util-inl.h" #include "async_wrap-inl.h" #include "env-inl.h" @@ -31,8 +32,10 @@ #include "stream_base-inl.h" #include "v8.h" +#include #include // free() #include // strdup(), strchr() +#include // This is a binding to llhttp (https://github.com/nodejs/llhttp) @@ -115,6 +118,7 @@ class BindingData : public BaseObject { std::vector parser_buffer; bool parser_buffer_in_use = false; + v8::Global native_headers_ctor; void MemoryInfo(MemoryTracker* tracker) const override { tracker->TrackField("parser_buffer", parser_buffer); @@ -123,6 +127,161 @@ class BindingData : public BaseObject { SET_MEMORY_INFO_NAME(BindingData) }; +// Owns header name/value bytes in C++. JS strings are created only when +// rawHeaders / headers / get() actually need them. +class NativeHttpHeaders : public BaseObject { + public: + NativeHttpHeaders(Environment* env, Local object) + : BaseObject(env, object) { + MakeWeak(); + buf_.reserve(512); + entries_.reserve(16); + } + + static void New(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + new NativeHttpHeaders(env, args.This()); + } + + void Add(const char* name, size_t nlen, const char* value, size_t vlen) { + Entry e; + e.name_off = static_cast(buf_.size()); + e.name_len = static_cast(nlen); + if (nlen != 0) + buf_.insert(buf_.end(), name, name + nlen); + e.value_off = static_cast(buf_.size()); + e.value_len = static_cast(vlen); + if (vlen != 0) + buf_.insert(buf_.end(), value, value + vlen); + entries_.push_back(e); + } + + size_t pair_count() const { return entries_.size(); } + + static void Has(const FunctionCallbackInfo& args) { + NativeHttpHeaders* self; + ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); + if (args.Length() < 1 || !args[0]->IsString()) { + args.GetReturnValue().Set(false); + return; + } + Utf8Value name(args.GetIsolate(), args[0]); + args.GetReturnValue().Set(self->HasHeader(*name, name.length())); + } + + static void Get(const FunctionCallbackInfo& args) { + NativeHttpHeaders* self; + ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); + if (args.Length() < 1 || !args[0]->IsString()) { + args.GetReturnValue().SetUndefined(); + return; + } + Utf8Value name(args.GetIsolate(), args[0]); + args.GetReturnValue().Set(self->GetHeader(name.length() == 0 ? nullptr : *name, + name.length())); + } + + static void ToArray(const FunctionCallbackInfo& args) { + NativeHttpHeaders* self; + ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); + size_t max_elements = self->entries_.size() * 2; + if (args.Length() > 0 && args[0]->IsUint32()) { + max_elements = std::min( + max_elements, + static_cast(args[0].As()->Value())); + } + args.GetReturnValue().Set(self->ToJSArray(max_elements)); + } + + bool HasHeader(const char* name, size_t nlen) const { + for (const Entry& e : entries_) { + if (HeaderNameEquals(e, name, nlen)) + return true; + } + return false; + } + + Local GetHeader(const char* name, size_t nlen) const { + Isolate* isolate = env()->isolate(); + if (name == nullptr) + return Undefined(isolate); + std::string joined; + bool found = false; + for (const Entry& e : entries_) { + if (!HeaderNameEquals(e, name, nlen)) + continue; + if (found) + joined.append(", "); + joined.append(buf_.data() + e.value_off, e.value_len); + found = true; + } + if (!found) + return Undefined(isolate); + return OneByteString(isolate, joined.data(), joined.size()); + } + + Local ToJSArray(size_t max_elements) const { + Isolate* isolate = env()->isolate(); + const size_t n = std::min(entries_.size() * 2, max_elements); + LocalVector out(isolate); + out.reserve(n); + for (size_t i = 0; i < n; i += 2) { + const Entry& e = entries_[i / 2]; + out.push_back(e.name_len == 0 + ? String::Empty(isolate) + : OneByteString(isolate, + buf_.data() + e.name_off, + e.name_len)); + if (i + 1 >= n) + break; + out.push_back(e.value_len == 0 + ? String::Empty(isolate) + : OneByteString(isolate, + buf_.data() + e.value_off, + e.value_len)); + } + return Array::New(isolate, out.data(), out.size()); + } + + void MemoryInfo(MemoryTracker* tracker) const override { + tracker->TrackField("buf", buf_); + tracker->TrackFieldWithSize("entries", + entries_.capacity() * sizeof(Entry)); + } + SET_MEMORY_INFO_NAME(NativeHttpHeaders) + SET_SELF_SIZE(NativeHttpHeaders) + + private: + struct Entry { + uint32_t name_off; + uint32_t name_len; + uint32_t value_off; + uint32_t value_len; + }; + + bool HeaderNameEquals(const Entry& e, + const char* name, + size_t nlen) const { + if (e.name_len != nlen) + return false; + const char* a = buf_.data() + e.name_off; + for (size_t i = 0; i < nlen; i++) { + unsigned char ca = static_cast(a[i]); + unsigned char cb = static_cast(name[i]); + if (ca >= 'A' && ca <= 'Z') + ca = static_cast(ca + 32); + if (cb >= 'A' && cb <= 'Z') + cb = static_cast(cb + 32); + if (ca != cb) + return false; + } + return true; + } + + std::vector buf_; + std::vector entries_; +}; + class Parser; class StringPtrAllocator { @@ -317,6 +476,7 @@ class Parser : public AsyncWrap, public StreamListener { num_fields_ = num_values_ = 0; headers_completed_ = false; + skip_body_ = false; chunk_extensions_nread_ = 0; received_data_ = true; last_message_start_ = uv_hrtime(); @@ -456,8 +616,11 @@ class Parser : public AsyncWrap, public StreamListener { // Slow case, flush remaining headers. Flush(); } else { - // Fast case, pass headers and URL to JS land. - argv[A_HEADERS] = CreateHeaders(); + // Keep header bytes in C++. JS materializes strings only if it + // reads rawHeaders / headers. + argv[A_HEADERS] = CreateNativeHeaders(); + if (argv[A_HEADERS].IsEmpty()) + return -1; if (parser_.type == HTTP_REQUEST) argv[A_URL] = url_.ToString(env()); } @@ -514,7 +677,7 @@ class Parser : public AsyncWrap, public StreamListener { int on_body(const char* at, size_t length) { - if (length == 0) + if (length == 0 || skip_body_) return 0; Environment* env = this->env(); @@ -788,6 +951,12 @@ class Parser : public AsyncWrap, public StreamListener { } + static void SetSkipBody(const FunctionCallbackInfo& args) { + Parser* parser; + ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); + parser->skip_body_ = args.Length() > 0 && args[0]->IsTrue(); + } + static void GetCurrentBuffer(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); @@ -955,6 +1124,55 @@ class Parser : public AsyncWrap, public StreamListener { return Array::New(env()->isolate(), headers_v, num_values_ * 2); } + Local CreateNativeHeaders() { + Isolate* isolate = env()->isolate(); + Local context = env()->context(); + Local ctor; + if (!binding_data_->native_headers_ctor.IsEmpty()) { + ctor = binding_data_->native_headers_ctor.Get(isolate); + } else { + Local ctor_v; + if (!binding_data_->object() + ->Get(context, + FIXED_ONE_BYTE_STRING(isolate, "NativeHttpHeaders")) + .ToLocal(&ctor_v) || + !ctor_v->IsFunction()) { + return CreateHeaders(); + } + ctor = ctor_v.As(); + binding_data_->native_headers_ctor.Reset(isolate, ctor); + } + + Local obj; + if (!ctor->NewInstance(context, 0, nullptr).ToLocal(&obj)) { + got_exception_ = true; + return Local(); + } + + NativeHttpHeaders* list; + ASSIGN_OR_RETURN_UNWRAP(&list, obj, Local()); + for (size_t i = 0; i < num_values_; ++i) { + size_t vlen = values_[i].size_; + const char* v = values_[i].str_; + while (vlen > 0 && v != nullptr && IsOWS(v[vlen - 1])) + vlen--; + list->Add(fields_[i].str_ == nullptr ? "" : fields_[i].str_, + fields_[i].size_, + v == nullptr ? "" : v, + vlen); + } + + if (obj->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "length"), + Integer::NewFromUnsigned( + isolate, static_cast(list->pair_count() * 2))) + .IsNothing()) { + got_exception_ = true; + return Local(); + } + return obj; + } + // spill headers and request path to JS land void Flush() { @@ -1032,6 +1250,7 @@ class Parser : public AsyncWrap, public StreamListener { got_exception_ = false; is_being_freed_ = false; headers_completed_ = false; + skip_body_ = false; max_http_header_size_ = max_http_header_size; header_pairs_ = 0; } @@ -1108,6 +1327,7 @@ class Parser : public AsyncWrap, public StreamListener { size_t current_buffer_len_; const char* current_buffer_data_; bool headers_completed_ = false; + bool skip_body_ = false; size_t header_pairs_ = 0; bool pending_pause_ = false; bool received_data_ = false; @@ -1405,9 +1625,20 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, SetProtoMethod(isolate, t, "consume", Parser::Consume); SetProtoMethod(isolate, t, "unconsume", Parser::Unconsume); SetProtoMethod(isolate, t, "getCurrentBuffer", Parser::GetCurrentBuffer); + SetProtoMethod(isolate, t, "setSkipBody", Parser::SetSkipBody); SetConstructorFunction(isolate, target, "HTTPParser", t); + Local nh = + NewFunctionTemplate(isolate, NativeHttpHeaders::New); + nh->InstanceTemplate()->SetInternalFieldCount( + NativeHttpHeaders::kInternalFieldCount); + nh->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "NativeHttpHeaders")); + SetProtoMethod(isolate, nh, "has", NativeHttpHeaders::Has); + SetProtoMethod(isolate, nh, "get", NativeHttpHeaders::Get); + SetProtoMethod(isolate, nh, "toArray", NativeHttpHeaders::ToArray); + SetConstructorFunction(isolate, target, "NativeHttpHeaders", nh); + Local c = NewFunctionTemplate(isolate, ConnectionsList::New); c->InstanceTemplate() @@ -1474,6 +1705,11 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Parser::Consume); registry->Register(Parser::Unconsume); registry->Register(Parser::GetCurrentBuffer); + registry->Register(Parser::SetSkipBody); + registry->Register(NativeHttpHeaders::New); + registry->Register(NativeHttpHeaders::Has); + registry->Register(NativeHttpHeaders::Get); + registry->Register(NativeHttpHeaders::ToArray); registry->Register(ConnectionsList::New); registry->Register(ConnectionsList::All); registry->Register(ConnectionsList::Idle); diff --git a/test/parallel/test-http-native-headers.js b/test/parallel/test-http-native-headers.js new file mode 100644 index 000000000000..ffdadbb739cc --- /dev/null +++ b/test/parallel/test-http-native-headers.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); + +// Headers stay in C++ until rawHeaders / headers are read. +// Server Host / Expect checks must not force that materialization. + +const server = http.createServer(common.mustCall((req, res) => { + assert.strictEqual(req._hasHeader('host'), true); + assert.strictEqual(req._hasHeader('x-test'), true); + assert.strictEqual(req._hasHeader('x-missing'), false); + assert.strictEqual(req._getHeader('x-test'), 'one, two'); + assert.strictEqual(req._hasBodyHeaders(), false); + + // Host / Expect checks must not copy header strings into JS. + const desc = Object.getOwnPropertyDescriptor(req, 'rawHeaders'); + assert.strictEqual(typeof desc.get, 'function'); + assert.ok(Object.hasOwn(req, 'rawHeaders')); + + // First public access materializes JS strings as an own data property. + assert.strictEqual(req.headers.host, `localhost:${server.address().port}`); + assert.strictEqual(req.headers['x-test'], 'one, two'); + assert.ok(Array.isArray(req.rawHeaders)); + assert.ok(req.rawHeaders.includes('X-Test')); + assert.strictEqual( + Object.getOwnPropertyDescriptor(req, 'rawHeaders').value, + req.rawHeaders); + res.end('ok'); +})); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + http.get({ + port, + headers: { + 'X-Test': ['one', 'two'], + }, + }, common.mustCall((res) => { + res.resume(); + res.on('end', common.mustCall(() => { + // Requests without Host are still rejected without building headers. + const client = net.connect(port, common.mustCall(() => { + client.write('GET / HTTP/1.1\r\n\r\n'); + })); + const chunks = []; + client.on('data', (c) => chunks.push(c)); + client.on('end', common.mustCall(() => { + const raw = Buffer.concat(chunks).toString('latin1'); + assert.match(raw, /^HTTP\/1\.1 400 /); + server.close(); + })); + })); + })); +})); diff --git a/test/parallel/test-http-parser.js b/test/parallel/test-http-parser.js index 2bf6271e2ddf..47e5f46c651f 100644 --- a/test/parallel/test-http-parser.js +++ b/test/parallel/test-http-parser.js @@ -31,6 +31,17 @@ const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; const kOnBody = HTTPParser.kOnBody | 0; const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; +// Fast-path kOnHeadersComplete now passes NativeHttpHeaders (C++-backed) +// instead of a JS string array. Materialize only when the test inspects them. +function headerList(headers, fallback) { + if (headers != null && + typeof headers.toArray === 'function' && + !Array.isArray(headers)) { + return headers.toArray(); + } + return headers || fallback || []; +} + // The purpose of this test is not to check HTTP compliance but to test the // binding. Tests for pathological http messages should be submitted // upstream to https://github.com/joyent/http-parser for inclusion into @@ -152,7 +163,7 @@ function expectBody(expected) { assert.strictEqual(method, undefined); assert.strictEqual(statusCode, 200); assert.strictEqual(statusMessage, 'Connection established'); - assert.deepStrictEqual(headers || parser.headers, []); + assert.deepStrictEqual(headerList(headers, parser.headers), []); }); const parser = newParser(RESPONSE); @@ -226,7 +237,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); assert.deepStrictEqual( - headers || parser.headers, + headerList(headers, parser.headers), ['X-Filler', '1337', 'X-Filler', '42', 'X-Filler2', '42']); }); @@ -256,7 +267,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); - headers ||= parser.headers; + headers = headerList(headers, parser.headers); assert.strictEqual(headers.length, 2 * 256); // 256 key/value pairs for (let i = 0; i < headers.length; i += 2) { @@ -480,7 +491,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 1); assert.deepStrictEqual( - headers || parser.headers, + headerList(headers, parser.headers), ['Content-Type', 'text/plain', 'Transfer-Encoding', 'chunked']); }); @@ -533,7 +544,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 1); assert.deepStrictEqual( - headers, + headerList(headers), ['Content-Type', 'text/plain', 'Transfer-Encoding', 'chunked']); }); @@ -544,7 +555,7 @@ function expectBody(expected) { assert.strictEqual(versionMajor, 1); assert.strictEqual(versionMinor, 0); assert.deepStrictEqual( - headers, + headerList(headers), ['Content-Type', 'text/plain', 'Content-Length', '4'] ); }); From 5554cbc22734ae8935f885511ca419a00ac828d0 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Sun, 16 Aug 2026 20:55:40 +0000 Subject: [PATCH 6/6] http: keep incoming headers packed until accessed Store header name/value bytes in a packed Buffer instead of a per-request native BaseObject or JS string array. IncomingMessage materializes rawHeaders only when read. Host/Expect/body-header checks use flag bits so the default server path never creates header strings. Dumped bodies skip Buffer::Copy via setSkipBody(). Signed-off-by: Yagiz Nizipli --- lib/_http_common.js | 20 +- lib/_http_incoming.js | 105 +++--- src/node_http_parser.cc | 402 ++++++++++++---------- test/parallel/test-http-native-headers.js | 10 +- test/parallel/test-http-parser.js | 13 +- 5 files changed, 303 insertions(+), 247 deletions(-) diff --git a/lib/_http_common.js b/lib/_http_common.js index 5a352eb1114d..00c640df88f0 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -22,14 +22,19 @@ 'use strict'; const { + ArrayIsArray, MathMin, Symbol, Uint8Array, } = primordials; const { setImmediate } = require('timers'); -const { methods, allMethods, HTTPParser, NativeHttpHeaders } = - internalBinding('http_parser'); +const { + methods, + allMethods, + HTTPParser, + nativeHeadersToArray, +} = internalBinding('http_parser'); const { getOptionValue } = require('internal/options'); const insecureHTTPParser = getOptionValue('--insecure-http-parser'); @@ -106,9 +111,7 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method, incoming.url = url; incoming.upgrade = upgrade; - if (headers !== undefined && - NativeHttpHeaders !== undefined && - headers instanceof NativeHttpHeaders) { + if (headers !== undefined && !ArrayIsArray(headers)) { incoming._setNativeHeaders(headers, parser.maxHeaderPairs); } else { let n = headers.length; @@ -334,6 +337,12 @@ function calculateLenientFlags(httpValidation, insecureHTTPParserOption) { return lenient ? HTTPParser.kLenientAll | 0 : HTTPParser.kLenientNone | 0; } +function unpackHeaderList(headers, fallback) { + if (headers != null && !ArrayIsArray(headers)) + return nativeHeadersToArray(headers); + return headers || fallback || []; +} + module.exports = { _checkInvalidHeaderChar: checkInvalidHeaderChar, _checkIsHttpToken: checkIsHttpToken, @@ -349,4 +358,5 @@ module.exports = { calculateLenientFlags, prepareError, kSkipPendingData, + unpackHeaderList, }; diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index fbe78df23b29..6514fc4faf99 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -32,6 +32,17 @@ const { Readable, finished } = require('stream'); const { deprecateInstantiation, } = require('internal/util'); +const { + nativeHeadersHas, + nativeHeadersGet, + nativeHeadersToArray, +} = internalBinding('http_parser'); + +const kNativeFlagHost = 1 << 0; +const kNativeFlagExpect = 1 << 1; +const kNativeFlagContentLength = 1 << 2; +const kNativeFlagTransferEncoding = 1 << 3; +const kNativeFlagTE = 1 << 4; const { AbortController } = require('internal/abort_controller'); @@ -39,6 +50,8 @@ const kHeaders = Symbol('kHeaders'); const kHeadersDistinct = Symbol('kHeadersDistinct'); const kHeadersCount = Symbol('kHeadersCount'); const kNativeHeaders = Symbol('kNativeHeaders'); +const kNativeFlags = Symbol('kNativeFlags'); +const kRawHeaders = Symbol('kRawHeaders'); const kTrailers = Symbol('kTrailers'); const kTrailersDistinct = Symbol('kTrailersDistinct'); const kTrailersCount = Symbol('kTrailersCount'); @@ -86,7 +99,8 @@ function IncomingMessage(socket) { this[kHeaders] = null; this[kHeadersCount] = 0; this[kNativeHeaders] = null; - this.rawHeaders = []; + this[kNativeFlags] = 0; + this[kRawHeaders] = null; this[kTrailers] = null; this[kTrailersCount] = 0; this.rawTrailers = []; @@ -126,30 +140,31 @@ ObjectDefineProperty(IncomingMessage.prototype, 'connection', { }, }); -function materializeRawHeaders() { - const native = this[kNativeHeaders]; - const arr = native === null ? [] : native.toArray(this[kHeadersCount]); - this[kNativeHeaders] = null; - ObjectDefineProperty(this, 'rawHeaders', { - __proto__: null, - configurable: true, - enumerable: true, - writable: true, - value: arr, - }); - return arr; -} - -function setRawHeaders(val) { - this[kNativeHeaders] = null; - ObjectDefineProperty(this, 'rawHeaders', { - __proto__: null, - configurable: true, - enumerable: true, - writable: true, - value: val, - }); -} +ObjectDefineProperty(IncomingMessage.prototype, 'rawHeaders', { + __proto__: null, + configurable: true, + enumerable: true, + get: function() { + if (this[kRawHeaders] === null) { + if (this[kNativeHeaders] !== null) { + this[kRawHeaders] = nativeHeadersToArray( + this[kNativeHeaders], + this[kHeadersCount], + ); + this[kNativeHeaders] = null; + this[kNativeFlags] = 0; + } else { + this[kRawHeaders] = []; + } + } + return this[kRawHeaders]; + }, + set: function(val) { + this[kRawHeaders] = val; + this[kNativeHeaders] = null; + this[kNativeFlags] = 0; + }, +}); ObjectDefineProperty(IncomingMessage.prototype, 'headers', { __proto__: null, @@ -341,21 +356,33 @@ function abortSignal(self) { IncomingMessage.prototype._setNativeHeaders = function _setNativeHeaders(native, maxPairs) { this[kNativeHeaders] = native; - const len = native.length | 0; + this[kRawHeaders] = null; + this[kNativeFlags] = native[8] | (native[9] << 8) | + (native[10] << 16) | (native[11] << 24); + const pairs = native[4] | (native[5] << 8) | + (native[6] << 16) | (native[7] << 24); + const len = pairs * 2; this[kHeadersCount] = maxPairs > 0 ? MathMin(len, maxPairs) : len; - // Keep bytes in C++ until rawHeaders / headers are actually read. - ObjectDefineProperty(this, 'rawHeaders', { - __proto__: null, - configurable: true, - enumerable: true, - get: materializeRawHeaders, - set: setRawHeaders, - }); }; IncomingMessage.prototype._hasHeader = function _hasHeader(name) { - if (this[kNativeHeaders] !== null) - return this[kNativeHeaders].has(name); + if (this[kNativeHeaders] !== null) { + const flags = this[kNativeFlags]; + switch (name) { + case 'host': + return (flags & kNativeFlagHost) !== 0; + case 'expect': + return (flags & kNativeFlagExpect) !== 0; + case 'content-length': + return (flags & kNativeFlagContentLength) !== 0; + case 'transfer-encoding': + return (flags & kNativeFlagTransferEncoding) !== 0; + case 'te': + return (flags & kNativeFlagTE) !== 0; + default: + return nativeHeadersHas(this[kNativeHeaders], name); + } + } const headers = this.rawHeaders; const n = this[kHeadersCount]; const want = name.length; @@ -369,7 +396,7 @@ IncomingMessage.prototype._hasHeader = function _hasHeader(name) { IncomingMessage.prototype._getHeader = function _getHeader(name) { if (this[kNativeHeaders] !== null) - return this[kNativeHeaders].get(name); + return nativeHeadersGet(this[kNativeHeaders], name); const headers = this.rawHeaders; const n = this[kHeadersCount]; const want = name.length; @@ -572,8 +599,8 @@ function _addHeaderLineDistinct(field, value, dest) { // Scan C++ headers (or rawHeaders) so callers do not force req.headers. IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() { if (this[kNativeHeaders] !== null) { - return this[kNativeHeaders].has('content-length') || - this[kNativeHeaders].has('transfer-encoding'); + return (this[kNativeFlags] & + (kNativeFlagContentLength | kNativeFlagTransferEncoding)) !== 0; } const headers = this.rawHeaders; const n = this[kHeadersCount]; diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index 9d48249284bc..235b95c5e556 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -118,7 +118,6 @@ class BindingData : public BaseObject { std::vector parser_buffer; bool parser_buffer_in_use = false; - v8::Global native_headers_ctor; void MemoryInfo(MemoryTracker* tracker) const override { tracker->TrackField("parser_buffer", parser_buffer); @@ -127,160 +126,204 @@ class BindingData : public BaseObject { SET_MEMORY_INFO_NAME(BindingData) }; -// Owns header name/value bytes in C++. JS strings are created only when -// rawHeaders / headers / get() actually need them. -class NativeHttpHeaders : public BaseObject { - public: - NativeHttpHeaders(Environment* env, Local object) - : BaseObject(env, object) { - MakeWeak(); - buf_.reserve(512); - entries_.reserve(16); - } +// Packed incoming headers: magic + count + flags + (nlen, vlen, name, value)* +// Kept as a Buffer so JS strings are created only when rawHeaders / headers +// are actually read. Avoids a native BaseObject per request. +constexpr uint32_t kNativeHeadersMagic = 0x5244484E; // 'NHDR' +constexpr uint32_t kNativeHeaderFlagHost = 1 << 0; +constexpr uint32_t kNativeHeaderFlagExpect = 1 << 1; +constexpr uint32_t kNativeHeaderFlagContentLength = 1 << 2; +constexpr uint32_t kNativeHeaderFlagTransferEncoding = 1 << 3; +constexpr uint32_t kNativeHeaderFlagTE = 1 << 4; +constexpr size_t kNativeHeadersPrefix = 12; + +inline void WriteU32(char* p, uint32_t v) { + memcpy(p, &v, sizeof(v)); +} - static void New(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - new NativeHttpHeaders(env, args.This()); - } +inline bool ReadU32(const char* p, const char* end, uint32_t* out) { + if (p + sizeof(uint32_t) > end) + return false; + memcpy(out, p, sizeof(uint32_t)); + return true; +} - void Add(const char* name, size_t nlen, const char* value, size_t vlen) { - Entry e; - e.name_off = static_cast(buf_.size()); - e.name_len = static_cast(nlen); - if (nlen != 0) - buf_.insert(buf_.end(), name, name + nlen); - e.value_off = static_cast(buf_.size()); - e.value_len = static_cast(vlen); - if (vlen != 0) - buf_.insert(buf_.end(), value, value + vlen); - entries_.push_back(e); +inline bool HeaderNameEquals(const char* a, size_t alen, + const char* b, size_t blen) { + if (alen != blen) + return false; + for (size_t i = 0; i < alen; i++) { + unsigned char ca = static_cast(a[i]); + unsigned char cb = static_cast(b[i]); + if (ca >= 'A' && ca <= 'Z') + ca = static_cast(ca + 32); + if (cb >= 'A' && cb <= 'Z') + cb = static_cast(cb + 32); + if (ca != cb) + return false; } + return true; +} + +bool GetPackedHeaders(Local value, + const char** data, + size_t* size, + uint32_t* count) { + if (!Buffer::HasInstance(value)) + return false; + Local obj = value.As(); + const char* p = Buffer::Data(obj); + const size_t n = Buffer::Length(obj); + if (n < kNativeHeadersPrefix) + return false; + uint32_t magic; + memcpy(&magic, p, sizeof(magic)); + if (magic != kNativeHeadersMagic) + return false; + memcpy(count, p + 4, sizeof(uint32_t)); + *data = p; + *size = n; + return true; +} - size_t pair_count() const { return entries_.size(); } +uint32_t KnownHeaderFlag(const char* name, size_t nlen) { + if (HeaderNameEquals(name, nlen, "host", 4)) + return kNativeHeaderFlagHost; + if (HeaderNameEquals(name, nlen, "expect", 6)) + return kNativeHeaderFlagExpect; + if (HeaderNameEquals(name, nlen, "content-length", 14)) + return kNativeHeaderFlagContentLength; + if (HeaderNameEquals(name, nlen, "transfer-encoding", 17)) + return kNativeHeaderFlagTransferEncoding; + if (HeaderNameEquals(name, nlen, "te", 2)) + return kNativeHeaderFlagTE; + return 0; +} - static void Has(const FunctionCallbackInfo& args) { - NativeHttpHeaders* self; - ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); - if (args.Length() < 1 || !args[0]->IsString()) { - args.GetReturnValue().Set(false); - return; - } - Utf8Value name(args.GetIsolate(), args[0]); - args.GetReturnValue().Set(self->HasHeader(*name, name.length())); +void NativeHeadersHas(const FunctionCallbackInfo& args) { + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 2 || + !args[1]->IsString() || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(false); + return; } - - static void Get(const FunctionCallbackInfo& args) { - NativeHttpHeaders* self; - ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); - if (args.Length() < 1 || !args[0]->IsString()) { - args.GetReturnValue().SetUndefined(); + Utf8Value name(args.GetIsolate(), args[1]); + const char* want = *name; + const size_t nlen = name.length(); + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + for (uint32_t i = 0; i < count; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + if (HeaderNameEquals(p, enlen, want, nlen)) { + args.GetReturnValue().Set(true); return; } - Utf8Value name(args.GetIsolate(), args[0]); - args.GetReturnValue().Set(self->GetHeader(name.length() == 0 ? nullptr : *name, - name.length())); - } - - static void ToArray(const FunctionCallbackInfo& args) { - NativeHttpHeaders* self; - ASSIGN_OR_RETURN_UNWRAP(&self, args.This()); - size_t max_elements = self->entries_.size() * 2; - if (args.Length() > 0 && args[0]->IsUint32()) { - max_elements = std::min( - max_elements, - static_cast(args[0].As()->Value())); - } - args.GetReturnValue().Set(self->ToJSArray(max_elements)); + p += enlen + evlen; } + args.GetReturnValue().Set(false); +} - bool HasHeader(const char* name, size_t nlen) const { - for (const Entry& e : entries_) { - if (HeaderNameEquals(e, name, nlen)) - return true; - } - return false; +void NativeHeadersGet(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 2 || + !args[1]->IsString() || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().SetUndefined(); + return; } - - Local GetHeader(const char* name, size_t nlen) const { - Isolate* isolate = env()->isolate(); - if (name == nullptr) - return Undefined(isolate); - std::string joined; - bool found = false; - for (const Entry& e : entries_) { - if (!HeaderNameEquals(e, name, nlen)) - continue; + Utf8Value name(isolate, args[1]); + const char* want = *name; + const size_t nlen = name.length(); + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + std::string joined; + bool found = false; + for (uint32_t i = 0; i < count; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + if (HeaderNameEquals(p, enlen, want, nlen)) { if (found) joined.append(", "); - joined.append(buf_.data() + e.value_off, e.value_len); + joined.append(p + enlen, evlen); found = true; } - if (!found) - return Undefined(isolate); - return OneByteString(isolate, joined.data(), joined.size()); + p += enlen + evlen; } - - Local ToJSArray(size_t max_elements) const { - Isolate* isolate = env()->isolate(); - const size_t n = std::min(entries_.size() * 2, max_elements); - LocalVector out(isolate); - out.reserve(n); - for (size_t i = 0; i < n; i += 2) { - const Entry& e = entries_[i / 2]; - out.push_back(e.name_len == 0 - ? String::Empty(isolate) - : OneByteString(isolate, - buf_.data() + e.name_off, - e.name_len)); - if (i + 1 >= n) - break; - out.push_back(e.value_len == 0 - ? String::Empty(isolate) - : OneByteString(isolate, - buf_.data() + e.value_off, - e.value_len)); - } - return Array::New(isolate, out.data(), out.size()); + if (!found) { + args.GetReturnValue().SetUndefined(); + return; } + args.GetReturnValue().Set(OneByteString(isolate, joined.data(), joined.size())); +} - void MemoryInfo(MemoryTracker* tracker) const override { - tracker->TrackField("buf", buf_); - tracker->TrackFieldWithSize("entries", - entries_.capacity() * sizeof(Entry)); +void NativeHeadersToArray(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 1 || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(Array::New(isolate, 0)); + return; } - SET_MEMORY_INFO_NAME(NativeHttpHeaders) - SET_SELF_SIZE(NativeHttpHeaders) - - private: - struct Entry { - uint32_t name_off; - uint32_t name_len; - uint32_t value_off; - uint32_t value_len; - }; - - bool HeaderNameEquals(const Entry& e, - const char* name, - size_t nlen) const { - if (e.name_len != nlen) - return false; - const char* a = buf_.data() + e.name_off; - for (size_t i = 0; i < nlen; i++) { - unsigned char ca = static_cast(a[i]); - unsigned char cb = static_cast(name[i]); - if (ca >= 'A' && ca <= 'Z') - ca = static_cast(ca + 32); - if (cb >= 'A' && cb <= 'Z') - cb = static_cast(cb + 32); - if (ca != cb) - return false; - } - return true; + size_t max_elements = static_cast(count) * 2; + if (args.Length() > 1 && args[1]->IsUint32()) { + max_elements = std::min( + max_elements, + static_cast(args[1].As()->Value())); } + const char* p = data + kNativeHeadersPrefix; + const char* end = data + size; + LocalVector out(isolate); + out.reserve(max_elements); + for (uint32_t i = 0; i < count && out.size() < max_elements; i++) { + uint32_t enlen, evlen; + if (!ReadU32(p, end, &enlen) || !ReadU32(p + 4, end, &evlen)) + break; + p += 8; + if (p + enlen + evlen > end) + break; + out.push_back(enlen == 0 + ? String::Empty(isolate) + : OneByteString(isolate, p, enlen)); + if (out.size() >= max_elements) { + p += enlen + evlen; + break; + } + out.push_back(evlen == 0 + ? String::Empty(isolate) + : OneByteString(isolate, p + enlen, evlen)); + p += enlen + evlen; + } + args.GetReturnValue().Set(Array::New(isolate, out.data(), out.size())); +} - std::vector buf_; - std::vector entries_; -}; +void NativeHeadersByteLength(const FunctionCallbackInfo& args) { + const char* data; + size_t size; + uint32_t count; + if (args.Length() < 1 || + !GetPackedHeaders(args[0], &data, &size, &count)) { + args.GetReturnValue().Set(0); + return; + } + args.GetReturnValue().Set(static_cast(count * 2)); +} class Parser; @@ -1125,52 +1168,46 @@ class Parser : public AsyncWrap, public StreamListener { } Local CreateNativeHeaders() { - Isolate* isolate = env()->isolate(); - Local context = env()->context(); - Local ctor; - if (!binding_data_->native_headers_ctor.IsEmpty()) { - ctor = binding_data_->native_headers_ctor.Get(isolate); - } else { - Local ctor_v; - if (!binding_data_->object() - ->Get(context, - FIXED_ONE_BYTE_STRING(isolate, "NativeHttpHeaders")) - .ToLocal(&ctor_v) || - !ctor_v->IsFunction()) { - return CreateHeaders(); - } - ctor = ctor_v.As(); - binding_data_->native_headers_ctor.Reset(isolate, ctor); - } - - Local obj; - if (!ctor->NewInstance(context, 0, nullptr).ToLocal(&obj)) { - got_exception_ = true; - return Local(); - } - - NativeHttpHeaders* list; - ASSIGN_OR_RETURN_UNWRAP(&list, obj, Local()); - for (size_t i = 0; i < num_values_; ++i) { + auto trimmed_vlen = [this](size_t i) { size_t vlen = values_[i].size_; const char* v = values_[i].str_; while (vlen > 0 && v != nullptr && IsOWS(v[vlen - 1])) vlen--; - list->Add(fields_[i].str_ == nullptr ? "" : fields_[i].str_, - fields_[i].size_, - v == nullptr ? "" : v, - vlen); - } + return vlen; + }; + + size_t size = kNativeHeadersPrefix; + for (size_t i = 0; i < num_values_; ++i) + size += 8 + fields_[i].size_ + trimmed_vlen(i); - if (obj->Set(context, - FIXED_ONE_BYTE_STRING(isolate, "length"), - Integer::NewFromUnsigned( - isolate, static_cast(list->pair_count() * 2))) - .IsNothing()) { + Local buf; + if (!Buffer::New(env()->isolate(), size).ToLocal(&buf)) { got_exception_ = true; return Local(); } - return obj; + + char* p = Buffer::Data(buf); + uint32_t flags = 0; + WriteU32(p, kNativeHeadersMagic); + WriteU32(p + 4, static_cast(num_values_)); + p += kNativeHeadersPrefix; + for (size_t i = 0; i < num_values_; ++i) { + const uint32_t nlen = static_cast(fields_[i].size_); + const uint32_t vlen = static_cast(trimmed_vlen(i)); + const char* name = fields_[i].str_ == nullptr ? "" : fields_[i].str_; + flags |= KnownHeaderFlag(name, nlen); + WriteU32(p, nlen); + WriteU32(p + 4, vlen); + p += 8; + if (nlen != 0) + memcpy(p, name, nlen); + p += nlen; + if (vlen != 0 && values_[i].str_ != nullptr) + memcpy(p, values_[i].str_, vlen); + p += vlen; + } + WriteU32(Buffer::Data(buf) + 8, flags); + return buf; } @@ -1629,15 +1666,10 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, SetConstructorFunction(isolate, target, "HTTPParser", t); - Local nh = - NewFunctionTemplate(isolate, NativeHttpHeaders::New); - nh->InstanceTemplate()->SetInternalFieldCount( - NativeHttpHeaders::kInternalFieldCount); - nh->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "NativeHttpHeaders")); - SetProtoMethod(isolate, nh, "has", NativeHttpHeaders::Has); - SetProtoMethod(isolate, nh, "get", NativeHttpHeaders::Get); - SetProtoMethod(isolate, nh, "toArray", NativeHttpHeaders::ToArray); - SetConstructorFunction(isolate, target, "NativeHttpHeaders", nh); + SetMethod(isolate, target, "nativeHeadersHas", NativeHeadersHas); + SetMethod(isolate, target, "nativeHeadersGet", NativeHeadersGet); + SetMethod(isolate, target, "nativeHeadersToArray", NativeHeadersToArray); + SetMethod(isolate, target, "nativeHeadersByteLength", NativeHeadersByteLength); Local c = NewFunctionTemplate(isolate, ConnectionsList::New); @@ -1706,10 +1738,10 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Parser::Unconsume); registry->Register(Parser::GetCurrentBuffer); registry->Register(Parser::SetSkipBody); - registry->Register(NativeHttpHeaders::New); - registry->Register(NativeHttpHeaders::Has); - registry->Register(NativeHttpHeaders::Get); - registry->Register(NativeHttpHeaders::ToArray); + registry->Register(NativeHeadersHas); + registry->Register(NativeHeadersGet); + registry->Register(NativeHeadersToArray); + registry->Register(NativeHeadersByteLength); registry->Register(ConnectionsList::New); registry->Register(ConnectionsList::All); registry->Register(ConnectionsList::Idle); diff --git a/test/parallel/test-http-native-headers.js b/test/parallel/test-http-native-headers.js index ffdadbb739cc..a87354180e5c 100644 --- a/test/parallel/test-http-native-headers.js +++ b/test/parallel/test-http-native-headers.js @@ -14,19 +14,11 @@ const server = http.createServer(common.mustCall((req, res) => { assert.strictEqual(req._getHeader('x-test'), 'one, two'); assert.strictEqual(req._hasBodyHeaders(), false); - // Host / Expect checks must not copy header strings into JS. - const desc = Object.getOwnPropertyDescriptor(req, 'rawHeaders'); - assert.strictEqual(typeof desc.get, 'function'); - assert.ok(Object.hasOwn(req, 'rawHeaders')); - - // First public access materializes JS strings as an own data property. + // First public access materializes JS strings. assert.strictEqual(req.headers.host, `localhost:${server.address().port}`); assert.strictEqual(req.headers['x-test'], 'one, two'); assert.ok(Array.isArray(req.rawHeaders)); assert.ok(req.rawHeaders.includes('X-Test')); - assert.strictEqual( - Object.getOwnPropertyDescriptor(req, 'rawHeaders').value, - req.rawHeaders); res.end('ok'); })); diff --git a/test/parallel/test-http-parser.js b/test/parallel/test-http-parser.js index 47e5f46c651f..abe881ffa7f9 100644 --- a/test/parallel/test-http-parser.js +++ b/test/parallel/test-http-parser.js @@ -23,7 +23,7 @@ const { mustCall, mustNotCall, mustCallAtLeast } = require('../common'); const assert = require('assert'); -const { methods, HTTPParser } = require('_http_common'); +const { methods, HTTPParser, unpackHeaderList } = require('_http_common'); const { REQUEST, RESPONSE } = HTTPParser; const kOnHeaders = HTTPParser.kOnHeaders | 0; @@ -31,15 +31,10 @@ const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; const kOnBody = HTTPParser.kOnBody | 0; const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; -// Fast-path kOnHeadersComplete now passes NativeHttpHeaders (C++-backed) -// instead of a JS string array. Materialize only when the test inspects them. +// Fast-path kOnHeadersComplete now passes a packed Buffer instead of a JS +// string array. Materialize only when the test inspects them. function headerList(headers, fallback) { - if (headers != null && - typeof headers.toArray === 'function' && - !Array.isArray(headers)) { - return headers.toArray(); - } - return headers || fallback || []; + return unpackHeaderList(headers, fallback); } // The purpose of this test is not to check HTTP compliance but to test the