
Article
Cloudflare Workers で汎用 Push Notification Gateway を作った
自分のアプリ群(iOS / macOS / Web)全部で共通して使える Push 通知の中継サーバを Cloudflare Workers で作った話。無料枠の範囲で、APNs / FCM / Web Push を一本の API で扱えるようになった。
きっかけ
個人開発で iOS や Mac のアプリを何本か作っていると、アプリごとに APNs を叩く処理を書くのが地味に面倒だった。Bundle ID も証明書も P8 キーもアプリごとに違うし、バックエンドが複数あると「どのアプリ用のクレデンシャルをどこに置くか」も散らかる。
それをアプリ側ではなく「中継サーバを 1 台立てて、そいつにトークン登録と送信依頼を投げれば APNs / FCM / Web Push に振り分けてくれる」という形にしたかった。既存サービス(Firebase Cloud Messaging, OneSignal, Pushwoosh など)も検討したが、個人の全アプリ分を足しても無料枠に収まる規模で、かつ P8 キーの置き場所を自分の管理下に置きたい という理由で自作することにした。
技術選定
- Cloudflare Workers: 無料枠で十分、コールドスタートほぼなし、HTTP/2 対応、Web Crypto が使える
- Hono: Workers 向けの軽量フレームワーク、型も効く
- D1: デバイストークンと送信ログ、そしてアプリ単位のクレデンシャルを保存
- TypeScript
fetch() で APNs / FCM / Web Push の各 HTTP API を直接叩く構成にした。外部依存は最小限で、JWT 署名も VAPID の ECDH も全部 Web Crypto API で完結する。
設計の肝 1: APNs の sandbox / production を「デプロイ 1 個」で扱う
APNs には HTTP エンドポイントが 2 つある。
api.sandbox.push.apple.com: Xcode からの実機デバッグビルド用api.push.apple.com: TestFlight / App Store 用
普通にやると「Xcode デバッグビルドのトークンは sandbox に、リリースビルドは production に送る」を切り替える必要があって、バックエンドも 2 面(staging / production)に分けるのが定石。でもこれ、個人開発だと面倒だ。
そこで、Worker は 1 デプロイで運用し、デバイス単位にどちらに送るかを記録する方式にした。
CREATE TABLE devices (
id TEXT PRIMARY KEY,
app TEXT NOT NULL,
platform TEXT NOT NULL,
device_token TEXT NOT NULL,
user_id TEXT,
environment TEXT DEFAULT 'production',
...
);
iOS クライアントは POST /devices 時に environment: "sandbox" | "production" を必ず送る。Swift 側は #if DEBUG 1 箇所だけで決まる。
let environment: String = {
#if DEBUG
return "sandbox"
#else
return "production"
#endif
}()
Worker は送信時に devices.environment を見て APNs ホストを切り替えるだけ。
const host = device.environment === 'sandbox'
? 'api.sandbox.push.apple.com'
: 'api.push.apple.com'
これで staging / production 環境の分離は不要になった。Bundle ID は DEBUG / Release で変えない前提(これは Xcode の Build Setting で統一するのが楽)。P8 キーは sandbox / production 共通で使えるので、クレデンシャル管理も一本化される。
設計の肝 2: クレデンシャルは D1 の apps テーブルに
普通の Cloudflare Workers のお作法だと、秘密情報は wrangler secret put で環境変数として Worker にバインドする。でも、複数アプリを扱うマルチテナント構成にするとこれが破綻する。
- アプリが増えるたびに secret が増える(
APNS_P8_COMD,APNS_P8_SIMPLEMONEY, など) - P8 をローテーションするたびに
wrangler secret putして再デプロイが要る - FCM の Service Account JSON は改行込みでサイズが大きく、CLI から入れるのが結構面倒
なのでアプリ単位のクレデンシャルは 全部 D1 に入れた。
CREATE TABLE apps (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
api_key_hash TEXT NOT NULL,
apns_p8_key TEXT,
apns_key_id TEXT,
apns_team_id TEXT,
apns_bundle_id TEXT,
fcm_service_account TEXT,
fcm_project_id TEXT,
vapid_public_key TEXT,
vapid_private_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Worker secret に入れるのは ブートストラップ用の ADMIN_API_KEY 1 個だけ。これで管理 API を叩いてアプリを登録する。
curl -X POST https://push.example.com/admin/apps \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-d '{
"id": "myapp",
"name": "My App",
"apns": { "p8_key": "...", "key_id": "...", "team_id": "...", "bundle_id": "..." }
}'
# → { "id": "myapp", "api_key": "pg_live_…" }
P8 ローテーションは PATCH /admin/apps/:id で差し替えるだけ。再デプロイ不要。
認証の 3 層
結果として認証情報が 3 層になった。
| 用途 | キー | 保管場所 |
|---|---|---|
| 管理 API(アプリ登録 / 削除) | Admin API Key | Worker secret |
| クライアントが Gateway を叩く | App API Key (pg_live_…) |
D1 apps.api_key_hash(SHA-256) |
| Gateway が APNs / FCM / Web Push を叩く | P8 / Service Account / VAPID | D1 apps テーブル |
App API Key は発行時にレスポンスで一度だけ平文で返し、D1 にはハッシュのみ保存。紛失したら POST /admin/apps/:id/rotate-key で再発行(旧キーは即失効)。ここは GitHub の Personal Access Token と同じ思想。
Web Crypto だけで JWT / VAPID を完結
Cloudflare Workers は Node 互換ではない(※ オプトインの nodejs_compat はあるが最小限に留めたかった)ので、APNs JWT(ES256)も VAPID(ES256)も Web Crypto API で書いた。
const key = await crypto.subtle.importKey(
'pkcs8',
pemToArrayBuffer(apns_p8_key),
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
new TextEncoder().encode(`${headerB64}.${payloadB64}`)
)
Web Crypto が返す ECDSA 署名は r || s の raw フォーマットで、JWT が欲しいのも同じ raw フォーマットなので Node みたいに DER から変換する手間がない。Workers 環境だと むしろ Node よりシンプルに書ける。
Web Push(RFC 8030 + RFC 8291)は少し面倒で、ECDH で共有鍵を導出して HKDF → AES-128-GCM で暗号化する。これも全部 Web Crypto で書けた。依存ライブラリゼロ。
FCM の OAuth2 トークンキャッシュ
FCM v1 API は Service Account JWT → OAuth2 access token → API call の 2 段構えで、access token は 1 時間有効。毎回取り直すと Google の token endpoint への無駄な呼び出しが増えるので、Worker のグローバルスコープに(インスタンス単位で)キャッシュを持たせた。
const tokenCache = new Map<string, { token: string; expiresAt: number }>()
async function getAccessToken(app: App): Promise<string> {
const cached = tokenCache.get(app.id)
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.token
}
const token = await fetchNewToken(app)
tokenCache.set(app.id, { token, expiresAt: Date.now() + 3_500_000 })
return token
}
Worker インスタンスは短命だが、同じインスタンス内の連続リクエストには効くし、トークン再取得時の 401 自動リトライも実装したので「キャッシュが古くて失敗」する事故は起きにくい。
運用: 最初の実ユーザーは自作の iOS アプリ
実装完了後、最初のコンシューマーとして自作の Atlas iOS アプリを繋いだ。Atlas は「受信ボックスに drop された PDF や画像を AI 処理してナレッジベースに取り込む」アプリで、処理完了通知を push で出したかった。
バックエンドの watcher から 1 分 debounce でまとめ通知を投げる構成にしたら、/send/user が綺麗に当てはまった。
POST /send/user
{
"app": "atlas",
"user_id": "me",
"title": "Inbox",
"body": "3 件追加されました",
"data": { "count": 3 }
}
push-gateway は user_id に紐づく全デバイス(iPhone, iPad, Mac)に fan-out してくれる。invalid_token(APNs が 410 を返したデバイス)は自動的に devices から削除されるので、お掃除も不要。
iOS 側の実装は事前に書いたガイドドキュメントどおりに進めて数時間で完了した。Swift 6 の strict concurrency 対応(actor でクライアントをラップ)も含めて、ハマりどころは特になし。
やってみた感想
- Workers + D1 は個人用マルチテナント中継サーバと相性がいい。無料枠で十分動くし、冷起動も気にならない。D1 のレイテンシも(APAC リージョンなら)無視できる
- APNs / FCM / Web Push の仕様差を吸収する層があると、上のアプリが一気に楽になる。クライアントは
title,body,dataを投げるだけで済む - クレデンシャルを DB に置く方式は、管理 API があればローテーションが楽。secret 管理はブートストラップキー 1 個だけで済む
- 単一デプロイ + environment カラムは staging / production 分離の複雑性を吸収する小さいが効く工夫。個人開発では特に効く
ソースを公開するかどうかはこれから考えるけど、同じようなことを考えている人がいたら設計の参考になれば幸い。
関連記事(予定): VAPID + AES-128-GCM を Web Crypto だけで書く / Cloudflare Workers の nodejs_compat を使わずにどこまで戦えるか

