リアルタイムのルーム

参加者どうしが WebRTC で映像・音声・データをやり取りする「ルーム」(SFU)の作り方です。参加トークン、サイマルキャスト、スポットライト、DataChannel、Webhook、カスケードまで説明します。

リアルタイム通信

このページの見出し
  1. 基本の考え方
  2. 準備(ノードの起動)
  3. ルームの REST API
  4. 参加トークン
  5. シグナリング
  6. マルチストリーム・サイマルキャスト・レイヤーの選択・転送フィルター
  7. スポットライト
  8. メッセージ(DataChannel・プッシュ)
  9. 参加者と統計
  10. ルームの移動と障害
  11. E2EE(パススルー)
  12. イベント(SSE)
  13. 上限
  14. 現在のノードで使えないもの
  15. 例
  16. 関連するページ

ブラウザーやアプリの参加者どうしが、WebRTC で映像・音声・データをやり取りする場が「ルーム」です。 SENCA はルームの中で SFU(Selective Forwarding Unit。受け取った映像・音声を変換せず、必要な相手へ転送するだけのサーバー)として動きます。

このページの要点:

  • ルームは POST /api/v1/rooms で作ります。参加者には参加トークン(POST /rooms/{id}/tokens)を渡し、WebSocket の GET /signaling で接続してもらいます。
  • 送信のみ・受信のみといった役割、1 人が複数の映像を送るマルチストリーム、サイマルキャスト(同じ映像を画質違いで同時に送り、受信側ごとに選ぶ方式)、スポットライト(話している人だけを高画質にする機能)が使えます。
  • 転送フィルター、DataChannel のメッセージ、E2EE のパススルー、統計にも対応します。参加者にはメタデータと ID(metadata・client_id・bundle_id)を付けられます。
  • ルームはグリッドのワークロードとして 1 つのノードに置かれます。別のノードに接続した参加者は、配置先へ案内(リダイレクト)されるか、カスケードでそのまま受け付けられます。
  • ルームで起きたことは SSE と Webhook で受け取れます。

基本の考え方

ルームはコントロールプレーンで管理するリソースで、POST /api/v1/rooms で作成し、どのノードからでも読み書きできます。ルームの ID はテナントをまたいで一意です。

作ったルームは、グリッドのワークロード(room:{id})として、ちょうど 1 つのノードに配置されます(クラスターとグリッド)。 そのノードがルームを開き、参加者のピア接続を受け付けます。どこに配置されたかは、ルームの placement_status でわかります。

参加者はどのノードに接続してもかまいません。シグナリングの GET /signaling は、最初の connect の room_id を見ます。 ルームが別のノードにあれば redirect {location} を返して接続を閉じ、クライアントを配置先へ案内します(リダイレクト)。 参加者・メッセージ・統計の REST API は、受け付けたノードがルームの配置先へ転送します。 ただし cascade: true のルームではリダイレクトせず、接続したノードで参加者を受け付けます(「ルームの移動と障害」のカスケード)。

ドレインやノードの停止で配置先が変わったときは、元のノードがまだ動いていれば、現在の参加者に redirect を送って新しいノードへ入り直してもらいます。

参加トークンは、どのノードでも発行・検証できます。署名鍵をコントロールプレーンに保存した鍵から導出しているので、全ノードで同じ鍵になるためです。

準備(ノードの起動)

ルームを使うノードでは WebRTC を有効にします。

