lakutata 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,524 @@
1
+ # 视频会议
2
+
3
+ 这个示例构建一个视频会议:每个人在页面上填一个名字和一个房间,然后就能看到、听到其他人,还能在聊天里给他们发消息。它从 `web-react` 模板开始,给应用和页面都加上 `@lakutata/webrtc`,展示它的三个部分如何配合:信令让同一个房间的人互相联系上,媒体服务器转发他们的视频,浏览器客户端把页面接到这两者上。有了媒体服务器,不管房间里有多少人,每个人的摄像头画面都只发一份。
4
+
5
+ | 位置 | 提供什么 |
6
+ |---|---|
7
+ | `GET /` 以及其他页面 | 会议页面,即构建好的 `ui` 包 |
8
+ | `/socket.io`,同一个端口 | 信令,走 Socket.IO |
9
+ | UDP 的 40000 到 40999 端口 | 浏览器到媒体服务器的连接 |
10
+ | `GET /health` | 告诉调用方进程是否还在响应(来自模板) |
11
+
12
+ ## 1. 创建项目
13
+
14
+ ```bash
15
+ npx @lakutata/cli create --name meeting --template web-react --yes
16
+ cd meeting
17
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
18
+ rm packages/app/src/middlewares/Authenticate.ts
19
+ npm install @lakutata/webrtc socket.io -w app
20
+ npm install socket.io-client --save-dev -w app
21
+ npm install @lakutata/webrtc socket.io-client -w ui
22
+ ```
23
+
24
+ 项目是一个工作区(见 [ProjectStructure](../ProjectStructure.md)):`packages/app` 是应用,`packages/ui` 是 React 页面。这里删掉了模板中的示例控制器,也删掉了中间件 `Authenticate.ts`:参加会议的人给的是名字,不是令牌(在公网上要加些什么,见第 8 节)。
25
+
26
+ 应用装上 `@lakutata/webrtc`(信令和媒体服务器)和 `socket.io`(信令所走的 Socket.IO 服务端);它的测试装上 `socket.io-client`,用来在 Node.js 里加入会议。页面装上 `@lakutata/webrtc`,只引用其中的浏览器入口 `@lakutata/webrtc/client`,以及 `socket.io-client`。
27
+
28
+ ## 2. 服务端
29
+
30
+ **`packages/app/src/config/Config.ts`**
31
+
32
+ ```typescript
33
+ import {resourcesPath, type ApplicationOptions} from 'lakutata'
34
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
35
+ import {expressHTTPEntrypoint, jsonEnvelope, staticUI} from 'lakutata/adapter/express'
36
+ import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
37
+ import {buildSFUOptions, buildSignalingOptions, type SignalingPeer, SignalingController} from '@lakutata/webrtc'
38
+ import compression from 'compression'
39
+ import {existsSync} from 'node:fs'
40
+ import type {Server} from 'node:http'
41
+ import {createRequire} from 'node:module'
42
+ import path from 'node:path'
43
+ import {HealthController} from '../controllers/HealthController.js'
44
+
45
+ /**
46
+ * 构建好的 ui 包所在的目录,构建之前为 undefined(开发时由 Vite 提供页面):打包后的应用(lakutata package bundle 或
47
+ * lakutata package exe)在资源中找(来自 package.json 中的 "lakutata.resources"),否则在 ui 包中找
48
+ */
49
+ function uiDirectory(): string | undefined {
50
+ const resources: string | undefined = resourcesPath()
51
+ try {
52
+ const directory: string = resources ? path.join(resources, 'ui') : path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist')
53
+ return existsSync(path.join(directory, 'index.html')) ? directory : undefined
54
+ } catch {
55
+ return undefined
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 一个人能不能加入会议:要有 1 到 40 个字符的名字
61
+ * @param peer 这个人的 id,以及他的页面给出的 metadata。
62
+ */
63
+ function named(peer: SignalingPeer): boolean {
64
+ const name: unknown = (peer.metadata as {name?: unknown} | undefined)?.name
65
+ return typeof name === 'string' && name.trim().length > 0 && name.length <= 40
66
+ }
67
+
68
+ /**
69
+ * 服务的选项
70
+ * @param port HTTP 服务器和信令的端口,默认取环境变量 PORT,没有时为 8080
71
+ */
72
+ export function config(port: number = Number(process.env.PORT ?? 8080)): ApplicationOptions {
73
+ //express 的 HTTP 服务器,Socket.IO 和它共用
74
+ let server: Server | undefined
75
+ return {
76
+ id: 'meeting',
77
+ name: 'meeting',
78
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
79
+ components: {
80
+ //房间:每个房间最多 50 人,每人都要给出名字
81
+ signaling: buildSignalingOptions({maxPeers: 50, authorize: named}),
82
+ //房间的媒体服务器。PUBLIC_ADDRESS:浏览器访问这台机器用的地址,在它不是本机自己的地址时(比如转发了 40000 到 40999 端口的 NAT)
83
+ sfu: buildSFUOptions({
84
+ icePortRange: [40000, 40999],
85
+ iceAdditionalHostAddresses: process.env.PUBLIC_ADDRESS ? [process.env.PUBLIC_ADDRESS] : []
86
+ }),
87
+ entrypoint: buildEntrypoints({
88
+ controllers: [HealthController, SignalingController],
89
+ http: expressHTTPEntrypoint({
90
+ port: port,
91
+ //结果和错误都是 {errno, errMsg, data},状态码都是 200
92
+ respond: jsonEnvelope({errorStatus: 200}),
93
+ body: {limit: '100mb'},
94
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
95
+ before: (app, context): void => {
96
+ server = context.server
97
+ app.use(compression())
98
+ },
99
+ //没有动作处理的路径都是应用的页面
100
+ fallback: ((directory: string | undefined) => directory ? staticUI(directory) : undefined)(uiDirectory())
101
+ }),
102
+ //信令,和页面用同一个端口
103
+ service: socketIOServiceEntrypoint({server: (): Server => server!})
104
+ })
105
+ },
106
+ bootstrap: ['sfu', 'entrypoint']
107
+ }
108
+ }
109
+ ```
110
+
111
+ 这是模板的 `Config.ts`,去掉示例控制器,换成三个组件:
112
+
113
+ - `signaling` 管理房间。每个人是某个房间的一个成员;成员的 id 是他的 Socket.IO 连接的 id,成员的 metadata 是他的页面加入时给出的内容:这里是 `{name}`。`authorize` 拒绝没有名字的成员,`maxPeers` 拒绝第 51 个成员。
114
+ - `sfu` 是媒体服务器。它以自己的成员身份加入每个房间;页面看到它后就连接它,而不再互相连接,它把每个人的音频和视频转发给其他人,转发时不解码。它的连接使用 UDP 的 40000 到 40999 端口,每个人在本机的每个地址上各占一个端口,并以这些地址作为候选地址(`PUBLIC_ADDRESS` 再加一个)。
115
+ - `SignalingController` 通过 Socket.IO 适配器处理信令的请求(`webrtc.join`、`webrtc.signal`……)。`socketIOServiceEntrypoint({server})` 把 Socket.IO 挂到 express 的 HTTP 服务器上,这个服务器由 `before` 给出:页面、它的信令和健康检查共用 8080 端口。
116
+
117
+ 应用先启动 `sfu`,它会把自己挂到 `signaling` 上,然后启动 entrypoint,开始监听。其余部分来自模板:`uiDirectory()` 和 `fallback` 提供构建好的页面,`jsonEnvelope` 包装动作的结果([WebApp](WebApp.md) 里有说明)。
118
+
119
+ ## 3. 会议
120
+
121
+ 页面把它的 WebRTC 代码放在一个单独的模块里,这个模块不依赖 React。
122
+
123
+ **`packages/ui/src/meeting.ts`**
124
+
125
+ ```typescript
126
+ import {io} from 'socket.io-client'
127
+ import {type Peer, type Room, SignalingConnection} from '@lakutata/webrtc/client'
128
+
129
+ /**
130
+ * 会议里的另一个人:名字,以及收到之后的摄像头画面
131
+ */
132
+ export type Participant = {
133
+ id: string
134
+ name: string
135
+ stream?: MediaStream
136
+ }
137
+
138
+ /**
139
+ * 聊天里的一条消息
140
+ */
141
+ export type Message = {
142
+ from: string
143
+ text: string
144
+ }
145
+
146
+ /**
147
+ * 会议告诉页面的事情
148
+ */
149
+ export type MeetingEvents = {
150
+ //其他人:每当有人来、有人走,或者收到某人的摄像头画面时
151
+ participants(participants: Participant[]): void
152
+ message(message: Message): void
153
+ }
154
+
155
+ /**
156
+ * 加入了的会议
157
+ */
158
+ export type Meeting = {
159
+ //这个页面的摄像头和麦克风
160
+ camera: MediaStream
161
+ //给所有人发一条消息
162
+ send(text: string): void
163
+ leave(): Promise<void>
164
+ }
165
+
166
+ /**
167
+ * 加入会议:把这个页面的摄像头和麦克风发给其他人,接收他们的,再给每个人开一条通道用来聊天
168
+ * @param name 显示给其他人的名字。
169
+ * @param roomName 房间。
170
+ * @param events 会议告诉页面的事情。
171
+ */
172
+ export async function joinMeeting(name: string, roomName: string, events: MeetingEvents): Promise<Meeting> {
173
+ const camera: MediaStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true})
174
+ //信令连到页面所在的源;这个成员的 id 每个页面都是新的
175
+ const signaling: SignalingConnection = new SignalingConnection(io({auth: {id: crypto.randomUUID()}, transports: ['websocket']}))
176
+ let room: Room
177
+ try {
178
+ room = await signaling.join(roomName, {metadata: {name: name}})
179
+ } catch (error) {
180
+ await signaling.close()
181
+ camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
182
+ throw error
183
+ }
184
+ const participants: Map<string, Participant> = new Map()
185
+ const channels: Map<string, RTCDataChannel> = new Map()
186
+ const changed = (): void => events.participants([...participants.values()])
187
+ room.on('peer', (peer: Peer): void => {
188
+ const participant: Participant = {id: peer.id, name: (peer.metadata as {name: string}).name}
189
+ participants.set(peer.id, participant)
190
+ changed()
191
+ //这个人的音频和视频,在同一个流里
192
+ peer.on('track', (event: RTCTrackEvent): void => {
193
+ participants.set(peer.id, {...participant, stream: event.streams[0]})
194
+ changed()
195
+ })
196
+ //聊天:给每个人开一条通道,也接收每个人开过来的通道
197
+ channels.set(peer.id, peer.createDataChannel('chat'))
198
+ peer.on('datachannel', (channel: RTCDataChannel): void => {
199
+ channel.addEventListener('message', (event: MessageEvent): void => events.message({from: participant.name, text: String(event.data)}))
200
+ })
201
+ })
202
+ room.on('peer-left', (peer: Peer): void => {
203
+ participants.delete(peer.id)
204
+ channels.delete(peer.id)
205
+ changed()
206
+ })
207
+ for (const track of camera.getTracks()) await room.publish(track, camera)
208
+ return {
209
+ camera: camera,
210
+ send: (text: string): void => {
211
+ for (const channel of channels.values()) if (channel.readyState === 'open') channel.send(text)
212
+ events.message({from: name, text: text})
213
+ },
214
+ leave: async (): Promise<void> => {
215
+ await room.leave()
216
+ await signaling.close()
217
+ camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
218
+ }
219
+ }
220
+ }
221
+ ```
222
+
223
+ - `SignalingConnection` 接收页面的 Socket.IO 连接。`io()` 不带地址时连接页面所在的源;`transports: ['websocket']` 让它只用一条连接,在负载均衡后面也不需要会话保持。成员的 id 是这条连接的 `auth.id`。
224
+ - `signaling.join()` 带着 metadata 里的名字加入房间,返回 `Room`。房间为其他每个人发出 `peer` 事件,先是已经在房间里的人,有人离开时发出 `peer-left`。
225
+ - `room.publish()` 把摄像头和麦克风发给每个人,之后加入的人也会收到。这个房间有媒体服务器(`room.sfu` 是它的 id),所以每条轨道只发一份给服务器。
226
+ - 每个 `Peer` 发出它那个人的轨道(`track`),以及那个人打开的通道(`datachannel`)。它的 `createDataChannel()` 打开一条到那个人的通道,经过媒体服务器,由服务器转发消息。没有媒体服务器时,这段代码会让页面之间直接连接,用法完全一样。
227
+ - 媒体服务器所在的节点下线时(部署了多个实例时,见指南),每个人会换到另一个媒体服务器:房间发出 `peer-left`,再重新发出 `peer`,通道也会重新创建。
228
+
229
+ ## 4. 页面
230
+
231
+ **`packages/ui/src/App.tsx`**
232
+
233
+ ```tsx
234
+ import {type FormEvent, useEffect, useRef, useState} from 'react'
235
+ import {joinMeeting, type Meeting, type Message, type Participant} from './meeting'
236
+
237
+ /**
238
+ * 一路摄像头画面,下面写着名字
239
+ * @param props 流(收到之前没有)、名字,以及是否静音(这个页面自己的摄像头)。
240
+ */
241
+ function Camera(props: {stream?: MediaStream, name: string, muted?: boolean}) {
242
+ const video = useRef<HTMLVideoElement>(null)
243
+ useEffect((): void => {
244
+ if (video.current) video.current.srcObject = props.stream ?? null
245
+ }, [props.stream])
246
+ return (
247
+ <figure>
248
+ <video ref={video} autoPlay playsInline muted={props.muted} width={320} height={240}/>
249
+ <figcaption>{props.name}</figcaption>
250
+ </figure>
251
+ )
252
+ }
253
+
254
+ export function App() {
255
+ const [name, setName] = useState<string>('')
256
+ const [room, setRoom] = useState<string>('')
257
+ const [meeting, setMeeting] = useState<Meeting>()
258
+ const [participants, setParticipants] = useState<Participant[]>([])
259
+ const [messages, setMessages] = useState<Message[]>([])
260
+ const [text, setText] = useState<string>('')
261
+ const [error, setError] = useState<string>('')
262
+
263
+ async function join(event: FormEvent<HTMLFormElement>): Promise<void> {
264
+ event.preventDefault()
265
+ try {
266
+ setMeeting(await joinMeeting(name, room, {
267
+ participants: setParticipants,
268
+ message: (message: Message): void => setMessages((before: Message[]): Message[] => [...before, message])
269
+ }))
270
+ setError('')
271
+ } catch (failure) {
272
+ setError((failure as Error).message)
273
+ }
274
+ }
275
+
276
+ async function leave(): Promise<void> {
277
+ await meeting?.leave()
278
+ setMeeting(undefined)
279
+ setParticipants([])
280
+ setMessages([])
281
+ }
282
+
283
+ function send(event: FormEvent<HTMLFormElement>): void {
284
+ event.preventDefault()
285
+ meeting?.send(text)
286
+ setText('')
287
+ }
288
+
289
+ if (!meeting) {
290
+ return (
291
+ <main>
292
+ <h1>Meeting</h1>
293
+ <form onSubmit={join}>
294
+ <input value={name} onChange={(event): void => setName(event.target.value)} placeholder="Your name" aria-label="Name" required/>
295
+ <input value={room} onChange={(event): void => setRoom(event.target.value)} placeholder="Room" aria-label="Room" required/>
296
+ <button>Join</button>
297
+ </form>
298
+ {error && <p role="alert">{error}</p>}
299
+ </main>
300
+ )
301
+ }
302
+ return (
303
+ <main>
304
+ <h1>{room} <button onClick={(): void => void leave()}>Leave</button></h1>
305
+ <section style={{display: 'flex', flexWrap: 'wrap'}}>
306
+ <Camera stream={meeting.camera} name={`${name} (you)`} muted/>
307
+ {participants.map((participant: Participant) => <Camera key={participant.id} stream={participant.stream} name={participant.name}/>)}
308
+ </section>
309
+ <ul>
310
+ {messages.map((message: Message, index: number) => <li key={index}><b>{message.from}</b>: {message.text}</li>)}
311
+ </ul>
312
+ <form onSubmit={send}>
313
+ <input value={text} onChange={(event): void => setText(event.target.value)} placeholder="Message" aria-label="Message" required/>
314
+ <button>Send</button>
315
+ </form>
316
+ </main>
317
+ )
318
+ }
319
+ ```
320
+
321
+ - 加入之前,页面先要填一个名字和一个房间。`joinMeeting()` 向浏览器申请摄像头和麦克风,浏览器会先征求用户同意;用户拒绝,或者服务端拒绝了这个名字,都会显示成错误。
322
+ - 进入会议后,页面显示自己的摄像头(不放声音,否则会有回声)和其他每个人的摄像头,收到画面之前是黑的;下面是聊天记录和输入框。
323
+ - 模板中渲染 `App` 的 `main.tsx` 不用改。
324
+
325
+ ## 5. 开发时的信令
326
+
327
+ **`packages/ui/vite.config.ts`**
328
+
329
+ ```typescript
330
+ import {defineConfig} from 'vite'
331
+ import react from '@vitejs/plugin-react'
332
+
333
+ //开发时,信令由 "npm run dev" 启动的服务提供
334
+ export default defineConfig({
335
+ plugins: [react()],
336
+ server: {
337
+ proxy: {
338
+ '/socket.io': {target: `http://127.0.0.1:${process.env.PORT ?? 8080}`, ws: true}
339
+ }
340
+ }
341
+ })
342
+ ```
343
+
344
+ 页面连接自己所在源上的 `/socket.io`。开发时这个源是 Vite:它的代理把 Socket.IO 的 WebSocket 转发给应用(`ws: true`)。页面没有 `/api` 下的 API,所以模板里 `/api` 的代理换成了这个。
345
+
346
+ ## 6. 运行
347
+
348
+ ```bash
349
+ npm run dev
350
+ ```
351
+
352
+ 这会构建并在 8080 端口启动应用,由 Vite 在 http://localhost:5173 上提供页面。在两个窗口里打开它,填上两个名字和同一个房间,允许使用摄像头:每个窗口都会显示两路摄像头画面,在一个窗口里写的消息会出现在另一个窗口里。在另一台电脑上打开页面需要 HTTPS(见第 8 节):浏览器只把摄像头给 `localhost` 和 HTTPS 页面。
353
+
354
+ ## 7. 测试
355
+
356
+ 模板中的测试换成了针对会议的测试。它在自己的端口上启动应用,然后在 Node.js 里让两个成员加入同一个会议:`SignalingClient` 是 `@lakutata/webrtc` 给应用用的客户端,房间和 peer 的用法和页面一样。Node.js 没有摄像头,所以每个成员发送一条视频轨道,包里带着自己的名字,再检查自己收到了对方的名字:
357
+
358
+ **`packages/app/src/tests/App.spec.ts`**
359
+
360
+ ```typescript
361
+ import {after, before, describe, it} from 'node:test'
362
+ import assert from 'node:assert'
363
+ import {Application} from 'lakutata'
364
+ import {MediaTrack, type Peer, type PeerChannel, type PeerTrackEvent, type Room, type RtpPacket, SignalingClient} from '@lakutata/webrtc'
365
+ import {config} from '../config/Config.js'
366
+
367
+ const PORT: number = Number(process.env.TEST_PORT ?? 18084)
368
+
369
+ /**
370
+ * Node.js 里的一个会议成员,和页面一样:其他每个成员的视频里带着的名字、收到的消息,以及停止发送视频的方法
371
+ */
372
+ type Member = {
373
+ room: Room
374
+ videos: Map<string, string>
375
+ messages: string[]
376
+ stop(): void
377
+ }
378
+
379
+ /**
380
+ * 一个 VP8 RTP 包的字节,负载是一段文字
381
+ * @param sequence
382
+ * @param text
383
+ */
384
+ function packet(sequence: number, text: string): Buffer {
385
+ const header: Buffer = Buffer.alloc(12)
386
+ header[0] = 0x80
387
+ header[1] = 96
388
+ header.writeUInt16BE(sequence & 0xffff, 2)
389
+ header.writeUInt32BE(sequence * 3000, 4)
390
+ return Buffer.concat([header, Buffer.from(text)])
391
+ }
392
+
393
+ /**
394
+ * 等到条件成立,最多 20 秒
395
+ * @param condition
396
+ * @param what 等的是什么,用于错误信息。
397
+ */
398
+ async function until(condition: () => boolean, what: string): Promise<void> {
399
+ const end: number = Date.now() + 20000
400
+ while (!condition()) {
401
+ if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`)
402
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
403
+ }
404
+ }
405
+
406
+ describe('meeting', (): void => {
407
+
408
+ let app: Application
409
+
410
+ const clients: SignalingClient[] = []
411
+
412
+ before(async (): Promise<void> => {
413
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
414
+ Application.run(config(PORT)).onLaunched((launched: Application): void => resolve(launched))
415
+ })
416
+ })
417
+
418
+ after(async (): Promise<void> => {
419
+ for (const client of clients) await client.close()
420
+ await app?.dispose()
421
+ })
422
+
423
+ /**
424
+ * 应用的信令的一个客户端
425
+ */
426
+ async function client(): Promise<SignalingClient> {
427
+ const created: SignalingClient = await app.getObject(SignalingClient, {url: `http://127.0.0.1:${PORT}`})
428
+ clients.push(created)
429
+ return created
430
+ }
431
+
432
+ /**
433
+ * 像页面一样加入房间 test:发布一条视频轨道,给其他每个成员开一条聊天通道
434
+ * @param name
435
+ */
436
+ async function member(name: string): Promise<Member> {
437
+ const room: Room = await (await client()).join('test', {metadata: {name: name}})
438
+ const videos: Map<string, string> = new Map()
439
+ const messages: string[] = []
440
+ room.on('peer', (peer: Peer): void => {
441
+ const from: string = (peer.metadata as {name: string}).name
442
+ peer.on('track', (event: PeerTrackEvent): void => {
443
+ event.track.on('rtp', (rtp: RtpPacket): void => void videos.set(from, new TextDecoder().decode(rtp.payload)))
444
+ })
445
+ peer.on('datachannel', (channel: PeerChannel): void => {
446
+ channel.on('message', (data: string | Buffer | ArrayBuffer): number => messages.push(`${from}: ${String(data)}`))
447
+ })
448
+ const chat: PeerChannel = peer.createDataChannel('chat')
449
+ chat.on('open', (): void => chat.send(`hello from ${name}`))
450
+ })
451
+ const video: MediaTrack = new MediaTrack('video', {codec: 'video/VP8'})
452
+ await room.publish(video, 'camera')
453
+ let sequence: number = 0
454
+ const timer: NodeJS.Timeout = setInterval((): void => video.writeRtp(packet(sequence++, name)), 20)
455
+ return {room: room, videos: videos, messages: messages, stop: (): void => clearInterval(timer)}
456
+ }
457
+
458
+ it('answers the health check', async (): Promise<void> => {
459
+ assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
460
+ })
461
+
462
+ it('serves the page', async (): Promise<void> => {
463
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/`, {headers: {Accept: 'text/html'}})
464
+ assert.strictEqual(response.status, 200)
465
+ assert.match(await response.text(), /<div id="root"><\/div>/)
466
+ })
467
+
468
+ it('forwards the video and the messages of the members of a meeting through the media server', {timeout: 60000}, async (): Promise<void> => {
469
+ const alice: Member = await member('Alice')
470
+ const bob: Member = await member('Bob')
471
+ try {
472
+ assert.match(alice.room.sfu ?? '', /^sfu:/)
473
+ await until((): boolean => alice.videos.get('Bob') === 'Bob' && bob.videos.get('Alice') === 'Alice', 'the videos')
474
+ await until((): boolean => alice.messages.includes('Bob: hello from Bob') && bob.messages.includes('Alice: hello from Alice'), 'the messages')
475
+ //Bob 离开:Alice 看到他走了
476
+ 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)))
477
+ await bob.room.leave()
478
+ assert.strictEqual(await left, 'Bob')
479
+ } finally {
480
+ alice.stop()
481
+ bob.stop()
482
+ }
483
+ })
484
+
485
+ it('refuses a member without a name', async (): Promise<void> => {
486
+ await assert.rejects((await client()).join('test'), {errno: 'E_SIGNALING_DENIED'})
487
+ })
488
+ })
489
+ ```
490
+
491
+ ```
492
+ app │ ✔ answers the health check
493
+ app │ ✔ serves the page
494
+ app │ ✔ forwards the video and the messages of the members of a meeting through the media server
495
+ app │ ✔ refuses a member without a name
496
+ app │ ℹ pass 4
497
+ ✔ test ran in app
498
+ ```
499
+
500
+ `npm test` 会先构建各个包,所以第二个测试运行时已经有构建好的页面。测试覆盖的是服务端和这个包的客户端,不包括 React 代码;`npm run typecheck -w ui` 检查页面的类型。
501
+
502
+ ## 8. 构建和部署
503
+
504
+ ```bash
505
+ npm run build
506
+ npm start
507
+ ```
508
+
509
+ 现在由一个进程提供页面、信令和媒体服务器:页面在 http://localhost:8080,它的连接用 UDP 的 40000 到 40999 端口。部署时:
510
+
511
+ - **HTTPS。** 浏览器只把摄像头和麦克风给 `localhost` 和 HTTPS 页面。适配器提供的是 HTTP:在应用前面放一个反向代理(nginx、Caddy),由它提供 HTTPS,并转发 Socket.IO 的 WebSocket(见 [HTTP](../HTTP.md))。
512
+ - **媒体服务器的端口。** 浏览器直接连接媒体服务器:在防火墙里放开 UDP 的 40000 到 40999 端口。如果在 NAT 后面,并且 NAT 按相同的端口号转发了这些端口,启动应用时把 `PUBLIC_ADDRESS` 设为公网地址。
513
+ - **不放行 UDP 的网络。** 有些公司网络和移动网络需要 TURN 服务器:加上 `TURNServer` 组件,把它的名字告诉信令(见 [WebRTC](../WebRTC.md) 第 3 节和第 12 节)。
514
+ - **谁能加入。** 能打开页面的人,可以用任何名字加入任何房间。在公网上,用 entrypoint 的中间件认证用户(模板里的 `Authenticate.ts` 检查的是令牌),在 `authorize` 里决定谁能加入哪个房间,它也能拿到请求的上下文。
515
+ - **更大的会议。** 一个进程能转发几百路视频(数字见 WebRTC 指南);媒体服务器的 `workers` 选项可以用上更多的核,多个实例还可以通过 `Cluster` 共同承载房间(见 [WebRTC](../WebRTC.md) 第 13 节)。
516
+
517
+ <!--
518
+ check:
519
+ create: meeting web-react
520
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
521
+ remove: packages/app/src/middlewares/Authenticate.ts
522
+ run: npm test
523
+ run: npm run typecheck -w ui
524
+ -->
@@ -11,4 +11,5 @@
11
11
  | [服务之间的调用](ServiceCalls.md) | 两个 `service` 项目:`@ServiceAction` 的模式与 `@Prefix`、HTTP 服务传输方式、调用它的代理组件、服务之间的令牌、被调用方的拒绝传回调用方(409)、各服务的测试(替身运行在测试进程中) |
12
12
  | [后台任务](BackgroundJobs.md) | `service` 模板、队列(内存驱动)及其 `@QueueAction`、重试和死信队列、借助锁只在一个实例中执行的 cron 定时任务、共用目录的两个实例、替换组件的测试 |
13
13
  | [给 AI 助手用的 MCP 服务器](MCPServer.md) | `default` 模板、工具、资源和提示词(通过 stdio 和 HTTP 提供)、用 DTO 校验参数、在 Claude Code 和 Claude Desktop 中注册、用 SDK 的 MCP 客户端编写的测试 |
14
+ | [视频会议](Meeting.md) | `web-react` 模板加上 `@lakutata/webrtc`:信令和媒体服务器与页面共用端口、浏览器客户端、房间里所有人的摄像头画面、经数据通道的聊天、用 Node.js 成员编写的测试 |
14
15
  | [设备上的服务](DeviceService.md) | `service` 模板及其 `installer` 功能:定时任务、带选项的组件、监控器、用环境变量配置、由 deb/rpm 软件包安装的系统服务、测试 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakutata",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "An IoC application framework for Node.js and TypeScript: one programming model for web services, CLI tools, desktop (Electron) and embedded applications.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -354,19 +354,19 @@
354
354
  "mcp"
355
355
  ],
356
356
  "dependencies": {
357
- "@lakutata/cache": "3.1.0",
358
- "@lakutata/containerd": "3.1.0",
359
- "@lakutata/core": "3.1.0",
360
- "@lakutata/docker": "3.1.0",
361
- "@lakutata/entrypoint": "3.1.0",
362
- "@lakutata/i18n": "3.1.0",
363
- "@lakutata/logger": "3.1.0",
364
- "@lakutata/monitor": "3.1.0",
365
- "@lakutata/mutex": "3.1.0",
366
- "@lakutata/orm": "3.1.0",
367
- "@lakutata/queue": "3.1.0",
368
- "@lakutata/security": "3.1.0",
369
- "@lakutata/utils": "3.1.0"
357
+ "@lakutata/cache": "3.1.1",
358
+ "@lakutata/containerd": "3.1.1",
359
+ "@lakutata/core": "3.1.1",
360
+ "@lakutata/docker": "3.1.1",
361
+ "@lakutata/entrypoint": "3.1.1",
362
+ "@lakutata/i18n": "3.1.1",
363
+ "@lakutata/logger": "3.1.1",
364
+ "@lakutata/monitor": "3.1.1",
365
+ "@lakutata/mutex": "3.1.1",
366
+ "@lakutata/orm": "3.1.1",
367
+ "@lakutata/queue": "3.1.1",
368
+ "@lakutata/security": "3.1.1",
369
+ "@lakutata/utils": "3.1.1"
370
370
  },
371
371
  "files": [
372
372
  "dist",