covekit 0.1.0

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 ADDED
@@ -0,0 +1,139 @@
1
+ # covekit
2
+
3
+ The official JavaScript/TypeScript SDK for the **CoveKit Media Suite**. This package provides headless WebRTC meeting orchestration, media transport handshakes, and signaling channel loops.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install covekit
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Integration Paths
16
+
17
+ Developers can integrate CoveKit in two ways:
18
+
19
+ 1. **Option A: Direct Embed (Low-Code / Complete UI)**: Embed our pre-built responsive UI widget inside an iframe.
20
+ 2. **Option B: Headless SDK (Pro-Code / Custom UI)**: Orchestrate WebRTC connections and media tracks programmatically to build a custom interface.
21
+
22
+ ---
23
+
24
+ ### Option A: Direct Embed
25
+
26
+ Render an `<iframe>` referencing the CoveKit meeting URL with specific query parameters to hide standard menus/headers and automatically enter the room:
27
+
28
+ ```html
29
+ <iframe
30
+ src="https://meet.covekit.com/m/lobby?embed=true&autoJoin=true&displayName=Satoshi&mic=on&cam=off"
31
+ allow="camera; microphone; display-capture; fullscreen"
32
+ style="width: 100%; height: 600px; border: none; border-radius: 8px;"
33
+ ></iframe>
34
+ ```
35
+
36
+ #### Supported Query Parameters:
37
+ * `embed=true` (Required): Strips out header spacing, navigation menus, and lobby wrappers.
38
+ * `autoJoin=true` (Optional): Skips the lobby settings stage and joins automatically.
39
+ * `displayName` (Optional): Prepopulates the user's name (required if `autoJoin` is active).
40
+ * `passcode` (Optional): Prepopulates the password.
41
+ * `mic=on|off` (Optional): Defaults the starting microphone state.
42
+ * `cam=on|off` (Optional): Defaults the starting camera state.
43
+
44
+ ---
45
+
46
+ ### Option B: Headless SDK
47
+
48
+ Orchestrate the WebRTC media transports programmatically to build a custom UI.
49
+
50
+ ```typescript
51
+ import { CoveKitClient, CoveKitRoom } from 'covekit';
52
+
53
+ // Initialize the client with your custom API and Media server endpoints
54
+ const client = new CoveKitClient(
55
+ 'https://api.covekit.com', // Control Plane API
56
+ 'wss://media.covekit.com/v1/media/ws' // Real-Time Signaling WS
57
+ );
58
+
59
+ // 1. Get meeting token
60
+ const session = await client.joinPublicMeeting('lobby-room', 'Alice');
61
+
62
+ // 2. Initialize connection
63
+ const room = new CoveKitRoom(client, {
64
+ onConnected: (info) => {
65
+ console.log('Connected! Session ID:', info.sessionId);
66
+ // Request permission & publish mic/cam tracks
67
+ room.publishLocalMedia(true, true);
68
+ },
69
+ onLocalStream: (stream) => {
70
+ // Render local preview player
71
+ const localVideo = document.getElementById('local-video') as HTMLVideoElement;
72
+ localVideo.srcObject = stream;
73
+ },
74
+ onRemoteTrack: (track, peerId, kind) => {
75
+ // Handle remote participant video/audio tracks
76
+ const mediaStream = new MediaStream([track]);
77
+ const remoteVideo = document.getElementById(`peer-${peerId}`) as HTMLVideoElement;
78
+ if (remoteVideo) {
79
+ remoteVideo.srcObject = mediaStream;
80
+ }
81
+ },
82
+ onPeerJoin: (peer) => {
83
+ console.log(`${peer.displayName} joined the call`);
84
+ },
85
+ onPeerLeave: (peerId) => {
86
+ console.log(`Peer ${peerId} left`);
87
+ },
88
+ onPeerMuteStateChanged: (peerId, kind, muted) => {
89
+ console.log(`Peer ${peerId} ${kind} is ${muted ? 'muted' : 'unmuted'}`);
90
+ },
91
+ onWhiteboardUpdate: (peerId, elements) => {
92
+ console.log('Received whiteboard vector sync chunk:', elements);
93
+ },
94
+ onError: (error) => {
95
+ console.error('Room signaling error:', error);
96
+ }
97
+ });
98
+
99
+ // 3. Connect WebRTC media loops
100
+ await room.join(session.token, session.ice_servers);
101
+ ```
102
+
103
+ ---
104
+
105
+ ## API Reference
106
+
107
+ ### `CoveKitClient`
108
+ Main client class for talking to the CoveKit Control Plane.
109
+
110
+ #### Constructor
111
+ ```typescript
112
+ new CoveKitClient(apiBaseUrl: string, mediaWsUrl: string)
113
+ ```
114
+
115
+ #### Methods
116
+ * `joinPublicMeeting(publicId: string, displayName: string, passcode?: string, hostToken?: string)`: Establishes a session token and connects to waiting rooms.
117
+ * `getPublicMeeting(publicId: string)`: Retrieves meeting metadata (e.g. title, status, meeting mode).
118
+ * `pollSessionStatus(publicId: string, sessionId: string)`: Checks if the host has admitted the user from the waiting room.
119
+
120
+ ---
121
+
122
+ ### `CoveKitRoom`
123
+ Handles room-wide media track lifecycle events and WebRTC connection pipelines.
124
+
125
+ #### Constructor
126
+ ```typescript
127
+ new CoveKitRoom(client: CoveKitClient, events: CoveKitRoomEvents)
128
+ ```
129
+
130
+ #### Methods
131
+ * `join(token: string, iceServers?: any[])`: Connects the WebRTC signaling WebSocket.
132
+ * `leave()`: Stops all local streams, closes active producers, and tears down signaling.
133
+ * `publishLocalMedia(audio: boolean, video: boolean, audioDeviceId?: string, existingStream?: MediaStream)`: Invokes the user's browser device permissions and publishes selected tracks.
134
+ * `publishTrack(kind: 'audio' | 'video', track: MediaStreamTrack, appData?: any)`: Publishes a custom track.
135
+ * `replaceTrack(kind: 'audio' | 'video', track: MediaStreamTrack | null, appData?: any)`: Swaps an active track in-place on the current WebRTC producer.
136
+ * `toggleMute(kind: 'audio' | 'video', muted: boolean)`: Mutes/unmutes media tracks and broadcasts state to peers.
137
+ * `sendWhiteboardUpdate(elements: any)`: Broadcasts real-time collaborative whiteboard updates to peers.
138
+ * `kickParticipant(peerId: string)`: *[Host Only]* Evicts a participant from the room.
139
+ * `endMeetingRoom()`: *[Host Only]* Terminates the meeting session for everyone.
@@ -0,0 +1,54 @@
1
+ import { ServerMessage } from './protocol';
2
+ export declare class CoveKitClient {
3
+ private apiBaseUrl;
4
+ private mediaWsUrl;
5
+ private ws;
6
+ constructor(apiBaseUrl: string, mediaWsUrl: string);
7
+ /**
8
+ * Control Plane REST: Fetch public meeting minimal info
9
+ */
10
+ getPublicMeeting(publicId: string): Promise<any>;
11
+ /**
12
+ * Control Plane REST: Creates an ephemeral anonymous participant session
13
+ */
14
+ joinPublicMeeting(publicId: string, displayName: string, passcode?: string, hostToken?: string): Promise<any>;
15
+ /**
16
+ * Control Plane REST: Polls session status for waiting room approval
17
+ */
18
+ pollSessionStatus(publicId: string, sessionId: string): Promise<any>;
19
+ /**
20
+ * Control Plane REST: Fetch waiting room participants (Host only)
21
+ */
22
+ getWaitingRoom(publicId: string, hostToken: string): Promise<any>;
23
+ /**
24
+ * Control Plane REST: Admit participant (Host only)
25
+ */
26
+ admitParticipant(publicId: string, sessionId: string, hostToken: string): Promise<void>;
27
+ /**
28
+ * Control Plane REST: Deny participant (Host only)
29
+ */
30
+ denyParticipant(publicId: string, sessionId: string, hostToken: string): Promise<void>;
31
+ /**
32
+ * Real-Time Plane WS: Initiates native signaling channel loops
33
+ */
34
+ connectMediaServer(token: string, onMessage: (msg: ServerMessage) => void): Promise<WebSocket>;
35
+ private sendSignal;
36
+ requestCapabilities(): void;
37
+ createWebRtcTransport(direction: 'send' | 'recv'): void;
38
+ connectWebRtcTransport(transportId: string, dtlsParameters: any): void;
39
+ produce(transportId: string, kind: 'audio' | 'video', rtpParameters: any, appData?: any): void;
40
+ consume(producerId: string, rtpCapabilities: any): void;
41
+ grantScreenShare(peerId: string): void;
42
+ revokeScreenShare(peerId: string): void;
43
+ closeProducer(producerId: string): void;
44
+ pauseConsumer(consumerId: string): void;
45
+ resumeConsumer(consumerId: string): void;
46
+ endMeetingRoom(): void;
47
+ kickParticipant(peerId: string): void;
48
+ toggleMute(kind: 'audio' | 'video', muted: boolean): void;
49
+ sendTranscriptChunk(text: string): void;
50
+ sendWhiteboardUpdate(elements: any): void;
51
+ sendPing(): void;
52
+ disconnect(): void;
53
+ }
54
+ export { CoveKitRoom, CoveKitRoomEvents } from './room';
package/dist/index.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Rust serde's `rename_all = "camelCase"` on an enum only renames variant
3
+ * names, NOT the field names within struct variants. The server therefore
4
+ * sends snake_case keys inside every payload object (e.g. `rtp_capabilities`,
5
+ * `session_id`, `ice_parameters`). This helper does a shallow conversion so
6
+ * the rest of the client can use the camelCase names defined in protocol.ts.
7
+ */
8
+ function shallowCamelizeKeys(obj) {
9
+ const out = {};
10
+ for (const key of Object.keys(obj)) {
11
+ const camel = key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
12
+ out[camel] = obj[key];
13
+ }
14
+ return out;
15
+ }
16
+ export class CoveKitClient {
17
+ constructor(apiBaseUrl, mediaWsUrl) {
18
+ this.ws = null;
19
+ this.apiBaseUrl = apiBaseUrl;
20
+ this.mediaWsUrl = mediaWsUrl;
21
+ }
22
+ /**
23
+ * Control Plane REST: Fetch public meeting minimal info
24
+ */
25
+ async getPublicMeeting(publicId) {
26
+ const response = await fetch(`${this.apiBaseUrl}/v1/public/meetings/${publicId}`, {
27
+ method: 'GET',
28
+ });
29
+ if (!response.ok) {
30
+ throw new Error(`Failed to fetch public meeting details: ${response.statusText}`);
31
+ }
32
+ return response.json();
33
+ }
34
+ /**
35
+ * Control Plane REST: Creates an ephemeral anonymous participant session
36
+ */
37
+ async joinPublicMeeting(publicId, displayName, passcode, hostToken) {
38
+ const url = new URL(`${this.apiBaseUrl}/v1/public/meetings/${publicId}/join`);
39
+ if (hostToken) {
40
+ url.searchParams.set('host_token', hostToken);
41
+ }
42
+ const response = await fetch(url.toString(), {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ display_name: displayName, passcode }),
46
+ });
47
+ if (!response.ok) {
48
+ const errPayload = await response.json().catch(() => ({}));
49
+ throw new Error(errPayload.error || `Failed public room entry: ${response.statusText}`);
50
+ }
51
+ // Returns payload containing short-lived JWT Join Token
52
+ return response.json();
53
+ }
54
+ /**
55
+ * Control Plane REST: Polls session status for waiting room approval
56
+ */
57
+ async pollSessionStatus(publicId, sessionId) {
58
+ const response = await fetch(`${this.apiBaseUrl}/v1/public/meetings/${publicId}/session/${sessionId}/status`, {
59
+ method: 'GET',
60
+ });
61
+ if (!response.ok) {
62
+ const errPayload = await response.json().catch(() => ({}));
63
+ throw new Error(errPayload.error || `Failed to check session status: ${response.statusText}`);
64
+ }
65
+ return response.json();
66
+ }
67
+ /**
68
+ * Control Plane REST: Fetch waiting room participants (Host only)
69
+ */
70
+ async getWaitingRoom(publicId, hostToken) {
71
+ const response = await fetch(`${this.apiBaseUrl}/v1/public/meetings/${publicId}/waiting-room?host_token=${hostToken}`, {
72
+ method: 'GET',
73
+ });
74
+ if (!response.ok)
75
+ throw new Error(`Failed to fetch waiting room: ${response.statusText}`);
76
+ return response.json();
77
+ }
78
+ /**
79
+ * Control Plane REST: Admit participant (Host only)
80
+ */
81
+ async admitParticipant(publicId, sessionId, hostToken) {
82
+ const response = await fetch(`${this.apiBaseUrl}/v1/public/meetings/${publicId}/waiting-room/${sessionId}/admit?host_token=${hostToken}`, {
83
+ method: 'POST',
84
+ });
85
+ if (!response.ok)
86
+ throw new Error(`Failed to admit participant: ${response.statusText}`);
87
+ }
88
+ /**
89
+ * Control Plane REST: Deny participant (Host only)
90
+ */
91
+ async denyParticipant(publicId, sessionId, hostToken) {
92
+ const response = await fetch(`${this.apiBaseUrl}/v1/public/meetings/${publicId}/waiting-room/${sessionId}/deny?host_token=${hostToken}`, {
93
+ method: 'POST',
94
+ });
95
+ if (!response.ok)
96
+ throw new Error(`Failed to deny participant: ${response.statusText}`);
97
+ }
98
+ /**
99
+ * Real-Time Plane WS: Initiates native signaling channel loops
100
+ */
101
+ connectMediaServer(token, onMessage) {
102
+ return new Promise((resolve, reject) => {
103
+ this.ws = new WebSocket(this.mediaWsUrl);
104
+ this.ws.onopen = () => {
105
+ console.log('Signaling pipe open. Launching cryptographic token validation handshake.');
106
+ this.sendSignal('init', { token });
107
+ resolve(this.ws);
108
+ };
109
+ this.ws.onmessage = (event) => {
110
+ try {
111
+ const parsed = JSON.parse(event.data);
112
+ // Shallow-camelize the payload so snake_case Rust field names
113
+ // (rtp_capabilities, session_id, …) map to the camelCase names
114
+ // used in protocol.ts and room.ts.
115
+ const camelPayload = parsed.payload && typeof parsed.payload === 'object'
116
+ ? shallowCamelizeKeys(parsed.payload)
117
+ : null;
118
+ const flatMsg = camelPayload
119
+ ? { type: parsed.type, ...camelPayload }
120
+ : { type: parsed.type };
121
+ onMessage(flatMsg);
122
+ }
123
+ catch (err) {
124
+ console.error('Failed processing raw incoming WebSocket event frame:', err);
125
+ }
126
+ };
127
+ this.ws.onerror = (err) => reject(err);
128
+ this.ws.onclose = () => {
129
+ console.log('Signaling WebSocket closed.');
130
+ // If this.ws is still set, it means we didn't call .disconnect() manually
131
+ if (this.ws) {
132
+ onMessage({ type: 'error', message: 'Connection to server lost.' });
133
+ }
134
+ };
135
+ });
136
+ }
137
+ sendSignal(type, payload) {
138
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
139
+ this.ws.send(JSON.stringify({ type, payload }));
140
+ }
141
+ }
142
+ requestCapabilities() {
143
+ this.sendSignal('getRouterRtpCapabilities');
144
+ }
145
+ createWebRtcTransport(direction) {
146
+ this.sendSignal('createWebRtcTransport', { direction });
147
+ }
148
+ connectWebRtcTransport(transportId, dtlsParameters) {
149
+ this.sendSignal('connectWebRtcTransport', { transportId, dtlsParameters });
150
+ }
151
+ produce(transportId, kind, rtpParameters, appData) {
152
+ this.sendSignal('produce', { transportId, kind, rtpParameters, appData });
153
+ }
154
+ consume(producerId, rtpCapabilities) {
155
+ this.sendSignal('consume', { producerId, rtpCapabilities });
156
+ }
157
+ grantScreenShare(peerId) {
158
+ this.sendSignal('grantScreenShare', { peerId });
159
+ }
160
+ revokeScreenShare(peerId) {
161
+ this.sendSignal('revokeScreenShare', { peerId });
162
+ }
163
+ closeProducer(producerId) {
164
+ this.sendSignal('closeProducer', { producerId });
165
+ }
166
+ pauseConsumer(consumerId) {
167
+ this.sendSignal('pauseConsumer', { consumerId });
168
+ }
169
+ resumeConsumer(consumerId) {
170
+ this.sendSignal('resumeConsumer', { consumerId });
171
+ }
172
+ endMeetingRoom() {
173
+ this.sendSignal('endMeeting');
174
+ }
175
+ kickParticipant(peerId) {
176
+ this.sendSignal('kickPeer', { peerId });
177
+ }
178
+ toggleMute(kind, muted) {
179
+ this.sendSignal('toggleMute', { kind, muted });
180
+ }
181
+ sendTranscriptChunk(text) {
182
+ this.sendSignal('transcriptChunk', { text });
183
+ }
184
+ sendWhiteboardUpdate(elements) {
185
+ this.sendSignal('whiteboardUpdate', { elements });
186
+ }
187
+ sendPing() {
188
+ this.sendSignal('ping');
189
+ }
190
+ disconnect() {
191
+ if (this.ws) {
192
+ this.ws.close();
193
+ this.ws = null;
194
+ }
195
+ }
196
+ }
197
+ export { CoveKitRoom } from './room';
@@ -0,0 +1,123 @@
1
+ export type ClientMessage = {
2
+ type: 'init';
3
+ token: string;
4
+ } | {
5
+ type: 'getRouterRtpCapabilities';
6
+ } | {
7
+ type: 'createWebRtcTransport';
8
+ direction: 'send' | 'recv';
9
+ } | {
10
+ type: 'connectWebRtcTransport';
11
+ transportId: string;
12
+ dtlsParameters: any;
13
+ } | {
14
+ type: 'produce';
15
+ transportId: string;
16
+ kind: 'audio' | 'video';
17
+ rtpParameters: any;
18
+ appData?: any;
19
+ } | {
20
+ type: 'consume';
21
+ producerId: string;
22
+ rtpCapabilities: any;
23
+ } | {
24
+ type: 'endMeeting';
25
+ } | {
26
+ type: 'kickPeer';
27
+ peerId: string;
28
+ } | {
29
+ type: 'toggleMute';
30
+ kind: 'audio' | 'video';
31
+ muted: boolean;
32
+ } | {
33
+ type: 'grantScreenShare';
34
+ peerId: string;
35
+ } | {
36
+ type: 'revokeScreenShare';
37
+ peerId: string;
38
+ } | {
39
+ type: 'closeProducer';
40
+ producerId: string;
41
+ } | {
42
+ type: 'pauseConsumer';
43
+ consumerId: string;
44
+ } | {
45
+ type: 'resumeConsumer';
46
+ consumerId: string;
47
+ } | {
48
+ type: 'transcriptChunk';
49
+ text: string;
50
+ } | {
51
+ type: 'whiteboardUpdate';
52
+ elements: any;
53
+ } | {
54
+ type: 'ping';
55
+ };
56
+ export type ServerMessage = {
57
+ type: 'connected';
58
+ sessionId: string;
59
+ role: 'host' | 'participant';
60
+ displayName: string;
61
+ isRecording: boolean;
62
+ autoRecord: boolean;
63
+ } | {
64
+ type: 'routerRtpCapabilities';
65
+ rtpCapabilities: any;
66
+ } | {
67
+ type: 'transportCreated';
68
+ id: string;
69
+ direction: 'send' | 'recv';
70
+ iceParameters: any;
71
+ iceCandidates: any[];
72
+ dtlsParameters: any;
73
+ } | {
74
+ type: 'producerCreated';
75
+ id: string;
76
+ } | {
77
+ type: 'consumerCreated';
78
+ id: string;
79
+ producerId: string;
80
+ kind: 'audio' | 'video';
81
+ rtpParameters: any;
82
+ appData?: any;
83
+ } | {
84
+ type: 'newProducer';
85
+ producerId: string;
86
+ peerId: string;
87
+ kind: 'audio' | 'video';
88
+ appData?: any;
89
+ } | {
90
+ type: 'producerClosed';
91
+ producerId: string;
92
+ peerId: string;
93
+ } | {
94
+ type: 'peerJoined';
95
+ peerId: string;
96
+ displayName: string;
97
+ role: 'host' | 'participant';
98
+ } | {
99
+ type: 'peerLeft';
100
+ peerId: string;
101
+ } | {
102
+ type: 'peerMuteStateChanged';
103
+ peerId: string;
104
+ kind: 'audio' | 'video';
105
+ muted: boolean;
106
+ } | {
107
+ type: 'screenSharePermissionUpdated';
108
+ peerId: string;
109
+ canShare: boolean;
110
+ } | {
111
+ type: 'recordingStarted';
112
+ } | {
113
+ type: 'recordingStopped';
114
+ } | {
115
+ type: 'whiteboardUpdate';
116
+ peerId: string;
117
+ elements: any;
118
+ } | {
119
+ type: 'error';
120
+ message: string;
121
+ } | {
122
+ type: 'pong';
123
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/room.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { CoveKitClient } from './index';
2
+ export interface CoveKitRoomEvents {
3
+ onConnected?: (info: {
4
+ sessionId: string;
5
+ role: string;
6
+ displayName: string;
7
+ isRecording: boolean;
8
+ autoRecord: boolean;
9
+ }) => void;
10
+ onLocalStream?: (stream: MediaStream) => void;
11
+ onRemoteTrack?: (track: MediaStreamTrack, peerId: string, kind: 'audio' | 'video', appData?: any) => void;
12
+ onPeerJoin?: (peer: {
13
+ peerId: string;
14
+ displayName: string;
15
+ role: string;
16
+ }) => void;
17
+ onPeerLeave?: (peerId: string) => void;
18
+ onPeerMuteStateChanged?: (peerId: string, kind: 'audio' | 'video', muted: boolean) => void;
19
+ onScreenSharePermissionUpdated?: (peerId: string, canShare: boolean) => void;
20
+ onRecordingStarted?: () => void;
21
+ onRecordingStopped?: () => void;
22
+ onWhiteboardUpdate?: (peerId: string, elements: any) => void;
23
+ onError?: (err: Error) => void;
24
+ }
25
+ export declare class CoveKitRoom {
26
+ private client;
27
+ private device;
28
+ private sendTransport;
29
+ private recvTransport;
30
+ private producers;
31
+ private consumers;
32
+ private producerToPeerMap;
33
+ private events;
34
+ private pendingProduceCallback;
35
+ private pendingPublishAudio;
36
+ private pendingPublishVideo;
37
+ private pendingPublishAudioDeviceId;
38
+ private pendingExistingStream;
39
+ private transportsReady;
40
+ private pendingConsumes;
41
+ private iceServers?;
42
+ private pingInterval;
43
+ constructor(client: CoveKitClient, events: CoveKitRoomEvents);
44
+ join(token: string, iceServers?: any[]): Promise<void>;
45
+ publishLocalMedia(audio: boolean, video: boolean, audioDeviceId?: string, existingStream?: MediaStream): Promise<void>;
46
+ private handleSignal;
47
+ private initializeTransport;
48
+ private consumeMutex;
49
+ private initializeConsumer;
50
+ endMeeting(): void;
51
+ sendTranscriptChunk(text: string): void;
52
+ sendWhiteboardUpdate(elements: any): void;
53
+ kickParticipant(peerId: string): void;
54
+ toggleMute(kind: 'audio' | 'video', muted: boolean): void;
55
+ publishTrack(kind: 'audio' | 'video', track: MediaStreamTrack, appData?: any): Promise<void>;
56
+ replaceTrack(kind: 'audio' | 'video', track: MediaStreamTrack | null, appData?: any): Promise<void>;
57
+ unpublishTrack(kind: 'audio' | 'video', appData?: any): Promise<void>;
58
+ closeProducer(producerId: string): void;
59
+ grantScreenShare(peerId: string): void;
60
+ revokeScreenShare(peerId: string): void;
61
+ pausePeerVideo(peerId: string): void;
62
+ resumePeerVideo(peerId: string): void;
63
+ resumeAllVideos(): void;
64
+ leave(): void;
65
+ }
package/dist/room.js ADDED
@@ -0,0 +1,386 @@
1
+ import { Device } from 'mediasoup-client';
2
+ export class CoveKitRoom {
3
+ constructor(client, events) {
4
+ this.device = null;
5
+ this.sendTransport = null;
6
+ this.recvTransport = null;
7
+ this.producers = new Map();
8
+ this.consumers = new Map();
9
+ this.producerToPeerMap = new Map();
10
+ this.pendingProduceCallback = null;
11
+ this.pendingPublishAudio = false;
12
+ this.pendingPublishVideo = false;
13
+ this.pendingPublishAudioDeviceId = undefined;
14
+ this.pendingExistingStream = undefined;
15
+ this.transportsReady = { send: false, recv: false };
16
+ // newProducer events that arrive before the device/transports are ready
17
+ // are queued here and flushed once both transports are initialized.
18
+ this.pendingConsumes = [];
19
+ this.pingInterval = null;
20
+ this.consumeMutex = Promise.resolve();
21
+ this.client = client;
22
+ this.events = events;
23
+ }
24
+ async join(token, iceServers) {
25
+ try {
26
+ this.iceServers = iceServers;
27
+ this.device = new Device();
28
+ // Connect to WebSocket signaling server
29
+ await this.client.connectMediaServer(token, (msg) => this.handleSignal(msg));
30
+ // Start heartbeat every 15 seconds
31
+ this.pingInterval = window.setInterval(() => {
32
+ this.client.sendPing();
33
+ }, 15000);
34
+ // Request Router RTP capabilities
35
+ this.client.requestCapabilities();
36
+ }
37
+ catch (err) {
38
+ this.events.onError?.(err);
39
+ }
40
+ }
41
+ async publishLocalMedia(audio, video, audioDeviceId, existingStream) {
42
+ // If the send transport isn't ready yet, queue the request — it will be
43
+ // fulfilled automatically once both transports have been initialized.
44
+ if (!this.sendTransport) {
45
+ this.pendingPublishAudio = audio;
46
+ this.pendingPublishVideo = video;
47
+ this.pendingPublishAudioDeviceId = audioDeviceId;
48
+ this.pendingExistingStream = existingStream;
49
+ return;
50
+ }
51
+ try {
52
+ const stream = existingStream || await navigator.mediaDevices.getUserMedia({
53
+ audio: audio
54
+ ? (audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true)
55
+ : false,
56
+ video: video ? { width: 1280, height: 720 } : false,
57
+ });
58
+ this.events.onLocalStream?.(stream);
59
+ if (!this.sendTransport) {
60
+ throw new Error('Send transport not initialized yet.');
61
+ }
62
+ if (audio) {
63
+ const audioTrack = stream.getAudioTracks()[0];
64
+ if (audioTrack) {
65
+ const producer = await this.sendTransport.produce({ track: audioTrack });
66
+ this.producers.set('audio-camera', producer);
67
+ }
68
+ }
69
+ if (video) {
70
+ const videoTrack = stream.getVideoTracks()[0];
71
+ if (videoTrack) {
72
+ const producer = await this.sendTransport.produce({
73
+ track: videoTrack,
74
+ encodings: [
75
+ { maxBitrate: 100000, scaleResolutionDownBy: 4 }, // Low: 180p
76
+ { maxBitrate: 300000, scaleResolutionDownBy: 2 }, // Medium: 360p
77
+ { maxBitrate: 1500000 }, // High: 720p
78
+ ],
79
+ });
80
+ this.producers.set('video-camera', producer);
81
+ }
82
+ }
83
+ }
84
+ catch (err) {
85
+ this.events.onError?.(err);
86
+ }
87
+ }
88
+ async handleSignal(msg) {
89
+ try {
90
+ switch (msg.type) {
91
+ case 'connected':
92
+ this.events.onConnected?.({
93
+ sessionId: msg.sessionId,
94
+ role: msg.role,
95
+ displayName: msg.displayName,
96
+ isRecording: msg.isRecording,
97
+ autoRecord: msg.autoRecord,
98
+ });
99
+ break;
100
+ case 'recordingStarted':
101
+ this.events.onRecordingStarted?.();
102
+ break;
103
+ case 'recordingStopped':
104
+ this.events.onRecordingStopped?.();
105
+ break;
106
+ case 'whiteboardUpdate':
107
+ this.events.onWhiteboardUpdate?.(msg.peerId, msg.elements);
108
+ break;
109
+ case 'routerRtpCapabilities': {
110
+ const caps = msg.rtpCapabilities;
111
+ if (!caps || typeof caps !== 'object') {
112
+ throw new Error(`Invalid routerRtpCapabilities received from server: ${JSON.stringify(caps)}`);
113
+ }
114
+ await this.device?.load({ routerRtpCapabilities: caps });
115
+ // Create send and recv transports
116
+ this.client.createWebRtcTransport('send');
117
+ this.client.createWebRtcTransport('recv');
118
+ break;
119
+ }
120
+ case 'transportCreated':
121
+ await this.initializeTransport(msg);
122
+ break;
123
+ case 'producerCreated':
124
+ if (this.pendingProduceCallback) {
125
+ this.pendingProduceCallback.callback({ id: msg.id });
126
+ this.pendingProduceCallback = null;
127
+ }
128
+ break;
129
+ case 'consumerCreated':
130
+ await this.initializeConsumer(msg);
131
+ break;
132
+ case 'newProducer':
133
+ // Map producerId to peerId
134
+ this.producerToPeerMap.set(msg.producerId, msg.peerId);
135
+ // If transports aren't ready yet (e.g. this is a late-joiner sync
136
+ // notification that arrived before device.load / createWebRtcTransport
137
+ // completed), queue the consume for later.
138
+ if (!this.transportsReady.send || !this.transportsReady.recv) {
139
+ this.pendingConsumes.push({ producerId: msg.producerId, peerId: msg.peerId });
140
+ }
141
+ else {
142
+ this.client.consume(msg.producerId, this.device?.rtpCapabilities);
143
+ }
144
+ break;
145
+ case 'peerJoined':
146
+ this.events.onPeerJoin?.(msg);
147
+ break;
148
+ case 'peerLeft':
149
+ this.events.onPeerLeave?.(msg.peerId);
150
+ break;
151
+ case 'peerMuteStateChanged':
152
+ this.events.onPeerMuteStateChanged?.(msg.peerId, msg.kind, msg.muted);
153
+ break;
154
+ case 'screenSharePermissionUpdated':
155
+ this.events.onScreenSharePermissionUpdated?.(msg.peerId, msg.canShare);
156
+ break;
157
+ case 'producerClosed': {
158
+ const consumer = Array.from(this.consumers.values()).find(c => c.producerId === msg.producerId);
159
+ if (consumer) {
160
+ consumer.close();
161
+ this.consumers.delete(consumer.id);
162
+ const peerId = this.producerToPeerMap.get(msg.producerId) || msg.peerId;
163
+ this.events.onRemoteTrack?.(null, peerId, consumer.kind, consumer.appData);
164
+ }
165
+ break;
166
+ }
167
+ case 'error':
168
+ this.events.onError?.(new Error(msg.message));
169
+ break;
170
+ }
171
+ }
172
+ catch (err) {
173
+ this.events.onError?.(err);
174
+ }
175
+ }
176
+ async initializeTransport(msg) {
177
+ if (!this.device)
178
+ return;
179
+ const { id, direction, iceParameters, iceCandidates, dtlsParameters } = msg;
180
+ const transportOptions = {
181
+ id,
182
+ iceParameters,
183
+ iceCandidates,
184
+ dtlsParameters,
185
+ iceServers: this.iceServers,
186
+ };
187
+ const transport = direction === 'send'
188
+ ? this.device.createSendTransport(transportOptions)
189
+ : this.device.createRecvTransport(transportOptions);
190
+ transport.on('connect', ({ dtlsParameters }, callback, errback) => {
191
+ try {
192
+ this.client.connectWebRtcTransport(transport.id, dtlsParameters);
193
+ callback();
194
+ }
195
+ catch (err) {
196
+ errback(err);
197
+ }
198
+ });
199
+ if (direction === 'send') {
200
+ transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
201
+ try {
202
+ this.client.produce(transport.id, kind, rtpParameters, appData);
203
+ this.pendingProduceCallback = { callback, errback };
204
+ }
205
+ catch (err) {
206
+ errback(err);
207
+ }
208
+ });
209
+ this.sendTransport = transport;
210
+ this.transportsReady.send = true;
211
+ }
212
+ else {
213
+ this.recvTransport = transport;
214
+ this.transportsReady.recv = true;
215
+ }
216
+ // Once both transports are up, flush queued publish and consume requests.
217
+ if (this.transportsReady.send && this.transportsReady.recv) {
218
+ // Flush pending local media publish
219
+ const audio = this.pendingPublishAudio;
220
+ const video = this.pendingPublishVideo;
221
+ const audioDeviceId = this.pendingPublishAudioDeviceId;
222
+ const existingStream = this.pendingExistingStream;
223
+ this.pendingPublishAudio = false;
224
+ this.pendingPublishVideo = false;
225
+ this.pendingPublishAudioDeviceId = undefined;
226
+ this.pendingExistingStream = undefined;
227
+ if (audio || video) {
228
+ await this.publishLocalMedia(audio, video, audioDeviceId, existingStream);
229
+ }
230
+ // Flush newProducer events that arrived before transports were ready
231
+ // (late-joiner sync from the server)
232
+ const toConsume = this.pendingConsumes.splice(0);
233
+ for (const { producerId } of toConsume) {
234
+ this.client.consume(producerId, this.device?.rtpCapabilities);
235
+ }
236
+ }
237
+ }
238
+ async initializeConsumer(msg) {
239
+ if (!this.recvTransport)
240
+ return;
241
+ const { id, producerId, kind, rtpParameters, appData } = msg;
242
+ // Queue the consume calls to avoid duplicate mid SDP errors when multiple consumers are created simultaneously
243
+ const consumer = await new Promise((resolve, reject) => {
244
+ this.consumeMutex = this.consumeMutex.then(async () => {
245
+ try {
246
+ if (!this.recvTransport)
247
+ throw new Error("No recv transport");
248
+ const c = await this.recvTransport.consume({
249
+ id,
250
+ producerId,
251
+ kind,
252
+ rtpParameters,
253
+ appData,
254
+ });
255
+ resolve(c);
256
+ }
257
+ catch (err) {
258
+ reject(err);
259
+ }
260
+ });
261
+ });
262
+ this.consumers.set(consumer.id, consumer);
263
+ const peerId = this.producerToPeerMap.get(producerId) || producerId;
264
+ this.events.onRemoteTrack?.(consumer.track, peerId, kind, appData);
265
+ }
266
+ endMeeting() {
267
+ this.client.endMeetingRoom();
268
+ }
269
+ sendTranscriptChunk(text) {
270
+ this.client.sendTranscriptChunk(text);
271
+ }
272
+ sendWhiteboardUpdate(elements) {
273
+ this.client.sendWhiteboardUpdate(elements);
274
+ }
275
+ kickParticipant(peerId) {
276
+ this.client.kickParticipant(peerId);
277
+ }
278
+ toggleMute(kind, muted) {
279
+ this.client.toggleMute(kind, muted);
280
+ }
281
+ async publishTrack(kind, track, appData) {
282
+ if (!this.sendTransport) {
283
+ throw new Error('Send transport not initialized yet.');
284
+ }
285
+ const producerKey = `${kind}-${appData?.source || 'camera'}`;
286
+ const existing = this.producers.get(producerKey);
287
+ if (existing) {
288
+ await existing.replaceTrack({ track });
289
+ }
290
+ else {
291
+ const producer = await this.sendTransport.produce({
292
+ track,
293
+ encodings: kind === 'video'
294
+ ? (appData?.source === 'screen'
295
+ ? [{ maxBitrate: 3500000 }] // Single high-quality layer for screen sharing (up to 3.5 Mbps)
296
+ : [
297
+ { maxBitrate: 100000, scaleResolutionDownBy: 4 }, // Low: 180p
298
+ { maxBitrate: 300000, scaleResolutionDownBy: 2 }, // Medium: 360p
299
+ { maxBitrate: 1500000 }, // High: 720p
300
+ ])
301
+ : undefined,
302
+ appData,
303
+ });
304
+ this.producers.set(producerKey, producer);
305
+ }
306
+ }
307
+ async replaceTrack(kind, track, appData) {
308
+ const producerKey = `${kind}-${appData?.source || 'camera'}`;
309
+ const producer = this.producers.get(producerKey);
310
+ if (producer) {
311
+ await producer.replaceTrack({ track });
312
+ }
313
+ }
314
+ async unpublishTrack(kind, appData) {
315
+ const producerKey = `${kind}-${appData?.source || 'camera'}`;
316
+ const producer = this.producers.get(producerKey);
317
+ if (producer) {
318
+ this.closeProducer(producer.id);
319
+ producer.close();
320
+ this.producers.delete(producerKey);
321
+ }
322
+ }
323
+ closeProducer(producerId) {
324
+ this.client.closeProducer(producerId);
325
+ }
326
+ grantScreenShare(peerId) {
327
+ this.client.grantScreenShare(peerId);
328
+ }
329
+ revokeScreenShare(peerId) {
330
+ this.client.revokeScreenShare(peerId);
331
+ }
332
+ pausePeerVideo(peerId) {
333
+ const consumer = Array.from(this.consumers.values()).find(c => {
334
+ const pId = this.producerToPeerMap.get(c.producerId);
335
+ return pId === peerId && c.kind === 'video' && c.appData?.source !== 'screen';
336
+ });
337
+ if (consumer && !consumer.paused) {
338
+ this.client.pauseConsumer(consumer.id);
339
+ consumer.pause();
340
+ }
341
+ }
342
+ resumePeerVideo(peerId) {
343
+ const consumer = Array.from(this.consumers.values()).find(c => {
344
+ const pId = this.producerToPeerMap.get(c.producerId);
345
+ return pId === peerId && c.kind === 'video' && c.appData?.source !== 'screen';
346
+ });
347
+ if (consumer && consumer.paused) {
348
+ this.client.resumeConsumer(consumer.id);
349
+ consumer.resume();
350
+ }
351
+ }
352
+ resumeAllVideos() {
353
+ for (const consumer of this.consumers.values()) {
354
+ if (consumer.kind === 'video' && consumer.appData?.source !== 'screen') {
355
+ if (consumer.paused) {
356
+ this.client.resumeConsumer(consumer.id);
357
+ consumer.resume();
358
+ }
359
+ }
360
+ }
361
+ }
362
+ leave() {
363
+ if (this.pingInterval) {
364
+ window.clearInterval(this.pingInterval);
365
+ this.pingInterval = null;
366
+ }
367
+ this.client.disconnect();
368
+ // Transport.close() throws AwaitQueueStoppedError if called while
369
+ // produce/consume operations are still pending in the internal queue.
370
+ // Swallow it — the transport is being torn down regardless.
371
+ try {
372
+ this.sendTransport?.close();
373
+ }
374
+ catch (_) { }
375
+ try {
376
+ this.recvTransport?.close();
377
+ }
378
+ catch (_) { }
379
+ this.sendTransport = null;
380
+ this.recvTransport = null;
381
+ this.producers.clear();
382
+ this.consumers.clear();
383
+ this.producerToPeerMap.clear();
384
+ this.transportsReady = { send: false, recv: false };
385
+ }
386
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "covekit",
3
+ "version": "0.1.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "dependencies": {
13
+ "mediasoup-client": "^3.7.0"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.0.0"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsc --watch"
21
+ }
22
+ }