Repository: nodejs/node
Pull request: http: add optimizeEmptyRequests option for IncomingMessage._dump

Backportable version of https://github.com/nodejs/node/pull/59747

cc: @ronag 

<img width="1902" height="969" alt="image" src="https://github.com/user-attachments/assets/89ff10b9-79d8-45cf-89a5-cdc0cd9d8053" />


Changed files:
filename: doc/api/http.md
patch: @@ -3555,6 +3555,9 @@ Found'`.
 <!-- YAML
 added: v0.1.13
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/59778
+    description: Add optimizeEmptyRequests option.
   - version: v24.9.0
     pr-url: https://github.com/nodejs/node/pull/59824
     description: The `shouldUpgradeCallback` option is now supported.
@@ -3660,6 +3663,11 @@ changes:
   * `rejectNonStandardBodyWrites` {boolean} If set to `true`, an error is thrown
     when writing to an HTTP response which does not have a body.
     **Default:** `false`.
+  * `optimizeEmptyRequests` {boolean} If set to `true`, requests without `Content-Length`
+    or `Transfer-Encoding` headers (indicating no body) will be initialized with an
+    already-ended body stream, so they will never emit any stream events
+    (like `'data'` or `'end'`). You can use `req.readableEnded` to detect this case.
+    **Default:** `false`.
 
 * `requestListener` {Function}
 

filename: lib/_http_incoming.js
patch: @@ -423,6 +423,15 @@ function _addHeaderLineDistinct(field, value, dest) {
   }
 }
 
+IncomingMessage.prototype._dumpAndCloseReadable = function _dumpAndCloseReadable() {
+  this._dumped = true;
+  this._readableState.ended = true;
+  this._readableState.endEmitted = true;
+  this._readableState.destroyed = true;
+  this._readableState.closed = true;
+  this._readableState.closeEmitted = true;
+};
+
 
 // Call this instead of resume() if we want to just
 // dump all the data to /dev/null

filename: lib/_http_server.js
patch: @@ -107,6 +107,8 @@ const onResponseFinishChannel = dc.channel('http.server.response.finish');
 const kServerResponse = Symbol('ServerResponse');
 const kServerResponseStatistics = Symbol('ServerResponseStatistics');
 
+const kOptimizeEmptyRequests = Symbol('OptimizeEmptyRequestsOption');
+
 const {
   hasObserver,
   startPerf,
@@ -455,6 +457,11 @@ function storeHTTPOptions(options) {
     validateInteger(maxHeaderSize, 'maxHeaderSize', 0);
   this.maxHeaderSize = maxHeaderSize;
 
+  const optimizeEmptyRequests = options.optimizeEmptyRequests;
+  if (optimizeEmptyRequests !== undefined)
+    validateBoolean(optimizeEmptyRequests, 'options.optimizeEmptyRequests');
+  this[kOptimizeEmptyRequests] = optimizeEmptyRequests || false;
+
   const insecureHTTPParser = options.insecureHTTPParser;
   if (insecureHTTPParser !== undefined)
     validateBoolean(insecureHTTPParser, 'options.insecureHTTPParser');
@@ -1069,6 +1076,10 @@ function emitCloseNT(self) {
   }
 }
 
+function hasBodyHeaders(headers) {
+  return ('content-length' in headers) || ('transfer-encoding' in headers);
+}
+
 // The following callback is issued after the headers have been read on a
 // new message. In this callback we setup the response object and pass it
 // to the user.
@@ -1120,6 +1131,19 @@ function parserOnIncoming(server, socket, state, req, keepAlive) {
     });
   }
 
+  // Check if we should optimize empty requests (those without Content-Length or Transfer-Encoding headers)
+  const shouldOptimize = server[kOptimizeEmptyRequests] === true && !hasBodyHeaders(req.headers);
+
+  if (shouldOptimize) {
+    // Fast processing where emitting 'data', 'end' and 'close' events is
+    // skipped and data is dumped.
+    // This avoids a lot of unnecessary overhead otherwise introduced by
+    // stream.Readable life cycle rules. The downside is that this will
+    // break some servers that read bodies for methods that don't have body headers.
+    req._dumpAndCloseReadable();
+    req._read();
+  }
+
   if (socket._httpMessage) {
     // There are already pending outgoing res, append.
     state.outgoing.push(res);

filename: test/parallel/test-http-server-optimize-empty-requests.js
patch: @@ -0,0 +1,77 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const http = require('http');
+const net = require('net');
+
+let reqs = 0;
+let optimizedReqs = 0;
+const server = http.createServer({
+  optimizeEmptyRequests: true
+}, (req, res) => {
+  reqs++;
+  if (req._dumped) {
+    optimizedReqs++;
+    req.on('data', common.mustNotCall());
+    req.on('end', common.mustNotCall());
+
+    assert.strictEqual(req._dumped, true);
+    assert.strictEqual(req.readableEnded, true);
+    assert.strictEqual(req.destroyed, true);
+  }
+  res.writeHead(200);
+  res.end('ok');
+});
+
+server.listen(0, common.mustCall(async () => {
+  // GET request without Content-Length (should be optimized)
+  const getRequest = 'GET / HTTP/1.1\r\nHost: localhost\r\n\r\n';
+  await makeRequest(getRequest);
+
+  // HEAD request (should always be optimized regardless of headers)
+  const headRequest = 'HEAD / HTTP/1.1\r\nHost: localhost\r\n\r\n';
+  await makeRequest(headRequest);
+
+  // POST request without body headers (should be optimized)
+  const postWithoutBodyHeaders = 'POST / HTTP/1.1\r\nHost: localhost\r\n\r\n';
+  await makeRequest(postWithoutBodyHeaders);
+
+  // DELETE request without body headers (should be optimized)
+  const deleteWithoutBodyHeaders = 'DELETE / HTTP/1.1\r\nHost: localhost\r\n\r\n';
+  await makeRequest(deleteWithoutBodyHeaders);
+
+  // POST request with Content-Length header (should not be optimized)
+  const postWithContentLength = 'POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n';
+  await makeRequest(postWithContentLength);
+
+  // GET request with Content-Length header (should not be optimized)
+  const getWithContentLength = 'GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n';
+  await makeRequest(getWithContentLength);
+
+  // POST request with Transfer-Encoding header (should not be optimized)
+  const postWithTransferEncoding = 'POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n';
+  await makeRequest(postWithTransferEncoding);
+
+  // GET request with Transfer-Encoding header (should not be optimized)
+  const getWithTransferEncoding = 'GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n';
+  await makeRequest(getWithTransferEncoding);
+
+  server.close();
+
+  assert.strictEqual(reqs, 8, `Expected 8 requests but got ${reqs}`);
+  assert.strictEqual(optimizedReqs, 4, `Expected 4 optimized requests but got ${optimizedReqs}`);
+}));
+
+function makeRequest(str) {
+  return new Promise((resolve) => {
+    const client = net.connect({ port: server.address().port }, common.mustCall(() => {
+      client.on('data', () => {});
+      client.on('end', common.mustCall(() => {
+        resolve();
+      }));
+      client.write(str);
+      client.end();
+    }));
+  });
+}