ほぼ黒のカードに白のPWAロゴ。P・W・Aが線を共有しながら一続きの角ばったロゴタイプになっている

Astroブログで読んだ記事をオフラインでも開けるようにする — Service Worker、ページ50件の上限、TTLなし

計測がAstroブログのService Workerの設計を決めました。11.7MBのHTMLではなく287KBを事前キャッシュし、ページは50件で上限、有効期限は設けません。

目次

はじめに

私はこのブログを読みます。書いている本人が言うのも妙ですが、調べ物のために自分の記事へ戻ることがあり、それはたいてい接続のない飛行機の中で、何も入っていないタブを前にしたときです。やりたかったことは単純でした。一度読んだ記事を、Wi-Fiを切ったまま開いて、そこにあってほしい。

それが可能なのかどうか、私は知りませんでした。最初に思いついたのは本文を localStorage に入れておいて後から読み出す方法で、これは推測で済ませず確認してもらいました。答えは「できない」で、理由は構造的なものです。localStorage はネットワーク層から到達できないため、ナビゲーションのリクエストがそれを参照することはありません。 オフラインのナビゲーションに応答できるのは、Cache API にレスポンスを保持した Service Worker だけです。

残りを決めたのは好みではなく、ビルドの計測でした。本記事では、順当に思えた設計を退けた数値、そこから出てきた4つのキャッシュ、そして途中で自信たっぷりに間違っていた4つのチェックを整理します。

localStorage はナビゲーションに応答できない

localStorage の魅力は、書き込みも読み出しも1行で済むことです。問題は、ブラウザがURLを取得しにいくときに何が起きるかにあります。参照されるのはHTTPキャッシュとネットワークだけで、それ以外はありません。ページが自分で保存した文字列が応答の候補として差し出される場所は、どこにもないのです。

さらに3つの制約があります。上限はおよそ5MBで、ここの記事1本でほぼ埋まります。保存できるのは文字列なので、HTML文書はヘッダーもステータスもContent-Typeも持たない1本の長い文字列として入ります。そして同期的であるため、大きなエントリの読み出しはメインスレッドを止めます。

Cache API はこの4点すべての逆です。 RequestResponse の組をヘッダーとステータスごと保存します。これはまさに、ナビゲーションに応答するために必要なものです。Service Worker はネットワークの手前にプロキシとして立ち、タブを閉じたあとも生き続け、保存したレスポンスを返せます。

// A service worker can answer a navigation. Nothing else in the browser can.
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(handleNavigation(event));
}
});

ビルドを計測すると何が見えるか

この時点での直感は「サイトを事前キャッシュして終わりにする」です。それを止めたのが .vercel/output/static/ の計測でした。

内訳 サイズ ファイル数
記事のHTML(公開73ページ、3言語) 8.87 MB 73
タグ・一覧・検索・プライバシーのHTML 2.81 MB 109
_astro/ の画像 22.81 MB 489
pagefind/ 2.20 MB 約100
Markdown の複製 1.42 MB 73
CSS・JS・Webフォント 0.21 MB 19
フィード・サイトマップ・llms.txt・アイコン 3.68 MB 約20
合計 42 MB 883

記事1ページのHTMLは最大で300KBに達します。 これは無駄ではありません。このサイトの図版はラベルを翻訳できるようインラインSVGのコンポーネントになっているため、図が別ファイルではなく文書の中に入ります。3言語を扱う以上それが正しい判断であり、同時に「HTMLだけ事前キャッシュすればいい」が11.7MBかかる理由でもあります。

1ページを求めただけの端末に11.7MBを送り込むのは、ほとんどの読者が使わない保証と引き換えにするには割に合いません。そこで設計は反転しました。シェルを事前キャッシュし、通りがかったページはそのつど保持します。 オフラインとは「すでに開いたものはすべてある」という意味になり、これは実際に起きる場面そのものです。しかも追加のバイトはかかりません。そのレスポンスはページを描くためにどのみち取得されていたからです。

