Service Worker(PWA)でRangeリクエストに対応する

## はじめに
アプリケーションをPWA(Progressive Web Apps)化する際のメリットの１つとしてキャッシュ機能があると思いますが、Service Workerで動画をキャッシュするとsafariで再生できない現象が発生します。
今回はその対応方法としてService WorkerにおけるRangeリクエストへの対応方法をご紹介します。
前提として、ionic(angular)、SW-Toolboxを使用しています。

## 問題点
ネットーワーク経由で動画を取得している時には問題なく動画が再生されるが、キャッシュから取得すると再生されない。(safariのみ)
ソースコード(抜粋)は以下の通り。

```html:hoge.html
<video [poster]="posterUrl" playsinline>
  <source [src]="videoUrl" type="video/mp4" />
</video>
```

```ts:hoge.ts
export class HogeComponent {
  @Input() posterUrl: string;
  @Input() videoUrl: string;
    :
}
```

```js:service-worker.js
'use strict';
importScripts('./build/sw-toolbox.js');

self.toolbox.options.cache = {
  name: 'ionic-cache'
};

self.toolbox.precache(
  [
    './build/main.js',
    './build/vendor.js',
    './build/main.css',
    './build/polyfills.js',
    'index.html',
    'manifest.json'
  ]
);

self.toolbox.router.any('/*', self.toolbox.fastest);
self.toolbox.router.default = self.toolbox.networkFirst;
```

## 原因
クライアントがサーバーから動画を取得する際に、HTTP Rangeリクエストをサーバーに行なっていたが、その際のステータスコードは

- ネットワーク経由で取得: 206(Partial Content)
- キャッシュから取得: 200(OK)

となっていた。（キャッシュはRangeリクエストに対応していないため、部分的にデータを取得することができていなかった。）

SafariではHTTP Rangeリクエストに対してレスポンスコード200を受け取るとそれ以降そのファイルを読み込まない仕様になっているため、キャッシュから動画を取得する際にはファイルが読み込まれず再生されていなかった。

***<ネットワークからの場合のHTTPヘッダ>***
![sw-log-ok.png](https://qiita-image-store.s3.amazonaws.com/0/92492/ef0d8a29-9b1d-fbd7-a3ca-4b53ded92e27.png)

***<キャッシュ(Service Worker)からの場合のHTTPヘッダ>***
![sw2-log-ng.png](https://qiita-image-store.s3.amazonaws.com/0/92492/42edd7ea-519f-d512-0fa8-67ca9db32d6c.png)

## 解決策
Service WorkerでRangeリクエストが来た際にステータスコード206で返却するようにレスポンスを上書きする。
リクエストがきた際にキャッシュがある場合はキャッシュから取得し、ない場合はネットワークから取得するだけの処理は下記のようになる。
（ファイルサーバーのURLを `https://hoge.hoge` としています。）

```js:service-worker.js
// Match URLs that begin with https://hoge.hoge
self.toolbox.router.get(/^https:\/\/hoge.hoge\//, (request) => {
  return new Promise((resolve, reject) => {
    caches.match(request).then((response) => {
      if (response) {
        // from cache
        resolve(response);
      }
      // from network
      return fetch(request)
        .then((response) => resolve(response))
        .catch((error) => reject(error));
    });
  });
});
```

上記処理だけだとRangeリクエストに対応できていないので、Rangeリクエストが来た場合の処理を追加します。

```js:service-worker.js

  :

// Match URLs that begin with https://hoge.hoge
self.toolbox.router.get(/^https:\/\/hoge.hoge\//, (request) => {
  return new Promise((resolve, reject) => {
    if (request.headers.get('range')) {
      // Range request
      let rangeHeader = request.headers.get('range');
      let rangeMatch = rangeHeader.match(/^bytes\=(\d+)\-(\d+)?/)
      let pos = Number(rangeMatch[1]);
      let pos2 = rangeMatch[2];
      if (pos2) { pos2 = Number(pos2); }

      caches.open('ionic-cache')
        .then((cache) => {
          return cache.match(request.url);
        }).then((res) => {
          if (!res) {
            return fetch(request.url).then(res => res.arrayBuffer());
          } else {
            return res.arrayBuffer();
          }
        }).then((ab) => {
          let responseHeaders = {
            status: 206,
            statusText: 'Partial Content',
            headers: [
              ['Content-Type', 'video/mp4'],
              ['Content-Range', 'bytes ' + pos + '-' + (pos2 || (ab.byteLength - 1)) + '/' + ab.byteLength]]
          };
          
          let abSliced = {};
          if (pos2 > 0){
            abSliced = ab.slice(pos, pos2 + 1);
          } else {
            abSliced = ab.slice(pos);
          }
          
          resolve(new Response(abSliced, responseHeaders));
        });
    } else {
      // Non-range request
      caches.match(request).then((response) => {
        if (response) {
          // from cache
          resolve(response);
        }
        // from network
        return fetch(request)
          .then((response) => resolve(response))
          .catch((error) => reject(error));
      });
    }
  })
});

  :

```

実行すると下記の様な結果となります。

![sw-log-ok2.png](https://qiita-image-store.s3.amazonaws.com/0/92492/fdcdafd0-dc21-6bc8-f14d-9b1404ad350e.png)

これでService WorkerのRangeリクエスト対応は完了です。
ただし、このままキャッシュをガンガン保存するとキャッシュ容量の上限に達して`Uncaught (in promise) DOMException: Quota exceeded. `というエラーが発生してしまう可能性が高いので、Service Workerにてキャッシュの制限を行うのがオススメです。
下記の例ではキャッシュの保持期限と保持数を定義しています。

```js:service-worker.js
  : 

self.toolbox.options.cache = {
  name: 'ionic-cache',
  maxAgeSeconds: 60 * 60 * 24,
  maxEntries: 50
};

  : 
```

---
<参考>

- [PWA - cached video will not play in Mobile Safari (11.4)](https://stackoverflow.com/questions/52087208/pwa-cached-video-will-not-play-in-mobile-safari-11-4)
- [Workbox で Service Woker のキャッシュを導入してみた話し、Webpack を添えて](https://blog.nagisa-inc.jp/archives/1132)
- [Safariで動画を表示する際、サーバーのHTTP Range Request対応が必須になっている](https://qiita.com/gecko655/items/110373a6c88177d23126)
- [“Status Code:200 OK (from ServiceWorker)” in Chrome Network DevTools?](https://stackoverflow.com/questions/33590378/status-code200-ok-from-serviceworker-in-chrome-network-devtools)