Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for AbortController #7

Merged
merged 6 commits into from
Nov 2, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"Promise": false,
"ReadableStream": false,
"Response": false,
"Symbol": false
"Symbol": false,
"AbortController": false
},
"parserOptions": {
"ecmaVersion": 6,
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ fetchStream('/endpoint')
.then(chunks => console.dir(chunks))
```

`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:

```js
const controller = new AbortController();

fetchStream('/endpoint', {
signal: controller.signal
}).then(() => {
// ...
});

// To abort the ongoing request:
controller.abort();
```

## Browser Compatibility
`fetch-readablestream` makes the following assumptions on the environment; legacy browsers will need to provide Polyfills for this functionality:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"release": "./release.sh ${npm_package_version}"
},
"devDependencies": {
"abortcontroller-polyfill": "^1.1.9",
"babel-cli": "^6.11.4",
"babel-polyfill": "^6.13.0",
"babel-preset-es2015": "^6.13.2",
Expand Down
37 changes: 34 additions & 3 deletions src/xhr.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { Headers as HeadersPolyfill } from './polyfill/Headers';

function createAbortError() {
// From https://github.com/mo/abortcontroller-polyfill/blob/master/src/abortableFetch.js#L56-L64

try {
return new DOMException('Aborted', 'AbortError');
} catch (err) {
// IE 11 does not support calling the DOMException constructor, use a
// regular error object on it instead.
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
return abortError;
}
}

export function makeXhrTransport({ responseType, responseParserFactory }) {
return function xhrTransport(url, options) {
const xhr = new XMLHttpRequest();
Expand All @@ -10,15 +24,15 @@ export function makeXhrTransport({ responseType, responseParserFactory }) {

const responseStream = new ReadableStream({
start(c) {
responseStreamController = c
responseStreamController = c;
},
cancel() {
cancelled = true;
xhr.abort()
xhr.abort();
}
});

const { method = 'GET' } = options;
const { method = 'GET', signal } = options;

xhr.open(method, url);
xhr.responseType = responseType;
Expand All @@ -34,6 +48,23 @@ export function makeXhrTransport({ responseType, responseParserFactory }) {
reject(new TypeError("Failed to execute 'fetchStream' on 'Window': Request with GET/HEAD method cannot have body"))
}

if (signal) {
if (signal.aborted) {
// If already aborted, reject immediately & send nothing.
reject(createAbortError());
return;
} else {
signal.addEventListener('abort', () => {
// If we abort later, kill the XHR & reject the promise if possible.
xhr.abort();
if (responseStreamController) {
responseStreamController.error(createAbortError());
}
reject(createAbortError());
}, { once: true });
}
}

xhr.onreadystatechange = function () {
if (xhr.readyState === xhr.HEADERS_RECEIVED) {
return resolve({
Expand Down
91 changes: 85 additions & 6 deletions test/integ/chunked-request.spec.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
import {
AbortController as AbortControllerPonyfill,
abortableFetch as buildAbortableFetch
} from 'abortcontroller-polyfill/dist/cjs-ponyfill';

import fetchStream from '../../src/index';
import { Headers as HeadersPolyfill } from '../../src/polyfill/Headers';
import { drainResponse, decodeUnaryJSON } from './util';
import { drainResponse, decodeUnaryJSON, wait } from './util';

if (!window.Headers) {
window.Headers = HeadersPolyfill;
}

const supportsAbort = !!window.AbortController;

if (!supportsAbort) {
if (window.fetch) {
// Make fetch abortable only if present.
// If it's not present, we'll use XHR anyway.
const abortableFetch = buildAbortableFetch(window.fetch);
window.fetch = abortableFetch.fetch;
}

window.AbortController = AbortControllerPonyfill;
}

function assertClosedByClient() {
return fetchStream('/srv?method=last-request-closed')
.then(drainResponse)
.then(decodeUnaryJSON)
.then(result => {
expect(result.value).toBe(true, 'response was closed by client');
});
}

// These integration tests run through Karma; check `karma.conf.js` for
// configuration. Note that the dev-server which provides the `/srv`
// endpoint is proxied through karma to work around CORS constraints.
Expand Down Expand Up @@ -35,12 +62,64 @@ describe('fetch-readablestream', () => {
return reader.read()
.then(() => reader.cancel())
})
.then(() => fetchStream('/srv?method=last-request-closed'))
.then(drainResponse)
.then(decodeUnaryJSON)
.then(result => {
expect(result.value).toBe(true, 'response was closed by client');
.then(assertClosedByClient)
.then(done, done);
});

it('can abort the response before sending, to never send a request', (done) => {
const controller = new AbortController();
controller.abort();

return fetchStream('/srv?method=send-chunks', {
method: 'POST',
body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]),
signal: controller.signal
})
.then(fail) // should never resolve successfully
.catch((error) => {
expect(error.name).toBe('AbortError');
})
.then(assertClosedByClient)
.then(done, done);
});

it('can abort the response before reading, to close the connection', (done) => {
const controller = new AbortController();
return fetchStream('/srv?method=send-chunks', {
method: 'POST',
body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]),
signal: controller.signal
})
.then(() => {
controller.abort();

// Wait briefly to make sure the abort reaches the server
return wait(50);
})
.then(supportsAbort ? assertClosedByClient : () => true)
.then(done, done);
});

it('can abort the response whilst reading, to close the connection', (done) => {
const controller = new AbortController();
let result;

return fetchStream('/srv?method=send-chunks', {
method: 'POST',
body: JSON.stringify([ 'chunk1', 'chunk2', 'chunk3', 'chunk4' ]),
signal: controller.signal
})
.then(response => {
// Open a reader and start reading
result = drainResponse(response);
controller.abort();
return result;
})
.then(supportsAbort ? fail : () => true) // should never resolve, if abort is supported
.catch((error) => {
expect(error.name).toBe('AbortError');
})
.then(supportsAbort ? assertClosedByClient : () => true)
.then(done, done);
});

Expand Down
4 changes: 4 additions & 0 deletions test/integ/util.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export function wait(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}

export function drainResponse(response) {
const chunks = [];
const reader = response.body.getReader();
Expand Down