stinky-moq-js 0.1.18 → 0.1.20

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 CHANGED
@@ -22,84 +22,109 @@ npm install stinky-moq-js
22
22
  ## Quick Start
23
23
 
24
24
  ```typescript
25
- import { MoQSession, SessionState } from 'stinky-moq-js';
25
+ import { MoqSessionBroadcaster, MoqSessionSubscriber } from 'stinky-moq-js';
26
26
 
27
- // Create a session
28
- const session = new MoQSession({
29
- relayUrl: 'wss://relay.quic.video',
30
- namespace: 'my-app',
31
- reconnection: {
32
- delay: 1000 // Reconnect delay in ms
33
- }
27
+ // Create a broadcaster
28
+ const broadcaster = new MoqSessionBroadcaster(
29
+ {
30
+ relayUrl: 'https://relay.quic.video',
31
+ namespace: 'my-app/user-123'
32
+ },
33
+ [
34
+ { trackName: 'video', priority: 10, type: 'video' },
35
+ { trackName: 'audio', priority: 5, type: 'audio' }
36
+ ]
37
+ );
38
+
39
+ // Create a subscriber
40
+ const subscriber = new MoqSessionSubscriber(
41
+ {
42
+ relayUrl: 'https://relay.quic.video',
43
+ namespace: 'my-app/user-456'
44
+ },
45
+ [
46
+ { trackName: 'video', priority: 10 },
47
+ { trackName: 'audio', priority: 5 }
48
+ ]
49
+ );
50
+
51
+ // Listen to events
52
+ broadcaster.on('stateChange', (status) => {
53
+ console.log('Broadcaster state:', status.state);
34
54
  });
35
55
 
36
- // Listen to session events
37
- session.on('stateChange', (status) => {
38
- console.log('Session state:', status.state);
56
+ subscriber.on('data', (trackName, data) => {
57
+ console.log(`Received ${data.length} bytes from ${trackName}`);
39
58
  });
40
59
 
41
- session.on('error', (error) => {
42
- console.error('Session error:', error);
43
- });
60
+ // Connect
61
+ await broadcaster.connect();
62
+ await subscriber.connect();
44
63
 
45
- // Listen for broadcast discoveries
46
- session.on('broadcastAnnounced', (announcement) => {
47
- console.log('Found broadcast:', announcement.path);
48
- });
49
-
50
- // Connect to the relay
51
- await session.connect();
64
+ // Send data
65
+ broadcaster.send('video', videoData, true);
52
66
  ```
53
67
 
54
68
  ## Working with Broadcasts
55
69
 
