アプリを作る気はなくてWebサービスで通知してかつPWAにしてクロスプラットフォームでアプリ化もできるようにしたいというモチベーションがあったので、Push通知の仕組みを理解するために最小構成で実装してみた。
PWA とは
Progressive Web Apps とは Web 技術 (HTML/CSS/JS) で作ったサイトを「インストール可能・オフライン対応・プッシュ通知対応」などアプリライクに拡張する一連のパターン / 機能群である。
コアになるのが:
要素 | 役割 |
---|---|
Service Worker | ネットワーク介在 / キャッシュ制御 / Push 受信 / バックグラウンド処理 |
Web App Manifest | 名前・アイコン・起動モード (display) 等を OS / ブラウザへ宣言 |
HTTPS | セキュアコンテキスト必須 (localhost は例外) |
Push API & Notifications API | サーバー発→ブラウザ→ユーザー通知 |
今回フォーカスするのは Push と Notification である。キャッシュや offline は割愛し最短ルートで通知を通す。
Push 通知の仕組み
- ブラウザが Service Worker を登録
- ユーザーが通知許可を与える (Notifications API)
- アプリ (フロント) が PushManager.subscribe() で購読を作成
- ブラウザはベンダー毎の Push Service (FCM, WNS, etc.) に endpoint を発行
- 公開鍵 (p256dh) と認証鍵 (auth) を含む JSON が返る
- その購読情報をアプリサーバーへ保存
- サーバーは web-push ライブラリで VAPID 署名付きの HTTP リクエストを Push Service へ投げる
- Push Service が対象ブラウザへ配送 → Service Worker の 'push' イベント発火
- Service Worker が showNotification() で OS 通知に表示
購読はブラウザ / 端末 * アプリオリジン 毎に固有である。1 ユーザーが複数端末を使えば購読は複数必要になる。
完成イメージ
シンプルな 1 ページ構成:
- 見出し下に「通知を表示」「通知解除」ボタン
- ステータス表示 (購読中 / 未購読)
- コンソールに最小ログ (サブスク完了 / サブスク終了)
- サーバー側 curl で任意タイトルと本文を即時通知
全体構成
開発環境
- VS Code Dev Container (Node.js & Typescript)
- Node v22.12.0
- Express + web-push
コンポーネント役割
コンポーネント | ファイル | 役割 |
---|---|---|
クライアント | public/main.js | 許可要求 / 購読生成 / サーバー同期 / 解除 |
Service Worker | public/sw.js | push イベント受信 → 通知表示 / クリック挙動 |
サーバー | server.js | VAPID 鍵生成・購読保存・送信・解除 |
簡易永続化 | subscriptions.json | 再起動後も購読を復元 |
ファイル構成 (抜粋)
public/
index.html
style.css
main.js
sw.js
server.js
vapid-keys.json (開発時自動生成)
subscriptions.json (購読保存)
主要技術要素と要点
要素 | ポイント |
---|---|
Service Worker | install/activate で即時制御 (skipWaiting + clients.claim) |
PushManager.subscribe | applicationServerKey は URL Safe Base64→Uint8Array 変換が必要 |
VAPID | サーバー署名で送信主体を証明 (メールアドレス or URL 指定) |
web-push ライブラリ | 暗号化 + VAPID 署名 + Push Service への送信を肩代わり |
失効処理 | 410 / 404 で購読削除しリーク防止 |
購読再同期 | 既存 subscription でもサーバー再起動後に /subscribe 再送 |
明示的解除 | unsubscribe + /unsubscribe でサーバー側も削除 |
実装ステップ詳もろもろ
1. プロジェクト作成パッケージ追加
npm init -y
npm install express web-push
2. VAPID 鍵生成
server.js
起動時に vapid-keys.json
が無ければ 生成する。
const generated = webPush.generateVAPIDKeys();
fs.writeFileSync(keyFile, JSON.stringify(generated, null, 2));
本番は環境変数 VAPID_PUBLIC_KEY
/ VAPID_PRIVATE_KEY
を注入する。
3. Service Worker 登録
navigator.serviceWorker.register('/sw.js?v=2');
クエリ付与でキャッシュをバストさせる。
4. 通知許可
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
5. 公開鍵取得エンドポイント
app.get('/vapidPublicKey', (_,res)=> res.json({ publicKey: vapidKeys.publicKey }));
6. 購読生成 & 再同期
const vapidRes = await fetch('/vapidPublicKey');
const { publicKey } = await vapidRes.json();
const keyBuf = urlBase64ToUint8Array(publicKey);
sub = await reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:keyBuf });
await fetch('/subscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(sub)});
7. サーバーで保存
/subscribe
受信 → endpoint
をキーに Map + JSONとして保存。
app.post('/subscribe',(req,res)=>{
const s=req.body; if(!s?.endpoint) return res.status(400).json({error:'Invalid'});
if(!app.locals.subscriptionMap) app.locals.subscriptionMap=new Map();
app.locals.subscriptionMap.set(s.endpoint,s); persistSubs();
console.log('サブスク完了');
console.log(`サブスク数 ${app.locals.subscriptionMap.size}`);
res.json({status:'ok'});
});
8. 送信 /send
await webPush.sendNotification(subscription, JSON.stringify({title, body}));
失効コード 410 / 404 を受け取った場合は削除し サブスク終了
を記録する。
9. Service Worker で通知
self.addEventListener('push',e=>{
let data={title:'Default', body:'...'};
if(e.data){try{data=JSON.parse(e.data.text())}catch{}}
e.waitUntil(self.registration.showNotification(data.title,{body:data.body}));
});
10. クリック挙動
既存タブをフォーカスするか新規オープンする。
11. 解除 /unsubscribe
app.post('/unsubscribe',(req,res)=>{ const {endpoint}=req.body||{}; const map=app.locals.subscriptionMap||new Map(); if(map.delete(endpoint)){persistSubs(); console.log('サブスク終了'); console.log(`サブスク数 ${map.size}`);} res.json({status:'ok'}); });
フロント側で sub.unsubscribe()
実行後にサーバーへ endpoint を POST する。
主要コード抜粋
server.js (要点)
function ensureVapidKeys(){ /* 省略 (自動生成 or 環境変数) */ }
webPush.setVapidDetails('mailto:example@example.com', vapidKeys.publicKey, vapidKeys.privateKey);
app.post('/send', async (req,res)=>{ /* 送信 & 410/404 削除 */ });
main.js 購読確立
async function ensureSubscription(){
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if(!sub){ /* 公開鍵取得 → subscribe() */ }
await fetch('/subscribe',{ method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(sub)});
console.log('サブスク完了');
return sub;
}
購読解除
const sub = await reg.pushManager.getSubscription();
await sub?.unsubscribe();
await fetch('/unsubscribe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({endpoint:sub?.endpoint})});
console.log('サブスク終了');
sw.js push イベント
self.addEventListener('push', event => { /* JSON 解析 → showNotification */ });
self.addEventListener('notificationclick', event => { /* 既存クライアント focus */ });
デバッグ & 確認ポイント
観点 | 方法 |
---|---|
登録済購読一覧 | GET /debug/subscriptions |
生データ | GET /debug/subscriptions/raw |
手動通知送信 | curl POST /send |
解除後の件数 | /unsubscribe 実行 → /debug/subscriptions |
失効処理 | endpoint を無効化 (別ブラウザで unsub) → /send |
動作確認
サーバー起動
npm run dev
ブラウザで http://localhost:3000
にアクセスし、通知を許可して「通知を表示」ボタンをクリック。
クリックすると「購読中」表示される。
PCの通知も来ること確認。
続いてブラウザを閉じてサーバー側で curl で送信してみる👀
# 通知送信
curl -s -X POST http://localhost:3000/send \
-H 'Content-Type: application/json' \
-d '{"title":"警告","body":"PCのあらゆるデータがインターネットに公開されました💪"}'
ちゃんと通知が来た。こんな通知きたらびっくりするよね。
まとめ
割と簡単にPush通知の実装ができた。
POCなのVAPIDの管理を簡易にしているが、本番では環境変数やAWS Secrets Manager等で厳重に管理することが不可欠である。また、Service WorkerとPush APIの仕様上、localhost環境以外ではHTTPSが必須。
あとは通知のタイミングはアプリのユースケースに合わせて設計する。
例えばチャットアプリなら新着メッセージ受信時、ECサイトなら注文状況更新時などなど。
以上!!