Supabase の Edge Function で Web Push する覚書

Supabase の Edge Function で WebPush させた備忘録です。

「Supabase の Edge Function で Hello World する覚書」
https://qiita.com/jackalope/items/452ce72555eeed66a6f0
にて Edge Function が動作する事を確認出来た次のステップになります。

## 前提条件
既にウェブアプリや PWA で WebPush 通知が実装されている事を想定します。
理由は以下の通りです
- **VAPID** 生成に必要な **endpoint**, **p256dh**, **auth** の情報がデータベースに存在しているはず
- 正常に Service Worker が動作している環境があれば確認しやすい

## バンドル
Deno 環境で動く web-push を自作するのは大変でコストが高く付きます。
探してみると **Code for FUKUI** 様の `web-push for Deno` なるものがあり、試した所動作したので今回はこちらを使用させて頂きました。
https://github.com/code4fukui/web-push/
有益なコードを公開してくださり感謝です。
この `web-push for Deno` の Readme を見れば事足りるという方は本ページの記事を見る必要はありません。

尚 MIT ライセンスだそうですので運用に利用される場合は留意して下さい。また本記事では実験の為の参考コードという事でコード内でのライセンス表記を割愛している事をご了承下さい。
もっともデプロイするとコードは見られなくなるのでライセンスは管理場所に置いてあれば良いという気もします。

## 試した環境
- Windows11
- Next.js v15.1.6
- Supabase CLI 2.15.8
- Docker 27.4.0

そして通常そうなってると思うので「**JWT認証が有効**」という前提にしてます。

Supabase CLI についてはグローバルインストールしていないので npx で呼び出してます。
グローバルインストールしている場合は良しなに読み替えて下さい。

## 準備
- Project ID
- Service Role Key
- ローカル実行用 anon key
- 本番用 anon key

を用意します。
以降それぞれ **{ProjectID}**, **{ServiceRole}**, **{LocalAnonKey}**, **{AnonKey}** と表記します。
よく判らない時は「Supabase の Edge Function を定期実行させる覚書」
https://qiita.com/jackalope/items/64a10e498a354dd42c94
も合わせて参照してみて下さい。

VAPID 生成用のキーも必要ですが、通常なら .env.local に記述されている事と思います。

### 環境変数
環境変数が必要ですがシェルには設定しません。
一旦 `.env.function` というファイルに記述しておく事にします。
ファイル名に決まりは無いので判りやすい感じにしておきます。
```.env.function
PROJECT_URL="https://{ProjectID}.supabase.co"
SERVICE_ROLE_KEY="{ServiceRole}"
VAPID_PUBLIC_KEY=ここにVAPIDのパブリックキーを記述する
VAPID_PRIVATE_KEY=ここにVAPIDのプライベートキーを記述する
```

## テーブル
この記事で操作されるテーブルはこのように設定し、名前は `web_push_subscriptions` としています。
![スクリーンショット 2025-03-05 151114.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4026777/dc7ef1a5-dd82-4274-9099-3414a5ef2dde.png)
ここは運用によって様々だと思いますが、今回は `user_id`, `endpoint`, `p256dh`, `auth` が単純に並んでいるだけの構造としてみました。

## Deno コード入力
それではコードを作成しましょう。名前は web-push としました。
> npx supabase functions new web-push

