Re: [whatwg/fetch] Aborting a fetch: The Next Generation (#447)

Ok, here goes. Here's a proposal based on @stuartpb's work and other discussion. It's formed of three parts:

* **A controller** - allows me to say what I want to happen.
* **A signal** - expresses what should happen.
* **An observer** - what's happening & what actually happened.

# Fetch controller

```webidl
[Constructor(), Exposed=(Window,Worker)]
interface FetchController {
  readonly attribute FetchSignal signal;

  void setPriority(octet priority);
  void abort();
  void follow(FetchSignal signal);
  void unfollow(FetchSignal signal);
};

dictionary RequestInit {
  // …
  FetchSignal signal;
}

[Exposed=(Window,Worker)]
interface FetchSignal {
  Promise<octet> getPriority();
  Promise<boolean> shouldAbort();

  attribute EventHandler onaborted;
  attribute EventHandler onprioritychange;
}
```

The fetch controller allows you to signal your intent to fetches.

```js
const controller = new FetchController();
const signal = controller.signal;

fetch(url, {signal}).then(r => r.json()).catch(err => {
  if (err.name == 'AbortError') {
    console.log('The request/response was aborted');
  }
});

// sometime later…
controller.abort();
```

A signal can be assigned to many fetches, meaning you can control many fetches at once.

```js
const controller = new FetchController();
const signal = controller.signal;

abortButton.addEventListener('click', () => {
  controller.abort();
}, {once: true});

startSpinner();

fetch('story.json', {signal}).then(async response => {
  const data = await response.json();
  // Fetch all the chapters
  const texts = data.chapterURLs.map(url =>
    fetch(url, {signal}).then(r => r.text())
  );

  // Add the chapters to the page as they arrive
  for await (const text of texts) {
    addToPage(text);
  }
}).catch(err => {
  if (err.name == 'AbortError') return;
  showErrorMessage();
}).finally(() => {
  stopSpinner();
});
```

Fetch will read state the current state from the signal, meaning this fetch will reject with an `AbortError` without issuing a request.

```js
const controller = new FetchController();
const signal = controller.signal;
controller.abort();

fetch(url, {signal});
```

Having the signal as a seperate object means you can pass the signal around without also giving the ability to control.

# Fetch observer

```webidl
[Exposed=(Window,Worker)]
interface FetchObserver : EventTarget {
  Promise<octet> getPriority();
  Promise<FetchState> getState();
  
  // Events
  attribute EventHandler onstatechange;
  attribute EventHandler onprioritychange;
  attribute EventHandler onrequestprogress;
  attribute EventHandler onresponseprogress;
};

dictionary RequestInit {
  // …
  ObserverCallback observe;
}

callback ObserverCallback void (FetchObserver observer);

enum FetchState {
  // Pending states
  "requesting", "responding",
  // Final states
  "aborted", "errored", "complete"
};

[Constructor(TODO), Exposed=(Window,Worker)]
interface FetchStateChangeEvent : Event {
  readonly attribute FetchState state;
}

[Constructor(TODO), Exposed=(Window,Worker)]
interface FetchPriorityChangeEvent : Event {
  readonly attribute octet priority;
}

[Constructor(TODO), Exposed=(Window,Worker)]
interface FetchProgressEvent : ProgressEvent {
  // Maybe we don't need anything else. Just move ProgressEvent to the fetch spec.
}
```

This lets you observe an ongoing fetch.

```js
fetch(url, {
  observe(observer) {
    observer.addEventListener('responseprogress', event => {
      if (!lengthComputable) return;
      progressEl.max = event.total;
      progressEl.value = event.loaded;
    });
  }
});
```

# Within a service worker

```webidl
partial interface FetchEvent {
  readonly attribute FetchSignal fetchSignal;
};
```

This allows you to react to signals from the client.

```js
addEventListener('fetch', event => {
  event.respondWith(async function() {
    const texts = [url1, url2, url3].map(url => 
      fetch(url, {signal: event.fetchSignal}).then(r => r.text())
    );

    const fullText = (await Promise.all(texts)).join('');

    return Response(fullText);
  }());
});
```

By passing the signal to all fetches, they'll all abort if the page signals to abort.

`fetchEvent.request` will already be associated with `event.fetchSignal`, meaning:

```js
// This will abort if event.fetchSignal signals to abort.
fetch(event.request);

// This does not follow event.fetchSignal.
fetch(event.request, {signal: undefined});
```

# Following signals

`controller.follow` lets you use an existing signal, but you retain the ability to control.

```js
const controller = new FetchController();
const signal = controller.signal;

controller.follow(event.fetchSignal);

fetch(event.request, {signal});
```

Now the fetch will abort if I cann `controller.abort`, or `event.fetchSignal` signals to abort.

# Signals elsewhere

I want to think a bit more about it, but it feels like `caches.match` and `cache.match` should also be able to take a fetch signal. Priority may not have an impact here, but the ability to abort is useful.

-- 
You are receiving this because you are subscribed to this thread.
Reply to this email directly or view it on GitHub:
https://github.com/whatwg/fetch/issues/447#issuecomment-281731850

Received on Wednesday, 22 February 2017 17:01:36 UTC