56
70
  ```typescript
57
- // Define what you want to broadcast
58
- const broadcasts = [
59
- { trackName: 'my-video-track', priority: 1 },
60
- { trackName: 'my-audio-track', priority: 2 }
61
- ];
62
-
63
- // Apply the configuration
64
- session.updateBroadcasts(broadcasts);
65
-
66
- // Listen to broadcast events through the session
67
- session.on('broadcastStateChange', (trackName, status) => {
71
+ // Create a broadcaster with track configurations
72
+ const broadcaster = new MoqSessionBroadcaster(
73
+ {
74
+ relayUrl: 'https://relay.quic.video',
75
+ namespace: 'my-app/user-123'
76
+ },
77
+ [
78
+ { trackName: 'video', priority: 10, type: 'video' },
79
+ { trackName: 'audio', priority: 5, type: 'audio' }
80
+ ]
81
+ );
82
+
83
+ // Listen to broadcast events
84
+ broadcaster.on('broadcastStateChange', (trackName, status) => {
68
85
  console.log(`Broadcast ${trackName} state:`, status.state);
69
86
  console.log('Bytes sent:', status.bytesSent);
70
87
  });
71
88
 
72
- session.on('broadcastError', (trackName, error) => {
89
+ broadcaster.on('broadcastError', (trackName, error) => {
73
90
  console.error(`Broadcast error on ${trackName}:`, error.message);
74
91
  });
75
92
 
76
- // Send binary data through the session
77
- const data = new Uint8Array([1, 2, 3, 4, 5]);
78
- await session.send('my-video-track', data);
79
- ```
80
-
81
- ## Working with Subscriptions
82
-
83
- ```typescript
84
- // Define what you want to subscribe to
85
- const subscriptions = [
86
- { trackName: 'remote-video-track', priority: 1 },
87
- { trackName: 'remote-audio-track', priority: 2, retry: { delay: 2000 } }
88
- ];
89
-
90
- // Apply the configuration
91
- session.updateSubscriptions(subscriptions);
93
+ // Connect and send data
94
+ await broadcaster.connect();
92
95
 
93
- // Listen to incoming data through the session
94
- session.on('data', (trackName, data) => {
96
+ // Send binary data (newGroup: true for keyframes/new groups)
97
+ const videoData = new Uint8Array([1, 2, 3, 4, 5]);
98
+ broCreate a subscriber with track configurations
99
+ const subscriber = new MoqSessionSubscriber(
100
+ {
101
+ relayUrl: 'https://relay.quic.video',
102
+ namespace: 'my-app/user-456'
103
+ },
104
+ [
105
+ { trackName: 'video', priority: 10 },
106
+ { trackName: 'audio', priority: 5, retry: { delay: 2000 } }
107
+ ]
108
+ );
109
+
110
+ // Listen to incoming data
111
+ subscriber.on('data', (trackName, data) => {
95
112
  console.log('Received data from:', trackName);
96
113
  console.log('Data length:', data.length);
97
114
  });
98
115
 
99
- // Listen to subscription status through the session
100
- session.on('subscriptionStateChange', (trackName, status) => {
116
+ // Listen to subscription status
117
+ subscriber.on('subscriptionStateChange', (trackName, status) => {
101
118
  console.log(`Subscription ${trackName} state:`, status.state);
102
119
  console.log('Bytes received:', status.bytesReceived);
120
+ });
121
+
122
+ subscriber.on('subscriptionError', (trackName, error) => {
123
+ console.error(`Subscription error on ${trackName}:`, error.message);
124
+ });
125
+
126
+ // Connect to start receiving data
127
+ await subscriber.connect( console.log('Bytes received:', status.bytesReceived);
103
128
  console.log('Retry attempts:', status.retryAttempts);
104
129
  });
105
130
 
@@ -120,6 +145,8 @@ interface MoQSessionConfig {
120
145
  delay?: number; // Delay between attempts in ms (default: 1000)
121
146
  };
122
147
  connectionTimeout?: number; // Connection timeout in ms (default: 10000)
148
+ discoveryOnly?: boolean; // (Subscriber only) Only discover broadcasts,
149
+ // don't auto-subscribe (default: false)
123
150
  }
124
151
  ```
125
152
 
