🔔

Next・Supabase Edge Functions・FCM でプッシュ通知を実装する

に公開

はじめに

現在、「Recoput」という Qiita や Zenn の記事を自分の嗜好に応じてレコメンドしてくれるサービスを開発しています。
最近は Next × Supabase の構成で Web アプリの開発をするのが流行っていると思いますが、自分たちもこの構成で開発しています。

今回は Next × Supabase の構成でプッシュ通知を実装しました。
以下のような通知が Web 上で通知されるようになります。

プッシュ通知の実装

技術スタック

具体的な技術スタックは以下です。

  • Firebase Cloud Messaging (FCM): プッシュ通知の配信
  • Supabase DB: ユーザーのFCMトークン管理用DB
  • Supabase Edge Functions: 通知送信処理を行う関数
  • Next.js: フロントエンド
  • Service Worker: バックグラウンド通知の処理
  • Cloudflare: ホスティング

Supabase 公式にもガイドがあるので、参考にしてみてください。

https://supabase.com/docs/guides/functions/examples/push-notifications?queryGroups=platform&platform=fcm

ちなみに FCM は Firebase が提供している「Firebase Cloud Messaging」の頭文字をとった通知ツールです。
↑の Supabase のガイド通りに進めれば FCM のセットアップも完了すると思いますが、念の為ドキュメントを載せておきます。

https://firebase.google.com/docs/cloud-messaging?hl=ja

通知の流れ

大まかな流れは以下です。

  1. ユーザが、画面上で通知を許可する。
  2. 通知用トークンを FCM から取得して、DB に保存する。
  3. Supabase Edge Functions で、DB に保存されたトークンを取得して、通知を送信する。
  4. ユーザに通知が届く。

前提

Firebase のセットアップは完了しているものとします。
先に記載した Supabase のガイドを参考にしてください。

実装

今回は、実装したコードのみを載せます。
コマンドの実行は適宜行なってください。

1. データベースに FCM トークンを保存するテーブルを作成

CREATE TABLE IF NOT EXISTS "fcm_tokens" (
  "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
  "user_id" uuid NOT NULL,
  "token" text NOT NULL,
  "device_info" jsonb,
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
  CONSTRAINT "fcm_tokens_user_id_token_unique" UNIQUE("user_id","token")
);

このテーブルでは、各ユーザーのデバイスごとのFCMトークンを保存します。これにより、同じユーザーが複数のデバイスを使用している場合でも、すべてのデバイスに通知を送信できます。

2. フロントエンドで Firebase 連携 & 通知許可コンポーネントを作成

まずは、フロントで Firebase との連携ができるように、firebase.ts を作成します。

import { initializeApp, getApps, getApp } from 'firebase/app'
import { getMessaging, getToken, onMessage } from 'firebase/messaging'
import { createClient } from '@/utils/supabase/client'

// Firebaseの設定
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}

// Firebaseアプリの初期化
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp()
// FCMのVAPIDキー
const vapidKey = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY

const supabase = createClient()

/**
 * FCMトークンを取得し、Supabaseに保存する
 * @returns FCMトークン
 */
export const getFCMToken = async (): Promise<string | null> => {
  try {
    // ブラウザがプッシュ通知をサポートしているか確認
    if (!('Notification' in window)) {
      console.error('このブラウザはプッシュ通知をサポートしていません')
      return null
    }

    // 通知の許可を取得
    const permission = await Notification.requestPermission()
    if (permission !== 'granted') {
      console.error('通知の許可が得られませんでした')
      return null
    }

    // FCMトークンを取得
    const messaging = getMessaging(app)
    const currentToken = await getToken(messaging, { vapidKey })

    if (!currentToken) {
      console.error('FCMトークンを取得できませんでした')
      return null
    }

    // ユーザーがログインしているか確認
    const {
      data: { user },
    } = await supabase.auth.getUser()

    console.log('user', user)

    if (!user) {
      console.error('ユーザーがログインしていません')
      return null
    }

    // デバイス情報を取得
    const deviceInfo = {
      userAgent: navigator.userAgent,
      platform: navigator.platform,
      language: navigator.language,
    }

    // FCMトークンをSupabaseに保存
    const { error } = await supabase.from('fcm_tokens').upsert(
      {
        user_id: user.id,
        token: currentToken,
        device_info: deviceInfo,
      },
      {
        onConflict: 'user_id, token',
      },
    )

    if (error) {
      console.error('FCMトークンの保存に失敗しました:', error)
      return null
    }

    return currentToken
  } catch (error) {
    console.error('FCMトークンの取得中にエラーが発生しました:', error)
    return null
  }
}

