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,573 @@
1
+ # A video meeting
2
+
3
+ This example builds a video meeting: a page where each person enters a name and a room, then sees and hears the others
4
+ and writes to them in a chat. It starts from the `web-react` template, adds `@lakutata/webrtc` to the application and
5
+ to the page, and shows its three parts working together: the signaling, which puts the people of a room in touch, the
6
+ media server, which forwards their video, and the browser client, which connects the page to both. With the media
7
+ server, each person sends their camera once, however many people are in the room.
8
+
9
+ | Where | What it serves |
10
+ |---|---|
11
+ | `GET /`, and any other page | The meeting page: the built `ui` package |
12
+ | `/socket.io`, on the same port | The signaling, over Socket.IO |
13
+ | UDP ports 40000 to 40999 | The connections of the browsers to the media server |
14
+ | `GET /health` | Tells whether the process answers (from the template) |
15
+
16
+ ## 1. Create the project
17
+
18
+ ```bash
19
+ npx @lakutata/cli create --name meeting --template web-react --yes
20
+ cd meeting
21
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
22
+ rm packages/app/src/middlewares/Authenticate.ts
23
+ npm install @lakutata/webrtc socket.io -w app
24
+ npm install socket.io-client --save-dev -w app
25
+ npm install @lakutata/webrtc socket.io-client -w ui
26
+ ```
27
+
28
+ The project is a workspace ([ProjectStructure](../ProjectStructure.md)): `packages/app` is the application, and
29
+ `packages/ui` the React page. The example controller of the template is removed, and so is its middleware
30
+ `Authenticate.ts`: the people of a meeting give a name, not a token (section 8 says what to add on a public network).
31
+
32
+ The application gets `@lakutata/webrtc` (the signaling and the media server) and `socket.io` (the Socket.IO server the
33
+ signaling goes through); its tests get `socket.io-client`, to join meetings from Node.js. The page gets
34
+ `@lakutata/webrtc`, of which it imports the browser entry `@lakutata/webrtc/client` only, and `socket.io-client`.
35
+
36
+ ## 2. The server
37
+
38
+ **`packages/app/src/config/Config.ts`**
39
+
40
+ ```typescript
41
+ import {resourcesPath, type ApplicationOptions} from 'lakutata'
42
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
43
+ import {expressHTTPEntrypoint, jsonEnvelope, staticUI} from 'lakutata/adapter/express'
44
+ import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
45
+ import {buildSFUOptions, buildSignalingOptions, type SignalingPeer, SignalingController} from '@lakutata/webrtc'
46
+ import compression from 'compression'
47
+ import {existsSync} from 'node:fs'
48
+ import type {Server} from 'node:http'
49
+ import {createRequire} from 'node:module'
50
+ import path from 'node:path'
51
+ import {HealthController} from '../controllers/HealthController.js'
52
+
53
+ /**
54
+ * The directory of the built ui package, undefined before it is built (in development, Vite serves the page): among the
55
+ * resources of a packed application (lakutata package bundle or lakutata package exe, from "lakutata.resources" in its
56
+ * package.json), otherwise in the ui package
57
+ */
58
+ function uiDirectory(): string | undefined {
59
+ const resources: string | undefined = resourcesPath()
60
+ try {
61
+ const directory: string = resources ? path.join(resources, 'ui') : path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist')
62
+ return existsSync(path.join(directory, 'index.html')) ? directory : undefined
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Whether a person may join a meeting: with a name of 1 to 40 characters
70
+ * @param peer The id of the person and the metadata of their page.
71
+ */
72
+ function named(peer: SignalingPeer): boolean {
73
+ const name: unknown = (peer.metadata as {name?: unknown} | undefined)?.name
74
+ return typeof name === 'string' && name.trim().length > 0 && name.length <= 40
75
+ }
76
+
77
+ /**
78
+ * The options of the service
79
+ * @param port the port of the HTTP server and of the signaling, the PORT environment variable or 8080 by default
80
+ */
81
+ export function config(port: number = Number(process.env.PORT ?? 8080)): ApplicationOptions {
82
+ //The HTTP server of express, which Socket.IO shares
83
+ let server: Server | undefined
84
+ return {
85
+ id: 'meeting',
86
+ name: 'meeting',
87
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
88
+ components: {
89
+ //The rooms: 50 people at most in each one, who give a name
90
+ signaling: buildSignalingOptions({maxPeers: 50, authorize: named}),
91
+ //The media server of the rooms. PUBLIC_ADDRESS: the address the browsers reach this machine at, when it is
92
+ //not one of its own (a NAT forwarding the ports 40000 to 40999)
93
+ sfu: buildSFUOptions({
94
+ icePortRange: [40000, 40999],
95
+ iceAdditionalHostAddresses: process.env.PUBLIC_ADDRESS ? [process.env.PUBLIC_ADDRESS] : []
96
+ }),
97
+ entrypoint: buildEntrypoints({
98
+ controllers: [HealthController, SignalingController],
99
+ http: expressHTTPEntrypoint({
100
+ port: port,
101
+ //The results and the errors are {errno, errMsg, data}, with the status 200
102
+ respond: jsonEnvelope({errorStatus: 200}),
103
+ body: {limit: '100mb'},
104
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
105
+ before: (app, context): void => {
106
+ server = context.server
107
+ app.use(compression())
108
+ },
109
+ //The paths that no action handles are the pages of the application
110
+ fallback: ((directory: string | undefined) => directory ? staticUI(directory) : undefined)(uiDirectory())
111
+ }),
112
+ //The signaling, on the port of the page
113
+ service: socketIOServiceEntrypoint({server: (): Server => server!})
114
+ })
115
+ },
116
+ bootstrap: ['sfu', 'entrypoint']
117
+ }
118
+ }
119
+ ```
120
+
121
+ This is the `Config.ts` of the template, with three components instead of the example controller:
122
+
123
+ - `signaling` holds the rooms. Each person is a member of a room; the member's id is the id of their Socket.IO
124
+ connection, and its metadata what their page gives when it joins: here `{name}`. `authorize` refuses a member
125
+ without a name, and `maxPeers` a 51st member.
126
+ - `sfu` is the media server. It joins each room as a member of its own; the pages see it and connect to it instead of
127
+ to each other, and it forwards the audio and the video of each person to the others, without decoding them. Its
128
+ connections use the UDP ports 40000 to 40999, one port on each address of the machine for each person, and these
129
+ addresses as their candidates (`PUBLIC_ADDRESS` adds another).
130
+ - `SignalingController` serves the requests of the signaling (`webrtc.join`, `webrtc.signal`…), through the Socket.IO
131
+ adapter. `socketIOServiceEntrypoint({server})` attaches Socket.IO to the HTTP server of express, which `before` gives:
132
+ the page, its signaling and the health check share port 8080.
133
+
134
+ The application bootstraps `sfu`, which attaches itself to `signaling`, then the entrypoint, which starts listening. The
135
+ rest comes from the template: `uiDirectory()` and `fallback` serve the built page, `jsonEnvelope` the results of the
136
+ actions ([WebApp](WebApp.md) explains them).
137
+
138
+ ## 3. The meeting
139
+
140
+ The page keeps its WebRTC code in a module of its own, which knows nothing of React.
141
+
142
+ **`packages/ui/src/meeting.ts`**
143
+
144
+ ```typescript
145
+ import {io} from 'socket.io-client'
146
+ import {type Peer, type Room, SignalingConnection} from '@lakutata/webrtc/client'
147
+
148
+ /**
149
+ * Another person of the meeting: their name, and their camera once it arrives
150
+ */
151
+ export type Participant = {
152
+ id: string
153
+ name: string
154
+ stream?: MediaStream
155
+ }
156
+
157
+ /**
158
+ * A message of the chat
159
+ */
160
+ export type Message = {
161
+ from: string
162
+ text: string
163
+ }
164
+
165
+ /**
166
+ * What the meeting tells the page
167
+ */
168
+ export type MeetingEvents = {
169
+ //The other people, each time one comes, leaves, or their camera arrives
170
+ participants(participants: Participant[]): void
171
+ message(message: Message): void
172
+ }
173
+
174
+ /**
175
+ * A meeting joined
176
+ */
177
+ export type Meeting = {
178
+ //The camera and the microphone of this page
179
+ camera: MediaStream
180
+ //Send a message to everyone
181
+ send(text: string): void
182
+ leave(): Promise<void>
183
+ }
184
+
185
+ /**
186
+ * Join a meeting: the camera and the microphone of this page are sent to the others, theirs are received, and a channel
187
+ * to each person carries the chat
188
+ * @param name The name shown to the others.
189
+ * @param roomName The room.
190
+ * @param events What the meeting tells the page.
191
+ */
192
+ export async function joinMeeting(name: string, roomName: string, events: MeetingEvents): Promise<Meeting> {
193
+ const camera: MediaStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true})
194
+ //The signaling, on the origin of the page; the id of this member is a new one for each page
195
+ const signaling: SignalingConnection = new SignalingConnection(io({auth: {id: crypto.randomUUID()}, transports: ['websocket']}))
196
+ let room: Room
197
+ try {
198
+ room = await signaling.join(roomName, {metadata: {name: name}})
199
+ } catch (error) {
200
+ await signaling.close()
201
+ camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
202
+ throw error
203
+ }
204
+ const participants: Map<string, Participant> = new Map()
205
+ const channels: Map<string, RTCDataChannel> = new Map()
206
+ const changed = (): void => events.participants([...participants.values()])
207
+ room.on('peer', (peer: Peer): void => {
208
+ const participant: Participant = {id: peer.id, name: (peer.metadata as {name: string}).name}
209
+ participants.set(peer.id, participant)
210
+ changed()
211
+ //The audio and the video of the person, in one stream
212
+ peer.on('track', (event: RTCTrackEvent): void => {
213
+ participants.set(peer.id, {...participant, stream: event.streams[0]})
214
+ changed()
215
+ })
216
+ //The chat: a channel to each person, and theirs to this page
217
+ channels.set(peer.id, peer.createDataChannel('chat'))
218
+ peer.on('datachannel', (channel: RTCDataChannel): void => {
219
+ channel.addEventListener('message', (event: MessageEvent): void => events.message({from: participant.name, text: String(event.data)}))
220
+ })
221
+ })
222
+ room.on('peer-left', (peer: Peer): void => {
223
+ participants.delete(peer.id)
224
+ channels.delete(peer.id)
225
+ changed()
226
+ })
227
+ for (const track of camera.getTracks()) await room.publish(track, camera)
228
+ return {
229
+ camera: camera,
230
+ send: (text: string): void => {
231
+ for (const channel of channels.values()) if (channel.readyState === 'open') channel.send(text)
232
+ events.message({from: name, text: text})
233
+ },
234
+ leave: async (): Promise<void> => {
235
+ await room.leave()
236
+ await signaling.close()
237
+ camera.getTracks().forEach((track: MediaStreamTrack): void => track.stop())
238
+ }
239
+ }
240
+ }
241
+ ```
242
+
243
+ - `SignalingConnection` takes the Socket.IO connection of the page. `io()` without an address connects to the origin of
244
+ the page; `transports: ['websocket']` makes it a single connection, which needs no sticky sessions behind a load
245
+ balancer. The id of the member is the `auth.id` of the connection.
246
+ - `signaling.join()` joins the room with the name in its metadata, and gives the `Room`. The room emits `peer` for each
247
+ other person, the ones already there first, and `peer-left` when one leaves.
248
+ - `room.publish()` sends the camera and the microphone to every person, the ones joining later too. The room has a
249
+ media server (`room.sfu` is its id), so each track goes once, to the server.
250
+ - Each `Peer` emits the tracks of its person (`track`), and the channels that person opens (`datachannel`). Its
251
+ `createDataChannel()` opens a channel to that person, through the media server, which relays the messages. Without a
252
+ media server, this code would connect the pages to each other and work the same.
253
+ - When the node of the media server goes down (with several instances, see the guide), each person moves to another
254
+ one: the room emits `peer-left`, then `peer` again, and the channels are created again.
255
+
256
+ ## 4. The page
257
+
258
+ **`packages/ui/src/App.tsx`**
259
+
260
+ ```tsx
261
+ import {type FormEvent, useEffect, useRef, useState} from 'react'
262
+ import {joinMeeting, type Meeting, type Message, type Participant} from './meeting'
263
+
264
+ /**
265
+ * A camera, with a name under it
266
+ * @param props The stream (none before it arrives), the name, and whether its sound is off (the camera of this page).
267
+ */
268
+ function Camera(props: {stream?: MediaStream, name: string, muted?: boolean}) {
269
+ const video = useRef<HTMLVideoElement>(null)
270
+ useEffect((): void => {
271
+ if (video.current) video.current.srcObject = props.stream ?? null
272
+ }, [props.stream])
273
+ return (
274
+ <figure>
275
+ <video ref={video} autoPlay playsInline muted={props.muted} width={320} height={240}/>
276
+ <figcaption>{props.name}</figcaption>
277
+ </figure>
278
+ )
279
+ }
280
+
281
+ export function App() {
282
+ const [name, setName] = useState<string>('')
283
+ const [room, setRoom] = useState<string>('')
284
+ const [meeting, setMeeting] = useState<Meeting>()
285
+ const [participants, setParticipants] = useState<Participant[]>([])
286
+ const [messages, setMessages] = useState<Message[]>([])
287
+ const [text, setText] = useState<string>('')
288
+ const [error, setError] = useState<string>('')
289
+
290
+ async function join(event: FormEvent<HTMLFormElement>): Promise<void> {
291
+ event.preventDefault()
292
+ try {
293
+ setMeeting(await joinMeeting(name, room, {
294
+ participants: setParticipants,
295
+ message: (message: Message): void => setMessages((before: Message[]): Message[] => [...before, message])
296
+ }))
297
+ setError('')
298
+ } catch (failure) {
299
+ setError((failure as Error).message)
300
+ }
301
+ }
302
+
303
+ async function leave(): Promise<void> {
304
+ await meeting?.leave()
305
+ setMeeting(undefined)
306
+ setParticipants([])
307
+ setMessages([])
308
+ }
309
+
310
+ function send(event: FormEvent<HTMLFormElement>): void {
311
+ event.preventDefault()
312
+ meeting?.send(text)
313
+ setText('')
314
+ }
315
+
316
+ if (!meeting) {
317
+ return (
318
+ <main>
319
+ <h1>Meeting</h1>
320
+ <form onSubmit={join}>
321
+ <input value={name} onChange={(event): void => setName(event.target.value)} placeholder="Your name" aria-label="Name" required/>
322
+ <input value={room} onChange={(event): void => setRoom(event.target.value)} placeholder="Room" aria-label="Room" required/>
323
+ <button>Join</button>
324
+ </form>
325
+ {error && <p role="alert">{error}</p>}
326
+ </main>
327
+ )
328
+ }
329
+ return (
330
+ <main>
331
+ <h1>{room} <button onClick={(): void => void leave()}>Leave</button></h1>
332
+ <section style={{display: 'flex', flexWrap: 'wrap'}}>
333
+ <Camera stream={meeting.camera} name={`${name} (you)`} muted/>
334
+ {participants.map((participant: Participant) => <Camera key={participant.id} stream={participant.stream} name={participant.name}/>)}
335
+ </section>
336
+ <ul>
337
+ {messages.map((message: Message, index: number) => <li key={index}><b>{message.from}</b>: {message.text}</li>)}
338
+ </ul>
339
+ <form onSubmit={send}>
340
+ <input value={text} onChange={(event): void => setText(event.target.value)} placeholder="Message" aria-label="Message" required/>
341
+ <button>Send</button>
342
+ </form>
343
+ </main>
344
+ )
345
+ }
346
+ ```
347
+
348
+ - Before joining, the page asks for a name and a room. `joinMeeting()` asks the browser for the camera and the
349
+ microphone, so the browser asks the person first; a refusal, or a name the server refuses, shows as an error.
350
+ - In the meeting, the page shows its own camera (without its sound, which would echo) and the camera of each other
351
+ person, whose video stays black until it arrives; then the chat, and the field to write in it.
352
+ - The `main.tsx` of the template, which renders `App`, does not change.
353
+
354
+ ## 5. The signaling during development
355
+
356
+ **`packages/ui/vite.config.ts`**
357
+
358
+ ```typescript
359
+ import {defineConfig} from 'vite'
360
+ import react from '@vitejs/plugin-react'
361
+
362
+ //In development, the signaling is the one of the service started by "npm run dev"
363
+ export default defineConfig({
364
+ plugins: [react()],
365
+ server: {
366
+ proxy: {
367
+ '/socket.io': {target: `http://127.0.0.1:${process.env.PORT ?? 8080}`, ws: true}
368
+ }
369
+ }
370
+ })
371
+ ```
372
+
373
+ The page connects to `/socket.io` on its own origin. During development, that origin is Vite: its proxy forwards the
374
+ WebSocket of Socket.IO to the application (`ws: true`). The page has no API under `/api`, so the template's proxy of
375
+ `/api` is replaced.
376
+
377
+ ## 6. Run it
378
+
379
+ ```bash
380
+ npm run dev
381
+ ```
382
+
383
+ This builds and starts the application on port 8080, and Vite serves the page on http://localhost:5173. Open it in two
384
+ windows, give two names and the same room, and allow the camera: each window shows both cameras, and a message written
385
+ in one appears in the other. On another computer, the page needs HTTPS (section 8): browsers give the camera to
386
+ `localhost` and to HTTPS pages only.
387
+
388
+ ## 7. Test it
389
+
390
+ The test of the template is replaced by a test of the meeting. It starts the application on its own port, and joins a
391
+ meeting with two members in Node.js: `SignalingClient` is the client of `@lakutata/webrtc` for the applications, with
392
+ the same rooms and peers as the page. Node.js has no camera, so each member sends a video track whose packets carry its
393
+ name, and checks it receives the name of the other one:
394
+
395
+ **`packages/app/src/tests/App.spec.ts`**
396
+
397
+ ```typescript
398
+ import {after, before, describe, it} from 'node:test'
399
+ import assert from 'node:assert'
400
+ import {Application} from 'lakutata'
401
+ import {MediaTrack, type Peer, type PeerChannel, type PeerTrackEvent, type Room, type RtpPacket, SignalingClient} from '@lakutata/webrtc'
402
+ import {config} from '../config/Config.js'
403
+
404
+ const PORT: number = Number(process.env.TEST_PORT ?? 18084)
405
+
406
+ /**
407
+ * A member of a meeting in Node.js, as a page is one: the name carried by the video of each other member, the messages
408
+ * it got, and a way to stop its video
409
+ */
410
+ type Member = {
411
+ room: Room
412
+ videos: Map<string, string>
413
+ messages: string[]
414
+ stop(): void
415
+ }
416
+
417
+ /**
418
+ * The bytes of an RTP packet of VP8 whose payload is a text
419
+ * @param sequence
420
+ * @param text
421
+ */
422
+ function packet(sequence: number, text: string): Buffer {
423
+ const header: Buffer = Buffer.alloc(12)
424
+ header[0] = 0x80
425
+ header[1] = 96
426
+ header.writeUInt16BE(sequence & 0xffff, 2)
427
+ header.writeUInt32BE(sequence * 3000, 4)
428
+ return Buffer.concat([header, Buffer.from(text)])
429
+ }
430
+
431
+ /**
432
+ * Wait until a condition holds, 20 seconds at most
433
+ * @param condition
434
+ * @param what What is waited for, for the error.
435
+ */
436
+ async function until(condition: () => boolean, what: string): Promise<void> {
437
+ const end: number = Date.now() + 20000
438
+ while (!condition()) {
439
+ if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`)
440
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
441
+ }
442
+ }
443
+
444
+ describe('meeting', (): void => {
445
+
446
+ let app: Application
447
+
448
+ const clients: SignalingClient[] = []
449
+
450
+ before(async (): Promise<void> => {
451
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
452
+ Application.run(config(PORT)).onLaunched((launched: Application): void => resolve(launched))
453
+ })
454
+ })
455
+
456
+ after(async (): Promise<void> => {
457
+ for (const client of clients) await client.close()
458
+ await app?.dispose()
459
+ })
460
+
461
+ /**
462
+ * A client of the signaling of the application
463
+ */
464
+ async function client(): Promise<SignalingClient> {
465
+ const created: SignalingClient = await app.getObject(SignalingClient, {url: `http://127.0.0.1:${PORT}`})
466
+ clients.push(created)
467
+ return created
468
+ }
469
+
470
+ /**
471
+ * Join the room test as the page does: a video track published, a chat channel to each other member
472
+ * @param name
473
+ */
474
+ async function member(name: string): Promise<Member> {
475
+ const room: Room = await (await client()).join('test', {metadata: {name: name}})
476
+ const videos: Map<string, string> = new Map()
477
+ const messages: string[] = []
478
+ room.on('peer', (peer: Peer): void => {
479
+ const from: string = (peer.metadata as {name: string}).name
480
+ peer.on('track', (event: PeerTrackEvent): void => {
481
+ event.track.on('rtp', (rtp: RtpPacket): void => void videos.set(from, new TextDecoder().decode(rtp.payload)))
482
+ })
483
+ peer.on('datachannel', (channel: PeerChannel): void => {
484
+ channel.on('message', (data: string | Buffer | ArrayBuffer): number => messages.push(`${from}: ${String(data)}`))
485
+ })
486
+ const chat: PeerChannel = peer.createDataChannel('chat')
487
+ chat.on('open', (): void => chat.send(`hello from ${name}`))
488
+ })
489
+ const video: MediaTrack = new MediaTrack('video', {codec: 'video/VP8'})
490
+ await room.publish(video, 'camera')
491
+ let sequence: number = 0
492
+ const timer: NodeJS.Timeout = setInterval((): void => video.writeRtp(packet(sequence++, name)), 20)
493
+ return {room: room, videos: videos, messages: messages, stop: (): void => clearInterval(timer)}
494
+ }
495
+
496
+ it('answers the health check', async (): Promise<void> => {
497
+ assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
498
+ })
499
+
500
+ it('serves the page', async (): Promise<void> => {
501
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/`, {headers: {Accept: 'text/html'}})
502
+ assert.strictEqual(response.status, 200)
503
+ assert.match(await response.text(), /<div id="root"><\/div>/)
504
+ })
505
+
506
+ it('forwards the video and the messages of the members of a meeting through the media server', {timeout: 60000}, async (): Promise<void> => {
507
+ const alice: Member = await member('Alice')
508
+ const bob: Member = await member('Bob')
509
+ try {
510
+ assert.match(alice.room.sfu ?? '', /^sfu:/)
511
+ await until((): boolean => alice.videos.get('Bob') === 'Bob' && bob.videos.get('Alice') === 'Alice', 'the videos')
512
+ await until((): boolean => alice.messages.includes('Bob: hello from Bob') && bob.messages.includes('Alice: hello from Alice'), 'the messages')
513
+ //Bob leaves: Alice sees him go
514
+ 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)))
515
+ await bob.room.leave()
516
+ assert.strictEqual(await left, 'Bob')
517
+ } finally {
518
+ alice.stop()
519
+ bob.stop()
520
+ }
521
+ })
522
+
523
+ it('refuses a member without a name', async (): Promise<void> => {
524
+ await assert.rejects((await client()).join('test'), {errno: 'E_SIGNALING_DENIED'})
525
+ })
526
+ })
527
+ ```
528
+
529
+ ```
530
+ app │ ✔ answers the health check
531
+ app │ ✔ serves the page
532
+ app │ ✔ forwards the video and the messages of the members of a meeting through the media server
533
+ app │ ✔ refuses a member without a name
534
+ app │ ℹ pass 4
535
+ ✔ test ran in app
536
+ ```
537
+
538
+ `npm test` builds the packages first, so the built page is there for the second test. The test covers the server and
539
+ the client of the package, not the React code; `npm run typecheck -w ui` checks the types of the page.
540
+
541
+ ## 8. Build it and deploy it
542
+
543
+ ```bash
544
+ npm run build
545
+ npm start
546
+ ```
547
+
548
+ The application now serves the page, the signaling and the media server, from one process: the page on
549
+ http://localhost:8080, its connections on the UDP ports 40000 to 40999. To deploy it:
550
+
551
+ - **HTTPS.** Browsers give the camera and the microphone to `localhost` and to HTTPS pages only. The adapters serve
552
+ HTTP: put a reverse proxy in front of the application (nginx, Caddy), which serves HTTPS and forwards the WebSockets
553
+ of Socket.IO ([HTTP](../HTTP.md)).
554
+ - **The ports of the media server.** The browsers connect to the media server directly: open the UDP ports 40000 to
555
+ 40999 in the firewall. Behind a NAT that forwards them with the same numbers, start the application with
556
+ `PUBLIC_ADDRESS` set to the public address.
557
+ - **The networks that let no UDP through.** Some company and mobile networks need a TURN server: add the `TURNServer`
558
+ component and give its name to the signaling ([WebRTC](../WebRTC.md), sections 3 and 12).
559
+ - **Who may join.** Anyone who reaches the page may join any room under any name. On a public network, authenticate the
560
+ people with a middleware of the entrypoint (the `Authenticate.ts` of the template checks a token), and decide who
561
+ joins which room in `authorize`, which also gets the context of the request.
562
+ - **Larger meetings.** One process forwards a few hundred videos (the WebRTC guide has the numbers); the `workers`
563
+ option of the media server uses more cores, and several instances share the rooms through a `Cluster`
564
+ ([WebRTC](../WebRTC.md), section 13).
565
+
566
+ <!--
567
+ check:
568
+ create: meeting web-react
569
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
570
+ remove: packages/app/src/middlewares/Authenticate.ts
571
+ run: npm test
572
+ run: npm run typecheck -w ui
573
+ -->
@@ -13,4 +13,5 @@ builds it and runs its tests.
13
13
  | [Calls between services](ServiceCalls.md) | Two `service` projects: `@ServiceAction` patterns with `@Prefix`, the HTTP service transport, a proxy component that calls it, a token between the services, the callee's refusal passed back to the caller (409), the tests of each service with a stand-in in the test process |
14
14
  | [Background jobs](BackgroundJobs.md) | The `service` template, a queue (memory driver) and its `@QueueAction`, retries and the dead letter queue, a cron schedule run in one instance with a lock, two instances sharing a directory, tests that replace a component |
15
15
  | [An MCP server for AI assistants](MCPServer.md) | The `default` template, tools, a resource and a prompt served over stdio and HTTP, arguments validated by DTOs, registration in Claude Code and Claude Desktop, tests with the SDK's MCP client |
16
+ | [A video meeting](Meeting.md) | The `web-react` template with `@lakutata/webrtc`: the signaling and the media server on the port of the page, the browser client, the cameras of everyone in a room, a chat over data channels, tests with members in Node.js |
16
17
  | [A service on a device](DeviceService.md) | The `service` template with its `installer` feature: a scheduled action, components with options, the monitors, configuration through environment variables, a system service installed by deb/rpm packages, tests |
package/doc/zh/Command.md CHANGED
@@ -55,6 +55,8 @@ lakutata create --name tool --template cli --package-manager none --no-git
55
55
 
56
56
  `lakutata add <name> --native gyp|cmake|rust` 添加一个用 C++ 或 Rust 编写的原生插件,`lakutata native <link|include|define> <value>` 给它链接库。见 [NativeAddons](NativeAddons.md)。
57
57
 
58
+ `lakutata ffi generate <header>` 根据 C 头文件生成 `@lakutata/ffi` 原生库的类。见 [FFI](FFI.md) 第 9 节。
59
+
58
60
  ## 4. 迁移与翻译
59
61
 
60
62
  `lakutata migration <action>` 生成、创建、执行、回滚和查看应用的数据库迁移。它用应用的 `dev` 脚本构建并启动应用,此时应用只连接数据库。见 [Migrations](Migrations.md) 第 2 节。