Service Worker の Background Fetch によるメディアのキャッシュ
Intro
Podcast を PWA 対応するために、待望だった機能の 1 つが Background Fetch だ。
これにより、通常 Range Request で取得するような、大きなファイルを事前にダウンロードしておくことができるようになる。
この API と、Service Worker およびブラウザにおける Range Request/Partial Response の扱いについて記す。
background fetch
Podcast は大きな音声ファイルがメインコンテンツとなる。
PWA のキャッシュ戦略典型例としては
- install 時に全てキャッシュする
- request 発生時にキャッシュする
といった方法がある。
しかし、この方法は一般的な Podcast としては少し使いにくい。
- install 時に全てのファイルをキャッシするのは現実的ではない
- request が Range なのでキャッシュが生成しにくい
前者は分かりやすいだろう。後者は少し補足する。
Range Request と Cache API
音声の再生は、必ずファイルの先頭からリクエストするわけではなく、途中から聞く場合は途中からリクエストを行う。
また、全てのレスポンスが揃ってから開始するわけではなく、届いた順に再生する。
HTTP 上は Range Request と Partial Response が使われ、途中から再生するような場合以下のようになる。
GET /mozaic-ep60.mp3 HTTP/1.1
Host: files.mozaic.fm
Range: bytes=54034432-
HTTP/1.1 206 Partial Content
content-type: audio/mpeg
Content-Range: bytes 54034432-101797887/101797888
Content-Length: 47763456
つまり、1 つの音声ファイルを取得するリクエスト/レスポンスが、1 つとは限らないというわけだ。
Service Worker での Range Request
ところが onfetch で Range になるはずのリクエストを見てみると、レスポンスが必ず 200 で返ってきていることに気付く。
self.addEventListener('fetch', (e) => {
e.respondWith((async () => {
const req = e.request
const res = await fetch(req)
console.log(req, res) // status 200
return res
})())
})
最初は Service Worker が 206 を 200 に変えてしまうバグかと思ったが、Content-Range もついてない。
もしやと思い、サーバ側のログを見ると、そもそも Range ヘッダが送られていない。
そもそも、fetch を通すと Range Request が普通のリクエストとして送られているのだ。
最初に試したのが 4 年ほど前で、Service Worker の初期の頃だったため、単に実装されてないだけろうと思い、実装されるのを待って漬けていた。
ブラウザにおける Range/Partial
以下のブログが出て初めて、実装ではなく仕様の問題だということがわかった。
結論から言うとこうだ。
They're standardised in HTTP, but not by HTML. We know what the headers look like, and when they should appear, but there's nothing to say what a browser should actually do with them.
— https://jakearchibald.com/2018/i-discovered-a-browser-bug/#range-requests-were-never-standardised
- Range/Partial の仕様はあくまで HTTP の仕様
- WHATWG において、それらがブラウザでどう扱われるかは、標準化されてない
<audio>
/<video>
などで Range を使うのはブラウザがそう実装してるだけ- Service Worker/Cache のような API でどうするかは決まってない
つまり、Service Worker はそもそも Range/Partial には対応してない というか標準化されてないと言って良いだろう。
それでも、200 では取得できるため、全体が得られればキャッシュは可能で、Safari などには 206 に置き換えて返すこともできる。
しかし、途中までしかキャッシュできてない状態でオフラインになると、途中で再生が止まるフラストレーションが溜まるだけだ。
なら Podcast アプリで聞いたほうが体験が良いということになる。
PWA で理想的な Podcast アプリを実現するのは難しそうだ。と諦めてから 4 年近く経った。
Background Fetch
Podcast のアプリは基本的に、ネットワークがある間にダウンロードを完了し、地下鉄などを移動する際に聞ける状態になっているだろう。
これと同じことが可能になるのが Background Fetch だ。
Service Worker に fetch を Task として追加し、バックグラウンドで実行させる。これは大きなファイルをブラウザでダウンロードしているときと同じような UX となる。
もちろん、取得が終われば結果を 1 つの大きな Response として取得できるため、それをそのまま Cache API で保存すれば良い。分割されてないため Cache HIT も普通にできる。
これを用いると Podcast アプリと近い UX を実現できるのだ。
API
backgroundFetch registration
取得したい URL とオプションを登録する。
ID はそのタスク自体を識別するために登録し、もし同じ ID のタスクがある状態で再登録しようとすると例外が出る。
また、ダウンロード対象を複数登録して同時にダウンロードさせることができる。
const id = 'ep01'
const mp3 = 'https://files.example.com/ep01.mp3'
const html = 'https://files.example.com/ep01.html'
const option = {
title: 'title of download',
downloadTotal: 65535, // size
icons: [{src: 'logo.png', sizes: '256x256', type: 'image/webp'}]
}
const registration = await navigator.serviceWorker.ready
const task = await registration.backgroundFetch.fetch(id, [html, mp3], option)
foreground event
ダウンロードの進捗は window 側で progress イベントで上がる
task.addEventListener('progress', (e) => console.log(e.downloaded))
abort()
中断は API から可能であり、abort()
を呼べば task が終了する。
task.abort()
ただし、Pause/Resume は API がなく、提案 はされてるが、その UI は OS 側が提供するというスタンスだ。
background event
Service Worker 側では以下のイベントが上がる。
- backgroundfetchsuccess
- backgroundfetchfail
- backgroundfetchabort
- backgroundfetchclick
// ダウンロード完了
self.addEventListener('backgroundfetchsuccess', (e) => {
e.waitUntil(async function() {
try {
// 結果を取り出す
const id = e.registration.id
const record = await e.registration.match(id)
// キャッシュ対象
const request = record.request
const response = await record.responseReady
// キャッシュ追加
const cache = await caches.open(CACHE_NAME)
await cache.put(request.url, response)
// 通知
await e.updateUI({ title: 'download finished' })
} catch (err) {
console.error(err)
e.updateUI({ title: `download failed ${e.registration.id}` })
}
}())
})
// download タスクをクリック
self.addEventListener('backgroundfetchclick', (e) => {
e.waitUntil(async function() {
const id = e.registration.id
// 該当ページを開く
clients.openWindow(`https://files.example.com/${id}.html`)
}())
})
self.addEventListener('backgroundfetchfail', (e) => {
// 特に何もする必要ないっぽい
console.error(e)
})
self.addEventListener('backgroundfetchabort', (e) => {
// 特に何もする必要ないっぽい
console.error(e)
})
fail/abort は、タスクが消えるため特にリソースの開放などは必要なさそうだ。
Cache HIT
キャッシュは Range などを気にする必要もなく普通に返せば良い。
実際に <audio>
タグは途中からでも普通に再生できた。
// ダウンロードしたものを返す
self.addEventListener('fetch', (e) => {
e.respondWith(async function() {
const cachedResponse = await caches.match(e.request.url)
if (cachedResponse) {
// cache hit
return cachedResponse
} else {
// fallback to fetch
return fetch(e.request)
}
}())
})
DEMO
動作するデモを以下に用意した。
本サイトへの適用
本サイトというか、適用先は mozaic.fm になる。
すでにコードはある程度できているが、まだ Storage Quota に達した時の挙動がよくわかってないため、もう少しエッジケースを潰せたら入れたい。
Next
ところが、Podcast アプリなら、ユーザが操作してないときに自動でダウンロードしておきたい。
ネットワークに繋がっている場合、定期的にサーバに RSS を問い合わせ、更新があれば fetch する。
ここでもう 1 つ待望だった API の Periodic Background Sync が利用できるだろう。
また、今回の保存先はあくまでもキャッシュなので、キャッシュが増えればいずれ消える。
そこでより積極的に保存を考える場合は File API も考慮できるだろう。
次回はそれらを解説したい。