0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vite PWAでFirebase Cloud Messagingを使うのだ

Posted at

前回Vite PWAでPush通知を試すのだでVitePWAつかって簡単にPWAアプリをつくれて、サービスワーカーも簡単に実装できることがわかった。もちろん、Push通知もできる。

実際のPush通知にあたりセグメンテーションしたりサブスク情報を管理するのが大変なので、Firebase Cloud Messaging(FCM)を使うことになりそう、どういうことでFCMを試してみた🐦

先に成果物

こんなのつくった。

https://github.com/11bluetree/vite-pwa-fcm-app

スクリーンショット 2025-08-31 114403.png

スクリーンショット 2025-08-31 114419.png

スクリーンショット 2025-08-31 114428.png

スクリーンショット 2025-08-31 114438.png

やってみる

前回https://github.com/11bluetree/vite-pwa-push-notice-appで作ったアプリをベースにFCMに切り替えた。

また開発環境も前回同様Windows VS Code Dev Container(Node.js)を使用した。

Firebaseのインストール

firebaseのライブラリをインストールする。

npm i firebase

Firebaseプロジェクトの作成

ライブラリ入れても通知できないまずはFirebaseのプロジェクトを作成する。

「Firebaseプロジェクトを設定して開始」をクリック。

スクリーンショット 2025-08-29 163543.png

testプロジェクトを作成した。Googleアナリティクスは今のところ不要なのでoffにした。

スクリーンショット 2025-08-29 163556.png

スクリーンショット 2025-08-29 163607.png

スクリーンショット 2025-08-29 163614.png

スクリーンショット 2025-08-29 163623.png

スクリーンショット 2025-08-29 163641.png

FCMの設定

続いてMessagingサービスを選択して設定する。
今回はWebアプリなので「ウェブ」を選択する。

スクリーンショット 2025-08-29 171159.png

アプリ名は「test-app」とした。

スクリーンショット 2025-08-29 171224.png

作成したら、初期化コードが表示されるのでそれを利用する。

スクリーンショット 2025-08-29 171253.png

Firebaseの初期化

環境変数から読み込むようにした。値については先ほどの初期化コードで確認できる。

import { initializeApp } from "firebase/app";
import { getMessaging } from "firebase/messaging";

const firebaseConfig = {
  apiKey: import.meta.env.VITE_API_KEY,
  authDomain: import.meta.env.VITE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_APP_ID
};

const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

FCM Service Workerの作成

前回はVAPIDキーを使ったPush通知だったが、今回はFCMを使う。
FCMではFirebaseが提供する仕組みを使うので、Service WorkerでもFirebase SDKを使う必要がある。

src/sw-fcm-push.tsをFCM対応で作成する。

import { precacheAndRoute } from 'workbox-precaching'

interface ExtendedServiceWorkerGlobalScope extends ServiceWorkerGlobalScope {
  addEventListener: (type: string, listener: (event: Event) => void) => void
  registration: ServiceWorkerRegistration
  clients: {
    openWindow: (url: string) => Promise<unknown>
  }
}

declare let self: ExtendedServiceWorkerGlobalScope

// Workbox のプリキャッシュを設定
precacheAndRoute(self.__WB_MANIFEST)

// Firebase App と Messaging をインポート
import { initializeApp } from 'firebase/app'
import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw'
import getFirebaseConfig from './firebase-config-shared'

// Firebase 設定(環境変数から取得)
const firebaseConfig = getFirebaseConfig()

// Firebase を初期化
const app = initializeApp(firebaseConfig)
const messaging = getMessaging(app)

// FCM バックグラウンド メッセージの処理
onBackgroundMessage(messaging, (payload) => {
  console.log('[sw-fcm-push.ts] Received background message ', payload)
  
  // 通知の内容をカスタマイズ
  const notificationTitle = payload.notification?.title || 'FCM Background Message'
  const notificationOptions = {
    body: payload.notification?.body || 'FCM Background Message body.',
    icon: payload.notification?.icon || '/favicon.svg',
    badge: '/favicon.svg',
    data: {
      ...payload.data,
      fcm_message_id: payload.messageId,
      dateOfArrival: Date.now(),
      primaryKey: 1
    },
    actions: [
      {
        action: 'open',
        title: '開く',
        icon: '/favicon.svg'
      },
      {
        action: 'close', 
        title: '閉じる',
        icon: '/favicon.svg'
      }
    ],
    vibrate: [100, 50, 100]
  }

  self.registration.showNotification(notificationTitle, notificationOptions)
})

