TypeScriptでの`Service Worker`

TypeScriptでの`Service Worker`

この記事ではTypeScriptでのService Workerについて説明します。

TypeScriptでのService Workerについて実際的なサンプルを含めて解説します。

YouTube Video

TypeScriptでのService Worker

Service Workerは、ブラウザとネットワークの間に位置する“リクエストプロキシ”です。Fetchの横取り、キャッシュ制御、オフライン動作、バックグラウンド処理(同期・プッシュ)を可能にします。TypeScriptで書くと型安全になり、保守性が上がります。

TypeScriptの準備

tsconfig.json(WebWorker型を有効化)

tsconfig.jsonで、WebWorker型を有効化した場合の例を見てみましょう。

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • libWebWorkerを入れることで、ServiceWorkerGlobalScopeなどの型が使えます。
  • DOMWebWorker は型が異なるため、ブラウザ側(アプリ本体)向けと Service Worker 用で、tsconfig.json の設定は分けるのが一般的です。
  • Service Workerファイルは最終的にスコープに合わせたパス(多くはサイトルート /sw.js)へ出力します。
  • セキュリティ上、Service Worker は HTTPS(もしくは localhost)でのみ動作します。

ブラウザ側の登録コード

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • この処理では、Service Workerを登録しています。scopeService Workerが制御できるパスの範囲 です。例えば /sw.js をルート直下に置き、scope に ルートディレクトリ(/) を指定すると、サイト全体のリソースを制御対象にできます。一方で、/app/ のように特定のディレクトリを指定すると、そのディレクトリ配下だけが対象となります。
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting新しい Service Worker がインストール済みで、アクティブ化を待っている状態 を示します。この段階では既存ページはまだ古い Service Worker によって制御されているため、ユーザに更新確認を行い、了承後に skipWaiting() を呼び出して即座に新しい Service Worker を有効化するのが一般的な流れです。これにより、次回のページリロードを待たずに最新の処理を反映できます。
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound新しい Service Worker のインストールが開始されたタイミングで発火します。このイベントが発生したら registration.installing に新しいワーカーがセットされるため、その statechange を監視することで「インストール完了 (installed)」を検知できます。さらに navigator.serviceWorker.controller が存在していれば、既に古い Service Worker が制御中であることを意味するため、この時点でユーザに新しいバージョンの存在を通知するきっかけにできます。
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • controllerchange イベントは、新しい Service Worker が現在のページを制御し始めたタイミングで発火します。この時点でリロードを行えば、新しいキャッシュ戦略や処理内容をすぐに反映できます。ただし、自動リロードはユーザ体験を損ねる場合があるため、ユーザ同意を得てからリロードする方が望ましいです。
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • postMessage({ type: 'SKIP_WAITING' }) を送るチェーンを Service Worker 側で受け取り self.skipWaiting() を実行することで、更新を促せます。

sw.tsでのスコープ宣言

続いて、典型的な「アプリシェル」キャッシュを行う Service Worker の例を見てみましょう。

Service Workerを利用する際、TypeScriptではselfに正しい型を与えると便利です。

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • TypeScriptではselfがデフォルトでany扱いになるため、そのままだとskipWaiting()clientsなどService Worker専用APIで型補完・型チェックが効きません。
  • ServiceWorkerGlobalScopeを指定することで、補完が有効になり誤用を防げるうえ、通常のDOMスクリプトと区別して安全に開発できるようになります。

基本のService Worker(インストール/有効化/フェッチ)

シンプルなキャッシュバージョン管理、インストールでのプリキャッシュ、アクティベートでの古いキャッシュ削除、フェッチでのキャッシュ戦略(静的アセットは cache-first、API は network-first)を示します。

sw.ts(最小構成+キャッシュ雛形)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • install イベントでは、アプリの**静的リソース(App Shell)**を事前にキャッシュします。また、self.skipWaiting() を呼び出すことで、新しい Service Worker がすぐにアクティブ化され、次回のアクセスを待たずに最新キャッシュが利用可能になります。
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • activate イベントでは、古いバージョンのキャッシュを削除し、Service Worker を最新状態に保ちます。さらに、self.clients.claim() を呼び出すことで、ページの再読み込みを待たずに、すべてのクライアントをこの新しい Service Worker が制御できるようになります。
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • fetchではリクエストを横取りしてレスポンスを制御できます。キャッシュ優先やネットワーク優先など戦略を実装し、オフライン対応や高速化に活用します。
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • SKIP_WAITING を受け取った場合、self.skipWaiting() を呼び出すことで、待機中の Service Worker を即座にアクティブ化できます。これにより、ページをリロードしなくても次のリクエストから新しいバージョンが適用されるようになります。

実用的なキャッシュ戦略の整理

cache-first

cache-first は、まずキャッシュを確認し、あれば即座に返します。なければネットワークから取得してキャッシュに保存します。静的ファイルに向いています。

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • このコードはキャッシュを最優先する場合の実装例です。キャッシュが存在すればそれを返し、なければネットワークから取得してキャッシュに保存します。画像やCSSなど変更頻度の少ない静的リソースに適しています。

network-first

