Durable Objectで、ブラウザからライブのClaude Codeセッションに話しかける
はじめに
ターミナルで動いているコーディングエージェント(Claude Code)に、スマホのブラウザから話しかけたい。しかもリアルタイムに、双方向で。
「PCの前を離れたけど、あの作業の続きを指示したい」「外出先でエラー報告だけ受け取りたい」——このニーズを、Cloudflareの Durable Object をハブにして解いた。作ってみたら、コーディングエージェントのセッションを「メッセージを送れる第一級のエンドポイント」として扱う、という発想がけっこう気持ちよかったので構成を残しておく。
セキュリティに関わる具体値(トークンや認証バイパスの詳細)は伏せて、パターンとして書く。
やりたいこと
登場人物は2人だ。
- ブラウザ(PWA) — スマホやPCで開くポータル。ここからメッセージを送る/受け取る
- 母艦セッション — PCのターミナルで動いているClaude Code本体
この2つを、低遅延の双方向チャネルで繋ぐ。ブラウザから送ったら即座に母艦へ届き、母艦の返事も即座にブラウザへ返る。しかも、母艦が一時的に落ちていてもメッセージは失われない。
素朴にやるとポーリング地獄になる。母艦がずっとサーバーに問い合わせ続けるのは、無駄なリクエストとCPU時間を垂れ流すことになるし、間隔ぶんの遅延も出る。ここをどう設計するかが肝だった。
全体像
メッセージが流れる経路はこうだ。
graph TD
B["ブラウザ(PWA)"]
W["Worker + Hono"]
D1[("D1 受信箱<br>status: pending")]
DO["Durable Object<br>ChatHub"]
S["母艦セッション<br>Claude Code"]
B -->|"POST /api/messages"| W
W -->|"保存(耐久)"| D1
W -->|"notify"| DO
DO -->|"WebSocket push(即時)"| S
S -.->|"起動時 pull(取りこぼし回収)"| D1
S -->|"返信 / ack"| W
W -->|"Web Push(節目通知)"| B
ポイントは、保存(D1)と通知(Durable Object)を分けたこと。
- D1は「受信箱」。届いたメッセージは必ずここに残る。これが耐久性を担保する
- Durable ObjectとWebSocketは「即時push」。今つながっている母艦へ、リアルタイムに突く
通知が失敗しても(母艦が切断中でも)、メッセージはD1に残っているので、母艦は次に起きたときに拾える。「即時性」と「取りこぼさなさ」を別々の仕組みに担当させるのが、この構成のいちばんの背骨だ。
なぜDurable Objectなのか
WebSocketの接続は「状態」だ。誰が今つながっているかを、どこかが覚えていないといけない。ステートレスなWorkerだけでは、この「接続を保持する一点」を作れない。
Durable Objectは、まさに「1つの実体(このアプリでは1つのチャットハブ)に処理を集約する」ためのものだ。母艦との唯一のWebSocket接続をここが握る。ブラウザからの通知は必ずこのDurable Objectを経由して、そこから母艦へ流れる。
そしてもう一つ大きいのが WebSocket Hibernation だ。接続を張りっぱなしにしても、メッセージが流れていない間はDurable Objectがメモリから退避(hibernate)される。正確に言うと、hibernate中はメモリを保持し続けるための課金(duration課金)が発生しない — 接続を維持したまま、待機時間ぶんのコストがほぼ消える。「常時接続だけど、待っている間の待機コストはほぼゼロ」という性質だ。
注意:ここは「接続維持がタダ」ではなく「メモリ保持のdurationが課金されない」という話。hibernateの解除(メッセージ到着でのリクエスト)やDOのストレージには当然コスト要素がある。またDurable Objects自体を使えるプランの条件(無料枠での提供状況)は変動してきた経緯があるので、真似するときは最新の料金・プラン条件を確認してほしい。
いちばん美味しいところ:空振り起床ゼロのイベント駆動waiter
母艦側(Claude Code)の受信は、小さなwaiterスクリプト1本でやっている。動きはこうだ。
- 起動すると、まずD1の受信箱に未読がないかを確認する(pull)
- 未読があれば、その1件を出力してプロセスが終了する
- 未読がなければ、WebSocketにつないで待機する(ここで眠る)
- メッセージが届くと、やはりプロセスが終了する
ここで「誰がプロセスの終了を拾うのか」が肝になる。waiterはバックグラウンドで起動しておき、そのプロセスの終了をコーディングエージェント側のハーネスがフックする。ハーネスは「バックグラウンドのプロセスが終わったら次のターンを起こす」という仕組みを持っているので、waiterがメッセージを受けて終了した瞬間に、母艦セッションが起きて処理を始められる。つまり、待機はwaiterプロセスに、起床の検知はハーネスに、それぞれ担当させている。
これがミソで、母艦はメッセージが来るまで一切動かない。5秒ごとに問い合わせる、みたいなポーリングをしない。空振りで起きることがゼロなので、無駄な起動コストが発生しない。イベント駆動そのものだ。
処理が終わったら、母艦はwaiterをもう一度バックグラウンド起動して、また眠りに戻る。「眠る → 起きる → 処理する → また眠る」のループを、メッセージという外部イベントだけが駆動する。
順序の罠:ack → 返信 → 再waiter
このループで一度ハマったのが順序だ。1件のメッセージを処理したあと、正しくはこの順で動かす。
- ack(受信箱でそのメッセージを既読にする)
- 返信を送る
- waiterを再起動する
ackを忘れて先にwaiterを立て直すと、そのメッセージは受信箱でまだ「未読」のままだ。すると再起動したwaiterが起動時のpullで同じメッセージをもう一度拾ってしまう。二重処理だ。
「既読にしてから、次の受信待ちに戻る」——分散システムでよくある「at-least-once」の取りこぼし対策の、ミニチュア版みたいな話だった。個人開発でも、耐久キューと既読管理を持った瞬間にこの手の順序が効いてくる。
母艦 → スマホの通知:Web Push
方向が逆の「母艦からブラウザへ知らせたい」も要る。作業完了・承認待ち・エラー報告といった節目を、スマホに飛ばしたい。
ここは PWA + Web Push(VAPID) でやっている。ポータルをPWAとしてインストールし、購読情報はD1に保存する。母艦が節目で通知を送ると、購読済みの端末にプッシュが飛ぶ。無効になった購読は、配信時に返る404/410を見て自動で掃除する。
チャットに残す「対話」と、残さない「節目通知」を役割で分けているのも地味な工夫だ。全部通知にすると鬱陶しいし、全部チャットに積むと流れる。
iOSはSafariで「ホーム画面に追加」したアイコンから起動しないと購読できない(iOS 16.4+の仕様)。ここは実装というより仕様の壁だった。とはいえ iOS 16.4 以降は Web Push 自体が使えるようになったので、この一手間さえ踏めばスマホにもちゃんと通知が届く。
認証は「経路ごとに」分ける(概念だけ)
2つの入口があるので、守り方も分けている。
- ブラウザ用の入口(
/api/messagesなど)は、メール許可リストによるアクセス制御で守る。人間がログインして使う経路 - 母艦セッション用の入口(
/api/session/*)は、パスを限定した上で共有シークレットで守る。プログラムが使う経路
「人間が使う経路」と「プログラムが使う経路」で認証方式を変える、という切り分けだ。具体的なトークンやバイパスの中身はここには書かないが、経路の性質に応じて防御を変えるという考え方だけ持ち帰ってもらえればと思う。
まとめ
- 保存(D1受信箱)と通知(Durable Object + WebSocket)を分ける。 即時性と取りこぼさなさを別々の仕組みが担当する
- Durable Objectが接続という「状態」を握り、Hibernationでアイドル0円。 常時接続なのに待機中はタダ
- 母艦は空振り起床ゼロのイベント駆動。 メッセージが来るまで眠り、来たら起きてack→返信→また眠る
- ack→返信→再waiterの順序を守らないと二重処理になる。耐久キュー+既読管理を持つと必ず出てくる論点
- 逆方向はPWA + Web Pushで節目だけ知らせる
コーディングエージェントのセッションを「話しかけられるエンドポイント」として扱うと、PCの前にいなくても開発の指示・確認ができる。Cloudflareの部品(Durable Object / WebSocket Hibernation / D1)が、この双方向・低遅延・低コストを個人開発の射程に収めてくれた、という話だった。