@@ -129,6 +156,7 @@ interface MoQSessionConfig {
129
156
  interface BroadcastConfig {
130
157
  trackName: string; // Track name for the broadcast
131
158
  priority?: number; // Priority for the track (default: 0)
159
+ type: 'video' | 'audio' | 'data'; // Type of track
132
160
  }
133
161
  ```
134
162
 
@@ -149,7 +177,7 @@ interface SubscriptionConfig {
149
177
  ### Session Status
150
178
 
151
179
  ```typescript
152
- const status = session.status;
180
+ const status = broadcaster.status; // or subscriber.status
153
181
  console.log({
154
182
  state: status.state, // SessionState enum
155
183
  reconnectAttempts: status.reconnectAttempts,
@@ -161,30 +189,29 @@ console.log({
161
189
  ### Broadcast Status
162
190
 
163
191
  ```typescript
164
- const status = session.getBroadcastStatus('my-track');
165
- if (status) {
192
+ // Broadcasters track individual broadcast status
193
+ broadcaster.on('broadcastStateChange', (trackName, status) => {
166
194
  console.log({
167
195
  state: status.state, // BroadcastState enum
168
196
  trackName: status.trackName,
169
197
  bytesSent: status.bytesSent,
170
198
  lastError: status.lastError
171
199
  });
172
- }
200
+ });
173
201
  ```
174
202
 
175
203
  ### Subscription Status
176
204
 
177
205
  ```typescript
178
- const status = session.getSubscriptionStatus('remote-track');
179
- if (status) {
206
+ // Subscribers track individual subscription status
207
+ subscriber.on('subscriptionStateChange', (trackName, status) => {
180
208
  console.log({
181
209
  state: status.state, // SubscriptionState enum
182
210
  trackName: status.trackName,
183
211
  bytesReceived: status.bytesReceived,
184
- retryAttempts: status.retryAttempts,
185
212
  lastError: status.lastError
186
213
  });
187
- }
214
+ });
188
215
  ```
189
216
 
190
217
  ## States
@@ -203,27 +230,26 @@ if (status) {
203
230
 
204
231
  ### Subscription States
205
232
  - `PENDING`: Waiting to subscribe
206
- - `SUBSCRIBED`: Successfully subscribed
207
- - `RETRYING`: Retrying after failure
208
- - `FAILED`: Subscription failed completely
209
-
233
+ - `IDLE`: Not subscribed
234
+ - `SUBSCRIBED`: Successfully subscribed and receiving data
235
+ - `FAILED`: Subscription failed
210
236
  ## Error Handling
211
237
 
212
238
  ```typescript
213
239
  // Session errors
214
240
  session.on('error', (error) => {
215
241
  console.error('Session error:', error.message);
242
+ broadcaster.on('error', (error) => {
243
+ console.error('Session error:', error.message);
216
244
  });
217
245
 
218
246
  // Broadcast errors (per track)
219
- session.on('broadcastError', (trackName, error) => {
247
+ broadcaster.on('broadcastError', (trackName, error) => {
220
248
  console.error(`Broadcast error on ${trackName}:`, error.message);
221
249
  });
222
250
 
223
251
  // Subscription errors (per track)
224
- session.on('subscriptionError', (trackName, error) => {
225
- console.error(`Subscription error on ${trackName}:`, error.message);
226
- });
252
+ subscriber
227
253
  ```
228
254
 
229
255
  ## Cleanup
@@ -231,145 +257,191 @@ session.on('subscriptionError', (trackName, error) => {
231
257
  ```typescript
232
258
  // Remove all broadcasts and subscriptions
233
259
  session.updateBroadcasts([]); // Empty array removes all
234
- session.updateSubscriptions([]);
260
+ sesDisconnect and cleanup
261
+ broadcaster.disconnect();
262
+ subscriber.dispose
263
+ ## Broadcast Discovery
235
264
 
236
- // Or disconnect to cleanup everything
237
- await session.disconnect();
238
- ```
265
+ The library automatically discovers available broadcasts via MoQ announcements. This is essential for building multi-user applications like video chat rooms.
239
266
 
240
- ## Broadcast Discovery
267
+ ### Discovery-Only Mode (Room-Level Discovery)
241
268
 
242
- The library automatically discovers available broadcasts via MoQ announcements:
269
+ Use `discoveryOnly: true` to discover broadcasts without automatically subscribing. This is perfect for room-level discovery where you want to find all users in a room and then selectively subscribe to them:
243
270
 
244
271
  ```typescript
245
- // Listen for broadcast announcements
246
- session.on('broadcastAnnounced', (announcement) => {
247
- console.log('Found broadcast:', announcement.path);
272
+ // Create a discovery subscriber for a room
273
+ const roomDiscovery = new MoqSessionSubscriber(
274
+ {
275
+ relayUrl: 'https://relay.quic.video',
276
+ namespace: 'lobby', // Room prefix
277
+ discoveryOnly: true // Only discover, don't auto-subscribe
278
+ },
279
+ [] // No subscriptions needed for discovery
280
+ );
281
+
282
+ // Listen for user announcements
283
+ roomDiscovery.on('broadcastAnnounced', (announcement) => {
284
+ console.log('User broadcast:', announcement.path); // e.g., "lobby/user/abc123"
248
285
  console.log('Is active:', announcement.active);
249
286
 
250
- // Subscribe to discovered broadcast
251
287
  if (announcement.active) {
252
- session.updateSubscriptions([
253
- { trackName: announcement.path }
254
- ]);
288
+ // Manually create a subscription to this user's tracks
289
+ const userSubscriber = new MoqSessionSubscriber(
290
+ {
291
+ relayUrl: 'https://relay.quic.video',
292
+ namespace: announcement.path, // e.g., "lobby/user/abc123"
293
+ discoveryOnly: false
294
+ },
295
+ [
296
+ { trackName: 'video', priority: 10 },
297
+ { trackName: 'audio', priority: 5 }
298
+ ]
299
+ );
300
+
301
+ await userSubscriber.connect();
302
+ } else {
303
+ // User left - clean up their subscription
304
+ console.log('User left:', announcement.path);
255
305
  }
256
306
  });
257
- ```
258
307
 
259
- ## Dynamic Track Management
308
+ await roomDiscovery.connect();
309
+ ```
260
310
 
261
- The library provides a declarative API for managing broadcasts and subscriptions. Simply define the desired state and the library handles all add/remove/update operations automatically:
311
+ ### Multi-User Video Chat Room Example
262
312
 
263
- ### Managing Broadcasts Dynamically
313
+ Here's a complete example of building a video chat room with discovery:
264
314
 
265
315
  ```typescript
266
- // Define the desired state of broadcasts
267
- const desiredBroadcasts = [
268
- { trackName: 'video', priority: 1 },
269
- { trackName: 'audio', priority: 2 },
270
- { trackName: 'metadata', priority: 0 }
271
- ];
272
-
273
- // Apply the desired state - library handles add/remove/update automatically
274
- session.updateBroadcasts(desiredBroadcasts);
275
-
276
- // Later, change the desired state
277
- const newBroadcasts = [
278
- { trackName: 'video', priority: 1 }, // Kept
279
- { trackName: 'chat', priority: 0 } // Audio and metadata removed, chat added
280
- ];
281
-
282
- session.updateBroadcasts(newBroadcasts);
283
- ```
316
+ // Namespace structure: {room}/user/{userId}
317
+ const roomName = 'lobby';
318
+ const myUserId = 'user-123';
284
319
 
285
- ### Managing Subscriptions Dynamically
320
+ // 1. Create a broadcaster for your own tracks
321
+ const myBroadcaster = new MoqSessionBroadcaster(
322
+ {
323
+ relayUrl: 'https://relay.quic.video',
324
+ namespace: `${roomName}/user/${myUserId}`
325
+ },
326
+ [
327
+ { trackName: 'video', priority: 10, type: 'video' },
328
+ { trackName: 'audio', priority: 5, type: 'audio' }
329
+ ]
330
+ );
331
+
332
+ await myBroadcaster.connect();
333
+
334
+ // 2. Create a discovery subscriber to find other users
335
+ const roomDiscovery = new MoqSessionSubscriber(
336
+ {
337
+ relayUrl: 'https://relay.quic.video',
338
+ namespace: roomName,
339
+ discoveryOnly: true
340
+ },
341
+ []
342
+ );
343
+
344
+ // Track active users
345
+ const activeUsers = new Map();
346
+
347
+ roomDiscovery.on('broadcastAnnounced', async (announcement) => {
348
+ const userPath = announcement.path; // e.g., "lobby/user/xyz789"
349
+
350
+ // Skip our own broadcast
351
+ if (userPath === `${roomName}/user/${myUserId}`) return;
352
+
353
+ if (announcement.active) {
354
+ // New user joined - subscribe to their tracks
355
+ const userSubscriber = new MoqSessionSubscriber(
356
+ {
357
+ relayUrl: 'https://relay.quic.video',
358
+ namespace: userPath
359
+ },
360
+ [
361
+ { trackName: 'video', priority: 10 },
362
+ { trackName: 'audio', priority: 5 }
363
+ ]
364
+ );
365
+
366
+ userSubscriber.on('data', (trackName, data) => {
367
+ console.log(`Received ${trackName} from ${userPath}:`, data.length, 'bytes');
368
+ // Process video/audio data
369
+ });
370
+
371
+ await userSubscriber.connect();
372
+ activeUsers.set(userPath, userSubscriber);
373
+
374
+ console.log(`User joined: ${userPath}`);
375
+ } else {
376
+ // User left - clean up
377
+ const userSubscriber = activeUsers.get(userPath);
378
+ if (userSubscriber) {
379
+ userSubscriber.dispose();
380
+ activeUsers.delete(userPath);
381
+ console.log(`User left: ${userPath}`);
382
+ }
383
+ }
384
+ });
286
385
 
287
- ```typescript
288
- // Define what you want to subscribe to
289
- const desiredSubscriptions = [
290
- { trackName: 'remote-video', priority: 1 },
291
- { trackName: 'remote-audio', priority: 2, retry: { delay: 1000 } },
292
- { trackName: 'remote-chat', priority: 0 }
293
- ];
294
-
295
- // Apply the desired state
296
- session.updateSubscriptions(desiredSubscriptions);
297
-
298
- // Update subscriptions based on user preferences
299
- const userPreferences = {
300
- videoEnabled: true,
301
- audioEnabled: false,
302
- chatEnabled: true
303
- };
304
-
305
- const activeSubscriptions = [
306
- ...(userPreferences.videoEnabled ? [{ trackName: 'remote-video', priority: 1 }] : []),
307
- ...(userPreferences.audioEnabled ? [{ trackName: 'remote-audio', priority: 2 }] : []),
308
- ...(userPreferences.chatEnabled ? [{ trackName: 'remote-chat', priority: 0 }] : [])
309
- ];
310
-
311
- session.updateSubscriptions(activeSubscriptions);
386
+ await roomDiscovery.connect();
387
+
388
+ // 3. Broadcast your video/audio
389
+ myBroadcaster.send('video', videoData, true);
390
+ myBroadcaster.send('audio', audioData, false);
312
391
  ```
313
392
 
314
- ### Benefits of Declarative API
393
+ ### How Discovery Works
315
394
 
316
- - **Cleaner State Management**: Just declare what you want, not how to get there
317
- - **Automatic Cleanup**: Tracks not in the desired state are automatically removed
318
- - **Idempotent**: Calling `updateBroadcasts`/`updateSubscriptions` multiple times with the same config is safe
319
- - **Less Error-Prone**: No need to manually track what's been added/removed
320
- - **Reactive Updates**: Easy to update based on user preferences or external state changes
395
+ The discovery mechanism uses the MoQ `announced()` method under the hood:
321
396
 
322
- ## Advanced Usage
397
+ - **Discovery uses `announced(prefix)`**: Listens for all broadcasts matching a prefix (e.g., `lobby`)
398
+ - **Subscriptions use `consume(path)`**: Subscribes to tracks from a specific broadcast path (e.g., `lobby/user/abc123`)
399
+ - **These are separate operations**: Discovery doesn't require consuming, allowing efficient room-level discovery
400
+ - **Active/Inactive events**: Announcements include an `active` flag - `true` when a broadcast starts, `false` when it ends
401
+
402
+ ## Dynamic Track Management
323
403
 
324
- ### Managing Multiple Tracks
404
+ You can add or remove subscriptions dynamically:
325
405
 
326
406
  ```typescript
327
- // Define multiple tracks at once
328
- const broadcasts = [
329
- { trackName: 'video', priority: 1 },
330
- { trackName: 'audio', priority: 2 },
331
- { trackName: 'screen', priority: 3 }
332
- ];
333
-
334
- const subscriptions = [
335
- { trackName: 'remote-video', priority: 1 },
336
- { trackName: 'remote-audio', priority: 2 },
337
- { trackName: 'remote-screen', priority: 3 }
338
- ];
339
-
340
- session.updateBroadcasts(broadcasts);
341
- session.updateSubscriptions(subscriptions);
342
-
343
- // Get all active track names
344
- const allBroadcasts = session.getAllBroadcastTrackNames();
345
- const allSubscriptions = session.getAllSubscriptionTrackNames();
346
- console.log('Broadcasting:', allBroadcasts);
347
- console.log('Subscribed to:', allSubscriptions);
407
+ // Add a subscription
408
+ subscriber.addSubscription({ trackName: 'chat', priority: 0 });
409
+
410
+ // Remove a subscription
411
+ subscriber.removeSubscription('chat');
412
+
413
+ // Update all subscriptions at once
414
+ subscriber.updateSubscriptions([
415
+ { trackName: 'video', priority: 10 },
416
+ { trackName: 'audio', priority: 5 }
417
+ ]);
348
418
  ```
349
419
 
420
+ ## Advanced Usage
421
+
350
422
  ### Infinite Retry Logic
351
423
 
352
424
  Both reconnection and subscription retries run infinitely with configurable delays:
353
425
 
354
426
  ```typescript
355
427
  // Session with custom reconnection delay
356
- const session = new MoQSession({
357
- relayUrl: 'wss://relay.quic.video',
358
- namespace: 'persistent-app',
359
- reconnection: {
360
- delay: 5000 // Wait 5 seconds between reconnection attempts
361
- }
362
- });
363
-
364
- // Subscription with custom retry delay
365
- session.updateSubscriptions([
428
+ const subscriber = new MoqSessionSubscriber(
366
429
  {
367
- trackName: 'important-track',
368
- retry: {
369
- delay: 3000 // Wait 3 seconds between retry attempts
430
+ relayUrl: 'https://relay.quic.video',
431
+ namespace: 'persistent-app',
432
+ reconnection: {
433
+ delay: 5000 // Wait 5 seconds between reconnection attempts
370
434
  }
371
- }
372
- ]);
435
+ },
436
+ [
437
+ {
438
+ trackName: 'important-track',
439
+ retry: {
440
+ delay: 3000 // Wait 3 seconds between retry attempts
441
+ }
442
+ }
443
+ ]
444
+ );
373
445
  ```
374
446
 
375
447
  ### Bidirectional Communication
@@ -400,47 +472,72 @@ sessionA.on('data', (trackName, data) => {
400
472
  const sessionB = new MoQSession({
401
473
  relayUrl: 'wss://relay.quic.video',
402
474
  namespace: 'app-b'
403
- });
404
-
405
- await sessionB.connect();
475
+ });users:
406
476
 
407
- // Set up tracks
408
- sessionB.updateSubscriptions([{ trackName: 'data-from-a' }]);
409
- sessionB.updateBroadcasts([{ trackName: 'data-from-b' }]);
477
+ ```typescript
478
+ // User A: Broadcaster and Subscriber
479
+ const broadcasterA = new MoqSessionBroadcaster(
480
+ {
481
+ relayUrl: 'https://relay.quic.video',
482
+ namespace: 'app/user-a'
483
+ },
484
+ [
485
+ { trackName: 'video', priority: 10, type: 'video' },
486
+ { trackName: 'audio', priority: 5, type: 'audio' }
487
+ ]
488
+ );
489
+
490
+ const subscriberA = new MoqSessionSubscriber(
491
+ {
492
+ relayUrl: 'https://relay.quic.video',
493
+ namespace: 'app/user-b'
494
+ },
495
+ [
496
+ { trackName: 'video', priority: 10 },
497
+ { trackName: 'audio', priority: 5 }
498
+ ]
499
+ );
500
+
501
+ // User B: Broadcaster and Subscriber
502
+ const broadcasterB = new MoqSessionBroadcaster(
503
+ {
504
+ relayUrl: 'https://relay.quic.video',
505
+ namespace: 'app/user-b'
506
+ },
507
+ [
508
+ { trackName: 'video', priority: 10, type: 'video' },
509
+ { trackName: 'audio', priority: 5, type: 'audio' }
510
+ ]
511
+ );
512
+
513
+ const subscriberB = new MoqSessionSubscriber(
514
+ {
515
+ relayUrl: 'https://relay.quic.video',
516
+ namespace: 'app/user-a'
517
+ },
518
+ [
519
+ { trackName: 'video', priority: 10 },
520
+ { trackName: 'audio', priority: 5 }
521
+ ]
522
+ );
523
+
524
+ // Connect all sessions
525
+ await Promise.all([
526
+ broadcasterA.connect(),
527
+ subscriberA.connect(),
528
+ broadcasterB.connect(),
529
+ subscriberB.connect()
530
+ ]);
410
531
 
411
- // Listen for data from App A
412
- sessionB.on('data', (trackName, data) => {
413
- if (trackName === 'data-from-a') {
414
- console.log('App B received data from App A:', data);
415
- }
532
+ // Listen for incoming data
533
+ subscriberA.on('data', (trackName, data) => {
534
+ console.log('User A received:', trackName, data.length);
416
535
  });
417
536
 
418
- // Send data from App A to App B
419
- await sessionA.send('data-from-a', new TextEncoder().encode('Hello from A'));
420
-
421
- // Send data from App B to App A
422
- await sessionB.send('data-from-b', new TextEncoder().encode('Hello from B'));
423
- ```
424
- ```
425
-
426
- ## Dependencies
427
-
428
- This library is built on top of:
429
- - [@kixelated/moq](https://github.com/kixelated/moq-js) v0.9.1 - Core MoQ implementation
430
- - Uses the `moq-lite-draft-04` specification (not the standard IETF MoQ draft)
431
- - WebTransport only (no WebSocket fallback)
432
-
433
- ## Browser Support
434
-
435
- Requires modern browsers with WebTransport support:
436
- - Chrome 97+
437
- - Edge 97+
438
- - Safari 15.4+ (with WebTransport enabled)
439
-
440
- ## License
441
-
442
- MIT
443
-
444
- ## Contributing
537
+ subscriberB.on('data', (trackName, data) => {
538
+ console.log('User B received:', trackName, data.length);
539
+ });
445
540
 
446
- Contributions are welcome! Please feel free to submit a Pull Request.
541
+ // Send data
542
+ broadcasterA.send('video', videoDataA, true);
543
+ broadcasterB.send('video', videoDataB, true