シェルは27エントリで287KBに収まりました。スタイルシート14本、スクリプト4本、Webフォント、アイコン、そして3つのウェブアプリマニフェストです。この数字は、以前に私が投げていた「サイトが黙って何かをダウンロードすることを読者は嫌がらないか」という問いにも決着をつけました。11.7MBなら答えは「嫌がる」です。287KBなら、同意ダイアログは守ろうとしているものより多くの注意を奪います。したがってダイアログは出さず、譲るべき箇所は別にしました。navigator.connection.saveData です。データセーバーを有効にしている読者にもワーカーは入り、読んだものは保持されます。ただし先回りの取得だけは発生しません。

この譲歩は聞こえるほど大きくないので、普遍的なものとして読まれるより、そう書いておくほうが誠実です。 Network Information API は Baseline ではなく、MDN は「広く使われているブラウザの一部で動作しない」という理由で限定的な利用可能性と記しています。navigator.connection が存在しない環境ではオプショナルチェーンが undefined を返し、ガードは何もせず、シェルは変わらず取得されます。この譲歩は実装しているブラウザでは本物で、それ以外では効きません。

4つのキャッシュと、1つだけ削除しない理由

4 つのキャッシュ、うち 3 つに上限キャッシュの枠が 4 つ。シェルはスタイルシート・スクリプト・フォント・アイコン・マニフェストを保持し、上限は 120 件。ページは記事の HTML を上限 50 件、画像は図版を上限 200 件で保持し、いずれも読者がページを開いた分だけ溜まる。オフラインのキャッシュはオフライン用ページ 3 枚を保持し、削除されることはない。oharu-shellCSS・JS・フォント・アイコン・マニフェスト上限 120事前キャッシュoharu-pages読んだ記事のHTML上限 50読んだ分だけoharu-imagesページが描画した図版上限 200読んだ分だけoharu-offlineオフライン用ページ 3 枚削除しない事前キャッシュ
シェルは削除ではなく上限で抑えます。デプロイ前にキャッシュされたHTMLが参照するスタイルシートを、そのまま見つけられるようにするためです。

増え方については、私から3つ質問しました。読者が実際にいる言語だけをキャッシュすべきか、エントリに期限を設けるべきか、上限を設けるべきか。答えは、はい・いいえ・はい、でした。

事前キャッシュされるのは訪問中の言語のホームページだけです。 スタイルシートとスクリプトは3言語で共有されるため、言語ごとに異なるのはホームページとオフラインページだけです。読者が開くことのない言語を先に取得するのは、この設計全体が避けている先回りそのものです。

有効期限は設けません。Cache API に期限の仕組みがなく、それを自作するのは持たないより悪いからです。 TTLを持たせるということは、陳腐化を推測するためだけにタイムスタンプの表を別に維持することを意味します。一方でビルドは、変わった分を正確に無効化しています。デプロイのたびにワーカーのバージョンが変わり、シェルはそれに合わせて突き合わせられます。さらにナビゲーションのたびに背景で再検証が走ります。この最後の点は記事には当てはまりましたが、記事を一覧するページには当てはまらず、後の記事で訂正しています。一覧ページはネットワークを先に見る必要がありました。時計を持ち込めば、読者が意図して残したページを、その内容が変わったかどうかとは無関係な周期で捨てることになります。

適した手段は上限のほうで、計測した平均値から決めています。記事のHTMLは121KB、_astro の画像は48KBです。

キャッシュ 保持するもの 上限 上限時のサイズ
oharu-pages 読んだ記事のHTML 50 約6 MB
oharu-images ページが描画した図版 200 約9.6 MB
oharu-shell CSS・JS・フォント・アイコン・マニフェスト 120 約1 MB
oharu-offline オフライン用ページ3枚 削除しない 約60 KB

破棄は挿入順のFIFOです。Cache API が露出している順序はこれだけだからです。 本当のLRUにはアクセス順を記録する IndexedDB の副テーブルが要りますが、時間軸におおむね沿って読まれるブログでは両者を区別できません。そのためにデータベースを1つ持ち込む価値はありません。

立ち止まる価値があるのは最後の行です。オフラインページが専用のキャッシュにあるのは、それらが最初に書き込まれるからで、FIFOの削除は先頭から始まります。 シェルに置いたままなら真っ先に破棄される側になり、サイトを使い込むほどフォールバックのページが消えていくことになります。

稼働中のサーバー相手に通ってしまったオフラインテスト

最初の検証はもっともらしく見えて、何の役にも立ちませんでした。

