Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Discordでリモートポケカしてたら、なぜかDOを25分間動かせるようになった話

 Discordでリモートポケカしてたら、なぜかDOを25分間動かせるようになった話

Cloudflare Workers Tech Talks in Tokyo #7 (2026-04-13)
https://workers-tech.connpass.com/event/387081/presentation/
Copyright 2026 Kaito Udagawa. All rights reserved.

Avatar for Kaito Udagawa

Kaito Udagawa

April 13, 2026

More Decks by Kaito Udagawa

Other Decks in Technology

Transcript

  1. © 2026 Kaito Udagawa All Rights Reserved. 🎮 Discord Matchclock

    Discordボイスチャンネル向け試合タイマーのアーキテクチャ Cloudflare Workers Durable Objects Discord SDK React Hono Lightning Talk 1
  2. © 2026 Kaito Udagawa All Rights Reserved. 何を作ったか? Discordアクティビティとして動く 試合用カウントダウンタイマー

    🔊 音声読み上げ 残り時間・開始/停止を 日本語で自動アナウンス 🔄 リアルタイム同期 WebSocketで全参加者が 同じ残り時間を表示 ⚙ サーバー設定 Discordコマンドで デフォルト試合時間を設定 24:59 2
  3. © 2026 Kaito Udagawa All Rights Reserved. システム構成 ⚛ packages/client

    React + Vite @discord/embedded-app-sdk 📦 packages/common 共有型定義 MatchclockConfig ☁ packages/server Hono + Cloudflare Workers Durable Objects ビルド npm workspaces + Vite + Wrangler デプロイ Cloudflare Workers (サーバー) / 静的ファイル (クライアント) イベント保存 Durable Objects + SQLite (EventRecorder) 設定保存 Cloudflare R2 (per guild JSON) 3
  4. © 2026 Kaito Udagawa All Rights Reserved. タイマー同期のしくみ ユーザー A

    (操作者) Cloudflare Worker Durable Object ユーザー B (参加者) ① スタートボタン押下 ② POST /timerEvents/:instanceId ③ EventRecorder.putEvent() → SQLite INSERT ④ WebSocket getEvents (1秒ポーリング) ⑤ getEventsResponse → handleTimerEvent() 💡 TimerLaunchedEvent → タイマー開始時に全クライアントが自動でイベント履歴を取得し、同じ状態に収束する 4
  5. © 2026 Kaito Udagawa All Rights Reserved. Durable Objects ×

    SQLite がキモ // EventRecorder.ts class EventRecorder extends DurableObject { sql: SqlStorage; constructor(ctx, env) { super(ctx, env); this.sql = ctx.storage.sql; // CREATE TABLE IF NOT EXISTS Events } async putEvent(dispatchedAt, payload) { this.sql.exec( "INSERT INTO Events VALUES (?,?)", dispatchedAt, payload ); } async getEvents() { return this.sql.exec("SELECT * FROM Events;") .toArray(); } } 📍 インスタンス ID = タイマーID idFromName('timer ' + instanceId) でルーム毎に1つのDurable Object 💾 SQLiteでイベントソーシング 全タイマー操作をINSERT。クライアントはSELECTで再生して状態を 復元 🔌 WebSocket内蔵 fetch()でWebSocketアップグレード。acceptWebSocket()でメッセー ジ処理 ⚡ ゼロコールドスタート Durable Objectはsticky routing。インスタンスは毎回同じノードで 動 作 5
  6. © 2026 Kaito Udagawa All Rights Reserved. まとめ Cloudflare Workers

    × Hono でサーバーレスAPI Durable Objects + SQLite でイベントソーシング WebSocket ポーリングでリアルタイム状態同期 Speech Synthesis APIで自動音声アナウンス npm workspaces でモノレポ管理 🔗 Links GitHub: kaito-tokyo/discord-matchclock Runtime: Cloudflare Workers Frontend: React 19 + Vite 6 Backend: Hono 4 + TypeScript Thank you! 🎉 6
  7. © 2026 Kaito Udagawa All Rights Reserved. 発表者紹介 #宇田川海斗 #32歳

    #YouTuber #絵描き • 氏名:宇田川 海斗(うだがわ かいと) • 早稲田大学CS学科の修士卒 • 社会人4年目(開発→SRE→DevExp→無職) • GitHub上の初コミットは2011年 • Arctic Code Vault Contributor • OSS貢献の経験あり(vim、Homebrew、AWS、nvmなど) • pnpmメンバーおよびobs-backgroundremovalメンテナ • ポケモンにほぼ全人生(28年間)捧げてます 9
  8. © 2026 Kaito Udagawa All Rights Reserved. アーキテクチャ図 WebSocketでDurable Objectを掴みっぱなし

    にします。 • Backend: Cloudflare Workers • Storage: Durable Objects SQL API (SQLite) • Config: R2(サーバー設定用) WebSocket WebSocket Discord Client A Cloudflare Workers Discord Client B Durable Object: EventRecorder SQLite Storage 11
  9. © 2026 Kaito Udagawa All Rights Reserved. 核心1:Discord Activityという「土壌」 通常、サーバーレスの世界ではクライアントがいつ居なくなるか

    が不安定です。しかし、 Discord Activityはこの常識を覆します。 • セッション = クライアントの寿命 ◦ ボイスチャンネルで対戦している間、 Activityは絶対に閉じられません。 ◦ つまり、クライアント側からサーバーへ 生存信号 を送り続けるための完璧な土壌が整っています。 このセッションが続く限りクライアントが生き続ける という特性を逆手に取り、 Cloudflareの制約をハックします。 12
  10. © 2026 Kaito Udagawa All Rights Reserved. 核心2:WebSocket=Durable Objectへの生命維持装置 クライアントの生存を、Durable

    Object (DO) の延命に直結させます。 • Durable Object側で this.ctx.acceptWebSocket(server) を実行 ◦ HTTPリクエストが終了しても、 Durable ObjectがWebSocket接続を直接管理しメモリに居座り続け ます。 • クライアントから1秒ごとにツンツン(通信) ◦ setInterval で { type: "getEvents" } を投げ続け、Durable Objectを仕事中の状態に保ち ます。 • 25分間の不老不死が完成 ◦ アクティブな通信がある限り、 Durable Objectはシャットダウンされずに動き続けます。 13
  11. © 2026 Kaito Udagawa All Rights Reserved. 技術的変態ポイント:永続時系列イベント DB Durable

    ObjectのSQLiteを時系列イベントDBとして活用しています。EventRecorder.ts async putEvent(dispatchedAt: number, payload: string) { this.sql.exec( "INSERT INTO Events (dispatched_at, payload) VALUES (?, ?)", dispatchedAt, payload ); } メリット:途中参加者が来ても、クライアントの現在時刻までシーケンスを再生すれば、決定論的 に状態をシステム間で同期することが可能です。 15
  12. © 2026 Kaito Udagawa All Rights Reserved. 認証:Discord Activityの認証 Workersのエントリポイントで、Discordからのリクエストが本物か厳格に検証しています。

    import { verifyKey } from "discord-interactions"; const isValidRequest = await verifyKey( rawBody, signature, // X-Signature-Ed25519 timestamp, // X-Signature-Timestamp c.env.DISCORD_PUBLIC_KEY, ); if (!isValidRequest) return c.text("Bad request signature!", 401); 16
  13. © 2026 Kaito Udagawa All Rights Reserved. セキュリティ:インスタンスごとの「隔離」 対戦データが混ざったら一大事。Durable Objectの命名規則で論理的に隔離していま

    す。 • 隔離の仕組み: instanceId(対戦ごとの一意なID)をDurable Objectの名前とし て使用。 • 実装: EVENT_RECORDER.idFromName(`timer ${instanceId}`) 各対戦(25分間)ごとに独立したSQLite DBが生成されるため、他人の対戦タイマーの 情報を盗むことは物理的に不可能です。認証もあるので、安心! 17
  14. © 2026 Kaito Udagawa All Rights Reserved. Future work •

    ReactからCustom Elementsに移行したい • 生のTypeScriptからTypeScript in JSDocとmtsに移行したい • 依存を減らしたいので、Honoを消して生のCloudflare Workersに触るようにしたい • 一応pnpmのメンテナなので、pnpmを使ってpnpmの宣伝したい(ごめんZoltan) 18
  15. © 2026 Kaito Udagawa All Rights Reserved. まとめ • Durable

    Objectは、WebSocketを繋げば25分でもずっと起きててくれる。 • 署名検証 と IDによる隔離 で、サーバーレスでもセキュアな対戦環境。 • SQLite (SQL API) は、複雑なステート同期を単なる「ログの挿入と再生」に変えて くれる。 19