lakutata 3.1.0 → 3.1.1
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.
- package/README.md +2 -2
- package/README.zh-CN.md +1 -1
- package/doc/en/HTTP.md +8 -4
- package/doc/en/Overview.md +3 -2
- package/doc/en/ServiceCalls.md +8 -4
- package/doc/en/WebRTC.md +1364 -0
- package/doc/en/examples/Meeting.md +573 -0
- package/doc/en/examples/README.md +1 -0
- package/doc/zh/HTTP.md +3 -2
- package/doc/zh/Overview.md +2 -1
- package/doc/zh/ServiceCalls.md +5 -2
- package/doc/zh/WebRTC.md +1049 -0
- package/doc/zh/examples/Meeting.md +524 -0
- package/doc/zh/examples/README.md +1 -0
- package/package.json +14 -14
|
@@ -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/HTTP.md
CHANGED
|
@@ -109,7 +109,7 @@ HTTP/1.1 400 Bad Request
|
|
|
109
109
|
| `port` | `80` | 端口;`0` 表示任选一个空闲端口。端口已被占用时,应用启动失败 |
|
|
110
110
|
| `host` | 所有网络接口 | 监听的地址,比如 `'127.0.0.1'` 表示只接受本机的请求 |
|
|
111
111
|
| `name` | | 适配器的名字,用来区分多个适配器 |
|
|
112
|
-
| `body` | `{limit: '1mb'}` |
|
|
112
|
+
| `body` | `{limit: '1mb'}` | 请求体的大小上限(`'5mb'`,或者字节数);`text`:按文本读取的内容类型(第 4 节);`false` 表示不解析请求体 |
|
|
113
113
|
| `server` | | Node.js HTTP 服务器的选项:`keepAliveTimeout`、`headersTimeout`、`requestTimeout`…… |
|
|
114
114
|
| `app` | 新建的应用 | 一个已有的 Express 应用,动作会加到它上面 |
|
|
115
115
|
| `input` | 查询参数和请求体 | 从请求构造输入(第 4 节) |
|
|
@@ -120,7 +120,7 @@ HTTP/1.1 400 Bad Request
|
|
|
120
120
|
| `stop` | | 适配器停止、服务器关闭之后调用 |
|
|
121
121
|
| `heartbeat` | `15000` | 每隔多久发送一条注释,让事件流保持连接,单位毫秒(第 5 节) |
|
|
122
122
|
|
|
123
|
-
`fastifyHTTPEntrypoint()` 的选项相同,只是没有 `
|
|
123
|
+
`fastifyHTTPEntrypoint()` 的选项相同,只是没有 `server` 和 `app`,取而代之的是 `fastify`:一个 Fastify 实例,或者要创建的实例的选项(`bodyLimit`,默认 1 MiB;`trustProxy`;`connectionTimeout`、`keepAliveTimeout`、`requestTimeout`;`logger`……)。它的 `body` 选项只有 `text`,大小上限是实例的 `bodyLimit`。
|
|
124
124
|
|
|
125
125
|
两者的不同之处:
|
|
126
126
|
|
|
@@ -191,6 +191,7 @@ POST /orders/7?id=1¬e=a 请求体为 {"id": 5, "note": "b"} → {id: '7
|
|
|
191
191
|
- **查询参数。** 出现多次的键是一个数组(`?tag=a&tag=b` → `tag: ['a', 'b']`)。在 Express 5 和 Fastify 中,方括号不会形成嵌套:`?filter[status]=open` 得到的键是 `'filter[status]'`。
|
|
192
192
|
- **JSON 请求体。** 数组无法作为输入使用(Express 把它的元素放在以序号为名的键下,Fastify 忽略它):列表要放在一个对象里发送(`{"items": [...]}`)。
|
|
193
193
|
- **表单。** Express 会解析 URL 编码的请求体,方括号会形成嵌套(`a[b]=1` → `a: {b: '1'}`);Fastify 以 415 拒绝它们。两者都不解析 `multipart/form-data`,也就是文件上传(第 7 节)。
|
|
194
|
+
- **其他文本。** `body.text` 里列出的内容类型(`['application/sdp']`,或者用 `'text/*'` 表示一整类)按文本读取,放在输入的 `body` 属性里交给动作。Fastify 不解析的其他类型以 415 拒绝。
|
|
194
195
|
- **上限。** 请求体超过上限时以 413 拒绝,JSON 格式错误时以 400 拒绝。
|
|
195
196
|
|
|
196
197
|
`input` 选项从请求构造输入。比如,把客户端的地址和语言交给动作(只有声明了 `ip` 和 `language` 的 DTO 才会保留它们):
|
package/doc/zh/Overview.md
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
| `lakutata/security`、`lakutata/provider/passwordHash` | 哈希、加解密、签名、`PasswordHash` 提供者 |
|
|
36
36
|
| `lakutata/utils` | 辅助函数 |
|
|
37
37
|
|
|
38
|
-
不在本包中的:`@lakutata/cli`(`lakutata` 命令:创建项目、执行迁移、打包应用,见 [Command](Command.md))、`@lakutata/grpc`(gRPC 客户端、服务端和服务传输)、`@lakutata/nats`(NATS 组件和服务传输)、`@lakutata/dbus`(D-Bus
|
|
38
|
+
不在本包中的:`@lakutata/cli`(`lakutata` 命令:创建项目、执行迁移、打包应用,见 [Command](Command.md))、`@lakutata/grpc`(gRPC 客户端、服务端和服务传输)、`@lakutata/nats`(NATS 组件和服务传输)、`@lakutata/dbus`(D-Bus)、`@lakutata/mcp`(Model Context Protocol)和 `@lakutata/webrtc`(WebRTC:信令、TURN 服务器、媒体服务器、对等连接)。
|
|
39
39
|
|
|
40
40
|
## 指南
|
|
41
41
|
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
| [Security](Security.md) | 哈希、加解密、签名、密码 |
|
|
79
79
|
| [DBus](DBus.md) | D-Bus 服务和客户端(`@lakutata/dbus`):方法、对象、属性、信号 |
|
|
80
80
|
| [MCP](MCP.md) | 模型上下文协议(`@lakutata/mcp`)的工具、资源和提示词,基于 stdio 和 HTTP |
|
|
81
|
+
| [WebRTC](WebRTC.md) | 点对点连接和媒体服务器(`@lakutata/webrtc`):信令、TURN 服务器、浏览器和应用、通过数据通道调用应用的动作、WHIP 和 WHEP |
|
|
81
82
|
| [Command](Command.md) | `lakutata` 命令:创建项目、迁移、翻译、环境变量 |
|
|
82
83
|
| [Workspaces](Workspaces.md) | 由多个包组成的项目:运行各个包的脚本、统一版本号、添加包 |
|
|
83
84
|
| [NativeAddons](NativeAddons.md) | 用 C++ 或 Rust 编写的原生插件:创建、链接库、发布 |
|
package/doc/zh/ServiceCalls.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lakutata 服务调用
|
|
2
2
|
|
|
3
|
-
服务动作(`@ServiceAction`)通过消息而不是 URL 来调用:消息是一个对象,其中一部分键用来选择动作,称为**模式**,其余的键就是输入。同一组动作可以同时通过多种传输方式提供:本指南介绍的普通 HTTP、Socket.IO、JSON-RPC 2.0(基于 HTTP、WebSocket 或标准输入输出)和 Electron 的 IPC,以及在各自指南中介绍的 gRPC 和
|
|
3
|
+
服务动作(`@ServiceAction`)通过消息而不是 URL 来调用:消息是一个对象,其中一部分键用来选择动作,称为**模式**,其余的键就是输入。同一组动作可以同时通过多种传输方式提供:本指南介绍的普通 HTTP、Socket.IO、JSON-RPC 2.0(基于 HTTP、WebSocket 或标准输入输出)和 Electron 的 IPC,以及在各自指南中介绍的 gRPC、NATS 和 WebRTC 的数据通道。控制器及其动作见 [Controllers](Controllers.md),入口及其适配器见 [Entrypoint](Entrypoint.md)。
|
|
4
4
|
|
|
5
5
|
## 1. 第一个服务
|
|
6
6
|
|
|
@@ -167,6 +167,7 @@ export async function call<T>(message: Record<string, unknown>): Promise<T> {
|
|
|
167
167
|
| `electronIPCServiceEntrypoint()` | `lakutata/adapter/electron` | (Electron) | `ipcRenderer.invoke()` | IPC 消息 |
|
|
168
168
|
| `grpcServiceEntrypoint()` | `@lakutata/grpc/service` | `@lakutata/grpc` | 见 [GRPC](GRPC.md) | gRPC 流 |
|
|
169
169
|
| `natsServiceEntrypoint()` | `@lakutata/nats` | `@lakutata/nats` | 见 [NATS](NATS.md) | NATS 消息 |
|
|
170
|
+
| `webrtcServiceEntrypoint()` | `@lakutata/webrtc` | `@lakutata/webrtc` | 数据通道上的 JSON-RPC 2.0 请求,见 [WebRTC](WebRTC.md) | 通知 |
|
|
170
171
|
|
|
171
172
|
每个适配器还接受 `name`,用来区分多个适配器,以及 `middlewares`,在入口的中间件之后执行(见 [Middlewares](Middlewares.md))。
|
|
172
173
|
|
|
@@ -418,6 +419,7 @@ contextBridge.exposeInMainWorld('service', {
|
|
|
418
419
|
| 基于 WebSocket 的 JSON-RPC | 连接的 `id` 请求头或 `id` 查询参数,否则为随机 id |
|
|
419
420
|
| 基于 stdio 的 JSON-RPC | `'stdio'` |
|
|
420
421
|
| Electron | 页面在订阅或调用时给出的 id |
|
|
422
|
+
| WebRTC | 调用方在房间里的成员 id |
|
|
421
423
|
|
|
422
424
|
动作从 `this.context.clientId` 读取它的客户端 id。`entrypoint` 组件负责推送事件:
|
|
423
425
|
|
|
@@ -451,9 +453,10 @@ class TaskController extends Controller {
|
|
|
451
453
|
| Socket.IO | `socket.on('progress', (percent, label) => …)` |
|
|
452
454
|
| JSON-RPC(WebSocket、stdio) | `{"jsonrpc": "2.0", "method": "progress", "params": [50, "half"]}` |
|
|
453
455
|
| Electron | 通过上面预加载脚本中的 `subscribe` 收到 `('progress', [50, 'half'])` |
|
|
456
|
+
| WebRTC | `PeerServiceProxy` 的 `proxy.on('progress', (percent, label) => …)` |
|
|
454
457
|
|
|
455
458
|
## 6. 停止与离开
|
|
456
459
|
|
|
457
460
|
- 应用停止时,新的请求以 `EntrypointStoppedException`(`E_ENTRYPOINT_STOPPED`;JSON-RPC 中错误码为 `-32001`)失败,正在处理的请求有入口的 `shutdownTimeout` 来完成(见 [Entrypoint](Entrypoint.md) 第 4 节)。
|
|
458
|
-
- 客户端在收到响应之前离开时(socket 断开、窗口关闭、HTTP
|
|
461
|
+
- 客户端在收到响应之前离开时(socket 断开、窗口关闭、HTTP 连接关闭、数据通道关闭),它的请求的 `this.signal` 会被中止。
|
|
459
462
|
- 传输方式不限制调用的时间:客户端自己设置超时,比如上文的 `socket.timeout(5000)`。
|