MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第8回】DataChannel でテキストチャット

    前回の第7回:画面共有で、カメラの映像と画面を再交渉なしに切り替えられるようにしました。今回は、通話中に URL や資料名を送るのに便利な「テキストチャット」を追加します。

    「ビデオ通話にチャットを付けるなら、チャット用のサーバーやデータベースも別に必要になるの?」

    結論から言うと、通話中だけのチャットなら、追加のサーバーは不要です。WebRTC には映像・音声と同じ P2P(端末どうしの直接通信)の接続の上で任意のデータを送れる DataChannel(データチャネル)という仕組みがあり、今回はこれでテキストをやりとりします。その代わり、メッセージはどこにも保存されません。「あとで履歴を見たい」が要件なら、別の設計が必要になります。

    目次

    今回作るもの:通話中だけ使えるテキストチャット

    • 通話画面の下にチャット欄を追加する(相手とつながっている間だけ送信できる)
    • 日本語・絵文字・改行を含むメッセージを送受信する
    • 1メッセージ500文字まで(絵文字1つを1文字として数える)。入力欄に文字数を表示する
    • Ctrl+Enter(Mac は ⌘+Enter)または「送信」ボタンで送る。Enter だけでは送らない
    • 相手が退出してもそれまでのやりとりは画面に残し、新しい相手とつながったら消す

    追加・変更するファイル

    ファイル役割
    code/app/composables/usePeerConnection.ts接続ごとにチャット用の DataChannel を作る
    code/app/composables/useDataChannel.ts送受信・受信データの検証・履歴の管理(新規)
    code/app/components/ChatPanel.vueチャットの表示と入力欄(新規)
    code/app/pages/room/[id].vue上記の組み込み

    仕組み:DataChannel と「negotiated」チャネル

    DataChannel は、RTCPeerConnection の中に作る「データ用の通り道」です。映像・音声と同じく DTLS(TLS を元にした暗号化方式)で暗号化され、アプリのサーバーは通りません。

    📰 出典:MDN「Using WebRTC data channels」

    チャネルの作り方には2通りあります。

    方式作り方特徴
    通常(negotiated: false)片方が createDataChannel() し、もう片方は datachannel イベントで受け取る作る側・受け取る側でコードが分かれる
    事前に取り決め(negotiated: true)両方が同じ id を指定して createDataChannel() する両側が同じコードで済む。どちらが先に作っても同じ1本になる

    今回は後者の negotiated: true, id: 0 を使います。id は 0〜65534 の範囲で、同じ接続の中で重複させてはいけません。そのほか、順序を保証するか(ordered、既定は true)、再送の回数・時間(maxRetransmits と maxPacketLifeTime、同時指定は不可)も指定できます。チャットは「順番どおり・必ず届く」ことが大切なので、既定のままにしています。

    📰 出典:MDN「RTCPeerConnection: createDataChannel() method」

    作るタイミングに注意:最初のチャネルは再交渉を起こす

    MDN には「その接続で最初のデータチャネルを作ると、negotiationneeded イベントが発生する」と書かれています。offer の中に「データ用の枠(SDP の m=application 行)」を追加する必要があるためです。

    第4回で、両側が同時に offer を作ると衝突し、検証環境で接続が止まることがあったため、「後から入った側(polite)だけが最初に offer を作る」形に直しました。DataChannel も同じルールに乗せないと、先にいた側が作った瞬間にまた offer を出して衝突してしまいます。そこで、トラックを載せる関数(addLocalTracks)の中でチャネルも作ることにしました。こうすると、

    • 後から入った側:接続を作った直後に、トラックとチャネルをまとめて追加 → offer は1回だけ
    • 先にいた側:相手の offer(データ用の枠を含む)を受け取った後に追加 → answer にそのまま含まれ、追加の offer は出ない

    という流れになります。

    実装1:usePeerConnection でチャネルを作る

    code/app/composables/usePeerConnection.ts(追加部分)

      /** 第8回: テキストチャット用の DataChannel(相手との接続ごとに作り直す) */
      const chatChannel = shallowRef<RTCDataChannel | null>(null)
    
      function addLocalTracks(conn: RTCPeerConnection) {
        if (localTracksAdded) return
        localTracksAdded = true
        const localStream = getLocalStream()
        localStream?.getTracks().forEach((track) => {
          const sending = track.kind === 'video' && videoOverride ? videoOverride : track
          conn.addTrack(sending, localStream)
        })
        // 第8回: チャット用の DataChannel もここで作る。
        // 最初の DataChannel を作ると negotiationneeded が発生するため、トラックと同じタイミングにして
        // offer を1回にまとめる(先にいた側は相手の offer を受け取ってから作る=衝突を増やさない)。
        // negotiated: true + 同じ id なら、両側がそれぞれ作ったチャネルが同じ1本としてつながる。
        chatChannel.value = conn.createDataChannel('chat', { negotiated: true, id: 0 })
      }

    退出時の close() では chatChannel.value = null にします。pc.close() を呼ぶと、その接続の DataChannel も閉じられます。RTCPeerConnection 本体は composable の外に出さず、チャネルだけを返す形にしました。

    実装2:useDataChannel で送受信する

    code/app/composables/useDataChannel.ts(抜粋)

    /** 1メッセージの最大文字数(絵文字1つ=1文字として数える) */
    export const MAX_CHAT_LENGTH = 500
    /** 画面に残すメッセージの上限(古いものから消す) */
    const MAX_HISTORY = 200
    
    /** DataChannel に流す形式。将来の拡張(既読・入力中など)に備えて type を付けておく */
    type ChatWireMessage = { type: 'chat'; text: string; sentAt: number }
    
    /** 文字数を「見た目の1文字」に近い単位で数える('😀'.length は 2 になるため) */
    export function countChars(text: string): number {
      return [...text].length
    }
    
    /** 相手から届いたデータは信用せず、形と長さを確認する */
    function parseWire(data: unknown): ChatWireMessage | null {
      if (typeof data !== 'string' || data.length > MAX_CHAT_LENGTH * 4) return null
      try {
        const v = JSON.parse(data) as Partial<ChatWireMessage> | null
        if (v?.type !== 'chat' || typeof v.text !== 'string' || typeof v.sentAt !== 'number') return null
        const text = v.text.trim()
        if (!text || countChars(text) > MAX_CHAT_LENGTH) return null
        return { type: 'chat', text, sentAt: v.sentAt }
      }
      catch {
        return null
      }
    }
    export function useDataChannel(channel: Ref<RTCDataChannel | null>) {
      const messages = ref<ChatMessage[]>([])
      const state = ref<RTCDataChannelState | 'none'>('none')
      const canSend = computed(() => state.value === 'open')
    
      watch(channel, (ch, _old, onCleanup) => {
        if (!ch) {
          // 相手が退出した等。やりとりした内容は画面に残しておく
          state.value = 'none'
          return
        }
        // 新しい相手とのチャネル。前の相手とのやりとりは消す
        messages.value = []
        state.value = ch.readyState
    
        const update = () => { state.value = ch.readyState }
        const onMessage = (event: MessageEvent) => {
          const msg = parseWire(event.data)
          if (msg) push('remote', msg.text, msg.sentAt)
        }
        ch.addEventListener('open', update)
        ch.addEventListener('close', update)
        ch.addEventListener('message', onMessage)
        onCleanup(() => { /* 3つのリスナーを外す(省略) */ })
      }, { immediate: true })
    
      /** 送信する。送れなかった場合は理由を返す */
      function send(input: string): string | null {
        const text = input.trim()
        if (!text) return 'メッセージを入力してください'
        if (countChars(text) > MAX_CHAT_LENGTH) return `${MAX_CHAT_LENGTH}文字以内で入力してください`
        const ch = channel.value
        if (!ch || ch.readyState !== 'open') return '相手とつながっていないため送信できません'
        const wire: ChatWireMessage = { type: 'chat', text, sentAt: Date.now() }
        ch.send(JSON.stringify(wire))
        push('self', text, wire.sentAt)
        return null
      }
    
      return { messages, state, canSend, send }
    }

    ポイントは次のとおりです。

    • 文字数は「コードポイント」で数える:JavaScript の length は UTF-16 の単位で数えるため、絵文字の多くは2と数えられます。[...text].length にすると「😀」は1文字になります。ただし、肌の色を変えた絵文字や家族の絵文字のように複数の文字を組み合わせたものは、これでも2以上になります。厳密に「見た目の1文字」で数えたい場合は Intl.Segmenter を使う方法があります。
    • 相手から届いたデータは検証する:DataChannel の相手は、改造したブラウザや開発者ツールから任意のデータを送れます。第2回でサーバー側のメッセージを検証したのと同じ考え方で、形式・種類・長さが合わないものは捨てます。
    • JSON に type を付ける:今はチャットだけですが、「入力中です」の表示や既読などを足すときに、同じチャネルで種類を見分けられます。
    • 送信時刻は相手の時計:sentAt は送った側の端末の時刻です。端末の時計がずれていると、表示もずれます。

    メッセージの大きさについて、MDN では「多くのブラウザは少なくとも256KBのメッセージを扱えるが、大きなメッセージには欠点があるので控えめな大きさにすべき」としています。500文字のテキストなら、UTF-8 で最大でも2KB程度なので問題ありません。ファイル送信のように大きなデータを扱う場合は、分割して送る設計が必要です。

    実装3:ChatPanel で表示と入力

    code/app/components/ChatPanel.vue(script の抜粋)

    <script setup lang="ts">
    const props = defineProps<{
      messages: ChatMessage[]
      canSend: boolean
      /** 送信する関数。送れなかったら理由を返す */
      send: (text: string) => string | null
    }>()
    
    const draft = ref('')
    const error = ref<string | null>(null)
    const length = computed(() => countChars(draft.value))
    const tooLong = computed(() => length.value > MAX_CHAT_LENGTH)
    
    function submit() {
      error.value = props.send(draft.value)
      if (!error.value) draft.value = ''
    }
    
    // Enter は改行、Ctrl+Enter(Mac は ⌘+Enter)で送信。
    // Enter だけで送信にすると、日本語入力の「変換を確定する Enter」で誤送信しやすいため
    function onKeydown(e: KeyboardEvent) {
      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.isComposing) {
        e.preventDefault()
        submit()
      }
    }
    </script>

    code/app/components/ChatPanel.vue(template の抜粋)

        <ol ref="listEl" class="chat__list" aria-live="polite">
          <li v-for="m in messages" :key="m.id" class="chat__item" :data-from="m.from">
            <span class="chat__meta">{{ m.from === 'self' ? '自分' : '相手' }}・{{ formatTime(m.sentAt) }}</span>
            <!-- {{ }} は HTML をエスケープして表示する。相手の入力を v-html で表示しない -->
            <p class="chat__text">{{ m.text }}</p>
          </li>
        </ol>

    表示では、Vue の {{ }}(テキストとして表示し、HTML として解釈しない)を使っています。相手が <img onerror=...> のような文字列を送ってきても、そのまま文字として表示されます。v-html で表示すると、相手のブラウザで任意のスクリプトが動く危険(XSS)があるため使いません。改行は CSS の white-space: pre-wrap で表示しています。

    ルームページでは、usePeerConnection が返す chatChannel を useDataChannel に渡し、ChatPanel を操作ボタンの下に置くだけです。

    code/app/pages/room/[id].vue(追加部分)

    <script setup lang="ts">
    const { remotePeerId, remoteStream, connectionState, routeType, chatChannel, replaceVideoTrack, close: closePeer }
      = usePeerConnection(signaling, () => localStream.value, () => iceConfig.value)
    const chat = useDataChannel(chatChannel)
    </script>
    
    <template>
      <ChatPanel :messages="chat.messages.value" :can-send="chat.canSend.value" :send="chat.send" />
    </template>

    動作確認の方法

    1. npm run dev で起動し、PC の2つのウィンドウで同じルームに入る
    2. つながったら、片方から日本語・絵文字・改行を含むメッセージを送り、もう片方に表示されることを確認する
    3. 500文字を超えると文字数が赤くなり、送信ボタンが押せなくなることを確認する
    4. 相手が退出すると入力欄が無効になり、それまでのやりとりは残ることを確認する
    5. Chrome なら chrome://webrtc-internals で、DataChannel(label が chat)が open になっていることを確認できる

    筆者の環境では、Playwright で Chromium を操作し、テスト用のカメラで次の点を確認しました。

    確認したこと結果
    offer/answer の回数両側とも setLocalDescription は1回ずつ(offer 1回・answer 1回)。SDP に音声・映像・データの3つの枠があり、5回試して5回とも接続
    日本語・絵文字・改行両側で同じ内容を表示
    HTML を含む文字列タグとして解釈されず文字のまま表示
    文字数絵文字500個は「500 / 500」で送信でき、相手に全文が届く。501文字では送信ボタンが無効
    キー操作Ctrl+Enter で送信、Enter のみでは改行
    不正なデータJSON でない・種類が違う・501文字・空白のみ・バイナリはすべて無視され、正しい形式のものだけ表示
    退出と再入室相手の退出後は履歴が残り入力欄が無効。相手が入り直すと履歴を消して送受信を再開

    実際の日本語入力(IME)での操作、Firefox・Safari・スマホのブラウザ、長時間の通話でのメッセージの扱いは、この環境では確認していません。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • DataChannel は「つながった後」にしか使えない:readyState が open になる前に send() すると例外になります。送信ボタンは open の間だけ有効にしています。
    • 最初のチャネルの作成も再交渉を起こす:映像の接続とは別のタイミングで作ると、offer の衝突が増える原因になります。
    • 相手のデータは信用しない:P2P でもサーバーのメッセージと同様に検証し、HTML として表示しません。URL を自動でリンクにする場合は、javascript: などの危険なスキームを除外する必要があります。
    • チャットは暗号化されるが、端末には表示される:通信経路は DTLS で暗号化されますが、画面共有中にチャット欄が映り込むことはあります。
    • サーバーに記録が残らない:トラブル時に「何が送られたか」をサービス側で確認する手段がありません。利用規約や運用で問題にならないか確認が必要です。

    発注者向けメモ:チャットは「履歴を残すか」で設計が変わる

    今回の P2P チャットは、追加のサーバー・データベースが不要で、比較的小さな工数で追加できます。一方で、履歴を保存したい・退出後も見たい・通話に参加していない人にも送りたいとなると、サーバー側でメッセージを受け取って保存する仕組みが必要になり、データベース、保存期間、閲覧権限、削除依頼への対応など、検討事項と運用が一気に増えます。

    • ☐ チャットの履歴を保存する必要があるか(誰が・いつまで・何のために見るか)
    • ☐ 1メッセージの文字数上限、ファイル・画像の送信が必要か
    • ☐ URL を自動でリンクにするか(する場合は安全な URL だけに限定する)
    • ☐ 不適切な発言があった場合に、運営として確認・対応する必要があるか
    • ☐ 通話が終わるとチャットが消えることを、利用者に画面で伝えるか

    開発会社への質問例:

    • 「チャットのメッセージはサーバーに保存されますか?保存しない場合、トラブル時に内容を確認する方法はありますか?」
    • 「相手から届いたメッセージの表示で、スクリプトが実行される心配はありませんか?」
    • 「文字数の上限はどう数えていますか?絵文字はどう扱いますか?」
    • 「後から履歴保存やファイル送信を追加する場合、どの程度の改修になりますか?」

    まとめと次回予告

    • DataChannel を使うと、映像・音声と同じ P2P の接続でテキストを送れる。追加のサーバーは不要
    • negotiated: true と同じ id で、両側が同じコードでチャネルを作れる
    • 最初のチャネルは再交渉を起こすので、トラックを載せるタイミングにそろえて offer の衝突を避ける
    • 相手から届いたデータは検証し、HTML として表示しない。文字数はコードポイントで数える
    • 履歴を残すかどうかで、設計と運用の規模が大きく変わる

    次回(第9回)は「MediaRecorder で録画する前に考えること」です。ブラウザだけで通話を録画する方法と、録画機能で本当に大変な「同意・保存・管理」の話をします。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

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


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

      この記事を書いた人

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

      コメント

      コメント一覧 (2件)

      目次