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


この記事はPWA Advent Calendar 2019の22日目の記事です。

気がついたら22日になっていて、日数が経過するスピードに着いていけずカレンダーを見てびびっています。モウイクツネルトオショウガツ…

さてさっそくですが表題の件ですが、先日開催されたJSConf JPの「Streams APIをちゃんと理解する」というセッションで、「Fetch API実行後に受け取れるResponseのBodyはReadableStreamなので、Service WorkerでリクエストをインターセプトしてStreamをResponseとして返すことができる」ということを知ったので少しさわってみました。

ためしてみた

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

Service Workerからポイントだけ抜粋します。

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

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が追加されます。

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

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でコンテンツをキャッシュからレスポンスとして返却します。

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イベントが取れないので、そのあたりどういう扱うか問題については課題

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

参考資料