await 使って Cache API 使ってみた

とりあえずやってみた。差別化要素としては `await` 使っている点。MDNとかの例だとPromiseの thenを多用していて読みにくいので `async function`, `await` を使って超簡単な実装をして動作を追ってみた。

やったこと

- ServiceWorker 経由で cache API を使ってみる
- ServiceWorker の更新（アプリのバージョンアップ）を想定した動作テストの実施
    - バージョン別のキャッシュの保持
    - 古いバージョンのキャッシュの管理(activate event handler)

# #01. 前準備

- HTTPSでアクセスできる環境を準備する
    - GithubPages, Firebase Hostingなどがある
    - しかし↑はめんどくさいのでLets Encryptとかで、VPS上のNginxとかでさくっと環境を作った方が後々楽
    - localhostなら HTTP環境でも ServiceWorker は動作するらしい(未確認)
- Chrome ブラウザを用意する
    - 「ctrl + shift + N」 で起動するシークレットモードを推奨
    - WebWorker が独自に立ち上がるため
    - chrome://inspect/#service-workers に適宜アクセスするとハッピーかも

# #02. 資材配置
ドキュメントルートをこんな感じにしてみた。`one.txt` がキャッシュ対象。`sw.js` がServiceWorker。

```ドキュメントルート.txt
/sw/index.html
/sw/app.js
/sw/sw.js
/sw/one.txt 
```

具体的に `one.txt` を作ろう。単純にこんな感じ。

```one.txt
1
```

`index.html` も特筆すべき点はないのでサクッと作る。

```index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script src="app.js"></script>
  </body>
</html>
```

`app.js` では、以下の二つの操作を記述

- serviceWorker の登録操作（ややこしいがテンプレどおり）
- リソース`one.txt`を fetch して body に書き込む操作

```app.js
// register serviceWorker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw/sw.js').then((reg) => {
    if(reg.installing) {
      console.log('sw:installing');
    } else if(reg.waiting) {
      console.log('sw:installed');
    } else if(reg.active) {
      console.log('sw:active');
    }
  }).catch(function(error) {
    console.log('sw:error' + error);
  });
}

// fetch and display
window.onload = async () => {
  const p = document.createElement('p');
  const resp = await fetch('/sw/one.txt');
  p.innerHTML = await resp.text();
  document.body.appendChild(p);
};
```

メインの `sw.js`。一度に引用するのではなく、ちょっとずつ引用する。

まずは、末尾のテンプレ部分。サービスワーカーのインストール時にキャッシュを構築し、起動時には（起動したバージョンよりも）古いキャッシュが存在していればそれを削除、あとは fetch イベントをインターセプトするようにイベントリスナを設定。

```sw(リスナ登録箇所のみ).js
//main
self.addEventListener('install', ev => ev.waitUntil(onInstall()));
self.addEventListener('activate', ev => ev.waitUntil(onActivate()));
self.addEventListener('fetch', ev => ev.respondWith(onFetch(ev.request)));
```

キャッシュ構築部はこんな感じ。`addAll()` を使ってまとめてキャッシュを構築する。

```sw(キャッシュ構築).js
// create caches for latest version on install event.
async function onInstall () {
  console.log(`triggered onInstall:${version}`);
  const cache = await caches.open(version);
  return cache.addAll([
    // INFO: sw.js should not be in this list.
    '/sw/index.html',
    '/sw/app.js',
    '/sw/one.txt',
  ]);
}
```

古いキャッシュの削除は、activate イベントに紐づける。具体的な実装はこんな感じでよいだろう。

```sw(キャッシュ削除).js
// delete old caches on activate event.
async function onActivate () {
  console.log(`triggered onActivate:${version}`);
  const targets = (await caches.keys()).filter(name => name !== version);
  return Promise.all(targets.map(t => caches.delete(t)));
}
```

後はfetch イベント。これもMDNの例と同じだけど、`await` 使っているので多少見通しが良くなっている。

```sw(fechインターセプト).js
// on fetch
async function onFetch (req) {
  // early return on cache hit
  const hit = await caches.match(req);
  if (hit !== undefined)
    return hit;
  // on cache miss
  try {
    const res = await fetch(req);
    const cloned = res.clone();
    const cache = await caches.open(version);
    cache.put(req, cloned); // cache update
    return res;
  } catch(err) {
    return new Response(`fetch error ${err}`);
  }
}
```

最後に `sw.js` の完全な実装を記載

```sw.js
const version = 'v7';

// create caches for latest version on install event.
async function onInstall () {
  console.log(`triggered onInstall:${version}`);
  const cache = await caches.open(version);
  return cache.addAll([
    // INFO: sw.js should not be in this list.
    '/sw/index.html',
    '/sw/app.js',
    '/sw/one.txt',
  ]);
}

// delete old caches on activate event.
async function onActivate () {
  console.log(`triggered onActivate:${version}`);
  const targets = (await caches.keys()).filter(name => name !== version);
  return Promise.all(targets.map(t => caches.delete(t)));
}

// on fetch
async function onFetch (req) {
  // early return on cache hit
  const hit = await caches.match(req);
  if (hit !== undefined)
    return hit;
  // on cache miss
  try {
    const res = await fetch(req);
    const cloned = res.clone();
    const cache = await caches.open(version);
    cache.put(req, cloned); // cache update
    return res;
  } catch(err) {
    return new Response(`fetch error ${err}`);
  }
}

//main
self.addEventListener('install', ev => ev.waitUntil(onInstall()));
self.addEventListener('activate', ev => ev.waitUntil(onActivate()));
self.addEventListener('fetch', ev => ev.respondWith(onFetch(ev.request)));
```

# #03. 実験手順

- chrome のプライベートウィンドウを開く
- `/sw/index.html` or `/sw/` にアクセスしてみる
    - 1 と表示されていることを確認
- コンソールを開く
    - install, activate イベントハンドラの動作を確認
    - `caches.keys().then(console.log)`を打ってみてキャッシュの存在を確認
- F5 を押下（ページの更新）
    - NetWorkタブを確認し、ディスクキャッシュからゲットできていることを確認
- sw.js をエディタで開きバージョン番号を上げる
- F5を押下（ページの更新）
    - install イベントハンドラが動作するのを確認
    - activate イベントハンドラは動作しないのを確認
- ブラウザを閉じて、再度立ち上げてアクセスしなおす
    - activate イベントハンドラが動作するのを確認

#04. 気づきとまとめ

キャッシュの検索では、`Cache#match` ではなく、`CacheStorage#match`を使うので、古いバージョンのキャッシュは明示的に削除するべきだ（`CacheStorage#match`は生成が一番古いものを返すため）。

`sw.js`が一バイトでも違えばServiceWorkerは新しいバージョンであるとみなす。一方セマンティクス上では`version`変数の値がバージョンとなる。変数の値を変更せずにsw.jsを更新したり、その逆をやった時にどのような挙動になるか？は押さえたい。この単純な例ではそんなへんなことにはならなそうだけど。

ServiceWorkerのライフサイクルは、なんだかんだでややこしい。特に1回目のロードや、バージョン更新時の動作（install, activateの違い）この辺りをしっかり押さえたい。人によってはブラウザのタブを開きっぱなしの人もいそうで、上手く全員がバージョンアップできるのか？という観点でのUI設計はした方がよさそうだ。