ガイド 第28

ライブエンゲージメント

poll/Q&A・KV view-follow・derived host key

@event/feedback

登壇者が「お題」を投げ、参加者が応え、主催がライブ結果を見る。この主催主導のリアルタイム投票を支えるのが @event/feedbacklive.ts である。状態はすべて KV (本番 Upstash / dev メモリ shim) に置き、DB も WebSocket も持たない — 参加者はポーリングで rev の変化を追う。createLiveStoreKvLike を注入して作る 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 つの状態型

フィールド意味
LivePromptid / unitId / kind:'poll' / question / options? / startedAt主催が投げた active お題 (LiveOption = { id, label })
LiveResultsrev / total / counts集計。total は counts の合計 (poll は 1 人 1 票)
LiveStateprompt / promptRev / resultsgetLiveState が 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.idpromptId を照合し、切替済みなら { 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 不要で立ち上がる

  • リアルタイムはポーリング + revviewRev (画面切替) と promptRev (お題切替) と LiveResults.rev

(票の増加) の 3 系統を参加者が観測して差分描画する

  • 認可の分離。store は投票ロジックだけを持ち、hostKeyFor/verifyHostKey は独立モジュール。admin

middleware で守るルートは host key を省き、公開 host ルートだけ照合する。手順は recipe add-live 参照