A declarative router for service workers
I'm looking for feedback on this API. It isn't yet supported in any standard or browser.
In the very early days of service workers (while they were still named "navigation controllers") we had the idea of a declarative router. This provided a high-level API to define the behaviour of particular routes, so the service worker wouldn't need to be started. We dropped the idea because we didn't want to optimise for things without clear evidence of what needed optimising, and we didn't want to design high-level APIs before common behaviours became clear.
Well, 5 years later, things are somewhat clearer now, and it seems like we'd benefit from something like this. But, I'd like you to be the judge.
The proposal
The idea is you can define routes during the install
event of the service worker:
addEventListener('install', (event) => {
const router = event.router;
router.get(new RouterIfURLStarts('/avatars/'), [
new RouterSourceCache(),
new RouterSourceNetwork(),
]);
router.get(
new RouterIfURLEnds('.mp4', { ignoreSearch: true }),
new RouterSourceNetwork(),
);
router.get(
new RouterIfURL('/', { ignoreSearch: true }),
new RouterSourceCache('/shell.html'),
);
// …
});
The above defines three routes. The first matches GET
requests for URLs that begin /avatars/
, and looks for a match in the cache, and failing that, goes to the network.
The second matches GET
requests for URLs that end .mp4
(ignoring any query string), and goes straight to the network.
The third matches GET
requests for /
(ignoring any query string) and serves a particular item from the cache.
For any fetch that would usually trigger a fetch
event in a particular service worker, the routes would be checked in the order they were declared. If the conditions of a particular route are met, then that route will be used instead of triggering a fetch
event.
These routes are scoped to this service worker. If you update the service worker to one without any routes, all the routes are gone. This is different to things like the cache API and IndexedDB, which live independently of the service worker.
router.add, router.get
router.add(conditions, sources);
router.get(conditions, sources);
conditions
is a single condition, or an array of conditions for this route. All conditions must match for the route to be used.
sources
is a single source, or an array of sources. A source is somewhere the service worker should attempt to get a response from. Each is tried in sequence until a response is found, and that response is used.
router.get
is the same as router.add
, except it includes the condition that the request must use the GET
method.
Conditions
I'm not sure which conditions we'd want to launch with, but here are a few I have in mind:
RouterIfMethod
router.add(
new RouterIfMethod('POST'),
// …
);
A condition that checks the method of the request.
RouterIfURL
router.add(
new RouterIfURL(url, { ignoreSearch }),
// …
);
A condition that checks the URL of the request. The whole URL must match, although the query string is ignored if ignoreSearch
is true. If url
is not absolute, it's resolved against the URL of the service worker.
RouterIfURLStarts
router.add(
new RouterIfURLStarts(url),
// …
);
A condition that checks if the URL of the request starts with url
. If url
is not absolute, it's resolved against the URL of the service worker. This means /articles/
will match a same-origin request to /articles/2019/css/
.
RouterIfURLEnds
router.add(
new RouterIfURLEnds(end, { ignoreSearch }),
// …
);
A condition that checks if the URL of the request ends with end
. This means new RouterIfURLEnds('.jpg', { ignoreSearch: true })
will match requests to any origin where the path ends .jpg
.
RouterIfDate
router.add(
new RouterIfDate({ from, to }),
// …
);
A condition that checks the current UTC date. from
defaults to 0, to
defaults to infinity.
This kinda feels like an edge case to me, but it's something Facebook are very interested in. They want to use it to 'expire' an old service worker. Eg, if the service worker is 5 days old, go straight to the network for everything.
Combining conditions
router.add(
[
new RouterIfURLStarts('/'),
new RouterIfURLEnds('.jpg', { ignoreSearch: true }),
],
// …
);
The above matches same-origin requests with a path that ends .jpg
.
Shortcuts
It might be intuitive to allow strings to be used as conditions:
'/'
meansnew RouterIfURL('/', { ignoreSearch: true })
.'/articles/*'
meansnew RouterIfURLStarts('/articles/')
.'*.jpg'
meansnew RouterIfURLEnds('.jpg', { ignoreSearch: true })
.
This means you could do:
router.get('/', new RouterSourceCache('/shell.html'));
router.get('*.mp4', new RouterSourceNetwork());
Sources
Again, this is something that can be expanded over time. Here are a few:
RouterSourceNetwork
router.add(
conditions,
new RouterSourceNetwork({ request, requireOkStatus }),
);
Try to get a response from the network. The current request will be used, although you can provide your own using the request
option.
A 404 will be treated as a successful response, unless requireOkStatus
is true.
RouterSourceCache
router.add(
conditions,
new RouterSourceCache({
request,
cacheName,
ignoreSearch,
ignoreMethod,
ignoreVary,
}),
);
Try to get a response from the cache API. The current request will be used, although you can provide your own using the request
option. The rest of the options are the same as caches.match
.
RouterSourceFetchEvent
router.add(conditions, new RouterSourceFetchEvent({ id }));
If a router matches, it's used instead of dispatching a fetch
event. RouterSourceFetchEvent
allows you to override this and fire a fetch
event. For example:
addEventListener('install', (event) => {
event.router.get('/articles/*', [
new RouterSourceCache({ ignoreSearch: true }),
new RouterSourceFetchEvent({ id: 'article-not-in-cache' }),
]);
// …
});
addEventListener('fetch', (event) => {
if (event.routerDispatchId === 'article-not-in-cache') {
// Get the article from the network & cache it
}
// …
});
I haven't thought much about the naming of routerDispatchId
, but you get the picture. This allows you to fall back to JS logic in cases the router can't handle.
Goals
Ok, that's the proposal. Here's the requirements that led me to the above design:
Handle fetches without starting the service worker
Right now, each request from a controlled page dispatches a fetch
event in the service worker. This means each request results in executing JS, which may also incur the cost of starting up the service worker.
If you're responding from the cache, you make a net performance gain, but if the service worker requests the response from the network (which is the default action without a service worker), then the service worker start-up and execution time is a waste.
Declarative routes allow you to avoid this cost for requests where the logic is simple and common.
However, this means the route logic needs to work without running JavaScript, that's why the proposal uses things like RouterIfURL
rather than plain if statements. It also means we can't use callbacks.
Things like RegExp are tricky as there's currently no definition for how they could work without JavaScript. This is complicated further when you consider the state held within a RegExp object. Also, writing URL paths in RegExp suuuuuuucks /\/wtf\/is\/this\//
.
Be composable
I've been looking at APIs like Express and Workbox when designing this.
Looking at registerRoute
in Workbox, I wanted to avoid the pattern where the 'handler' is in-between two conditions. Instead, I want to group the conditions together at the start.
I also wanted to avoid separate strategies for "network first", "network only", "cache first" etc etc. Instead, by having a network and a cache source, you can achieve all of these patterns by changing the combination and order.
Be a layer on top of the 'fetch' event
You shouldn't be able to do anything with the router that you can't already do within a fetch
event. If we want to add new behaviours, we should add them at a lower level first.
This means the router can be polyfilled as a fetch
event listener.
Avoid nibbling at the problem
We took a swing at some performance issues with navigation preload. I'm still happy with this API, as it's pretty low-level. However, I'm worried about adding a series of APIs that deal with individual performance issues.
For example:
addEventListener('install', (event) => {
event.router.get(
new RouterIfDate({ to: Date.now() + fiveDays }),
new RouterSourceNetwork(),
);
event.router.get('*.mp4', new RouterSourceNetwork());
});
…could instead be something like:
addEventListener('install', (event) => {
event.expireFetchEvent(Date.now() + fiveDays);
event.addPathExclusion({
method: 'GET',
endsWith: '.mp4',
});
});
…where we battle each use-case with a separate API. However, the composability of the router feels like it would cover more use-cases and involve fewer independent moving parts for developers to learn.
Avoid state across service workers
The router could be stored along with the service worker registration, and be mutated from both pages and service workers (this is now navigation preload works).
However, this would create a footgun where service worker v1 adds some routes, v2 removes some, v3 adds some more… if the user jumps from v1 to v3 they could end up in a broken state that's really hard to debug. You'd probably need an upgrade system similar to IndexedDB to prevent this, and if you're looking to IndexedDB for API guidance… well it's probably best to rethink.
So yeah, I think it's important to tie the state to a single service worker. Routes would need to be defined before the install
event completes, so the routes a service worker activates with are the same throughout its life.
Feedback please!
Is this an API you'd be happy using? Is it something you'd use even if you weren't trying to optimise for a particular performance problem? Is there anything missing?
The goal isn't to replace the fetch
event, we're only trying to avoid it for common cases where the execution of the service worker outweighs its result.
Leave your comments below, or if you want to dive into more of the technical details see the GitHub thread.
Update: I've also explored a similar API that doesn't involve as many constructors.