Stream APIのReadableStreamでコンテンツ以外をキャッシュしてあそんだ話

この記事は[PWA Advent Calendar 2019](https://qiita.com/advent-calendar/2019/pwa)の22日目の記事です。

気がついたら22日になっていて、日数が経過するスピードに着いていけずカレンダーを見てびびっています。ﾓｳｲｸﾂﾈﾙﾄｵｼｮｳｶﾞﾂ…

さてさっそくですが表題の件ですが、先日開催された[JSConf JP](https://jsconf.jp/2019/)の「[Streams APIをちゃんと理解する](https://speakerdeck.com/tkskto/streams-apiwotiyantoli-jie-suru)」というセッションで、「Fetch API実行後に受け取れるResponseのBodyは`ReadableStream`なので、Service WorkerでリクエストをインターセプトしてStreamをResponseとして返すことができる」ということを知ったので少しさわってみました。

## ためしてみた

今回は、`template.html`というファイルを用意して、そこからコンテンツの上部と下部を切り出し、Fetch時にリクエストされたらキャッシュした部分と、データとして保持しているHTMLをReadableStreamで読み込んでレスポンスとして返却する、というかんたんなデモを作成しました。動作デモは[こちら](https://fluflufluffy.github.io/sw-stream-api-sample/)です。

[Service Worker](https://github.com/fluflufluffy/sw-stream-api-sample/blob/master/service-worker.js)からポイントだけ抜粋します。

下準備として、`template.html`というファイルを用意しService Workerのインストール時にコンテンツの上部と下部に分割してキャッシュします。

```javascript
const CACHE_LIST = [
  'template.html'
  // ...
];

self.addEventListener('install', (installEvent) => {
  console.log('[Service Worker] Installed');

  installEvent.waitUntil(
    self.skipWaiting(),
    (async () => {
      const cache = await caches.open(CACHE_VERSION);

      await cache.addAll(CACHE_LIST)
      .then(() => cache.match('template.html'))
      .then((response) => {
        return response.text().then(text => {
          const headerEndIndex = text.indexOf('<div class="o-layout -main"><div class="o-layout__inner">');
          const footerStartIndex = text.indexOf('</div></div></main>');
          return Promise.all([
            cache.put('template-start.html', new Response(text.slice(0, headerEndIndex), response)),
            cache.put('template-end.html', new Response(text.slice(footerStartIndex), response))
          ]);
        });
      })
      .then(() => console.log('Required assets successfully cached !'))
      .catch((error) => {
        console.warn('Required assets failed to cache. :', error);
      })
    })());
});

```

こうするとキャッシュに`template.html`から生成された'template-start.html'と`template-end.html`が追加されます。

<img width="984" alt="スクリーンショット 2019-12-22 午前9.14.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425122/761105c9-700f-fac6-3e35-c62c94336403.png">

続いてキャッシュやデータから`ReadableStream`でリクエストを生成する関数を作成します。

```javascript
const createStream = async (request) => {
  const stream = new ReadableStream({
    start(controller) {
      const url = new URL(request.url);
      const header = caches.match('template-start.html');
      const footer = caches.match('template-end.html');
      const filename = /\/$/.test(url.pathname) ? 
       'index.html' : url.pathname.match(".+/(.+?)([\?#;].*)?$")[1];
      const targetPath = 'assets/data/'+ filename;
      const contents = fetch(targetPath)
      .then(response => {
        if (!response.ok && response.status === 404) {
          return caches.match('assets/data/404.html');
        }
        return response;
      });
      const pushStream = (stream) => {
        const reader = stream.getReader();
        const process = (result) => {
          if (result.done) return;
          controller.enqueue(result.value);
          return reader.read().then(process);
        }

        return reader.read().then(process);
      }

      header
        .then(response => pushStream(response.body)).then(() => contents)
        .then(response => pushStream(response.body)).then(() => footer)
        .then(response => pushStream(response.body)).then(() => controller.close());
    }
  });

  return new Response(stream, {
    headers: {'Content-Type': 'text/html; charset=utf-8'}
  })
}
```

そして`fetch`の際に、`ReadableStream`でコンテンツをキャッシュからレスポンスとして返却します。

```javascript
self.addEventListener('fetch', (fetchEvent) => {
  console.log('[Service Worker] Fetch Event');

  const url = new URL(fetchEvent.request.url);
  const destination = fetchEvent.request.destination;
  const pathname = url.pathname;

  if(url.origin === location.origin) {
    if(destination === 'document') {
      if (pathname === scope || pathname === scope + 'index.html') {
        fetchEvent.respondWith(
          cacheFallingBackToNetwork(fetchEvent)
        );
      } else {
        fetchEvent.respondWith(createStream(fetchEvent.request));
      }

      return;
    }

    fetchEvent.respondWith(
      cacheFallingBackToNetwork(fetchEvent)
    );
  }
});
```

## さわってみた感想

- `head`の中身（`title`など）をどうしよう問題にぶち当たるので、静的ページで用いるには向いてなさそう
    - コンテンツ情報を動的に引っ張ってくるページやアプリケーションのフレームなどには使えそう
- キャッシュの扱い方のひとつとして勉強になりました
- 初回訪問時などはページのFetchイベントが取れないので、そのあたりどういう扱うか問題については課題

それではよいお年をお過ごしください！

### 参考資料

- [Streams APIをちゃんと理解する](https://speakerdeck.com/tkskto/streams-apiwotiyantoli-jie-suru)
- [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
- [2016 - the year of web streams](https://jakearchibald.com/2016/streams-ftw/#creating-one-stream-from-multiple-sources-to-supercharge-page-render-times)
- [JavaScriptのStreams APIで細切れのデータを読み書きする](https://sbfl.net/blog/2018/05/26/javascript-streams-api/)