视频会议
这个示例构建一个视频会议:每个人在页面上填一个名字和一个房间,然后就能看到、听到其他人,还能在聊天里给他们发消息。它从 web-react 模板开始,给应用和页面都加上 @lakutata/webrtc,展示它的三个部分如何配合:信令让同一个房间的人互相联系上,媒体服务器转发他们的视频,浏览器客户端把页面接到这两者上。有了媒体服务器,不管房间里有多少人,每个人的摄像头画面都只发一份。
| 位置 | 提供什么 |
|---|---|
GET / 以及其他页面 | 会议页面,即构建好的 ui 包 |
/socket.io,同一个端口 | 信令,走 Socket.IO |
| UDP 的 40000 到 40999 端口 | 浏览器到媒体服务器的连接 |
GET /health | 告诉调用方进程是否还在响应(来自模板) |
1. 创建项目
npx @lakutata/cli create --name meeting --template web-react --yes
cd meeting
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
rm packages/app/src/middlewares/Authenticate.ts
npm install @lakutata/webrtc socket.io -w app
npm install socket.io-client --save-dev -w app
npm install @lakutata/webrtc socket.io-client -w ui项目是一个工作区(见 ProjectStructure):packages/app 是应用,packages/ui 是 React 页面。这里删掉了模板中的示例控制器,也删掉了中间件 Authenticate.ts:参加会议的人给的是名字,不是令牌(在公网上要加些什么,见第 8 节)。
应用装上 @lakutata/webrtc(信令和媒体服务器)和 socket.io(信令所走的 Socket.IO 服务端);它的测试装上 socket.io-client,用来在 Node.js 里加入会议。页面装上 @lakutata/webrtc,只引用其中的浏览器入口 @lakutata/webrtc/client,以及 socket.io-client。
2. 服务端
packages/app/src/config/Config.ts
import {resourcesPath, type ApplicationOptions} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope, staticUI} from 'lakutata/adapter/express'
import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
import {buildSFUOptions, buildSignalingOptions, type SignalingPeer, SignalingController} from '@lakutata/webrtc'
import compression from 'compression'
import {existsSync} from 'node:fs'
import type {Server} from 'node:http'
import {createRequire} from 'node:module'
import path from 'node:path'
import {HealthController} from '../controllers/HealthController.js'
/**
* 构建好的 ui 包所在的目录,构建之前为 undefined(开发时由 Vite 提供页面):打包后的应用(lakutata package bundle 或
* lakutata package exe)在资源中找(来自 package.json 中的 "lakutata.resources"),否则在 ui 包中找
*/
function uiDirectory(): string | undefined {
const resources: string | undefined = resourcesPath()
try {
const directory: string = resources ? path.join(resources, 'ui') : path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist')
return existsSync(path.join(directory, 'index.html')) ? directory : undefined
} catch {
return undefined
}
}
/**
* 一个人能不能加入会议:要有 1 到 40 个字符的名字
* @param peer 这个人的 id,以及他的页面给出的 metadata。
*/
function named(peer: SignalingPeer): boolean {
const name: unknown = (peer.metadata as {name?: unknown} | undefined)?.name
return typeof name === 'string' && name.trim().length > 0 && name.length <= 40
}
/**
* 服务的选项
* @param port HTTP 服务器和信令的端口,默认取环境变量 PORT,没有时为 8080
*/
export function config(port: number = Number(process.env.PORT ?? 8080)): ApplicationOptions {
//express 的 HTTP 服务器,Socket.IO 和它共用
let server: Server | undefined
return {
id: 'meeting',
name: 'meeting',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//房间:每个房间最多 50 人,每人都要给出名字
signaling: buildSignalingOptions({maxPeers: 50, authorize: named}),
//房间的媒体服务器。PUBLIC_ADDRESS:浏览器访问这台机器用的地址,在它不是本机自己的地址时(比如转发了 40000 到 40999 端口的 NAT)
sfu: buildSFUOptions({
icePortRange: [40000, 40999],
iceAdditionalHostAddresses: process.env.PUBLIC_ADDRESS ? [process.env.PUBLIC_ADDRESS] : []
}),
entrypoint: buildEntrypoints({
controllers: [HealthController, SignalingController],
http: expressHTTPEntrypoint({
port: port,
//结果和错误都是 {errno, errMsg, data},状态码都是 200
respond: jsonEnvelope({errorStatus: 200}),
body: {limit: '100mb'},
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
before: (app, context): void => {
server = context.server
app.use(compression())
},
//没有动作处理的路径都是应用的页面
fallback: ((directory: string | undefined) => directory ? staticUI(directory) : undefined)(uiDirectory())
}),
//信令,和页面用同一个端口
service: socketIOServiceEntrypoint({server: (): Server => server!})
})
},
bootstrap: ['sfu', 'entrypoint']
}
}这是模板的 Config.ts,去掉示例控制器,换成三个组件:
signaling管理房间。每个人是某个房间的一个成员;成员的 id 是他的 Socket.IO 连接的 id,成员的 metadata 是他的页面加入时给出的内容:这里是{name}。authorize拒绝没有名字的成员,maxPeers拒绝第 51 个成员。sfu是媒体服务器。它以自己的成员身份加入每个房间;页面看到它后就连接它,而不再互相连接,它把每个人的音频和视频转发给其他人,转发时不解码。它的连接使用 UDP 的 40000 到 40999 端口,每个人在本机的每个地址上各占一个端口,并以这些地址作为候选地址(PUBLIC_ADDRESS再加一个)。SignalingController通过 Socket.IO 适配器处理信令的请求(webrtc.join、webrtc.signal……)。socketIOServiceEntrypoint({server})把 Socket.IO 挂到 express 的 HTTP 服务器上,这个服务器由before给出:页面、它的信令和健康检查共用 8080 端口。
应用先启动 sfu,它会把自己挂到 signaling 上,然后启动 entrypoint,开始监听。其余部分来自模板:uiDirectory() 和 fallback 提供构建好的页面,jsonEnvelope 包装动作的结果(WebApp 里有说明)。
3. 会议
页面把它的 WebRTC 代码放在一个单独的模块里,这个模块不依赖 React。
packages/ui/src/meeting.ts
import {io} from 'socket.io-client'
import {type Peer, type Room, SignalingConnection} from '@lakutata/webrtc/client'
/**
* 会议里的另一个人:名字,以及收到之后的摄像头画面
*/
export type Participant = {
id: string
name: string
stream?: MediaStream
}
/**
* 聊天里的一条消息
*/
export type Message = {
from: string
text: string
}
/**
* 会议告诉页面的事情
*/
export type MeetingEvents = {
//其他人:每当有人来、有人走,或者收到某人的摄像头画面时
participants(participants: Participant[]): void
message(message: Message): void
}
/**
* 加入了的会议
*/
export type Meeting = {
//这个页面的摄像头和麦克风
camera: MediaStream
//给所有人发一条消息
send(text: string): void
leave(): Promise<void>
}
/**
* 加入会议:把这个页面的摄像头和麦克风发给其他人,接收他们的,再给每个人开一条通道用来聊天
* @param name 显示给其他人的名字。
* @param roomName 房间。
* @param events 会议告诉页面的事情。
*/
export async function joinMeeting(name: string, roomName: string, events: MeetingEvents): Promise<Meeting> {
const camera: MediaStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true})
//信令连到页面所在的源;这个成员的 id 每个页面都是新的
const signaling: SignalingConnection = new SignalingConnection(io({auth: {id: crypto.randomUUID()}, transports: ['websocket']}))
let room: Room
try {
room = await signaling.join(roomName, {metadata: {name: name}})
} catch (error) {
await signaling.close()
camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
throw error
}
const participants: Map<string, Participant> = new Map()
const channels: Map<string, RTCDataChannel> = new Map()
const changed = (): void => events.participants([...participants.values()])
room.on('peer', (peer: Peer): void => {
const participant: Participant = {id: peer.id, name: (peer.metadata as {name: string}).name}
participants.set(peer.id, participant)
changed()
//这个人的音频和视频,在同一个流里
peer.on('track', (event: RTCTrackEvent): void => {
participants.set(peer.id, {...participant, stream: event.streams[0]})
changed()
})
//聊天:给每个人开一条通道,也接收每个人开过来的通道
channels.set(peer.id, peer.createDataChannel('chat'))
peer.on('datachannel', (channel: RTCDataChannel): void => {
channel.addEventListener('message', (event: MessageEvent): void => events.message({from: participant.name, text: String(event.data)}))
})
})
room.on('peer-left', (peer: Peer): void => {
participants.delete(peer.id)
channels.delete(peer.id)
changed()
})
for (const track of camera.getTracks()) await room.publish(track, camera)
return {
camera: camera,
send: (text: string): void => {
for (const channel of channels.values()) if (channel.readyState === 'open') channel.send(text)
events.message({from: name, text: text})
},
leave: async (): Promise<void> => {
await room.leave()
await signaling.close()
camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
}
}
}SignalingConnection接收页面的 Socket.IO 连接。io()不带地址时连接页面所在的源;transports: ['websocket']让它只用一条连接,在负载均衡后面也不需要会话保持。成员的 id 是这条连接的auth.id。signaling.join()带着 metadata 里的名字加入房间,返回Room。房间为其他每个人发出peer事件,先是已经在房间里的人,有人离开时发出peer-left。room.publish()把摄像头和麦克风发给每个人,之后加入的人也会收到。这个房间有媒体服务器(room.sfu是它的 id),所以每条轨道只发一份给服务器。- 每个
Peer发出它那个人的轨道(track),以及那个人打开的通道(datachannel)。它的createDataChannel()打开一条到那个人的通道,经过媒体服务器,由服务器转发消息。没有媒体服务器时,这段代码会让页面之间直接连接,用法完全一样。 - 媒体服务器所在的节点下线时(部署了多个实例时,见指南),每个人会换到另一个媒体服务器:房间发出
peer-left,再重新发出peer,通道也会重新创建。
4. 页面
packages/ui/src/App.tsx
import {type FormEvent, useEffect, useRef, useState} from 'react'
import {joinMeeting, type Meeting, type Message, type Participant} from './meeting'
/**
* 一路摄像头画面,下面写着名字
* @param props 流(收到之前没有)、名字,以及是否静音(这个页面自己的摄像头)。
*/
function Camera(props: {stream?: MediaStream, name: string, muted?: boolean}) {
const video = useRef<HTMLVideoElement>(null)
useEffect((): void => {
if (video.current) video.current.srcObject = props.stream ?? null
}, [props.stream])
return (
<figure>
<video ref={video} autoPlay playsInline muted={props.muted} width={320} height={240}/>
<figcaption>{props.name}</figcaption>
</figure>
)
}
export function App() {
const [name, setName] = useState<string>('')
const [room, setRoom] = useState<string>('')
const [meeting, setMeeting] = useState<Meeting>()
const [participants, setParticipants] = useState<Participant[]>([])
const [messages, setMessages] = useState<Message[]>([])
const [text, setText] = useState<string>('')
const [error, setError] = useState<string>('')
async function join(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault()
try {
setMeeting(await joinMeeting(name, room, {
participants: setParticipants,
message: (message: Message): void => setMessages((before: Message[]): Message[] => [...before, message])
}))
setError('')
} catch (failure) {
setError((failure as Error).message)
}
}
async function leave(): Promise<void> {
await meeting?.leave()
setMeeting(undefined)
setParticipants([])
setMessages([])
}
function send(event: FormEvent<HTMLFormElement>): void {
event.preventDefault()
meeting?.send(text)
setText('')
}
if (!meeting) {
return (
<main>
<h1>Meeting</h1>
<form onSubmit={join}>
<input value={name} onChange={(event): void => setName(event.target.value)} placeholder="Your name" aria-label="Name" required/>
<input value={room} onChange={(event): void => setRoom(event.target.value)} placeholder="Room" aria-label="Room" required/>
<button>Join</button>
</form>
{error && <p role="alert">{error}</p>}
</main>
)
}
return (
<main>
<h1>{room} <button onClick={(): void => void leave()}>Leave</button></h1>
<section style={{display: 'flex', flexWrap: 'wrap'}}>
<Camera stream={meeting.camera} name={`${name} (you)`} muted/>
{participants.map((participant: Participant) => <Camera key={participant.id} stream={participant.stream} name={participant.name}/>)}
</section>
<ul>
{messages.map((message: Message, index: number) => <li key={index}><b>{message.from}</b>: {message.text}</li>)}
</ul>
<form onSubmit={send}>
<input value={text} onChange={(event): void => setText(event.target.value)} placeholder="Message" aria-label="Message" required/>
<button>Send</button>
</form>
</main>
)
}- 加入之前,页面先要填一个名字和一个房间。
joinMeeting()向浏览器申请摄像头和麦克风,浏览器会先征求用户同意;用户拒绝,或者服务端拒绝了这个名字,都会显示成错误。 - 进入会议后,页面显示自己的摄像头(不放声音,否则会有回声)和其他每个人的摄像头,收到画面之前是黑的;下面是聊天记录和输入框。
- 模板中渲染
App的main.tsx不用改。
5. 开发时的信令
packages/ui/vite.config.ts
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
//开发时,信令由 "npm run dev" 启动的服务提供
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/socket.io': {target: `http://127.0.0.1:${process.env.PORT ?? 8080}`, ws: true}
}
}
})页面连接自己所在源上的 /socket.io。开发时这个源是 Vite:它的代理把 Socket.IO 的 WebSocket 转发给应用(ws: true)。页面没有 /api 下的 API,所以模板里 /api 的代理换成了这个。
6. 运行
npm run dev这会构建并在 8080 端口启动应用,由 Vite 在 http://localhost:5173 上提供页面。在两个窗口里打开它,填上两个名字和同一个房间,允许使用摄像头:每个窗口都会显示两路摄像头画面,在一个窗口里写的消息会出现在另一个窗口里。在另一台电脑上打开页面需要 HTTPS(见第 8 节):浏览器只把摄像头给 localhost 和 HTTPS 页面。
7. 测试
模板中的测试换成了针对会议的测试。它在自己的端口上启动应用,然后在 Node.js 里让两个成员加入同一个会议:SignalingClient 是 @lakutata/webrtc 给应用用的客户端,房间和 peer 的用法和页面一样。Node.js 没有摄像头,所以每个成员发送一条视频轨道,包里带着自己的名字,再检查自己收到了对方的名字:
packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {Application} from 'lakutata'
import {MediaTrack, type Peer, type PeerChannel, type PeerTrackEvent, type Room, type RtpPacket, SignalingClient} from '@lakutata/webrtc'
import {config} from '../config/Config.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18084)
/**
* Node.js 里的一个会议成员,和页面一样:其他每个成员的视频里带着的名字、收到的消息,以及停止发送视频的方法
*/
type Member = {
room: Room
videos: Map<string, string>
messages: string[]
stop(): void
}
/**
* 一个 VP8 RTP 包的字节,负载是一段文字
* @param sequence
* @param text
*/
function packet(sequence: number, text: string): Buffer {
const header: Buffer = Buffer.alloc(12)
header[0] = 0x80
header[1] = 96
header.writeUInt16BE(sequence & 0xffff, 2)
header.writeUInt32BE(sequence * 3000, 4)
return Buffer.concat([header, Buffer.from(text)])
}
/**
* 等到条件成立,最多 20 秒
* @param condition
* @param what 等的是什么,用于错误信息。
*/
async function until(condition: () => boolean, what: string): Promise<void> {
const end: number = Date.now() + 20000
while (!condition()) {
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`)
await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
}
}
describe('meeting', (): void => {
let app: Application
const clients: SignalingClient[] = []
before(async (): Promise<void> => {
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
for (const client of clients) await client.close()
await app?.dispose()
})
/**
* 应用的信令的一个客户端
*/
async function client(): Promise<SignalingClient> {
const created: SignalingClient = await app.getObject(SignalingClient, {url: `http://127.0.0.1:${PORT}`})
clients.push(created)
return created
}
/**
* 像页面一样加入房间 test:发布一条视频轨道,给其他每个成员开一条聊天通道
* @param name
*/
async function member(name: string): Promise<Member> {
const room: Room = await (await client()).join('test', {metadata: {name: name}})
const videos: Map<string, string> = new Map()
const messages: string[] = []
room.on('peer', (peer: Peer): void => {
const from: string = (peer.metadata as {name: string}).name
peer.on('track', (event: PeerTrackEvent): void => {
event.track.on('rtp', (rtp: RtpPacket): void => void videos.set(from, new TextDecoder().decode(rtp.payload)))
})
peer.on('datachannel', (channel: PeerChannel): void => {
channel.on('message', (data: string | Buffer | ArrayBuffer): number => messages.push(`${from}: ${String(data)}`))
})
const chat: PeerChannel = peer.createDataChannel('chat')
chat.on('open', (): void => chat.send(`hello from ${name}`))
})
const video: MediaTrack = new MediaTrack('video', {codec: 'video/VP8'})
await room.publish(video, 'camera')
let sequence: number = 0
const timer: NodeJS.Timeout = setInterval((): void => video.writeRtp(packet(sequence++, name)), 20)
return {room: room, videos: videos, messages: messages, stop: (): void => clearInterval(timer)}
}
it('answers the health check', async (): Promise<void> => {
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
})
it('serves the page', async (): Promise<void> => {
const response: Response = await fetch(`http://127.0.0.1:${PORT}/`, {headers: {Accept: 'text/html'}})
assert.strictEqual(response.status, 200)
assert.match(await response.text(), /<div id="root"><\/div>/)
})
it('forwards the video and the messages of the members of a meeting through the media server', {timeout: 60000}, async (): Promise<void> => {
const alice: Member = await member('Alice')
const bob: Member = await member('Bob')
try {
assert.match(alice.room.sfu ?? '', /^sfu:/)
await until((): boolean => alice.videos.get('Bob') === 'Bob' && bob.videos.get('Alice') === 'Alice', 'the videos')
await until((): boolean => alice.messages.includes('Bob: hello from Bob') && bob.messages.includes('Alice: hello from Alice'), 'the messages')
//Bob 离开:Alice 看到他走了
const left: Promise<string> = new Promise((resolve: (name: string) => void): unknown => alice.room.once('peer-left', (peer: Peer): void => resolve((peer.metadata as {name: string}).name)))
await bob.room.leave()
assert.strictEqual(await left, 'Bob')
} finally {
alice.stop()
bob.stop()
}
})
it('refuses a member without a name', async (): Promise<void> => {
await assert.rejects((await client()).join('test'), {errno: 'E_SIGNALING_DENIED'})
})
})app │ ✔ answers the health check
app │ ✔ serves the page
app │ ✔ forwards the video and the messages of the members of a meeting through the media server
app │ ✔ refuses a member without a name
app │ ℹ pass 4
✔ test ran in appnpm test 会先构建各个包,所以第二个测试运行时已经有构建好的页面。测试覆盖的是服务端和这个包的客户端,不包括 React 代码;npm run typecheck -w ui 检查页面的类型。
8. 构建和部署
npm run build
npm start现在由一个进程提供页面、信令和媒体服务器:页面在 http://localhost:8080,它的连接用 UDP 的 40000 到 40999 端口。部署时:
- HTTPS。 浏览器只把摄像头和麦克风给
localhost和 HTTPS 页面。适配器提供的是 HTTP:在应用前面放一个反向代理(nginx、Caddy),由它提供 HTTPS,并转发 Socket.IO 的 WebSocket(见 HTTP)。 - 媒体服务器的端口。 浏览器直接连接媒体服务器:在防火墙里放开 UDP 的 40000 到 40999 端口。如果在 NAT 后面,并且 NAT 按相同的端口号转发了这些端口,启动应用时把
PUBLIC_ADDRESS设为公网地址。 - 不放行 UDP 的网络。 有些公司网络和移动网络需要 TURN 服务器:加上
TURNServer组件,把它的名字告诉信令(见 WebRTC 第 3 节和第 12 节)。 - 谁能加入。 能打开页面的人,可以用任何名字加入任何房间。在公网上,用 entrypoint 的中间件认证用户(模板里的
Authenticate.ts检查的是令牌),在authorize里决定谁能加入哪个房间,它也能拿到请求的上下文。 - 更大的会议。 一个进程能转发几百路视频(数字见 WebRTC 指南);媒体服务器的
workers选项可以用上更多的核,多个实例还可以通过Cluster共同承载房间(见 WebRTC 第 13 节)。