2026年2月13日金曜日

サーバから Chrome ブラウザプッシュする実装の流れ

サーバから Chrome ブラウザプッシュする実装の流れ

概要

実装の流れを紹介します

環境

  • macOS 26.2
  • Python 3.12.11

鍵の作成

こちらを参考に作成します
鍵ファイルと JavaScript 用の鍵文字列を作成すれば OK です

サブスクリプション情報の保存 (Python)

JavaScrpt 側で通知の許可をした場合に通知先の情報をサーバ側に保存しておきます
今回は Redis で紹介しますが永続化できるものであれば何でも OK です

@app.route("/subscribe", methods=["POST"])
def subscribe():
    """フロントから送られてくる Subscription 情報を Redis に保存"""
    data = request.get_json()
    client_id = data.get("client_id")
    subscription_info = data.get("subscription")

    redis_conn = get_kv()
    # client_id ごとに購読情報を保存
    redis_conn.set(f"push_sub:{client_id}", json.dumps(subscription_info))
    return jsonify({"ok": True})

プッシュ送信処理 (Python)

あとで使いますが実際にプッシュを送信する処理です
メールアドレス部分はダミーでも問題ないですが一応ちゃんと設定した方がいいです (公開アプリの場合)

VAPID_PRIVATE_KEY_PATH = os.getenv("VAPID_PRIVATE_KEY_PATH", "./private_key.pem")
VAPID_CLAIMS = {"sub": "mailto:user@mail"}

def send_web_push(subscription_info, message_body):
    try:
        webpush(
            subscription_info=json.loads(subscription_info),
            data=json.dumps(message_body),
            vapid_private_key=VAPID_PRIVATE_KEY_PATH,
            vapid_claims=VAPID_CLAIMS,  # type: ignore
        )
    except WebPushException as ex:
        print("Push failed:", repr(ex))

ServiceWorker (JavaScript)

リモートプッシュはこの ServiceWorker がないと動きません
Python からのプッシュをこの ServiceWorker が受け取り通知を表示する感じです

通知のスタイルなどはシンプルにしているので必要であればアイコンなど設定してください

self.addEventListener("push", function (event) {
  console.log("Push received");
  let data = { title: "新着", body: "メッセージがあります" };

  try {
    if (event.data) {
      data = event.data.json(); // ここでエラーが起きると通知が出ない
    }
  } catch (e) {
    console.error("JSON parse error:", e);
  }

  const options = {
    body: data.body,
  };

  event.waitUntil(self.registration.showNotification(data.title, options));
});

ブラウザ側のサブスクリプション登録 (JavaScript)

ブラウザで特定のサイトなどにアクセスすると通知を許可しますかなど出るサイトがありますがそれを実装します
許可するをクリックしたら必要な情報を Python 側に送ります

urlBase64ToUint8Array に鍵文字列は冒頭で作成したものを使ってください

function urlBase64ToUint8Array(base64String) {
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

async function setupPush() {
  const register = await navigator.serviceWorker.register("/static/js/sw.js");

  // すでに購読済みかチェック
  let subscription = await register.pushManager.getSubscription();

  // 未購読なら新規作成
  if (!subscription) {
    subscription = await register.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(
        "xxx",
      ),
    });
  }

  // サーバーへ送信(常に送ることで client_id との紐付けを確実に更新する)
  await fetch("/subscribe", {
    method: "POST",
    body: JSON.stringify({
      client_id: CLIENT_ID,
      subscription: subscription,
    }),
    headers: { "Content-Type": "application/json" },
  });
}

通知許可ダイアログ表示 (JavaScript)

ページが読み込まれた場合に自動で出してもいいですが嫌がられるケースもあるので特定のボタンを押したら許可する流れにします

document.addEventListener("DOMContentLoaded", () => {
  // Click title to set client_id and request notification permission
  const title = document.getElementById("notification");
  if (title) {
    title.addEventListener("click", async () => {
      // 通知の許可を求める
      if ("Notification" in window) {
        const permission = await Notification.requestPermission();
        if (permission === "granted") {
          // ここで実行!
          await setupPush();
        }
      }
    });
  }
  // 追加: すでに許可済みなら、ページ読み込み時に購読情報を同期する
  if ("Notification" in window && Notification.permission === "granted") {
    setupPush().catch((err) => console.error("Auto-setup failed:", err));
  }
});

プッシュ通知送信タイミング (Python)

あとは任意のタイミングでサーバ側で送信すれば OK です
当然ですが送信しまくると大変なことになるので条件など設けて適切なタイミングで送るようにしましょう

@app.route("/send", methods=["POST"])
def redis_chat_send():
    # 1... 既存の処理
    # 2. プッシュ通知の送信 (自分以外の購読者へ)
    try:
        # push_sub: で始まるキーをすべて取得
        # ※ ユーザー数が多い場合はメンバーリストを別途管理するのが理想ですが、
        # 現状のチャットルーム運用であれば keys で十分です
        sub_keys = redis_conn.keys("push_sub:*")

        for sub_key in sub_keys:  # type: ignore
            target_id = sub_key.decode().split(":")[-1]

            # 送信者(自分)以外にのみ送る
            if target_id != client_id:
                sub_info = redis_conn.get(sub_key)
                if sub_info:
                    logger.info(f"Sending push to {target_id}")
                    # 非同期処理にするのが理想ですが、まずは直列で実装
                    send_web_push(
                        sub_info,
                        {"title": "新着メッセージ", "body": f"{client_id}: {msg}"},
                    )
    except Exception as e:
        logger.error(f"Push notification error: {e}")

    return jsonify({"ok": True})

サブスクリプション確認

  • Redis 側にキーが登録されていること
  • 開発者コンソールで ServiceWorker が登録されていること

プッシュが届かない場合

  • Mac であれば通知の設定を確認しましょう
    • Mac の Chrome はデフォルトでオフになっている
    • Chrome で許可していても Mac 側で拒否していることが多い
  • サイトが https 化されていない
    • localhost ならテストできるようです
    • IP ベースのサイトや http のサイトでは動作しません

スマホ or タブレット版でプッシュ通知が届かない場合

  • ラップトップ版と違いかなり作りが違う
  • 単純に通知を許可することができない
  • iOS 側で当然許可する必要があるがそれでもサブスクリプションできないことがある
  • サイトを PWA 化して上げる必要などがあるかも

最後に

参考サイト

0 件のコメント:

コメントを投稿