// FCMメッセージのペイロード型定義
export interface FCMMessagePayload {
  notification?: {
    title?: string
    body?: string
    image?: string
  }
  data?: Record<string, string>
  from: string
  messageId: string
  collapseKey?: string
  fcmOptions?: {
    analyticsLabel?: string
  }
}

/**
 * フォアグラウンドでのメッセージ受信ハンドラを設定
 * @param callback メッセージを受信したときに呼び出されるコールバック関数
 */
export const onMessageListener = (
  callback: (payload: FCMMessagePayload) => void,
): (() => void) => {
  if (typeof window === 'undefined') return () => {}

  try {
    const messaging = getMessaging(app)
    const unsubscribe = onMessage(messaging, (payload) => {
      callback(payload as FCMMessagePayload)
    })

    return unsubscribe
  } catch (error) {
    console.error('メッセージリスナーの設定中にエラーが発生しました:', error)
    return () => {}
  }
}

export { app }

このファイルでは以下のような関数を定義しています。

  • getFCMToken: FCM トークンを取得して、Supabase に保存する。
  • onMessageListener: フォアグラウンドでのメッセージ受信ハンドラを設定する。画面を開いているときに通知が来たら、トースト通知として表示。

次に、通知許可のトーストを表示させるコンポーネントを作成します。

通知許可画面

コードは以下のようになります。

'use client'

import { useEffect, useState } from 'react'
import {
  getFCMToken,
  onMessageListener,
  FCMMessagePayload,
} from '@/lib/firebase'
import { toast } from 'react-hot-toast'

interface PushNotificationManagerProps {
  children: React.ReactNode
}

export const PushNotificationManager: React.FC<
  PushNotificationManagerProps
> = ({ children }) => {
  const [fcmToken, setFcmToken] = useState<string | null>(null)
  const [notificationPermission, setNotificationPermission] = useState<
    NotificationPermission | 'default' | null
  >(null)

  // 通知の許可状態を確認
  useEffect(() => {
    if (typeof window === 'undefined') return

    if ('Notification' in window) {
      setNotificationPermission(Notification.permission)
    }
  }, [])

  // FCMトークンを取得
  useEffect(() => {
    const initializeFCM = async () => {
      try {
        const token = await getFCMToken()
        if (token) {
          setFcmToken(token)
        }
      } catch {
        throw new Error()
      }
    }

    // ユーザーが通知を許可している場合のみFCMを初期化
    if (notificationPermission === 'granted') {
      initializeFCM()
    }
  }, [notificationPermission])

  // フォアグラウンドでの通知リスナーを設定
  useEffect(() => {
    if (typeof window === 'undefined' || !fcmToken) return

    const unsubscribe = onMessageListener((payload: FCMMessagePayload) => {
      toast(
        (t) => (
          <div
            className="flex cursor-pointer items-start gap-2"
            onClick={() => {
              // 通知のクリックアクションを実行
              const url = payload.data?.url || '/'
              window.location.href = url
              toast.dismiss(t.id)
            }}
          >
            <div className="flex-1">
              <div className="font-bold">{payload.notification?.title}</div>
              <div className="text-p-s">{payload.notification?.body}</div>
            </div>
          </div>
        ),
        {
          duration: 6000,
          position: 'top-right',
        },
      )
    })

    return () => {
      unsubscribe()
    }
  }, [fcmToken])

  // 通知の許可を要求するハンドラー
  const requestNotificationPermission = async () => {
    if (!('Notification' in window)) {
      alert('このブラウザはプッシュ通知をサポートしていません')
      return
    }

    try {
      // ブラウザによっては、非同期関数内でPromiseを直接呼び出すとブロックされることがある
      // 直接呼び出しに変更
      const permission = Notification.requestPermission()
      if (permission instanceof Promise) {
        permission.then((result) => {
          setNotificationPermission(result)

          if (result === 'granted') {
            getFCMToken().then((token) => {
              if (token) {
                setFcmToken(token)
              }
            })
          }
        })
      } else {
        // 古いブラウザ向け(非Promise形式)
        setNotificationPermission(permission)

        if (permission === 'granted') {
          const token = await getFCMToken()
          if (token) {
            setFcmToken(token)
          }
        }
      }
    } catch {
      throw new Error()
    }
  }

  return (
    <>
      {children}
      {notificationPermission === 'default' && (
        <div className="fixed bottom-4 right-4 z-overlay max-w-sm rounded-lg bg-white p-4 shadow-lg">
          <h3 className="mb-2 font-bold">通知を受け取りますか?</h3>
          <p className="mb-4 text-p-m">
            最新の情報やアップデートをプッシュ通知で受け取ることができます。
          </p>
          <div className="flex justify-end gap-2">
            <button
              className="px-4 py-2 text-p-m text-gray-600"
              onClick={() => setNotificationPermission('denied')}
            >
              後で
            </button>
            <button
              className="rounded bg-blue-600 px-4 py-2 text-p-m text-white"
              onClick={requestNotificationPermission}
            >
              許可する
            </button>
          </div>
        </div>
      )}
    </>
  )
}

