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')