プッシュ通知(Web Push)
ユーザーがブラウザを閉じていても通知を出せる仕組み。 サーバからPush Service(Apple / Google / Mozilla)に投げて、Service Worker が受信→表示する。
仕組み
- クライアントが
pushManager.subscribe()で Push Service の URL を取得 - その URL(subscription)を自社サーバに保存
- サーバが Push Service に POST で通知を送る(VAPID 認証)
- Push Service が SW に届ける
- 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 では軽量実装を使う:
- webpush-rs + WASM
- 自前で VAPID + AES-128-GCM 暗号化を Web Crypto で書く
- 外部サービス(OneSignal / Pusher Beams 等)にデリゲート
iOS Safari の制約
- iOS 16.4+ で対応開始(だがホーム画面追加が必須)
- ブラウザ Safari からは送れない
- ホーム画面追加されると Web Push が機能する
- 権限を出すにはユーザー操作起点必須
サーバ側: 主要サービス
| サービス | 特徴 |
|---|---|
| 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 | 同じタグは置き換え |
renotify | tag 上書き時に再通知音 |
requireInteraction | 消すまで残る |
silent | 音・振動なし |
vibrate | 振動パターン(数値配列) |
data | カスタムデータ |
actions | アクションボタン(最大2〜3個) |
timestamp | 表示時刻 |
dir | テキスト方向 |
lang | 言語 |
UX 設計
- 役立つ通知だけ送る。プロモは嫌われる
- ユーザーがカテゴリ別に ON/OFF できる
- 送信頻度を抑える(1日数回まで)
- クリックしたら関連ページに直行(タブ復元)
- 静音時間を尊重(夜間は送らない)
注意点
- iOS の制約(前述)— iOS 用の UI 案内は別途
- userVisibleOnly: true 必須(無音 push は不可)
- 購読は有効期限がある(数か月〜)。失効時の再購読フロー
- ペイロードは小さく(多くの Push Service で4KB 程度まで)
- HTTPS 必須(localhost は除く)