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