前回の第3回:RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)で、同じルームに入った2人がビデオ通話できるようになりました。ただ、ルームIDは自分でURLに打ち込む必要があり、3人目が入ってくると何も表示されないまま待たされる状態でした。今回は、ビデオチャットの「入口」まわりを整えます。
「ビデオ通話の招待リンクって、どうやって作るの?URLを知っている人なら誰でも入れてしまうのは大丈夫?」
結論から言うと、招待リンクは「推測されにくいルームID(UUID)をURLに入れる」だけで作れます。ただしこれは「URLを知っている人は誰でも入れる」設計です。今回のサンプルでは、定員(1対1なので2名)を超えた入室をサーバーで断るところまでを実装し、認証・有効期限・待合室のような本格的な入室制御は発注時に決めるべき論点として整理します。
今回作るもの:ルーム作成・招待リンク・入室前のカメラ確認
- トップページの「新しいルームを作成する」ボタンで、UUID のルームIDを発行してルームページへ移動する
- ルームページの上部に招待リンクを表示し、「コピー」ボタンでクリップボードにコピーできる
- 入室前に、カメラ映像のプレビューとカメラ・マイクの選択ができる(第1回の部品を再利用)
- 定員(2名)に達しているルームに入ろうとすると、サーバーが断り、理由を画面に表示する
- あわせて、第3回の接続処理で見つかった「まれに接続が始まらない」問題を修正する
追加・変更するファイル
| ファイル | 役割 |
|---|---|
| code/server/utils/rooms.ts | 定員(ROOM_CAPACITY)のチェックを追加 |
| code/server/routes/_ws.ts | 定員オーバー時に room-full エラーを返す |
| code/shared/types/signaling.ts | エラーコードに room-full を追加 |
| code/app/composables/useSignaling.ts | サーバーから届いたエラーを error として公開 |
| code/app/pages/index.vue | ルーム作成ボタン |
| code/app/components/JoinPanel.vue | 入室前のカメラ・マイク確認パネル(新規) |
| code/app/pages/room/[id].vue | 招待リンク、JoinPanel の組み込み、定員オーバーの表示 |
| code/app/composables/usePeerConnection.ts | 最初の offer を作る側を一方に固定(不具合修正) |
仕組み:招待リンクと定員チェックの役割分担
| 役割 | 担当 | 方法 |
|---|---|---|
| ルームIDを作る | ブラウザ | crypto.randomUUID() で UUID(v4)を生成 |
| 招待リンクを見せる・コピーする | ブラウザ | location.origin + /room/<ID> を表示し、Clipboard API でコピー |
| 入室できるか判断する | サーバー(シグナリング) | ルームの人数を数え、定員以上なら参加させない |
ここで大事なのは、「入れるかどうか」の判断はサーバー側で行うことです。画面側で「満員です」と表示するだけだと、ブラウザを操作できる人なら画面の判定を飛ばして参加できてしまいます。第2回から、ルームの参加者はサーバーのメモリで管理しているので、そこに定員チェックを足します。
UUID(v4)は、暗号学的に安全な乱数で作られる36文字のIDです。連番や日付のIDと違い、他人のルームIDを当て推量で見つけることは現実的ではありません。ただし「推測されにくい」だけで、URLが転送されたり画面共有で映り込んだりすれば、誰でも入れます。
📰 出典:MDN「Crypto: randomUUID() method」
実装1:サーバーで定員を超えた入室を断る
ルームの定員チェック
code/server/utils/rooms.ts(抜粋)
/** 1ルームの定員。第11回(メッシュで少人数通話)で 4 に増やす */
export const ROOM_CAPACITY = 2
/**
* ルームに参加する。成功したら先にいたピアの一覧を、定員オーバーなら null を返す。
*/
export function joinRoom(roomId: string, peerId: string): string[] | null {
const members = rooms.get(roomId) ?? new Set<string>()
if (members.size >= ROOM_CAPACITY && !members.has(peerId)) return null
const others = [...members].filter(id => id !== peerId)
members.add(peerId)
rooms.set(roomId, members)
peerRooms.set(peerId, roomId)
return others
}
戻り値を「先にいたピアの一覧、または null(満員)」にしました。定員は定数にしておき、第11回で少人数通話に広げるときに変更します。
WebSocket ハンドラで room-full を返す
code/server/routes/_ws.ts(join の処理)
case 'join': {
leave(peer) // 別のルームにいたら先に抜ける(1接続=1ルーム)
const others = joinRoom(msg.roomId, peer.id)
if (!others) {
// 第4回: 定員オーバー。subscribe しないので、このルームのメッセージは届かない
sendTo(peer, { type: 'error', code: 'room-full', message: `このルームは定員(${ROOM_CAPACITY}名)に達しています` })
return
}
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
}
満員のときは subscribe も peer-joined の通知もしないので、通話中の2人には何も届かず、通話は影響を受けません。エラーコードは shared/types/signaling.ts の SignalingErrorCode に 'room-full' を追加しています(サーバーとブラウザで同じ型を使うため、片方だけ直し忘れると型チェックで気付けます)。
ブラウザ側の useSignaling では、error メッセージを受け取ったら error という ref に入れて公開するようにしました(join のたびに null に戻します)。
実装2:トップページでルームを作る
code/app/pages/index.vue(追加部分)
<script setup lang="ts">
// 第4回: 新しいルームを作る。推測されにくい ID として UUID(v4)を使う
const createError = ref('')
async function createRoom() {
// crypto.randomUUID() も Secure Context 限定(HTTP の LAN アドレス等では使えない)
if (typeof crypto.randomUUID !== 'function') {
createError.value = 'このページではルームを作成できません。HTTPS(開発中は http://localhost)で開いてください。'
return
}
await navigateTo(`/room/${crypto.randomUUID()}`)
}
</script>
crypto.randomUUID() は、カメラの API と同じく Secure Context(HTTPS か localhost)でしか使えません。第0回で説明した「LAN のIPアドレスを http で開くと動かない」問題がここでも起きるため、使えないときはメッセージを出します。UUID は英数字とハイフンだけなので、第2回で決めたルームIDのルール(ROOM_ID_PATTERN)にもそのまま合います。
ルームIDをサーバーで発行する方法もありますが、今回のサーバーは「ルームは誰かが入った時点で作られ、全員抜けたら消える」だけのシンプルな作りなので、ブラウザで作っても困りません。予約機能や有効期限を持たせる場合は、サーバー側で発行・保存する設計に変えることになります。
実装3:入室前にカメラとマイクを確認する JoinPanel
code/app/components/JoinPanel.vue(script 部分の抜粋)
<script setup lang="ts">
// カメラの取得(useLocalMedia)は通話でもそのまま使うため、親ページが持ち、ここでは操作だけを行う。
import type { LocalMedia } from '~/composables/useLocalMedia'
const props = defineProps<{
media: LocalMedia
/** 入室できない理由(定員オーバー等)。親から渡す */
joinError?: string | null
}>()
const emit = defineEmits<{ join: [] }>()
const { videoInputs, audioInputs, refresh } = useDevices()
const videoDeviceId = ref('')
const audioDeviceId = ref('')
async function check() {
await props.media.start({
videoDeviceId: videoDeviceId.value || undefined,
audioDeviceId: audioDeviceId.value || undefined,
})
if (!props.media.stream.value) return
// 許可後はデバイス名が取れるので一覧を取り直し、実際に使われているデバイスを選択状態にする
await refresh()
videoDeviceId.value = props.media.currentDeviceId('video') ?? ''
audioDeviceId.value = props.media.currentDeviceId('audio') ?? ''
}
</script>
中身は第1回のプレビューページ(preview.vue)とほぼ同じです。違いは、カメラの MediaStream をこのコンポーネントではなく親のルームページが持っている点です。入室前に確認した映像を、そのまま通話に使いたいからです。そこで useLocalMedia() の戻り値をまるごと props で受け取り、start を呼ぶだけにしています(型は useLocalMedia.ts に export type LocalMedia = ReturnType<typeof useLocalMedia> を追加しました)。
画面は「カメラとマイクを確認する」→ プレビューとデバイス選択 →「この設定で入室する」の順です。確認前には映像も音声も誰にも送られないこと、を冒頭に書いています。
実装4:ルームページに招待リンクと定員オーバーの表示を追加
code/app/pages/room/[id].vue(script 部分の抜粋)
<script setup lang="ts">
const media = useLocalMedia()
const { stream: localStream, stop: stopMedia } = media
const signaling = useSignaling()
const { remoteStream, connectionState, close: closePeer } = usePeerConnection(signaling, () => localStream.value)
const joined = computed(() => signaling.status.value === 'open' && signaling.roomId.value !== null)
const joinError = ref<string | null>(null)
// location はブラウザにしか無いため、URL は onMounted で組み立てる
const inviteUrl = ref('')
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
const inviteInput = ref<HTMLInputElement | null>(null)
onMounted(() => {
inviteUrl.value = `${location.origin}/room/${encodeURIComponent(roomId.value)}`
})
async function copyInviteUrl() {
try {
// Clipboard API は Secure Context 限定。ブラウザの設定で拒否されることもある
await navigator.clipboard.writeText(inviteUrl.value)
copyState.value = 'copied'
}
catch {
// コピーできなければ全選択して、手動でコピーしてもらう
copyState.value = 'failed'
inviteInput.value?.select()
}
}
// カメラ・マイクは JoinPanel で確認済み。取得できている時だけ参加する
function enter() {
if (!localStream.value) return
joinError.value = null
signaling.join(roomId.value)
}
// 定員オーバーなどで参加を断られたら、接続を閉じて入室前の画面に理由を出す
watch(signaling.error, (err) => {
if (err?.code !== 'room-full') return
joinError.value = `${err.message}。通話が終わってから、もう一度お試しください。`
signaling.disconnect()
})
</script>
ポイントは3つです。
- 招待URLは onMounted で作る:Nuxt はページをサーバーでも描画(SSR)するため、サーバー側には
locationがありません。ブラウザで表示された後に組み立てます。 - コピーに失敗しても困らないようにする:Clipboard API(
navigator.clipboard.writeText)は Secure Context 限定で、ブラウザの設定によっては拒否されます。失敗したら入力欄の URL を全選択し、手動でコピーしてもらう案内を出します。 - 定員オーバーはサーバーからのエラーで判断する:
room-fullを受け取ったら WebSocket を閉じ、JoinPanel に理由を表示します。カメラは止めないので、通話が空いたらそのまま「この設定で入室する」を押し直せます。
📰 出典:MDN「Clipboard: writeText() method」
テンプレート(省略)では、入室前は JoinPanel を、入室後は第3回と同じ通話画面を表示するように v-if で切り替えています。
不具合修正:最初の offer は後から入った側だけが作る
今回の動作確認を繰り返す中で、2人が入室しても「相手を待っています」のまま接続が始まらないことがあると分かりました。筆者の検証環境(Chromium を自動操作)で同じ手順を8回繰り返したところ、3回で発生しました。
調べると、失敗したときは次のような状態でした。
- 第3回のコードでは、2人とも接続を作った直後にカメラのトラックを載せるため、毎回お互いが同時に offer を送り合う(衝突する)
- 衝突は Perfect negotiation の仕組みで解決され、answer までは正しく返っている
- しかし、譲った側(polite)のブラウザで ICE 候補の収集と接続確認が始まらず、状態が new のまま止まる
Perfect negotiation は衝突しても正しく動くための仕組みですが、「毎回必ず衝突させる」必要はありません。そこで、最初の offer は後から入った側(polite)だけが作り、先にいた側は offer を受け取ってからトラックを載せるように変えました。
code/app/composables/usePeerConnection.ts(変更部分の抜粋)
let localTracksAdded = false
/** 自分のカメラ・マイクのトラックを載せる。stable な状態で載せると negotiationneeded が発生する */
function addLocalTracks(conn: RTCPeerConnection) {
if (localTracksAdded) return
localTracksAdded = true
const localStream = getLocalStream()
localStream?.getTracks().forEach(track => conn.addTrack(track, localStream))
}
// connect() の中
// 第4回で変更: 最初の offer は「後から入った側(polite)」だけが作る。
if (isPolite) addLocalTracks(conn)
// handleSignal() の中(offer を受け入れた後)
if (description.type === 'offer') {
// 相手の offer を受け入れた後に載せると、offer の音声・映像の枠がそのまま使われ、answer に含まれる
addLocalTracks(conn)
await conn.setLocalDescription()
sendDescription(conn.localDescription)
}
相手の offer を受け入れた(setRemoteDescription した)後に addTrack すると、offer によって作られた音声・映像の枠(トランシーバー)がそのまま使われ、返す answer に自分の映像・音声も含まれます。修正後は同じ手順を10回繰り返してすべて接続し、双方で音声・映像の両方を受信できることを確認しました。衝突時の処理(polite/impolite)はそのまま残しているので、後の回で通話中に再交渉が起きた場合にも対応できます。
なお、失敗の根本原因がブラウザ側の実装によるものかどうかまでは特定できていません。「毎回衝突させない」ことで回避した、という位置づけです。
動作確認の方法
npm run build→node .output/server/index.mjs(またはnpm run dev)で起動する- http://localhost:3000 を開き、「新しいルームを作成する」を押す → /room/UUID に移動し、招待リンクが表示される
- 「コピー」を押し、別のブラウザ(またはシークレットウィンドウ)に貼り付けて開く
- 両方で「カメラとマイクを確認する」→「この設定で入室する」→ 両方「接続しました」になる
- 3つ目のウィンドウで同じURLを開いて入室しようとすると、定員オーバーのメッセージが出る。通話中の2人は影響を受けない
- 2人のうち1人が退出すると、3人目は入室し直せる
筆者の環境では、Playwright で Chromium を操作し、テスト用のカメラ・マイク(--use-fake-device-for-media-stream)で次の点を確認しました。
| 確認したこと | 結果 |
|---|---|
| ルーム作成 | UUID のルームIDでルームページに移動し、招待リンクが表示される |
| コピー | クリップボードに招待リンクが入る。writeText を失敗させた場合は URL が全選択され、手動コピーの案内が出る |
| LAN のIPアドレスを http で開いて作成 | 「このページではルームを作成できません」と表示される |
| 招待リンクから2人目が入室 | 両方「接続しました」、音声・映像トラックを受信 |
| 3人目 | 「定員(2名)に達しています」と表示され、通話中の2人は接続を維持 |
| 1人退出後に3人目が再入室 | 残っていた人と接続 |
実際のカメラ・マイク、スマホのブラウザ、Safari・Firefox での表示やクリップボードの挙動はこの環境では確認していません。npm run build と npm run typecheck は通っています。
つまずきやすい点とセキュリティ上の注意
- 定員チェックは画面ではなくサーバーで:画面で満員表示をするだけでは、WebSocket に直接つなげば入れてしまいます。今回のようにシグナリングサーバーで判定します。
- 定員チェックは「同時に接続している人数」だけを見ている:ルームのメンバーはサーバーのメモリにあるため、サーバーを再起動すると消えます。複数台のサーバーで動かす場合は、Redis などの共有ストアに移す必要があります。
- 招待リンク=鍵:URLを知っていれば誰でも入れます。チャットツールへの貼り付け、画面共有への映り込み、ブラウザの履歴などから漏れる可能性があります。社外秘の会議に使うなら、ログインや参加者の承認(待合室)が必要です。
- ルームIDをログに出しすぎない:アクセスログやエラー監視サービスにURLが残ると、そこから入れてしまいます。ログの保存先と閲覧権限も確認しておきます。
- 入室前プレビューのカメラは入室後も使い続ける:JoinPanel と通話画面で別々にカメラを取得すると、OSや機種によっては2つ目の取得に失敗することがあります(同じカメラを二重に開けない場合があるため)。今回はページが1つの MediaStream を持つ形にしています。
発注者向けメモ:「URLを知っていれば入れる」でよいかを最初に決める
招待リンク方式は手軽で、利用者にも分かりやすい方法です。一方で、誰が入れるのかという「入室の管理」は、あとから足すと画面・サーバー・運用のすべてに影響します。要件定義の早い段階で、次の点を決めておくと手戻りを防げます。
- ☐ ルームに入れるのは「URLを知っている人」でよいか、ログインした社員だけか、社外ゲストも入れるか
- ☐ ルームに有効期限(予約した時間帯だけ入れる等)が必要か
- ☐ 主催者が参加者を承認する「待合室」が必要か、途中で退出させる機能が必要か
- ☐ 1ルームの定員は何名か(2名か、数名か、それ以上か。第11回で扱う構成の選択に直結します)
- ☐ 定員オーバーやURL間違いのとき、利用者にどう案内するか(文言・問い合わせ先)
開発会社への質問例:
- 「招待URLが第三者に転送された場合、その人は入室できますか?防ぐ仕組みは何がありますか?」
- 「定員や入室の可否は、画面ではなくサーバー側で判定していますか?」
- 「サーバーを複数台にしたとき、ルームの参加者情報はどこで管理しますか?」
- 「待合室や有効期限を後から追加する場合、どの部分の改修が必要になりますか?」
認証や待合室を追加すると、ログイン画面、参加者管理、主催者向けの操作画面などが増えるため、工数も大きく変わります。「最初はURL方式で、社外秘の会議には使わない」といった運用ルールで割り切るのも一つの判断です。
まとめと次回予告
- 招待リンクは、推測されにくい UUID をルームIDにしてURLに入れるだけで作れる。
crypto.randomUUID()と Clipboard API はどちらも Secure Context 限定 - 定員などの「入れるかどうか」の判断は、画面ではなくシグナリングサーバーで行う
- 入室前プレビューは、確認した MediaStream をそのまま通話に使うよう、ページ側で1つだけ持つ
- 最初の offer を後から入った側だけが作るように変え、接続が始まらないことがある問題を回避した
- 「URLを知っていれば入れる」でよいかは、発注時に早めに決めるべき論点
次回(第5回)は「ミュートとカメラオフ」です。track.enabled でマイクとカメラを一時的に止め、その状態をシグナリングで相手に伝えて表示するところまで作ります。
この連載の記事一覧
この記事は連載「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件)
[…] 前回の第4回:ルーム作成と招待リンク、入室前プレビューで、ルームを作って招待リンクを送り、入室前にカメラを確認できるようになりました。今回は、ビデオ通話に欠かせない「マイクのミュート」と「カメラのオフ」を追加します。 […]