From 8a0057a64f7dcf385332b26ea14cf8cff74fd97f Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 9 Jul 2026 11:04:39 -0400 Subject: [PATCH 1/3] http: add native single-shot response builder Serialize the common HTTP/1.1 header block (and optional body) in C++ so _storeHeader and ServerResponse.end avoid repeated JS string concatenation and multi-write path overhead. The public API and wire format are unchanged; complex cases fall back to the existing JS path. On benchmark/http/simple.js (bytes/4/1, 50 connections, 5s) this raised throughput from ~80k to ~118-126k req/s (~+48% to +58%) on the same machine. Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 639 ++++++++++++++++++++++++++++++++++----- src/node_http_parser.cc | 640 ++++++++++++++++++++++++++++++++-------- 2 files changed, 1088 insertions(+), 191 deletions(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 7694fe4b5b3f3e..55aef70a9ea5e3 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -32,6 +32,7 @@ const { ObjectValues, SafeSet, Symbol, + Uint32Array, } = primordials; const { getDefaultHighWaterMark } = require('internal/streams/state'); @@ -74,11 +75,16 @@ const { validateString } = require('internal/validators'); const { assignFunctionName } = require('internal/util'); const { isUint8Array } = require('internal/util/types'); +const { + buildHttpMessage, +} = internalBinding('http_parser'); + let debug = require('internal/util/debuglog').debuglog('http', (fn) => { debug = fn; }); const kCorked = Symbol('corked'); +const kLenientValidation = Symbol('kLenientValidation'); const kSocket = Symbol('kSocket'); const kChunkedBuffer = Symbol('kChunkedBuffer'); const kChunkedLength = Symbol('kChunkedLength'); @@ -92,6 +98,35 @@ const nop = () => {}; const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; +// Keep in sync with BuildHttpMessageFlags in src/node_http_parser.cc. +const kBuildSendDate = 1 << 0; +const kBuildShouldKeepAlive = 1 << 1; +const kBuildMaxRequestsReached = 1 << 2; +const kBuildDefaultKeepAlive = 1 << 3; +const kBuildHasBody = 1 << 4; +const kBuildUseChunkedByDefault = 1 << 5; +const kBuildRemovedConnection = 1 << 6; +const kBuildRemovedContLen = 1 << 7; +const kBuildRemovedTE = 1 << 8; +const kBuildHasAgent = 1 << 9; + +// Keep in sync with BuildHttpMessageOut in src/node_http_parser.cc. +const kOutLast = 0; +const kOutChunked = 1; +const kOutContentLength = 2; +const kOutHasContentLength = 3; +const kOutCount = 4; + +// Reused across calls to avoid allocating a Uint32Array per response. +const buildHttpMessageOut = new Uint32Array(kOutCount); + +// Lazy to avoid a require cycle with _http_server. +let _statusCodes; +function statusCodes() { + _statusCodes ??= require('_http_server').STATUS_CODES; + return _statusCodes; +} + // 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. @@ -143,6 +178,7 @@ function OutgoingMessage(options) { this[kChunkedBuffer] = []; this[kChunkedLength] = 0; this._closed = false; + this[kLenientValidation] = undefined; this[kSocket] = null; this._header = null; @@ -163,28 +199,39 @@ ObjectSetPrototypeOf(OutgoingMessage, Stream); // For ClientRequest: checks this.httpValidation or this.insecureHTTPParser // For ServerResponse: checks the server's httpValidation or insecureHTTPParser // Falls back to global --insecure-http-parser flag. +// The answer is invariant for the lifetime of the message (the options are +// fixed at construction time), but this runs on every setHeader/appendHeader +// call and once per stored header block, so the multi-step lookup chain is +// resolved once per message and cached. OutgoingMessage.prototype._isLenientHeaderValidation = function() { + const cached = this[kLenientValidation]; + if (cached !== undefined) + return cached; + return this[kLenientValidation] = lenientHeaderValidation(this); +}; + +function lenientHeaderValidation(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, @@ -315,11 +362,12 @@ OutgoingMessage.prototype.uncork = function uncork() { callbacks.push(buf[n + 2]); } } - this._send(crlf_buf, null, callbacks.length ? (err) => { - for (const callback of callbacks) { - callback(err); - } - } : null); + // The runner is a shared top-level function so flushing a corked + // chunked buffer does not allocate a fresh closure environment per + // flush. + this._send(crlf_buf, null, callbacks?.length ? + runChunkCallbacks.bind(undefined, callbacks) : + null); this[kChunkedBuffer].length = 0; this[kChunkedLength] = 0; @@ -381,14 +429,50 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // This is a shameful hack to get the headers and first body chunk onto // the same packet. Future versions of Node are going to take care of // this at a lower level and in a more general way. - if (!this._headerSent && this._header !== null) { + let header; + if (!this._headerSent && (header = this._header) !== null) { // `this._header` can be null if OutgoingMessage is used without a proper Socket // See: /test/parallel/test-http-outgoing-message-inheritance.js - if (typeof data === 'string' && - (encoding === 'utf8' || encoding === 'latin1' || !encoding)) { - data = this._header + data; + // + // Headers are stored as a latin1 JS string of the on-wire bytes. If we + // naively do `header + body` and write as UTF-8, high-byte header values + // (e.g. content-disposition) get double-encoded. Prefer Buffer concat + // when the body is present; pure header flushes use latin1. + if (typeof data === 'string' && data.length > 0 && + (encoding === 'latin1' || encoding === 'binary')) { + data = header + data; + encoding = 'latin1'; + } else if (typeof data === 'string' && data.length > 0 && + (encoding === 'utf8' || encoding === 'utf-8' || !encoding)) { + // ASCII headers can concatenate as a string under UTF-8; high-byte + // latin1 header values must be Buffer-concatenated to avoid double + // encoding on the wire. + let ascii = true; + for (let i = 0; i < header.length; i++) { + if (header.charCodeAt(i) > 127) { + ascii = false; + break; + } + } + if (ascii) { + data = header + data; + } else { + const bodyBuf = Buffer.from(data, encoding || 'utf8'); + data = Buffer.concat([Buffer.from(header, 'latin1'), bodyBuf]); + encoding = 'buffer'; + byteLength = data.byteLength; + } + } else if (isUint8Array(data) && data.byteLength > 0) { + data = Buffer.concat([Buffer.from(header, 'latin1'), data]); + encoding = 'buffer'; + byteLength = data.byteLength; + } else if (typeof data === 'string' && data.length === 0) { + // Header-only flush (end without body, Expect: 100-continue, etc.). + data = header; + encoding = 'latin1'; } else { - const header = this._header; + // Non-utf8/latin1 body encodings (hex, utf16le, base64, ...): keep the + // header as a separate latin1 write so body encoding is unchanged. this.outputData.unshift({ data: header, encoding: 'latin1', @@ -434,6 +518,202 @@ function _writeRaw(data, encoding, callback, size) { OutgoingMessage.prototype._storeHeader = _storeHeader; function _storeHeader(firstLine, headers) { + // Prefer the C++ single-buffer builder. Falls back to the legacy JS path + // only when headers need JS-only special handling (content-disposition + // latin1, unique-header cookie joining, etc.). + if (tryNativeStoreHeader(this, firstLine, headers)) { + return; + } + legacyStoreHeader(this, firstLine, headers); +} + +function buildFlags(msg) { + let flags = 0; + if (msg.sendDate) flags |= kBuildSendDate; + if (msg.shouldKeepAlive) flags |= kBuildShouldKeepAlive; + if (msg.maxRequestsOnConnectionReached) flags |= kBuildMaxRequestsReached; + if (msg._defaultKeepAlive) flags |= kBuildDefaultKeepAlive; + if (msg._hasBody) flags |= kBuildHasBody; + // useChunkedEncodingByDefault and agent are independent: agent only + // participates in keep-alive decisions, not Content-Length emission. + if (msg.useChunkedEncodingByDefault) flags |= kBuildUseChunkedByDefault; + if (msg._removedConnection) flags |= kBuildRemovedConnection; + if (msg._removedContLen) flags |= kBuildRemovedContLen; + if (msg._removedTE) flags |= kBuildRemovedTE; + if (msg.agent) flags |= kBuildHasAgent; + return flags; +} + +function applyBuildResult(msg, out) { + if (out[kOutLast]) msg._last = true; + if (out[kOutChunked]) msg.chunkedEncoding = true; + else msg.chunkedEncoding = false; + if (out[kOutHasContentLength]) + msg._contentLength = out[kOutContentLength]; +} + +// Flatten headers into [name, value, ...] for the C++ builder. Returns null +// when a header requires the legacy JS path. +function flattenHeadersForNative(msg, headers, validate) { + const flat = []; + const lenient = msg._isLenientHeaderValidation(); + + function pushPair(key, value, doValidate) { + if (doValidate) { + validateHeaderName(key); + } + // content-disposition + content-length needs latin1 Buffer values - JS only. + if (isContentDispositionField(key) && msg._contentLength) { + return false; + } + if (ArrayIsArray(value)) { + const valueLength = value.length; + if ( + (valueLength < 2 || !isCookieField(key)) && + (!msg[kUniqueHeaders] || !msg[kUniqueHeaders].has(key.toLowerCase())) + ) { + for (let i = 0; i < valueLength; i++) { + if (doValidate) validateHeaderValue(key, value[i], lenient); + // Buffers not supported on the native path. + if (typeof value[i] !== 'string') return false; + flat.push(key, value[i]); + } + return true; + } + value = value.join('; '); + } + if (doValidate) validateHeaderValue(key, value, lenient); + if (typeof value !== 'string') return false; + flat.push(key, value); + return true; + } + + if (!headers) return flat; + + if (headers === msg[kOutHeaders]) { + for (const key in headers) { + const entry = headers[key]; + if (!pushPair(entry[0], entry[1], false)) return null; + } + return flat; + } + + if (ArrayIsArray(headers)) { + const headersLength = headers.length; + if (headersLength && ArrayIsArray(headers[0])) { + for (let i = 0; i < headersLength; i++) { + const entry = headers[i]; + if (!pushPair(entry[0], entry[1], validate)) return null; + } + } else { + if (headersLength % 2 !== 0) { + throw new ERR_INVALID_ARG_VALUE('headers', headers); + } + for (let n = 0; n < headersLength; n += 2) { + if (!pushPair(headers[n], headers[n + 1], validate)) return null; + } + } + return flat; + } + + for (const key in headers) { + if (ObjectHasOwn(headers, key)) { + if (!pushPair(key, headers[key], validate)) return null; + } + } + return flat; +} + +function headerListHasChunkedTE(flat) { + for (let i = 0; i < flat.length; i += 2) { + const name = flat[i]; + if (name.length === 17 && name.toLowerCase() === 'transfer-encoding') { + if (RE_TE_CHUNKED.test(flat[i + 1])) return true; + } + } + return false; +} + +function tryNativeStoreHeader(msg, firstLine, headers) { + const flat = flattenHeadersForNative( + msg, headers, headers !== msg[kOutHeaders]); + if (flat === null) return false; + + // Force the connection to close when the response is a 204 No Content or + // a 304 Not Modified and the user has set a "Transfer-Encoding: chunked" + // header. Must run before building so Connection: close is emitted. + // RFC 2616: 204/304 MUST NOT have a body; keep TE on the wire for compat + // but do not actually chunk-encode, and close the connection. + const noBodyStatus = msg.statusCode === 204 || msg.statusCode === 304; + if (noBodyStatus && (msg.chunkedEncoding || headerListHasChunkedTE(flat))) { + debug(msg.statusCode + ' response should not use chunked encoding,' + + ' closing connection.'); + msg.chunkedEncoding = false; + msg.shouldKeepAlive = false; + } + + const knownLength = + typeof msg._contentLength === 'number' ? msg._contentLength : -1; + const keepAliveSec = msg._keepAliveTimeout ? + MathFloor(msg._keepAliveTimeout / 1000) : + 0; + const maxReq = msg._maxRequestsPerSocket | 0; + const date = msg.sendDate ? utcDate() : ''; + + buildHttpMessageOut[0] = 0; + buildHttpMessageOut[1] = 0; + buildHttpMessageOut[2] = 0; + buildHttpMessageOut[3] = 0; + + const buf = buildHttpMessage( + firstLine, + flat, + null, + 0, + buildFlags(msg), + date, + keepAliveSec, + maxReq, + knownLength, + buildHttpMessageOut, + ); + if (!buf) return false; + + applyBuildResult(msg, buildHttpMessageOut); + + if (noBodyStatus) { + msg.chunkedEncoding = false; + } + + // Trailer without chunked is invalid (same check as legacy path). + // The C++ builder does not throw; detect trailer presence cheaply. + for (let i = 0; i < flat.length; i += 2) { + const name = flat[i]; + if (name.length === 7 && (name === 'Trailer' || name === 'trailer' || + name.toLowerCase() === 'trailer') && !msg.chunkedEncoding) { + throw new ERR_HTTP_TRAILER_INVALID(); + } + } + + // latin1 string of the wire bytes. _send() must write this as latin1 (or + // Buffer-concat) rather than UTF-8 so high-byte values (content-disposition) + // are not double-encoded. + msg._header = buf.toString('latin1'); + msg._headerSent = false; + + // Expect: 100-continue forces an early header flush (legacy behavior). + for (let i = 0; i < flat.length; i += 2) { + const name = flat[i]; + if (name.length === 6 && (name === 'Expect' || name === 'expect' || + name.toLowerCase() === 'expect')) { + msg._send(''); + break; + } + } + return true; +} + +function legacyStoreHeader(self, firstLine, headers) { // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n' // in the case of response it is: 'HTTP/1.1 200 OK\r\n' const state = { @@ -445,33 +725,37 @@ function _storeHeader(firstLine, headers) { trailer: false, header: firstLine, }; - const lenient = this._isLenientHeaderValidation(); + const lenient = self._isLenientHeaderValidation(); if (headers) { - if (headers === this[kOutHeaders]) { + if (headers === self[kOutHeaders]) { for (const key in headers) { const entry = headers[key]; - processHeader(this, state, entry[0], entry[1], false, lenient); + processHeader(self, state, entry[0], entry[1], false, lenient); } } else if (ArrayIsArray(headers)) { - if (headers.length && ArrayIsArray(headers[0])) { - for (let i = 0; i < headers.length; i++) { + // The length is hoisted into a local because the engine cannot fold + // the reload across the processHeader calls; the array is never + // mutated while this loop runs. + const headersLength = headers.length; + if (headersLength && ArrayIsArray(headers[0])) { + for (let i = 0; i < headersLength; i++) { const entry = headers[i]; - processHeader(this, state, entry[0], entry[1], true, lenient); + processHeader(self, state, entry[0], entry[1], true, lenient); } } else { - if (headers.length % 2 !== 0) { + if (headersLength % 2 !== 0) { throw new ERR_INVALID_ARG_VALUE('headers', headers); } - for (let n = 0; n < headers.length; n += 2) { - processHeader(this, state, headers[n + 0], headers[n + 1], true, lenient); + for (let n = 0; n < headersLength; n += 2) { + processHeader(self, state, headers[n + 0], headers[n + 1], true, lenient); } } } else { for (const key in headers) { if (ObjectHasOwn(headers, key)) { - processHeader(this, state, key, headers[key], true, lenient); + processHeader(self, state, key, headers[key], true, lenient); } } } @@ -480,7 +764,7 @@ function _storeHeader(firstLine, headers) { let { header } = state; // Date header - if (this.sendDate && !state.date) { + if (self.sendDate && !state.date) { header += 'Date: ' + utcDate() + '\r\n'; } @@ -495,53 +779,56 @@ function _storeHeader(firstLine, headers) { // It was pointed out that this might confuse reverse proxies to the point // of creating security liabilities, so suppress the zero chunk and force // the connection to close. - if (this.chunkedEncoding && (this.statusCode === 204 || - this.statusCode === 304)) { - debug(this.statusCode + ' response should not use chunked encoding,' + + if (self.chunkedEncoding && (self.statusCode === 204 || + self.statusCode === 304)) { + debug(self.statusCode + ' response should not use chunked encoding,' + ' closing connection.'); - this.chunkedEncoding = false; - this.shouldKeepAlive = false; + self.chunkedEncoding = false; + self.shouldKeepAlive = false; } // keep-alive logic - if (this._removedConnection) { + if (self._removedConnection) { // shouldKeepAlive is generally true for HTTP/1.1. In that common case, // even if the connection header isn't sent, we still persist by default. - this._last = !this.shouldKeepAlive; + self._last = !self.shouldKeepAlive; } else if (!state.connection) { - const shouldSendKeepAlive = this.shouldKeepAlive && - (state.contLen || this.useChunkedEncodingByDefault || this.agent); - if (shouldSendKeepAlive && this.maxRequestsOnConnectionReached) { + const shouldSendKeepAlive = self.shouldKeepAlive && + (state.contLen || self.useChunkedEncodingByDefault || self.agent); + if (shouldSendKeepAlive && self.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); + const keepAliveTimeout = self._keepAliveTimeout; + if (keepAliveTimeout && self._defaultKeepAlive) { + const timeoutSeconds = MathFloor(keepAliveTimeout / 1000); + const maxRequestsPerSocket = self._maxRequestsPerSocket; let max = ''; - if (~~this._maxRequestsPerSocket > 0) { - max = `, max=${this._maxRequestsPerSocket}`; + if (~~maxRequestsPerSocket > 0) { + max = `, max=${maxRequestsPerSocket}`; } header += `Keep-Alive: timeout=${timeoutSeconds}${max}\r\n`; } } else { - this._last = true; + self._last = true; header += 'Connection: close\r\n'; } } + let contentLength; if (!state.contLen && !state.te) { - if (!this._hasBody) { + if (!self._hasBody) { // Make sure we don't end the 0\r\n\r\n at the end of the message. - this.chunkedEncoding = false; - } else if (!this.useChunkedEncodingByDefault) { - this._last = true; + self.chunkedEncoding = false; + } else if (!self.useChunkedEncodingByDefault) { + self._last = true; } else if (!state.trailer && - !this._removedContLen && - typeof this._contentLength === 'number') { - header += 'Content-Length: ' + this._contentLength + '\r\n'; - } else if (!this._removedTE) { + !self._removedContLen && + typeof (contentLength = self._contentLength) === 'number') { + header += 'Content-Length: ' + contentLength + '\r\n'; + } else if (!self._removedTE) { header += 'Transfer-Encoding: chunked\r\n'; - this.chunkedEncoding = true; + self.chunkedEncoding = true; } else { // We should only be able to get here if both Content-Length and // Transfer-Encoding are removed by the user. @@ -550,7 +837,7 @@ function _storeHeader(firstLine, headers) { // We can't keep alive in this case, because with no header info the body // is defined as all data until the connection is closed. - this._last = true; + self._last = true; } } @@ -558,16 +845,16 @@ function _storeHeader(firstLine, headers) { // message will be terminated by the first empty line after the // header fields, regardless of the header fields present in the // message, and thus cannot contain a message body or 'trailers'. - if (this.chunkedEncoding !== true && state.trailer) { + if (self.chunkedEncoding !== true && state.trailer) { throw new ERR_HTTP_TRAILER_INVALID(); } - this._header = header + '\r\n'; - this._headerSent = false; + self._header = header + '\r\n'; + self._headerSent = false; // Wait until the first body chunk, or close(), is sent to flush, // UNLESS we're sending Expect: 100-continue. - if (state.expect) this._send(''); + if (state.expect) self._send(''); } function processHeader(self, state, key, value, validate, lenient) { @@ -590,13 +877,14 @@ function processHeader(self, state, key, value, validate, lenient) { } if (ArrayIsArray(value)) { + const valueLength = value.length; if ( - (value.length < 2 || !isCookieField(key)) && + (valueLength < 2 || !isCookieField(key)) && (!self[kUniqueHeaders] || !self[kUniqueHeaders].has(key.toLowerCase())) ) { // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 - for (let i = 0; i < value.length; i++) + for (let i = 0; i < valueLength; i++) storeHeader(self, state, key, value[i], validate, lenient); return; } @@ -613,8 +901,23 @@ function storeHeader(self, state, key, value, validate, lenient) { } function matchHeader(self, state, field, value) { - if (field.length < 4 || field.length > 17) - return; + const len = field.length; + // Cheap (length, first character) pre-filter so the common case, a + // header that is not one of the eight known fields below, returns + // without paying the per-header toLowerCase() allocation. The filter + // only rejects names that cannot possibly match the switch: `| 0x20` + // lower-cases ASCII letters and maps no other token character onto a + // letter, and every known field length is enumerated. + const c = field.charCodeAt(0) | 0x20; + switch (len) { + case 4: if (c !== 0x64) return; break; // Date + case 6: if (c !== 0x65) return; break; // Expect + case 7: if (c !== 0x74) return; break; // Trailer + case 10: if (c !== 0x63 && c !== 0x6b) return; break; // Connection, Keep-Alive + case 14: if (c !== 0x63) return; break; // Content-Length + case 17: if (c !== 0x74) return; break; // Transfer-Encoding + default: return; // No known field has this length + } field = field.toLowerCase(); switch (field) { case 'connection': @@ -997,9 +1300,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } - if (!fromEnd && msg.socket && !msg.socket.writableCorked) { - msg.socket.cork(); - process.nextTick(connectionCorkNT, msg.socket); + // `socket` is an accessor on the prototype: load it once instead of + // paying the getter three times on every body write. + let socket; + if (!fromEnd && (socket = msg.socket) && !socket.writableCorked) { + socket.cork(); + process.nextTick(connectionCorkNT, socket); } let ret; @@ -1028,6 +1334,12 @@ function connectionCorkNT(conn) { conn.uncork(); } +function runChunkCallbacks(callbacks, err) { + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](err); + } +} + OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { if (this.finished) { throw new ERR_HTTP_HEADERS_SENT('set trailing'); @@ -1036,6 +1348,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { this._trailer = ''; const keys = ObjectKeys(headers); const isArray = ArrayIsArray(headers); + const lenient = this._isLenientHeaderValidation(); // Retain for(;;) loop for performance reasons // Refs: https://github.com/nodejs/node/pull/30958 for (let i = 0, l = keys.length; i < l; i++) { @@ -1052,7 +1365,6 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { // Check if the field must be sent several times const isArrayValue = ArrayIsArray(value); - const lenient = this._isLenientHeaderValidation(); if ( isArrayValue && value.length > 1 && (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) @@ -1093,6 +1405,13 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { encoding = null; } + // Single-shot fast path: headers not yet rendered, socket assigned, one + // body chunk (or empty). Builds the entire HTTP message in C++ and issues + // a single socket.write(). + if (tryFastEnd(this, chunk, encoding, callback)) { + return this; + } + if (chunk) { if (this.finished) { onError(this, @@ -1105,7 +1424,17 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this[kSocket].cork(); } - write_(this, chunk, encoding, null, true); + try { + write_(this, chunk, encoding, null, true); + } catch (err) { + // write_() can throw on invalid chunk types before any data is + // queued; undo the cork so a subsequent end() is not stuck. + if (this[kSocket]) { + this[kSocket]._writableState.corked = 1; + this[kSocket].uncork(); + } + throw err; + } } else if (this.finished) { if (typeof callback === 'function') { if (!this.writableFinished) { @@ -1163,6 +1492,184 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { return this; }; +function getFastFirstLine(msg) { + // ServerResponse + if (typeof msg.statusCode === 'number') { + const statusCode = msg.statusCode | 0; + if (statusCode < 100 || statusCode > 999) return null; + let statusMessage = msg.statusMessage; + if (statusMessage === undefined || statusMessage === null) { + statusMessage = statusCodes()[statusCode] || 'unknown'; + msg.statusMessage = statusMessage; + } + if (checkInvalidHeaderChar(statusMessage)) { + throw new ERR_INVALID_CHAR('statusMessage'); + } + // Align with writeHead() side effects for informational / no-body codes. + if (statusCode === 204 || statusCode === 304 || + (statusCode >= 100 && statusCode <= 199)) { + msg._hasBody = false; + } + if (msg._expect_continue && !msg._sent100) { + msg.shouldKeepAlive = false; + } + return `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`; + } + // ClientRequest + if (typeof msg.method === 'string' && typeof msg.path === 'string') { + return msg.method + ' ' + msg.path + ' HTTP/1.1\r\n'; + } + return null; +} + +function tryFastEnd(msg, chunk, encoding, callback) { + // ServerResponse only. ClientRequest has method-specific Content-Length + // rules (GET/HEAD/DELETE must not emit CL:0) and CONNECT tunnel framing + // that the C++ builder does not model. + if (typeof msg.statusCode !== 'number') + return false; + + // Preconditions: nothing rendered yet, socket ready, no queued output, + // no trailers, not already finished/destroyed. + if (msg._header || msg._headerSent || msg.finished || msg.destroyed) + return false; + if (msg._trailer) return false; + if (msg.outputData.length !== 0) return false; + if (msg[kCorked]) return false; + if (msg._expect_continue && !msg._sent100) return false; + + const socket = msg[kSocket]; + // Require a real net.Socket (has a libuv _handle). Standalone + // ServerResponse tests assign arbitrary Writables that must keep the + // multi-write _send() behaviour. + if (!socket || socket._httpMessage !== msg || !socket.writable || + socket.destroyed || !socket._writableState || !socket._handle) { + return false; + } + + // Body encoding must be latin1/utf8/buffer (what the C++ builder supports). + let body = null; + let bodyEncoding = 0; // 0 = buffer/none + let bodyLen = 0; + if (chunk) { + if (typeof chunk === 'string') { + if (encoding && encoding !== 'utf8' && encoding !== 'utf-8' && + encoding !== 'latin1' && encoding !== 'binary' && + encoding !== 'ascii') { + return false; + } + body = chunk; + if (encoding === 'latin1' || encoding === 'binary' || + encoding === 'ascii') { + bodyEncoding = 1; + bodyLen = chunk.length; + } else { + bodyEncoding = 2; + bodyLen = Buffer.byteLength(chunk, 'utf8'); + } + } else if (isUint8Array(chunk)) { + body = chunk; + bodyEncoding = 0; + bodyLen = chunk.byteLength; + } else { + return false; + } + } + + if (!msg._hasBody && body && bodyLen > 0) { + if (msg[kRejectNonStandardBodyWrites]) { + throw new ERR_HTTP_BODY_NOT_ALLOWED(); + } + // Legacy: ignore body for HEAD / 204 / 304. + body = null; + bodyLen = 0; + } + + const firstLine = getFastFirstLine(msg); + if (firstLine === null) return false; + + let flat; + try { + flat = flattenHeadersForNative(msg, msg[kOutHeaders], false); + } catch { + return false; + } + if (flat === null) return false; + + if (body && msg._hasBody) { + msg._contentLength = bodyLen; + } else if (!msg._header) { + msg._contentLength = 0; + } + + const knownLength = + typeof msg._contentLength === 'number' ? msg._contentLength : -1; + const keepAliveSec = msg._keepAliveTimeout ? + MathFloor(msg._keepAliveTimeout / 1000) : + 0; + const maxReq = msg._maxRequestsPerSocket | 0; + const date = msg.sendDate ? utcDate() : ''; + + buildHttpMessageOut[0] = 0; + buildHttpMessageOut[1] = 0; + buildHttpMessageOut[2] = 0; + buildHttpMessageOut[3] = 0; + + const buf = buildHttpMessage( + firstLine, + flat, + body, + bodyEncoding, + buildFlags(msg), + date, + keepAliveSec, + maxReq, + knownLength, + buildHttpMessageOut, + ); + if (!buf) return false; + + applyBuildResult(msg, buildHttpMessageOut); + + if (msg.chunkedEncoding && (msg.statusCode === 204 || + msg.statusCode === 304)) { + // Should not happen with single-shot body preference, but stay safe. + return false; + } + + if (typeof callback === 'function') + msg.once('finish', callback); + + // Mark headers as rendered+sent so subsequent writes fail correctly. + msg._header = buf.toString('latin1'); + msg._headerSent = true; + msg.finished = true; + if (bodyLen && msg._hasBody) { + msg[kBytesWritten] = bodyLen; + } + + const finish = onFinish.bind(undefined, msg); + + // The socket is typically corked by OutgoingMessage.socket setter / + // assignSocket. Mirror end()'s uncork so the single write is flushed; + // without this the write callback never runs and the response hangs. + if (!socket.writableCorked) { + socket.cork(); + } + const ret = socket.write(buf, finish); + // Force a full uncork (same as the slow end() path). + socket._writableState.corked = 1; + socket.uncork(); + + if (!ret) msg[kNeedDrain] = true; + + debug('outgoing message fast end.'); + if (msg.outputData.length === 0 && socket._httpMessage === msg) { + msg._finish(); + } + return true; +} + // This function is called once all user data are flushed to the socket. // Note that it has a chance that the socket is not drained. diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index d99b61780fad6c..b7fef483954bee 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -31,9 +31,11 @@ #include "stream_base-inl.h" #include "v8.h" +#include // snprintf #include // free() #include // strdup(), strchr() - +#include +#include // This is a binding to llhttp (https://github.com/nodejs/llhttp) // The goal is to decouple sockets from parsing for more javascript-level @@ -46,7 +48,6 @@ // No copying is performed when slicing the buffer, only small reference // allocations. - namespace node { namespace http_parser { // NOLINT(build/namespaces) @@ -70,6 +71,7 @@ using v8::Object; using v8::ObjectTemplate; using v8::String; using v8::Uint32; +using v8::Uint32Array; using v8::Undefined; using v8::Value; @@ -252,44 +254,36 @@ struct ParserComparator { class ConnectionsList : public BaseObject { public: - static void New(const FunctionCallbackInfo& args); + static void New(const FunctionCallbackInfo& args); - static void All(const FunctionCallbackInfo& args); + static void All(const FunctionCallbackInfo& args); - static void Idle(const FunctionCallbackInfo& args); + static void Idle(const FunctionCallbackInfo& args); - static void Active(const FunctionCallbackInfo& args); + static void Active(const FunctionCallbackInfo& args); - static void Expired(const FunctionCallbackInfo& args); + static void Expired(const FunctionCallbackInfo& args); - void Push(Parser* parser) { - all_connections_.insert(parser); - } + void Push(Parser* parser) { all_connections_.insert(parser); } - void Pop(Parser* parser) { - all_connections_.erase(parser); - } + void Pop(Parser* parser) { all_connections_.erase(parser); } - void PushActive(Parser* parser) { - active_connections_.insert(parser); - } + void PushActive(Parser* parser) { active_connections_.insert(parser); } - void PopActive(Parser* parser) { - active_connections_.erase(parser); - } + void PopActive(Parser* parser) { active_connections_.erase(parser); } - SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(ConnectionsList) - SET_SELF_SIZE(ConnectionsList) + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(ConnectionsList) + SET_SELF_SIZE(ConnectionsList) private: - ConnectionsList(Environment* env, Local object) + ConnectionsList(Environment* env, Local object) : BaseObject(env, object) { - MakeWeak(); - } + MakeWeak(); + } - std::set all_connections_; - std::set active_connections_; + std::set all_connections_; + std::set active_connections_; }; class Parser : public AsyncWrap, public StreamListener { @@ -329,14 +323,14 @@ class Parser : public AsyncWrap, public StreamListener { connectionsList_->PushActive(this); } - Local cb = object()->Get(env()->context(), kOnMessageBegin) - .ToLocalChecked(); + Local cb = + object()->Get(env()->context(), kOnMessageBegin).ToLocalChecked(); if (cb->IsFunction()) { InternalCallbackScope callback_scope( - this, InternalCallbackScope::kSkipTaskQueues); + this, InternalCallbackScope::kSkipTaskQueues); - MaybeLocal r = cb.As()->Call( - env()->context(), object(), 0, nullptr); + MaybeLocal r = + cb.As()->Call(env()->context(), object(), 0, nullptr); if (r.IsEmpty()) callback_scope.MarkAsFailed(); } @@ -344,7 +338,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_url(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -355,7 +348,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_status(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -366,7 +358,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_header_field(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -393,7 +384,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_header_value(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -414,7 +404,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_headers_complete() { headers_completed_ = true; header_nread_ = 0; @@ -437,15 +426,13 @@ class Parser : public AsyncWrap, public StreamListener { Local argv[A_MAX]; Local obj = object(); - Local cb = obj->Get(env()->context(), - kOnHeadersComplete).ToLocalChecked(); + Local cb = + obj->Get(env()->context(), kOnHeadersComplete).ToLocalChecked(); - if (!cb->IsFunction()) - return 0; + if (!cb->IsFunction()) return 0; Local undefined = Undefined(env()->isolate()); - for (size_t i = 0; i < arraysize(argv); i++) - argv[i] = undefined; + for (size_t i = 0; i < arraysize(argv); i++) argv[i] = undefined; if (have_flushed_) { // Slow case, flush remaining headers. @@ -453,8 +440,7 @@ class Parser : public AsyncWrap, public StreamListener { } else { // Fast case, pass headers and URL to JS land. argv[A_HEADERS] = CreateHeaders(); - if (parser_.type == HTTP_REQUEST) - argv[A_URL] = url_.ToString(env()); + if (parser_.type == HTTP_REQUEST) argv[A_URL] = url_.ToString(env()); } num_fields_ = 0; @@ -468,8 +454,7 @@ class Parser : public AsyncWrap, public StreamListener { // STATUS if (parser_.type == HTTP_RESPONSE) { - argv[A_STATUS_CODE] = - Integer::New(env()->isolate(), parser_.status_code); + argv[A_STATUS_CODE] = Integer::New(env()->isolate(), parser_.status_code); argv[A_STATUS_MESSAGE] = status_message_.ToString(env()); } @@ -506,18 +491,15 @@ class Parser : public AsyncWrap, public StreamListener { return static_cast(val); } - int on_body(const char* at, size_t length) { - if (length == 0) - return 0; + if (length == 0) return 0; Environment* env = this->env(); HandleScope handle_scope(env->isolate()); Local cb = object()->Get(env->context(), kOnBody).ToLocalChecked(); - if (!cb->IsFunction()) - return 0; + if (!cb->IsFunction()) return 0; Local buffer = Buffer::Copy(env, at, length).ToLocalChecked(); @@ -532,7 +514,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int on_message_complete() { HandleScope scope(env()->isolate()); @@ -549,15 +530,13 @@ class Parser : public AsyncWrap, public StreamListener { connectionsList_->Push(this); } - if (num_fields_) - Flush(); // Flush trailing HTTP headers. + if (num_fields_) Flush(); // Flush trailing HTTP headers. Local obj = object(); - Local cb = obj->Get(env()->context(), - kOnMessageComplete).ToLocalChecked(); + Local cb = + obj->Get(env()->context(), kOnMessageComplete).ToLocalChecked(); - if (!cb->IsFunction()) - return 0; + if (!cb->IsFunction()) return 0; MaybeLocal r; { @@ -579,8 +558,8 @@ class Parser : public AsyncWrap, public StreamListener { chunk_extensions_nread_ += length; if (chunk_extensions_nread_ > kMaxChunkExtensionsSize) { - llhttp_set_error_reason(&parser_, - "HPE_CHUNK_EXTENSIONS_OVERFLOW:Chunk extensions overflow"); + llhttp_set_error_reason( + &parser_, "HPE_CHUNK_EXTENSIONS_OVERFLOW:Chunk extensions overflow"); return HPE_USER; } @@ -594,7 +573,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - // Reset nread for the next chunk int on_chunk_complete() { header_nread_ = 0; @@ -660,22 +638,18 @@ class Parser : public AsyncWrap, public StreamListener { Local ret = parser->Execute(buffer.data(), buffer.length()); - if (!ret.IsEmpty()) - args.GetReturnValue().Set(ret); + if (!ret.IsEmpty()) args.GetReturnValue().Set(ret); } - static void Finish(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); Local ret = parser->Execute(nullptr, 0); - if (!ret.IsEmpty()) - args.GetReturnValue().Set(ret); + if (!ret.IsEmpty()) args.GetReturnValue().Set(ret); } - static void Initialize(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -715,9 +689,8 @@ class Parser : public AsyncWrap, public StreamListener { CHECK_EQ(env, parser->env()); AsyncWrap::ProviderType provider = - (type == HTTP_REQUEST ? - AsyncWrap::PROVIDER_HTTPINCOMINGMESSAGE - : AsyncWrap::PROVIDER_HTTPCLIENTREQUEST); + (type == HTTP_REQUEST ? AsyncWrap::PROVIDER_HTTPINCOMINGMESSAGE + : AsyncWrap::PROVIDER_HTTPCLIENTREQUEST); parser->set_provider_type(provider); parser->AsyncReset(args[1].As()); @@ -773,22 +746,20 @@ class Parser : public AsyncWrap, public StreamListener { ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); // Already unconsumed - if (parser->stream_ == nullptr) - return; + if (parser->stream_ == nullptr) return; parser->stream_->RemoveStreamListener(parser); } - static void GetCurrentBuffer(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); Local ret; - if (Buffer::Copy( - parser->env(), - parser->current_buffer_data_, - parser->current_buffer_len_).ToLocal(&ret)) { + if (Buffer::Copy(parser->env(), + parser->current_buffer_data_, + parser->current_buffer_len_) + .ToLocal(&ret)) { args.GetReturnValue().Set(ret); } } @@ -810,7 +781,6 @@ class Parser : public AsyncWrap, public StreamListener { return uv_buf_init(binding_data_->parser_buffer.data(), kAllocBufferSize); } - void OnStreamRead(ssize_t nread, const uv_buf_t& buf) override { HandleScope scope(env()->isolate()); // Once we’re done here, either indicate that the HTTP parser buffer @@ -829,20 +799,17 @@ class Parser : public AsyncWrap, public StreamListener { } // Ignore, empty reads have special meaning in http parser - if (nread == 0) - return; + if (nread == 0) return; Local ret = Execute(buf.base, nread); // Exception - if (ret.IsEmpty()) - return; + if (ret.IsEmpty()) return; Local cb = object()->Get(env()->context(), kOnExecute).ToLocalChecked(); - if (!cb->IsFunction()) - return; + if (!cb->IsFunction()) return; // Hooks for GetCurrentBuffer current_buffer_len_ = nread; @@ -854,7 +821,6 @@ class Parser : public AsyncWrap, public StreamListener { current_buffer_data_ = nullptr; } - Local Execute(const char* data, size_t len) { EscapableHandleScope scope(env()->isolate()); @@ -893,8 +859,7 @@ class Parser : public AsyncWrap, public StreamListener { current_buffer_data_ = nullptr; // If there was an exception in one of the callbacks - if (got_exception_) - return scope.Escape(Local()); + if (got_exception_) return scope.Escape(Local()); Local nread_obj = Integer::New(env()->isolate(), nread); @@ -902,11 +867,10 @@ class Parser : public AsyncWrap, public StreamListener { // TODO(bnoordhuis) What if there is an error on EOF? if (!parser_.upgrade && err != HPE_OK) { Local e = Exception::Error(env()->parse_error_string()); - Local obj = e->ToObject(env()->isolate()->GetCurrentContext()) - .ToLocalChecked(); - obj->Set(env()->context(), - env()->bytes_parsed_string(), - nread_obj).Check(); + Local obj = + e->ToObject(env()->isolate()->GetCurrentContext()).ToLocalChecked(); + obj->Set(env()->context(), env()->bytes_parsed_string(), nread_obj) + .Check(); const char* errno_reason = llhttp_get_error_reason(&parser_); Local code; @@ -947,7 +911,6 @@ class Parser : public AsyncWrap, public StreamListener { return Array::New(env()->isolate(), headers_v, num_values_ * 2); } - // spill headers and request path to JS land void Flush() { HandleScope scope(env()->isolate()); @@ -955,27 +918,21 @@ class Parser : public AsyncWrap, public StreamListener { Local obj = object(); Local cb = obj->Get(env()->context(), kOnHeaders).ToLocalChecked(); - if (!cb->IsFunction()) - return; + if (!cb->IsFunction()) return; - Local argv[2] = { - CreateHeaders(), - url_.ToString(env()) - }; + Local argv[2] = {CreateHeaders(), url_.ToString(env())}; - MaybeLocal r = MakeCallback(cb.As(), - arraysize(argv), - argv); + MaybeLocal r = + MakeCallback(cb.As(), arraysize(argv), argv); - if (r.IsEmpty()) - got_exception_ = true; + if (r.IsEmpty()) got_exception_ = true; url_.Reset(); have_flushed_ = true; } - - void Init(llhttp_type_t type, uint64_t max_http_header_size, + void Init(llhttp_type_t type, + uint64_t max_http_header_size, uint32_t lenient_flags) { llhttp_init(&parser_, type, &settings); @@ -1027,7 +984,6 @@ class Parser : public AsyncWrap, public StreamListener { max_http_header_size_ = max_http_header_size; } - int TrackHeader(size_t len) { header_nread_ += len; if (header_nread_ >= max_http_header_size_) { @@ -1037,7 +993,6 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } - int MaybePause() { if (!pending_pause_) { return 0; @@ -1048,7 +1003,6 @@ class Parser : public AsyncWrap, public StreamListener { return HPE_PAUSED; } - bool IsNotIndicativeOfMemoryLeakAtExit() const override { // HTTP parsers are able to emit events without any GC root referring // to them, because they receive events directly from the underlying @@ -1056,7 +1010,6 @@ class Parser : public AsyncWrap, public StreamListener { return true; } - llhttp_t parser_; StringPtrAllocator allocator_; // shared slab for all StringPtrs StringPtr fields_[kMaxHeaderFieldsCount]; // header fields @@ -1083,10 +1036,11 @@ class Parser : public AsyncWrap, public StreamListener { // These are helper functions for filling `http_parser_settings`, which turn // a member function of Parser into a C-style HTTP parser callback. - template struct Proxy; - template + template + struct Proxy; + template struct Proxy { - static int Raw(llhttp_t* p, Args ... args) { + static int Raw(llhttp_t* p, Args... args) { Parser* parser = ContainerOf(&Parser::parser_, p); if (parser->is_being_freed_) { return 0; @@ -1188,9 +1142,9 @@ void ConnectionsList::Expired(const FunctionCallbackInfo& args) { CHECK(args[0]->IsNumber()); CHECK(args[1]->IsNumber()); uint64_t headers_timeout = - static_cast(args[0].As()->Value()) * 1000000; + static_cast(args[0].As()->Value()) * 1000000; uint64_t request_timeout = - static_cast(args[1].As()->Value()) * 1000000; + static_cast(args[1].As()->Value()) * 1000000; if (headers_timeout == 0 && request_timeout == 0) { return args.GetReturnValue().Set(Array::New(isolate, 0)); @@ -1225,13 +1179,10 @@ void ConnectionsList::Expired(const FunctionCallbackInfo& args) { iter++; // Check for expiration. - if ( - (!parser->headers_completed_ && headers_deadline > 0 && - parser->last_message_start_ < headers_deadline) || - ( - request_deadline > 0 && - parser->last_message_start_ < request_deadline) - ) { + if ((!parser->headers_completed_ && headers_deadline > 0 && + parser->last_message_start_ < headers_deadline) || + (request_deadline > 0 && + parser->last_message_start_ < request_deadline)) { result.emplace_back(parser->object()); list->active_connections_.erase(parser); @@ -1293,6 +1244,441 @@ const llhttp_settings_t Parser::settings = { nullptr, }; +// Flags for BuildHttpMessage — keep in sync with lib/_http_outgoing.js. +enum BuildHttpMessageFlags : uint32_t { + kBuildSendDate = 1u << 0, + kBuildShouldKeepAlive = 1u << 1, + kBuildMaxRequestsReached = 1u << 2, + kBuildDefaultKeepAlive = 1u << 3, + kBuildHasBody = 1u << 4, + kBuildUseChunkedByDefault = 1u << 5, + kBuildRemovedConnection = 1u << 6, + kBuildRemovedContLen = 1u << 7, + kBuildRemovedTE = 1u << 8, + kBuildHasAgent = 1u << 9, +}; + +// Out indices written into the optional Uint32Array passed as args[8]. +enum BuildHttpMessageOut : uint32_t { + kOutLast = 0, + kOutChunked = 1, + kOutContentLength = 2, + kOutHasContentLength = 3, + kOutCount = 4, +}; + +static bool HeaderTokenEquals(const char* a, + size_t a_len, + const char* b, + size_t b_len) { + if (a_len != b_len) return false; + for (size_t i = 0; i < a_len; i++) { + char ca = a[i]; + char cb = 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; +} + +static bool ValueContainsTokenCI(const char* value, + size_t len, + const char* token, + size_t token_len) { + if (token_len == 0 || len < token_len) return false; + for (size_t i = 0; i + token_len <= len; i++) { + bool match = true; + for (size_t j = 0; j < token_len; j++) { + char c = value[i + j]; + if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); + if (c != token[j]) { + match = false; + break; + } + } + if (!match) continue; + const bool left_ok = i == 0 || value[i - 1] == ' ' || value[i - 1] == ',' || + value[i - 1] == '\t'; + const bool right_ok = i + token_len == len || value[i + token_len] == ' ' || + value[i + token_len] == ',' || + value[i + token_len] == '\t'; + if (left_ok && right_ok) return true; + } + return false; +} + +static void AppendNumber(std::string* out, uint64_t n) { + char buf[32]; + size_t i = sizeof(buf); + do { + buf[--i] = static_cast('0' + (n % 10)); + n /= 10; + } while (n > 0); + out->append(buf + i, sizeof(buf) - i); +} + +// Build a complete HTTP/1.1 message (header block, optionally plus body) in a +// single contiguous Buffer. Mirrors _storeHeader() keep-alive / length logic +// so the JS fast path can skip intermediate string concatenation. +// +// args[0] firstLine String e.g. "HTTP/1.1 200 OK\r\n" +// args[1] headers Array|null flat [name, value, name, value, ...] +// args[2] body String|Uint8Array|null +// args[3] bodyEncoding 0=none/buffer, 1=latin1, 2=utf8 (only for string body) +// args[4] flags uint32 BuildHttpMessageFlags +// args[5] date String (pre-formatted UTC date) or empty +// args[6] keepAliveSec uint32 keep-alive timeout in seconds +// args[7] maxRequests int32 max requests per socket (0 = omit) +// args[8] knownLength int32 content-length if already known, else -1 +// args[9] out Uint32Array(kOutCount) optional result flags +void BuildHttpMessage(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + HandleScope scope(isolate); + + if (args.Length() < 9 || !args[0]->IsString()) { + return; + } + + Local first_line_v = args[0].As(); + + const uint32_t flags = + args[4]->IsUint32() ? args[4].As()->Value() : 0; + const bool send_date = (flags & kBuildSendDate) != 0; + const bool should_keep_alive = (flags & kBuildShouldKeepAlive) != 0; + const bool max_requests_reached = (flags & kBuildMaxRequestsReached) != 0; + const bool default_keep_alive = (flags & kBuildDefaultKeepAlive) != 0; + const bool has_body = (flags & kBuildHasBody) != 0; + const bool use_chunked_by_default = (flags & kBuildUseChunkedByDefault) != 0; + const bool removed_connection = (flags & kBuildRemovedConnection) != 0; + const bool removed_cont_len = (flags & kBuildRemovedContLen) != 0; + const bool removed_te = (flags & kBuildRemovedTE) != 0; + const bool has_agent = (flags & kBuildHasAgent) != 0; + + int64_t known_length = -1; + if (args[8]->IsNumber()) { + known_length = args[8]->IntegerValue(env->context()).FromMaybe(-1); + } + + // Measure / extract body. + size_t body_len = 0; + bool body_is_buffer = false; + bool body_is_string = false; + int body_encoding = 0; // 0=buffer/none, 1=latin1, 2=utf8 + Local body_str; + const char* body_buf_data = nullptr; + + if (!args[2]->IsNullOrUndefined()) { + if (Buffer::HasInstance(args[2])) { + body_is_buffer = true; + body_buf_data = Buffer::Data(args[2]); + body_len = Buffer::Length(args[2]); + } else if (args[2]->IsString()) { + body_is_string = true; + body_str = args[2].As(); + body_encoding = args[3]->IsUint32() ? args[3].As()->Value() : 2; + if (body_encoding == 1) { + body_len = static_cast(body_str->Length()); + } else { + body_len = static_cast(body_str->Utf8LengthV2(isolate)); + body_encoding = 2; + } + } + } + + bool saw_connection = false; + bool saw_cont_len = false; + bool saw_te = false; + bool saw_date = false; + bool saw_trailer = false; + bool saw_keep_alive = false; + bool connection_close = false; + bool te_chunked = false; + int64_t header_content_length = -1; + + std::vector, Local>> header_pairs; + if (args[1]->IsArray()) { + Local headers = args[1].As(); + const uint32_t len = headers->Length(); + if (len % 2u != 0u) { + return; // Invalid; JS validates before calling. + } + header_pairs.reserve(len / 2); + Local context = env->context(); + for (uint32_t i = 0; i < len; i += 2) { + Local k_v, v_v; + if (!headers->Get(context, i).ToLocal(&k_v) || + !headers->Get(context, i + 1).ToLocal(&v_v) || !k_v->IsString() || + !v_v->IsString()) { + return; + } + Local key = k_v.As(); + Local val = v_v.As(); + header_pairs.emplace_back(key, val); + + const int klen = key->Length(); + const int vlen = val->Length(); + + // Cheap length pre-filter matching matchHeader() in JS. + if (klen != 4 && klen != 6 && klen != 7 && klen != 10 && klen != 14 && + klen != 17) { + continue; + } + + char name_buf[32]; + if (klen < 0 || klen >= static_cast(sizeof(name_buf))) continue; + key->WriteOneByteV2( + isolate, 0, klen, reinterpret_cast(name_buf)); + + char value_buf_stack[128]; + std::string value_heap; + const char* value_ptr; + size_t value_len = static_cast(vlen); + if (vlen < static_cast(sizeof(value_buf_stack))) { + val->WriteOneByteV2( + isolate, 0, vlen, reinterpret_cast(value_buf_stack)); + value_ptr = value_buf_stack; + } else { + value_heap.resize(value_len); + val->WriteOneByteV2( + isolate, 0, vlen, reinterpret_cast(&value_heap[0])); + value_ptr = value_heap.data(); + } + + if (HeaderTokenEquals(name_buf, klen, "date", 4)) { + saw_date = true; + } else if (HeaderTokenEquals(name_buf, klen, "trailer", 7)) { + saw_trailer = true; + } else if (HeaderTokenEquals(name_buf, klen, "connection", 10)) { + saw_connection = true; + if (ValueContainsTokenCI(value_ptr, value_len, "close", 5)) + connection_close = true; + } else if (HeaderTokenEquals(name_buf, klen, "keep-alive", 10)) { + saw_keep_alive = true; + } else if (HeaderTokenEquals(name_buf, klen, "content-length", 14)) { + saw_cont_len = true; + header_content_length = 0; + for (size_t j = 0; j < value_len; j++) { + if (value_ptr[j] < '0' || value_ptr[j] > '9') break; + header_content_length = + header_content_length * 10 + (value_ptr[j] - '0'); + } + } else if (HeaderTokenEquals(name_buf, klen, "transfer-encoding", 17)) { + saw_te = true; + if (ValueContainsTokenCI(value_ptr, value_len, "chunked", 7)) + te_chunked = true; + } + } + } + + bool is_last = false; + bool chunked = te_chunked; + int64_t content_length = -1; + + if (saw_cont_len) { + content_length = header_content_length; + } else if (known_length >= 0) { + content_length = known_length; + } else if (body_is_buffer || body_is_string) { + content_length = static_cast(body_len); + } + + // Match _storeHeader connection logic. + // JS: shouldKeepAlive && (contLen || useChunkedByDefault || agent) + bool emit_connection_close = false; + bool emit_connection_keep_alive = false; + if (removed_connection) { + is_last = !should_keep_alive; + } else if (!saw_connection) { + const bool should_send_keep_alive = + should_keep_alive && (saw_cont_len || content_length >= 0 || + use_chunked_by_default || has_agent); + if (should_send_keep_alive && max_requests_reached) { + emit_connection_close = true; + } else if (should_send_keep_alive) { + emit_connection_keep_alive = true; + } else { + is_last = true; + emit_connection_close = true; + } + } else if (connection_close) { + is_last = true; + } + + // Body framing. Note: useChunkedByDefault alone gates Content-Length / + // Transfer-Encoding auto headers — agent is NOT involved here. + bool need_auto_cont_len = false; + bool need_auto_te = false; + if (!saw_cont_len && !saw_te) { + if (!has_body) { + chunked = false; + } else if (!use_chunked_by_default) { + is_last = true; + } else if (!saw_trailer && !removed_cont_len && content_length >= 0) { + need_auto_cont_len = true; + } else if (!removed_te) { + need_auto_te = true; + chunked = true; + } else { + is_last = true; + } + } + + // Single-shot body: prefer Content-Length over chunked when the message + // actually uses chunked-by-default (servers / POST/PUT clients). + if ((body_is_buffer || body_is_string) && content_length >= 0 && !saw_te && + !removed_cont_len && use_chunked_by_default) { + need_auto_cont_len = !saw_cont_len; + need_auto_te = false; + chunked = false; + } + + // statusCode is not passed in; 204/304 + chunked is handled by JS before + // or after calling this builder (see tryNativeStoreHeader). + + // Build into a std::string then copy once into a Buffer. Header blocks are + // small; the extra copy is cheaper than getting the size estimate wrong. + std::string msg; + msg.reserve(256 + body_len); + + // body arg: null/undefined => headers-only (_storeHeader); string/buffer + // (possibly empty) => complete message. Headers-only must not append the + // chunked terminator — JS end() still does that. + const bool headers_only = args[2]->IsNullOrUndefined(); + + // HTTP headers are latin1 on the wire. Prefer WriteOneByteV2; for two-byte + // strings write the low byte of each code unit (matches Buffer latin1). + auto append_v8_latin1 = [&](Local s) { + const int n = s->Length(); + const size_t at = msg.size(); + msg.resize(at + static_cast(n)); + if (s->IsOneByte()) { + s->WriteOneByteV2(isolate, 0, n, reinterpret_cast(&msg[at])); + } else { + // Two-byte string: extract low bytes (latin1 semantics). + std::vector tmp(static_cast(n)); + s->WriteV2(isolate, 0, n, tmp.data()); + for (int i = 0; i < n; i++) { + msg[at + static_cast(i)] = + static_cast(tmp[static_cast(i)] & 0xff); + } + } + }; + + append_v8_latin1(first_line_v); + + for (const auto& pair : header_pairs) { + append_v8_latin1(pair.first); + msg.append(": "); + append_v8_latin1(pair.second); + msg.append("\r\n"); + } + + if (send_date && !saw_date && args[5]->IsString()) { + msg.append("Date: "); + append_v8_latin1(args[5].As()); + msg.append("\r\n"); + } + + if (emit_connection_keep_alive) { + msg.append("Connection: keep-alive\r\n"); + // Match matchHeader('keep-alive'): a user-supplied Keep-Alive header + // disables the default timeout emission. + uint32_t ka = 0; + if (args[6]->IsNumber()) { + ka = args[6]->Uint32Value(env->context()).FromMaybe(0); + } + if (default_keep_alive && !saw_keep_alive && ka > 0) { + msg.append("Keep-Alive: timeout="); + AppendNumber(&msg, ka); + int32_t max_req = 0; + if (args[7]->IsNumber()) { + max_req = args[7]->Int32Value(env->context()).FromMaybe(0); + } + if (max_req > 0) { + msg.append(", max="); + AppendNumber(&msg, static_cast(max_req)); + } + msg.append("\r\n"); + } + } else if (emit_connection_close) { + msg.append("Connection: close\r\n"); + } + + if (need_auto_cont_len && content_length >= 0) { + msg.append("Content-Length: "); + AppendNumber(&msg, static_cast(content_length)); + msg.append("\r\n"); + } + if (need_auto_te) { + msg.append("Transfer-Encoding: chunked\r\n"); + } + + msg.append("\r\n"); + + // Body section only for complete messages. Headers-only builds leave + // chunked framing to the existing JS end()/write path. + if (!headers_only && has_body && (body_is_buffer || body_is_string)) { + if (chunked && body_len > 0) { + char hex[16]; + const int n = + std::snprintf(hex, + sizeof(hex), + "%llx", + static_cast(body_len)); // NOLINT + msg.append(hex, static_cast(n)); + msg.append("\r\n"); + } + if (body_is_buffer && body_len > 0) { + msg.append(body_buf_data, body_len); + } else if (body_is_string && body_len > 0) { + const size_t at = msg.size(); + if (body_encoding == 1) { + msg.resize(at + body_len); + body_str->WriteOneByteV2(isolate, + 0, + body_str->Length(), + reinterpret_cast(&msg[at])); + } else { + // +1 capacity so WriteUtf8V2 can null-terminate; we drop the NUL. + msg.resize(at + body_len + 1); + const size_t written = body_str->WriteUtf8V2( + isolate, &msg[at], body_len + 1, String::WriteFlags::kNone); + // written includes the trailing NUL when it fits. + size_t payload = written; + if (payload > 0 && msg[at + payload - 1] == '\0') payload--; + msg.resize(at + payload); + } + } + if (chunked) { + if (body_len > 0) msg.append("\r\n"); + msg.append("0\r\n\r\n"); + } + } + + Local buf_obj; + if (!Buffer::Copy(env, msg.data(), msg.size()).ToLocal(&buf_obj)) { + return; + } + + if (args.Length() > 9 && args[9]->IsUint32Array()) { + Local out_arr = args[9].As(); + if (out_arr->Length() >= kOutCount) { + auto* data = + static_cast(out_arr->Buffer()->GetBackingStore()->Data()); + data += out_arr->ByteOffset() / sizeof(uint32_t); + data[kOutLast] = is_last ? 1 : 0; + data[kOutChunked] = chunked ? 1 : 0; + data[kOutContentLength] = + content_length >= 0 ? static_cast(content_length) : 0; + data[kOutHasContentLength] = content_length >= 0 ? 1 : 0; + } + } + + args.GetReturnValue().Set(buf_obj); +} + void CreatePerIsolateProperties(IsolateData* isolate_data, Local target) { Isolate* isolate = isolate_data->isolate(); @@ -1370,10 +1756,13 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, SetConstructorFunction(isolate, target, "HTTPParser", t); + // Single-shot HTTP/1.1 message builder used by the OutgoingMessage fast path. + SetMethod(isolate, target, "buildHttpMessage", BuildHttpMessage); + Local c = NewFunctionTemplate(isolate, ConnectionsList::New); - c->InstanceTemplate() - ->SetInternalFieldCount(ConnectionsList::kInternalFieldCount); + c->InstanceTemplate()->SetInternalFieldCount( + ConnectionsList::kInternalFieldCount); SetProtoMethod(isolate, c, "all", ConnectionsList::All); SetProtoMethod(isolate, c, "idle", ConnectionsList::Idle); SetProtoMethod(isolate, c, "active", ConnectionsList::Active); @@ -1436,6 +1825,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(Parser::Consume); registry->Register(Parser::Unconsume); registry->Register(Parser::GetCurrentBuffer); + registry->Register(BuildHttpMessage); registry->Register(ConnectionsList::New); registry->Register(ConnectionsList::All); registry->Register(ConnectionsList::Idle); From e1d24a7da0db5bbfd1400593ddc3d502c04d3090 Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 9 Jul 2026 12:08:46 -0400 Subject: [PATCH 2/3] http: address review feedback for native builder Revert incidental ConnectionsList reformatting in node_http_parser.cc, document the buildHttpMessage flag and out-param slots in JS, and avoid Buffer.concat when pairing headers with a body by queueing a separate latin1 header write instead. Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 56 +++++++---- src/node_http_parser.cc | 202 +++++++++++++++++++++++++--------------- 2 files changed, 164 insertions(+), 94 deletions(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 55aef70a9ea5e3..b24fa645687276 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -98,7 +98,21 @@ const nop = () => {}; const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i; -// Keep in sync with BuildHttpMessageFlags in src/node_http_parser.cc. +// Flags passed as args[4] to native buildHttpMessage(). Keep in sync with +// BuildHttpMessageFlags in src/node_http_parser.cc. Combined with | so C++ can +// decide which automatic headers (Date, Connection, Content-Length / TE) to +// emit without re-reading OutgoingMessage fields one-by-one. +// kBuildSendDate - inject Date if the user did not set it +// kBuildShouldKeepAlive - prefer Connection: keep-alive when valid +// kBuildMaxRequestsReached - force Connection: close (server max) +// kBuildDefaultKeepAlive - also emit Keep-Alive: timeout=... +// kBuildHasBody - message may carry a body (not HEAD/204/304) +// kBuildUseChunkedByDefault - allow auto Content-Length / TE: chunked +// kBuildRemovedConnection - user called removeHeader('connection') +// kBuildRemovedContLen - user called removeHeader('content-length') +// kBuildRemovedTE - user called removeHeader('transfer-encoding') +// kBuildHasAgent - ClientRequest has an agent (keep-alive only; +// does NOT enable auto Content-Length) const kBuildSendDate = 1 << 0; const kBuildShouldKeepAlive = 1 << 1; const kBuildMaxRequestsReached = 1 << 2; @@ -110,14 +124,20 @@ const kBuildRemovedContLen = 1 << 7; const kBuildRemovedTE = 1 << 8; const kBuildHasAgent = 1 << 9; +// Output slots written by buildHttpMessage into args[9] (Uint32Array). // Keep in sync with BuildHttpMessageOut in src/node_http_parser.cc. +// [kOutLast] - 1 if this is the last message on the connection +// [kOutChunked] - 1 if Transfer-Encoding: chunked was selected +// [kOutContentLength] - resolved body length when known +// [kOutHasContentLength] - 1 if kOutContentLength is valid const kOutLast = 0; const kOutChunked = 1; const kOutContentLength = 2; const kOutHasContentLength = 3; const kOutCount = 4; -// Reused across calls to avoid allocating a Uint32Array per response. +// Scratch buffer for native out-params. Reused so each response does not +// allocate a new Uint32Array; only the four slots above are written/read. const buildHttpMessageOut = new Uint32Array(kOutCount); // Lazy to avoid a require cycle with _http_server. @@ -434,19 +454,17 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // `this._header` can be null if OutgoingMessage is used without a proper Socket // See: /test/parallel/test-http-outgoing-message-inheritance.js // - // Headers are stored as a latin1 JS string of the on-wire bytes. If we - // naively do `header + body` and write as UTF-8, high-byte header values - // (e.g. content-disposition) get double-encoded. Prefer Buffer concat - // when the body is present; pure header flushes use latin1. + // Headers are stored as a latin1 JS string of the on-wire bytes. String + // concat under UTF-8 would double-encode high-byte header values (e.g. + // content-disposition). For those cases queue the header as a separate + // latin1 write instead of allocating a concatenated Buffer. if (typeof data === 'string' && data.length > 0 && (encoding === 'latin1' || encoding === 'binary')) { data = header + data; encoding = 'latin1'; } else if (typeof data === 'string' && data.length > 0 && (encoding === 'utf8' || encoding === 'utf-8' || !encoding)) { - // ASCII headers can concatenate as a string under UTF-8; high-byte - // latin1 header values must be Buffer-concatenated to avoid double - // encoding on the wire. + // ASCII headers can safely share one UTF-8 write with the body. let ascii = true; for (let i = 0; i < header.length; i++) { if (header.charCodeAt(i) > 127) { @@ -457,22 +475,22 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL if (ascii) { data = header + data; } else { - const bodyBuf = Buffer.from(data, encoding || 'utf8'); - data = Buffer.concat([Buffer.from(header, 'latin1'), bodyBuf]); - encoding = 'buffer'; - byteLength = data.byteLength; + // High-byte headers: queue header first; body keeps its encoding. + this.outputData.unshift({ + data: header, + encoding: 'latin1', + callback: null, + }); + this.outputSize += header.length; + this._onPendingData(header.length); } - } else if (isUint8Array(data) && data.byteLength > 0) { - data = Buffer.concat([Buffer.from(header, 'latin1'), data]); - encoding = 'buffer'; - byteLength = data.byteLength; } else if (typeof data === 'string' && data.length === 0) { // Header-only flush (end without body, Expect: 100-continue, etc.). data = header; encoding = 'latin1'; } else { - // Non-utf8/latin1 body encodings (hex, utf16le, base64, ...): keep the - // header as a separate latin1 write so body encoding is unchanged. + // Buffers / hex / utf16le / base64 / empty: separate latin1 header write + // so the body encoding is left unchanged (no Buffer.concat). this.outputData.unshift({ data: header, encoding: 'latin1', diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index b7fef483954bee..bfa283095ea29d 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -48,6 +48,7 @@ // No copying is performed when slicing the buffer, only small reference // allocations. + namespace node { namespace http_parser { // NOLINT(build/namespaces) @@ -254,36 +255,44 @@ struct ParserComparator { class ConnectionsList : public BaseObject { public: - static void New(const FunctionCallbackInfo& args); + static void New(const FunctionCallbackInfo& args); - static void All(const FunctionCallbackInfo& args); + static void All(const FunctionCallbackInfo& args); - static void Idle(const FunctionCallbackInfo& args); + static void Idle(const FunctionCallbackInfo& args); - static void Active(const FunctionCallbackInfo& args); + static void Active(const FunctionCallbackInfo& args); - static void Expired(const FunctionCallbackInfo& args); + static void Expired(const FunctionCallbackInfo& args); - void Push(Parser* parser) { all_connections_.insert(parser); } + void Push(Parser* parser) { + all_connections_.insert(parser); + } - void Pop(Parser* parser) { all_connections_.erase(parser); } + void Pop(Parser* parser) { + all_connections_.erase(parser); + } - void PushActive(Parser* parser) { active_connections_.insert(parser); } + void PushActive(Parser* parser) { + active_connections_.insert(parser); + } - void PopActive(Parser* parser) { active_connections_.erase(parser); } + void PopActive(Parser* parser) { + active_connections_.erase(parser); + } - SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(ConnectionsList) - SET_SELF_SIZE(ConnectionsList) + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(ConnectionsList) + SET_SELF_SIZE(ConnectionsList) private: - ConnectionsList(Environment* env, Local object) + ConnectionsList(Environment* env, Local object) : BaseObject(env, object) { - MakeWeak(); - } + MakeWeak(); + } - std::set all_connections_; - std::set active_connections_; + std::set all_connections_; + std::set active_connections_; }; class Parser : public AsyncWrap, public StreamListener { @@ -323,14 +332,14 @@ class Parser : public AsyncWrap, public StreamListener { connectionsList_->PushActive(this); } - Local cb = - object()->Get(env()->context(), kOnMessageBegin).ToLocalChecked(); + Local cb = object()->Get(env()->context(), kOnMessageBegin) + .ToLocalChecked(); if (cb->IsFunction()) { InternalCallbackScope callback_scope( - this, InternalCallbackScope::kSkipTaskQueues); + this, InternalCallbackScope::kSkipTaskQueues); - MaybeLocal r = - cb.As()->Call(env()->context(), object(), 0, nullptr); + MaybeLocal r = cb.As()->Call( + env()->context(), object(), 0, nullptr); if (r.IsEmpty()) callback_scope.MarkAsFailed(); } @@ -338,6 +347,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_url(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -348,6 +358,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_status(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -358,6 +369,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_header_field(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -384,6 +396,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_header_value(const char* at, size_t length) { int rv = TrackHeader(length); if (rv != 0) { @@ -404,6 +417,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_headers_complete() { headers_completed_ = true; header_nread_ = 0; @@ -426,13 +440,15 @@ class Parser : public AsyncWrap, public StreamListener { Local argv[A_MAX]; Local obj = object(); - Local cb = - obj->Get(env()->context(), kOnHeadersComplete).ToLocalChecked(); + Local cb = obj->Get(env()->context(), + kOnHeadersComplete).ToLocalChecked(); - if (!cb->IsFunction()) return 0; + if (!cb->IsFunction()) + return 0; Local undefined = Undefined(env()->isolate()); - for (size_t i = 0; i < arraysize(argv); i++) argv[i] = undefined; + for (size_t i = 0; i < arraysize(argv); i++) + argv[i] = undefined; if (have_flushed_) { // Slow case, flush remaining headers. @@ -440,7 +456,8 @@ class Parser : public AsyncWrap, public StreamListener { } else { // Fast case, pass headers and URL to JS land. argv[A_HEADERS] = CreateHeaders(); - if (parser_.type == HTTP_REQUEST) argv[A_URL] = url_.ToString(env()); + if (parser_.type == HTTP_REQUEST) + argv[A_URL] = url_.ToString(env()); } num_fields_ = 0; @@ -454,7 +471,8 @@ class Parser : public AsyncWrap, public StreamListener { // STATUS if (parser_.type == HTTP_RESPONSE) { - argv[A_STATUS_CODE] = Integer::New(env()->isolate(), parser_.status_code); + argv[A_STATUS_CODE] = + Integer::New(env()->isolate(), parser_.status_code); argv[A_STATUS_MESSAGE] = status_message_.ToString(env()); } @@ -491,15 +509,18 @@ class Parser : public AsyncWrap, public StreamListener { return static_cast(val); } + int on_body(const char* at, size_t length) { - if (length == 0) return 0; + if (length == 0) + return 0; Environment* env = this->env(); HandleScope handle_scope(env->isolate()); Local cb = object()->Get(env->context(), kOnBody).ToLocalChecked(); - if (!cb->IsFunction()) return 0; + if (!cb->IsFunction()) + return 0; Local buffer = Buffer::Copy(env, at, length).ToLocalChecked(); @@ -514,6 +535,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int on_message_complete() { HandleScope scope(env()->isolate()); @@ -530,13 +552,15 @@ class Parser : public AsyncWrap, public StreamListener { connectionsList_->Push(this); } - if (num_fields_) Flush(); // Flush trailing HTTP headers. + if (num_fields_) + Flush(); // Flush trailing HTTP headers. Local obj = object(); - Local cb = - obj->Get(env()->context(), kOnMessageComplete).ToLocalChecked(); + Local cb = obj->Get(env()->context(), + kOnMessageComplete).ToLocalChecked(); - if (!cb->IsFunction()) return 0; + if (!cb->IsFunction()) + return 0; MaybeLocal r; { @@ -558,8 +582,8 @@ class Parser : public AsyncWrap, public StreamListener { chunk_extensions_nread_ += length; if (chunk_extensions_nread_ > kMaxChunkExtensionsSize) { - llhttp_set_error_reason( - &parser_, "HPE_CHUNK_EXTENSIONS_OVERFLOW:Chunk extensions overflow"); + llhttp_set_error_reason(&parser_, + "HPE_CHUNK_EXTENSIONS_OVERFLOW:Chunk extensions overflow"); return HPE_USER; } @@ -573,6 +597,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + // Reset nread for the next chunk int on_chunk_complete() { header_nread_ = 0; @@ -638,18 +663,22 @@ class Parser : public AsyncWrap, public StreamListener { Local ret = parser->Execute(buffer.data(), buffer.length()); - if (!ret.IsEmpty()) args.GetReturnValue().Set(ret); + if (!ret.IsEmpty()) + args.GetReturnValue().Set(ret); } + static void Finish(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); Local ret = parser->Execute(nullptr, 0); - if (!ret.IsEmpty()) args.GetReturnValue().Set(ret); + if (!ret.IsEmpty()) + args.GetReturnValue().Set(ret); } + static void Initialize(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -689,8 +718,9 @@ class Parser : public AsyncWrap, public StreamListener { CHECK_EQ(env, parser->env()); AsyncWrap::ProviderType provider = - (type == HTTP_REQUEST ? AsyncWrap::PROVIDER_HTTPINCOMINGMESSAGE - : AsyncWrap::PROVIDER_HTTPCLIENTREQUEST); + (type == HTTP_REQUEST ? + AsyncWrap::PROVIDER_HTTPINCOMINGMESSAGE + : AsyncWrap::PROVIDER_HTTPCLIENTREQUEST); parser->set_provider_type(provider); parser->AsyncReset(args[1].As()); @@ -746,20 +776,22 @@ class Parser : public AsyncWrap, public StreamListener { ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); // Already unconsumed - if (parser->stream_ == nullptr) return; + if (parser->stream_ == nullptr) + return; parser->stream_->RemoveStreamListener(parser); } + static void GetCurrentBuffer(const FunctionCallbackInfo& args) { Parser* parser; ASSIGN_OR_RETURN_UNWRAP(&parser, args.This()); Local ret; - if (Buffer::Copy(parser->env(), - parser->current_buffer_data_, - parser->current_buffer_len_) - .ToLocal(&ret)) { + if (Buffer::Copy( + parser->env(), + parser->current_buffer_data_, + parser->current_buffer_len_).ToLocal(&ret)) { args.GetReturnValue().Set(ret); } } @@ -781,6 +813,7 @@ class Parser : public AsyncWrap, public StreamListener { return uv_buf_init(binding_data_->parser_buffer.data(), kAllocBufferSize); } + void OnStreamRead(ssize_t nread, const uv_buf_t& buf) override { HandleScope scope(env()->isolate()); // Once we’re done here, either indicate that the HTTP parser buffer @@ -799,17 +832,20 @@ class Parser : public AsyncWrap, public StreamListener { } // Ignore, empty reads have special meaning in http parser - if (nread == 0) return; + if (nread == 0) + return; Local ret = Execute(buf.base, nread); // Exception - if (ret.IsEmpty()) return; + if (ret.IsEmpty()) + return; Local cb = object()->Get(env()->context(), kOnExecute).ToLocalChecked(); - if (!cb->IsFunction()) return; + if (!cb->IsFunction()) + return; // Hooks for GetCurrentBuffer current_buffer_len_ = nread; @@ -821,6 +857,7 @@ class Parser : public AsyncWrap, public StreamListener { current_buffer_data_ = nullptr; } + Local Execute(const char* data, size_t len) { EscapableHandleScope scope(env()->isolate()); @@ -859,7 +896,8 @@ class Parser : public AsyncWrap, public StreamListener { current_buffer_data_ = nullptr; // If there was an exception in one of the callbacks - if (got_exception_) return scope.Escape(Local()); + if (got_exception_) + return scope.Escape(Local()); Local nread_obj = Integer::New(env()->isolate(), nread); @@ -867,10 +905,11 @@ class Parser : public AsyncWrap, public StreamListener { // TODO(bnoordhuis) What if there is an error on EOF? if (!parser_.upgrade && err != HPE_OK) { Local e = Exception::Error(env()->parse_error_string()); - Local obj = - e->ToObject(env()->isolate()->GetCurrentContext()).ToLocalChecked(); - obj->Set(env()->context(), env()->bytes_parsed_string(), nread_obj) - .Check(); + Local obj = e->ToObject(env()->isolate()->GetCurrentContext()) + .ToLocalChecked(); + obj->Set(env()->context(), + env()->bytes_parsed_string(), + nread_obj).Check(); const char* errno_reason = llhttp_get_error_reason(&parser_); Local code; @@ -911,6 +950,7 @@ class Parser : public AsyncWrap, public StreamListener { return Array::New(env()->isolate(), headers_v, num_values_ * 2); } + // spill headers and request path to JS land void Flush() { HandleScope scope(env()->isolate()); @@ -918,21 +958,27 @@ class Parser : public AsyncWrap, public StreamListener { Local obj = object(); Local cb = obj->Get(env()->context(), kOnHeaders).ToLocalChecked(); - if (!cb->IsFunction()) return; + if (!cb->IsFunction()) + return; - Local argv[2] = {CreateHeaders(), url_.ToString(env())}; + Local argv[2] = { + CreateHeaders(), + url_.ToString(env()) + }; - MaybeLocal r = - MakeCallback(cb.As(), arraysize(argv), argv); + MaybeLocal r = MakeCallback(cb.As(), + arraysize(argv), + argv); - if (r.IsEmpty()) got_exception_ = true; + if (r.IsEmpty()) + got_exception_ = true; url_.Reset(); have_flushed_ = true; } - void Init(llhttp_type_t type, - uint64_t max_http_header_size, + + void Init(llhttp_type_t type, uint64_t max_http_header_size, uint32_t lenient_flags) { llhttp_init(&parser_, type, &settings); @@ -984,6 +1030,7 @@ class Parser : public AsyncWrap, public StreamListener { max_http_header_size_ = max_http_header_size; } + int TrackHeader(size_t len) { header_nread_ += len; if (header_nread_ >= max_http_header_size_) { @@ -993,6 +1040,7 @@ class Parser : public AsyncWrap, public StreamListener { return 0; } + int MaybePause() { if (!pending_pause_) { return 0; @@ -1003,6 +1051,7 @@ class Parser : public AsyncWrap, public StreamListener { return HPE_PAUSED; } + bool IsNotIndicativeOfMemoryLeakAtExit() const override { // HTTP parsers are able to emit events without any GC root referring // to them, because they receive events directly from the underlying @@ -1010,6 +1059,7 @@ class Parser : public AsyncWrap, public StreamListener { return true; } + llhttp_t parser_; StringPtrAllocator allocator_; // shared slab for all StringPtrs StringPtr fields_[kMaxHeaderFieldsCount]; // header fields @@ -1036,11 +1086,10 @@ class Parser : public AsyncWrap, public StreamListener { // These are helper functions for filling `http_parser_settings`, which turn // a member function of Parser into a C-style HTTP parser callback. - template - struct Proxy; - template + template struct Proxy; + template struct Proxy { - static int Raw(llhttp_t* p, Args... args) { + static int Raw(llhttp_t* p, Args ... args) { Parser* parser = ContainerOf(&Parser::parser_, p); if (parser->is_being_freed_) { return 0; @@ -1142,9 +1191,9 @@ void ConnectionsList::Expired(const FunctionCallbackInfo& args) { CHECK(args[0]->IsNumber()); CHECK(args[1]->IsNumber()); uint64_t headers_timeout = - static_cast(args[0].As()->Value()) * 1000000; + static_cast(args[0].As()->Value()) * 1000000; uint64_t request_timeout = - static_cast(args[1].As()->Value()) * 1000000; + static_cast(args[1].As()->Value()) * 1000000; if (headers_timeout == 0 && request_timeout == 0) { return args.GetReturnValue().Set(Array::New(isolate, 0)); @@ -1179,10 +1228,13 @@ void ConnectionsList::Expired(const FunctionCallbackInfo& args) { iter++; // Check for expiration. - if ((!parser->headers_completed_ && headers_deadline > 0 && - parser->last_message_start_ < headers_deadline) || - (request_deadline > 0 && - parser->last_message_start_ < request_deadline)) { + if ( + (!parser->headers_completed_ && headers_deadline > 0 && + parser->last_message_start_ < headers_deadline) || + ( + request_deadline > 0 && + parser->last_message_start_ < request_deadline) + ) { result.emplace_back(parser->object()); list->active_connections_.erase(parser); @@ -1244,7 +1296,7 @@ const llhttp_settings_t Parser::settings = { nullptr, }; -// Flags for BuildHttpMessage — keep in sync with lib/_http_outgoing.js. +// Flags for BuildHttpMessage - keep in sync with lib/_http_outgoing.js. enum BuildHttpMessageFlags : uint32_t { kBuildSendDate = 1u << 0, kBuildShouldKeepAlive = 1u << 1, @@ -1507,7 +1559,7 @@ void BuildHttpMessage(const FunctionCallbackInfo& args) { } // Body framing. Note: useChunkedByDefault alone gates Content-Length / - // Transfer-Encoding auto headers — agent is NOT involved here. + // Transfer-Encoding auto headers - agent is NOT involved here. bool need_auto_cont_len = false; bool need_auto_te = false; if (!saw_cont_len && !saw_te) { @@ -1544,7 +1596,7 @@ void BuildHttpMessage(const FunctionCallbackInfo& args) { // body arg: null/undefined => headers-only (_storeHeader); string/buffer // (possibly empty) => complete message. Headers-only must not append the - // chunked terminator — JS end() still does that. + // chunked terminator - JS end() still does that. const bool headers_only = args[2]->IsNullOrUndefined(); // HTTP headers are latin1 on the wire. Prefer WriteOneByteV2; for two-byte @@ -1761,8 +1813,8 @@ void CreatePerIsolateProperties(IsolateData* isolate_data, Local c = NewFunctionTemplate(isolate, ConnectionsList::New); - c->InstanceTemplate()->SetInternalFieldCount( - ConnectionsList::kInternalFieldCount); + c->InstanceTemplate() + ->SetInternalFieldCount(ConnectionsList::kInternalFieldCount); SetProtoMethod(isolate, c, "all", ConnectionsList::All); SetProtoMethod(isolate, c, "idle", ConnectionsList::Idle); SetProtoMethod(isolate, c, "active", ConnectionsList::Active); From 1dd52f69d7a328dd300120c9850b8c086b9fa2fd Mon Sep 17 00:00:00 2001 From: Yagiz Nizipli Date: Thu, 9 Jul 2026 12:32:53 -0400 Subject: [PATCH 3/3] http: speed up native response path further Hot writeHead()+end() now flushes via tryFastFlushEnd instead of the full write_()/extra empty write path. Server-side native headers stay as Buffers (no latin1 string copy) until write time; the C++ builder writes into a single malloc transferred to Buffer (no std::string + Copy). Reuse a scratch flat-header array and a prebuilt 200 status line. Signed-off-by: Yagiz Nizipli --- lib/_http_outgoing.js | 293 +++++++++++++++++++++++++++++----------- src/node_http_parser.cc | 129 ++++++++++-------- 2 files changed, 284 insertions(+), 138 deletions(-) diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index b24fa645687276..862dcc1f6b95be 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -444,6 +444,20 @@ function emitDestroyNT(self) { } +// Queue an unsent header block ahead of the body write. Used for Buffer +// headers (native builder) and for string headers that cannot share the +// body's encoding (high-byte latin1 / non-utf8 body encodings). +function queueHeaderWrite(msg, header, encoding) { + msg.outputData.unshift({ + data: header, + encoding, + callback: null, + }); + const len = header.length; + msg.outputSize += len; + msg._onPendingData(len); +} + // This abstract either writing directly to the socket or buffering it. OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteLength) { // This is a shameful hack to get the headers and first body chunk onto @@ -454,11 +468,40 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL // `this._header` can be null if OutgoingMessage is used without a proper Socket // See: /test/parallel/test-http-outgoing-message-inheritance.js // - // Headers are stored as a latin1 JS string of the on-wire bytes. String - // concat under UTF-8 would double-encode high-byte header values (e.g. - // content-disposition). For those cases queue the header as a separate - // latin1 write instead of allocating a concatenated Buffer. - if (typeof data === 'string' && data.length > 0 && + // Native builder stores _header as a Buffer (server path). Legacy and + // ClientRequest keep a latin1 string. Never Buffer.concat: queue the + // header as its own write when it cannot share the body encoding. + if (isUint8Array(header)) { + // Native Buffer header. Prefer a single write when the body encoding + // can share the stream; otherwise queue the header separately. + if (typeof data === 'string' && data.length === 0) { + data = header; + encoding = 'buffer'; + } else if (typeof data === 'string' && data.length > 0 && + (encoding === 'latin1' || encoding === 'binary')) { + data = header.toString('latin1') + data; + encoding = 'latin1'; + } else if (typeof data === 'string' && data.length > 0 && + (encoding === 'utf8' || encoding === 'utf-8' || !encoding)) { + let ascii = true; + for (let i = 0; i < header.byteLength; i++) { + if (header[i] > 127) { + ascii = false; + break; + } + } + if (ascii) { + // latin1 decode of ASCII is identity; one utf8 write with body. + data = header.toString('latin1') + data; + } else { + queueHeaderWrite(this, header, 'buffer'); + } + } else if (isUint8Array(data) && data.byteLength > 0) { + queueHeaderWrite(this, header, 'buffer'); + } else { + queueHeaderWrite(this, header, 'buffer'); + } + } else if (typeof data === 'string' && data.length > 0 && (encoding === 'latin1' || encoding === 'binary')) { data = header + data; encoding = 'latin1'; @@ -475,29 +518,15 @@ OutgoingMessage.prototype._send = function _send(data, encoding, callback, byteL if (ascii) { data = header + data; } else { - // High-byte headers: queue header first; body keeps its encoding. - this.outputData.unshift({ - data: header, - encoding: 'latin1', - callback: null, - }); - this.outputSize += header.length; - this._onPendingData(header.length); + queueHeaderWrite(this, header, 'latin1'); } } else if (typeof data === 'string' && data.length === 0) { // Header-only flush (end without body, Expect: 100-continue, etc.). data = header; encoding = 'latin1'; } else { - // Buffers / hex / utf16le / base64 / empty: separate latin1 header write - // so the body encoding is left unchanged (no Buffer.concat). - this.outputData.unshift({ - data: header, - encoding: 'latin1', - callback: null, - }); - this.outputSize += header.length; - this._onPendingData(header.length); + // Buffers / hex / utf16le / base64: separate header write. + queueHeaderWrite(this, header, 'latin1'); } this._headerSent = true; } @@ -573,7 +602,8 @@ function applyBuildResult(msg, out) { // Flatten headers into [name, value, ...] for the C++ builder. Returns null // when a header requires the legacy JS path. function flattenHeadersForNative(msg, headers, validate) { - const flat = []; + const flat = flatHeadersScratch; + flat.length = 0; const lenient = msg._isLenientHeaderValidation(); function pushPair(key, value, doValidate) { @@ -713,10 +743,15 @@ function tryNativeStoreHeader(msg, firstLine, headers) { } } - // latin1 string of the wire bytes. _send() must write this as latin1 (or - // Buffer-concat) rather than UTF-8 so high-byte values (content-disposition) - // are not double-encoded. - msg._header = buf.toString('latin1'); + // ServerResponse: keep the Buffer to avoid a latin1 string copy on the + // hot writeHead()+end() path. ClientRequest: materialize a latin1 string + // so userland/tests that treat _header as a string (e.g. .match) keep + // working. + if (typeof msg.statusCode === 'number') { + msg._header = buf; + } else { + msg._header = buf.toString('latin1'); + } msg._headerSent = false; // Expect: 100-continue forces an early header flush (legacy behavior). @@ -731,6 +766,9 @@ function tryNativeStoreHeader(msg, firstLine, headers) { return true; } +// Reused flat header list for native builds (sync only; reset each call). +const flatHeadersScratch = []; + function legacyStoreHeader(self, firstLine, headers) { // firstLine in the case of request is: 'GET /index.html HTTP/1.1\r\n' // in the case of response it is: 'HTTP/1.1 200 OK\r\n' @@ -1540,41 +1578,19 @@ function getFastFirstLine(msg) { return null; } -function tryFastEnd(msg, chunk, encoding, callback) { - // ServerResponse only. ClientRequest has method-specific Content-Length - // rules (GET/HEAD/DELETE must not emit CL:0) and CONNECT tunnel framing - // that the C++ builder does not model. - if (typeof msg.statusCode !== 'number') - return false; - - // Preconditions: nothing rendered yet, socket ready, no queued output, - // no trailers, not already finished/destroyed. - if (msg._header || msg._headerSent || msg.finished || msg.destroyed) - return false; - if (msg._trailer) return false; - if (msg.outputData.length !== 0) return false; - if (msg[kCorked]) return false; - if (msg._expect_continue && !msg._sent100) return false; - - const socket = msg[kSocket]; - // Require a real net.Socket (has a libuv _handle). Standalone - // ServerResponse tests assign arbitrary Writables that must keep the - // multi-write _send() behaviour. - if (!socket || socket._httpMessage !== msg || !socket.writable || - socket.destroyed || !socket._writableState || !socket._handle) { - return false; - } +// Common status lines (writeHead / fast end without custom reason). +const kStatusLine200 = 'HTTP/1.1 200 OK\r\n'; - // Body encoding must be latin1/utf8/buffer (what the C++ builder supports). +function prepareFastBody(msg, chunk, encoding) { let body = null; - let bodyEncoding = 0; // 0 = buffer/none + let bodyEncoding = 0; // 0 = buffer/none, 1 = latin1, 2 = utf8 let bodyLen = 0; if (chunk) { if (typeof chunk === 'string') { if (encoding && encoding !== 'utf8' && encoding !== 'utf-8' && encoding !== 'latin1' && encoding !== 'binary' && encoding !== 'ascii') { - return false; + return null; } body = chunk; if (encoding === 'latin1' || encoding === 'binary' || @@ -1590,7 +1606,7 @@ function tryFastEnd(msg, chunk, encoding, callback) { bodyEncoding = 0; bodyLen = chunk.byteLength; } else { - return false; + return null; } } @@ -1598,25 +1614,155 @@ function tryFastEnd(msg, chunk, encoding, callback) { if (msg[kRejectNonStandardBodyWrites]) { throw new ERR_HTTP_BODY_NOT_ALLOWED(); } - // Legacy: ignore body for HEAD / 204 / 304. body = null; bodyLen = 0; } + return { body, bodyEncoding, bodyLen }; +} + +function finishFastSocketWrite(msg, socket, ret) { + if (!ret) msg[kNeedDrain] = true; + debug('outgoing message fast end.'); + if (msg.outputData.length === 0 && socket._httpMessage === msg) { + msg._finish(); + } +} - const firstLine = getFastFirstLine(msg); - if (firstLine === null) return false; +// writeHead() already rendered headers: flush header + body without write_(). +// This is the benchmark/http/simple.js path. +function tryFastFlushEnd(msg, chunk, encoding, callback) { + if (!msg._header || msg._headerSent || msg.finished || msg.destroyed) + return false; + if (msg.chunkedEncoding || msg._trailer) return false; + if (msg.outputData.length !== 0 || msg[kCorked]) return false; + if (msg._expect_continue && !msg._sent100) return false; + if (strictContentLength(msg)) return false; - let flat; - try { - flat = flattenHeadersForNative(msg, msg[kOutHeaders], false); - } catch { + const socket = msg[kSocket]; + if (!socket || socket._httpMessage !== msg || !socket.writable || + socket.destroyed || !socket._writableState || !socket._handle) { return false; } + + const prepared = prepareFastBody(msg, chunk, encoding); + if (prepared === null) return false; + const { body, bodyEncoding, bodyLen } = prepared; + + if (typeof callback === 'function') + msg.once('finish', callback); + + const finish = onFinish.bind(undefined, msg); + const header = msg._header; + + if (!socket.writableCorked) + socket.cork(); + + let ret; + if (isUint8Array(header)) { + // Native Buffer header: one or two writes under cork (no string copy). + if (body && bodyLen > 0 && msg._hasBody) { + socket.write(header); + if (bodyEncoding === 0) { + ret = socket.write(body, finish); + } else if (bodyEncoding === 1) { + ret = socket.write(body, 'latin1', finish); + } else { + ret = socket.write(body, 'utf8', finish); + } + } else { + ret = socket.write(header, finish); + } + } else if (body && bodyLen > 0 && msg._hasBody) { + if (typeof body === 'string' && bodyEncoding === 1) { + ret = socket.write(header + body, 'latin1', finish); + } else if (typeof body === 'string') { + // utf8 body: only concat when the header is pure ASCII. + let ascii = true; + for (let i = 0; i < header.length; i++) { + if (header.charCodeAt(i) > 127) { + ascii = false; + break; + } + } + if (ascii) { + ret = socket.write(header + body, finish); + } else { + socket.write(header, 'latin1'); + ret = socket.write(body, 'utf8', finish); + } + } else { + socket.write(header, 'latin1'); + ret = socket.write(body, finish); + } + } else { + ret = socket.write(header, 'latin1', finish); + } + + socket._writableState.corked = 1; + socket.uncork(); + + msg._headerSent = true; + msg.finished = true; + if (bodyLen && msg._hasBody) + msg[kBytesWritten] = bodyLen; + + finishFastSocketWrite(msg, socket, ret); + return true; +} + +function tryFastEnd(msg, chunk, encoding, callback) { + // ServerResponse only. ClientRequest has method-specific Content-Length + // rules (GET/HEAD/DELETE must not emit CL:0) and CONNECT tunnel framing + // that the C++ builder does not model. + if (typeof msg.statusCode !== 'number') + return false; + + if (msg.finished || msg.destroyed) + return false; + + // Hot path after writeHead(): headers already built, not yet flushed. + if (msg._header && !msg._headerSent) { + return tryFastFlushEnd(msg, chunk, encoding, callback); + } + + // Full native single-shot: nothing rendered yet. + if (msg._header || msg._headerSent) + return false; + if (msg._trailer) return false; + if (msg.outputData.length !== 0) return false; + if (msg[kCorked]) return false; + if (msg._expect_continue && !msg._sent100) return false; + + const socket = msg[kSocket]; + // Require a real net.Socket (has a libuv _handle). Standalone + // ServerResponse tests assign arbitrary Writables that must keep the + // multi-write _send() behaviour. + if (!socket || socket._httpMessage !== msg || !socket.writable || + socket.destroyed || !socket._writableState || !socket._handle) { + return false; + } + + const prepared = prepareFastBody(msg, chunk, encoding); + if (prepared === null) return false; + const { body, bodyEncoding, bodyLen } = prepared; + + let firstLine; + if (msg.statusCode === 200 && + (msg.statusMessage === undefined || msg.statusMessage === null || + msg.statusMessage === 'OK')) { + msg.statusMessage = 'OK'; + firstLine = kStatusLine200; + } else { + firstLine = getFastFirstLine(msg); + if (firstLine === null) return false; + } + + const flat = flattenHeadersForNative(msg, msg[kOutHeaders], false); if (flat === null) return false; if (body && msg._hasBody) { msg._contentLength = bodyLen; - } else if (!msg._header) { + } else { msg._contentLength = 0; } @@ -1628,11 +1774,6 @@ function tryFastEnd(msg, chunk, encoding, callback) { const maxReq = msg._maxRequestsPerSocket | 0; const date = msg.sendDate ? utcDate() : ''; - buildHttpMessageOut[0] = 0; - buildHttpMessageOut[1] = 0; - buildHttpMessageOut[2] = 0; - buildHttpMessageOut[3] = 0; - const buf = buildHttpMessage( firstLine, flat, @@ -1651,15 +1792,14 @@ function tryFastEnd(msg, chunk, encoding, callback) { if (msg.chunkedEncoding && (msg.statusCode === 204 || msg.statusCode === 304)) { - // Should not happen with single-shot body preference, but stay safe. return false; } if (typeof callback === 'function') msg.once('finish', callback); - // Mark headers as rendered+sent so subsequent writes fail correctly. - msg._header = buf.toString('latin1'); + // Keep Buffer form for consistency with tryNativeStoreHeader (server). + msg._header = buf; msg._headerSent = true; msg.finished = true; if (bodyLen && msg._hasBody) { @@ -1668,23 +1808,14 @@ function tryFastEnd(msg, chunk, encoding, callback) { const finish = onFinish.bind(undefined, msg); - // The socket is typically corked by OutgoingMessage.socket setter / - // assignSocket. Mirror end()'s uncork so the single write is flushed; - // without this the write callback never runs and the response hangs. if (!socket.writableCorked) { socket.cork(); } const ret = socket.write(buf, finish); - // Force a full uncork (same as the slow end() path). socket._writableState.corked = 1; socket.uncork(); - if (!ret) msg[kNeedDrain] = true; - - debug('outgoing message fast end.'); - if (msg.outputData.length === 0 && socket._httpMessage === msg) { - msg._finish(); - } + finishFastSocketWrite(msg, socket, ret); return true; } diff --git a/src/node_http_parser.cc b/src/node_http_parser.cc index bfa283095ea29d..950e1e97ab61f5 100644 --- a/src/node_http_parser.cc +++ b/src/node_http_parser.cc @@ -1360,16 +1360,6 @@ static bool ValueContainsTokenCI(const char* value, return false; } -static void AppendNumber(std::string* out, uint64_t n) { - char buf[32]; - size_t i = sizeof(buf); - do { - buf[--i] = static_cast('0' + (n % 10)); - n /= 10; - } while (n > 0); - out->append(buf + i, sizeof(buf) - i); -} - // Build a complete HTTP/1.1 message (header block, optionally plus body) in a // single contiguous Buffer. Mirrors _storeHeader() keep-alive / length logic // so the JS fast path can skip intermediate string concatenation. @@ -1589,88 +1579,108 @@ void BuildHttpMessage(const FunctionCallbackInfo& args) { // statusCode is not passed in; 204/304 + chunked is handled by JS before // or after calling this builder (see tryNativeStoreHeader). - // Build into a std::string then copy once into a Buffer. Header blocks are - // small; the extra copy is cheaper than getting the size estimate wrong. - std::string msg; - msg.reserve(256 + body_len); - // body arg: null/undefined => headers-only (_storeHeader); string/buffer // (possibly empty) => complete message. Headers-only must not append the // chunked terminator - JS end() still does that. const bool headers_only = args[2]->IsNullOrUndefined(); - // HTTP headers are latin1 on the wire. Prefer WriteOneByteV2; for two-byte - // strings write the low byte of each code unit (matches Buffer latin1). + // Size estimate (upper bound) so we can write into a single malloc and + // hand it to Buffer without a second copy. + size_t est = static_cast(first_line_v->Length()) + 128 + body_len; + for (const auto& pair : header_pairs) { + est += static_cast(pair.first->Length()) + + static_cast(pair.second->Length()) + 4; // ": \r\n" + } + if (chunked) est += 32; // chunk framing + + char* raw = UncheckedMalloc(est); + if (raw == nullptr) { + return; + } + size_t off = 0; + + auto append_raw = [&](const char* p, size_t n) { + CHECK_LE(off + n, est); + if (n > 0) { + std::memcpy(raw + off, p, n); + off += n; + } + }; + auto append_lit = [&](const char* lit) { append_raw(lit, std::strlen(lit)); }; auto append_v8_latin1 = [&](Local s) { const int n = s->Length(); - const size_t at = msg.size(); - msg.resize(at + static_cast(n)); + CHECK_LE(off + static_cast(n), est); if (s->IsOneByte()) { - s->WriteOneByteV2(isolate, 0, n, reinterpret_cast(&msg[at])); + s->WriteOneByteV2(isolate, 0, n, reinterpret_cast(raw + off)); } else { - // Two-byte string: extract low bytes (latin1 semantics). std::vector tmp(static_cast(n)); s->WriteV2(isolate, 0, n, tmp.data()); for (int i = 0; i < n; i++) { - msg[at + static_cast(i)] = + raw[off + static_cast(i)] = static_cast(tmp[static_cast(i)] & 0xff); } } + off += static_cast(n); + }; + auto append_number = [&](uint64_t n) { + char buf[32]; + size_t i = sizeof(buf); + do { + buf[--i] = static_cast('0' + (n % 10)); + n /= 10; + } while (n > 0); + append_raw(buf + i, sizeof(buf) - i); }; append_v8_latin1(first_line_v); for (const auto& pair : header_pairs) { append_v8_latin1(pair.first); - msg.append(": "); + append_lit(": "); append_v8_latin1(pair.second); - msg.append("\r\n"); + append_lit("\r\n"); } if (send_date && !saw_date && args[5]->IsString()) { - msg.append("Date: "); + append_lit("Date: "); append_v8_latin1(args[5].As()); - msg.append("\r\n"); + append_lit("\r\n"); } if (emit_connection_keep_alive) { - msg.append("Connection: keep-alive\r\n"); - // Match matchHeader('keep-alive'): a user-supplied Keep-Alive header - // disables the default timeout emission. + append_lit("Connection: keep-alive\r\n"); uint32_t ka = 0; if (args[6]->IsNumber()) { ka = args[6]->Uint32Value(env->context()).FromMaybe(0); } if (default_keep_alive && !saw_keep_alive && ka > 0) { - msg.append("Keep-Alive: timeout="); - AppendNumber(&msg, ka); + append_lit("Keep-Alive: timeout="); + append_number(ka); int32_t max_req = 0; if (args[7]->IsNumber()) { max_req = args[7]->Int32Value(env->context()).FromMaybe(0); } if (max_req > 0) { - msg.append(", max="); - AppendNumber(&msg, static_cast(max_req)); + append_lit(", max="); + append_number(static_cast(max_req)); } - msg.append("\r\n"); + append_lit("\r\n"); } } else if (emit_connection_close) { - msg.append("Connection: close\r\n"); + append_lit("Connection: close\r\n"); } if (need_auto_cont_len && content_length >= 0) { - msg.append("Content-Length: "); - AppendNumber(&msg, static_cast(content_length)); - msg.append("\r\n"); + append_lit("Content-Length: "); + append_number(static_cast(content_length)); + append_lit("\r\n"); } if (need_auto_te) { - msg.append("Transfer-Encoding: chunked\r\n"); + append_lit("Transfer-Encoding: chunked\r\n"); } - msg.append("\r\n"); + append_lit("\r\n"); - // Body section only for complete messages. Headers-only builds leave - // chunked framing to the existing JS end()/write path. if (!headers_only && has_body && (body_is_buffer || body_is_string)) { if (chunked && body_len > 0) { char hex[16]; @@ -1679,38 +1689,43 @@ void BuildHttpMessage(const FunctionCallbackInfo& args) { sizeof(hex), "%llx", static_cast(body_len)); // NOLINT - msg.append(hex, static_cast(n)); - msg.append("\r\n"); + append_raw(hex, static_cast(n)); + append_lit("\r\n"); } if (body_is_buffer && body_len > 0) { - msg.append(body_buf_data, body_len); + append_raw(body_buf_data, body_len); } else if (body_is_string && body_len > 0) { - const size_t at = msg.size(); + CHECK_LE(off + body_len + 1, est); if (body_encoding == 1) { - msg.resize(at + body_len); body_str->WriteOneByteV2(isolate, 0, body_str->Length(), - reinterpret_cast(&msg[at])); + reinterpret_cast(raw + off)); + off += body_len; } else { - // +1 capacity so WriteUtf8V2 can null-terminate; we drop the NUL. - msg.resize(at + body_len + 1); const size_t written = body_str->WriteUtf8V2( - isolate, &msg[at], body_len + 1, String::WriteFlags::kNone); - // written includes the trailing NUL when it fits. + isolate, raw + off, body_len + 1, String::WriteFlags::kNone); size_t payload = written; - if (payload > 0 && msg[at + payload - 1] == '\0') payload--; - msg.resize(at + payload); + if (payload > 0 && raw[off + payload - 1] == '\0') payload--; + off += payload; } } if (chunked) { - if (body_len > 0) msg.append("\r\n"); - msg.append("0\r\n\r\n"); + if (body_len > 0) append_lit("\r\n"); + append_lit("0\r\n\r\n"); } } Local buf_obj; - if (!Buffer::Copy(env, msg.data(), msg.size()).ToLocal(&buf_obj)) { + // Transfer ownership of `raw` to the Buffer; free callback releases it. + if (!Buffer::New( + isolate, + raw, + off, + [](char* data, void* /*hint*/) { free(data); }, + nullptr) + .ToLocal(&buf_obj)) { + free(raw); return; }