Skip to content

Commit 867b723

Browse files
committed
http: add uniqueHeaders option to request and createServer
1 parent ed77955 commit 867b723

File tree

6 files changed

+385
-13
lines changed

6 files changed

+385
-13
lines changed

doc/api/http.md

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2223,8 +2223,28 @@ header name:
22232223
`last-modified`, `location`, `max-forwards`, `proxy-authorization`, `referer`,
22242224
`retry-after`, `server`, or `user-agent` are discarded.
22252225
* `set-cookie` is always an array. Duplicates are added to the array.
2226-
* For duplicate `cookie` headers, the values are joined together with '; '.
2227-
* For all other headers, the values are joined together with ', '.
2226+
* For duplicate `cookie` headers, the values are joined together with `; `.
2227+
* For all other headers, the values are joined together with `, `.
2228+
2229+
### `message.headersDistinct`
2230+
2231+
<!-- YAML
2232+
added: REPLACEME
2233+
-->
2234+
2235+
* {Object}
2236+
2237+
Similar to [`message.headers`][], but there is no join logic and the values are
2238+
always arrays of strings, even for headers received just once.
2239+
2240+
```js
2241+
// Prints something like:
2242+
//
2243+
// { 'user-agent': ['curl/7.22.0'],
2244+
// host: ['127.0.0.1:8000'],
2245+
// accept: ['*/*'] }
2246+
console.log(request.headersDistinct);
2247+
```
22282248

22292249
### `message.httpVersion`
22302250

@@ -2358,6 +2378,18 @@ added: v0.3.0
23582378

23592379
The request/response trailers object. Only populated at the `'end'` event.
23602380

2381+
### `message.trailersDistinct`
2382+
2383+
<!-- YAML
2384+
added: REPLACEME
2385+
-->
2386+
2387+
* {Object}
2388+
2389+
Similar to [`message.trailers`][], but there is no join logic and the values are
2390+
always arrays of strings, even for headers received just once.
2391+
Only populated at the `'end'` event.
2392+
23612393
### `message.url`
23622394

23632395
<!-- YAML
@@ -2455,7 +2487,7 @@ Adds HTTP trailers (headers but at the end of the message) to the message.
24552487
Trailers are **only** be emitted if the message is chunked encoded. If not,
24562488
the trailer will be silently discarded.
24572489

2458-
HTTP requires the `Trailer` header to be sent to emit trailers,
2490+
HTTP requires the `Trailer` header to be sent to emit trailers,
24592491
with a list of header fields in its value, e.g.
24602492

