stinky-moq-js
v0.1.22
Published
A TypeScript library for Media over Quic (MoQ) with session management and simple API
Downloads
543
Maintainers
Readme
Stinky MoQ JS
A TypeScript library for Media over Quic (MoQ) with session management, automatic reconnection, and simple API for browser applications. Built on top of @kixelated/moq using the moq-lite-draft-04 specification.
Features
- 🔄 Infinite Reconnection: Persistent reconnection with configurable delays
- 🔍 Broadcast Discovery: Automatic discovery of available broadcasts via announcements
- 📡 Session Management: Handle MoQ relay connections with namespace support
- 📤 Broadcasting: Send binary data to MoQ tracks with status reporting
- 📥 Subscriptions: Receive binary data from MoQ tracks with automatic retry
- 🎯 Type Safe: Full TypeScript support with comprehensive type definitions
- 🌐 WebTransport Only: Uses modern WebTransport for optimal performance
- 🔁 Bidirectional Communication: Full pub/sub support for two-way data exchange
Installation
npm install stinky-moq-jsQuick Start
import { MoqSessionBroadcaster, MoqSessionSubscriber } from 'stinky-moq-js';
// Create a broadcaster
const broadcaster = new MoqSessionBroadcaster(
{
relayUrl: 'https://relay.quic.video',
namespace: 'my-app/user-123'
},
[
{ trackName: 'video', priority: 10, type: 'video' },
{ trackName: 'audio', priority: 5, type: 'audio' }
]
);
// Create a subscriber
const subscriber = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'my-app/user-456'
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]
);
// Listen to events
broadcaster.on('stateChange', (status) => {
console.log('Broadcaster state:', status.state);
});
subscriber.on('data', (trackName, data) => {
console.log(`Received ${data.length} bytes from ${trackName}`);
});
// Connect
await broadcaster.connect();
await subscriber.connect();
// Send data
broadcaster.send('video', videoData, true);Working with Broadcasts
// Create a broadcaster with track configurations
const broadcaster = new MoqSessionBroadcaster(
{
relayUrl: 'https://relay.quic.video',
namespace: 'my-app/user-123'
},
[
{ trackName: 'video', priority: 10, type: 'video' },
{ trackName: 'audio', priority: 5, type: 'audio' }
]
);
// Listen to broadcast events
broadcaster.on('broadcastStateChange', (trackName, status) => {
console.log(`Broadcast ${trackName} state:`, status.state);
console.log('Bytes sent:', status.bytesSent);
});
broadcaster.on('broadcastError', (trackName, error) => {
console.error(`Broadcast error on ${trackName}:`, error.message);
});
// Connect and send data
await broadcaster.connect();
// Send binary data (newGroup: true for keyframes/new groups)
const videoData = new Uint8Array([1, 2, 3, 4, 5]);
broCreate a subscriber with track configurations
const subscriber = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'my-app/user-456'
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5, retry: { delay: 2000 } }
]
);
// Listen to incoming data
subscriber.on('data', (trackName, data) => {
console.log('Received data from:', trackName);
console.log('Data length:', data.length);
});
// Listen to subscription status
subscriber.on('subscriptionStateChange', (trackName, status) => {
console.log(`Subscription ${trackName} state:`, status.state);
console.log('Bytes received:', status.bytesReceived);
});
subscriber.on('subscriptionError', (trackName, error) => {
console.error(`Subscription error on ${trackName}:`, error.message);
});
// Connect to start receiving data
await subscriber.connect( console.log('Bytes received:', status.bytesReceived);
console.log('Retry attempts:', status.retryAttempts);
});
session.on('subscriptionError', (trackName, error) => {
console.error(`Subscription error on ${trackName}:`, error.message);
});Configuration
Session Configuration
interface MoQSessionConfig {
relayUrl: string; // URL of the MoQ relay server
namespace: string; // MoQ namespace (broadcast name)
reconnection?: {
delay?: number; // Delay between attempts in ms (default: 1000)
};
connectionTimeout?: number; // Connection timeout in ms (default: 10000)
discoveryOnly?: boolean; // (Subscriber only) Only discover broadcasts,
// don't auto-subscribe (default: false)
}Broadcast Configuration
interface BroadcastConfig {
trackName: string; // Track name for the broadcast
priority?: number; // Priority for the track (default: 0)
type: 'video' | 'audio' | 'data'; // Type of track
}Subscription Configuration
interface SubscriptionConfig {
trackName: string; // Track name to subscribe to
priority?: number; // Priority for the subscription (default: 0)
retry?: {
delay?: number; // Delay between retries in ms (default: 2000)
};
}Status Monitoring
Session Status
const status = broadcaster.status; // or subscriber.status
console.log({
state: status.state, // SessionState enum
reconnectAttempts: status.reconnectAttempts,
lastError: status.lastError,
connectedAt: status.connectedAt
});Broadcast Status
// Broadcasters track individual broadcast status
broadcaster.on('broadcastStateChange', (trackName, status) => {
console.log({
state: status.state, // BroadcastState enum
trackName: status.trackName,
bytesSent: status.bytesSent,
lastError: status.lastError
});
});Subscription Status
// Subscribers track individual subscription status
subscriber.on('subscriptionStateChange', (trackName, status) => {
console.log({
state: status.state, // SubscriptionState enum
trackName: status.trackName,
bytesReceived: status.bytesReceived,
lastError: status.lastError
});
});States
Session States
DISCONNECTED: Not connected to relayCONNECTING: Attempting to connectCONNECTED: Successfully connectedRECONNECTING: Attempting to reconnect after disconnectFAILED: Connection failed completely
Broadcast States
IDLE: Not broadcastingBROADCASTING: Actively broadcastingERROR: Error occurred
Subscription States
PENDING: Waiting to subscribeIDLE: Not subscribedSUBSCRIBED: Successfully subscribed and receiving dataFAILED: Subscription failed
Error Handling
// Session errors
session.on('error', (error) => {
console.error('Session error:', error.message);
broadcaster.on('error', (error) => {
console.error('Session error:', error.message);
});
// Broadcast errors (per track)
broadcaster.on('broadcastError', (trackName, error) => {
console.error(`Broadcast error on ${trackName}:`, error.message);
});
// Subscription errors (per track)
subscriberCleanup
// Remove all broadcasts and subscriptions
session.updateBroadcasts([]); // Empty array removes all
sesDisconnect and cleanup
broadcaster.disconnect();
subscriber.dispose
## Broadcast Discovery
The library automatically discovers available broadcasts via MoQ announcements. This is essential for building multi-user applications like video chat rooms.
### Discovery-Only Mode (Room-Level Discovery)
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:
```typescript
// Create a discovery subscriber for a room
const roomDiscovery = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'lobby', // Room prefix
discoveryOnly: true // Only discover, don't auto-subscribe
},
[] // No subscriptions needed for discovery
);
// Listen for user announcements
roomDiscovery.on('broadcastAnnounced', (announcement) => {
console.log('User broadcast:', announcement.path); // e.g., "lobby/user/abc123"
console.log('Is active:', announcement.active);
if (announcement.active) {
// Manually create a subscription to this user's tracks
const userSubscriber = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: announcement.path, // e.g., "lobby/user/abc123"
discoveryOnly: false
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]
);
await userSubscriber.connect();
} else {
// User left - clean up their subscription
console.log('User left:', announcement.path);
}
});
await roomDiscovery.connect();Multi-User Video Chat Room Example
Here's a complete example of building a video chat room with discovery:
// Namespace structure: {room}/user/{userId}
const roomName = 'lobby';
const myUserId = 'user-123';
// 1. Create a broadcaster for your own tracks
const myBroadcaster = new MoqSessionBroadcaster(
{
relayUrl: 'https://relay.quic.video',
namespace: `${roomName}/user/${myUserId}`
},
[
{ trackName: 'video', priority: 10, type: 'video' },
{ trackName: 'audio', priority: 5, type: 'audio' }
]
);
await myBroadcaster.connect();
// 2. Create a discovery subscriber to find other users
const roomDiscovery = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: roomName,
discoveryOnly: true
},
[]
);
// Track active users
const activeUsers = new Map();
roomDiscovery.on('broadcastAnnounced', async (announcement) => {
const userPath = announcement.path; // e.g., "lobby/user/xyz789"
// Skip our own broadcast
if (userPath === `${roomName}/user/${myUserId}`) return;
if (announcement.active) {
// New user joined - subscribe to their tracks
const userSubscriber = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: userPath
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]
);
userSubscriber.on('data', (trackName, data) => {
console.log(`Received ${trackName} from ${userPath}:`, data.length, 'bytes');
// Process video/audio data
});
await userSubscriber.connect();
activeUsers.set(userPath, userSubscriber);
console.log(`User joined: ${userPath}`);
} else {
// User left - clean up
const userSubscriber = activeUsers.get(userPath);
if (userSubscriber) {
userSubscriber.dispose();
activeUsers.delete(userPath);
console.log(`User left: ${userPath}`);
}
}
});
await roomDiscovery.connect();
// 3. Broadcast your video/audio
myBroadcaster.send('video', videoData, true);
myBroadcaster.send('audio', audioData, false);How Discovery Works
The discovery mechanism uses the MoQ announced() method under the hood:
- Discovery uses
announced(prefix): Listens for all broadcasts matching a prefix (e.g.,lobby) - Subscriptions use
consume(path): Subscribes to tracks from a specific broadcast path (e.g.,lobby/user/abc123) - These are separate operations: Discovery doesn't require consuming, allowing efficient room-level discovery
- Active/Inactive events: Announcements include an
activeflag -truewhen a broadcast starts,falsewhen it ends
Dynamic Track Management
You can add or remove subscriptions dynamically:
// Add a subscription
subscriber.addSubscription({ trackName: 'chat', priority: 0 });
// Remove a subscription
subscriber.removeSubscription('chat');
// Update all subscriptions at once
subscriber.updateSubscriptions([
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]);Advanced Usage
Infinite Retry Logic
Both reconnection and subscription retries run infinitely with configurable delays:
// Session with custom reconnection delay
const subscriber = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'persistent-app',
reconnection: {
delay: 5000 // Wait 5 seconds between reconnection attempts
}
},
[
{
trackName: 'important-track',
retry: {
delay: 3000 // Wait 3 seconds between retry attempts
}
}
]
);Bidirectional Communication
Set up full bidirectional communication between two applications:
// App A: Publisher and Subscriber
const sessionA = new MoQSession({
relayUrl: 'wss://relay.quic.video',
namespace: 'app-a'
});
await sessionA.connect();
// Set up tracks
sessionA.updateBroadcasts([{ trackName: 'data-from-a' }]);
sessionA.updateSubscriptions([{ trackName: 'data-from-b' }]);
// Listen for data from App B
sessionA.on('data', (trackName, data) => {
if (trackName === 'data-from-b') {
console.log('App A received data from App B:', data);
}
});
// App B: Subscriber and Publisher
const sessionB = new MoQSession({
relayUrl: 'wss://relay.quic.video',
namespace: 'app-b'
});users:
```typescript
// User A: Broadcaster and Subscriber
const broadcasterA = new MoqSessionBroadcaster(
{
relayUrl: 'https://relay.quic.video',
namespace: 'app/user-a'
},
[
{ trackName: 'video', priority: 10, type: 'video' },
{ trackName: 'audio', priority: 5, type: 'audio' }
]
);
const subscriberA = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'app/user-b'
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]
);
// User B: Broadcaster and Subscriber
const broadcasterB = new MoqSessionBroadcaster(
{
relayUrl: 'https://relay.quic.video',
namespace: 'app/user-b'
},
[
{ trackName: 'video', priority: 10, type: 'video' },
{ trackName: 'audio', priority: 5, type: 'audio' }
]
);
const subscriberB = new MoqSessionSubscriber(
{
relayUrl: 'https://relay.quic.video',
namespace: 'app/user-a'
},
[
{ trackName: 'video', priority: 10 },
{ trackName: 'audio', priority: 5 }
]
);
// Connect all sessions
await Promise.all([
broadcasterA.connect(),
subscriberA.connect(),
broadcasterB.connect(),
subscriberB.connect()
]);
// Listen for incoming data
subscriberA.on('data', (trackName, data) => {
console.log('User A received:', trackName, data.length);
});
subscriberB.on('data', (trackName, data) => {
console.log('User B received:', trackName, data.length);
});
// Send data
broadcasterA.send('video', videoDataA, true);
broadcasterB.send('video', videoDataB, true