ServiceWorkerとCache APIを使ってオフラインでも動くWebアプリを作る

#はじめに
Thetaの360°画像にぼかしを入れるWebアプリ[SphereBlur.com](https://sphereblur.com)を[作った](http://qiita.com/horo/items/abb8bf40c23b21ce6e5b)際に、オフラインでも動くようにするために、HTML5の新しい技術Service Workerを使った。なお、下の動画でホーム画面からネイティブアプリっぽく起動しているのは[Web App Manifestのおかげ](http://qiita.com/horo/items/ff665e4a6613e7684f8f)である。

[![オフラインでも動く](https://media.giphy.com/media/l2JHYO4WvEWtJKq1q/giphy.gif)](https://youtu.be/N9VXV9Ov7iU)

#Service Worker
Service Workerは、通常のページの環境とは別に、バックグラウンドで実行されるJavaScript実行環境で、ページからのネットワークリクエストを横取りしたり、ウェブサイトからのPush通知を受けとって表示するといった、今まではできなかった処理をすることができる。Push通知の方は、去年[Facebookが使い始めた](http://jp.techcrunch.com/2015/09/15/20150914facechrome/)ので有名になったが、今回はPush通知ではなく、ネットワークリクエストを横取りする機能を使ってオフライン対応をした。

#ネットワークリクエストを横取り(Fetchイベントハンドラ)

ページ側で下記のコードを実行すると、sw.jsがService Workerとして登録される。

```js:index.html
navigator.serviceWorker.register('./sw.js', {scope: './'});
```

そして、sw.js側で、下記のようなFetchイベントハンドラを登録しておくと、`scope`で指定したパス以下のページからのネットワークリクエストが来るたびに`console.log(event.request.url);`が実行される。

```js:sw.js
self.addEventListener('fetch', function(event) {
    console.log(event.request.url);
  });
```

例えば、ページ側に`<img src="test.png">`があると、ページをリロードをするたびに、Service WorkerのDeveloper ToolsのConsoleにこのように表示される。 [デモ](https://horo-t.github.io/qiita/sw_demo01/index.html)

<img width="688" alt="sw_demo01.png" src="https://qiita-image-store.s3.amazonaws.com/0/110601/709f998b-d164-7267-67fd-0361f6b4b885.png">


#Service Worker内で生成したレスポンスを返す
Fetchイベントハンドラ内で、`event.respondWith()`を呼ぶと、実際にサーバーへリクエストを投げるのではなく、Service Worker内でレスポンスを生成してページに返すことができる。 この[デモページ](https://horo-t.github.io/serviceworker/demo/simpleblob/index.html)で、ボタンをクリックしてから"test"と書かれたリンクをクリックすと"Hello world"と表示されるが、これはService Worker内で生成されたレスポンスである。

```js:sw.js
self.addEventListener('fetch', function(event) {
    if (event.request.url.indexOf('test') != -1) {
      event.respondWith(new Response('Hello world'));
    }
  });
```

#サーバーから受け取ったレスポンスをService Worker内で変換して返す
Service Worker内ではFetch API`fetch()`を使うことでサーバへHTTPリクエストを投げることができる。これと`event.respondWith()`を組み合わせることで、サーバーから受け取ったレスポンスをService Worker内で変換してページに返す事ができる。例えばこの[デモ](https://horo-t.github.io/tiff2bmpsw/tiff2bmpsw.html)では、Emscriptenを使ってコンパイルしたLibTIFFを使って、TIFFファイルをService Worker上でBMPファイルに変換してページに返している。

```js:tiff2bmpsw.js
function getBmp(url) {
  return new Promise(function(resolve, reject) {
    fetch(url)
      .then(function(res) { return res.arrayBuffer(); })
      .then(function(buffer) {
        var filename = "tmp" + (++filecount) + ".tiff";
        FS.createDataFile(
            '/',
            filename,
            new Uint8Array(buffer), true, false);
        bmpOutput = [];
        var ret = Module.ccall('tiff_to_bmp',
                               'number',
                               ['string'],
                               [filename])
        if (ret == 0)
          resolve(new Response(new Blob(bmpOutput, {type: "image/bmp"})));
        else
          reject();
      });
  });
}

self.addEventListener('fetch', function(event) {
  if (event.request.url.indexOf('.tif') == -1)
    return;
  event.respondWith(getBmp(event.request.url));
});
```

現在仕様策定中の[Streams API](https://streams.spec.whatwg.org/)を使えば、Jake Archibaldのこの[デモ](https://jakearchibald.com/2016/streams-ftw/#mpeg-to-gif)のように、Service Worker上でMPEGファイルをGIFアニメーションファイルに徐々に変換しながらページに返すという、なんともトリッキーなこともできるようになる。

#Fetch APIとCache APIを使ったレスポンスの保存
[SphereBlur.com](https://sphereblur.com)では静的なコンテンツをページに返すだけでいいので、ServiceWorkerのインストール時に、必要となるリソースをFetch APIでサーバーから取ってきてCache APIを使って保存しておき、Fetchイベントハンドラでキャッシュから取り出して返すということをしている。

まず、インストール時は下記のようなコードで、STATIC_FILESにあるすべてのURLに対して`fetch()`を呼び、そのぞれのレスポンスを`cache.put()`で保存する。なお、`response.ok`をチェックすることで、サーバーエラー等のレスポンスを誤って保存しないようにしている。

```js:sw.js
var VERSION = 1;
var STATIC_CACHE_NAME = 'static_' + VERSION;
var ORIGIN = location.protocol + '//' + location.hostname +
             (location.port ? ':' + location.port : '');
var STATIC_FILES = [
  ORIGIN + '/',
  ORIGIN + '/jpeg_encoder_basic.js',
  ORIGIN + '/?homescreen',
  ORIGIN + '/img/ic_photo_black_24px.svg',
  ORIGIN + '/img/ic_save_black_24px.svg',
  ORIGIN + '/img/ic_loop_black_24px.svg',
  ORIGIN + '/img/4pt.svg',
  ORIGIN + '/img/7pt.svg',
  ORIGIN + '/img/10pt.svg'];
var STATIC_FILE_URL_HASH = {};
STATIC_FILES.forEach(function(x){ STATIC_FILE_URL_HASH[x] = true; });

self.addEventListener('install', function(evt) {
    evt.waitUntil(
        caches.open(STATIC_CACHE_NAME).then(function(cache) {
            return Promise.all(STATIC_FILES.map(function(url) {
                return fetch(new Request(url)).then(function(response) {
                    if (response.ok)
                      return cache.put(response.url, response);
                    return Promise.reject(
                        'Invalid response.  URL:' + response.url +
                        ' Status: ' + response.status);
                  });
              }));
          }));
  });
```

#キャッシュからレスポンスの取り出し

Fetchイベントハンドラは、下記のようにキャッシュにあるURLのFetchイベントの場合に`caches.match()`でレスポンスを取り出してページに返している。

```js:sw.js
self.addEventListener('fetch', function(evt) {
    if (!STATIC_FILE_URL_HASH[evt.request.url])
      return;
    evt.respondWith(caches.match(evt.request, {cacheName: STATIC_CACHE_NAME}));
  });
```

#古いキャッシュの削除

また、バージョンアップ時に、要らなくなったキャッシュを削除するために、Activateイベントハンドラは下記のようなコードになっている。

```js:sw.js
self.addEventListener('activate', function(evt) {
    evt.waitUntil(
      caches.keys().then(function(keys) {
            var promises = [];
            keys.forEach(function(cacheName) {
              if (cacheName != STATIC_CACHE_NAME)
                promises.push(caches.delete(cacheName));
            });
            return Promise.all(promises);
          }));
  });
```

#最後に
Service WorkerのFetchイベントハンドラを使うと、今回のように単純にキャッシュから返すだけ以外にも様々な事ができる。Jakeのこの記事[The offline cookbook](https://jakearchibald.com/2014/offline-cookbook/)に詳しく載っているので、Fetchイベントを使いこなしたい場合はおすすめ。

また、今回は手動で静的コンテンツの一覧の管理をしているが、[sw-precache](https://github.com/GoogleChrome/sw-precache)を使うと自動化できるので、コンテンツが多数ある場合はこちらを使うと良い。