このコンポーネントは、ユーザーに通知許可を求め、許可された場合はFCMトークンを取得してデータベースに保存します。また、フォアグラウンドで受信した通知をトースト通知として表示する役割も担っています。

3. Service Worker を作成

2で通知が許可されたら、アプリを閉じていてもバックグラウンドで通知を受け取れるようにします。

Service Worker は Next.js の場合、public フォルダに配置する必要があります。
以下のように配置されるようにします。

|-- public
|   |-- firebase-messaging-sw.js

このファイルをビルド時に生成するように package.json に以下のようなスクリプトを追加します。
「pre」をスクリプト名の前につけることで、「npm run dev」や「npm run build」の前に実行されるようになります。

"predev": "node scripts/generate-sw.mjs dev",
"prebuild": "node scripts/generate-sw.mjs build"

scripts/generate-sw.mjs は以下のようになります。

#!/usr/bin/env node

import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import dotenv from 'dotenv'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

// コマンドによって環境変数を読み込む
const buildCommand = process.argv[2]
if (!buildCommand) {
  throw new Error('Command is required. (dev or build)')
}
const envFile = buildCommand === 'build' ? '.env' : '.env.local'
dotenv.config({ path: path.resolve(__dirname, `../${envFile}`) })

// 環境変数からFirebaseの設定
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}

// カスタムの変換関数を作成
const formatFirebaseConfig = (config) => {
  // Object.entriesを使用してキーと値のペアを取得し、処理
  const entries = Object.entries(config)
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    .filter(([_, value]) => value !== undefined) // undefined値を除外
    .map(([key, value]) => {
      // 文字列値をシングルクォーテーションで囲む
      const formattedValue = typeof value === 'string' ? `'${value}'` : value
      return `  ${key}: ${formattedValue},`
    })
    .join('\n')

  return `{\n${entries}\n}`
}

// テンプレートからサービスワーカーを生成
const swTemplatePath = path.join(
  __dirname,
  '../public/firebase-messaging-sw.template.js',
)
const swTemplate = fs.readFileSync(swTemplatePath, 'utf8')

// テンプレート内の変数を置換
const swCode = swTemplate.replace(
  'FIREBASE_CONFIG_PLACEHOLDER',
  formatFirebaseConfig(firebaseConfig),
)

// 生成したサービスワーカーを書き込み
const swOutputPath = path.join(__dirname, '../public/firebase-messaging-sw.js')
fs.writeFileSync(swOutputPath, swCode)

