├── .babelrc.js ├── .eslintrc ├── .github └── workflows │ ├── github-actions-demo.yml │ └── publish.yml ├── .gitignore ├── .npmignore ├── .nycrc ├── CHANGELOG.md ├── ERROR-HANDLING.md ├── LICENSE.md ├── LIMITS.md ├── README.md ├── README_DEV.md ├── build ├── babel-plugin.js └── rollup-plugin.js ├── codecov.yml ├── index.d.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── blob.js ├── body.js ├── common.js ├── fetch-error.js ├── headers.js ├── index.js ├── request.js └── response.js └── test ├── coverage-reporter.js ├── dummy.txt ├── server.js ├── test-typescript.ts └── test.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | 'env': { 5 | 'test': { 6 | 'presets': [ 7 | [ 8 | '@babel/preset-env', 9 | { 10 | 'loose': true, 11 | 'targets': { 12 | 'node': 6 13 | } 14 | } 15 | ] 16 | ], 17 | 'plugins': [ 18 | path.resolve('./build/babel-plugin.js') 19 | ] 20 | }, 21 | 'coverage': { 22 | 'presets': [ 23 | [ 24 | '@babel/preset-env', 25 | { 26 | 'loose': true, 27 | 'targets': { 28 | 'node': 6 29 | } 30 | } 31 | ] 32 | ], 33 | 'plugins': [ 34 | [ 35 | 'istanbul', 36 | { 37 | 'exclude': [ 38 | 'src/blob.js', 39 | 'build', 40 | 'test' 41 | ] 42 | } 43 | ], 44 | path.resolve('./build/babel-plugin.js') 45 | ] 46 | }, 47 | 'rollup': { 48 | 'presets': [ 49 | [ 50 | '@babel/preset-env', 51 | { 52 | 'loose': true, 53 | 'targets': { 54 | 'node': 6 55 | }, 56 | 'modules': false 57 | } 58 | ] 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard" 4 | ], 5 | "parser": "babel-eslint" 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/github-actions-demo.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | quality: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Install Node.js 20 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: 20.x 14 | cache: 'npm' 15 | - run: npm ci 16 | - run: npm run lint 17 | - run: npm run test:typings 18 | 19 | tests: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | electron-version: 24 | - 5.0.13 25 | - 6.1.12 26 | - 7.3.3 27 | - 8.5.5 28 | - 9.4.4 29 | - 10.4.7 30 | - 11.5.0 31 | - 12.2.3 32 | - 13.6.9 33 | - 14.2.9 34 | - 15.5.7 35 | - 16.2.8 36 | - 17.4.11 37 | - 18.3.15 38 | - 19.1.9 39 | - 20.3.12 40 | - 21.4.4 41 | - 22.3.27 42 | - 23.3.13 43 | - 24.8.8 44 | - 25.9.8 45 | - 26.6.10 46 | - 27.3.11 47 | - 28.3.3 48 | - 29.4.5 49 | - 30.2.0 50 | - 31.2.1 51 | formdata-version: 52 | - 4.0.0 53 | include: 54 | - electron-version: 31.2.1 55 | formdata-version: 1.0.0 56 | - electron-version: 31.2.1 57 | formdata-version: 2.5.1 58 | - electron-version: 31.2.1 59 | formdata-version: 3.0.1 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Install Node.js 20 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: 20.x 66 | cache: 'npm' 67 | - run: npm ci 68 | - run: if [ "${{ matrix.electron-version }}" ]; then npm install electron@^${{ matrix.electron-version }}; fi 69 | - run: if [ "${{ matrix.formdata-version }}" ]; then npm install form-data@^${{ matrix.formdata-version }}; fi 70 | - run: npm run report 71 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 20.x 14 | registry-url: 'https://registry.npmjs.org' 15 | cache: 'npm' 16 | - run: npm ci 17 | - run: npm run prepublishOnly 18 | - id: get_npm_label 19 | run: if (npx semver ${{ github.ref_name }} --range '>0.0.0'); then echo ::set-output name=NPM_LABEL::latest; else echo ::set-output name=NPM_LABEL::beta; fi; # Using the fact that semver by default considers that pre-releases do not respect stable ranges 20 | - run: npm publish --tag=${NPM_LABEL} --access public 21 | env: 22 | NPM_LABEL: ${{ steps.get_npm_label.outputs.NPM_LABEL }} 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like nyc and istanbul 14 | .nyc_output 15 | coverage 16 | cov 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # Dependency directory 25 | # Commenting this out is preferred by some people, see 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Users Environment Variables 30 | .lock-wscript 31 | 32 | # OS files 33 | .DS_Store 34 | 35 | # Babel-compiled files 36 | lib/**/* 37 | .idea/ 38 | 39 | # typescript declarations 40 | !lib/**/*.d.ts 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like nyc and istanbul 14 | .nyc_output 15 | coverage 16 | cov 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # Dependency directory 25 | # Commenting this out is preferred by some people, see 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 27 | node_modules 28 | 29 | # Users Environment Variables 30 | .lock-wscript 31 | 32 | # OS files 33 | .DS_Store 34 | 35 | .idea/ 36 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "babel-register" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | # electron-fetch 1.x 6 | 7 | ## Unreleased 8 | - Fix compatibility with node >= 19 by backporting https://github.com/node-fetch/node-fetch/pull/1765/files 9 | 10 | ## v1.9.1 11 | - Fix typings for FetchError's `code` attribute 12 | - Update dependencies 13 | 14 | ## v1.9.0 15 | - Fix handling of invalid headers (thanks wheezard) 16 | - Update dependencies 17 | 18 | ## v1.8.0 19 | - Fix typings for FetchError 20 | - Add 'onLogin' handler 21 | - Update dependencies 22 | 23 | ## V1.7.4 24 | - Fix typing of fetch function to accept RequestInfo 25 | - update dependencies 26 | 27 | ## V1.7.3 28 | - Fix execution in electron renderer process (it still does not make sense to use electron-fetch in renderer, so it runs only in node mode, but at least it does not crash) 29 | - update dependencies 30 | 31 | ## V1.7.2 32 | - Properly cancel request to server on a abort / timeout / error 33 | - update dependencies 34 | 35 | ## V1.7.1 36 | - Fix type declaration of `signal` parameter 37 | 38 | ## V1.7.0 39 | - Add AbortController support (thanks @Informatic) 40 | - Update all dependencies 41 | 42 | ## V1.6.0 43 | - Add option `useSessionCookies` to use session cookies when running on Electron >=7 (thanks @taratatach) 44 | - Update all dependencies 45 | 46 | ## V1.5.0 47 | - Fix requests with empty stream as body & tests on electron >= 7 (thanks @taratatach) 48 | - Update all dependencies 49 | 50 | ## V1.4.0 51 | - Fix a few problems with electron@7 (other things are still broken) 52 | - Add `agent` option when not using `electron.net` 53 | - Remove tolerance for slightly invalid GZip responses, as it is broken in recent node versions 54 | - Update all dependencies 55 | 56 | ## V1.3.0 57 | - Fix TypeScript typings & add tests so they cannot break again 58 | - Updating dependencies 59 | 60 | ## V1.2.0 61 | - Adding TypeScript typings (thanks @BurningEnlightenment) 62 | - Updating dependencies 63 | - Using electron's `defaultSession` by default 64 | 65 | ## V1.1.0 66 | 67 | - Added option to pass proxy credentials on Electron. Thanks @CharlieHess! 68 | - Fixed a bug where `session` was not passed correctly. Thanks @tex0l! 69 | 70 | ## v1.0.0 71 | 72 | First electron-fetch version 73 | 74 | - Made everything compatible with Electron's `net` module. 75 | - Removed node-fetch specific options `agent` and `compress`. 76 | - Added electron-specific option `session`. 77 | 78 | # node-fetch 2.x release (base of fork) 79 | 80 | ## v2.0.0 81 | 82 | This is a major release. Check [our upgrade guide](https://github.com/bitinn/node-fetch/blob/master/UPGRADE-GUIDE.md) for an overview on some key differences between v1 and v2. 83 | 84 | ### General changes 85 | 86 | - Major: Node.js 0.10.x and 0.12.x support is dropped 87 | - Major: `require('node-fetch/lib/response')` etc. is now unsupported; use `require('node-fetch').Response` or ES6 module imports 88 | - Enhance: start testing on Node.js 4, 6, 7 89 | - Enhance: use Rollup to produce a distributed bundle (less memory overhead and faster startup) 90 | - Enhance: make `Object.prototype.toString()` on Headers, Requests, and Responses return correct class strings 91 | - Other: rewrite in ES2015 using Babel 92 | - Other: use Codecov for code coverage tracking 93 | 94 | ### HTTP requests 95 | 96 | - Major: overwrite user's `Content-Length` if we can be sure our information is correct (per spec) 97 | - Fix: support WHATWG URL objects, created by `whatwg-url` package or `require('url').URL` in Node.js 7+ 98 | 99 | ### Response and Request classes 100 | 101 | - Major: `response.text()` no longer attempts to detect encoding, instead always opting for UTF-8 (per spec); use `response.textConverted()` for the v1 behavior 102 | - Major: make `response.json()` throw error instead of returning an empty object on 204 no-content respose (per spec; reverts behavior changed in v1.6.2) 103 | - Major: internal methods are no longer exposed 104 | - Major: throw error when a `GET` or `HEAD` Request is constructed with a non-null body (per spec) 105 | - Enhance: add `response.arrayBuffer()` (also applies to Requests) 106 | - Enhance: add experimental `response.blob()` (also applies to Requests) 107 | - Fix: fix Request and Response with `null` body 108 | 109 | ### Headers class 110 | 111 | - Major: remove `headers.getAll()`; make `get()` return all headers delimited by commas (per spec) 112 | - Enhance: make Headers iterable 113 | - Enhance: make Headers constructor accept an array of tuples 114 | - Enhance: make sure header names and values are valid in HTTP 115 | - Fix: coerce Headers prototype function parameters to strings, where applicable 116 | 117 | ### Documentation 118 | 119 | - Enhance: more comprehensive API docs 120 | - Enhance: add a list of default headers in README 121 | 122 | 123 | # node-fetch 1.x release 124 | 125 | ## v1.6.3 126 | 127 | - Enhance: error handling document to explain `FetchError` design 128 | - Fix: support `form-data` 2.x releases (requires `form-data` >= 2.1.0) 129 | 130 | ## v1.6.2 131 | 132 | - Enhance: minor document update 133 | - Fix: response.json() returns empty object on 204 no-content response instead of throwing a syntax error 134 | 135 | ## v1.6.1 136 | 137 | - Fix: if `res.body` is a non-stream non-formdata object, we will call `body.toString` and send it as a string 138 | - Fix: `counter` value is incorrectly set to `follow` value when wrapping Request instance 139 | - Fix: documentation update 140 | 141 | ## v1.6.0 142 | 143 | - Enhance: added `res.buffer()` api for convenience, it returns body as a Node.js buffer 144 | - Enhance: better old server support by handling raw deflate response 145 | - Enhance: skip encoding detection for non-HTML/XML response 146 | - Enhance: minor document update 147 | - Fix: HEAD request doesn't need decompression, as body is empty 148 | - Fix: `req.body` now accepts a Node.js buffer 149 | 150 | ## v1.5.3 151 | 152 | - Fix: handle 204 and 304 responses when body is empty but content-encoding is gzip/deflate 153 | - Fix: allow resolving response and cloned response in any order 154 | - Fix: avoid setting `content-length` when `form-data` body use streams 155 | - Fix: send DELETE request with content-length when body is present 156 | - Fix: allow any url when calling new Request, but still reject non-http(s) url in fetch 157 | 158 | ## v1.5.2 159 | 160 | - Fix: allow node.js core to handle keep-alive connection pool when passing a custom agent 161 | 162 | ## v1.5.1 163 | 164 | - Fix: redirect mode `manual` should work even when there is no redirection or broken redirection 165 | 166 | ## v1.5.0 167 | 168 | - Enhance: rejected promise now use custom `Error` (thx to @pekeler) 169 | - Enhance: `FetchError` contains `err.type` and `err.code`, allows for better error handling (thx to @pekeler) 170 | - Enhance: basic support for redirect mode `manual` and `error`, allows for location header extraction (thx to @jimmywarting for the initial PR) 171 | 172 | ## v1.4.1 173 | 174 | - Fix: wrapping Request instance with FormData body again should preserve the body as-is 175 | 176 | ## v1.4.0 177 | 178 | - Enhance: Request and Response now have `clone` method (thx to @kirill-konshin for the initial PR) 179 | - Enhance: Request and Response now have proper string and buffer body support (thx to @kirill-konshin) 180 | - Enhance: Body constructor has been refactored out (thx to @kirill-konshin) 181 | - Enhance: Headers now has `forEach` method (thx to @tricoder42) 182 | - Enhance: back to 100% code coverage 183 | - Fix: better form-data support (thx to @item4) 184 | - Fix: better character encoding detection under chunked encoding (thx to @dsuket for the initial PR) 185 | 186 | ## v1.3.3 187 | 188 | - Fix: make sure `Content-Length` header is set when body is string for POST/PUT/PATCH requests 189 | - Fix: handle body stream error, for cases such as incorrect `Content-Encoding` header 190 | - Fix: when following certain redirects, use `GET` on subsequent request per Fetch Spec 191 | - Fix: `Request` and `Response` constructors now parse headers input using `Headers` 192 | 193 | ## v1.3.2 194 | 195 | - Enhance: allow auto detect of form-data input (no `FormData` spec on node.js, this is form-data specific feature) 196 | 197 | ## v1.3.1 198 | 199 | - Enhance: allow custom host header to be set (server-side only feature, as it's a forbidden header on client-side) 200 | 201 | ## v1.3.0 202 | 203 | - Enhance: now `fetch.Request` is exposed as well 204 | 205 | ## v1.2.1 206 | 207 | - Enhance: `Headers` now normalized `Number` value to `String`, prevent common mistakes 208 | 209 | ## v1.2.0 210 | 211 | - Enhance: now fetch.Headers and fetch.Response are exposed, making testing easier 212 | 213 | ## v1.1.2 214 | 215 | - Fix: `Headers` should only support `String` and `Array` properties, and ignore others 216 | 217 | ## v1.1.1 218 | 219 | - Enhance: now req.headers accept both plain object and `Headers` instance 220 | 221 | ## v1.1.0 222 | 223 | - Enhance: timeout now also applies to response body (in case of slow response) 224 | - Fix: timeout is now cleared properly when fetch is done/has failed 225 | 226 | ## v1.0.6 227 | 228 | - Fix: less greedy content-type charset matching 229 | 230 | ## v1.0.5 231 | 232 | - Fix: when `follow = 0`, fetch should not follow redirect 233 | - Enhance: update tests for better coverage 234 | - Enhance: code formatting 235 | - Enhance: clean up doc 236 | 237 | ## v1.0.4 238 | 239 | - Enhance: test iojs support 240 | - Enhance: timeout attached to socket event only fire once per redirect 241 | 242 | ## v1.0.3 243 | 244 | - Fix: response size limit should reject large chunk 245 | - Enhance: added character encoding detection for xml, such as rss/atom feed (encoding in DTD) 246 | 247 | ## v1.0.2 248 | 249 | - Fix: added res.ok per spec change 250 | 251 | ## v1.0.0 252 | 253 | - Enhance: better test coverage and doc 254 | 255 | 256 | # node-fetch 0.x release 257 | 258 | ## v0.1 259 | 260 | - Major: initial public release 261 | -------------------------------------------------------------------------------- /ERROR-HANDLING.md: -------------------------------------------------------------------------------- 1 | 2 | Error handling with electron-fetch 3 | ============================== 4 | 5 | Because `window.fetch` isn't designed to transparent about the cause of request errors, we have to come up with our own solutions. 6 | 7 | The basics: 8 | 9 | - All [operational errors][joyent-guide] are rejected as [FetchError](https://github.com/arantes555/electron-fetch/blob/master/README.md#class-fetcherror), you can handle them all through promise `catch` clause. 10 | 11 | - All errors comes with `err.message` detailing the cause of errors. 12 | 13 | - All errors originated from `electron-fetch` are marked with custom `err.type`. 14 | 15 | - All errors originated from Electron's net module are marked with `err.type = 'system'`, and contains addition `err.code` and `err.errno` for error handling, they are alias to error codes thrown by Node.js core. 16 | 17 | - [Programmer errors][joyent-guide] are either thrown as soon as possible, or rejected with default `Error` with `err.message` for ease of troubleshooting. 18 | 19 | List of error types: 20 | 21 | - Because we maintain 100% coverage, see [test.js](https://github.com/arantes555/electron-fetch/blob/master/test/test.js) for a full list of custom `FetchError` types, as well as some of the common errors from Electron 22 | 23 | The limits: 24 | 25 | - If the servers responds with an incorrect or unknown content-encoding, Electron's net module throws an uncatchable error... (see https://github.com/electron/electron/issues/8867). 26 | 27 | [joyent-guide]: https://www.joyent.com/node-js/production/design/errors#operational-errors-vs-programmer-errors 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mehdi Kouhen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | Based on node-fetch, which has the following license: 25 | 26 | The MIT License (MIT) 27 | 28 | Copyright (c) 2016 David Frank 29 | 30 | Permission is hereby granted, free of charge, to any person obtaining a copy 31 | of this software and associated documentation files (the "Software"), to deal 32 | in the Software without restriction, including without limitation the rights 33 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 34 | copies of the Software, and to permit persons to whom the Software is 35 | furnished to do so, subject to the following conditions: 36 | 37 | The above copyright notice and this permission notice shall be included in all 38 | copies or substantial portions of the Software. 39 | 40 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 41 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 42 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 43 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 44 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 45 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 46 | SOFTWARE. 47 | -------------------------------------------------------------------------------- /LIMITS.md: -------------------------------------------------------------------------------- 1 | 2 | Known differences 3 | ================= 4 | 5 | *As of 1.x release* 6 | 7 | - Topics such as Cross-Origin, Content Security Policy, Mixed Content, Service Workers are ignored. 8 | 9 | - URL input must be an absolute URL, using either `http` or `https` as scheme. 10 | 11 | - On the upside, there are no forbidden headers. 12 | 13 | - `res.url` DOES NOT contain the final url when following redirects while running on Electron, due to a limit in Electron's net module (see https://github.com/electron/electron/issues/8868). 14 | 15 | - Impossible to control redirection behaviour when running on Electron (see https://github.com/electron/electron/issues/8868). 16 | 17 | - For convenience, `res.body` is a Node.js [Readable stream][readable-stream], so decoding can be handled independently. 18 | 19 | - Similarly, `req.body` can either be `null`, a string, a buffer or a Readable stream. 20 | 21 | - Also, you can handle rejected fetch requests through checking `err.type` and `err.code`. See [ERROR-HANDLING.md][] for more info. 22 | 23 | - Only support `res.text()`, `res.json()`, `res.blob()`, `res.arraybuffer()`, `res.buffer()` 24 | 25 | - There is currently no built-in caching, as server-side caching varies by use-cases. 26 | 27 | - Current implementation lacks cookie store, you will need to extract `Set-Cookie` headers manually. 28 | 29 | - If you are using `res.clone()` and writing an isomorphic app, note that stream on Node.js have a smaller internal buffer size (16Kb, aka `highWaterMark`) from client-side browsers (>1Mb, not consistent across browsers). 30 | 31 | - Cannot know if a certificate error happened when running on Electron (see https://github.com/electron/electron/issues/8074) 32 | 33 | - When running on Electron, if content-encoding is invalid an error is thrown. In node, it does not decompress content and passes it raw. 34 | 35 | [readable-stream]: https://nodejs.org/api/stream.html#stream_readable_streams 36 | [ERROR-HANDLING.md]: https://github.com/bitinn/node-fetch/blob/master/ERROR-HANDLING.md 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | electron-fetch 3 | ========== 4 | 5 | [![npm version][npm-image]][npm-url] 6 | [![build status][travis-image]][travis-url] 7 | [![coverage status][codecov-image]][codecov-url] 8 | 9 | A light-weight module that brings `window.fetch` to Electron's background process. 10 | Forked from [`node-fetch`](https://github.com/bitinn/node-fetch). 11 | 12 | ## Motivation 13 | 14 | Instead of implementing `XMLHttpRequest` over Electron's `net` module to run browser-specific [Fetch polyfill](https://github.com/github/fetch), why not go from native `net.request` to `fetch` API directly? Hence `electron-fetch`, minimal code for a `window.fetch` compatible API on Electron's background runtime. 15 | 16 | Why not simply use node-fetch? Well, Electron's `net` module does a better job than Node.js' `http` module at handling web proxies. 17 | 18 | 19 | ## Features 20 | 21 | - Stay consistent with `window.fetch` API. 22 | - Runs on both Electron and Node.js, using either Electron's `net` module, or Node.js `http` module as backend. 23 | - Make conscious trade-off when following [whatwg fetch spec][whatwg-fetch] and [stream spec](https://streams.spec.whatwg.org/) implementation details, document known difference. 24 | - Use native promise. 25 | - Use native stream for body, on both request and response. 26 | - Decode content encoding (gzip/deflate) properly, and convert string output (such as `res.text()` and `res.json()`) to UTF-8 automatically. 27 | - Useful extensions such as timeout, redirect limit (when running on Node.js), response size limit, [explicit errors][] for troubleshooting. 28 | 29 | 30 | ## Difference from client-side fetch 31 | 32 | - See [Known Differences](https://github.com/arantes555/electron-fetch/blob/master/LIMITS.md) for details. 33 | - If you happen to use a missing feature that `window.fetch` offers, feel free to open an issue. 34 | - Pull requests are welcomed too! 35 | 36 | 37 | ## Difference from node-fetch 38 | 39 | - Removed node-fetch specific options, such as `compression`. 40 | - Added electron-specific options to specify the `Session` & to enable using cookies from it. 41 | - Added electron-specific option `useElectronNet`, which can be set to false when running on Electron in order to behave as Node.js. 42 | - Removed possibility to use custom Promise implementation (it's 2018, `Promise` is available everywhere!). 43 | - Removed the possibility to forbid content compression (incompatible with Electron's `net` module, and of limited interest) 44 | - [`standard`-ized](http://standardjs.com) the code. 45 | 46 | ## Install 47 | 48 | ```sh 49 | $ npm install electron-fetch --save 50 | ``` 51 | 52 | 53 | ## Usage 54 | 55 | ```javascript 56 | import fetch from 'electron-fetch' 57 | // or 58 | // const fetch = require('electron-fetch').default 59 | 60 | // plain text or html 61 | 62 | fetch('https://github.com/') 63 | .then(res => res.text()) 64 | .then(body => console.log(body)) 65 | 66 | // json 67 | 68 | fetch('https://api.github.com/users/github') 69 | .then(res => res.json()) 70 | .then(json => console.log(json)) 71 | 72 | // catching network error 73 | // 3xx-5xx responses are NOT network errors, and should be handled in then() 74 | // you only need one catch() at the end of your promise chain 75 | 76 | fetch('http://domain.invalid/') 77 | .catch(err => console.error(err)) 78 | 79 | // stream 80 | // the node.js way is to use stream when possible 81 | 82 | fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png') 83 | .then(res => { 84 | const dest = fs.createWriteStream('./octocat.png') 85 | res.body.pipe(dest) 86 | }) 87 | 88 | // buffer 89 | // if you prefer to cache binary data in full, use buffer() 90 | // note that buffer() is a electron-fetch only API 91 | 92 | import fileType from 'file-type' 93 | 94 | fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png') 95 | .then(res => res.buffer()) 96 | .then(buffer => fileType(buffer)) 97 | .then(type => { /* ... */ }) 98 | 99 | // meta 100 | 101 | fetch('https://github.com/') 102 | .then(res => { 103 | console.log(res.ok) 104 | console.log(res.status) 105 | console.log(res.statusText) 106 | console.log(res.headers.raw()) 107 | console.log(res.headers.get('content-type')) 108 | }) 109 | 110 | // post 111 | 112 | fetch('http://httpbin.org/post', { method: 'POST', body: 'a=1' }) 113 | .then(res => res.json()) 114 | .then(json => console.log(json)) 115 | 116 | // post with stream from file 117 | 118 | import { createReadStream } from 'fs' 119 | 120 | const stream = createReadStream('input.txt') 121 | fetch('http://httpbin.org/post', { method: 'POST', body: stream }) 122 | .then(res => res.json()) 123 | .then(json => console.log(json)) 124 | 125 | // post with JSON 126 | 127 | const body = { a: 1 } 128 | fetch('http://httpbin.org/post', { 129 | method: 'POST', 130 | body: JSON.stringify(body), 131 | headers: { 'Content-Type': 'application/json' }, 132 | }) 133 | .then(res => res.json()) 134 | .then(json => console.log(json)) 135 | 136 | // post with form-data (detect multipart) 137 | 138 | import FormData from 'form-data' 139 | 140 | const form = new FormData() 141 | form.append('a', 1) 142 | fetch('http://httpbin.org/post', { method: 'POST', body: form }) 143 | .then(res => res.json()) 144 | .then(json => console.log(json)) 145 | 146 | // post with form-data (custom headers) 147 | // note that getHeaders() is non-standard API 148 | 149 | import FormData from 'form-data' 150 | 151 | const form = new FormData() 152 | form.append('a', 1) 153 | fetch('http://httpbin.org/post', { method: 'POST', body: form, headers: form.getHeaders() }) 154 | .then(res => res.json()) 155 | .then(json => console.log(json)) 156 | 157 | // node 7+ with async function 158 | 159 | (async function () { 160 | const res = await fetch('https://api.github.com/users/github') 161 | const json = await res.json() 162 | console.log(json) 163 | })() 164 | 165 | // providing proxy credentials (electron-specific) 166 | 167 | fetch(url, { 168 | onLogin (authInfo) { // this 'authInfo' is the one received by the 'login' event. See https://www.electronjs.org/docs/latest/api/client-request#event-login 169 | return Promise.resolve({ username: 'testuser', password: 'testpassword' }) 170 | } 171 | }) 172 | ``` 173 | 174 | See [test cases](https://github.com/arantes555/electron-fetch/blob/master/test/test.js) for more examples. 175 | 176 | 177 | ## API 178 | 179 | ### fetch(url[, options]) 180 | 181 | - `url` A string representing the URL for fetching 182 | - `options` [Options](#fetch-options) for the HTTP(S) request 183 | - Returns: Promise<[Response](#class-response)> 184 | 185 | Perform an HTTP(S) fetch. 186 | 187 | `url` should be an absolute url, such as `http://example.com/`. A path-relative URL (`/file/under/root`) or protocol-relative URL (`//can-be-http-or-https.com/`) will result in a rejected promise. 188 | 189 | 190 | #### Options 191 | 192 | The default values are shown after each option key. 193 | 194 | ```js 195 | const defaultOptions = { 196 | // These properties are part of the Fetch Standard 197 | method: 'GET', 198 | headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below) 199 | body: null, // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream 200 | redirect: 'follow', // (/!\ only works when running on Node.js) set to `manual` to extract redirect headers, `error` to reject redirect 201 | signal: null, // the AbortSignal from an AbortController instance. 202 | 203 | // The following properties are electron-fetch extensions 204 | follow: 20, // (/!\ only works when running on Node.js) maximum redirect count. 0 to not follow redirect 205 | timeout: 0, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) 206 | size: 0, // maximum response body size in bytes. 0 to disable 207 | session: session.defaultSession, // (/!\ only works when running on Electron) Electron Session object., 208 | agent: null, // (/!\ only works when useElectronNet is false) Node HTTP Agent., 209 | useElectronNet: true, // When running on Electron, defaults to true. On Node.js, defaults to false and cannot be set to true. 210 | useSessionCookies: true, // (/!\ only works when running on Electron >= 7) Whether or not to automatically send cookies from session., 211 | user: undefined, // When running on Electron behind an authenticated HTTP proxy, username to use to authenticate 212 | password: undefined, // When running on Electron behind an authenticated HTTP proxy, password to use to authenticate 213 | onLogin: undefined // When running on Electron behind an authenticated HTTP proxy, handler of electron.ClientRequest's login event. Can be used for acquiring proxy credentials in an async manner (e.g. prompting the user). Receives an `AuthInfo` object, and must return a `Promise<{ username: string, password: string }>`. 214 | } 215 | ``` 216 | 217 | If no agent is specified, the default agent provided by Node.js is used. Note that [this changed in Node.js 19](https://github.com/nodejs/node/blob/4267b92604ad78584244488e7f7508a690cb80d0/lib/_http_agent.js#L564) to have `keepalive` true by default. If you wish to enable `keepalive` in an earlier version of Node.js, you can override the agent as per the following code sample. 218 | 219 | ##### Default Headers 220 | 221 | If no values are set, the following request headers will be sent automatically: 222 | 223 | | Header | Value | 224 | |-------------------|----------------------------------------------------------------------| 225 | | `Accept-Encoding` | `gzip,deflate` | 226 | | `Accept` | `*/*` | 227 | | `Content-Length` | _(automatically calculated, if possible)_ | 228 | | `User-Agent` | `electron-fetch/1.0 (+https://github.com/arantes555/electron-fetch)` | 229 | 230 | 231 | ### Class: Request 232 | 233 | An HTTP(S) request containing information about URL, method, headers, and the body. This class implements the [Body](#iface-body) interface. 234 | 235 | Due to the nature of Node.js, the following properties are not implemented at this moment: 236 | 237 | - `type` 238 | - `destination` 239 | - `referrer` 240 | - `referrerPolicy` 241 | - `mode` 242 | - `credentials` 243 | - `cache` 244 | - `integrity` 245 | - `keepalive` 246 | 247 | The following electron-fetch extension properties are provided: 248 | 249 | - `follow` (/!\ only works when running on Node.js) 250 | - `counter` (/!\ only works when running on Node.js) 251 | - `session` (/!\ only works when running on Electron) 252 | - `agent` (/!\ only works when running on Node.js) 253 | - `useElectronNet` (/!\ only works when running on Electron, throws when set to true on Node.js) 254 | - `useSessionCookies` (/!\ only works when running on Electron >= 7. For electron < 11, it saves received cookies regardless of this option, but only sends them if true. For electron >= 11, it saves them only if true.) 255 | 256 | See [options](#fetch-options) for exact meaning of these extensions. 257 | 258 | #### new Request(input[, options]) 259 | 260 | *(spec-compliant)* 261 | 262 | - `input` A string representing a URL, or another `Request` (which will be cloned) 263 | - `options` [Options][#fetch-options] for the HTTP(S) request 264 | 265 | Constructs a new `Request` object. The constructor is identical to that in the [browser](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). 266 | 267 | In most cases, directly `fetch(url, options)` is simpler than creating a `Request` object. 268 | 269 | 270 | ### Class: Response 271 | 272 | An HTTP(S) response. This class implements the [Body](#iface-body) interface. 273 | 274 | The following properties are not implemented in electron-fetch at this moment: 275 | 276 | - `Response.error()` 277 | - `Response.redirect()` 278 | - `type` 279 | - `redirected` 280 | - `trailer` 281 | 282 | #### new Response([body[, options]]) 283 | 284 | *(spec-compliant)* 285 | 286 | - `body` A string or [Readable stream][node-readable] 287 | - `options` A [`ResponseInit`][response-init] options dictionary 288 | 289 | Constructs a new `Response` object. The constructor is identical to that in the [browser](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response). 290 | 291 | Because Node.js & Electron's background do not implement service workers (for which this class was designed), one rarely has to construct a `Response` directly. 292 | 293 | 294 | ### Class: Headers 295 | 296 | This class allows manipulating and iterating over a set of HTTP headers. All methods specified in the [Fetch Standard][whatwg-fetch] are implemented. 297 | 298 | #### new Headers([init]) 299 | 300 | *(spec-compliant)* 301 | 302 | - `init` Optional argument to pre-fill the `Headers` object 303 | 304 | Construct a new `Headers` object. `init` can be either `null`, a `Headers` object, an key-value map object, or any iterable object. 305 | 306 | ```js 307 | // Example adapted from https://fetch.spec.whatwg.org/#example-headers-class 308 | 309 | const meta = { 310 | 'Content-Type': 'text/xml', 311 | 'Breaking-Bad': '<3' 312 | } 313 | const headers = new Headers(meta) 314 | 315 | // The above is equivalent to 316 | const meta = [ 317 | [ 'Content-Type', 'text/xml' ], 318 | [ 'Breaking-Bad', '<3' ] 319 | ] 320 | const headers = new Headers(meta) 321 | 322 | // You can in fact use any iterable objects, like a Map or even another Headers 323 | const meta = new Map() 324 | meta.set('Content-Type', 'text/xml') 325 | meta.set('Breaking-Bad', '<3') 326 | const headers = new Headers(meta) 327 | const copyOfHeaders = new Headers(headers) 328 | ``` 329 | 330 | 331 | ### Interface: Body 332 | 333 | `Body` is an abstract interface with methods that are applicable to both `Request` and `Response` classes. 334 | 335 | The following methods are not yet implemented in electron-fetch at this moment: 336 | 337 | - `formData()` 338 | 339 | #### body.body 340 | 341 | *(deviation from spec)* 342 | 343 | * Node.js [`Readable` stream][node-readable] 344 | 345 | The data encapsulated in the `Body` object. Note that while the [Fetch Standard][whatwg-fetch] requires the property to always be a WHATWG `ReadableStream`, in electron-fetch it is a Node.js [`Readable` stream][node-readable]. 346 | 347 | #### body.bodyUsed 348 | 349 | *(spec-compliant)* 350 | 351 | * `Boolean` 352 | 353 | A boolean property for if this body has been consumed. Per spec, a consumed body cannot be used again. 354 | 355 | #### body.arrayBuffer() 356 | #### body.blob() 357 | #### body.json() 358 | #### body.text() 359 | 360 | *(spec-compliant)* 361 | 362 | * Returns: Promise 363 | 364 | Consume the body and return a promise that will resolve to one of these formats. 365 | 366 | #### body.buffer() 367 | 368 | *(electron-fetch extension)* 369 | 370 | * Returns: Promise<Buffer> 371 | 372 | Consume the body and return a promise that will resolve to a Buffer. 373 | 374 | #### body.textConverted() 375 | 376 | *(electron-fetch extension)* 377 | 378 | * Returns: Promise<String> 379 | 380 | Identical to `body.text()`, except instead of always converting to UTF-8, encoding sniffing will be performed and text converted to UTF-8, if possible. 381 | 382 | 383 | ### Class: FetchError 384 | 385 | *(electron-fetch extension)* 386 | 387 | An operational error in the fetching process. See [ERROR-HANDLING.md][] for more info. 388 | 389 | ## License 390 | 391 | MIT 392 | 393 | 394 | ## Acknowledgement 395 | 396 | Thanks to [github/fetch](https://github.com/github/fetch) for providing a solid implementation reference. 397 | Thanks to [node-fetch](https://github.com/bitinn/node-fetch) for providing a solid base to fork. 398 | 399 | 400 | [npm-image]: https://img.shields.io/npm/v/electron-fetch.svg?style=flat-square 401 | [npm-url]: https://www.npmjs.com/package/electron-fetch 402 | [travis-image]: https://img.shields.io/travis/com/arantes555/electron-fetch.svg?style=flat-square 403 | [travis-url]: https://travis-ci.com/arantes555/electron-fetch 404 | [codecov-image]: https://img.shields.io/codecov/c/github/arantes555/electron-fetch.svg?style=flat-square 405 | [codecov-url]: https://codecov.io/gh/arantes555/electron-fetch 406 | [ERROR-HANDLING.md]: https://github.com/arantes555/electron-fetch/blob/master/ERROR-HANDLING.md 407 | [whatwg-fetch]: https://fetch.spec.whatwg.org/ 408 | [response-init]: https://fetch.spec.whatwg.org/#responseinit 409 | [node-readable]: https://nodejs.org/api/stream.html#stream_readable_streams 410 | [mdn-headers]: https://developer.mozilla.org/en-US/docs/Web/API/Headers 411 | -------------------------------------------------------------------------------- /README_DEV.md: -------------------------------------------------------------------------------- 1 | - Be careful to keep Mocha fixed to the same version as electron-mocha's Mocha, in order for coverage-reporter to work 2 | -------------------------------------------------------------------------------- /build/babel-plugin.js: -------------------------------------------------------------------------------- 1 | // This Babel plugin makes it possible to do CommonJS-style function exports 2 | 3 | const walked = Symbol('walked') 4 | 5 | module.exports = ({ types: t }) => ({ 6 | visitor: { 7 | Program: { 8 | exit (program) { 9 | if (program[walked]) { 10 | return 11 | } 12 | 13 | for (const path of program.get('body')) { 14 | if (path.isExpressionStatement()) { 15 | const expr = path.get('expression') 16 | if (expr.isAssignmentExpression() && 17 | expr.get('left').matchesPattern('exports.*')) { 18 | const prop = expr.get('left').get('property') 19 | if (prop.isIdentifier({ name: 'default' })) { 20 | program.unshiftContainer('body', [ 21 | t.expressionStatement( 22 | t.assignmentExpression('=', 23 | t.identifier('exports'), 24 | t.assignmentExpression('=', 25 | t.memberExpression( 26 | t.identifier('module'), t.identifier('exports') 27 | ), 28 | expr.node.right 29 | ) 30 | ) 31 | ), 32 | t.expressionStatement( 33 | t.assignmentExpression('=', 34 | expr.node.left, t.identifier('exports') 35 | ) 36 | ) 37 | ]) 38 | path.remove() 39 | } 40 | } 41 | } 42 | } 43 | 44 | program[walked] = true 45 | } 46 | } 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /build/rollup-plugin.js: -------------------------------------------------------------------------------- 1 | export default function tweakDefault () { 2 | return { 3 | transformBundle: function (source) { 4 | const lines = source.split('\n') 5 | for (let i = 0; i < lines.length; i++) { 6 | const line = lines[i] 7 | const matches = /^exports\['default'] = (.*);$/.exec(line) 8 | if (matches) { 9 | lines[i] = 'module.exports = exports = ' + matches[1] + ';' 10 | break 11 | } 12 | } 13 | return lines.join('\n') 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | parsers: 2 | javascript: 3 | enable_partials: yes 4 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Stream } from 'stream' 2 | import { AuthInfo, Session } from 'electron' 3 | import { Agent } from 'https' 4 | 5 | export default fetch 6 | 7 | declare function fetch ( 8 | url: RequestInfo, 9 | options?: RequestInit 10 | ): Promise 11 | 12 | export enum FetchErrorType { 13 | BodyTimeout = "body-timeout", 14 | System = "system", 15 | MaxSize = "max-size", 16 | Abort = "abort", 17 | RequestTimeout = "request-timeout", 18 | Proxy = "proxy", 19 | NoRedirect = "no-redirect", 20 | MaxRedirect = "max-redirect", 21 | InvalidRedirect = "invalid-redirect", 22 | } 23 | 24 | export class FetchError extends Error { 25 | constructor(message: string, type: FetchErrorType, systemError?: { code: string }); 26 | type: string; 27 | code?: string; 28 | } 29 | 30 | export type HeadersInit = Headers | string[][] | { [key: string]: string } 31 | 32 | export class Headers { 33 | constructor (init?: HeadersInit) 34 | 35 | append (name: string, value: string): void 36 | 37 | delete (name: string): void 38 | 39 | get (name: string): string | null 40 | 41 | has (name: string): boolean 42 | 43 | set (name: string, value: string): void 44 | 45 | // WebIDL pair iterator: iterable 46 | entries (): IterableIterator<[string, string]> 47 | 48 | forEach (callback: (value: string, name: string, headers: Headers) => void, thisArg?: any): void 49 | 50 | keys (): IterableIterator 51 | 52 | values (): IterableIterator 53 | 54 | [Symbol.iterator] (): IterableIterator<[string, string]> 55 | } 56 | 57 | export type BodyInit = Stream | string | Blob | Buffer | null 58 | 59 | export interface Body { 60 | readonly bodyUsed: boolean 61 | 62 | arrayBuffer (): Promise 63 | 64 | blob (): Promise 65 | 66 | formData (): Promise 67 | 68 | json (): Promise 69 | 70 | text (): Promise 71 | 72 | buffer (): Promise 73 | } 74 | 75 | export class Response implements Body { 76 | constructor (body: BodyInit, init?: ResponseInit) 77 | 78 | readonly url: string 79 | readonly status: number 80 | readonly ok: boolean 81 | readonly statusText: string 82 | readonly headers: Headers 83 | readonly body: Readable | string 84 | 85 | clone (): Response 86 | 87 | // Body impl 88 | readonly bodyUsed: boolean 89 | 90 | arrayBuffer (): Promise 91 | 92 | blob (): Promise 93 | 94 | formData (): Promise 95 | 96 | json (): Promise 97 | 98 | text (): Promise 99 | 100 | buffer (): Promise 101 | } 102 | 103 | export interface RequestInit { 104 | // These properties are part of the Fetch Standard 105 | method?: string 106 | headers?: HeadersInit 107 | body?: BodyInit 108 | signal?: AbortSignal 109 | // (/!\ only works when running on Node.js) set to `manual` to extract redirect headers, `error` to reject redirect 110 | redirect?: RequestRedirect 111 | 112 | //////////////////////////////////////////////////////////////////////////// 113 | // The following properties are electron-fetch extensions 114 | 115 | // (/!\ only works when running on Node.js) maximum redirect count. 0 to not follow redirect 116 | follow?: number 117 | // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) 118 | timeout?: number 119 | // maximum response body size in bytes. 0 to disable 120 | size?: number 121 | session?: Session 122 | agent?: Agent, 123 | useElectronNet?: boolean 124 | useSessionCookies?: boolean 125 | // When running on Electron behind an authenticated HTTP proxy, username to use to authenticate 126 | user?: string 127 | // When running on Electron behind an authenticated HTTP proxy, password to use to authenticate 128 | password?: string 129 | /** 130 | * When running on Electron behind an authenticated HTTP proxy, handler of `electron.ClientRequest`'s `login` event. 131 | * Can be used for acquiring proxy credentials in an async manner (e.g. prompting the user). 132 | */ 133 | onLogin?: (authInfo: AuthInfo) => Promise<{ username: string, password: string } | undefined> 134 | } 135 | 136 | export type RequestInfo = Request | string 137 | 138 | export class Request implements Body { 139 | constructor (input: RequestInfo, init?: RequestInit) 140 | 141 | readonly method: string 142 | readonly url: string 143 | readonly headers: Headers 144 | 145 | readonly redirect: RequestRedirect 146 | readonly signal: AbortSignal 147 | 148 | clone (): Request 149 | 150 | //////////////////////////////////////////////////////////////////////////// 151 | // The following properties are electron-fetch extensions 152 | 153 | // (/!\ only works when running on Node.js) maximum redirect count. 0 to not follow redirect 154 | follow: number 155 | // (/!\ only works when running on Node.js) 156 | counter: number 157 | // (/!\ only works when running on Electron) 158 | session?: Session 159 | // (/!\ only works when running on Electron, throws when set to true on Node.js) 160 | useElectronNet: boolean 161 | // (/!\ only works when running on Electron) 162 | useSessionCookies?: boolean 163 | 164 | //////////////////////////////////////////////////////////////////////////// 165 | // Body impl 166 | readonly bodyUsed: boolean 167 | 168 | arrayBuffer (): Promise 169 | 170 | blob (): Promise 171 | 172 | formData (): Promise 173 | 174 | json (): Promise 175 | 176 | text (): Promise 177 | 178 | buffer (): Promise 179 | 180 | readonly body: Readable 181 | } 182 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-fetch", 3 | "version": "1.9.2-2", 4 | "description": "A light-weight module that brings window.fetch to electron's background process", 5 | "main": "lib/index.js", 6 | "module": "lib/index.es.js", 7 | "types": "index.d.ts", 8 | "files": [ 9 | "lib/index.js", 10 | "lib/index.es.js", 11 | "index.d.ts" 12 | ], 13 | "engines": { 14 | "node": ">=6" 15 | }, 16 | "scripts": { 17 | "build": "cross-env BABEL_ENV=rollup rollup -c", 18 | "prepublishOnly": "npm run build", 19 | "lint": "standard", 20 | "test": "npm run test:electron && npm run test:node && npm run test:typings && standard", 21 | "pretest:typings": "npm run build", 22 | "test:typings": "ts-node test/test-typescript.ts", 23 | "test:electron": "xvfb-maybe cross-env BABEL_ENV=test electron-mocha --require @babel/register test/test.js", 24 | "test:node": "cross-env BABEL_ENV=test mocha --require @babel/register test/test.js", 25 | "coverage": "xvfb-maybe cross-env BABEL_ENV=coverage electron-mocha --require @babel/register test/test.js --reporter test/coverage-reporter.js", 26 | "report": "npm run coverage && codecov -f coverage/coverage-final.json" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/arantes555/electron-fetch.git" 31 | }, 32 | "keywords": [ 33 | "fetch", 34 | "http", 35 | "promise", 36 | "electron" 37 | ], 38 | "author": "Mehdi Kouhen", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/arantes555/electron-fetch/issues" 42 | }, 43 | "homepage": "https://github.com/arantes555/electron-fetch", 44 | "devDependencies": { 45 | "@babel/core": "^7.19.3", 46 | "@babel/preset-env": "^7.19.3", 47 | "@babel/register": "^7.18.9", 48 | "abortcontroller-polyfill": "^1.7.3", 49 | "babel-eslint": "^10.1.0", 50 | "babel-plugin-istanbul": "^6.1.1", 51 | "basic-auth-parser": "0.0.2", 52 | "chai": "^4.3.6", 53 | "chai-as-promised": "^7.1.1", 54 | "codecov": "^3.8.3", 55 | "cross-env": "^7.0.3", 56 | "electron": "^31", 57 | "electron-mocha": "^11.0.2", 58 | "form-data": "^4.0.0", 59 | "is-builtin-module": "^3.2.0", 60 | "istanbul-api": "^3.0.0", 61 | "istanbul-lib-coverage": "^3.2.0", 62 | "mocha": "^10.0.0", 63 | "nyc": "^15.1.0", 64 | "parted": "^0.1.1", 65 | "promise": "^8.2.0", 66 | "proxy": "^1.0.2", 67 | "resumer": "0.0.0", 68 | "rollup": "^2.79.1", 69 | "rollup-plugin-babel": "^4.4.0", 70 | "standard": "^17.0.0", 71 | "stoppable": "^1.1.0", 72 | "ts-node": "^10.9.1", 73 | "typescript": "^4.8.4", 74 | "whatwg-url": "^11.0.0", 75 | "xvfb-maybe": "^0.2.1" 76 | }, 77 | "dependencies": { 78 | "encoding": "^0.1.13" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import isBuiltin from 'is-builtin-module' 2 | import babel from 'rollup-plugin-babel' 3 | import tweakDefault from './build/rollup-plugin' 4 | 5 | process.env.BABEL_ENV = 'rollup' 6 | 7 | export default { 8 | input: 'src/index.js', 9 | plugins: [ 10 | babel({ 11 | runtimeHelpers: true 12 | }), 13 | tweakDefault() 14 | ], 15 | output: [ 16 | { file: 'lib/index.js', format: 'cjs', exports: 'named' }, 17 | { file: 'lib/index.es.js', format: 'es' } 18 | ], 19 | external: function (id) { 20 | if (isBuiltin(id)) { 21 | return true 22 | } 23 | id = id.split('/').slice(0, id[0] === '@' ? 2 : 1).join('/') 24 | return !!require('./package.json').dependencies[id] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/blob.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/tmpvar/jsdom/blob/aa85b2abf07766ff7bf5c1f6daafb3726f2f2db5/lib/jsdom/living/blob.js 2 | // (MIT licensed) 3 | 4 | export const BUFFER = Symbol('buffer') 5 | const TYPE = Symbol('type') 6 | const CLOSED = Symbol('closed') 7 | 8 | export default class Blob { 9 | constructor () { 10 | Object.defineProperty(this, Symbol.toStringTag, { 11 | value: 'Blob', 12 | writable: false, 13 | enumerable: false, 14 | configurable: true 15 | }) 16 | 17 | this[CLOSED] = false 18 | this[TYPE] = '' 19 | 20 | const blobParts = arguments[0] 21 | const options = arguments[1] 22 | 23 | const buffers = [] 24 | 25 | if (blobParts) { 26 | const a = blobParts 27 | const length = Number(a.length) 28 | for (let i = 0; i < length; i++) { 29 | const element = a[i] 30 | let buffer 31 | if (element instanceof Buffer) { 32 | buffer = element 33 | } else if (ArrayBuffer.isView(element)) { 34 | buffer = Buffer.from(new Uint8Array(element.buffer, element.byteOffset, element.byteLength)) 35 | } else if (element instanceof ArrayBuffer) { 36 | buffer = Buffer.from(new Uint8Array(element)) 37 | } else if (element instanceof Blob) { 38 | buffer = element[BUFFER] 39 | } else { 40 | buffer = Buffer.from(typeof element === 'string' ? element : String(element)) 41 | } 42 | buffers.push(buffer) 43 | } 44 | } 45 | 46 | this[BUFFER] = Buffer.concat(buffers) 47 | 48 | const type = options && options.type !== undefined && String(options.type).toLowerCase() 49 | if (type && !/[^\u0020-\u007E]/.test(type)) { 50 | this[TYPE] = type 51 | } 52 | } 53 | 54 | get size () { 55 | return this[CLOSED] ? 0 : this[BUFFER].length 56 | } 57 | 58 | get type () { 59 | return this[TYPE] 60 | } 61 | 62 | get isClosed () { 63 | return this[CLOSED] 64 | } 65 | 66 | slice () { 67 | const size = this.size 68 | 69 | const start = arguments[0] 70 | const end = arguments[1] 71 | let relativeStart, relativeEnd 72 | if (start === undefined) { 73 | relativeStart = 0 74 | } else if (start < 0) { 75 | relativeStart = Math.max(size + start, 0) 76 | } else { 77 | relativeStart = Math.min(start, size) 78 | } 79 | if (end === undefined) { 80 | relativeEnd = size 81 | } else if (end < 0) { 82 | relativeEnd = Math.max(size + end, 0) 83 | } else { 84 | relativeEnd = Math.min(end, size) 85 | } 86 | const span = Math.max(relativeEnd - relativeStart, 0) 87 | 88 | const buffer = this[BUFFER] 89 | const slicedBuffer = buffer.slice( 90 | relativeStart, 91 | relativeStart + span 92 | ) 93 | const blob = new Blob([], { type: arguments[2] }) 94 | blob[BUFFER] = slicedBuffer 95 | blob[CLOSED] = this[CLOSED] 96 | return blob 97 | } 98 | 99 | close () { 100 | this[CLOSED] = true 101 | } 102 | } 103 | 104 | Object.defineProperty(Blob.prototype, Symbol.toStringTag, { 105 | value: 'BlobPrototype', 106 | writable: false, 107 | enumerable: false, 108 | configurable: true 109 | }) 110 | -------------------------------------------------------------------------------- /src/body.js: -------------------------------------------------------------------------------- 1 | /** 2 | * body.js 3 | * 4 | * Body interface provides common methods for Request and Response 5 | */ 6 | 7 | import { convert } from 'encoding' 8 | import Stream, { PassThrough } from 'stream' 9 | import Blob, { BUFFER } from './blob.js' 10 | import FetchError from './fetch-error.js' 11 | 12 | const DISTURBED = Symbol('disturbed') 13 | 14 | /** 15 | * Body class 16 | * 17 | * Cannot use ES6 class because Body must be called with .call(). 18 | * 19 | * @param {Stream|string|Blob|Buffer|null} body Readable stream 20 | * @param {number} size 21 | * @param {number} timeout 22 | */ 23 | export default function Body (body, { size = 0, timeout = 0 } = {}) { 24 | if (body == null) { 25 | // body is undefined or null 26 | body = null 27 | } else if (typeof body === 'string') { 28 | // body is string 29 | } else if (body instanceof Blob) { 30 | // body is blob 31 | } else if (Buffer.isBuffer(body)) { 32 | // body is buffer 33 | } else if (body instanceof Stream) { 34 | // body is stream 35 | } else { 36 | // none of the above 37 | // coerce to string 38 | body = String(body) 39 | } 40 | this.body = body 41 | this[DISTURBED] = false 42 | this.size = size 43 | this.timeout = timeout 44 | } 45 | 46 | Body.prototype = { 47 | get bodyUsed () { 48 | return this[DISTURBED] 49 | }, 50 | 51 | /** 52 | * Decode response as ArrayBuffer 53 | * 54 | * @return {Promise} 55 | */ 56 | arrayBuffer () { 57 | return consumeBody.call(this).then(buf => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) 58 | }, 59 | 60 | /** 61 | * Return raw response as Blob 62 | * 63 | * @return {Promise} 64 | */ 65 | blob () { 66 | const ct = (this.headers && this.headers.get('content-type')) || '' 67 | return consumeBody.call(this).then(buf => Object.assign( 68 | // Prevent copying 69 | new Blob([], { 70 | type: ct.toLowerCase() 71 | }), 72 | { 73 | [BUFFER]: buf 74 | } 75 | )) 76 | }, 77 | 78 | /** 79 | * Decode response as json 80 | * 81 | * @return {Promise} 82 | */ 83 | json () { 84 | return consumeBody.call(this).then(buffer => JSON.parse(buffer.toString())) 85 | }, 86 | 87 | /** 88 | * Decode response as text 89 | * 90 | * @return {Promise} 91 | */ 92 | text () { 93 | return consumeBody.call(this).then(buffer => buffer.toString()) 94 | }, 95 | 96 | /** 97 | * Decode response as buffer (non-spec api) 98 | * 99 | * @return {Promise} 100 | */ 101 | buffer () { 102 | return consumeBody.call(this) 103 | }, 104 | 105 | /** 106 | * Decode response as text, while automatically detecting the encoding and 107 | * trying to decode to UTF-8 (non-spec api) 108 | * 109 | * @return {Promise} 110 | */ 111 | textConverted () { 112 | return consumeBody.call(this).then(buffer => convertBody(buffer, this.headers)) 113 | } 114 | 115 | } 116 | 117 | Body.mixIn = function (proto) { 118 | for (const name of Object.getOwnPropertyNames(Body.prototype)) { 119 | // istanbul ignore else 120 | if (!(name in proto)) { 121 | const desc = Object.getOwnPropertyDescriptor(Body.prototype, name) 122 | Object.defineProperty(proto, name, desc) 123 | } 124 | } 125 | } 126 | 127 | /** 128 | * Decode buffers into utf-8 string 129 | * 130 | * @return {Promise} 131 | */ 132 | function consumeBody () { 133 | if (this[DISTURBED]) { 134 | return Promise.reject(new Error(`body used already for: ${this.url}`)) 135 | } 136 | 137 | this[DISTURBED] = true 138 | 139 | // body is null 140 | if (this.body === null) { 141 | return Promise.resolve(Buffer.alloc(0)) 142 | } 143 | 144 | // body is string 145 | if (typeof this.body === 'string') { 146 | return Promise.resolve(Buffer.from(this.body)) 147 | } 148 | 149 | // body is blob 150 | if (this.body instanceof Blob) { 151 | return Promise.resolve(this.body[BUFFER]) 152 | } 153 | 154 | // body is buffer 155 | if (Buffer.isBuffer(this.body)) { 156 | return Promise.resolve(this.body) 157 | } 158 | 159 | // istanbul ignore if: should never happen 160 | if (!(this.body instanceof Stream)) { 161 | return Promise.resolve(Buffer.alloc(0)) 162 | } 163 | 164 | // body is stream 165 | // get ready to actually consume the body 166 | const accum = [] 167 | let accumBytes = 0 168 | let abort = false 169 | 170 | return new Promise((resolve, reject) => { 171 | let resTimeout 172 | 173 | // allow timeout on slow response body 174 | if (this.timeout) { 175 | resTimeout = setTimeout(() => { 176 | abort = true 177 | reject(new FetchError(`Response timeout while trying to fetch ${this.url} (over ${this.timeout}ms)`, 'body-timeout')) 178 | this.body.emit('cancel-request') 179 | }, this.timeout) 180 | } 181 | 182 | // handle stream error, such as incorrect content-encoding 183 | this.body.on('error', err => { 184 | reject(new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err)) 185 | }) 186 | 187 | this.body.on('data', chunk => { 188 | if (abort || chunk === null) { 189 | return 190 | } 191 | 192 | if (this.size && accumBytes + chunk.length > this.size) { 193 | abort = true 194 | reject(new FetchError(`content size at ${this.url} over limit: ${this.size}`, 'max-size')) 195 | this.body.emit('cancel-request') 196 | return 197 | } 198 | 199 | accumBytes += chunk.length 200 | accum.push(chunk) 201 | }) 202 | 203 | this.body.on('end', () => { 204 | if (abort) { 205 | return 206 | } 207 | 208 | clearTimeout(resTimeout) 209 | resolve(Buffer.concat(accum)) 210 | }) 211 | }) 212 | } 213 | 214 | /** 215 | * Detect buffer encoding and convert to target encoding 216 | * ref: http://www.w3.org/TR/2011/WD-html5-20110113/parsing.html#determining-the-character-encoding 217 | * 218 | * @param {Buffer} buffer Incoming buffer 219 | * @param {Headers} headers 220 | * @return {string} 221 | */ 222 | function convertBody (buffer, headers) { 223 | const ct = headers.get('content-type') 224 | let charset = 'utf-8' 225 | let res 226 | 227 | // header 228 | if (ct) { 229 | res = /charset=([^;]*)/i.exec(ct) 230 | } 231 | 232 | // no charset in content type, peek at response body for at most 1024 bytes 233 | const str = buffer.slice(0, 1024).toString() 234 | 235 | // html5 236 | if (!res && str) { 237 | res = /= 94 && ch <= 122) { return true } 34 | if (ch >= 65 && ch <= 90) { 35 | return true 36 | } 37 | if (ch === 45) { 38 | return true 39 | } 40 | if (ch >= 48 && ch <= 57) { 41 | return true 42 | } 43 | if (ch === 34 || ch === 40 || ch === 41 || ch === 44) { 44 | return false 45 | } 46 | if (ch >= 33 && ch <= 46) { 47 | return true 48 | } 49 | if (ch === 124 || ch === 126) { 50 | return true 51 | } 52 | return false 53 | } 54 | // istanbul ignore next 55 | function checkIsHttpToken (val) { 56 | if (typeof val !== 'string' || val.length === 0) { return false } 57 | if (!isValidTokenChar(val.charCodeAt(0))) { 58 | return false 59 | } 60 | const len = val.length 61 | if (len > 1) { 62 | if (!isValidTokenChar(val.charCodeAt(1))) { return false } 63 | if (len > 2) { 64 | if (!isValidTokenChar(val.charCodeAt(2))) { 65 | return false 66 | } 67 | if (len > 3) { 68 | if (!isValidTokenChar(val.charCodeAt(3))) { 69 | return false 70 | } 71 | for (let i = 4; i < len; i++) { 72 | if (!isValidTokenChar(val.charCodeAt(i))) { 73 | return false 74 | } 75 | } 76 | } 77 | } 78 | } 79 | return true 80 | } 81 | export { checkIsHttpToken } 82 | 83 | /** 84 | * True if val contains an invalid field-vchar 85 | * field-value = *( field-content / obs-fold ) 86 | * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] 87 | * field-vchar = VCHAR / obs-text 88 | * 89 | * checkInvalidHeaderChar() is currently designed to be inlinable by v8, 90 | * so take care when making changes to the implementation so that the source 91 | * code size does not exceed v8's default max_inlined_source_size setting. 92 | **/ 93 | // istanbul ignore next 94 | function checkInvalidHeaderChar (val) { 95 | val += '' 96 | if (val.length < 1) { return false } 97 | let c = val.charCodeAt(0) 98 | if ((c <= 31 && c !== 9) || c > 255 || c === 127) { return true } 99 | if (val.length < 2) { 100 | return false 101 | } 102 | c = val.charCodeAt(1) 103 | if ((c <= 31 && c !== 9) || c > 255 || c === 127) { 104 | return true 105 | } 106 | if (val.length < 3) { 107 | return false 108 | } 109 | c = val.charCodeAt(2) 110 | if ((c <= 31 && c !== 9) || c > 255 || c === 127) { 111 | return true 112 | } 113 | for (let i = 3; i < val.length; ++i) { 114 | c = val.charCodeAt(i) 115 | if ((c <= 31 && c !== 9) || c > 255 || c === 127) { return true } 116 | } 117 | return false 118 | } 119 | export { checkInvalidHeaderChar } 120 | -------------------------------------------------------------------------------- /src/fetch-error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * fetch-error.js 3 | * 4 | * FetchError interface for operational errors 5 | */ 6 | 7 | /** 8 | * Create FetchError instance 9 | * 10 | * @param {string} message Error message for human 11 | * @param {string} type Error type for machine 12 | * @param {string} systemError For Node.js system error 13 | * @return {FetchError} 14 | */ 15 | 16 | const netErrorMap = { 17 | ERR_CONNECTION_REFUSED: 'ECONNREFUSED', 18 | ERR_EMPTY_RESPONSE: 'ECONNRESET', 19 | ERR_NAME_NOT_RESOLVED: 'ENOTFOUND', 20 | ERR_CONTENT_DECODING_FAILED: 'Z_DATA_ERROR', 21 | ERR_CONTENT_DECODING_INIT_FAILED: 'Z_DATA_ERROR' 22 | } 23 | 24 | export default function FetchError (message, type, systemError) { 25 | Error.call(this, message) 26 | const regex = /^.*net::(.*)/ 27 | if (regex.test(message)) { 28 | let errorCode = regex.exec(message)[1] 29 | // istanbul ignore else 30 | if (Object.prototype.hasOwnProperty.call(netErrorMap, errorCode)) errorCode = netErrorMap[errorCode] 31 | systemError = { code: errorCode } 32 | } 33 | this.message = message 34 | this.type = type 35 | 36 | // when err.type is `system`, err.code contains system error code 37 | if (systemError) { 38 | this.code = this.errno = systemError.code 39 | } 40 | 41 | // hide custom error implementation details from end-users 42 | Error.captureStackTrace(this, this.constructor) 43 | } 44 | 45 | FetchError.prototype = Object.create(Error.prototype) 46 | FetchError.prototype.constructor = FetchError 47 | FetchError.prototype.name = 'FetchError' 48 | -------------------------------------------------------------------------------- /src/headers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * headers.js 3 | * 4 | * Headers class offers convenient helpers 5 | */ 6 | 7 | import { checkInvalidHeaderChar, checkIsHttpToken } from './common.js' 8 | 9 | function sanitizeName (name) { 10 | name += '' 11 | if (!checkIsHttpToken(name)) { 12 | throw new TypeError(`${name} is not a legal HTTP header name`) 13 | } 14 | return name.toLowerCase() 15 | } 16 | 17 | function sanitizeValue (value) { 18 | value += '' 19 | if (checkInvalidHeaderChar(value)) { 20 | throw new TypeError(`${value} is not a legal HTTP header value`) 21 | } 22 | return value 23 | } 24 | 25 | const MAP = Symbol('map') 26 | export default class Headers { 27 | /** 28 | * Headers class 29 | * 30 | * @param {Object} init Response headers 31 | */ 32 | constructor (init = undefined) { 33 | this[MAP] = Object.create(null) 34 | 35 | // We don't worry about converting prop to ByteString here as append() 36 | // will handle it. 37 | if (init == null) { 38 | // no op 39 | } else if (typeof init === 'object') { 40 | const method = init[Symbol.iterator] 41 | if (method != null) { 42 | if (typeof method !== 'function') { 43 | throw new TypeError('Header pairs must be iterable') 44 | } 45 | 46 | // sequence> 47 | // Note: per spec we have to first exhaust the lists then process them 48 | const pairs = [] 49 | for (const pair of init) { 50 | if (typeof pair !== 'object' || typeof pair[Symbol.iterator] !== 'function') { 51 | throw new TypeError('Each header pair must be iterable') 52 | } 53 | pairs.push(Array.from(pair)) 54 | } 55 | 56 | for (const pair of pairs) { 57 | if (pair.length !== 2) { 58 | throw new TypeError('Each header pair must be a name/value tuple') 59 | } 60 | this.append(pair[0], pair[1]) 61 | } 62 | } else { 63 | // record 64 | for (const key of Object.keys(init)) { 65 | const value = init[key] 66 | this.append(key, value) 67 | } 68 | } 69 | } else { 70 | throw new TypeError('Provided initializer must be an object') 71 | } 72 | 73 | Object.defineProperty(this, Symbol.toStringTag, { 74 | value: 'Headers', 75 | writable: false, 76 | enumerable: false, 77 | configurable: true 78 | }) 79 | } 80 | 81 | /** 82 | * Return first header value given name 83 | * 84 | * @param {string} name Header name 85 | * @return {string} 86 | */ 87 | get (name) { 88 | const list = this[MAP][sanitizeName(name)] 89 | if (!list) { 90 | return null 91 | } 92 | 93 | return list.join(',') 94 | } 95 | 96 | /** 97 | * Iterate over all headers 98 | * 99 | * @param {function} callback Executed for each item with parameters (value, name, thisArg) 100 | * @param {boolean} thisArg `this` context for callback function 101 | */ 102 | forEach (callback, thisArg = undefined) { 103 | let pairs = getHeaderPairs(this) 104 | let i = 0 105 | while (i < pairs.length) { 106 | const [name, value] = pairs[i] 107 | callback.call(thisArg, value, name, this) 108 | pairs = getHeaderPairs(this) 109 | i++ 110 | } 111 | } 112 | 113 | /** 114 | * Overwrite header values given name 115 | * 116 | * @param {string} name Header name 117 | * @param {string|Array.|*} value Header value 118 | */ 119 | set (name, value) { 120 | this[MAP][sanitizeName(name)] = [sanitizeValue(value)] 121 | } 122 | 123 | /** 124 | * Append a value onto existing header 125 | * 126 | * @param {string} name Header name 127 | * @param {string|Array.|*} value Header value 128 | */ 129 | append (name, value) { 130 | if (!this.has(name)) { 131 | this.set(name, value) 132 | return 133 | } 134 | 135 | this[MAP][sanitizeName(name)].push(sanitizeValue(value)) 136 | } 137 | 138 | /** 139 | * Check for header name existence 140 | * 141 | * @param {string} name Header name 142 | * @return {boolean} 143 | */ 144 | has (name) { 145 | return !!this[MAP][sanitizeName(name)] 146 | } 147 | 148 | /** 149 | * Delete all header values given name 150 | * 151 | * @param {string} name Header name 152 | */ 153 | delete (name) { 154 | delete this[MAP][sanitizeName(name)] 155 | } 156 | 157 | /** 158 | * Return raw headers (non-spec api) 159 | * 160 | * @return {Object} 161 | */ 162 | raw () { 163 | return this[MAP] 164 | } 165 | 166 | /** 167 | * Get an iterator on keys. 168 | * 169 | * @return {Iterator} 170 | */ 171 | keys () { 172 | return createHeadersIterator(this, 'key') 173 | } 174 | 175 | /** 176 | * Get an iterator on values. 177 | * 178 | * @return {Iterator} 179 | */ 180 | values () { 181 | return createHeadersIterator(this, 'value') 182 | } 183 | 184 | /** 185 | * Get an iterator on entries. 186 | * 187 | * This is the default iterator of the Headers object. 188 | * 189 | * @return {Iterator} 190 | */ 191 | [Symbol.iterator] () { 192 | return createHeadersIterator(this, 'key+value') 193 | } 194 | } 195 | Headers.prototype.entries = Headers.prototype[Symbol.iterator] 196 | 197 | Object.defineProperty(Headers.prototype, Symbol.toStringTag, { 198 | value: 'HeadersPrototype', 199 | writable: false, 200 | enumerable: false, 201 | configurable: true 202 | }) 203 | 204 | function getHeaderPairs (headers, kind) { 205 | if (kind === 'key') return Object.keys(headers[MAP]).sort().map(k => [k]) 206 | const pairs = [] 207 | for (const key of Object.keys(headers[MAP]).sort()) { 208 | for (const value of headers[MAP][key]) { 209 | pairs.push([key, value]) 210 | } 211 | } 212 | return pairs 213 | } 214 | 215 | const INTERNAL = Symbol('internal') 216 | 217 | function createHeadersIterator (target, kind) { 218 | const iterator = Object.create(HeadersIteratorPrototype) 219 | iterator[INTERNAL] = { 220 | target, 221 | kind, 222 | index: 0 223 | } 224 | return iterator 225 | } 226 | 227 | const HeadersIteratorPrototype = Object.setPrototypeOf({ 228 | next () { 229 | // istanbul ignore if 230 | if (!this || 231 | Object.getPrototypeOf(this) !== HeadersIteratorPrototype) { 232 | throw new TypeError('Value of `this` is not a HeadersIterator') 233 | } 234 | 235 | const { 236 | target, 237 | kind, 238 | index 239 | } = this[INTERNAL] 240 | const values = getHeaderPairs(target, kind) 241 | const len = values.length 242 | if (index >= len) { 243 | return { 244 | value: undefined, 245 | done: true 246 | } 247 | } 248 | 249 | const pair = values[index] 250 | this[INTERNAL].index = index + 1 251 | 252 | let result 253 | if (kind === 'key') { 254 | result = pair[0] 255 | } else if (kind === 'value') { 256 | result = pair[1] 257 | } else { 258 | result = pair 259 | } 260 | 261 | return { 262 | value: result, 263 | done: false 264 | } 265 | } 266 | }, Object.getPrototypeOf( 267 | Object.getPrototypeOf([][Symbol.iterator]()) 268 | )) 269 | 270 | Object.defineProperty(HeadersIteratorPrototype, Symbol.toStringTag, { 271 | value: 'HeadersIterator', 272 | writable: false, 273 | enumerable: false, 274 | configurable: true 275 | }) 276 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * index.js 3 | * 4 | * a request API compatible with window.fetch 5 | */ 6 | 7 | // eslint-disable-next-line n/no-deprecated-api 8 | import { resolve as resolveURL } from 'url' 9 | import * as http from 'http' 10 | import * as https from 'https' 11 | import * as zlib from 'zlib' 12 | import { PassThrough } from 'stream' 13 | 14 | import { writeToStream } from './body' 15 | import Response from './response' 16 | import Headers from './headers' 17 | import Request, { getNodeRequestOptions } from './request' 18 | import FetchError from './fetch-error' 19 | 20 | let electron 21 | // istanbul ignore else 22 | if (process.versions.electron) { 23 | electron = require('electron') 24 | } 25 | 26 | const isReady = electron && electron.app && !electron.app.isReady() 27 | ? new Promise(resolve => electron.app.once('ready', resolve)) 28 | : Promise.resolve() 29 | 30 | /** 31 | * Fetch function 32 | * 33 | * @param {string|Request} url Absolute url or Request instance 34 | * @param {Object} [opts] Fetch options 35 | * @return {Promise} 36 | */ 37 | export default function fetch (url, opts = {}) { 38 | // wrap http.request into fetch 39 | return isReady.then(() => new Promise((resolve, reject) => { 40 | // build request object 41 | const request = new Request(url, opts) 42 | const options = getNodeRequestOptions(request) 43 | 44 | const send = request.useElectronNet 45 | ? electron.net.request 46 | : (options.protocol === 'https:' ? https : http).request 47 | 48 | // http.request only support string as host header, this hack make custom host header possible 49 | if (options.headers.host) { 50 | options.headers.host = options.headers.host[0] 51 | } 52 | 53 | if (request.signal && request.signal.aborted) { 54 | reject(new FetchError('request aborted', 'abort')) 55 | return 56 | } 57 | 58 | // send request 59 | let headers 60 | if (request.useElectronNet) { 61 | headers = options.headers 62 | delete options.headers 63 | options.session = opts.session || electron.session.defaultSession 64 | options.useSessionCookies = request.useSessionCookies 65 | } else { 66 | if (opts.agent) options.agent = opts.agent 67 | if (opts.onLogin) reject(new Error('"onLogin" option is only supported with "useElectronNet" enabled')) 68 | } 69 | const req = send(options) 70 | if (request.useElectronNet) { 71 | for (const headerName in headers) { 72 | if (typeof headers[headerName] === 'string') req.setHeader(headerName, headers[headerName]) 73 | else { 74 | for (const headerValue of headers[headerName]) { 75 | req.setHeader(headerName, headerValue) 76 | } 77 | } 78 | } 79 | } 80 | let reqTimeout 81 | 82 | const cancelRequest = () => { 83 | if (request.useElectronNet) { 84 | req.abort() // in electron, `req.destroy()` does not send abort to server 85 | } else { 86 | req.destroy() // in node.js, `req.abort()` is deprecated 87 | } 88 | } 89 | const abortRequest = () => { 90 | const err = new FetchError('request aborted', 'abort') 91 | reject(err) 92 | cancelRequest() 93 | req.emit('error', err) 94 | } 95 | 96 | if (request.signal) { 97 | request.signal.addEventListener('abort', abortRequest) 98 | } 99 | 100 | if (request.timeout) { 101 | reqTimeout = setTimeout(() => { 102 | const err = new FetchError(`network timeout at: ${request.url}`, 'request-timeout') 103 | reject(err) 104 | cancelRequest() 105 | }, request.timeout) 106 | } 107 | 108 | if (request.useElectronNet) { 109 | // handle authenticating proxies 110 | req.on('login', (authInfo, callback) => { 111 | if (opts.user && opts.password) { 112 | callback(opts.user, opts.password) 113 | } else if (opts.onLogin) { 114 | opts.onLogin(authInfo).then(credentials => { 115 | if (credentials) { 116 | callback(credentials.username, credentials.password) 117 | } else { 118 | callback() 119 | } 120 | }).catch(error => { 121 | cancelRequest() 122 | reject(error) 123 | }) 124 | } else { 125 | cancelRequest() 126 | reject(new FetchError(`login event received from ${authInfo.host} but no credentials or onLogin handler provided`, 'proxy', { code: 'PROXY_AUTH_FAILED' })) 127 | } 128 | }) 129 | } 130 | 131 | req.on('error', err => { 132 | clearTimeout(reqTimeout) 133 | if (request.signal) { 134 | request.signal.removeEventListener('abort', abortRequest) 135 | } 136 | 137 | reject(new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err)) 138 | }) 139 | 140 | req.on('abort', () => { 141 | clearTimeout(reqTimeout) 142 | if (request.signal) { 143 | request.signal.removeEventListener('abort', abortRequest) 144 | } 145 | }) 146 | 147 | req.on('response', res => { 148 | try { 149 | clearTimeout(reqTimeout) 150 | if (request.signal) { 151 | request.signal.removeEventListener('abort', abortRequest) 152 | } 153 | 154 | // handle redirect 155 | if (fetch.isRedirect(res.statusCode) && request.redirect !== 'manual') { 156 | if (request.redirect === 'error') { 157 | reject(new FetchError(`redirect mode is set to error: ${request.url}`, 'no-redirect')) 158 | return 159 | } 160 | 161 | if (request.counter >= request.follow) { 162 | reject(new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect')) 163 | return 164 | } 165 | 166 | if (!res.headers.location) { 167 | reject(new FetchError(`redirect location header missing at: ${request.url}`, 'invalid-redirect')) 168 | return 169 | } 170 | 171 | // per fetch spec, for POST request with 301/302 response, or any request with 303 response, use GET when following redirect 172 | if (res.statusCode === 303 || 173 | ((res.statusCode === 301 || res.statusCode === 302) && request.method === 'POST')) { 174 | request.method = 'GET' 175 | request.body = null 176 | request.headers.delete('content-length') 177 | } 178 | 179 | request.counter++ 180 | 181 | resolve(fetch(resolveURL(request.url, res.headers.location), request)) 182 | return 183 | } 184 | 185 | // normalize location header for manual redirect mode 186 | const headers = new Headers() 187 | for (const name of Object.keys(res.headers)) { 188 | if (Array.isArray(res.headers[name])) { 189 | for (const val of res.headers[name]) { 190 | headers.append(name, val) 191 | } 192 | } else { 193 | headers.append(name, res.headers[name]) 194 | } 195 | } 196 | if (request.redirect === 'manual' && headers.has('location')) { 197 | headers.set('location', resolveURL(request.url, headers.get('location'))) 198 | } 199 | 200 | // prepare response 201 | let body = new PassThrough() 202 | res.on('error', err => body.emit('error', err)) 203 | res.pipe(body) 204 | body.on('error', cancelRequest) 205 | body.on('cancel-request', cancelRequest) 206 | 207 | const abortBody = () => { 208 | res.destroy() 209 | res.emit('error', new FetchError('request aborted', 'abort')) // separated from the `.destroy()` because somehow Node's IncomingMessage streams do not emit errors on destroy 210 | } 211 | 212 | if (request.signal) { 213 | request.signal.addEventListener('abort', abortBody) 214 | res.on('end', () => { 215 | request.signal.removeEventListener('abort', abortBody) 216 | }) 217 | res.on('error', () => { 218 | request.signal.removeEventListener('abort', abortBody) 219 | }) 220 | } 221 | 222 | const responseOptions = { 223 | url: request.url, 224 | status: res.statusCode, 225 | statusText: res.statusMessage, 226 | headers, 227 | size: request.size, 228 | timeout: request.timeout, 229 | useElectronNet: request.useElectronNet, 230 | useSessionCookies: request.useSessionCookies 231 | } 232 | 233 | // HTTP-network fetch step 16.1.2 234 | const codings = headers.get('Content-Encoding') 235 | 236 | // HTTP-network fetch step 16.1.3: handle content codings 237 | 238 | // in following scenarios we ignore compression support 239 | // 1. running on Electron/net module (it manages it for us) 240 | // 2. HEAD request 241 | // 3. no Content-Encoding header 242 | // 4. no content response (204) 243 | // 5. content not modified response (304) 244 | if (!request.useElectronNet && request.method !== 'HEAD' && codings !== null && 245 | res.statusCode !== 204 && res.statusCode !== 304) { 246 | // Be less strict when decoding compressed responses, since sometimes 247 | // servers send slightly invalid responses that are still accepted 248 | // by common browsers. 249 | // Always using Z_SYNC_FLUSH is what cURL does. 250 | // /!\ This is disabled for now, because it seems broken in recent node 251 | // const zlibOptions = { 252 | // flush: zlib.Z_SYNC_FLUSH, 253 | // finishFlush: zlib.Z_SYNC_FLUSH 254 | // } 255 | 256 | if (codings === 'gzip' || codings === 'x-gzip') { // for gzip 257 | body = body.pipe(zlib.createGunzip()) 258 | } else if (codings === 'deflate' || codings === 'x-deflate') { // for deflate 259 | // handle the infamous raw deflate response from old servers 260 | // a hack for old IIS and Apache servers 261 | const raw = res.pipe(new PassThrough()) 262 | return raw.once('data', chunk => { 263 | // see http://stackoverflow.com/questions/37519828 264 | if ((chunk[0] & 0x0F) === 0x08) { 265 | body = body.pipe(zlib.createInflate()) 266 | } else { 267 | body = body.pipe(zlib.createInflateRaw()) 268 | } 269 | const response = new Response(body, responseOptions) 270 | resolve(response) 271 | }) 272 | } 273 | } 274 | 275 | const response = new Response(body, responseOptions) 276 | resolve(response) 277 | } catch (error) { 278 | reject(new FetchError(`Invalid response: ${error.message}`, 'invalid-response')) 279 | cancelRequest() 280 | } 281 | }) 282 | 283 | writeToStream(req, request) 284 | })) 285 | } 286 | 287 | /** 288 | * Redirect code matching 289 | * 290 | * @param {number} code Status code 291 | * @return {boolean} 292 | */ 293 | fetch.isRedirect = code => code === 301 || code === 302 || code === 303 || code === 307 || code === 308 294 | 295 | export { 296 | Headers, 297 | Request, 298 | Response, 299 | FetchError 300 | } 301 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | /** 2 | * request.js 3 | * 4 | * Request class contains server only options 5 | */ 6 | 7 | // eslint-disable-next-line n/no-deprecated-api 8 | import { format as formatURL, parse as parseURL } from 'url' 9 | import Headers from './headers.js' 10 | import Body, { clone, extractContentType, getTotalBytes } from './body' 11 | 12 | const PARSED_URL = Symbol('url') 13 | 14 | /** 15 | * Request class 16 | * 17 | * @param {string|Request} input Url or Request instance 18 | * @param {Object} init Custom options 19 | */ 20 | export default class Request { 21 | constructor (input, init = {}) { 22 | let parsedURL 23 | 24 | // normalize input 25 | if (!(input instanceof Request)) { 26 | if (input && input.href) { 27 | // in order to support Node.js' Url objects; though WHATWG's URL objects 28 | // will fall into this branch also (since their `toString()` will return 29 | // `href` property anyway) 30 | parsedURL = parseURL(input.href) 31 | } else { 32 | // coerce input to a string before attempting to parse 33 | parsedURL = parseURL(`${input}`) 34 | } 35 | input = {} 36 | } else { 37 | parsedURL = parseURL(input.url) 38 | } 39 | 40 | const method = init.method || input.method || 'GET' 41 | 42 | if ((init.body != null || (input instanceof Request && input.body !== null)) && 43 | (method === 'GET' || method === 'HEAD')) { 44 | throw new TypeError('Request with GET/HEAD method cannot have body') 45 | } 46 | 47 | const inputBody = init.body != null 48 | ? init.body 49 | : input instanceof Request && input.body !== null 50 | ? clone(input) 51 | : null 52 | 53 | Body.call(this, inputBody, { 54 | timeout: init.timeout || input.timeout || 0, 55 | size: init.size || input.size || 0 56 | }) 57 | 58 | // fetch spec options 59 | this.method = method.toUpperCase() 60 | this.redirect = init.redirect || input.redirect || 'follow' 61 | this.signal = init.signal || input.signal || null 62 | this.headers = new Headers(init.headers || input.headers || {}) 63 | this.headers.delete('Content-Length') // user cannot set content-length themself as per fetch spec 64 | this.chunkedEncoding = false 65 | this.useElectronNet = init.useElectronNet !== undefined // have to do this instead of || because it can be set to false 66 | ? init.useElectronNet 67 | : input.useElectronNet 68 | 69 | // istanbul ignore if 70 | if (this.useElectronNet && !process.versions.electron) throw new Error('Cannot use Electron/net module on Node.js!') 71 | 72 | if (this.useElectronNet === undefined) { 73 | this.useElectronNet = Boolean(process.versions.electron) 74 | } 75 | 76 | if (this.useElectronNet) { 77 | this.useSessionCookies = init.useSessionCookies !== undefined 78 | ? init.useSessionCookies 79 | : input.useSessionCookies 80 | } 81 | 82 | if (init.body != null) { 83 | const contentType = extractContentType(this) 84 | if (contentType !== null && !this.headers.has('Content-Type')) { 85 | this.headers.append('Content-Type', contentType) 86 | } 87 | } 88 | 89 | // server only options 90 | this.follow = init.follow !== undefined 91 | ? init.follow 92 | : input.follow !== undefined 93 | ? input.follow 94 | : 20 95 | this.counter = init.counter || input.counter || 0 96 | this.session = init.session || input.session 97 | 98 | this[PARSED_URL] = parsedURL 99 | Object.defineProperty(this, Symbol.toStringTag, { 100 | value: 'Request', 101 | writable: false, 102 | enumerable: false, 103 | configurable: true 104 | }) 105 | } 106 | 107 | get url () { 108 | return formatURL(this[PARSED_URL]) 109 | } 110 | 111 | /** 112 | * Clone this request 113 | * 114 | * @return {Request} 115 | */ 116 | clone () { 117 | return new Request(this) 118 | } 119 | } 120 | 121 | Body.mixIn(Request.prototype) 122 | 123 | Object.defineProperty(Request.prototype, Symbol.toStringTag, { 124 | value: 'RequestPrototype', 125 | writable: false, 126 | enumerable: false, 127 | configurable: true 128 | }) 129 | 130 | export function getNodeRequestOptions (request) { 131 | const parsedURL = request[PARSED_URL] 132 | const headers = new Headers(request.headers) 133 | 134 | // fetch step 3 135 | if (!headers.has('Accept')) { 136 | headers.set('Accept', '*/*') 137 | } 138 | 139 | // Basic fetch 140 | if (!parsedURL.protocol || !parsedURL.hostname) { 141 | throw new TypeError('Only absolute URLs are supported') 142 | } 143 | 144 | if (!/^https?:$/.test(parsedURL.protocol)) { 145 | throw new TypeError('Only HTTP(S) protocols are supported') 146 | } 147 | 148 | // HTTP-network-or-cache fetch steps 5-9 149 | let contentLengthValue = null 150 | if (request.body == null && /^(POST|PUT)$/i.test(request.method)) { 151 | contentLengthValue = '0' 152 | } 153 | if (request.body != null) { 154 | const totalBytes = getTotalBytes(request) 155 | if (typeof totalBytes === 'number') { 156 | contentLengthValue = String(totalBytes) 157 | } 158 | } 159 | if (contentLengthValue) { 160 | if (!request.useElectronNet) headers.set('Content-Length', contentLengthValue) 161 | } else { 162 | request.chunkedEncoding = true 163 | } 164 | 165 | // HTTP-network-or-cache fetch step 12 166 | if (!headers.has('User-Agent')) { 167 | headers.set('User-Agent', `electron-fetch/1.0 ${request.useElectronNet ? 'electron' : 'node'} (+https://github.com/arantes555/electron-fetch)`) 168 | } 169 | 170 | // HTTP-network-or-cache fetch step 16 171 | headers.set('Accept-Encoding', 'gzip,deflate') 172 | 173 | // HTTP-network fetch step 4 174 | // chunked encoding is handled by Node.js when not running in electron 175 | 176 | return Object.assign({}, parsedURL, { 177 | method: request.method, 178 | headers: headers.raw() 179 | }) 180 | } 181 | -------------------------------------------------------------------------------- /src/response.js: -------------------------------------------------------------------------------- 1 | /** 2 | * response.js 3 | * 4 | * Response class provides content decoding 5 | */ 6 | 7 | import { STATUS_CODES } from 'http' 8 | import Headers from './headers.js' 9 | import Body, { clone } from './body' 10 | 11 | /** 12 | * Response class 13 | * 14 | * @param {Stream} body Readable stream 15 | * @param {Object} opts Response options 16 | */ 17 | export default class Response { 18 | constructor (body = null, opts = {}) { 19 | Body.call(this, body, opts) 20 | 21 | this.url = opts.url 22 | this.status = opts.status || 200 23 | this.statusText = opts.statusText || STATUS_CODES[this.status] 24 | this.headers = new Headers(opts.headers) 25 | this.useElectronNet = opts.useElectronNet 26 | 27 | Object.defineProperty(this, Symbol.toStringTag, { 28 | value: 'Response', 29 | writable: false, 30 | enumerable: false, 31 | configurable: true 32 | }) 33 | } 34 | 35 | /** 36 | * Convenience property representing if the request ended normally 37 | */ 38 | get ok () { 39 | return this.status >= 200 && this.status < 300 40 | } 41 | 42 | /** 43 | * Clone this response 44 | * 45 | * @return {Response} 46 | */ 47 | clone () { 48 | return new Response(clone(this), { 49 | url: this.url, 50 | status: this.status, 51 | statusText: this.statusText, 52 | headers: this.headers, 53 | ok: this.ok, 54 | useElectronNet: this.useElectronNet 55 | }) 56 | } 57 | } 58 | 59 | Body.mixIn(Response.prototype) 60 | 61 | Object.defineProperty(Response.prototype, Symbol.toStringTag, { 62 | value: 'ResponsePrototype', 63 | writable: false, 64 | enumerable: false, 65 | configurable: true 66 | }) 67 | -------------------------------------------------------------------------------- /test/coverage-reporter.js: -------------------------------------------------------------------------------- 1 | // Inspired from https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL- 2 | const istanbulAPI = require('istanbul-api') // TODO: deprecated, change this 3 | const libCoverage = require('istanbul-lib-coverage') 4 | const specReporter = require('mocha/lib/reporters/spec.js') 5 | const inherits = require('mocha/lib/utils').inherits 6 | 7 | function Istanbul (runner) { 8 | specReporter.call(this, runner) 9 | 10 | runner.on('end', () => { 11 | const mainReporter = istanbulAPI.createReporter() 12 | const coverageMap = libCoverage.createCoverageMap() 13 | 14 | coverageMap.merge(global.__coverage__ || {}) 15 | 16 | mainReporter.addAll(['text', 'json', 'lcov']) 17 | mainReporter.write(coverageMap, {}) 18 | }) 19 | } 20 | 21 | inherits(Istanbul, specReporter) 22 | 23 | module.exports = Istanbul 24 | -------------------------------------------------------------------------------- /test/dummy.txt: -------------------------------------------------------------------------------- 1 | i am a dummy -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | import * as http from 'http' 2 | // eslint-disable-next-line n/no-deprecated-api 3 | import { parse } from 'url' 4 | import * as zlib from 'zlib' 5 | import { convert } from 'encoding' 6 | import { multipart as Multipart } from 'parted' 7 | import proxy from 'proxy' 8 | import basicAuthParser from 'basic-auth-parser' 9 | import stoppable from 'stoppable' 10 | 11 | export class TestServer { 12 | constructor ({ port = 30001 } = {}) { 13 | this.server = stoppable(http.createServer(this.getRouter()), 1000) 14 | this.port = port 15 | this.hostname = 'localhost' 16 | this.server.on('error', function (err) { 17 | console.log(err.stack) 18 | }) 19 | this.server.on('connection', function (socket) { 20 | socket.setTimeout(1500) 21 | }) 22 | this.inFlightRequests = 0 23 | } 24 | 25 | start (cb) { 26 | this.server.listen(this.port, '127.0.0.1', this.hostname, cb) 27 | } 28 | 29 | stop (cb) { 30 | this.server.stop(cb) 31 | } 32 | 33 | getRouter () { 34 | return (req, res) => { 35 | this.inFlightRequests++ 36 | res.on('close', () => { 37 | this.inFlightRequests-- 38 | }) 39 | const p = parse(req.url).pathname 40 | 41 | if (p === '/hello') { 42 | res.statusCode = 200 43 | res.setHeader('Content-Type', 'text/plain') 44 | res.end('world') 45 | } 46 | 47 | if (p === '/plain') { 48 | res.statusCode = 200 49 | res.setHeader('Content-Type', 'text/plain') 50 | res.end('text') 51 | } 52 | 53 | if (p === '/options') { 54 | res.statusCode = 200 55 | res.setHeader('Allow', 'GET, HEAD, OPTIONS') 56 | res.end('hello world') 57 | } 58 | 59 | if (p === '/html') { 60 | res.statusCode = 200 61 | res.setHeader('Content-Type', 'text/html') 62 | res.end('') 63 | } 64 | 65 | if (p === '/json') { 66 | res.statusCode = 200 67 | res.setHeader('Content-Type', 'application/json') 68 | res.end(JSON.stringify({ 69 | name: 'value' 70 | })) 71 | } 72 | 73 | if (p === '/gzip') { 74 | res.statusCode = 200 75 | res.setHeader('Content-Type', 'text/plain') 76 | res.setHeader('Content-Encoding', 'gzip') 77 | zlib.gzip('hello world', function (err, buffer) { 78 | if (err) console.error(err) 79 | res.end(buffer) 80 | }) 81 | } 82 | 83 | if (p === '/gzip-truncated') { 84 | res.statusCode = 200 85 | res.setHeader('Content-Type', 'text/plain') 86 | res.setHeader('Content-Encoding', 'gzip') 87 | zlib.gzip('hello world', function (err, buffer) { 88 | // truncate the CRC checksum and size check at the end of the stream 89 | if (err) console.error(err) 90 | res.end(buffer.slice(0, buffer.length - 8)) 91 | }) 92 | } 93 | 94 | if (p === '/deflate') { 95 | res.statusCode = 200 96 | res.setHeader('Content-Type', 'text/plain') 97 | res.setHeader('Content-Encoding', 'deflate') 98 | zlib.deflate('hello world', function (err, buffer) { 99 | if (err) console.error(err) 100 | res.end(buffer) 101 | }) 102 | } 103 | 104 | if (p === '/deflate-raw') { 105 | res.statusCode = 200 106 | res.setHeader('Content-Type', 'text/plain') 107 | res.setHeader('Content-Encoding', 'deflate') 108 | zlib.deflateRaw('hello world', function (err, buffer) { 109 | if (err) console.error(err) 110 | res.end(buffer) 111 | }) 112 | } 113 | 114 | if (p === '/sdch') { 115 | res.statusCode = 200 116 | res.setHeader('Content-Type', 'text/plain') 117 | res.setHeader('Content-Encoding', 'sdch') 118 | res.end('fake sdch string') 119 | } 120 | 121 | if (p === '/invalid-content-encoding') { 122 | res.statusCode = 200 123 | res.setHeader('Content-Type', 'text/plain') 124 | res.setHeader('Content-Encoding', 'gzip') 125 | res.end('fake gzip string') 126 | } 127 | 128 | if (p === '/timeout') { 129 | setTimeout(function () { 130 | res.statusCode = 200 131 | res.setHeader('Content-Type', 'text/plain') 132 | res.end('text') 133 | }, 1000) 134 | } 135 | 136 | if (p === '/slow') { 137 | res.statusCode = 200 138 | res.setHeader('Content-Type', 'text/plain') 139 | res.write('test') 140 | setTimeout(function () { 141 | res.end('test') 142 | }, 1000) 143 | } 144 | 145 | if (p === '/cookie') { 146 | res.statusCode = 200 147 | res.setHeader('Set-Cookie', ['a=1', 'b=1']) 148 | res.end('cookie') 149 | } 150 | 151 | if (p === '/size/chunk') { 152 | res.statusCode = 200 153 | res.setHeader('Content-Type', 'text/plain') 154 | setTimeout(function () { 155 | res.write('test') 156 | }, 50) 157 | setTimeout(function () { 158 | res.end('test') 159 | }, 100) 160 | } 161 | 162 | if (p === '/size/long') { 163 | res.statusCode = 200 164 | res.setHeader('Content-Type', 'text/plain') 165 | res.end('testtest') 166 | } 167 | 168 | if (p === '/encoding/gbk') { 169 | res.statusCode = 200 170 | res.setHeader('Content-Type', 'text/html') 171 | res.end(convert('
中文
', 'gbk')) 172 | } 173 | 174 | if (p === '/encoding/gb2312') { 175 | res.statusCode = 200 176 | res.setHeader('Content-Type', 'text/html') 177 | res.end(convert('
中文
', 'gb2312')) 178 | } 179 | 180 | if (p === '/encoding/shift-jis') { 181 | res.statusCode = 200 182 | res.setHeader('Content-Type', 'text/html; charset=Shift-JIS') 183 | res.end(convert('
日本語
', 'Shift_JIS')) 184 | } 185 | 186 | if (p === '/encoding/euc-jp') { 187 | res.statusCode = 200 188 | res.setHeader('Content-Type', 'text/xml') 189 | res.end(convert('日本語', 'EUC-JP')) 190 | } 191 | 192 | if (p === '/encoding/utf8') { 193 | res.statusCode = 200 194 | res.end('中文') 195 | } 196 | 197 | if (p === '/encoding/order1') { 198 | res.statusCode = 200 199 | res.setHeader('Content-Type', 'charset=gbk; text/plain') 200 | res.end(convert('中文', 'gbk')) 201 | } 202 | 203 | if (p === '/encoding/order2') { 204 | res.statusCode = 200 205 | res.setHeader('Content-Type', 'text/plain; charset=gbk; qs=1') 206 | res.end(convert('中文', 'gbk')) 207 | } 208 | 209 | if (p === '/encoding/chunked') { 210 | res.statusCode = 200 211 | res.setHeader('Content-Type', 'text/html') 212 | res.setHeader('Transfer-Encoding', 'chunked') 213 | res.write('a'.repeat(10)) 214 | res.end(convert('
日本語
', 'Shift_JIS')) 215 | } 216 | 217 | if (p === '/encoding/invalid') { 218 | res.statusCode = 200 219 | res.setHeader('Content-Type', 'text/html') 220 | res.setHeader('Transfer-Encoding', 'chunked') 221 | res.write('a'.repeat(1200)) 222 | res.end(convert('中文', 'gbk')) 223 | } 224 | 225 | if (p === '/redirect/301') { 226 | res.statusCode = 301 227 | res.setHeader('Location', '/inspect') 228 | res.end() 229 | } 230 | 231 | if (p === '/redirect/302') { 232 | res.statusCode = 302 233 | res.setHeader('Location', '/inspect') 234 | res.end() 235 | } 236 | 237 | if (p === '/redirect/303') { 238 | res.statusCode = 303 239 | res.setHeader('Location', '/inspect') 240 | res.end() 241 | } 242 | 243 | if (p === '/redirect/307') { 244 | res.statusCode = 307 245 | res.setHeader('Location', '/inspect') 246 | res.end() 247 | } 248 | 249 | if (p === '/redirect/308') { 250 | res.statusCode = 308 251 | res.setHeader('Location', '/inspect') 252 | res.end() 253 | } 254 | 255 | if (p === '/redirect/chain') { 256 | res.statusCode = 301 257 | res.setHeader('Location', '/redirect/301') 258 | res.end() 259 | } 260 | 261 | if (p === '/error/redirect') { 262 | res.statusCode = 301 263 | // res.setHeader('Location', '/inspect'); 264 | res.end() 265 | } 266 | 267 | if (p === '/error/400') { 268 | res.statusCode = 400 269 | res.setHeader('Content-Type', 'text/plain') 270 | res.end('client error') 271 | } 272 | 273 | if (p === '/error/404') { 274 | res.statusCode = 404 275 | res.setHeader('Content-Encoding', 'gzip') 276 | res.end() 277 | } 278 | 279 | if (p === '/error/500') { 280 | res.statusCode = 500 281 | res.setHeader('Content-Type', 'text/plain') 282 | res.end('server error') 283 | } 284 | 285 | if (p === '/error/reset') { 286 | res.destroy() 287 | } 288 | 289 | if (p === '/error/json') { 290 | res.statusCode = 200 291 | res.setHeader('Content-Type', 'application/json') 292 | res.end('invalid json') 293 | } 294 | 295 | if (p === '/no-content') { 296 | res.statusCode = 204 297 | res.end() 298 | } 299 | 300 | if (p === '/no-content/gzip') { 301 | res.statusCode = 204 302 | res.setHeader('Content-Encoding', 'gzip') 303 | res.end() 304 | } 305 | 306 | if (p === '/not-modified') { 307 | res.statusCode = 304 308 | res.end() 309 | } 310 | 311 | if (p === '/not-modified/gzip') { 312 | res.statusCode = 304 313 | res.setHeader('Content-Encoding', 'gzip') 314 | res.end() 315 | } 316 | 317 | if (p === '/inspect') { 318 | res.statusCode = 200 319 | res.setHeader('Content-Type', 'application/json') 320 | let body = '' 321 | req.on('data', function (c) { body += c }) 322 | req.on('end', function () { 323 | res.end(JSON.stringify({ 324 | method: req.method, 325 | url: req.url, 326 | headers: req.headers, 327 | body 328 | })) 329 | }) 330 | } 331 | 332 | if (p === '/multipart') { 333 | res.statusCode = 200 334 | res.setHeader('Content-Type', 'application/json') 335 | const parser = new Multipart(req.headers['content-type']) 336 | let body = '' 337 | parser.on('part', function (field, part) { 338 | body += field + '=' + part 339 | }) 340 | parser.on('end', function () { 341 | res.end(JSON.stringify({ 342 | method: req.method, 343 | url: req.url, 344 | headers: req.headers, 345 | body 346 | })) 347 | }) 348 | req.pipe(parser) 349 | } 350 | } 351 | } 352 | } 353 | 354 | export class TestProxy { 355 | constructor ({ credentials = null, port = 30002 } = {}) { 356 | this.port = port 357 | this.hostname = 'localhost' 358 | this.server = stoppable(proxy(http.createServer()), 1000) 359 | if (credentials && typeof credentials.username === 'string' && typeof credentials.password === 'string') { 360 | this.server.authenticate = (req, fn) => { 361 | const auth = req.headers['proxy-authorization'] 362 | if (!auth) { 363 | // optimization: don't invoke the child process if no 364 | // "Proxy-Authorization" header was given 365 | return fn(null, false) 366 | } 367 | const parsed = basicAuthParser(auth) 368 | return fn(null, parsed.username === credentials.username && parsed.password === credentials.password) 369 | } 370 | } 371 | this.server.on('error', function (err) { 372 | console.log(err.stack) 373 | }) 374 | this.server.on('connection', function (socket) { 375 | socket.setTimeout(1500) 376 | }) 377 | } 378 | 379 | start (cb) { 380 | this.server.listen(this.port, '127.0.0.1', cb) 381 | } 382 | 383 | stop (cb) { 384 | this.server.stop(cb) 385 | } 386 | } 387 | 388 | if (require.main === module) { 389 | const server = new TestServer() 390 | server.start(() => { 391 | console.log(`Server started listening at port ${server.port}`) 392 | }) 393 | } 394 | -------------------------------------------------------------------------------- /test/test-typescript.ts: -------------------------------------------------------------------------------- 1 | import fetch, { FetchError, Headers, Request, Response } from '../' 2 | 3 | import { ok } from 'assert' 4 | 5 | ok(typeof fetch === 'function') 6 | 7 | ok(typeof FetchError === 'function') 8 | 9 | ok(typeof Headers === 'function') 10 | 11 | ok(typeof Request === 'function') 12 | 13 | ok(typeof Response === 'function') 14 | 15 | console.log('typings look ok') 16 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable no-unused-expressions */ 3 | // test tools 4 | import chai from 'chai' 5 | import chaiPromised from 'chai-as-promised' 6 | import { spawn } from 'child_process' 7 | import * as stream from 'stream' 8 | import resumer from 'resumer' 9 | import FormData from 'form-data' 10 | // eslint-disable-next-line n/no-deprecated-api 11 | import { parse as parseURL } from 'url' 12 | import { URL } from 'whatwg-url' // TODO: remove 13 | import * as fs from 'fs' 14 | import { AbortController } from 'abortcontroller-polyfill/dist/cjs-ponyfill' 15 | 16 | import { TestProxy, TestServer } from './server' 17 | // test subjects 18 | import fetch, { FetchError, Headers, Request, Response } from '../src/' 19 | import FetchErrorOrig from '../src/fetch-error.js' 20 | import HeadersOrig from '../src/headers.js' 21 | import RequestOrig from '../src/request.js' 22 | import ResponseOrig from '../src/response.js' 23 | import Body from '../src/body.js' 24 | import Blob from '../src/blob.js' 25 | 26 | chai.use(chaiPromised) 27 | 28 | const { expect, assert } = chai 29 | 30 | const supportToString = ({ [Symbol.toStringTag]: 'z' }).toString() === '[object z]' 31 | 32 | const testServer = new TestServer() 33 | const unauthenticatedProxy = new TestProxy({ 34 | port: 30002 35 | }) 36 | const authenticatedProxy = new TestProxy({ 37 | credentials: { username: 'testuser', password: 'testpassword' }, 38 | port: 30003 39 | }) 40 | const base = `http://${testServer.hostname}:${testServer.port}/` 41 | let url, opts 42 | 43 | const isIterable = (value) => value != null && typeof value[Symbol.iterator] === 'function' 44 | const deepEqual = (value, expectedValue) => { 45 | try { 46 | assert.deepStrictEqual(value, expectedValue) 47 | return true 48 | } catch (err) { 49 | return false 50 | } 51 | } 52 | const deepIteratesOver = (value, expectedValue) => deepEqual(Array.from(value), Array.from(expectedValue)) 53 | 54 | before(function (done) { 55 | testServer.start(() => 56 | unauthenticatedProxy.start(() => 57 | authenticatedProxy.start(done))) 58 | }) 59 | 60 | after(function (done) { 61 | this.timeout(5000) 62 | testServer.stop(() => 63 | unauthenticatedProxy.stop(() => 64 | authenticatedProxy.stop(done) 65 | ) 66 | ) 67 | }) 68 | 69 | const createTestSuite = (useElectronNet) => { 70 | describe(`electron-fetch: ${useElectronNet ? 'electron' : 'node'}`, () => { 71 | afterEach('Check server connexion closed', () => 72 | new Promise(resolve => setTimeout((resolve), 10)) 73 | .then(() => { 74 | if (testServer.inFlightRequests !== 0) throw new Error('Server request not finished') 75 | }) 76 | ) 77 | 78 | it('should return a promise', function () { 79 | url = 'http://example.com/' 80 | const p = fetch(url, { useElectronNet }) 81 | expect(p).to.be.an.instanceof(Promise) 82 | expect(p).to.respondTo('then') 83 | }) 84 | 85 | it('should expose Headers, Response and Request constructors', function () { 86 | expect(FetchError).to.equal(FetchErrorOrig) 87 | expect(Headers).to.equal(HeadersOrig) 88 | expect(Response).to.equal(ResponseOrig) 89 | expect(Request).to.equal(RequestOrig) 90 | }) 91 | 92 | if (supportToString) { 93 | it('should support proper toString output for Headers, Response and Request objects', function () { 94 | expect(new Headers().toString()).to.equal('[object Headers]') 95 | expect(new Response().toString()).to.equal('[object Response]') 96 | expect(new Request(base).toString()).to.equal('[object Request]') 97 | }) 98 | } 99 | 100 | it('should reject with error if url is protocol relative', function () { 101 | url = '//example.com/' 102 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejectedWith(TypeError, 'Only absolute URLs are supported') 103 | }) 104 | 105 | it('should reject with error if url is relative path', function () { 106 | url = '/some/path' 107 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejectedWith(TypeError, 'Only absolute URLs are supported') 108 | }) 109 | 110 | it('should reject with error if protocol is unsupported', function () { 111 | url = 'ftp://example.com/' 112 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejectedWith(TypeError, 'Only HTTP(S) protocols are supported') 113 | }) 114 | 115 | it('should reject with error on network failure', function () { 116 | this.timeout(5000) // on windows, 2s are not enough to get the network failure 117 | url = 'http://localhost:50000/' 118 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejected 119 | .and.be.an.instanceOf(FetchError) 120 | .and.include({ type: 'system', code: 'ECONNREFUSED', errno: 'ECONNREFUSED' }) 121 | }) 122 | 123 | it('should resolve into response', function () { 124 | url = `${base}hello` 125 | return fetch(url, { useElectronNet }).then(res => { 126 | expect(res).to.be.an.instanceof(Response) 127 | expect(res.headers).to.be.an.instanceof(Headers) 128 | expect(res.body).to.be.an.instanceof(stream.Transform) 129 | expect(res.bodyUsed).to.be.false 130 | 131 | expect(res.url).to.equal(url) 132 | expect(res.ok).to.be.true 133 | expect(res.status).to.equal(200) 134 | expect(res.statusText).to.equal('OK') 135 | }) 136 | }) 137 | 138 | it('should accept plain text response', function () { 139 | url = `${base}plain` 140 | return fetch(url, { useElectronNet }).then(res => { 141 | expect(res.headers.get('content-type')).to.equal('text/plain') 142 | return res.text().then(result => { 143 | expect(res.bodyUsed).to.be.true 144 | expect(result).to.be.a('string') 145 | expect(result).to.equal('text') 146 | }) 147 | }) 148 | }) 149 | 150 | it('should accept html response (like plain text)', function () { 151 | url = `${base}html` 152 | return fetch(url, { useElectronNet }).then(res => { 153 | expect(res.headers.get('content-type')).to.equal('text/html') 154 | return res.text().then(result => { 155 | expect(res.bodyUsed).to.be.true 156 | expect(result).to.be.a('string') 157 | expect(result).to.equal('') 158 | }) 159 | }) 160 | }) 161 | 162 | it('should accept json response', function () { 163 | url = `${base}json` 164 | return fetch(url, { useElectronNet }).then(res => { 165 | expect(res.headers.get('content-type')).to.equal('application/json') 166 | return res.json().then(result => { 167 | expect(res.bodyUsed).to.be.true 168 | expect(result).to.be.an('object') 169 | expect(result).to.deep.equal({ name: 'value' }) 170 | }) 171 | }) 172 | }) 173 | 174 | it('should send request with custom headers', function () { 175 | url = `${base}inspect` 176 | opts = { 177 | headers: { 'x-custom-header': 'abc' }, 178 | useElectronNet 179 | } 180 | return fetch(url, opts).then(res => { 181 | return res.json() 182 | }).then(res => { 183 | expect(res.headers['x-custom-header']).to.equal('abc') 184 | }) 185 | }) 186 | 187 | it('should send request with custom Cookie headers', function () { 188 | url = `${base}inspect` 189 | opts = { 190 | headers: { Cookie: 'toto=tata' }, 191 | useElectronNet 192 | } 193 | return fetch(url, opts).then(res => { 194 | return res.json() 195 | }).then(res => { 196 | expect(res.headers.cookie).to.equal('toto=tata') 197 | }) 198 | }) 199 | 200 | it('should accept headers instance', function () { 201 | url = `${base}inspect` 202 | opts = { 203 | headers: new Headers({ 'x-custom-header': 'abc' }), 204 | useElectronNet 205 | } 206 | return fetch(url, opts).then(res => { 207 | return res.json() 208 | }).then(res => { 209 | expect(res.headers['x-custom-header']).to.equal('abc') 210 | }) 211 | }) 212 | 213 | if (useElectronNet) { 214 | // for some reason, Node.js parses the header value differently 215 | // so this test doesn't work in node, only in electron 216 | it('should reject with error when headers contain invalid symbols', function () { 217 | // This test somehow fails 80% of the time in CI... 218 | // probably because the test matrix overloads the remote server or something? 219 | if (process.env.CI) return this.skip() 220 | url = 'https://www.gov.am/en/' 221 | // node doesn't allow setting an invalid header, so have to use an external resource 222 | opts = { 223 | useElectronNet 224 | } 225 | return expect(fetch(url, opts)).to.eventually.be.rejected 226 | .and.be.an.instanceOf(FetchError) 227 | .and.satisfy(({ message }) => message.includes('Invalid response:'), 'Message does not contain the string `Invalid response:`') 228 | }) 229 | } 230 | 231 | it('should accept custom host header', function () { 232 | if (useElectronNet && parseInt(process.versions.electron) >= 7) return this.skip() // https://github.com/electron/electron/issues/21148 233 | url = `${base}inspect` 234 | opts = { 235 | headers: { 236 | host: 'example.com' 237 | }, 238 | useElectronNet 239 | } 240 | return fetch(url, opts).then(res => { 241 | return res.json() 242 | }).then(res => { 243 | expect(res.headers.host).to.equal('example.com') 244 | }) 245 | }) 246 | 247 | it('should accept connection header', function () { 248 | url = `${base}inspect` 249 | opts = { 250 | headers: { 251 | connection: 'close' 252 | }, 253 | useElectronNet 254 | } 255 | return fetch(url, opts).then(res => { 256 | return res.json() 257 | }).then(res => { 258 | expect(res.headers.connection).to.equal('close') 259 | }) 260 | }) 261 | 262 | it('should follow redirect code 301', function () { 263 | url = `${base}redirect/301` 264 | return fetch(url, { useElectronNet }).then(res => { 265 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) // actually follows the redirects, just does not update the res.url ... 266 | expect(res.status).to.equal(200) 267 | expect(res.ok).to.be.true 268 | }) 269 | }) 270 | 271 | it('should follow redirect code 302', function () { 272 | url = `${base}redirect/302` 273 | return fetch(url, { useElectronNet }).then(res => { 274 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 275 | expect(res.status).to.equal(200) 276 | }) 277 | }) 278 | 279 | it('should follow redirect code 303', function () { 280 | url = `${base}redirect/303` 281 | return fetch(url, { useElectronNet }).then(res => { 282 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 283 | expect(res.status).to.equal(200) 284 | }) 285 | }) 286 | 287 | it('should follow redirect code 307', function () { 288 | url = `${base}redirect/307` 289 | return fetch(url, { useElectronNet }).then(res => { 290 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 291 | expect(res.status).to.equal(200) 292 | }) 293 | }) 294 | 295 | it('should follow redirect code 308', function () { 296 | url = `${base}redirect/308` 297 | return fetch(url, { useElectronNet }).then(res => { 298 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 299 | expect(res.status).to.equal(200) 300 | }) 301 | }) 302 | 303 | it('should follow redirect chain', function () { 304 | url = `${base}redirect/chain` 305 | return fetch(url, { useElectronNet }).then(res => { 306 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 307 | expect(res.status).to.equal(200) 308 | }) 309 | }) 310 | 311 | it('should follow POST request redirect code 301 with GET', function () { 312 | url = `${base}redirect/301` 313 | opts = { 314 | method: 'POST', 315 | body: 'a=1', 316 | useElectronNet 317 | } 318 | return fetch(url, opts).then(res => { 319 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 320 | expect(res.status).to.equal(200) 321 | return res.json().then(result => { 322 | expect(result.method).to.equal('GET') 323 | expect(result.body).to.equal('') 324 | }) 325 | }) 326 | }) 327 | 328 | it('should follow POST request redirect code 302 with GET', function () { 329 | url = `${base}redirect/302` 330 | opts = { 331 | method: 'POST', 332 | body: 'a=1', 333 | useElectronNet 334 | } 335 | return fetch(url, opts).then(res => { 336 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 337 | expect(res.status).to.equal(200) 338 | return res.json().then(result => { 339 | expect(result.method).to.equal('GET') 340 | expect(result.body).to.equal('') 341 | }) 342 | }) 343 | }) 344 | 345 | it('should follow redirect code 303 with GET', function () { 346 | url = `${base}redirect/303` 347 | opts = { 348 | method: 'PUT', 349 | body: 'a=1', 350 | useElectronNet 351 | } 352 | return fetch(url, opts).then(res => { 353 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) 354 | expect(res.status).to.equal(200) 355 | return res.json().then(result => { 356 | expect(result.method).to.equal('GET') 357 | expect(result.body).to.equal('') 358 | }) 359 | }) 360 | }) 361 | 362 | if (useElectronNet) { 363 | it('should default to using electron net module', function () { 364 | url = `${base}inspect` 365 | return fetch(url) 366 | .then(res => { 367 | expect(res.useElectronNet).to.be.true 368 | return res.json() 369 | }) 370 | .then(resBody => { 371 | expect(resBody.headers['user-agent']).to.satisfy(s => s.startsWith('electron-fetch/1.0 electron')) 372 | }) 373 | }) 374 | } else { 375 | it('should obey maximum redirect, reject case', function () { // Not compatible with electron.net 376 | url = `${base}redirect/chain` 377 | opts = { 378 | follow: 1, 379 | useElectronNet 380 | } 381 | return expect(fetch(url, opts)).to.eventually.be.rejected 382 | .and.be.an.instanceOf(FetchError) 383 | .and.have.property('type', 'max-redirect') 384 | }) 385 | 386 | it('should obey redirect chain, resolve case', function () { // useless, follow option not compatible 387 | url = `${base}redirect/chain` 388 | opts = { 389 | follow: 2, 390 | useElectronNet 391 | } 392 | return fetch(url, opts).then(res => { 393 | expect(res.url).to.equal(`${base}inspect`) 394 | expect(res.status).to.equal(200) 395 | }) 396 | }) 397 | 398 | it('should allow not following redirect', function () { // Not compatible with electron.net 399 | url = `${base}redirect/301` 400 | opts = { 401 | follow: 0, 402 | useElectronNet 403 | } 404 | return expect(fetch(url, opts)).to.eventually.be.rejected 405 | .and.be.an.instanceOf(FetchError) 406 | .and.have.property('type', 'max-redirect') 407 | }) 408 | 409 | it('should support redirect mode, manual flag', function () { // Not compatible with electron.net 410 | url = `${base}redirect/301` 411 | opts = { 412 | redirect: 'manual', 413 | useElectronNet 414 | } 415 | return fetch(url, opts).then(res => { 416 | expect(res.url).to.equal(url) 417 | expect(res.status).to.equal(301) 418 | expect(res.headers.get('location')).to.equal(`${base}inspect`) 419 | }) 420 | }) 421 | 422 | it('should support redirect mode, error flag', function () { // Not compatible with electron.net 423 | url = `${base}redirect/301` 424 | opts = { 425 | redirect: 'error', 426 | useElectronNet 427 | } 428 | return expect(fetch(url, opts)).to.eventually.be.rejected 429 | .and.be.an.instanceOf(FetchError) 430 | .and.have.property('type', 'no-redirect') 431 | }) 432 | 433 | it('should not allow the onLogin option', function () { 434 | url = `${base}inspect` 435 | opts = { onLogin: () => Promise.resolve(undefined), useElectronNet } 436 | return expect(fetch(url, opts)).to.eventually.be.rejected 437 | .and.be.an.instanceOf(Error, '"onLogin" option is only supported with "useElectronNet" enabled') 438 | }) 439 | } 440 | 441 | it('should support redirect mode, manual flag when there is no redirect', function () { // Pretty useless on electron, but why not 442 | url = `${base}hello` 443 | opts = { 444 | redirect: 'manual', 445 | useElectronNet 446 | } 447 | return fetch(url, opts).then(res => { 448 | expect(res.url).to.equal(url) 449 | expect(res.status).to.equal(200) 450 | expect(res.headers.get('location')).to.be.null 451 | }) 452 | }) 453 | 454 | it('should follow redirect code 301 and keep existing headers', function () { 455 | url = `${base}redirect/301` 456 | opts = { 457 | headers: new Headers({ 'x-custom-header': 'abc' }), 458 | useElectronNet 459 | } 460 | return fetch(url, opts).then(res => { 461 | if (!useElectronNet) expect(res.url).to.equal(`${base}inspect`) // Not compatible with electron.net 462 | return res.json() 463 | }).then(res => { 464 | expect(res.headers['x-custom-header']).to.equal('abc') 465 | }) 466 | }) 467 | 468 | it('should reject broken redirect', function () { 469 | url = `${base}error/redirect` 470 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejected 471 | .and.be.an.instanceOf(FetchError) 472 | .and.have.property('type', 'invalid-redirect') 473 | }) 474 | 475 | it('should not reject broken redirect under manual redirect', function () { 476 | url = `${base}error/redirect` 477 | opts = { 478 | redirect: 'manual', 479 | useElectronNet 480 | } 481 | return fetch(url, opts).then(res => { 482 | expect(res.url).to.equal(url) 483 | expect(res.status).to.equal(301) 484 | expect(res.headers.get('location')).to.be.null 485 | }) 486 | }) 487 | 488 | it('should handle client-error response', function () { 489 | url = `${base}error/400` 490 | return fetch(url, { useElectronNet }).then(res => { 491 | expect(res.headers.get('content-type')).to.equal('text/plain') 492 | expect(res.status).to.equal(400) 493 | expect(res.statusText).to.equal('Bad Request') 494 | expect(res.ok).to.be.false 495 | return res.text().then(result => { 496 | expect(res.bodyUsed).to.be.true 497 | expect(result).to.be.a('string') 498 | expect(result).to.equal('client error') 499 | }) 500 | }) 501 | }) 502 | 503 | it('should handle server-error response', function () { 504 | url = `${base}error/500` 505 | return fetch(url, { useElectronNet }).then(res => { 506 | expect(res.headers.get('content-type')).to.equal('text/plain') 507 | expect(res.status).to.equal(500) 508 | expect(res.statusText).to.equal('Internal Server Error') 509 | expect(res.ok).to.be.false 510 | return res.text().then(result => { 511 | expect(res.bodyUsed).to.be.true 512 | expect(result).to.be.a('string') 513 | expect(result).to.equal('server error') 514 | }) 515 | }) 516 | }) 517 | 518 | it('should handle network-error response', function () { 519 | url = `${base}error/reset` 520 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejected 521 | .and.be.an.instanceOf(FetchError) 522 | .and.have.property('code', 'ECONNRESET') 523 | }) 524 | 525 | it('should handle DNS-error response', function () { 526 | // The domain may be invalid, but we must use a valid TLD, or github-actions DNS server responds with 527 | // `status: SERVFAIL`, which triggers an unexpected `EAI_AGAIN` error in node 528 | url = 'http://this-is-an-invalid-domain.com' 529 | return expect(fetch(url, { useElectronNet })).to.eventually.be.rejected 530 | .and.be.an.instanceOf(FetchError) 531 | .and.have.property('code', 'ENOTFOUND') 532 | }) 533 | 534 | it('should reject invalid json response', function () { 535 | url = `${base}error/json` 536 | return fetch(url, { useElectronNet }).then(res => { 537 | expect(res.headers.get('content-type')).to.equal('application/json') 538 | return expect(res.json()).to.eventually.be.rejectedWith(Error) 539 | }) 540 | }) 541 | 542 | it('should handle no content response', function () { 543 | url = `${base}no-content` 544 | return fetch(url, { useElectronNet }).then(res => { 545 | expect(res.status).to.equal(204) 546 | expect(res.statusText).to.equal('No Content') 547 | expect(res.ok).to.be.true 548 | return res.text().then(result => { 549 | expect(result).to.be.a('string') 550 | expect(result).to.be.empty 551 | }) 552 | }) 553 | }) 554 | 555 | it('should handle no content response with gzip encoding', function () { 556 | url = `${base}no-content/gzip` 557 | return fetch(url, { useElectronNet }).then(res => { 558 | expect(res.status).to.equal(204) 559 | expect(res.statusText).to.equal('No Content') 560 | expect(res.headers.get('content-encoding')).to.equal('gzip') 561 | expect(res.ok).to.be.true 562 | return res.text().then(result => { 563 | expect(result).to.be.a('string') 564 | expect(result).to.be.empty 565 | }) 566 | }) 567 | }) 568 | 569 | it('should handle not modified response', function () { 570 | url = `${base}not-modified` 571 | return fetch(url, { useElectronNet }).then(res => { 572 | expect(res.status).to.equal(304) 573 | expect(res.statusText).to.equal('Not Modified') 574 | expect(res.ok).to.be.false 575 | return res.text().then(result => { 576 | expect(result).to.be.a('string') 577 | expect(result).to.be.empty 578 | }) 579 | }) 580 | }) 581 | 582 | it('should handle not modified response with gzip encoding', function () { 583 | url = `${base}not-modified/gzip` 584 | return fetch(url, { useElectronNet }).then(res => { 585 | expect(res.status).to.equal(304) 586 | expect(res.statusText).to.equal('Not Modified') 587 | expect(res.headers.get('content-encoding')).to.equal('gzip') 588 | expect(res.ok).to.be.false 589 | return res.text().then(result => { 590 | expect(result).to.be.a('string') 591 | expect(result).to.be.empty 592 | }) 593 | }) 594 | }) 595 | 596 | it('should decompress gzip response', function () { 597 | url = `${base}gzip` 598 | return fetch(url, { useElectronNet }).then(res => { 599 | expect(res.headers.get('content-type')).to.equal('text/plain') 600 | return res.text().then(result => { 601 | expect(result).to.be.a('string') 602 | expect(result).to.equal('hello world') 603 | }) 604 | }) 605 | }) 606 | 607 | // /!\ This is disabled for now, because it seems broken in recent node 608 | // it('should decompress slightly invalid gzip response', function () { 609 | // url = `${base}gzip-truncated` 610 | // return fetch(url, { useElectronNet }).then(res => { 611 | // expect(res.headers.get('content-type')).to.equal('text/plain') 612 | // return res.text().then(result => { 613 | // expect(result).to.be.a('string') 614 | // expect(result).to.equal('hello world') 615 | // }) 616 | // }) 617 | // }) 618 | 619 | it('should decompress deflate response', function () { 620 | url = `${base}deflate` 621 | return fetch(url, { useElectronNet }).then(res => { 622 | expect(res.headers.get('content-type')).to.equal('text/plain') 623 | return res.text().then(result => { 624 | expect(result).to.be.a('string') 625 | expect(result).to.equal('hello world') 626 | }) 627 | }) 628 | }) 629 | 630 | it('should decompress deflate raw response from old apache server', function () { 631 | url = `${base}deflate-raw` 632 | return fetch(url, { useElectronNet }).then(res => { 633 | expect(res.headers.get('content-type')).to.equal('text/plain') 634 | return res.text().then(result => { 635 | expect(result).to.be.a('string') 636 | expect(result).to.equal('hello world') 637 | }) 638 | }) 639 | }) 640 | 641 | it('should skip decompression if unsupported', function () { 642 | url = `${base}sdch` 643 | return fetch(url, { useElectronNet }).then(res => { 644 | expect(res.headers.get('content-type')).to.equal('text/plain') 645 | return res.text().then(result => { 646 | expect(result).to.be.a('string') 647 | expect(result).to.equal('fake sdch string') 648 | }) 649 | }) 650 | }) 651 | 652 | it('should reject if response compression is invalid', function () { 653 | // broken on electron 4 <= version < 7, so we disable it. It seems fixed on electron >= 7 654 | if (useElectronNet && parseInt(process.versions.electron) >= 4 && parseInt(process.versions.electron) < 7) return this.skip() 655 | url = `${base}invalid-content-encoding` 656 | return fetch(url, { useElectronNet }).then(res => { 657 | expect(res.headers.get('content-type')).to.equal('text/plain') 658 | return expect(res.text()).to.eventually.be.rejected 659 | .and.be.an.instanceOf(FetchError) 660 | .and.have.property('code', 'Z_DATA_ERROR') 661 | }) 662 | }) 663 | 664 | it('should allow custom timeout', function () { 665 | this.timeout(500) 666 | url = `${base}timeout` 667 | opts = { 668 | timeout: 100, 669 | useElectronNet 670 | } 671 | return expect(fetch(url, opts)).to.eventually.be.rejected 672 | .and.be.an.instanceOf(FetchError) 673 | .and.have.property('type', 'request-timeout') 674 | }) 675 | 676 | it('should allow custom timeout on response body', function () { // This fails on windows and we get a request-timeout 677 | this.timeout(500) 678 | url = `${base}slow` 679 | opts = { 680 | timeout: 100, 681 | useElectronNet 682 | } 683 | return fetch(url, opts).then(res => { 684 | expect(res.ok).to.be.true 685 | return expect(res.text()).to.eventually.be.rejectedWith(FetchError) 686 | .and.have.property('type', 'body-timeout') 687 | }) 688 | }) 689 | 690 | it('should handle aborts before request', function () { 691 | const abort = new AbortController() 692 | abort.abort() 693 | url = `${base}timeout` 694 | opts = { 695 | useElectronNet, 696 | signal: abort.signal 697 | } 698 | return expect(fetch(url, opts)).to.eventually.be.rejectedWith(FetchError) 699 | .and.have.property('type', 'abort') 700 | .then(() => { 701 | assert.isUndefined(abort.signal.listeners.abort) 702 | }) 703 | }) 704 | 705 | it('should handle aborts during a request', function () { 706 | const abort = new AbortController() 707 | setTimeout(() => { 708 | abort.abort() 709 | }, 100) 710 | url = `${base}timeout` 711 | opts = { 712 | useElectronNet, 713 | signal: abort.signal 714 | } 715 | assert.isUndefined(abort.signal.listeners.abort) 716 | const fetchPromise = fetch(url, opts) 717 | assert.notDeepEqual(abort.signal.listeners.abort, []) 718 | return expect(fetchPromise).to.eventually.be.rejectedWith(FetchError) 719 | .and.have.property('type', 'abort') 720 | .then(() => { 721 | assert.deepEqual(abort.signal.listeners.abort, []) 722 | }) 723 | }) 724 | 725 | it('should handle aborts during a response', function () { 726 | const abort = new AbortController() 727 | setTimeout(() => { 728 | abort.abort() 729 | }, 100) 730 | url = `${base}slow` 731 | opts = { 732 | useElectronNet, 733 | signal: abort.signal 734 | } 735 | assert.isUndefined(abort.signal.listeners.abort) 736 | return fetch(url, opts) 737 | .then(res => { 738 | expect(res.ok).to.be.true 739 | assert.notDeepEqual(abort.signal.listeners.abort, []) 740 | return expect(res.text()).to.eventually.be.rejectedWith(FetchError) 741 | .and.to.satisfy(e => e.message.endsWith('request aborted')) // checking `.property('type', 'abort')` would not work, as the abort error on the response stream is caught by the `.text()` code and re-thrown 742 | }) 743 | .then(() => { 744 | assert.deepEqual(abort.signal.listeners.abort, []) 745 | }) 746 | }) 747 | 748 | it('should handle aborts after request finish', function () { 749 | const abort = new AbortController() 750 | url = `${base}hello` 751 | opts = { 752 | useElectronNet, 753 | signal: abort.signal 754 | } 755 | 756 | assert.isUndefined(abort.signal.listeners.abort) 757 | return fetch(url, opts) 758 | .then(res => { 759 | return res.text() 760 | }) 761 | .then(r => { 762 | assert.deepEqual(abort.signal.listeners.abort, []) 763 | abort.abort() 764 | }) 765 | }) 766 | 767 | it('should handle aborts after request error', function () { 768 | const abort = new AbortController() 769 | url = `${base}error/reset` 770 | opts = { 771 | useElectronNet, 772 | signal: abort.signal 773 | } 774 | 775 | assert.isUndefined(abort.signal.listeners.abort) 776 | return expect(fetch(url, opts)).to.eventually.be.rejectedWith(FetchError) 777 | .and.have.property('code', 'ECONNRESET') 778 | .then(() => { 779 | assert.deepEqual(abort.signal.listeners.abort, []) 780 | abort.abort() 781 | }) 782 | }) 783 | 784 | it('should clear internal timeout on fetch response', function (done) { // these tests don't make much sense on electron.. 785 | this.timeout(1000) 786 | spawn('node', ['-e', `require('./')('${base}hello', { timeout: 5000 })`]) 787 | .on('exit', () => { 788 | done() 789 | }) 790 | }) 791 | 792 | it('should clear internal timeout on fetch redirect', function (done) { 793 | this.timeout(1000) 794 | spawn('node', ['-e', `require('./')('${base}redirect/301', { timeout: 5000 })`]) 795 | .on('exit', () => { 796 | done() 797 | }) 798 | }) 799 | 800 | it('should clear internal timeout on fetch error', function (done) { 801 | this.timeout(1000) 802 | spawn('node', ['-e', `require('./')('${base}error/reset', { timeout: 5000 })`]) 803 | .on('exit', () => { 804 | done() 805 | }) 806 | }) 807 | 808 | it('should set default User-Agent', function () { 809 | url = `${base}inspect` 810 | return fetch(url, { useElectronNet }).then(res => res.json()).then(res => { 811 | expect(res.headers['user-agent']).to.satisfy(s => s.startsWith('electron-fetch/')) 812 | }) 813 | }) 814 | 815 | it('should allow setting User-Agent', function () { 816 | url = `${base}inspect` 817 | opts = { 818 | headers: { 819 | 'user-agent': 'faked' 820 | }, 821 | useElectronNet 822 | } 823 | fetch(url, opts).then(res => res.json()).then(res => { 824 | expect(res.headers['user-agent']).to.equal('faked') 825 | }) 826 | }) 827 | 828 | it('should set default Accept header', function () { 829 | url = `${base}inspect` 830 | fetch(url, { useElectronNet }).then(res => res.json()).then(res => { 831 | expect(res.headers.accept).to.equal('*/*') 832 | }) 833 | }) 834 | 835 | it('should allow setting Accept header', function () { 836 | url = `${base}inspect` 837 | opts = { 838 | headers: { 839 | accept: 'application/json' 840 | }, 841 | useElectronNet 842 | } 843 | fetch(url, opts).then(res => res.json()).then(res => { 844 | expect(res.headers.accept).to.equal('application/json') 845 | }) 846 | }) 847 | 848 | it('should allow POST request', function () { 849 | url = `${base}inspect` 850 | opts = { 851 | method: 'POST', 852 | useElectronNet 853 | } 854 | return fetch(url, opts).then(res => { 855 | return res.json() 856 | }).then(res => { 857 | expect(res.method).to.equal('POST') 858 | expect(res.headers['transfer-encoding']).to.be.undefined 859 | expect(res.headers['content-type']).to.be.undefined 860 | expect(res.headers['content-length']).to.equal('0') 861 | }) 862 | }) 863 | 864 | it('should allow POST request with string body', function () { 865 | url = `${base}inspect` 866 | opts = { 867 | method: 'POST', 868 | body: 'a=1', 869 | useElectronNet 870 | } 871 | return fetch(url, opts).then(res => { 872 | return res.json() 873 | }).then(res => { 874 | expect(res.method).to.equal('POST') 875 | expect(res.body).to.equal('a=1') 876 | expect(res.headers['transfer-encoding']).to.be.undefined 877 | expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8') 878 | expect(res.headers['content-length']).to.equal('3') 879 | }) 880 | }) 881 | 882 | it('should allow POST request with buffer body', function () { 883 | url = `${base}inspect` 884 | opts = { 885 | method: 'POST', 886 | body: Buffer.from('a=1', 'utf-8'), 887 | useElectronNet 888 | } 889 | return fetch(url, opts).then(res => { 890 | return res.json() 891 | }).then(res => { 892 | expect(res.method).to.equal('POST') 893 | expect(res.body).to.equal('a=1') 894 | expect(res.headers['transfer-encoding']).to.be.undefined 895 | expect(res.headers['content-type']).to.be.undefined 896 | expect(res.headers['content-length']).to.equal('3') 897 | }) 898 | }) 899 | 900 | it('should allow POST request with blob body without type', function () { 901 | url = `${base}inspect` 902 | opts = { 903 | method: 'POST', 904 | body: new Blob(['a=1']), 905 | useElectronNet 906 | } 907 | return fetch(url, opts).then(res => { 908 | return res.json() 909 | }).then(res => { 910 | expect(res.method).to.equal('POST') 911 | expect(res.body).to.equal('a=1') 912 | expect(res.headers['transfer-encoding']).to.be.undefined 913 | expect(res.headers['content-type']).to.be.undefined 914 | expect(res.headers['content-length']).to.equal('3') 915 | }) 916 | }) 917 | 918 | it('should allow POST request with blob body with type', function () { 919 | url = `${base}inspect` 920 | opts = { 921 | method: 'POST', 922 | body: new Blob(['a=1'], { 923 | type: 'text/plain;charset=UTF-8' 924 | }), 925 | useElectronNet 926 | } 927 | return fetch(url, opts).then(res => { 928 | return res.json() 929 | }).then(res => { 930 | expect(res.method).to.equal('POST') 931 | expect(res.body).to.equal('a=1') 932 | expect(res.headers['transfer-encoding']).to.be.undefined 933 | expect(res.headers['content-type']).to.equal('text/plain;charset=utf-8') 934 | expect(res.headers['content-length']).to.equal('3') 935 | }) 936 | }) 937 | 938 | it('should allow POST request with readable stream as body', function () { 939 | const body = resumer().queue('a=1').end() 940 | 941 | url = `${base}inspect` 942 | opts = { 943 | method: 'POST', 944 | body, 945 | useElectronNet 946 | } 947 | return fetch(url, opts).then(res => { 948 | return res.json() 949 | }).then(res => { 950 | expect(res.method).to.equal('POST') 951 | expect(res.body).to.equal('a=1') 952 | expect(res.headers['transfer-encoding']).to.equal('chunked') 953 | expect(res.headers['content-type']).to.be.undefined 954 | expect(res.headers['content-length']).to.be.undefined 955 | }) 956 | }) 957 | 958 | it('should allow POST request with empty readable stream as body', function () { 959 | const body = new stream.PassThrough().end() 960 | 961 | url = `${base}inspect` 962 | opts = { 963 | method: 'POST', 964 | body, 965 | useElectronNet 966 | } 967 | 968 | return fetch(url, opts).then(res => { 969 | return res.json() 970 | }).then(res => { 971 | expect(res.method).to.equal('POST') 972 | expect(res.body).to.equal('') 973 | expect(res.headers['content-type']).to.be.undefined 974 | if (useElectronNet) { 975 | expect(res.headers['transfer-encoding']).to.equal('chunked') 976 | expect(res.headers['content-length']).to.be.undefined 977 | } else { // node automatically detects empty stream and sets content-length to 0 978 | expect(res.headers['content-length']).to.eql('0') 979 | } 980 | }) 981 | }) 982 | 983 | it('should allow POST request with form-data as body', function () { 984 | const form = new FormData() 985 | form.append('a', '1') 986 | 987 | url = `${base}multipart` 988 | opts = { 989 | method: 'POST', 990 | body: form, 991 | useElectronNet 992 | } 993 | return fetch(url, opts).then(res => { 994 | return res.json() 995 | }).then(res => { 996 | expect(res.method).to.equal('POST') 997 | expect(res.headers['content-type']).to.satisfy(s => s.startsWith('multipart/form-data;boundary=')) 998 | expect(res.headers['content-length']).to.be.a('string') 999 | expect(res.body).to.equal('a=1') 1000 | }) 1001 | }) 1002 | 1003 | it('should allow POST request with form-data using stream as body', function () { 1004 | const form = new FormData() 1005 | form.append('my_field', fs.createReadStream('test/dummy.txt')) 1006 | 1007 | url = `${base}multipart` 1008 | opts = { 1009 | method: 'POST', 1010 | body: form, 1011 | useElectronNet 1012 | } 1013 | 1014 | return fetch(url, opts).then(res => { 1015 | return res.json() 1016 | }).then(res => { 1017 | expect(res.method).to.equal('POST') 1018 | expect(res.headers['content-type']).to.satisfy(s => s.startsWith('multipart/form-data;boundary=')) 1019 | expect(res.headers['content-length']).to.be.undefined 1020 | expect(res.body).to.contain('my_field=') 1021 | }) 1022 | }) 1023 | 1024 | it('should allow POST request with form-data as body and custom headers', function () { 1025 | const form = new FormData() 1026 | form.append('a', '1') 1027 | 1028 | const headers = form.getHeaders() 1029 | headers.b = '2' 1030 | 1031 | url = `${base}multipart` 1032 | opts = { 1033 | method: 'POST', 1034 | body: form, 1035 | headers, 1036 | useElectronNet 1037 | } 1038 | return fetch(url, opts).then(res => { 1039 | return res.json() 1040 | }).then(res => { 1041 | expect(res.method).to.equal('POST') 1042 | expect(res.headers['content-type']).to.satisfy(s => s.startsWith('multipart/form-data; boundary=')) 1043 | expect(res.headers['content-length']).to.be.a('string') 1044 | expect(res.headers.b).to.equal('2') 1045 | expect(res.body).to.equal('a=1') 1046 | }) 1047 | }) 1048 | 1049 | it('should allow POST request with object body', function () { 1050 | url = `${base}inspect` 1051 | // note that fetch simply calls tostring on an object 1052 | opts = { 1053 | method: 'POST', 1054 | body: { a: 1 }, 1055 | useElectronNet 1056 | } 1057 | return fetch(url, opts).then(res => { 1058 | return res.json() 1059 | }).then(res => { 1060 | expect(res.method).to.equal('POST') 1061 | expect(res.body).to.equal('[object Object]') 1062 | expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8') 1063 | expect(res.headers['content-length']).to.equal('15') 1064 | }) 1065 | }) 1066 | 1067 | it('should overwrite Content-Length if possible', function () { 1068 | url = `${base}inspect` 1069 | // note that fetch simply calls tostring on an object 1070 | opts = { 1071 | method: 'POST', 1072 | headers: { 1073 | 'Content-Length': '1000' 1074 | }, 1075 | body: 'a=1', 1076 | useElectronNet 1077 | } 1078 | return fetch(url, opts).then(res => { 1079 | return res.json() 1080 | }).then(res => { 1081 | expect(res.method).to.equal('POST') 1082 | expect(res.body).to.equal('a=1') 1083 | expect(res.headers['transfer-encoding']).to.be.undefined 1084 | expect(res.headers['content-type']).to.equal('text/plain;charset=UTF-8') 1085 | expect(res.headers['content-length']).to.equal('3') 1086 | }) 1087 | }) 1088 | 1089 | it('should allow PUT request', function () { 1090 | url = `${base}inspect` 1091 | opts = { 1092 | method: 'PUT', 1093 | body: 'a=1', 1094 | useElectronNet 1095 | } 1096 | return fetch(url, opts).then(res => { 1097 | return res.json() 1098 | }).then(res => { 1099 | expect(res.method).to.equal('PUT') 1100 | expect(res.body).to.equal('a=1') 1101 | }) 1102 | }) 1103 | 1104 | it('should allow DELETE request', function () { 1105 | url = `${base}inspect` 1106 | opts = { 1107 | method: 'DELETE', 1108 | useElectronNet 1109 | } 1110 | return fetch(url, opts).then(res => { 1111 | return res.json() 1112 | }).then(res => { 1113 | expect(res.method).to.equal('DELETE') 1114 | }) 1115 | }) 1116 | 1117 | it('should allow DELETE request with string body', function () { 1118 | url = `${base}inspect` 1119 | opts = { 1120 | method: 'DELETE', 1121 | body: 'a=1', 1122 | useElectronNet 1123 | } 1124 | return fetch(url, opts).then(res => { 1125 | return res.json() 1126 | }).then(res => { 1127 | expect(res.method).to.equal('DELETE') 1128 | expect(res.body).to.equal('a=1') 1129 | expect(res.headers['transfer-encoding']).to.be.undefined 1130 | expect(res.headers['content-length']).to.equal('3') 1131 | }) 1132 | }) 1133 | 1134 | it('should allow PATCH request', function () { 1135 | url = `${base}inspect` 1136 | opts = { 1137 | method: 'PATCH', 1138 | body: 'a=1', 1139 | useElectronNet 1140 | } 1141 | return fetch(url, opts).then(res => { 1142 | return res.json() 1143 | }).then(res => { 1144 | expect(res.method).to.equal('PATCH') 1145 | expect(res.body).to.equal('a=1') 1146 | }) 1147 | }) 1148 | 1149 | it('should allow HEAD request', function () { 1150 | url = `${base}hello` 1151 | opts = { 1152 | method: 'HEAD', 1153 | useElectronNet 1154 | } 1155 | return fetch(url, opts).then(res => { 1156 | expect(res.status).to.equal(200) 1157 | expect(res.statusText).to.equal('OK') 1158 | expect(res.headers.get('content-type')).to.equal('text/plain') 1159 | expect(res.body).to.be.an.instanceof(stream.Transform) 1160 | return res.text() 1161 | }).then(text => { 1162 | expect(text).to.equal('') 1163 | }) 1164 | }) 1165 | 1166 | it('should allow HEAD request with content-encoding header', function () { 1167 | url = `${base}error/404` 1168 | opts = { 1169 | method: 'HEAD', 1170 | useElectronNet 1171 | } 1172 | return fetch(url, opts).then(res => { 1173 | expect(res.status).to.equal(404) 1174 | expect(res.headers.get('content-encoding')).to.equal('gzip') 1175 | return res.text() 1176 | }).then(text => { 1177 | expect(text).to.equal('') 1178 | }) 1179 | }) 1180 | 1181 | it('should allow OPTIONS request', function () { 1182 | url = `${base}options` 1183 | opts = { 1184 | method: 'OPTIONS', 1185 | useElectronNet 1186 | } 1187 | return fetch(url, opts).then(res => { 1188 | expect(res.status).to.equal(200) 1189 | expect(res.statusText).to.equal('OK') 1190 | expect(res.headers.get('allow')).to.equal('GET, HEAD, OPTIONS') 1191 | expect(res.body).to.be.an.instanceof(stream.Transform) 1192 | }) 1193 | }) 1194 | 1195 | it('should reject decoding body twice', function () { 1196 | url = `${base}plain` 1197 | return fetch(url, { useElectronNet }).then(res => { 1198 | expect(res.headers.get('content-type')).to.equal('text/plain') 1199 | return res.text().then(() => { 1200 | expect(res.bodyUsed).to.be.true 1201 | return expect(res.text()).to.eventually.be.rejectedWith(Error) 1202 | }) 1203 | }) 1204 | }) 1205 | 1206 | it('should support maximum response size, multiple chunk', function () { 1207 | url = `${base}size/chunk` 1208 | opts = { 1209 | size: 5, 1210 | useElectronNet 1211 | } 1212 | return fetch(url, opts).then(res => { 1213 | expect(res.status).to.equal(200) 1214 | expect(res.headers.get('content-type')).to.equal('text/plain') 1215 | return expect(res.text()).to.eventually.be.rejected 1216 | .and.be.an.instanceOf(FetchError) 1217 | .and.have.property('type', 'max-size') 1218 | }) 1219 | }) 1220 | 1221 | it('should support maximum response size, single chunk', function () { 1222 | url = `${base}size/long` 1223 | opts = { 1224 | size: 5, 1225 | useElectronNet 1226 | } 1227 | return fetch(url, opts).then(res => { 1228 | expect(res.status).to.equal(200) 1229 | expect(res.headers.get('content-type')).to.equal('text/plain') 1230 | return expect(res.text()).to.eventually.be.rejected 1231 | .and.be.an.instanceOf(FetchError) 1232 | .and.have.property('type', 'max-size') 1233 | }) 1234 | }) 1235 | 1236 | it('should only use UTF-8 decoding with text()', function () { 1237 | url = `${base}encoding/euc-jp` 1238 | return fetch(url, { useElectronNet }).then(res => { 1239 | expect(res.status).to.equal(200) 1240 | return res.text().then(result => { 1241 | expect(result).to.equal('\ufffd\ufffd\ufffd\u0738\ufffd') 1242 | }) 1243 | }) 1244 | }) 1245 | 1246 | it('should support encoding decode, xml dtd detect', function () { 1247 | url = `${base}encoding/euc-jp` 1248 | return fetch(url, { useElectronNet }).then(res => { 1249 | expect(res.status).to.equal(200) 1250 | return res.textConverted().then(result => { 1251 | expect(result).to.equal('日本語') 1252 | }) 1253 | }) 1254 | }) 1255 | 1256 | it('should support encoding decode, content-type detect', function () { 1257 | url = `${base}encoding/shift-jis` 1258 | return fetch(url, { useElectronNet }).then(res => { 1259 | expect(res.status).to.equal(200) 1260 | return res.textConverted().then(result => { 1261 | expect(result).to.equal('
日本語
') 1262 | }) 1263 | }) 1264 | }) 1265 | 1266 | it('should support encoding decode, html5 detect', function () { 1267 | url = `${base}encoding/gbk` 1268 | return fetch(url, { useElectronNet }).then(res => { 1269 | expect(res.status).to.equal(200) 1270 | return res.textConverted().then(result => { 1271 | expect(result).to.equal('
中文
') 1272 | }) 1273 | }) 1274 | }) 1275 | 1276 | it('should support encoding decode, html4 detect', function () { 1277 | url = `${base}encoding/gb2312` 1278 | return fetch(url, { useElectronNet }).then(res => { 1279 | expect(res.status).to.equal(200) 1280 | return res.textConverted().then(result => { 1281 | expect(result).to.equal('
中文
') 1282 | }) 1283 | }) 1284 | }) 1285 | 1286 | it('should default to utf8 encoding', function () { 1287 | url = `${base}encoding/utf8` 1288 | return fetch(url, { useElectronNet }).then(res => { 1289 | expect(res.status).to.equal(200) 1290 | expect(res.headers.get('content-type')).to.be.null 1291 | return res.textConverted().then(result => { 1292 | expect(result).to.equal('中文') 1293 | }) 1294 | }) 1295 | }) 1296 | 1297 | it('should support uncommon content-type order, charset in front', function () { 1298 | url = `${base}encoding/order1` 1299 | return fetch(url, { useElectronNet }).then(res => { 1300 | expect(res.status).to.equal(200) 1301 | return res.textConverted().then(result => { 1302 | expect(result).to.equal('中文') 1303 | }) 1304 | }) 1305 | }) 1306 | 1307 | it('should support uncommon content-type order, end with qs', function () { 1308 | url = `${base}encoding/order2` 1309 | return fetch(url, { useElectronNet }).then(res => { 1310 | expect(res.status).to.equal(200) 1311 | return res.textConverted().then(result => { 1312 | expect(result).to.equal('中文') 1313 | }) 1314 | }) 1315 | }) 1316 | 1317 | it('should support chunked encoding, html4 detect', function () { 1318 | url = `${base}encoding/chunked` 1319 | return fetch(url, { useElectronNet }).then(res => { 1320 | expect(res.status).to.equal(200) 1321 | const padding = 'a'.repeat(10) 1322 | return res.textConverted().then(result => { 1323 | expect(result).to.equal(`${padding}
日本語
`) 1324 | }) 1325 | }) 1326 | }) 1327 | 1328 | it('should only do encoding detection up to 1024 bytes', function () { 1329 | url = `${base}encoding/invalid` 1330 | return fetch(url, { useElectronNet }).then(res => { 1331 | expect(res.status).to.equal(200) 1332 | const padding = 'a'.repeat(1200) 1333 | return res.textConverted().then(result => { 1334 | expect(result).to.not.equal(`${padding}中文`) 1335 | }) 1336 | }) 1337 | }) 1338 | 1339 | it('should allow piping response body as stream', function () { 1340 | url = `${base}hello` 1341 | return fetch(url, { useElectronNet }).then(res => { 1342 | expect(res.body).to.be.an.instanceof(stream.Transform) 1343 | return streamToPromise(res.body, chunk => { 1344 | if (chunk === null) { 1345 | return 1346 | } 1347 | expect(chunk.toString()).to.equal('world') 1348 | }) 1349 | }) 1350 | }) 1351 | 1352 | it('should allow cloning a response, and use both as stream', function () { 1353 | url = `${base}hello` 1354 | return fetch(url, { useElectronNet }).then(res => { 1355 | const r1 = res.clone() 1356 | expect(res.body).to.be.an.instanceof(stream.Transform) 1357 | expect(r1.body).to.be.an.instanceof(stream.Transform) 1358 | const dataHandler = chunk => { 1359 | if (chunk === null) { 1360 | return 1361 | } 1362 | expect(chunk.toString()).to.equal('world') 1363 | } 1364 | 1365 | return Promise.all([ 1366 | streamToPromise(res.body, dataHandler), 1367 | streamToPromise(r1.body, dataHandler) 1368 | ]) 1369 | }) 1370 | }) 1371 | 1372 | it('should allow cloning a json response and log it as text response', function () { 1373 | url = `${base}json` 1374 | return fetch(url, { useElectronNet }).then(res => { 1375 | const r1 = res.clone() 1376 | return Promise.all([res.json(), r1.text()]).then(results => { 1377 | expect(results[0]).to.deep.equal({ name: 'value' }) 1378 | expect(results[1]).to.equal('{"name":"value"}') 1379 | }) 1380 | }) 1381 | }) 1382 | 1383 | it('should allow cloning a json response, and then log it as text response', function () { 1384 | url = `${base}json` 1385 | return fetch(url, { useElectronNet }).then(res => { 1386 | const r1 = res.clone() 1387 | return res.json().then(result => { 1388 | expect(result).to.deep.equal({ name: 'value' }) 1389 | return r1.text().then(result => { 1390 | expect(result).to.equal('{"name":"value"}') 1391 | }) 1392 | }) 1393 | }) 1394 | }) 1395 | 1396 | it('should allow cloning a json response, first log as text response, then return json object', function () { 1397 | url = `${base}json` 1398 | return fetch(url, { useElectronNet }).then(res => { 1399 | const r1 = res.clone() 1400 | return r1.text().then(result => { 1401 | expect(result).to.equal('{"name":"value"}') 1402 | return res.json().then(result => { 1403 | expect(result).to.deep.equal({ name: 'value' }) 1404 | }) 1405 | }) 1406 | }) 1407 | }) 1408 | 1409 | it('should not allow cloning a response after its been used', function () { 1410 | url = `${base}hello` 1411 | return fetch(url, { useElectronNet }).then(res => 1412 | res.text().then(() => { 1413 | expect(() => { 1414 | res.clone() 1415 | }).to.throw(Error) 1416 | }) 1417 | ) 1418 | }) 1419 | 1420 | it('should allow get all responses of a header', function () { 1421 | // TODO: broken on electron@7 https://github.com/electron/electron/issues/20631 1422 | url = `${base}cookie` 1423 | return fetch(url, { useElectronNet }).then(res => { 1424 | expect(res.headers.get('set-cookie')).to.equal('a=1,b=1') 1425 | }) 1426 | }) 1427 | 1428 | it('should allow iterating through all headers with forEach', function () { 1429 | const headers = new Headers([ 1430 | ['b', '2'], 1431 | ['c', '4'], 1432 | ['b', '3'], 1433 | ['a', '1'] 1434 | ]) 1435 | expect(headers).to.have.property('forEach') 1436 | 1437 | const result = [] 1438 | headers.forEach((val, key) => { 1439 | result.push([key, val]) 1440 | }) 1441 | 1442 | expect(result).to.deep.equal([ 1443 | ['a', '1'], 1444 | ['b', '2'], 1445 | ['b', '3'], 1446 | ['c', '4'] 1447 | ]) 1448 | }) 1449 | 1450 | it('should allow iterating through all headers with for-of loop', function () { 1451 | const headers = new Headers([ 1452 | ['b', '2'], 1453 | ['c', '4'], 1454 | ['a', '1'] 1455 | ]) 1456 | headers.append('b', '3') 1457 | expect(headers).to.satisfy(i => isIterable(i)) 1458 | 1459 | const result = [] 1460 | for (const pair of headers) { 1461 | result.push(pair) 1462 | } 1463 | expect(result).to.deep.equal([ 1464 | ['a', '1'], 1465 | ['b', '2'], 1466 | ['b', '3'], 1467 | ['c', '4'] 1468 | ]) 1469 | }) 1470 | 1471 | it('should allow iterating through all headers with entries()', function () { 1472 | const headers = new Headers([ 1473 | ['b', '2'], 1474 | ['c', '4'], 1475 | ['a', '1'] 1476 | ]) 1477 | headers.append('b', '3') 1478 | 1479 | const entries = headers.entries() 1480 | assert(isIterable(entries)) 1481 | assert(deepIteratesOver(entries, [ 1482 | ['a', '1'], 1483 | ['b', '2'], 1484 | ['b', '3'], 1485 | ['c', '4'] 1486 | ])) 1487 | }) 1488 | 1489 | it('should allow iterating through all headers with keys()', function () { 1490 | const headers = new Headers([ 1491 | ['b', '2'], 1492 | ['c', '4'], 1493 | ['a', '1'] 1494 | ]) 1495 | headers.append('b', '3') 1496 | 1497 | const keys = headers.keys() 1498 | assert(isIterable(keys)) 1499 | assert(deepIteratesOver(keys, ['a', 'b', 'c'])) 1500 | }) 1501 | 1502 | it('should allow iterating through all headers with values()', function () { 1503 | const headers = new Headers([ 1504 | ['b', '2'], 1505 | ['c', '4'], 1506 | ['a', '1'] 1507 | ]) 1508 | headers.append('b', '3') 1509 | 1510 | const values = headers.values() 1511 | assert(isIterable(values)) 1512 | assert(deepIteratesOver(values, ['1', '2', '3', '4'])) 1513 | }) 1514 | 1515 | it('should allow deleting header', function () { 1516 | url = `${base}cookie` 1517 | return fetch(url, { useElectronNet }).then(res => { 1518 | res.headers.delete('set-cookie') 1519 | expect(res.headers.get('set-cookie')).to.be.null 1520 | }) 1521 | }) 1522 | 1523 | it('should reject illegal header', function () { 1524 | const headers = new Headers() 1525 | expect(() => new Headers({ 'He y': 'ok' })).to.throw(TypeError) 1526 | expect(() => new Headers({ 'Hé-y': 'ok' })).to.throw(TypeError) 1527 | expect(() => new Headers({ 'He-y': 'ăk' })).to.throw(TypeError) 1528 | expect(() => headers.append('Hé-y', 'ok')).to.throw(TypeError) 1529 | expect(() => headers.delete('Hé-y')).to.throw(TypeError) 1530 | expect(() => headers.get('Hé-y')).to.throw(TypeError) 1531 | expect(() => headers.has('Hé-y')).to.throw(TypeError) 1532 | expect(() => headers.set('Hé-y', 'ok')).to.throw(TypeError) 1533 | 1534 | // 'o k' is valid value but invalid name 1535 | expect(() => new Headers({ 'He-y': 'o k' })).not.to.throw(TypeError) 1536 | }) 1537 | 1538 | it('should ignore unsupported attributes while reading headers', function () { 1539 | const FakeHeader = function () {} 1540 | // prototypes are currently ignored 1541 | // This might change in the future: #181 1542 | FakeHeader.prototype.z = 'fake' 1543 | 1544 | const res = new FakeHeader() 1545 | res.a = 'string' 1546 | res.b = ['1', '2'] 1547 | res.c = '' 1548 | res.d = [] 1549 | res.e = 1 1550 | res.f = [1, 2] 1551 | res.g = { a: 1 } 1552 | res.h = undefined 1553 | res.i = null 1554 | res.j = NaN 1555 | res.k = true 1556 | res.l = false 1557 | res.m = Buffer.from('test') 1558 | 1559 | const h1 = new Headers(res) 1560 | h1.set('n', [1, 2]) 1561 | h1.append('n', ['3', 4]) 1562 | 1563 | const h1Raw = h1.raw() 1564 | 1565 | expect(h1Raw.a).to.include('string') 1566 | expect(h1Raw.b).to.include('1,2') 1567 | expect(h1Raw.c).to.include('') 1568 | expect(h1Raw.d).to.include('') 1569 | expect(h1Raw.e).to.include('1') 1570 | expect(h1Raw.f).to.include('1,2') 1571 | expect(h1Raw.g).to.include('[object Object]') 1572 | expect(h1Raw.h).to.include('undefined') 1573 | expect(h1Raw.i).to.include('null') 1574 | expect(h1Raw.j).to.include('NaN') 1575 | expect(h1Raw.k).to.include('true') 1576 | expect(h1Raw.l).to.include('false') 1577 | expect(h1Raw.m).to.include('test') 1578 | expect(h1Raw.n).to.include('1,2') 1579 | expect(h1Raw.n).to.include('3,4') 1580 | 1581 | expect(h1Raw.z).to.be.undefined 1582 | }) 1583 | 1584 | it('should wrap headers', function () { 1585 | const h1 = new Headers({ 1586 | a: '1' 1587 | }) 1588 | const h1Raw = h1.raw() 1589 | 1590 | const h2 = new Headers(h1) 1591 | h2.set('b', '1') 1592 | const h2Raw = h2.raw() 1593 | 1594 | const h3 = new Headers(h2) 1595 | h3.append('a', '2') 1596 | const h3Raw = h3.raw() 1597 | 1598 | expect(h1Raw.a).to.include('1') 1599 | expect(h1Raw.a).to.not.include('2') 1600 | 1601 | expect(h2Raw.a).to.include('1') 1602 | expect(h2Raw.a).to.not.include('2') 1603 | expect(h2Raw.b).to.include('1') 1604 | 1605 | expect(h3Raw.a).to.include('1') 1606 | expect(h3Raw.a).to.include('2') 1607 | expect(h3Raw.b).to.include('1') 1608 | }) 1609 | 1610 | it('should accept headers as an iterable of tuples', function () { 1611 | let headers 1612 | 1613 | headers = new Headers([ 1614 | ['a', '1'], 1615 | ['b', '2'], 1616 | ['a', '3'] 1617 | ]) 1618 | expect(headers.get('a')).to.equal('1,3') 1619 | expect(headers.get('b')).to.equal('2') 1620 | 1621 | headers = new Headers([ 1622 | new Set(['a', '1']), 1623 | ['b', '2'], 1624 | new Map([['a', null], ['3', null]]).keys() 1625 | ]) 1626 | expect(headers.get('a')).to.equal('1,3') 1627 | expect(headers.get('b')).to.equal('2') 1628 | 1629 | headers = new Headers(new Map([ 1630 | ['a', '1'], 1631 | ['b', '2'] 1632 | ])) 1633 | expect(headers.get('a')).to.equal('1') 1634 | expect(headers.get('b')).to.equal('2') 1635 | }) 1636 | 1637 | it('should throw a TypeError if non-tuple exists in a headers initializer', function () { 1638 | expect(() => new Headers([['b', '2', 'huh?']])).to.throw(TypeError) 1639 | expect(() => new Headers(['b2'])).to.throw(TypeError) 1640 | expect(() => new Headers('b2')).to.throw(TypeError) 1641 | expect(() => new Headers({ [Symbol.iterator]: 42 })).to.throw(TypeError) 1642 | }) 1643 | 1644 | it('should support fetch with Request instance', function () { 1645 | url = `${base}hello` 1646 | const req = new Request(url) 1647 | return fetch(req, { useElectronNet }).then(res => { 1648 | expect(res.url).to.equal(url) 1649 | expect(res.ok).to.be.true 1650 | expect(res.status).to.equal(200) 1651 | }) 1652 | }) 1653 | 1654 | it('should support fetch with Node.js URL object', function () { 1655 | url = `${base}hello` 1656 | const urlObj = parseURL(url) 1657 | const req = new Request(urlObj) 1658 | return fetch(req, { useElectronNet }).then(res => { 1659 | expect(res.url).to.equal(url) 1660 | expect(res.ok).to.be.true 1661 | expect(res.status).to.equal(200) 1662 | }) 1663 | }) 1664 | 1665 | it('should support fetch with WHATWG URL object', function () { 1666 | url = `${base}hello` 1667 | const urlObj = new URL(url) 1668 | const req = new Request(urlObj) 1669 | return fetch(req, { useElectronNet }).then(res => { 1670 | expect(res.url).to.equal(url) 1671 | expect(res.ok).to.be.true 1672 | expect(res.status).to.equal(200) 1673 | }) 1674 | }) 1675 | 1676 | it('should support blob round-trip', function () { 1677 | url = `${base}hello` 1678 | 1679 | let length, type 1680 | 1681 | return fetch(url, { useElectronNet }).then(res => res.blob()).then(blob => { 1682 | url = `${base}inspect` 1683 | length = blob.size 1684 | type = blob.type 1685 | return fetch(url, { 1686 | method: 'POST', 1687 | body: blob, 1688 | useElectronNet 1689 | }) 1690 | }).then(res => res.json()).then(({ body, headers }) => { 1691 | expect(body).to.equal('world') 1692 | expect(headers['content-type']).to.equal(type) 1693 | expect(headers['content-length']).to.equal(String(length)) 1694 | }) 1695 | }) 1696 | 1697 | it('should support wrapping Request instance', function () { 1698 | url = `${base}hello` 1699 | 1700 | const form = new FormData() 1701 | form.append('a', '1') 1702 | 1703 | const r1 = new Request(url, { 1704 | method: 'POST', 1705 | follow: 1, 1706 | body: form 1707 | }) 1708 | const r2 = new Request(r1, { 1709 | follow: 2 1710 | }) 1711 | 1712 | expect(r2.url).to.equal(url) 1713 | expect(r2.method).to.equal('POST') 1714 | // note that we didn't clone the body 1715 | expect(r2.body).to.equal(form) 1716 | expect(r1.follow).to.equal(1) 1717 | expect(r2.follow).to.equal(2) 1718 | expect(r1.counter).to.equal(0) 1719 | expect(r2.counter).to.equal(0) 1720 | }) 1721 | 1722 | it('should support overwrite Request instance', function () { 1723 | url = `${base}inspect` 1724 | const req = new Request(url, { 1725 | method: 'POST', 1726 | headers: { 1727 | a: '1' 1728 | }, 1729 | useElectronNet 1730 | }) 1731 | return fetch(req, { 1732 | method: 'GET', 1733 | headers: { 1734 | a: '2' 1735 | } 1736 | }).then(res => { 1737 | return res.json() 1738 | }).then(body => { 1739 | expect(body.method).to.equal('GET') 1740 | expect(body.headers.a).to.equal('2') 1741 | }) 1742 | }) 1743 | 1744 | it('should throw error with GET/HEAD requests with body', function () { 1745 | expect(() => new Request('.', { body: '' })) 1746 | .to.throw(TypeError) 1747 | expect(() => new Request('.', { body: 'a' })) 1748 | .to.throw(TypeError) 1749 | expect(() => new Request('.', { body: '', method: 'HEAD' })) 1750 | .to.throw(TypeError) 1751 | expect(() => new Request('.', { body: 'a', method: 'HEAD' })) 1752 | .to.throw(TypeError) 1753 | }) 1754 | 1755 | it('should support empty options in Response constructor', function () { 1756 | let body = resumer().queue('a=1').end() 1757 | body = body.pipe(new stream.PassThrough()) 1758 | const res = new Response(body) 1759 | return res.text().then(result => { 1760 | expect(result).to.equal('a=1') 1761 | }) 1762 | }) 1763 | 1764 | it('should support parsing headers in Response constructor', function () { 1765 | const res = new Response(null, { 1766 | headers: { 1767 | a: '1' 1768 | } 1769 | }) 1770 | expect(res.headers.get('a')).to.equal('1') 1771 | }) 1772 | 1773 | it('should support text() method in Response constructor', function () { 1774 | const res = new Response('a=1') 1775 | return res.text().then(result => { 1776 | expect(result).to.equal('a=1') 1777 | }) 1778 | }) 1779 | 1780 | it('should support json() method in Response constructor', function () { 1781 | const res = new Response('{"a":1}') 1782 | return res.json().then(result => { 1783 | expect(result.a).to.equal(1) 1784 | }) 1785 | }) 1786 | 1787 | it('should support buffer() method in Response constructor', function () { 1788 | const res = new Response('a=1') 1789 | return res.buffer().then(result => { 1790 | expect(result.toString()).to.equal('a=1') 1791 | }) 1792 | }) 1793 | 1794 | it('should support blob() method in Response constructor', function () { 1795 | const res = new Response('a=1', { 1796 | method: 'POST', 1797 | headers: { 1798 | 'Content-Type': 'text/plain' 1799 | } 1800 | }) 1801 | return res.blob().then(function (result) { 1802 | expect(result).to.be.an.instanceOf(Blob) 1803 | expect(result.isClosed).to.be.false 1804 | expect(result.size).to.equal(3) 1805 | expect(result.type).to.equal('text/plain') 1806 | 1807 | result.close() 1808 | expect(result.isClosed).to.be.true 1809 | expect(result.size).to.equal(0) 1810 | expect(result.type).to.equal('text/plain') 1811 | }) 1812 | }) 1813 | 1814 | it('should support clone() method in Response constructor', function () { 1815 | let body = resumer().queue('a=1').end() 1816 | body = body.pipe(new stream.PassThrough()) 1817 | const res = new Response(body, { 1818 | headers: { 1819 | a: '1' 1820 | }, 1821 | url: base, 1822 | status: 346, 1823 | statusText: 'production' 1824 | }) 1825 | const cl = res.clone() 1826 | expect(cl.headers.get('a')).to.equal('1') 1827 | expect(cl.url).to.equal(base) 1828 | expect(cl.status).to.equal(346) 1829 | expect(cl.statusText).to.equal('production') 1830 | expect(cl.ok).to.be.false 1831 | // clone body shouldn't be the same body 1832 | expect(cl.body).to.not.equal(body) 1833 | return cl.text().then(result => { 1834 | expect(result).to.equal('a=1') 1835 | }) 1836 | }) 1837 | 1838 | it('should support stream as body in Response constructor', function () { 1839 | let body = resumer().queue('a=1').end() 1840 | body = body.pipe(new stream.PassThrough()) 1841 | const res = new Response(body) 1842 | return res.text().then(result => { 1843 | expect(result).to.equal('a=1') 1844 | }) 1845 | }) 1846 | 1847 | it('should support string as body in Response constructor', function () { 1848 | const res = new Response('a=1') 1849 | return res.text().then(result => { 1850 | expect(result).to.equal('a=1') 1851 | }) 1852 | }) 1853 | 1854 | it('should support buffer as body in Response constructor', function () { 1855 | const res = new Response(Buffer.from('a=1')) 1856 | return res.text().then(result => { 1857 | expect(result).to.equal('a=1') 1858 | }) 1859 | }) 1860 | 1861 | it('should support blob as body in Response constructor', function () { 1862 | const res = new Response(new Blob(['a=1'])) 1863 | return res.text().then(result => { 1864 | expect(result).to.equal('a=1') 1865 | }) 1866 | }) 1867 | 1868 | it('should default to null as body', function () { 1869 | const res = new Response() 1870 | expect(res.body).to.equal(null) 1871 | const req = new Request('.') 1872 | expect(req.body).to.equal(null) 1873 | 1874 | const cb = result => expect(result).to.equal('') 1875 | return Promise.all([ 1876 | res.text().then(cb), 1877 | req.text().then(cb) 1878 | ]) 1879 | }) 1880 | 1881 | it('should default to 200 as status code', function () { 1882 | const res = new Response(null) 1883 | expect(res.status).to.equal(200) 1884 | }) 1885 | 1886 | it('should support parsing headers in Request constructor', function () { 1887 | url = base 1888 | const req = new Request(url, { 1889 | headers: { 1890 | a: '1' 1891 | } 1892 | }) 1893 | expect(req.url).to.equal(url) 1894 | expect(req.headers.get('a')).to.equal('1') 1895 | }) 1896 | 1897 | it('should support arrayBuffer() method in Request constructor', function () { 1898 | url = base 1899 | const req = new Request(url, { 1900 | method: 'POST', 1901 | body: 'a=1' 1902 | }) 1903 | expect(req.url).to.equal(url) 1904 | return req.arrayBuffer().then(function (result) { 1905 | expect(result).to.be.an.instanceOf(ArrayBuffer) 1906 | const str = String.fromCharCode.apply(null, new Uint8Array(result)) 1907 | expect(str).to.equal('a=1') 1908 | }) 1909 | }) 1910 | 1911 | it('should support text() method in Request constructor', function () { 1912 | url = base 1913 | const req = new Request(url, { 1914 | method: 'POST', 1915 | body: 'a=1' 1916 | }) 1917 | expect(req.url).to.equal(url) 1918 | return req.text().then(result => { 1919 | expect(result).to.equal('a=1') 1920 | }) 1921 | }) 1922 | 1923 | it('should support json() method in Request constructor', function () { 1924 | url = base 1925 | const req = new Request(url, { 1926 | method: 'POST', 1927 | body: '{"a":1}' 1928 | }) 1929 | expect(req.url).to.equal(url) 1930 | return req.json().then(result => { 1931 | expect(result.a).to.equal(1) 1932 | }) 1933 | }) 1934 | 1935 | it('should support buffer() method in Request constructor', function () { 1936 | url = base 1937 | const req = new Request(url, { 1938 | method: 'POST', 1939 | body: 'a=1' 1940 | }) 1941 | expect(req.url).to.equal(url) 1942 | return req.buffer().then(result => { 1943 | expect(result.toString()).to.equal('a=1') 1944 | }) 1945 | }) 1946 | 1947 | it('should support blob() method in Request constructor', function () { 1948 | url = base 1949 | const req = new Request(url, { 1950 | method: 'POST', 1951 | body: Buffer.from('a=1') 1952 | }) 1953 | expect(req.url).to.equal(url) 1954 | return req.blob().then(function (result) { 1955 | expect(result).to.be.an.instanceOf(Blob) 1956 | expect(result.isClosed).to.be.false 1957 | expect(result.size).to.equal(3) 1958 | expect(result.type).to.equal('') 1959 | 1960 | result.close() 1961 | expect(result.isClosed).to.be.true 1962 | expect(result.size).to.equal(0) 1963 | expect(result.type).to.equal('') 1964 | }) 1965 | }) 1966 | 1967 | it('should support arbitrary url in Request constructor', function () { 1968 | url = 'anything' 1969 | const req = new Request(url) 1970 | expect(req.url).to.equal('anything') 1971 | }) 1972 | 1973 | it('should support clone() method in Request constructor', function () { 1974 | url = base 1975 | let body = resumer().queue('a=1').end() 1976 | body = body.pipe(new stream.PassThrough()) 1977 | const req = new Request(url, { 1978 | body, 1979 | method: 'POST', 1980 | redirect: 'manual', 1981 | headers: { 1982 | b: '2' 1983 | }, 1984 | follow: 3 1985 | }) 1986 | const cl = req.clone() 1987 | expect(cl.url).to.equal(url) 1988 | expect(cl.method).to.equal('POST') 1989 | expect(cl.redirect).to.equal('manual') 1990 | expect(cl.headers.get('b')).to.equal('2') 1991 | expect(cl.follow).to.equal(3) 1992 | expect(cl.method).to.equal('POST') 1993 | expect(cl.counter).to.equal(0) 1994 | // clone body shouldn't be the same body 1995 | expect(cl.body).to.not.equal(body) 1996 | return Promise.all([cl.text(), req.text()]).then(results => { 1997 | expect(results[0]).to.equal('a=1') 1998 | expect(results[1]).to.equal('a=1') 1999 | }) 2000 | }) 2001 | 2002 | it('should support arrayBuffer(), blob(), text(), json() and buffer() method in Body constructor', function () { 2003 | const body = new Body('a=1') 2004 | expect(body).to.have.property('arrayBuffer') 2005 | expect(body).to.have.property('blob') 2006 | expect(body).to.have.property('text') 2007 | expect(body).to.have.property('json') 2008 | expect(body).to.have.property('buffer') 2009 | }) 2010 | 2011 | it('should create custom FetchError', function funcName () { 2012 | const systemError = new Error('system') 2013 | systemError.code = 'ESOMEERROR' 2014 | 2015 | const err = new FetchError('test message', 'test-error', systemError) 2016 | expect(err).to.be.an.instanceof(Error) 2017 | expect(err).to.be.an.instanceof(FetchError) 2018 | expect(err.name).to.equal('FetchError') 2019 | expect(err.message).to.equal('test message') 2020 | expect(err.type).to.equal('test-error') 2021 | expect(err.code).to.equal('ESOMEERROR') 2022 | expect(err.errno).to.equal('ESOMEERROR') 2023 | expect(err.stack).to.include('funcName') 2024 | .and.to.satisfy(s => s.startsWith(`${err.name}: ${err.message}`)) 2025 | }) 2026 | 2027 | it('should support https request', function () { 2028 | this.timeout(5000) 2029 | url = 'https://github.com/' 2030 | opts = { 2031 | method: 'HEAD', 2032 | useElectronNet 2033 | } 2034 | return fetch(url, opts).then(res => { 2035 | expect(res.status).to.equal(200) 2036 | expect(res.ok).to.be.true 2037 | }) 2038 | }) 2039 | 2040 | it('should throw on https with bad cert', function () { 2041 | if (useElectronNet && parseInt(process.versions.electron) < 7) return this.skip() // https://github.com/electron/electron/issues/8074 2042 | this.timeout(5000) 2043 | url = 'https://expired.badssl.com//' 2044 | opts = { 2045 | method: 'GET', 2046 | useElectronNet 2047 | } 2048 | return expect(fetch(url, opts)).to.eventually.be.rejectedWith(FetchError) 2049 | }) 2050 | 2051 | it('should send an https post request', function () { 2052 | this.timeout(5000) 2053 | const body = 'tototata' 2054 | return fetch('https://httpbin.org/post', { 2055 | url: 'https://httpbin.org/post', 2056 | method: 'POST', 2057 | body, 2058 | useElectronNet 2059 | }).then(res => { 2060 | expect(res.status).to.equal(200) 2061 | expect(res.ok).to.be.true 2062 | return res.json() 2063 | }).then(res => { 2064 | expect(res.data).to.equal(body) 2065 | }) 2066 | }) 2067 | 2068 | if (useElectronNet) { 2069 | const electron = require('electron') 2070 | const testCookiesSession = electron.session.fromPartition('test-cookies') 2071 | const unauthenticatedProxySession = electron.session.fromPartition('unauthenticated-proxy') 2072 | const authenticatedProxySession = electron.session.fromPartition('authenticated-proxy') 2073 | const waitForSessions = parseInt(process.versions.electron) < 6 2074 | ? new Promise(resolve => unauthenticatedProxySession.setProxy({ 2075 | proxyRules: `http://${unauthenticatedProxy.hostname}:${unauthenticatedProxy.port}`, 2076 | proxyBypassRules: '<-loopback>' 2077 | }, () => resolve())) 2078 | .then(() => new Promise(resolve => authenticatedProxySession.setProxy({ 2079 | proxyRules: `http://${authenticatedProxy.hostname}:${authenticatedProxy.port}`, 2080 | proxyBypassRules: '<-loopback>' 2081 | }, () => resolve()))) 2082 | : unauthenticatedProxySession.setProxy({ 2083 | proxyRules: `http://${unauthenticatedProxy.hostname}:${unauthenticatedProxy.port}`, 2084 | proxyBypassRules: '<-loopback>' 2085 | }) 2086 | .then(() => authenticatedProxySession.setProxy({ 2087 | proxyRules: `http://${authenticatedProxy.hostname}:${authenticatedProxy.port}`, 2088 | proxyBypassRules: '<-loopback>' 2089 | })) 2090 | 2091 | afterEach('Clear authenticated proxy session auth cache', () => { 2092 | return parseInt(process.versions.electron) < 7 2093 | ? new Promise(resolve => authenticatedProxySession.clearAuthCache({ type: 'password' }, () => resolve())) 2094 | : authenticatedProxySession.clearAuthCache() 2095 | }) 2096 | 2097 | it('should connect through unauthenticated proxy', () => { 2098 | url = `${base}plain` 2099 | return waitForSessions 2100 | .then(() => fetch(url, { 2101 | useElectronNet, 2102 | session: unauthenticatedProxySession 2103 | })) 2104 | .then(res => { 2105 | expect(res.headers.get('content-type')).to.equal('text/plain') 2106 | return res.text().then(result => { 2107 | expect(res.bodyUsed).to.be.true 2108 | expect(result).to.be.a('string') 2109 | expect(result).to.equal('text') 2110 | }) 2111 | }) 2112 | }) 2113 | 2114 | it('should fail through authenticated proxy without credentials', () => { 2115 | url = `${base}plain` 2116 | return waitForSessions 2117 | .then(() => expect( 2118 | fetch(url, { 2119 | useElectronNet, 2120 | session: authenticatedProxySession 2121 | }) 2122 | .then(res => res.text()) 2123 | ).to.eventually.be.rejectedWith(FetchError).and.have.property('code', 'PROXY_AUTH_FAILED')) 2124 | }) 2125 | 2126 | it('should connect through authenticated proxy with credentials', () => { 2127 | url = `${base}plain` 2128 | return waitForSessions 2129 | .then(() => fetch(url, { 2130 | useElectronNet, 2131 | session: authenticatedProxySession, 2132 | user: 'testuser', 2133 | password: 'testpassword' 2134 | })) 2135 | .then(res => { 2136 | expect(res.headers.get('content-type')).to.equal('text/plain') 2137 | return res.text().then(result => { 2138 | expect(res.bodyUsed).to.be.true 2139 | expect(result).to.be.a('string') 2140 | expect(result).to.equal('text') 2141 | }) 2142 | }) 2143 | }) 2144 | 2145 | it('should connect through authenticated proxy with onLogin callback', () => { 2146 | url = `${base}plain` 2147 | return waitForSessions 2148 | .then(() => fetch(url, { 2149 | useElectronNet, 2150 | session: authenticatedProxySession, 2151 | onLogin (authInfo) { 2152 | return Promise.resolve({ username: 'testuser', password: 'testpassword' }) 2153 | } 2154 | })) 2155 | .then(res => { 2156 | expect(res.headers.get('content-type')).to.equal('text/plain') 2157 | return res.text().then(result => { 2158 | expect(res.bodyUsed).to.be.true 2159 | expect(result).to.be.a('string') 2160 | expect(result).to.equal('text') 2161 | }) 2162 | }) 2163 | }) 2164 | 2165 | it('should fail through authenticated proxy when credentials not returned from onLogin handler', () => { 2166 | url = `${base}plain` 2167 | return waitForSessions 2168 | .then(() => fetch(url, { 2169 | useElectronNet, 2170 | session: authenticatedProxySession, 2171 | onLogin (authInfo) { 2172 | return Promise.resolve() 2173 | } 2174 | })) 2175 | .then(res => { 2176 | expect(res.status).to.equal(407) 2177 | expect(res.statusText).to.equal('Proxy Authentication Required') 2178 | expect(res.ok).to.be.false 2179 | return res.text().then(result => { 2180 | expect(result).to.be.a('string') 2181 | expect(result).to.be.empty 2182 | }) 2183 | }) 2184 | }) 2185 | 2186 | it('should fail through authenticated proxy when onLogin handler rejects', () => { 2187 | url = `${base}plain` 2188 | return waitForSessions 2189 | .then(() => expect( 2190 | fetch(url, { 2191 | useElectronNet, 2192 | session: authenticatedProxySession, 2193 | onLogin (authInfo) { 2194 | return Promise.reject(new Error('onLogin failed')) 2195 | } 2196 | }) 2197 | ).to.eventually.be.rejectedWith(Error, 'onLogin failed')) 2198 | }) 2199 | 2200 | it('should send cookies stored in session if requested', function () { 2201 | if (parseInt(process.versions.electron) < 7) return this.skip() 2202 | url = `${base}cookie` 2203 | return fetch(url, { 2204 | useElectronNet, 2205 | useSessionCookies: true, // For electron >= 11, this is necessary to save received cookies. For electron from 7 to 10, it does not change anything. 2206 | session: testCookiesSession 2207 | }) 2208 | .then(() => fetch(`${base}inspect`, { 2209 | useElectronNet, 2210 | useSessionCookies: true, 2211 | session: testCookiesSession 2212 | })) 2213 | .then(res => res.json()) 2214 | .then(res => { 2215 | expect(res.headers.cookie).to.equal('a=1; b=1') 2216 | }) 2217 | }) 2218 | 2219 | it('should not send cookies stored in session by default', function () { 2220 | if (parseInt(process.versions.electron) < 7) return this.skip() 2221 | url = `${base}cookie` 2222 | return fetch(url, { 2223 | useElectronNet, 2224 | useSessionCookies: true, // For electron >= 11, this is necessary to save received cookies. For electron from 7 to 10, it does not change anything. 2225 | session: testCookiesSession 2226 | }) 2227 | .then(() => fetch(`${base}inspect`, { 2228 | useElectronNet, 2229 | session: testCookiesSession 2230 | })) 2231 | .then(res => res.json()) 2232 | .then(res => { 2233 | expect(res.headers.cookie).to.equal(undefined) 2234 | }) 2235 | }) 2236 | 2237 | it('should not send cookies stored in session if asked not to', function () { 2238 | if (parseInt(process.versions.electron) < 7) return this.skip() 2239 | url = `${base}cookie` 2240 | return fetch(url, { 2241 | useElectronNet, 2242 | useSessionCookies: true, // For electron >= 11, this is necessary to save received cookies. For electron from 7 to 10, it does not change anything. 2243 | session: testCookiesSession 2244 | }) 2245 | .then(() => fetch(`${base}inspect`, { 2246 | useElectronNet, 2247 | useSessionCookies: false, 2248 | session: testCookiesSession 2249 | })) 2250 | .then(res => res.json()) 2251 | .then(res => { 2252 | expect(res.headers.cookie).to.equal(undefined) 2253 | }) 2254 | }) 2255 | } 2256 | }) 2257 | 2258 | function streamToPromise (stream, dataHandler) { 2259 | return new Promise((resolve, reject) => { 2260 | stream.on('data', (...args) => { 2261 | Promise.resolve() 2262 | .then(() => dataHandler(...args)) 2263 | .catch(reject) 2264 | }) 2265 | stream.on('end', resolve) 2266 | stream.on('error', reject) 2267 | }) 2268 | } 2269 | } 2270 | 2271 | createTestSuite(false) 2272 | if (process.versions.electron) createTestSuite(true) 2273 | --------------------------------------------------------------------------------