Vite PWA⚡ を使用して、Service Worker を TypeScript で書く

2024/12/25に公開

動機

Firebase Cloud Messaging(以下: FCM) を利用するにあたって、Service Worker の実装が必要になりました。React Router v7 をフレームワークとして採用していたこともあり、public ディレクトリ直下に Service Worker 用の js ファイルを配置すれば問題ないのですが、以下の要件をできれば満たしたいと思ったのがきっかけになります。

  1. Service Worker の実装も TypeScript で書きたい
    • 開発中は TypeScript のファイルでも、良い感じに Service Worker が動いて欲しい
    • Vite の build に乗っかる形で js ファイルにコンパイルして欲しい
  2. Service Worker でも ESM 形式で書きたい
    • 一部、React のライフタイムサイクル上で動くコードを再利用したい
    • ブラウザによっては import 文が対応していないので build 時に良い感じにして欲しい
  3. public ディレクトリ配下ではなく、他の実装コードと同じ app ディレクトリ配下に Service Worker 用のファイルを配置したい

Vite PWA ⚡

私の面倒くさがりな性格からくる要望を満たすための機能を提供してくれるのが、タイトルにある Vite PWA というプラグインでした!

https://vite-pwa-org.netlify.app/

名前の通り、Vite を使用しているアプリケーションに PWA を簡単に追加できるようにするためのプラグインなのですが、Service Worker のみの管理にも使用することができます🙆‍♂️

導入手順

フロント用のディレクトリ構成は以下のようになっていると仮定し、今回 Service Worker 用のファイルは app/lib/firebase/serviceWorker.ts に配置するとします。
また、Vite PWA はインストール済みとします。

├── app
│   ├── lib
│        ├── firebase
│             ├──serviceWorker.ts
│ 
├── tsconfig.json
├── vite.config.ts

vite.config.ts に Vite PWA をプラグインとして追加します。
設定内容は以下の内容を参考にしました。

https://vite-pwa-org.netlify.app/guide/service-worker-without-pwa-capabilities.html

vite.config.ts
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  ...
  plugins: [
    VitePWA({
      // Service Worker 用のファイルを自前で準備
      strategies: "injectManifest",
      // Service Worker として、登録したいファイルを設定
      srcDir: "app/lib/firebase",
      filename: "serviceWorker.ts",
      // Service Worker は手動で登録するので false にする
      injectRegister: false,
      // PWA は使用しないので、manifest は不要
      manifest: false,
      injectManifest: {
        injectionPoint: undefined,
      },
      // 開発環境で Service Worker が動くようにする
      // また、開発環境では ESM で動かす。
      devOptions: {
        enabled: true,
        type: "module",
      },
    })
  ]
})

次に、Service Worker 用の .ts ファイルで Worker に関する型定義が有効になるように設定します。
まず、tsconfig.json の lib 設定に WebWorker を追加します。

tsconfig.json
{
  ...,
  "compilerOptions": {
    "lib": [..., "WebWorker"],
  }
}

そして、Service Worker 用の .ts ファイルで以下のように globalThis をキャストすることで型定義が動くようになります。

app/lib/firebase/serviceWorker.ts
// ServiceWorkerGlobalScope でキャストする
const swSelf = globalThis as unknown as ServiceWorkerGlobalScope;

swSelf.addEventListener("install", (event) => {
  event.waitUntil(swSelf.skipWaiting());
});

次に Service Worker を登録する必要があるのですが、こちらは Web API を使用して登録を行います。
https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerContainer/register

scriptURL はデプロイ環境と開発環境で動的に切り替えます。
理由としては、Vite PWA が開発環境では /dev-sw.js?dev-sw に Service Worker 用のファイルをよしなに配置してくれます。そして、build を実行すると build ディレクトリ直下に <fileName>.js で吐き出してくれるためです。

また、type option ですが、開発環境では ESM 形式で JS ファイルを生成するようにしていますが、本番環境では Service Worker 内での import 文に対応していないブラウザ(例: firefox)を考慮して、Vite PWA が import 先の依存関係を解決して良い感じに 1 つの js ファイルにコンパイルしてくれます!!

navigator.serviceWorker.register(
  import.meta.env.MODE === "production" ? "./serviceWorker.js" : "./dev-sw.js?dev-sw",
  {
    type: import.meta.env.MODE === "production" ? "classic" : "module",
  }
)

上記のコードをブラウザ上で呼び出すと、下図のように Service Worker が期待通りに登録されていることを確認できると思います 🎉

その他

動機にもあったように Service Worker を導入するきっかけになったのは、FCM を利用するためでした。(registration token を取得する処理ですね!)

トークンの取得には getToken 関数を利用することになるのですが、第 2 引数に ServiceWorkerRegistration を設定することができるので、そちらに先程の ServiceWorkerContainer.register() の戻り値を設定することで期待通りに動くと思います!

// イメージ
import { getMessaging, isSupported, getToken, onMessage } from "firebase/messaging";
import { initializeApp } from "firebase/app";


const VAPID_KEY = "xxxxxx"

export const firebase = initializeApp({...});

export async function getFCMToken(): Promise<string | null> {
  const supported = await isSupported();
  if (!supported) {
    return null;
  }

  try {
    const worker = await navigator.serviceWorker.register(
      import.meta.env.MODE === "production" ? "./serviceWorker.js" : "./dev-sw.js?dev-sw",
      {
        type: import.meta.env.MODE === "production" ? "classic" : "module",
      },
    );
    const token = await getToken(getMessaging(firebase), {
      vapidKey: VAPID_KEY,
      // 設定することで、firebase-messaging-sw.js ではなく、任意のファイルを登録することができます🙆‍♂️
      serviceWorkerRegistration: worker,
    });

    return token;
  } catch (e) {
    console.error("Failed to get FCM token:", e);
    return null;
  }
}

Discussion