From ed051a3756dc20dc5f27648d90e1cf1ed0dfbc19 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 4 Sep 2026 21:15:15 +0200 Subject: [PATCH] http: reduce per-request allocations in the server Remove allocations and repeated work performed for every request: - reuse a per-connection updateOutgoingData closure and a shared 'finish' listener instead of binding two functions per request, reaching resOnFinish through the connection state stored on the response - cache the ServerResponse options object per server (custom response classes keep receiving a fresh object) - check for Host, Expect, Content-Length and Transfer-Encoding by scanning rawHeaders instead of materializing req.headers, which was built (with per-name toLowerCase calls) for every request even when the application never reads it - cache the rendered status line per status code when the reason phrase is the default, skipping its character validation - cache the complete 'Date: ...' header line in the utcDate cache and the keep-alive header pair for the current server settings - compute the lenient-validation option chain once per message Signed-off-by: Matteo Collina --- lib/_http_incoming.js | 1 + lib/_http_outgoing.js | 50 ++++++++++++++------- lib/_http_server.js | 100 ++++++++++++++++++++++++++++++++++-------- lib/internal/http.js | 10 +++++ 4 files changed, 128 insertions(+), 33 deletions(-) diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js index 067a4cda3e39..2e25d3d0484c 100644 --- a/lib/_http_incoming.js +++ b/lib/_http_incoming.js @@ -529,6 +529,7 @@ function onError(self, error, cb) { module.exports = { IncomingMessage, kDetachAbortSignal, + kHeadersCount, readStart, readStop, }; diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index ebbd24acdcb2..a9d982796749 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -38,7 +38,7 @@ const { getDefaultHighWaterMark } = require('internal/streams/state'); const assert = require('internal/assert'); const EE = require('events'); const Stream = require('stream'); -const { kOutHeaders, utcDate, kNeedDrain } = require('internal/http'); +const { kOutHeaders, utcDateHeader, kNeedDrain } = require('internal/http'); const { Buffer } = require('buffer'); const { _checkIsHttpToken: checkIsHttpToken, @@ -89,6 +89,11 @@ const kChunkedLength = Symbol('kChunkedLength'); const kUniqueHeaders = Symbol('kUniqueHeaders'); const kBytesWritten = Symbol('kBytesWritten'); const kErrored = Symbol('errored'); +const kLenientCache = Symbol('kLenientCache'); + +let keepAliveTimeoutCache = -1; +let keepAliveMaxCache = -1; +let keepAliveHeaderCache = ''; const kWritableFinished = Symbol('kWritableFinished'); const kEndCallbacks = Symbol('kEndCallbacks'); const kFlushError = Symbol('kFlushError'); @@ -169,6 +174,7 @@ function OutgoingMessage(options) { this[kFlushError] = null; this[kHighWaterMark] = options?.highWaterMark ?? getDefaultHighWaterMark(); this[kRejectNonStandardBodyWrites] = options?.rejectNonStandardBodyWrites ?? false; + this[kLenientCache] = null; } ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype); ObjectSetPrototypeOf(OutgoingMessage, Stream); @@ -178,27 +184,34 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + // The underlying options cannot change during the lifetime of a message: + // compute the lookup chain only once per message. + this[kLenientCache] ??= isLenientHeaderValidation(this); + return this[kLenientCache]; +}; + +function isLenientHeaderValidation(msg) { // New httpValidation option takes priority (ClientRequest case) - if (this.httpValidation !== undefined) { - return this.httpValidation !== 'strict'; + if (msg.httpValidation !== undefined) { + return msg.httpValidation !== 'strict'; } // ServerResponse: check server's httpValidation option - const serverHttpValidation = this.req?.socket?.server?.httpValidation; + const serverHttpValidation = msg.req?.socket?.server?.httpValidation; if (serverHttpValidation !== undefined) { return serverHttpValidation !== 'strict'; } // Legacy insecureHTTPParser - ClientRequest has it directly - if (typeof this.insecureHTTPParser === 'boolean') { - return this.insecureHTTPParser; + if (typeof msg.insecureHTTPParser === 'boolean') { + return msg.insecureHTTPParser; } // ServerResponse can access via req.socket.server - const serverOption = this.req?.socket?.server?.insecureHTTPParser; + const serverOption = msg.req?.socket?.server?.insecureHTTPParser; if (typeof serverOption === 'boolean') { return serverOption; } // Fall back to global option return isLenient(); -}; +} ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { __proto__: null, @@ -508,7 +521,7 @@ function _storeHeader(firstLine, headers) { // Date header if (this.sendDate && !state.date) { - header += 'Date: ' + utcDate() + '\r\n'; + header += utcDateHeader(); } // Force the connection to close when the response is a 204 No Content or @@ -541,14 +554,21 @@ function _storeHeader(firstLine, headers) { if (shouldSendKeepAlive && this.maxRequestsOnConnectionReached) { header += 'Connection: close\r\n'; } else if (shouldSendKeepAlive) { - header += 'Connection: keep-alive\r\n'; if (this._keepAliveTimeout && this._defaultKeepAlive) { - const timeoutSeconds = MathFloor(this._keepAliveTimeout / 1000); - let max = ''; - if (~~this._maxRequestsPerSocket > 0) { - max = `, max=${this._maxRequestsPerSocket}`; + // The keep-alive header lines are identical for every response of a + // given server: cache the last rendered value. + const timeout = this._keepAliveTimeout; + const max = ~~this._maxRequestsPerSocket; + if (timeout !== keepAliveTimeoutCache || max !== keepAliveMaxCache) { + keepAliveTimeoutCache = timeout; + keepAliveMaxCache = max; + keepAliveHeaderCache = 'Connection: keep-alive\r\n' + + `Keep-Alive: timeout=${MathFloor(timeout / 1000)}` + + (max > 0 ? `, max=${max}` : '') + '\r\n'; } - header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`; + header += keepAliveHeaderCache; + } else { + header += 'Connection: keep-alive\r\n'; } } else { this._last = true; diff --git a/lib/_http_server.js b/lib/_http_server.js index 6cede195b879..64189a4a6640 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -73,6 +73,7 @@ const { const { IncomingMessage, kDetachAbortSignal, + kHeadersCount, } = require('_http_incoming'); const { ConnResetException, @@ -117,6 +118,10 @@ const kServerResponseStatistics = Symbol('ServerResponseStatistics'); const kUpgradeStream = Symbol('UpgradeStream'); const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption'); +const kConnectionState = Symbol('ConnectionState'); +const kResponseOptions = Symbol('ResponseOptions'); + +const statusLineCache = []; const { hasObserver, @@ -475,10 +480,18 @@ function writeHead(statusCode, reason, obj) { headers = obj; } - if (checkInvalidHeaderChar(this.statusMessage)) - throw new ERR_INVALID_CHAR('statusMessage'); + let statusLine; + if (this.statusMessage === STATUS_CODES[statusCode]) { + // Default reason phrases contain no invalid characters and are shared + // across responses: cache the whole status line. + statusLine = statusLineCache[statusCode] ??= + `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } else { + if (checkInvalidHeaderChar(this.statusMessage)) + throw new ERR_INVALID_CHAR('statusMessage'); - const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`; + } if (statusCode === 204 || statusCode === 304 || (statusCode >= 100 && statusCode <= 199)) { @@ -827,7 +840,11 @@ function connectionListenerInternal(server, socket) { outgoingData: 0, requestsCount: 0, keepAliveTimeoutSet: false, + onPendingData: null, + server, + socket, }; + state.onPendingData = updateOutgoingData.bind(undefined, socket, state); state.onData = socketOnData.bind(undefined, server, socket, parser, state); state.onEnd = socketOnEnd.bind(undefined, @@ -1264,8 +1281,61 @@ function emitCloseNT(self) { } } -function hasBodyHeaders(headers) { - return ('content-length' in headers) || ('transfer-encoding' in headers); +// Check for the presence of a request header by scanning rawHeaders instead +// of materializing the req.headers object. Only the first req[kHeadersCount] +// entries are considered, matching what the req.headers getter exposes. +function hasRequestHeader(req, length, lowerName) { + const rawHeaders = req.rawHeaders; + const count = req[kHeadersCount]; + for (let i = 0; i < count; i += 2) { + const key = rawHeaders[i]; + if (key.length === length && + (key === lowerName || key.toLowerCase() === lowerName)) { + return true; + } + } + return false; +} + +function hasBodyHeaders(req) { + const rawHeaders = req.rawHeaders; + const count = req[kHeadersCount]; + for (let i = 0; i < count; i += 2) { + const length = rawHeaders[i].length; + if (length === 14 || length === 17) { + const key = rawHeaders[i].toLowerCase(); + if (key === 'content-length' || key === 'transfer-encoding') { + return true; + } + } + } + return false; +} + +// The options object passed to the ServerResponse constructor is identical +// for every request of a given server: cache it. Custom response classes get +// a fresh object since they may retain or mutate it. +function getResponseOptions(server, socket) { + const highWaterMark = socket.writableHighWaterMark; + const rejectNonStandardBodyWrites = server.rejectNonStandardBodyWrites; + if (server[kServerResponse] !== ServerResponse) { + return { highWaterMark, rejectNonStandardBodyWrites }; + } + let options = server[kResponseOptions]; + if (options === undefined || + options.highWaterMark !== highWaterMark || + options.rejectNonStandardBodyWrites !== rejectNonStandardBodyWrites) { + options = server[kResponseOptions] = + { highWaterMark, rejectNonStandardBodyWrites }; + } + return options; +} + +// Shared 'finish' listener: everything resOnFinish needs is reachable from +// the response, avoiding a bound function per request. +function onResponseFinish() { + const state = this[kConnectionState]; + resOnFinish(this.req, this, state.socket, state, state.server); } // The following callback is issued after the headers have been read on a @@ -1298,15 +1368,11 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { } } - const res = new server[kServerResponse](req, - { - highWaterMark: socket.writableHighWaterMark, - rejectNonStandardBodyWrites: server.rejectNonStandardBodyWrites, - }); + const res = new server[kServerResponse](req, getResponseOptions(server, socket)); res._keepAliveTimeout = server.keepAliveTimeout; res._maxRequestsPerSocket = server.maxRequestsPerSocket; - res._onPendingData = updateOutgoingData.bind(undefined, - socket, state); + res._onPendingData = state.onPendingData; + res[kConnectionState] = state; res.shouldKeepAlive = keepAlive; res[kUniqueHeaders] = server[kUniqueHeaders]; @@ -1321,7 +1387,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 @@ -1342,9 +1408,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) { // When we're finished writing the response, check if this is the last // response, if so destroy the socket. - res.on('finish', - resOnFinish.bind(undefined, - req, res, socket, state, server)); + res.on('finish', onResponseFinish); let handled = false; @@ -1354,7 +1418,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 && !hasRequestHeader(req, 4, 'host')) { res.writeHead(400, ['Connection', 'close']); res.end(); return 0; @@ -1377,7 +1441,7 @@ 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 (hasRequestHeader(req, 6, 'expect')) { handled = true; if (continueExpression.test(req.headers.expect)) { diff --git a/lib/internal/http.js b/lib/internal/http.js index 304ef04d6638..6e8048174f59 100644 --- a/lib/internal/http.js +++ b/lib/internal/http.js @@ -22,12 +22,20 @@ const { Buffer } = require('buffer'); const { isIPv4 } = require('internal/net'); const { ERR_PROXY_INVALID_CONFIG } = require('internal/errors').codes; let utcCache; +let utcHeaderCache; function utcDate() { if (!utcCache) cache(); return utcCache; } +// The complete `Date: ...\r\n` header line, cached alongside utcDate() so +// that the serializer does not re-concatenate it for every response. +function utcDateHeader() { + utcHeaderCache ||= 'Date: ' + utcDate() + '\r\n'; + return utcHeaderCache; +} + function cache() { const d = new Date(); utcCache = d.toUTCString(); @@ -36,6 +44,7 @@ function cache() { function resetCache() { utcCache = undefined; + utcHeaderCache = undefined; } let traceEventId = 0; @@ -277,6 +286,7 @@ module.exports = { checkShouldUseProxy, parseProxyConfigFromEnv, utcDate, + utcDateHeader, traceBegin, traceEnd, getNextTraceEventId,