MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第2回】Nitro の WebSocket でシグナリングサーバーを作る

    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)
      },
    })

    処理の流れは単純です。

    1. join を受け取ったら、ルームの一覧に加えてトピックを購読し、本人には joined(自分のIDと、先にいた相手の一覧)、相手には peer-joined を送る
    2. description と ice-candidate は、送ってきた接続がいるルームへそのまま中継する
    3. 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・同じルームの相手・受信ログが表示されます。

    手元で試す手順は次のとおりです。

    1. npm run dev で起動し、http://localhost:3000/signaling-test を2つのタブで開く
    2. 両方のタブで同じルームID(例:room-a)で参加する → お互いの「同じルームの相手」に相手のIDが表示される
    3. 3つ目のタブで別のルームID(例:room-b)で参加する → room-a の2タブには何も届かない
    4. 片方のタブで「退出する」を押すか、タブを閉じる → もう一方に peer-left が届く
    5. ブラウザの開発者ツールの「ネットワーク」タブで /_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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次