エージェントは Chrome DevTools を Network: Offline にし、キャッシュ済みの記事へ移動して、表示されるのを確認しました。続いて一度も訪れていない記事へ移動すると、それも表示されました。読んだ分だけ保持する設計でできるはずのないことです。決め手になったのは、ページ内で実行した1行でした。

try { await fetch('/robots.txt', { cache: 'no-store' }); }
catch (e) { console.log('THREW: ' + e.message); } // THREW: Failed to fetch

ページには本当にネットワークがありませんでした。ワーカーにはありました。

DevTools の通信制限はターゲット単位同じプレビューサーバーへ 2 本の経路が向かう。上の経路はページからのもので、Offline のエミュレーションに遮られて fetch が失敗する。下の経路は Service Worker からのもので、制限がかからずサーバーに届くため、ワーカーはその場で取得したページをナビゲーションに返してしまう。ページのターゲットfetch は失敗するOffline はここに適用ワーカーのターゲットService Workerfetch は通ってしまうプレビューサーバー絞られるのは片方のターゲットだけ
Network.emulateNetworkConditions はターゲット単位で適用され、ワーカーは自分が配信するページとは別のターゲットです。

Service Worker は独立した DevTools のターゲットであり、Network.emulateNetworkConditions は設定したターゲットにだけ効きます。 ページはオフラインで、ワーカーはそうではなく、ワーカーは各ページをその場で取得して返していました。すべてのテストが誤った理由で通っていたわけです。このエミュレーションでは navigator.onLinetrue のままで、気づく手がかりになり得たもう一方の信号も消えます。

修正は、エミュレーションをやめてサーバーごと止めることです。

ターミナルウィンドウ
lsof -ti:4321 | xargs kill

本当に死んだオリジンに対して測り直せば、結果に意味が出ます。訪問済みの記事は、スタイルシートと実際に取得済みだった画像2点を伴い、63段落が完全に表示されました。未訪問のURLは正しい言語のオフラインページを返し、ステータスは503、要求したURLはアドレスバーに残ったままでした。

決して成立しないリダイレクト判定

コードレビューがクラッシュを見つけました。探しにいって初めて出てくる類のものです。

redirected の判定を素通りする opaqueredirectナビゲーションの fetch が 3xx に当たると opaqueredirect として解決し、status は 0、ok は false、redirected も false になる。旧コードは redirected を見ていたが、ここでは決して true にならないため素通りし、ok の判定も抜けて Response のコンストラクタに status 0 が渡り RangeError を投げていた。修正版は type を見てリダイレクトをそのまま返し、ブラウザ自身にたどらせる。ナビゲーションの fetch が 3xx に当たるopaqueredirectstatus 0 ・ ok false ・ redirected false修正前if (response.redirected)ここでは決して true にならず素通りするif (response.ok)status 0 は ok ではないnew Response(body, { status: 0 })RangeError ・ respondWith が失敗 ・ ブラウザのエラー画面修正後if (type === "opaqueredirect") return responseリダイレクトはブラウザ自身がたどる
opaqueredirect では redirected が false なので、それを見ていた判定は一度も動かず、レスポンスは status 0 を拒否するコンストラクタへ落ちていきました。

ナビゲーションの Request はリダイレクトモード manual を持ちます。 つまり3xxは「たどられたリダイレクト」としては届きません。届くのはopaqueredirectで、type'opaqueredirect'status0okfalse、そして redirectedfalse です。元のコードは、まさに決して true にならないフィールドを見ていました。

// Never true for a navigation. The response falls straight through.
if (response.redirected) return Response.redirect(response.url, 302);
if (response.ok) { /* … */ }
return offlineResponse(url, response.status); // status === 0

その先で offlineResponsenew Response(body, { status: 0 }) に到達しますが、Response のコンストラクタは200から599しか受け付けません。RangeError が投げられ、その reject が respondWith の外へ抜け、ワーカーさえ入っていなければ問題なく開けたURLに対して、読者はブラウザ自身のエラー画面を受け取ります。 301を返すローカルサーバーで再現したときは net::ERR_FAILED で、修正後は同じURLが開きます。

if (response.type === 'opaqueredirect' || response.status === 0) return response;

