Skip to content

Commit

Permalink
http: add uniqueHeaders option to request and createServer
Browse files Browse the repository at this point in the history
PR-URL: #41397
Reviewed-By: Robert Nagy <[email protected]>
Reviewed-By: Matteo Collina <[email protected]>
  • Loading branch information
ShogunPanda authored and targos committed Jul 12, 2022
1 parent 2265236 commit cb74011
Show file tree
Hide file tree
Showing 6 changed files with 380 additions and 13 deletions.
72 changes: 67 additions & 5 deletions doc/api/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -2333,8 +2333,28 @@ header name:
`last-modified`, `location`, `max-forwards`, `proxy-authorization`, `referer`,
`retry-after`, `server`, or `user-agent` are discarded.
* `set-cookie` is always an array. Duplicates are added to the array.
* For duplicate `cookie` headers, the values are joined together with '; '.
* For all other headers, the values are joined together with ', '.
* For duplicate `cookie` headers, the values are joined together with `; `.
* For all other headers, the values are joined together with `, `.

### `message.headersDistinct`

<!-- YAML
added: REPLACEME
-->

* {Object}

Similar to [`message.headers`][], but there is no join logic and the values are
always arrays of strings, even for headers received just once.

```js
// Prints something like:
//
// { 'user-agent': ['curl/7.22.0'],
// host: ['127.0.0.1:8000'],
// accept: ['*/*'] }
console.log(request.headersDistinct);
```

### `message.httpVersion`

Expand Down Expand Up @@ -2468,6 +2488,18 @@ added: v0.3.0

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

### `message.trailersDistinct`

<!-- YAML
added: REPLACEME
-->

* {Object}

Similar to [`message.trailers`][], but there is no join logic and the values are
always arrays of strings, even for headers received just once.
Only populated at the `'end'` event.

### `message.url`

<!-- YAML
Expand Down Expand Up @@ -2565,7 +2597,7 @@ Adds HTTP trailers (headers but at the end of the message) to the message.
Trailers will **only** be emitted if the message is chunked encoded. If not,
the trailers will be silently discarded.

HTTP requires the `Trailer` header to be sent to emit trailers,
HTTP requires the `Trailer` header to be sent to emit trailers,
with a list of header field names in its value, e.g.

