diff --git a/src/headers.js b/src/headers.js index f767ef1..8ee47a6 100644 --- a/src/headers.js +++ b/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 | Iterable | Iterable[]} 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> // 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]); - } + return [...pair]; + }).map(pair => { + if (pair.length !== 2) { + throw new TypeError('Each header pair must be a name/value tuple'); + } - for (const pair of pairs) { - 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 - 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> or record)'); } + + // Validate and lowercase + result = + result.length > 0 ? + result.map(([name, value]) => { + validateName(name); + 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); } - /** - * Return combined header value given name - * - * @param String name Header name - * @return Mixed - */ get(name) { - name = `${name}`; - validateName(name); - const key = find(this[MAP], name); - if (key === undefined) { + 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} */ 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(', '); - } else { - iterator = header => [header.toLowerCase(), headers[MAP][header].join(', ')]; - } - - return keys.map(header => iterator(header)); -} - -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 - */ -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; -} - -/** - * Create a Headers object from an object of headers, ignoring those that do - * not conform to HTTP grammar productions. - * - * @param Object obj Object of headers - * @return Headers - */ -export function createHeadersLenient(object) { - const headers = new Headers(); - for (const name of Object.keys(object)) { - if (invalidTokenRegex.test(name)) { - continue; - } - - if (Array.isArray(object[name])) { - for (const value of object[name]) { - if (invalidHeaderCharRegex.test(value)) { - continue; - } - - if (headers[MAP][name] === undefined) { - headers[MAP][name] = [value]; - } else { - headers[MAP][name].push(value); - } + [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 { + res[key] = values.length > 1 ? values : values[0]; } - } else if (!invalidHeaderCharRegex.test(object[name])) { - headers[MAP][name] = [object[name]]; - } - } - return headers; + return res; + }, {}); + } +} + +/** + * Re-shaping object for Web IDL tests + * Only need to do it for overridden methods + */ +Object.defineProperties( + Headers.prototype, + ['get', 'entries', 'forEach', 'values'].reduce((res, property) => { + res[property] = {enumerable: true}; + return res; + }, {}) +); + +/** + * Create a Headers object from an http.IncomingMessage.rawHeaders, ignoring those that do + * not conform to HTTP grammar productions. + * @param {import('http').IncomingMessage['rawHeaders']} headers + */ +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)); + } + + return result; + }, []) + .filter(([name, value]) => !(invalidTokenRegex.test(name) || invalidHeaderCharRegex.test(value))) + + ); } diff --git a/src/index.js b/src/index.js index 71c502c..e36434d 100644 --- a/src/index.js +++ b/src/index.js @@ -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)) { diff --git a/src/request.js b/src/request.js index fc23e14..4a32161 100644 --- a/src/request.js +++ b/src/request.js @@ -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 }; diff --git a/test/main.js b/test/main.js index 750f96a..d28401e 100644 --- a/test/main.js +++ b/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', () => {