登壇者が「お題」を投げ、参加者が応え、主催がライブ結果を見る。この主催主導のリアルタイム投票を支えるのが @event/feedback の live.ts である。状態はすべて KV (本番 Upstash / dev メモリ shim) に置き、DB も WebSocket も持たない — 参加者はポーリングで rev の変化を追う。createLiveStore は KvLike を注入して作る store factory で、認可 (主催パスワード) は hostKey.ts が秘密からの導出で担う。ivs-cms の poll から session→unit に一般化し、絵文字リアクションを廃して投票 (poll) 1 種に絞った版である。
createLiveStore — KvLike 注入で作る
import { createLiveStore, newPromptId, voterHashOf } from "@event/feedback";
import { kv } from "@event/core"; // 本番 Upstash / dev in-memory shim
const live = createLiveStore({
kv: kv(), // KvLike。唯一の必須オプション
keyPrefix: "live:", // KV キー prefix。既定 'live:'
views: ["survey", "qa"], // 許可する view 集合。先頭が既定 view
});
LiveStoreOptions はこの 3 つだけ。KV への読み書きは全部 store 内に閉じ、キーは <prefix><unitId>:prompt / :promptRev / :view / :viewRev と <prefix><promptId>:counts / :rev / :voters に分かれる。集計を promptId でキーするので、同じ unit で次のお題を投げると別カウントに なる (前のお題の結果は promptId キーに残る)。
LivePrompt / LiveResults / LiveState — 3 つの状態型
| 型 | フィールド | 意味 |
|---|---|---|
LivePrompt | id / unitId / kind:'poll' / question / options? / startedAt | 主催が投げた active お題 (LiveOption = { id, label }) |
LiveResults | rev / total / counts | 集計。total は counts の合計 (poll は 1 人 1 票) |
LiveState | prompt / promptRev / results | getLiveState が 1 リクエストで返すまとめ |
LiveStore のメソッドは getLiveState / getResultsById / startPrompt / stopPrompt / respond / getLiveView / setLiveView / applyPromptAction。お題の start/stop は applyPromptAction(unitId, body) に集約され、body.action==='stop' で終了、それ以外は question (140 字に trim) と 2 枠以上の options (空ラベルは A/B/C/D にフォールバック) を検証して newPromptId() で起動する。認可は呼び出し側の前提。
view-follow — rev で全端末を追従させる
主催が出す「画面」(LiveView = 任意文字列。ivs では 'survey' / 'qa') を全参加者に同期する仕組み。 WebSocket を張らず、viewRev の単調増加を参加者がポーリングで観測して切替を検知する:
await live.setLiveView(unitId, "qa"); // → { view:'qa', rev: n+1 } rev を incr
// 参加者側は getLiveView をポーリングし、rev が上がったら画面を差し替える
const { view, rev } = await live.getLiveView(unitId);
未知の view は既定 (views[0]) に倒して KV を汚さない。同じ画面を再ブロードキャストしても rev は上がるので 「もう一度全員こっちを見て」も通る。お題の start/stop も別系統の promptRev を incr し、同じ追従を促す。
respond — 1 人 1 票の dedup
import { voterHashOf } from "@event/feedback";
// UA + IP + promptId から 24 文字の voterHash を導出 (個人特定不可)
const voterHash = voterHashOf(req.headers.get("user-agent") ?? "", ip, promptId);
const r = await live.respond(unitId, promptId, { optionId: "o1" }, voterHash);
// r: { ok, deduped?, stale? }
respond はまず active お題の prompt.id と promptId を照合し、切替済みなら { ok:true, stale:true } で黙って無視する。option が実在すれば sadd(voters, voterHash) で dedup — 戻り 0 (既存投票者) なら { deduped:true }、新規なら hincrby で counts を原子的に加算し rev を上げる。同一端末の連打は 1 票に収束する。voterHashOf は promptId を混ぜるので、次のお題では同じ人がまた投票できる。
derived host key — 保存しない主催パスワード
hostKeyFor(unitId) は サーバ秘密 + unitId から決定的に導出する 8 文字キーで、DB に保存しない。 全リクエストで安定し (config の保存有無に依存しない)、unitId が入るので unit 間で衝突しない。
import { hostKeyFor, verifyHostKey } from "@event/feedback";
// admin ページで登壇者/モデレーターに配布する主催パスワードを表示
const key = hostKeyFor(unitId); // 例 'kp7m9x2h' (紛らわしい 0/O/1/l/I を除いた base32-ish)
// host ルート: パスワード照合 → applyPromptAction (認可を先に済ませる)
if (!verifyHostKey(unitId, submittedKey)) return new Response("forbidden", { status: 403 });
const res = await live.applyPromptAction(unitId, body);
秘密は HostKeyOptions.secret で注入でき、既定は env LIVE_HOST_SECRET ?? ADMIN_PASSWORD。公開リテラルの フォールバックは持たない (持つと誰でも導出できてしまう) ので、どちらの env も無ければ throw して機能を無効化する。 全 unit のパスワード一括ローテーションは env を差し替えるだけ (unit 別保存が無いので個別失効はしない設計)。 verifyHostKey は長さチェック後に XOR 畳み込みで定数時間比較し、タイミング攻撃で桁を推定させない。
配線の要点
- 状態は KV に一元化。dev は
kv()のメモリ shim (Next dev は単一プロセスなのでリクエスト跨ぎで保持)、
本番は Upstash。DB 不要で立ち上がる
- リアルタイムはポーリング + rev。
viewRev(画面切替) とpromptRev(お題切替) とLiveResults.rev
(票の増加) の 3 系統を参加者が観測して差分描画する
- 認可の分離。store は投票ロジックだけを持ち、
hostKeyFor/verifyHostKeyは独立モジュール。admin
middleware で守るルートは host key を省き、公開 host ルートだけ照合する。手順は recipe add-live 参照