@edge-base/web 0.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.
Files changed (51) hide show
  1. package/README.md +352 -0
  2. package/dist/analytics.d.ts +60 -0
  3. package/dist/analytics.d.ts.map +1 -0
  4. package/dist/analytics.js +146 -0
  5. package/dist/analytics.js.map +1 -0
  6. package/dist/auth-refresh.d.ts +5 -0
  7. package/dist/auth-refresh.d.ts.map +1 -0
  8. package/dist/auth-refresh.js +26 -0
  9. package/dist/auth-refresh.js.map +1 -0
  10. package/dist/auth.d.ts +314 -0
  11. package/dist/auth.d.ts.map +1 -0
  12. package/dist/auth.js +518 -0
  13. package/dist/auth.js.map +1 -0
  14. package/dist/browser-storage.d.ts +7 -0
  15. package/dist/browser-storage.d.ts.map +1 -0
  16. package/dist/browser-storage.js +43 -0
  17. package/dist/browser-storage.js.map +1 -0
  18. package/dist/client.d.ts +145 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +310 -0
  21. package/dist/client.js.map +1 -0
  22. package/dist/database-live.d.ts +65 -0
  23. package/dist/database-live.d.ts.map +1 -0
  24. package/dist/database-live.js +486 -0
  25. package/dist/database-live.js.map +1 -0
  26. package/dist/index.d.ts +21 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +28 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/match-filter.d.ts +30 -0
  31. package/dist/match-filter.d.ts.map +1 -0
  32. package/dist/match-filter.js +86 -0
  33. package/dist/match-filter.js.map +1 -0
  34. package/dist/room-realtime-media.d.ts +96 -0
  35. package/dist/room-realtime-media.d.ts.map +1 -0
  36. package/dist/room-realtime-media.js +418 -0
  37. package/dist/room-realtime-media.js.map +1 -0
  38. package/dist/room.d.ts +450 -0
  39. package/dist/room.d.ts.map +1 -0
  40. package/dist/room.js +1506 -0
  41. package/dist/room.js.map +1 -0
  42. package/dist/token-manager.d.ts +73 -0
  43. package/dist/token-manager.d.ts.map +1 -0
  44. package/dist/token-manager.js +378 -0
  45. package/dist/token-manager.js.map +1 -0
  46. package/dist/turnstile.d.ts +56 -0
  47. package/dist/turnstile.d.ts.map +1 -0
  48. package/dist/turnstile.js +191 -0
  49. package/dist/turnstile.js.map +1 -0
  50. package/llms.txt +549 -0
  51. package/package.json +50 -0