// 通知クリックイベントのリスナー
self.addEventListener('notificationclick', (event: Event) => {
  const notificationEvent = event as unknown as {
    notification: {
      close: () => void
      data?: Record<string, unknown>
    }
    action?: string
    waitUntil: (promise: Promise<void>) => void
  }

  console.log('[sw-fcm-push.ts] Notification click received.', notificationEvent)

  notificationEvent.notification.close()

  if (notificationEvent.action === 'open') {
    // アプリを開く
    notificationEvent.waitUntil(
      self.clients.openWindow('/').then(() => undefined)
    )
  } else if (notificationEvent.action === 'close') {
    // 通知を閉じるだけ
    console.log('Notification closed by user action.')
  } else {
    // デフォルトのクリック動作 - アプリを開く
    notificationEvent.waitUntil(
      self.clients.openWindow('/').then(() => undefined)
    )
  }
})

ポイントは以下:

  • Firebase SDK for Service Workers: firebase/messaging/swからonBackgroundMessageをインポート
  • バックグラウンド通知: アプリがバックグラウンドにあるときのメッセージ処理
  • 通知のカスタマイズ: タイトル、本文、アイコン、アクションボタンを設定
  • Workbox統合: precacheAndRouteでキャッシュ管理も継続

FCMとVAPIDの違いとして、FCMはFirebaseのインフラを使うため、VAPIDキーの管理やメッセージの配信をFirebaseが代行してくれる。

vite.config.tsでService Workerのファイル名を指定することも忘れずに📝

export default defineConfig({
  plugins: [react(), VitePWA({
    strategies: 'injectManifest',
    srcDir: 'src',
    filename: 'sw-fcm-push.ts', // FCM対応のSWファイル名
    // ... 他の設定
  })],
})

通知コンポーネントの作成

FCM用の通知コンポーネントを作成する。VAPIDキーの代わりにFCMトークンを管理する。

まずsrc/services/fcm-service.tsでFCMサービスを作成。

import { getToken, onMessage, deleteToken, Unsubscribe } from 'firebase/messaging';
import { messaging } from '../firebase-config';

export interface FCMTokenResult {
  token: string | null;
  error?: string;
}

export interface FCMMessage {
  notification?: {
    title?: string;
    body?: string;
    icon?: string;
  };
  data?: Record<string, string>;
}

/**
 * 通知権限を要求する
 */
export const requestNotificationPermission = async (): Promise<NotificationPermission> => {
  if (!('Notification' in window)) {
    throw new Error('このブラウザは通知をサポートしていません');
  }

  if (Notification.permission === 'granted') {
    return Notification.permission;
  }

  const permission = await Notification.requestPermission();
  return permission;
};

/**
 * FCM トークンを取得する
 */
export const getFCMToken = async (): Promise<FCMTokenResult> => {
  try {
    // 通知権限を確認・要求
    const permission = await requestNotificationPermission();
    
    if (permission !== 'granted') {
      return {
        token: null,
        error: '通知の権限が許可されていません'
      };
    }

    // Service Worker の登録を確認
    if (!('serviceWorker' in navigator)) {
      return {
        token: null,
        error: 'Service Worker がサポートされていません'
      };
    }

    // Service Worker が ready になるまで待機
    const registration = await navigator.serviceWorker.ready;
    
    // FCM トークンを取得(カスタム Service Worker を使用)
    const token = await getToken(messaging, {
      serviceWorkerRegistration: registration,
    });

    if (!token) {
      return {
        token: null,
        error: 'FCM トークンの取得に失敗しました'
      };
    }

    console.log('FCM Token:', token);
    return { token };

  } catch (error) {
    console.error('FCM トークン取得エラー:', error);
    return {
      token: null,
      error: error instanceof Error ? error.message : 'FCM トークンの取得に失敗しました'
    };
  }
};

/**
 * フォアグラウンドでのメッセージを監視する
 */
export const onForegroundMessage = (callback: (payload: FCMMessage) => void): Unsubscribe => {
  return onMessage(messaging, (payload) => {
    console.log('フォアグラウンドメッセージを受信:', payload);
    callback(payload);
  });
};

