├── .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 |
--------------------------------------------------------------------------------