Serviceworker: Allow respondWith() to be called asynchronously or allow some way to cancel the response.

Created on 18 Feb 2016  Â·  14Comments  Â·  Source: w3c/ServiceWorker

Following the app shell architecture, we are providing WordPress Plugins able to cache content and shell separately. For them to collaborate we are providing an infrastructure that combine multiple service workers files which leads to the situation when having several fetch event handlers is possible.

Actually, one the handlers has this form:

    event.respondWith(
      caches.match(onlyPath(request))
        .then(function(response) {
          // Cache hit - return the response from the cached version
          if (response) {
            console.log(
              '[fetch] Returning from ServiceWorker cache: ',
              event.request.url
            );
            return response;
          }
          // Not in cache - return the result from the live server
          // `fetch` is essentially a "fallback"
          return fetch(event.request);
        }
      )
    );

But if I want to give the opportunity to other handlers for acting when there is no hit in the cache, I need some way to unset the _respond-with entered_ flag of the event. So I can do:

    event.respondWith(
      caches.match(onlyPath(request))
        .then(function(response) {
          // Cache hit - return the response from the cached version
          if (response) {
            console.log(
              '[fetch] Returning from ServiceWorker cache: ',
              event.request.url
            );
            return response;
          }
          // Not in cache - allow other handlers to act
          return event.cancelResponse();
        }
      )
    );

The other option is to allow .respondWith() to be called asynchronously:

    event.waitUntil(
      caches.match(onlyPath(request))
        .then(function(response) {
          // Cache hit - return the response from the cached version
          if (response) {
            console.log(
              '[fetch] Returning from ServiceWorker cache: ',
              event.request.url
            );
            event.respondWith(response); // we respond now we know there is a match
          }
          // Not in cache - do nothing and allow other handlers to act
        }
      )
    );

But this is not working. It results in an error saying:

Uncaught InvalidStateError: Failed to execute 'respondWith' on 'FetchEvent': The fetch event has already been responded to.

Most helpful comment

You can do this already by passing a promise to respondWith that you later resolve, no? The reason this works this way is the event model. If you don't reply to the event, we'll go to the network.

All 14 comments

In #771 we agreed that it should be possible to call waitUntil asynchronously, but I think so far the plan was to still require respondWith to be called asynchronous. I personally think that your use case sounds pretty reasonable though. Of course allowing respondWith to be called asynchronously (but still only once, and only while there are still unresolved waitUntil promises) could potentially make things confusing for developers as figuring out where a fetch request is handled becomes a bit more complicated, but that's just what you choose for if you decide to have multiple fetch event handlers.

So I think that especially since we're already going to allow waitUntil to be called asynchronously we might as well also allow respondWith to be called asynchronously. I don't remember if there were any strong arguments against doing so, other than that it didn't seem particularly useful.

You can do this already by passing a promise to respondWith that you later resolve, no? The reason this works this way is the event model. If you don't reply to the event, we'll go to the network.

@annevk not if you have multiple fetch event listeners. I agree that in most cases it should be possible for a fetch event listener to synchronously decide whether to call respondWith (and claim the event) or not call it (and let other event listeners have a go at it), but it might not in all cases.
But thinking about it more, I don't actually think that allowing respondWith to be called asynchronously would actually really solve the use case presented here. Calling respondWith asynchronously would just introduce a race between the possibly multiple fetch event listeners and whichever ends up calling it first will end up handling the fetch (rather than the described behavior where other event listeners should only get a chance when the first listener asynchronously decides not to handle the fetch...).

It seems like a better solution to the OP's use case is for them to provide a middleware framework, so that there's still only one fetch listener, but they have control over exactly how that listener delegates to various middleware in the chain. See e.g. http://expressjs.com/en/guide/using-middleware.html which shows how a popular server-side framework manages to leverage Node.js's single request-processing callback (analogous to the single onfetch event) into a composable system of chained middleware.

@domenic, actually, @arcturus and I at Firefox OS, maintain ServiceWorkerWare which is just that.

But this was more about allowing collaboration as a low-level feature.

@mkruisselbrink you're right. Perhaps the solution of cancelling the respondWith() by using a special response could do the trick if we block threads at respondWith() call:

// handler 1:
// 1. Gets the lock
event.respondWith(new Promise(resolve => {
  // 3. After 1s, cancel the respond and release the lock.
  setTimeout(() => { resolve(event.cancelRespond()); }, 1000); 
}));

// handler 2
// 2. Blocks and enqueue itself.
// 4. After 1s, unblocks and get the lock.
event.respondWith(new Promise(resolve => { 
  // 5. After 2s, it responds with \0/ making other enqueued handlers to throw.
  setTimeout(() => { resolve(new Response("\0/")); }, 2000); 
}));

When respond's promise is resolved with a non cancelRespond() result, all other enqueued respondWith() throw:

Uncaught InvalidStateError: Failed to execute 'respondWith' on 'FetchEvent': The fetch event has already been responded to.

I'm beginning to agree with @domenic that this particular use case really should just be handled by a middleware layer rather than trying to somehow provide APIs that allow this. Blocking respondWith would be really weird (and it couldn't really be blocking anyway, that's just not how javascript works; it could return a promise and reject that promise later of course, but I don't think allowing multiple respondWith calls in such a ways is really something we would want to do.

If you really want to make this work, you can sort of do this today already like this:

