revamp Headers module (#834)
Co-authored-by: Antoni Kepinski <xxczaki@pm.me>
This commit is contained in:
parent
94e5b92de1
commit
ca4703dd15
457
src/headers.js
457
src/headers.js
|
@ -1,76 +1,59 @@
|
|||
|
||||
/**
|
||||
* Headers.js
|
||||
*
|
||||
* Headers class offers convenient helpers
|
||||
*/
|
||||
|
||||
import {types} from 'util';
|
||||
|
||||
const invalidTokenRegex = /[^`\-\w!#$%&'*+.|~]/;
|
||||
const invalidHeaderCharRegex = /[^\t\u0020-\u007E\u0080-\u00FF]/;
|
||||
|
||||
function validateName(name) {
|
||||
name = `${name}`;
|
||||
name = String(name);
|
||||
if (invalidTokenRegex.test(name) || name === '') {
|
||||
throw new TypeError(`${name} is not a legal HTTP header name`);
|
||||
throw new TypeError(`'${name}' is not a legal HTTP header name`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateValue(value) {
|
||||
value = `${value}`;
|
||||
value = String(value);
|
||||
if (invalidHeaderCharRegex.test(value)) {
|
||||
throw new TypeError(`${value} is not a legal HTTP header value`);
|
||||
throw new TypeError(`'${value}' is not a legal HTTP header value`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the key in the map object given a header name.
|
||||
*
|
||||
* Returns undefined if not found.
|
||||
*
|
||||
* @param String name Header name
|
||||
* @return String|Undefined
|
||||
* @typedef {Headers | Record<string, string> | Iterable<readonly [string, string]> | Iterable<string>[]} HeadersInit
|
||||
*/
|
||||
function find(map, name) {
|
||||
name = name.toLowerCase();
|
||||
for (const key in map) {
|
||||
if (key.toLowerCase() === name) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const MAP = Symbol('map');
|
||||
export default class Headers {
|
||||
/**
|
||||
* This Fetch API interface allows you to perform various actions on HTTP request and response headers.
|
||||
* These actions include retrieving, setting, adding to, and removing.
|
||||
* A Headers object has an associated header list, which is initially empty and consists of zero or more name and value pairs.
|
||||
* You can add to this using methods like append() (see Examples.)
|
||||
* In all methods of this interface, header names are matched by case-insensitive byte sequence.
|
||||
*
|
||||
*/
|
||||
export default class Headers extends URLSearchParams {
|
||||
/**
|
||||
* Headers class
|
||||
*
|
||||
* @param Object headers Response headers
|
||||
* @return Void
|
||||
* @constructor
|
||||
* @param {HeadersInit} [init] - Response headers
|
||||
*/
|
||||
constructor(init = undefined) {
|
||||
this[MAP] = Object.create(null);
|
||||
|
||||
constructor(init) {
|
||||
// Validate and normalize init object in [name, value(s)][]
|
||||
/** @type {string[][]} */
|
||||
let result = [];
|
||||
if (init instanceof Headers) {
|
||||
const rawHeaders = init.raw();
|
||||
const headerNames = Object.keys(rawHeaders);
|
||||
|
||||
for (const headerName of headerNames) {
|
||||
for (const value of rawHeaders[headerName]) {
|
||||
this.append(headerName, value);
|
||||
const raw = init.raw();
|
||||
for (const [name, values] of Object.entries(raw)) {
|
||||
result.push(...values.map(value => [name, value]));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't worry about converting prop to ByteString here as append()
|
||||
// will handle it.
|
||||
// eslint-disable-next-line no-eq-null, eqeqeq
|
||||
if (init == null) {
|
||||
} else if (init == null) { // eslint-disable-line no-eq-null, eqeqeq
|
||||
// No op
|
||||
} else if (typeof init === 'object') {
|
||||
} else if (typeof init === 'object' && !types.isBoxedPrimitive(init)) {
|
||||
const method = init[Symbol.iterator];
|
||||
// eslint-disable-next-line no-eq-null, eqeqeq
|
||||
if (method != null) {
|
||||
|
@ -80,312 +63,190 @@ export default class Headers {
|
|||
|
||||
// Sequence<sequence<ByteString>>
|
||||
// Note: per spec we have to first exhaust the lists then process them
|
||||
const pairs = [];
|
||||
for (const pair of init) {
|
||||
if (typeof pair !== 'object' || typeof pair[Symbol.iterator] !== 'function') {
|
||||
throw new TypeError('Each header pair must be iterable');
|
||||
result = [...init]
|
||||
.map(pair => {
|
||||
if (
|
||||
typeof pair !== 'object' || types.isBoxedPrimitive(pair)
|
||||
) {
|
||||
throw new TypeError('Each header pair must be an iterable object');
|
||||
}
|
||||
|
||||
pairs.push([...pair]);
|
||||
}
|
||||
|
||||
for (const pair of pairs) {
|
||||
return [...pair];
|
||||
}).map(pair => {
|
||||
if (pair.length !== 2) {
|
||||
throw new TypeError('Each header pair must be a name/value tuple');
|
||||
}
|
||||
|
||||
this.append(pair[0], pair[1]);
|
||||
}
|
||||
return [...pair];
|
||||
});
|
||||
} else {
|
||||
// Record<ByteString, ByteString>
|
||||
for (const key of Object.keys(init)) {
|
||||
const value = init[key];
|
||||
this.append(key, value);
|
||||
}
|
||||
result.push(...Object.entries(init));
|
||||
}
|
||||
} else {
|
||||
throw new TypeError('Provided initializer must be an object');
|
||||
}
|
||||
throw new TypeError('Failed to construct \'Headers\': The provided value is not of type \'(sequence<sequence<ByteString>> or record<ByteString, ByteString>)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return combined header value given name
|
||||
*
|
||||
* @param String name Header name
|
||||
* @return Mixed
|
||||
*/
|
||||
get(name) {
|
||||
name = `${name}`;
|
||||
// Validate and lowercase
|
||||
result =
|
||||
result.length > 0 ?
|
||||
result.map(([name, value]) => {
|
||||
validateName(name);
|
||||
const key = find(this[MAP], name);
|
||||
if (key === undefined) {
|
||||
validateValue(value);
|
||||
return [String(name).toLowerCase(), value];
|
||||
}) :
|
||||
undefined;
|
||||
|
||||
super(result);
|
||||
|
||||
// Returning a Proxy that will lowercase key names, validate parameters and sort keys
|
||||
// eslint-disable-next-line no-constructor-return
|
||||
return new Proxy(this, {
|
||||
get(target, p, receiver) {
|
||||
switch (p) {
|
||||
case 'append':
|
||||
case 'set':
|
||||
return (name, value) => {
|
||||
validateName(name);
|
||||
validateValue(value);
|
||||
return URLSearchParams.prototype[p].call(
|
||||
receiver,
|
||||
String(name).toLowerCase(),
|
||||
value
|
||||
);
|
||||
};
|
||||
|
||||
case 'delete':
|
||||
case 'has':
|
||||
case 'getAll':
|
||||
return name => {
|
||||
validateName(name);
|
||||
return URLSearchParams.prototype[p].call(
|
||||
receiver,
|
||||
String(name).toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
case 'keys':
|
||||
return () => {
|
||||
target.sort();
|
||||
return new Set(URLSearchParams.prototype.keys.call(target)).keys();
|
||||
};
|
||||
|
||||
default:
|
||||
return Reflect.get(target, p, receiver);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag]() {
|
||||
return 'Headers';
|
||||
}
|
||||
|
||||
toString() {
|
||||
return Object.prototype.toString.call(this);
|
||||
}
|
||||
|
||||
get(name) {
|
||||
const values = this.getAll(name);
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let value = this[MAP][key].join(', ');
|
||||
if (name.toLowerCase() === 'content-encoding') {
|
||||
let value = values.join(', ');
|
||||
if (/^content-encoding$/i.test(name)) {
|
||||
value = value.toLowerCase();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over all headers
|
||||
*
|
||||
* @param Function callback Executed for each item with parameters (value, name, thisArg)
|
||||
* @param Boolean thisArg `this` context for callback function
|
||||
* @return Void
|
||||
*/
|
||||
forEach(callback, thisArg = undefined) {
|
||||
let pairs = getHeaders(this);
|
||||
let i = 0;
|
||||
while (i < pairs.length) {
|
||||
const [name, value] = pairs[i];
|
||||
callback.call(thisArg, value, name, this);
|
||||
pairs = getHeaders(this);
|
||||
i++;
|
||||
forEach(callback) {
|
||||
for (const name of this.keys()) {
|
||||
callback(this.get(name), name);
|
||||
}
|
||||
}
|
||||
|
||||
* values() {
|
||||
for (const name of this.keys()) {
|
||||
yield this.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite header values given name
|
||||
*
|
||||
* @param String name Header name
|
||||
* @param String value Header value
|
||||
* @return Void
|
||||
* @type {() => IterableIterator<[string, string]>}
|
||||
*/
|
||||
set(name, value) {
|
||||
name = `${name}`;
|
||||
value = `${value}`;
|
||||
validateName(name);
|
||||
validateValue(value);
|
||||
const key = find(this[MAP], name);
|
||||
this[MAP][key !== undefined ? key : name] = [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a value onto existing header
|
||||
*
|
||||
* @param String name Header name
|
||||
* @param String value Header value
|
||||
* @return Void
|
||||
*/
|
||||
append(name, value) {
|
||||
name = `${name}`;
|
||||
value = `${value}`;
|
||||
validateName(name);
|
||||
validateValue(value);
|
||||
const key = find(this[MAP], name);
|
||||
if (key !== undefined) {
|
||||
this[MAP][key].push(value);
|
||||
} else {
|
||||
this[MAP][name] = [value];
|
||||
* entries() {
|
||||
for (const name of this.keys()) {
|
||||
yield [name, this.get(name)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for header name existence
|
||||
*
|
||||
* @param String name Header name
|
||||
* @return Boolean
|
||||
*/
|
||||
has(name) {
|
||||
name = `${name}`;
|
||||
validateName(name);
|
||||
return find(this[MAP], name) !== undefined;
|
||||
[Symbol.iterator]() {
|
||||
return this.entries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all header values given name
|
||||
*
|
||||
* @param String name Header name
|
||||
* @return Void
|
||||
*/
|
||||
delete(name) {
|
||||
name = `${name}`;
|
||||
validateName(name);
|
||||
const key = find(this[MAP], name);
|
||||
if (key !== undefined) {
|
||||
delete this[MAP][key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return raw headers (non-spec api)
|
||||
*
|
||||
* @return Object
|
||||
* Node-fetch non-spec method
|
||||
* returning all headers and their values as array
|
||||
* @returns {Record<string, string[]>}
|
||||
*/
|
||||
raw() {
|
||||
return this[MAP];
|
||||
return [...this.keys()].reduce((res, key) => {
|
||||
res[key] = this.getAll(key);
|
||||
return res;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator on keys.
|
||||
*
|
||||
* @return Iterator
|
||||
* For better console.log(headers) and also to convert Headers into Node.js Request compatible format
|
||||
*/
|
||||
keys() {
|
||||
return createHeadersIterator(this, 'key');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator on values.
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
values() {
|
||||
return createHeadersIterator(this, 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator on entries.
|
||||
*
|
||||
* This is the default iterator of the Headers object.
|
||||
*
|
||||
* @return Iterator
|
||||
*/
|
||||
[Symbol.iterator]() {
|
||||
return createHeadersIterator(this, 'key+value');
|
||||
}
|
||||
}
|
||||
Headers.prototype.entries = Headers.prototype[Symbol.iterator];
|
||||
|
||||
Object.defineProperty(Headers.prototype, Symbol.toStringTag, {
|
||||
value: 'Headers',
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
Object.defineProperties(Headers.prototype, {
|
||||
get: {enumerable: true},
|
||||
forEach: {enumerable: true},
|
||||
set: {enumerable: true},
|
||||
append: {enumerable: true},
|
||||
has: {enumerable: true},
|
||||
delete: {enumerable: true},
|
||||
keys: {enumerable: true},
|
||||
values: {enumerable: true},
|
||||
entries: {enumerable: true}
|
||||
});
|
||||
|
||||
function getHeaders(headers, kind = 'key+value') {
|
||||
const keys = Object.keys(headers[MAP]).sort();
|
||||
|
||||
let iterator;
|
||||
if (kind === 'key') {
|
||||
iterator = header => header.toLowerCase();
|
||||
} else if (kind === 'value') {
|
||||
iterator = header => headers[MAP][header].join(', ');
|
||||
[Symbol.for('nodejs.util.inspect.custom')]() {
|
||||
return [...this.keys()].reduce((res, key) => {
|
||||
const values = this.getAll(key);
|
||||
// Http.request() only supports string as Host header.
|
||||
// This hack makes specifying custom Host header possible.
|
||||
if (key === 'host') {
|
||||
res[key] = values[0];
|
||||
} else {
|
||||
iterator = header => [header.toLowerCase(), headers[MAP][header].join(', ')];
|
||||
res[key] = values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
return keys.map(header => iterator(header));
|
||||
return res;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const INTERNAL = Symbol('internal');
|
||||
|
||||
function createHeadersIterator(target, kind) {
|
||||
const iterator = Object.create(HeadersIteratorPrototype);
|
||||
iterator[INTERNAL] = {
|
||||
target,
|
||||
kind,
|
||||
index: 0
|
||||
};
|
||||
return iterator;
|
||||
}
|
||||
|
||||
const HeadersIteratorPrototype = Object.setPrototypeOf({
|
||||
next() {
|
||||
// istanbul ignore if
|
||||
if (!this ||
|
||||
Object.getPrototypeOf(this) !== HeadersIteratorPrototype) {
|
||||
throw new TypeError('Value of `this` is not a HeadersIterator');
|
||||
}
|
||||
|
||||
const {
|
||||
target,
|
||||
kind,
|
||||
index
|
||||
} = this[INTERNAL];
|
||||
const values = getHeaders(target, kind);
|
||||
const length_ = values.length;
|
||||
if (index >= length_) {
|
||||
return {
|
||||
value: undefined,
|
||||
done: true
|
||||
};
|
||||
}
|
||||
|
||||
this[INTERNAL].index = index + 1;
|
||||
|
||||
return {
|
||||
value: values[index],
|
||||
done: false
|
||||
};
|
||||
}
|
||||
}, Object.getPrototypeOf(
|
||||
Object.getPrototypeOf([][Symbol.iterator]())
|
||||
));
|
||||
|
||||
Object.defineProperty(HeadersIteratorPrototype, Symbol.toStringTag, {
|
||||
value: 'HeadersIterator',
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
/**
|
||||
* Export the Headers object in a form that Node.js can consume.
|
||||
*
|
||||
* @param Headers headers
|
||||
* @return Object
|
||||
* Re-shaping object for Web IDL tests
|
||||
* Only need to do it for overridden methods
|
||||
*/
|
||||
export function exportNodeCompatibleHeaders(headers) {
|
||||
const object = {__proto__: null, ...headers[MAP]};
|
||||
|
||||
// Http.request() only supports string as Host header. This hack makes
|
||||
// specifying custom Host header possible.
|
||||
const hostHeaderKey = find(headers[MAP], 'Host');
|
||||
if (hostHeaderKey !== undefined) {
|
||||
object[hostHeaderKey] = object[hostHeaderKey][0];
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
Object.defineProperties(
|
||||
Headers.prototype,
|
||||
['get', 'entries', 'forEach', 'values'].reduce((res, property) => {
|
||||
res[property] = {enumerable: true};
|
||||
return res;
|
||||
}, {})
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a Headers object from an object of headers, ignoring those that do
|
||||
* Create a Headers object from an http.IncomingMessage.rawHeaders, ignoring those that do
|
||||
* not conform to HTTP grammar productions.
|
||||
*
|
||||
* @param Object obj Object of headers
|
||||
* @return Headers
|
||||
* @param {import('http').IncomingMessage['rawHeaders']} headers
|
||||
*/
|
||||
export function createHeadersLenient(object) {
|
||||
const headers = new Headers();
|
||||
for (const name of Object.keys(object)) {
|
||||
if (invalidTokenRegex.test(name)) {
|
||||
continue;
|
||||
export function fromRawHeaders(headers = []) {
|
||||
return new Headers(
|
||||
headers
|
||||
// Split into pairs
|
||||
.reduce((result, value, index, array) => {
|
||||
if (index % 2 === 0) {
|
||||
result.push(array.slice(index, index + 2));
|
||||
}
|
||||
|
||||
if (Array.isArray(object[name])) {
|
||||
for (const value of object[name]) {
|
||||
if (invalidHeaderCharRegex.test(value)) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}, [])
|
||||
.filter(([name, value]) => !(invalidTokenRegex.test(name) || invalidHeaderCharRegex.test(value)))
|
||||
|
||||
if (headers[MAP][name] === undefined) {
|
||||
headers[MAP][name] = [value];
|
||||
} else {
|
||||
headers[MAP][name].push(value);
|
||||
}
|
||||
}
|
||||
} else if (!invalidHeaderCharRegex.test(object[name])) {
|
||||
headers[MAP][name] = [object[name]];
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import dataURIToBuffer from 'data-uri-to-buffer';
|
|||
|
||||
import Body, {writeToStream, getTotalBytes} from './body.js';
|
||||
import Response from './response.js';
|
||||
import Headers, {createHeadersLenient} from './headers.js';
|
||||
import Headers, {fromRawHeaders} from './headers.js';
|
||||
import Request, {getNodeRequestOptions} from './request.js';
|
||||
import FetchError from './errors/fetch-error.js';
|
||||
import AbortError from './errors/abort-error.js';
|
||||
|
@ -108,7 +108,7 @@ export default function fetch(url, options_) {
|
|||
|
||||
request_.on('response', res => {
|
||||
request_.setTimeout(0);
|
||||
const headers = createHeadersLenient(res.headers);
|
||||
const headers = fromRawHeaders(res.rawHeaders);
|
||||
|
||||
// HTTP fetch step 5
|
||||
if (isRedirect(res.statusCode)) {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import {format as formatUrl} from 'url';
|
||||
import Headers, {exportNodeCompatibleHeaders} from './headers.js';
|
||||
import Headers from './headers.js';
|
||||
import Body, {clone, extractContentType, getTotalBytes} from './body.js';
|
||||
import {isAbortSignal} from './utils/is.js';
|
||||
import {getSearch} from './utils/get-search.js';
|
||||
|
@ -251,7 +251,7 @@ export function getNodeRequestOptions(request) {
|
|||
query: parsedURL.query,
|
||||
href: parsedURL.href,
|
||||
method: request.method,
|
||||
headers: exportNodeCompatibleHeaders(headers),
|
||||
headers: headers[Symbol.for('nodejs.util.inspect.custom')](),
|
||||
agent
|
||||
};
|
||||
|
||||
|
|
23
test/main.js
23
test/main.js
|
@ -30,7 +30,7 @@ import fetch, {
|
|||
Response
|
||||
} from '../src/index.js';
|
||||
import FetchErrorOrig from '../src/errors/fetch-error.js';
|
||||
import HeadersOrig, {createHeadersLenient} from '../src/headers.js';
|
||||
import HeadersOrig, {fromRawHeaders} from '../src/headers.js';
|
||||
import RequestOrig from '../src/request.js';
|
||||
import ResponseOrig from '../src/response.js';
|
||||
import Body, {getTotalBytes, extractContentType} from '../src/body.js';
|
||||
|
@ -545,15 +545,18 @@ describe('node-fetch', () => {
|
|||
});
|
||||
|
||||
it('should ignore invalid headers', () => {
|
||||
let headers = {
|
||||
'Invalid-Header ': 'abc\r\n',
|
||||
'Invalid-Header-Value': '\u0007k\r\n',
|
||||
'Set-Cookie': ['\u0007k\r\n', '\u0007kk\r\n']
|
||||
};
|
||||
headers = createHeadersLenient(headers);
|
||||
expect(headers).to.not.have.property('Invalid-Header ');
|
||||
expect(headers).to.not.have.property('Invalid-Header-Value');
|
||||
expect(headers).to.not.have.property('Set-Cookie');
|
||||
const headers = fromRawHeaders([
|
||||
'Invalid-Header ',
|
||||
'abc\r\n',
|
||||
'Invalid-Header-Value',
|
||||
'\u0007k\r\n',
|
||||
'Cookie',
|
||||
'\u0007k\r\n',
|
||||
'Cookie',
|
||||
'\u0007kk\r\n'
|
||||
]);
|
||||
expect(headers).to.be.instanceOf(Headers);
|
||||
expect(headers.raw()).to.deep.equal({});
|
||||
});
|
||||
|
||||
it('should handle client-error response', () => {
|
||||
|
|
Loading…
Reference in New Issue