├── .vscode ├── settings.json └── launch.json ├── .eslintignore ├── .husky └── pre-commit ├── banner.jpeg ├── .gitignore ├── .mocha-multi.json ├── .nycrc.json ├── .npmignore ├── .github ├── ISSUE_TEMPLATE │ ├── discussion.md │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md ├── workflows │ ├── semver-check.yaml │ └── main.yaml └── move.yml ├── .releaserc.cjs ├── src ├── package.cjs ├── core │ ├── errors.js │ ├── lock.js │ ├── index.js │ ├── h1.js │ ├── h2.js │ └── request.js ├── index.js ├── fetch │ ├── errors.js │ ├── policy.js │ ├── cacheableResponse.js │ ├── abort.js │ ├── response.js │ ├── headers.js │ ├── request.js │ └── body.js ├── index.d.ts ├── common │ ├── formData.js │ └── utils.js └── api.d.ts ├── .tidelift.yml ├── renovate.json ├── test ├── core │ ├── errors.test.js │ ├── h2c.test.js │ └── misc.test.js ├── runServer.js ├── utils.js ├── fetch │ ├── errors.test.js │ ├── misc.test.js │ ├── redirect.test.js │ ├── resiliance.test.js │ ├── abort.test.js │ ├── index.http2.test.js │ ├── headers.test.js │ ├── response.test.js │ ├── index.http1.test.js │ └── request.test.js ├── keys.json ├── common │ ├── formData.test.js │ └── utils.test.js └── server.js ├── package.json ├── .eslintrc.cjs ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | coverage/* -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/fetch/HEAD/banner.jpeg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .nyc_output/ 3 | node_modules/ 4 | junit 5 | tmp 6 | logs 7 | .DS_Store 8 | test-results.xml 9 | dist 10 | -------------------------------------------------------------------------------- /.mocha-multi.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporterEnabled": "spec,xunit", 3 | "xunitReporterOptions": { 4 | "output": "junit/test-results.xml" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": [ 3 | "lcov", 4 | "text", 5 | "text-summary" 6 | ], 7 | "check-coverage": true, 8 | "lines": 100, 9 | "branches": 100, 10 | "statements": 100 11 | } 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # !README.md and !CHANGELOG.md are implicit 2 | *.md 3 | *.tgz 4 | /.* 5 | banner.jpeg 6 | build 7 | coverage 8 | junit 9 | logs 10 | node_modules 11 | snykmocha.jsl 12 | test 13 | test-results.xml 14 | renovate.json 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Discussion 3 | about: Start a new discussion 4 | labels: question 5 | 6 | --- 7 | 8 | ## Overview 9 | whats' this discussion about? 10 | 11 | ## Details 12 | more details 13 | 14 | ## Proposed Actions 15 | and now? 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Please ensure your pull request adheres to the following guidelines: 2 | - [ ] make sure to link the related issues in this description 3 | - [ ] when merging / squashing, make sure the fixed issue references are visible in the commits, for easy compilation of release notes 4 | 5 | ## Related Issues 6 | 7 | 8 | Thanks for contributing! 9 | -------------------------------------------------------------------------------- /.github/workflows/semver-check.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - 'main' 5 | 6 | jobs: 7 | ci_trigger: 8 | runs-on: ubuntu-latest 9 | name: Comment Semantic Release Status 10 | steps: 11 | - name: Comment 12 | id: comment 13 | uses: adobe-rnd/github-semantic-release-comment-action@main 14 | with: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/move.yml: -------------------------------------------------------------------------------- 1 | # Configuration for move-issues - https://github.com/dessant/move-issues 2 | 3 | # Delete the command comment when it contains no other content 4 | deleteCommand: true 5 | 6 | # Close the source issue after moving 7 | closeSourceIssue: true 8 | 9 | # Lock the source issue after moving 10 | lockSourceIssue: true 11 | 12 | # Mention issue and comment authors 13 | mentionAuthors: true 14 | 15 | # Preserve mentions in the issue content 16 | keepContentMentions: true 17 | -------------------------------------------------------------------------------- /.releaserc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | ["@semantic-release/changelog", { 6 | "changelogFile": "CHANGELOG.md", 7 | }], 8 | "@semantic-release/npm", 9 | ["@semantic-release/git", { 10 | "assets": ["package.json", "CHANGELOG.md"], 11 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 12 | }], 13 | ["@semantic-release/github", {}] 14 | ], 15 | branches: ['main'] 16 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: enhancement 5 | 6 | --- 7 | 8 | **Is your feature request related to a problem? Please describe.** 9 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | 11 | **Describe the solution you'd like** 12 | A clear and concise description of what you want to happen. 13 | 14 | **Describe alternatives you've considered** 15 | A clear and concise description of any alternative solutions or features you've considered. 16 | 17 | **Additional context** 18 | Add any other context or screenshots about the feature request here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: bug 5 | 6 | --- 7 | 8 | **Description** 9 | A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** 12 | Steps to reproduce the behavior: 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Version:** 25 | The version of @adobe/fetch used. 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /src/package.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | module.exports = require('../package.json'); 13 | -------------------------------------------------------------------------------- /.tidelift.yml: -------------------------------------------------------------------------------- 1 | licensing: 2 | disallowed: 3 | - AGPL-1.0-only 4 | - AGPL-1.0-or-later 5 | - AGPL-3.0-only 6 | - AGPL-3.0-or-later 7 | - AGPL-1.0 8 | - AGPL-3.0 9 | - CC-BY-NC-ND-1.0 10 | - CC-BY-NC-ND-2.0 11 | - CC-BY-NC-ND-2.5 12 | - CC-BY-NC-ND-3.0 13 | - CC-BY-NC-ND-4.0 14 | - CC-BY-NC-SA-1.0 15 | - CC-BY-NC-SA-2.0 16 | - CC-BY-NC-SA-2.5 17 | - CC-BY-NC-SA-3.0 18 | - CC-BY-NC-SA-4.0 19 | - CC-BY-SA-1.0 20 | - CC-BY-SA-2.0 21 | - CC-BY-SA-2.5 22 | - CC-BY-SA-3.0 23 | - CC-BY-SA-4.0 24 | - GPL-1.0-only 25 | - GPL-1.0-or-later 26 | - GPL-2.0-only 27 | - GPL-2.0-or-later 28 | - GPL-3.0-only 29 | - GPL-3.0-or-later 30 | - SSPL-1.0 31 | - Sleepycat 32 | - Facebook -------------------------------------------------------------------------------- /src/core/errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /** 14 | * Error thrown if a request is aborted via an AbortSignal. 15 | */ 16 | class RequestAbortedError extends Error { 17 | get name() { 18 | return this.constructor.name; 19 | } 20 | 21 | get [Symbol.toStringTag]() { 22 | return this.constructor.name; 23 | } 24 | } 25 | 26 | // eslint-disable-next-line import/prefer-default-export 27 | export { RequestAbortedError }; 28 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":semanticCommits", 5 | ":autodetectPinVersions" 6 | ], 7 | "baseBranches": ["main"], 8 | "timezone": "Europe/Zurich", 9 | "branchPrefix": "renovate-", 10 | "packageRules": [ 11 | { 12 | "matchPackageNames": ["eslint"], 13 | "allowedVersions": "<9.0.0" 14 | }, 15 | { 16 | "matchPackageNames": ["nock"], 17 | "allowedVersions": "<14.0.0" 18 | }, 19 | { 20 | "matchPackageNames": ["lru-cache"], 21 | "allowedVersions": "<8.0.0" 22 | }, 23 | { 24 | "groupName": "external fixes", 25 | "updateTypes": ["patch", "pin", "digest", "minor"], 26 | "automerge": true, 27 | "schedule": ["after 2pm on Saturday"], 28 | "packagePatterns": ["^.+"] 29 | }, 30 | { 31 | "groupName": "external major", 32 | "updateTypes": ["major"], 33 | "automerge": false, 34 | "packagePatterns": ["^.+"], 35 | "schedule": ["after 2pm on Monday"] 36 | }, 37 | { 38 | "datasources": ["orb"], 39 | "updateTypes": ["patch", "minor"], 40 | "automerge": true 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/core/errors.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | 17 | import { RequestAbortedError } from '../../src/core/errors.js'; 18 | 19 | describe('core errors Tests', () => { 20 | it('RequestAbortedError', () => { 21 | const err = new RequestAbortedError('test'); 22 | assert(err instanceof Error); 23 | assert.strictEqual(err.message, 'test'); 24 | assert.strictEqual(err.name, 'RequestAbortedError'); 25 | assert.strictEqual(Object.prototype.toString.call(err), '[object RequestAbortedError]'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/runServer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import Server from './server.js'; 14 | 15 | let server; 16 | 17 | const HELLO_MSG = 'Hello, World!'; 18 | 19 | process.on('message', async (args) => { 20 | // received msg from parent process 21 | const { 22 | httpMajorVersion = 2, 23 | secure = true, 24 | helloMsg = HELLO_MSG, 25 | port = 0, 26 | options = {}, 27 | } = args; 28 | server = new Server(httpMajorVersion, secure, helloMsg, options); 29 | await server.start(port); 30 | // send msg to parent process 31 | process.send({ port: server.port, origin: server.origin, pid: process.pid }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import api from './fetch/index.js'; 14 | 15 | export const ALPNProtocol = { 16 | ALPN_HTTP2: api.ALPN_HTTP2, 17 | ALPN_HTTP2C: api.ALPN_HTTP2C, 18 | ALPN_HTTP1_1: api.ALPN_HTTP1_1, 19 | ALPN_HTTP1_0: api.ALPN_HTTP1_0, 20 | }; 21 | 22 | export const { 23 | fetch, 24 | context, 25 | reset, 26 | noCache, 27 | h1, 28 | keepAlive, 29 | h1NoCache, 30 | keepAliveNoCache, 31 | cacheStats, 32 | clearCache, 33 | offPush, 34 | onPush, 35 | createUrl, 36 | timeoutSignal, 37 | Body, 38 | Headers, 39 | Request, 40 | Response, 41 | AbortController, 42 | AbortError, 43 | AbortSignal, 44 | FetchBaseError, 45 | FetchError, 46 | ALPN_HTTP2, 47 | ALPN_HTTP2C, 48 | ALPN_HTTP1_1, 49 | ALPN_HTTP1_0, 50 | } = api; 51 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable no-underscore-dangle */ 14 | import { parse, getBoundary } from 'parse-multipart-data'; 15 | 16 | // misc. test helpers 17 | 18 | const isReadableStream = (val) => val !== null 19 | && typeof val === 'object' 20 | && typeof val.pipe === 'function' 21 | && val.readable !== false 22 | && typeof val._read === 'function' 23 | && typeof val._readableState === 'object'; 24 | 25 | const parseMultiPartFormData = (contentType, body) => { 26 | const boundary = getBoundary(contentType); 27 | const parts = parse(body, boundary); 28 | const form = {}; 29 | for (const { name, data } of parts) { 30 | form[name] = data.toString(); 31 | } 32 | return form; 33 | }; 34 | 35 | export { isReadableStream, parseMultiPartFormData }; 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | permissions: 5 | contents: write # semantic-release-dry verifies the write permissions 6 | issues: write # needed by semantic-release 7 | pull-requests: write # needed by semantic-release 8 | id-token: write # needed for npm trusted publishers with OIDC 9 | 10 | env: 11 | CI_BUILD_NUM: ${{ github.run_id }} 12 | CI_BRANCH: ${{ github.ref_name }} 13 | 14 | jobs: 15 | test: 16 | name: Test 17 | runs-on: macos-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Use Node.js 24.x 21 | uses: actions/setup-node@v6 22 | with: 23 | node-version: '24.x' 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm test 27 | - uses: codecov/codecov-action@v5 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | - name: Semantic Release (Dry Run) 31 | run: npm run semantic-release-dry 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} 35 | 36 | release: 37 | name: Release 38 | runs-on: ubuntu-latest 39 | if: github.ref == 'refs/heads/main' 40 | needs: test 41 | steps: 42 | - uses: actions/checkout@v6 43 | with: 44 | persist-credentials: false 45 | - name: Use Node.js 24.x 46 | uses: actions/setup-node@v6 47 | with: 48 | node-version: '24.x' 49 | - run: npm ci 50 | - run: npm run semantic-release 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | NPM_TOKEN: ${{ secrets.ADOBE_BOT_NPM_TOKEN }} 54 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch current file", 11 | "program": "${file}", 12 | "env": { "NODE_OPTIONS": "--trace-tls", "NODE_DEBUG": "*" }, 13 | "skipFiles": [ 14 | "/**" 15 | ] 16 | }, 17 | { 18 | "type": "node", 19 | "request": "launch", 20 | "name": "Launch Program", 21 | "skipFiles": [ 22 | "/**" 23 | ], 24 | "program": "${workspaceFolder}/index.js" 25 | }, 26 | { 27 | "type": "node", 28 | "request": "launch", 29 | "name": "Mocha All", 30 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 31 | "skipFiles": [ 32 | "/**" 33 | ], 34 | "args": [ 35 | "--recursive", 36 | "--timeout", 37 | "999999", 38 | "--colors", 39 | "${workspaceFolder}/test" 40 | ], 41 | "console": "integratedTerminal", 42 | "internalConsoleOptions": "neverOpen" 43 | }, 44 | { 45 | "type": "node", 46 | "request": "launch", 47 | "name": "Mocha Current File", 48 | "skipFiles": [ 49 | "/**" 50 | ], 51 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 52 | "args": [ 53 | "--timeout", 54 | "999999", 55 | "--colors", 56 | "${file}" 57 | ], 58 | "console": "integratedTerminal", 59 | "internalConsoleOptions": "neverOpen" 60 | } 61 | ] 62 | } -------------------------------------------------------------------------------- /test/fetch/errors.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | 17 | import { FetchBaseError, FetchError, AbortError } from '../../src/fetch/errors.js'; 18 | 19 | describe('fetch errors Tests', () => { 20 | it('FetchBaseError', () => { 21 | const err = new FetchBaseError('test'); 22 | assert(err instanceof Error); 23 | assert.strictEqual(err.message, 'test'); 24 | assert.strictEqual(err.name, 'FetchBaseError'); 25 | assert.strictEqual(Object.prototype.toString.call(err), '[object FetchBaseError]'); 26 | }); 27 | 28 | it('FetchError', () => { 29 | const err = new FetchError('test'); 30 | assert(err instanceof FetchBaseError); 31 | assert.strictEqual(err.message, 'test'); 32 | assert.strictEqual(err.name, 'FetchError'); 33 | assert.strictEqual(Object.prototype.toString.call(err), '[object FetchError]'); 34 | }); 35 | 36 | it('AbortError', () => { 37 | const err = new AbortError('test'); 38 | assert(err instanceof FetchBaseError); 39 | assert.strictEqual(err.type, 'aborted'); 40 | assert.strictEqual(err.message, 'test'); 41 | assert.strictEqual(err.name, 'AbortError'); 42 | assert.strictEqual(Object.prototype.toString.call(err), '[object AbortError]'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/fetch/errors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable max-classes-per-file */ 14 | 15 | class FetchBaseError extends Error { 16 | constructor(message, type) { 17 | super(message); 18 | this.type = type; 19 | } 20 | 21 | get name() { 22 | return this.constructor.name; 23 | } 24 | 25 | get [Symbol.toStringTag]() { 26 | return this.constructor.name; 27 | } 28 | } 29 | 30 | /** 31 | * @typedef {{ 32 | * address?: string, code: string, dest?: string, errno: number, info?: object, 33 | * message: string, path?: string, port?: number, syscall: string 34 | * }} SystemError 35 | */ 36 | 37 | class FetchError extends FetchBaseError { 38 | /** 39 | * @param {string} message error message 40 | * @param {string} [type] identifies the kind of error 41 | * @param {SystemError} [systemError] node system error 42 | */ 43 | constructor(message, type, systemError) { 44 | super(message, type); 45 | if (systemError) { 46 | this.code = systemError.code; 47 | this.errno = systemError.errno; 48 | this.erroredSysCall = systemError.syscall; 49 | } 50 | } 51 | } 52 | 53 | class AbortError extends FetchBaseError { 54 | constructor(message, type = 'aborted') { 55 | super(message, type); 56 | } 57 | } 58 | 59 | export { FetchBaseError, FetchError, AbortError }; 60 | -------------------------------------------------------------------------------- /test/core/h2c.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | import { finished } from 'stream'; 17 | import { promisify } from 'util'; 18 | 19 | import { WritableStreamBuffer } from 'stream-buffers'; 20 | 21 | import { isReadableStream } from '../utils.js'; 22 | import Server from '../server.js'; 23 | import core from '../../src/core/index.js'; 24 | 25 | const { request, reset } = core; 26 | const streamFinished = promisify(finished); 27 | 28 | const readStream = async (stream) => { 29 | const out = new WritableStreamBuffer(); 30 | stream.pipe(out); 31 | return streamFinished(out).then(() => out.getContents()); 32 | }; 33 | 34 | const HELLO_WORLD = 'Hello, World!'; 35 | 36 | describe('unencrypted HTTP/2 (h2c)-specific Core Tests', () => { 37 | let server; 38 | 39 | before(async () => { 40 | // start unencrypted HTTP/2 (h2c) server 41 | server = await Server.launch(2, false, HELLO_WORLD); 42 | }); 43 | 44 | after(async () => { 45 | await reset(); 46 | try { 47 | process.kill(server.pid); 48 | } catch (ignore) { /* ignore */ } 49 | }); 50 | 51 | it('supports unencrypted HTTP/2 (h2c)', async () => { 52 | const resp = await request(`${server.origin}/hello`); 53 | assert.strictEqual(resp.statusCode, 200); 54 | assert.strictEqual(resp.httpVersionMajor, 2); 55 | assert(isReadableStream(resp.readable)); 56 | 57 | const buf = await readStream(resp.readable); 58 | assert.strictEqual(buf.toString(), HELLO_WORLD); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/core/lock.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { EventEmitter } from 'events'; 14 | 15 | /** 16 | * Creates a lock (mutex) for asynchronous resources. 17 | * 18 | * Based on https://medium.com/trabe/synchronize-cache-updates-in-node-js-with-a-mutex-d5b395457138 19 | */ 20 | const lock = () => { 21 | const locked = {}; 22 | const ee = new EventEmitter(); 23 | ee.setMaxListeners(0); 24 | 25 | return { 26 | /** 27 | * Acquire a mutual exclusive lock. 28 | * 29 | * @param {string} key resource key to lock 30 | * @returns {Promise<*>} Promise which resolves with an option value passed on #release 31 | */ 32 | acquire: (key) => new Promise((resolve) => { 33 | if (!locked[key]) { 34 | locked[key] = true; 35 | resolve(); 36 | return; 37 | } 38 | 39 | const tryAcquire = (value) => { 40 | if (!locked[key]) { 41 | locked[key] = true; 42 | ee.removeListener(key, tryAcquire); 43 | resolve(value); 44 | } 45 | }; 46 | 47 | ee.on(key, tryAcquire); 48 | }), 49 | 50 | /** 51 | * Release the mutual exclusive lock. 52 | * 53 | * @param {string} key resource key to release 54 | * @param {*} [value] optional value to be propagated 55 | * to all the code that's waiting for 56 | * the lock to release 57 | */ 58 | release: (key, value) => { 59 | Reflect.deleteProperty(locked, key); 60 | setImmediate(() => ee.emit(key, value)); 61 | }, 62 | }; 63 | }; 64 | 65 | export default lock; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adobe/fetch", 3 | "version": "4.2.3", 4 | "description": "Light-weight Fetch implementation transparently supporting both HTTP/1(.1) and HTTP/2", 5 | "main": "./src/index.js", 6 | "module": "./src/index.js", 7 | "sideEffects": false, 8 | "type": "module", 9 | "scripts": { 10 | "lint": "eslint .", 11 | "test": "c8 mocha", 12 | "test-ci": "c8 mocha", 13 | "semantic-release": "semantic-release", 14 | "semantic-release-dry": "semantic-release --dry-run --branches $CI_BRANCH", 15 | "prepare": "husky" 16 | }, 17 | "mocha": { 18 | "timeout": "5000", 19 | "recursive": "true", 20 | "reporter": "mocha-multi-reporters", 21 | "reporter-options": "configFile=.mocha-multi.json" 22 | }, 23 | "engines": { 24 | "node": ">=14.16" 25 | }, 26 | "types": "src/index.d.ts", 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/adobe/fetch.git" 30 | }, 31 | "author": "", 32 | "license": "Apache-2.0", 33 | "bugs": { 34 | "url": "https://github.com/adobe/fetch/issues" 35 | }, 36 | "homepage": "https://github.com/adobe/fetch#readme", 37 | "keywords": [ 38 | "fetch", 39 | "whatwg", 40 | "Fetch API", 41 | "http", 42 | "https", 43 | "http2", 44 | "h2", 45 | "promise", 46 | "async", 47 | "request", 48 | "RFC 7234", 49 | "7234", 50 | "caching", 51 | "cache" 52 | ], 53 | "dependencies": { 54 | "debug": "4.4.3", 55 | "http-cache-semantics": "4.2.0", 56 | "lru-cache": "7.18.3" 57 | }, 58 | "devDependencies": { 59 | "@semantic-release/changelog": "6.0.3", 60 | "@semantic-release/git": "10.0.1", 61 | "@semantic-release/npm": "13.1.3", 62 | "c8": "10.1.3", 63 | "chai": "6.2.1", 64 | "eslint": "8.57.1", 65 | "eslint-config-airbnb-base": "15.0.0", 66 | "eslint-plugin-header": "3.1.1", 67 | "eslint-plugin-import": "2.32.0", 68 | "formdata-node": "6.0.3", 69 | "husky": "9.1.7", 70 | "lint-staged": "16.2.7", 71 | "mocha": "11.7.5", 72 | "mocha-multi-reporters": "1.5.1", 73 | "nock": "13.5.6", 74 | "parse-cache-control": "1.0.1", 75 | "parse-multipart-data": "1.5.0", 76 | "semantic-release": "25.0.2", 77 | "sinon": "21.0.0", 78 | "stream-buffers": "3.0.3" 79 | }, 80 | "lint-staged": { 81 | "*.js": "eslint" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/keys.json: -------------------------------------------------------------------------------- 1 | {"cert":"-----BEGIN CERTIFICATE-----\nMIICpDCCAYwCCQCSYPsUCKH+wzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls\nb2NhbGhvc3QwHhcNMjIxMTA4MTMyMDIzWhcNMjMxMTA4MTMyMDIzWjAUMRIwEAYD\nVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDm\nKWgvh/WI2PEL4jUXwba77Ugj5vh8vRvAqcMk0bKJEnnXqbtsyp021UZfKtdVANov\ncJ71UwN3fqNzjCfai385PM/bS24Uny2e3+oNvqQE3HmX46jUO0AcFYlXGmFn8rGd\ntzQCixLubof8IK0NmOO/OTfzWhOL+E3wLkeT15Bsn9qBgfRAZPlXVkboteduRmyj\naRGw37WrzmUEWnK69uX8OUs4jqVEdX5UJw3MAfljFplBOnOW0uC9fmvWd7v8pPT1\nmDBwjVbDHmxkKLSFm4pwoJ+lFlrJpnL4U+sh56/+7+sDqiJyyrtDjHOCBMgmqsmk\nGUm281XeUVx0+heESJilAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAOBnEQWYsdpJ\nM2LRWNdF82hB1EkNX/NUBb0uqezBTOvPRmJNjZIbXLSbkYOJlm0vrZ1wLpwJYm2j\nmdiSEvCBrSTRc9+lbsUmZ/OTNlOzThxG7QontBMImpLjSNPTZN3OMb2MQMqen4ty\nrdYdfNxQg5EHUz6lnDzaijPMar8SprKBmjdv2Tdh5B/VxpORGNKam+tTLIqVrLZP\nj1SmVrPPxTC3QJfZTSyzq9W2xBdImj/9xpPinaQPh7SF1PFBL6zYffE2r/oSec6n\ni6itk4YldhZUvhd4WgeaIXOYrBjeQhouKqH/rRYi5iW5NAdf1Vxzk2tTlVYltBie\nksdvxCLDEGU=\n-----END CERTIFICATE-----","key":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA5iloL4f1iNjxC+I1F8G2u+1II+b4fL0bwKnDJNGyiRJ516m7\nbMqdNtVGXyrXVQDaL3Ce9VMDd36jc4wn2ot/OTzP20tuFJ8tnt/qDb6kBNx5l+Oo\n1DtAHBWJVxphZ/Kxnbc0AosS7m6H/CCtDZjjvzk381oTi/hN8C5Hk9eQbJ/agYH0\nQGT5V1ZG6LXnbkZso2kRsN+1q85lBFpyuvbl/DlLOI6lRHV+VCcNzAH5YxaZQTpz\nltLgvX5r1ne7/KT09ZgwcI1Wwx5sZCi0hZuKcKCfpRZayaZy+FPrIeev/u/rA6oi\ncsq7Q4xzggTIJqrJpBlJtvNV3lFcdPoXhEiYpQIDAQABAoIBABysyfsqADA1fwqQ\n+zZjfwW40UUP0KOR1/RP8i0TJyAmuiFhVyV9tMqRFbxjlQJVIdnJOTStTABGOcz6\niDiVKpuR9P+ITUWqcz+Y/AsMVlj4svl2dqFCZIFbOYotkjcmKHTSz1sFwq0L/ksu\nMwE/rBg147+g2FCW9fbXfTlxIOA3abwzMIY5mZZfh0pyZr/L/vqfwJYcNgkmsf/N\nTQd3rZf6iXST7SrG3vUW791JiZsRd+6ANQTt8ih+m4bq/JrOOLb5aXoeBrGhlHMB\nK7bHfMak675TpYgKF6v1I6n5SauX4WjqISubtPUjue3dg5WFvStgLtUbvCdVjaBW\nowsgut0CgYEA+LCGYjYaMCLWKyg5X+FpE/wSMjjoBI4g12T4sPSpIN3fJZudOVQB\nYgHHPAkk6lWD1+7eCaf5C95ICPWrfJZC0vgK/zg40LHdEGxFY62UM1o6Du9VWHGk\naG24Jk/LlSwZxCbcAEeJqmJRm2369CmAEJ3uZJRLoe3DJoGXJKeNOvMCgYEA7O10\nMgX2I0i4Pc1p5kMxOTfa9TtuO/fv3F3YDvFEXLD802aokIGuV5Swnk2m8kBtEYZH\nG8vUj7lYT1QZbFX1qk8A5z2tAjzD4XPW8KdEllKz3tWw1dT2exktkt8Pavdommfv\nNWWTQbOm6rcHzrBnXqkBeISt3wxgB0zkXQArFAcCgYEAg3Q79JoIib1AXButCEOM\nWaX6sFVoP1Aph/G1i0QoSlk9ZRG2r+D5wiaSe+eRfdSqFnALKiuB94YNqb7CYN7E\nf+PhXDsJvTbnVZEFtMTB+8sLuW1FPbUEZLbqikXPpRBkpkysKfGmkUvbOz+NUZbG\nPQJeSwggWn3lk+sYS6XNf4UCgYEAz9NOfF6sVLECI/wea4P/FpC/OSPDg0juFuAe\nmKTb8W6yp4FCVVfbpSlhb8rvqUoIIjol/+Tg9J2BMDy1/Ei+sICSa0S3kOHadNXW\n8cvXpwCulMXfUwJ2nu4sAsw1Sv/wuph2xODvtc8vmG2qpIwqcGdRSfUgSyogxeaV\n3lo6b1sCgYBX2hZjtyfKvXq/ApT78oXnrb19wuB2ZMRheK/SKVHygwqOUeRXH6Ig\nXdLQO4LpbjCRHHhYi4ElA0mPfgZyC6dVrH7wRf5fVXHlATXtgeCpmU7ep0WLWsFH\nhLMGFH75fuatF2ctW6eimP07SvUohrVYuNIsHiAuHQ+0/rz9dMygfg==\n-----END RSA PRIVATE KEY-----"} -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import debugFactory from 'debug'; 14 | 15 | import { 16 | request, 17 | setupContext, 18 | resetContext, 19 | RequestAbortedError, 20 | ALPN_HTTP2, 21 | ALPN_HTTP2C, 22 | ALPN_HTTP1_1, 23 | ALPN_HTTP1_0, 24 | } from './request.js'; 25 | 26 | const debug = debugFactory('adobe/fetch:core'); 27 | 28 | class RequestContext { 29 | constructor(options) { 30 | // setup context 31 | this.options = { ...(options || {}) }; 32 | setupContext(this); 33 | } 34 | 35 | /** 36 | * Returns the core API. 37 | */ 38 | api() { 39 | return { 40 | /** 41 | * Requests a resource from the network. Returns a Promise which resolves once 42 | * the response is available. 43 | * 44 | * @param {string} url 45 | * @param {Object} options 46 | * 47 | * @throws RequestAbortedError if the request is aborted via an AbortSignal 48 | */ 49 | request: async (url, options) => this.request(url, options), 50 | 51 | /** 52 | * This function returns an object which looks like the global `@adobe/fetch` API, 53 | * i.e. it will have the functions `request`, `reset`, etc. and provide its 54 | * own isolated caches. 55 | * 56 | * @param {Object} options 57 | */ 58 | context: (options = {}) => new RequestContext(options).api(), 59 | 60 | /** 61 | * Resets the current context, i.e. disconnects all open/pending sessions, clears caches etc.. 62 | */ 63 | reset: async () => this.reset(), 64 | 65 | /** 66 | * Error thrown if a request is aborted via an AbortSignal. 67 | */ 68 | RequestAbortedError, 69 | 70 | ALPN_HTTP2, 71 | ALPN_HTTP2C, 72 | ALPN_HTTP1_1, 73 | ALPN_HTTP1_0, 74 | }; 75 | } 76 | 77 | async request(url, options) { 78 | return request(this, url, options); 79 | } 80 | 81 | async reset() { 82 | debug('resetting context'); 83 | return resetContext(this); 84 | } 85 | } 86 | 87 | export default new RequestContext().api(); 88 | -------------------------------------------------------------------------------- /test/common/formData.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable no-underscore-dangle */ 15 | 16 | import assert from 'assert'; 17 | import { fileURLToPath } from 'url'; 18 | 19 | import { FormData, File, Blob } from 'formdata-node'; 20 | // eslint-disable-next-line import/no-unresolved 21 | import { fileFromPathSync } from 'formdata-node/file-from-path'; 22 | 23 | import { isReadableStream } from '../utils.js'; 24 | import { streamToBuffer } from '../../src/common/utils.js'; 25 | import { isFormData, FormDataSerializer } from '../../src/common/formData.js'; 26 | 27 | // Workaround for ES6 which doesn't support the NodeJS global __filename 28 | const __filename = fileURLToPath(import.meta.url); 29 | 30 | describe('FormData Helpers Test', () => { 31 | it('isFormData works', () => { 32 | assert(!isFormData()); 33 | assert(!isFormData(null)); 34 | assert(!isFormData({ foo: 'bar' })); 35 | assert(!isFormData('form=data')); 36 | assert(!isFormData(new URLSearchParams({ foo: 'bar' }))); 37 | // spec-compliant FormData implementation 38 | const fd = new FormData(); 39 | fd.set('foo', 'bar'); 40 | assert(isFormData(fd)); 41 | }); 42 | 43 | it('FormDataSerializer works', async () => { 44 | // spec-compliant FormData implementation 45 | const fd = new FormData(); 46 | fd.set('field1', 'foo'); 47 | fd.set('field2', 'bar'); 48 | fd.set('blob', new Blob([0x68, 0x65, 0x6c, 0x69, 0x78, 0x2d, 0x66, 0x65, 0x74, 0x63, 0x68])); 49 | fd.set('file', new File(['File content goes here'], 'file.txt')); 50 | fd.set('other_file', fileFromPathSync(__filename, 'source.js', { type: 'application/javascript' })); 51 | fd.set('file', fileFromPathSync(__filename)); 52 | const fds = new FormDataSerializer(fd); 53 | const stream = fds.stream(); 54 | assert(isReadableStream(stream)); 55 | assert(typeof fds.length() === 'number'); 56 | const buf = await streamToBuffer(fds.stream()); 57 | assert.strictEqual(fds.length(), buf.length); 58 | assert(fds.contentType().startsWith('multipart/form-data; boundary=')); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/fetch/misc.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable guard-for-in */ 15 | /* eslint-disable no-restricted-syntax */ 16 | 17 | import assert from 'assert'; 18 | 19 | import { createUrl, timeoutSignal } from '../../src/index.js'; 20 | 21 | describe('Misc. Tests', () => { 22 | it('createUrl encodes query paramters', async () => { 23 | const EXPECTED = 'https://example.com/test?foo=42&dummy=true&name=Andr%C3%A9+Citro%C3%ABn&rumple=stiltskin&nephews=Huey&nephews=Louie&nephews=Dewey'; 24 | const qs = { 25 | foo: 42, 26 | dummy: true, 27 | name: 'André Citroën', 28 | rumple: 'stiltskin', 29 | nephews: ['Huey', 'Louie', 'Dewey'], 30 | }; 31 | const ACTUAL = createUrl('https://example.com/test', qs); 32 | assert.strictEqual(ACTUAL, EXPECTED); 33 | }); 34 | 35 | it('createUrl works without qs object', async () => { 36 | const EXPECTED = 'https://example.com/test'; 37 | const ACTUAL = createUrl('https://example.com/test'); 38 | assert.strictEqual(ACTUAL, EXPECTED); 39 | }); 40 | 41 | it('createUrl checks arguments types', async () => { 42 | assert.throws(() => createUrl(true)); 43 | assert.throws(() => createUrl('https://example.com/test', 'abc')); 44 | assert.throws(() => createUrl('https://example.com/test', 123)); 45 | assert.throws(() => createUrl('https://example.com/test', ['foo', 'bar'])); 46 | }); 47 | 48 | it('timeoutSignal works', async () => { 49 | const fired = async (signal) => new Promise((resolve) => { 50 | signal.addEventListener('abort', resolve); 51 | }); 52 | 53 | const ts0 = Date.now(); 54 | await fired(timeoutSignal(500)); 55 | const ts1 = Date.now(); 56 | assert((ts1 - ts0) < 500 * 1.05); 57 | }); 58 | 59 | it('timeoutSignal can be cleared', async () => { 60 | const signal = timeoutSignal(30000); 61 | // if the timeout is not cleared the node process will hang for 30s on exit. 62 | signal.clear(); 63 | }); 64 | 65 | it('timeoutSignal expects integer argument', async () => { 66 | assert.throws(() => timeoutSignal('test'), TypeError); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | module.exports = { 13 | root: true, 14 | env: { 15 | node: true, 16 | es6: true, 17 | }, 18 | parserOptions: { 19 | sourceType: 'module', 20 | ecmaVersion: 2022, 21 | }, 22 | extends: [ 23 | 'eslint-config-airbnb-base', 24 | ].map(require.resolve), 25 | plugins: [ 26 | 'header', 27 | ], 28 | rules: { 29 | strict: 0, 30 | 31 | // Forbid multiple statements in one line 32 | 'max-statements-per-line': ['error', { max: 1 }], 33 | 34 | // Allow for-of loops 35 | 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], 36 | 37 | // Allow return before else & redundant else statements 38 | 'no-else-return': 'off', 39 | 40 | // allow dangling underscores for 'fields' 41 | 'no-underscore-dangle': ['error', { 42 | allowAfterThis: true, 43 | allow: [ 44 | '__ow_method', 45 | '__ow_headers', 46 | '__ow_path', 47 | '__ow_user', 48 | '__ow_body', 49 | '__ow_query'], 50 | }], 51 | 52 | // allow '_' as a throw-away variable 53 | 'no-unused-vars': ['error', { 54 | argsIgnorePattern: '^_$', 55 | }], 56 | 57 | 'no-shadow': ['error', { 58 | allow: ['_'], 59 | }], 60 | 61 | // don't enforce extension rules 62 | 'import/extensions': 0, 63 | 64 | // enforce license header 65 | 'header/header': [2, 'block', ['', 66 | { pattern: ' * Copyright \\d{4} Adobe\\. All rights reserved\\.', template: ' * Copyright 2022 Adobe. All rights reserved.' }, 67 | ' * This file is licensed to you under the Apache License, Version 2.0 (the "License");', 68 | ' * you may not use this file except in compliance with the License. You may obtain a copy', 69 | ' * of the License at http://www.apache.org/licenses/LICENSE-2.0', 70 | ' *', 71 | ' * Unless required by applicable law or agreed to in writing, software distributed under', 72 | ' * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS', 73 | ' * OF ANY KIND, either express or implied. See the License for the specific language', 74 | ' * governing permissions and limitations under the License.', 75 | ' ', 76 | ]], 77 | 78 | 'id-match': ['error', '^(?!.*?([wW][hH][iI][tT][eE]|[bB][lL][aA][cC][kK]).*[lL][iI][sS][tT]).*$', { 79 | properties: true, 80 | }], 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /test/fetch/redirect.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | 17 | import Server from '../server.js'; 18 | import { context } from '../../src/index.js'; 19 | 20 | describe('Redirect-specific Fetch Tests', () => { 21 | it('connection error in redirected http/1.1 location is handled correctly', async () => { 22 | // start http/1.1 server 23 | const server = await Server.launch(1); 24 | 25 | const ctx = context({ rejectUnauthorized: false }); 26 | 27 | try { 28 | // redirected request works 29 | let location = `${server.origin}/hello`; 30 | let url = `${server.origin}/redirect-to?url=${encodeURIComponent(location)}&status_code=302`; 31 | const resp = await ctx.fetch(url, { cache: 'no-store' }); 32 | assert.strictEqual(resp.status, 200); 33 | assert.strictEqual(resp.httpVersion, '1.1'); 34 | assert.strictEqual(resp.redirected, true); 35 | 36 | // redirected request is aborted 37 | location = `${server.origin}/abort`; 38 | url = `${server.origin}/redirect-to?url=${encodeURIComponent(location)}&status_code=302`; 39 | await assert.rejects(async () => ctx.fetch(url, { cache: 'no-store' }), { name: 'FetchError', code: 'ECONNRESET' }); 40 | } finally { 41 | await ctx.reset(); 42 | // shutdown server 43 | try { 44 | process.kill(server.pid); 45 | } catch (ignore) { /* ignore */ } 46 | } 47 | }); 48 | 49 | it('connection error in redirected http/2 location is handled correctly', async () => { 50 | // start http/2 server 51 | const server = await Server.launch(2); 52 | 53 | const ctx = context({ rejectUnauthorized: false }); 54 | 55 | try { 56 | // redirected request works 57 | let location = `${server.origin}/hello`; 58 | let url = `${server.origin}/redirect-to?url=${encodeURIComponent(location)}&status_code=302`; 59 | const resp = await ctx.fetch(url, { cache: 'no-store' }); 60 | assert.strictEqual(resp.status, 200); 61 | assert.strictEqual(resp.httpVersion, '2.0'); 62 | assert.strictEqual(resp.redirected, true); 63 | 64 | // redirected request is aborted 65 | location = `${server.origin}/abort`; 66 | url = `${server.origin}/redirect-to?url=${encodeURIComponent(location)}&status_code=302`; 67 | await assert.rejects(async () => ctx.fetch(url, { cache: 'no-store' }), { name: 'FetchError', code: 'ERR_HTTP2_SESSION_ERROR' }); 68 | } finally { 69 | await ctx.reset(); 70 | // shutdown server 71 | try { 72 | process.kill(server.pid); 73 | } catch (ignore) { /* ignore */ } 74 | } 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | export * from './api'; 14 | import * as api from './api.d'; 15 | import { ContextOptions } from './api'; 16 | 17 | declare type FetchAPI = typeof api; 18 | 19 | /** 20 | * This function returns an object which looks like the public API, 21 | * i.e. it will have the functions `fetch`, `context`, `reset`, etc. and provide its 22 | * own isolated caches and specific behavior according to `options`. 23 | * 24 | * @param {ContextOptions} options 25 | */ 26 | export declare function context(options?: ContextOptions): FetchAPI; 27 | 28 | /** 29 | * Convenience function which creates a new context with disabled caching, 30 | * the equivalent of `context({ maxCacheSize: 0 })`. 31 | * 32 | * The optional `options` parameter allows to specify further options. 33 | * 34 | * @param {ContextOptions} options 35 | */ 36 | export declare function noCache(options?: ContextOptions): FetchAPI; 37 | 38 | /** 39 | * Convenience function which creates a new context with enforced HTTP/1.1 protocol 40 | * and disabled persistent connections (keep-alive), the equivalent of 41 | * `context({ alpnProtocols: [ALPN_HTTP1_1], h1: { keepAlive: false } })`. 42 | * 43 | * The optional `options` parameter allows to specify further options. 44 | * 45 | * @param {ContextOptions} options 46 | */ 47 | export declare function h1(options?: ContextOptions): FetchAPI; 48 | 49 | /** 50 | * Convenience function which creates a new context with enforced HTTP/1.1 protocol 51 | * with persistent connections (keep-alive), the equivalent of 52 | * `context({ alpnProtocols: [ALPN_HTTP1_1], h1: { keepAlive: true } })`. 53 | * 54 | * The optional `options` parameter allows to specify further options. 55 | * 56 | * @param {ContextOptions} options 57 | */ 58 | export declare function keepAlive(options?: ContextOptions): FetchAPI; 59 | 60 | /** 61 | * Convenience function which creates a new context with disabled caching, 62 | * and enforced HTTP/1.1 protocol with disabled persistent connections (keep-alive), 63 | * a combination of `h1()` and `noCache()`. 64 | * 65 | * The optional `options` parameter allows to specify further options. 66 | * 67 | * @param {ContextOptions} options 68 | */ 69 | export declare function h1NoCache(options?: ContextOptions): FetchAPI; 70 | 71 | /** 72 | * Convenience function which creates a new context with disabled caching 73 | * and enforced HTTP/1.1 protocol with persistent connections (keep-alive), 74 | * a combination of `keepAlive()` and `noCache()`. 75 | * 76 | * The optional `options` parameter allows to specify further options. 77 | * 78 | * @param {ContextOptions} options 79 | */ 80 | export declare function keepAliveNoCache(options?: ContextOptions): FetchAPI; 81 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `@adobe/fetch` 2 | 3 | This project is an Open Development project and welcomes contributions from everyone who finds it useful or lacking. 4 | 5 | ## Code Of Conduct 6 | 7 | This project adheres to the Adobe [code of conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to cstaub at adobe dot com. 8 | 9 | ## Contributor License Agreement 10 | 11 | All third-party contributions to this project must be accompanied by a signed contributor license. This gives Adobe permission to redistribute your contributions as part of the project. [Sign our CLA](http://opensource.adobe.com/cla.html)! You only need to submit an Adobe CLA one time, so if you have submitted one previously, you are good to go! 12 | 13 | ## Things to Keep in Mind 14 | 15 | This project uses a **commit then review** process, which means that for approved maintainers, changes can be merged immediately, but will be reviewed by others. 16 | 17 | For other contributors, a maintainer of the project has to approve the pull request. 18 | 19 | # Before You Contribute 20 | 21 | * Check that there is an existing issue in GitHub issues 22 | * Check if there are other pull requests that might overlap or conflict with your intended contribution 23 | 24 | # How to Contribute 25 | 26 | 1. Fork the repository 27 | 2. Make some changes on a branch on your fork 28 | 3. Create a pull request from your branch 29 | 30 | In your pull request, outline: 31 | 32 | * What the changes intend 33 | * How they change the existing code 34 | * If (and what) they breaks 35 | * Start the pull request with the GitHub issue ID, e.g. #123 36 | 37 | Lastly, please follow the [pull request template](.github/pull_request_template.md) when submitting a pull request! 38 | 39 | Each commit message that is not part of a pull request: 40 | 41 | * Should contain the issue ID like `#123` 42 | * Can contain the tag `[trivial]` for trivial changes that don't relate to an issue 43 | 44 | 45 | 46 | ## Coding Styleguides 47 | 48 | We enforce a coding styleguide using `eslint`. As part of your build, run `npm run lint` to check if your code is conforming to the style guide. We do the same for every PR in our CI, so PRs will get rejected if they don't follow the style guide. 49 | 50 | You can fix some of the issues automatically by running `npx eslint . --fix`. 51 | 52 | ## Commit Message Format 53 | 54 | This project uses a structured commit changelog format that should be used for every commit. Use `npm run commit` instead of your usual `git commit` to generate commit messages using a wizard. 55 | 56 | ```bash 57 | # either add all changed files 58 | $ git add -A 59 | # or selectively add files 60 | $ git add package.json 61 | # then commit using the wizard 62 | $ npm run commit 63 | ``` 64 | 65 | # How Contributions get Reviewed 66 | 67 | One of the maintainers will look at the pull request within one week. Feedback on the pull request will be given in writing, in GitHub. 68 | 69 | # Release Management 70 | 71 | The project's committers will release to the [Adobe organization on npmjs.org](https://www.npmjs.com/org/adobe). 72 | Please contact the [Adobe Open Source Advisory Board](https://git.corp.adobe.com/OpenSourceAdvisoryBoard/discuss/issues) to get access to the npmjs organization. 73 | 74 | The release process is fully automated using `semantic-release`, increasing the version numbers, etc. based on the contents of the commit messages found. 75 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Adobe Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at Grp-opensourceoffice@adobe.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /src/fetch/policy.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import CachePolicy from 'http-cache-semantics'; 14 | 15 | import Headers from './headers.js'; 16 | 17 | /** 18 | * 19 | * @param {Request} req 20 | * @returns {Object} 21 | */ 22 | const convertRequest = (req) => ({ 23 | url: req.url, 24 | method: req.method, 25 | headers: req.headers.plain(), 26 | }); 27 | 28 | /** 29 | * 30 | * @param {Response} res 31 | * @returns {Object} 32 | */ 33 | const convertResponse = (res) => ({ 34 | status: res.status, 35 | headers: res.headers.plain(), 36 | }); 37 | 38 | /** 39 | * Wrapper for CachePolicy, supporting Request and Response argument types 40 | * as specified by the Fetch API. 41 | * 42 | * @class 43 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API 44 | * @see https://github.com/kornelski/http-cache-semantics 45 | */ 46 | class CachePolicyWrapper { 47 | /** 48 | * Creates a new CachePolicyWrapper instance. 49 | * 50 | * @see https://github.com/kornelski/http-cache-semantics#constructor-options 51 | * 52 | * @constructor 53 | * @param {Request} req 54 | * @param {Response} res 55 | * @param {Object} options 56 | */ 57 | constructor(req, res, options) { 58 | this.policy = new CachePolicy(convertRequest(req), convertResponse(res), options); 59 | } 60 | 61 | /** 62 | * @see https://github.com/kornelski/http-cache-semantics#storable 63 | */ 64 | storable() { 65 | return this.policy.storable(); 66 | } 67 | 68 | /** 69 | * @see https://github.com/kornelski/http-cache-semantics#satisfieswithoutrevalidationnewrequest 70 | * 71 | * @param {Request} req 72 | * @returns boolean 73 | */ 74 | satisfiesWithoutRevalidation(req) { 75 | return this.policy.satisfiesWithoutRevalidation(convertRequest(req)); 76 | } 77 | 78 | /** 79 | * @see https://github.com/kornelski/http-cache-semantics#responseheaders 80 | * 81 | * @param {Response} res 82 | * @returns {Headers} 83 | */ 84 | responseHeaders(res) { 85 | return new Headers(this.policy.responseHeaders(convertResponse(res))); 86 | } 87 | 88 | /** 89 | * @see https://github.com/kornelski/http-cache-semantics#timetolive 90 | */ 91 | timeToLive() { 92 | return this.policy.timeToLive(); 93 | } 94 | /* 95 | age() { 96 | return this.policy.age(); 97 | } 98 | 99 | maxAge() { 100 | return this.policy.maxAge(); 101 | } 102 | 103 | stale() { 104 | return this.policy.stale(); 105 | } 106 | 107 | revalidationHeaders(incomingReq) { 108 | return this.policy.revalidationHeaders(convertRequest(incomingReq)); 109 | } 110 | 111 | revalidatedPolicy(request, response) { 112 | return this.policy.revalidatedPolicy(convertRequest(request), convertResponse(response)); 113 | } 114 | */ 115 | } 116 | 117 | export default CachePolicyWrapper; 118 | -------------------------------------------------------------------------------- /src/common/formData.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { randomBytes } from 'crypto'; 14 | import { Readable } from 'stream'; 15 | 16 | // Misc. helper functions for dealing with spec-compliant FormData objects 17 | 18 | const isBlob = (obj) => (typeof obj === 'object' 19 | && [ 20 | 'arrayBuffer', 21 | 'stream', 22 | 'text', 23 | 'slice', 24 | 'constructor', 25 | ] 26 | .map((nm) => typeof obj[nm]) 27 | .filter((type) => type !== 'function') 28 | .length === 0 29 | && typeof obj.type === 'string' 30 | && typeof obj.size === 'number' 31 | && /^(Blob|File)$/.test(obj[Symbol.toStringTag])); 32 | 33 | const isFormData = (obj) => (obj != null // neither null nor undefined 34 | && typeof obj === 'object' 35 | && [ 36 | 'append', 37 | 'delete', 38 | 'get', 39 | 'getAll', 40 | 'has', 41 | 'set', 42 | 'keys', 43 | 'values', 44 | 'entries', 45 | 'constructor', 46 | ] 47 | .map((nm) => typeof obj[nm]) 48 | .filter((type) => type !== 'function') 49 | .length === 0 50 | && obj[Symbol.toStringTag] === 'FormData'); 51 | 52 | const getFooter = (boundary) => `--${boundary}--\r\n\r\n`; 53 | 54 | const getHeader = (boundary, name, field) => { 55 | let header = ''; 56 | 57 | header += `--${boundary}\r\n`; 58 | header += `Content-Disposition: form-data; name="${name}"`; 59 | 60 | if (isBlob(field)) { 61 | header += `; filename="${field.name}"\r\n`; 62 | header += `Content-Type: ${field.type || 'application/octet-stream'}`; 63 | } 64 | 65 | return `${header}\r\n\r\n`; 66 | }; 67 | 68 | /** 69 | * @param {FormData} form 70 | * @param {string} boundary 71 | * 72 | * @returns {string} 73 | */ 74 | async function* formDataIterator(form, boundary) { 75 | for (const [name, value] of form) { 76 | yield getHeader(boundary, name, value); 77 | 78 | if (isBlob(value)) { 79 | yield* value.stream(); 80 | } else { 81 | yield value; 82 | } 83 | 84 | yield '\r\n'; 85 | } 86 | 87 | yield getFooter(boundary); 88 | } 89 | 90 | /** 91 | * @param {FormData} form 92 | * @param {string} boundary 93 | * 94 | * @returns {number} 95 | */ 96 | const getFormDataLength = (form, boundary) => { 97 | let length = 0; 98 | 99 | for (const [name, value] of form) { 100 | length += Buffer.byteLength(getHeader(boundary, name, value)); 101 | length += isBlob(value) ? value.size : Buffer.byteLength(String(value)); 102 | length += Buffer.byteLength('\r\n'); 103 | } 104 | length += Buffer.byteLength(getFooter(boundary)); 105 | 106 | return length; 107 | }; 108 | 109 | class FormDataSerializer { 110 | constructor(formData) { 111 | this.fd = formData; 112 | this.boundary = randomBytes(8).toString('hex'); 113 | } 114 | 115 | length() { 116 | if (typeof this._length === 'undefined') { 117 | this._length = getFormDataLength(this.fd, this.boundary); 118 | } 119 | return this._length; 120 | } 121 | 122 | contentType() { 123 | return `multipart/form-data; boundary=${this.boundary}`; 124 | } 125 | 126 | stream() { 127 | return Readable.from(formDataIterator(this.fd, this.boundary)); 128 | } 129 | } 130 | 131 | export { 132 | isFormData, FormDataSerializer, 133 | }; 134 | -------------------------------------------------------------------------------- /test/core/misc.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | import { finished } from 'stream'; 17 | import { promisify } from 'util'; 18 | 19 | import { WritableStreamBuffer } from 'stream-buffers'; 20 | 21 | import { AbortController } from '../../src/fetch/abort.js'; 22 | import { RequestAbortedError } from '../../src/core/errors.js'; 23 | import core from '../../src/core/index.js'; 24 | import Server from '../server.js'; 25 | 26 | const { context, ALPN_HTTP1_1 } = core; 27 | 28 | const streamFinished = promisify(finished); 29 | 30 | const readStream = async (stream) => { 31 | const out = new WritableStreamBuffer(); 32 | stream.pipe(out); 33 | return streamFinished(out).then(() => out.getContents()); 34 | }; 35 | 36 | const WOKEUP = 'woke up!'; 37 | const sleep = (ms) => new Promise((resolve) => { 38 | setTimeout(resolve, ms, WOKEUP); 39 | }); 40 | 41 | describe('Misc. Core Tests (edge cases to improve code coverage)', () => { 42 | it('AbortController works (premature abort) (code coverage, HTTP/1.1)', async () => { 43 | const controller = new AbortController(); 44 | setTimeout(() => controller.abort(), 0); 45 | const { signal } = controller; 46 | 47 | // make sure signal has fired 48 | await sleep(10); 49 | assert(signal.aborted); 50 | 51 | // force HTTP/1.1 52 | const customCtx = context({ alpnProtocols: [ALPN_HTTP1_1], rejectUnauthorized: false }); 53 | 54 | const server = await Server.launch(1); 55 | 56 | let ts0; 57 | try { 58 | // first prime alpn cache 59 | await customCtx.request(`${server.origin}/status/200`); 60 | // now send request with signal 61 | ts0 = Date.now(); 62 | await customCtx.request(`${server.origin}/status/200`, { signal }); 63 | assert.fail(); 64 | } catch (err) { 65 | assert(err instanceof RequestAbortedError); 66 | } finally { 67 | await customCtx.reset(); 68 | try { 69 | process.kill(server.pid); 70 | } catch (ignore) { /* ignore */ } 71 | } 72 | const ts1 = Date.now(); 73 | assert((ts1 - ts0) < 10); 74 | }); 75 | 76 | it('supports text body (code coverage, HTTP/1.1)', async () => { 77 | const method = 'POST'; 78 | const body = 'Hello, World!'; 79 | 80 | // force HTTP/1.1 81 | const customCtx = context({ alpnProtocols: [ALPN_HTTP1_1], rejectUnauthorized: false }); 82 | 83 | const server = await Server.launch(1); 84 | 85 | try { 86 | const resp = await customCtx.request(`${server.origin}/inspect`, { method, body }); 87 | assert.strictEqual(resp.statusCode, 200); 88 | assert.strictEqual(resp.httpVersionMajor, 1); 89 | assert.strictEqual(resp.headers['content-type'], 'application/json'); 90 | const buf = await readStream(resp.readable); 91 | const json = JSON.parse(buf); 92 | assert(typeof json === 'object'); 93 | assert.strictEqual(json.headers['content-type'], 'text/plain; charset=utf-8'); 94 | assert.strictEqual(+json.headers['content-length'], body.length); 95 | assert.strictEqual(json.body, body); 96 | } finally { 97 | await customCtx.reset(); 98 | try { 99 | process.kill(server.pid); 100 | } catch (ignore) { /* ignore */ } 101 | } 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/fetch/cacheableResponse.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable max-classes-per-file */ 14 | 15 | import { Readable } from 'stream'; 16 | 17 | import Headers from './headers.js'; 18 | import Response from './response.js'; 19 | 20 | const INTERNALS = Symbol('CacheableResponse internals'); 21 | 22 | /** 23 | * Convert a NodeJS Buffer to an ArrayBuffer 24 | * 25 | * @see https://stackoverflow.com/a/31394257 26 | * 27 | * @param {Buffer} buf 28 | * @returns {ArrayBuffer} 29 | */ 30 | const toArrayBuffer = (buf) => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); 31 | 32 | /** 33 | * Wrapper for the Fetch API Response class, providing support for buffering 34 | * the body stream and thus allowing repeated reads of the body. 35 | * 36 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Response 37 | */ 38 | class CacheableResponse extends Response { 39 | /** 40 | * Constructs a new Response instance 41 | * 42 | * @constructor 43 | * @param {Buffer} body 44 | * @param {Object} [init] 45 | */ 46 | constructor(body, init) { 47 | super(body, init); 48 | 49 | const headers = new Headers(init.headers); 50 | 51 | this[INTERNALS] = { 52 | headers, 53 | bufferedBody: body, 54 | }; 55 | } 56 | 57 | get headers() { 58 | return this[INTERNALS].headers; 59 | } 60 | 61 | set headers(headers) { 62 | if (headers instanceof Headers) { 63 | this[INTERNALS].headers = headers; 64 | } else { 65 | throw new TypeError('instance of Headers expected'); 66 | } 67 | } 68 | 69 | get body() { 70 | return Readable.from(this[INTERNALS].bufferedBody); 71 | } 72 | 73 | // eslint-disable-next-line class-methods-use-this 74 | get bodyUsed() { 75 | return false; 76 | } 77 | 78 | async buffer() { 79 | return this[INTERNALS].bufferedBody; 80 | } 81 | 82 | async arrayBuffer() { 83 | return toArrayBuffer(this[INTERNALS].bufferedBody); 84 | } 85 | 86 | async text() { 87 | return this[INTERNALS].bufferedBody.toString(); 88 | } 89 | 90 | async json() { 91 | return JSON.parse(await this.text()); 92 | } 93 | 94 | clone() { 95 | const { 96 | url, status, statusText, headers, httpVersion, decoded, counter, 97 | } = this; 98 | return new CacheableResponse( 99 | this[INTERNALS].bufferedBody, 100 | { 101 | url, status, statusText, headers, httpVersion, decoded, counter, 102 | }, 103 | ); 104 | } 105 | 106 | get [Symbol.toStringTag]() { 107 | return this.constructor.name; 108 | } 109 | } 110 | 111 | /** 112 | * Creates a cacheable response. 113 | * 114 | * According to the Fetch API the body of a response can be read only once. 115 | * In order to allow caching we need to serialize the body into a buffer first. 116 | * 117 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Body 118 | * 119 | * @param {Response} res 120 | */ 121 | const cacheableResponse = async (res) => { 122 | const buf = await res.buffer(); 123 | const { 124 | url, status, statusText, headers, httpVersion, decoded, redirected, 125 | } = res; 126 | return new CacheableResponse( 127 | buf, 128 | { 129 | url, status, statusText, headers, httpVersion, decoded, counter: redirected ? 1 : 0, 130 | }, 131 | ); 132 | }; 133 | 134 | export default cacheableResponse; 135 | -------------------------------------------------------------------------------- /src/fetch/abort.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable max-classes-per-file */ 14 | 15 | import { EventEmitter } from 'events'; 16 | 17 | const SIGNAL_INTERNALS = Symbol('AbortSignal internals'); 18 | 19 | /** 20 | * The AbortSignal class. 21 | * 22 | * @see https://dom.spec.whatwg.org/#interface-AbortSignal 23 | */ 24 | class AbortSignal { 25 | constructor() { 26 | this[SIGNAL_INTERNALS] = { 27 | eventEmitter: new EventEmitter(), 28 | onabort: null, 29 | aborted: false, 30 | }; 31 | } 32 | 33 | get aborted() { 34 | return this[SIGNAL_INTERNALS].aborted; 35 | } 36 | 37 | get onabort() { 38 | return this[SIGNAL_INTERNALS].onabort; 39 | } 40 | 41 | set onabort(handler) { 42 | this[SIGNAL_INTERNALS].onabort = handler; 43 | } 44 | 45 | get [Symbol.toStringTag]() { 46 | return this.constructor.name; 47 | } 48 | 49 | removeEventListener(name, handler) { 50 | this[SIGNAL_INTERNALS].eventEmitter.removeListener(name, handler); 51 | } 52 | 53 | addEventListener(name, handler) { 54 | this[SIGNAL_INTERNALS].eventEmitter.on(name, handler); 55 | } 56 | 57 | dispatchEvent(type) { 58 | const event = { type, target: this }; 59 | const handlerName = `on${type}`; 60 | 61 | if (typeof this[SIGNAL_INTERNALS][handlerName] === 'function') { 62 | this[handlerName](event); 63 | } 64 | 65 | this[SIGNAL_INTERNALS].eventEmitter.emit(type, event); 66 | } 67 | 68 | fire() { 69 | this[SIGNAL_INTERNALS].aborted = true; 70 | this.dispatchEvent('abort'); 71 | } 72 | } 73 | 74 | Object.defineProperties(AbortSignal.prototype, { 75 | addEventListener: { enumerable: true }, 76 | removeEventListener: { enumerable: true }, 77 | dispatchEvent: { enumerable: true }, 78 | aborted: { enumerable: true }, 79 | onabort: { enumerable: true }, 80 | }); 81 | 82 | /** 83 | * The TimeoutSignal class. 84 | */ 85 | class TimeoutSignal extends AbortSignal { 86 | constructor(timeout) { 87 | if (!Number.isInteger(timeout)) { 88 | throw new TypeError(`Expected an integer, got ${typeof timeout}`); 89 | } 90 | super(); 91 | this[SIGNAL_INTERNALS].timerId = setTimeout(() => { 92 | this.fire(); 93 | }, timeout); 94 | } 95 | 96 | /** 97 | * Clear the timeout associated with this signal. 98 | */ 99 | clear() { 100 | clearTimeout(this[SIGNAL_INTERNALS].timerId); 101 | } 102 | } 103 | 104 | Object.defineProperties(TimeoutSignal.prototype, { 105 | clear: { enumerable: true }, 106 | }); 107 | 108 | const CONTROLLER_INTERNALS = Symbol('AbortController internals'); 109 | 110 | /** 111 | * The AbortController class. 112 | * 113 | * @see https://dom.spec.whatwg.org/#interface-abortcontroller 114 | */ 115 | class AbortController { 116 | constructor() { 117 | this[CONTROLLER_INTERNALS] = { 118 | signal: new AbortSignal(), 119 | }; 120 | } 121 | 122 | get signal() { 123 | return this[CONTROLLER_INTERNALS].signal; 124 | } 125 | 126 | get [Symbol.toStringTag]() { 127 | return this.constructor.name; 128 | } 129 | 130 | abort() { 131 | if (this[CONTROLLER_INTERNALS].signal.aborted) { 132 | return; 133 | } 134 | 135 | this[CONTROLLER_INTERNALS].signal.fire(); 136 | } 137 | } 138 | 139 | Object.defineProperties(AbortController.prototype, { 140 | signal: { enumerable: true }, 141 | abort: { enumerable: true }, 142 | }); 143 | 144 | export { AbortController, AbortSignal, TimeoutSignal }; 145 | -------------------------------------------------------------------------------- /test/fetch/resiliance.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | 17 | import Server from '../server.js'; 18 | import { context } from '../../src/index.js'; 19 | 20 | const HELLO_MSG = 'Hello, World!'; 21 | 22 | describe('Fetch Resiliance Tests', () => { 23 | it('handles server restart', async () => { 24 | // start server 25 | let server = await Server.launch(2, true, HELLO_MSG); 26 | 27 | const ctx = context({ rejectUnauthorized: false }); 28 | try { 29 | let resp = await ctx.fetch(`${server.origin}/hello`); 30 | assert.strictEqual(resp.status, 200); 31 | assert.strictEqual(resp.httpVersion, '2.0'); 32 | let body = await resp.text(); 33 | assert.strictEqual(body, HELLO_MSG); 34 | 35 | // restart server 36 | try { 37 | process.kill(server.pid); 38 | } catch (ignore) { /* ignore */ } 39 | server = await Server.launch(2, true, HELLO_MSG, server.port); 40 | 41 | resp = await ctx.fetch(`${server.origin}/hello`); 42 | assert.strictEqual(resp.status, 200); 43 | assert.strictEqual(resp.httpVersion, '2.0'); 44 | body = await resp.text(); 45 | assert.strictEqual(body, HELLO_MSG); 46 | } finally { 47 | await ctx.reset(); 48 | try { 49 | process.kill(server.pid); 50 | } catch (ignore) { /* ignore */ } 51 | } 52 | }); 53 | 54 | it('handles server protocol downgrade', async () => { 55 | // start h2 server 56 | let server = await Server.launch(2, true, HELLO_MSG); 57 | 58 | const ctx = context({ rejectUnauthorized: false }); 59 | try { 60 | let resp = await ctx.fetch(`${server.origin}/hello`); 61 | assert.strictEqual(resp.status, 200); 62 | assert.strictEqual(resp.httpVersion, '2.0'); 63 | let body = await resp.text(); 64 | assert.strictEqual(body, HELLO_MSG); 65 | 66 | // stop h2 server 67 | try { 68 | process.kill(server.pid); 69 | } catch (ignore) { /* ignore */ } 70 | // start h1 server 71 | server = await Server.launch(1, true, HELLO_MSG, server.port); 72 | // expect FetchError: Protocol error (message depends on node version) 73 | await assert.rejects(ctx.fetch(`${server.origin}/hello`), { name: 'FetchError' }); 74 | // the fetch context should have recovered by now, next request should succeed 75 | resp = await ctx.fetch(`${server.origin}/hello`); 76 | assert.strictEqual(resp.status, 200); 77 | assert.strictEqual(resp.httpVersion, '1.1'); 78 | body = await resp.text(); 79 | assert.strictEqual(body, HELLO_MSG); 80 | } finally { 81 | await ctx.reset(); 82 | try { 83 | process.kill(server.pid); 84 | } catch (ignore) { /* ignore */ } 85 | } 86 | }); 87 | 88 | it('handles aborted request', async () => { 89 | // start server 90 | const server = await Server.launch(2, true, HELLO_MSG); 91 | 92 | const ctx = context({ rejectUnauthorized: false }); 93 | try { 94 | let resp = await ctx.fetch(`${server.origin}/hello`); 95 | assert.strictEqual(resp.status, 200); 96 | assert.strictEqual(resp.httpVersion, '2.0'); 97 | let body = await resp.text(); 98 | assert.strictEqual(body, HELLO_MSG); 99 | 100 | // request is aborted by the server 101 | await assert.rejects(async () => ctx.fetch(`${server.origin}/abort`, { cache: 'no-store' }), { name: 'FetchError', code: 'ERR_HTTP2_SESSION_ERROR' }); 102 | 103 | // try again 104 | resp = await ctx.fetch(`${server.origin}/hello`); 105 | assert.strictEqual(resp.status, 200); 106 | assert.strictEqual(resp.httpVersion, '2.0'); 107 | body = await resp.text(); 108 | assert.strictEqual(body, HELLO_MSG); 109 | } finally { 110 | await ctx.reset(); 111 | try { 112 | process.kill(server.pid); 113 | } catch (ignore) { /* ignore */ } 114 | } 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/fetch/response.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { Body, cloneStream, guessContentType } from './body.js'; 14 | import Headers from './headers.js'; 15 | import { isPlainObject } from '../common/utils.js'; 16 | import { isFormData, FormDataSerializer } from '../common/formData.js'; 17 | 18 | const INTERNALS = Symbol('Response internals'); 19 | 20 | /** 21 | * Response class 22 | * 23 | * @see https://fetch.spec.whatwg.org/#response-class 24 | */ 25 | class Response extends Body { 26 | /** 27 | * Constructs a new Response instance 28 | * 29 | * @constructor 30 | * @param {Readable|Buffer|String|URLSearchParams} [body=null] (see https://fetch.spec.whatwg.org/#bodyinit-unions) 31 | * @param {Object} [init={}] 32 | */ 33 | constructor(body = null, init = {}) { 34 | const headers = new Headers(init.headers); 35 | 36 | let respBody = body; 37 | 38 | if (isFormData(respBody)) { 39 | // spec-compliant FormData 40 | if (!headers.has('content-type')) { 41 | const fd = new FormDataSerializer(respBody); 42 | respBody = fd.stream(); 43 | headers.set('content-type', fd.contentType()); 44 | if (!headers.has('transfer-encoding') 45 | && !headers.has('content-length')) { 46 | headers.set('content-length', fd.length()); 47 | } 48 | } 49 | } 50 | 51 | if (respBody !== null && !headers.has('content-type')) { 52 | if (isPlainObject(respBody)) { 53 | // non-spec extension: support plain js object body (JSON serialization) 54 | respBody = JSON.stringify(respBody); 55 | headers.set('content-type', 'application/json'); 56 | } else { 57 | const contentType = guessContentType(respBody); 58 | if (contentType) { 59 | headers.set('content-type', contentType); 60 | } 61 | } 62 | } 63 | 64 | // call Body constructor 65 | super(respBody); 66 | 67 | this[INTERNALS] = { 68 | url: init.url, 69 | status: init.status || 200, 70 | statusText: init.statusText || '', 71 | headers, 72 | httpVersion: init.httpVersion, 73 | decoded: init.decoded, 74 | counter: init.counter, 75 | }; 76 | } 77 | 78 | get url() { 79 | return this[INTERNALS].url || ''; 80 | } 81 | 82 | get status() { 83 | return this[INTERNALS].status; 84 | } 85 | 86 | get statusText() { 87 | return this[INTERNALS].statusText; 88 | } 89 | 90 | get ok() { 91 | return this[INTERNALS].status >= 200 && this[INTERNALS].status < 300; 92 | } 93 | 94 | get redirected() { 95 | return this[INTERNALS].counter > 0; 96 | } 97 | 98 | get headers() { 99 | return this[INTERNALS].headers; 100 | } 101 | 102 | // non-spec extension 103 | get httpVersion() { 104 | return this[INTERNALS].httpVersion; 105 | } 106 | 107 | // non-spec extension 108 | get decoded() { 109 | return this[INTERNALS].decoded; 110 | } 111 | 112 | /** 113 | * Create a redirect response. 114 | * 115 | * @param {string} url The URL that the new response is to originate from. 116 | * @param {number} [status=302] An optional status code for the response (default: 302) 117 | * @returns {Response} A Response object. 118 | * 119 | * See https://fetch.spec.whatwg.org/#dom-response-redirect 120 | */ 121 | static redirect(url, status = 302) { 122 | if (![301, 302, 303, 307, 308].includes(status)) { 123 | throw new RangeError('Invalid status code'); 124 | } 125 | 126 | return new Response(null, { 127 | headers: { 128 | location: new URL(url).toString(), 129 | }, 130 | status, 131 | }); 132 | } 133 | 134 | /** 135 | * Clone this response 136 | * 137 | * @returns {Response} 138 | */ 139 | clone() { 140 | if (this.bodyUsed) { 141 | throw new TypeError('Cannot clone: already read'); 142 | } 143 | 144 | return new Response(cloneStream(this), { ...this[INTERNALS] }); 145 | } 146 | 147 | get [Symbol.toStringTag]() { 148 | return this.constructor.name; 149 | } 150 | } 151 | 152 | Object.defineProperties(Response.prototype, { 153 | url: { enumerable: true }, 154 | status: { enumerable: true }, 155 | ok: { enumerable: true }, 156 | redirected: { enumerable: true }, 157 | statusText: { enumerable: true }, 158 | headers: { enumerable: true }, 159 | clone: { enumerable: true }, 160 | }); 161 | 162 | export default Response; 163 | -------------------------------------------------------------------------------- /test/fetch/abort.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable guard-for-in */ 15 | /* eslint-disable no-restricted-syntax */ 16 | 17 | import assert from 'assert'; 18 | 19 | import sinon from 'sinon'; 20 | 21 | import { AbortController, AbortSignal } from '../../src/fetch/abort.js'; 22 | 23 | describe('AbortController Tests', () => { 24 | let controller; 25 | 26 | beforeEach(() => { 27 | controller = new AbortController(); 28 | }); 29 | 30 | it('should not be callable', () => { 31 | assert.throws(() => (AbortController)(), TypeError); 32 | }); 33 | 34 | it('should have 2 properties', () => { 35 | const keys = new Set(); 36 | keys.add('signal'); 37 | keys.add('abort'); 38 | 39 | for (const key in controller) { 40 | assert(keys.has(key), `'${key}' not found, but should have it`); 41 | keys.delete(key); 42 | } 43 | 44 | keys.forEach((key) => { 45 | assert(false, `'${key}' not found`); 46 | }); 47 | }); 48 | 49 | it('should be stringified as [object AbortController]', () => { 50 | assert(controller.toString() === '[object AbortController]'); 51 | }); 52 | 53 | describe('"signal" property', () => { 54 | let signal; 55 | 56 | beforeEach(() => { 57 | signal = controller.signal; 58 | }); 59 | 60 | it('should return the same instance always', () => { 61 | assert(signal === controller.signal); 62 | }); 63 | 64 | it('should be a AbortSignal object', () => { 65 | assert(signal instanceof AbortSignal); 66 | }); 67 | 68 | it('should have 5 properties', () => { 69 | const keys = new Set(); 70 | keys.add('addEventListener'); 71 | keys.add('removeEventListener'); 72 | keys.add('dispatchEvent'); 73 | keys.add('aborted'); 74 | keys.add('onabort'); 75 | 76 | for (const key in signal) { 77 | assert(keys.has(key), `'${key}' found, but should not have it`); 78 | keys.delete(key); 79 | } 80 | 81 | keys.forEach((key) => { 82 | assert(false, `'${key}' not found`); 83 | }); 84 | }); 85 | 86 | it('should have "aborted" property which is false by default', () => { 87 | assert(signal.aborted === false); 88 | }); 89 | 90 | it('should have "onabort" property which is null by default', () => { 91 | assert(signal.onabort === null); 92 | }); 93 | 94 | it('should be stringified as [object AbortSignal]', () => { 95 | assert(signal.toString() === '[object AbortSignal]'); 96 | }); 97 | }); 98 | 99 | describe('"abort" method', () => { 100 | it('should set true to "signal.aborted" property', () => { 101 | controller.abort(); 102 | assert(controller.signal.aborted); 103 | }); 104 | 105 | it('should fire "abort" event on "signal" (addEventListener)', () => { 106 | const listener = sinon.fake(); 107 | controller.signal.addEventListener('abort', listener); 108 | controller.abort(); 109 | 110 | assert(listener.calledOnce); 111 | }); 112 | 113 | it('should fire "abort" event on "signal" (onabort)', () => { 114 | const listener = sinon.fake(); 115 | controller.signal.onabort = listener; 116 | controller.abort(); 117 | 118 | assert(listener.calledOnce); 119 | }); 120 | 121 | it('should not fire "abort" event twice', () => { 122 | const listener = sinon.fake(); 123 | controller.signal.addEventListener('abort', listener); 124 | controller.abort(); 125 | controller.abort(); 126 | controller.abort(); 127 | 128 | assert(listener.calledOnce); 129 | }); 130 | 131 | it('should not fire "abort" event after removing listener', () => { 132 | const listener = sinon.fake(); 133 | controller.signal.addEventListener('abort', listener); 134 | controller.abort(); 135 | 136 | assert(listener.calledOnce); 137 | 138 | controller.signal.removeEventListener('abort', listener); 139 | controller.abort(); 140 | 141 | assert(listener.calledOnce); 142 | }); 143 | 144 | it('should throw a TypeError if "this" is not an AbortController object', () => { 145 | assert.throws(() => controller.abort.call({}), TypeError); 146 | }); 147 | }); 148 | }); 149 | 150 | describe('AbortSignal Tests', () => { 151 | it('should not be callable', () => { 152 | assert.throws(() => (AbortSignal)(), TypeError); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/fetch/headers.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import http from 'http'; 14 | 15 | import { isPlainObject } from '../common/utils.js'; 16 | 17 | const { validateHeaderName, validateHeaderValue } = http; 18 | 19 | const INTERNALS = Symbol('Headers internals'); 20 | 21 | const normalizeName = (name) => { 22 | const nm = typeof name !== 'string' ? String(name) : name; 23 | validateHeaderName(nm); 24 | return nm.toLowerCase(); 25 | }; 26 | 27 | const normalizeValue = (value, name) => { 28 | const val = typeof value !== 'string' ? String(value) : value; 29 | validateHeaderValue(name, val); 30 | return val; 31 | }; 32 | 33 | /** 34 | * Headers class 35 | * 36 | * @see https://fetch.spec.whatwg.org/#headers-class 37 | */ 38 | class Headers { 39 | /** 40 | * Constructs a new Headers instance 41 | * 42 | * @constructor 43 | * @param {Object} [init={}] 44 | */ 45 | constructor(init = {}) { 46 | this[INTERNALS] = { 47 | map: new Map(), 48 | }; 49 | 50 | if (init instanceof Headers) { 51 | init[INTERNALS].map.forEach((value, name) => { 52 | this[INTERNALS].map.set(name, Array.isArray(value) ? [...value] : value); 53 | }); 54 | } else if (Array.isArray(init)) { 55 | init.forEach(([name, value]) => { 56 | if (Array.isArray(value)) { 57 | // special case for Set-Cookie header which can have an array of values 58 | value.forEach((val) => { 59 | this.append(name, val); 60 | }); 61 | } else { 62 | this.append(name, value); 63 | } 64 | }); 65 | } else if (isPlainObject(init)) { 66 | for (const [name, value] of Object.entries(init)) { 67 | if (Array.isArray(value)) { 68 | // special case for Set-Cookie header which can have an array of values 69 | value.forEach((val) => { 70 | this.append(name, val); 71 | }); 72 | } else { 73 | this.set(name, value); 74 | } 75 | } 76 | } 77 | } 78 | 79 | set(name, value) { 80 | this[INTERNALS].map.set(normalizeName(name), normalizeValue(value, name)); 81 | } 82 | 83 | has(name) { 84 | return this[INTERNALS].map.has(normalizeName(name)); 85 | } 86 | 87 | get(name) { 88 | const val = this[INTERNALS].map.get(normalizeName(name)); 89 | if (val === undefined) { 90 | return null; 91 | } else if (Array.isArray(val)) { 92 | return val.join(', '); 93 | } else { 94 | return val; 95 | } 96 | } 97 | 98 | append(name, value) { 99 | const nm = normalizeName(name); 100 | const val = normalizeValue(value, name); 101 | const oldVal = this[INTERNALS].map.get(nm); 102 | if (Array.isArray(oldVal)) { 103 | oldVal.push(val); 104 | } else if (oldVal === undefined) { 105 | this[INTERNALS].map.set(nm, val); 106 | } else { 107 | this[INTERNALS].map.set(nm, [oldVal, val]); 108 | } 109 | } 110 | 111 | delete(name) { 112 | this[INTERNALS].map.delete(normalizeName(name)); 113 | } 114 | 115 | forEach(callback, thisArg) { 116 | for (const name of this.keys()) { 117 | callback.call(thisArg, this.get(name), name); 118 | } 119 | } 120 | 121 | keys() { 122 | return Array.from(this[INTERNALS].map.keys()) 123 | .sort(); 124 | } 125 | 126 | * values() { 127 | for (const name of this.keys()) { 128 | yield this.get(name); 129 | } 130 | } 131 | 132 | /** 133 | * @type {() => IterableIterator<[string, string]>} 134 | */ 135 | * entries() { 136 | for (const name of this.keys()) { 137 | yield [name, this.get(name)]; 138 | } 139 | } 140 | 141 | /** 142 | * @type {() => IterableIterator<[string, string]>} 143 | */ 144 | [Symbol.iterator]() { 145 | return this.entries(); 146 | } 147 | 148 | get [Symbol.toStringTag]() { 149 | return this.constructor.name; 150 | } 151 | 152 | /** 153 | * Returns the headers as a plain object. 154 | * (non-spec extension) 155 | * 156 | * @returns {Record} 157 | */ 158 | plain() { 159 | return [...this.keys()].reduce((result, key) => { 160 | // eslint-disable-next-line no-param-reassign 161 | result[key] = this.get(key); 162 | return result; 163 | }, {}); 164 | } 165 | 166 | /** 167 | * Returns the internal/raw representation of the 168 | * headers, i.e. the value of an multi-valued header 169 | * (added with append()) is an array of strings. 170 | * (non-spec extension) 171 | * 172 | * @returns {Record} 173 | */ 174 | raw() { 175 | return Object.fromEntries(this[INTERNALS].map); 176 | } 177 | } 178 | 179 | /** 180 | * Re-shaping object for Web IDL tests 181 | */ 182 | Object.defineProperties( 183 | Headers.prototype, 184 | [ 185 | 'append', 186 | 'delete', 187 | 'entries', 188 | 'forEach', 189 | 'get', 190 | 'has', 191 | 'keys', 192 | 'set', 193 | 'values', 194 | ].reduce((result, property) => { 195 | // eslint-disable-next-line no-param-reassign 196 | result[property] = { enumerable: true }; 197 | return result; 198 | }, {}), 199 | ); 200 | 201 | export default Headers; 202 | -------------------------------------------------------------------------------- /test/common/utils.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | import { Readable } from 'stream'; 17 | import { promisify } from 'util'; 18 | import zlib from 'zlib'; 19 | 20 | import sinon from 'sinon'; 21 | 22 | import { 23 | decodeStream, isPlainObject, sizeof, streamToBuffer, 24 | } from '../../src/common/utils.js'; 25 | 26 | const gzip = promisify(zlib.gzip); 27 | const deflate = promisify(zlib.deflate); 28 | const brotliCompress = promisify(zlib.brotliCompress); 29 | 30 | const TEST_DATA = Buffer.from('Hello, World!', 'utf8'); 31 | 32 | describe('isPlainObject Tests', () => { 33 | it('isPlainObject works', () => { 34 | // plain object 35 | assert(isPlainObject({ foo: 1 })); 36 | assert(isPlainObject(Object.create(null))); 37 | // eslint-disable-next-line no-new-object 38 | assert(isPlainObject(new Object())); 39 | // not a plain object 40 | assert(!isPlainObject(new Date())); 41 | assert(!isPlainObject([1, 2, 3])); 42 | }); 43 | }); 44 | 45 | describe('decodeStream Tests', () => { 46 | it('decode gzip stream works', async () => { 47 | const encBuf = await gzip(TEST_DATA); 48 | const encStream = Readable.from(encBuf); 49 | const onError = sinon.fake(); 50 | const decStream = decodeStream(200, { 'content-length': encBuf.length, 'content-encoding': 'gzip' }, encStream, onError); 51 | assert(onError.notCalled); 52 | const decBuf = await streamToBuffer(decStream); 53 | assert.strictEqual(Buffer.compare(decBuf, TEST_DATA), 0); 54 | }); 55 | 56 | it('decode deflate stream works', async () => { 57 | const encBuf = await deflate(TEST_DATA); 58 | const encStream = Readable.from(encBuf); 59 | const onError = sinon.fake(); 60 | const decStream = decodeStream(200, { 'content-length': encBuf.length, 'content-encoding': 'deflate' }, encStream, onError); 61 | assert(onError.notCalled); 62 | const decBuf = await streamToBuffer(decStream); 63 | assert.strictEqual(Buffer.compare(decBuf, TEST_DATA), 0); 64 | }); 65 | 66 | it('decode brotli stream works', async () => { 67 | const encBuf = await brotliCompress(TEST_DATA); 68 | const encStream = Readable.from(encBuf); 69 | const onError = sinon.fake(); 70 | const decStream = decodeStream(200, { 'content-length': encBuf.length, 'content-encoding': 'br' }, encStream, onError); 71 | assert(onError.notCalled); 72 | const decBuf = await streamToBuffer(decStream); 73 | assert.strictEqual(Buffer.compare(decBuf, TEST_DATA), 0); 74 | }); 75 | 76 | it('decode gzip stream reports error if stream is corrupted', async () => { 77 | let encBuf = await gzip(TEST_DATA); 78 | // truncate, i.e. corrupt the encoded data 79 | encBuf = encBuf.slice(8); 80 | const encStream = Readable.from(encBuf); 81 | const onError = sinon.fake(); 82 | const decStream = decodeStream(200, { 'content-length': encBuf.length, 'content-encoding': 'gzip' }, encStream, onError); 83 | await assert.rejects(async () => streamToBuffer(decStream)); 84 | assert(onError.calledOnce); 85 | }); 86 | 87 | it('don\'t decode status 204 response', async () => { 88 | const encBuf = await gzip(TEST_DATA); 89 | const encStream = Readable.from(encBuf); 90 | const onError = sinon.fake(); 91 | const decStream = decodeStream(204, { 'content-length': encBuf.length, 'content-encoding': 'gzip' }, encStream, onError); 92 | assert(onError.notCalled); 93 | assert.strictEqual(encStream, decStream); 94 | }); 95 | 96 | it('don\'t decode stream if content-encoding is invalid', async () => { 97 | const encBuf = await gzip(TEST_DATA); 98 | const encStream = Readable.from(encBuf); 99 | const onError = sinon.fake(); 100 | const decStream = decodeStream(200, { 'content-length': encBuf.length, 'content-encoding': 'Gzip' }, encStream, onError); 101 | assert(onError.notCalled); 102 | assert.strictEqual(encStream, decStream); 103 | }); 104 | }); 105 | 106 | describe('sizeof Tests', () => { 107 | it('sizeof primitives works', async () => { 108 | assert.strictEqual(10, sizeof('12345')); 109 | assert.strictEqual(8, sizeof(42)); 110 | assert.strictEqual(4, sizeof(true)); 111 | }); 112 | 113 | it('sizeof symbols works', async () => { 114 | const localSymbal = Symbol('foo'); 115 | assert.strictEqual(6, sizeof(localSymbal)); 116 | const globalSymbal = Symbol.for('bar'); 117 | assert.strictEqual(6, sizeof(globalSymbal)); 118 | }); 119 | 120 | it('sizeof object with circular reference works', async () => { 121 | const obj = { 122 | a: 'a', 123 | }; 124 | assert.strictEqual(sizeof(obj), 4); 125 | obj.b = obj; 126 | assert.strictEqual(sizeof(obj), 4 + 2); 127 | }); 128 | 129 | it('sizeof array with circular reference works', async () => { 130 | const arr = ['a']; 131 | assert.strictEqual(sizeof(arr), 2); 132 | arr.push(arr); 133 | assert.strictEqual(sizeof(arr), 2); 134 | }); 135 | }); 136 | 137 | describe('streamToBuffer Tests', () => { 138 | it('streamToBuffer works', async () => { 139 | const stream = Readable.from(TEST_DATA); 140 | const buf = await streamToBuffer(stream); 141 | assert.strictEqual(Buffer.compare(buf, TEST_DATA), 0); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /src/common/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable guard-for-in */ 14 | import { constants as bufferConstants } from 'buffer'; 15 | import { pipeline, PassThrough } from 'stream'; 16 | import { promisify } from 'util'; 17 | import { 18 | createGunzip, createInflate, createBrotliDecompress, constants as zlibConstants, 19 | } from 'zlib'; 20 | import debugFactory from 'debug'; 21 | 22 | const debug = debugFactory('adobe/fetch:utils'); 23 | const { MAX_LENGTH: maxBufferLength } = bufferConstants; 24 | const { Z_SYNC_FLUSH } = zlibConstants; 25 | 26 | const asyncPipeline = promisify(pipeline); 27 | 28 | const canDecode = (statusCode, headers) => { 29 | if (statusCode === 204 || statusCode === 304) { 30 | return false; 31 | } 32 | if (+headers['content-length'] === 0) { 33 | return false; 34 | } 35 | return /^\s*(?:(x-)?deflate|(x-)?gzip|br)\s*$/.test(headers['content-encoding']); 36 | }; 37 | 38 | const decodeStream = (statusCode, headers, readableStream, onError) => { 39 | if (!canDecode(statusCode, headers)) { 40 | return readableStream; 41 | } 42 | 43 | const cb = (err) => { 44 | if (err) { 45 | debug(`encountered error while decoding stream: ${err}`); 46 | onError(err); 47 | } 48 | }; 49 | 50 | switch (headers['content-encoding'].trim()) { 51 | case 'gzip': 52 | case 'x-gzip': 53 | // use Z_SYNC_FLUSH like cURL does 54 | return pipeline( 55 | readableStream, 56 | createGunzip({ flush: Z_SYNC_FLUSH, finishFlush: Z_SYNC_FLUSH }), 57 | cb, 58 | ); 59 | 60 | case 'deflate': 61 | case 'x-deflate': 62 | return pipeline(readableStream, createInflate(), cb); 63 | 64 | case 'br': 65 | return pipeline(readableStream, createBrotliDecompress(), cb); 66 | 67 | /* c8 ignore next 4 */ 68 | default: 69 | // dead branch since it's covered by shouldDecode already; 70 | // only here to make eslint stop complaining 71 | return readableStream; 72 | } 73 | }; 74 | 75 | const isPlainObject = (val) => { 76 | if (!val || typeof val !== 'object') { 77 | return false; 78 | } 79 | if (Object.prototype.toString.call(val) !== '[object Object]') { 80 | return false; 81 | } 82 | if (Object.getPrototypeOf(val) === null) { 83 | return true; 84 | } 85 | let proto = val; 86 | while (Object.getPrototypeOf(proto) !== null) { 87 | proto = Object.getPrototypeOf(proto); 88 | } 89 | return Object.getPrototypeOf(val) === proto; 90 | }; 91 | 92 | const calcSize = (obj, processed) => { 93 | if (Buffer.isBuffer(obj)) { 94 | return obj.length; 95 | } 96 | 97 | switch (typeof obj) { 98 | case 'string': 99 | return obj.length * 2; 100 | case 'boolean': 101 | return 4; 102 | case 'number': 103 | return 8; 104 | case 'symbol': 105 | return Symbol.keyFor(obj) 106 | ? Symbol.keyFor(obj).length * 2 // global symbol '' 107 | : (obj.toString().length - 8) * 2; // local symbol 'Symbol()' 108 | case 'object': 109 | if (Array.isArray(obj)) { 110 | // eslint-disable-next-line no-use-before-define 111 | return calcArraySize(obj, processed); 112 | } else { 113 | // eslint-disable-next-line no-use-before-define 114 | return calcObjectSize(obj, processed); 115 | } 116 | default: 117 | return 0; 118 | } 119 | }; 120 | 121 | const calcArraySize = (arr, processed) => { 122 | processed.add(arr); 123 | 124 | return arr.map((entry) => { 125 | if (processed.has(entry)) { 126 | // skip circular references 127 | return 0; 128 | } 129 | return calcSize(entry, processed); 130 | }).reduce((acc, curr) => acc + curr, 0); 131 | }; 132 | 133 | const calcObjectSize = (obj, processed) => { 134 | if (obj == null) { 135 | return 0; 136 | } 137 | 138 | processed.add(obj); 139 | 140 | let bytes = 0; 141 | const names = []; 142 | 143 | // eslint-disable-next-line no-restricted-syntax 144 | for (const key in obj) { 145 | names.push(key); 146 | } 147 | 148 | names.push(...Object.getOwnPropertySymbols(obj)); 149 | 150 | names.forEach((nm) => { 151 | // key 152 | bytes += calcSize(nm, processed); 153 | // value 154 | if (typeof obj[nm] === 'object' && obj[nm] !== null) { 155 | if (processed.has(obj[nm])) { 156 | // skip circular references 157 | return; 158 | } 159 | processed.add(obj[nm]); 160 | } 161 | bytes += calcSize(obj[nm], processed); 162 | }); 163 | 164 | return bytes; 165 | }; 166 | 167 | const sizeof = (obj) => calcSize(obj, new WeakSet()); 168 | 169 | const streamToBuffer = async (stream) => { 170 | const passThroughStream = new PassThrough(); 171 | 172 | let length = 0; 173 | const chunks = []; 174 | 175 | passThroughStream.on('data', (chunk) => { 176 | /* c8 ignore next 3 */ 177 | if ((length + chunk.length) > maxBufferLength) { 178 | throw new Error('Buffer.constants.MAX_SIZE exceeded'); 179 | } 180 | chunks.push(chunk); 181 | length += chunk.length; 182 | }); 183 | 184 | await asyncPipeline(stream, passThroughStream); 185 | return Buffer.concat(chunks, length); 186 | }; 187 | 188 | export { 189 | decodeStream, isPlainObject, sizeof, streamToBuffer, 190 | }; 191 | -------------------------------------------------------------------------------- /src/fetch/request.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { Body, cloneStream, guessContentType } from './body.js'; 14 | import Headers from './headers.js'; 15 | import { isPlainObject } from '../common/utils.js'; 16 | import { isFormData, FormDataSerializer } from '../common/formData.js'; 17 | 18 | const DEFAULT_FOLLOW = 20; 19 | 20 | const INTERNALS = Symbol('Request internals'); 21 | 22 | /** 23 | * Request class 24 | * 25 | * @see https://fetch.spec.whatwg.org/#request-class 26 | */ 27 | class Request extends Body { 28 | /** 29 | * Constructs a new Request instance 30 | * 31 | * @constructor 32 | * @param {Request|String} input 33 | * @param {Object} [init={}] 34 | */ 35 | constructor(input, init = {}) { 36 | // normalize input 37 | const req = input instanceof Request ? input : null; 38 | const parsedURL = req ? new URL(req.url) : new URL(input); 39 | 40 | let method = init.method || (req && req.method) || 'GET'; 41 | method = method.toUpperCase(); 42 | 43 | // eslint-disable-next-line no-eq-null, eqeqeq 44 | if ((init.body != null // neither null nor undefined 45 | || (req && req.body !== null)) 46 | && ['GET', 'HEAD'].includes(method)) { 47 | throw new TypeError('Request with GET/HEAD method cannot have body'); 48 | } 49 | 50 | let body = init.body || (req && req.body ? cloneStream(req) : null); 51 | const headers = new Headers(init.headers || (req && req.headers) || {}); 52 | 53 | if (isFormData(body)) { 54 | // spec-compliant FormData 55 | if (!headers.has('content-type')) { 56 | const fd = new FormDataSerializer(body); 57 | body = fd.stream(); 58 | headers.set('content-type', fd.contentType()); 59 | if (!headers.has('transfer-encoding') 60 | && !headers.has('content-length')) { 61 | headers.set('content-length', fd.length()); 62 | } 63 | } 64 | } 65 | 66 | if (!headers.has('content-type')) { 67 | if (isPlainObject(body)) { 68 | // non-spec extension: support plain js object body (JSON serialization) 69 | body = JSON.stringify(body); 70 | headers.set('content-type', 'application/json'); 71 | } else { 72 | const contentType = guessContentType(body); 73 | if (contentType) { 74 | headers.set('content-type', contentType); 75 | } 76 | } 77 | } 78 | 79 | // call Body constructor 80 | super(body); 81 | 82 | let signal = req ? req.signal : null; 83 | if ('signal' in init) { 84 | signal = init.signal; 85 | } 86 | 87 | if (signal 88 | && (typeof signal !== 'object' 89 | || typeof signal.aborted !== 'boolean' 90 | || typeof signal.addEventListener !== 'function')) { 91 | throw new TypeError('signal must be an AbortSignal'); 92 | } 93 | 94 | const redirect = init.redirect || (req && req.redirect) || 'follow'; 95 | if (!['follow', 'error', 'manual'].includes(redirect)) { 96 | throw new TypeError(`'${redirect}' is not a valid redirect option`); 97 | } 98 | 99 | const cache = init.cache || (req && req.cache) || 'default'; 100 | if (!['default', 'no-store', 'reload', 'no-cache', 'force-cache', 'only-if-cached'].includes(cache)) { 101 | throw new TypeError(`'${cache}' is not a valid cache option`); 102 | } 103 | 104 | this[INTERNALS] = { 105 | init: { ...init }, 106 | method, 107 | redirect, 108 | cache, 109 | headers, 110 | parsedURL, 111 | signal, 112 | }; 113 | 114 | // non-spec extension options 115 | if (init.follow === undefined) { 116 | if (!req || req.follow === undefined) { 117 | this.follow = DEFAULT_FOLLOW; 118 | } else { 119 | this.follow = req.follow; 120 | } 121 | } else { 122 | this.follow = init.follow; 123 | } 124 | this.counter = init.counter || (req && req.counter) || 0; 125 | if (init.compress === undefined) { 126 | if (!req || req.compress === undefined) { 127 | // default 128 | this.compress = true; 129 | } else { 130 | this.compress = req.compress; 131 | } 132 | } else { 133 | this.compress = init.compress; 134 | } 135 | if (init.decode === undefined) { 136 | if (!req || req.decode === undefined) { 137 | // default 138 | this.decode = true; 139 | } else { 140 | this.decode = req.decode; 141 | } 142 | } else { 143 | this.decode = init.decode; 144 | } 145 | } 146 | 147 | get method() { 148 | return this[INTERNALS].method; 149 | } 150 | 151 | get url() { 152 | return this[INTERNALS].parsedURL.toString(); 153 | } 154 | 155 | get headers() { 156 | return this[INTERNALS].headers; 157 | } 158 | 159 | get redirect() { 160 | return this[INTERNALS].redirect; 161 | } 162 | 163 | get cache() { 164 | return this[INTERNALS].cache; 165 | } 166 | 167 | get signal() { 168 | return this[INTERNALS].signal; 169 | } 170 | 171 | /** 172 | * Clone this request 173 | * 174 | * @return {Request} 175 | */ 176 | clone() { 177 | return new Request(this); 178 | } 179 | 180 | get init() { 181 | return this[INTERNALS].init; 182 | } 183 | 184 | get [Symbol.toStringTag]() { 185 | return this.constructor.name; 186 | } 187 | } 188 | 189 | Object.defineProperties(Request.prototype, { 190 | method: { enumerable: true }, 191 | url: { enumerable: true }, 192 | headers: { enumerable: true }, 193 | redirect: { enumerable: true }, 194 | cache: { enumerable: true }, 195 | clone: { enumerable: true }, 196 | signal: { enumerable: true }, 197 | }); 198 | 199 | export default Request; 200 | -------------------------------------------------------------------------------- /src/fetch/body.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { PassThrough, Readable } from 'stream'; 14 | import { types } from 'util'; 15 | 16 | import { FetchError, FetchBaseError } from './errors.js'; 17 | import { streamToBuffer } from '../common/utils.js'; 18 | 19 | const { isAnyArrayBuffer } = types; 20 | 21 | const EMPTY_BUFFER = Buffer.alloc(0); 22 | const INTERNALS = Symbol('Body internals'); 23 | 24 | /** 25 | * Convert a NodeJS Buffer to an ArrayBuffer 26 | * 27 | * @see https://stackoverflow.com/a/31394257 28 | * 29 | * @param {Buffer} buf 30 | * @returns {ArrayBuffer} 31 | */ 32 | const toArrayBuffer = (buf) => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); 33 | 34 | /** 35 | * Consume the body's stream and return a Buffer with the stream's content. 36 | * 37 | * Ref: https://fetch.spec.whatwg.org/#concept-body-consume-body 38 | * 39 | * @param {Body} body 40 | * @return Promise 41 | */ 42 | const consume = async (body) => { 43 | if (body[INTERNALS].disturbed) { 44 | throw new TypeError('Already read'); 45 | } 46 | 47 | if (body[INTERNALS].error) { 48 | throw new TypeError(`Stream had error: ${body[INTERNALS].error.message}`); 49 | } 50 | 51 | // eslint-disable-next-line no-param-reassign 52 | body[INTERNALS].disturbed = true; 53 | 54 | const { stream } = body[INTERNALS]; 55 | 56 | if (stream === null) { 57 | return EMPTY_BUFFER; 58 | } 59 | 60 | return streamToBuffer(stream); 61 | }; 62 | 63 | /** 64 | * Body mixin 65 | * 66 | * @see https://fetch.spec.whatwg.org/#body 67 | */ 68 | class Body { 69 | /** 70 | * Constructs a new Body instance 71 | * 72 | * @constructor 73 | * @param {Readable|Buffer|String|URLSearchParams|FormData} [body] (see https://fetch.spec.whatwg.org/#bodyinit-unions) 74 | */ 75 | constructor(body) { 76 | let stream; 77 | 78 | if (body == null) { 79 | stream = null; 80 | } else if (body instanceof URLSearchParams) { 81 | stream = Readable.from(body.toString()); 82 | } else if (body instanceof Readable) { 83 | stream = body; 84 | } else if (Buffer.isBuffer(body)) { 85 | stream = Readable.from(body); 86 | } else if (isAnyArrayBuffer(body)) { 87 | stream = Readable.from(Buffer.from(body)); 88 | } else if (typeof body === 'string' || body instanceof String) { 89 | stream = Readable.from(body); 90 | } else { 91 | // none of the above: coerce to string 92 | stream = Readable.from(String(body)); 93 | } 94 | 95 | this[INTERNALS] = { 96 | stream, 97 | disturbed: false, 98 | error: null, 99 | }; 100 | if (body instanceof Readable) { 101 | stream.on('error', (err) => { 102 | const error = err instanceof FetchBaseError 103 | ? err 104 | : new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err); 105 | this[INTERNALS].error = error; 106 | }); 107 | } 108 | } 109 | 110 | /** 111 | * Return a Node.js Readable stream. 112 | * (deviation from spec) 113 | * 114 | * @return {Readable} 115 | */ 116 | get body() { 117 | return this[INTERNALS].stream; 118 | } 119 | 120 | get bodyUsed() { 121 | return this[INTERNALS].disturbed; 122 | } 123 | 124 | /** 125 | * Consume the body and return a promise that will resolve to a Node.js Buffer. 126 | * (non-spec extension) 127 | * 128 | * @return {Promise} 129 | */ 130 | async buffer() { 131 | return consume(this); 132 | } 133 | 134 | /** 135 | * Consume the body and return a promise that will resolve to an ArrayBuffer. 136 | * 137 | * @return {Promise} 138 | */ 139 | async arrayBuffer() { 140 | return toArrayBuffer(await this.buffer()); 141 | } 142 | 143 | /** 144 | * Consume the body and return a promise that will resolve to a String. 145 | * 146 | * @return {Promise} 147 | */ 148 | async text() { 149 | const buf = await consume(this); 150 | return buf.toString(); 151 | } 152 | 153 | /** 154 | * Consume the body and return a promise that will 155 | * resolve to the result of JSON.parse(responseText). 156 | * 157 | * @return {Promise<*>} 158 | */ 159 | async json() { 160 | return JSON.parse(await this.text()); 161 | } 162 | } 163 | 164 | Object.defineProperties(Body.prototype, { 165 | body: { enumerable: true }, 166 | bodyUsed: { enumerable: true }, 167 | arrayBuffer: { enumerable: true }, 168 | json: { enumerable: true }, 169 | text: { enumerable: true }, 170 | }); 171 | 172 | /** 173 | * Clone the body's stream. 174 | * 175 | * @param {Body} body 176 | * @return {Readable} 177 | */ 178 | const cloneStream = (body) => { 179 | if (body[INTERNALS].disturbed) { 180 | throw new TypeError('Cannot clone: already read'); 181 | } 182 | 183 | const { stream } = body[INTERNALS]; 184 | let result = stream; 185 | 186 | if (stream instanceof Readable) { 187 | result = new PassThrough(); 188 | const clonedStream = new PassThrough(); 189 | stream.pipe(result); 190 | stream.pipe(clonedStream); 191 | // set body's stream to cloned stream and return result (i.e. the other clone) 192 | // eslint-disable-next-line no-param-reassign 193 | body[INTERNALS].stream = clonedStream; 194 | } 195 | return result; 196 | }; 197 | 198 | /** 199 | * Guesses the `Content-Type` based on the type of body. 200 | * 201 | * @param {Readable|Buffer|String|URLSearchParams|FormData} body Any options.body input 202 | * @returns {string|null} 203 | */ 204 | const guessContentType = (body) => { 205 | if (body === null) { 206 | return null; 207 | } 208 | 209 | if (typeof body === 'string') { 210 | return 'text/plain; charset=utf-8'; 211 | } 212 | 213 | if (body instanceof URLSearchParams) { 214 | return 'application/x-www-form-urlencoded; charset=utf-8'; 215 | } 216 | 217 | if (Buffer.isBuffer(body)) { 218 | return null; 219 | } 220 | 221 | if (isAnyArrayBuffer(body)) { 222 | return null; 223 | } 224 | 225 | if (body instanceof Readable) { 226 | return null; 227 | } 228 | 229 | // fallback: body is coerced to string 230 | return 'text/plain; charset=utf-8'; 231 | }; 232 | 233 | export { 234 | Body, 235 | cloneStream, 236 | guessContentType, 237 | }; 238 | -------------------------------------------------------------------------------- /test/fetch/index.http2.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | import { createHash } from 'crypto'; 17 | 18 | import sinon from 'sinon'; 19 | 20 | import Server from '../server.js'; 21 | import { 22 | fetch, context, noCache, reset, onPush, offPush, 23 | } from '../../src/index.js'; 24 | 25 | const WOKEUP = 'woke up!'; 26 | const sleep = (ms) => new Promise((resolve) => { 27 | setTimeout(resolve, ms, WOKEUP); 28 | }); 29 | 30 | const HELLO_WORLD = 'Hello, World!'; 31 | 32 | describe('HTTP/2-specific Fetch Tests', () => { 33 | let server; 34 | 35 | before(async () => { 36 | // start secure HTTP/2 server 37 | server = await Server.launch(2, true, HELLO_WORLD); 38 | }); 39 | 40 | after(async () => { 41 | try { 42 | process.kill(server.pid); 43 | } catch (ignore) { /* ignore */ } 44 | }); 45 | 46 | afterEach(async () => { 47 | await reset(); 48 | }); 49 | 50 | it('supports self signed certificate', async () => { 51 | // self signed certificates are rejected by default 52 | await assert.rejects(() => fetch(`${server.origin}/hello`)); 53 | 54 | const ctx = context({ rejectUnauthorized: false }); 55 | try { 56 | let resp = await ctx.fetch(`${server.origin}/hello`, { cache: 'no-store' }); 57 | assert.strictEqual(resp.status, 200); 58 | assert.strictEqual(resp.httpVersion, '2.0'); 59 | let body = await resp.text(); 60 | assert.strictEqual(body, HELLO_WORLD); 61 | 62 | // try again 63 | resp = await ctx.fetch(`${server.origin}/hello`, { cache: 'no-store' }); 64 | assert.strictEqual(resp.status, 200); 65 | assert.strictEqual(resp.httpVersion, '2.0'); 66 | body = await resp.text(); 67 | assert.strictEqual(body, HELLO_WORLD); 68 | } finally { 69 | await ctx.reset(); 70 | } 71 | }); 72 | 73 | it('fetch supports HTTP/2 server push', async () => { 74 | // returns a promise which resolves with the url and the pushed response 75 | const pushedResource = () => new Promise((resolve) => { 76 | const handler = (url, response) => { 77 | offPush(handler); 78 | resolve({ url, response }); 79 | }; 80 | onPush(handler); 81 | }); 82 | 83 | // see https://nghttp2.org/blog/2015/02/10/nghttp2-dot-org-enabled-http2-server-push/ 84 | const resp = await fetch('https://nghttp2.org'); 85 | assert.strictEqual(resp.httpVersion, '2.0'); 86 | assert.strictEqual(resp.status, 200); 87 | assert.strictEqual(resp.headers.get('content-type'), 'text/html'); 88 | let buf = await resp.buffer(); 89 | assert.strictEqual(+resp.headers.get('content-length'), buf.length); 90 | // pushed resource 91 | const { url, response } = await pushedResource(); 92 | assert.strictEqual(url, 'https://nghttp2.org/stylesheets/screen.css'); 93 | assert.strictEqual(response.status, 200); 94 | assert.strictEqual(response.headers.get('content-type'), 'text/css'); 95 | buf = await response.buffer(); 96 | assert.strictEqual(+response.headers.get('content-length'), buf.length); 97 | }); 98 | 99 | it('HTTP/2 server push can be disabled', async () => { 100 | const ctx = context({ h2: { enablePush: false } }); 101 | 102 | const handler = sinon.fake(); 103 | ctx.onPush(handler); 104 | 105 | try { 106 | // see https://nghttp2.org/blog/2015/02/10/nghttp2-dot-org-enabled-http2-server-push/ 107 | const resp = await ctx.fetch('https://nghttp2.org'); 108 | assert.strictEqual(resp.httpVersion, '2.0'); 109 | assert.strictEqual(resp.status, 200); 110 | assert.strictEqual(resp.headers.get('content-type'), 'text/html'); 111 | const buf = await resp.buffer(); 112 | assert.strictEqual(+resp.headers.get('content-length'), buf.length); 113 | await sleep(1000); 114 | assert(handler.notCalled); 115 | } finally { 116 | await ctx.reset(); 117 | } 118 | }); 119 | 120 | it('concurrent HTTP/2 requests to same origin', async () => { 121 | const N = 100; // # of parallel requests 122 | const TEST_URL = `${server.origin}/bytes`; 123 | // generete array of 'randomized' urls 124 | const urls = Array.from({ length: N }, () => Math.floor(Math.random() * N)).map((num) => `${TEST_URL}?count=${num}`); 125 | 126 | const ctx = noCache({ rejectUnauthorized: false }); 127 | try { 128 | // send requests 129 | const responses = await Promise.all(urls.map((url) => ctx.fetch(url))); 130 | // read bodies 131 | await Promise.all(responses.map((resp) => resp.arrayBuffer())); 132 | const ok = responses.filter((res) => res.ok && res.httpVersion === '2.0'); 133 | assert.strictEqual(ok.length, N); 134 | } finally { 135 | await ctx.reset(); 136 | } 137 | }); 138 | 139 | it('handles concurrent HTTP/2 requests to subdomains sharing the same IP address (using wildcard SAN cert)', async () => { 140 | // https://github.com/adobe/fetch/issues/52 141 | const doFetch = async (url) => { 142 | const res = await fetch(url); 143 | assert.strictEqual(res.httpVersion, '2.0'); 144 | const data = await res.text(); 145 | return createHash('md5').update(data).digest().toString('hex'); 146 | }; 147 | 148 | const results = await Promise.all([ 149 | doFetch('https://en.wikipedia.org/wiki/42'), 150 | doFetch('https://fr.wikipedia.org/wiki/42'), 151 | doFetch('https://it.wikipedia.org/wiki/42'), 152 | ]); 153 | 154 | assert.strictEqual(results.length, 3); 155 | assert.notStrictEqual(results[0], results[1]); 156 | assert.notStrictEqual(results[0], results[2]); 157 | assert.notStrictEqual(results[1], results[2]); 158 | }); 159 | 160 | it('concurrent HTTP/2 requests to same origin using different contexts', async () => { 161 | const doFetch = async (ctx, url) => ctx.fetch(url); 162 | 163 | const N = 50; // # of parallel requests 164 | const contexts = Array.from({ length: N }, () => context({ rejectUnauthorized: false })); 165 | const TEST_URL = `${server.origin}/bytes`; 166 | // generete array of 'randomized' urls 167 | const args = contexts 168 | .map((ctx) => ({ ctx, num: Math.floor(Math.random() * N) })) 169 | .map(({ ctx, num }) => ({ ctx, url: `${TEST_URL}?count=${num}` })); 170 | // send requests 171 | const responses = await Promise.all(args.map(({ ctx, url }) => doFetch(ctx, url))); 172 | // cleanup 173 | await Promise.all(contexts.map((ctx) => ctx.reset())); 174 | const ok = responses.filter((res) => res.ok && res.httpVersion === '2.0'); 175 | assert.strictEqual(ok.length, N); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /test/fetch/headers.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable no-unused-expressions */ 15 | /* eslint-disable guard-for-in */ 16 | 17 | import { expect, assert } from 'chai'; 18 | import { Headers } from '../../src/index.js'; 19 | 20 | const isIterable = (obj) => obj != null && typeof obj[Symbol.iterator] === 'function'; 21 | 22 | describe('Headers Tests', () => { 23 | it('overrides toStringTag', () => { 24 | const headers = new Headers(); 25 | expect(Object.prototype.toString.call(headers)).to.be.equal('[object Headers]'); 26 | }); 27 | 28 | it('should have attributes conforming to Web IDL', () => { 29 | const headers = new Headers(); 30 | expect(Object.getOwnPropertyNames(headers)).to.be.empty; 31 | const enumerableProperties = []; 32 | 33 | // eslint-disable-next-line no-restricted-syntax 34 | for (const property in headers) { 35 | enumerableProperties.push(property); 36 | } 37 | 38 | for (const toCheck of [ 39 | 'append', 40 | 'delete', 41 | 'entries', 42 | 'forEach', 43 | 'get', 44 | 'has', 45 | 'keys', 46 | 'set', 47 | 'values', 48 | ]) { 49 | expect(enumerableProperties).to.contain(toCheck); 50 | } 51 | }); 52 | 53 | it('should allow iterating through all headers with forEach', () => { 54 | const headers = new Headers([ 55 | ['b', '2'], 56 | ['c', '4'], 57 | ['b', '3'], 58 | ['a', '1'], 59 | ['d', ['5', '6']], 60 | ]); 61 | expect(headers).to.have.property('forEach'); 62 | 63 | const result = []; 64 | headers.forEach((value, key) => { 65 | result.push([key, value]); 66 | }); 67 | 68 | expect(result).to.deep.equal([ 69 | ['a', '1'], 70 | ['b', '2, 3'], 71 | ['c', '4'], 72 | ['d', '5, 6'], 73 | ]); 74 | }); 75 | 76 | it('should allow iterating through all headers with for-of loop', () => { 77 | const headers = new Headers([ 78 | ['b', '2'], 79 | ['c', '4'], 80 | ['a', '1'], 81 | ]); 82 | headers.append('b', '3'); 83 | assert(isIterable(headers)); 84 | 85 | const result = []; 86 | for (const pair of headers) { 87 | result.push(pair); 88 | } 89 | 90 | expect(result).to.deep.equal([ 91 | ['a', '1'], 92 | ['b', '2, 3'], 93 | ['c', '4'], 94 | ]); 95 | }); 96 | 97 | it('should allow iterating through all headers with entries()', () => { 98 | const headers = new Headers([ 99 | ['b', '2'], 100 | ['c', '4'], 101 | ['a', '1'], 102 | ]); 103 | headers.append('b', '3'); 104 | 105 | assert(isIterable(headers.entries())); 106 | 107 | const result = []; 108 | for (const entry of headers.entries()) { 109 | result.push(entry); 110 | } 111 | 112 | expect(result).to.deep.equal([ 113 | ['a', '1'], 114 | ['b', '2, 3'], 115 | ['c', '4'], 116 | ]); 117 | }); 118 | 119 | it('should allow iterating through all headers with keys()', () => { 120 | const headers = new Headers([ 121 | ['b', '2'], 122 | ['c', '4'], 123 | ['a', '1'], 124 | ]); 125 | headers.append('b', '3'); 126 | 127 | assert(isIterable(headers.keys())); 128 | 129 | const result = []; 130 | for (const key of headers.keys()) { 131 | result.push(key); 132 | } 133 | 134 | expect(result).to.deep.equal(['a', 'b', 'c']); 135 | }); 136 | 137 | it('should allow iterating through all headers with values()', () => { 138 | const headers = new Headers([ 139 | ['b', '2'], 140 | ['c', '4'], 141 | ['a', '1'], 142 | ]); 143 | headers.append('b', '3'); 144 | 145 | assert(isIterable(headers.values())); 146 | 147 | const result = []; 148 | for (const val of headers.values()) { 149 | result.push(val); 150 | } 151 | 152 | expect(result).to.deep.equal(['1', '2, 3', '4']); 153 | }); 154 | 155 | it('should reject illegal header', () => { 156 | const headers = new Headers(); 157 | expect(() => new Headers({ 'He y': 'ok' })).to.throw(TypeError); 158 | expect(() => new Headers({ 'Hé-y': 'ok' })).to.throw(TypeError); 159 | expect(() => new Headers({ 'He-y': 'ăk' })).to.throw(TypeError); 160 | expect(() => headers.append('Hé-y', 'ok')).to.throw(TypeError); 161 | expect(() => headers.delete('Hé-y')).to.throw(TypeError); 162 | expect(() => headers.get('Hé-y')).to.throw(TypeError); 163 | expect(() => headers.has('Hé-y')).to.throw(TypeError); 164 | expect(() => headers.set('Hé-y', 'ok')).to.throw(TypeError); 165 | // Should reject empty header 166 | expect(() => headers.append('', 'ok')).to.throw(TypeError); 167 | // Should repoort header name in error 168 | expect(() => new Headers({ 'Faulty-Header': 'ăk' })).to.throw(/Faulty-Header/); 169 | }); 170 | 171 | it('constructor should support plain object', () => { 172 | const headers = new Headers({ foo: 'bar', 'x-cookies': ['a=1', 'b=2'] }); 173 | expect(headers.get('foo')).to.be.equal('bar'); 174 | expect(headers.get('x-cookies')).to.be.equal('a=1, b=2'); 175 | }); 176 | 177 | it('get should return null if not found', () => { 178 | const headers = new Headers(); 179 | expect(headers.get('not-found')).to.be.null; 180 | }); 181 | 182 | it('should coerce name to string', () => { 183 | const headers = new Headers(); 184 | expect(() => headers.set(true, 'ok')).to.not.throw(); 185 | expect(headers.get(true)).to.be.equal('ok'); 186 | expect(headers.get('true')).to.be.equal('ok'); 187 | }); 188 | 189 | it('should coerce value to string', () => { 190 | const headers = new Headers(); 191 | const vals = [true, [1, 2, 3], 42, null, undefined]; 192 | 193 | for (const val of vals) { 194 | expect(() => headers.set('a', val)).to.not.throw(); 195 | expect(headers.get('a')).to.be.equal(String(val)); 196 | } 197 | }); 198 | 199 | it('plain() should return plain object representation', () => { 200 | const hdrObj = { foo: 'bar', 'x-cookies': ['a=1', 'b=2'] }; 201 | const headers = new Headers(hdrObj); 202 | expect(headers.plain()).to.be.deep.equal({ foo: 'bar', 'x-cookies': 'a=1, b=2' }); 203 | }); 204 | 205 | it('raw() should return multi-valued headers as array of strings', () => { 206 | const hdrObj = { foo: 'bar', 'x-cookies': ['a=1', 'b=2'] }; 207 | const headers = new Headers(hdrObj); 208 | expect(headers.raw()).to.be.deep.equal(hdrObj); 209 | }); 210 | 211 | it('should support multi-valued headers (e.g. Set-Cookie)', () => { 212 | let headers = new Headers(); 213 | headers.append('set-cookie', 't=1; Secure'); 214 | headers.append('set-cookie', 'u=2; Secure'); 215 | expect(headers.get('set-cookie')).to.be.equal('t=1; Secure, u=2; Secure'); 216 | expect(headers.plain()['set-cookie']).to.be.deep.equal('t=1; Secure, u=2; Secure'); 217 | expect(headers.raw()['set-cookie']).to.be.deep.equal(['t=1; Secure', 'u=2; Secure']); 218 | 219 | headers = new Headers(headers); 220 | expect(headers.get('set-cookie')).to.be.equal('t=1; Secure, u=2; Secure'); 221 | expect(headers.plain()['set-cookie']).to.be.deep.equal('t=1; Secure, u=2; Secure'); 222 | expect(headers.raw()['set-cookie']).to.be.deep.equal(['t=1; Secure', 'u=2; Secure']); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /src/core/h1.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import http from 'http'; 14 | import https from 'https'; 15 | import { Readable } from 'stream'; 16 | 17 | import debugFactory from 'debug'; 18 | 19 | import { RequestAbortedError } from './errors.js'; 20 | import { decodeStream } from '../common/utils.js'; 21 | 22 | const debug = debugFactory('adobe/fetch:h1'); 23 | 24 | const getAgent = (ctx, protocol) => { 25 | // getAgent is synchronous, no need for lock/mutex 26 | const { h1, options: { h1: opts, rejectUnauthorized } } = ctx; 27 | 28 | if (protocol === 'https:') { 29 | // secure http 30 | if (h1.httpsAgent) { 31 | return h1.httpsAgent; 32 | } 33 | // use agent if either h1 options or rejectUnauthorized context option was specified 34 | if (opts || typeof rejectUnauthorized === 'boolean') { 35 | h1.httpsAgent = new https.Agent(typeof rejectUnauthorized === 'boolean' ? { ...(opts || {}), rejectUnauthorized } : opts); 36 | return h1.httpsAgent; 37 | } 38 | // use default (global) agent 39 | /* c8 ignore next 13 */ 40 | /* => code coverage depends on the node version */ 41 | if (https.globalAgent.keepAlive) /* node >= 19 */ { 42 | // As of Node.js v19 the global agent has keep-alive enabled by default: 43 | // https://nodejs.org/api/http.html#class-httpagent 44 | // https://github.com/nodejs/node/issues/37184 45 | // https://github.com/nodejs/node/pull/43522/files#diff-494d2deee304c672124ecd82d090283595fd3d8c5a80a1825d972a2d229e4944L334-R334 46 | // In order to guarantee consistent behavior across node versions we 47 | // always create a new agent with keep-alive disabled on Node.js v19+. 48 | h1.httpsAgent = new https.Agent({ keepAlive: false }); 49 | return h1.httpsAgent; 50 | } else /* node <= 18 */ { 51 | return undefined; 52 | } 53 | } else { 54 | // plain http 55 | if (h1.httpAgent) { 56 | return h1.httpAgent; 57 | } 58 | if (opts) { 59 | h1.httpAgent = new http.Agent(opts); 60 | return h1.httpAgent; 61 | } 62 | // use default (global) agent 63 | /* c8 ignore next 13 */ 64 | /* => code coverage depends on the node version */ 65 | if (http.globalAgent.keepAlive) /* node >= 19 */ { 66 | // As of Node.js v19 the global agent has keep-alive enabled by default: 67 | // https://nodejs.org/api/http.html#class-httpagent 68 | // https://github.com/nodejs/node/issues/37184 69 | // https://github.com/nodejs/node/pull/43522/files#diff-494d2deee304c672124ecd82d090283595fd3d8c5a80a1825d972a2d229e4944L334-R334 70 | // In order to guarantee consistent behavior across node versions we 71 | // always create a new agent with keep-alive disabled on Node.js v19+. 72 | h1.httpAgent = new http.Agent({ keepAlive: false }); 73 | return h1.httpAgent; 74 | } else /* node <= 18 */ { 75 | return undefined; 76 | } 77 | } 78 | }; 79 | 80 | const setupContext = (ctx) => { 81 | // const { options: { h1: opts } } = ctx; 82 | ctx.h1 = {}; 83 | // custom agents will be lazily instantiated 84 | }; 85 | 86 | const resetContext = async ({ h1 }) => { 87 | if (h1.httpAgent) { 88 | debug('resetContext: destroying httpAgent'); 89 | h1.httpAgent.destroy(); 90 | // eslint-disable-next-line no-param-reassign 91 | delete h1.httpAgent; 92 | } 93 | if (h1.httpsAgent) { 94 | debug('resetContext: destroying httpsAgent'); 95 | h1.httpsAgent.destroy(); 96 | // eslint-disable-next-line no-param-reassign 97 | delete h1.httpsAgent; 98 | } 99 | }; 100 | 101 | const createResponse = (incomingMessage, decode, onError) => { 102 | const { 103 | statusCode, 104 | statusMessage, 105 | httpVersion, 106 | httpVersionMajor, 107 | httpVersionMinor, 108 | headers, // header names are always lower-cased 109 | } = incomingMessage; 110 | const readable = decode 111 | ? decodeStream(statusCode, headers, incomingMessage, onError) 112 | : incomingMessage; 113 | const decoded = !!(decode && readable !== incomingMessage); 114 | return { 115 | statusCode, 116 | statusText: statusMessage, 117 | httpVersion, 118 | httpVersionMajor, 119 | httpVersionMinor, 120 | headers, 121 | readable, 122 | decoded, 123 | }; 124 | }; 125 | 126 | const h1Request = async (ctx, url, options) => { 127 | const { request } = url.protocol === 'https:' ? https : http; 128 | const agent = getAgent(ctx, url.protocol); 129 | const opts = { ...options, agent }; 130 | const { socket, body } = opts; 131 | if (socket) { 132 | // we've got a socket from initial protocol negotiation via ALPN 133 | delete opts.socket; 134 | /* c8 ignore next 27 */ 135 | if (!socket.assigned) { 136 | socket.assigned = true; 137 | // reuse socket for actual request 138 | if (agent) { 139 | // if there's an agent we need to override the agent's createConnection 140 | opts.agent = new Proxy(agent, { 141 | get: (target, property) => { 142 | if (property === 'createConnection' && !socket.inUse) { 143 | return (_connectOptions, cb) => { 144 | debug(`agent reusing socket #${socket.id} (${socket.servername})`); 145 | socket.inUse = true; 146 | cb(null, socket); 147 | }; 148 | } else { 149 | return target[property]; 150 | } 151 | }, 152 | }); 153 | } else { 154 | // no agent, provide createConnection function in options 155 | opts.createConnection = (_connectOptions, cb) => { 156 | debug(`reusing socket #${socket.id} (${socket.servername})`); 157 | socket.inUse = true; 158 | cb(null, socket); 159 | }; 160 | } 161 | } 162 | } 163 | 164 | return new Promise((resolve, reject) => { 165 | debug(`${opts.method} ${url.href}`); 166 | let req; 167 | 168 | // intercept abort signal in order to cancel request 169 | const { signal } = opts; 170 | const onAbortSignal = () => { 171 | // deregister from signal 172 | signal.removeEventListener('abort', onAbortSignal); 173 | /* c8 ignore next 5 */ 174 | if (socket && !socket.inUse) { 175 | // we have no use for the passed socket 176 | debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); 177 | socket.destroy(); 178 | } 179 | reject(new RequestAbortedError()); 180 | if (req) { 181 | req.abort(); 182 | } 183 | }; 184 | if (signal) { 185 | if (signal.aborted) { 186 | reject(new RequestAbortedError()); 187 | return; 188 | } 189 | signal.addEventListener('abort', onAbortSignal); 190 | } 191 | 192 | req = request(url, opts); 193 | req.once('response', (res) => { 194 | if (signal) { 195 | signal.removeEventListener('abort', onAbortSignal); 196 | } 197 | /* c8 ignore next 5 */ 198 | if (socket && !socket.inUse) { 199 | // we have no use for the passed socket 200 | debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); 201 | socket.destroy(); 202 | } 203 | resolve(createResponse(res, opts.decode, reject)); 204 | }); 205 | req.once('error', (err) => { 206 | // error occured during the request 207 | if (signal) { 208 | signal.removeEventListener('abort', onAbortSignal); 209 | } 210 | /* c8 ignore next 5 */ 211 | if (socket && !socket.inUse) { 212 | // we have no use for the passed socket 213 | debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); 214 | socket.destroy(); 215 | } 216 | /* c8 ignore next 6 */ 217 | if (!req.aborted) { 218 | debug(`${opts.method} ${url.href} failed with: ${err.message}`); 219 | // TODO: better call req.destroy(err) instead of req.abort() ? 220 | req.abort(); 221 | reject(err); 222 | } 223 | }); 224 | // send request body? 225 | if (body instanceof Readable) { 226 | body.pipe(req); 227 | } else { 228 | if (body) { 229 | req.write(body); 230 | } 231 | req.end(); 232 | } 233 | }); 234 | }; 235 | 236 | export default { request: h1Request, setupContext, resetContext }; 237 | -------------------------------------------------------------------------------- /test/fetch/response.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable guard-for-in */ 15 | /* eslint-disable no-unused-expressions */ 16 | 17 | import { Readable } from 'stream'; 18 | 19 | import { expect } from 'chai'; 20 | import { FormData } from 'formdata-node'; 21 | 22 | import { Response } from '../../src/index.js'; 23 | 24 | describe('Response Tests', () => { 25 | it('overrides toStringTag', () => { 26 | const res = new Response(); 27 | expect(Object.prototype.toString.call(res)).to.be.equal('[object Response]'); 28 | }); 29 | 30 | it('should have attributes conforming to Web IDL', () => { 31 | const res = new Response(); 32 | const enumerableProperties = []; 33 | // eslint-disable-next-line no-restricted-syntax 34 | for (const property in res) { 35 | enumerableProperties.push(property); 36 | } 37 | 38 | for (const toCheck of [ 39 | 'body', 40 | 'bodyUsed', 41 | 'arrayBuffer', 42 | 'json', 43 | 'text', 44 | 'url', 45 | 'status', 46 | 'ok', 47 | 'redirected', 48 | 'statusText', 49 | 'headers', 50 | 'clone', 51 | ]) { 52 | expect(enumerableProperties).to.contain(toCheck); 53 | } 54 | 55 | for (const toCheck of [ 56 | 'body', 57 | 'bodyUsed', 58 | 'url', 59 | 'status', 60 | 'ok', 61 | 'redirected', 62 | 'statusText', 63 | 'headers', 64 | ]) { 65 | expect(() => { 66 | res[toCheck] = 'abc'; 67 | }).to.throw(); 68 | } 69 | }); 70 | 71 | it('should support empty options', () => { 72 | const res = new Response(Readable.from('a=1')); 73 | return res.text().then((result) => { 74 | expect(result).to.equal('a=1'); 75 | }); 76 | }); 77 | 78 | it('should support parsing headers', () => { 79 | const res = new Response(null, { 80 | headers: { 81 | a: '1', 82 | }, 83 | }); 84 | expect(res.headers.get('a')).to.equal('1'); 85 | }); 86 | 87 | it('should support text() method', () => { 88 | const res = new Response('a=1'); 89 | return res.text().then((result) => { 90 | expect(result).to.equal('a=1'); 91 | }); 92 | }); 93 | 94 | it('should support json() method', () => { 95 | const res = new Response('{"a":1}'); 96 | return res.json().then((result) => { 97 | expect(result.a).to.equal(1); 98 | }); 99 | }); 100 | 101 | it('should support buffer() method', () => { 102 | const res = new Response('a=1'); 103 | return res.buffer().then((result) => { 104 | expect(result.toString()).to.equal('a=1'); 105 | }); 106 | }); 107 | 108 | it('should support clone() method', () => { 109 | const body = Readable.from('a=1'); 110 | const res = new Response(body, { 111 | headers: { 112 | a: '1', 113 | }, 114 | url: 'http://example.com/', 115 | status: 346, 116 | statusText: 'production', 117 | }); 118 | const cl = res.clone(); 119 | expect(cl.headers.get('a')).to.equal('1'); 120 | expect(cl.url).to.equal('http://example.com/'); 121 | expect(cl.status).to.equal(346); 122 | expect(cl.statusText).to.equal('production'); 123 | expect(cl.ok).to.be.false; 124 | // Clone body shouldn't be the same body 125 | expect(cl.body).to.not.equal(body); 126 | return cl.text().then((result) => { 127 | expect(result).to.equal('a=1'); 128 | }); 129 | }); 130 | 131 | it('clone() should throw if body is already consumed', () => { 132 | const body = Readable.from('a=1'); 133 | const res = new Response(body); 134 | const clone = () => res.clone(); 135 | return res.text().then((result) => { 136 | expect(result).to.be.equal('a=1'); 137 | expect(clone).to.throw(TypeError); 138 | }); 139 | }); 140 | 141 | it('should support stream as body', () => { 142 | const body = Readable.from('a=1'); 143 | const res = new Response(body); 144 | return res.text().then((result) => { 145 | expect(result).to.equal('a=1'); 146 | }); 147 | }); 148 | 149 | it('should support string as body', () => { 150 | const res = new Response('a=1'); 151 | return res.text().then((result) => { 152 | expect(result).to.equal('a=1'); 153 | }); 154 | }); 155 | 156 | it('should support String as body', () => { 157 | // eslint-disable-next-line no-new-wrappers 158 | const res = new Response(new String('a=1')); 159 | return res.text().then((result) => { 160 | expect(result).to.equal('a=1'); 161 | }); 162 | }); 163 | 164 | it('should coerce body to string', () => { 165 | const res = new Response(true); 166 | return res.text().then((result) => { 167 | expect(result).to.equal('true'); 168 | }); 169 | }); 170 | 171 | it('should support buffer as body', () => { 172 | const res = new Response(Buffer.from('a=1')); 173 | return res.text().then((result) => { 174 | expect(result).to.equal('a=1'); 175 | }); 176 | }); 177 | 178 | it('should support spec-compliant FormData body', () => { 179 | const form = new FormData(); 180 | form.set('foo', 'bar'); 181 | const res = new Response(form); 182 | // eslint-disable-next-line no-unused-expressions 183 | expect(res.headers.get('content-type')).to.contain('multipart/form-data; boundary='); 184 | return res.text().then((result) => { 185 | expect(result).to.contain('Content-Disposition: form-data; name="foo"'); 186 | }); 187 | }); 188 | 189 | it('should guess content-type header', () => { 190 | // string body 191 | let res = new Response('Hello, World!'); 192 | expect(res.headers.get('content-type')).to.equal('text/plain; charset=utf-8'); 193 | // plain js object body 194 | res = new Response({ foo: 42 }); 195 | expect(res.headers.get('content-type')).to.equal('application/json'); 196 | }); 197 | 198 | it('should not override content-type header', () => { 199 | const res = new Response( 200 | '