```js
Expand All @@ -2579,6 +2611,28 @@ message.end();
Attempting to set a header field name or value that contains invalid characters
will result in a `TypeError` being thrown.

### `outgoingMessage.appendHeader(name, value)`

<!-- YAML
added: REPLACEME
-->

* `name` {string} Header name
* `value` {string|string\[]} Header value
* Returns: {this}

Append a single header value for the header object.

If the value is an array, this is equivalent of calling this method multiple
times.

If there were no previous value for the header, this is equivalent of calling
[`outgoingMessage.setHeader(name, value)`][].

Depending of the value of `options.uniqueHeaders` when the client request or the
server were created, this will end up in the header being sent multiple times or
a single time with values joined using `; `.

### `outgoingMessage.connection`

<!-- YAML
Expand Down Expand Up @@ -2970,6 +3024,9 @@ changes:
* `keepAliveInitialDelay` {number} If set to a positive number, it sets the
initial delay before the first keepalive probe is sent on an idle socket.
**Default:** `0`.
* `uniqueHeaders` {Array} A list of response headers that should be sent only
once. If the header's value is an array, the items will be joined
using `; `.

* `requestListener` {Function}

Expand Down Expand Up @@ -3202,12 +3259,15 @@ changes:
* `protocol` {string} Protocol to use. **Default:** `'http:'`.
* `setHost` {boolean}: Specifies whether or not to automatically add the
`Host` header. Defaults to `true`.
* `signal` {AbortSignal}: An AbortSignal that may be used to abort an ongoing
request.
* `socketPath` {string} Unix domain socket. Cannot be used if one of `host`
or `port` is specified, as those specify a TCP Socket.
* `timeout` {number}: A number specifying the socket timeout in milliseconds.
This will set the timeout before the socket is connected.
* `signal` {AbortSignal}: An AbortSignal that may be used to abort an ongoing
request.
* `uniqueHeaders` {Array} A list of request headers that should be sent
only once. If the header's value is an array, the items will be joined
using `; `.
* `callback` {Function}
* Returns: {http.ClientRequest}

Expand Down Expand Up @@ -3513,11 +3573,13 @@ try {
[`http.request()`]: #httprequestoptions-callback
[`message.headers`]: #messageheaders
[`message.socket`]: #messagesocket
[`message.trailers`]: #messagetrailers
[`net.Server.close()`]: net.md#serverclosecallback
[`net.Server`]: net.md#class-netserver
[`net.Socket`]: net.md#class-netsocket
[`net.createConnection()`]: net.md#netcreateconnectionoptions-connectlistener
[`new URL()`]: url.md#new-urlinput-base
[`outgoingMessage.setHeader(name, value)`]: #outgoingmessagesetheadername-value
[`outgoingMessage.socket`]: #outgoingmessagesocket
[`removeHeader(name)`]: #requestremoveheadername
[`request.destroy()`]: #requestdestroyerror
Expand Down
8 changes: 7 additions & 1 deletion lib/_http_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ const {
isLenient,
prepareError,
} = require('_http_common');
const { OutgoingMessage } = require('_http_outgoing');
const {
kUniqueHeaders,
parseUniqueHeadersOption,
OutgoingMessage
} = require('_http_outgoing');
const Agent = require('_http_agent');
const { Buffer } = require('buffer');
const { defaultTriggerAsyncIdScope } = require('internal/async_hooks');
Expand Down Expand Up @@ -303,6 +307,8 @@ function ClientRequest(input, options, cb) {
options.headers);
}

this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders);

let optsWithoutSignal = options;
if (optsWithoutSignal.signal) {
optsWithoutSignal = ObjectAssign({}, options);
Expand Down
50 changes: 50 additions & 0 deletions lib/_http_incoming.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ const {
const { Readable, finished } = require('stream');

const kHeaders = Symbol('kHeaders');
const kHeadersDistinct = Symbol('kHeadersDistinct');
const kHeadersCount = Symbol('kHeadersCount');
const kTrailers = Symbol('kTrailers');
const kTrailersDistinct = Symbol('kTrailersDistinct');
const kTrailersCount = Symbol('kTrailersCount');

function readStart(socket) {
Expand Down Expand Up @@ -123,6 +125,25 @@ ObjectDefineProperty(IncomingMessage.prototype, 'headers', {
}
});

ObjectDefineProperty(IncomingMessage.prototype, 'headersDistinct', {
get: function() {
if (!this[kHeadersDistinct]) {
this[kHeadersDistinct] = {};

const src = this.rawHeaders;
const dst = this[kHeadersDistinct];

for (let n = 0; n < this[kHeadersCount]; n += 2) {
this._addHeaderLineDistinct(src[n + 0], src[n + 1], dst);
}
}
return this[kHeadersDistinct];
},
set: function(val) {
this[kHeadersDistinct] = val;
}
});

ObjectDefineProperty(IncomingMessage.prototype, 'trailers', {
get: function() {
if (!this[kTrailers]) {
Expand All @@ -142,6 +163,25 @@ ObjectDefineProperty(IncomingMessage.prototype, 'trailers', {
}
});

ObjectDefineProperty(IncomingMessage.prototype, 'trailersDistinct', {
get: function() {
if (!this[kTrailersDistinct]) {
this[kTrailersDistinct] = {};

const src = this.rawTrailers;
const dst = this[kTrailersDistinct];

for (let n = 0; n < this[kTrailersCount]; n += 2) {
this._addHeaderLineDistinct(src[n + 0], src[n + 1], dst);
}
}
return this[kTrailersDistinct];
},
set: function(val) {
this[kTrailersDistinct] = val;
}
});

IncomingMessage.prototype.setTimeout = function setTimeout(msecs, callback) {
if (callback)
this.on('timeout', callback);
Expand Down Expand Up @@ -358,6 +398,16 @@ function _addHeaderLine(field, value, dest) {
}
}

IncomingMessage.prototype._addHeaderLineDistinct = _addHeaderLineDistinct;
function _addHeaderLineDistinct(field, value, dest) {
field = StringPrototypeToLowerCase(field);
if (!dest[field]) {
dest[field] = [value];
} else {
dest[field].push(value);
}
}


// Call this instead of resume() if we want to just
// dump all the data to /dev/null
Expand Down
82 changes: 76 additions & 6 deletions lib/_http_outgoing.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const {
ObjectPrototypeHasOwnProperty,
ObjectSetPrototypeOf,
RegExpPrototypeTest,
SafeSet,
StringPrototypeToLowerCase,
Symbol,
} = primordials;
Expand Down Expand Up @@ -82,6 +83,7 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
const HIGH_WATER_MARK = getDefaultHighWaterMark();

const kCorked = Symbol('corked');
const kUniqueHeaders = Symbol('kUniqueHeaders');

const nop = () => {};

Expand Down Expand Up @@ -502,7 +504,10 @@ function processHeader(self, state, key, value, validate) {
if (validate)
validateHeaderName(key);
if (ArrayIsArray(value)) {
if (value.length < 2 || !isCookieField(key)) {
if (
(value.length < 2 || !isCookieField(key)) &&
(!self[kUniqueHeaders] || !self[kUniqueHeaders].has(StringPrototypeToLowerCase(key)))
) {
// Retain for(;;) loop for performance reasons
// Refs: https://github.com/nodejs/node/pull/30958
for (let i = 0; i < value.length; i++)
Expand Down Expand Up @@ -571,6 +576,20 @@ const validateHeaderValue = hideStackFrames((name, value) => {
}
});

function parseUniqueHeadersOption(headers) {
if (!ArrayIsArray(headers)) {
return null;
}

const unique = new SafeSet();
const l = headers.length;
for (let i = 0; i < l; i++) {
unique.add(StringPrototypeToLowerCase(headers[i]));
}

return unique;
}

OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
if (this._header) {
throw new ERR_HTTP_HEADERS_SENT('set');
Expand All @@ -586,6 +605,36 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) {
return this;
};

OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) {
if (this._header) {
throw new ERR_HTTP_HEADERS_SENT('append');
}
validateHeaderName(name);
validateHeaderValue(name, value);

const field = StringPrototypeToLowerCase(name);
const headers = this[kOutHeaders];
if (headers === null || !headers[field]) {
return this.setHeader(name, value);
}

// Prepare the field for appending, if required
if (!ArrayIsArray(headers[field][1])) {
headers[field][1] = [headers[field][1]];
}

const existingValues = headers[field][1];
if (ArrayIsArray(value)) {
for (let i = 0, length = value.length; i < length; i++) {
existingValues.push(value[i]);
}
} else {
existingValues.push(value);
}

return this;
};


OutgoingMessage.prototype.getHeader = function getHeader(name) {
validateString(name, 'name');
Expand Down Expand Up @@ -797,7 +846,6 @@ function connectionCorkNT(conn) {
conn.uncork();
}


OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
this._trailer = '';
const keys = ObjectKeys(headers);
Expand All @@ -817,11 +865,31 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) {
if (typeof field !== 'string' || !field || !checkIsHttpToken(field)) {
throw new ERR_INVALID_HTTP_TOKEN('Trailer name', field);
}
if (checkInvalidHeaderChar(value)) {
debug('Trailer "%s" contains invalid characters', field);
throw new ERR_INVALID_CHAR('trailer content', field);

// Check if the field must be sent several times
const isArrayValue = ArrayIsArray(value);
if (
isArrayValue && value.length > 1 &&
(!this[kUniqueHeaders] || !this[kUniqueHeaders].has(StringPrototypeToLowerCase(field)))
) {
for (let j = 0, l = value.length; j < l; j++) {
if (checkInvalidHeaderChar(value[j])) {
debug('Trailer "%s"[%d] contains invalid characters', field, j);
throw new ERR_INVALID_CHAR('trailer content', field);
}
this._trailer += field + ': ' + value[j] + '\r\n';
}
} else {
if (isArrayValue) {
value = ArrayPrototypeJoin(value, '; ');
}

if (checkInvalidHeaderChar(value)) {
debug('Trailer "%s" contains invalid characters', field);
throw new ERR_INVALID_CHAR('trailer content', field);
}
this._trailer += field + ': ' + value + '\r\n';
}
this._trailer += field + ': ' + value + '\r\n';
}
};

Expand Down Expand Up @@ -997,6 +1065,8 @@ function(err, event) {
};

module.exports = {
kUniqueHeaders,
parseUniqueHeadersOption,
validateHeaderName,
validateHeaderValue,
OutgoingMessage
Expand Down
8 changes: 7 additions & 1 deletion lib/_http_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ const {
_checkInvalidHeaderChar: checkInvalidHeaderChar,
prepareError,
} = require('_http_common');
const { OutgoingMessage } = require('_http_outgoing');
const {
kUniqueHeaders,
parseUniqueHeadersOption,
OutgoingMessage
} = require('_http_outgoing');
const {
kOutHeaders,
kNeedDrain,
Expand Down Expand Up @@ -404,6 +408,7 @@ function Server(options, requestListener) {
this.maxRequestsPerSocket = 0;
this.headersTimeout = 60 * 1000; // 60 seconds
this.requestTimeout = 0;
this[kUniqueHeaders] = parseUniqueHeadersOption(options.uniqueHeaders);
}
ObjectSetPrototypeOf(Server.prototype, net.Server.prototype);
ObjectSetPrototypeOf(Server, net.Server);
Expand Down Expand Up @@ -886,6 +891,7 @@ function parserOnIncoming(server, socket, state, req, keepAlive) {
socket, state);

res.shouldKeepAlive = keepAlive;
res[kUniqueHeaders] = server[kUniqueHeaders];
DTRACE_HTTP_SERVER_REQUEST(req, socket);

if (onRequestStartChannel.hasSubscribers) {
Expand Down
Loading

0 comments on commit cb74011

Please sign in to comment.