怖くないPWA - 既存静的サイトを爆速でPWA化する

先日、以前より公開していたMarkdownエディタのお試し版WebアプリをPWA化しました。
これによって、オフラインで利用できるようになりました。
(実際に、この記事をオフラインのChromebookで下書きしています)

[MDNE Online](https://shellyln.github.io/mdne/)　([Welcomeテキスト表示なし](https://shellyln.github.io/mdne/#filename=untitled.md&open.d=eJwDAAAAAAE))
[Githubリポジトリはこちら](https://github.com/shellyln/mdne)

今回は、既存静的サイトを簡単に、そして正しくサイトが更新されるように、PWA化するノウハウをお伝えしたいと思います。

# 静的コンテンツをPWA化するメリットとデメリット
## メリット
### オフライン利用
上述の拙作のエディタやサーバーとの通信がないミニゲーム等では、オフライン状態での利用が可能になります。
また、オンラインヘルプやAPIドキュメント等をPWA化すれば、通信が不安定な場所でも見ることができます。

### デスクトップ (またはホーム画面) へのインストール
ネイティブアプリケーションのように、OSのデスクトップやホーム画面にアイコンを配置できます。
ユーザーがワンクリックで起動できる「一等地」に陣取れます。(ユーザーが価値を感じれば、ですが)


## デメリット
### 最新情報の表示が遅れる
画面表示に後述の[Service Worker](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API)がキャッシュしたコンテンツの更新が反映されるのは、更新を検知した後、ブラウザが完全に終了し、次回サイトに訪れたときとなります。
従って、今回の例のように静的サイトをノン・コーディングでPWA化する場合、どうしても最新情報を見せなければならないコンテンツはキャッシュしてはいけません。
(静的サイトでなければ、キャッシュから表示した後、ajaxで最新コンテンツを取得することを検討しましょう)


# PWAに必要なもの

ブラウザがそのサイトをPWAとして認識するのに必要なものは

* 必須項目が記載された `manifest.json` (と複数サイズのアプリアイコン)
* [Service Worker API](https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API)でService Workerを登録するJavaScriptコード

の2点です。登録されたService Workerは同API経由で
オフライン動作に必要な静的コンテンツをキャッシュする責務を負います。
(ブラウザがService Workerと認識するのにはキャッシュは必須ではありません)

更に実用上、オフライン動作用の動的コンテンツをIndexedDB等にキャッシュする必要があります。


静的コンテンツのPWA化には、`manifest.json`とService Workerを登録するJavaScriptコードがあれば良いことになります。



# 用意するもの

以下の5ファイルを用意します。

* `contents/index.html`
* `contents/manifest.json`
* `contents/serviceWorker.js`
* `service-worker.js`
* `scripts/make-precache-manifest.js`

うち、サイトにデプロイするのは `contents/*` のファイルです。

> Note: このサンプルでは、デプロイするコンテンツを `contents/` 配下でソース管理しています。`contents/` 配下をサイトのルートまたはサブディレクトリにデプロイすることを仮定しています。


## contents/index.html

アプリのエントリーポイントです。
既存の `index.html` に、 `manifest.json` と Service Worker を読み込むスクリプトの読み込みと呼び出しを追記します。

```html:index.html
<!DOCTYPE html>
<head>
    ...

    <meta name="theme-color" content="#000000">
    <link rel="manifest" href="manifest.json" />

    ...

    <script src="serviceWorker.js" crossorigin="anonymous"></script>
    <script>
        registerMyServiceWorker();
    </script>
</head>

...
```



## contents/manifest.json

アプリの定義情報です。アプリをインストールする際にこの情報が使用されます。
`start_url` は、 `index.html` からの相対にて、アプリ起動時に読み込むURLを指定します。
ハッシュも例えば `./#filename=untitled.md&open.d=eJwDAAAAAAE` のように指定できます。

```json:contents/manifest.json
{
  "short_name": "My Awesome App",
  "name": "My Awesome App",
  "icons": [
    {
      "src": "favicon.ico",
      "sizes": "256x256 152x152 144x144 128x128 96x96 72x72 64x64 48x48 32x32 24x24 16x16",
      "type": "image/x-icon"
    },
    {
      "src": "images/icons/icon-16x16.png",
      "sizes": "16x16",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-24x24.png",
      "sizes": "24x24",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-32x32.png",
      "sizes": "32x32",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-48x48.png",
      "sizes": "48x48",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-64x64.png",
      "sizes": "64x64",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "images/icons/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "theme_color": "#000000",
  "background_color": "#ffffff"
}
```


## contents/serviceWorker.js

`index.html`から呼び出す、 Service Worker 登録用のコードです。
`index.html` から `registerMyServiceWorker()` を呼び出すと登録、 `unregisterMyServiceWorker()` を呼び出すと登録解除されます。

> Note: このコードは、 [create-react-app](https://github.com/facebook/create-react-app)
> が自動生成するコードをもとに作成しました。

```javascript:contents/serviceWorker.js
// This optional code is used to register a service worker.
// register() is not called by default.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.

// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA

{

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    // [::1] is the IPv6 localhost address.
    window.location.hostname === '[::1]' ||
    // 127.0.0.1/8 is considered localhost for IPv4.
    window.location.hostname.match(
      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
    )
);

window.registerMyServiceWorker = function register(config) {
  if ('serviceWorker' in navigator) {
    // The URL constructor is available in all browsers that support SW.
    const publicUrl = new URL(
      '.',
      window.location.href
    );
    if (publicUrl.origin !== window.location.origin) {
      // Our service worker won't work if PUBLIC_URL is on a different origin
      // from what our page is served on. This might happen if a CDN is used to
      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
      return;
    }

    window.addEventListener('load', () => {
      const swUrl = `./service-worker.js`;

      if (isLocalhost) {
        // This is running on localhost. Let's check if a service worker still exists or not.
        checkValidServiceWorker(swUrl, config);

        // Add some additional logging to localhost, pointing developers to the
        // service worker/PWA documentation.
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit https://bit.ly/CRA-PWA'
          );
        });
      } else {
        // Is not localhost. Just register service worker
        registerValidSW(swUrl, config);
      }
    });
  }
}

function registerValidSW(swUrl, config) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // At this point, the updated precached content has been fetched,
              // but the previous service worker will still serve the older
              // content until all client tabs are closed.
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
              );

              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              // At this point, everything has been precached.
              // It's the perfect time to display a
              // "Content is cached for offline use." message.
              console.log('Content is cached for offline use.');

              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch(error => {
      console.error('Error during service worker registration:', error);
    });
}

function checkValidServiceWorker(swUrl, config) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl)
    .then(response => {
      // Ensure service worker exists, and that we really are getting a JS file.
      const contentType = response.headers.get('content-type');
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then(registration => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.'
      );
    });
}

window.unregisterMyServiceWorker = function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(registration => {
      registration.unregister();
    });
  }
}

}
```


## contents/service-worker.js

Service Workerの処理である、キャッシュ登録およびネットワーク要求の横取りを行います。
[workbox](https://developers.google.com/web/tools/workbox) というライブラリが実際の複雑で面倒な処理を担当します。

設定によって、キャッシュしないコンテンツ(blacklist)を指定することができます。

`contents/precache-manifest.?????????.js` というファイルから、 Service Worker 新規・更新登録時に、事前にキャッシュすべきコンテンツの情報を取得します。
`precache-manifest` の各ファイルエントリには、ファイルのハッシュが「バージョン」として記録されており、現在のキャッシュと「バージョン」が異なるファイルのみworkboxは再取得してキャッシュを更新します。
また、`precache-manifest`のファイル名自体も、自身の内容のハッシュから付けられており、
`contents/service-worker.js` でファイル名を直接記述することで、コンテンツ内の1ファイルでも変更がある場合に、Service Workerの再登録が実行されるようにします。

> ※ `contents/service-worker.js` が前回のブラウザが読んだものと異なっていないとService Workerの再登録は行われません。

> Note: このコードは、 [create-react-app](https://github.com/facebook/create-react-app)
> が自動生成するコードをもとに作成しました。

```javascript:contents/service-worker.js
/**
 * Welcome to your Workbox-powered service worker!
 *
 * You'll need to register this file in your web app and you should
 * disable HTTP caching for this file too.
 * See https://goo.gl/nhQhGp
 *
 * ~~The rest of the code is auto-generated. Please don't update this file~~
 * ~~directly; instead, make changes to your Workbox build configuration~~
 * ~~and re-run your build process.~~
 * ~~See https://goo.gl/2aRDsh ~~
 */

importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");

importScripts(
  `./precache-manifest.${'713b5d14422999b97425e1f1ac8d1623'}.js`
);

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

workbox.core.clientsClaim();

/**
 * The workboxSW.precacheAndRoute() method efficiently caches and responds to
 * requests for URLs in the manifest.
 * See https://goo.gl/S9QRab
 */
self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("./index.html"), {
  blacklist: [
    // TODO: ここで、キャッシュ対象外とするパスを正規表現で指定します
    /^\/_/,
    /\/(?!((error|embed|empty|online).html)|(serviceWorker).js)([^\/?]+\.[^\/]+)$/,
  ],
});
```


## scripts/make-precache-manifest.js (独自)

`contents/precache-manifest.?????????.js` を再生成するスクリプトです。
create-react-app がリリースビルドするときに生成する `precache-manifest` を自身で作成します。

```bash
node scripts/make-precache-manifest.js
```
として実行した後、 `contents/service-worker.js` が新しい `precache-manifest` を読むように修正します。

```javascript:scripts/make-precache-manifest.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

// TODO: ここで、事前キャッシュ対象外とするパスを正規表現で指定します
const blacklist = [
    /^\.\/icons\//,
    /^\.\/out\//,
    /^\.\/desktop-carlo\.html/,
    /^\.\/manifest\.json/,
    /^\.\/favicon\./,
    /^\.\/precache-manifest\./,
    /^\.\/service-worker\.js/,
];

function makePrecacheEntries(srcDir, options) {
    const opts = Object.assign({}, {
    }, options || {});

    if (fs.lstatSync(srcDir).isDirectory()) {
        const files = fs.readdirSync(srcDir);
        FILE_ENT: for (const entry of files) {
            const srcEntryPath = path.join(srcDir, entry);
            if (fs.lstatSync(srcEntryPath).isDirectory()) {
                makePrecacheEntries(srcEntryPath, options);
            } else {
                const entryUrl = srcEntryPath.replace(/\\/g, '/').replace(options.baseDir, '.');
                for (const pat of blacklist) {
                    if (entryUrl.match(pat)) {
                        continue FILE_ENT;
                    }
                }
                const s = fs.readFileSync(srcEntryPath, {encoding: 'utf8'});
                const hash = crypto.createHash('sha256');
                hash.update(s);
                options.entries.push({
                    revision: hash.digest('hex'),
                    url: entryUrl,
                });
            }
        }
    }
    return opts;
}


const v = makePrecacheEntries('./contents', {
    baseDir: 'contents',
    entries: [],
});

const precacheManifest = `
/* eslint-disable no-undef */
self.__precacheManifest = (self.__precacheManifest || []).concat(${
    JSON.stringify(v.entries, null, 2)
});
`;
const precacheManifestHash = (() => {
    const hash = crypto.createHash('md5');
    hash.update(precacheManifest);
    return hash.digest('hex');
})();

fs.writeFileSync(
    `./contents/precache-manifest.${precacheManifestHash}.js`,
    precacheManifest, {encoding: 'utf8'});
```


# さいごに

workboxとcreate-react-appのスキャフォールディングのおかげで本当に簡単にPWA化が実現できました。
新規開発のアプリでなくてもできますので、是非お試しください。