network-first は、まずネットワークを試み、失敗時にキャッシュを返します。最新性が重要なAPIに適しています。

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • このコードはネットワークを最優先する場合の実装例です。ネットワーク応答が得られればキャッシュに保存し、失敗した場合はキャッシュを返します。ニュース記事やAPIレスポンスなど最新性が重視されるリソースに適しています。

stale-while-revalidate

stale-while-revalidate は、まずキャッシュを返し、同時にバックグラウンドでネットワークから更新します。応答速度と更新性を両立できます。

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • このコードはキャッシュがあれば即座に返しつつ、裏でネットワークから最新データを取得してキャッシュを更新します。ユーザーには高速な応答を提供しながら、次回アクセス時には更新済みの内容を利用できるため、UIや軽量データの配信に適しています。

更新フローの最適化(更新通知と安全なリロード)

Service Worker の更新は即時ではなく、既存のタブが閉じられるまで新しいバージョンは待機状態になります。

ここでは、新バージョンの準備完了をクライアントに通知し、ユーザ操作によって安全にリロードする仕組みを実装します。

Service Worker側で新バージョンの準備完了を通知

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • このコードでは、activate イベントの最後で notifyClientsUpdated を呼び出し、接続中のすべてのクライアントに「新バージョン準備完了」を通知します。clients.claim()現在開いているページ(クライアント)を、新しくアクティブになった Service Worker で即座に制御する メソッドです。通常、Service Worker次回ページロード時に初めて制御を開始しますが、clients.claim() を使うとページをリロードしなくても即時に制御可能です。

クライアント側で更新UIを出し、ユーザ操作でリロード

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • クライアントは message イベントで SW_UPDATED を受け取り、UI 上に更新通知を表示します。ユーザがリロードを選択すると window.location.reload() が実行され、ページ内の古いHTMLやCSSなどを最新バージョンに更新できます。これにより、clients.claim() で切り替えられた Service Worker のキャッシュや制御内容が、ページ全体に反映されます。

オフラインフォールバック

重要なナビゲーションには/offline.htmlを用意し、画像やフォントがなくても意味が伝わる最小UIにします。API に失敗した場合は、可能であれば キャッシュされた最後の状態 を表示し、同時にバックグラウンドで再取得を試みると UX が向上します。

実装例

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • install イベントで /offline.html をプリキャッシュし、ネットワークが落ちても最低限のページを返せるように準備します。
  • fetch イベントでは、request.mode === 'navigate' でナビゲーションリクエストを監視し、ページ遷移だけを対象にできます。
  • ネットワーク失敗時にフォールバックし、オフライン時でも /offline.html を表示します。

クライアントとService Worker間のメッセージング

Service Worker はページのライフサイクルから独立して動作するため、状態の通知やコマンドの実行には 双方向メッセージング が重要になります。メッセージに型を指定することで、誤ったメッセージ送信を防ぎ、補完も効くので実装が堅牢になります。

コード例

  1. メッセージ型の定義
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient は、Service Worker からクライアントに送るメッセージの型です。
  • ClientToSw は、クライアントから Service Worker に送るメッセージの型です。
  • これにより双方向通信でやり取りできるイベントの種類を明確化できます。
  1. Service Worker 側の処理
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • クライアントからのメッセージを受け取り、型で分岐処理しています。
  • CLEAR_CACHEの場合、キャッシュを削除してから、全クライアントに CACHE_CLEARED を通知します。
  • PINGの場合、元のクライアントにタイムスタンプ付きで PING を返信します。
  1. Service Worker から全クライアントへ通知
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • clients.matchAll を使って全てのウィンドウタブを取得します。
  • それぞれに postMessage を送ることでブロードキャストが可能です。
  • 更新通知 (SW_UPDATED など) やエラー通知に使えます。
  1. クライアント側の処理
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • クライアントから PING を送信し、Service Worker 側からの応答を受け取ることで、双方向通信が正しく動作しているかを確認できます。これにより、接続状態やメッセージハンドリングのテストが容易になります。
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

型付きメッセージングの利点

  • 型付きメッセージを導入すると、どのメッセージが送受信できるか明確になり、補完や型チェックが効いて安全です。
  • postMessage によって 1対1broadcast によって 1対多 の通信が可能です。
  • 更新通知 (SW_UPDATED)、キャッシュ管理 (CACHE_CLEARED)、ヘルスチェック (PING) など、運用に不可欠な仕組みをシンプルに実現できます。

まとめ

  • TypeScript を利用することで、Service Worker の API 呼び出しやメッセージングが型安全になり、開発効率と保守性が大幅に向上します。
  • installactivatefetch といったライフサイクルを正しく理解し、cache-firstnetwork-first などのキャッシュ戦略をシーンに応じて選択することが、快適なユーザー体験につながります。
  • 運用面では、キャッシュのバージョン管理や更新フロー(updatefoundwaitingSKIP_WAITING など)の理解が欠かせません。
  • クライアントと Service Worker 間の通信には型付きメッセージングを導入することで、誤実装を防ぎ、長期的に見ても拡張・保守しやすい仕組みを構築できます。

YouTubeチャンネルでは、Visual Studio Codeを用いて上記の記事を見ながら確認できます。 ぜひYouTubeチャンネルもご覧ください。

YouTube Video