目次12

アカウント登録もメールアドレスも無しで通知を届けたい、という要件から入ると、Web Push は最初の一歩でつまずく。「通知を送る」の前に「誰に送るのか」を何かで持たないといけないのに、ログインが無いのだから user_id もメールも無い。

個人でヤスゴロという価格ウォッチャーを作って公開した。ウォッチした消耗品が安くなったら通知する、というものだが、ここは「ログイン不要・匿名・メール登録なし」を看板にした。だから通知の宛先を、アカウントではなく匿名の識別子で持つ必要があった。

この記事は「何を通知するか」(買い時の判定)ではなく、「ログイン無しでどう届けるか」の配線に絞る。匿名 device_token の発行、購読の保存、失効の扱い、そして iOS と FCM の実機で踏んだ穴まで。宛先の持ち方さえ決まれば、あとの Web Push 自体は仕様どおりの標準的な配管だ。

宛先はメールでもアカウントでもなく匿名 device_token にする

ログイン無しで通知を届けるなら、識別子は端末に発行する匿名トークンにするのが素直だ。ヤスゴロは初回訪問で UUID v4 を 1 つ発行し、それを device_token として cookie に保存する。

cookie は httpOnly + Secure + SameSite=Lax、パスはアプリのサブパスに固定、有効期限は 1 年。サーバ側のミドルウェアが「cookie が無ければ発行する」だけを担当する。

export function deviceTokenMiddleware() {
  return async (c, next) => {
    let token = getCookie(c, DEVICE_COOKIE);
    if (!token) {
      token = crypto.randomUUID();            // 匿名識別子(UUID v4)
      setCookie(c, DEVICE_COOKIE, token, {
        path: '/yasugoro', httpOnly: true, secure: true,
        sameSite: 'Lax', maxAge: 60 * 60 * 24 * 365,
      });
    }
    c.set('deviceToken', token);
    await next();
  };
}

メールアドレスを取らないので、個人情報を保持せずに済む。宛先は「この端末」であって「この人」ではない、と割り切るのがこの設計の芯だ。crypto.randomUUID() は Cloudflare Workers でも Node でも同じ API で使える。

device_token で user を UPSERT し、購読をぶら下げる

匿名トークンが決まれば、あとは普通のリレーションだ。device_token をユニークキーにした users 行を UPSERT し、Web Push の購読(push_subscriptions)を user に紐づける。

// device_token で users を冪等に確保して user.id を返す
await db.prepare(
  `INSERT INTO users (device_token, settings_json, plan, created_at, updated_at)
   VALUES (?, '{}', 'free', ?, ?)
   ON CONFLICT(device_token) DO UPDATE SET updated_at = excluded.updated_at`
).bind(deviceToken, ts, ts).run();

購読側は 1 ユーザーに複数行ぶら下げる。ブラウザの PushManager.subscribe() が返す購読オブジェクトから endpoint / p256dh / auth を取り出して保存する。PC とスマホから同じウォッチを登録すれば、2 端末とも通知が飛ぶ。

中身メモ
user_iddevice_token で確保した users.idON DELETE CASCADE
endpointプッシュサービスの URLUNIQUE(端末+ブラウザで一意)
p256dh / auth購読の公開鍵と認証シークレットペイロード暗号化に使う
uaUser-AgentiOS PWA 等の端末判別(到達率 KPI)
revoked_at失効時刻404/410 で立てる。DELETE しない

endpointUNIQUE にしておくと、同じ端末が再購読しても行が増えない。

flowchart TD
  Visit[初回訪問] --> Cookie[device_token 発行 / cookie]
  Cookie --> Allow[通知を許可]
  Allow --> Sub[PushManager.subscribe]
  Sub --> Post[POST /api/subscribe]
  Post --> User[users UPSERT device_token]
  User --> Save[push_subscriptions に保存]
  Save --> Send[Workers から VAPID 署名で送信]

購読を確定する POST /api/subscribe は、購読情報の保存とウォッチ登録を 1 リクエストで済ませている。cookie の device_token はサーバが持っているので、クライアントは「何をウォッチするか」と購読オブジェクトだけを送ればいい。

送信は Cloudflare Workers + VAPID を webcrypto で

送信側は Cloudflare Workers から VAPID 署名付きで各端末のエンドポイントを叩く。Node の web-push ライブラリは Workers では動かないので、webcrypto で動くライブラリ(@block65/webcrypto-web-push)を使う。

やることは 3 つだけだ。有効な購読を引く、ペイロードを暗号化してヘッダを組む、エンドポイントへ fetch する。

const req = await buildPushPayload(
  { data: toWebPushBody(payload) },   // SW が受け取る JSON
  toLibSubscription(sub),             // endpoint + p256dh + auth
  { subject, publicKey, privateKey }, // VAPID 鍵
);
const res = await fetch(req.endpoint, req.init);

ペイロードは購読ごとの鍵で aes128gcm 暗号化される。VAPID 鍵は env(シークレット)から組み、subject は mailto: かアプリの URL にする。送信本体はここまでで、通知の中身(タイトル・本文・遷移先 URL)は上流で組んだものを Payload として受け取るだけにしてある。ここに「いつ・なぜ送るか」の判断は持ち込まない。

実機で踏んだ穴: FCM の VAPID ヘッダと iOS の standalone

ローカルで通ったコードが実機の Chrome と iPhone で無言で失敗する、というのが Web Push の洗礼だった。詰まったのは 2 か所。

FCM が VAPID の Authorization ヘッダを拒否する。 使ったライブラリは古いドラフト(draft-01)の Authorization: WebPush <jwt> 形式でヘッダを出すが、Chrome/Android の裏にいる FCM はこれを invalid JWT provided(403)で弾く。新しいドラフト(draft-02)の vapid t=<jwt>, k=<publicKey> 形式に組み替えると 201 で受理された。Apple や autopush も draft-02 を受けるので、送信直前にヘッダを変換している。

