VanishChat - DBなし・完全ステートレスなメッセンジャーをCloudflare Workersで作った
はじめに
Web Pushの仕様を調べていたときのこと。
「受信者がオフラインの場合、メッセージはブラウザベンダーのPush Service(GoogleやApple、Mozillaが運営)に一時的に保管される」
この一文を見て、ピンときました。
「これ、一時的なDBとして使えるのでは?」
メッセンジャーアプリを作ろうとすると、相手がオフラインのときにメッセージを保存しておく仕組みが必要になります。普通はDBを用意するわけですが、Push Serviceが勝手にキューイングしてくれるなら、サーバー側にDBは要らないはず。
そう思って作ったのがVanishChatです。
せっかくサーバーにデータを保存しないなら、E2E暗号化してサーバーが中身を読めないようにしたら面白いのでは?さらに、セルフホストできるようにして、異なるサーバー間でも通信できたら面白いのでは?
そんな好奇心ドリブンで開発を進めました。
ちなみにVanishChatという名前は、Push Serviceの仕様次第でメッセージがふわっと消えてしまう(vanish)ところから。配信保証がないので、文字通り「消えるチャット」になる可能性があるわけです。
🔗 デモ: https://vanishchat.karakuri-maker.com/
📦 リポジトリ: https://github.com/kiyo-e/vanishchat
対象読者
- Cloudflare Workersで何か作りたい人
- DBの管理から解放されたい人
- Web Pushの実用的な使い方に興味がある人
- E2E暗号化の実装例を見たい人
アーキテクチャの全体像
まず全体像から。
┌─────────────────┐ Web Push ┌─────────────────┐
│ Browser A │◄──────────────────│ Browser B │
│ (IndexedDB) │ │ (IndexedDB) │
└────────┬────────┘ └────────┬────────┘
│ │
│ E2E Encrypted │
│ Payload │
│ │
▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Cloudflare Workers (Relay Only) │
│ │
│ • Verifies capability tokens │
│ • Forwards encrypted payloads to Web Push endpoints │
│ • Cannot decrypt message contents │
└──────────────────────────────────────────────────────────┘
ポイントは3つ。
- サーバーはリレーのみ - メッセージを受け取って転送するだけ。保存しない
- データはすべてブラウザのIndexedDB - 鍵も連絡先もメッセージ履歴もローカル
- E2E暗号化 - サーバーもPush Serviceも中身を読めない
Web PushをDBとして使う発想
通常、リアルタイムメッセージングを実装しようとすると、相手がオフラインの場合に備えてメッセージをどこかに保存しておく必要があります。これがDBを使いたくなる最大の理由。
でもWeb Pushの仕様を見ると、オフライン時のメッセージ保持はPush Serviceがやってくれる。GoogleのFCMやAppleのAPNsが、デバイスがオンラインになるまでメッセージをキューイングしてくれるわけです。
つまり、Push Serviceを「他人が運営してくれる一時DB」として使える。
ただし、Push Serviceの保管期間は送信時に指定するTTL(Time To Live)に依存します。VanishChatではttl: 86400(24時間)を設定しているので、最大24時間のベストエフォート配信という形。それを過ぎると消えます。
この割り切りがあるからこそ、Cloudflare Workersの無料枠だけで動くメッセンジャーが成立しています。
Capability Token - DBなしで認可する仕組み
DBがないということは、「誰が誰にメッセージを送れるか」をサーバー側で管理できないということ。
これを解決するのがCapability Tokenです。
トークンの中身
Capability Tokenは、JWT風のHS256署名付きトークンで、中に受信者のPush Subscription情報がまるごと入っています。
{
"v": 1,
"iat": 1706000000,
"exp": 1706086400,
"sub": {
"endpoint": "https://fcm.googleapis.com/fcm/send/xxx...",
"keys": {
"p256dh": "...",
"auth": "..."
}
}
}
トークンを持っている=その人にPushを送る能力(capability)を持っている、という考え方。
サーバーは署名を検証して、トークンのsubからPush Subscription情報を取り出し、そのままWeb Pushを送るだけ。DBルックアップは一切不要です。
2種類のトークン
トークンは有効期限で2種類に分けています。
| 種類 | 有効期限 | 用途 |
|---|---|---|
| Invite Token | 24時間 | ペアリングリンク用。短命なので漏れても被害は限定的 |
| Long Token | 10年 | ペアリング完了後の連絡先間通信用 |
招待リンクには短命トークンを使い、ペアリングが完了したら長期トークンを交換する。これでセキュリティと利便性のバランスを取っています。
ペアリングフロー
友達追加の流れを見てみましょう。
1. 招待リンクの生成
招待する側は、以下の情報を含むリンクを生成します。
- ホームサーバーURL
- 短命Capability Token(24時間)
- E2E暗号化用の公開鍵
- 署名検証用の公開鍵
- 表示名(任意)
2. リンクを開いて連絡先追加
招待された側がリンクを開くと、相手が連絡先に追加されます。この時点では一方通行。
3. システムメッセージで双方向化
連絡先追加時に、招待された側から自動的に「cap-update」というシステムメッセージが送られます。
{
"type": "cap-update",
"capToken": "長期トークン",
"expiresAt": "...",
"hs": "ホームサーバーURL",
"encPubRawB64": "暗号化公開鍵",
"name": "表示名(任意)",
"reply": true
}
招待した側はこのメッセージを受け取って、自動的に相手を連絡先に追加。reply: trueなので、お返しに自分の長期トークンも送ります。
これで双方向の通信が確立。サーバーには何も保存せず、クライアント同士でトークン交換が完結するのがミソです。
セルフホスト間の通信
このペアリングフローの面白いところは、異なるホームサーバー間でも通信できること。
招待リンクにはホームサーバーURLが含まれているので、AさんがサーバーX、BさんがサーバーYを使っていても問題なし。メッセージ送信時は相手のホームサーバーに直接リクエストを投げるだけ。
ActivityPubのような複雑なフェデレーションプロトコルなしで、シンプルにサーバー間通信が実現できています。
E2E暗号化の実装
メッセージの暗号化はすべてブラウザ側で行います。WebCrypto APIを使って以下を実装。
暗号化
- 鍵交換: ECDH P-256
- 鍵導出: HKDF-SHA256
- 暗号化: AES-256-GCM
送信時は毎回エフェメラル鍵を生成して、受信者の公開鍵と組み合わせて共通鍵を導出。ペイロードは以下の形式。
{
"v": 1,
"s": "ソルト(base64url)",
"e": "エフェメラル公開鍵(base64url)",
"c": "暗号文(base64url)"
}
署名
暗号化に加えて、送信者の真正性を保証するためにECDSA P-256で署名もつけています。
復号後の平文メッセージには署名が含まれていて、受信側で送信者の公開鍵を使って検証。なりすましを防げます。
ハマりポイント
ペイロードサイズの壁
Web Pushのペイロードサイズ制限がブラウザやPush Serviceによってバラバラで、これが地獄でした。
結局、安全マージンを取って以下の制限を設けています。
- サーバー側ハードリミット: 2048バイト
- クライアント側ハードリミット: 1800バイト
- 警告表示: 約700バイト以上
チャンキング(分割送信)も検討しましたが、複雑さに対してメリットが薄いので見送り。「長いメッセージは短くしてね」という割り切りにしました。
iOS対応とpostMessageの罠
iOSでもWeb Pushを使うにはPWAとしてホーム画面に追加してもらう必要があります。なのでPWA対応を進めたのですが、ここで予想外の壁にぶつかりました。
Service Workerとメインスレッド間のpostMessageやBroadcastChannelの動作がiOSだと不安定。Pushを受信したときにService WorkerからクライアントにメッセージIDを通知して、クライアント側でIndexedDBから詳細を取得する、という流れを想定していたのですが、iOSだとこの通知がたまに届かない。
結局、iOS対策としてIndexedDBのポーリング(1.5秒間隔)を常時併用する形に落ち着きました。Service Worker側ではpostMessageとBroadcastChannelも送っているので、対応しているブラウザではそちらで即時反映されます。エレガントではないですが、動くことが正義。
未知の送信者からの通知
ペアリングが完了していない相手(未承認の送信者)からのメッセージをどう扱うか。通知を出すとスパムの温床になるし、完全に無視するとペアリングフローが成立しない。
結局、システムメッセージ(cap-update)は処理するけど、通常メッセージは未承認送信者からは通知を出さない、という方針に落ち着きました。
所感
Web Pushの仕様を調べていたら「これDBの代わりに使えるのでは?」と思いついて、そのまま勢いで作ったプロジェクトでした。
実際やってみると、Push Serviceの挙動はブラウザごとに微妙に違うし、iOSのPWA対応は癖が強いし、想定外のハマりポイントは多かったです。でも、DBなしでメッセンジャーが成立することは証明できました。
Capability Tokenの考え方は他のユースケースにも応用できそうな気がします。トークンに必要な情報を全部埋め込んでしまえば、サーバー側のステートを減らせる。一時的なファイル共有リンクとか、招待制のイベントページとか。
あと、セルフホスト可能なメッセンジャーをこんなにシンプルに作れるのは意外でした。ActivityPubみたいな複雑なプロトコルなしでも、トークンにホームサーバーURLを含めるだけでサーバー間通信ができる。
まとめ
VanishChatは、以下の技術を組み合わせてDBなしメッセンジャーを実現しています。
- Web Push - メッセージ配信とオフラインキューイング(Push ServiceをDBとして活用、TTL 24時間)
- Capability Token - DBレスな認可
- E2E暗号化 - プライバシー保護
- IndexedDB - クライアントサイドストレージ
デモは https://vanishchat.karakuri-maker.com/ で公開しています。友達と試してみてください。
リポジトリは https://github.com/kiyo-e/vanishchat で公開中。Cloudflare Workersの無料枠でセルフホストできるので、興味があればぜひ。
Discussion