console.log(
  '\n \x1b[32m✓ \x1b[0mFirebase Messaging Service Worker generated successfully\n',
)

このスクリプトは以下のような処理を行っています。

  1. 環境変数から Firebase の設定を読み込む。
  2. public/firebase-messaging-sw.template.js のテンプレートファイルからサービスワーカーのファイルを生成する。
  3. 生成したサービスワーカーを書き込む。

2でテンプレートファイルを用意した理由は、これがないと環境変数を直接サービスワーカーのファイルに記述しなければならないからです。
どういうことかというと、通常 public フォルダはビルド対象になりません。
そのため、環境変数を読み込むことができません。
読み込めないため、直接サービスワーカーのファイルに環境変数を記述しなければならなくなり、Git 管理ができなくなります。

この状態で先にも記述した以下のいずれかのスクリプトを実行すると、public/firebase-messaging-sw.js が生成されます。
さらに環境変数が展開された状態になるので、安心して Git 管理できます。

"predev": "node scripts/generate-sw.mjs dev",
"prebuild": "node scripts/generate-sw.mjs build"

4. サーバーサイドの実装

Supabase Edge Functionsを使用して、通知を送信する処理を実装します:

import { createClient } from 'jsr:@supabase/supabase-js@2'
import { JWT } from 'npm:google-auth-library@8.9.0'

import serviceAccount from '../_shered/env/firebase/service-account.json' with { type: 'json' }
import { addDays, getToday } from '../_shered/utils/date.ts'

// 今日レコメンドした記事のユーザに通知を送信するFunction
Deno.serve(async () => {
  try {
    // 今日レコメンドした記事のユーザのIDを取得
    const today = getToday()
    const tomorrow = addDays(today, 1)
    const { data: userArticles } = await supabase
      .from('user_articles')
      .select('user_id')
      .gte('created_at', today)
      .lt('created_at', tomorrow)

    // ユーザーのFCMトークンを取得
    const { data: fcmTokens } = await supabase
      .from('fcm_tokens')
      .select('token')
      .in(
        'user_id',
        userArticles.map((userArticle) => userArticle.user_id),
      )

    // JWTを使用してアクセストークンを取得
    const accessToken = await getAccessToken({
      clientEmail: serviceAccount.client_email,
      privateKey: serviceAccount.private_key,
    })

    // すべてのトークンに通知を送信
    const notificationResults = await Promise.all(
      fcmTokens.map(async (fcmToken) => {
        try {
          // FCM V1 APIを使用して通知を送信
          const res = await fetch(
            `https://fcm.googleapis.com/v1/projects/${serviceAccount.project_id}/messages:send`,
            {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${accessToken}`,
              },
              body: JSON.stringify({
                message: {
                  token: fcmToken.token,
                  notification: {
                    title: '今日のおすすめ記事が配信されました。',
                    body: '記事を読んでインプットを続けましょう。',
                  },
                },
              }),
            },
          )
          // 省略...
        } catch (error) {
          // エラー処理
        }
      }),
    )

    // 結果のサマリーを返す
    return new Response(JSON.stringify(summary), {
      headers: { 'Content-Type': 'application/json' },
    })
  } catch (error) {
    // エラー処理
  }
})

この関数で、今日記事をレコメンドしたユーザーを対象に通知を送信します。
Firebase Admin SDKを使用してFCM V1 APIを呼び出し、各ユーザーのデバイスに通知を送信しています。

service-account.json は、Firebase のサービスアカウントの認証情報を含む JSON ファイルです。
ガイドに従って Firebase のサービスアカウントを作成して、認証情報を取得してください。

もし、Supabase Edge Functions を使用する場合、この JSON ファイルも当然ですがデプロイする必要があります。

以上で実装完了です。

最後に

Next × Supabase は何をするにしても爆速で実装ができますね。
Service Worker で環境変数をビルド時に展開する方法は工夫したポイントなので、ぜひ参考にしてみてください。

それでは、良い Next × Supabase ライフを!
Recoput のリリースもお楽しみに。

GitHubで編集を提案
OT Official

Discussion