// draft-01「WebPush <jwt>」→ draft-02「vapid t=<jwt>, k=<key>」
const m = headers[authKey].match(/^WebPush\s+(.+)$/i);
if (m) headers[authKey] = `vapid t=${m[1].trim()}, k=${vapid.publicKey}`;

iOS はホーム画面に追加しないと購読できない。 iOS/iPadOS の Safari は、PWA をホーム画面に追加して standalone で起動したときだけ Web Push を購読できる。通常のタブでは PushManager.subscribe() が失敗する。だからクライアントは、iOS かつ standalone でない端末を検出したら、購読を試みずにホーム画面追加のガイドを出す。

if (isIOS() && !isStandalone()) {
  showHomeScreenGuide();   // 購読は試みない
  return;
}

ここを分岐しないと、iPhone ユーザーは許可ボタンを押しても何も起きない、という一番わかりにくい失敗をする。standalone 判定は matchMedia('(display-mode: standalone)')navigator.standalone の両方を見る。

失効した購読は DELETE せず revoked_at で寝かせる

送信が 404 か 410 を返した購読は、行を消さずに revoked_at を立てて論理失効にする。到達率を計測したいからだ。物理削除すると、発行された購読のうちどれだけが生き残っているかを後から数えられない。

送信結果は HTTP ステータスで 3 つに分けている。

  • 404 / 410 Gone … 失効。revoked_at を更新して、以降は宛先から外す(リトライしない)
  • 429 / 5xx … 一時失敗。リトライ可能として上流のディスパッチャに戻す
  • その他 2xx … 成功
export function classifyPushStatus(status: number) {
  if (status === 404 || status === 410) return { revoked: true, retryable: false };
  if (status === 429 || status >= 500) return { revoked: false, retryable: true };
  return { revoked: false, retryable: false };
}

有効な購読を引くクエリは WHERE user_id = ? AND revoked_at IS NULL で、revoked_at に部分インデックスを張って有効行だけを速く引く。1 ユーザーの全端末に送って 1 つでも成功すれば「届いた」と見なし、全滅かつリトライ可能なときだけ再送に回す。失効しか残っていなければ再送しない(宛先が消えたのだから、待っても届かない)。

ログイン無しでも CSRF は device_token 由来の HMAC で守る

ログインが無くても POST は守る必要がある。ヤスゴロは device_token を鍵にした HMAC で CSRF トークンを導出していて、サーバ側にセッションを持たない。

// device_token を種に CSRF トークンを HMAC-SHA256 で導出(サーバ状態不要)
const key = await crypto.subtle.importKey('raw', secretBytes,
  { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key,
  new TextEncoder().encode(`csrf:${deviceToken}`));

POST を受けるときは、同一オリジン(Origin/Referer 検証)に加えて、送られてきた _csrf が cookie の device_token から導けるトークンと一致するかを定時間比較で確かめる。ログインセッションが無くても、cookie に紐づくトークンで「このフォームはこの端末が出した」ことを確認できる。

まとめ

ログイン無しの Web Push は、宛先を匿名 device_token にした瞬間にほとんど解ける。あとは購読を user にぶら下げ、Workers から VAPID で送り、失効を寝かせる、という標準的な配管だ。実機で効いてくるのは、FCM のヘッダ形式と iOS の standalone 制約という 2 つの落とし穴のほうだった。

ここで書いたのは「どう届けるか」だけで、「何を・いつ届けるか」(実質価格の比較と買い時の判定)はヤスゴロの別の層にある。価格の計算を確実性で 3 層に分けた話は実質価格の計算メモに、複数モールで同じ商品をどう同定したかはJAN 横断マッチングの記事に書いた。動くものはヤスゴロで、ログイン不要のまま試してほしい。

よくある質問

ログインもメールも無しで、誰に通知を送るかはどう管理するの?

初回訪問で匿名の device_token(UUID v4) を発行し、httpOnly cookie に保存します。この device_token をキーに users 行を UPSERT し、通知の購読情報(push_subscriptions)を user に紐づけます。メールアドレスもアカウントも持たず、識別子は端末に発行した匿名トークンだけ。1 ユーザーに複数端末の購読をぶら下げられるので、PC とスマホの両方から同じウォッチに通知できます。

device_token が変わると別端末として扱われ、マイウォッチ画面は空になります。ログイン無しの割り切りなので、履歴消去や別ブラウザは新しい匿名ユーザーになります。cookie 消去にある程度耐えるため device_token は localStorage にも二重持ちしていますが、これは保険で、完全な永続を約束するものではありません。永続が要る人向けに逃げ道(LINE 連携)は別に用意しています。

失効した購読を DELETE せず revoked_at で論理失効させるのはなぜ?

送信レスポンスが 404/410 Gone を返した購読は、行を消さず revoked_at にタイムスタンプを立てて論理失効にしています。到達率を KPI として見たいからで、物理削除すると「発行された購読のうちどれだけが失効したか」を後から数えられなくなります。有効購読を引くクエリは revoked_at IS NULL で絞り、そこに部分インデックスを張っています。

iOS で通知が来ないのはなぜ?

iOS/iPadOS の Safari は、ホーム画面に追加した PWA を standalone で起動したときだけ Web Push を購読できます。通常のブラウザタブでは購読自体が失敗するので、iOS かつ standalone でない端末では購読 API を叩かず、ホーム画面追加の手順ガイドを出す分岐にしています。ここを踏まないと、許可ボタンを押しても無言で失敗します。