「アカウント登録なし・メールなし」で Web Push を届けたい、という要件は、実装の最初の一歩でつまずきます。通知を送る前に「誰に送るか」を何かで持つ必要があるのに、ログインが無いと user_id もメールもありません。
価格ウォッチャーを個人で作ったとき、ここを匿名の device_token で解きました。この記事は Cloudflare Workers + Hono を前提に、ログイン不要の Web Push を成立させる最小構成をまとめます。宛先の持ち方さえ決まれば、あとは仕様どおりの配線です。
宛先は匿名 device_token(UUID) にする
ログインが無いなら、識別子は端末ごとに発行する匿名トークンにするのが素直です。初回訪問で UUID を 1 つ発行し、httpOnly cookie に保存します。
// Hono middleware: cookie が無ければ発行するだけ
export function deviceTokenMiddleware() {
return async (c, next) => {
let token = getCookie(c, 'ysg_device');
if (!token) {
token = crypto.randomUUID(); // 匿名 UUID v4
setCookie(c, 'ysg_device', token, {
path: '/', httpOnly: true, secure: true,
sameSite: 'Lax', maxAge: 60 * 60 * 24 * 365,
});
}
c.set('deviceToken', token);
await next();
};
}
crypto.randomUUID() は Workers でも Node でも同じ API です。メールを取らないので個人情報を持たずに済みます。宛先は「この人」ではなく「この端末」、と割り切るのがポイントです。
device_token で user を UPSERT し、購読をぶら下げる
匿名トークンが決まれば、あとは普通のリレーションです。device_token をユニークキーにした users を UPSERT し、Web Push の購読を user に紐づけます(D1 / SQLite の例)。
await db.prepare(
`INSERT INTO users (device_token, created_at, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(device_token) DO UPDATE SET updated_at = excluded.updated_at`
).bind(deviceToken, ts, ts).run();
購読は 1 ユーザーに複数行ぶら下げます。ブラウザの PushManager.subscribe() が返すオブジェクトから endpoint / p256dh / auth を保存すれば、PC とスマホの両方に届きます。endpoint を UNIQUE にしておくと、再購読で行が増えません。
CREATE TABLE push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT UNIQUE NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
revoked_at TEXT -- 404/410 で立てる。DELETE しない
);
失効した購読は DELETE せず revoked_at を立てる論理失効にしておくと、有効購読は WHERE revoked_at IS NULL で引けます。
送信は Workers + VAPID を webcrypto で
Node の web-push は Workers では動かないので、webcrypto で動くライブラリ(@block65/webcrypto-web-push)を使います。やることは、有効な購読を引く・ペイロードを暗号化してヘッダを組む・fetch する、の 3 つです。
const req = await buildPushPayload(
{ data: { title, body, data: { url }, tag } }, // SW が受け取る JSON
{ endpoint, expirationTime: null, keys: { p256dh, auth } },
{ subject, publicKey, privateKey }, // VAPID 鍵(env)
);
const res = await fetch(req.endpoint, req.init);
ペイロードは購読ごとの鍵で aes128gcm 暗号化されます。VAPID 鍵は env のシークレットから組み、subject は mailto: かアプリ URL にします。
実機で詰まる2つ: FCM のヘッダと iOS の standalone
ローカルで通ったコードが Chrome と iPhone で無言で失敗する、というのが Web Push の洗礼でした。
FCM が VAPID の Authorization ヘッダを拒否する。 ライブラリが出す古いドラフト(draft-01)の Authorization: WebPush <jwt> を、FCM は invalid JWT provided(403)で弾きます。新しい draft-02 の vapid t=<jwt>, k=<publicKey> 形式に組み替えると 201 で受理されました。Apple や autopush も draft-02 を受けます。
const m = headers[authKey].match(/^WebPush\s+(.+)$/i);
if (m) headers[authKey] = `vapid t=${m[1].trim()}, k=${publicKey}`;
iOS はホーム画面に追加しないと購読できない。 iOS/iPadOS の Safari は、PWA をホーム画面に追加して standalone 起動したときだけ購読できます。通常タブでは subscribe() が失敗するので、iOS かつ standalone でない端末は購読を試みず、ホーム画面追加のガイドを出します。
if (isIOS() && !isStandalone()) {
showHomeScreenGuide(); // 購読は試みない
return;
}
// standalone 判定は matchMedia('(display-mode: standalone)') と navigator.standalone の両方を見る
ここを分岐しないと、iPhone ユーザーは許可ボタンを押しても何も起きない、という一番わかりにくい失敗をします。
まとめ
ログイン無しの Web Push は、宛先を匿名 device_token にした瞬間にほとんど解けます。あとは購読を user にぶら下げ、Workers から VAPID で送るだけ。実機で効くのは FCM のヘッダ形式と iOS の standalone 制約の 2 つです。
失効購読を DELETE せず到達率 KPI として残す設計、ログイン無しでの CSRF(device_token 由来の HMAC)、通知を冪等に届ける outbox など、実運用で見えた設計は Aulvem 本家にまとめました → Aulvem|ログイン不要でWeb Push を届ける。動くものはヤスゴロで、ログイン不要のまま試せます。