├── .gitignore ├── .lintstagedrc.json ├── .commitlintrc.json ├── .huskyrc.json ├── babel.config.js ├── fetch.js ├── .editorconfig ├── .flowconfig ├── run-tests.js ├── src ├── ArrayBufferResponse.js ├── BlobResponse.js ├── utils.js ├── Response.js ├── Headers.js ├── Request.js ├── Body.js └── Fetch.js ├── .eslintrc.json ├── LICENSE ├── CHANGELOG.md ├── package.json ├── CONTRIBUTING.md ├── fetch.js.flow ├── test ├── server.js └── index.js ├── .github └── workflows │ └── node-ci.yml ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint --fix" 3 | } 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged", 4 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | presets: ["@babel/preset-flow"], 5 | plugins: ["@babel/plugin-syntax-class-properties"], 6 | }; 7 | -------------------------------------------------------------------------------- /fetch.js: -------------------------------------------------------------------------------- 1 | import Headers from "./src/Headers"; 2 | import Request from "./src/Request"; 3 | import Response from "./src/Response"; 4 | import Fetch from "./src/Fetch"; 5 | 6 | const fetch = (resource, options) => new Fetch(resource, options); 7 | 8 | export { Headers, Request, Response, fetch }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{package.json,*.yml}] 13 | indent_size = 2 14 | 15 | [{*.md,*.snap}] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/test/.* 3 | .*/node_modules/.* 4 | 5 | [lints] 6 | sketchy-null-number=warn 7 | sketchy-null-mixed=warn 8 | sketchy-number=warn 9 | untyped-type-import=warn 10 | nonstrict-import=warn 11 | deprecated-type=warn 12 | unsafe-getters-setters=warn 13 | unnecessary-invariant=warn 14 | signature-verification-failure=warn 15 | 16 | [strict] 17 | deprecated-type 18 | nonstrict-import 19 | sketchy-null 20 | unclear-type 21 | unsafe-getters-setters 22 | untyped-import 23 | untyped-type-import 24 | -------------------------------------------------------------------------------- /run-tests.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const execa = require("execa"); 4 | const createServer = require("./test/server"); 5 | 6 | async function run() { 7 | const server = createServer(); 8 | let exitCode; 9 | 10 | try { 11 | const result = await execa("rn-test", process.argv.slice(2), { 12 | preferLocal: true, 13 | stdio: ["ignore", "inherit", "inherit"], 14 | }); 15 | exitCode = result.exitCode; 16 | } catch (error) { 17 | exitCode = error.exitCode; 18 | } 19 | 20 | await server.close(); 21 | 22 | process.exit(exitCode); 23 | } 24 | 25 | run(); 26 | -------------------------------------------------------------------------------- /src/ArrayBufferResponse.js: -------------------------------------------------------------------------------- 1 | import { toByteArray } from "base64-js"; 2 | import Response from "./Response"; 3 | 4 | class ArrayBufferResponse extends Response { 5 | constructor(base64, options) { 6 | const buffer = toByteArray(base64); 7 | super(buffer, options); 8 | this._base64 = base64; 9 | } 10 | 11 | clone() { 12 | return new ArrayBufferResponse(this._base64, { 13 | status: this.status, 14 | statusText: this.statusText, 15 | headers: new Headers(this.headers), 16 | url: this.url, 17 | }); 18 | } 19 | } 20 | 21 | export default ArrayBufferResponse; 22 | -------------------------------------------------------------------------------- /src/BlobResponse.js: -------------------------------------------------------------------------------- 1 | import BlobManager from "react-native/Libraries/Blob/BlobManager"; 2 | import Response from "./Response"; 3 | 4 | class BlobResponse extends Response { 5 | constructor(blobData, options) { 6 | const blob = BlobManager.createFromOptions(blobData); 7 | super(blob, options); 8 | this._blobData = blobData; 9 | } 10 | 11 | clone() { 12 | return new BlobResponse(this._blobData, { 13 | status: this.status, 14 | statusText: this.statusText, 15 | headers: new Headers(this.headers), 16 | url: this.url, 17 | }); 18 | } 19 | } 20 | 21 | export default BlobResponse; 22 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@babel/eslint-parser", 4 | "extends": [ 5 | "@react-native-community", 6 | "prettier", 7 | "prettier/flowtype", 8 | "plugin:flowtype/recommended" 9 | ], 10 | "plugins": [ 11 | "@babel/eslint-plugin", 12 | "flowtype" 13 | ], 14 | "settings": { 15 | "flowtype": { 16 | "onlyFilesWithFlowAnnotation": true 17 | } 18 | }, 19 | "globals": { 20 | "ReadableStream": true, 21 | "Blob": true, 22 | "FileReader": true, 23 | "TextDecoder": true, 24 | "TextEncoder": true, 25 | "Buffer": true, 26 | "AbortController": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) React Native Community 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | function createBlobReader(blob) { 2 | const reader = new FileReader(); 3 | const fileReaderReady = new Promise((resolve, reject) => { 4 | reader.onload = function () { 5 | resolve(reader.result); 6 | }; 7 | reader.onerror = function () { 8 | reject(reader.error); 9 | }; 10 | }); 11 | 12 | return { 13 | readAsArrayBuffer: async () => { 14 | reader.readAsArrayBuffer(blob); 15 | return fileReaderReady; 16 | }, 17 | readAsText: async () => { 18 | reader.readAsText(blob); 19 | return fileReaderReady; 20 | }, 21 | }; 22 | } 23 | 24 | async function drainStream(stream) { 25 | const chunks = []; 26 | const reader = stream.getReader(); 27 | 28 | function readNextChunk() { 29 | return reader.read().then(({ done, value }) => { 30 | if (done) { 31 | return chunks.reduce( 32 | (bytes, chunk) => [...bytes, ...chunk], 33 | [] 34 | ); 35 | } 36 | 37 | chunks.push(value); 38 | 39 | return readNextChunk(); 40 | }); 41 | } 42 | 43 | const bytes = await readNextChunk(); 44 | 45 | return new Uint8Array(bytes); 46 | } 47 | 48 | function readArrayBufferAsText(array) { 49 | const decoder = new TextDecoder(); 50 | 51 | return decoder.decode(array); 52 | } 53 | 54 | export { createBlobReader, drainStream, readArrayBufferAsText }; 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [3.0.0](https://github.com/react-native-community/fetch/compare/v2.0.0...v3.0.0) (2021-08-02) 6 | 7 | 8 | ### ⚠ BREAKING CHANGES 9 | 10 | * Response returns the instance rather than a promise in all cases 11 | 12 | ### Bug Fixes 13 | 14 | * response correctly returns instance instead of promise ([8b6c3d3](https://github.com/react-native-community/fetch/commit/8b6c3d3ee97ba142c2bd1d341e2c072bac6262f8)) 15 | 16 | ## [2.0.0](https://github.com/react-native-community/fetch/compare/v1.0.2...v2.0.0) (2021-06-28) 17 | 18 | ### [1.0.2](https://github.com/react-native-community/fetch/compare/v1.0.1...v1.0.2) (2021-01-31) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * do not abort native request if none was made yet ([3cc8669](https://github.com/react-native-community/fetch/commit/3cc8669c9ed016e115e820a959b3015ed7bdab0d)) 24 | * implement Response.body for each input body type ([0eec6f4](https://github.com/react-native-community/fetch/commit/0eec6f49e2f01c9e528db3c6318406d9cc2fa833)) 25 | 26 | ### [1.0.1](https://github.com/react-native-community/fetch/compare/v1.0.0...v1.0.1) (2021-01-25) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * handle blobs as stream when FileReader.readAsArrayBuffer is available ([6ebb08a](https://github.com/react-native-community/fetch/commit/6ebb08a9093888a8c3f1839cdec22b4728a0b033)) 32 | 33 | ## 1.0.0 (2021-01-25) 34 | -------------------------------------------------------------------------------- /src/Response.js: -------------------------------------------------------------------------------- 1 | import Body from "./Body"; 2 | import Headers from "./Headers"; 3 | 4 | class Response { 5 | constructor(body, options = {}) { 6 | this.type = "basic"; 7 | this.status = options.status ?? 200; 8 | this.ok = this.status >= 200 && this.status < 300; 9 | this.statusText = options.statusText ?? ""; 10 | this.headers = new Headers(options.headers); 11 | this.url = options.url ?? ""; 12 | this._body = new Body(body); 13 | 14 | if (!this.headers.has("content-type") && this._body._mimeType) { 15 | this.headers.set("content-type", this._body._mimeType); 16 | } 17 | } 18 | 19 | get bodyUsed() { 20 | return this._body.bodyUsed; 21 | } 22 | 23 | clone() { 24 | return new Response(this._body._bodyInit, { 25 | status: this.status, 26 | statusText: this.statusText, 27 | headers: new Headers(this.headers), 28 | url: this.url, 29 | }); 30 | } 31 | 32 | blob() { 33 | return this._body.blob(); 34 | } 35 | 36 | arrayBuffer() { 37 | return this._body.arrayBuffer(); 38 | } 39 | 40 | text() { 41 | return this._body.text(); 42 | } 43 | 44 | json() { 45 | return this._body.json(); 46 | } 47 | 48 | formData() { 49 | return this._body.formData(); 50 | } 51 | 52 | get body() { 53 | return this._body.body; 54 | } 55 | } 56 | 57 | Response.error = () => { 58 | const response = new Response(null, { status: 0, statusText: "" }); 59 | response.type = "error"; 60 | return response; 61 | }; 62 | 63 | Response.redirect = (url, status) => { 64 | const redirectStatuses = [301, 302, 303, 307, 308]; 65 | 66 | if (!redirectStatuses.includes(status)) { 67 | throw new RangeError(`Invalid status code: ${status}`); 68 | } 69 | 70 | return new Response(null, { status: status, headers: { location: url } }); 71 | }; 72 | 73 | export default Response; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-fetch-api", 3 | "description": "A fetch API polyfill for React Native with text streaming support.", 4 | "version": "3.0.0", 5 | "main": "fetch.js", 6 | "author": { 7 | "name": "André Costa Lima", 8 | "email": "andreclima.pt@gmail.com", 9 | "url": "https://github.com/acostalima/" 10 | }, 11 | "repository": "react-native-community/fetch", 12 | "license": "MIT", 13 | "keywords": [ 14 | "react-native", 15 | "fetch", 16 | "stream" 17 | ], 18 | "devDependencies": { 19 | "@babel/core": "^7.12.3", 20 | "@babel/eslint-parser": "^7.12.1", 21 | "@babel/eslint-plugin": "^7.12.1", 22 | "@babel/plugin-syntax-class-properties": "^7.12.1", 23 | "@babel/preset-flow": "^7.12.1", 24 | "@commitlint/cli": "^11.0.0", 25 | "@commitlint/config-conventional": "^11.0.0", 26 | "@react-native-community/eslint-config": "^2.0.0", 27 | "conventional-github-releaser": "^3.1.5", 28 | "delay": "^4.4.0", 29 | "eslint": "^6.5.1", 30 | "eslint-config-prettier": "^6.15.0", 31 | "eslint-plugin-flowtype": "^5.2.0", 32 | "execa": "^5.0.0", 33 | "flow-bin": "^0.136.0", 34 | "husky": "^4.3.7", 35 | "lint-staged": "^10.5.3", 36 | "querystring": "^0.2.0", 37 | "react-native-polyfill-globals": "^2.0.0", 38 | "react-native-test-runner": "^5.0.0", 39 | "react-native-url-polyfill": "^1.2.0", 40 | "standard-version": "^9.1.0", 41 | "text-encoding": "^0.7.0", 42 | "web-streams-polyfill": "^3.0.1", 43 | "zora": "^4.0.2" 44 | }, 45 | "files": [ 46 | "src", 47 | "fetch.js", 48 | "fetch.js.flow" 49 | ], 50 | "scripts": { 51 | "flow": "flow", 52 | "lint": "eslint --cache --ignore-path .gitignore .", 53 | "test:ios": "./run-tests.js --platform ios test/index.js", 54 | "test:android": "./run-tests.js --platform android test/index.js", 55 | "release": "standard-version", 56 | "postrelease": "git push --follow-tags origin HEAD && conventional-github-releaser -p angular" 57 | }, 58 | "dependencies": { 59 | "p-defer": "^3.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to our `fetch` polyfill! 4 | 5 | Note that we only accept features that are also described in the official [fetch 6 | specification][]. However, the aim of this project is not to implement the 7 | complete specification; just the parts that are feasible to emulate using 8 | XMLHttpRequest. See [Caveats][] for some examples of features that we are 9 | unlikely to implement. 10 | 11 | Contributions to this project are [released][tos] to the public under the 12 | [project's open source license](LICENSE). 13 | 14 | ## Running tests 15 | 16 | Running `npm test` will: 17 | 18 | 1. Build the `dist/` files; 19 | 1. Run the test suite in headless Chrome & Firefox; 20 | 1. Run the same test suite in Web Worker mode. 21 | 22 | When editing tests or implementation, keep `npm run karma` running: 23 | 24 | - You can connect additional browsers by navigating to `http://localhost:9876/`; 25 | - Changes to [test.js](test/test.js) will automatically re-run the tests in all 26 | connected browsers; 27 | - When changing [fetch.js](fetch.js), re-run tests by executing `make`; 28 | - Re-run specific tests with `./node_modules/.bin/karma run -- --grep=`. 29 | 30 | ## Submitting a pull request 31 | 32 | 1. [Fork][fork] and clone the repository; 33 | 1. Create a new branch: `git checkout -b my-branch-name`; 34 | 1. Make your change, push to your fork and [submit a pull request][pr]; 35 | 1. Pat your self on the back and wait for your pull request to be reviewed. 36 | 37 | Here are a few things you can do that will increase the likelihood of your pull 38 | request being accepted: 39 | 40 | - Keep your change as focused as possible. If there are multiple changes you 41 | would like to make that are not dependent upon each other, consider submitting 42 | them as separate pull requests. 43 | - Write a [good commit message][]. 44 | 45 | ## Resources 46 | 47 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 48 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 49 | - [GitHub Help](https://help.github.com) 50 | 51 | 52 | [fetch specification]: https://fetch.spec.whatwg.org 53 | [tos]: https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license 54 | [fork]: https://github.com/github/fetch/fork 55 | [pr]: https://github.com/github/fetch/compare 56 | [good commit message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 57 | [caveats]: https://github.github.io/fetch/#caveats 58 | -------------------------------------------------------------------------------- /src/Headers.js: -------------------------------------------------------------------------------- 1 | function normalizeName(name) { 2 | if (typeof name !== "string") { 3 | name = String(name); 4 | } 5 | 6 | name = name.trim(); 7 | 8 | if (name.length === 0) { 9 | throw new TypeError("Header field name is empty"); 10 | } 11 | 12 | if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name)) { 13 | throw new TypeError(`Invalid character in header field name: ${name}`); 14 | } 15 | 16 | return name.toLowerCase(); 17 | } 18 | 19 | function normalizeValue(value) { 20 | if (typeof value !== "string") { 21 | value = String(value); 22 | } 23 | return value; 24 | } 25 | 26 | class Headers { 27 | map = new Map(); 28 | 29 | constructor(init = {}) { 30 | if (init instanceof Headers) { 31 | init.forEach(function (value, name) { 32 | this.append(name, value); 33 | }, this); 34 | 35 | return this; 36 | } 37 | 38 | if (Array.isArray(init)) { 39 | init.forEach(function ([name, value]) { 40 | this.append(name, value); 41 | }, this); 42 | 43 | return this; 44 | } 45 | 46 | Object.getOwnPropertyNames(init).forEach((name) => 47 | this.append(name, init[name]) 48 | ); 49 | } 50 | 51 | append(name, value) { 52 | name = normalizeName(name); 53 | value = normalizeValue(value); 54 | const oldValue = this.get(name); 55 | // From MDN: If the specified header already exists and accepts multiple values, append() will append the new value to the end of the value set. 56 | // However, we're a missing a check on whether the header does indeed accept multiple values 57 | this.map.set(name, oldValue ? oldValue + ", " + value : value); 58 | } 59 | 60 | delete(name) { 61 | this.map.delete(normalizeName(name)); 62 | } 63 | 64 | get(name) { 65 | name = normalizeName(name); 66 | return this.has(name) ? this.map.get(name) : null; 67 | } 68 | 69 | has(name) { 70 | return this.map.has(normalizeName(name)); 71 | } 72 | 73 | set(name, value) { 74 | this.map.set(normalizeName(name), normalizeValue(value)); 75 | } 76 | 77 | forEach(callback, thisArg) { 78 | this.map.forEach(function (value, name) { 79 | callback.call(thisArg, value, name, this); 80 | }, this); 81 | } 82 | 83 | keys() { 84 | return this.map.keys(); 85 | } 86 | 87 | values() { 88 | return this.map.values(); 89 | } 90 | 91 | entries() { 92 | return this.map.entries(); 93 | } 94 | 95 | [Symbol.iterator]() { 96 | return this.entries(); 97 | } 98 | } 99 | 100 | export default Headers; 101 | -------------------------------------------------------------------------------- /src/Request.js: -------------------------------------------------------------------------------- 1 | import Body from "./Body"; 2 | import Headers from "./Headers"; 3 | 4 | class Request { 5 | credentials = "same-origin"; 6 | method = "GET"; 7 | 8 | constructor(input, options = {}) { 9 | this.url = input; 10 | 11 | if (input instanceof Request) { 12 | if (input._body.bodyUsed) { 13 | throw new TypeError("Already read"); 14 | } 15 | 16 | this.__handleRequestInput(input, options); 17 | } 18 | 19 | this._body = this._body ?? new Body(options.body); 20 | this.method = options.method ?? this.method; 21 | this.method = this.method.toUpperCase(); 22 | 23 | if (this._body._bodyInit && ["GET", "HEAD"].includes(this.method)) { 24 | throw new TypeError("Body not allowed for GET or HEAD requests"); 25 | } 26 | 27 | if (this._body._bodyReadableStream) { 28 | throw new TypeError("Streaming request bodies is not supported"); 29 | } 30 | 31 | this.credentials = options.credentials ?? this.credentials; 32 | this.headers = this.headers ?? new Headers(options.headers); 33 | this.signal = options.signal ?? this.signal; 34 | 35 | if (!this.headers.has("content-type") && this._body._mimeType) { 36 | this.headers.set("content-type", this._body._mimeType); 37 | } 38 | 39 | this.__handleCacheOption(options.cache); 40 | } 41 | 42 | __handleRequestInput(request, options) { 43 | this.url = request.url; 44 | this.credentials = request.credentials; 45 | this.method = request.method; 46 | this.signal = request.signal; 47 | this.headers = new Headers(options.headers ?? request.headers); 48 | 49 | if (!options.body && request._body._bodyInit) { 50 | this._body = new Body(request._body._bodyInit); 51 | request._body.bodyUsed = true; 52 | } 53 | } 54 | 55 | __handleCacheOption(cache) { 56 | if (!["GET", "HEAD"].includes(this.method)) { 57 | return; 58 | } 59 | 60 | if (!["no-store", "no-cache"].includes(cache)) { 61 | return; 62 | } 63 | 64 | const currentTime = Date.now(); 65 | // Search for a '_' parameter in the query string 66 | const querySearchRegExp = /([?&])_=[^&]*/; 67 | 68 | if (querySearchRegExp.test(this.url)) { 69 | this.url = this.url.replace( 70 | querySearchRegExp, 71 | `$1_=${currentTime}` 72 | ); 73 | 74 | return; 75 | } 76 | 77 | const hasQueryRegExp = /\?/; 78 | const querySeparator = hasQueryRegExp.test(this.url) ? "&" : "?"; 79 | 80 | this.url += `${querySeparator}_=${currentTime}`; 81 | } 82 | 83 | get bodyUsed() { 84 | return this._body.bodyUsed; 85 | } 86 | 87 | clone() { 88 | return new Request(this, { body: this._body._bodyInit }); 89 | } 90 | 91 | blob() { 92 | return this._body.blob(); 93 | } 94 | 95 | arrayBuffer() { 96 | return this._body.arrayBuffer(); 97 | } 98 | 99 | text() { 100 | return this._body.text(); 101 | } 102 | 103 | json() { 104 | return this._body.json(); 105 | } 106 | 107 | formData() { 108 | return this._body.formData(); 109 | } 110 | } 111 | 112 | export default Request; 113 | -------------------------------------------------------------------------------- /fetch.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | type CredentialsType = "omit" | "same-origin" | "include"; 4 | 5 | type ResponseType = "basic" | "error"; 6 | 7 | type CacheType = "no-store" | "no-cache"; 8 | 9 | type BodyInit = 10 | | string 11 | | URLSearchParams 12 | | FormData 13 | | Blob 14 | | ArrayBuffer 15 | | ReadableStream 16 | | $ArrayBufferView; 17 | 18 | type RequestInfo = Request | URL | string; 19 | 20 | type RequestOptions = {| 21 | body?: ?BodyInit, 22 | credentials?: CredentialsType, 23 | headers?: HeadersInit, 24 | method?: string, 25 | mode?: string, 26 | referrer?: string, 27 | signal?: ?AbortSignal, 28 | cache?: ?CacheType, 29 | |}; 30 | 31 | type ResponseOptions = {| 32 | status?: number, 33 | statusText?: string, 34 | headers?: HeadersInit, 35 | url?: string, 36 | |}; 37 | 38 | type HeadersInit = Headers | { [string]: string }; 39 | 40 | // https://github.com/facebook/flow/blob/f68b89a5012bd995ab3509e7a41b7325045c4045/lib/bom.js#L902-L914 41 | declare class Headers { 42 | @@iterator(): Iterator<[string, string]>; 43 | constructor(init?: HeadersInit): void; 44 | append(name: string, value: string): void; 45 | delete(name: string): void; 46 | entries(): Iterator<[string, string]>; 47 | forEach( 48 | (value: string, name: string, headers: Headers) => any, 49 | thisArg?: any 50 | ): void; 51 | get(name: string): null | string; 52 | has(name: string): boolean; 53 | keys(): Iterator; 54 | set(name: string, value: string): void; 55 | values(): Iterator; 56 | } 57 | 58 | // https://github.com/facebook/flow/pull/6548 59 | interface AbortSignal { 60 | aborted: boolean; 61 | addEventListener( 62 | type: string, 63 | listener: (Event) => void, 64 | options?: EventListenerOptionsOrUseCapture 65 | ): void; 66 | removeEventListener( 67 | type: string, 68 | listener: (Event) => void, 69 | options?: EventListenerOptionsOrUseCapture 70 | ): void; 71 | } 72 | 73 | // https://github.com/facebook/flow/blob/f68b89a5012bd995ab3509e7a41b7325045c4045/lib/bom.js#L994-L1018 74 | // unsupported in polyfill: 75 | // - cache 76 | // - integrity 77 | // - redirect 78 | // - referrerPolicy 79 | declare class Request { 80 | constructor(input: RequestInfo, init?: RequestOptions): void; 81 | clone(): Request; 82 | 83 | url: string; 84 | 85 | credentials: CredentialsType; 86 | headers: Headers; 87 | method: string; 88 | mode: ModeType; 89 | referrer: string; 90 | signal: ?AbortSignal; 91 | 92 | // Body methods and attributes 93 | bodyUsed: boolean; 94 | 95 | arrayBuffer(): Promise; 96 | blob(): Promise; 97 | formData(): Promise; 98 | json(): Promise; 99 | text(): Promise; 100 | } 101 | 102 | // https://github.com/facebook/flow/blob/f68b89a5012bd995ab3509e7a41b7325045c4045/lib/bom.js#L968-L992 103 | // unsupported in polyfill: 104 | // - redirected 105 | // - trailer 106 | declare class Response { 107 | constructor(input?: ?BodyInit, init?: ResponseOptions): void; 108 | clone(): Response; 109 | static error(): Response; 110 | static redirect(url: string, status?: number): Response; 111 | 112 | type: ResponseType; 113 | url: string; 114 | ok: boolean; 115 | status: number; 116 | statusText: string; 117 | headers: Headers; 118 | 119 | // Body methods and attributes 120 | bodyUsed: boolean; 121 | body: ReadableStream; 122 | arrayBuffer(): Promise; 123 | blob(): Promise; 124 | formData(): Promise; 125 | json(): Promise; 126 | text(): Promise; 127 | } 128 | 129 | declare class AbortError extends Error { 130 | constructor(message?: string, name?: string): void; 131 | } 132 | 133 | declare module.exports: { 134 | fetch(input: RequestInfo, init?: RequestOptions): Promise, 135 | Headers: typeof Headers, 136 | Request: typeof Request, 137 | Response: typeof Response, 138 | AbortError: typeof AbortError, 139 | }; 140 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const querystring = require("querystring"); 2 | const delay = require("delay"); 3 | const url = require("url"); 4 | const http = require("http"); 5 | const { promisify } = require("util"); 6 | 7 | const routes = { 8 | "/request": function (res, req) { 9 | res.writeHead(200, { "Content-Type": "application/json" }); 10 | let data = ""; 11 | req.on("data", function (c) { 12 | data += c; 13 | }); 14 | req.on("end", function () { 15 | res.end( 16 | JSON.stringify({ 17 | method: req.method, 18 | url: req.url, 19 | headers: req.headers, 20 | data: data, 21 | }) 22 | ); 23 | }); 24 | }, 25 | "/hello": function (res, req) { 26 | res.writeHead(200, { 27 | "Content-Type": "text/plain", 28 | "X-Request-URL": "http://" + req.headers.host + req.url, 29 | }); 30 | res.end("hi"); 31 | }, 32 | "/hello/utf8": function (res) { 33 | res.writeHead(200, { 34 | "Content-Type": "text/plain; charset=utf-8", 35 | }); 36 | // "hello" 37 | const buf = Buffer.from([104, 101, 108, 108, 111]); 38 | res.end(buf); 39 | }, 40 | "/hello/utf16le": function (res) { 41 | res.writeHead(200, { 42 | "Content-Type": "text/plain; charset=utf-16le", 43 | }); 44 | // "hello" 45 | const buf = Buffer.from([104, 0, 101, 0, 108, 0, 108, 0, 111, 0]); 46 | res.end(buf); 47 | }, 48 | "/binary": function (res) { 49 | res.writeHead(200, { "Content-Type": "application/octet-stream" }); 50 | const buf = Buffer.alloc(256); 51 | for (let i = 0; i < 256; i++) { 52 | buf[i] = i; 53 | } 54 | res.end(buf); 55 | }, 56 | "/redirect/301": function (res) { 57 | res.writeHead(301, { Location: "/hello" }); 58 | res.end(); 59 | }, 60 | "/redirect/302": function (res) { 61 | res.writeHead(302, { Location: "/hello" }); 62 | res.end(); 63 | }, 64 | "/redirect/303": function (res) { 65 | res.writeHead(303, { Location: "/hello" }); 66 | res.end(); 67 | }, 68 | "/redirect/307": function (res) { 69 | res.writeHead(307, { Location: "/hello" }); 70 | res.end(); 71 | }, 72 | "/redirect/308": function (res) { 73 | res.writeHead(308, { Location: "/hello" }); 74 | res.end(); 75 | }, 76 | "/boom": function (res) { 77 | res.writeHead(500, { "Content-Type": "text/plain" }); 78 | res.end("boom"); 79 | }, 80 | "/empty": function (res) { 81 | res.writeHead(204); 82 | res.end(); 83 | }, 84 | "/slow": function (res) { 85 | setTimeout(function () { 86 | res.writeHead(200, { 87 | "Cache-Control": "no-cache, must-revalidate", 88 | }); 89 | res.end(); 90 | }, 200); 91 | }, 92 | "/error": function (res) { 93 | res.destroy(); 94 | }, 95 | "/form": function (res) { 96 | res.writeHead(200, { 97 | "Content-Type": "application/x-www-form-urlencoded", 98 | }); 99 | res.end("number=1&space=one+two&empty=&encoded=a%2Bb&"); 100 | }, 101 | "/json": function (res) { 102 | res.writeHead(200, { "Content-Type": "application/json" }); 103 | res.end(JSON.stringify({ name: "Hubot", login: "hubot" })); 104 | }, 105 | "/json-error": function (res) { 106 | res.writeHead(200, { "Content-Type": "application/json" }); 107 | res.end("not json {"); 108 | }, 109 | "/cookie": function (res, req) { 110 | let setCookie, cookie; 111 | const params = querystring.parse(url.parse(req.url).query); 112 | if (params.name && params.value) { 113 | setCookie = [params.name, params.value].join("="); 114 | } 115 | if (params.name) { 116 | cookie = querystring.parse(req.headers.cookie, "; ")[params.name]; 117 | } 118 | res.writeHead(200, { 119 | "Content-Type": "text/plain", 120 | "Set-Cookie": setCookie || "", 121 | }); 122 | res.end(cookie); 123 | }, 124 | "/headers": function (res) { 125 | res.writeHead(200, { 126 | Date: "Mon, 13 Oct 2014 21:02:27 GMT", 127 | "Content-Type": "text/html; charset=utf-8", 128 | }); 129 | res.end(); 130 | }, 131 | "/stream": async function (res) { 132 | res.writeHead(200, { 133 | "Content-Type": "text/html; charset=utf-8", 134 | "Transfer-Encoding": "chunked", 135 | }); 136 | await delay(100); 137 | res.write("Hello "); 138 | await delay(300); 139 | res.write("world"); 140 | await delay(100); 141 | res.end("!"); 142 | }, 143 | }; 144 | 145 | module.exports = ({ port = 8082 } = {}) => { 146 | const server = http.createServer((req, res) => { 147 | const path = url.parse(req.url).pathname; 148 | const route = routes[path]; 149 | 150 | route(res, req); 151 | }); 152 | const originalClose = server.close.bind(server); 153 | 154 | server.listen(port); 155 | server.close = () => promisify(originalClose); 156 | 157 | return server; 158 | }; 159 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | prepare: 11 | runs-on: macos-latest 12 | name: "Prepare" 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node.js 14 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 14 21 | 22 | - name: Cache node_modules 23 | uses: actions/cache@v2 24 | with: 25 | path: ~/.npm 26 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: node_modules- 28 | 29 | - name: Install node_modules 30 | run: | 31 | npm ci --prefer-offline --no-audit 32 | 33 | android: 34 | needs: prepare 35 | runs-on: macos-latest 36 | env: 37 | REACT_NATIVE_VERSION: "0.63.4" 38 | name: "Android" 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@v2 42 | 43 | - name: Set up Node.js 14 44 | uses: actions/setup-node@v1 45 | with: 46 | node-version: 14 47 | 48 | - name: Cache node_modules 49 | uses: actions/cache@v2 50 | with: 51 | path: ~/.npm 52 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 53 | restore-keys: node_modules- 54 | 55 | - name: Install node_modules 56 | run: | 57 | npm ci --prefer-offline --no-audit 58 | 59 | - name: Lint 60 | run: | 61 | npm run lint 62 | 63 | - name: Cache Android build 64 | uses: actions/cache@v2 65 | with: 66 | path: | 67 | ~/.gradle/caches 68 | ~/.gradle/wrapper 69 | ~/.android/build-cache 70 | key: ${{ runner.os }}-gradle-rn-${{ env.REACT_NATIVE_VERSION }} 71 | 72 | - name: Inject test app path into a job-wide environment variable 73 | run: | 74 | DIR=~/rn-android-test-app 75 | echo "REACT_NATIVE_TEST_APP=$DIR" >> $GITHUB_ENV 76 | 77 | - name: Cache React Native test app 78 | uses: actions/cache@v2 79 | with: 80 | path: ${{ env.REACT_NATIVE_TEST_APP }} 81 | key: ${{ runner.os }}-rn-android-${{ env.REACT_NATIVE_VERSION }} 82 | 83 | - name: Run Android tests 84 | uses: reactivecircus/android-emulator-runner@v2 85 | with: 86 | api-level: 28 87 | target: default 88 | arch: x86_64 89 | profile: pixel 90 | avd-name: google-pixel 91 | script: | 92 | ./run-tests.js --platform android test/index.js 93 | 94 | ios: 95 | needs: prepare 96 | runs-on: macos-latest 97 | env: 98 | REACT_NATIVE_VERSION: "0.62.0" 99 | name: "iOS" 100 | steps: 101 | - name: Checkout code 102 | uses: actions/checkout@v2 103 | 104 | - name: Set up Node.js 14 105 | uses: actions/setup-node@v1 106 | with: 107 | node-version: 14 108 | 109 | - name: Cache node_modules 110 | uses: actions/cache@v2 111 | with: 112 | path: ~/.npm 113 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 114 | restore-keys: node_modules- 115 | 116 | - name: Install node_modules 117 | run: | 118 | npm ci --prefer-offline --no-audit 119 | 120 | - name: Lint 121 | run: | 122 | npm run lint 123 | 124 | # - name: List environment capabilities 125 | # run: | 126 | # xcversion --version 127 | # xcodebuild -version 128 | # xcrun simctl list 129 | # xcversion select 12.1 130 | 131 | - name: Create and run iOS simulator 132 | id: setup-ios-simulator 133 | run: | 134 | SIMULATOR_RUNTIME=$(echo "iOS 14.4" | sed 's/[ \.]/-/g') 135 | SIMULATOR_ID=$(xcrun simctl create "iPhone 11" com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.$SIMULATOR_RUNTIME) 136 | echo "::set-output name=simulator_id::$SIMULATOR_ID" 137 | xcrun simctl boot $SIMULATOR_ID & 138 | 139 | - name: Cache iOS build 140 | uses: actions/cache@v2 141 | with: 142 | path: ~/Library/Developer/Xcode/DerivedData/Test-* 143 | key: ${{ runner.os }}-xcodebuild-rn-${{ env.REACT_NATIVE_VERSION }} 144 | 145 | - name: Cache pods and repositories 146 | uses: actions/cache@v2 147 | with: 148 | path: | 149 | ~/Library/Caches/CocoaPods 150 | ~/.cocoapods 151 | key: ${{ runner.os }}-cocoapods-rn-${{ env.REACT_NATIVE_VERSION }} 152 | 153 | - name: Inject test app path into a job-wide environment variable 154 | run: | 155 | DIR=~/rn-ios-test-app 156 | echo "REACT_NATIVE_TEST_APP=$DIR" >> $GITHUB_ENV 157 | 158 | - name: Cache React Native test app 159 | uses: actions/cache@v2 160 | with: 161 | path: ${{ env.REACT_NATIVE_TEST_APP }} 162 | key: ${{ runner.os }}-rn-ios-${{ env.REACT_NATIVE_VERSION }} 163 | 164 | - name: Run iOS tests 165 | run: | 166 | ./run-tests.js --platform ios --rn ${{ env.REACT_NATIVE_VERSION }} test/index.js 167 | 168 | - name: Shutdown iOS simulator 169 | run: | 170 | xcrun simctl shutdown ${{ steps.setup-ios-simulator.outputs.simulator_id }} 171 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | andreclima.pt@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /src/Body.js: -------------------------------------------------------------------------------- 1 | import { createBlobReader, drainStream, readArrayBufferAsText } from "./utils"; 2 | 3 | class Body { 4 | constructor(body) { 5 | this.bodyUsed = false; 6 | this._bodyInit = body; 7 | 8 | if (!body) { 9 | this._bodyText = ""; 10 | return this; 11 | } 12 | 13 | if (body instanceof Blob) { 14 | this._bodyBlob = body; 15 | this._mimeType = body.type; 16 | return this; 17 | } 18 | 19 | if (body instanceof FormData) { 20 | this._bodyFormData = body; 21 | this._mimeType = "multipart/form-data"; 22 | return this; 23 | } 24 | 25 | if (body instanceof URLSearchParams) { 26 | // URLSearchParams is not handled natively so we reassign bodyInit for fetch to send it as text 27 | this._bodyText = this._bodyInit = body.toString(); 28 | this._mimeType = "application/x-www-form-urlencoded;charset=UTF-8"; 29 | return this; 30 | } 31 | 32 | if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { 33 | this._bodyArrayBuffer = body.slice?.(0) ?? body.buffer; 34 | this._mimeType = "application/octet-stream"; 35 | return this; 36 | } 37 | 38 | if (body instanceof ReadableStream) { 39 | this._bodyReadableStream = body; 40 | this._mimeType = "application/octet-stream"; 41 | return this; 42 | } 43 | 44 | this._bodyText = body.toString(); 45 | this._mimeType = "text/plain;charset=UTF-8"; 46 | } 47 | 48 | __consumed() { 49 | if (this.bodyUsed) { 50 | return Promise.reject(new TypeError("Already read")); 51 | } 52 | this.bodyUsed = true; 53 | } 54 | 55 | async blob() { 56 | const alreadyConsumed = this.__consumed(); 57 | if (alreadyConsumed) { 58 | return alreadyConsumed; 59 | } 60 | 61 | if (this._bodyBlob) { 62 | return this._bodyBlob; 63 | } 64 | 65 | // Currently not supported by React Native. It will throw. 66 | // Blobs cannot be constructed from ArrayBuffers or ArrayBufferViews. 67 | if (this._bodyReadableStream) { 68 | const typedArray = await drainStream(this._bodyReadableStream); 69 | 70 | return new Blob([typedArray]); 71 | } 72 | 73 | // Currently not supported by React Native. It will throw. 74 | // Blobs cannot be constructed from ArrayBuffers or ArrayBufferViews. 75 | if (this._bodyArrayBuffer) { 76 | if (ArrayBuffer.isView(this._bodyArrayBuffer)) { 77 | return new Blob([this._bodyArrayBuffer.buffer]); 78 | } 79 | 80 | return new Blob([this._bodyArrayBuffer]); 81 | } 82 | 83 | if (this._bodyFormData) { 84 | throw new Error("Could not read FormData body as blob"); 85 | } 86 | 87 | return new Blob([this._bodyText]); 88 | } 89 | 90 | async arrayBuffer() { 91 | if (this._bodyText) { 92 | const blob = await this.blob(); 93 | 94 | return createBlobReader(blob).readAsArrayBuffer(); 95 | } 96 | 97 | const alreadyConsumed = this.__consumed(); 98 | if (alreadyConsumed) { 99 | return alreadyConsumed; 100 | } 101 | 102 | if (this._bodyReadableStream) { 103 | const typedArray = await drainStream(this._bodyReadableStream); 104 | 105 | return typedArray.buffer; 106 | } 107 | 108 | if (this._bodyArrayBuffer) { 109 | if (ArrayBuffer.isView(this._bodyArrayBuffer)) { 110 | const { 111 | buffer, 112 | byteOffset, 113 | byteLength, 114 | } = this._bodyArrayBuffer; 115 | 116 | return Promise.resolve( 117 | buffer.slice(byteOffset, byteOffset + byteLength) 118 | ); 119 | } 120 | 121 | return Promise.resolve(this._bodyArrayBuffer); 122 | } 123 | } 124 | 125 | async text() { 126 | const alreadyConsumed = this.__consumed(); 127 | if (alreadyConsumed) { 128 | return alreadyConsumed; 129 | } 130 | 131 | if (this._bodyReadableStream) { 132 | const typedArray = await drainStream(this._bodyReadableStream); 133 | 134 | return readArrayBufferAsText(typedArray); 135 | } 136 | 137 | if (this._bodyBlob) { 138 | return createBlobReader(this._bodyBlob).readAsText(); 139 | } 140 | 141 | if (this._bodyArrayBuffer) { 142 | return readArrayBufferAsText(this._bodyArrayBuffer); 143 | } 144 | 145 | if (this._bodyFormData) { 146 | throw new Error("Could not read FormData body as text"); 147 | } 148 | 149 | return this._bodyText; 150 | } 151 | 152 | async json() { 153 | const text = await this.text(); 154 | 155 | return JSON.parse(text); 156 | } 157 | 158 | async formData() { 159 | const text = await this.text(); 160 | const formData = new FormData(); 161 | 162 | text.trim() 163 | .split("&") 164 | .forEach((pairs) => { 165 | if (!pairs) { 166 | return; 167 | } 168 | 169 | const split = pairs.split("="); 170 | const name = split.shift().replace(/\+/g, " "); 171 | const value = split.join("=").replace(/\+/g, " "); 172 | formData.append( 173 | decodeURIComponent(name), 174 | decodeURIComponent(value) 175 | ); 176 | }); 177 | 178 | return formData; 179 | } 180 | 181 | get body() { 182 | if (this._bodyReadableStream) { 183 | return this._bodyReadableStream; 184 | } 185 | 186 | if (this._bodyArrayBuffer) { 187 | const typedArray = new Uint8Array(this._bodyArrayBuffer); 188 | 189 | return new ReadableStream({ 190 | start(controller) { 191 | typedArray.forEach((chunk) => { 192 | controller.enqueue(chunk); 193 | }); 194 | 195 | controller.close(); 196 | }, 197 | }); 198 | } 199 | 200 | if (this._bodyBlob) { 201 | return new ReadableStream({ 202 | start: async (controller) => { 203 | const arrayBuffer = await createBlobReader( 204 | this._bodyBlob 205 | ).readAsArrayBuffer(); 206 | const typedArray = new Uint8Array(arrayBuffer); 207 | 208 | typedArray.forEach((chunk) => { 209 | controller.enqueue(chunk); 210 | }); 211 | 212 | controller.close(); 213 | }, 214 | }); 215 | } 216 | 217 | const text = this._bodyFormData?.toString() ?? this._bodyText; 218 | 219 | return new ReadableStream({ 220 | start: async (controller) => { 221 | const typedArray = new Uint8Array(text); 222 | 223 | typedArray.forEach((chunk) => { 224 | controller.enqueue(chunk); 225 | }); 226 | 227 | controller.close(); 228 | }, 229 | }); 230 | } 231 | } 232 | 233 | export default Body; 234 | -------------------------------------------------------------------------------- /src/Fetch.js: -------------------------------------------------------------------------------- 1 | import { Networking } from "react-native"; 2 | import pDefer from "p-defer"; 3 | import Request from "./Request"; 4 | import Response from "./Response"; 5 | import BlobResponse from "./BlobResponse"; 6 | import ArrayBufferResponse from "./ArrayBufferResponse"; 7 | 8 | class AbortError extends Error { 9 | constructor() { 10 | super("Aborted"); 11 | this.name = "AbortError"; 12 | Error.captureStackTrace?.(this, this.constructor); 13 | } 14 | } 15 | 16 | function createStream(cancel) { 17 | let streamController; 18 | 19 | const stream = new ReadableStream({ 20 | start(controller) { 21 | streamController = controller; 22 | }, 23 | cancel, 24 | }); 25 | 26 | return { 27 | stream, 28 | streamController, 29 | }; 30 | } 31 | 32 | class Fetch { 33 | _nativeNetworkSubscriptions = new Set(); 34 | _nativeResponseType = "blob"; 35 | _nativeRequestHeaders = {}; 36 | _nativeResponseHeaders = {}; 37 | _nativeRequestTimeout = 0; 38 | _nativeResponse; 39 | _textEncoder = new TextEncoder(); 40 | _requestId; 41 | _response; 42 | _streamController; 43 | _deferredPromise; 44 | _responseStatus = 0; // requests shall not time out 45 | _responseUrl = ""; 46 | 47 | constructor(resource, options = {}) { 48 | this._request = new Request(resource, options); 49 | 50 | if (this._request.signal?.aborted) { 51 | throw new AbortError(); 52 | } 53 | 54 | this._abortFn = this.__abort.bind(this); 55 | this._deferredPromise = pDefer(); 56 | this._request.signal?.addEventListener("abort", this._abortFn); 57 | 58 | for (const [name, value] of this._request.headers.entries()) { 59 | this._nativeRequestHeaders[name] = value; 60 | } 61 | 62 | this.__setNativeResponseType(options); 63 | this.__doFetch(); 64 | 65 | return this._deferredPromise.promise; 66 | } 67 | 68 | __setNativeResponseType({ reactNative }) { 69 | if (reactNative?.textStreaming) { 70 | this._nativeResponseType = "text"; 71 | 72 | return; 73 | } 74 | 75 | this._nativeResponseType = 76 | reactNative?.__nativeResponseType ?? this._nativeResponseType; 77 | } 78 | 79 | __subscribeToNetworkEvents() { 80 | [ 81 | "didReceiveNetworkResponse", 82 | "didReceiveNetworkData", 83 | "didReceiveNetworkIncrementalData", 84 | // "didReceiveNetworkDataProgress", 85 | "didCompleteNetworkResponse", 86 | ].forEach((eventName) => { 87 | const subscription = Networking.addListener(eventName, (args) => { 88 | this[`__${eventName}`](...args); 89 | }); 90 | this._nativeNetworkSubscriptions.add(subscription); 91 | }); 92 | } 93 | 94 | __clearNetworkSubscriptions() { 95 | this._nativeNetworkSubscriptions.forEach((subscription) => 96 | subscription.remove() 97 | ); 98 | this._nativeNetworkSubscriptions.clear(); 99 | } 100 | 101 | __abort() { 102 | this._requestId && Networking.abortRequest(this._requestId); 103 | this._streamController?.error(new AbortError()); 104 | this._deferredPromise.reject(new AbortError()); 105 | this.__clearNetworkSubscriptions(); 106 | } 107 | 108 | __doFetch() { 109 | this.__subscribeToNetworkEvents(); 110 | 111 | Networking.sendRequest( 112 | this._request.method, 113 | this._request.url.toString(), // request tracking name 114 | this._request.url.toString(), 115 | this._nativeRequestHeaders, 116 | this._request._body._bodyInit ?? null, 117 | this._nativeResponseType, 118 | this._nativeResponseType === "text", // send incremental events only when response type is text 119 | this._nativeRequestTimeout, 120 | this.__didCreateRequest.bind(this), 121 | ["include", "same-origin"].includes(this._request.credentials) // with credentials 122 | ); 123 | } 124 | 125 | __didCreateRequest(requestId) { 126 | // console.log("fetch __didCreateRequest", { requestId }); 127 | this._requestId = requestId; 128 | } 129 | 130 | __didReceiveNetworkResponse(requestId, status, headers, url) { 131 | // console.log("fetch __didReceiveNetworkResponse", { 132 | // requestId, 133 | // status, 134 | // headers, 135 | // url, 136 | // }); 137 | 138 | if (requestId !== this._requestId) { 139 | return; 140 | } 141 | 142 | const { stream, streamController } = createStream(() => { 143 | this.__clearNetworkSubscriptions(); 144 | Networking.abortRequest(this._requestId); 145 | }); 146 | 147 | this._streamController = streamController; 148 | this._stream = stream; 149 | this._responseStatus = status; 150 | this._nativeResponseHeaders = headers; 151 | this._responseUrl = url; 152 | 153 | if (this._nativeResponseType === "text") { 154 | this._response = new Response(stream, { status, headers, url }); 155 | this._deferredPromise.resolve(this._response); 156 | } 157 | } 158 | 159 | __didReceiveNetworkData(requestId, response) { 160 | // console.log("fetch __didReceiveNetworkData", { requestId, response }); 161 | if (requestId !== this._requestId) { 162 | return; 163 | } 164 | 165 | this._nativeResponse = response; 166 | } 167 | 168 | __didReceiveNetworkIncrementalData( 169 | requestId, 170 | responseText, 171 | progress, 172 | total 173 | ) { 174 | // console.log("fetch __didReceiveNetworkIncrementalData", { 175 | // requestId, 176 | // responseText, 177 | // progress, 178 | // total, 179 | // }); 180 | if (requestId !== this._requestId) { 181 | return; 182 | } 183 | 184 | const typedArray = this._textEncoder.encode(responseText, { 185 | stream: true, 186 | }); 187 | this._streamController.enqueue(typedArray); 188 | } 189 | 190 | // __didReceiveNetworkDataProgress(requestId, loaded, total) { 191 | // console.log('fetch __didReceiveNetworkDataProgress', { requestId, loaded, total }); 192 | // if (requestId !== this._requestId) { 193 | // return; 194 | // } 195 | // } 196 | 197 | async __didCompleteNetworkResponse(requestId, errorMessage, didTimeOut) { 198 | // console.log("fetch __didCompleteNetworkResponse", { 199 | // requestId, 200 | // errorMessage, 201 | // didTimeOut, 202 | // }); 203 | 204 | if (requestId !== this._requestId) { 205 | return; 206 | } 207 | 208 | this.__clearNetworkSubscriptions(); 209 | this._request.signal?.removeEventListener("abort", this._abortFn); 210 | 211 | if (didTimeOut) { 212 | this.__closeStream(); 213 | 214 | return this._deferredPromise.reject( 215 | new TypeError("Network request timed out") 216 | ); 217 | } 218 | 219 | if (errorMessage) { 220 | this.__closeStream(); 221 | 222 | return this._deferredPromise.reject( 223 | new TypeError(`Network request failed: ${errorMessage}`) 224 | ); 225 | } 226 | 227 | if (this._nativeResponseType === "text") { 228 | this.__closeStream(); 229 | return; 230 | } 231 | 232 | let ResponseClass; 233 | 234 | if (this._nativeResponseType === "blob") { 235 | ResponseClass = BlobResponse; 236 | } 237 | 238 | if (this._nativeResponseType === "base64") { 239 | ResponseClass = ArrayBufferResponse; 240 | } 241 | 242 | try { 243 | this._response = new ResponseClass(this._nativeResponse, { 244 | status: this._responseStatus, 245 | url: this._responseUrl, 246 | headers: this._nativeResponseHeaders, 247 | }); 248 | this._deferredPromise.resolve(this._response); 249 | } catch (error) { 250 | this._deferredPromise.reject(error); 251 | } finally { 252 | this.__closeStream(); 253 | } 254 | } 255 | 256 | __closeStream() { 257 | this._streamController?.close(); 258 | } 259 | } 260 | 261 | export default Fetch; 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fetch 2 | 3 | [![npm version][npm-image]][npm-url] [![ci][github-ci-image]][github-ci-url] 4 | 5 | [npm-url]:https://www.npmjs.com/package/react-native-fetch-api 6 | [npm-image]:https://img.shields.io/npm/v/react-native-fetch-api.svg 7 | [github-ci-url]:https://github.com/react-native-community/fetch/actions 8 | [github-ci-image]:https://github.com/react-native-community/fetch/workflows/Node%20CI/badge.svg 9 | 10 | > A fetch API polyfill for React Native with text streaming support 11 | 12 | This is a fork of GitHub's fetch [polyfill](https://github.com/github/fetch), the fetch implementation React Native currently [provides](https://github.com/facebook/react-native/blob/master/Libraries/Network/fetch.js). This project features an alternative fetch implementation directy built on top of React Native's [Networking API](https://github.com/facebook/react-native/tree/master/Libraries/Network) instead of `XMLHttpRequest` for performance gains. At the same time, it aims to fill in some gaps of the [WHATWG specification](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for fetch, namely the support for text streaming. 13 | 14 | In practice, this implementation is a drop-in replacement to GitHub's polyfill as it closely follows its implementation. Do not use this implementation if your application does not require to stream text. 15 | 16 | ## Motivation 17 | 18 | GitHub's fetch polyfill, originally designed with the intention to be used in web browsers without support for the fetch standard, most notably does not support the consumption of a response body as a stream. 19 | 20 | However, as React Native does not yet provide direct access to the underlying byte stream for responses, we either have to fallback to [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) or React Native's networking API for [iOS](https://github.com/facebook/react-native/blob/v0.63.4/Libraries/Network/RCTNetworking.ios.js) and [Android](https://github.com/facebook/react-native/blob/v0.63.4/Libraries/Network/RCTNetworking.android.js). Currently, only strings can be transfered through the bridge, thus binary data has to be base64-encoded ([source](https://github.com/react-native-community/discussions-and-proposals/issues/107)) and while React Native's XHR provides [progress events](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/progress_event) to receive incremental data, it concatenates the response string as data comes in. Although [very inefficient](https://github.com/jonnyreeves/fetch-readablestream/blob/cabccb98788a0141b001e6e775fc7fce87c62081/src/defaultTransportFactory.js#L33), the response can be sliced up, each chunk encoded into its UTF-8 representation with [TextEncoder](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) and finally enqueued to the stream. 21 | 22 | Instead of relying on `XMLHttpRequest`, which degrades performance, we remove it out of the equation and have fetch interact with React Native's Networking API directly instead. To make `Response.body` work, `ReadableStream`'s controller was integrated with native progress events. It's important to stress that progress events are only fired when the native response type is set to `text` (https://github.com/facebook/react-native/blob/v0.63.4/Libraries/Network/RCTNetworking.mm#L544-L547), therefore limiting streaming to text-only transfers. If you wish to consume binary data, either `blob` or `base64` response types have to be used. In this case, the downside is that the final response body is read as a whole and enqueued to the stream's controller as a single chunk. There is no way to read a partial response of a binary transfer. 23 | 24 | For more context, read the following: 25 | - https://github.com/github/fetch/issues/746 26 | - https://github.com/facebook/react-native/issues/27741 27 | - https://hpbn.co/xmlhttprequest/#streaming-data-with-xhr 28 | 29 | Related: 30 | - https://github.com/react-native-community/discussions-and-proposals/issues/99 31 | 32 | ## Requirements 33 | 34 | React Native v0.62.0+ is the minimum version supported where the [Networking API has been made public](https://github.com/facebook/react-native/commit/42ee5ec93425c95dee6125a6ff6864ec647636aa). 35 | 36 | This implementation depends on the following web APIs which are not currently available in React Native: 37 | 38 | - [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/TextEncoder) 39 | - [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/TextDecoder) 40 | - [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) 41 | 42 | It should be possible remove the dependency on `TextEncoder` and `TextDecoder`, but not on `ReadableStream`. Either way, beware the bundle size of your application will inevitable increase. 43 | 44 | To polyfill the above APIs, use [react-native-polyfill-globals](https://github.com/acostalima/react-native-polyfill-globals). 45 | 46 | ## Install 47 | 48 | ``` 49 | $ npm install react-native-fetch-api --save 50 | ``` 51 | 52 | ## Setup 53 | 54 | The APIs provided by GitHub's implementation in React Native have to be replaced by those provided by this implementation. To do so, check and install [react-native-polyfill-globals](https://github.com/acostalima/react-native-polyfill-globals) and follow the instructions therein. 55 | 56 | ## Usage 57 | 58 | No need to import anything after the [setup](#setup) is done. All APIs will be available globally. 59 | 60 | Example: 61 | 62 | ```js 63 | fetch('https://jsonplaceholder.typicode.com/todos/1') 64 | .then(response => response.json()) 65 | .then(json => console.log(json)) 66 | ``` 67 | 68 | Check fetch's [official documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to learn more about the concepts and extended usage. 69 | 70 | ### Enable text streaming 71 | 72 | A non-standard option was added to `fetch` to enable incremental events in React Native's networking layer. 73 | 74 | ```js 75 | fetch('https://jsonplaceholder.typicode.com/todos/1', { reactNative: { textStreaming: true } }) 76 | .then(response => response.body) 77 | .then(stream => ...) 78 | ``` 79 | ### Aborting requests 80 | 81 | It's possible to [abort an on-going request](https://developers.google.com/web/updates/2017/09/abortable-fetch) and React Native already supports [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), so there is no need for a polyfill. 82 | 83 | ```js 84 | const controller = new AbortController(); 85 | 86 | fetch('https://jsonplaceholder.typicode.com/todos/1', { signal: controller.signal }) 87 | .then(response => response.json()) 88 | .then(json => console.log(json)) 89 | ``` 90 | 91 | Learn more about aborting fetch at https://developers.google.com/web/updates/2017/09/abortable-fetch. 92 | 93 | ### Cookies 94 | 95 | There is no concept of Cross-Origin Resource Sharing (CORS) in native apps. React Native only accepts a boolean value for the [`credentials`](https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials) option. As such, to send cookies you can either use `same-origin` and `include`. 96 | 97 | The `Set-Cookie` response header returned from the server is a [forbidden header name](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name) and therefore can't be programmatically read with `response.headers.get()`. Instead, the platform's native networking stack automatically manages cookies for you. 98 | 99 | If you run into issues with cookie-based authentication, read the following: 100 | - https://reactnative.dev/docs/network#known-issues-with-fetch-and-cookie-based-authentication 101 | - https://build.affinity.co/persisting-sessions-with-react-native-4c46af3bfd83 102 | - https://medium.com/locastic/react-native-cookie-based-authentication-80ee18f4c71b 103 | 104 | Alternatively, you may consider using the [react-native-cookies](https://github.com/react-native-cookies/cookies). 105 | 106 | ### Request caching directive 107 | 108 | The only values supported for the [`cache`](https://developer.mozilla.org/en-US/docs/Web/API/Request/cache) option are `no-cache` and `no-store` and Both achieve exactly the same result. All other values are ignored. Following GitHub's implementation, a cache-busting mechanism is provided by using the query parameter `_` which holds the number of milliseconds elapsed since the Epoch when either `no-cache` or `no-store` are specified. 109 | 110 | ### Redirect modes directive 111 | 112 | The fetch specification defines these values for the [`redirect`](https://developer.mozilla.org/en-US/docs/Web/API/Request/redirect) option: `follow` (the default), `error`, and `manual`. React Native does not accept such option but it does transparently follow a redirect response given the `Location` header for 30x status codes. 113 | 114 | ## Tests 115 | 116 | To run the test suite, you must use [`react-native-test-runner`](https://github.com/acostalima/react-native-test-runner) CLI. Run the `run-tests.js` wrapper script to spin up a local HTTP server to execute the networking tests against. 117 | 118 | ### iOS 119 | 120 | ``` 121 | $ ./run-tests.js --platform ios --simulator '' test/index.js 122 | ``` 123 | 124 | Where `` can be a combination of a device type and iOS version, e.g. `iPhone 11 (14.1)`, or a device UUID. 125 | Check which simulators are available in your system by running the following command: 126 | 127 | ``` 128 | $ xcrun xctrace list devices 129 | ``` 130 | 131 | ### Android 132 | 133 | ``` 134 | $ ./run-tests.js --platform android --emulator '' test/index.js 135 | ``` 136 | 137 | Where `` is the name of the Android Virtual Device (AVD), e.g. `Pixel_API_28_AOSP`. 138 | Check which emulators are available in your system by running the following command: 139 | 140 | ``` 141 | $ emulator -list-avds 142 | ``` 143 | 144 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { polyfill as polyfillEncoding } from "react-native-polyfill-globals/src/encoding"; 3 | import { polyfill as polyfillReadableStream } from "react-native-polyfill-globals/src/readable-stream"; 4 | import { polyfill as polyfillURL } from "react-native-polyfill-globals/src/url"; 5 | import { test } from "zora"; 6 | import delay from "delay"; 7 | import { Headers, Request, Response, fetch } from "../"; 8 | 9 | polyfillEncoding(); 10 | polyfillReadableStream(); 11 | polyfillURL(); 12 | 13 | const BASE_URL = Platform.select({ 14 | android: "http://10.0.2.2:8082", 15 | ios: "http://localhost:8082", 16 | }); 17 | 18 | function createBlobReader(blob) { 19 | const reader = new FileReader(); 20 | const fileReaderReady = new Promise((resolve, reject) => { 21 | reader.onload = function () { 22 | resolve(reader.result); 23 | }; 24 | reader.onerror = function () { 25 | reject(reader.error); 26 | }; 27 | }); 28 | 29 | return { 30 | readAsArrayBuffer: async () => { 31 | reader.readAsArrayBuffer(blob); 32 | return fileReaderReady; 33 | }, 34 | readAsText: async () => { 35 | reader.readAsText(blob); 36 | return fileReaderReady; 37 | }, 38 | }; 39 | } 40 | 41 | function readArrayBufferAsText(array) { 42 | const decoder = new TextDecoder(); 43 | 44 | return decoder.decode(array); 45 | } 46 | 47 | function createTypedArrayFromText(text) { 48 | const decoder = new TextEncoder(); 49 | 50 | return decoder.encode(text); 51 | } 52 | 53 | async function drainStream(stream) { 54 | const chunks = []; 55 | const reader = stream.getReader(); 56 | 57 | function readNextChunk() { 58 | return reader.read().then(({ done, value }) => { 59 | if (done) { 60 | return chunks.reduce( 61 | (bytes, chunk) => [...bytes, ...chunk], 62 | [] 63 | ); 64 | } 65 | 66 | chunks.push(value); 67 | 68 | return readNextChunk(); 69 | }); 70 | } 71 | 72 | const bytes = await readNextChunk(); 73 | 74 | return new Uint8Array(bytes); 75 | } 76 | 77 | function allSettled(promises) { 78 | return new Promise((resolve, reject) => { 79 | const results = []; 80 | 81 | function resolveWhenAllSettled() { 82 | if (promises.length === results.length) { 83 | resolve(results); 84 | } 85 | } 86 | 87 | promises.forEach((promise) => { 88 | promise 89 | .then((value) => { 90 | results.push({ status: "fulfilled", value }); 91 | }) 92 | .catch((error) => { 93 | results.push({ status: "rejected", error }); 94 | }) 95 | .finally(resolveWhenAllSettled); 96 | }); 97 | }); 98 | } 99 | 100 | test("headers", (t) => { 101 | t.test("constructor copies headers", (t) => { 102 | const original = new Headers(); 103 | original.append("Accept", "application/json"); 104 | original.append("Accept", "text/plain"); 105 | original.append("Content-Type", "text/html"); 106 | 107 | const headers = new Headers(original); 108 | 109 | t.eq(headers.get("Accept"), "application/json, text/plain"); 110 | t.eq(headers.get("Content-type"), "text/html"); 111 | }); 112 | 113 | t.test("constructor works with arrays", (t) => { 114 | const array = [ 115 | ["Content-Type", "text/xml"], 116 | ["Breaking-Bad", "<3"], 117 | ]; 118 | const headers = new Headers(array); 119 | 120 | t.eq(headers.get("Content-Type"), "text/xml"); 121 | t.eq(headers.get("Breaking-Bad"), "<3"); 122 | }); 123 | 124 | t.test("headers are case insensitive", (t) => { 125 | const headers = new Headers({ Accept: "application/json" }); 126 | 127 | t.eq(headers.get("ACCEPT"), "application/json"); 128 | t.eq(headers.get("Accept"), "application/json"); 129 | t.eq(headers.get("accept"), "application/json"); 130 | }); 131 | 132 | t.test("appends to existing", (t) => { 133 | const headers = new Headers({ Accept: "application/json" }); 134 | t.notOk(headers.has("Content-Type")); 135 | 136 | headers.append("Content-Type", "application/json"); 137 | t.ok(headers.has("Content-Type")); 138 | t.eq(headers.get("Content-Type"), "application/json"); 139 | }); 140 | 141 | t.test("appends values to existing header name", (t) => { 142 | const headers = new Headers({ Accept: "application/json" }); 143 | headers.append("Accept", "text/plain"); 144 | 145 | t.eq(headers.get("Accept"), "application/json, text/plain"); 146 | }); 147 | 148 | t.test("sets header name and value", (t) => { 149 | const headers = new Headers(); 150 | headers.set("Content-Type", "application/json"); 151 | 152 | t.eq(headers.get("Content-Type"), "application/json"); 153 | }); 154 | 155 | t.test("returns null on no header found", (t) => { 156 | const headers = new Headers(); 157 | 158 | t.is(headers.get("Content-Type"), null); 159 | }); 160 | 161 | t.test("has headers that are set", (t) => { 162 | const headers = new Headers(); 163 | headers.set("Content-Type", "application/json"); 164 | 165 | t.ok(headers.has("Content-Type")); 166 | }); 167 | 168 | t.test("deletes headers", (t) => { 169 | const headers = new Headers(); 170 | headers.set("Content-Type", "application/json"); 171 | t.ok(headers.has("Content-Type")); 172 | 173 | headers.delete("Content-Type"); 174 | t.notOk(headers.has("Content-Type")); 175 | t.is(headers.get("Content-Type"), null); 176 | }); 177 | 178 | t.test("converts field name to string on set and get", (t) => { 179 | const headers = new Headers(); 180 | headers.set(1, "application/json"); 181 | 182 | t.ok(headers.has("1")); 183 | t.equal(headers.get(1), "application/json"); 184 | }); 185 | 186 | t.test("converts field value to string on set and get", (t) => { 187 | const headers = new Headers(); 188 | headers.set("Content-Type", 1); 189 | headers.set("X-CSRF-Token", undefined); 190 | 191 | t.equal(headers.get("Content-Type"), "1"); 192 | t.equal(headers.get("X-CSRF-Token"), "undefined"); 193 | }); 194 | 195 | t.test("throws TypeError on invalid character in field name", (t) => { 196 | t.throws(() => { 197 | new Headers({ "[Accept]": "application/json" }); 198 | }, TypeError); 199 | t.throws(() => { 200 | new Headers({ "Accept:": "application/json" }); 201 | }, TypeError); 202 | t.throws(() => { 203 | const headers = new Headers(); 204 | headers.set({ field: "value" }, "application/json"); 205 | }, TypeError); 206 | t.throws(() => { 207 | new Headers({ "": "application/json" }); 208 | }, TypeError); 209 | }); 210 | 211 | t.test("is iterable with forEach", (t) => { 212 | const headers = new Headers(); 213 | headers.append("Accept", "application/json"); 214 | headers.append("Accept", "text/plain"); 215 | headers.append("Content-Type", "text/html"); 216 | 217 | const results = []; 218 | headers.forEach((value, name, object) => { 219 | results.push({ value, name, object }); 220 | }); 221 | 222 | t.eq(results.length, 2); 223 | t.eq( 224 | { 225 | name: "accept", 226 | value: "application/json, text/plain", 227 | object: headers, 228 | }, 229 | results[0] 230 | ); 231 | t.eq( 232 | { 233 | name: "content-type", 234 | value: "text/html", 235 | object: headers, 236 | }, 237 | results[1] 238 | ); 239 | }); 240 | 241 | t.test("forEach accepts second thisArg argument", (t) => { 242 | const headers = new Headers({ Accept: "application/json" }); 243 | const thisArg = {}; 244 | headers.forEach(function () { 245 | t.is(this, thisArg); 246 | }, thisArg); 247 | }); 248 | 249 | t.test("is iterable with keys", (t) => { 250 | const headers = new Headers(); 251 | headers.append("Accept", "application/json"); 252 | headers.append("Accept", "text/plain"); 253 | headers.append("Content-Type", "text/html"); 254 | 255 | const iterator = headers.keys(); 256 | t.eq({ done: false, value: "accept" }, iterator.next()); 257 | t.eq({ done: false, value: "content-type" }, iterator.next()); 258 | t.eq({ done: true, value: undefined }, iterator.next()); 259 | }); 260 | 261 | t.test("is iterable with values", (t) => { 262 | const headers = new Headers(); 263 | headers.append("Accept", "application/json"); 264 | headers.append("Accept", "text/plain"); 265 | headers.append("Content-Type", "text/html"); 266 | 267 | var iterator = headers.values(); 268 | t.eq( 269 | { done: false, value: "application/json, text/plain" }, 270 | iterator.next() 271 | ); 272 | t.eq({ done: false, value: "text/html" }, iterator.next()); 273 | t.eq({ done: true, value: undefined }, iterator.next()); 274 | }); 275 | 276 | t.test("is iterable with entries", (t) => { 277 | const headers = new Headers(); 278 | headers.append("Accept", "application/json"); 279 | headers.append("Accept", "text/plain"); 280 | headers.append("Content-Type", "text/html"); 281 | 282 | const iterator = headers.entries(); 283 | t.eq( 284 | { done: false, value: ["accept", "application/json, text/plain"] }, 285 | iterator.next() 286 | ); 287 | t.eq( 288 | { done: false, value: ["content-type", "text/html"] }, 289 | iterator.next() 290 | ); 291 | t.eq({ done: true, value: undefined }, iterator.next()); 292 | }); 293 | 294 | t.test("headers is an iterator which returns entries iterator", (t) => { 295 | const headers = new Headers(); 296 | headers.append("Accept", "application/json"); 297 | headers.append("Accept", "text/plain"); 298 | headers.append("Content-Type", "text/html"); 299 | 300 | const iterator = headers[Symbol.iterator](); 301 | t.eq( 302 | { done: false, value: ["accept", "application/json, text/plain"] }, 303 | iterator.next() 304 | ); 305 | t.eq( 306 | { done: false, value: ["content-type", "text/html"] }, 307 | iterator.next() 308 | ); 309 | t.eq({ done: true, value: undefined }, iterator.next()); 310 | }); 311 | }); 312 | 313 | test("request", (t) => { 314 | t.test("should throw when called without constructor", (t) => { 315 | t.throws(() => { 316 | Request("https://fetch.spec.whatwg.org/"); 317 | }); 318 | }); 319 | 320 | t.test("construct with string url", (t) => { 321 | const req = new Request("https://fetch.spec.whatwg.org/"); 322 | t.eq(req.url, "https://fetch.spec.whatwg.org/"); 323 | }); 324 | 325 | t.test("construct with URL instance", (t) => { 326 | const url = new URL("https://fetch.spec.whatwg.org/pathname"); 327 | const req = new Request(url); 328 | t.eq(req.url.toString(), "https://fetch.spec.whatwg.org/pathname"); 329 | }); 330 | 331 | t.test("construct with non-request object", (t) => { 332 | const url = { 333 | toString: () => { 334 | return "https://fetch.spec.whatwg.org/"; 335 | }, 336 | }; 337 | const req = new Request(url); 338 | t.eq(req.url.toString(), "https://fetch.spec.whatwg.org/"); 339 | }); 340 | 341 | t.test("construct with request", async (t) => { 342 | const request1 = new Request("https://fetch.spec.whatwg.org/", { 343 | method: "POST", 344 | body: "I work out", 345 | headers: { 346 | accept: "application/json", 347 | "Content-Type": "text/plain", 348 | }, 349 | }); 350 | const request2 = new Request(request1); 351 | 352 | const body2 = await request2.text(); 353 | t.eq(body2, "I work out"); 354 | t.eq(request2.method, "POST"); 355 | t.eq(request2.url, "https://fetch.spec.whatwg.org/"); 356 | t.eq(request2.headers.get("accept"), "application/json"); 357 | t.eq(request2.headers.get("content-type"), "text/plain"); 358 | 359 | try { 360 | await request1.text(); 361 | t.ok(false, "original request body should have been consumed"); 362 | } catch (error) { 363 | t.ok( 364 | error instanceof TypeError, 365 | "expected TypeError for already read body" 366 | ); 367 | } 368 | }); 369 | 370 | t.test("construct with request and override headers", (t) => { 371 | const request1 = new Request("https://fetch.spec.whatwg.org/", { 372 | method: "POST", 373 | body: "I work out", 374 | headers: { 375 | accept: "application/json", 376 | "X-Request-ID": "123", 377 | }, 378 | }); 379 | const request2 = new Request(request1, { 380 | headers: { "x-test": "42" }, 381 | }); 382 | 383 | t.eq(request2.headers.get("accept"), null); 384 | t.eq(request2.headers.get("x-request-id"), null); 385 | t.eq(request2.headers.get("x-test"), "42"); 386 | }); 387 | 388 | t.test("construct with request and override body", async (t) => { 389 | const request1 = new Request("https://fetch.spec.whatwg.org/", { 390 | method: "POST", 391 | body: "I work out", 392 | headers: { 393 | "Content-Type": "text/plain", 394 | }, 395 | }); 396 | const request2 = new Request(request1, { 397 | body: '{"wiggles": 5}', 398 | headers: { "Content-Type": "application/json" }, 399 | }); 400 | 401 | const body = await request2.json(); 402 | 403 | t.eq(body.wiggles, 5); 404 | t.eq(request2.headers.get("content-type"), "application/json"); 405 | }); 406 | 407 | t.test("construct with used request body", async (t) => { 408 | const request1 = new Request("https://fetch.spec.whatwg.org/", { 409 | method: "post", 410 | body: "I work out", 411 | }); 412 | 413 | await request1.text(); 414 | 415 | t.throws(() => { 416 | new Request(request1); 417 | }, TypeError); 418 | }); 419 | 420 | t.test("GET should not have implicit Content-Type", (t) => { 421 | const req = new Request("https://fetch.spec.whatwg.org/"); 422 | 423 | t.eq(req.headers.get("content-type"), null); 424 | }); 425 | 426 | t.test( 427 | "POST with blank body should not have implicit Content-Type", 428 | (t) => { 429 | const req = new Request("https://fetch.spec.whatwg.org/", { 430 | method: "POST", 431 | }); 432 | t.eq(req.headers.get("content-type"), null); 433 | } 434 | ); 435 | 436 | t.test("construct with string body sets Content-Type header", (t) => { 437 | const req = new Request("https://fetch.spec.whatwg.org/", { 438 | method: "POST", 439 | body: "I work out", 440 | }); 441 | 442 | t.equal(req.headers.get("content-type"), "text/plain;charset=UTF-8"); 443 | }); 444 | 445 | t.test( 446 | "construct with Blob body and type sets Content-Type header", 447 | (t) => { 448 | const req = new Request("https://fetch.spec.whatwg.org/", { 449 | method: "POST", 450 | body: new Blob(["test"], { type: "image/png" }), 451 | }); 452 | 453 | t.eq(req.headers.get("content-type"), "image/png"); 454 | } 455 | ); 456 | 457 | t.test("construct with body and explicit header uses header", (t) => { 458 | const req = new Request("https://fetch.spec.whatwg.org/", { 459 | method: "POST", 460 | headers: { "Content-Type": "image/png" }, 461 | body: "I work out", 462 | }); 463 | 464 | t.eq(req.headers.get("content-type"), "image/png"); 465 | }); 466 | 467 | t.test("construct with Blob body and explicit Content-Type header", (t) => { 468 | const req = new Request("https://fetch.spec.whatwg.org/", { 469 | method: "POST", 470 | headers: { "Content-Type": "image/png" }, 471 | body: new Blob(["test"], { type: "text/plain" }), 472 | }); 473 | 474 | t.eq(req.headers.get("content-type"), "image/png"); 475 | }); 476 | 477 | t.test( 478 | "construct with URLSearchParams body sets Content-Type header", 479 | (t) => { 480 | const req = new Request("https://fetch.spec.whatwg.org/", { 481 | method: "POST", 482 | body: new URLSearchParams("a=1&b=2"), 483 | }); 484 | 485 | t.eq( 486 | req.headers.get("content-type"), 487 | "application/x-www-form-urlencoded;charset=UTF-8" 488 | ); 489 | } 490 | ); 491 | 492 | t.test( 493 | "construct with URLSearchParams body and explicit Content-Type header", 494 | (t) => { 495 | const req = new Request("https://fetch.spec.whatwg.org/", { 496 | method: "post", 497 | headers: { "Content-Type": "image/png" }, 498 | body: new URLSearchParams("a=1&b=2"), 499 | }); 500 | 501 | t.eq(req.headers.get("content-type"), "image/png"); 502 | } 503 | ); 504 | 505 | t.test("construct with unsupported body type", async (t) => { 506 | const req = new Request("https://fetch.spec.whatwg.org/", { 507 | method: "POST", 508 | body: {}, 509 | }); 510 | 511 | t.eq(req.headers.get("content-type"), "text/plain;charset=UTF-8"); 512 | 513 | const body = await req.text(); 514 | 515 | t.eq(body, "[object Object]"); 516 | }); 517 | 518 | t.test("construct with null body", async (t) => { 519 | const req = new Request("https://fetch.spec.whatwg.org/", { 520 | method: "POST", 521 | }); 522 | 523 | t.is(req.headers.get("content-type"), null); 524 | 525 | const body = await req.text(); 526 | 527 | t.eq(body, ""); 528 | }); 529 | 530 | t.test("clone GET request", (t) => { 531 | const req = new Request("https://fetch.spec.whatwg.org/", { 532 | headers: { "content-type": "text/plain" }, 533 | }); 534 | const clone = req.clone(); 535 | 536 | t.eq(clone.url, req.url); 537 | t.eq(clone.method, "GET"); 538 | t.eq(clone.headers.get("content-type"), "text/plain"); 539 | t.isNot(clone.headers, req.headers); 540 | t.notOk(req.bodyUsed); 541 | }); 542 | 543 | t.test("clone POST request", async (t) => { 544 | const req = new Request("https://fetch.spec.whatwg.org/", { 545 | method: "POST", 546 | headers: { "content-type": "text/plain" }, 547 | body: "I work out", 548 | }); 549 | const clone = req.clone(); 550 | 551 | t.eq(clone.method, "POST"); 552 | t.eq(clone.headers.get("content-type"), "text/plain"); 553 | t.isNot(clone.headers, req.headers); 554 | t.notOk(req.bodyUsed); 555 | 556 | const bodies = await Promise.all([clone.text(), req.clone().text()]); 557 | 558 | t.eq(bodies, ["I work out", "I work out"]); 559 | }); 560 | 561 | t.test("clone with used request body", async (t) => { 562 | const req = new Request("https://fetch.spec.whatwg.org/", { 563 | method: "POST", 564 | body: "I work out", 565 | }); 566 | 567 | await req.text(); 568 | 569 | t.throws(() => { 570 | req.clone(); 571 | }, TypeError); 572 | }); 573 | 574 | t.test("credentials defaults to same-origin", (t) => { 575 | const req = new Request(""); 576 | 577 | t.eq(req.credentials, "same-origin"); 578 | }); 579 | 580 | t.test("credentials is overridable", (t) => { 581 | const req = new Request("", { credentials: "omit" }); 582 | 583 | t.eq(req.credentials, "omit"); 584 | }); 585 | 586 | t.test("GET with body throws TypeError", (t) => { 587 | t.throws(() => { 588 | new Request("", { method: "GET", body: "invalid" }); 589 | }, TypeError); 590 | }); 591 | 592 | t.test("HEAD with body throws TypeError", (t) => { 593 | t.throws(() => { 594 | new Request("", { method: "HEAD", body: "invalid" }); 595 | }, TypeError); 596 | }); 597 | 598 | t.test("consume request body as text when input is text", async (t) => { 599 | const text = "Hello world!"; 600 | const req = new Request("", { method: "POST", body: text }); 601 | 602 | t.eq(await req.text(), text); 603 | }); 604 | 605 | t.test("consume request body as Blob when input is text", async (t) => { 606 | const text = "Hello world!"; 607 | const req = new Request("", { method: "POST", body: text }); 608 | const blob = await req.blob(); 609 | 610 | t.eq(await createBlobReader(blob).readAsText(), text); 611 | }); 612 | 613 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 614 | t.skip( 615 | "consume request body as ArrayBuffer when input is text", 616 | async (t) => { 617 | const text = "Hello world!"; 618 | const req = new Request("", { method: "POST", body: text }); 619 | const arrayBuffer = await req.arrayBuffer(); 620 | 621 | t.eq(readArrayBufferAsText(arrayBuffer), text); 622 | } 623 | ); 624 | 625 | t.test( 626 | "consume request body as text when input is ArrayBuffer", 627 | async (t) => { 628 | const array = createTypedArrayFromText("Hello world!"); 629 | const req = new Request("", { method: "POST", body: array.buffer }); 630 | 631 | t.eq(await req.text(), "Hello world!"); 632 | } 633 | ); 634 | 635 | t.test( 636 | "consume request body as text when input is ArrayBufferView", 637 | async (t) => { 638 | const array = createTypedArrayFromText("Hello world!"); 639 | const req = new Request("", { method: "POST", body: array }); 640 | 641 | t.eq(await req.text(), "Hello world!"); 642 | } 643 | ); 644 | 645 | // React Native does not support Blob construction from ArrayBuffer or ArrayBufferView 646 | t.skip( 647 | "consume request body as Blob when input is ArrayBuffer", 648 | async (t) => { 649 | const array = createTypedArrayFromText("Hello world!"); 650 | const req = new Request("", { method: "POST", body: array.buffer }); 651 | const blob = await req.blob(); 652 | 653 | t.eq(await createBlobReader(blob).readAsText(), "Hello world!"); 654 | } 655 | ); 656 | 657 | // React Native does not support Blob construction from ArrayBuffer or ArrayBufferView 658 | t.skip( 659 | "consume request body as Blob when input is ArrayBufferView", 660 | async (t) => { 661 | const array = createTypedArrayFromText("Hello world!"); 662 | const req = new Request("", { method: "POST", body: array }); 663 | const blob = await req.blob(); 664 | 665 | t.eq(await createBlobReader(blob).readAsText(), "Hello world!"); 666 | } 667 | ); 668 | 669 | t.test( 670 | "consume request body as ArrayBuffer when input is ArrayBuffer", 671 | async (t) => { 672 | const array = createTypedArrayFromText("Hello world!"); 673 | const req = new Request("", { method: "POST", body: array }); 674 | const text = new TextDecoder().decode(await req.arrayBuffer()); 675 | 676 | t.eq(text, "Hello world!"); 677 | } 678 | ); 679 | 680 | t.test( 681 | "consume request body as ArrayBuffer when input is ArrayBufferView", 682 | async (t) => { 683 | const array = createTypedArrayFromText("Hello world!"); 684 | const req = new Request("", { method: "POST", body: array }); 685 | const text = new TextDecoder().decode(await req.arrayBuffer()); 686 | 687 | t.eq(text, "Hello world!"); 688 | } 689 | ); 690 | 691 | t.test("cache-busting query parameter", (t) => { 692 | const originalDateNow = Date.now; 693 | global.Date.now = () => 12345; 694 | 695 | t.test("should not append current time for POST", (t) => { 696 | const req = new Request("https://reactnative.dev/", { 697 | cache: "no-cache", 698 | method: "POST", 699 | }); 700 | 701 | const searchParams = new URL(req.url).searchParams; 702 | 703 | t.notOk(searchParams.has("_")); 704 | }); 705 | 706 | t.test("should not append current time for PUT", (t) => { 707 | const req = new Request("https://reactnative.dev/", { 708 | cache: "no-cache", 709 | method: "PUT", 710 | }); 711 | 712 | const searchParams = new URL(req.url).searchParams; 713 | 714 | t.notOk(searchParams.has("_")); 715 | }); 716 | 717 | t.test("should not append current time for PATCH", (t) => { 718 | const req = new Request("https://reactnative.dev/", { 719 | cache: "no-cache", 720 | method: "PATCH", 721 | }); 722 | 723 | const searchParams = new URL(req.url).searchParams; 724 | 725 | t.notOk(searchParams.has("_")); 726 | }); 727 | 728 | t.test("should append current time for GET", (t) => { 729 | const req = new Request("https://reactnative.dev/", { 730 | cache: "no-cache", 731 | method: "GET", 732 | }); 733 | 734 | const searchParams = new URL(req.url).searchParams; 735 | 736 | t.ok(searchParams.has("_")); 737 | t.eq(searchParams.get("_"), `${Date.now()}`); 738 | }); 739 | 740 | t.test("should append current time for HEAD", (t) => { 741 | const req = new Request("https://reactnative.dev/", { 742 | cache: "no-cache", 743 | method: "HEAD", 744 | }); 745 | 746 | const searchParams = new URL(req.url).searchParams; 747 | 748 | t.ok(searchParams.has("_")); 749 | t.eq(searchParams.get("_"), "12345"); 750 | }); 751 | 752 | t.test("should replace existing value", (t) => { 753 | const req = new Request("https://reactnative.dev/?_=67890", { 754 | cache: "no-cache", 755 | method: "GET", 756 | }); 757 | 758 | const searchParams = new URL(req.url).searchParams; 759 | 760 | t.ok(searchParams.has("_")); 761 | t.eq(searchParams.get("_"), "12345"); 762 | }); 763 | 764 | t.test("should append current time with no-store option too", (t) => { 765 | const req = new Request("https://reactnative.dev/", { 766 | cache: "no-store", 767 | method: "GET", 768 | }); 769 | 770 | const searchParams = new URL(req.url).searchParams; 771 | 772 | t.ok(searchParams.has("_")); 773 | t.eq(searchParams.get("_"), "12345"); 774 | }); 775 | 776 | t.test("should replace existing value with no-store option", (t) => { 777 | const req = new Request("https://reactnative.dev/?_=67890", { 778 | cache: "no-store", 779 | method: "GET", 780 | }); 781 | 782 | const searchParams = new URL(req.url).searchParams; 783 | 784 | t.ok(searchParams.has("_")); 785 | t.eq(searchParams.get("_"), "12345"); 786 | }); 787 | 788 | global.Date.now = originalDateNow; 789 | }); 790 | }); 791 | 792 | test("response", (t) => { 793 | t.test("default status is 200", (t) => { 794 | const res = new Response(); 795 | 796 | t.eq(res.status, 200); 797 | t.eq(res.statusText, ""); 798 | t.ok(res.ok); 799 | }); 800 | 801 | t.test( 802 | "default status is 200 when an explicit undefined status is passed", 803 | (t) => { 804 | const res = new Response("", { status: undefined }); 805 | 806 | t.eq(res.status, 200); 807 | t.eq(res.statusText, ""); 808 | t.ok(res.ok); 809 | } 810 | ); 811 | 812 | t.test("should throw when called without constructor", (t) => { 813 | t.throws(() => { 814 | Response('{"foo":"bar"}', { 815 | headers: { "content-type": "application/json" }, 816 | }); 817 | }); 818 | }); 819 | 820 | t.test("creates headers object from raw headers", async (t) => { 821 | const res = new Response('{"foo":"bar"}', { 822 | headers: { "content-type": "application/json" }, 823 | }); 824 | const json = await res.json(); 825 | 826 | t.ok(res.headers instanceof Headers); 827 | t.eq(json.foo, "bar"); 828 | }); 829 | 830 | t.test("always creates a new headers instance", (t) => { 831 | const headers = new Headers({ "x-hello": "world" }); 832 | const res = new Response("", { headers }); 833 | 834 | t.eq(res.headers.get("x-hello"), "world"); 835 | t.isNot(res.headers, headers); 836 | }); 837 | 838 | t.test("clone text response", async (t) => { 839 | const res = new Response('{"foo":"bar"}', { 840 | headers: { "content-type": "application/json" }, 841 | }); 842 | const clone = res.clone(); 843 | 844 | t.isNot(clone.headers, res.headers, "headers were cloned"); 845 | t.eq(clone.headers.get("content-type"), "application/json"); 846 | 847 | const jsons = await Promise.all([clone.json(), res.json()]); 848 | t.eq( 849 | jsons[0], 850 | jsons[1], 851 | "json of cloned object is the same as original" 852 | ); 853 | }); 854 | 855 | t.test("error creates error Response", (t) => { 856 | const res = Response.error(); 857 | 858 | t.ok(res instanceof Response); 859 | t.eq(res.status, 0); 860 | t.eq(res.statusText, ""); 861 | t.eq(res.type, "error"); 862 | }); 863 | 864 | t.test("redirect creates redirect Response", (t) => { 865 | const res = Response.redirect("https://fetch.spec.whatwg.org/", 301); 866 | 867 | t.ok(res instanceof Response); 868 | t.eq(res.status, 301); 869 | t.eq(res.headers.get("Location"), "https://fetch.spec.whatwg.org/"); 870 | }); 871 | 872 | t.test("construct with string body sets Content-Type header", (t) => { 873 | const res = new Response("I work out"); 874 | 875 | t.eq(res.headers.get("content-type"), "text/plain;charset=UTF-8"); 876 | }); 877 | 878 | t.test( 879 | "construct with Blob body and type sets Content-Type header", 880 | (t) => { 881 | const res = new Response( 882 | new Blob(["test"], { type: "text/plain" }) 883 | ); 884 | 885 | t.eq(res.headers.get("content-type"), "text/plain"); 886 | } 887 | ); 888 | 889 | t.test("construct with body and explicit header uses header", (t) => { 890 | const res = new Response("I work out", { 891 | headers: { 892 | "Content-Type": "text/plain", 893 | }, 894 | }); 895 | 896 | t.eq(res.headers.get("content-type"), "text/plain"); 897 | }); 898 | 899 | t.test("init object as first argument", async (t) => { 900 | const res = new Response({ 901 | status: 201, 902 | headers: { 903 | "Content-Type": "text/html", 904 | }, 905 | }); 906 | 907 | t.eq(res.status, 200); 908 | t.eq(res.headers.get("content-type"), "text/plain;charset=UTF-8"); 909 | 910 | const text = await res.text(); 911 | 912 | t.eq(text, "[object Object]"); 913 | }); 914 | 915 | t.test("null as first argument", async (t) => { 916 | const res = new Response(null); 917 | 918 | t.is(res.headers.get("content-type"), null); 919 | 920 | const text = await res.text(); 921 | 922 | t.eq(text, ""); 923 | }); 924 | 925 | t.test("consume response body as text when input is text", async (t) => { 926 | const text = "Hello world!"; 927 | const req = new Response(text); 928 | 929 | t.eq(await req.text(), text); 930 | }); 931 | 932 | t.test("consume response body as Blob when input is text", async (t) => { 933 | const text = "Hello world!"; 934 | const res = new Response(text); 935 | const blob = await res.blob(); 936 | 937 | t.eq(await createBlobReader(blob).readAsText(), text); 938 | }); 939 | 940 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 941 | t.skip( 942 | "consume request body as ArrayBuffer when input is text", 943 | async (t) => { 944 | const text = "Hello world!"; 945 | const res = new Response(text); 946 | const arrayBuffer = await res.arrayBuffer(); 947 | 948 | t.eq(readArrayBufferAsText(arrayBuffer), text); 949 | } 950 | ); 951 | 952 | t.test( 953 | "consume request body as text when input is ArrayBuffer", 954 | async (t) => { 955 | const array = createTypedArrayFromText("Hello world!"); 956 | const res = new Response(array.buffer); 957 | 958 | t.eq(await res.text(), "Hello world!"); 959 | } 960 | ); 961 | 962 | t.test( 963 | "consume request body as text when input is ArrayBufferView", 964 | async (t) => { 965 | const array = createTypedArrayFromText("Hello world!"); 966 | const res = new Response(array); 967 | 968 | t.eq(await res.text(), "Hello world!"); 969 | } 970 | ); 971 | 972 | // React Native does not support Blob construction from ArrayBuffer or ArrayBufferView 973 | t.skip( 974 | "consume request body as Blob when input is ArrayBuffer", 975 | async (t) => { 976 | const array = createTypedArrayFromText("Hello world!").buffer; 977 | const res = new Response(array.buffer); 978 | const blob = await res.blob(); 979 | 980 | t.eq(await createBlobReader(blob).readAsText(), "Hello world!"); 981 | } 982 | ); 983 | 984 | // React Native does not support Blob construction from ArrayBuffer or ArrayBufferView 985 | t.skip( 986 | "consume request body as Blob when input is ArrayBufferView", 987 | async (t) => { 988 | const array = createTypedArrayFromText("Hello world!"); 989 | const res = new Response("", { method: "POST", body: array }); 990 | const blob = await res.blob(); 991 | 992 | t.eq(await createBlobReader(blob).readAsText(), "Hello world!"); 993 | } 994 | ); 995 | 996 | t.test( 997 | "consume request body as ArrayBuffer when input is ArrayBuffer", 998 | async (t) => { 999 | const array = createTypedArrayFromText("Hello world!"); 1000 | const res = new Response(array); 1001 | const text = new TextDecoder().decode(await res.arrayBuffer()); 1002 | 1003 | t.eq(text, "Hello world!"); 1004 | } 1005 | ); 1006 | 1007 | t.test( 1008 | "consume request body as ArrayBuffer when input is ArrayBufferView", 1009 | async (t) => { 1010 | const array = createTypedArrayFromText("Hello world!"); 1011 | const res = new Response(array); 1012 | const text = new TextDecoder().decode(await res.arrayBuffer()); 1013 | 1014 | t.eq(text, "Hello world!"); 1015 | } 1016 | ); 1017 | 1018 | t.test("consume request body as stream when input is stream", async (t) => { 1019 | const rs = new ReadableStream({ 1020 | async pull(c) { 1021 | await delay(100); 1022 | c.enqueue(createTypedArrayFromText("Hello ")); 1023 | await delay(100); 1024 | c.enqueue(createTypedArrayFromText("world")); 1025 | await delay(100); 1026 | c.enqueue(createTypedArrayFromText("!")); 1027 | c.close(); 1028 | }, 1029 | }); 1030 | const res = new Response(rs); 1031 | const text = new TextDecoder().decode(await drainStream(res.body)); 1032 | 1033 | t.eq(text, "Hello world!"); 1034 | }); 1035 | 1036 | t.test("consume request body as text when input is stream", async (t) => { 1037 | const rs = new ReadableStream({ 1038 | async pull(c) { 1039 | await delay(100); 1040 | c.enqueue(createTypedArrayFromText("Hello ")); 1041 | await delay(100); 1042 | c.enqueue(createTypedArrayFromText("world")); 1043 | await delay(100); 1044 | c.enqueue(createTypedArrayFromText("!")); 1045 | c.close(); 1046 | }, 1047 | }); 1048 | const res = new Response(rs); 1049 | const text = await res.text(); 1050 | 1051 | t.eq(text, "Hello world!"); 1052 | }); 1053 | 1054 | // React Native does not support Blob construction from ArrayBuffer or ArrayBufferView 1055 | t.skip("consume request body as Blob when input is stream", async (t) => { 1056 | const rs = new ReadableStream({ 1057 | async pull(c) { 1058 | await delay(250); 1059 | c.enqueue(createTypedArrayFromText("Hello ")); 1060 | await delay(250); 1061 | c.enqueue(createTypedArrayFromText("world")); 1062 | await delay(250); 1063 | c.enqueue(createTypedArrayFromText("!")); 1064 | c.close(); 1065 | }, 1066 | }); 1067 | const res = new Response(rs); 1068 | const text = await res.text(); 1069 | 1070 | t.eq(text, "Hello world!"); 1071 | }); 1072 | 1073 | t.test( 1074 | "consume request body as ArrayBuffer when input is stream", 1075 | async (t) => { 1076 | const rs = new ReadableStream({ 1077 | async pull(c) { 1078 | await delay(100); 1079 | c.enqueue(createTypedArrayFromText("Hello ")); 1080 | await delay(100); 1081 | c.enqueue(createTypedArrayFromText("world")); 1082 | await delay(100); 1083 | c.enqueue(createTypedArrayFromText("!")); 1084 | c.close(); 1085 | }, 1086 | }); 1087 | const res = new Response(rs); 1088 | const text = new TextDecoder().decode(await res.arrayBuffer()); 1089 | 1090 | t.eq(text, "Hello world!"); 1091 | } 1092 | ); 1093 | }); 1094 | 1095 | test("body mixin", (t) => { 1096 | t.test("arrayBuffer", (t) => { 1097 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1098 | t.skip("resolves arrayBuffer promise", async (t) => { 1099 | const url = new URL("/hello", BASE_URL); 1100 | const res = await fetch(url); 1101 | const buf = await res.arrayBuffer(); 1102 | 1103 | t.ok(buf instanceof ArrayBuffer, "buf is an ArrayBuffer instance"); 1104 | t.eq(buf.byteLength, 2); 1105 | }); 1106 | 1107 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1108 | t.skip("arrayBuffer handles binary data", async (t) => { 1109 | const url = new URL("/binary", BASE_URL); 1110 | const res = await fetch(url); 1111 | const buf = await res.arrayBuffer(); 1112 | 1113 | t.ok(buf instanceof ArrayBuffer, "buf is an ArrayBuffer instance"); 1114 | t.eq(buf.byteLength, 256, "buf.byteLength is correct"); 1115 | 1116 | const expected = Array.from({ length: 256 }, (_, i) => i); 1117 | const actual = Array.from(new Uint8Array(buf)); 1118 | 1119 | t.eq(actual, expected); 1120 | }); 1121 | 1122 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1123 | t.skip("arrayBuffer handles utf-8 data", async (t) => { 1124 | const url = new URL("/hello/utf8", BASE_URL); 1125 | const res = await fetch(url); 1126 | const buf = await res.arrayBuffer(); 1127 | 1128 | t.ok(buf instanceof ArrayBuffer, "buf is an ArrayBuffer instance"); 1129 | t.eq(buf.byteLength, 5, "buf.byteLength is correct"); 1130 | 1131 | const array = Array.from(new Uint8Array(buf)); 1132 | t.eq(array, [104, 101, 108, 108, 111]); 1133 | }); 1134 | 1135 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1136 | t.skip("arrayBuffer handles utf-16le data", async (t) => { 1137 | const url = new URL("/hello/utf16le", BASE_URL); 1138 | const res = await fetch(url); 1139 | const buf = await res.arrayBuffer(); 1140 | 1141 | t.ok(buf instanceof ArrayBuffer, "buf is an ArrayBuffer instance"); 1142 | t.eq(buf.byteLength, 10, "buf.byteLength is correct"); 1143 | 1144 | const array = Array.from(new Uint8Array(buf)); 1145 | t.eq(array, [104, 0, 101, 0, 108, 0, 108, 0, 111, 0]); 1146 | }); 1147 | 1148 | t.test("native base64", (t) => { 1149 | t.test("arrayBuffer handles binary data", async (t) => { 1150 | const url = new URL("/binary", BASE_URL); 1151 | const res = await fetch(url, { 1152 | reactNative: { 1153 | __nativeResponseType: "base64", 1154 | }, 1155 | }); 1156 | const buf = await res.arrayBuffer(); 1157 | 1158 | t.ok( 1159 | buf instanceof ArrayBuffer, 1160 | "buf is an ArrayBuffer instance" 1161 | ); 1162 | t.eq(buf.byteLength, 256, "buf.byteLength is correct"); 1163 | 1164 | const expected = Array.from({ length: 256 }, (_, i) => i); 1165 | const actual = Array.from(new Uint8Array(buf)); 1166 | 1167 | t.eq(actual, expected); 1168 | }); 1169 | 1170 | t.test( 1171 | "arrayBuffer handles binary data (native base64)", 1172 | async (t) => { 1173 | const url = new URL("/binary", BASE_URL); 1174 | const res = await fetch(url, { 1175 | reactNative: { 1176 | __nativeResponseType: "base64", 1177 | }, 1178 | }); 1179 | const buf = await res.arrayBuffer(); 1180 | 1181 | t.ok( 1182 | buf instanceof ArrayBuffer, 1183 | "buf is an ArrayBuffer instance" 1184 | ); 1185 | t.eq(buf.byteLength, 256, "buf.byteLength is correct"); 1186 | 1187 | const expected = Array.from({ length: 256 }, (_, i) => i); 1188 | const actual = Array.from(new Uint8Array(buf)); 1189 | 1190 | t.eq(actual, expected); 1191 | } 1192 | ); 1193 | 1194 | t.test("arrayBuffer handles utf-8 data", async (t) => { 1195 | const url = new URL("/hello/utf8", BASE_URL); 1196 | const res = await fetch(url, { 1197 | reactNative: { 1198 | __nativeResponseType: "base64", 1199 | }, 1200 | }); 1201 | const buf = await res.arrayBuffer(); 1202 | 1203 | t.ok( 1204 | buf instanceof ArrayBuffer, 1205 | "buf is an ArrayBuffer instance" 1206 | ); 1207 | t.eq(buf.byteLength, 5, "buf.byteLength is correct"); 1208 | 1209 | const array = Array.from(new Uint8Array(buf)); 1210 | t.eq(array, [104, 101, 108, 108, 111]); 1211 | }); 1212 | 1213 | t.test("arrayBuffer handles utf-16le data", async (t) => { 1214 | const url = new URL("/hello/utf16le", BASE_URL); 1215 | const res = await fetch(url, { 1216 | reactNative: { 1217 | __nativeResponseType: "base64", 1218 | }, 1219 | }); 1220 | const buf = await res.arrayBuffer(); 1221 | 1222 | t.ok( 1223 | buf instanceof ArrayBuffer, 1224 | "buf is an ArrayBuffer instance" 1225 | ); 1226 | t.eq(buf.byteLength, 10, "buf.byteLength is correct"); 1227 | 1228 | const array = Array.from(new Uint8Array(buf)); 1229 | t.eq(array, [104, 0, 101, 0, 108, 0, 108, 0, 111, 0]); 1230 | }); 1231 | }); 1232 | 1233 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1234 | t.skip( 1235 | "rejects arrayBuffer promise after body is consumed", 1236 | async (t) => { 1237 | const url = new URL("/hello", BASE_URL); 1238 | const res = await fetch(url); 1239 | 1240 | t.eq(res.bodyUsed, false); 1241 | await res.blob(); 1242 | t.eq(res.bodyUsed, true); 1243 | t.throws( 1244 | () => res.arrayBuffer(), 1245 | TypeError, 1246 | "Promise rejected after body consumed" 1247 | ); 1248 | } 1249 | ); 1250 | }); 1251 | 1252 | t.test("blob", (t) => { 1253 | t.test("resolves blob promise", async (t) => { 1254 | const url = new URL("/hello", BASE_URL); 1255 | const res = await fetch(url); 1256 | const blob = await res.blob(); 1257 | 1258 | t.ok(blob instanceof Blob, "blob is a Blob instance"); 1259 | t.eq(blob.size, 2); 1260 | }); 1261 | 1262 | t.test("blob handles binary data", async (t) => { 1263 | const url = new URL("/binary", BASE_URL); 1264 | const res = await fetch(url); 1265 | const blob = await res.blob(); 1266 | 1267 | t.ok(blob instanceof Blob, "blob is a Blob instance"); 1268 | t.eq(blob.size, 256, "blob.size is correct"); 1269 | }); 1270 | 1271 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1272 | t.skip("blob handles utf-8 data", async (t) => { 1273 | const url = new URL("/hello/utf8", BASE_URL); 1274 | const res = await fetch(url); 1275 | const blob = await res.blob(); 1276 | const array = Array.from( 1277 | await createBlobReader(blob).readAsArrayBuffer() 1278 | ); 1279 | 1280 | t.eq(array.length, 5, "blob.size is correct"); 1281 | t.eq(array, [104, 101, 108, 108, 111]); 1282 | }); 1283 | 1284 | // TODO: Test fails while React Native does not implement FileReader.readAsArrayBuffer 1285 | t.skip("blob handles utf-16le data", async (t) => { 1286 | const url = new URL("/hello/utf16le", BASE_URL); 1287 | const res = await fetch(url); 1288 | const blob = await res.blob(); 1289 | const array = Array.from( 1290 | await createBlobReader(blob).readAsArrayBuffer() 1291 | ); 1292 | 1293 | t.eq(array.length, 10, "blob.size is correct"); 1294 | t.eq(array, [104, 0, 101, 0, 108, 0, 108, 0, 111, 0]); 1295 | }); 1296 | 1297 | t.test("rejects blob promise after body is consumed", async (t) => { 1298 | const url = new URL("/hello", BASE_URL); 1299 | const res = await fetch(url); 1300 | 1301 | t.ok(res.blob, "Body does not implement blob"); 1302 | t.notOk(res.bodyUsed); 1303 | await res.text(); 1304 | t.ok(res.bodyUsed); 1305 | 1306 | try { 1307 | await res.blob(); 1308 | t.fail("Promise should have been rejected after body consumed"); 1309 | } catch (error) { 1310 | t.ok( 1311 | error instanceof TypeError, 1312 | "Promise rejected after body consumed" 1313 | ); 1314 | } 1315 | }); 1316 | }); 1317 | 1318 | t.test("formData", (t) => { 1319 | t.test("POST sets Content-Type header for FormData", async (t) => { 1320 | const url = new URL("/request", BASE_URL); 1321 | const formData = new FormData(); 1322 | formData.append("key", "value"); 1323 | const res = await fetch(url, { 1324 | method: "POST", 1325 | body: formData, 1326 | }); 1327 | const json = await res.json(); 1328 | 1329 | t.eq(json.method, "POST"); 1330 | t.ok(/^multipart\/form-data;/.test(json.headers["content-type"])); 1331 | }); 1332 | 1333 | t.test("formData rejects after body was consumed", async (t) => { 1334 | const url = new URL("/json", BASE_URL); 1335 | const res = await fetch(url); 1336 | 1337 | t.ok(res.formData, "Body does not implement formData"); 1338 | t.notOk(res.bodyUsed); 1339 | await res.formData(); 1340 | t.ok(res.bodyUsed); 1341 | 1342 | try { 1343 | await res.formData(); 1344 | t.fail("Promise should have been rejected after body consumed"); 1345 | } catch (error) { 1346 | t.ok( 1347 | error instanceof TypeError, 1348 | "Promise rejected after body consumed" 1349 | ); 1350 | } 1351 | }); 1352 | 1353 | t.test("parses form-encoded response", async (t) => { 1354 | const url = new URL("/form", BASE_URL); 1355 | const res = await fetch(url); 1356 | const formData = await res.formData(); 1357 | 1358 | t.ok(formData instanceof FormData, "Parsed a FormData object"); 1359 | }); 1360 | }); 1361 | 1362 | t.test("json", (t) => { 1363 | t.test("parses json response", async (t) => { 1364 | const url = new URL("/json", BASE_URL); 1365 | const res = await fetch(url); 1366 | const json = await res.json(); 1367 | 1368 | t.eq(json.name, "Hubot"); 1369 | t.eq(json.login, "hubot"); 1370 | }); 1371 | 1372 | t.test("rejects json promise after body is consumed", async (t) => { 1373 | const url = new URL("/json", BASE_URL); 1374 | const res = await fetch(url); 1375 | 1376 | t.ok(res.json, "Body does not implement json"); 1377 | t.eq(res.bodyUsed, false); 1378 | await res.text(); 1379 | t.eq(res.bodyUsed, true); 1380 | 1381 | try { 1382 | await res.json(); 1383 | t.fail("Promise should have been rejected after body consumed"); 1384 | } catch (error) { 1385 | t.ok( 1386 | error instanceof TypeError, 1387 | "Promise rejected after body consumed" 1388 | ); 1389 | } 1390 | }); 1391 | 1392 | t.test("handles json parse error", async (t) => { 1393 | const url = new URL("/json-error", BASE_URL); 1394 | const res = await fetch(url); 1395 | 1396 | try { 1397 | await res.json(); 1398 | t.fail("Promise should have been rejected with invalid JSON"); 1399 | } catch (error) { 1400 | t.ok(error instanceof SyntaxError); 1401 | } 1402 | }); 1403 | }); 1404 | 1405 | t.test("text", () => { 1406 | t.test("handles 204 No Content response", async (t) => { 1407 | const url = new URL("/empty", BASE_URL); 1408 | const res = await fetch(url); 1409 | const text = await res.text(); 1410 | 1411 | t.eq(res.status, 204); 1412 | t.eq(text, ""); 1413 | }); 1414 | 1415 | t.test("resolves text promise", async (t) => { 1416 | const url = new URL("/hello", BASE_URL); 1417 | const res = await fetch(url); 1418 | const text = await res.text(); 1419 | 1420 | t.eq(text, "hi"); 1421 | }); 1422 | 1423 | t.test("rejects text promise after body is consumed", async (t) => { 1424 | const url = new URL("/hello", BASE_URL); 1425 | const res = await fetch(url); 1426 | 1427 | t.ok(res.text, "Body does not implement text"); 1428 | t.eq(res.bodyUsed, false); 1429 | await res.text(); 1430 | t.eq(res.bodyUsed, true); 1431 | 1432 | try { 1433 | await res.text(); 1434 | t.fail("Promise should have been rejected after body consumed"); 1435 | } catch (error) { 1436 | t.ok( 1437 | error instanceof TypeError, 1438 | "Promise rejected after body consumed" 1439 | ); 1440 | } 1441 | }); 1442 | }); 1443 | }); 1444 | 1445 | test("fetch method", (t) => { 1446 | t.test("resolves promise on 500 error", async (t) => { 1447 | const url = new URL("/boom", BASE_URL); 1448 | const res = await fetch(url); 1449 | const text = await res.text(); 1450 | 1451 | t.eq(res.status, 500); 1452 | t.notOk(res.ok); 1453 | t.eq(text, "boom"); 1454 | }); 1455 | 1456 | t.test("rejects promise for network error", async (t) => { 1457 | const url = new URL("/error", BASE_URL); 1458 | 1459 | try { 1460 | const res = await fetch(url); 1461 | t.fail(`HTTP status ${res.status} was treated as success`); 1462 | } catch (error) { 1463 | t.ok(error instanceof TypeError, "Rejected with TypeError"); 1464 | } 1465 | }); 1466 | 1467 | t.test("rejects when Request constructor throws", async (t) => { 1468 | const url = new URL("/request", BASE_URL); 1469 | 1470 | try { 1471 | await fetch(url, { method: "GET", body: "invalid" }); 1472 | t.fail("GET request accepted a body"); 1473 | } catch (error) { 1474 | t.ok(error instanceof TypeError, "Rejected with TypeError"); 1475 | } 1476 | }); 1477 | 1478 | t.test("sends headers", async (t) => { 1479 | const url = new URL("/request", BASE_URL); 1480 | const res = await fetch(url, { 1481 | headers: { 1482 | Accept: "application/json", 1483 | "X-Test": "42", 1484 | }, 1485 | }); 1486 | const json = await res.json(); 1487 | 1488 | t.eq(json.headers.accept, "application/json"); 1489 | t.eq(json.headers["x-test"], "42"); 1490 | }); 1491 | 1492 | t.test("with Request as argument", async (t) => { 1493 | const url = new URL("/request", BASE_URL); 1494 | const req = new Request(url, { 1495 | headers: { 1496 | Accept: "application/json", 1497 | "X-Test": "42", 1498 | }, 1499 | }); 1500 | const res = await fetch(req); 1501 | const json = await res.json(); 1502 | 1503 | t.eq(json.headers.accept, "application/json"); 1504 | t.eq(json.headers["x-test"], "42"); 1505 | }); 1506 | 1507 | t.test("reusing same Request multiple times", async (t) => { 1508 | const url = new URL("/request", BASE_URL); 1509 | const request = new Request(url, { 1510 | headers: { 1511 | Accept: "application/json", 1512 | "X-Test": "42", 1513 | }, 1514 | }); 1515 | const responses = await Promise.all([ 1516 | fetch(request), 1517 | fetch(request), 1518 | fetch(request), 1519 | ]); 1520 | const jsons = await Promise.all(responses.map((res) => res.json())); 1521 | 1522 | jsons.forEach((json) => { 1523 | t.eq(json.headers.accept, "application/json"); 1524 | t.eq(json.headers["x-test"], "42"); 1525 | }); 1526 | }); 1527 | 1528 | t.test("send ArrayBufferView body", async (t) => { 1529 | const url = new URL("/request", BASE_URL); 1530 | const res = await fetch(url, { 1531 | method: "POST", 1532 | body: createTypedArrayFromText("name=Hubot"), 1533 | }); 1534 | const json = await res.json(); 1535 | 1536 | t.eq(json.method, "POST"); 1537 | t.eq(json.data, "name=Hubot"); 1538 | }); 1539 | 1540 | t.test("send ArrayBuffer body", async (t) => { 1541 | const url = new URL("/request", BASE_URL); 1542 | const res = await fetch(url, { 1543 | method: "POST", 1544 | body: createTypedArrayFromText("name=Hubot").buffer, 1545 | }); 1546 | const json = await res.json(); 1547 | 1548 | t.eq(json.method, "POST"); 1549 | t.eq(json.data, "name=Hubot"); 1550 | }); 1551 | 1552 | t.test("send DataView body", async (t) => { 1553 | const url = new URL("/request", BASE_URL); 1554 | const res = await fetch(url, { 1555 | method: "POST", 1556 | body: new DataView(createTypedArrayFromText("name=Hubot").buffer), 1557 | }); 1558 | const json = await res.json(); 1559 | 1560 | t.eq(json.method, "POST"); 1561 | t.eq(json.data, "name=Hubot"); 1562 | }); 1563 | 1564 | t.test("send URLSearchParams body", async (t) => { 1565 | const url = new URL("/request", BASE_URL); 1566 | const search = new URLSearchParams({ 1567 | c: "3", 1568 | }); 1569 | search.append("a", "1"); 1570 | search.append("b", "2"); 1571 | const res = await fetch(url, { 1572 | method: "POST", 1573 | body: search, 1574 | }); 1575 | const json = await res.json(); 1576 | 1577 | t.eq(json.method, "POST"); 1578 | t.eq(json.data, "c=3&a=1&b=2"); 1579 | }); 1580 | 1581 | t.test("populates response body", async (t) => { 1582 | const url = new URL("/hello", BASE_URL); 1583 | const res = await fetch(url); 1584 | const text = await res.text(); 1585 | 1586 | t.eq(res.status, 200); 1587 | t.ok(res.ok); 1588 | t.eq(text, "hi"); 1589 | }); 1590 | 1591 | t.test("parses response headers", async (t) => { 1592 | const url = new URL("/headers", BASE_URL); 1593 | const res = await fetch(url); 1594 | 1595 | t.eq(res.headers.get("Date"), "Mon, 13 Oct 2014 21:02:27 GMT"); 1596 | t.eq(res.headers.get("Content-Type"), "text/html; charset=utf-8"); 1597 | }); 1598 | 1599 | t.test("text streaming", async (t) => { 1600 | const url = new URL("/stream", BASE_URL); 1601 | const res = await fetch(url, { reactNative: { textStreaming: true } }); 1602 | const stream = await res.body; 1603 | const text = new TextDecoder().decode(await drainStream(stream)); 1604 | 1605 | t.ok( 1606 | stream instanceof ReadableStream, 1607 | "Response implements streaming body" 1608 | ); 1609 | t.eq(text, "Hello world!"); 1610 | }); 1611 | 1612 | t.test("aborting", (t) => { 1613 | t.test("initially aborted signal", async (t) => { 1614 | const controller = new AbortController(); 1615 | controller.abort(); 1616 | 1617 | const url = new URL("/request", BASE_URL); 1618 | try { 1619 | await fetch(url, { signal: controller.signal }); 1620 | t.fail("Fetch did not throw when signal is aborted"); 1621 | } catch (error) { 1622 | t.eq(error.name, "AbortError"); 1623 | } 1624 | }); 1625 | 1626 | t.test("initially aborted signal within Request", async (t) => { 1627 | const controller = new AbortController(); 1628 | controller.abort(); 1629 | 1630 | const url = new URL("/request", BASE_URL); 1631 | const request = new Request(url, { signal: controller.signal }); 1632 | 1633 | try { 1634 | await fetch(request); 1635 | t.fail("Fetch did not throw when signal is aborted"); 1636 | } catch (error) { 1637 | t.eq(error.name, "AbortError"); 1638 | } 1639 | }); 1640 | 1641 | t.test("abort signal mid-request", async (t) => { 1642 | const controller = new AbortController(); 1643 | const url = new URL(`/slow?_=${new Date().getTime()}`, BASE_URL); 1644 | const result = fetch(url, { signal: controller.signal }); 1645 | controller.abort(); 1646 | 1647 | try { 1648 | await result; 1649 | t.fail("Fetch did not throw when signal is aborted"); 1650 | } catch (error) { 1651 | t.eq(error.name, "AbortError"); 1652 | } 1653 | }); 1654 | 1655 | t.test("abort signal mid-request within Request", async (t) => { 1656 | const controller = new AbortController(); 1657 | const url = new URL("/slow", BASE_URL); 1658 | const request = new Request(url, { signal: controller.signal }); 1659 | const result = fetch(request); 1660 | controller.abort(); 1661 | 1662 | try { 1663 | await result; 1664 | t.fail("Fetch did not throw when signal is aborted"); 1665 | } catch (error) { 1666 | t.eq(error.name, "AbortError"); 1667 | } 1668 | }); 1669 | 1670 | t.test("abort multiple requests with same signal", async (t) => { 1671 | const controller = new AbortController(); 1672 | const url = new URL("/slow", BASE_URL); 1673 | const settled = allSettled([ 1674 | fetch(url, { signal: controller.signal }), 1675 | fetch(url, { signal: controller.signal }), 1676 | fetch(url, { signal: controller.signal }), 1677 | ]); 1678 | controller.abort(); 1679 | 1680 | const results = await settled; 1681 | 1682 | results.forEach(({ status, error }) => { 1683 | t.eq(status, "rejected", "Fetch threw when signal was aborted"); 1684 | t.eq(error.name, "AbortError"); 1685 | }); 1686 | }); 1687 | }); 1688 | 1689 | t.test("HTTP methods", (t) => { 1690 | t.test("supports HTTP GET", async (t) => { 1691 | const url = new URL("/request", BASE_URL); 1692 | const res = await fetch(url, { method: "GET" }); 1693 | const json = await res.json(); 1694 | 1695 | t.eq(json.method, "GET"); 1696 | t.eq(json.data, ""); 1697 | }); 1698 | 1699 | t.test("supports HTTP POST", async (t) => { 1700 | const url = new URL("/request", BASE_URL); 1701 | const res = await fetch(url, { 1702 | method: "POST", 1703 | body: "name=Hubot", 1704 | }); 1705 | const json = await res.json(); 1706 | 1707 | t.eq(json.method, "POST"); 1708 | t.eq(json.data, "name=Hubot"); 1709 | }); 1710 | 1711 | t.test("supports HTTP PUT", async (t) => { 1712 | const url = new URL("/request", BASE_URL); 1713 | const res = await fetch(url, { method: "PUT", body: "name=Hubot" }); 1714 | const json = await res.json(); 1715 | 1716 | t.eq(json.method, "PUT"); 1717 | t.eq(json.data, "name=Hubot"); 1718 | }); 1719 | 1720 | t.test("supports HTTP PATCH", async (t) => { 1721 | const url = new URL("/request", BASE_URL); 1722 | const res = await fetch(url, { 1723 | method: "PATCH", 1724 | body: "name=Hubot", 1725 | }); 1726 | const json = await res.json(); 1727 | 1728 | t.eq(json.method, "PATCH"); 1729 | t.eq(json.data, "name=Hubot"); 1730 | }); 1731 | 1732 | t.test("supports HTTP DELETE", async (t) => { 1733 | const url = new URL("/request", BASE_URL); 1734 | const res = await fetch(url, { method: "DELETE" }); 1735 | const json = await res.json(); 1736 | 1737 | t.eq(json.method, "DELETE"); 1738 | t.eq(json.data, ""); 1739 | }); 1740 | }); 1741 | 1742 | t.test("Atomic HTTP redirect handling", (t) => { 1743 | t.test("handles 301 redirect response", async (t) => { 1744 | const url = new URL("/redirect/301", BASE_URL); 1745 | const res = await fetch(url); 1746 | const text = await res.text(); 1747 | 1748 | t.eq(res.status, 200); 1749 | t.ok(res.ok); 1750 | t.ok(/\/hello/.test(res.url)); 1751 | t.ok(text, "hi"); 1752 | }); 1753 | 1754 | t.test("handles 302 redirect response", async (t) => { 1755 | const url = new URL("/redirect/302", BASE_URL); 1756 | const res = await fetch(url); 1757 | const text = await res.text(); 1758 | 1759 | t.eq(res.status, 200); 1760 | t.ok(res.ok); 1761 | t.ok(/\/hello/.test(res.url)); 1762 | t.ok(text, "hi"); 1763 | }); 1764 | 1765 | t.test("handles 303 redirect response", async (t) => { 1766 | const url = new URL("/redirect/303", BASE_URL); 1767 | const res = await fetch(url); 1768 | const text = await res.text(); 1769 | 1770 | t.eq(res.status, 200); 1771 | t.ok(res.ok); 1772 | t.ok(/\/hello/.test(res.url)); 1773 | t.ok(text, "hi"); 1774 | }); 1775 | 1776 | t.test("handles 307 redirect response", async (t) => { 1777 | const url = new URL("/redirect/307", BASE_URL); 1778 | const res = await fetch(url); 1779 | const text = await res.text(); 1780 | 1781 | t.eq(res.status, 200); 1782 | t.ok(res.ok); 1783 | t.ok(/\/hello/.test(res.url)); 1784 | t.ok(text, "hi"); 1785 | }); 1786 | 1787 | t.test("handles 308 redirect response", async (t) => { 1788 | const url = new URL("/redirect/308", BASE_URL); 1789 | const res = await fetch(url); 1790 | const text = await res.text(); 1791 | 1792 | t.eq(res.status, 200); 1793 | t.ok(res.ok); 1794 | t.ok(/\/hello/.test(res.url)); 1795 | t.ok(text, "hi"); 1796 | }); 1797 | }); 1798 | 1799 | t.test("credentials mode", (t) => { 1800 | t.test("does not accept cookies with omit credentials", async (t) => { 1801 | const url1 = new URL("/cookie?name=foo1&value=bar1", BASE_URL); 1802 | const url2 = new URL("/cookie?name=foo1", BASE_URL); 1803 | 1804 | // Respond with Set-Cookie header: foo1=bar1 1805 | const res1 = await fetch(url1, { credentials: "same-origin" }); 1806 | // Cookie IS NOT sent to the server 1807 | const res2 = await fetch(url2, { credentials: "omit" }); 1808 | // Cookie IS sent to the server and its value returned in body 1809 | const res3 = await fetch(url2, { credentials: "same-origin" }); 1810 | 1811 | t.eq(await res1.text(), ""); 1812 | t.eq(await res2.text(), ""); 1813 | t.eq(await res3.text(), "bar1"); 1814 | }); 1815 | 1816 | t.test("does not send cookies with omit credentials", async (t) => { 1817 | const url1 = new URL("/cookie?name=foo2&value=bar2", BASE_URL); 1818 | const url2 = new URL("/cookie?name=foo2", BASE_URL); 1819 | 1820 | // Respond with Set-Cookie header: foo2=bar2 1821 | const res1 = await fetch(url1, { credentials: "same-origin" }); 1822 | // Cookie IS NOT sent to the server 1823 | const res2 = await fetch(url2, { credentials: "omit" }); 1824 | 1825 | t.eq(await res1.text(), ""); 1826 | t.eq(await res2.text(), ""); 1827 | }); 1828 | 1829 | t.test("send cookies with same-origin credentials", async (t) => { 1830 | const url1 = new URL("/cookie?name=foo3&value=bar3", BASE_URL); 1831 | const url2 = new URL("/cookie?name=foo3", BASE_URL); 1832 | 1833 | // Respond with Set-Cookie header: foo3=bar3 1834 | const res1 = await fetch(url1, { credentials: "same-origin" }); 1835 | // Cookie IS sent to the server and its value returned in body 1836 | const res2 = await fetch(url2, { credentials: "same-origin" }); 1837 | const res3 = await fetch(url2, { credentials: "same-origin" }); 1838 | 1839 | t.eq(await res1.text(), ""); 1840 | t.eq(await res2.text(), "bar3"); 1841 | t.eq(await res3.text(), "bar3"); 1842 | }); 1843 | 1844 | t.test("send cookies with include credentials", async (t) => { 1845 | const url1 = new URL("/cookie?name=foo4&value=bar4", BASE_URL); 1846 | const url2 = new URL("/cookie?name=foo4", BASE_URL); 1847 | 1848 | // Respond with Set-Cookie header: foo4=bar4 1849 | const res1 = await fetch(url1, { credentials: "include" }); 1850 | // Cookie IS sent to the server and its value returned in body 1851 | const res2 = await fetch(url2, { credentials: "include" }); 1852 | const res3 = await fetch(url2, { credentials: "include" }); 1853 | 1854 | t.eq(await res1.text(), ""); 1855 | t.eq(await res2.text(), "bar4"); 1856 | t.eq(await res3.text(), "bar4"); 1857 | }); 1858 | }); 1859 | 1860 | t.test("cloning", (t) => { 1861 | t.test("cloning response from text stream", async (t) => { 1862 | const url = new URL("/stream", BASE_URL); 1863 | const res = await fetch(url, { 1864 | reactNative: { textStreaming: true }, 1865 | }); 1866 | const clone = res.clone(); 1867 | const stream = await clone.body; 1868 | const text = new TextDecoder().decode(await drainStream(stream)); 1869 | 1870 | t.ok( 1871 | stream instanceof ReadableStream, 1872 | "Response implements streaming body" 1873 | ); 1874 | t.eq(text, "Hello world!"); 1875 | }); 1876 | 1877 | t.test("cloning blob response", async (t) => { 1878 | const url = new URL("/request", BASE_URL); 1879 | const res = await fetch(url, { 1880 | headers: { 1881 | Accept: "application/json", 1882 | }, 1883 | }); 1884 | const clone = res.clone(); 1885 | const json = await clone.json(); 1886 | t.eq(json.headers.accept, "application/json"); 1887 | }); 1888 | 1889 | t.test("cloning array buffer response", async (t) => { 1890 | const url = new URL("/binary", BASE_URL); 1891 | const res = await fetch(url, { 1892 | reactNative: { 1893 | __nativeResponseType: "base64", 1894 | }, 1895 | }); 1896 | const clone = res.clone(); 1897 | const buf = await clone.arrayBuffer(); 1898 | 1899 | t.ok(buf instanceof ArrayBuffer, "buf is an ArrayBuffer instance"); 1900 | t.eq(buf.byteLength, 256, "buf.byteLength is correct"); 1901 | 1902 | const expected = Array.from({ length: 256 }, (_, i) => i); 1903 | const actual = Array.from(new Uint8Array(buf)); 1904 | 1905 | t.eq(actual, expected); 1906 | }); 1907 | }); 1908 | }); 1909 | --------------------------------------------------------------------------------