// https://github.com/Ethan-Arrowood/undici-fetch 'use strict'; const { kConstruct } = require('../../core/symbols'); const { kEnumerableProperty } = require('../../core/util'); const { iteratorMixin, isValidHeaderName, isValidHeaderValue, } = require('./util'); const { webidl } = require('./webidl'); const assert = require('node:assert'); const util = require('node:util'); /** * @param {number} code * @returns {code is (0x0a | 0x0d | 0x09 | 0x20)} */ function isHTTPWhiteSpaceCharCode(code) { return code === 0x0a || code === 0x0d || code === 0x09 || code === 0x20; } /** * @see https://fetch.spec.whatwg.org/#concept-header-value-normalize * @param {string} potentialValue * @returns {string} */ function headerValueNormalize(potentialValue) { // To normalize a byte sequence potentialValue, remove // any leading and trailing HTTP whitespace bytes from // potentialValue. let i = 0; let j = potentialValue.length; while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(j - 1))) --j; while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(i))) ++i; return i === 0 && j === potentialValue.length ? potentialValue : potentialValue.substring(i, j); } /** * @param {Headers} headers * @param {Array|Object} object */ function fill(headers, object) { // To fill a Headers object headers with a given object object, run these steps: // 1. If object is a sequence, then for each header in object: // Note: webidl conversion to array has already been done. if (Array.isArray(object)) { for (let i = 0; i < object.length; ++i) { const header = object[i]; // 1. If header does not contain exactly two items, then throw a TypeError. if (header.length !== 2) { throw webidl.errors.exception({ header: 'Headers constructor', message: `expected name/value pair to be length 2, found ${header.length}.`, }); } // 2. Append (header’s first item, header’s second item) to headers. appendHeader(headers, header[0], header[1]); } } else if (typeof object === 'object' && object !== null) { // Note: null should throw // 2. Otherwise, object is a record, then for each key → value in object, // append (key, value) to headers const keys = Object.keys(object); for (let i = 0; i < keys.length; ++i) { appendHeader(headers, keys[i], object[keys[i]]); } } else { throw webidl.errors.conversionFailed({ prefix: 'Headers constructor', argument: 'Argument 1', types: [ 'sequence>', 'record', ], }); } } /** * @see https://fetch.spec.whatwg.org/#concept-headers-append * @param {Headers} headers * @param {string} name * @param {string} value */ function appendHeader(headers, name, value) { // 1. Normalize value. value = headerValueNormalize(value); // 2. If name is not a header name or value is not a // header value, then throw a TypeError. if (!isValidHeaderName(name)) { throw webidl.errors.invalidArgument({ prefix: 'Headers.append', value: name, type: 'header name', }); } else if (!isValidHeaderValue(value)) { throw webidl.errors.invalidArgument({ prefix: 'Headers.append', value, type: 'header value', }); } // 3. If headers’s guard is "immutable", then throw a TypeError. // 4. Otherwise, if headers’s guard is "request" and name is a // forbidden header name, return. // 5. Otherwise, if headers’s guard is "request-no-cors": // TODO // Note: undici does not implement forbidden header names if (getHeadersGuard(headers) === 'immutable') { throw new TypeError('immutable'); } // 6. Otherwise, if headers’s guard is "response" and name is a // forbidden response-header name, return. // 7. Append (name, value) to headers’s header list. return getHeadersList(headers).append(name, value, false); // 8. If headers’s guard is "request-no-cors", then remove // privileged no-CORS request headers from headers } // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine /** * @param {Headers} target */ function headersListSortAndCombine(target) { const headersList = getHeadersList(target); if (!headersList) { return []; } if (headersList.sortedMap) { return headersList.sortedMap; } // 1. Let headers be an empty list of headers with the key being the name // and value the value. const headers = []; // 2. Let names be the result of convert header names to a sorted-lowercase // set with all the names of the headers in list. const names = headersList.toSortedArray(); const cookies = headersList.cookies; // fast-path if (cookies === null || cookies.length === 1) { // Note: The non-null assertion of value has already been done by `HeadersList#toSortedArray` return (headersList.sortedMap = names); } // 3. For each name of names: for (let i = 0; i < names.length; ++i) { const { 0: name, 1: value } = names[i]; // 1. If name is `set-cookie`, then: if (name === 'set-cookie') { // 1. Let values be a list of all values of headers in list whose name // is a byte-case-insensitive match for name, in order. // 2. For each value of values: // 1. Append (name, value) to headers. for (let j = 0; j < cookies.length; ++j) { headers.push([name, cookies[j]]); } } else { // 2. Otherwise: // 1. Let value be the result of getting name from list. // 2. Assert: value is non-null. // Note: This operation was done by `HeadersList#toSortedArray`. // 3. Append (name, value) to headers. headers.push([name, value]); } } // 4. Return headers. return (headersList.sortedMap = headers); } function compareHeaderName(a, b) { return a[0] < b[0] ? -1 : 1; } class HeadersList { /** @type {[string, string][]|null} */ cookies = null; sortedMap; headersMap; constructor(init) { if (init instanceof HeadersList) { this.headersMap = new Map(init.headersMap); this.sortedMap = init.sortedMap; this.cookies = init.cookies === null ? null : [...init.cookies]; } else { this.headersMap = new Map(init); this.sortedMap = null; } } /** * @see https://fetch.spec.whatwg.org/#header-list-contains * @param {string} name * @param {boolean} isLowerCase */ contains(name, isLowerCase) { // A header list list contains a header name name if list // contains a header whose name is a byte-case-insensitive // match for name. return this.headersMap.has(isLowerCase ? name : name.toLowerCase()); } clear() { this.headersMap.clear(); this.sortedMap = null; this.cookies = null; } /** * @see https://fetch.spec.whatwg.org/#concept-header-list-append * @param {string} name * @param {string} value * @param {boolean} isLowerCase */ append(name, value, isLowerCase) { this.sortedMap = null; // 1. If list contains name, then set name to the first such // header’s name. const lowercaseName = isLowerCase ? name : name.toLowerCase(); const exists = this.headersMap.get(lowercaseName); // 2. Append (name, value) to list. if (exists) { const delimiter = lowercaseName === 'cookie' ? '; ' : ', '; this.headersMap.set(lowercaseName, { name: exists.name, value: `${exists.value}${delimiter}${value}`, }); } else { this.headersMap.set(lowercaseName, { name, value }); } if (lowercaseName === 'set-cookie') { (this.cookies ??= []).push(value); } } /** * @see https://fetch.spec.whatwg.org/#concept-header-list-set * @param {string} name * @param {string} value * @param {boolean} isLowerCase */ set(name, value, isLowerCase) { this.sortedMap = null; const lowercaseName = isLowerCase ? name : name.toLowerCase(); if (lowercaseName === 'set-cookie') { this.cookies = [value]; } // 1. If list contains name, then set the value of // the first such header to value and remove the // others. // 2. Otherwise, append header (name, value) to list. this.headersMap.set(lowercaseName, { name, value }); } /** * @see https://fetch.spec.whatwg.org/#concept-header-list-delete * @param {string} name * @param {boolean} isLowerCase */ delete(name, isLowerCase) { this.sortedMap = null; if (!isLowerCase) name = name.toLowerCase(); if (name === 'set-cookie') { this.cookies = null; } this.headersMap.delete(name); } /** * @see https://fetch.spec.whatwg.org/#concept-header-list-get * @param {string} name * @param {boolean} isLowerCase * @returns {string | null} */ get(name, isLowerCase) { // 1. If list does not contain name, then return null. // 2. Return the values of all headers in list whose name // is a byte-case-insensitive match for name, // separated from each other by 0x2C 0x20, in order. return ( this.headersMap.get(isLowerCase ? name : name.toLowerCase())?.value ?? null ); } *[Symbol.iterator]() { // use the lowercased name for (const { 0: name, 1: { value }, } of this.headersMap) { yield [name, value]; } } get entries() { const headers = {}; if (this.headersMap.size !== 0) { for (const { name, value } of this.headersMap.values()) { headers[name] = value; } } return headers; } rawValues() { return this.headersMap.values(); } get entriesList() { const headers = []; if (this.headersMap.size !== 0) { for (const { 0: lowerName, 1: { name, value }, } of this.headersMap) { if (lowerName === 'set-cookie') { for (const cookie of this.cookies) { headers.push([name, cookie]); } } else { headers.push([name, value]); } } } return headers; } // https://fetch.spec.whatwg.org/#convert-header-names-to-a-sorted-lowercase-set toSortedArray() { const size = this.headersMap.size; const array = new Array(size); // In most cases, you will use the fast-path. // fast-path: Use binary insertion sort for small arrays. if (size <= 32) { if (size === 0) { // If empty, it is an empty array. To avoid the first index assignment. return array; } // Improve performance by unrolling loop and avoiding double-loop. // Double-loop-less version of the binary insertion sort. const iterator = this.headersMap[Symbol.iterator](); const firstValue = iterator.next().value; // set [name, value] to first index. array[0] = [firstValue[0], firstValue[1].value]; // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine // 3.2.2. Assert: value is non-null. assert(firstValue[1].value !== null); for ( let i = 1, j = 0, right = 0, left = 0, pivot = 0, x, value; i < size; ++i ) { // get next value value = iterator.next().value; // set [name, value] to current index. x = array[i] = [value[0], value[1].value]; // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine // 3.2.2. Assert: value is non-null. assert(x[1] !== null); left = 0; right = i; // binary search while (left < right) { // middle index pivot = left + ((right - left) >> 1); // compare header name if (array[pivot][0] <= x[0]) { left = pivot + 1; } else { right = pivot; } } if (i !== pivot) { j = i; while (j > left) { array[j] = array[--j]; } array[left] = x; } } /* c8 ignore next 4 */ if (!iterator.next().done) { // This is for debugging and will never be called. throw new TypeError('Unreachable'); } return array; } else { // This case would be a rare occurrence. // slow-path: fallback let i = 0; for (const { 0: name, 1: { value }, } of this.headersMap) { array[i++] = [name, value]; // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine // 3.2.2. Assert: value is non-null. assert(value !== null); } return array.sort(compareHeaderName); } } } // https://fetch.spec.whatwg.org/#headers-class class Headers { #guard; /** * @type {HeadersList} */ #headersList; /** * @param {HeadersInit|Symbol} [init] * @returns */ constructor(init = undefined) { webidl.util.markAsUncloneable(this); if (init === kConstruct) { return; } this.#headersList = new HeadersList(); // The new Headers(init) constructor steps are: // 1. Set this’s guard to "none". this.#guard = 'none'; // 2. If init is given, then fill this with init. if (init !== undefined) { init = webidl.converters.HeadersInit(init, 'Headers constructor', 'init'); fill(this, init); } } // https://fetch.spec.whatwg.org/#dom-headers-append append(name, value) { webidl.brandCheck(this, Headers); webidl.argumentLengthCheck(arguments, 2, 'Headers.append'); const prefix = 'Headers.append'; name = webidl.converters.ByteString(name, prefix, 'name'); value = webidl.converters.ByteString(value, prefix, 'value'); return appendHeader(this, name, value); } // https://fetch.spec.whatwg.org/#dom-headers-delete delete(name) { webidl.brandCheck(this, Headers); webidl.argumentLengthCheck(arguments, 1, 'Headers.delete'); const prefix = 'Headers.delete'; name = webidl.converters.ByteString(name, prefix, 'name'); // 1. If name is not a header name, then throw a TypeError. if (!isValidHeaderName(name)) { throw webidl.errors.invalidArgument({ prefix: 'Headers.delete', value: name, type: 'header name', }); } // 2. If this’s guard is "immutable", then throw a TypeError. // 3. Otherwise, if this’s guard is "request" and name is a // forbidden header name, return. // 4. Otherwise, if this’s guard is "request-no-cors", name // is not a no-CORS-safelisted request-header name, and // name is not a privileged no-CORS request-header name, // return. // 5. Otherwise, if this’s guard is "response" and name is // a forbidden response-header name, return. // Note: undici does not implement forbidden header names if (this.#guard === 'immutable') { throw new TypeError('immutable'); } // 6. If this’s header list does not contain name, then // return. if (!this.#headersList.contains(name, false)) { return; } // 7. Delete name from this’s header list. // 8. If this’s guard is "request-no-cors", then remove // privileged no-CORS request headers from this. this.#headersList.delete(name, false); } // https://fetch.spec.whatwg.org/#dom-headers-get get(name) { webidl.brandCheck(this, Headers); webidl.argumentLengthCheck(arguments, 1, 'Headers.get'); const prefix = 'Headers.get'; name = webidl.converters.ByteString(name, prefix, 'name'); // 1. If name is not a header name, then throw a TypeError. if (!isValidHeaderName(name)) { throw webidl.errors.invalidArgument({ prefix, value: name, type: 'header name', }); } // 2. Return the result of getting name from this’s header // list. return this.#headersList.get(name, false); } // https://fetch.spec.whatwg.org/#dom-headers-has has(name) { webidl.brandCheck(this, Headers); webidl.argumentLengthCheck(arguments, 1, 'Headers.has'); const prefix = 'Headers.has'; name = webidl.converters.ByteString(name, prefix, 'name'); // 1. If name is not a header name, then throw a TypeError. if (!isValidHeaderName(name)) { throw webidl.errors.invalidArgument({ prefix, value: name, type: 'header name', }); } // 2. Return true if this’s header list contains name; // otherwise false. return this.#headersList.contains(name, false); } // https://fetch.spec.whatwg.org/#dom-headers-set set(name, value) { webidl.brandCheck(this, Headers); webidl.argumentLengthCheck(arguments, 2, 'Headers.set'); const prefix = 'Headers.set'; name = webidl.converters.ByteString(name, prefix, 'name'); value = webidl.converters.ByteString(value, prefix, 'value'); // 1. Normalize value. value = headerValueNormalize(value); // 2. If name is not a header name or value is not a // header value, then throw a TypeError. if (!isValidHeaderName(name)) { throw webidl.errors.invalidArgument({ prefix, value: name, type: 'header name', }); } else if (!isValidHeaderValue(value)) { throw webidl.errors.invalidArgument({ prefix, value, type: 'header value', }); } // 3. If this’s guard is "immutable", then throw a TypeError. // 4. Otherwise, if this’s guard is "request" and name is a // forbidden header name, return. // 5. Otherwise, if this’s guard is "request-no-cors" and // name/value is not a no-CORS-safelisted request-header, // return. // 6. Otherwise, if this’s guard is "response" and name is a // forbidden response-header name, return. // Note: undici does not implement forbidden header names if (this.#guard === 'immutable') { throw new TypeError('immutable'); } // 7. Set (name, value) in this’s header list. // 8. If this’s guard is "request-no-cors", then remove // privileged no-CORS request headers from this this.#headersList.set(name, value, false); } // https://fetch.spec.whatwg.org/#dom-headers-getsetcookie getSetCookie() { webidl.brandCheck(this, Headers); // 1. If this’s header list does not contain `Set-Cookie`, then return « ». // 2. Return the values of all headers in this’s header list whose name is // a byte-case-insensitive match for `Set-Cookie`, in order. const list = this.#headersList.cookies; if (list) { return [...list]; } return []; } [util.inspect.custom](depth, options) { options.depth ??= depth; return `Headers ${util.formatWithOptions(options, this.#headersList.entries)}`; } static getHeadersGuard(o) { return o.#guard; } static setHeadersGuard(o, guard) { o.#guard = guard; } /** * @param {Headers} o */ static getHeadersList(o) { return o.#headersList; } /** * @param {Headers} target * @param {HeadersList} list */ static setHeadersList(target, list) { target.#headersList = list; } } const { getHeadersGuard, setHeadersGuard, getHeadersList, setHeadersList } = Headers; Reflect.deleteProperty(Headers, 'getHeadersGuard'); Reflect.deleteProperty(Headers, 'setHeadersGuard'); Reflect.deleteProperty(Headers, 'getHeadersList'); Reflect.deleteProperty(Headers, 'setHeadersList'); iteratorMixin('Headers', Headers, headersListSortAndCombine, 0, 1); Object.defineProperties(Headers.prototype, { append: kEnumerableProperty, delete: kEnumerableProperty, get: kEnumerableProperty, has: kEnumerableProperty, set: kEnumerableProperty, getSetCookie: kEnumerableProperty, [Symbol.toStringTag]: { value: 'Headers', configurable: true, }, [util.inspect.custom]: { enumerable: false, }, }); webidl.converters.HeadersInit = function (V, prefix, argument) { if (webidl.util.Type(V) === webidl.util.Types.OBJECT) { const iterator = Reflect.get(V, Symbol.iterator); // A work-around to ensure we send the properly-cased Headers when V is a Headers object. // Read https://github.com/nodejs/undici/pull/3159#issuecomment-2075537226 before touching, please. if (!util.types.isProxy(V) && iterator === Headers.prototype.entries) { // Headers object try { return getHeadersList(V).entriesList; } catch { // fall-through } } if (typeof iterator === 'function') { return webidl.converters['sequence>']( V, prefix, argument, iterator.bind(V) ); } return webidl.converters['record']( V, prefix, argument ); } throw webidl.errors.conversionFailed({ prefix: 'Headers constructor', argument: 'Argument 1', types: ['sequence>', 'record'], }); }; module.exports = { fill, // for test. compareHeaderName, Headers, HeadersList, getHeadersGuard, setHeadersGuard, setHeadersList, getHeadersList, };