/**
 * FCM トークンを削除して購読を完全に解除する
 */
export const deleteFCMToken = async (): Promise<{ success: boolean; error?: string }> => {
  try {
    if (!messaging) {
      return {
        success: false,
        error: 'Firebase Messaging が初期化されていません'
      };
    }

    await deleteToken(messaging);
    
    return {
      success: true
    };
  } catch (error) {
    console.error('FCM トークンの削除に失敗:', error);
    return {
      success: false,
      error: error instanceof Error ? error.message : 'トークンの削除に失敗しました'
    };
  }
};

続いてsrc/components/FCMNotification.tsxでUIコンポーネントを作成。

import { useState, useEffect, useCallback } from 'react'
import {
  getFCMToken,
  onForegroundMessage,
  showLocalNotification,
  copyTokenToClipboard,
  deleteFCMToken,
  FCMMessage,
  FCMTokenResult
} from '../services/fcm-service'
import './FCMNotification.css'

const FCMNotification: React.FC = () => {
  const [tokenResult, setTokenResult] = useState<FCMTokenResult>({ token: null })
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [lastMessage, setLastMessage] = useState<FCMMessage | null>(null)
  const [copySuccess, setCopySuccess] = useState(false)

  // FCM 購読を開始
  const startSubscription = useCallback(async () => {
    setIsLoading(true)
    setError(null)

    try {
      const result = await getFCMToken()
      setTokenResult(result)
      
      if (result.error) {
        setError(result.error)
      }
    } catch (err) {
      const errorMessage = err instanceof Error ? err.message : 'FCM の購読開始に失敗しました'
      setError(errorMessage)
      setTokenResult({ token: null, error: errorMessage })
    } finally {
      setIsLoading(false)
    }
  }, [])

  // フォアグラウンドメッセージの監視
  useEffect(() => {
    if (!tokenResult.token) return

    const unsubscribe = onForegroundMessage((payload: FCMMessage) => {
      console.log('フォアグラウンドメッセージ受信:', payload)
      setLastMessage(payload)
      
      // フォアグラウンドでもブラウザ通知を表示
      if (payload.notification) {
        showLocalNotification(
          payload.notification.title || 'FCM 通知',
          {
            body: payload.notification.body,
            icon: payload.notification.icon || '/favicon.svg',
            data: payload.data
          }
        )
      }
    })

    return () => {
      unsubscribe()
    }
  }, [tokenResult.token])

  return (
    <div className="fcm-notification">
      <h3>Firebase Cloud Messaging</h3>
      
      <div className="status">
        <p>🔔 購読状態: {tokenResult.token ? '✅ 購読中' : '❌ 未購読'}</p>
      </div>
      
      <div className="controls">
        {!tokenResult.token ? (
          <button onClick={startSubscription} disabled={isLoading}>
            {isLoading ? '🔄 購読開始中...' : '🤚 プッシュ通知を開始'}
          </button>
        ) : (
          <div className="fcm-active">
            <button onClick={copyTokenToClipboard}>
              📋 トークンをコピー
            </button>
          </div>
        )}
      </div>

      {/* トークン表示エリア */}
      {tokenResult.token && (
        <details className="token-details">
          <summary>🔑 FCM トークン</summary>
          <div className="token-container">
            <textarea
              readOnly
              value={tokenResult.token}
              className="token-display"
              rows={6}
            />
            <p className="token-info">
              💡 このトークンを使用して、Firebase Console や サーバーから通知を送信できます
            </p>
          </div>
        </details>
      )}
    </div>
  )
}

export default FCMNotification

主な機能:

  • FCMトークン取得: ユーザーの同意を得てFCMトークンを取得
  • フォアグラウンド通知: アプリがアクティブな時のメッセージ表示
  • トークン管理: トークンのコピー、表示、削除機能
  • エラーハンドリング: 権限エラーやネットワークエラーの適切な処理

App.tsxでFCMNotificationコンポーネントを組み込む。

import FCMNotification from './components/FCMNotification'

function App() {
  return (
    <>
      {/* 他のコンポーネント */}
      <FCMNotification />
      <PWABadge />
    </>
  )
}

ビルド・プレビュー

Firebase設定を環境変数で管理するので、.envファイルを作成する。

