Skip to content

Commit 62327c4

Browse files
committed
http,http2: coalesce chunked end() and cache status lines
Send headers, the last chunk, and the chunked terminator in a single write() when res.end() is used with Transfer-Encoding: chunked. Reuse prebuilt HTTP/1.1 status lines for default reason phrases, skip toLowerCase() on common outgoing header names, and hoist HTTP/2 header serialization off the per-call closure. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent fbb6c1c commit 62327c4

6 files changed

Lines changed: 346 additions & 112 deletions

File tree

‎lib/_http_incoming.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,11 +503,12 @@ IncomingMessage.prototype._hasBodyHeaders = function _hasBodyHeaders() {
503503
const len = name.length;
504504
if (len === 14) {
505505
if (name === 'Content-Length' || name === 'content-length' ||
506-
name.toLowerCase() === 'content-length') {
506+
name === 'CONTENT-LENGTH' || name.toLowerCase() === 'content-length') {
507507
return true;
508508
}
509509
} else if (len === 17) {
510510
if (name === 'Transfer-Encoding' || name === 'transfer-encoding' ||
511+
name === 'TRANSFER-ENCODING' ||
511512
name.toLowerCase() === 'transfer-encoding') {
512513
return true;
513514
}

‎lib/_http_outgoing.js‎

Lines changed: 132 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -94,21 +94,22 @@ const kFlushError = Symbol('kFlushError');
9494
const kHighWaterMark = Symbol('kHighWaterMark');
9595
const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites');
9696
const kLenientHeaderValidation = Symbol('kLenientHeaderValidation');
97+
const kChunkedEndSent = Symbol('kChunkedEndSent');
9798
const kMaxHeaderBodyCoalesce = 16 * 1024;
9899

99100
const nop = () => {};
100101

101102
const RE_CONN_CLOSE = /(?:^|\W)close(?:$|\W)/i;
102103

103-
// isCookieField performs a case-insensitive comparison of a provided string
104-
// against the word "cookie." As of V8 6.6 this is faster than handrolling or
105-
// using a case-insensitive RegExp.
106104
function isCookieField(s) {
107-
return s.length === 6 && s.toLowerCase() === 'cookie';
105+
return s.length === 6 &&
106+
(s === 'Cookie' || s === 'cookie' || s.toLowerCase() === 'cookie');
108107
}
109108

110109
function isContentDispositionField(s) {
111-
return s.length === 19 && s.toLowerCase() === 'content-disposition';
110+
return s.length === 19 &&
111+
(s === 'Content-Disposition' || s === 'content-disposition' ||
112+
s.toLowerCase() === 'content-disposition');
112113
}
113114

114115
function OutgoingMessage(options) {
@@ -637,24 +638,80 @@ function storeHeader(self, state, key, value, validate, lenient) {
637638
matchHeader(self, state, key, value);
638639
}
639640

641+
function matchConnection(self, state, value) {
642+
state.connection = true;
643+
self._removedConnection = false;
644+
if (RE_CONN_CLOSE.test(value))
645+
self._last = true;
646+
else
647+
self.shouldKeepAlive = true;
648+
}
649+
650+
function matchTransferEncoding(self, state, value) {
651+
state.te = true;
652+
self._removedTE = false;
653+
if (RE_TE_CHUNKED.test(value))
654+
self.chunkedEncoding = true;
655+
}
656+
640657
function matchHeader(self, state, field, value) {
641-
if (field.length < 4 || field.length > 17)
658+
const len = field.length;
659+
if (len < 4 || len > 17)
642660
return;
643-
field = field.toLowerCase();
644-
switch (field) {
661+
662+
// Avoid toLowerCase() for the usual Title-Case / lowercase spellings.
663+
switch (len) {
664+
case 4:
665+
if (field === 'Date' || field === 'date') {
666+
state.date = true;
667+
return;
668+
}
669+
break;
670+
case 6:
671+
if (field === 'Expect' || field === 'expect') {
672+
state.expect = true;
673+
return;
674+
}
675+
break;
676+
case 7:
677+
if (field === 'Trailer' || field === 'trailer') {
678+
state.trailer = true;
679+
return;
680+
}
681+
break;
682+
case 10:
683+
if (field === 'Connection' || field === 'connection') {
684+
matchConnection(self, state, value);
685+
return;
686+
}
687+
if (field === 'Keep-Alive' || field === 'keep-alive') {
688+
self._defaultKeepAlive = false;
689+
return;
690+
}
691+
break;
692+
case 14:
693+
if (field === 'Content-Length' || field === 'content-length') {
694+
state.contLen = true;
695+
self._contentLength = +value;
696+
self._removedContLen = false;
697+
return;
698+
}
699+
break;
700+
case 17:
701+
if (field === 'Transfer-Encoding' || field === 'transfer-encoding') {
702+
matchTransferEncoding(self, state, value);
703+
return;
704+
}
705+
break;
706+
}
707+
708+
const lower = field.toLowerCase();
709+
switch (lower) {
645710
case 'connection':
646-
state.connection = true;
647-
self._removedConnection = false;
648-
if (RE_CONN_CLOSE.test(value))
649-
self._last = true;
650-
else
651-
self.shouldKeepAlive = true;
711+
matchConnection(self, state, value);
652712
break;
653713
case 'transfer-encoding':
654-
state.te = true;
655-
self._removedTE = false;
656-
if (RE_TE_CHUNKED.test(value))
657-
self.chunkedEncoding = true;
714+
matchTransferEncoding(self, state, value);
658715
break;
659716
case 'content-length':
660717
state.contLen = true;
@@ -664,7 +721,7 @@ function matchHeader(self, state, field, value) {
664721
case 'date':
665722
case 'expect':
666723
case 'trailer':
667-
state[field] = true;
724+
state[lower] = true;
668725
break;
669726
case 'keep-alive':
670727
self._defaultKeepAlive = false;
@@ -917,6 +974,37 @@ ObjectDefineProperty(OutgoingMessage.prototype, 'writableNeedDrain', {
917974
});
918975

919976
const crlf_buf = Buffer.from('\r\n');
977+
978+
// One write() for headers + last chunk + terminator. Used by end(chunk)
979+
// when Transfer-Encoding is chunked and headers have not been flushed.
980+
function trySendCombinedChunkedEnd(msg, chunk, encoding, len, callback) {
981+
if (msg._headerSent || msg._header === null || msg[kChunkedLength] !== 0)
982+
return undefined;
983+
984+
const suffix = '\r\n0\r\n' + msg._trailer + '\r\n';
985+
const hex = len.toString(16);
986+
987+
if (typeof chunk === 'string' &&
988+
(encoding === 'utf8' || encoding === 'latin1' || !encoding)) {
989+
msg[kChunkedEndSent] = true;
990+
return msg._send(hex + '\r\n' + chunk + suffix, encoding, callback);
991+
}
992+
993+
if (isUint8Array(chunk) && chunk.byteLength <= kMaxHeaderBodyCoalesce) {
994+
const prefix = msg._header + hex + '\r\n';
995+
const total = prefix.length + chunk.byteLength + suffix.length;
996+
const combined = Buffer.allocUnsafe(total);
997+
combined.write(prefix, 0, prefix.length, 'latin1');
998+
combined.set(chunk, prefix.length);
999+
combined.write(suffix, prefix.length + chunk.byteLength, suffix.length, 'latin1');
1000+
msg._headerSent = true;
1001+
msg[kChunkedEndSent] = true;
1002+
return msg._writeRaw(combined, undefined, callback, total);
1003+
}
1004+
1005+
return undefined;
1006+
}
1007+
9201008
OutgoingMessage.prototype.write = function write(chunk, encoding, callback) {
9211009
if (typeof encoding === 'function') {
9221010
callback = encoding;
@@ -962,8 +1050,14 @@ function strictContentLength(msg) {
9621050
}
9631051

9641052
function write_(msg, chunk, encoding, callback, fromEnd) {
965-
if (typeof callback !== 'function')
1053+
let endCallback;
1054+
if (fromEnd && typeof callback === 'function') {
1055+
// end() owns the finish callback unless the chunked fast path sends it.
1056+
endCallback = callback;
1057+
callback = nop;
1058+
} else if (typeof callback !== 'function') {
9661059
callback = nop;
1060+
}
9671061

9681062
if (chunk === null) {
9691063
throw new ERR_STREAM_NULL_VALUES();
@@ -1030,6 +1124,12 @@ function write_(msg, chunk, encoding, callback, fromEnd) {
10301124
let ret;
10311125
if (msg.chunkedEncoding && chunk.length !== 0) {
10321126
len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength;
1127+
if (fromEnd && !msg.strictContentLength) {
1128+
const combined = trySendCombinedChunkedEnd(
1129+
msg, chunk, encoding, len, endCallback ?? nop);
1130+
if (combined !== undefined)
1131+
return combined;
1132+
}
10331133
if (msg[kCorked] && msg._headerSent) {
10341134
msg[kChunkedBuffer].push(chunk, encoding, callback);
10351135
msg[kChunkedLength] += len;
@@ -1170,7 +1270,7 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
11701270
this[kSocket].cork();
11711271
}
11721272

1173-
write_(this, chunk, encoding, null, true);
1273+
write_(this, chunk, encoding, onFinish.bind(undefined, this), true);
11741274
} else if (this.finished) {
11751275
if (typeof callback === 'function') {
11761276
queueEndCallback(this, callback);
@@ -1192,14 +1292,18 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) {
11921292
throw new ERR_HTTP_CONTENT_LENGTH_MISMATCH(this[kBytesWritten], this._contentLength);
11931293
}
11941294

1195-
const finish = onFinish.bind(undefined, this);
1196-
1197-
if (this._hasBody && this.chunkedEncoding) {
1198-
this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish);
1199-
} else if (!this._headerSent || this.writableLength || chunk) {
1200-
this._send('', 'latin1', finish);
1295+
if (this[kChunkedEndSent]) {
1296+
// Headers, last chunk, and terminator were already flushed together.
12011297
} else {
1202-
process.nextTick(finish);
1298+
const finish = onFinish.bind(undefined, this);
1299+
1300+
if (this._hasBody && this.chunkedEncoding) {
1301+
this._send('0\r\n' + this._trailer + '\r\n', 'latin1', finish);
1302+
} else if (!this._headerSent || this.writableLength || chunk) {
1303+
this._send('', 'latin1', finish);
1304+
} else {
1305+
process.nextTick(finish);
1306+
}
12031307
}
12041308

12051309
if (this[kSocket]) {

‎lib/_http_server.js‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ const STATUS_CODES = {
190190
511: 'Network Authentication Required', // RFC 6585 6
191191
};
192192

193+
const STATUS_LINES = { __proto__: null };
194+
{
195+
const codes = ObjectKeys(STATUS_CODES);
196+
for (let i = 0; i < codes.length; i++) {
197+
const code = codes[i];
198+
STATUS_LINES[code] = `HTTP/1.1 ${code} ${STATUS_CODES[code]}\r\n`;
199+
}
200+
}
201+
193202
const kOnExecute = HTTPParser.kOnExecute | 0;
194203
const kOnTimeout = HTTPParser.kOnTimeout | 0;
195204

@@ -331,7 +340,8 @@ ServerResponse.prototype.writeInformation = function writeInformation(
331340
}
332341

333342
const statusMessage = STATUS_CODES[statusCode] || 'unknown';
334-
let head = `HTTP/1.1 ${statusCode} ${statusMessage}\r\n`;
343+
let head = STATUS_LINES[statusCode] ||
344+
`HTTP/1.1 ${statusCode} ${statusMessage}\r\n`;
335345

336346
const lenient = this._isLenientHeaderValidation();
337347
if (headers !== undefined && headers !== null) {
@@ -427,12 +437,13 @@ function writeHead(statusCode, reason, obj) {
427437
}
428438

429439

440+
const defaultStatusMessage = STATUS_CODES[statusCode] || 'unknown';
430441
if (typeof reason === 'string') {
431442
// writeHead(statusCode, reasonPhrase[, headers])
432443
this.statusMessage = reason;
433444
} else {
434445
// writeHead(statusCode[, headers])
435-
this.statusMessage ||= STATUS_CODES[statusCode] || 'unknown';
446+
this.statusMessage ||= defaultStatusMessage;
436447
obj ??= reason;
437448
}
438449
this.statusCode = statusCode;
@@ -475,10 +486,16 @@ function writeHead(statusCode, reason, obj) {
475486
headers = obj;
476487
}
477488

478-
if (checkInvalidHeaderChar(this.statusMessage))
479-
throw new ERR_INVALID_CHAR('statusMessage');
480-
481-
const statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`;
489+
let statusLine;
490+
if (this.statusMessage === defaultStatusMessage) {
491+
// Default reason phrases are already valid; reuse the prebuilt status line.
492+
statusLine = STATUS_LINES[statusCode] ||
493+
`HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`;
494+
} else {
495+
if (checkInvalidHeaderChar(this.statusMessage))
496+
throw new ERR_INVALID_CHAR('statusMessage');
497+
statusLine = `HTTP/1.1 ${statusCode} ${this.statusMessage}\r\n`;
498+
}
482499

483500
if (statusCode === 204 || statusCode === 304 ||
484501
(statusCode >= 100 && statusCode <= 199)) {

‎lib/internal/http2/core.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2858,7 +2858,15 @@ function prepareResponseHeadersArray(headers, options) {
28582858
let isDateSet = false;
28592859

28602860
for (let i = 0; i < headers.length; i += 2) {
2861-
const header = headers[i].toLowerCase();
2861+
let header = headers[i];
2862+
const headerLen = header.length;
2863+
for (let j = 0; j < headerLen; j++) {
2864+
const c = header.charCodeAt(j);
2865+
if (c >= 65 && c <= 90) {
2866+
header = header.toLowerCase();
2867+
break;
2868+
}
2869+
}
28622870
const value = headers[i + 1];
28632871

28642872
if (header === HTTP2_HEADER_STATUS) {

0 commit comments

Comments
 (0)