PWA test project to find out how to get audio caching working with Workbox, including scrub/seek using the range requests plugin.
Workbox caching is great unless you want to cache media (audio/video). Then it gets complex, and things start not working, and you begin to wonder if you will ever get your PWA working with cached media, or if you made some hideous mistake by ever thinking that this was a good idea...
However, I now have a solution to caching media with Workbox so that it can be played offline. This sounds simple but it took quite a bit of figuring out.
The solution you will find here also works at runtime, caching media files on-demand for offline play and to avoid further fetches after the first one.
If you would like to get a flavour of the road I took to get to this solution see the following threads:
- Stackoverflow:
- Workbox project on Github:
I have also published my code for handling service worker updates gracefully.
Background to this part is as follows:
- Inconsistent behaviour with workbox-window.update
- Unexpected behavior with workbox-window when used with registration.update()
There are two versions of this:
- SwClient - uses vanilla js.
- SwClientV2 - uses Workbox Window.
- I want the app to cache all media (audio/video) and for this media to be playable offline, including scrub/seek.
- I want to be able to update cached media easily, including removal of orphan files (stuff that has been removed from the app).
I began by precaching all media. This can be done in one of two ways:
- Using Workbox injectManifest.
- By manually adding audio files to a cache using
cache.add(URL)
(see here)
Precaching media works fine (once you know how to make it work).
- Generate a list of media files to precache using
injectManifest
- Register a route with Workbox that intercepts requests for media and routes them to the precache via a handler that is configured to deal with range requests.
But precaching media it is not very user-friendly for people on slow mobile data connections, or who have limited data. The first time they click a link to your site they will get a lot more data than they may have bargained for, and the app will take ages to load up because your bandwidth will be used up by the service worker as it installs and loads the precache.
You can of course wait for the service worker to finish installing, and have some kind of loading screen until it does:
navigator.serviceWorker.ready.then(() => {
// When a new service worker has finished installing and become active.
// i.e. after precaching etc has completed.
app.closeLoadingScreen();
});
What I really wanted was runtime caching for media. But this is not possible with Workbox 'out of the box' because we are dealing with partial range requests. "Please just give me a bit of this file, not the whole thing...' And we can't cache bits of files. It just won't work.
The solution is to leverage Workbox to intercept requests for media, ignore the range part of the request, cache the media fully, and then serve it from the cache using the CacheOnly stragegy. So the first time you click play it takes a while if you are on a slow internet connection. Subsequently, however, it takes no time at all. And you can run offline.
Once you have managed to cache media the next question is: how do we keep the cached media up to date, or get rid of media files that are no longer part of the app, or are out of scope in some way?
The simplest solution I could think of (the one you will find here) leverages Workbox injetManifest
to generate revision information for media files and inject this into your service worker.
Cached media is annotated with this revision information so that you can tell when a runtime-cached
media file (or any other file cached using this strategy) has been updated in a new build of your PWA. If so then the
cache is updated automatically.
The code is all documented, or self-documenting. I hope that you can make sense of it, and that you find it useful. And if you can think of any improvements please suggest them in the usual manner.
See:
So this project uses a new caching strategy which you could call 'CacheFullyFirst' or 'CachBeforeCacheOnly'. Not sure. What does it do?
- It caches resources at runtime - on-demand.
- It seems to be able to handle pretty much any kind of resource, including media (audio/video).
- It caches any resource in scope (see below) fully the first time it is is requested and serves this resource using a CacheOnly strategy from then on.
- Leverages Workbox injectManifest
to build a runtime manifest of all the files in the app that we want to cache eventually. This enables us:
- To remove orphaned files: At startup, any cached files that do no appear in the runtime manifest are deleted from the cache.
- To detect updates easily: If the revision of a cached file has changed then the cache is updated with the new version.
Note that the media elements are configured as follows:
preload=none
:- Because I want to delay caching until the user actually wants to play something.
- Because this setting is best if you have a page containing multiple media elements. If you use
preload=metadata
with multiple media elements you end up fighting for bandwidth and causing performance issues. - Also it seems that using
preload=metadata
(in Chrome at least) sometime results in the caching being bypassed. Sometimes, at pageload, the metadata request is intercepted by the service worker and the media file is cached. And other times it seems that Chrome caches the media file and no further requests are received for it. So if you want consistent results usepreload=none
.
crossorigin=anonymous
:- Because this is needed to get caching working. Not exactly sure why.
- See this thread with Jeff Posnick for more information.
- Timestamp appended to end of src to facilitate testing. (See note in index.html)
At the moment the build uses a bash script. Apologies for that. I will migrate it to gulp ASAP. (So much has happened since I last put on coding gloves. it was all Apache Ant back then...)
So, for now:
- Edit the files in the
www/
directory. - Open a console in the project root, install the npm packages and run the local dev server:
$ npm install $ npm start
- Run the build (syncs
www/
towww-deploy/
):$ ./build
- Goto http://localhost:8081
You can also take a look at the running app here, on Firebase: