Service Workers / PWA(@vite-pwa/sveltekit 実装)

SvelteKit には素の $service-worker モジュールによる Service Worker サポートが組み込まれていますが、Workbox の高度なキャッシュ戦略・更新通知 UX・manifest 管理を統合したい場合は @vite-pwa/sveltekit プラグインの利用が現実的な選択肢になります。

本記事では @vite-pwa/sveltekit 1.1+ を軸に、本プロジェクト(shuji-bonji/Svelte-and-SvelteKit-with-TypeScript)の実装をケーススタディとして引用しながら、SvelteKit における PWA 構築のベストプラクティスを TypeScript で解説します。

この記事で学べること

  • @vite-pwa/sveltekit 1.1+ の全体像と素の $service-worker との違い
  • registerType: 'prompt' による更新通知 UX 設計
  • virtual:pwa-register を Svelte 5 Runes と組み合わせた hook
  • manifestTransformskit.trailingSlash: 'always' の整合
  • globPatterns で全 prerender 済み HTML を precache する戦略
  • dev サーバでの SW 有効化と HMR との干渉回避
  • clientsClaim: false / skipWaiting: false の UX 判断
  • manifest / アイコン生成のワークフロー

アーキテクチャ全体像

@vite-pwa/sveltekit は内部で vite-plugin-pwa(Workbox ベース)を SvelteKit 向けにラップしたプラグインです。ビルド時に manifestTransforms で SvelteKit 固有の URL を整形し、Workbox の generateSW モードで Service Worker を生成します。

@vite-pwa/sveltekit と素の $service-worker の違い

SvelteKit には標準で src/service-worker.ts をバンドルする仕組みが用意されています。シンプルな precache であれば素の $service-worker で十分ですが、以下の要件が出てきたら @vite-pwa/sveltekit が有利になります。

機能素の $service-worker@vite-pwa/sveltekit
precache の生成build + files を手で配列に詰めるWorkbox の precache manifest を自動生成
更新検出 UX自前で updatefound を実装virtual:pwa-register + workbox-window が提供
Web App Manifeststatic/manifest.json を手書きプラグイン設定オブジェクトから生成
ランタイムキャッシュ戦略fetch イベントで自前実装runtimeCaching の宣言的設定
trailingSlash 整合手動manifestTransforms で自動
dev サーバでの SW手動登録が必要devOptions.enabled 一発で有効化
アイコン生成自前@vite-pwa/assets-generator 連携
ハイブリッドは推奨されない

@vite-pwa/sveltekit を導入すると src/service-worker.ts使われなくなる ことに注意してください。Workbox が生成する SW が dist/sw.js として配置されるため、両者を同時に有効化することは想定されていません。

基本セットアップ

インストール

npm install -D @vite-pwa/sveltekit workbox-window
# アイコン自動生成を使う場合
npm install -D @vite-pwa/assets-generator

vite.config.js

本プロジェクトの vite.config.js から、最小限の構成を抜粋します(全文は次節以降で順番に解説)。

// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { defineConfig } from 'vite';

const base = process.env.BASE_PATH ?? '';

export default defineConfig({
  plugins: [
    sveltekit(),
    SvelteKitPWA({
      strategies: 'generateSW',     // Workbox の generateSW モード
      scope: `${base}/`,            // 静的サイト出力に合わせて base 配下に固定
      base: `${base}/`,
      registerType: 'prompt',       // 新版検知時にユーザーへ確認
      injectRegister: false,        // 登録は手動制御(後述)
      manifest: {
        /* ... */
      },
      workbox: {
        /* ... */
      },
      devOptions: {
        enabled: false              // dev では SW 無効(HMR 干渉回避)
      },
      kit: {
        trailingSlash: 'always'     // SvelteKit 側の trailingSlash と整合
      }
    })
  ]
});
`strategies` の選択
  • 'generateSW': Workbox が SW を一括生成(本プロジェクトはこちら)
  • 'injectManifest': 自前の SW ソースに precache manifest だけ注入。navigation などを細かく書きたい場合に有効

シンプルさを最優先するなら 'generateSW' で十分です。

registerType: ‘prompt’ vs ‘autoUpdate’

registerType は新版 SW を どう適用するか を決める最重要オプションです。

挙動向く用途
'autoUpdate'新版検出時、即座に新 SW を skipWaiting() させ自動リロード管理画面など、ユーザーへの中断を許容できるアプリ
'prompt'新版検出時、ユーザーに「更新しますか?」と確認記事・ブログ・ドキュメントなど読み中の状態を尊重したい用途

本プロジェクトは学習ガイドなので、読み途中でリロードされない 'prompt' を採用しています。

SvelteKitPWA({
  registerType: 'prompt',
  injectRegister: false, // 登録 UI を自前で描画するため、自動インジェクトは無効
  workbox: {
    clientsClaim: false,  // 既存タブを自動奪取しない
    skipWaiting: false    // 自動でアクティベートしない
  }
});
`registerType` と `skipWaiting` の関係

registerType: 'autoUpdate' でも、内部的には skipWaiting() を呼ぶことで即時切り替えを実現しています。逆に 'prompt' にしたうえで skipWaiting: true にしてしまうと、確認 UI を出している最中に SW が切り替わって挙動が破綻します。'prompt' のときは clientsClaim: false / skipWaiting: false を必ずセット にしてください。

virtual:pwa-register / workbox-window 連携

@vite-pwa/sveltekitvirtual:pwa-register という仮想モジュールを提供し、Workbox の registerSW 関数を呼び出せます。本プロジェクトの PwaUpdatePrompt.svelte は、これを Svelte 5 の Runes と組み合わせて更新通知トーストを実装しています。

型定義

仮想モジュールは svelte-check から見えないため、src/app.d.ts で ambient 宣言します。

// src/app.d.ts
declare module 'virtual:pwa-register' {
  import type { RegisterSWOptions } from 'vite-plugin-pwa/types';
  export type { RegisterSWOptions };
  export function registerSW(
    options?: RegisterSWOptions
  ): (reloadPage?: boolean) => Promise<void>;
}

declare module 'virtual:pwa-info' {
  export interface PwaInfo {
    webManifest: {
      href: string;
      useCredentials: boolean;
      linkTag: string;
    };
    registerSW?: {
      inline?: boolean;
      mode?: 'registerSW' | 'auto';
      scope?: string;
      type?: 'classic' | 'module';
      script?: string;
    };
  }
  export const pwaInfo: PwaInfo | undefined;
}

Svelte 5 Runes による更新通知コンポーネント

本プロジェクトの src/lib/components/PwaUpdatePrompt.svelte から、コア部分を抜粋します。

<script lang="ts">
  import { onMount } from 'svelte';

  // SW 登録状態(リアクティブ)
  let needRefresh = $state(false);
  let offlineReady = $state(false);
  let updateSW: ((reloadPage?: boolean) => Promise<void>) | undefined = $state();

  onMount(async () => {
    // SSR 時には実行しない(onMount はクライアントのみ)
    if (typeof window === 'undefined') return;

    try {
      // virtual:pwa-register は vite-plugin-pwa が提供する仮想モジュール
      const mod = await import('virtual:pwa-register');

      updateSW = mod.registerSW({
        immediate: true,
        onNeedRefresh() {
          // 新版が waiting 状態になったタイミングで呼ばれる
          needRefresh = true;
        },
        onOfflineReady() {
          // 初回 install が完了し、オフライン閲覧可能になったタイミング
          offlineReady = true;
          setTimeout(() => {
            offlineReady = false;
          }, 5000);
        },
        onRegisteredSW(_url, registration) {
          // 1 時間ごとに更新チェック(ポーリング)
          if (registration) {
            setInterval(
              () => {
                void registration.update();
              },
              60 * 60 * 1000
            );
          }
        }
      });
    } catch (err) {
      // dev 環境や SW 未対応ブラウザでは静かに失敗させる
      console.warn('[PWA] SW registration skipped:', err);
    }
  });

  function handleReload() {
    needRefresh = false;
    // updateSW(true) で skipWaiting + reload が走る
    void updateSW?.(true);
  }
</script>

{#if needRefresh}
  <div class="pwa-toast" role="alert" aria-live="polite">
    <strong>新しいバージョンがあります</strong>
    <button onclick={handleReload}>更新</button>
    <button onclick={() => (needRefresh = false)}>後で</button>
  </div>
{/if}

{#if offlineReady}
  <div class="pwa-toast" role="status" aria-live="polite">
    <strong>オフライン対応の準備ができました</strong>
  </div>
{/if}
`updateSW(true)` の意味

registerSW が返す関数を updateSW(true) のように呼ぶと、内部で skipWaiting() メッセージを SW に送ったあと、controllerchange を待ってページをリロードします。これにより ユーザーが「更新」を押した瞬間に確実に新版へ切り替わる ことが保証されます。

レイアウトでの呼び出し

src/routes/+layout.svelte でコンポーネントを配置するだけです。

<script lang="ts">
  import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
  import type { Snippet } from 'svelte';

  let { children }: { children: Snippet } = $props();
</script>

{@render children()}

<PwaUpdatePrompt />

manifestTransforms と trailingSlash 整合

本プロジェクトは svelte.config.jspaths.relative: false を使い、また URL の末尾には常にスラッシュを付ける運用です(adapter-static + prerender.entries: ['*'] で全ページ事前生成)。

@vite-pwa/sveltekitkit.trailingSlash: 'always' を指定すると、Workbox の manifestTransforms を自動挿入して precache 対象の URL に末尾スラッシュを付与します。

SvelteKitPWA({
  /* ... */
  kit: {
    // SvelteKit 側の trailingSlash 設定と揃える
    // 'always' を指定すると prerendered HTML の URL が `/foo/` 形式に変換される
    trailingSlash: 'always'
  }
});
`navigateFallback` を明示的に書かない

プラグインは自動で navigateFallback${base}/ (= manifestTransforms 後の root index.html URL)に設定します。workbox.navigateFallback を独自に書き換えると、precache manifest と URL がズレて Workbox の non-precached-url エラー が発生しやすくなります。デフォルトに任せましょう。

globPatterns 戦略:全 prerender HTML を precache

本プロジェクトでは記事ページのリロード時に「トップに戻ってしまう」現象を防ぐため、すべての prerender 済み HTML を precache する戦略を採用しています。

workbox: {
  globPatterns: [
    'client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}',
    'prerendered/**/*.html'
  ],
  // 記事 HTML が大きめになるため上限を引き上げ
  maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
  globIgnores: ['**/sw*', '**/workbox-*', 'server/**'],
  navigateFallbackDenylist: [
    /^/api//,
    /sw.js$/,
    /sitemap.xml$/
  ]
}
なぜ全 HTML を precache するのか

@vite-pwa/sveltekit のデフォルト挙動では、ナビゲーションリクエストはすべて navigateFallback(= root index.html)にフォールバックします。SvelteKit のクライアントルータは URL を見てハイドレーションするため理屈の上では動きますが、リロード時にハイドレーションがトップページの状態から始まるため UX が破綻します。

各 URL ごとに専用の HTML を precache しておくと、リロード時もそのページの HTML が直接返り、ハイドレーションも正しい位置から始まります。サイズは増えますが、静的サイトであれば許容範囲です。

runtimeCaching の例

precache に乗らない動的リソース(外部画像、Google Fonts など)は runtimeCaching でカバーします。

runtimeCaching: [
  {
    // 記事ページ HTML: ネットワーク優先、失敗時はキャッシュ
    urlPattern: ({ request, url }) =>
      request.destination === 'document' &&
      url.origin === self.location.origin,
    handler: 'NetworkFirst',
    options: {
      cacheName: 'pages-cache',
      expiration: {
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 24 * 30 // 30 日
      },
      networkTimeoutSeconds: 3
    }
  },
  {
    // 同一オリジンの画像
    urlPattern: ({ request, url }) =>
      request.destination === 'image' &&
      url.origin === self.location.origin,
    handler: 'StaleWhileRevalidate',
    options: {
      cacheName: 'images-cache',
      expiration: {
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 * 24 * 60
      }
    }
  },
  {
    // Google Fonts
    urlPattern: /^https://fonts.(?:googleapis|gstatic).com/.*/i,
    handler: 'CacheFirst',
    options: {
      cacheName: 'fonts-cache',
      expiration: {
        maxEntries: 30,
        maxAgeSeconds: 60 * 60 * 24 * 365
      }
    }
  }
]
handler戦略主な用途
NetworkFirstネットワーク優先、失敗時キャッシュ動的 HTML、API レスポンス
CacheFirstキャッシュ優先フォント、不変な静的アセット
StaleWhileRevalidateキャッシュ即返し + 裏で更新画像、滅多に変わらない記事サムネ
NetworkOnly常にネットワーク認証 API
CacheOnly常にキャッシュ完全オフライン専用リソース

dev サーバでの SW 有効化と HMR 干渉

devOptions.enabled: true を指定すると、vite dev 中にも Service Worker が動作します。

devOptions: {
  enabled: true,
  type: 'module',
  navigateFallback: '/'
}

ただし、HMR と Service Worker は キャッシュレイヤーが衝突する ため、開発体験を著しく損なうケースがあります。

本プロジェクトでは dev では SW を無効化

本プロジェクトは devOptions.enabled: false にしています。理由は次のとおりです。

  1. HMR で更新された JS/CSS が古いキャッシュから返されると、変更が反映されない
  2. vite devprerendered/**/*.html を生成しないため、precache manifest が空になり挙動が異なる
  3. デバッグのたびに DevTools の「Application > Service Workers > Unregister」が必要になる

PWA の最終確認は npm run build && npm run preview で行うのが現実的です。

dev で SW を有効化する場合の対策

どうしても dev で動かす場合は、次の対策が有効です。

  • DevTools の「Application > Service Workers > Update on reload」を有効にする
  • 「Bypass for network」をオンにして SW を一時的に迂回
  • vite dev 起動時に毎回 Unregister するスクリプトを用意

clientsClaim / skipWaiting の UX 設計

これらは Workbox の SW に渡るオプションで、「新版 SW がいつ既存タブを支配するか」を制御します。

オプションデフォルト意味
skipWaitingfalsetrue だと新版 SW が install 完了と同時にアクティベート(waiting フェーズをスキップ)
clientsClaimfalsetrue だと新版 SW がアクティベート直後に 既存タブ を奪う(Clients.claim()

自動更新(managed app など)

ユーザー操作を待たずに最新版に揃えたい場合。

SvelteKitPWA({
  registerType: 'autoUpdate',
  workbox: {
    clientsClaim: true,
    skipWaiting: true
  }
});

ユーザー確認型(本プロジェクト採用)

読み中の状態を尊重し、明示的な「更新」操作を待つ。

SvelteKitPWA({
  registerType: 'prompt',
  workbox: {
    clientsClaim: false,
    skipWaiting: false
  }
});
ハイブリッド方針

「初回インストール直後は自動で完了させたいが、その後の更新は確認を取りたい」というケースでは、registerSWonOfflineReady で初回フラグを記憶する設計が有効です。Workbox 側は clientsClaim: false / skipWaiting: false のまま、updateSW(true) 呼び出し条件を JS 側で制御します。

Web App Manifest

manifest オプションに JSON 相当のオブジェクトを渡すと、ビルド時に manifest.webmanifest が自動生成されます。

manifest: {
  name: 'TypeScriptで学ぶ Svelte 5/SvelteKit 完全マスター学習ガイド',
  short_name: 'Svelte & Kit Guide',
  description:
    'TypeScript による Svelte 5 / SvelteKit の日本語完全学習ガイド',
  lang: 'ja',
  dir: 'ltr',
  start_url: `${base}/`,
  scope: `${base}/`,
  display: 'standalone',
  orientation: 'portrait-primary',
  theme_color: '#ff3e00',
  background_color: '#1a1a1a',
  categories: ['education', 'developer', 'books'],
  icons: [
    {
      src: `${base}/icon-192.png`,
      sizes: '192x192',
      type: 'image/png',
      purpose: 'any'
    },
    {
      src: `${base}/icon-512.png`,
      sizes: '512x512',
      type: 'image/png',
      purpose: 'any'
    },
    {
      src: `${base}/icon-maskable-512.png`,
      sizes: '512x512',
      type: 'image/png',
      purpose: 'maskable'
    }
  ]
}

app.html のメタタグ

manifest.webmanifest への参照と、iOS Safari 向けのメタタグは src/app.html で明示します(本プロジェクトの実装)。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <!-- Favicons -->
    <link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="any" />
    <link rel="apple-touch-icon" sizes="180x180"
          href="%sveltekit.assets%/apple-touch-icon.png" />

    <!-- PWA -->
    <link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
    <meta name="theme-color" content="#ff3e00" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
    <meta name="apple-mobile-web-app-title" content="Svelte & Kit Guide" />
    <meta name="application-name" content="Svelte & Kit Guide" />

    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>
`pwaAssets.disabled: true` の意味

本プロジェクトは pwaAssets: { disabled: true } を指定し、static/ に手動配置したアイコンを使用しています。@vite-pwa/assets-generator で生成済みのファイルがあるため、ビルド時の再生成は不要です。

アイコン生成のワークフロー(@vite-pwa/assets-generator)

192 / 512 / maskable / Apple Touch Icon を 1 枚の SVG から一括生成できます。

インストールと設定

npm install -D @vite-pwa/assets-generator

プロジェクト直下に pwa-assets.config.ts を置きます。

// pwa-assets.config.ts
import {
  defineConfig,
  minimal2023Preset as preset
} from '@vite-pwa/assets-generator/config';

export default defineConfig({
  headLinkOptions: {
    preset: '2023'
  },
  preset,
  images: ['static/logo.svg']
});

生成コマンド

npx pwa-assets-generator

これで以下が static/ 配下に出力されます。

  • favicon.icofavicon-16.pngfavicon-32.png
  • apple-touch-icon.png
  • icon-192.pngicon-512.png
  • icon-maskable-192.pngicon-maskable-512.png
CI への組み込み

ロゴ SVG を更新したときだけ走らせれば十分なので、npm run スクリプトに "pwa:assets": "pwa-assets-generator" を追加し、デザイン変更時に手動実行する運用がシンプルです。本プロジェクトもこの方針です。

トラブルシューティング

non-precached-url エラーが出る

precache manifest に含まれていない URL が navigateFallback に指定されています。navigateFallback を明示的に書かず、プラグインのデフォルトに任せてください。

リロードでトップに戻る

globPatternsprerendered/**/*.html が含まれていないか、manifestTransforms の trailingSlash 設定がずれています。本記事の戦略どおりに設定すれば解消します。

新版が反映されない

registerType: 'prompt' 採用時は 必ず updateSW(true) を呼ぶこと。引数なしの updateSW() だと skipWaiting() が走らず、既存タブを閉じるまで反映されません。

dev で SW が消えない

DevTools「Application > Service Workers > Unregister」で明示的に解除するか、シークレットウィンドウで開発するのが確実です。

まとめ

@vite-pwa/sveltekit を採用すると、SvelteKit の prerender 出力・base path・trailingSlash と Workbox の precache manifest を自動で整合させながら、Svelte 5 Runes 流の更新通知 UI を組めます。

  • 素の $service-worker で足りるならそれが最もシンプル
  • 高度な戦略・更新 UX が欲しいなら @vite-pwa/sveltekit
  • 「読み途中で勝手にリロードしない」UX を作るなら registerType: 'prompt' + clientsClaim: false
  • precache 戦略は prerendered/**/*.html を全部入りにするのが SvelteKit と相性が良い

本プロジェクトのソースコード(vite.config.js / PwaUpdatePrompt.svelte / app.d.ts / app.html)も併せて参照してください。

次のステップ

参考リンク