├── tsconfig.json ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json ├── test ├── http-loader.js ├── own-misc-test.any.js ├── wpt_test.js └── header-setcookie.any.js ├── README.md ├── headers.d.ts └── headers.js /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["headers.js"], 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "lib": ["ES2020", "DOM"], 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "allowJs": true, 9 | "checkJs": false, 10 | "declaration": true, 11 | "emitDeclarationOnly": true, 12 | "allowSyntheticDefaultImports": true, 13 | "skipLibCheck": true, 14 | "strictNullChecks": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | paths: 8 | - "**.js" 9 | - "package.json" 10 | - ".github/workflows/ci.yml" 11 | 12 | jobs: 13 | test: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest, macOS-latest] 17 | node: ["12.20.0"] 18 | exclude: 19 | # On Windows, run tests with only the LTS environments. 20 | - os: windows-latest 21 | node: "12.22.3" 22 | - os: windows-latest 23 | node: "16.0.0" 24 | # On macOS, run tests with only the LTS environments. 25 | - os: macOS-latest 26 | node: "12.22.3" 27 | - os: macOS-latest 28 | node: "16.0.0" 29 | 30 | runs-on: ${{ matrix.os }} 31 | steps: 32 | - uses: actions/checkout@v2 33 | 34 | - uses: actions/setup-node@v2 35 | with: 36 | node-version: ${{ matrix.node }} 37 | 38 | - run: npm install 39 | 40 | - run: npm test -- --colors -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jimmy Wärting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-headers", 3 | "version": "3.0.1", 4 | "description": "fetch Headers", 5 | "main": "headers.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node --no-warnings --experimental-loader ./test/http-loader.js ./test/wpt_test.js", 9 | "test:deno": "deno test test --allow-net --allow-read --coverage=x && deno coverage x && rm -rf x" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/jimmywarting/fetch-headers.git" 14 | }, 15 | "exports": { 16 | ".": "./headers.js" 17 | }, 18 | "types": "./headers.d.ts", 19 | "keywords": [ 20 | "Headers", 21 | "fetch", 22 | "spec", 23 | "whatwg" 24 | ], 25 | "author": "Jimmy Wärting (https://jimmy.warting.se/opensource)", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/jimmywarting/fetch-headers/issues" 29 | }, 30 | "engines": { 31 | "node": ">=12.20" 32 | }, 33 | "homepage": "https://github.com/jimmywarting/fetch-headers#readme", 34 | "funding": [ 35 | { 36 | "type": "github", 37 | "url": "https://github.com/sponsors/jimmywarting" 38 | }, 39 | { 40 | "type": "github", 41 | "url": "https://paypal.me/jimmywarting" 42 | } 43 | ], 44 | "devDependencies": { 45 | "node-fetch": "^3.1.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/http-loader.js: -------------------------------------------------------------------------------- 1 | import { get } from 'https' 2 | 3 | /** 4 | * @param {string} specifier 5 | * @param {{ 6 | * conditions: !Array, 7 | * parentURL: !(string | undefined), 8 | * }} context 9 | * @param {Function} defaultResolve 10 | * @returns {Promise<{ url: string }>} 11 | */ 12 | 13 | export async function resolve (specifier, context, defaultResolve) { 14 | const { parentURL = null } = context 15 | 16 | // Normally Node.js would error on specifiers starting with 'https://', so 17 | // this hook intercepts them and converts them into absolute URLs to be 18 | // passed along to the later hooks below. 19 | if (specifier.startsWith('https://')) { 20 | return { 21 | url: specifier 22 | } 23 | } else if (parentURL && parentURL.startsWith('https://')) { 24 | return { 25 | url: new URL(specifier, parentURL).href 26 | } 27 | } 28 | 29 | // Let Node.js handle all other specifiers. 30 | return defaultResolve(specifier, context, defaultResolve) 31 | } 32 | 33 | export function load (url, context, defaultLoad) { 34 | // For JavaScript to be loaded over the network, we need to fetch and 35 | // return it. 36 | if (url.startsWith('https://')) { 37 | return new Promise((resolve, reject) => { 38 | get(url, (res) => { 39 | let data = '' 40 | res.on('data', (chunk) => data += chunk) 41 | res.on('end', () => resolve({ 42 | // This example assumes all network-provided JavaScript is ES module 43 | // code. 44 | format: 'module', 45 | source: data 46 | })) 47 | }).on('error', (err) => reject(err)) 48 | }) 49 | } 50 | 51 | // Let Node.js handle all other URLs. 52 | return defaultLoad(url, context, defaultLoad) 53 | } 54 | -------------------------------------------------------------------------------- /test/own-misc-test.any.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Tests for some misc stuff not covered by the WPT test suite. 3 | * needed for full test coverage. 4 | */ 5 | 6 | import { Headers, bag } from '../headers.js' 7 | 8 | for (const method of Object.keys(Headers.prototype)) { 9 | test(() => { 10 | assert_throws_js(TypeError, function () { 11 | const fn = new Headers()[method] 12 | fn() 13 | }) 14 | }, 'illigal invokation of Headers.prototype.' + method) 15 | } 16 | 17 | test(() => { 18 | const name = new Headers({ 19 | 'Content-Type': '\u000A\u000Atext/plain\u000A\u000A' 20 | }) 21 | 22 | assert_equals(name.get('Content-Type'), 'text/plain') 23 | }, 'it trims leading and trailing whitespace') 24 | 25 | test(() => { 26 | const name = new Headers({ 27 | 'Content-Type': '\u000Atext/plain\u000A' 28 | }).toString() 29 | 30 | assert_equals(name, '[object Headers]') 31 | }, 'new Headers().toString() has to be "[object Headers]"') 32 | 33 | test(() => { 34 | const headers = new Headers() 35 | bag.get(headers).guard = 'immutable' 36 | assert_throws_js(TypeError, () => headers.append('foo', 'bar')) 37 | assert_throws_js(TypeError, () => headers.set('foo', 'bar')) 38 | }, 'it throws when guard is immutable') 39 | 40 | test(() => { 41 | const headers = new Headers([ 42 | ['a', '1'].values(), 43 | new Set(['b', '2']), 44 | ]) 45 | assert_equals(headers.get('a'), '1') 46 | assert_equals(headers.get('b'), '2') 47 | }, 'it can convert iterables to sequence') 48 | 49 | test(() => { 50 | // string is also iterable but not possible to converted to a sequence 51 | assert_throws_js(TypeError, () => new Headers([ 'a1' ])) 52 | }, 'it cant convert string to sequence') 53 | 54 | test(() => { 55 | new Headers({a: '1'}).forEach(function () { 56 | assert_equals(this, globalThis) 57 | }) 58 | }, '`this` in foreach is global object') 59 | 60 | test(() => { 61 | new Headers({a: '1'}).forEach(function () { 62 | assert_equals(this, globalThis) 63 | }) 64 | }, '`this` in foreach is global object') 65 | 66 | test(() => { 67 | new Headers({a: '1'}).forEach(function () { 68 | assert_true(Array.isArray(this)) 69 | }, []) 70 | }, 'change thisArg in foreach') 71 | 72 | const headers = new Headers() 73 | headers[Symbol.for('nodejs.util.inspect.custom')]() 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fetch-headers 2 | 3 | The `Headers` class for NodeJS
4 | Executed against wpt test suits so it follows the spec correctly. 5 | 6 | ## Install 7 | 8 | `fetch-headers` is an ESM-only module - you are not able to import it with `require`. If you are unable to use ESM in your project you can use the async `import('fetch-headers')` from CommonJS to load `fetch-headers` asynchronously.
9 | `npm install fetch-headers` 10 | 11 | ## Use 12 | 13 | ```js 14 | import { Headers, bag } from 'fetch-headers' 15 | 16 | const headers = new Headers({ 17 | 'content-type': 'text/plain' 18 | }) 19 | 20 | // Turn headers to become immutable. 21 | bag.get(headers).guard = 'immutable' 22 | headers.set('content-type', 'text/html') // Throws 23 | ``` 24 | 25 | ### Regards to Set-Cookie and values joined by comma 26 | 27 | The new norm is that all headers with the same key should be joined by a comma value. 28 | but `set-cookies` Can still contain a comma value for historical reasons. (It's best to avoid using it in any header value). All other headers are not allowed to have it. 29 | 30 | Browser don't expose `Set-Cookies` headers in any way. That's why there is no issue with `headers.get(name).split(',')` that should always return a string joined by comma value, This header class will apply to this rule as well. meaning `headers.get('set-cookie')` will return a string with every `Set-Cookie` joined together. 31 | 32 | So parsing it can be tricky, that's why iterating over the headers can be the preferred way, this is the least way we could expose all `set-cookie` headers individually without deviating from the spec by adding a custom `getAll()` or `raw()` method that don't exist in the spec. 33 | 34 | ```js 35 | const header = new Headers() 36 | headers.append('xyz', 'abc') 37 | headers.append('xyz', 'abc') 38 | headers.append('Set-Cookie', 'a=1') 39 | headers.append('Set-Cookie', 'b=2') 40 | 41 | for (const [name, value] of headers) { 42 | if (name === 'set-cookie') { 43 | // Could happen twice 44 | } else { 45 | // Will never see the same `name` twice 46 | } 47 | } 48 | 49 | console.log([...headers]) 50 | // yields [[ "set-cookie", "a=1" ], ["set-cookie", "b=2"], ["xyz", "abc, abc"]] 51 | ``` 52 | 53 | This matches the same way Deno handles headers in core as well. 54 | It also looks like we might be getting a [getSetCookie method](https://github.com/whatwg/fetch/issues/973) soon. 55 | -------------------------------------------------------------------------------- /headers.d.ts: -------------------------------------------------------------------------------- 1 | export class Headers { 2 | /** @param {HeadersInit} [init] */ 3 | constructor (init?: HeadersInit | undefined) 4 | 5 | /** 6 | * The append() method of the Headers interface appends a new 7 | * value onto an existing header inside a Headers object, or 8 | * adds the header if it does not already exist. 9 | * 10 | * @param name The name of the HTTP header you want to add 11 | * to the Headers object. 12 | * @param value The value of the HTTP header you want to add. 13 | */ 14 | append(name, value): void 15 | 16 | /** 17 | * The delete() method of the Headers interface deletes a header 18 | * from the current Headers object. 19 | * 20 | * @param name The name of the HTTP header you want to delete 21 | * from the Headers object. 22 | */ 23 | delete(name): void 24 | 25 | /** 26 | * The get() method of the Headers interface returns a byte string 27 | * of all the values of a header within a Headers object with a 28 | * given name. If the requested header doesn't exist in the Headers 29 | * object, it returns null. 30 | * 31 | * @param name The name of the HTTP header whose values you want to 32 | * retrieve from the Headers object. The name is case insensitive. 33 | * @returns {string | null} 34 | */ 35 | get(name): string | null 36 | 37 | /** 38 | * @param name 39 | * @returns Returns a boolean stating whether a `Headers` object contains a 40 | * certain header. 41 | */ 42 | has(name): boolean 43 | 44 | /** 45 | * @param name 46 | * @param value 47 | */ 48 | set(name, value): void 49 | 50 | /** 51 | * @param {(value: string, key: string, parent: Headers) => void} callback 52 | * @param thisArg 53 | */ 54 | forEach( 55 | callback: ( 56 | value: string, 57 | key: string, 58 | parent: Headers 59 | ) => void, 60 | thisArg: globalThis 61 | ): void 62 | 63 | toString(): string 64 | 65 | /** 66 | * @see https://github.com/whatwg/fetch/pull/1346 67 | */ 68 | getSetCookie(): string[] 69 | 70 | /** 71 | * Returns an iterator allowing to go through all keys of the key/value 72 | * pairs contained in this object. 73 | */ 74 | keys(): IterableIterator 75 | 76 | /** 77 | * Returns an iterator allowing to go through all values of the key/value 78 | * pairs contained in this object. 79 | */ 80 | values(): IterableIterator 81 | 82 | /** 83 | * Returns an iterator allowing to go through all key/value pairs contained 84 | * in the Headers object. 85 | */ 86 | entries(): IterableIterator<[string, string]>; 87 | 88 | [Symbol.iterator](): IterableIterator<[string, string]> 89 | } 90 | 91 | export let bag: WeakMap 92 | export type Bag = { 93 | items: { [x: string]: string } 94 | cookies: Array 95 | guard: string 96 | } 97 | -------------------------------------------------------------------------------- /test/wpt_test.js: -------------------------------------------------------------------------------- 1 | import { Headers } from '../headers.js' 2 | // import {Headers} from 'undici' 3 | 4 | globalThis.Headers = Headers 5 | globalThis.hasFailed = false 6 | globalThis.self = globalThis 7 | globalThis.GLOBAL = { 8 | isWorker: () => true 9 | } 10 | 11 | // We are not using the same kind of iterator, but it have same properties. 12 | const { getOwnPropertyDescriptor } = Object 13 | Object.getOwnPropertyDescriptor = function (obj, name) { 14 | return name === 'next' 15 | ? { 16 | configurable: true, 17 | enumerable: true, 18 | writable: true 19 | } 20 | : getOwnPropertyDescriptor(obj, name) 21 | } 22 | 23 | ;(async () => { 24 | if (!globalThis.Response) { 25 | // Presumable it's a node.js environment 26 | const { Request, Response } = await import('node-fetch') 27 | globalThis.Request = Request 28 | globalThis.Response = Response 29 | } 30 | 31 | await import('https://wpt.live/resources/testharness.js') 32 | 33 | setup({ explicit_timeout: true, explicit_done: true }) 34 | 35 | globalThis.add_result_callback(test => { 36 | const INDENT_SIZE = 2 37 | const reporter = {} 38 | 39 | reporter.startSuite = name => console.log(`\n ${(name)}\n`) 40 | 41 | reporter.pass = message => console.log((indent(('√ ') + message, INDENT_SIZE))) 42 | 43 | reporter.fail = message => console.log((indent('\u00D7 ' + message, INDENT_SIZE))) 44 | 45 | reporter.reportStack = stack => console.log((indent(stack, INDENT_SIZE * 2))) 46 | 47 | function indent (string, times) { 48 | const prefix = ' '.repeat(times) 49 | return string.split('\n').map(l => prefix + l).join('\n') 50 | } 51 | 52 | if (test.status === 0) { 53 | reporter.pass(test.name) 54 | } else if (test.status === 1) { 55 | reporter.fail(`${test.name}\n`) 56 | reporter.reportStack(`${test.message}\n${test.stack}`) 57 | globalThis.hasFailed = true 58 | } else if (test.status === 2) { 59 | reporter.fail(`${test.name} (timeout)\n`) 60 | reporter.reportStack(`${test.message}\n${test.stack}`) 61 | globalThis.hasFailed = true 62 | } else if (test.status === 3) { 63 | reporter.fail(`${test.name} (incomplete)\n`) 64 | reporter.reportStack(`${test.message}\n${test.stack}`) 65 | globalThis.hasFailed = true 66 | } else if (test.status === 4) { 67 | reporter.fail(`${test.name} (precondition failed)\n`) 68 | reporter.reportStack(`${test.message}\n${test.stack}`) 69 | globalThis.hasFailed = true 70 | } else { 71 | reporter.fail(`unknown test status: ${test.status}`) 72 | globalThis.hasFailed = true 73 | } 74 | 75 | if (globalThis.hasFailed) globalThis.Deno ? Deno.exit(1) : process.exit(1) 76 | }) 77 | 78 | // Works 79 | await import('https://wpt.live/fetch/api/headers/headers-record.any.js') 80 | await Promise.all([ 81 | // this requires http request 82 | // await import('https://wpt.live/fetch/api/headers/header-values-normalize.any.js') 83 | // await import('https://wpt.live/fetch/api/headers/header-values.any.js') 84 | // await import('https://wpt.live/fetch/api/headers/headers-no-cors.any.js') 85 | 86 | import('https://wpt.live/fetch/api/headers/headers-basic.any.js'), 87 | import('https://wpt.live/fetch/api/headers/headers-casing.any.js'), 88 | import('https://wpt.live/fetch/api/headers/headers-combine.any.js'), 89 | import('https://wpt.live/fetch/api/headers/headers-errors.any.js'), 90 | import('https://wpt.live/fetch/api/headers/headers-normalize.any.js'), 91 | import('https://wpt.live/fetch/api/headers/headers-structure.any.js'), 92 | import('./own-misc-test.any.js'), 93 | 94 | // This one has broken tests: 95 | // await import('https://raw.githubusercontent.com/web-platform-tests/wpt/352ee4227f71c9be1c355f7d812a8e28e7b18008/fetch/api/headers/header-setcookie.any.js') 96 | // Use this for now... 97 | import('./header-setcookie.any.js') 98 | ]) 99 | })() 100 | -------------------------------------------------------------------------------- /test/header-setcookie.any.js: -------------------------------------------------------------------------------- 1 | // META: title=Headers set-cookie special cases 2 | // META: global=window,worker 3 | 4 | const headerList = [ 5 | ['set-cookie', 'foo=bar'], 6 | ['Set-Cookie', 'fizz=buzz; domain=example.com'] 7 | ] 8 | 9 | const setCookie2HeaderList = [ 10 | ['set-cookie2', 'foo2=bar2'], 11 | ['Set-Cookie2', 'fizz2=buzz2; domain=example2.com'] 12 | ] 13 | 14 | function assert_nested_array_equals (actual, expected) { 15 | assert_equals(actual.length, expected.length, 'Array length is not equal') 16 | for (let i = 0; i < expected.length; i++) { 17 | assert_array_equals(actual[i], expected[i]) 18 | } 19 | } 20 | 21 | test(function () { 22 | new Headers({ 'Set-Cookie': 'foo=bar' }) 23 | }, 'Create headers with a single set-cookie header in object') 24 | 25 | test(function () { 26 | new Headers([headerList[0]]) 27 | }, 'Create headers with a single set-cookie header in list') 28 | 29 | test(function () { 30 | new Headers(headerList) 31 | }, 'Create headers with multiple set-cookie header in list') 32 | 33 | test(function () { 34 | const headers = new Headers(headerList) 35 | assert_equals( 36 | headers.get('set-cookie'), 37 | 'foo=bar, fizz=buzz; domain=example.com' 38 | ) 39 | }, 'Headers.prototype.get combines set-cookie headers in order') 40 | 41 | test(function () { 42 | const headers = new Headers(headerList) 43 | const list = [...headers] 44 | assert_nested_array_equals(list, [ 45 | ['set-cookie', 'foo=bar'], 46 | ['set-cookie', 'fizz=buzz; domain=example.com'] 47 | ]) 48 | }, 'Headers iterator does not combine set-cookie headers') 49 | 50 | test(function () { 51 | const headers = new Headers(setCookie2HeaderList) 52 | const list = [...headers] 53 | assert_nested_array_equals(list, [ 54 | ['set-cookie2', 'foo2=bar2, fizz2=buzz2; domain=example2.com'] 55 | ]) 56 | }, 'Headers iterator does not special case set-cookie2 headers') 57 | 58 | test(function () { 59 | const headers = new Headers([...headerList, ...setCookie2HeaderList]) 60 | const list = [...headers] 61 | assert_nested_array_equals(list, [ 62 | ['set-cookie', 'foo=bar'], 63 | ['set-cookie', 'fizz=buzz; domain=example.com'], 64 | ['set-cookie2', 'foo2=bar2, fizz2=buzz2; domain=example2.com'] 65 | ]) 66 | }, 'Headers iterator does not combine set-cookie & set-cookie2 headers') 67 | 68 | test(function () { 69 | // Values are in non alphabetic order, and the iterator should yield in the 70 | // headers in the exact order of the input. 71 | const headers = new Headers([ 72 | ['set-cookie', 'z=z'], 73 | ['set-cookie', 'a=a'], 74 | ['set-cookie', 'n=n'] 75 | ]) 76 | const list = [...headers] 77 | assert_nested_array_equals(list, [ 78 | ['set-cookie', 'z=z'], 79 | ['set-cookie', 'a=a'], 80 | ['set-cookie', 'n=n'] 81 | ]) 82 | }, 'Headers iterator preserves set-cookie ordering') 83 | 84 | test( 85 | function () { 86 | const headers = new Headers([ 87 | ['xylophone-header', '1'], 88 | ['best-header', '2'], 89 | ['set-cookie', '3'], 90 | ['a-cool-header', '4'], 91 | ['set-cookie', '5'], 92 | ['a-cool-header', '6'], 93 | ['best-header', '7'] 94 | ]) 95 | const list = [...headers] 96 | assert_nested_array_equals(list, [ 97 | ['a-cool-header', '4, 6'], 98 | ['best-header', '2, 7'], 99 | ['set-cookie', '3'], 100 | ['set-cookie', '5'], 101 | ['xylophone-header', '1'] 102 | ]) 103 | }, 104 | 'Headers iterator preserves per header ordering, but sorts keys alphabetically' 105 | ) 106 | 107 | test( 108 | function () { 109 | const headers = new Headers([ 110 | ['xylophone-header', '7'], 111 | ['best-header', '6'], 112 | ['set-cookie', '5'], 113 | ['a-cool-header', '4'], 114 | ['set-cookie', '3'], 115 | ['a-cool-header', '2'], 116 | ['best-header', '1'] 117 | ]) 118 | const list = [...headers] 119 | assert_nested_array_equals(list, [ 120 | ['a-cool-header', '4, 2'], 121 | ['best-header', '6, 1'], 122 | ['set-cookie', '5'], 123 | ['set-cookie', '3'], 124 | ['xylophone-header', '7'] 125 | ]) 126 | }, 127 | 'Headers iterator preserves per header ordering, but sorts keys alphabetically (and ignores value ordering)' 128 | ) 129 | 130 | test(function () { 131 | const headers = new Headers(headerList) 132 | headers.set('set-cookie', 'foo2=bar2') 133 | const list = [...headers] 134 | assert_nested_array_equals(list, [ 135 | ['set-cookie', 'foo2=bar2'] 136 | ]) 137 | }, 'Headers.prototype.set works for set-cookie') 138 | 139 | test(function () { 140 | const headers = new Headers() 141 | assert_array_equals(headers.getSetCookie(), []) 142 | }, 'Headers.prototype.getSetCookie with no headers present') 143 | 144 | test(function () { 145 | const headers = new Headers([headerList[0]]) 146 | assert_array_equals(headers.getSetCookie(), ['foo=bar']) 147 | }, 'Headers.prototype.getSetCookie with one header') 148 | 149 | test(function () { 150 | const headers = new Headers(headerList) 151 | assert_array_equals(headers.getSetCookie(), [ 152 | 'foo=bar', 153 | 'fizz=buzz; domain=example.com' 154 | ]) 155 | }, 'Headers.prototype.getSetCookie with multiple headers') 156 | 157 | test(function () { 158 | const headers = new Headers([['set-cookie', '']]) 159 | assert_array_equals(headers.getSetCookie(), ['']) 160 | }, 'Headers.prototype.getSetCookie with an empty header') 161 | 162 | test(function () { 163 | const headers = new Headers([['set-cookie', 'x'], ['set-cookie', 'x']]) 164 | assert_array_equals(headers.getSetCookie(), ['x', 'x']) 165 | }, 'Headers.prototype.getSetCookie with two equal headers') 166 | 167 | test(function () { 168 | const headers = new Headers([ 169 | ['set-cookie2', 'x'], 170 | ['set-cookie', 'y'], 171 | ['set-cookie2', 'z'] 172 | ]) 173 | assert_array_equals(headers.getSetCookie(), ['y']) 174 | }, 'Headers.prototype.getSetCookie ignores set-cookie2 headers') 175 | 176 | test(function () { 177 | // Values are in non alphabetic order, and the iterator should yield in the 178 | // headers in the exact order of the input. 179 | const headers = new Headers([ 180 | ['set-cookie', 'z=z'], 181 | ['set-cookie', 'a=a'], 182 | ['set-cookie', 'n=n'] 183 | ]) 184 | assert_array_equals(headers.getSetCookie(), ['z=z', 'a=a', 'n=n']) 185 | }, 'Headers.prototype.getSetCookie preserves header ordering') 186 | -------------------------------------------------------------------------------- /headers.js: -------------------------------------------------------------------------------- 1 | /*! fetch-headers. MIT License. Jimmy Wärting */ 2 | 3 | /** @param {Headers} instance */ 4 | function assert (instance, argsCount = 0, requiredArgs = 0, method = '') { 5 | if (!(instance instanceof Headers)) { 6 | throw new TypeError('Illegal invocation') 7 | } 8 | if (argsCount < requiredArgs) { 9 | throw new TypeError(`"Failed to execute '${method}' on 'Headers'" requires at least ${requiredArgs} argument, but only ${argsCount} were provided.`) 10 | } 11 | return /** @type {Bag} */ (wm.get(instance)) 12 | } 13 | 14 | /** 15 | * @typedef Bag 16 | * @property {Object} items 17 | * @property {Array} cookies 18 | * @property {string} guard 19 | */ 20 | 21 | /** 22 | * @param {Bag} bag 23 | * @param {HeadersInit} object 24 | */ 25 | function fillHeaders (bag, object) { 26 | if (object === null) throw new TypeError("HeadersInit can't be null.") 27 | 28 | const iterable = object[Symbol.iterator] 29 | 30 | if (iterable) { 31 | // @ts-ignore 32 | for (let header of object) { 33 | if (typeof header === 'string') { 34 | throw new TypeError('The provided value cannot be converted to a sequence.') 35 | } 36 | 37 | if (header[Symbol.iterator] && !Array.isArray(header)) { 38 | header = [...header] 39 | } 40 | 41 | if (header.length !== 2) { 42 | throw new TypeError(`Invalid header. Length must be 2, but is ${header.length}`) 43 | } 44 | 45 | appendHeader(bag, header[0], header[1]) 46 | } 47 | } else { 48 | for (const key of Reflect.ownKeys(object)) { 49 | const x = Reflect.getOwnPropertyDescriptor(object, key) 50 | if (x === undefined || !x.enumerable) continue 51 | 52 | if (typeof key === 'symbol') { 53 | throw new TypeError('Invalid header. Symbol key is not supported.') 54 | } 55 | 56 | if (!HTTP_TOKEN_CODE_POINT_RE.test(key)) { 57 | throw new TypeError('Header name is not valid.') 58 | } 59 | 60 | appendHeader(bag, key, Reflect.get(object, key)) 61 | } 62 | } 63 | } 64 | 65 | const ILLEGAL_VALUE_CHARS = /[\x00\x0A\x0D]/g 66 | const IS_BYTE_STRING = /^[\x00-\xFF]*$/ 67 | const HTTP_TOKEN_CODE_POINT_RE = /^[\u0021\u0023\u0024\u0025\u0026\u0027\u002a\u002b\u002d\u002e\u005e\u005f\u0060\u007c\u007e\u0030-\u0039\u0041-\u005a\u0061-\u007a]+$/ 68 | const HTTP_BETWEEN_WHITESPACE = /^[\u000a\u000d\u0009\u0020]*(.*?)[\u000a\u000d\u0009\u0020]*$/ 69 | 70 | /** @param {string} char */ 71 | function isHttpWhitespace (char) { 72 | switch (char) { 73 | case '\u0009': 74 | case '\u000A': 75 | case '\u000D': 76 | case '\u0020': 77 | return true 78 | } 79 | 80 | return false 81 | } 82 | 83 | /** @param {string} s */ 84 | function httpTrim (s) { 85 | if (!isHttpWhitespace(s[0]) && !isHttpWhitespace(s[s.length - 1])) { 86 | return s 87 | } 88 | 89 | const match = HTTP_BETWEEN_WHITESPACE.exec(s) 90 | return match && match[1] 91 | } 92 | 93 | /** 94 | * https://fetch.spec.whatwg.org/#concept-headers-append 95 | * @param {Bag} bag 96 | * @param {string} name 97 | * @param {string} value 98 | */ 99 | function appendHeader (bag, name, value) { 100 | value = httpTrim(`${value}`) || '' 101 | 102 | if (!HTTP_TOKEN_CODE_POINT_RE.test(name)) { 103 | throw new TypeError('Header name is not valid.') 104 | } 105 | 106 | if (ILLEGAL_VALUE_CHARS.test(value) || !IS_BYTE_STRING.test(value)) { 107 | throw new TypeError(`Header value ${JSON.stringify(value)} is not valid.`) 108 | } 109 | 110 | if (bag.guard === 'immutable') { 111 | throw new TypeError('Headers are immutable.') 112 | } 113 | 114 | name = String(name).toLocaleLowerCase() 115 | 116 | bag.items[name] = name in bag.items ? `${bag.items[name]}, ${value}` : value 117 | 118 | if (name === 'set-cookie') { 119 | bag.cookies.push(value) 120 | } 121 | } 122 | 123 | /** @param {string} name */ 124 | function normalizeName (name) { 125 | name = `${name}`.toLowerCase() 126 | if (!HTTP_TOKEN_CODE_POINT_RE.test(name)) throw new TypeError('Header name is not valid.') 127 | return name 128 | } 129 | 130 | /** @type {WeakMap} */ 131 | const wm = new WeakMap() 132 | 133 | export class Headers { 134 | /** @param {HeadersInit} [init] */ 135 | constructor (init = undefined) { 136 | const bag = { 137 | items: Object.create(null), 138 | cookies: [], 139 | guard: 'mutable' 140 | } 141 | 142 | wm.set(this, bag) 143 | 144 | if (init !== undefined) { 145 | fillHeaders(bag, init) 146 | } 147 | } 148 | 149 | append (name, value) { 150 | const bag = assert(this, arguments.length, 2, 'append') 151 | appendHeader(bag, name, value) 152 | } 153 | 154 | delete (name) { 155 | const bag = assert(this, arguments.length, 1, 'delete') 156 | name = normalizeName(name) 157 | delete bag.items[name] 158 | if (name === 'set-cookie') bag.cookies.length = 0 159 | } 160 | 161 | get (name) { 162 | const bag = assert(this, arguments.length, 1, 'get') 163 | name = normalizeName(name) 164 | return name in bag.items ? bag.items[name] : null 165 | } 166 | 167 | has (name) { 168 | const bag = assert(this, arguments.length, 1, 'has') 169 | return normalizeName(name) in bag.items 170 | } 171 | 172 | set (name, value) { 173 | const bag = assert(this, arguments.length, 2, 'set') 174 | this.delete(name) 175 | appendHeader(bag, name, value) 176 | } 177 | 178 | forEach (callback, thisArg = globalThis) { 179 | const bag = assert(this, arguments.length, 1, 'forEach') 180 | if (typeof callback !== 'function') { 181 | throw new TypeError( 182 | "Failed to execute 'forEach' on 'Headers': parameter 1 is not of type 'Function'." 183 | ) 184 | } 185 | 186 | for (const x of this) { 187 | callback.call(thisArg, x[1], x[0], this) 188 | } 189 | } 190 | 191 | toString () { 192 | return '[object Headers]' 193 | } 194 | 195 | getSetCookie () { 196 | const bag = assert(this, 0, 0, '') 197 | return bag.cookies.slice(0) 198 | } 199 | 200 | keys () { 201 | assert(this, 0, 0, '') 202 | return [...this].map(x => x[0]).values() 203 | } 204 | 205 | values () { 206 | assert(this, 0, 0, '') 207 | return [...this].map(x => x[1]).values() 208 | } 209 | 210 | entries () { 211 | const bag = assert(this, 0, 0, '') 212 | /** @type {Array<[string, string]>} */ 213 | const result = [] 214 | 215 | const entries = [ 216 | ...Object.entries(bag.items).sort((a, b) => a[0] > b[0] ? 1 : -1) 217 | ] 218 | 219 | for (const [name, value] of entries) { 220 | if (name === 'set-cookie') { 221 | for (const cookie of bag.cookies) { 222 | result.push([name, cookie]) 223 | } 224 | } else result.push([name, value]) 225 | } 226 | 227 | return result.values() 228 | } 229 | 230 | [Symbol.iterator] () { 231 | return this.entries() 232 | } 233 | 234 | [Symbol.for('nodejs.util.inspect.custom')] () { 235 | const bag = assert(this, 0, 0, '') 236 | class Headers extends URLSearchParams { } 237 | return new Headers(bag.items) 238 | } 239 | } 240 | 241 | export const bag = wm 242 | 243 | const enumerable = { enumerable: true } 244 | 245 | Object.defineProperties(Headers.prototype, { 246 | append: enumerable, 247 | delete: enumerable, 248 | entries: enumerable, 249 | forEach: enumerable, 250 | get: enumerable, 251 | getSetCookie: enumerable, 252 | has: enumerable, 253 | keys: enumerable, 254 | set: enumerable, 255 | values: enumerable 256 | }) 257 | --------------------------------------------------------------------------------