フラグ要否意味
--webrtc-udp ADDR必須WebRTC の UDP ソケット(WHIP・WHEP と共用)。0.0.0.0:ポート で待ち受けると、ICE の候補にはノードのネットワークインターフェースのアドレスが入ります
--webrtc-public-ip IPNAT・Docker の内側では必須クライアントから見えるノードの IP アドレス。ICE の候補にはこのアドレスが入ります(カンマ区切りか、オプションの繰り返しで複数指定できます)
--public-http-url URL推奨ルーム・トークンの応答に含まれる signaling_url の元になります(http:// は ws://、https:// は wss:// に変え、/signaling を付けます)。指定しないと、自ノードに配置されたルームの signaling_url は null になります
--https ADDR + --tls-cert・--tls-key任意wss:// で受け付けます(/signaling は HTTP と HTTPS の両方の待ち受けで動作します)
senca-node --data-dir /var/lib/senca --http 0.0.0.0:8080 \
  --webrtc-udp 0.0.0.0:20000 --webrtc-public-ip 203.0.113.10 --public-http-url https://media.example.com

--webrtc-udp を指定していないノードでは、GET /signaling が HTTP 503(text/plain、webrtc (--webrtc-udp) is off)を返します。 そのノードに配置されたルームでは、参加者・メッセージ・統計の API も 503 CAPACITY_UNAVAILABLE になります。

クラスターでの注意: グリッドは、WebRTC が有効なノードかどうかを見ずにルームを配置します。WebRTC が無効なノードに置かれたルームには、誰も参加できません。 全ノードで --webrtc-udp を有効にするか、WebRTC が有効なノードに --label webrtc=on を付け、ルームの placement.labels で配置先を絞ってください。

リダイレクトと API の転送には、クラスター設定にある各ピアの api(管理 API の URL)を使います。 api が無いノードに配置されたルームは、他のノードからは「配置先が決まっていない」ように見えます(「ルームの REST API」の「ルームを配置したノードへの問い合わせ」)。

ルームの REST API

すべて /api/v1 の下にあり、共通の規約(Bearer、problem+json、Idempotency-Key、ETag・If-Match、limit・cursor、未知のフィールドは 400)に従います (API と SDK)。

操作メソッドとパス権限結果
作成POST /rooms {id?, settings?, metadata?, placement?}stream:write201、Location・ETag
一覧GET /rooms?limit&cursorstream:read200 {items, next_cursor}(ID 順。limit の既定は 100、1〜1000)
取得GET /rooms/{id}stream:read200、ETag: "{generation}"
更新PATCH /rooms/{id} {settings?, metadata?, placement?}stream:write200。If-Match 必須(無ければ 428、古ければ 412)
削除DELETE /rooms/{id}stream:write204。参加者は room_deleted で切断されます
参加トークンPOST /rooms/{id}/tokensstream:write201(「参加トークン」)
参加者の一覧GET /rooms/{id}/participants?limit&cursorstream:read200 {items, next_cursor}
参加者の取得GET /rooms/{id}/participants/{connection_id}stream:read200
参加者のメタデータの変更PATCH /rooms/{id}/participants/{connection_id} {metadata}stream:write200。全員に connection.updated が通知されます
参加者の切断DELETE /rooms/{id}/participants/{connection_id}stream:write204。理由は kicked
メッセージの送信POST /rooms/{id}/messages {to?, label?, data}stream:write200 {delivered}(「メッセージ」)
統計GET /rooms/{id}/statsstream:read200(「参加者と統計」)

参加者の変更・切断は、どのノードに送っても、参加者が接続しているノード(カスケードのエッジノードを含む)に転送されて実行されます。

作成

id には 1〜128 文字の英数字と -_.:@ が使えます。省略すれば、24 文字の 16 進数の ID が自動で付きます。 同じ ID のルームがすでにあると、それがどのテナントのルームでも 409 CONFLICT です。1 つのテナントで作れるルームは 10000 個までで、超えると 403 QUOTA_EXCEEDED になります。

metadata には任意の JSON を 8 KiB まで入れられます。

placement は、入力・出力と同じ配置の指定です(node・device・labels・anti_affinity・pin_policy・priority・strategy)。 node にはノードの ID か名前を書け、保存するときに ID に変換されます。degrade はパイプライン専用なので、ルームに指定すると 400 です。 詳しくは クラスターとグリッド を参照してください。

ルームの設定(settings)

省略したフィールドは既定値になり、未知のフィールドは 400 です。作成時にも更新時にも下の範囲を検査し、 外れていれば 400 INVALID_CONFIG を返します(details.errors に該当するフィールドが入ります)。

フィールド既定範囲・意味
max_participants501〜10000。ルームの参加者数の上限です。グリッドでの資源の見積もり(下記)にも使います
e2eefalseE2EE のルームにします(「E2EE」)
video_codecvp8vp8・vp9・h264・av1。ルームで 1 つです(SFU は変換しないため、全員が同じコーデックで送信します)
audio_codecopusopus のみ
max_video_bitrate_kbpsなし50〜50000。参加時の connected メッセージで、送信する参加者に伝える映像のビットレートの上限(推奨値)
spotlightなし{number, unfocused?, focus_rid?}。number は 1〜16、unfocused は lowest(既定)か off、focus_rid は 1〜16 文字の英数字(「スポットライト」)
recordingnonenone・per_participant・composite。per_participant(参加者ごと)か composite(全員を並べた 1 本)にすると、録画用のメディアパイプラインが自動で作られます(「ルームの REST API」の「ルームの録画」)。e2ee: true と composite は併用できません(422)
forwarding_filterなしルーム全体の転送フィルター(「マルチストリーム・サイマルキャスト・レイヤーの選択・転送フィルター」)
data_labels["#*"]使用を許可する DataChannel のラベルのパターン。# で始まる 64 バイトまでの文字列を 32 個まで指定できます。末尾の * は前方一致を表します
empty_timeout_secs60自動で作成されたルーム(auto: true)が空になってから削除されるまでの秒数
cascadefalsetrue にすると、ルームの配置先でないノードに接続した参加者も、そのノードで受け付けます(「ルームの移動と障害」のカスケード)。false なら配置先へリダイレクトします
auth_webhook_urlなしこのルームの認証 Webhook の URL(http://・https://)。ノードの --rooms-auth-webhook より優先されます(「参加トークン」)

更新

settings には変えたいフィールドだけを書きます(JSON Merge Patch、RFC 7396)。書かなかったフィールドはそのまま残り、null にしたフィールドは既定値に戻ります。 spotlight のような入れ子のオブジェクトも同じ規則です。変更を反映した結果は、作成時と同じ規則で検査されます。

参加者がいる間は、e2ee・video_codec・audio_codec を変えられません(409)。配置の指定は placement: null で解除できます。

ルームの録画

settings.recording を per_participant か composite にすると、そのルームの録画用のメディアパイプライン(ID は room-rec.{ルームの ID}、 managed: true)が自動で作られ、以後はルームの設定に合わせて更新されます(メディアパイプライン)。

値録画されるもの
per_participant参加者ごとにエクスポートしたストリームを、それぞれ録画します。E2EE のルームでも、暗号化されたまま録画できます
composite映像のある参加者を格子に並べた 1 本のストリーム(AV1、1280x720)を作り、それを録画します

none に戻すとメディアパイプラインは削除されますが、エクスポートしたストリームと録画は残ります。 このメディアパイプラインを API から直接変更・削除することはできません(409)。録画の方法を変えたいときは、ルームの設定を変更してください。

ルームの設定の変更は、ルームを配置したノードが検知して反映します。転送フィルターやスポットライトを変えた場合は、その場で受信スロットを再割り当てします。

ルームの表示

POST・GET・PATCH の応答には、コントロールプレーンに保存されたルームの情報が含まれます(参加者数などは含みません)。

フィールド意味
id・generation・settings・metadataルームの ID、世代(更新のたびに増える番号。ETag に使います)、設定、メタデータ
auto参加トークンの発行時に自動で作成されたルームかどうか
created_at・updated_at作成日時と更新日時(RFC 3339)
signaling_url接続先。ルームが別のノードにあればそのノードの URL、そうでなければこのノードの --public-http-url から作った URL(無ければ null)
placement配置の指定
placement_status配置の状態。state は running・degraded・pending・unschedulable・not_scheduled のどれかです。配置済みなら node・node_name・device・level・level_label・since・reason が、配置できなければ reasons が入ります

グリッドでの資源の見積もり

ルームに必要な資源は、max_participants(N)から見積もります。

資源見積もり
CPU200 + 20 × N ミリコア
メモリ64 + 2 × N MB
受信2 × N Mbit/s
送信2 × N × min(N, 8) Mbit/s

ルームで動作中のメディアパイプライン(desired_state: running)があれば、1 つにつき CPU 20 × N ミリコアとメモリ 8 × N MB を加えます。 既定の 50 人なら、CPU 1200 ミリコア、メモリ 164 MB、受信 100 Mbit/s、送信 800 Mbit/s です。 5 人で使うルームなのに既定の 50 のままだと、その分ノードの容量を余計に押さえてしまいます。max_participants を実際の人数に合わせれば、1 つのノードにより多くのルームを置けます。ノードの容量は --net-mbps などで設定します(クラスターとグリッド)。

ルームを配置したノードへの問い合わせ

参加者・メッセージ・統計の API に応答するのは、ルームを配置したノードです。 ルームが別のノードにあれば、リクエストを受けたノードが同じヘッダー(Authorization・Content-Type・If-Match・Idempotency-Key・X-Request-Id)のまま 配置先の /api/v1 へ転送し、ステータスコード・本文・ETag をそのまま返します。転送のタイムアウトは 10 秒です。

転送されてきたリクエストを、さらに別のノードへ転送することはありません。そうなる場面では 503(retryable: true)を返します。 このほかに返るエラーは次のとおりです。

状況応答
配置先がまだ決まっていない(作成直後など)503 CAPACITY_UNAVAILABLE(retryable: true)
ルームの処理が 5 秒以内に応答しない503(retryable: true)
配置先のノードがまだルームを開いていない404

応答に含まれる時刻(joined_at・at など)は RFC 3339 の形式です。

参加トークン

参加者がルームに入るには、参加トークンが要ります。アプリケーションのサーバーで POST /rooms/{id}/tokens を呼んで発行し、ブラウザーやアプリへ渡してください。リクエストの本文は次のとおりです。

フィールド既定意味
role必須sendrecv(送受信)・sendonly(送信のみ)・recvonly(受信のみ)。参加時に選べる役割の上限です(sendrecv ならどれでも選べます)
client_idなし参加者を見分けるための ID。1〜255 文字で、制御文字は使えません
bundle_idなし同じ利用者の複数の接続をまとめる ID。1〜255 文字で、制御文字は使えません
metadataなし他の参加者に公開するメタデータ(8 KiB まで)
permissions下の既定値{publish, subscribe, data}(「権限」)
ttl_secs600トークンの有効期間(秒)。10〜86400

応答(201):

{"token":"eyJ…","room_id":"daily","expires_at":"2026-11-03T10:10:00Z","signaling_url":"wss://media.example.com/signaling"}

まだ無いルームのトークンを発行すると、そのルームが自動で作られます(既定の設定で、auto: true が付きます)。 自動で作られたルームは、空のまま empty_timeout_secs(既定 60 秒)が過ぎると削除されます。誰も参加しないまま時間が過ぎたルームも同じです。 空かどうかは、ルームを配置したノードが 5 秒ごとに確かめます。なお、他のテナントのルームの ID を指定すると 404 になります。

トークンは JWT 互換の形式で、Ed25519(EdDSA、kid: senca-rooms-1)で署名されます。iss は "senca"、aud は "senca-rooms"、sub はテナントです。 クラスターの初期化前で署名鍵がまだ無いときは、503(retryable: true)が返ります。

権限(permissions)

フィールド既定意味
publish["audio","video"]送信を許可するトラックの種類。許可されていない種類を送ると forbidden になります
subscribetrue他の参加者のトラックを受信できるかどうか
data["#*"]使用を許可する DataChannel のラベルのパターン

検証の規則

参加時(connect)に、ルームを配置したノードが次の規則で検証します。

  • トークンは必須です(既定)。トークンの無い connect は auth_failed になります(ルームのシグナリングの「エラー」)。 --rooms-auth-mode only のときだけ、認証 Webhook がトークンの代わりに参加を認めます。
  • exp(有効期限)は必須です。時計のずれは 30 秒まで許容します。現在から exp までが 24 時間を超えるトークンは拒否します。発行時刻の iat が先の時刻になりすぎているトークンも拒否します。
  • トークンの room と connect.room_id が違うと forbidden になります。connect.role がトークンの role の範囲を超えた場合も forbidden です。
  • client_id・bundle_id は、クライアントが自分で名乗った値を信用しません(転送フィルターの条件に使われるためです)。
    • トークンに client_id があれば、それを使います。connect.client_id を付ける場合は、同じ値でなければ forbidden です。
    • トークンに client_id が無いのに connect.client_id を付けると、forbidden(client_id must be issued in the token)になります。
    • bundle_id も同じです。トークンに無いのに connect.bundle_id を付けると forbidden になります。どちらにも無ければ、bundle_id は connection_id と同じ値になります。
  • 参加者のメタデータには、トークンの metadata を使います。connect.metadata は他の参加者には公開されません(認証 Webhook に渡すためのフィールドです)。

認証 Webhook

参加の可否を自分のサーバーでも判断したいときは、認証 Webhook を使います。 ルームの設定 auth_webhook_url(無ければノードの --rooms-auth-webhook)に URL があると、ノードはトークンを検証した後、 その URL へ署名付きの POST で問い合わせます(本文と応答の形式は ルームのシグナリングの「認証」)。 どちらの URL も無いルームでは、トークンだけで参加を認めます。

Webhook が allowed: false を返すと auth_failed になり、reason はそのままクライアントに届きます。 許可する場合は、応答で role・client_id・metadata・permissions を上書きできます。5 秒以内に応答が無いときや、2xx 以外が返ったときは拒否として扱います。

署名用のシークレットは --rooms-webhook-secret-file で渡します(16 バイト以上。Senca-Signature ヘッダーに使います)。 シークレットの無いノードでは、Webhook の URL を持つルームには誰も参加できません。 宛先は --egress-allow の方針で検査されるので、社内のアドレスへ送るときは許可しておいてください。

シグナリング

シグナリングは、WebSocket の GET /signaling(subprotocol senca.rooms.v1 が必須)で JSON のメッセージをやり取りします。 メッセージの詳しい形式は ルームのシグナリング にあり、ここではノードでの処理の流れだけを示します。

  1. アプリケーションのサーバーが POST /rooms/{id}/tokens でトークンを発行し、token と signaling_url をブラウザーへ渡します。
  2. クライアントが signaling_url へ接続し、10 秒以内に connect {version: 1, room_id, token, role, ...} を送ります(HTTP の Authorization ヘッダーは使いません)。
  3. 接続を受けたノードは、ルームの配置先を確認します(作成直後のルームでは、配置先が決まるまで最大 3 秒待ちます)。
    • ルームが別のノードにある場合は、{"type":"redirect","location":"ws://…/signaling"} を送り、クローズコード 4300 で閉じます。クライアントはその URL へ接続し直します。
    • 配置先が決まらない場合は、{"type":"error","code":"capacity_unavailable","fatal":true,…} を送り、クローズコード 4503 で閉じます。
    • ルームがこのノードにある場合は、connect を処理して connected を返します。
  4. クライアントが SDP の offer を送ります。offer を送るのは常にクライアントです。 サーバーは ICE-lite で動作し、answer だけを返します。ICE と DTLS の接続が確立すると、メディアが流れ始めます。
  5. サーバーは 10 秒ごとに ping を送ります。10 秒以内に pong が返らなければ、ping_timeout で切断します。

主なクローズコード(WebSocket を閉じるときの番号)は次のとおりです。

クローズコード発生する場面
4408WebSocket を開いてから 10 秒以内に最初のメッセージが届かない
1013ルームの処理をまだ開始できない(署名鍵が無い、クラスターの初期化前)
4300上記のリダイレクト
4503上記の capacity_unavailable、または room_full

その他のエラーコード、クローズコード、切断の理由は、ルームのシグナリングの「エラー」の表のとおりです。 ルームが存在しない場合(コントロールプレーンに無い、または配置先のノードがまだルームを開いていない場合)は room_not_found になります(ノードは参加時にルームを作成しません)。

マルチストリーム・サイマルキャスト・レイヤーの選択・転送フィルター

受信スロット

クライアントの SDP にある受信用の m 行 1 つが、受信スロット 1 つになります。サーバーはルームのトラックを受信スロットに割り当て、足りなければ renegotiate を送ってスロットを増やしてもらいます。 受信スロットは種類(音声・映像)ごとに 32 個までです。multistream: false の参加者は、種類ごとに 1 本だけ受信します。 自分のトラックと、同じ bundle_id を持つ接続のトラックは届きません。

サイマルキャストとレイヤーの選択

サイマルキャストで送る参加者は、connect.simulcast {rids, default_rid?} で、送信するレイヤーの rid を低い順に 1〜4 個指定します(1〜16 文字の英数字、重複不可)。

どのレイヤーを受け取るかは、受信者ごと・トラックごとに決まります。優先順位は次のとおりです。

  1. クライアントが送った layer {track_id, rid}(rid: null で自動に戻ります)
  2. スポットライト
  3. simulcast.default_rid
  4. 自動。推定帯域から音声の分として 64 kbit/s を引き、0.9 を掛け、受信している映像の数で割ります。その帯域に収まる最も高いレイヤーを選びます。

自動で選ぶ場合、レイヤーを下げるのは帯域の不足が 1 秒続いてからです(推定値が必要量の 0.6 倍を下回ったときはすぐ下げます)。上げるのは、帯域に収まる状態が 2 秒続いてからです。 切り替えは新しいレイヤーのキーフレームが来た時点で行い、それまでは今のレイヤーを転送し続けます。いま受信しているレイヤーは notify {event: "layer.changed"} でわかります。

転送フィルター

ルームの settings.forwarding_filter と、接続ごとの connect.forwarding_filter の両方の条件を満たすトラックだけが転送されます。

{"action":"block","rules":[[{"field":"kind","operator":"is_in","values":["video"]},
                            {"field":"client_id","operator":"is_not_in","values":["teacher"]}]]}

この例は「先生(client_id が teacher)以外の映像は受信しない」という指定です。rules は、AND で結んだ規則の組を OR でつないだものです(1〜10 組、各組 1〜10 規則)。 field には送信する側の connection_id・client_id・bundle_id・kind・metadata.<key>(キーは 64 バイトまで、値が文字列・数値・真偽値の場合)を指定します。 operator は is_in・is_not_in、values は 1〜100 個(各 255 バイトまで。kind の場合は audio・video)です。 action は block(一致したものを転送しない)か allow(一致したものだけを転送する)です。

スポットライト

大人数の会議で全員を高画質で受け取ると、帯域がすぐに足りなくなります。スポットライトを使うと、いま話している number 人の映像だけを高いレイヤーで転送できます。 ルームの settings.spotlight で有効にしてください。 注目していない参加者の映像は、最も低いレイヤーで転送するか(unfocused: "lowest")、転送を止めます("off"。受信スロットは残り、paused になります)。

話しているかどうかは、RFC 6464 の音量情報で判定します。音量情報が無ければ Opus のパケットサイズから推定します。 音声が 200 ms 続けば話し始め、1.5 秒無音が続けば話し終わりとみなします。一度注目した人は、2 秒間は注目から外しません。 注目する人の組はルームで 1 つで、変わると spotlight.changed が通知されます。ただし受信者ごとに自分は除かれるので、各受信者が注目するのは自分以外の number 人です。

注目した人の映像は focus_rid のレイヤーで転送します。focus_rid が無ければ、帯域に合わせて自動で選びます。 クライアントも connect.spotlight {number, focus_rid?, unfocused?} で、ルームの設定の範囲内で値を選べます(number はルームの値を超えないよう切り詰めます)。

メッセージ(DataChannel・プッシュ)

参加者どうしのメッセージには、ラベルが # で始まる DataChannel を使います。クライアントは connect.data_channels で宣言してから DataChannel を開きます。 受け付けるのは、ルームの data_labels とトークンの permissions.data の両方に一致するラベルだけです。 届いたメッセージは、同じラベルの DataChannel を受信用に開いている他の参加者全員へ、そのまま転送されます。

特定の参加者にだけ送りたいときは、シグナリングの message {label, to: [connection_id], data} を使います。受信者には from 付きで届きます。

アプリケーションのサーバーから送るときは、POST /rooms/{id}/messages を使います。本文は次のとおりです。

フィールド意味
to宛先の connection_id の配列。省略するか空にすると全員に送ります。ルームにいない ID が含まれていると 404 になります
label指定すると、そのラベルの DataChannel を開いている参加者へ送ります(# で始まること、data が文字列であること)
dataJSON(64 KiB まで)。label が無ければ、シグナリングの push {data} として届きます

応答は {"delivered": N} で、N はメッセージを届けた参加者の数です。

大きさの上限は、アプリケーションのメッセージが 64 KiB、シグナリングのメッセージが 256 KiB です。 1 つの接続から送れるのは毎秒 50 メッセージまでで、超えると rate_limited、その 4 倍を超えると切断されます。

参加者と統計

参加者の情報(GET …/participants)には、次のフィールドが含まれます。

  • connection_id・client_id・bundle_id・role・metadata
  • tracks[] {track_id, connection_id, kind, rids?}: 送信しているトラック
  • joined_at(参加した日時)・peer_connected(ピア接続ができているか)・signaling(websocket か datachannel)
  • subscriptions[]: 受信スロットの一覧。mid・kind・state(active・paused・empty)・track_id・connection_id・client_id が入ります

統計(GET …/stats)は {room_id, at, participants[]} の形で返ります。参加者ごとに次の値が含まれます。

  • connection_id・rtt_ms(往復の遅延)・bandwidth_bps(推定帯域)・send_bitrate_bps
  • retransmitted_packets・nack_requested・received_bytes・forwarded_bytes・forwarded_frames
  • published[] {track_id, kind, layers[] {rid, bitrate_bps}}: 送信しているトラックとレイヤーごとのビットレート
  • subscriptions[] {mid, track_id, state, rid}: 受信スロットの状態
  • client: クライアントが pong.stats や stats で送った getStats の要約

ルームの移動と障害

ドレイン(POST /nodes/{id}/drain)やノードの停止で、グリッドがルームを別のノードへ移すことがあります。 このとき元のノードは、現在の参加者に redirect を送り、disconnect {reason: "redirected"} を送ってから接続を閉じてルームを手放します。 新しいノードがルームを開き、参加者はそこへ入り直します。connection_id とトラック ID は新しくなります。

元のノードが異常停止した場合は、リダイレクトを送れません。参加者のピア接続とシグナリングが切れるので、クライアントが自分で入り直します。 入り直す先はどのノードでもよく、新しい配置先へリダイレクトされます。SDK の url に複数のノードを書いておけば、停止したノードを飛ばして接続できます。 グリッドがルームを移すまでにかかる時間は クラスターとグリッド を参照してください。

どちらの場合も、移動前のルームの状態(参加者・トラック・スポットライト)は引き継がれず、新しいノードでは空のルームから始まります。

カスケード(ルームを複数のノードにまたがらせる)

ルームの設定を cascade: true にする(またはノードで --rooms-cascade を指定する)と、参加者は近くのノードに接続したまま同じルームに参加できます。 ノード間の中継を使うので、クラスターを組んでいることが前提です。

  • ルームの配置先のノードをホームノード、それ以外で参加者を受け付けるノードをエッジノードと呼びます。エッジノードでのルームは参加者が来たときに開かれ、 参加者がいなくなってから 20 秒で閉じます。ドレイン中のノードはエッジノードにならず、ホームノードへリダイレクトします。
  • 別のノードの参加者は、node フィールド付きの参加者として見えます。映像・音声・DataChannel のメッセージは、ノード間の中継で届きます。 REST の参加者一覧(ホームノードへ転送されます)では、別のノードの参加者は signaling: "remote" と表示されます。
  • max_participants は、ノードをまたいで合計した人数で判定します。別々のノードで同時に参加すると、短い間だけ上限を超えることがあります。
  • ホームノードが停止すると、グリッドがルームを別のノードへ移します。移動先がエッジノードだった場合、そこにいた参加者は切断されずにそのまま続けられます。 停止したノードにいた参加者は、別のノードへ接続し直せば入り直せます(SDK の url に複数のノードを書いておきます)。
  • 別のノードにいる参加者を切断・更新する REST API は、どのノードで呼んでも、参加者がいるノードへ転送されます。
  • メディアパイプラインは、エッジノードにいる参加者のトラックも、そのノードから中継で受け取ってエクスポートします。
  • カスケードの状態は、GET /api/v1/nodes の自ノードの rooms で確認できます。

E2EE(パススルー)

settings.e2ee: true のルームでは、クライアントが SFrame などで暗号化したメディアを、SFU は復号せずにそのまま転送します。

参加者は connect.e2ee をルームの e2ee と同じ値にしてください。違うと e2ee_mismatch になります。参加者がいる間は、ルームの e2ee を変えられません。

暗号化・復号と鍵の配布は、アプリケーションが行います。ブラウザー SDK はルームの設定と食い違っていないかを確かめるだけで、暗号化の機能(Encoded Transform)は持ちません。

サーバーは中身を見られないので、サーバーで中身を処理する操作はすべて 422(UNSUPPORTED_CODEC、details.reason: "e2ee")になります。対象は次のとおりです。

  • ルームの操作: recording: "composite" の指定、合成(composite)か文字起こし(captions)を含むメディアパイプラインの作成・変更、 合成・文字起こしのメディアパイプラインがあるルームを e2ee: true に変えること。
  • E2EE のルームからエクスポートしたストリーム(metadata.e2ee: "true" が付きます)の利用: 静止画の取得(GET /streams/{id}/snapshot)、 字幕(captions)、静止画の保存(snapshots)、静止画の予約、パイプラインの入力(source)。録画のサムネイルも作られません。

つまり E2EE のルームでできるのは、参加者ごとのエクスポートと、暗号化されたままの録画だけです。

イベント(SSE)

ルームのイベントは GET /api/v1/events(API と SDK)に流れます。イベントは 2 種類あり、取りこぼしたときの扱いが違います。

制御のイベントは、クラスターの合意ログに記録されます。SSE の id を持つため、Last-Event-ID で途中から受信を再開できます。

event発生する場面data
room.updatedルームの作成、自動作成、更新{auto}
room.deletedルームの削除(自動作成されたルームの自動削除を含む){}

ルームの中のイベント(参加者やトラックの出入りなど)は、参加者がいるノードの購読者にだけ流れます。 カスケードでは、エッジノードにいる参加者のイベントはそのエッジノードに流れ、room.started・room.stopped はホームノードにだけ流れます。 こちらは SSE の id を持たないので、Last-Event-ID で再開できず、取りこぼしたイベントも再送されません。

event発生する場面
room.startedノードがルームを開いた
room.stoppedノードがルームを閉じた(削除や、別のノードへの移動のため)
room.connection.created参加者が参加した
room.connection.updated参加者のメタデータが変わった
room.connection.destroyed参加者が退出した(data に reason・duration_ms が入ります)
room.track.publishedトラックが公開された
room.track.unpublishedトラックの公開が終わった
room.spotlight.changedスポットライトで注目する人の組が変わった

ルームの中のイベントの形式:

{"type":"room.connection.destroyed","resource_id":"daily","event_id":"…","node":"…","occurred_at":"2026-11-03T10:00:00Z",
 "data":{"connection_id":"…","client_id":"alice","reason":"normal","duration_ms":61234}}

ルームの中のイベントを漏れなく受け取るには、ルームを配置したノード(placement_status.node)の SSE を購読してください。 イベントはテナントで絞り込まれるため、他のテナントのルームのイベントは流れません。

イベント Webhook

--rooms-event-webhook URL と --rooms-webhook-secret-file FILE を指定すると、各ノードは自ノードのルームの中のイベントを、 その URL へ署名付きで POST します(本文の形式は ルームのシグナリングの「イベント」。type には connection.created などの Webhook 用の名前が入ります)。

イベントは少なくとも 1 回届けるので、同じイベントが 2 回以上届くことがあります。受信側では、本文の id(Senca-Webhook-Id ヘッダーと同じ値)で重複を取り除いてください。

2xx が返れば配信完了です。408・429・5xx、接続の失敗、10 秒のタイムアウトのときは、1、2、4…秒と間隔を空けて再送し、10 回失敗したら諦めます。 それ以外の 4xx が返ったときは、再送せずにすぐ諦めます。諦めた件数と最近のイベントの ID は、GET /api/v1/nodes の自ノードの rooms.event_webhook で確認できます。

宛先は --egress-allow の方針で検査されます。Webhook を設定しても、SSE にはこれまでどおりイベントが流れます。

上限

項目値
ルームの参加者数max_participants(既定 50、1〜10000)。超えると room_full
ノードあたりの参加者数・ルーム数それぞれ 10000(超えると room_full)
テナントあたりのルーム数10000
受信スロット種類ごとに 32
サイマルキャストのレイヤー1〜4
メタデータ8 KiB(ルーム・参加者・トークン)
アプリケーションのメッセージ64 KiB
シグナリングのメッセージ256 KiB、毎秒 50 個
トークンの有効期間10〜86400 秒(既定 600)
connect までの期限10 秒
ping10 秒ごと、pong の期限は 10 秒
ピア接続の期限参加から 30 秒以内に接続できなければ peer_failed

現在のノードで使えないもの

次の機能は ルームのシグナリング の仕様にありますが、現在のバージョンのノードでは使えません。

  • 満員のときに別のノードへ案内する overflow_redirect: 設定できません。ノードが満員なら room_full になります。
  • 参加者が自分のメタデータを変更するメッセージ: シグナリング v1 にはありません。アプリケーションのサーバーから PATCH /rooms/{id}/participants/{connection_id} で変更してください。
  • 接続の再開(resume): 切断されたら、新しい接続として入り直します。

例

curl

API=https://media.example.com/api/v1
H="Authorization: Bearer $SENCA_TOKEN"

curl -s -X POST -H "$H" -H 'Content-Type: application/json' "$API/rooms" \
  -d '{"id":"demo","settings":{"max_participants":10,"video_codec":"vp8","spotlight":{"number":2}},"metadata":{"title":"standup"}}'

curl -s -H "$H" "$API/rooms/demo"

curl -s -X POST -H "$H" -H 'Content-Type: application/json' "$API/rooms/demo/tokens" \
  -d '{"role":"sendrecv","client_id":"alice","metadata":{"name":"Alice"},"ttl_secs":600}'

curl -s -H "$H" "$API/rooms/demo/participants"
curl -s -H "$H" "$API/rooms/demo/stats"

curl -s -X POST -H "$H" -H 'Content-Type: application/json' "$API/rooms/demo/messages" -d '{"data":{"notice":"5 分後に終わります"}}'
curl -s -X POST -H "$H" -H 'Content-Type: application/json' "$API/rooms/demo/messages" -d '{"label":"#chat","data":"hello"}'

curl -s -X PATCH -H "$H" -H 'Content-Type: application/json' -H 'If-Match: "1"' "$API/rooms/demo" \
  -d '{"settings":{"max_participants":20,"spotlight":{"number":2}}}'

curl -s -X DELETE -H "$H" "$API/rooms/demo/participants/$CONNECTION_ID"
curl -s -X DELETE -H "$H" "$API/rooms/demo"

ブラウザー SDK(@senca/sdk)

import { Senca } from '@senca/sdk';

const camera = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: true });
const session = await Senca.connect({
  url: 'wss://media.example.com/signaling',   // トークンの応答の signaling_url(複数書くと、入り直すときに順に切り替える)
  room: 'demo',
  token: () => fetch('/my-backend/room-token').then((r) => r.text()),   // 入り直すたびに取得し直す
  publish: { stream: camera, simulcast: true },
  dataChannels: ['#chat'],
});

for await (const track of session.remoteTracks()) {
  const video = track.attach(document.createElement('video'));
  document.body.append(video);
  track.ended.then(() => video.remove());
}
await session.send('#chat', { text: 'hello' });                  // 全員へ(DataChannel)
for await (const m of session.messages('#chat')) console.log(m.sender?.clientId, m.json());
const t = [...session.tracks.values()].find((t) => t.kind === 'video');
await t.setLayer('r0');                                          // 'r0' | 'r1' | 'r2' | 'auto'
await session.close();

SDP のやり取り、受信スロットの管理、再ネゴシエーション、ping への応答、リダイレクト(3 回まで)、再接続は SDK が自動で行います。 再接続は 0.5 秒後から始め、間隔を倍々に延ばしながら(最大 10 秒)10 回まで試みます。 オプションとイベントの一覧は、SDK の README と API と SDK にあります。

関連するページ

この文書の原文は、リポジトリの docs/guide/ja/10-rooms.md にあります。内容は現在の版(v0.0.1)に合わせています。