VITE_API_KEY=your_api_key_here
VITE_AUTH_DOMAIN=your_project.firebaseapp.com
VITE_PROJECT_ID=your_project_id
VITE_STORAGE_BUCKET=your_project.appspot.com
VITE_MESSAGING_SENDER_ID=your_sender_id
VITE_APP_ID=your_app_id

値はFirebase Consoleの「プロジェクトの設定」→「全般」→「マイアプリ」から確認できる。

Dev環境で開発する場合はdevOptions.enabled: trueにしておく。

// vite.config.ts
export default defineConfig({
  plugins: [react(), VitePWA({
    // ... 他の設定
    devOptions: {
      enabled: true, // 開発環境でもSWを有効化
      navigateFallback: 'index.html',
      suppressWarnings: true,
      type: 'module',
    },
  })],
})

ビルド・プレビューする。

npm run build
npm run preview -- --host

ブラウザでhttp://localhost:4173/を開くとFCM対応のPWAアプリが表示される。

「プッシュ通知を開始」ボタンをクリックすると通知権限を求められ、許可するとFCMトークンが発行される。

このときPush通知が来ているはず。

FCMからの通知テスト

Messagingサービスでキャンペーンを作成してテスト通知を送信できる。

「最初のキャンペーンを作成」をクリック。

スクリーンショット 2025-08-31 120814.png

「Firebase Notification メッセージ」を選択。

スクリーンショット 2025-08-31 120846.png

通知内容を設定して次へ
(怖いメッセージだね🥳)

スクリーンショット 2025-08-31 121004.png

ターゲットはtest-appにする。

image.png

スケジュールは現在にする。
スクリーンショット 2025-08-31 121110.png

オプションはつけずに確認ボタンをクリック。

スクリーンショット 2025-08-31 121136.png

メッセージの再確認をしたら公開する。

スクリーンショット 2025-08-31 121201.png

アプリで通知開始していたら通知が届くはず。

スクリーンショット 2025-08-31 121314.png

まとめ

  • FCM統合: Firebase SDKを使ってService WorkerとReactコンポーネントでFCMを実装できた
  • トークン管理: FCMトークンの取得、表示、削除機能を実装
  • バックグラウンド通知: onBackgroundMessageでアプリがバックグラウンドの時の通知処理
  • フォアグラウンド通知: onMessageでアプリがアクティブの時の通知処理
  • Firebase Console: GUIで簡単に通知テストができる
  • API対応: REST APIでプログラマブルに通知送信も可能

Vite PWA + FCMの組み合わせで、本格的なプッシュ通知対応PWAが作れることが確認できた。
VAPIDキーを自分で管理する必要がなく、Firebaseのインフラを活用できるのが大きなメリット。

次は通知のセグメンテーションやスケジュール配信なども試してみたい。

番外編 Firebase MCPをつかって通知してみる

Firebase MCP サーバーなるものがあった。

これはFirebaseのプロジェクト管理や各種サービスの実行ができるMCPでMessageの送信もできとあった。
なので試してみた。

Firebase CLIのインストール

npm install -g firebase-tools

Firebase CLI を認証

npx -y firebase-tools@latest login --reauth

MCP クライアントを設定する

VS Codeをつかっているのでワークスペースの.vscode/mcp.jsonに記載・実行。
(ユーザー単位で設定するならC:\Users[ユーザー名]\AppData\Roaming\Code\User\mcp.jsonに記載してね)

ついでにブラウザでFCMトークン取得してもらうためにPlaywrightも設定しておく。

{
 "servers": {
  "firebase": {
   "type": "stdio",
   "command": "npx",
   "args": [
    "-y",
    "firebase-tools@latest",
    "experimental:mcp"
   ]
  },
  "playwright": {
   "command": "npx",
   "args": [
    "@playwright/mcp@latest"
   ],
   "type": "stdio"
  },
 }
}

ツールが多すぎるとCopilotだとエラーになるのでmessaging_send_messageだけにする。

スクリーンショット 2025-08-31 122656.png

メッセージ送るように指示する

あとはCopilot Agentで「#messaging_send_message テスト通知して」と指示するだけ。

以下のことを勝手にやってくれた。ちゃんと通知も届いた。

  • サーバー起動
  • Playwrightブラウザ起動
  • ブラウザ操作でFCMトークン取得
  • トークン使ってメッセージ送信

スクリーンショット 2025-08-31 130016.png

スクリーンショット 2025-08-31 123523.png

以上!!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?