24612493
```js
@@ -2469,6 +2501,28 @@ message.end();
24692501
Attempting to set a header field name or value that contains invalid characters
24702502
will result in a `TypeError` being thrown.
24712503

2504+
### `outgoingMessage.appendHeader(name, value)`
2505+
2506+
<!-- YAML
2507+
added: REPLACEME
2508+
-->
2509+
2510+
* `name` {string} Header name
2511+
* `value` {string|string} Header value
2512+
* Returns: {this}
2513+
2514+
Append a single header value for the header object.
2515+
2516+
If the value is an array, this is equivalent of calling this method multiple
2517+
times.
2518+
2519+
If there were no previous value for the header, this is equivalent of calling
2520+
[`outgoingMessage.setHeader(name, value)`][].
2521+
2522+
Depending of the value of `options.uniqueHeaders` when the client request or the
2523+
server were created, this will end up in the header being sent multiple times or
2524+
a single time with values joined using `, `.
2525+
24722526
### `outgoingMessage.connection`
24732527

24742528
<!-- YAML
@@ -2865,6 +2919,9 @@ changes:
28652919
[`--max-http-header-size`][] for requests received by this server, i.e.
28662920
the maximum length of request headers in bytes.
28672921
**Default:** 16384 (16 KB).
2922+
* `uniqueHeaders` {Array} A list of response headers that should be sent only
2923+
once. If the header's value is an array, the items will be joined
2924+
using `; `.
28682925

28692926
* `requestListener` {Function}
28702927

@@ -3099,12 +3156,15 @@ changes:
30993156
* `protocol` {string} Protocol to use. **Default:** `'http:'`.
31003157
* `setHost` {boolean}: Specifies whether or not to automatically add the
31013158
`Host` header. Defaults to `true`.
3159+
* `signal` {AbortSignal}: An AbortSignal that may be used to abort an ongoing
3160+
request.
31023161
* `socketPath` {string} Unix domain socket. Cannot be used if one of `host`
31033162
or `port` is specified, as those specify a TCP Socket.
31043163
* `timeout` {number}: A number specifying the socket timeout in milliseconds.
31053164
This will set the timeout before the socket is connected.
3106-
* `signal` {AbortSignal}: An AbortSignal that may be used to abort an ongoing
3107-
request.
3165+
* `uniqueHeaders` {Array} A list of request headers that should be sent
3166+
only once. If the header's value is an array, the items will be joined
3167+
using `; `.
31083168
* `callback` {Function}
31093169
* Returns: {http.ClientRequest}
31103170

@@ -3407,11 +3467,13 @@ try {
34073467
[`http.request()`]: #httprequestoptions-callback
34083468
[`message.headers`]: #messageheaders
34093469
[`message.socket`]: #messagesocket
3470+
[`message.trailers`]: #messagetrailers
34103471
[`net.Server.close()`]: net.md#serverclosecallback
34113472
[`net.Server`]: net.md#class-netserver
34123473
[`net.Socket`]: net.md#class-netsocket
34133474
[`net.createConnection()`]: net.md#netcreateconnectionoptions-connectlistener
34143475
[`new URL()`]: url.md#new-urlinput-base
3476+
[`outgoingMessage.setHeader(name, value)`]: #outgoingmessagesetheadername-value
34153477
[`outgoingMessage.socket`]: #outgoingmessagesocket
34163478
[`removeHeader(name)`]: #requestremoveheadername
34173479
[`request.destroy()`]: #requestdestroyerror

lib/_http_client.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ const {
5252
isLenient,
5353
prepareError,
5454
} = require('_http_common');
55-
const { OutgoingMessage } = require('_http_outgoing');
55+
const {
56+
kUniqueHeaders,
57+
parseUniqueHeaders,
58+
OutgoingMessage
59+
} = require('_http_outgoing');
5660
const Agent = require('_http_agent');
5761
const { Buffer } = require('buffer');
5862
const { defaultTriggerAsyncIdScope } = require('internal/async_hooks');
@@ -294,6 +298,8 @@ function ClientRequest(input, options, cb) {
294298
options.headers);
295299
}
296300

301+
this[kUniqueHeaders] = parseUniqueHeaders(options.uniqueHeaders);
302+
297303
let optsWithoutSignal = options;
298304
if (optsWithoutSignal.signal) {
299305
optsWithoutSignal = ObjectAssign({}, options);

lib/_http_incoming.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ const {
3333
const { Readable, finished } = require('stream');
3434

3535
const kHeaders = Symbol('kHeaders');
36+
const kHeadersDistinct = Symbol('kHeadersDistinct');
3637
const kHeadersCount = Symbol('kHeadersCount');
3738
const kTrailers = Symbol('kTrailers');
39+
const kTrailersDistinct = Symbol('kTrailersDistinct');
3840
const kTrailersCount = Symbol('kTrailersCount');
3941

4042
function readStart(socket) {
@@ -123,6 +125,25 @@ ObjectDefineProperty(IncomingMessage.prototype, 'headers', {
123125
}
124126
});
125127

128+
ObjectDefineProperty(IncomingMessage.prototype, 'headersDistinct', {
129+
get: function() {
130+
if (!this[kHeadersDistinct]) {
131+
this[kHeadersDistinct] = {};
132+
133+
const src = this.rawHeaders;
134+
const dst = this[kHeadersDistinct];
135+
136+
for (let n = 0; n < this[kHeadersCount]; n += 2) {
137+
this._addHeaderLineDistinct(src[n + 0], src[n + 1], dst);
138+
}
139+
}
140+
return this[kHeadersDistinct];
141+
},
142+
set: function(val) {
143+
this[kHeadersDistinct] = val;
144+
}
145+
});
146+
126147
ObjectDefineProperty(IncomingMessage.prototype, 'trailers', {
127148
get: function() {
128149
if (!this[kTrailers]) {
@@ -142,6 +163,25 @@ ObjectDefineProperty(IncomingMessage.prototype, 'trailers', {
142163
}
143164
});
144165

166+
ObjectDefineProperty(IncomingMessage.prototype, 'trailersDistinct', {
167+
get: function() {
168+
if (!this[kTrailersDistinct]) {
169+
this[kTrailersDistinct] = {};
170+
171+
const src = this.rawTrailers;
172+
const dst = this[kTrailersDistinct];
173+
174+
for (let n = 0; n < this[kTrailersCount]; n += 2) {
175+
this._addHeaderLineDistinct(src[n + 0], src[n + 1], dst);
176+
}
177+
}
178+
return this[kTrailersDistinct];
179+
},
180+
set: function(val) {
181+
this[kTrailersDistinct] = val;
182+
}
183+
});
184+
145185
IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) {
146186
if (callback)
147187
this.on('timeout', callback);
@@ -361,6 +401,16 @@ function _addHeaderLine(field, value, dest) {
361401
}
362402
}
363403

404+
IncomingMessage.prototype._addHeaderLineDistinct = _addHeaderLineDistinct;
405+
function _addHeaderLineDistinct(field, value, dest) {
406+
field = StringPrototypeToLowerCase(field);
407+
if (!dest[field]) {
408+
dest[field] = [value];
409+
} else {
410+
dest[field].push(value);
411+
}
412+
}
413+
364414

365415
// Call this instead of resume() if we want to just
366416
// dump all the data to /dev/null

lib/_http_outgoing.js

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
const {
2525
Array,
2626
ArrayIsArray,
27+
ArrayPrototypeIncludes,
2728
ArrayPrototypeJoin,
2829
MathFloor,
2930
NumberPrototypeToString,
@@ -82,6 +83,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
8283
const HIGH_WATER_MARK = getDefaultHighWaterMark();
8384

8485
const kCorked = Symbol('corked');
86+
const kUniqueHeaders = Symbol('kUniqueHeaders');
8587

8688
const nop = () => {};
8789

@@ -502,14 +504,19 @@ function processHeader(self, state, key, value, validate) {
502504
if (validate)
503505
validateHeaderName(key);
504506
if (ArrayIsArray(value)) {
505-
if (value.length < 2 || !isCookieField(key)) {
507+
if (
508+
(value.length < 2 || !isCookieField(key)) &&
509+
!ArrayPrototypeIncludes(
510+
self[kUniqueHeaders], StringPrototypeToLowerCase(key)
511+
)
512+
) {
506513
// Retain for(;;) loop for performance reasons
507514
// Refs: https://github.com/nodejs/node/pull/30958
508515
for (let i = 0; i < value.length; i++)
509516
storeHeader(self, state, key, value[i], validate);
510517
return;
511518
}
512-
value = ArrayPrototypeJoin(value, '; ');
519+
value = ArrayPrototypeJoin(value, ', ');
513520
}
514521
storeHeader(self, state, key, value, validate);
515522
}
@@ -571,6 +578,20 @@ const validateHeaderValue = hideStackFrames((name, value) => {
571578
}
572579
});
573580

581+
function parseUniqueHeaders(headers) {
582+
if (!ArrayIsArray(headers)) {
583+
return [];
584+
}
585+
586+
const l = headers.length;
587+
const unique = Array(l);
588+
for (let i = 0; i < l; i++) {
589+
unique[i] = StringPrototypeToLowerCase(headers[i]);
590+
}
591+
592+
return unique;
593+
}
594+
574595
OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
575596
if (this._header) {
576597
throw new ERR_HTTP_HEADERS_SENT('set');
@@ -586,6 +607,36 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
586607
return this;
587608
};
588609

610+
OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) {
611+
if (this._header) {
612+
throw new ERR_HTTP_HEADERS_SENT('append');
613+
}
614+
validateHeaderName(name);
615+
validateHeaderValue(name, value);
616+
617+
const field = StringPrototypeToLowerCase(name);
618+
const headers = this[kOutHeaders];
619+
if (headers === null || !headers[field]) {
620+
return this.setHeader(name, value);
621+
}
622+
623+
// Prepare the field for appending, if required
624+
if (!ArrayIsArray(headers[field][1])) {
625+
headers[field][1] = [headers[field][1]];
626+
}
627+
628+
const existingValues = headers[field][1];
629+
if (ArrayIsArray(value)) {
630+
for (let i = 0, length = value.length; i < length; i++) {
631+
existingValues.push(value[i]);
632+
}
633+
} else {
634+
existingValues.push(value);
635+
}
636+
637+
return this;
638+
};
639+
589640

590641
OutgoingMessage.prototype.getHeader = function getHeader(name) {
591642
validateString(name, 'name');
@@ -817,11 +868,33 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
817868
if (typeof field !== 'string' || !field || !checkIsHttpToken(field)) {
818869
throw new ERR_INVALID_HTTP_TOKEN('Trailer name', field);
819870
}
820-
if (checkInvalidHeaderChar(value)) {
821-
debug('Trailer "%s" contains invalid characters', field);
822-
throw new ERR_INVALID_CHAR('trailer content', field);
871+
872+
// Check if the field must be sent several times
873+
const isArrayValue = ArrayIsArray(value);
874+
if (
875+
isArrayValue && value.length > 1 &&
876+
!ArrayPrototypeIncludes(
877+
this[kUniqueHeaders], StringPrototypeToLowerCase(field)
878+
)
879+
) {
880+
for (let j = 0, l = value.length; j < l; j++) {
881+
if (checkInvalidHeaderChar(value[j])) {
882+
debug('Trailer "%s"[%d] contains invalid characters', field, j);
883+
throw new ERR_INVALID_CHAR('trailer content', field);
884+
}
885+
this._trailer += field + ': ' + value[j] + '\r\n';
886+
}
887+
} else {
888+
if (isArrayValue) {
889+
value = ArrayPrototypeJoin(value, ', ');
890+
}
891+
892+
if (checkInvalidHeaderChar(value)) {
893+
debug('Trailer "%s" contains invalid characters', field);
894+
throw new ERR_INVALID_CHAR('trailer content', field);
895+
}
896+
this._trailer += field + ': ' + value + '\r\n';
823897
}
824-
this._trailer += field + ': ' + value + '\r\n';
825898
}
826899
};
827900

@@ -997,6 +1070,8 @@ function(err, event) {
9971070
};
9981071

9991072
module.exports = {
1073+
kUniqueHeaders,
1074+
parseUniqueHeaders,
10001075
validateHeaderName,
10011076
validateHeaderValue,
10021077
OutgoingMessage

0 commit comments

Comments
 (0)