ここで1つ訂正します。緊急性について、私は誤った説明を受けていました。 そのとき示された理由は、Vercelは末尾のスラッシュがないディレクトリURLに308を返すので、スラッシュの落ちたリンクはすべて壊れる、というものでした。あとから本番を調べると、リダイレクトは起きていません。/ja/privacy/ja/tags、さらに /…/index.html まで、すべて200です。クラッシュは実在し再現もしましたが、その根拠として挙げられた引き金は現在のルーティングでは発火しません。この修正が実際に買っているのは、今日の通信に潜むバグの解消ではなく、将来リダイレクト規則が追加されたときの安全であり、この2つは同じ主張ではありません。

PNGのバイト列は環境をまたいで一致しない

ビルドはホーム画面用のアイコンを public/favicon.svg から生成しており、生成元との食い違いを捕まえるために --check モードを書きました。生成したPNGのバイト列を、コミット済みのものと比較する作りです。手元では通り、CIでは落ちました。

PWA icons are stale:
public/pwa-icon-512-maskable.png — differs from 20575 bytes
public/apple-touch-icon.png — differs from 6395 bytes

手元でのこの2つは20596バイトと6399バイトです。差が出た2つは、sharp.composite() を通る2つとちょうど一致します。 素のラスタライズだけで作られる残り2つはバイト単位で一致しました。libvips は macOS の arm64 と CI の Linux x64 とで合成もエンコードも異なるため、このチェックは何の問題もないアートワークを報告しながら、CIで永久に落ち続けたはずです。

このチェックが本来捕まえたいのは、favicon を編集して再生成を忘れる事態です。それは入力の変化なので、いまは入力どうしを比べます。SVGのハッシュ、アイコン仕様のハッシュ、そして各ファイルから実際に読み直した寸法です。

scripts/pwa-icons.lock.json
{
"source": "public/favicon.svg",
"sourceHash": "64655d5b744ddbff",
"specHash": "ebf2f5ba3712cce2"
}

寸法はロックの記載を信じずファイルから読むので、切り詰められたPNGや手で置き換えられたPNGも捕まります。そしてこのチェック自体、わざと壊して確かめました。sourceHash を書き換えれば1で終了し、再生成すれば0で終了します。通るところしか見たことのないチェックは、まだテストされていません。

すでに完成していたコードに残っていたTODO

ほかの3つは、何も問題がないときに「問題なし」と言うチェックでした。これは逆向きです。

offlineLocaleFor はどの言語のオフラインページを返すかを決める関数で、TODO と、接頭辞のないURLの扱いを編集上の未決事項として説明する20行の注記を抱えたまま出ていました。未決ではありませんでした。prefixDefaultLocale: false により英語のページは src/pages/ の直下にあるので、/blog/some-slug/ は言語が曖昧なアドレスではありません。英語のアドレスであり、この関数はサイトが生成しうるすべてのURLに対してすでに正しい答えを返していました。

function offlineLocaleFor(url) {
const prefix = url.pathname.split('/')[1];
return LOCALES.includes(prefix) ? prefix : DEFAULT_LOCALE;
}

実際に未決だったのはもっと小さな選好であり、欠陥ではありません。 navigator.language を読んで、URLの言語を読者の言語で上書きするかどうかです。これは見送りました。ブラウザを覗くワーカーは、このサイトで唯一言語を推測する部品になってしまいますし、ja-JP を持ちながら意図して英語を読んでいる読者が少なくないここでは、英語のリンクに日本語の通知を返すことになるからです。マーカーは外し、その理由はドキュメンテーションコメントへ移しました。そこでなら、単に「無い」と気づかれるのではなく、議論の対象になれます。

何が出て、何がまだ確かめられていないか