Hello, World!

', 201 | { headers: { 'Content-Type': 'text/html' } }, 202 | ); 203 | expect(res.headers.get('content-type')).to.equal('text/html'); 204 | }); 205 | 206 | it('should not auto-set content-type header if body is null', () => { 207 | const res = new Response(null, { status: 301, headers: { location: 'https://example.com' } }); 208 | expect(res.headers.get('content-type')).to.be.null; 209 | }); 210 | 211 | it('should default to null as body', () => { 212 | const res = new Response(); 213 | expect(res.body).to.equal(null); 214 | 215 | return res.text().then((result) => expect(result).to.equal('')); 216 | }); 217 | 218 | it('should default to 200 as status code', () => { 219 | const res = new Response(null); 220 | expect(res.status).to.equal(200); 221 | }); 222 | 223 | it('should default to empty string as url', () => { 224 | const res = new Response(); 225 | expect(res.url).to.equal(''); 226 | }); 227 | 228 | it('should reject if error on stream', async () => { 229 | const stream = Readable.from('a=1'); 230 | const res = new Response(stream); 231 | stream.emit('error', new Error('test')); 232 | res.text().catch((e) => { 233 | expect(e).to.be.an.instanceof(TypeError); 234 | }); 235 | }); 236 | 237 | it('should set bodyUsed', () => { 238 | const res = new Response(Readable.from('a=1')); 239 | expect(res.bodyUsed).to.be.false; 240 | return res.text().then((result) => { 241 | expect(res.bodyUsed).to.be.true; 242 | expect(result).to.equal('a=1'); 243 | }); 244 | }); 245 | 246 | it('should reject if bodyUsed', () => { 247 | const res = new Response(Readable.from('a=1')); 248 | expect(res.bodyUsed).to.be.false; 249 | return res.text().then(async (result) => { 250 | expect(res.bodyUsed).to.be.true; 251 | expect(result).to.equal('a=1'); 252 | // repeated access should fail 253 | res.text().catch((e) => { 254 | expect(e).to.be.an.instanceof(TypeError); 255 | }); 256 | }); 257 | }); 258 | 259 | it('redirect creates redirect Response', () => { 260 | const res = Response.redirect('https://fetch.spec.whatwg.org/', 301); 261 | expect(res).to.be.an.instanceof(Response); 262 | expect(res.status).to.equal(301); 263 | expect(res.headers.get('Location')).to.equal('https://fetch.spec.whatwg.org/'); 264 | }); 265 | 266 | it('redirect defaults to redirect status 302', () => { 267 | const res = Response.redirect('https://fetch.spec.whatwg.org/'); 268 | expect(res).to.be.an.instanceof(Response); 269 | expect(res.status).to.equal(302); 270 | expect(res.headers.get('Location')).to.equal('https://fetch.spec.whatwg.org/'); 271 | }); 272 | 273 | it('redirect throws on non-redirect status', () => { 274 | const redirect = () => Response.redirect('https://fetch.spec.whatwg.org/', 200); 275 | expect(redirect).to.throw(RangeError); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /test/fetch/index.http1.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | 15 | import assert from 'assert'; 16 | import { createHash } from 'crypto'; 17 | 18 | import Server from '../server.js'; 19 | import { 20 | context, 21 | h1, 22 | keepAlive, 23 | h1NoCache, 24 | keepAliveNoCache, 25 | ALPN_HTTP1_0, 26 | ALPN_HTTP1_1, 27 | } from '../../src/index.js'; 28 | 29 | const testParams = [ 30 | { 31 | name: 'plain HTTP', 32 | protocol: 'http', 33 | }, 34 | { 35 | name: 'secure HTTP', 36 | protocol: 'https', 37 | }, 38 | ]; 39 | 40 | testParams.forEach((params) => { 41 | const { 42 | name, 43 | protocol, 44 | } = params; 45 | 46 | describe(`HTTP/1.x-specific Fetch Tests (${name})`, () => { 47 | let server; 48 | 49 | before(async () => { 50 | // start HTTP/1.1 server 51 | server = await Server.launch(1, protocol === 'https'); 52 | }); 53 | 54 | after(async () => { 55 | try { 56 | process.kill(server.pid); 57 | } catch (ignore) { /* ignore */ } 58 | }); 59 | 60 | it(`forcing HTTP/1.1 using context option works' (${name})`, async () => { 61 | const { fetch, reset } = context( 62 | { alpnProtocols: [ALPN_HTTP1_1], rejectUnauthorized: false }, 63 | ); 64 | try { 65 | const resp = await fetch(`${server.origin}/status/200`); 66 | assert.strictEqual(resp.status, 200); 67 | assert.strictEqual(resp.httpVersion, '1.1'); 68 | } finally { 69 | await reset(); 70 | } 71 | }); 72 | 73 | it(`forcing HTTP/1.1 using h1() works' (${name})`, async () => { 74 | const { fetch, reset } = h1({ rejectUnauthorized: false }); 75 | try { 76 | const resp = await fetch(`${server.origin}/status/200`); 77 | assert.strictEqual(resp.status, 200); 78 | assert.strictEqual(resp.httpVersion, '1.1'); 79 | } finally { 80 | await reset(); 81 | } 82 | }); 83 | 84 | it(`h1() defaults to 'no keep-alive' (${name})`, async () => { 85 | const { fetch, reset } = h1({ rejectUnauthorized: false }); 86 | try { 87 | const resp = await fetch(`${server.origin}/status/200`); 88 | assert.strictEqual(resp.status, 200); 89 | assert.strictEqual(resp.httpVersion, '1.1'); 90 | assert.strictEqual(resp.headers.get('connection'), 'close'); 91 | } finally { 92 | await reset(); 93 | } 94 | }); 95 | 96 | it(`supports h1NoCache() (${name})`, async () => { 97 | const { fetch, cacheStats, reset } = h1NoCache({ rejectUnauthorized: false }); 98 | try { 99 | let resp = await fetch(`${server.origin}/cache?max_age=60`); 100 | assert.strictEqual(resp.status, 200); 101 | assert.strictEqual(resp.httpVersion, '1.1'); 102 | // re-fetch (force reuse of custom agent => coverage) 103 | resp = await fetch(`${server.origin}/cache?max_age=60`); 104 | assert.strictEqual(resp.status, 200); 105 | assert.strictEqual(resp.httpVersion, '1.1'); 106 | assert(!resp.fromCache); 107 | 108 | const { size, count } = cacheStats(); 109 | assert(size === 0); 110 | assert(count === 0); 111 | } finally { 112 | await reset(); 113 | } 114 | }); 115 | 116 | it(`supports h1.keepAlive context option (${name})`, async () => { 117 | const { fetch, reset } = context( 118 | { alpnProtocols: [ALPN_HTTP1_1], h1: { keepAlive: true }, rejectUnauthorized: false }, 119 | ); 120 | try { 121 | let resp = await fetch(`${server.origin}/status/200`, { cache: 'no-store' }); 122 | assert.strictEqual(resp.status, 200); 123 | assert.strictEqual(resp.httpVersion, '1.1'); 124 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 125 | // re-fetch (force reuse of custom agent => coverage) 126 | resp = await fetch(`${server.origin}/status/200`, { cache: 'no-store' }); 127 | assert.strictEqual(resp.status, 200); 128 | assert.strictEqual(resp.httpVersion, '1.1'); 129 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 130 | } finally { 131 | await reset(); 132 | } 133 | }); 134 | 135 | it(`supports keepAlive() (${name})`, async () => { 136 | const { fetch, reset } = keepAlive({ rejectUnauthorized: false }); 137 | try { 138 | let resp = await fetch(`${server.origin}/status/200`, { cache: 'no-store' }); 139 | assert.strictEqual(resp.status, 200); 140 | assert.strictEqual(resp.httpVersion, '1.1'); 141 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 142 | // re-fetch (force reuse of custom agent => coverage) 143 | resp = await fetch(`${server.origin}/status/200`, { cache: 'no-store' }); 144 | assert.strictEqual(resp.status, 200); 145 | assert.strictEqual(resp.httpVersion, '1.1'); 146 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 147 | } finally { 148 | await reset(); 149 | } 150 | }); 151 | 152 | it(`supports keepAliveNoCache() (${name})`, async () => { 153 | const { fetch, reset, cacheStats } = keepAliveNoCache({ rejectUnauthorized: false }); 154 | try { 155 | let resp = await fetch(`${server.origin}/cache?max_age=60`); 156 | assert.strictEqual(resp.status, 200); 157 | assert.strictEqual(resp.httpVersion, '1.1'); 158 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 159 | // re-fetch (force reuse of custom agent => coverage) 160 | resp = await fetch(`${server.origin}/cache?max_age=60`); 161 | assert.strictEqual(resp.status, 200); 162 | assert.strictEqual(resp.httpVersion, '1.1'); 163 | assert.strictEqual(resp.headers.get('connection'), 'keep-alive'); 164 | assert(!resp.fromCache); 165 | 166 | const { size, count } = cacheStats(); 167 | assert(size === 0); 168 | assert(count === 0); 169 | } finally { 170 | await reset(); 171 | } 172 | }); 173 | 174 | it(`supports HTTP/1.0 (${name})`, async () => { 175 | const { fetch, reset } = context( 176 | { alpnProtocols: [ALPN_HTTP1_0], rejectUnauthorized: false }, 177 | ); 178 | try { 179 | const resp = await fetch(`${server.origin}/status/200`); 180 | assert.strictEqual(resp.status, 200); 181 | assert(['1.0', '1.1'].includes(resp.httpVersion)); 182 | } finally { 183 | await reset(); 184 | } 185 | }); 186 | 187 | it(`negotiates protocol (${name})`, async () => { 188 | const { fetch, reset } = context( 189 | { alpnProtocols: [ALPN_HTTP1_0, ALPN_HTTP1_1], rejectUnauthorized: false }, 190 | ); 191 | try { 192 | const resp = await fetch(`${server.origin}/status/200`); 193 | assert.strictEqual(resp.status, 200); 194 | assert(['1.0', '1.1'].includes(resp.httpVersion)); 195 | } finally { 196 | await reset(); 197 | } 198 | }); 199 | 200 | it(`concurrent HTTP/1.1 requests to same origin (${name})`, async () => { 201 | const { fetch, reset } = h1NoCache({ rejectUnauthorized: false }); 202 | const N = 50; // # of parallel requests 203 | const TEST_URL = `${server.origin}/bytes`; 204 | // generete array of 'randomized' urls 205 | const urls = Array.from({ length: N }, () => Math.floor(Math.random() * N)).map((num) => `${TEST_URL}?count=${num}`); 206 | 207 | let responses; 208 | try { 209 | // send requests 210 | responses = await Promise.all(urls.map((url) => fetch(url))); 211 | // read bodies 212 | await Promise.all(responses.map((resp) => resp.arrayBuffer())); 213 | } finally { 214 | await reset(); 215 | } 216 | const ok = responses.filter((res) => res.ok && res.httpVersion === '1.1'); 217 | assert.strictEqual(ok.length, N); 218 | }); 219 | 220 | it(`handles concurrent HTTP/1.1 requests to subdomains sharing the same IP address (${name})`, async () => { 221 | const { fetch, reset } = context({ alpnProtocols: [ALPN_HTTP1_1] }); 222 | 223 | const doFetch = async (url) => { 224 | const res = await fetch(url); 225 | assert.strictEqual(res.httpVersion, '1.1'); 226 | const data = await res.text(); 227 | return createHash('md5').update(data).digest().toString('hex'); 228 | }; 229 | 230 | let results; 231 | try { 232 | results = await Promise.all([ 233 | doFetch(`${protocol}://en.wikipedia.org/wiki/42`), 234 | doFetch(`${protocol}://fr.wikipedia.org/wiki/42`), 235 | doFetch(`${protocol}://it.wikipedia.org/wiki/42`), 236 | ]); 237 | } finally { 238 | await reset(); 239 | } 240 | 241 | assert.strictEqual(results.length, 3); 242 | assert.notStrictEqual(results[0], results[1]); 243 | assert.notStrictEqual(results[0], results[2]); 244 | assert.notStrictEqual(results[1], results[2]); 245 | }); 246 | 247 | it(`concurrent HTTP/1.1 requests to same origin using different contexts (${name})`, async () => { 248 | const doFetch = async (ctx, url) => ctx.fetch(url); 249 | 250 | const N = 50; // # of parallel requests 251 | const contexts = Array.from({ length: N }, () => h1NoCache({ rejectUnauthorized: false })); 252 | const TEST_URL = `${server.origin}/bytes`; 253 | // generete array of 'randomized' urls 254 | const args = contexts 255 | .map((ctx) => ({ ctx, num: Math.floor(Math.random() * N) })) 256 | .map(({ ctx, num }) => ({ ctx, url: `${TEST_URL}?count=${num}` })); 257 | // send requests 258 | const responses = await Promise.all(args.map(({ ctx, url }) => doFetch(ctx, url))); 259 | // cleanup 260 | await Promise.all(contexts.map((ctx) => ctx.reset())); 261 | const ok = responses.filter((res) => res.ok && res.httpVersion === '1.1'); 262 | assert.strictEqual(ok.length, N); 263 | }); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /test/fetch/request.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-env mocha */ 14 | /* eslint-disable guard-for-in */ 15 | 16 | import { Readable } from 'stream'; 17 | 18 | import { expect } from 'chai'; 19 | import { FormData } from 'formdata-node'; 20 | 21 | import { Request, AbortController } from '../../src/index.js'; 22 | 23 | const BASE_URL = 'https://example.com/'; 24 | 25 | describe('Request Tests', () => { 26 | it('overrides toStringTag', () => { 27 | const req = new Request(BASE_URL); 28 | expect(Object.prototype.toString.call(req)).to.be.equal('[object Request]'); 29 | }); 30 | 31 | it('should have attributes conforming to Web IDL', () => { 32 | const request = new Request('https://github.com/'); 33 | const enumerableProperties = []; 34 | // eslint-disable-next-line no-restricted-syntax 35 | for (const property in request) { 36 | enumerableProperties.push(property); 37 | } 38 | 39 | for (const toCheck of [ 40 | 'body', 41 | 'bodyUsed', 42 | 'json', 43 | 'text', 44 | 'method', 45 | 'url', 46 | 'headers', 47 | 'redirect', 48 | 'cache', 49 | 'clone', 50 | 'signal', 51 | ]) { 52 | expect(enumerableProperties).to.contain(toCheck); 53 | } 54 | 55 | for (const toCheck of [ 56 | 'body', 'bodyUsed', 'method', 'url', 'headers', 'redirect', 'cache', 'signal', 57 | ]) { 58 | expect(() => { 59 | request[toCheck] = 'abc'; 60 | }).to.throw(); 61 | } 62 | }); 63 | 64 | it('should support wrapping Request instance', () => { 65 | const url = `${BASE_URL}hello`; 66 | 67 | const params = new URLSearchParams(); 68 | params.append('a', '1'); 69 | const { signal } = new AbortController(); 70 | 71 | const r1 = new Request(url, { 72 | method: 'POST', 73 | follow: 1, 74 | body: params, 75 | signal, 76 | }); 77 | const r2 = new Request(r1, { 78 | follow: 2, 79 | }); 80 | 81 | expect(r2.url).to.equal(url); 82 | expect(r2.method).to.equal('POST'); 83 | expect(r2.signal).to.equal(signal); 84 | expect(r1.counter).to.equal(0); 85 | expect(r2.counter).to.equal(0); 86 | return r2.text().then((str) => expect(str).to.equal(params.toString())); 87 | }); 88 | 89 | it('should override signal on derived Request instances', () => { 90 | const parentAbortController = new AbortController(); 91 | const derivedAbortController = new AbortController(); 92 | const parentRequest = new Request(`${BASE_URL}hello`, { 93 | signal: parentAbortController.signal, 94 | }); 95 | const derivedRequest = new Request(parentRequest, { 96 | signal: derivedAbortController.signal, 97 | }); 98 | expect(parentRequest.signal).to.equal(parentAbortController.signal); 99 | expect(derivedRequest.signal).to.equal(derivedAbortController.signal); 100 | }); 101 | 102 | it('should allow removing signal on derived Request instances', () => { 103 | const parentAbortController = new AbortController(); 104 | const parentRequest = new Request(`${BASE_URL}hello`, { 105 | signal: parentAbortController.signal, 106 | }); 107 | const derivedRequest = new Request(parentRequest, { 108 | signal: null, 109 | }); 110 | expect(parentRequest.signal).to.equal(parentAbortController.signal); 111 | expect(derivedRequest.signal).to.equal(null); 112 | }); 113 | 114 | it('should throw error with GET/HEAD requests with body', () => { 115 | expect(() => new Request(BASE_URL, { body: '' })) 116 | .to.throw(TypeError); 117 | expect(() => new Request(BASE_URL, { body: 'a' })) 118 | .to.throw(TypeError); 119 | expect(() => new Request(BASE_URL, { body: '', method: 'HEAD' })) 120 | .to.throw(TypeError); 121 | expect(() => new Request(BASE_URL, { body: 'a', method: 'HEAD' })) 122 | .to.throw(TypeError); 123 | expect(() => new Request(BASE_URL, { body: 'a', method: 'get' })) 124 | .to.throw(TypeError); 125 | expect(() => new Request(BASE_URL, { body: 'a', method: 'head' })) 126 | .to.throw(TypeError); 127 | }); 128 | 129 | it('should default to null as body', () => { 130 | const request = new Request(BASE_URL); 131 | expect(request.body).to.equal(null); 132 | return request.text().then((result) => expect(result).to.equal('')); 133 | }); 134 | 135 | it('should support parsing headers', () => { 136 | const url = BASE_URL; 137 | const request = new Request(url, { 138 | headers: { 139 | a: '1', 140 | }, 141 | }); 142 | expect(request.url).to.equal(url); 143 | expect(request.headers.get('a')).to.equal('1'); 144 | }); 145 | 146 | it('should support arrayBuffer() method', () => { 147 | const url = BASE_URL; 148 | const request = new Request(url, { 149 | method: 'POST', 150 | body: 'a=1', 151 | }); 152 | expect(request.url).to.equal(url); 153 | return request.arrayBuffer().then((result) => { 154 | expect(result).to.be.an.instanceOf(ArrayBuffer); 155 | const string = String.fromCharCode.apply(null, new Uint8Array(result)); 156 | expect(string).to.equal('a=1'); 157 | }); 158 | }); 159 | 160 | it('should support text() method', () => { 161 | const url = BASE_URL; 162 | const request = new Request(url, { 163 | method: 'POST', 164 | body: 'a=1', 165 | }); 166 | expect(request.url).to.equal(url); 167 | return request.text().then((result) => { 168 | expect(result).to.equal('a=1'); 169 | }); 170 | }); 171 | 172 | it('should support json() method', () => { 173 | const url = BASE_URL; 174 | const request = new Request(url, { 175 | method: 'POST', 176 | body: '{"a":1}', 177 | }); 178 | expect(request.url).to.equal(url); 179 | return request.json().then((result) => { 180 | expect(result.a).to.equal(1); 181 | }); 182 | }); 183 | 184 | it('should support buffer() method', () => { 185 | const url = BASE_URL; 186 | const request = new Request(url, { 187 | method: 'POST', 188 | body: 'a=1', 189 | }); 190 | expect(request.url).to.equal(url); 191 | return request.buffer().then((result) => { 192 | expect(result.toString()).to.equal('a=1'); 193 | }); 194 | }); 195 | 196 | it('should support clone() method', () => { 197 | const url = BASE_URL; 198 | const body = Readable.from('a=1'); 199 | const { signal } = new AbortController(); 200 | const request = new Request(url, { 201 | body, 202 | method: 'POST', 203 | redirect: 'manual', 204 | headers: { 205 | b: '2', 206 | }, 207 | cache: 'no-store', 208 | follow: 3, 209 | compress: false, 210 | decode: false, 211 | signal, 212 | }); 213 | const cl = request.clone(); 214 | expect(cl.url).to.equal(url); 215 | expect(cl.method).to.equal('POST'); 216 | expect(cl.redirect).to.equal('manual'); 217 | expect(cl.cache).to.equal('no-store'); 218 | expect(cl.headers.get('b')).to.equal('2'); 219 | expect(cl.method).to.equal('POST'); 220 | expect(cl.follow).to.equal(3); 221 | expect(cl.compress).to.equal(false); 222 | expect(cl.decode).to.equal(false); 223 | expect(cl.counter).to.equal(0); 224 | expect(cl.signal).to.equal(signal); 225 | // Clone body shouldn't be the same body 226 | expect(cl.body).to.not.equal(body); 227 | return Promise.all([cl.text(), request.text()]).then((results) => { 228 | expect(results[0]).to.equal('a=1'); 229 | expect(results[1]).to.equal('a=1'); 230 | }); 231 | }); 232 | 233 | it('clone() should throw if body is already consumed', () => { 234 | const body = Readable.from('a=1'); 235 | const request = new Request(BASE_URL, { 236 | method: 'POST', 237 | body, 238 | }); 239 | // consume body 240 | return request.text().then((result) => { 241 | expect(result).to.equal('a=1'); 242 | // clone should fail 243 | expect(() => request.clone()).to.throw(TypeError); 244 | }); 245 | }); 246 | 247 | it('should throw on illegal redirect value', () => { 248 | expect(() => new Request(BASE_URL, { redirect: 'huh?' })).to.throw(TypeError); 249 | }); 250 | 251 | it('should throw on illegal cache value', () => { 252 | expect(() => new Request(BASE_URL, { cache: 'huh?' })).to.throw(TypeError); 253 | }); 254 | 255 | it('should throw on invalid signal', () => { 256 | expect(() => new Request(BASE_URL, { signal: { name: 'not a signal' } })).to.throw(TypeError); 257 | }); 258 | 259 | it('should coerce body to string', () => { 260 | const method = 'PUT'; 261 | const body = true; 262 | const req = new Request(BASE_URL, { method, body }); 263 | expect(req.headers.get('content-type')).to.equal('text/plain; charset=utf-8'); 264 | return req.text().then((result) => { 265 | expect(result).to.equal('true'); 266 | }); 267 | }); 268 | 269 | it('should support buffer body', () => { 270 | const method = 'POST'; 271 | const body = Buffer.from('Hello, World!', 'utf8'); 272 | const req = new Request(BASE_URL, { method, body }); 273 | // eslint-disable-next-line no-unused-expressions 274 | expect(req.headers.get('content-type')).to.be.null; 275 | return req.text().then((result) => { 276 | expect(result).to.equal('Hello, World!'); 277 | }); 278 | }); 279 | 280 | it('should support ArrayBuffer body', () => { 281 | const method = 'POST'; 282 | const data = new Uint8Array([0xfe, 0xff, 0x41]); 283 | const body = data.buffer; // ArrayBuffer instance 284 | const req = new Request(BASE_URL, { method, body }); 285 | // eslint-disable-next-line no-unused-expressions 286 | expect(req.headers.get('content-type')).to.be.null; 287 | return req.arrayBuffer().then((result) => { 288 | expect(new Uint8Array(result)).to.deep.equal(Buffer.from(data)); 289 | }); 290 | }); 291 | 292 | it('should support spec-compliant FormData body', () => { 293 | const method = 'POST'; 294 | const form = new FormData(); 295 | form.set('foo', 'bar'); 296 | const req = new Request(BASE_URL, { method, body: form }); 297 | // eslint-disable-next-line no-unused-expressions 298 | expect(req.headers.get('content-type')).to.contain('multipart/form-data; boundary='); 299 | return req.text().then((result) => { 300 | expect(result).to.contain('Content-Disposition: form-data; name="foo"'); 301 | }); 302 | }); 303 | 304 | it('wrapping requests preserves init options', () => { 305 | const method = 'POST'; 306 | const body = { foo: 'bar', baz: { count: 313 } }; 307 | const req = new Request(BASE_URL, { method, body }); 308 | expect(req.init.body).to.deep.equal(body); 309 | const req1 = new Request(req.url, req.init); 310 | expect(req1.init.body).to.deep.equal(body); 311 | const req2 = new Request(req1.url, req1.init); 312 | expect(req2.init.body).to.deep.equal(body); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /src/core/h2.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { connect, constants } from 'http2'; 14 | import { Readable } from 'stream'; 15 | 16 | import debugFactory from 'debug'; 17 | 18 | import { RequestAbortedError } from './errors.js'; 19 | import { decodeStream } from '../common/utils.js'; 20 | 21 | const debug = debugFactory('adobe/fetch:h2'); 22 | 23 | const { NGHTTP2_CANCEL } = constants; 24 | 25 | const SESSION_IDLE_TIMEOUT = 5 * 60 * 1000; // 5m 26 | const PUSHED_STREAM_IDLE_TIMEOUT = 5000; // 5s 27 | 28 | const setupContext = (ctx) => { 29 | ctx.h2 = { sessionCache: {} }; 30 | }; 31 | 32 | // eslint-disable-next-line arrow-body-style 33 | const resetContext = async ({ h2 }) => { 34 | return Promise.all(Object.values(h2.sessionCache).map( 35 | (session) => new Promise((resolve) => { 36 | session.on('close', resolve); 37 | debug(`resetContext: destroying session (socket #${session.socket && session.socket.id}, ${session.socket && session.socket.servername})`); 38 | session.destroy(); 39 | }), 40 | )); 41 | }; 42 | 43 | const createResponse = ( 44 | headers, 45 | clientHttp2Stream, 46 | decode, 47 | /* c8 ignore next */ onError = () => {}, 48 | ) => { 49 | const hdrs = { ...headers }; 50 | const statusCode = hdrs[':status']; 51 | delete hdrs[':status']; 52 | 53 | const readable = decode 54 | ? decodeStream(statusCode, headers, clientHttp2Stream, onError) 55 | : clientHttp2Stream; 56 | const decoded = !!(decode && readable !== clientHttp2Stream); 57 | return { 58 | statusCode, 59 | statusText: '', 60 | httpVersion: '2.0', 61 | httpVersionMajor: 2, 62 | httpVersionMinor: 0, 63 | headers: hdrs, // header names are always lower-cased 64 | readable, 65 | decoded, 66 | }; 67 | }; 68 | 69 | const handlePush = (ctx, origin, decode, pushedStream, requestHeaders, flags) => { 70 | const { 71 | options: { 72 | h2: { 73 | pushPromiseHandler, 74 | pushHandler, 75 | pushedStreamIdleTimeout = PUSHED_STREAM_IDLE_TIMEOUT, 76 | }, 77 | }, 78 | } = ctx; 79 | 80 | const path = requestHeaders[':path']; 81 | const url = `${origin}${path}`; 82 | 83 | debug(`received PUSH_PROMISE: ${url}, stream #${pushedStream.id}, headers: ${JSON.stringify(requestHeaders)}, flags: ${flags}`); 84 | if (pushPromiseHandler) { 85 | const rejectPush = () => { 86 | pushedStream.close(NGHTTP2_CANCEL); 87 | }; 88 | // give handler opportunity to reject the push 89 | pushPromiseHandler(url, requestHeaders, rejectPush); 90 | } 91 | pushedStream.on('push', (responseHeaders, flgs) => { 92 | // received headers for the pushed stream 93 | // similar to 'response' event on ClientHttp2Stream 94 | debug(`received push headers for ${origin}${path}, stream #${pushedStream.id}, headers: ${JSON.stringify(responseHeaders)}, flags: ${flgs}`); 95 | 96 | // set timeout to automatically discard pushed streams that aren't consumed for some time 97 | pushedStream.setTimeout(pushedStreamIdleTimeout, () => { 98 | /* c8 ignore next 2 */ 99 | debug(`closing pushed stream #${pushedStream.id} after ${pushedStreamIdleTimeout} ms of inactivity`); 100 | pushedStream.close(NGHTTP2_CANCEL); 101 | }); 102 | 103 | if (pushHandler) { 104 | pushHandler(url, requestHeaders, createResponse(responseHeaders, pushedStream, decode)); 105 | } 106 | }); 107 | // log stream errors 108 | pushedStream.on('aborted', () => { 109 | /* c8 ignore next */ 110 | debug(`pushed stream #${pushedStream.id} aborted`); 111 | }); 112 | pushedStream.on('error', (err) => { 113 | /* c8 ignore next */ 114 | debug(`pushed stream #${pushedStream.id} encountered error: ${err}`); 115 | }); 116 | pushedStream.on('frameError', (type, code, id) => { 117 | /* c8 ignore next */ 118 | debug(`pushed stream #${pushedStream.id} encountered frameError: type: ${type}, code: ${code}, id: ${id}`); 119 | }); 120 | }; 121 | 122 | const request = async (ctx, url, options) => { 123 | const { 124 | origin, pathname, search, hash, 125 | } = url; 126 | const path = `${pathname}${search}${hash}`; 127 | 128 | const { 129 | options: { 130 | h2: ctxOpts = {}, 131 | }, 132 | h2: { 133 | sessionCache, 134 | }, 135 | } = ctx; 136 | const { 137 | idleSessionTimeout = SESSION_IDLE_TIMEOUT, 138 | pushPromiseHandler, 139 | pushHandler, 140 | } = ctxOpts; 141 | 142 | const opts = { ...options }; 143 | const { 144 | method, 145 | headers, 146 | socket, 147 | body, 148 | decode, 149 | } = opts; 150 | if (socket) { 151 | delete opts.socket; 152 | } 153 | if (headers.host) { 154 | headers[':authority'] = headers.host; 155 | delete headers.host; 156 | } 157 | 158 | return new Promise((resolve, reject) => { 159 | // lookup session from session cache 160 | let session = sessionCache[origin]; 161 | if (!session || session.closed || session.destroyed) { 162 | // connect and setup new session 163 | // (connect options: https://nodejs.org/api/http2.html#http2_http2_connect_authority_options_listener) 164 | const rejectUnauthorized = !((ctx.options.rejectUnauthorized === false 165 | || ctxOpts.rejectUnauthorized === false)); 166 | const connectOptions = { ...ctxOpts, rejectUnauthorized }; 167 | if (socket && !socket.inUse) { 168 | // we've got a socket from initial protocol negotiation via ALPN 169 | // reuse socket for new session 170 | connectOptions.createConnection = (/* url, options */) => { 171 | debug(`reusing socket #${socket.id} (${socket.servername})`); 172 | socket.inUse = true; 173 | return socket; 174 | }; 175 | } 176 | 177 | const enablePush = !!(pushPromiseHandler || pushHandler); 178 | session = connect( 179 | origin, 180 | { 181 | ...connectOptions, 182 | settings: { 183 | ...connectOptions.settings, 184 | enablePush, 185 | }, 186 | }, 187 | ); 188 | session.setMaxListeners(1000); 189 | session.setTimeout(idleSessionTimeout, () => { 190 | debug(`closing session ${origin} after ${idleSessionTimeout} ms of inactivity`); 191 | session.close(); 192 | }); 193 | session.once('connect', () => { 194 | debug(`session ${origin} established`); 195 | debug(`caching session ${origin}`); 196 | sessionCache[origin] = session; 197 | }); 198 | session.on('localSettings', (settings) => { 199 | debug(`session ${origin} localSettings: ${JSON.stringify(settings)}`); 200 | }); 201 | session.on('remoteSettings', (settings) => { 202 | debug(`session ${origin} remoteSettings: ${JSON.stringify(settings)}`); 203 | }); 204 | session.once('close', () => { 205 | debug(`session ${origin} closed`); 206 | if (sessionCache[origin] === session) { 207 | debug(`discarding cached session ${origin}`); 208 | delete sessionCache[origin]; 209 | } 210 | }); 211 | session.once('error', (err) => { 212 | debug(`session ${origin} encountered error: ${err}`); 213 | if (sessionCache[origin] === session) { 214 | // FIXME: redundant because 'close' event will follow? 215 | debug(`discarding cached session ${origin}`); 216 | delete sessionCache[origin]; 217 | } 218 | }); 219 | session.on('frameError', (type, code, id) => { 220 | /* c8 ignore next */ 221 | debug(`session ${origin} encountered frameError: type: ${type}, code: ${code}, id: ${id}`); 222 | }); 223 | session.once('goaway', (errorCode, lastStreamID, opaqueData) => { 224 | debug(`session ${origin} received GOAWAY frame: errorCode: ${errorCode}, lastStreamID: ${lastStreamID}, opaqueData: ${opaqueData ? /* c8 ignore next */ opaqueData.toString() : undefined}`); 225 | // session will be closed automatically 226 | }); 227 | session.on('stream', (stream, hdrs, flags) => { 228 | handlePush(ctx, origin, decode, stream, hdrs, flags); 229 | }); 230 | } else { 231 | // we have a cached session 232 | /* c8 ignore next 6 */ 233 | // eslint-disable-next-line no-lonely-if 234 | if (socket && socket.id !== session.socket.id && !socket.inUse) { 235 | // we have no use for the passed socket 236 | debug(`discarding redundant socket used for ALPN: #${socket.id} ${socket.servername}`); 237 | socket.destroy(); 238 | } 239 | } 240 | 241 | debug(`${method} ${url.host}${path}`); 242 | let req; 243 | 244 | // intercept abort signal in order to cancel request 245 | const { signal } = opts; 246 | const onAbortSignal = () => { 247 | signal.removeEventListener('abort', onAbortSignal); 248 | reject(new RequestAbortedError()); 249 | if (req) { 250 | req.close(NGHTTP2_CANCEL); 251 | } 252 | }; 253 | if (signal) { 254 | if (signal.aborted) { 255 | reject(new RequestAbortedError()); 256 | return; 257 | } 258 | signal.addEventListener('abort', onAbortSignal); 259 | } 260 | 261 | /* c8 ignore next 4 */ 262 | const onSessionError = (err) => { 263 | debug(`session ${origin} encountered error during ${opts.method} ${url.href}: ${err}`); 264 | reject(err); 265 | }; 266 | // listen on session errors during request 267 | session.once('error', onSessionError); 268 | 269 | req = session.request({ ':method': method, ':path': path, ...headers }); 270 | req.once('response', (hdrs) => { 271 | session.off('error', onSessionError); 272 | if (signal) { 273 | signal.removeEventListener('abort', onAbortSignal); 274 | } 275 | resolve(createResponse(hdrs, req, opts.decode, reject)); 276 | }); 277 | req.once('error', (err) => { 278 | // error occured during the request 279 | session.off('error', onSessionError); 280 | if (signal) { 281 | signal.removeEventListener('abort', onAbortSignal); 282 | } 283 | // if (!req.aborted) { 284 | if (req.rstCode !== NGHTTP2_CANCEL) { 285 | debug(`${opts.method} ${url.href} failed with: ${err.message}`); 286 | req.close(NGHTTP2_CANCEL); // neccessary? 287 | reject(err); 288 | } 289 | }); 290 | req.once('frameError', (type, code, id) => { 291 | /* c8 ignore next 2 */ 292 | session.off('error', onSessionError); 293 | debug(`encountered frameError during ${opts.method} ${url.href}: type: ${type}, code: ${code}, id: ${id}`); 294 | }); 295 | req.on('push', (hdrs, flags) => { 296 | /* c8 ignore next */ 297 | debug(`received 'push' event: headers: ${JSON.stringify(hdrs)}, flags: ${flags}`); 298 | }); 299 | // send request body? 300 | if (body instanceof Readable) { 301 | body.pipe(req); 302 | } else { 303 | if (body) { 304 | req.write(body); 305 | } 306 | req.end(); 307 | } 308 | }); 309 | }; 310 | 311 | export default { request, setupContext, resetContext }; 312 | -------------------------------------------------------------------------------- /src/core/request.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | import { types } from 'util'; 14 | import { Readable } from 'stream'; 15 | import tls from 'tls'; 16 | 17 | import LRUCache from 'lru-cache'; 18 | import debugFactory from 'debug'; 19 | 20 | import { RequestAbortedError } from './errors.js'; 21 | import h1 from './h1.js'; 22 | import h2 from './h2.js'; 23 | import lock from './lock.js'; 24 | import { isFormData, FormDataSerializer } from '../common/formData.js'; 25 | import { isPlainObject } from '../common/utils.js'; 26 | 27 | import pkg from '../package.cjs'; 28 | 29 | const { version } = pkg; 30 | 31 | const { isAnyArrayBuffer } = types; 32 | 33 | const debug = debugFactory('adobe/fetch:core'); 34 | 35 | const ALPN_HTTP2 = 'h2'; 36 | const ALPN_HTTP2C = 'h2c'; 37 | const ALPN_HTTP1_0 = 'http/1.0'; 38 | const ALPN_HTTP1_1 = 'http/1.1'; 39 | 40 | // context option defaults 41 | const ALPN_CACHE_SIZE = 100; // # of entries 42 | const ALPN_CACHE_TTL = 60 * 60 * 1000; // (ms): 1h 43 | const ALPN_PROTOCOLS = [ALPN_HTTP2, ALPN_HTTP1_1, ALPN_HTTP1_0]; 44 | 45 | const DEFAULT_USER_AGENT = `adobe-fetch/${version}`; 46 | 47 | // request option defaults 48 | const DEFAULT_OPTIONS = { 49 | method: 'GET', 50 | compress: true, 51 | decode: true, 52 | }; 53 | 54 | let socketIdCounter = 0; 55 | 56 | const connectionLock = lock(); 57 | 58 | const connectTLS = (url, options) => new Promise((resolve, reject) => { 59 | // intercept abort signal in order to cancel connect 60 | const { signal } = options; 61 | let socket; 62 | const onAbortSignal = () => { 63 | signal.removeEventListener('abort', onAbortSignal); 64 | const err = new RequestAbortedError(); 65 | reject(err); 66 | if (socket) { 67 | socket.destroy(err); 68 | } 69 | }; 70 | if (signal) { 71 | if (signal.aborted) { 72 | reject(new RequestAbortedError()); 73 | return; 74 | } 75 | signal.addEventListener('abort', onAbortSignal); 76 | } 77 | 78 | const port = +url.port || 443; 79 | 80 | const onError = (err) => { 81 | // error occured while connecting 82 | if (signal) { 83 | signal.removeEventListener('abort', onAbortSignal); 84 | } 85 | if (!(err instanceof RequestAbortedError)) { 86 | debug(`connecting to ${url.hostname}:${port} failed with: ${err.message}`); 87 | reject(err); 88 | } 89 | }; 90 | 91 | socket = tls.connect(port, url.hostname, options); 92 | socket.once('secureConnect', () => { 93 | if (signal) { 94 | signal.removeEventListener('abort', onAbortSignal); 95 | } 96 | socket.off('error', onError); 97 | socketIdCounter += 1; 98 | socket.id = socketIdCounter; 99 | debug(`established TLS connection: #${socket.id} (${socket.servername})`); 100 | resolve(socket); 101 | }); 102 | socket.once('error', onError); 103 | }); 104 | 105 | const connect = async (url, options) => { 106 | // use mutex to avoid concurrent socket creation to same origin 107 | let socket = await connectionLock.acquire(url.origin); 108 | try { 109 | if (!socket) { 110 | socket = await connectTLS(url, options); 111 | } 112 | return socket; 113 | } finally { 114 | connectionLock.release(url.origin, socket); 115 | } 116 | }; 117 | 118 | const determineProtocol = async (ctx, url, signal) => { 119 | // url.origin is null if url.protocol is neither 'http:' nor 'https:' ... 120 | const origin = `${url.protocol}//${url.host}`; 121 | 122 | switch (url.protocol) { 123 | case 'http:': 124 | // for simplicity, we assume unencrypted HTTP to be HTTP/1.1 125 | // (although, theoretically, it could also be plain-text HTTP/2 (h2c)) 126 | return { protocol: ALPN_HTTP1_1 }; 127 | 128 | case 'http2:': 129 | // HTTP/2 over TCP (h2c) 130 | return { protocol: ALPN_HTTP2C }; 131 | 132 | case 'https:': 133 | // need to negotiate protocol 134 | break; 135 | 136 | default: 137 | throw new TypeError(`unsupported protocol: ${url.protocol}`); 138 | } 139 | 140 | if (ctx.alpnProtocols.length === 1 141 | && (ctx.alpnProtocols[0] === ALPN_HTTP1_1 || ctx.alpnProtocols[0] === ALPN_HTTP1_0)) { 142 | // shortcut: forced HTTP/1.X, default to HTTP/1.1 (no need to use ALPN to negotiate protocol) 143 | return { protocol: ALPN_HTTP1_1 }; 144 | } 145 | 146 | // lookup ALPN cache 147 | let protocol = ctx.alpnCache.get(origin); 148 | if (protocol) { 149 | return { protocol }; 150 | } 151 | 152 | // negotiate via ALPN 153 | const { 154 | options: { 155 | rejectUnauthorized: _rejectUnauthorized, 156 | h1: h1Opts = {}, 157 | h2: h2Opts = {}, 158 | }, 159 | } = ctx; 160 | const rejectUnauthorized = !((_rejectUnauthorized === false 161 | || h1Opts.rejectUnauthorized === false 162 | || h2Opts.rejectUnauthorized === false)); 163 | const connectOptions = { 164 | servername: url.hostname, // enable SNI (Server Name Indication) extension 165 | ALPNProtocols: ctx.alpnProtocols, 166 | signal, // optional abort signal 167 | rejectUnauthorized, 168 | }; 169 | const socket = await connect(url, connectOptions); 170 | // socket.alpnProtocol contains the negotiated protocol (e.g. 'h2', 'http1.1', 'http1.0') 171 | protocol = socket.alpnProtocol; 172 | /* c8 ignore next 3 */ 173 | if (!protocol) { 174 | protocol = ALPN_HTTP1_1; // default fallback 175 | } 176 | ctx.alpnCache.set(origin, protocol); 177 | return { protocol, socket }; 178 | }; 179 | 180 | const sanitizeHeaders = (headers) => { 181 | const result = {}; 182 | // make all header names lower case 183 | Object.keys(headers).forEach((name) => { 184 | result[name.toLowerCase()] = headers[name]; 185 | }); 186 | return result; 187 | }; 188 | 189 | const request = async (ctx, uri, options) => { 190 | const url = new URL(uri); 191 | 192 | const opts = { ...DEFAULT_OPTIONS, ...(options || {}) }; 193 | 194 | // sanitze method name 195 | if (typeof opts.method === 'string') { 196 | opts.method = opts.method.toUpperCase(); 197 | } 198 | // sanitize headers (lowercase names) 199 | opts.headers = sanitizeHeaders(opts.headers || {}); 200 | // set Host header if none is provided 201 | if (opts.headers.host === undefined) { 202 | opts.headers.host = url.host; 203 | } 204 | // User-Agent header 205 | if (ctx.userAgent) { 206 | if (opts.headers['user-agent'] === undefined) { 207 | opts.headers['user-agent'] = ctx.userAgent; 208 | } 209 | } 210 | // some header magic 211 | let contentType; 212 | if (opts.body instanceof URLSearchParams) { 213 | contentType = 'application/x-www-form-urlencoded; charset=utf-8'; 214 | opts.body = opts.body.toString(); 215 | } else if (isFormData(opts.body)) { 216 | // spec-compliant FormData 217 | const fd = new FormDataSerializer(opts.body); 218 | contentType = fd.contentType(); 219 | opts.body = fd.stream(); 220 | if (opts.headers['transfer-encoding'] === undefined 221 | && opts.headers['content-length'] === undefined) { 222 | opts.headers['content-length'] = String(fd.length()); 223 | } 224 | } else if (typeof opts.body === 'string' || opts.body instanceof String) { 225 | contentType = 'text/plain; charset=utf-8'; 226 | } else if (isPlainObject(opts.body)) { 227 | opts.body = JSON.stringify(opts.body); 228 | contentType = 'application/json'; 229 | } else if (isAnyArrayBuffer(opts.body)) { 230 | opts.body = Buffer.from(opts.body); 231 | } 232 | 233 | if (opts.headers['content-type'] === undefined && contentType !== undefined) { 234 | opts.headers['content-type'] = contentType; 235 | } 236 | // by now all supported custom body types are converted to string, readable or buffer 237 | if (opts.body != null) { 238 | if (!(opts.body instanceof Readable)) { 239 | // non-stream body 240 | if (!(typeof opts.body === 'string' || opts.body instanceof String) 241 | && !Buffer.isBuffer(opts.body)) { 242 | // neither a string or buffer: coerce to string 243 | opts.body = String(opts.body); 244 | } 245 | // string or buffer body 246 | if (opts.headers['transfer-encoding'] === undefined 247 | && opts.headers['content-length'] === undefined) { 248 | opts.headers['content-length'] = String(Buffer.isBuffer(opts.body) 249 | ? opts.body.length 250 | : Buffer.byteLength(opts.body, 'utf-8')); 251 | } 252 | } 253 | } 254 | if (opts.headers.accept === undefined) { 255 | opts.headers.accept = '*/*'; 256 | } 257 | if (opts.body == null && ['POST', 'PUT'].includes(opts.method)) { 258 | opts.headers['content-length'] = '0'; 259 | } 260 | if (opts.compress && opts.headers['accept-encoding'] === undefined) { 261 | opts.headers['accept-encoding'] = 'gzip,deflate,br'; 262 | } 263 | 264 | // extract optional abort signal 265 | const { signal } = opts; 266 | 267 | // delegate to protocol-specific request handler 268 | const { protocol, socket = null } = await determineProtocol(ctx, url, signal); 269 | debug(`${url.host} -> ${protocol}`); 270 | switch (protocol) { 271 | case ALPN_HTTP2: 272 | try { 273 | return await h2.request(ctx, url, socket ? { ...opts, socket } : opts); 274 | } catch (err) { 275 | const { code, message } = err; 276 | /* c8 ignore next 2 */ 277 | if ((code === 'ERR_HTTP2_ERROR' && message === 'Protocol error') 278 | || code === 'ERR_HTTP2_STREAM_CANCEL') { 279 | // server potentially downgraded from h2 to h1: clear alpn cache entry 280 | ctx.alpnCache.delete(`${url.protocol}//${url.host}`); 281 | } 282 | throw err; 283 | } 284 | case ALPN_HTTP2C: 285 | // plain-text HTTP/2 (h2c) 286 | // url.protocol = 'http:'; => doesn't work ?! 287 | return h2.request( 288 | ctx, 289 | new URL(`http://${url.host}${url.pathname}${url.hash}${url.search}`), 290 | /* c8 ignore next */ 291 | socket ? { ...opts, socket } : opts, 292 | 293 | ); 294 | /* c8 ignore next */ case ALPN_HTTP1_0: 295 | case ALPN_HTTP1_1: 296 | return h1.request(ctx, url, socket ? { ...opts, socket } : opts); 297 | /* c8 ignore next 4 */ 298 | default: 299 | // dead branch: only here to make eslint stop complaining 300 | throw new TypeError(`unsupported protocol: ${protocol}`); 301 | } 302 | }; 303 | 304 | const resetContext = async (ctx) => { 305 | ctx.alpnCache.clear(); 306 | return Promise.all([ 307 | h1.resetContext(ctx), 308 | h2.resetContext(ctx), 309 | ]); 310 | }; 311 | 312 | const setupContext = (ctx) => { 313 | const { 314 | options: { 315 | alpnProtocols = ALPN_PROTOCOLS, 316 | alpnCacheTTL = ALPN_CACHE_TTL, 317 | alpnCacheSize = ALPN_CACHE_SIZE, 318 | userAgent = DEFAULT_USER_AGENT, 319 | }, 320 | } = ctx; 321 | 322 | ctx.alpnProtocols = alpnProtocols; 323 | ctx.alpnCache = new LRUCache({ max: alpnCacheSize, ttl: alpnCacheTTL }); 324 | 325 | ctx.userAgent = userAgent; 326 | 327 | h1.setupContext(ctx); 328 | h2.setupContext(ctx); 329 | }; 330 | 331 | export { 332 | request, 333 | setupContext, 334 | resetContext, 335 | RequestAbortedError, 336 | ALPN_HTTP2, 337 | ALPN_HTTP2C, 338 | ALPN_HTTP1_1, 339 | ALPN_HTTP1_0, 340 | }; 341 | -------------------------------------------------------------------------------- /src/api.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | export const enum ALPNProtocol { 14 | ALPN_HTTP2 = 'h2', 15 | ALPN_HTTP2C = 'h2c', 16 | ALPN_HTTP1_1 = 'http/1.1', 17 | ALPN_HTTP1_0 = 'http/1.0', 18 | } 19 | 20 | export interface Http1Options { 21 | /** 22 | * Keep sockets around in a pool to be used by other requests in the future. 23 | * @default false 24 | */ 25 | keepAlive?: boolean; 26 | /** 27 | * When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. 28 | * Only relevant if keepAlive is set to true. 29 | * @default 1000 30 | */ 31 | keepAliveMsecs?: number; 32 | /** 33 | * Maximum number of sockets to allow per host. 34 | * @default Infinity 35 | */ 36 | maxSockets?: number; 37 | /** 38 | * Maximum number of sockets allowed for all hosts in total. Each request will use a new socket until the maximum is reached. 39 | * @default Infinity 40 | */ 41 | maxTotalSockets?: number; 42 | /** 43 | * Maximum number of sockets to leave open in a free state. Only relevant if keepAlive is set to true. 44 | * @default 256 45 | */ 46 | maxFreeSockets?: number; 47 | /** 48 | * Socket timeout in milliseconds. This will set the timeout when the socket is connected. 49 | */ 50 | timeout?: number; 51 | /** 52 | * Scheduling strategy to apply when picking the next free socket to use. 53 | * @default 'fifo' 54 | */ 55 | scheduling?: 'fifo' | 'lifo'; 56 | /** 57 | * (HTTPS only) 58 | * If not false, the server certificate is verified against the list of supplied CAs. An 'error' event is emitted if verification fails; err.code contains the OpenSSL error code. 59 | * @default true 60 | */ 61 | rejectUnauthorized?: boolean; 62 | /** 63 | * (HTTPS only) 64 | * Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. 65 | * @default 100 66 | */ 67 | maxCachedSessions?: number; 68 | } 69 | 70 | type HeadersInit = Headers | Object | Iterable | Iterable>; 71 | 72 | export interface RequestInit { 73 | /** 74 | * A BodyInit object or null to set request's body. 75 | */ 76 | body?: BodyInit | Object |null; 77 | /** 78 | * A Headers object, an object literal, or an array of two-item arrays to set request's headers. 79 | */ 80 | headers?: HeadersInit; 81 | /** 82 | * A string to set request's method. 83 | */ 84 | method?: string; 85 | } 86 | 87 | export interface ResponseInit { 88 | headers?: HeadersInit; 89 | status?: number; 90 | statusText?: string; 91 | } 92 | 93 | type BodyInit = 94 | | Buffer 95 | | URLSearchParams 96 | | NodeJS.ReadableStream 97 | | string; 98 | 99 | export class Headers implements Iterable<[string, string]> { 100 | constructor(init?: HeadersInit); 101 | 102 | append(name: string, value: string): void; 103 | delete(name: string): void; 104 | get(name: string): string | null; 105 | has(name: string): boolean; 106 | set(name: string, value: string): void; 107 | 108 | raw(): Record; 109 | 110 | entries(): Iterator<[string, string]>; 111 | keys(): Iterator; 112 | values(): Iterator; 113 | [Symbol.iterator](): Iterator<[string, string]>; 114 | } 115 | 116 | export class Body { 117 | constructor(body?: BodyInit); 118 | 119 | readonly body: NodeJS.ReadableStream | null; 120 | readonly bodyUsed: boolean; 121 | 122 | buffer(): Promise; 123 | arrayBuffer(): Promise; 124 | json(): Promise; 125 | text(): Promise; 126 | } 127 | 128 | type RequestInfo = string | Body; 129 | 130 | export class Request extends Body { 131 | constructor(input: RequestInfo, init?: RequestInit); 132 | readonly headers: Headers; 133 | readonly method: string; 134 | readonly url: string; 135 | } 136 | 137 | export class Response extends Body { 138 | constructor(body?: BodyInit | Object | null, init?: ResponseInit); 139 | 140 | readonly url: string; 141 | readonly status: number; 142 | readonly statusText: string; 143 | readonly ok: boolean; 144 | readonly redirected: boolean; 145 | readonly httpVersion: string; 146 | readonly decoded: boolean; 147 | headers: Headers; 148 | clone(): Response; 149 | 150 | // non-spec extensions 151 | /** 152 | * A boolean specifying whether the response was retrieved from the cache. 153 | */ 154 | readonly fromCache?: boolean; 155 | } 156 | 157 | export interface Http2Options { 158 | /** 159 | * Max idle time in milliseconds after which a session will be automatically closed. 160 | * @default 5 * 60 * 1000 161 | */ 162 | idleSessionTimeout?: number; 163 | /** 164 | * Enable HTTP/2 Server Push? 165 | * @default true 166 | */ 167 | enablePush?: boolean; 168 | /** 169 | * Max idle time in milliseconds after which a pushed stream will be automatically closed. 170 | * @default 5000 171 | */ 172 | pushedStreamIdleTimeout?: number; 173 | /** 174 | * (HTTPS only) 175 | * If not false, the server certificate is verified against the list of supplied CAs. An 'error' event is emitted if verification fails; err.code contains the OpenSSL error code. 176 | * @default true 177 | */ 178 | rejectUnauthorized?: boolean; 179 | } 180 | 181 | export interface ContextOptions { 182 | /** 183 | * Value of `user-agent` request header 184 | * @default 'adobe-fetch/' 185 | */ 186 | userAgent?: string; 187 | /** 188 | * The maximum total size of the cached entries (in bytes). 0 disables caching. 189 | * @default 100 * 1024 * 1024 190 | */ 191 | maxCacheSize?: number; 192 | /** 193 | * The protocols to be negotiated, in order of preference 194 | * @default [ALPN_HTTP2, ALPN_HTTP1_1, ALPN_HTTP1_0] 195 | */ 196 | alpnProtocols?: ReadonlyArray< ALPNProtocol >; 197 | /** 198 | * How long (in milliseconds) should ALPN information be cached for a given host? 199 | * @default 60 * 60 * 1000 200 | */ 201 | alpnCacheTTL?: number; 202 | /** 203 | * Maximum number of ALPN cache entries 204 | * @default 100 205 | */ 206 | alpnCacheSize?: number; 207 | /** 208 | * (HTTPS only, applies to HTTP/1.x and HTTP/2) 209 | * If not false, the server certificate is verified against the list of supplied CAs. An 'error' event is emitted if verification fails; err.code contains the OpenSSL error code. 210 | * @default true 211 | */ 212 | rejectUnauthorized?: boolean; 213 | 214 | h1?: Http1Options; 215 | h2?: Http2Options; 216 | } 217 | 218 | export class AbortSignal { 219 | readonly aborted: boolean; 220 | 221 | addEventListener(type: 'abort', listener: (this: AbortSignal) => void): void; 222 | removeEventListener(type: 'abort', listener: (this: AbortSignal) => void): void; 223 | } 224 | 225 | export class TimeoutSignal extends AbortSignal { 226 | constructor(timeout: number); 227 | 228 | clear(): void; 229 | } 230 | 231 | export class AbortController { 232 | readonly signal: AbortSignal; 233 | abort(): void; 234 | } 235 | 236 | export interface RequestOptions { 237 | /** 238 | * A string specifying the HTTP request method. 239 | * @default 'GET' 240 | */ 241 | method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS' | 'PATCH'; 242 | /** 243 | * A Headers object, an object literal, or an array of two-item arrays to set request's headers. 244 | * @default {} 245 | */ 246 | headers?: Headers | Object | Iterable | Iterable>; 247 | /** 248 | * The request's body. 249 | * @default null 250 | */ 251 | body?: BodyInit | Object | FormData; 252 | /** 253 | * A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. 254 | * @default 'follow' 255 | */ 256 | redirect?: 'follow' | 'manual' | 'error'; 257 | /** 258 | * A string indicating how the request will interact with the browser's cache to set request's cache. 259 | * @default 'default' 260 | */ 261 | cache?: 'default' | 'no-store' | 'reload' | 'no-cache' | 'force-cache' | 'only-if-cached'; 262 | /** 263 | * An AbortSignal to set request's signal. 264 | * @default null 265 | */ 266 | signal?: AbortSignal; 267 | 268 | // non-spec extensions 269 | /** 270 | * A boolean specifying support of gzip/deflate/brotli content encoding. 271 | * @default true 272 | */ 273 | compress?: boolean; 274 | /** 275 | * A boolean specifying whether gzip/deflate/brotli-endoced content should be decoded. 276 | * @default true 277 | */ 278 | decode?: boolean; 279 | /** 280 | * Maximum number of redirects to follow, 0 to not follow redirect. 281 | * @default 20 282 | */ 283 | follow?: number; 284 | } 285 | 286 | // Errors 287 | export class FetchBaseError extends Error { 288 | type?: string; 289 | } 290 | 291 | export type SystemError = { 292 | address?: string; 293 | code: string; 294 | dest?: string; 295 | errno: number; 296 | info?: object; 297 | message: string; 298 | path?: string; 299 | port?: number; 300 | syscall: string; 301 | }; 302 | 303 | export class FetchError extends FetchBaseError { 304 | code: string; 305 | errno?: number; 306 | erroredSysCall?: string; 307 | } 308 | 309 | export class AbortError extends FetchBaseError { 310 | type: 'aborted' 311 | } 312 | 313 | interface CacheStats { 314 | size: number; 315 | count: number; 316 | } 317 | 318 | export type PushHandler = ( 319 | url: string, 320 | response: Response 321 | ) => void; 322 | 323 | /** 324 | * Fetches a resource from the network. Returns a Promise which resolves once 325 | * the response is available. 326 | * 327 | * @param {string|Request} url 328 | * @param {RequestOptions} [options] 329 | * @returns {Promise} 330 | * @throws {FetchError} 331 | * @throws {AbortError} 332 | * @throws {TypeError} 333 | */ 334 | export function fetch(url: string|Request, options?: RequestOptions): Promise; 335 | 336 | /** 337 | * Resets the current context, i.e. disconnects all open/pending sessions, clears caches etc.. 338 | */ 339 | export function reset(): Promise<[void, void]>; 340 | 341 | /** 342 | * Register a callback which gets called once a server Push has been received. 343 | * 344 | * @param {PushHandler} fn callback function invoked with the url and the pushed Response 345 | */ 346 | export function onPush(fn: PushHandler): void; 347 | 348 | /** 349 | * Deregister a callback previously registered with {#onPush}. 350 | * 351 | * @param {PushHandler} fn callback function registered with {#onPush} 352 | */ 353 | export function offPush(fn: PushHandler): void; 354 | 355 | /** 356 | * Create a URL with query parameters 357 | * 358 | * @param {string} url request url 359 | * @param {object} [qs={}] request query parameters 360 | */ 361 | export function createUrl(url: string, qs?: Record): string; 362 | 363 | /** 364 | * Creates a timeout signal which allows to specify 365 | * a timeout for a `fetch` operation via the `signal` option. 366 | * 367 | * @param {number} ms timeout in milliseconds 368 | */ 369 | export function timeoutSignal(ms: number): TimeoutSignal; 370 | 371 | /** 372 | * Clear the cache entirely, throwing away all values. 373 | */ 374 | export function clearCache(): void; 375 | 376 | /** 377 | * Cache stats for diagnostic purposes 378 | */ 379 | export function cacheStats(): CacheStats; 380 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Adobe. All rights reserved. 3 | * This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. You may obtain a copy 5 | * of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | * 7 | * Unless required by applicable law or agreed to in writing, software distributed under 8 | * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | * OF ANY KIND, either express or implied. See the License for the specific language 10 | * governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint-disable no-underscore-dangle */ 14 | import http from 'http'; 15 | import https from 'https'; 16 | import http2 from 'http2'; 17 | import { fork } from 'child_process'; 18 | import { randomBytes } from 'crypto'; 19 | import { readFile } from 'fs/promises'; 20 | import { dirname } from 'path'; 21 | import { fileURLToPath } from 'url'; 22 | import { promisify } from 'util'; 23 | import zlib from 'zlib'; 24 | 25 | const gzip = promisify(zlib.gzip); 26 | const deflate = promisify(zlib.deflate); 27 | const brotliCompress = promisify(zlib.brotliCompress); 28 | 29 | // Workaround for ES6 which doesn't support the NodeJS global __dirname 30 | const __dirname = dirname(fileURLToPath(import.meta.url)); 31 | 32 | const WOKEUP = 'woke up!'; 33 | const sleep = (ms) => new Promise((resolve) => { 34 | setTimeout(resolve, ms, WOKEUP); 35 | }); 36 | 37 | const HELLO_WORLD = 'Hello, World!'; 38 | 39 | const writeChunked = (buf, out, chunkSize) => { 40 | let off = 0; 41 | let processed = 0; 42 | 43 | while (processed < buf.length) { 44 | out.write(buf.subarray(off, off + chunkSize)); 45 | off += chunkSize; 46 | processed += chunkSize; 47 | } 48 | }; 49 | 50 | // remove h2 headers (e.g. :path) 51 | const sanitizeHeaders = (obj) => Object.fromEntries(Object.entries(obj) 52 | .map(([key, val]) => [key === ':authority' ? 'host' : key, val]) 53 | .filter(([key, _]) => !key.startsWith(':'))); 54 | 55 | class Server { 56 | constructor(httpMajorVersion = 2, secure = true, helloMsg = HELLO_WORLD, options = {}) { 57 | if (![1, 2].includes(httpMajorVersion)) { 58 | throw new Error(`Unsupported httpMajorVersion: ${httpMajorVersion}`); 59 | } 60 | this.httpMajorVersion = httpMajorVersion; 61 | this.secure = secure; 62 | this.server = null; 63 | this.helloMsg = helloMsg; 64 | this.options = { ...options }; 65 | this.connections = {}; 66 | this.sessions = new Set(); 67 | } 68 | 69 | async start(port = 0) { 70 | if (this.server) { 71 | throw Error('Server already started'); 72 | } 73 | 74 | await new Promise((resolve, reject) => { 75 | const reqHandler = async (req, res) => { 76 | const { pathname, searchParams } = new URL(req.url, `https://localhost:${this.server.address().port}`); 77 | let count; 78 | const data = []; 79 | 80 | switch (pathname) { 81 | case '/status/200': 82 | case '/status/201': 83 | case '/status/202': 84 | case '/status/203': 85 | case '/status/204': 86 | case '/status/308': 87 | case '/status/500': 88 | await sleep(+(searchParams.get('delay') || 0)); 89 | res.writeHead(+pathname.split('/')[2]); 90 | res.end(); 91 | break; 92 | 93 | case '/hello': 94 | await sleep(+(searchParams.get('delay') || 0)); 95 | res.writeHead(+(searchParams.get('status_code') || 200), { 'Content-Type': 'text/plain; charset=utf-8' }); 96 | res.end(this.helloMsg); 97 | break; 98 | 99 | case '/cache': 100 | await sleep(+(searchParams.get('delay') || 0)); 101 | res.writeHead( 102 | +(searchParams.get('status_code') || 200), 103 | { 104 | 'Content-Type': 'text/plain; charset=utf-8', 105 | 'Cache-Control': `public, max-age=${searchParams.get('max_age')}`, 106 | }, 107 | ); 108 | res.end(this.helloMsg); 109 | break; 110 | 111 | case '/cookies/set': 112 | res.statusCode = 200; 113 | searchParams.sort(); 114 | res.setHeader('Set-Cookie', searchParams.toString().split('&')); 115 | res.end(); 116 | break; 117 | 118 | case '/inspect': 119 | await sleep(+(searchParams.get('delay') || 0)); 120 | res.writeHead(200, { 'Content-Type': 'application/json' }); 121 | req.on('data', (chunk) => { 122 | data.push(chunk); 123 | }); 124 | req.on('end', () => { 125 | const buf = Buffer.concat(data); 126 | res.end(JSON.stringify({ 127 | method: req.method, 128 | url: req.url, 129 | headers: sanitizeHeaders(req.headers), 130 | body: buf.toString(), 131 | base64Body: buf.toString('base64'), 132 | })); 133 | }); 134 | break; 135 | 136 | case '/gzip': 137 | await sleep(+(searchParams.get('delay') || 0)); 138 | res.writeHead( 139 | 200, 140 | { 141 | 'Content-Type': 'text/plain; charset=utf-8', 142 | 'Content-Encoding': 'gzip', 143 | }, 144 | ); 145 | res.end(await gzip(this.helloMsg)); 146 | break; 147 | 148 | case '/deflate': 149 | await sleep(+(searchParams.get('delay') || 0)); 150 | res.writeHead( 151 | 200, 152 | { 153 | 'Content-Type': 'text/plain; charset=utf-8', 154 | 'Content-Encoding': 'deflate', 155 | }, 156 | ); 157 | res.end(await deflate(this.helloMsg)); 158 | break; 159 | 160 | case '/brotli': 161 | await sleep(+(searchParams.get('delay') || 0)); 162 | res.writeHead( 163 | 200, 164 | { 165 | 'Content-Type': 'text/plain; charset=utf-8', 166 | 'Content-Encoding': 'br', 167 | }, 168 | ); 169 | res.end(await brotliCompress(this.helloMsg)); 170 | break; 171 | 172 | case '/abort': 173 | await sleep(+(searchParams.get('delay') || 0)); 174 | // destroy current socket/session 175 | if (req.stream && req.stream.session) { 176 | // h2 server 177 | req.stream.session.destroy('aborted'); 178 | } else { 179 | // h1 server 180 | req.socket.destroy(); 181 | } 182 | break; 183 | 184 | case '/redirect-to': 185 | await sleep(+(searchParams.get('delay') || 0)); 186 | res.writeHead(+(searchParams.get('status_code') || 302), { Location: searchParams.get('url') }); 187 | res.end(); 188 | break; 189 | 190 | case '/redirect/1': 191 | case '/redirect/2': 192 | case '/redirect/3': 193 | case '/redirect/4': 194 | case '/redirect/5': 195 | await sleep(+(searchParams.get('delay') || 0)); 196 | count = +pathname.split('/')[2]; 197 | if (count > 1) { 198 | res.writeHead(302, { Location: `/redirect/${count - 1}` }); 199 | } else { 200 | res.writeHead(302, { Location: '/hello' }); 201 | } 202 | res.end(); 203 | break; 204 | 205 | case '/bytes': 206 | await sleep(+(searchParams.get('delay') || 0)); 207 | count = +(searchParams.get('count') || 32); 208 | res.writeHead(200, { 209 | 'Content-Type': 'application/octet-stream', 210 | 'Content-Length': `${count}`, 211 | }); 212 | res.end(randomBytes(count)); 213 | break; 214 | 215 | case '/stream-bytes': 216 | await sleep(+(searchParams.get('delay') || 0)); 217 | count = +(searchParams.get('count') || 32); 218 | res.writeHead(200, { 219 | 'Content-Type': 'application/octet-stream', 220 | }); 221 | writeChunked(randomBytes(count), res, 128); 222 | res.end(); 223 | break; 224 | 225 | default: 226 | res.writeHead(404); 227 | res.end('Not found'); 228 | } 229 | }; 230 | 231 | const createServer = async (handler) => { 232 | let options = {}; 233 | if (this.secure) { 234 | const keys = JSON.parse(await readFile(`${__dirname}/keys.json`)); 235 | options = { ...keys }; 236 | } 237 | // merge with user-provided options 238 | options = { ...options, ...this.options }; 239 | 240 | if (this.httpMajorVersion === 1) { 241 | return this.secure 242 | ? https.createServer(options, handler) : http.createServer(options, handler); 243 | } else { 244 | return this.secure 245 | ? http2.createSecureServer(options, handler) : http2.createServer(options, handler); 246 | } 247 | }; 248 | let resolved = false; 249 | createServer(reqHandler) 250 | .then((server) => { 251 | server.listen(port) 252 | .on('error', (err) => { 253 | if (!resolved) { 254 | reject(err); 255 | } 256 | }) 257 | .on('close', () => { 258 | this.server = null; 259 | this.sessions.clear(); 260 | }) 261 | .on('connection', (conn) => { 262 | const key = `${conn.remoteAddress}:${conn.remotePort}`; 263 | this.connections[key] = conn; 264 | conn.on('close', () => { 265 | delete this.connections[key]; 266 | }); 267 | }) 268 | .on('session', (session) => { 269 | // h2 specific 270 | this.sessions.add(session); 271 | session.on('close', () => { 272 | this.sessions.delete(session); 273 | }); 274 | }) 275 | .on('listening', () => { 276 | this.server = server; 277 | resolve(); 278 | resolved = true; 279 | }); 280 | }); 281 | }); 282 | } 283 | 284 | get port() { 285 | if (!this.server) { 286 | throw Error('Server not started'); 287 | } 288 | return this.server.address().port; 289 | } 290 | 291 | get origin() { 292 | const { port } = this; 293 | // eslint-disable-next-line no-nested-ternary 294 | const proto = this.secure ? 'https' : (this.httpMajorVersion === 2 ? 'http2' : 'http'); 295 | return `${proto}://localhost:${port}`; 296 | } 297 | 298 | async shutDown(force = false) { 299 | if (!this.server) { 300 | throw Error('server not started'); 301 | } 302 | if (this.sessions.size) { 303 | // h2 specific 304 | this.sessions.forEach((session) => { 305 | if (force) { 306 | session.destroy(); 307 | } else { 308 | session.close(); 309 | } 310 | }); 311 | } 312 | Object.keys(this.connections).forEach((key) => { 313 | if (force) { 314 | this.connections[key].destroy(); 315 | } else { 316 | this.connections[key].end(); 317 | } 318 | }); 319 | return new Promise((resolve, reject) => { 320 | this.server.close((err) => { 321 | if (err) { 322 | reject(err); 323 | } else { 324 | resolve(); 325 | } 326 | }); 327 | }); 328 | } 329 | 330 | async destroy() { 331 | return this.shutDown(true); 332 | } 333 | 334 | async close() { 335 | return this.shutDown(false); 336 | } 337 | 338 | async restart() { 339 | if (!this.server) { 340 | throw Error('server not started'); 341 | } 342 | const { port } = this; 343 | await this.destroy(); 344 | return this.start(port); 345 | } 346 | 347 | // eslint-disable-next-line max-len 348 | static async launch(httpMajorVersion = 2, secure = true, helloMsg = HELLO_WORLD, port = 0, options = {}) { 349 | const childProcess = fork(`${__dirname}/runServer.js`); 350 | return new Promise((resolve, reject) => { 351 | childProcess.send( 352 | { 353 | httpMajorVersion, secure, helloMsg, port, options, 354 | }, 355 | (err) => { 356 | if (err) { 357 | reject(err); 358 | } 359 | }, 360 | ); 361 | childProcess.on('message', (msg) => { 362 | // const { pid, port, origin } = msg; 363 | resolve(msg); 364 | }); 365 | }); 366 | } 367 | } 368 | 369 | export default Server; 370 | --------------------------------------------------------------------------------