プッシュ通知(Web Push)

ユーザーがブラウザを閉じていても通知を出せる仕組み。 サーバからPush Service(Apple / Google / Mozilla)に投げて、Service Worker が受信→表示する。

仕組み

  1. クライアントが pushManager.subscribe() で Push Service の URL を取得
  2. その URL(subscription)を自社サーバに保存
  3. サーバが Push Service に POST で通知を送る(VAPID 認証)
  4. Push Service が SW に届ける
  5. SW の push イベントが発火し、showNotification

VAPID キーを生成

Web Push ではVAPID(Voluntary Application Server Identification)で認証する。 公開鍵をクライアントに、秘密鍵をサーバに。

npx web-push generate-vapid-keys
# Public Key と Private Key が出る

クライアント側: 購読

async function subscribePush() {
  // 通知許可
  const perm = await Notification.requestPermission()
  if (perm !== "granted") return

  const reg = await navigator.serviceWorker.ready
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  })

  // サーバへ送信
  await fetch("/api/push/subscribe", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(sub),
  })
}

function urlBase64ToUint8Array(s) {
  const padding = "=".repeat((4 - s.length % 4) % 4)
  const base64 = (s + padding).replace(/-/g, "+").replace(/_/g, "/")
  const raw = atob(base64)
  return new Uint8Array([...raw].map((c) => c.charCodeAt(0)))
}

許可の取り方

SW 側: 通知の受信と表示

// sw.js
self.addEventListener("push", (e) => {
  const data = e.data?.json() ?? { title: "通知", body: "" }

  e.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icons/icon-192.png",
      badge: "/icons/badge-72.png",
      image: data.image,
      data: { url: data.url },
      tag: data.tag,                // 同じ tag は上書き
      renotify: false,              // tag 上書き時に再通知
      requireInteraction: false,    // ユーザーが消すまで残る
      silent: false,
      vibrate: [200, 100, 200],
      actions: [
        { action: "open", title: "開く" },
        { action: "dismiss", title: "閉じる" },
      ],
    })
  )
})

// クリックされた時
self.addEventListener("notificationclick", (e) => {
  e.notification.close()
  const url = e.notification.data?.url ?? "/"

  e.waitUntil((async () => {
    const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
    for (const c of clients) {
      if (c.url === url && "focus" in c) return c.focus()
    }
    if (self.clients.openWindow) return self.clients.openWindow(url)
  })())
})

サーバ側: 送信(Node.js)

npm install web-push
import webpush from "web-push"

webpush.setVapidDetails(
  "mailto:contact@example.com",
  process.env.VAPID_PUBLIC,
  process.env.VAPID_PRIVATE,
)

await webpush.sendNotification(subscription, JSON.stringify({
  title: "新着メッセージ",
  body: "Alice からメッセージが届きました",
  url: "/messages/123",
  tag: "msg-123",
}))

失敗したら購読を削除

try {
  await webpush.sendNotification(subscription, payload)
} catch (e) {
  if (e.statusCode === 404 || e.statusCode === 410) {
    // 購読が無効化された → DB から削除
    await db.deleteSubscription(subscription.endpoint)
  }
}

サーバ側: Cloudflare Workers

web-push は Node 専用(一部 native)。Workers では軽量実装を使う:

iOS Safari の制約

サーバ側: 主要サービス

サービス特徴
OneSignal無料枠あり。ダッシュボードからも送れる
Pusher Beamsシンプル。SDK 完備
Firebase Cloud Messaging (FCM)Google 製。モバイルとも統合
Knockワークフロー定義型の通知配信
自前実装 (web-push)シンプルだがスケール時はサービスを検討

通知の表示

Notification API(SW なしでも使える)

if (Notification.permission === "granted") {
  new Notification("タイトル", { body: "本文", icon: "/icon.png" })
}

SW 経由(PWA でデフォルト)

const reg = await navigator.serviceWorker.ready
await reg.showNotification("タイトル", {
  body: "本文",
  icon: "/icon.png",
  data: { url: "/path" },
})

通知のオプション

プロパティ意味
body本文
iconアイコン
badgeステータスバーの小アイコン(Android)
image大きな画像
tag同じタグは置き換え
renotifytag 上書き時に再通知音
requireInteraction消すまで残る
silent音・振動なし
vibrate振動パターン(数値配列)
dataカスタムデータ
actionsアクションボタン(最大2〜3個)
timestamp表示時刻
dirテキスト方向
lang言語

UX 設計

注意点