ワーカーは稼働しています。記事を読めばキャッシュされ、/api/likes/*/pagefind/* はどのキャッシュにも入らず、シェルはアセット27点で落ち着きます。読者自身のホームページはシェルではなく、読んだページと同じ側に保存され、ほかの2言語からは何も入りません。

そのうえで、端末を機内モードにしてサイトを開きました。

機内モードのiPhoneに表示されたオフラインページ。「You are offline」の見出しと、Try again・Homeのボタン、その上にサイトのヘッダーが並んでいる

一度もキャッシュされていないURLです。文言は「見つかりません」ではなくオフラインのほうで、これは DevTools のテストでは到達できなかった分岐です。

このスクリーンショットは、デスクトップでの検証では確かめられなかったことに決着をつけます。ページは navigator.onLine を見て2つの文言を選び分けますが、DevTools のエミュレーションではこの値が true のままなので、どれだけ通信を絞ってもオフラインの文言は現れませんでした。実際に電波を切ることだけがこの分岐を動かし、そして正しいほうが選ばれました。

同じiPhone、機内モードのまま。以前に読んだ記事がサムネイル画像とタイトルごと完全に表示されている

先に読んでおいた記事が、電波なしでキャッシュから返っています。サムネイルが一緒に出ているのは、そのページがすでに取得済みだったからです。

これが、このために作ったものが、このために想定していた端末で動いている状態です。 ヘッダーも言語切り替えもフッターも揃っているのでシェルは届いており、記事と画像は2つのランタイムキャッシュから出ています。

計画時から答えの出ないまま持ち越していた問いが1つあり、本番へのデプロイがそれに決着をつけました。Vercel のアダプターは自前で .vercel/output/config.json を書き、CIは --prebuilt でデプロイするため、vercel.jsonheaders が生き残るのかどうかは本当に不明でした。

$ curl -sSI https://oharu121.com/sw.js | grep -i cache-control
cache-control: public, max-age=0, must-revalidate

このヘッダーは vercel.json 以外のどこにも存在しないので、マージされています。 後処理の工程は結局まったく不要でした。

先の2枚はアドレスバーとタブ数が見えている Safari の画面なので、それ自体が示すのはブラウザのタブでオフライン閲覧ができることだけで、インストール済みのアプリについては何も言いません。そこで次に、サイトをホーム画面へ入れました。

iPhoneのホーム画面に入ったアイコン。白い桜の字が入った朱色の角丸正方形で、角は透明ではなく塗られている

iOSはアイコンを自分の角丸正方形に切り抜きます。角が黒ではなく朱色なのは、このPNGを不透明で生成しているからです。

マニフェストが求めたものは、すべてそのとおりに扱われました。 アイコンはページのスクリーンショットではなく印そのもので、iOSの角丸マスクはどの角でも朱色に接します。透明なPNGなら生じていた黒い楔はありません。「ホーム画面に追加」のシートが提示した名前は oharu で、これはマニフェストの short_name です。起動するとアドレスバーもタブバーもない状態で開くので、display: standalone が読まれています。そして英語のルートからインストールすれば英語のホームページが開きます。start_url が働いている証拠です。

未決のまま残っているのは、1週間かかるほうです。 Safari はサイトが保存したものを7日間の未使用で消去し、ホーム画面のアプリがその例外だと文書化されています。そこで navigator.storage.persist() が実際に許可されるのか、8日間開かなかったキャッシュが残るのかは、どちらも未検証です。saveData の分岐も同じで、こちらは誠実にエミュレートする方法のない1つのガードです。

まとめ

ここでの設計は選ばれたものではありません。42MBのビルドを計測し、そのうち11.7MBがHTMLだと分かったあとに残ったものです。翻訳できる図版が文書の中にあるからそうなります。サイトの事前キャッシュは最初から成り立たず、だからシェルは287KBで、ページは読んだ分だけ保持されます。

うまくいかなかった4つのうち3つは同じ形をしていました。チェックは存在し、実行され、何も証明しませんでした。 オフラインテストが通ったのは、制限がワーカーまで届かなかったからです。リダイレクトの判定は、対象のレスポンスでは常にfalseになるフィールドを読んでいました。アイコンのチェックは、2台の機械が決して一致させないバイト列を比べていました。どれも検証に見えて検証ではなく、これはチェックが無いことより高くつきます。誤った安心まで一緒に買ってしまうからです。

4つめは逆でした。サイトが生成しうるすべてのURLをすでに処理していた関数に TODO が付いたまま出て、完成していたコードが未完成として扱われていました。 それは外され、代わりに入ったものが、この規則がこうである理由になっています。

やりたかったことは動きます。端末を機内モードにして、先に読んでおいた記事を開いたら、そこにありました。ホーム画面へのインストールも、アイコンも名前もスタンドアロン起動も含めて動きます。まだ確かめられないのは1週間かかるほうのテストで、インストールしたものをしばらく放置し、iOSが保存内容を保ち続けるかどうかを見るというものです。

参考リンク

この記事をシェア