function cancelResponse(event) {
  var newEvent = new FetchEvent("fetch",
      {request: event.request, clientId: event.clientId, isReload: event.isReload});
  newEvent.shouldIgnore = true;
  var resolver
  console.log("Cancelling response");
  var promise = new Promise(resolve => {
    newEvent.respondWith = function(response) {
      console.log("Called respondWith");
      resolve(response);
    };
  });
  self.dispatchEvent(newEvent);
  return promise;
}

self.addEventListener('fetch', event => {
  if (event.shouldIgnore) return;
  console.log('Inside the base handler');
  event.respondWith(new Promise(resolve => {
      setTimeout(() => resolve(cancelResponse(event)), 1000);
    }));
});

The best I could come up with that approximates the original desired semantics - multiple cooperating handlers asynchronously decide if they are the one that will service the request, and have unfettered access to the fetch event - is something like the following. Like @mkruisselbrink I'm relying on the fact that multiple handlers see the same Event object and can decorate it.

function Helper(event) {
  var promises = [], count = 0, resolve;
  this.response = new Promise(r => resolve = r);
  this.add = function(p) {
    p.then(
      r => resolve(r),
      () => {
        if (++count === promises.length)
          resolve(fetch(event.request));
      });
  };
}

// Multiple of these allowed
self.addEventListener('fetch', event => {
  if (!event.helper) {
    event.helper = new Helper(event);
    event.respondWith(event.helper.response);
  }
  event.helper.add(new Promise((resolve, reject) => {
    self.caches.match(event.request).then(response => {
      if (response)
        resolve(response);
      else
        reject();
    }).catch(reject);
  }));
});

I believe this has a race, though: if the first handler's cache check resolves before the second event handler is run, the Helper will conclude all associated Promises have completed. That could be addressed with a setTimeout(..., 0), or maybe we can't run microtasks during event dispatch and it's moot.

That's an awful lot of boilerplate per handler, though. So I agree that middleware is probably cleanest. If the handlers add Request → Promise<Response> functions to a collection (self.fetchHandlers, say) then the actual fetch handler can "race" them all, ignoring ones that reject. If all reject, it falls back to fetch(event.request)

If we let evt.respondWith() happen async so different handlers could respond over time. How would we handle the case where none of the handlers chose to intercept? At what point would we proceed with normal processing?

If the answer is "the waitUntil() promise", then that seems the same as passing a promise to .respondWith() and using middleware to me. The event handlers have to coordinate to resolve the waitUntil promise together, so middleware is already in use here.

An alternative that might be implementable would be something like:

1) If FetchEvent.respondWith() is not called synchronously, then normal network processing takes place like today.
2) If FetchEvent.respondWith() is called again (sync or async) with a promise, we don't throw like today, but instead override the respondWith() promise with the new promise.
3) If FetchEvent.respondWith() is called again (sync or async) with null or undefined, reset the network request to the default behavior.

So you could immediately provide a respondWith() that is not really resolved. Then let event handlers replace it at a later time. And then finally have the option of letting the request proceed to the network.

The event handlers would still need some coordination to deal with the "no one wants to intercept this so let the network request proceed" case.

@mkruisselbrink yes, JS is not blocking. Is the underlaying respondWith() mechanism which is blocked.

@wanderview, so null or undefined are the "cancel values" for respondWith(). The "no one wants to intercept this request" scenario is not very different from having a respondWith() passed with a promise that never resolves. Actually, it is slightly better as you could answer from the network instead.

Upon further thought, I was wrong about waitUntil() requiring coordination. Since waitUntil() promises basically get passed to Promise.all(), no middleware is necessary to do that.

Agree this should be middleware, since it might be a series or a race depending on the app, and you may not want to run additional handlers until others fail to provide a response.

const handlers = [];

self.addEventListener('fetch', event => {
  event.respondWith(async function() {
    for (const handler of handers) {
      const response = await handler(event.request);
      if (response) return response;
    }
    return fetch(event.request);
  }());
});

handlers.push(request => {
  const url = new URL(request.url);
  if (url.pathname.endsWith('.jpg')) {
    return caches.match(request);
  }
});

handlers.push(request => {
  // …
});

handlers.push(request => {
  // …
});

Sorry to necrobump guys:

I think this issue was sort of sidetracked by middleware as the use case. Once you call event.respondWith afaik there is no way to fallback to the default behavior. Unfortunately for range requests this is important.

Suppose I want to asynchronously get a decision whether to pull from cache, use fetch, or just fallback to the default behavior.

self.addEventListener('fetch', event => {
  return event.respondWith(async function () {
    const action = await getAction();
    if (action === 'cache') {
      return await getFromCache(event.request);
    } else if (action === 'fetch') {
      return await fetch(event.request);
    } else {
      // How to fallback to default behavior as if I didn't call respondWith.
    }
  });
});

My expectation would be that I could either resolve my respondWith to null or wrap with waitUtil and then never call event.respondWith. I don't see those in spec and they don't work.

Has there been an update to allowing async respondWith or are we just waiting for fetch to get in line with the default passthrough behavior with partial content and whatnot.

Once you call event.respondWith afaik there is no way to fallback to the default behavior

Should just be fetch(event.request).

Unfortunately for range requests this is important.

This is a bug https://bugs.chromium.org/p/chromium/issues/detail?id=620386

Was this page helpful?
0 / 5 - 0 ratings

Related issues

inian picture inian  Â·  10Comments

annevk picture annevk  Â·  9Comments

lewispham picture lewispham  Â·  4Comments

vgalisson picture vgalisson  Â·  4Comments

jeffposnick picture jeffposnick  Â·  11Comments