diff --git a/README.md b/README.md
index 32cc3c20..ff3e5e7f 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ expected to uphold this code.
* [Sending cookies](#sending-cookies)
* [Receiving cookies](#receiving-cookies)
* [Obtaining the Response URL](#obtaining-the-response-url)
+ * [Aborting requests](#aborting-requests)
* [Browser Support](#browser-support)
## Read this first
@@ -260,6 +261,36 @@ response.headers['X-Request-URL'] = request.url
This server workaround is necessary if you need reliable `response.url` in
Firefox < 32, Chrome < 37, Safari, or IE.
+#### Aborting requests
+
+This polyfill supports
+[the abortable fetch API](https://developers.google.com/web/updates/2017/09/abortable-fetch).
+However, aborting a fetch requires use of two additional DOM APIs:
+[AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) and
+[AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).
+Typically, browsers that do not support fetch will also not support
+AbortController or AbortSignal. Consequently, you will need to include
+[an additional polyfill](https://github.com/mo/abortcontroller-polyfill#readme)
+for these APIs to abort fetches:
+
+```js
+import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
+import {fetch} from 'whatwg-fetch'
+
+const controller = new AbortController()
+
+fetch('/avatars', {
+ signal: controller.signal
+}).catch(function(ex) {
+ if (ex.name === 'AbortError') {
+ console.log('request aborted')
+ }
+})
+
+// some time later...
+controller.abort()
+```
+
## Browser Support
- Chrome
diff --git a/fetch.js b/fetch.js
index 47b419d1..b684ff1b 100644
--- a/fetch.js
+++ b/fetch.js
@@ -321,6 +321,7 @@ export function Request(input, options) {
}
this.method = input.method
this.mode = input.mode
+ this.signal = input.signal
if (!body && input._bodyInit != null) {
body = input._bodyInit
input.bodyUsed = true
@@ -335,6 +336,7 @@ export function Request(input, options) {
}
this.method = normalizeMethod(options.method || this.method || 'GET')
this.mode = options.mode || this.mode || null
+ this.signal = options.signal || this.signal
this.referrer = null
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
@@ -422,11 +424,34 @@ Response.redirect = function(url, status) {
return new Response(null, {status: status, headers: {location: url}})
}
+export var DOMException = self.DOMException
+try {
+ new DOMException()
+} catch (err) {
+ DOMException = function(message, name) {
+ this.message = message
+ this.name = name
+ var error = Error(message)
+ this.stack = error.stack
+ }
+ DOMException.prototype = Object.create(Error.prototype)
+ DOMException.prototype.constructor = DOMException
+}
+
export function fetch(input, init) {
return new Promise(function(resolve, reject) {
var request = new Request(input, init)
+
+ if (request.signal && request.signal.aborted) {
+ return reject(new DOMException('Aborted', 'AbortError'))
+ }
+
var xhr = new XMLHttpRequest()
+ function abortXhr() {
+ xhr.abort()
+ }
+
xhr.onload = function() {
var options = {
status: xhr.status,
@@ -446,6 +471,10 @@ export function fetch(input, init) {
reject(new TypeError('Network request failed'))
}
+ xhr.onabort = function() {
+ reject(new DOMException('Aborted', 'AbortError'))
+ }
+
xhr.open(request.method, request.url, true)
if (request.credentials === 'include') {
@@ -462,6 +491,17 @@ export function fetch(input, init) {
xhr.setRequestHeader(name, value)
})
+ if (request.signal) {
+ request.signal.addEventListener('abort', abortXhr)
+
+ xhr.onreadystatechange = function() {
+ // DONE (success or failure)
+ if (xhr.readyState === 4) {
+ request.signal.removeEventListener('abort', abortXhr)
+ }
+ }
+ }
+
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit)
})
}
diff --git a/package.json b/package.json
index 30ae630d..5153a7f4 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"repository": "github/fetch",
"license": "MIT",
"devDependencies": {
+ "abortcontroller-polyfill": "^1.1.9",
"chai": "1.10.0",
"eslint": "^4.19.1",
"eslint-plugin-github": "^1.0.0",
diff --git a/script/server b/script/server
index 861a16d6..e89b9c91 100755
--- a/script/server
+++ b/script/server
@@ -80,6 +80,12 @@ var routes = {
res.writeHead(204);
res.end();
},
+ '/slow': function(res) {
+ setTimeout(function() {
+ res.writeHead(200);
+ res.end();
+ }, 100);
+ },
'/error': function(res) {
res.destroy();
},
diff --git a/test/test.html b/test/test.html
index 93de175c..eea0df32 100644
--- a/test/test.html
+++ b/test/test.html
@@ -33,6 +33,7 @@
+
diff --git a/test/test.js b/test/test.js
index 3d5f5b37..1308c63c 100644
--- a/test/test.js
+++ b/test/test.js
@@ -1048,6 +1048,142 @@ exercise.forEach(function(exerciseMode) {
})
})
+ suite('aborting', function() {
+ test('initially aborted signal', function() {
+ var controller = new AbortController()
+ controller.abort()
+
+ return fetch('/request', {
+ signal: controller.signal
+ }).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.instanceOf(error, WHATWGFetch.DOMException)
+ assert.equal(error.name, 'AbortError')
+ }
+ )
+ })
+
+ test('initially aborted signal within Request', function() {
+ var controller = new AbortController()
+ controller.abort()
+
+ var request = new Request('/request', {signal: controller.signal})
+
+ return fetch(request).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.equal(error.name, 'AbortError')
+ }
+ )
+ })
+
+ test('mid-request', function() {
+ var controller = new AbortController()
+
+ setTimeout(function() {
+ controller.abort()
+ }, 30)
+
+ return fetch('/slow', {
+ signal: controller.signal
+ }).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.equal(error.name, 'AbortError')
+ }
+ )
+ })
+
+ test('mid-request within Request', function() {
+ var controller = new AbortController()
+ var request = new Request('/slow', {signal: controller.signal})
+
+ setTimeout(function() {
+ controller.abort()
+ }, 30)
+
+ return fetch(request).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.equal(error.name, 'AbortError')
+ }
+ )
+ })
+
+ test('abort multiple with same signal', function() {
+ var controller = new AbortController()
+
+ setTimeout(function() {
+ controller.abort()
+ }, 30)
+
+ return Promise.all([
+ fetch('/slow', {
+ signal: controller.signal
+ }).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.equal(error.name, 'AbortError')
+ }
+ ),
+ fetch('/slow', {
+ signal: controller.signal
+ }).then(
+ function() {
+ assert.ok(false)
+ },
+ function(error) {
+ assert.equal(error.name, 'AbortError')
+ }
+ )
+ ])
+ })
+
+ test('does not leak memory', function() {
+ var controller = new AbortController()
+ var signal = controller.signal
+
+ // success
+ return fetch('/request', {
+ signal: signal
+ })
+ .then(function() {
+ assert.deepEqual(signal.listeners['abort'], [])
+ })
+ .then(function() {
+ // failure
+ return fetch('/boom', {
+ signal: signal
+ }).catch(function() {
+ assert.deepEqual(signal.listeners['abort'], [])
+ })
+ })
+ .then(function() {
+ // aborted
+ setTimeout(function() {
+ signal.dispatchEvent({type: 'abort'})
+ }, 30)
+
+ return fetch('/slow', {
+ signal: signal
+ }).catch(function() {
+ assert.deepEqual(signal.listeners['abort'], [])
+ })
+ })
+ })
+ })
+
suite('response', function() {
test('populates body', function() {
return fetch('/hello')
diff --git a/test/worker.js b/test/worker.js
index 7cfb5dea..5bc87c41 100644
--- a/test/worker.js
+++ b/test/worker.js
@@ -5,6 +5,7 @@ mocha.setup('tdd')
self.assert = chai.assert
importScripts('/node_modules/promise-polyfill/promise.js')
+importScripts('/node_modules/abortcontroller-polyfill/dist/abortcontroller-polyfill-only.js')
importScripts('/dist/fetch.umd.js')
importScripts('/test/test.js')