WebRTC の映像はブラウザ同士で直接やりとりしますが、最初に「相手はどこにいて、どんな形式で話すか」を伝え合う仲介役が必要です。これがシグナリングサーバーです。前回の第1回:カメラとマイクを取得してプレビューし、デバイスを選べるようにするでは、自分のカメラ映像をプレビューし、エラーを分かりやすく表示するところまで作りました。
「WebRTCのシグナリングサーバーって、Node.jsで別に立てないといけないの?Nuxtだけで作れる?」
結論から言うと、Nuxt 4 のサーバー部分(Nitro)の WebSocket 機能を使えば、Nuxt のプロジェクト1つでシグナリングサーバーまで作れます。今回は、同じルームにいる相手にだけメッセージを中継するサーバーと、ブラウザ側の接続用 composable を実装します。ただし、WebSocket は Nitro v2 では実験的機能で、常時動いているサーバーで動かすことが前提になる点には注意が必要です。
今回作るもの:ルーム単位でメッセージを中継するサーバー
- ブラウザが WebSocket(=サーバーとブラウザが接続したまま双方向にやりとりする通信)で /_ws に接続する
- join メッセージでルームに参加すると、同じルームの相手に「参加した」ことが届く
- 通話の準備に使う情報(次回使う SDP と ICE 候補)を、同じルームの自分以外にだけ中継する
- タブを閉じたり退出したりすると、相手に「退出した」ことが届く
- 動作確認用に /signaling-test ページを用意する
追加するファイル
| ファイル | 役割 |
|---|---|
| code/shared/types/signaling.ts | サーバーとブラウザで共有するメッセージの型 |
| code/server/routes/_ws.ts | シグナリングサーバー本体(WebSocket ハンドラ) |
| code/server/utils/rooms.ts | どの接続がどのルームにいるかの管理 |
| code/server/utils/signaling.ts | 届いたメッセージの検証 |
| code/app/composables/useSignaling.ts | ブラウザ側の接続と受信処理 |
| code/app/pages/signaling-test.vue | 動作確認用ページ |
仕組み:シグナリングで何をやりとりするのか
WebRTC で2つのブラウザがつながるまでには、次の情報を交換する必要があります。
| 情報 | 中身 | 今回のメッセージ |
|---|---|---|
| 参加・退出の知らせ | 誰が同じルームにいるか | join / joined / peer-joined / peer-left |
| SDP(=セッションの説明書) | 映像・音声の形式、暗号化の情報など。offer(提案)と answer(応答)がある | description |
| ICE 候補 | 「このIPアドレスとポートなら届くかも」という経路の候補 | ice-candidate |
WebRTC の仕様は、これらの情報を「どうやって相手に届けるか」を決めていません。WebSocket でも HTTP でも構わず、アプリ側で用意する必要があります。今回はこの配送役を Nuxt 自身に担わせます。
📰 出典:MDN「Signaling and video calling」
Nitro v2 の WebSocket と pub/sub
Nuxt 4 のサーバー部分は Nitro v2 で動いており、WebSocket は実験的機能として提供されています。第0回で nuxt.config.ts に nitro.experimental.websocket: true を設定済みなので、server/routes/_ws.ts に defineWebSocketHandler を書けば /_ws で接続を受け付けられます。フックは open(接続時)・message(受信時)・close(切断時)・error の4つです。
📰 出典:Nitro v2「WebSocket」
Nitro の WebSocket は内部で crossws というライブラリを使っており、接続(peer)ごとに pub/sub(=トピックを購読した相手にまとめて配信する仕組み)の機能があります。
| 機能 | 動き |
|---|---|
| peer.subscribe(topic) | その接続をトピックの購読者に加える |
| peer.publish(topic, data) | トピックの購読者に送る。送信者自身には届かない |
| peer.send(data) | その接続にだけ送る |
📰 出典:crossws「Pub / Sub」
crossws の公式サイトは新しい 0.4 系の内容です。Nuxt 4 が使う 0.3 系で publish が送信者を除外することは、インストールされたパッケージのソースコード(Node.js 用アダプター)で確認しました。このため「ルームID = トピック」にしておけば、publish するだけで「同じルームの自分以外」に届きます。
実装1:メッセージの型をサーバーとブラウザで共有する
code/shared/types/signaling.ts(抜粋)
export type PeerId = string
/** RTCSessionDescriptionInit 相当(サーバー側には DOM の型が無いため自前で定義) */
export type SessionDescription = {
type: 'offer' | 'answer' | 'pranswer' | 'rollback'
sdp?: string
}
/** RTCIceCandidateInit 相当 */
export type IceCandidate = {
candidate?: string
sdpMid?: string | null
sdpMLineIndex?: number | null
usernameFragment?: string | null
}
/** ブラウザ → サーバー */
export type ClientMessage =
| { type: 'join'; roomId: string }
| { type: 'leave' }
| { type: 'description'; description: SessionDescription }
| { type: 'ice-candidate'; candidate: IceCandidate | null }
/** サーバー → ブラウザ */
export type ServerMessage =
| { type: 'joined'; roomId: string; selfId: PeerId; peers: PeerId[] }
| { type: 'peer-joined'; peerId: PeerId }
| { type: 'peer-left'; peerId: PeerId }
| { type: 'description'; from: PeerId; description: SessionDescription }
| { type: 'ice-candidate'; from: PeerId; candidate: IceCandidate | null }
| { type: 'error'; code: SignalingErrorCode; message: string }
/** ルームIDとして受け付ける文字列(英数字・ハイフン・アンダースコア、1〜64文字) */
export const ROOM_ID_PATTERN = /^[\w-]{1,64}$/
Nuxt 4 では shared/ ディレクトリに置いたコードを、画面側(app/)とサーバー側(server/)の両方から #shared という別名で読み込めます。メッセージは type フィールドで種類を見分ける「判別共用体」にしているので、switch (msg.type) と書くと、それぞれの分岐の中で使えるフィールドが TypeScript によって正しく絞り込まれます。
サーバー側の TypeScript には、ブラウザ用の RTCSessionDescriptionInit などの型がありません。そのため、同じ形の型を自前で定義しています。
実装2:届いたメッセージを検証する
code/server/utils/signaling.ts(抜粋)
/** 1メッセージの上限(SDP は数KB〜十数KB程度。余裕を見て 64KB) */
export const MAX_MESSAGE_BYTES = 64 * 1024
export function parseClientMessage(text: string): ClientMessage | null {
if (text.length > MAX_MESSAGE_BYTES) return null
let data: unknown
try {
data = JSON.parse(text)
}
catch {
return null
}
if (!isRecord(data)) return null
switch (data.type) {
case 'join':
return typeof data.roomId === 'string' && ROOM_ID_PATTERN.test(data.roomId)
? { type: 'join', roomId: data.roomId }
: null
case 'leave':
return { type: 'leave' }
case 'description':
return isDescription(data.description) ? { type: 'description', description: data.description } : null
case 'ice-candidate':
return isCandidate(data.candidate) ? { type: 'ice-candidate', candidate: data.candidate } : null
default:
return null
}
}
WebSocket のエンドポイントには、ブラウザの画面を通さなくても誰でも接続できます。型定義はあくまで「こう送ってくるはず」という約束なので、サーバーに届いたデータは必ず形をチェックします。サイズの上限、JSON として読めるか、type が既知のものか、ルームIDに使える文字だけか、を確認し、合わなければ null を返してエラーメッセージを送り返します(isRecord・isDescription・isCandidate は、オブジェクトかどうかやフィールドの型を確かめる小さな関数です)。
実装3:ルームの参加者を管理する
code/server/utils/rooms.ts
const rooms = new Map<string, Set<string>>() // roomId -> peerId の集合
const peerRooms = new Map<string, string>() // peerId -> roomId
export function joinRoom(roomId: string, peerId: string): string[] {
const members = rooms.get(roomId) ?? new Set<string>()
const others = [...members]
members.add(peerId)
rooms.set(roomId, members)
peerRooms.set(peerId, roomId)
return others
}
export function leaveRoom(peerId: string): string | undefined {
const roomId = peerRooms.get(peerId)
if (!roomId) return undefined
peerRooms.delete(peerId)
const members = rooms.get(roomId)
members?.delete(peerId)
if (members && members.size === 0) rooms.delete(roomId)
return roomId
}
export function getRoomOf(peerId: string): string | undefined {
return peerRooms.get(peerId)
}
export function roomTopic(roomId: string): string {
return `room:${roomId}`
}
pub/sub だけでも中継はできますが、「今このルームに誰がいるか」を参加した本人に返すため、サーバーのメモリ上に参加者の一覧を持っています。server/utils/ に置いた関数は Nitro が自動で読み込む(auto-import)ので、_ws.ts から import 文なしで使えます。
メモリ上に持っているため、サーバーを再起動すると一覧は消えますし、サーバーを複数台に増やすと台数ごとに別々の一覧になります。本番で複数台構成にする場合は、Redis などの共有ストアを使う設計が別途必要です。第4回で、この一覧を使って定員(1対1なら2名)のチェックを追加します。
実装4:シグナリングサーバー本体
code/server/routes/_ws.ts(型定義と error フックを省略した抜粋)
/** 本人にだけ送る */
function sendTo(peer: SignalingPeer, message: ServerMessage) {
peer.send(JSON.stringify(message))
}
/** 同じルームの自分以外に送る(publish は送信者自身には届かない) */
function broadcast(peer: SignalingPeer, roomId: string, message: ServerMessage) {
peer.publish(roomTopic(roomId), JSON.stringify(message))
}
/** ルームから抜けて、残った相手に知らせる */
function leave(peer: SignalingPeer) {
const roomId = leaveRoom(peer.id)
if (!roomId) return
broadcast(peer, roomId, { type: 'peer-left', peerId: peer.id })
peer.unsubscribe(roomTopic(roomId))
}
export default defineWebSocketHandler({
open(peer) {
// 接続しただけではどのルームにも入らない。join メッセージを待つ
if (import.meta.dev) console.log('[ws] open', peer.id)
},
message(peer, message) {
const msg = parseClientMessage(message.text())
if (!msg) {
sendTo(peer, { type: 'error', code: 'invalid-message', message: 'メッセージの形式が正しくありません' })
return
}
switch (msg.type) {
case 'join': {
leave(peer) // 別のルームにいたら先に抜ける(1接続=1ルーム)
const others = joinRoom(msg.roomId, peer.id)
peer.subscribe(roomTopic(msg.roomId))
sendTo(peer, { type: 'joined', roomId: msg.roomId, selfId: peer.id, peers: others })
broadcast(peer, msg.roomId, { type: 'peer-joined', peerId: peer.id })
return
}
case 'leave':
leave(peer)
return
case 'description':
case 'ice-candidate': {
const roomId = getRoomOf(peer.id)
if (!roomId) {
sendTo(peer, { type: 'error', code: 'not-joined', message: '先にルームに参加してください' })
return
}
// 送信者は from としてサーバーが付け直す(ブラウザが名乗った送信者は信用しない)
broadcast(peer, roomId, msg.type === 'description'
? { type: 'description', from: peer.id, description: msg.description }
: { type: 'ice-candidate', from: peer.id, candidate: msg.candidate })
return
}
}
},
close(peer) {
// タブを閉じた・回線が切れた場合も、ルームから外して相手に知らせる
leave(peer)
if (import.meta.dev) console.log('[ws] close', peer.id)
},
})
処理の流れは単純です。
- join を受け取ったら、ルームの一覧に加えてトピックを購読し、本人には joined(自分のIDと、先にいた相手の一覧)、相手には peer-joined を送る
- description と ice-candidate は、送ってきた接続がいるルームへそのまま中継する
- leave を受け取るか、接続が切れたら(close)、一覧から外して相手に peer-left を送る
中継するときに、送信者のID(from)はサーバーが接続情報から付け直しています。ブラウザから届いた「私は誰それです」という自己申告を信じないためです。peer.id は crossws が接続ごとに発行するランダムなIDです。
実装5:ブラウザ側の useSignaling
code/app/composables/useSignaling.ts(抜粋)
export function useSignaling() {
const status = ref<SignalingStatus>('idle')
const roomId = ref<string | null>(null)
const selfId = ref<PeerId | null>(null)
/** 同じルームにいる自分以外のピア */
const peers = ref<PeerId[]>([])
let ws: WebSocket | null = null
const listeners = new Set<(msg: ServerMessage) => void>()
function send(msg: ClientMessage) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
}
/** サーバーからのメッセージを受け取る関数を登録する。戻り値を呼ぶと登録解除 */
function onMessage(listener: (msg: ServerMessage) => void) {
listeners.add(listener)
return () => listeners.delete(listener)
}
function join(targetRoomId: string) {
disconnect()
// ページが https なら wss、http(localhost)なら ws で同じホストに接続する
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const socket = new WebSocket(`${protocol}//${location.host}/_ws`)
ws = socket
status.value = 'connecting'
socket.addEventListener('open', () => {
status.value = 'open'
send({ type: 'join', roomId: targetRoomId })
})
socket.addEventListener('message', (event) => {
if (typeof event.data !== 'string') return
try {
handle(JSON.parse(event.data) as ServerMessage)
}
catch {
console.warn('[signaling] JSON ではないメッセージを無視しました')
}
})
// close 時の状態リセットは省略
}
onScopeDispose(disconnect)
return { status, roomId, selfId, peers, join, disconnect, send, onMessage }
}
handle 関数(省略)では、joined・peer-joined・peer-left を受け取るたびに peers(同じルームの相手一覧)を更新し、そのあと onMessage で登録された関数にメッセージを渡します。次回の通話処理(usePeerConnection)は、この onMessage で description と ice-candidate を受け取る形になります。
接続先は location.host から組み立てているので、http://localhost なら ws://、HTTPS で公開したページなら暗号化された wss:// に自動で切り替わります。disconnect は onScopeDispose で呼ばれるため、ページを離れると退出の連絡と切断が行われます。自動再接続は第10回で追加します。
動作確認の方法
動作確認用の /signaling-test ページでは、ルームIDを入力して「参加する」を押すと、接続状態・自分のID・同じルームの相手・受信ログが表示されます。
手元で試す手順は次のとおりです。
- npm run dev で起動し、http://localhost:3000/signaling-test を2つのタブで開く
- 両方のタブで同じルームID(例:room-a)で参加する → お互いの「同じルームの相手」に相手のIDが表示される
- 3つ目のタブで別のルームID(例:room-b)で参加する → room-a の2タブには何も届かない
- 片方のタブで「退出する」を押すか、タブを閉じる → もう一方に peer-left が届く
- ブラウザの開発者ツールの「ネットワーク」タブで /_ws を選ぶと、送受信したメッセージを1件ずつ確認できる
筆者の環境では、次の2つの方法で確認しました。
| 方法 | 確認したこと |
|---|---|
| Node.js の WebSocket クライアント(ws パッケージ)で4接続を作るスクリプト | 同じルームの相手にだけ description・ice-candidate が届き、送信者自身と別ルームには届かない。不正な JSON・不正なルームIDは invalid-message、未参加での送信は not-joined になる。切断時に相手へ peer-left が届く |
| Playwright で Chromium の3タブを操作 | 上の手順2〜4の表示どおりになる |
いずれも npm run dev(開発サーバー)と、npm run build 後の node .output/server/index.mjs(本番用ビルド)の両方で同じ結果でした。npm run typecheck も通っています。なお、SDP と ICE 候補の中継が実際の通話で機能するかは、次回の1対1通話で確認します。
つまずきやすい点とセキュリティ上の注意
- publish は自分に届かない:送信者本人にも結果を返したいときは send を別に呼びます。今回の joined がその例です。
- ページが表示されてすぐにボタンを押すと動かないことがある:SSR のページは、ブラウザ側の処理が有効になる(ハイドレーション)前にフォームを送信すると、通常のフォーム送信になってしまいます。確認時は表示が落ち着いてから操作してください。
- ログに SDP や ICE 候補を出さない:これらには IP アドレスが含まれます。サンプルではサーバーのログに接続IDしか出さず、それも開発時だけにしています。
- ルームIDを知っていれば誰でも参加できる:今回のサーバーには認証も定員もありません。第4回で定員を入れますが、本番では参加者の認証、ルームの有効期限、接続数やメッセージ頻度の制限なども検討が必要です。
- サーバーレス環境では動かないことがある:WebSocket は接続を張りっぱなしにするため、常時動いているサーバーが前提です。Nitro は実行環境(プラットフォーム)ごとの対応状況を GitHub の issue で案内しており、デプロイ先の対応は第6回で確認します。
発注者向けメモ:シグナリングサーバーは「常時動くサーバー」が前提
シグナリングは短いテキストのやりとりなので、サーバーの処理自体は軽いものです。ただし、接続を張りっぱなしにする WebSocket を使うため、常時動いているサーバーと、その運用(監視・再起動・更新)が必要になります。一般的なホームページのように「静的ファイルを置くだけ」のホスティングでは動きません。
- ☐ 通話機能のサーバーを、どこで(クラウドのどのサービスで)動かすか決まっているか
- ☐ サーバーが止まったとき・再起動したときに、通話中の利用者にどんな影響が出るかを説明してもらったか
- ☐ 同時に何ルーム・何人程度の利用を想定するか、開発会社に伝えているか(サーバーの台数や構成が変わる)
- ☐ ルームに入れる人の制限(ログイン必須か、URLを知っていれば入れるか)を決めているか
開発会社への質問例:
- 「シグナリングサーバーはどの環境で動かす想定ですか?WebSocket に対応していますか?」
- 「サーバーを複数台にする必要が出た場合、構成はどう変わりますか?」
- 「サーバーの再起動や更新の作業中、通話中の人はどうなりますか?」
- 「部外者がルームに入れないようにする仕組みは、どこまで見積もりに含まれていますか?」
Nuxt 1つで画面とシグナリングをまとめられるのは、開発・運用する物が少なくて済むという利点があります。一方で、ホスティング先の選び方には制約が出るので、早めにすり合わせておきましょう。
まとめと次回予告
- シグナリングサーバーは、SDP と ICE 候補を相手に届ける配送役。WebRTC の仕様では決まっておらず、アプリ側で用意する
- Nuxt 4(Nitro v2)では、実験的機能の WebSocket を有効にし、server/routes/_ws.ts に defineWebSocketHandler を書く
- 「ルームID = pub/sub のトピック」にすると、publish だけで同じルームの自分以外に届けられる
- メッセージの型は shared/ で共有し、サーバーでは届いた中身を必ず検証する
次回(第3回)は「RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)」です。今回のシグナリングと第1回のカメラ映像を組み合わせ、2つのタブの間で実際に映像と音声をやりとりします。
この連載の記事一覧
この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【NuxtとWebRTCで作るビデオチャット 第0回】Nuxt 4でビデオチャットの土台を作る:なぜHTTPSが必要なのか
- 【NuxtとWebRTCで作るビデオチャット 第1回】カメラとマイクを取得してプレビューし、デバイスを選べるようにする
- 【NuxtとWebRTCで作るビデオチャット 第2回】Nitro の WebSocket でシグナリングサーバーを作る(この記事)
- 【NuxtとWebRTCで作るビデオチャット 第3回】RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)
- 【NuxtとWebRTCで作るビデオチャット 第4回】ルーム作成と招待リンク、入室前プレビュー
- 【NuxtとWebRTCで作るビデオチャット 第5回】ミュートとカメラオフ
- 【NuxtとWebRTCで作るビデオチャット 第6回】STUN/TURN と本番デプロイ:「社外とつながらない」を解消する
- 【NuxtとWebRTCで作るビデオチャット 第7回】画面共有
- 【NuxtとWebRTCで作るビデオチャット 第8回】DataChannel でテキストチャット
- 【NuxtとWebRTCで作るビデオチャット 第9回】MediaRecorder で録画する前に考えること
- 【NuxtとWebRTCで作るビデオチャット 第10回】切断・再接続とエラーハンドリング
- 【NuxtとWebRTCで作るビデオチャット 第11回】少人数通話へ:メッシュ実装と SFU という選択肢










コメント
コメント一覧 (1件)
[…] いよいよ、2つのブラウザの間で映像と音声がつながります。前回の第2回:Nitro の WebSocket でシグナリングサーバーを作るでは、同じルームの相手にだけメッセージを中継するサーバーを作りました。今回はそれと第1回のカメラ映像を組み合わせ、RTCPeerConnection(=ブラウザ同士の通話を管理するAPI)で1対1のビデオ通話を完成させます。 […]