├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .nvmrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── karma.conf.js ├── package-lock.json ├── package.json ├── post-build.sh ├── release.sh ├── run-karma.sh ├── src ├── .eslintrc.json ├── defaultTransportFactory.js ├── fetch.js ├── index.js ├── polyfill │ └── Headers.js └── xhr.js └── test ├── integ ├── .eslintrc.json ├── chunked-request.spec.js └── util.js └── server ├── .eslintrc.json └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["es2015"] } 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | build/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "rules": { 4 | "indent": [2, 2], 5 | "no-var": 2, 6 | "prefer-const": 2 7 | }, 8 | "env": { 9 | "browser": true 10 | }, 11 | "globals": { 12 | "Uint8Array": false, 13 | "TextEncoder": false, 14 | "Promise": false, 15 | "ReadableStream": false, 16 | "Response": false, 17 | "Symbol": false, 18 | "AbortController": false 19 | }, 20 | "parserOptions": { 21 | "ecmaVersion": 6, 22 | "sourceType": "module" 23 | }, 24 | "root": true 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | dist/ 4 | build/ 5 | .idea/ 6 | npm-debug* 7 | sauce_connect.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # If this file is not present the contents of .gitignore is used 2 | build/ 3 | test/ 4 | .* 5 | karma.conf.js 6 | release.sh 7 | run-karma.sh 8 | sauce_connect.log -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.9.4 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - node_modules 5 | before_install: 6 | - true && `base64 --decode <<< ZXhwb3J0IFNBVUNFX1VTRVJOQU1FPWpvbm55cmVldmVz` 7 | - true && `base64 --decode <<< ZXhwb3J0IFNBVUNFX0FDQ0VTU19LRVk9NTgzMzU1NDUtOWYxYS00Y2M3LThmZmItYjFmZjgwMzg5NmVm` 8 | install: npm install 9 | script: 10 | - npm run test -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.2.0] - 08/11/2018 2 | - Support for AbortController [@pimterry](https://github.com/pimterry) in [#7](https://github.com/jonnyreeves/fetch-readablestream/pull/7) 3 | 4 | ## [0.1.0] - 17/09/2016 5 | - Initial release, lifted from github.com/jonnyreeves/chunked-request 6 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | - @jonnyreeves 2 | - @ariutta 3 | - @MarcusLongmuir 4 | - @Ruben-Hartog 5 | - @jimmywarting -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 John Reeves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fetch-readablestream 2 | Compatibility layer for efficient streaming of binary data using [WHATWG Streams](https://streams.spec.whatwg.org/) 3 | 4 | ## Why 5 | This library provides a consistent, cross browser API for streaming a response from an HTTP server based on the [WHATWG Streams specification](https://streams.spec.whatwg.org/). At the time of writing, Chrome is the only browser to nativley support returning a `ReadableStream` from its `fetch` implementation - all other browsers need to fall back to `XMLHttpRequest`. 6 | 7 | FireFox does provide the ability to efficiently retrieve a byte-stream from a server; however only via it's `XMLHttpRequest` implementation (when using `responsetype=moz-chunked-arraybuffer`). Other browsers do not provide access to the underlying byte-stream and must therefore fall-back to concatenating the response string and then encoding it into it's UTF-8 byte representation using the [`TextEncoder` API](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder). 8 | 9 | *Nb:* If you are happy using a node-style API (using callbacks and events) I would suggest taking a look at [`stream-http`](https://github.com/jhiesey/stream-http). 10 | 11 | ## Installation 12 | This package can be installed with `npm`: 13 | 14 | ``` 15 | $ npm install fetch-readablestream --save 16 | ``` 17 | 18 | Once installed you can import it directly: 19 | 20 | ```js 21 | import fetchStream from 'fetch-readablestream'; 22 | ``` 23 | 24 | Or you can add a script tag pointing to the `dist/fetch-readablestream.js` bundle and use the `fetchStream` global: 25 | 26 | ```html 27 | 28 | 31 | ``` 32 | 33 | ## Usage 34 | The `fetchStream` api provides a subset of the [`fetch` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch); in particular, the ability to get a `ReadableStream` back from the `Response` object which can be used to efficiently stream a chunked-transfer encoded response from the server. 35 | 36 | ```js 37 | function readAllChunks(readableStream) { 38 | const reader = readableStream.getReader(); 39 | const chunks = []; 40 | 41 | function pump() { 42 | return reader.read().then(({ value, done }) => { 43 | if (done) { 44 | return chunks; 45 | } 46 | chunks.push(value); 47 | return pump(); 48 | }); 49 | } 50 | 51 | return pump(); 52 | } 53 | 54 | fetchStream('/endpoint') 55 | .then(response => readAllChunks(response.body)) 56 | .then(chunks => console.dir(chunks)) 57 | ``` 58 | 59 | `AbortController` is supported [in many environments](https://caniuse.com/#feat=abortcontroller), and allows you to abort ongoing requests. This is fully supported in any environment that supports both ReadableStreams & AbortController directly (e.g. Chrome 66+), and has basic support in most other environments, though you may need [a polyfill](https://www.npmjs.com/package/abortcontroller-polyfill) in your own code to use it. To abort a request: 60 | 61 | ```js 62 | const controller = new AbortController(); 63 | 64 | fetchStream('/endpoint', { 65 | signal: controller.signal 66 | }).then(() => { 67 | // ... 68 | }); 69 | 70 | // To abort the ongoing request: 71 | controller.abort(); 72 | ``` 73 | 74 | ## Browser Compatibility 75 | `fetch-readablestream` makes the following assumptions on the environment; legacy browsers will need to provide Polyfills for this functionality: 76 | 77 | | Feature | Browsers | Polyfill | 78 | |--------------------------------|----------------------------------|----------| 79 | | ReadableStream | Firefox, Safari, IE11, PhantomJS | [web-streams-polyfill](https://www.npmjs.com/package/web-streams-polyfill) | 80 | | TextEncoder | Safari, IE11, PhantomJS | [text-encoding](https://www.npmjs.com/package/text-encoding) | 81 | | Promise, Symbol, Object.assign | IE11, PhantomJS | [babel-polyfill](https://www.npmjs.com/package/babel-polyfill) | 82 | 83 | ## Contributing 84 | Use `npm run watch` to fire up karma with live-reloading. Visit http://localhost:9876/ in a bunch of browsers to capture them - the test suite will run automatically and report any failures. 85 | 86 | 87 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Wed Feb 17 2016 15:48:21 GMT+0000 (GMT) 3 | 4 | /*eslint-env node*/ 5 | /*eslint no-var: 0*/ 6 | module.exports = function(config) { 7 | 8 | // Browsers to run on Sauce Labs 9 | // Check out https://saucelabs.com/platforms for all browser/OS combos 10 | var customLaunchers = { 11 | /* Safari appears broken on saucelabs, but works locally. 12 | 'SL_Safari': { 13 | base: 'SauceLabs', 14 | browserName: 'safari', 15 | platform: 'OS X 10.11' 16 | }, 17 | */ 18 | 'SL_Chrome': { 19 | base: 'SauceLabs', 20 | browserName: 'chrome', 21 | platform: 'linux' 22 | }, 23 | 'SL_Firefox': { 24 | base: 'SauceLabs', 25 | browserName: 'firefox', 26 | platform: 'linux' 27 | }, 28 | /* need to figure out what's broken in edge. 29 | 'SL_Edge': { 30 | base: 'SauceLabs', 31 | browserName: 'MicrosoftEdge', 32 | platform: 'Windows 10' 33 | }, 34 | */ 35 | 'SL_IE10': { 36 | base: 'SauceLabs', 37 | browserName: 'internet explorer', 38 | platform: 'Windows 7', 39 | version: '11' 40 | } 41 | }; 42 | 43 | var reporters = ['dots']; 44 | var browsers = []; 45 | var singlerun = false; 46 | 47 | if (process.env.SAUCE_USERNAME) { 48 | reporters.push('saucelabs'); 49 | Array.prototype.push.apply(browsers, Object.keys(customLaunchers)); 50 | singlerun = true; 51 | } 52 | 53 | config.set({ 54 | 55 | // base path that will be used to resolve all patterns (eg. files, exclude) 56 | basePath: '', 57 | 58 | 59 | // frameworks to use 60 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 61 | frameworks: ['jasmine'], 62 | 63 | sauceLabs: { 64 | recordScreenshots: false 65 | }, 66 | 67 | // list of files / patterns to load in the browser 68 | files: [ 69 | './node_modules/babel-polyfill/dist/polyfill.js', 70 | 'node_modules/text-encoding/lib/encoding.js', 71 | 'node_modules/web-streams-polyfill/dist/polyfill.js', 72 | 'build/integration-tests.js' 73 | ], 74 | 75 | 76 | // list of files to exclude 77 | exclude: [ 78 | ], 79 | 80 | proxies: { 81 | '/srv': 'http://localhost:2001/srv', 82 | }, 83 | 84 | 85 | // preprocess matching files before serving them to the browser 86 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 87 | preprocessors: { 88 | }, 89 | 90 | 91 | // test results reporter to use 92 | // possible values: 'dots', 'progress' 93 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 94 | reporters: reporters, 95 | 96 | 97 | // web server port 98 | port: 9876, 99 | 100 | 101 | // enable / disable colors in the output (reporters and logs) 102 | colors: true, 103 | 104 | 105 | // level of logging 106 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 107 | logLevel: config.LOG_INFO, 108 | 109 | 110 | // enable / disable watching file and executing tests whenever any file changes 111 | autoWatch: true, 112 | 113 | 114 | browsers: browsers, 115 | captureTimeout: 120000, 116 | customLaunchers: customLaunchers, 117 | 118 | // Continuous Integration mode 119 | // if true, Karma captures browsers, runs the tests and exits 120 | singleRun: singlerun, 121 | 122 | // Concurrency level 123 | // how many browser should be started simultaneous 124 | concurrency: 1 125 | }) 126 | }; 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-readablestream", 3 | "version": "0.2.0", 4 | "main": "lib/entry.js", 5 | "jsnext:main": "src/index.js", 6 | "repository": "https://github.com/jonnyreeves/fetch-readablestream", 7 | "license": "MIT", 8 | "keywords": [ 9 | "xhr", 10 | "fetch", 11 | "polyfill", 12 | "readablestream" 13 | ], 14 | "scripts": { 15 | "prepare": "npm run clean && npm run build:lib && ./post-build.sh", 16 | "clean": "rm -rf build/* && rm -rf dist/*", 17 | "build:integ": "mkdir -p build && $BROWSERIFY_CMD test/integ/*.spec.js -o build/integration-tests.js --debug -t [ babelify ]", 18 | "build:lib": "mkdir -p lib && babel --out-dir lib src", 19 | "lint": "eslint .", 20 | "test": "npm run lint && npm run test:integ", 21 | "test:integ": "BROWSERIFY_CMD=browserify npm run build:integ && ./run-karma.sh --single-run", 22 | "watch": "(BROWSERIFY_CMD=watchify npm run build:integ &) && ./run-karma.sh --auto-watch", 23 | "release": "./release.sh ${npm_package_version}" 24 | }, 25 | "devDependencies": { 26 | "abortcontroller-polyfill": "^1.1.9", 27 | "babel-cli": "^6.11.4", 28 | "babel-polyfill": "^6.13.0", 29 | "babel-preset-es2015": "^6.13.2", 30 | "babelify": "^7.3.0", 31 | "browserify": "^13.1.0", 32 | "cookie": "^0.3.1", 33 | "eslint": "^3.3.1", 34 | "jasmine": "^2.4.1", 35 | "jasmine-core": "^2.4.1", 36 | "karma": "^1.7.1", 37 | "karma-jasmine": "^1.1.2", 38 | "karma-sauce-launcher": "^1.2.0", 39 | "text-encoding": "^0.6.0", 40 | "url": "^0.11.0", 41 | "watchify": "^3.7.0", 42 | "web-streams-polyfill": "^1.3.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /post-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Patch up ES6 module entry point so it's more pleasant to use. 4 | echo 'module.exports = require("./index").default;' > ./lib/entry.js 5 | 6 | mkdir -p dist 7 | ./node_modules/.bin/browserify -s fetchStream -e ./lib/entry.js -o dist/fetch-readablestream.js -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=${1} 5 | if [ -z ${VERSION} ]; then 6 | echo "VERSION not set" 7 | exit 1 8 | fi 9 | 10 | if [[ `git status --porcelain` ]]; then 11 | echo "There are pending changes, refusing to release." 12 | exit 1 13 | fi 14 | 15 | read -p "Release v${VERSION}? " -n 1 -r 16 | echo 17 | if [[ $REPLY =~ ^[Yy]$ ]] 18 | then 19 | echo "Staring npm publish" 20 | npm publish 21 | 22 | echo "Building standalone artifact" 23 | npm run build:lib 24 | 25 | echo "Creating Github release branch release/v${VERSION}" 26 | git checkout -b release/v${VERSION} 27 | git add . 28 | git commit --allow-empty -m "Release ${VERSION}" 29 | git tag v${VERSION} 30 | git push origin --tags 31 | 32 | echo "All done!" 33 | fi 34 | -------------------------------------------------------------------------------- /run-karma.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | function killChunkedResponseServer { 6 | echo "Killing ChunkedResponse Server..." 7 | kill ${SERVER_PID} &> /dev/null 8 | } 9 | 10 | echo "Starting ChunkedResponse Server..." 11 | node ./test/server/index.js & 12 | SERVER_PID=$! 13 | 14 | # Check the ChunkedResponse server started up ok. 15 | sleep 0.5 16 | ps ${SERVER_PID} &> /dev/null 17 | 18 | # Kill the ChunkedREsponse server when this script exists. 19 | trap killChunkedResponseServer EXIT 20 | 21 | ./node_modules/.bin/karma start $@ 22 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-var": 2 4 | } 5 | } -------------------------------------------------------------------------------- /src/defaultTransportFactory.js: -------------------------------------------------------------------------------- 1 | import fetchRequest from './fetch'; 2 | import { makeXhrTransport } from './xhr'; 3 | 4 | // selected is used to cache the detected transport. 5 | let selected = null; 6 | 7 | // defaultTransportFactory selects the most appropriate transport based on the 8 | // capabilities of the current environment. 9 | export default function defaultTransportFactory() { 10 | if (!selected) { 11 | selected = detectTransport(); 12 | } 13 | return selected; 14 | } 15 | 16 | function detectTransport() { 17 | if (typeof Response !== 'undefined' && Response.prototype.hasOwnProperty("body")) { 18 | // fetch with ReadableStream support. 19 | return fetchRequest; 20 | } 21 | 22 | const mozChunked = 'moz-chunked-arraybuffer'; 23 | if (supportsXhrResponseType(mozChunked)) { 24 | // Firefox, ArrayBuffer support. 25 | return makeXhrTransport({ 26 | responseType: mozChunked, 27 | responseParserFactory: function () { 28 | return response => new Uint8Array(response); 29 | } 30 | }); 31 | } 32 | 33 | // Bog-standard, expensive, text concatenation with byte encoding :( 34 | return makeXhrTransport({ 35 | responseType: 'text', 36 | responseParserFactory: function () { 37 | const encoder = new TextEncoder(); 38 | let offset = 0; 39 | return function (response) { 40 | const chunk = response.substr(offset); 41 | offset = response.length; 42 | return encoder.encode(chunk, { stream: true }); 43 | } 44 | } 45 | }); 46 | } 47 | 48 | function supportsXhrResponseType(type) { 49 | try { 50 | const tmpXhr = new XMLHttpRequest(); 51 | tmpXhr.responseType = type; 52 | return tmpXhr.responseType === type; 53 | } catch (e) { /* IE throws on setting responseType to an unsupported value */ } 54 | return false; 55 | } 56 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | // thin wrapper around `fetch()` to ensure we only expose the properties provided by 2 | // the XHR polyfil; / fetch-readablestream Response API. 3 | export default function fetchRequest(url, options) { 4 | return fetch(url, options) 5 | .then(r => { 6 | return { 7 | body: r.body, 8 | headers: r.headers, 9 | ok: r.ok, 10 | status: r.status, 11 | statusText: r.statusText, 12 | url: r.url 13 | }; 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import defaultTransportFactory from './defaultTransportFactory'; 2 | 3 | export default function fetchStream(url, options = {}) { 4 | let transport = options.transport; 5 | if (!transport) { 6 | transport = fetchStream.transportFactory(); 7 | } 8 | 9 | return transport(url, options); 10 | } 11 | 12 | // override this function to delegate to an alternative transport function selection 13 | // strategy; useful when testing. 14 | fetchStream.transportFactory = defaultTransportFactory; -------------------------------------------------------------------------------- /src/polyfill/Headers.js: -------------------------------------------------------------------------------- 1 | // Headers is a partial polyfill for the HTML5 Headers class. 2 | export class Headers { 3 | constructor(h = {}) { 4 | this.h = {}; 5 | if (h instanceof Headers) { 6 | h.forEach((value, key) => this.append(key, value)); 7 | } 8 | Object.getOwnPropertyNames(h) 9 | .forEach(key => this.append(key, h[key])); 10 | } 11 | append(key, value) { 12 | key = key.toLowerCase(); 13 | if (!Array.isArray(this.h[key])) { 14 | this.h[key] = []; 15 | } 16 | this.h[key].push(value); 17 | } 18 | set(key, value) { 19 | this.h[key.toLowerCase()] = [ value ]; 20 | } 21 | has(key) { 22 | return Array.isArray(this.h[key.toLowerCase()]); 23 | } 24 | get(key) { 25 | key = key.toLowerCase(); 26 | if (Array.isArray(this.h[key])) { 27 | return this.h[key][0]; 28 | } 29 | } 30 | getAll(key) { 31 | return this.h[key.toLowerCase()].concat(); 32 | } 33 | entries() { 34 | const items = []; 35 | this.forEach((value, key) => { items.push([key, value]) }); 36 | return makeIterator(items); 37 | } 38 | 39 | // forEach is not part of the official spec. 40 | forEach(callback, thisArg) { 41 | Object.getOwnPropertyNames(this.h) 42 | .forEach(key => { 43 | this.h[key].forEach(value => callback.call(thisArg, value, key, this)); 44 | }, this); 45 | } 46 | } 47 | 48 | function makeIterator(items) { 49 | return { 50 | next() { 51 | const value = items.shift(); 52 | return { 53 | done: value === undefined, 54 | value: value 55 | } 56 | }, 57 | [Symbol.iterator]() { 58 | return this; 59 | } 60 | }; 61 | } -------------------------------------------------------------------------------- /src/xhr.js: -------------------------------------------------------------------------------- 1 | import { Headers as HeadersPolyfill } from './polyfill/Headers'; 2 | 3 | function createAbortError() { 4 | // From https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortableFetch.js#L56-L64 5 | 6 | try { 7 | return new DOMException('Aborted', 'AbortError'); 8 | } catch (err) { 9 | // IE 11 does not support calling the DOMException constructor, use a 10 | // regular error object on it instead. 11 | const abortError = new Error('Aborted'); 12 | abortError.name = 'AbortError'; 13 | return abortError; 14 | } 15 | } 16 | 17 | export function makeXhrTransport({ responseType, responseParserFactory }) { 18 | return function xhrTransport(url, options) { 19 | const xhr = new XMLHttpRequest(); 20 | const responseParser = responseParserFactory(); 21 | 22 | let responseStreamController; 23 | let cancelled = false; 24 | 25 | const responseStream = new ReadableStream({ 26 | start(c) { 27 | responseStreamController = c; 28 | }, 29 | cancel() { 30 | cancelled = true; 31 | xhr.abort(); 32 | } 33 | }); 34 | 35 | const { method = 'GET', signal } = options; 36 | 37 | xhr.open(method, url); 38 | xhr.responseType = responseType; 39 | xhr.withCredentials = (options.credentials !== 'omit'); 40 | if (options.headers) { 41 | for (const pair of options.headers.entries()) { 42 | xhr.setRequestHeader(pair[0], pair[1]); 43 | } 44 | } 45 | 46 | return new Promise((resolve, reject) => { 47 | if (options.body && (method === 'GET' || method === 'HEAD')) { 48 | reject(new TypeError("Failed to execute 'fetchStream' on 'Window': Request with GET/HEAD method cannot have body")) 49 | } 50 | 51 | if (signal) { 52 | if (signal.aborted) { 53 | // If already aborted, reject immediately & send nothing. 54 | reject(createAbortError()); 55 | return; 56 | } else { 57 | signal.addEventListener('abort', () => { 58 | // If we abort later, kill the XHR & reject the promise if possible. 59 | xhr.abort(); 60 | if (responseStreamController) { 61 | responseStreamController.error(createAbortError()); 62 | } 63 | reject(createAbortError()); 64 | }, { once: true }); 65 | } 66 | } 67 | 68 | xhr.onreadystatechange = function () { 69 | if (xhr.readyState === xhr.HEADERS_RECEIVED) { 70 | return resolve({ 71 | body: responseStream, 72 | headers: parseResposneHeaders(xhr.getAllResponseHeaders()), 73 | ok: xhr.status >= 200 && xhr.status < 300, 74 | status: xhr.status, 75 | statusText: xhr.statusText, 76 | url: makeResponseUrl(xhr.responseURL, url) 77 | }); 78 | } 79 | }; 80 | 81 | xhr.onerror = function () { 82 | return reject(new TypeError('Network request failed')); 83 | }; 84 | 85 | xhr.ontimeout = function() { 86 | reject(new TypeError('Network request failed')) 87 | }; 88 | 89 | xhr.onprogress = function () { 90 | if (!cancelled) { 91 | const bytes = responseParser(xhr.response); 92 | responseStreamController.enqueue(bytes); 93 | } 94 | }; 95 | 96 | xhr.onload = function () { 97 | responseStreamController.close(); 98 | }; 99 | 100 | xhr.send(options.body); 101 | }); 102 | } 103 | } 104 | 105 | function makeHeaders() { 106 | // Prefer the native method if provided by the browser. 107 | if (typeof Headers !== 'undefined') { 108 | return new Headers(); 109 | } 110 | return new HeadersPolyfill(); 111 | } 112 | 113 | function makeResponseUrl(responseUrl, requestUrl) { 114 | if (!responseUrl) { 115 | // best guess; note this will not correctly handle redirects. 116 | if (requestUrl.substring(0, 4) !== "http") { 117 | return location.origin + requestUrl; 118 | } 119 | return requestUrl; 120 | } 121 | return responseUrl; 122 | } 123 | 124 | export function parseResposneHeaders(str) { 125 | const hdrs = makeHeaders(); 126 | if (str) { 127 | const pairs = str.split('\u000d\u000a'); 128 | for (let i = 0; i < pairs.length; i++) { 129 | const p = pairs[i]; 130 | const index = p.indexOf('\u003a\u0020'); 131 | if (index > 0) { 132 | const key = p.substring(0, index); 133 | const value = p.substring(index + 2); 134 | hdrs.append(key, value); 135 | } 136 | } 137 | } 138 | return hdrs; 139 | } -------------------------------------------------------------------------------- /test/integ/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jasmine": true 4 | }, 5 | "rules": { 6 | "no-console": 0 7 | } 8 | } -------------------------------------------------------------------------------- /test/integ/chunked-request.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | AbortController as AbortControllerPonyfill, 3 | abortableFetch as buildAbortableFetch 4 | } from 'abortcontroller-polyfill/dist/cjs-ponyfill'; 5 | 6 | import fetchStream from '../../src/index'; 7 | import { Headers as HeadersPolyfill } from '../../src/polyfill/Headers'; 8 | import { drainResponse, decodeUnaryJSON, wait } from './util'; 9 | 10 | if (!window.Headers) { 11 | window.Headers = HeadersPolyfill; 12 | } 13 | 14 | const supportsAbort = !!window.AbortController; 15 | 16 | if (!supportsAbort) { 17 | if (window.fetch) { 18 | // Make fetch abortable only if present. 19 | // If it's not present, we'll use XHR anyway. 20 | const abortableFetch = buildAbortableFetch(window.fetch); 21 | window.fetch = abortableFetch.fetch; 22 | } 23 | 24 | window.AbortController = AbortControllerPonyfill; 25 | } 26 | 27 | function assertClosedByClient() { 28 | return fetchStream('/srv?method=last-request-closed') 29 | .then(drainResponse) 30 | .then(decodeUnaryJSON) 31 | .then(result => { 32 | expect(result.value).toBe(true, 'response was closed by client'); 33 | }); 34 | } 35 | 36 | // These integration tests run through Karma; check `karma.conf.js` for 37 | // configuration. Note that the dev-server which provides the `/srv` 38 | // endpoint is proxied through karma to work around CORS constraints. 39 | 40 | describe('fetch-readablestream', () => { 41 | 42 | it('returns each chunk sent by the server as a Uint8Array of bytes', (done) => { 43 | return fetchStream('/srv?method=send-chunks', { 44 | method: 'POST', 45 | body: JSON.stringify([ 'chunk1', 'chunk2' ]) 46 | }) 47 | .then(drainResponse) 48 | .then(chunks => { 49 | expect(chunks.length).toBe(2, '2 chunks received'); 50 | expect(chunks.every(bytes => bytes.BYTES_PER_ELEMENT === 1)).toBe(true, 'Uint8Array'); 51 | }) 52 | .then(done, done); 53 | }); 54 | 55 | it('will cancel the response stream and close the connection', (done) => { 56 | return fetchStream('/srv?method=send-chunks', { 57 | method: 'POST', 58 | body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]) 59 | }) 60 | .then(response => { 61 | const reader = response.body.getReader(); 62 | return reader.read() 63 | .then(() => reader.cancel()) 64 | }) 65 | .then(assertClosedByClient) 66 | .then(done, done); 67 | }); 68 | 69 | it('can abort the response before sending, to never send a request', (done) => { 70 | const controller = new AbortController(); 71 | controller.abort(); 72 | 73 | return fetchStream('/srv?method=send-chunks', { 74 | method: 'POST', 75 | body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]), 76 | signal: controller.signal 77 | }) 78 | .then(fail) // should never resolve successfully 79 | .catch((error) => { 80 | expect(error.name).toBe('AbortError'); 81 | }) 82 | .then(assertClosedByClient) 83 | .then(done, done); 84 | }); 85 | 86 | it('can abort the response before reading, to close the connection', (done) => { 87 | const controller = new AbortController(); 88 | return fetchStream('/srv?method=send-chunks', { 89 | method: 'POST', 90 | body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]), 91 | signal: controller.signal 92 | }) 93 | .then(() => { 94 | controller.abort(); 95 | 96 | // Wait briefly to make sure the abort reaches the server 97 | return wait(50); 98 | }) 99 | .then(supportsAbort ? assertClosedByClient : () => true) 100 | .then(done, done); 101 | }); 102 | 103 | it('can abort the response whilst reading, to close the connection', (done) => { 104 | const controller = new AbortController(); 105 | let result; 106 | 107 | return fetchStream('/srv?method=send-chunks', { 108 | method: 'POST', 109 | body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]), 110 | signal: controller.signal 111 | }) 112 | .then(response => { 113 | // Open a reader and start reading 114 | result = drainResponse(response); 115 | controller.abort(); 116 | return result; 117 | }) 118 | .then(supportsAbort ? fail : () => true) // should never resolve, if abort is supported 119 | .catch((error) => { 120 | expect(error.name).toBe('AbortError'); 121 | }) 122 | .then(supportsAbort ? assertClosedByClient : () => true) 123 | .then(done, done); 124 | }); 125 | 126 | it('returns a subset of the fetch API', (done) => { 127 | return fetchStream('/srv?method=echo', { 128 | method: 'POST', 129 | body: "hello world", 130 | }) 131 | .then(result => { 132 | expect(result.ok).toBe(true); 133 | expect(result.headers.get('x-powered-by')).toBe('nodejs'); 134 | expect(result.status).toBe(200); 135 | expect(result.statusText).toBe('OK'); 136 | expect(result.url).toBe('http://localhost:9876/srv?method=echo'); 137 | }) 138 | .then(done, done); 139 | }); 140 | 141 | it('resolves the fetchStream Promise on server error', (done) => { 142 | return fetchStream('/srv?method=500') 143 | .then(result => { 144 | expect(result.status).toBe(500); 145 | expect(result.ok).toBe(false); 146 | }) 147 | .then(done, done); 148 | }); 149 | 150 | it('sends the expected values to the server', (done) => { 151 | return fetchStream('/srv?method=echo', { 152 | method: 'POST', 153 | body: "hello world", 154 | headers: new Headers({ 'x-foo': 'expected' }) 155 | }) 156 | .then(drainResponse) 157 | .then(decodeUnaryJSON) 158 | .then(res => { 159 | expect(res.method).toBe("POST"); 160 | expect(res.body).toBe("hello world"); 161 | expect(res.headers).toEqual(jasmine.objectContaining({ 'x-foo': 'expected' })); 162 | }) 163 | .then(done, done); 164 | }); 165 | 166 | it('includes cookies when credentials=include', (done) => { 167 | document.cookie = 'myCookie=myValue'; 168 | 169 | return fetchStream('/srv?method=echo', { 170 | method: 'POST', 171 | credentials: 'include' 172 | }) 173 | .then(drainResponse) 174 | .then(decodeUnaryJSON) 175 | .then(res => { 176 | expect(res.cookies).toEqual(jasmine.objectContaining({ myCookie: "myValue" })); 177 | }) 178 | .then(done, done); 179 | }); 180 | 181 | /* xhr will always send cookies to the same domain. 182 | it('omits cookies when credentials=omit', (done) => { 183 | document.cookie = 'myCookie=myValue'; 184 | 185 | return fetchStream('/srv?method=echo', { 186 | method: 'POST', 187 | credentials: 'omit' 188 | }) 189 | .then(drainResponse) 190 | .then(decodeUnaryJSON) 191 | .then(res => { 192 | expect(res.cookies).not.toEqual(jasmine.objectContaining({ myCookie: "myValue" })); 193 | }) 194 | .then(done, done); 195 | }); 196 | */ 197 | 198 | }); 199 | -------------------------------------------------------------------------------- /test/integ/util.js: -------------------------------------------------------------------------------- 1 | export function wait(delay) { 2 | return new Promise((resolve) => setTimeout(resolve, delay)) 3 | } 4 | 5 | export function drainResponse(response) { 6 | const chunks = []; 7 | const reader = response.body.getReader(); 8 | 9 | function pump() { 10 | return reader.read() 11 | .then(({ done, value }) => { 12 | if (done) { 13 | return chunks 14 | } 15 | chunks.push(value); 16 | return pump(); 17 | }) 18 | } 19 | return pump(); 20 | } 21 | 22 | export function decodeUnaryJSON(chunks) { 23 | return new Promise((resolve) => resolve(JSON.parse(decodeResponse(chunks)))) 24 | } 25 | 26 | function decodeResponse(chunks) { 27 | const decoder = new TextDecoder(); 28 | return chunks 29 | .map((bytes, idx) => { 30 | const isLastChunk = idx === (chunks.length - 1); 31 | return decoder.decode(bytes, { stream: !isLastChunk }) 32 | }) 33 | .join(""); 34 | } -------------------------------------------------------------------------------- /test/server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "rules": { 6 | "no-console": 0 7 | } 8 | } -------------------------------------------------------------------------------- /test/server/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const url = require('url'); 5 | const cookieParser = require('cookie'); 6 | 7 | // Which port should HTTP traffic be served over? 8 | const httpPort = process.env.HTTP_PORT || 2001; 9 | 10 | // How frequently should chunks be written to the response? Note that we have no 11 | // control over when chunks are actually emitted to the client so it's best to keep 12 | // this value high and pray to the gods of TCP. 13 | // 14 | // Nb: lower values appear to cause problems for internet explorer. 15 | const CHUNK_INTERVAL_MS = process.env.CHUNK_INTERVAL_MS || 1000; 16 | 17 | let _lastRequestClosedByClient = false; 18 | 19 | function readBody(req) { 20 | return new Promise(function (resolve) { 21 | const body = []; 22 | req.on('data', function (chunk) { 23 | body.push(chunk); 24 | }).on('end', function () { 25 | resolve(Buffer.concat(body).toString('utf8')); 26 | }); 27 | }); 28 | } 29 | 30 | function echoHandler(req, res) { 31 | readBody(req) 32 | .then(function (body) { 33 | res.setHeader('x-powered-by', 'nodejs'); 34 | res.end(JSON.stringify({ 35 | headers: req.headers, 36 | method: req.method, 37 | cookies: cookieParser.parse(req.headers.cookie || ''), 38 | body: body 39 | })); 40 | }); 41 | } 42 | 43 | function serveChunksHandler(req, res) { 44 | _lastRequestClosedByClient = false; 45 | req.on('close', function () { 46 | _lastRequestClosedByClient = true; 47 | }); 48 | 49 | readBody(req) 50 | .then(function (body) { 51 | try { 52 | const chunks = JSON.parse(body); 53 | 54 | res.setHeader('Transfer-Encoding', 'chunked'); 55 | res.setHeader('Content-Type', 'text/html; charset=UTF-8'); 56 | 57 | for (let i = 0; i < chunks.length; i++) { 58 | setTimeout(function (idx) { 59 | res.write(chunks[idx] + "\n"); 60 | if (idx === chunks.length - 1) { 61 | res.end(); 62 | } 63 | }, i * CHUNK_INTERVAL_MS, i); 64 | } 65 | } catch (e) { 66 | res.writeHead(400); 67 | res.end("Invalid JSON payload: " + e.message); 68 | } 69 | }); 70 | } 71 | 72 | function lastRequestClosedHandler(req, res) { 73 | res.end(JSON.stringify({ value: _lastRequestClosedByClient })); 74 | } 75 | 76 | function failureHandler(req, res) { 77 | res.writeHead(500); 78 | res.end("Intentional server error"); 79 | } 80 | 81 | function handleSrv(req, res) { 82 | const url = req.parsedUrl; 83 | switch (url.query.method) { 84 | case "echo": 85 | return echoHandler(req, res); 86 | case "500": 87 | return failureHandler(req, res); 88 | case "send-chunks": 89 | return serveChunksHandler(req, res); 90 | case "last-request-closed": 91 | return lastRequestClosedHandler(req, res); 92 | default: 93 | res.writeHead(400); 94 | res.end("Unsupported method: ?method=" + url.query.method); 95 | } 96 | } 97 | 98 | function handler(req, res) { 99 | req.parsedUrl = url.parse(req.url, true); 100 | if (req.parsedUrl.pathname === '/srv') { 101 | return handleSrv(req, res); 102 | } 103 | res.writeHead(404); 104 | res.end("Not found, try /srv"); 105 | } 106 | 107 | console.log("Serving on http://localhost:" + httpPort); 108 | http.createServer(handler).listen(httpPort); --------------------------------------------------------------------------------