package/dist/room.js ADDED
@@ -0,0 +1,1506 @@
1
+ import { EdgeBaseError } from '@edge-base/core';
2
+ import { refreshAccessToken } from './auth-refresh.js';
3
+ import { RoomRealtimeMediaTransport, } from './room-realtime-media.js';
4
+ // ─── Helpers ───
5
+ function deepSet(obj, path, value) {
6
+ const parts = path.split('.');
7
+ let current = obj;
8
+ for (let i = 0; i < parts.length - 1; i++) {
9
+ const key = parts[i];
10
+ if (typeof current[key] !== 'object' || current[key] === null) {
11
+ current[key] = {};
12
+ }
13
+ current = current[key];
14
+ }
15
+ const lastKey = parts[parts.length - 1];
16
+ if (value === null) {
17
+ delete current[lastKey];
18
+ }
19
+ else {
20
+ current[lastKey] = value;
21
+ }
22
+ }
23
+ function generateRequestId() {
24
+ // Use crypto.randomUUID if available, fallback to simple counter
25
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
26
+ return crypto.randomUUID();
27
+ }
28
+ return `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
29
+ }
30
+ function cloneValue(value) {
31
+ if (typeof structuredClone === 'function') {
32
+ return structuredClone(value);
33
+ }
34
+ return JSON.parse(JSON.stringify(value ?? null));
35
+ }
36
+ function cloneRecord(value) {
37
+ return cloneValue(value);
38
+ }
39
+ const WS_CONNECTING = 0;
40
+ const WS_OPEN = 1;
41
+ const ROOM_EXPLICIT_LEAVE_CLOSE_CODE = 4005;
42
+ const ROOM_EXPLICIT_LEAVE_REASON = 'Client left room';
43
+ const ROOM_EXPLICIT_LEAVE_CLOSE_DELAY_MS = 40;
44
+ function isSocketOpenOrConnecting(socket) {
45
+ return !!socket && (socket.readyState === WS_OPEN || socket.readyState === WS_CONNECTING);
46
+ }
47
+ function closeSocketAfterLeave(socket, reason) {
48
+ globalThis.setTimeout(() => {
49
+ try {
50
+ socket.close(ROOM_EXPLICIT_LEAVE_CLOSE_CODE, reason);
51
+ }
52
+ catch {
53
+ // Socket already closed.
54
+ }
55
+ }, ROOM_EXPLICIT_LEAVE_CLOSE_DELAY_MS);
56
+ }
57
+ // ─── RoomClient v2 ───
58
+ export class RoomClient {
59
+ baseUrl;
60
+ tokenManager;
61
+ options;
62
+ /** Room namespace (e.g. 'game', 'chat') */
63
+ namespace;
64
+ /** Room instance ID within the namespace */
65
+ roomId;
66
+ // ─── State ───
67
+ _sharedState = {};
68
+ _sharedVersion = 0;
69
+ _playerState = {};
70
+ _playerVersion = 0;
71
+ _members = [];
72
+ _mediaMembers = [];
73
+ // ─── Connection ───
74
+ ws = null;
75
+ reconnectAttempts = 0;
76
+ connected = false;
77
+ authenticated = false;
78
+ joined = false;
79
+ currentUserId = null;
80
+ currentConnectionId = null;
81
+ connectionState = 'idle';
82
+ reconnectInfo = null;
83
+ connectingPromise = null;
84
+ heartbeatTimer = null;
85
+ intentionallyLeft = false;
86
+ waitingForAuth = false;
87
+ joinRequested = false;
88
+ unsubAuthState = null;
89
+ // ─── Pending send() requests (requestId → { resolve, reject, timeout }) ───
90
+ pendingRequests = new Map();
91
+ pendingSignalRequests = new Map();
92
+ pendingAdminRequests = new Map();
93
+ pendingMemberStateRequests = new Map();
94
+ pendingMediaRequests = new Map();
95
+ // ─── Subscriptions ───
96
+ sharedStateHandlers = [];
97
+ playerStateHandlers = [];
98
+ messageHandlers = new Map(); // messageType → handlers
99
+ allMessageHandlers = [];
100
+ errorHandlers = [];
101
+ kickedHandlers = [];
102
+ memberSyncHandlers = [];
103
+ memberJoinHandlers = [];
104
+ memberLeaveHandlers = [];
105
+ memberStateHandlers = [];
106
+ signalHandlers = new Map();
107
+ anySignalHandlers = [];
108
+ mediaTrackHandlers = [];
109
+ mediaTrackRemovedHandlers = [];
110
+ mediaStateHandlers = [];
111
+ mediaDeviceHandlers = [];
112
+ reconnectHandlers = [];
113
+ connectionStateHandlers = [];
114
+ state = {
115
+ getShared: () => this.getSharedState(),
116
+ getMine: () => this.getPlayerState(),
117
+ onSharedChange: (handler) => this.onSharedState(handler),
118
+ onMineChange: (handler) => this.onPlayerState(handler),
119
+ send: (actionType, payload) => this.send(actionType, payload),
120
+ };
121
+ meta = {
122
+ get: () => this.getMetadata(),
123
+ };
124
+ signals = {
125
+ send: (event, payload, options) => this.sendSignal(event, payload, options),
126
+ sendTo: (memberId, event, payload) => this.sendSignal(event, payload, { memberId }),
127
+ on: (event, handler) => this.onSignal(event, handler),
128
+ onAny: (handler) => this.onAnySignal(handler),
129
+ };
130
+ members = {
131
+ list: () => cloneValue(this._members),
132
+ onSync: (handler) => this.onMembersSync(handler),
133
+ onJoin: (handler) => this.onMemberJoin(handler),
134
+ onLeave: (handler) => this.onMemberLeave(handler),
135
+ setState: (state) => this.sendMemberState(state),
136
+ clearState: () => this.clearMemberState(),
137
+ onStateChange: (handler) => this.onMemberStateChange(handler),
138
+ };
139
+ admin = {
140
+ kick: (memberId) => this.sendAdmin('kick', memberId),
141
+ mute: (memberId) => this.sendAdmin('mute', memberId),
142
+ block: (memberId) => this.sendAdmin('block', memberId),
143
+ setRole: (memberId, role) => this.sendAdmin('setRole', memberId, { role }),
144
+ disableVideo: (memberId) => this.sendAdmin('disableVideo', memberId),
145
+ stopScreenShare: (memberId) => this.sendAdmin('stopScreenShare', memberId),
146
+ };
147
+ media = {
148
+ list: () => cloneValue(this._mediaMembers),
149
+ audio: {
150
+ enable: (payload) => this.sendMedia('publish', 'audio', payload),
151
+ disable: () => this.sendMedia('unpublish', 'audio'),
152
+ setMuted: (muted) => this.sendMedia('mute', 'audio', { muted }),
153
+ },
154
+ video: {
155
+ enable: (payload) => this.sendMedia('publish', 'video', payload),
156
+ disable: () => this.sendMedia('unpublish', 'video'),
157
+ setMuted: (muted) => this.sendMedia('mute', 'video', { muted }),
158
+ },
159
+ screen: {
160
+ start: (payload) => this.sendMedia('publish', 'screen', payload),
161
+ stop: () => this.sendMedia('unpublish', 'screen'),
162
+ },
163
+ devices: {
164
+ switch: (payload) => this.switchMediaDevices(payload),
165
+ },
166
+ realtime: {
167
+ createSession: (payload) => this.requestRealtimeMedia('session', 'POST', payload),
168
+ getIceServers: (payload) => this.requestRealtimeMedia('turn', 'POST', payload),
169
+ addTracks: (payload) => this.requestRealtimeMedia('tracks/new', 'POST', payload),
170
+ renegotiate: (payload) => this.requestRealtimeMedia('renegotiate', 'PUT', payload),
171
+ closeTracks: (payload) => this.requestRealtimeMedia('tracks/close', 'PUT', payload),
172
+ transport: (options) => new RoomRealtimeMediaTransport(this, options),
173
+ },
174
+ onTrack: (handler) => this.onMediaTrack(handler),
175
+ onTrackRemoved: (handler) => this.onMediaTrackRemoved(handler),
176
+ onStateChange: (handler) => this.onMediaStateChange(handler),
177
+ onDeviceChange: (handler) => this.onMediaDeviceChange(handler),
178
+ };
179
+ session = {
180
+ onError: (handler) => this.onError(handler),
181
+ onKicked: (handler) => this.onKicked(handler),
182
+ onReconnect: (handler) => this.onReconnect(handler),
183
+ onConnectionStateChange: (handler) => this.onConnectionStateChange(handler),
184
+ };
185
+ constructor(baseUrl, namespace, roomId, tokenManager, options) {
186
+ this.baseUrl = baseUrl;
187
+ this.namespace = namespace;
188
+ this.roomId = roomId;
189
+ this.tokenManager = tokenManager;
190
+ this.options = {
191
+ autoReconnect: options?.autoReconnect ?? true,
192
+ maxReconnectAttempts: options?.maxReconnectAttempts ?? 10,
193
+ reconnectBaseDelay: options?.reconnectBaseDelay ?? 1000,
194
+ sendTimeout: options?.sendTimeout ?? 10000,
195
+ };
196
+ this.unsubAuthState = this.tokenManager.onAuthStateChange((user) => {
197
+ this.handleAuthStateChange(user);
198
+ });
199
+ }
200
+ // ─── State Accessors ───
201
+ /** Get current shared state (read-only snapshot) */
202
+ getSharedState() {
203
+ return cloneRecord(this._sharedState);
204
+ }
205
+ /** Get current player state (read-only snapshot) */
206
+ getPlayerState() {
207
+ return cloneRecord(this._playerState);
208
+ }
209
+ // ─── Metadata (HTTP, no WebSocket needed) ───
210
+ /**
211
+ * Get room metadata without joining (HTTP GET).
212
+ * Returns developer-defined metadata set by room.setMetadata() on the server.
213
+ */
214
+ async getMetadata() {
215
+ return RoomClient.getMetadata(this.baseUrl, this.namespace, this.roomId);
216
+ }
217
+ /**
218
+ * Static: Get room metadata without creating a RoomClient instance.
219
+ * Useful for lobby screens where you need room info before joining.
220
+ */
221
+ static async getMetadata(baseUrl, namespace, roomId) {
222
+ const url = `${baseUrl.replace(/\/$/, '')}/api/room/metadata?namespace=${encodeURIComponent(namespace)}&id=${encodeURIComponent(roomId)}`;
223
+ const res = await fetch(url);
224
+ if (!res.ok) {
225
+ throw new EdgeBaseError(res.status, `Failed to get room metadata: ${res.statusText}`);
226
+ }
227
+ return res.json();
228
+ }
229
+ async requestRealtimeMedia(path, method, payload) {
230
+ const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
231
+ if (!token) {
232
+ throw new EdgeBaseError(401, 'Authentication required');
233
+ }
234
+ const url = new URL(`${this.baseUrl.replace(/\/$/, '')}/api/room/media/realtime/${path}`);
235
+ url.searchParams.set('namespace', this.namespace);
236
+ url.searchParams.set('id', this.roomId);
237
+ const response = await fetch(url.toString(), {
238
+ method,
239
+ headers: {
240
+ Authorization: `Bearer ${token}`,
241
+ 'Content-Type': 'application/json',
242
+ },
243
+ body: method === 'GET' ? undefined : JSON.stringify(payload ?? {}),
244
+ });
245
+ const data = (await response.json().catch(() => ({})));
246
+ if (!response.ok) {
247
+ throw new EdgeBaseError(response.status, (typeof data.message === 'string' && data.message) || `Realtime media request failed: ${response.statusText}`);
248
+ }
249
+ return data;
250
+ }
251
+ // ─── Connection Lifecycle ───
252
+ /** Connect to the room, authenticate, and join */
253
+ async join() {
254
+ this.intentionallyLeft = false;
255
+ this.joinRequested = true;
256
+ if (isSocketOpenOrConnecting(this.ws)) {
257
+ return this.connectingPromise ?? Promise.resolve();
258
+ }
259
+ this.setConnectionState(this.reconnectInfo ? 'reconnecting' : 'connecting');
260
+ return this.ensureConnection();
261
+ }
262
+ /** Leave the room and disconnect. Cleans up all pending requests. */
263
+ leave() {
264
+ this.intentionallyLeft = true;
265
+ this.joinRequested = false;
266
+ this.waitingForAuth = false;
267
+ this.stopHeartbeat();
268
+ // Reject all pending send() requests
269
+ for (const [reqId, pending] of this.pendingRequests) {
270
+ clearTimeout(pending.timeout);
271
+ pending.reject(new EdgeBaseError(499, 'Room left'));
272
+ }
273
+ this.pendingRequests.clear();
274
+ this.rejectPendingVoidRequests(this.pendingSignalRequests, new EdgeBaseError(499, 'Room left'));
275
+ this.rejectPendingVoidRequests(this.pendingAdminRequests, new EdgeBaseError(499, 'Room left'));
276
+ this.rejectPendingVoidRequests(this.pendingMemberStateRequests, new EdgeBaseError(499, 'Room left'));
277
+ this.rejectPendingVoidRequests(this.pendingMediaRequests, new EdgeBaseError(499, 'Room left'));
278
+ if (this.ws) {
279
+ const socket = this.ws;
280
+ this.sendRaw({ type: 'leave' });
281
+ closeSocketAfterLeave(socket, ROOM_EXPLICIT_LEAVE_REASON);
282
+ this.ws = null;
283
+ }
284
+ this.connected = false;
285
+ this.authenticated = false;
286
+ this.joined = false;
287
+ this.connectingPromise = null;
288
+ this._sharedState = {};
289
+ this._sharedVersion = 0;
290
+ this._playerState = {};
291
+ this._playerVersion = 0;
292
+ this._members = [];
293
+ this._mediaMembers = [];
294
+ this.currentUserId = null;
295
+ this.currentConnectionId = null;
296
+ this.reconnectInfo = null;
297
+ this.setConnectionState('disconnected');
298
+ }
299
+ // ─── Actions ───
300
+ /**
301
+ * Send an action to the server.
302
+ * Returns a Promise that resolves with the action result from the server.
303
+ *
304
+ * @example
305
+ * const result = await room.send('SET_SCORE', { score: 42 });
306
+ */
307
+ async send(actionType, payload) {
308
+ if (!this.ws || !this.connected || !this.authenticated) {
309
+ throw new EdgeBaseError(400, 'Not connected to room');
310
+ }
311
+ const requestId = generateRequestId();
312
+ return new Promise((resolve, reject) => {
313
+ const timeout = setTimeout(() => {
314
+ this.pendingRequests.delete(requestId);
315
+ reject(new EdgeBaseError(408, `Action '${actionType}' timed out`));
316
+ }, this.options.sendTimeout);
317
+ this.pendingRequests.set(requestId, { resolve, reject, timeout });
318
+ this.sendRaw({
319
+ type: 'send',
320
+ actionType,
321
+ payload: payload ?? {},
322
+ requestId,
323
+ });
324
+ });
325
+ }
326
+ // ─── Subscriptions (v2 API) ───
327
+ /**
328
+ * Subscribe to shared state changes.
329
+ * Called on full sync and on each shared_delta.
330
+ *
331
+ * @returns Subscription with unsubscribe()
332
+ */
333
+ onSharedState(handler) {
334
+ this.sharedStateHandlers.push(handler);
335
+ return {
336
+ unsubscribe: () => {
337
+ const idx = this.sharedStateHandlers.indexOf(handler);
338
+ if (idx >= 0)
339
+ this.sharedStateHandlers.splice(idx, 1);
340
+ },
341
+ };
342
+ }
343
+ /**
344
+ * Subscribe to player state changes.
345
+ * Called on full sync and on each player_delta.
346
+ *
347
+ * @returns Subscription with unsubscribe()
348
+ */
349
+ onPlayerState(handler) {
350
+ this.playerStateHandlers.push(handler);
351
+ return {
352
+ unsubscribe: () => {
353
+ const idx = this.playerStateHandlers.indexOf(handler);
354
+ if (idx >= 0)
355
+ this.playerStateHandlers.splice(idx, 1);
356
+ },
357
+ };
358
+ }
359
+ /**
360
+ * Subscribe to messages of a specific type sent by room.sendMessage().
361
+ *
362
+ * @example
363
+ * room.onMessage('game_over', (data) => { console.log(data.winner); });
364
+ *
365
+ * @returns Subscription with unsubscribe()
366
+ */
367
+ onMessage(messageType, handler) {
368
+ if (!this.messageHandlers.has(messageType)) {
369
+ this.messageHandlers.set(messageType, []);
370
+ }
371
+ this.messageHandlers.get(messageType).push(handler);
372
+ return {
373
+ unsubscribe: () => {
374
+ const handlers = this.messageHandlers.get(messageType);
375
+ if (handlers) {
376
+ const idx = handlers.indexOf(handler);
377
+ if (idx >= 0)
378
+ handlers.splice(idx, 1);
379
+ }
380
+ },
381
+ };
382
+ }
383
+ /**
384
+ * Subscribe to ALL messages regardless of type.
385
+ *
386
+ * @returns Subscription with unsubscribe()
387
+ */
388
+ onAnyMessage(handler) {
389
+ this.allMessageHandlers.push(handler);
390
+ return {
391
+ unsubscribe: () => {
392
+ const idx = this.allMessageHandlers.indexOf(handler);
393
+ if (idx >= 0)
394
+ this.allMessageHandlers.splice(idx, 1);
395
+ },
396
+ };
397
+ }
398
+ /** Subscribe to errors */
399
+ onError(handler) {
400
+ this.errorHandlers.push(handler);
401
+ return {
402
+ unsubscribe: () => {
403
+ const idx = this.errorHandlers.indexOf(handler);
404
+ if (idx >= 0)
405
+ this.errorHandlers.splice(idx, 1);
406
+ },
407
+ };
408
+ }
409
+ /** Subscribe to kick events */
410
+ onKicked(handler) {
411
+ this.kickedHandlers.push(handler);
412
+ return {
413
+ unsubscribe: () => {
414
+ const idx = this.kickedHandlers.indexOf(handler);
415
+ if (idx >= 0)
416
+ this.kickedHandlers.splice(idx, 1);
417
+ },
418
+ };
419
+ }
420
+ onSignal(event, handler) {
421
+ if (!this.signalHandlers.has(event)) {
422
+ this.signalHandlers.set(event, []);
423
+ }
424
+ this.signalHandlers.get(event).push(handler);
425
+ return {
426
+ unsubscribe: () => {
427
+ const handlers = this.signalHandlers.get(event);
428
+ if (!handlers)
429
+ return;
430
+ const index = handlers.indexOf(handler);
431
+ if (index >= 0)
432
+ handlers.splice(index, 1);
433
+ },
434
+ };
435
+ }
436
+ onAnySignal(handler) {
437
+ this.anySignalHandlers.push(handler);
438
+ return {
439
+ unsubscribe: () => {
440
+ const index = this.anySignalHandlers.indexOf(handler);
441
+ if (index >= 0)
442
+ this.anySignalHandlers.splice(index, 1);
443
+ },
444
+ };
445
+ }
446
+ onMembersSync(handler) {
447
+ this.memberSyncHandlers.push(handler);
448
+ return {
449
+ unsubscribe: () => {
450
+ const index = this.memberSyncHandlers.indexOf(handler);
451
+ if (index >= 0)
452
+ this.memberSyncHandlers.splice(index, 1);
453
+ },
454
+ };
455
+ }
456
+ onMemberJoin(handler) {
457
+ this.memberJoinHandlers.push(handler);
458
+ return {
459
+ unsubscribe: () => {
460
+ const index = this.memberJoinHandlers.indexOf(handler);
461
+ if (index >= 0)
462
+ this.memberJoinHandlers.splice(index, 1);
463
+ },
464
+ };
465
+ }
466
+ onMemberLeave(handler) {
467
+ this.memberLeaveHandlers.push(handler);
468
+ return {
469
+ unsubscribe: () => {
470
+ const index = this.memberLeaveHandlers.indexOf(handler);
471
+ if (index >= 0)
472
+ this.memberLeaveHandlers.splice(index, 1);
473
+ },
474
+ };
475
+ }
476
+ onMemberStateChange(handler) {
477
+ this.memberStateHandlers.push(handler);
478
+ return {
479
+ unsubscribe: () => {
480
+ const index = this.memberStateHandlers.indexOf(handler);
481
+ if (index >= 0)
482
+ this.memberStateHandlers.splice(index, 1);
483
+ },
484
+ };
485
+ }
486
+ onReconnect(handler) {
487
+ this.reconnectHandlers.push(handler);
488
+ return {
489
+ unsubscribe: () => {
490
+ const index = this.reconnectHandlers.indexOf(handler);
491
+ if (index >= 0)
492
+ this.reconnectHandlers.splice(index, 1);
493
+ },
494
+ };
495
+ }
496
+ onConnectionStateChange(handler) {
497
+ this.connectionStateHandlers.push(handler);
498
+ return {
499
+ unsubscribe: () => {
500
+ const index = this.connectionStateHandlers.indexOf(handler);
501
+ if (index >= 0)
502
+ this.connectionStateHandlers.splice(index, 1);
503
+ },
504
+ };
505
+ }
506
+ onMediaTrack(handler) {
507
+ this.mediaTrackHandlers.push(handler);
508
+ return {
509
+ unsubscribe: () => {
510
+ const index = this.mediaTrackHandlers.indexOf(handler);
511
+ if (index >= 0)
512
+ this.mediaTrackHandlers.splice(index, 1);
513
+ },
514
+ };
515
+ }
516
+ onMediaTrackRemoved(handler) {
517
+ this.mediaTrackRemovedHandlers.push(handler);
518
+ return {
519
+ unsubscribe: () => {
520
+ const index = this.mediaTrackRemovedHandlers.indexOf(handler);
521
+ if (index >= 0)
522
+ this.mediaTrackRemovedHandlers.splice(index, 1);
523
+ },
524
+ };
525
+ }
526
+ onMediaStateChange(handler) {
527
+ this.mediaStateHandlers.push(handler);
528
+ return {
529
+ unsubscribe: () => {
530
+ const index = this.mediaStateHandlers.indexOf(handler);
531
+ if (index >= 0)
532
+ this.mediaStateHandlers.splice(index, 1);
533
+ },
534
+ };
535
+ }
536
+ onMediaDeviceChange(handler) {
537
+ this.mediaDeviceHandlers.push(handler);
538
+ return {
539
+ unsubscribe: () => {
540
+ const index = this.mediaDeviceHandlers.indexOf(handler);
541
+ if (index >= 0)
542
+ this.mediaDeviceHandlers.splice(index, 1);
543
+ },
544
+ };
545
+ }
546
+ async sendSignal(event, payload, options) {
547
+ if (!this.ws || !this.connected || !this.authenticated) {
548
+ throw new EdgeBaseError(400, 'Not connected to room');
549
+ }
550
+ const requestId = generateRequestId();
551
+ return new Promise((resolve, reject) => {
552
+ const timeout = setTimeout(() => {
553
+ this.pendingSignalRequests.delete(requestId);
554
+ reject(new EdgeBaseError(408, `Signal '${event}' timed out`));
555
+ }, this.options.sendTimeout);
556
+ this.pendingSignalRequests.set(requestId, { resolve, reject, timeout });
557
+ this.sendRaw({
558
+ type: 'signal',
559
+ event,
560
+ payload: payload ?? {},
561
+ includeSelf: options?.includeSelf === true,
562
+ memberId: options?.memberId,
563
+ requestId,
564
+ });
565
+ });
566
+ }
567
+ async sendMemberState(state) {
568
+ return this.sendMemberStateRequest({
569
+ type: 'member_state',
570
+ state,
571
+ });
572
+ }
573
+ async clearMemberState() {
574
+ return this.sendMemberStateRequest({
575
+ type: 'member_state_clear',
576
+ });
577
+ }
578
+ async sendMemberStateRequest(payload) {
579
+ if (!this.ws || !this.connected || !this.authenticated) {
580
+ throw new EdgeBaseError(400, 'Not connected to room');
581
+ }
582
+ const requestId = generateRequestId();
583
+ return new Promise((resolve, reject) => {
584
+ const timeout = setTimeout(() => {
585
+ this.pendingMemberStateRequests.delete(requestId);
586
+ reject(new EdgeBaseError(408, 'Member state update timed out'));
587
+ }, this.options.sendTimeout);
588
+ this.pendingMemberStateRequests.set(requestId, { resolve, reject, timeout });
589
+ this.sendRaw({ ...payload, requestId });
590
+ });
591
+ }
592
+ async sendAdmin(operation, memberId, payload) {
593
+ if (!this.ws || !this.connected || !this.authenticated) {
594
+ throw new EdgeBaseError(400, 'Not connected to room');
595
+ }
596
+ const requestId = generateRequestId();
597
+ return new Promise((resolve, reject) => {
598
+ const timeout = setTimeout(() => {
599
+ this.pendingAdminRequests.delete(requestId);
600
+ reject(new EdgeBaseError(408, `Admin operation '${operation}' timed out`));
601
+ }, this.options.sendTimeout);
602
+ this.pendingAdminRequests.set(requestId, { resolve, reject, timeout });
603
+ this.sendRaw({
604
+ type: 'admin',
605
+ operation,
606
+ memberId,
607
+ payload: payload ?? {},
608
+ requestId,
609
+ });
610
+ });
611
+ }
612
+ async sendMedia(operation, kind, payload) {
613
+ if (!this.ws || !this.connected || !this.authenticated) {
614
+ throw new EdgeBaseError(400, 'Not connected to room');
615
+ }
616
+ const requestId = generateRequestId();
617
+ return new Promise((resolve, reject) => {
618
+ const timeout = setTimeout(() => {
619
+ this.pendingMediaRequests.delete(requestId);
620
+ reject(new EdgeBaseError(408, `Media operation '${operation}' timed out`));
621
+ }, this.options.sendTimeout);
622
+ this.pendingMediaRequests.set(requestId, { resolve, reject, timeout });
623
+ this.sendRaw({
624
+ type: 'media',
625
+ operation,
626
+ kind,
627
+ payload: payload ?? {},
628
+ requestId,
629
+ });
630
+ });
631
+ }
632
+ async switchMediaDevices(payload) {
633
+ const operations = [];
634
+ if (payload.audioInputId) {
635
+ operations.push(this.sendMedia('device', 'audio', { deviceId: payload.audioInputId }));
636
+ }
637
+ if (payload.videoInputId) {
638
+ operations.push(this.sendMedia('device', 'video', { deviceId: payload.videoInputId }));
639
+ }
640
+ if (payload.screenInputId) {
641
+ operations.push(this.sendMedia('device', 'screen', { deviceId: payload.screenInputId }));
642
+ }
643
+ await Promise.all(operations);
644
+ }
645
+ // ─── Private: Connection ───
646
+ async establishConnection() {
647
+ return new Promise((resolve, reject) => {
648
+ const wsUrl = this.buildWsUrl();
649
+ const ws = new WebSocket(wsUrl);
650
+ this.ws = ws;
651
+ ws.onopen = () => {
652
+ this.connected = true;
653
+ this.reconnectAttempts = 0;
654
+ this.startHeartbeat();
655
+ this.authenticate()
656
+ .then(() => {
657
+ this.waitingForAuth = false;
658
+ resolve();
659
+ })
660
+ .catch((error) => {
661
+ this.handleAuthenticationFailure(error);
662
+ reject(error);
663
+ });
664
+ };
665
+ ws.onmessage = (event) => {
666
+ this.handleMessage(event.data);
667
+ };
668
+ ws.onclose = (event) => {
669
+ this.connected = false;
670
+ this.authenticated = false;
671
+ this.joined = false;
672
+ this.ws = null;
673
+ this.stopHeartbeat();
674
+ if (event.code === 4004 && this.connectionState !== 'kicked') {
675
+ this.handleKicked();
676
+ }
677
+ if (!this.intentionallyLeft &&
678
+ !this.waitingForAuth &&
679
+ this.options.autoReconnect &&
680
+ this.reconnectAttempts < this.options.maxReconnectAttempts) {
681
+ this.scheduleReconnect();
682
+ }
683
+ else if (!this.intentionallyLeft && this.connectionState !== 'kicked' && this.connectionState !== 'auth_lost') {
684
+ this.setConnectionState('disconnected');
685
+ }
686
+ };
687
+ ws.onerror = () => {
688
+ reject(new EdgeBaseError(500, 'Room WebSocket connection error'));
689
+ };
690
+ });
691
+ }
692
+ ensureConnection() {
693
+ if (this.connectingPromise) {
694
+ return this.connectingPromise;
695
+ }
696
+ const nextPromise = this.establishConnection().finally(() => {
697
+ if (this.connectingPromise === nextPromise) {
698
+ this.connectingPromise = null;
699
+ }
700
+ });
701
+ this.connectingPromise = nextPromise;
702
+ return nextPromise;
703
+ }
704
+ async authenticate() {
705
+ const token = await this.tokenManager.getAccessToken((refreshToken) => refreshAccessToken(this.baseUrl, refreshToken));
706
+ if (!token) {
707
+ throw new EdgeBaseError(401, 'No access token available. Sign in first.');
708
+ }
709
+ return new Promise((resolve, reject) => {
710
+ const timeout = setTimeout(() => {
711
+ reject(new EdgeBaseError(401, 'Room auth timeout'));
712
+ }, 10000);
713
+ const originalOnMessage = this.ws?.onmessage;
714
+ if (this.ws) {
715
+ this.ws.onmessage = (event) => {
716
+ const msg = JSON.parse(event.data);
717
+ if (msg.type === 'auth_success' || msg.type === 'auth_refreshed') {
718
+ clearTimeout(timeout);
719
+ this.authenticated = true;
720
+ this.currentUserId = typeof msg.userId === 'string' ? msg.userId : this.currentUserId;
721
+ this.currentConnectionId = typeof msg.connectionId === 'string' ? msg.connectionId : this.currentConnectionId;
722
+ if (this.ws)
723
+ this.ws.onmessage = originalOnMessage ?? null;
724
+ // Send join message with last known state for eviction recovery
725
+ this.sendRaw({
726
+ type: 'join',
727
+ lastSharedState: this._sharedState,
728
+ lastSharedVersion: this._sharedVersion,
729
+ lastPlayerState: this._playerState,
730
+ lastPlayerVersion: this._playerVersion,
731
+ });
732
+ this.joined = true;
733
+ resolve();
734
+ }
735
+ else if (msg.type === 'error') {
736
+ clearTimeout(timeout);
737
+ reject(new EdgeBaseError(401, msg.message));
738
+ }
739
+ };
740
+ }
741
+ this.sendRaw({ type: 'auth', token });
742
+ });
743
+ }
744
+ // ─── Private: Message Handling ───
745
+ handleMessage(raw) {
746
+ let msg;
747
+ try {
748
+ msg = JSON.parse(raw);
749
+ }
750
+ catch {
751
+ return;
752
+ }
753
+ const type = msg.type;
754
+ switch (type) {
755
+ case 'auth_success':
756
+ case 'auth_refreshed':
757
+ this.handleAuthAck(msg);
758
+ break;
759
+ case 'sync':
760
+ this.handleSync(msg);
761
+ break;
762
+ case 'shared_delta':
763
+ this.handleSharedDelta(msg);
764
+ break;
765
+ case 'player_delta':
766
+ this.handlePlayerDelta(msg);
767
+ break;
768
+ case 'action_result':
769
+ this.handleActionResult(msg);
770
+ break;
771
+ case 'action_error':
772
+ this.handleActionError(msg);
773
+ break;
774
+ case 'message':
775
+ this.handleServerMessage(msg);
776
+ break;
777
+ case 'signal':
778
+ this.handleSignalFrame(msg);
779
+ break;
780
+ case 'signal_sent':
781
+ this.handleSignalSent(msg);
782
+ break;
783
+ case 'signal_error':
784
+ this.handleSignalError(msg);
785
+ break;
786
+ case 'members_sync':
787
+ this.handleMembersSync(msg);
788
+ break;
789
+ case 'media_sync':
790
+ this.handleMediaSync(msg);
791
+ break;
792
+ case 'member_join':
793
+ this.handleMemberJoinFrame(msg);
794
+ break;
795
+ case 'member_leave':
796
+ this.handleMemberLeaveFrame(msg);
797
+ break;
798
+ case 'member_state':
799
+ this.handleMemberStateFrame(msg);
800
+ break;
801
+ case 'member_state_error':
802
+ this.handleMemberStateError(msg);
803
+ break;
804
+ case 'media_track':
805
+ this.handleMediaTrackFrame(msg);
806
+ break;
807
+ case 'media_track_removed':
808
+ this.handleMediaTrackRemovedFrame(msg);
809
+ break;
810
+ case 'media_state':
811
+ this.handleMediaStateFrame(msg);
812
+ break;
813
+ case 'media_device':
814
+ this.handleMediaDeviceFrame(msg);
815
+ break;
816
+ case 'media_result':
817
+ this.handleMediaResult(msg);
818
+ break;
819
+ case 'media_error':
820
+ this.handleMediaError(msg);
821
+ break;
822
+ case 'admin_result':
823
+ this.handleAdminResult(msg);
824
+ break;
825
+ case 'admin_error':
826
+ this.handleAdminError(msg);
827
+ break;
828
+ case 'kicked':
829
+ this.handleKicked();
830
+ break;
831
+ case 'error':
832
+ this.handleError(msg);
833
+ break;
834
+ case 'pong':
835
+ // Heartbeat response — no action needed
836
+ break;
837
+ }
838
+ }
839
+ handleSync(msg) {
840
+ this._sharedState = msg.sharedState;
841
+ this._sharedVersion = msg.sharedVersion;
842
+ this._playerState = msg.playerState;
843
+ this._playerVersion = msg.playerVersion;
844
+ const reconnectInfo = this.reconnectInfo;
845
+ this.reconnectInfo = null;
846
+ this.setConnectionState('connected');
847
+ // Notify handlers with full state as changes
848
+ const sharedSnapshot = cloneRecord(this._sharedState);
849
+ const playerSnapshot = cloneRecord(this._playerState);
850
+ for (const handler of this.sharedStateHandlers) {
851
+ handler(sharedSnapshot, cloneRecord(sharedSnapshot));
852
+ }
853
+ for (const handler of this.playerStateHandlers) {
854
+ handler(playerSnapshot, cloneRecord(playerSnapshot));
855
+ }
856
+ if (reconnectInfo) {
857
+ for (const handler of this.reconnectHandlers) {
858
+ handler(reconnectInfo);
859
+ }
860
+ }
861
+ }
862
+ handleSharedDelta(msg) {
863
+ const delta = msg.delta;
864
+ this._sharedVersion = msg.version;
865
+ // Apply delta to local state
866
+ for (const [path, value] of Object.entries(delta)) {
867
+ deepSet(this._sharedState, path, value);
868
+ }
869
+ const sharedSnapshot = cloneRecord(this._sharedState);
870
+ const deltaSnapshot = cloneRecord(delta);
871
+ for (const handler of this.sharedStateHandlers) {
872
+ handler(sharedSnapshot, deltaSnapshot);
873
+ }
874
+ }
875
+ handlePlayerDelta(msg) {
876
+ const delta = msg.delta;
877
+ this._playerVersion = msg.version;
878
+ // Apply delta to local player state
879
+ for (const [path, value] of Object.entries(delta)) {
880
+ deepSet(this._playerState, path, value);
881
+ }
882
+ const playerSnapshot = cloneRecord(this._playerState);
883
+ const deltaSnapshot = cloneRecord(delta);
884
+ for (const handler of this.playerStateHandlers) {
885
+ handler(playerSnapshot, deltaSnapshot);
886
+ }
887
+ }
888
+ handleActionResult(msg) {
889
+ const requestId = msg.requestId;
890
+ const pending = this.pendingRequests.get(requestId);
891
+ if (pending) {
892
+ clearTimeout(pending.timeout);
893
+ this.pendingRequests.delete(requestId);
894
+ pending.resolve(msg.result);
895
+ }
896
+ }
897
+ handleActionError(msg) {
898
+ const requestId = msg.requestId;
899
+ const pending = this.pendingRequests.get(requestId);
900
+ if (pending) {
901
+ clearTimeout(pending.timeout);
902
+ this.pendingRequests.delete(requestId);
903
+ pending.reject(new EdgeBaseError(400, msg.message));
904
+ }
905
+ }
906
+ handleAuthAck(msg) {
907
+ this.currentUserId = typeof msg.userId === 'string' ? msg.userId : this.currentUserId;
908
+ this.currentConnectionId =
909
+ typeof msg.connectionId === 'string' ? msg.connectionId : this.currentConnectionId;
910
+ }
911
+ handleServerMessage(msg) {
912
+ const messageType = msg.messageType;
913
+ const data = msg.data;
914
+ // Type-specific handlers
915
+ const handlers = this.messageHandlers.get(messageType);
916
+ if (handlers) {
917
+ for (const handler of handlers)
918
+ handler(data);
919
+ }
920
+ // All-message handlers
921
+ for (const handler of this.allMessageHandlers) {
922
+ handler(messageType, data);
923
+ }
924
+ }
925
+ handleSignalFrame(msg) {
926
+ const event = typeof msg.event === 'string' ? msg.event : '';
927
+ if (!event)
928
+ return;
929
+ const meta = this.normalizeSignalMeta(msg.meta);
930
+ const payload = msg.payload;
931
+ const handlers = this.signalHandlers.get(event);
932
+ if (handlers) {
933
+ for (const handler of handlers)
934
+ handler(payload, meta);
935
+ }
936
+ for (const handler of this.anySignalHandlers) {
937
+ handler(event, payload, meta);
938
+ }
939
+ }
940
+ handleSignalSent(msg) {
941
+ const requestId = msg.requestId;
942
+ if (!requestId)
943
+ return;
944
+ const pending = this.pendingSignalRequests.get(requestId);
945
+ if (!pending)
946
+ return;
947
+ clearTimeout(pending.timeout);
948
+ this.pendingSignalRequests.delete(requestId);
949
+ pending.resolve();
950
+ }
951
+ handleSignalError(msg) {
952
+ const requestId = msg.requestId;
953
+ if (!requestId)
954
+ return;
955
+ const pending = this.pendingSignalRequests.get(requestId);
956
+ if (!pending)
957
+ return;
958
+ clearTimeout(pending.timeout);
959
+ this.pendingSignalRequests.delete(requestId);
960
+ pending.reject(new EdgeBaseError(400, msg.message || 'Signal failed'));
961
+ }
962
+ handleMembersSync(msg) {
963
+ const members = this.normalizeMembers(msg.members);
964
+ this._members = members;
965
+ for (const member of members) {
966
+ this.syncMediaMemberInfo(member);
967
+ }
968
+ const snapshot = cloneValue(this._members);
969
+ for (const handler of this.memberSyncHandlers) {
970
+ handler(snapshot);
971
+ }
972
+ }
973
+ handleMediaSync(msg) {
974
+ this._mediaMembers = this.normalizeMediaMembers(msg.members);
975
+ }
976
+ handleMemberJoinFrame(msg) {
977
+ const member = this.normalizeMember(msg.member);
978
+ if (!member)
979
+ return;
980
+ this.upsertMember(member);
981
+ this.syncMediaMemberInfo(member);
982
+ const snapshot = cloneValue(member);
983
+ for (const handler of this.memberJoinHandlers) {
984
+ handler(snapshot);
985
+ }
986
+ }
987
+ handleMemberLeaveFrame(msg) {
988
+ const member = this.normalizeMember(msg.member);
989
+ if (!member)
990
+ return;
991
+ this.removeMember(member.memberId);
992
+ this.removeMediaMember(member.memberId);
993
+ const reason = this.normalizeLeaveReason(msg.reason);
994
+ const snapshot = cloneValue(member);
995
+ for (const handler of this.memberLeaveHandlers) {
996
+ handler(snapshot, reason);
997
+ }
998
+ }
999
+ handleMemberStateFrame(msg) {
1000
+ const member = this.normalizeMember(msg.member);
1001
+ const state = this.normalizeState(msg.state);
1002
+ if (!member)
1003
+ return;
1004
+ member.state = state;
1005
+ this.upsertMember(member);
1006
+ this.syncMediaMemberInfo(member);
1007
+ const requestId = msg.requestId;
1008
+ if (requestId && member.memberId === this.currentUserId) {
1009
+ const pending = this.pendingMemberStateRequests.get(requestId);
1010
+ if (pending) {
1011
+ clearTimeout(pending.timeout);
1012
+ this.pendingMemberStateRequests.delete(requestId);
1013
+ pending.resolve();
1014
+ }
1015
+ }
1016
+ const memberSnapshot = cloneValue(member);
1017
+ const stateSnapshot = cloneRecord(state);
1018
+ for (const handler of this.memberStateHandlers) {
1019
+ handler(memberSnapshot, stateSnapshot);
1020
+ }
1021
+ }
1022
+ handleMemberStateError(msg) {
1023
+ const requestId = msg.requestId;
1024
+ if (!requestId)
1025
+ return;
1026
+ const pending = this.pendingMemberStateRequests.get(requestId);
1027
+ if (!pending)
1028
+ return;
1029
+ clearTimeout(pending.timeout);
1030
+ this.pendingMemberStateRequests.delete(requestId);
1031
+ pending.reject(new EdgeBaseError(400, msg.message || 'Member state update failed'));
1032
+ }
1033
+ handleMediaTrackFrame(msg) {
1034
+ const member = this.normalizeMember(msg.member);
1035
+ const track = this.normalizeMediaTrack(msg.track);
1036
+ if (!member || !track)
1037
+ return;
1038
+ const mediaMember = this.ensureMediaMember(member);
1039
+ this.upsertMediaTrack(mediaMember, track);
1040
+ this.mergeMediaState(mediaMember, track.kind, {
1041
+ published: true,
1042
+ muted: track.muted,
1043
+ trackId: track.trackId,
1044
+ deviceId: track.deviceId,
1045
+ publishedAt: track.publishedAt,
1046
+ adminDisabled: track.adminDisabled,
1047
+ providerSessionId: track.providerSessionId,
1048
+ });
1049
+ const memberSnapshot = cloneValue(mediaMember.member);
1050
+ const trackSnapshot = cloneValue(track);
1051
+ for (const handler of this.mediaTrackHandlers) {
1052
+ handler(trackSnapshot, memberSnapshot);
1053
+ }
1054
+ }
1055
+ handleMediaTrackRemovedFrame(msg) {
1056
+ const member = this.normalizeMember(msg.member);
1057
+ const track = this.normalizeMediaTrack(msg.track);
1058
+ if (!member || !track)
1059
+ return;
1060
+ const mediaMember = this.ensureMediaMember(member);
1061
+ this.removeMediaTrack(mediaMember, track);
1062
+ mediaMember.state = {
1063
+ ...mediaMember.state,
1064
+ [track.kind]: {
1065
+ published: false,
1066
+ muted: false,
1067
+ adminDisabled: false,
1068
+ providerSessionId: undefined,
1069
+ },
1070
+ };
1071
+ const memberSnapshot = cloneValue(mediaMember.member);
1072
+ const trackSnapshot = cloneValue(track);
1073
+ for (const handler of this.mediaTrackRemovedHandlers) {
1074
+ handler(trackSnapshot, memberSnapshot);
1075
+ }
1076
+ }
1077
+ handleMediaStateFrame(msg) {
1078
+ const member = this.normalizeMember(msg.member);
1079
+ if (!member)
1080
+ return;
1081
+ const mediaMember = this.ensureMediaMember(member);
1082
+ mediaMember.state = this.normalizeMediaState(msg.state);
1083
+ const memberSnapshot = cloneValue(mediaMember.member);
1084
+ const stateSnapshot = cloneValue(mediaMember.state);
1085
+ for (const handler of this.mediaStateHandlers) {
1086
+ handler(memberSnapshot, stateSnapshot);
1087
+ }
1088
+ }
1089
+ handleMediaDeviceFrame(msg) {
1090
+ const member = this.normalizeMember(msg.member);
1091
+ const kind = this.normalizeMediaKind(msg.kind);
1092
+ const deviceId = typeof msg.deviceId === 'string' ? msg.deviceId : '';
1093
+ if (!member || !kind || !deviceId)
1094
+ return;
1095
+ const mediaMember = this.ensureMediaMember(member);
1096
+ this.mergeMediaState(mediaMember, kind, { deviceId });
1097
+ for (const track of mediaMember.tracks) {
1098
+ if (track.kind === kind) {
1099
+ track.deviceId = deviceId;
1100
+ }
1101
+ }
1102
+ const memberSnapshot = cloneValue(mediaMember.member);
1103
+ const change = { kind, deviceId };
1104
+ for (const handler of this.mediaDeviceHandlers) {
1105
+ handler(memberSnapshot, change);
1106
+ }
1107
+ }
1108
+ handleMediaResult(msg) {
1109
+ const requestId = msg.requestId;
1110
+ if (!requestId)
1111
+ return;
1112
+ const pending = this.pendingMediaRequests.get(requestId);
1113
+ if (!pending)
1114
+ return;
1115
+ clearTimeout(pending.timeout);
1116
+ this.pendingMediaRequests.delete(requestId);
1117
+ pending.resolve();
1118
+ }
1119
+ handleMediaError(msg) {
1120
+ const requestId = msg.requestId;
1121
+ if (!requestId)
1122
+ return;
1123
+ const pending = this.pendingMediaRequests.get(requestId);
1124
+ if (!pending)
1125
+ return;
1126
+ clearTimeout(pending.timeout);
1127
+ this.pendingMediaRequests.delete(requestId);
1128
+ pending.reject(new EdgeBaseError(400, msg.message || 'Media operation failed'));
1129
+ }
1130
+ handleAdminResult(msg) {
1131
+ const requestId = msg.requestId;
1132
+ if (!requestId)
1133
+ return;
1134
+ const pending = this.pendingAdminRequests.get(requestId);
1135
+ if (!pending)
1136
+ return;
1137
+ clearTimeout(pending.timeout);
1138
+ this.pendingAdminRequests.delete(requestId);
1139
+ pending.resolve();
1140
+ }
1141
+ handleAdminError(msg) {
1142
+ const requestId = msg.requestId;
1143
+ if (!requestId)
1144
+ return;
1145
+ const pending = this.pendingAdminRequests.get(requestId);
1146
+ if (!pending)
1147
+ return;
1148
+ clearTimeout(pending.timeout);
1149
+ this.pendingAdminRequests.delete(requestId);
1150
+ pending.reject(new EdgeBaseError(400, msg.message || 'Admin operation failed'));
1151
+ }
1152
+ handleKicked() {
1153
+ for (const handler of this.kickedHandlers)
1154
+ handler();
1155
+ // Don't auto-reconnect after being kicked
1156
+ this.intentionallyLeft = true;
1157
+ this.reconnectInfo = null;
1158
+ this.setConnectionState('kicked');
1159
+ }
1160
+ handleError(msg) {
1161
+ for (const handler of this.errorHandlers) {
1162
+ handler({ code: msg.code, message: msg.message });
1163
+ }
1164
+ }
1165
+ refreshAuth() {
1166
+ const token = this.tokenManager.currentAccessToken;
1167
+ if (!token || !this.ws || !this.connected)
1168
+ return;
1169
+ this.sendRaw({ type: 'auth', token });
1170
+ }
1171
+ handleAuthStateChange(user) {
1172
+ if (user) {
1173
+ if (this.ws && this.connected && this.authenticated) {
1174
+ this.refreshAuth();
1175
+ return;
1176
+ }
1177
+ this.waitingForAuth = false;
1178
+ if (this.joinRequested
1179
+ && !this.connectingPromise
1180
+ && !isSocketOpenOrConnecting(this.ws)) {
1181
+ this.reconnectAttempts = 0;
1182
+ this.ensureConnection().catch(() => {
1183
+ // Connection errors are surfaced through the normal socket lifecycle.
1184
+ });
1185
+ }
1186
+ return;
1187
+ }
1188
+ this.waitingForAuth = this.joinRequested;
1189
+ this.reconnectInfo = null;
1190
+ this.setConnectionState('auth_lost');
1191
+ if (this.ws) {
1192
+ const socket = this.ws;
1193
+ this.sendRaw({ type: 'leave' });
1194
+ this.stopHeartbeat();
1195
+ this.ws = null;
1196
+ this.connected = false;
1197
+ this.authenticated = false;
1198
+ this.joined = false;
1199
+ this._mediaMembers = [];
1200
+ this.currentUserId = null;
1201
+ this.currentConnectionId = null;
1202
+ try {
1203
+ closeSocketAfterLeave(socket, 'Signed out');
1204
+ }
1205
+ catch {
1206
+ // Ignore close failures — socket is already unusable.
1207
+ }
1208
+ return;
1209
+ }
1210
+ this.connected = false;
1211
+ this.authenticated = false;
1212
+ this.joined = false;
1213
+ this._mediaMembers = [];
1214
+ }
1215
+ handleAuthenticationFailure(error) {
1216
+ const authError = error instanceof EdgeBaseError
1217
+ ? error
1218
+ : new EdgeBaseError(500, 'Room authentication failed.');
1219
+ this.waitingForAuth = authError.code === 401 && this.joinRequested;
1220
+ this.stopHeartbeat();
1221
+ this.connected = false;
1222
+ this.authenticated = false;
1223
+ this.joined = false;
1224
+ this.connectingPromise = null;
1225
+ if (this.ws) {
1226
+ const socket = this.ws;
1227
+ this.ws = null;
1228
+ try {
1229
+ socket.close(4001, authError.message);
1230
+ }
1231
+ catch {
1232
+ // Ignore close failures — the server will time out stale sockets.
1233
+ }
1234
+ }
1235
+ }
1236
+ normalizeMembers(value) {
1237
+ if (!Array.isArray(value)) {
1238
+ return [];
1239
+ }
1240
+ return value
1241
+ .map((member) => this.normalizeMember(member))
1242
+ .filter((member) => !!member);
1243
+ }
1244
+ normalizeMediaMembers(value) {
1245
+ if (!Array.isArray(value)) {
1246
+ return [];
1247
+ }
1248
+ return value
1249
+ .map((member) => this.normalizeMediaMember(member))
1250
+ .filter((member) => !!member);
1251
+ }
1252
+ normalizeMember(value) {
1253
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1254
+ return null;
1255
+ }
1256
+ const member = value;
1257
+ if (typeof member.memberId !== 'string' || typeof member.userId !== 'string') {
1258
+ return null;
1259
+ }
1260
+ return {
1261
+ memberId: member.memberId,
1262
+ userId: member.userId,
1263
+ connectionId: typeof member.connectionId === 'string' ? member.connectionId : undefined,
1264
+ connectionCount: typeof member.connectionCount === 'number' ? member.connectionCount : undefined,
1265
+ role: typeof member.role === 'string' ? member.role : undefined,
1266
+ state: this.normalizeState(member.state),
1267
+ };
1268
+ }
1269
+ normalizeState(value) {
1270
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1271
+ return {};
1272
+ }
1273
+ return cloneRecord(value);
1274
+ }
1275
+ normalizeMediaMember(value) {
1276
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1277
+ return null;
1278
+ }
1279
+ const entry = value;
1280
+ const member = this.normalizeMember(entry.member);
1281
+ if (!member) {
1282
+ return null;
1283
+ }
1284
+ return {
1285
+ member,
1286
+ state: this.normalizeMediaState(entry.state),
1287
+ tracks: this.normalizeMediaTracks(entry.tracks),
1288
+ };
1289
+ }
1290
+ normalizeMediaState(value) {
1291
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1292
+ return {};
1293
+ }
1294
+ const state = value;
1295
+ return {
1296
+ audio: this.normalizeMediaKindState(state.audio),
1297
+ video: this.normalizeMediaKindState(state.video),
1298
+ screen: this.normalizeMediaKindState(state.screen),
1299
+ };
1300
+ }
1301
+ normalizeMediaKindState(value) {
1302
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1303
+ return undefined;
1304
+ }
1305
+ const state = value;
1306
+ return {
1307
+ published: state.published === true,
1308
+ muted: state.muted === true,
1309
+ trackId: typeof state.trackId === 'string' ? state.trackId : undefined,
1310
+ deviceId: typeof state.deviceId === 'string' ? state.deviceId : undefined,
1311
+ publishedAt: typeof state.publishedAt === 'number' ? state.publishedAt : undefined,
1312
+ adminDisabled: state.adminDisabled === true,
1313
+ providerSessionId: typeof state.providerSessionId === 'string' ? state.providerSessionId : undefined,
1314
+ };
1315
+ }
1316
+ normalizeMediaTracks(value) {
1317
+ if (!Array.isArray(value)) {
1318
+ return [];
1319
+ }
1320
+ return value
1321
+ .map((track) => this.normalizeMediaTrack(track))
1322
+ .filter((track) => !!track);
1323
+ }
1324
+ normalizeMediaTrack(value) {
1325
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1326
+ return null;
1327
+ }
1328
+ const track = value;
1329
+ const kind = this.normalizeMediaKind(track.kind);
1330
+ if (!kind) {
1331
+ return null;
1332
+ }
1333
+ return {
1334
+ kind,
1335
+ trackId: typeof track.trackId === 'string' ? track.trackId : undefined,
1336
+ deviceId: typeof track.deviceId === 'string' ? track.deviceId : undefined,
1337
+ muted: track.muted === true,
1338
+ publishedAt: typeof track.publishedAt === 'number' ? track.publishedAt : undefined,
1339
+ adminDisabled: track.adminDisabled === true,
1340
+ providerSessionId: typeof track.providerSessionId === 'string' ? track.providerSessionId : undefined,
1341
+ };
1342
+ }
1343
+ normalizeMediaKind(value) {
1344
+ switch (value) {
1345
+ case 'audio':
1346
+ case 'video':
1347
+ case 'screen':
1348
+ return value;
1349
+ default:
1350
+ return null;
1351
+ }
1352
+ }
1353
+ normalizeSignalMeta(value) {
1354
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1355
+ return {};
1356
+ }
1357
+ const meta = value;
1358
+ return {
1359
+ memberId: typeof meta.memberId === 'string' || meta.memberId === null ? meta.memberId : undefined,
1360
+ userId: typeof meta.userId === 'string' || meta.userId === null ? meta.userId : undefined,
1361
+ connectionId: typeof meta.connectionId === 'string' || meta.connectionId === null
1362
+ ? meta.connectionId
1363
+ : undefined,
1364
+ sentAt: typeof meta.sentAt === 'number' ? meta.sentAt : undefined,
1365
+ serverSent: meta.serverSent === true,
1366
+ };
1367
+ }
1368
+ normalizeLeaveReason(value) {
1369
+ switch (value) {
1370
+ case 'leave':
1371
+ case 'timeout':
1372
+ case 'kicked':
1373
+ return value;
1374
+ default:
1375
+ return 'leave';
1376
+ }
1377
+ }
1378
+ upsertMember(member) {
1379
+ const index = this._members.findIndex((entry) => entry.memberId === member.memberId);
1380
+ if (index >= 0) {
1381
+ this._members[index] = cloneValue(member);
1382
+ return;
1383
+ }
1384
+ this._members.push(cloneValue(member));
1385
+ }
1386
+ removeMember(memberId) {
1387
+ this._members = this._members.filter((member) => member.memberId !== memberId);
1388
+ }
1389
+ syncMediaMemberInfo(member) {
1390
+ const mediaMember = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1391
+ if (!mediaMember) {
1392
+ return;
1393
+ }
1394
+ mediaMember.member = cloneValue(member);
1395
+ }
1396
+ ensureMediaMember(member) {
1397
+ const existing = this._mediaMembers.find((entry) => entry.member.memberId === member.memberId);
1398
+ if (existing) {
1399
+ existing.member = cloneValue(member);
1400
+ return existing;
1401
+ }
1402
+ const created = {
1403
+ member: cloneValue(member),
1404
+ state: {},
1405
+ tracks: [],
1406
+ };
1407
+ this._mediaMembers.push(created);
1408
+ return created;
1409
+ }
1410
+ removeMediaMember(memberId) {
1411
+ this._mediaMembers = this._mediaMembers.filter((member) => member.member.memberId !== memberId);
1412
+ }
1413
+ upsertMediaTrack(mediaMember, track) {
1414
+ const index = mediaMember.tracks.findIndex((entry) => entry.kind === track.kind &&
1415
+ entry.trackId === track.trackId);
1416
+ if (index >= 0) {
1417
+ mediaMember.tracks[index] = cloneValue(track);
1418
+ return;
1419
+ }
1420
+ mediaMember.tracks = mediaMember.tracks
1421
+ .filter((entry) => !(entry.kind === track.kind && !track.trackId))
1422
+ .concat(cloneValue(track));
1423
+ }
1424
+ removeMediaTrack(mediaMember, track) {
1425
+ mediaMember.tracks = mediaMember.tracks.filter((entry) => {
1426
+ if (track.trackId) {
1427
+ return !(entry.kind === track.kind && entry.trackId === track.trackId);
1428
+ }
1429
+ return entry.kind !== track.kind;
1430
+ });
1431
+ }
1432
+ mergeMediaState(mediaMember, kind, partial) {
1433
+ const next = {
1434
+ published: partial.published ?? mediaMember.state[kind]?.published ?? false,
1435
+ muted: partial.muted ?? mediaMember.state[kind]?.muted ?? false,
1436
+ trackId: partial.trackId ?? mediaMember.state[kind]?.trackId,
1437
+ deviceId: partial.deviceId ?? mediaMember.state[kind]?.deviceId,
1438
+ publishedAt: partial.publishedAt ?? mediaMember.state[kind]?.publishedAt,
1439
+ adminDisabled: partial.adminDisabled ?? mediaMember.state[kind]?.adminDisabled,
1440
+ providerSessionId: partial.providerSessionId ?? mediaMember.state[kind]?.providerSessionId,
1441
+ };
1442
+ mediaMember.state = {
1443
+ ...mediaMember.state,
1444
+ [kind]: next,
1445
+ };
1446
+ }
1447
+ rejectPendingVoidRequests(pendingRequests, error) {
1448
+ for (const [, pending] of pendingRequests) {
1449
+ clearTimeout(pending.timeout);
1450
+ pending.reject(error);
1451
+ }
1452
+ pendingRequests.clear();
1453
+ }
1454
+ setConnectionState(next) {
1455
+ if (this.connectionState === next) {
1456
+ return;
1457
+ }
1458
+ this.connectionState = next;
1459
+ for (const handler of this.connectionStateHandlers) {
1460
+ handler(next);
1461
+ }
1462
+ }
1463
+ // ─── Private: Helpers ───
1464
+ sendRaw(data) {
1465
+ if (this.ws && this.connected) {
1466
+ this.ws.send(JSON.stringify(data));
1467
+ return;
1468
+ }
1469
+ }
1470
+ buildWsUrl() {
1471
+ const httpUrl = this.baseUrl.replace(/\/$/, '');
1472
+ const wsUrl = httpUrl.replace(/^http/, 'ws');
1473
+ return `${wsUrl}/api/room?namespace=${encodeURIComponent(this.namespace)}&id=${encodeURIComponent(this.roomId)}`;
1474
+ }
1475
+ scheduleReconnect() {
1476
+ const attempt = this.reconnectAttempts + 1;
1477
+ const delay = this.options.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts);
1478
+ this.reconnectAttempts++;
1479
+ this.reconnectInfo = { attempt };
1480
+ this.setConnectionState('reconnecting');
1481
+ setTimeout(() => {
1482
+ if (this.connectingPromise
1483
+ || !this.joinRequested
1484
+ || this.waitingForAuth
1485
+ || isSocketOpenOrConnecting(this.ws)) {
1486
+ return;
1487
+ }
1488
+ this.ensureConnection().catch(() => { });
1489
+ }, Math.min(delay, 30000));
1490
+ }
1491
+ startHeartbeat() {
1492
+ this.stopHeartbeat();
1493
+ this.heartbeatTimer = setInterval(() => {
1494
+ if (this.ws && this.connected) {
1495
+ this.ws.send(JSON.stringify({ type: 'ping' }));
1496
+ }
1497
+ }, 30000);
1498
+ }
1499
+ stopHeartbeat() {
1500
+ if (this.heartbeatTimer) {
1501
+ clearInterval(this.heartbeatTimer);
1502
+ this.heartbeatTimer = null;
1503
+ }
1504
+ }
1505
+ }
1506
+ //# sourceMappingURL=room.js.map