このコマンドで `supabase/functions/web-push/index.ts` が作成されるのでこれを編集します。
日本語のコメントを含める場合は必ず **UTF-8** で保存して下さい。
各環境に合わせて**修正の必要な箇所があります**が後述します。
見通しの良さに為、エラーハンドリングもあまりしっかりやっていません。
```index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import webpush from "https://code4fukui.github.io/web-push/src/index.js";

import { createClient } from "https://esm.sh/@supabase/supabase-js";

const publicKey  = Deno.env.get("VAPID_PUBLIC_KEY");
const privateKey = Deno.env.get("VAPID_PRIVATE_KEY");
const mail       = "mailto:ここに管理者のメールアドレスを記述";

webpush.setVapidDetails(mail, publicKey, privateKey);

const supabaseUrl = Deno.env.get("PROJECT_URL")!;
const supabaseServiceKey = Deno.env.get("SERVICE_ROLE_KEY")!;

const supabase = createClient(supabaseUrl, supabaseServiceKey);

const user_id = "ユーザーIDを埋め込む";
const payload = { title: "タイトル", body: "各環境に合わせて変更", url: "/" };

Deno.serve(async (req) => {
  try {
    // user_id が一致する行を取得
    const { data: subscriptions, error: selectError } = await supabase
      .from("web_push_subscriptions")
      .select("*")
      .eq("user_id", user_id)

    if (selectError) {
      throw new Error(selectError.message);
    }
    if (!subscriptions || subscriptions.length === 0) {
      return new Response(
        JSON.stringify({ success: false, message: "No subscriptions found" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }

    // 取得した各サブスクリプションに対して通知を送信
    for (const sub of subscriptions) {
      const subscription = {
         endpoint: sub.endpoint,
         keys: {
           p256dh: sub.p256dh,
           auth: sub.auth,
         },
      };
      //console.log(subscription);

      await webpush.sendNotification(subscription, JSON.stringify(payload));
    }

    return new Response(
      JSON.stringify({ success: true, message: "Push sent: " + subscriptions.length }),
      { status: 200, headers: { "Content-Type": "application/json" } }
    );
  } catch (e) {
    console.error(e);
    return new Response(
      JSON.stringify({ success: false, message: e.message }),
      { status: 400, headers: { "Content-Type": "application/json" } }
    );
  }
})
```

### 修正箇所
```
const mail       = "mailto:ここに管理者のメールアドレスを記述";
```
↑ ここは既に運用中の VAPID に設定しているものと同じようにすれば良いです。
環境変数から取得するようにしても良いでしょう。

```
const user_id = "ユーザーIDを埋め込む";
```
↑ 通知を出したいユーザーID を埋め込んでます。適当な相手を設定して下さい。

```
const payload = { title: "タイトル", body: "各環境に合わせて変更", url: "/" };
```
↑ 通知する内容も埋め込みです。
運用によって違うので、各 Service Worker に合わせて設定します。

```
    const { data: subscriptions, error: selectError } = await supabase
      .from("web_push_subscriptions")
      .select("*")
      .eq("user_id", user_id)
```
↑ そしてデータベースの読み込みも当然自分のものに合わせる必要があります。

### 動作説明
- ```webpush.setVapidDetails``` で WebPush の初期化をする
- `web_push_subscriptions` というテーブルから指定した `user_id` の行を取得
複数の端末から通知を許可してる場合もあるので取得される結果は複数を想定する
- 取得した情報を元に ```webpush.sendNotification``` を呼び出す

とこれだけです。
デプロイの前に一応手元で動作確認しておきましょう。
まずサーバーの起動です。
> npx supabase functions serve web-push --env-file .env.function

環境変数は `--env-file` オプションで設定出来ます。
サーバーを起動したら別コンソールから **curl** 実行します。
> curl -X POST http://127.0.0.1:54321/functions/v1/web-push -H "Authorization: Bearer {LocalAnonKey}"

問題が無ければここで各端末に通知が届きます。

## デプロイ
コードにユーザー ID など埋め込んでいるのでデプロイまでは必要ないと思いますが、サーバーから通知される所まで確認したい場合はデプロイして実行します。

まずデプロイする環境変数を設定します。
`.env.function` に記述した内容をそのまま `supabase secrets set` でセットするだけです。
因みに supabase への設定権限が無いとエラーになります。
```
npx supabase secrets set PROJECT_URL="https://{ProjectID}.supabase.co"
npx supabase secrets set SERVICE_ROLE_KEY="{ServiceRole}"
npx supabase secrets set VAPID_PUBLIC_KEY=ここにVAPIDのパブリックキーを記述する
npx supabase secrets set VAPID_PRIVATE_KEY=ここにVAPIDのプライベートキーを記述する
```
準備が出来たらデプロイします。
> npx supabase functions deploy web-push

これでダッシュボードの Edge Functions に登録されたので実行してみましょう。
> curl -X POST https://{ProjectID}.supabase.co/functions/v1/web-push -H "Authorization: Bearer {AnonKey}"

ローカル実行の時と同様に通知されたと思います。
「Supabase の Edge Function を定期実行させる覚書」
https://qiita.com/jackalope/items/64a10e498a354dd42c94
と合わせると Cron による定期実行で必要に応じて通知を出すという事も可能になりますね。