openrtc 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.
Files changed (122) hide show
  1. package/README.md +106 -0
  2. package/dist/OpenRTC.d.ts +71 -0
  3. package/dist/OpenRTC.js +130 -0
  4. package/dist/api/MediaTransport.d.ts +7 -0
  5. package/dist/api/MediaTransport.js +262 -0
  6. package/dist/api/PlutoPeerConnection.d.ts +50 -0
  7. package/dist/api/PlutoPeerConnection.js +434 -0
  8. package/dist/api/PlutoWebSocket.d.ts +24 -0
  9. package/dist/api/PlutoWebSocket.js +113 -0
  10. package/dist/api/PlutoWebTransport.d.ts +29 -0
  11. package/dist/api/PlutoWebTransport.js +105 -0
  12. package/dist/auth/host.d.ts +73 -0
  13. package/dist/auth/host.js +513 -0
  14. package/dist/auth/index.d.ts +1 -0
  15. package/dist/auth/index.js +1 -0
  16. package/dist/auth/internal.d.ts +1 -0
  17. package/dist/auth/internal.js +1 -0
  18. package/dist/compat/index.d.ts +20 -0
  19. package/dist/compat/index.js +20 -0
  20. package/dist/core/Client.d.ts +352 -0
  21. package/dist/core/Client.js +3689 -0
  22. package/dist/core/Connection.d.ts +67 -0
  23. package/dist/core/Connection.js +404 -0
  24. package/dist/core/IEngineBridge.d.ts +18 -0
  25. package/dist/core/IEngineBridge.js +1 -0
  26. package/dist/core/ISignalingTransport.d.ts +13 -0
  27. package/dist/core/ISignalingTransport.js +1 -0
  28. package/dist/core/PeerLifecycleManager.d.ts +43 -0
  29. package/dist/core/PeerLifecycleManager.js +330 -0
  30. package/dist/core/PeerProjectionStore.d.ts +43 -0
  31. package/dist/core/PeerProjectionStore.js +327 -0
  32. package/dist/core/PlutoRTC.d.ts +13 -0
  33. package/dist/core/PlutoRTC.js +26 -0
  34. package/dist/core/Room.d.ts +47 -0
  35. package/dist/core/Room.js +376 -0
  36. package/dist/core/Signaling.d.ts +39 -0
  37. package/dist/core/Signaling.js +147 -0
  38. package/dist/core/bridges/BridgeCodecs.d.ts +19 -0
  39. package/dist/core/bridges/BridgeCodecs.js +317 -0
  40. package/dist/core/bridges/BrowserFirestoreRooms.d.ts +36 -0
  41. package/dist/core/bridges/BrowserFirestoreRooms.js +169 -0
  42. package/dist/core/bridges/BrowserFirestoreSignaling.d.ts +111 -0
  43. package/dist/core/bridges/BrowserFirestoreSignaling.js +1010 -0
  44. package/dist/core/bridges/NativeIpcBridge.d.ts +156 -0
  45. package/dist/core/bridges/NativeIpcBridge.js +1472 -0
  46. package/dist/core/bridges/RtdbPresence.d.ts +122 -0
  47. package/dist/core/bridges/RtdbPresence.js +201 -0
  48. package/dist/core/bridges/TauriBridge.d.ts +8 -0
  49. package/dist/core/bridges/TauriBridge.js +7 -0
  50. package/dist/core/bridges/WasmBridge.d.ts +233 -0
  51. package/dist/core/bridges/WasmBridge.js +1811 -0
  52. package/dist/core/config.d.ts +31 -0
  53. package/dist/core/config.js +138 -0
  54. package/dist/core/transports/ITransport.d.ts +12 -0
  55. package/dist/core/transports/ITransport.js +1 -0
  56. package/dist/core/transports/LocalDaemonBridge.d.ts +41 -0
  57. package/dist/core/transports/LocalDaemonBridge.js +227 -0
  58. package/dist/core/transports/TauriSignaling.d.ts +28 -0
  59. package/dist/core/transports/TauriSignaling.js +373 -0
  60. package/dist/core/transports/TransportFactory.d.ts +9 -0
  61. package/dist/core/transports/TransportFactory.js +59 -0
  62. package/dist/core/transports/WasmSignaling.d.ts +78 -0
  63. package/dist/core/transports/WasmSignaling.js +522 -0
  64. package/dist/core/transports/WebRTCTransport.d.ts +23 -0
  65. package/dist/core/transports/WebRTCTransport.js +132 -0
  66. package/dist/core/trust.d.ts +23 -0
  67. package/dist/core/trust.js +99 -0
  68. package/dist/core/types.d.ts +559 -0
  69. package/dist/core/types.js +19 -0
  70. package/dist/index.d.ts +3 -0
  71. package/dist/index.js +3 -0
  72. package/dist/moq/MoQMediaTransport.d.ts +6 -0
  73. package/dist/moq/MoQMediaTransport.js +106 -0
  74. package/dist/moq/MoQPublisher.d.ts +22 -0
  75. package/dist/moq/MoQPublisher.js +179 -0
  76. package/dist/moq/MoQSession.d.ts +35 -0
  77. package/dist/moq/MoQSession.js +166 -0
  78. package/dist/moq/MoQSubscriber.d.ts +19 -0
  79. package/dist/moq/MoQSubscriber.js +235 -0
  80. package/dist/moq/common.d.ts +47 -0
  81. package/dist/moq/common.js +304 -0
  82. package/dist/moq/messages.d.ts +105 -0
  83. package/dist/moq/messages.js +367 -0
  84. package/dist/presets/plutoHosted.d.ts +13 -0
  85. package/dist/presets/plutoHosted.js +18 -0
  86. package/dist/runtime/DelegatingRuntimeAdapter.d.ts +68 -0
  87. package/dist/runtime/DelegatingRuntimeAdapter.js +250 -0
  88. package/dist/runtime/IpcRuntimeAdapter.d.ts +9 -0
  89. package/dist/runtime/IpcRuntimeAdapter.js +13 -0
  90. package/dist/runtime/RuntimeClient.d.ts +257 -0
  91. package/dist/runtime/RuntimeClient.js +1484 -0
  92. package/dist/runtime/TauriRuntimeAdapter.d.ts +1 -0
  93. package/dist/runtime/TauriRuntimeAdapter.js +1 -0
  94. package/dist/runtime/WasmRuntimeAdapter.d.ts +6 -0
  95. package/dist/runtime/WasmRuntimeAdapter.js +11 -0
  96. package/dist/runtime/factories.d.ts +24 -0
  97. package/dist/runtime/factories.js +66 -0
  98. package/dist/runtime/index.d.ts +6 -0
  99. package/dist/runtime/index.js +5 -0
  100. package/dist/runtime/ipc.d.ts +8 -0
  101. package/dist/runtime/ipc.js +65 -0
  102. package/dist/runtime/ipcContract.d.ts +46 -0
  103. package/dist/runtime/ipcContract.js +1 -0
  104. package/dist/runtime/tauri.d.ts +16 -0
  105. package/dist/runtime/tauri.js +22 -0
  106. package/dist/runtime/types.d.ts +110 -0
  107. package/dist/runtime/types.js +1 -0
  108. package/dist/transports/TauriTransport.d.ts +17 -0
  109. package/dist/transports/TauriTransport.js +49 -0
  110. package/dist/transports/WebTransport.d.ts +23 -0
  111. package/dist/transports/WebTransport.js +71 -0
  112. package/dist/utils/debug.d.ts +4 -0
  113. package/dist/utils/debug.js +33 -0
  114. package/dist/utils/runtime.d.ts +1 -0
  115. package/dist/utils/runtime.js +10 -0
  116. package/package.json +97 -0
  117. package/wasm/README.md +64 -0
  118. package/wasm/openrtc.d.ts +284 -0
  119. package/wasm/openrtc.js +2132 -0
  120. package/wasm/openrtc_bg.wasm +0 -0
  121. package/wasm/openrtc_bg.wasm.d.ts +110 -0
  122. package/wasm/package.json +16 -0
@@ -0,0 +1,13 @@
1
+ import type { Device, SignalingSession } from './types';
2
+ import type { AuthContext, IEngineBridge } from './IEngineBridge';
3
+ export declare class PlutoRTC {
4
+ private readonly transport;
5
+ constructor(transport: IEngineBridge);
6
+ setAuthContext(context: AuthContext | null): Promise<void>;
7
+ searchDevices(excludeNodeId?: string): Promise<Device[]>;
8
+ onDevicesChange(callback: (devices: Device[]) => void, excludeNodeId?: string): () => void;
9
+ subscribeSessions(localDeviceId: string, callback: (session: SignalingSession) => void): Promise<() => void>;
10
+ updatePresence(localNodeId: string, ticket: string, isOnline?: boolean, ttlMs?: number, metadata?: string): Promise<void>;
11
+ setOffline(localNodeId: string): Promise<void>;
12
+ forceReconnectSnapshot(): void;
13
+ }
@@ -0,0 +1,26 @@
1
+ export class PlutoRTC {
2
+ constructor(transport) {
3
+ this.transport = transport;
4
+ }
5
+ async setAuthContext(context) {
6
+ await this.transport.setAuthContext(context);
7
+ }
8
+ async searchDevices(excludeNodeId) {
9
+ return this.transport.searchDevices(excludeNodeId);
10
+ }
11
+ onDevicesChange(callback, excludeNodeId) {
12
+ return this.transport.onDevicesChange(callback, excludeNodeId);
13
+ }
14
+ async subscribeSessions(localDeviceId, callback) {
15
+ return this.transport.subscribeSessions(localDeviceId, callback);
16
+ }
17
+ async updatePresence(localNodeId, ticket, isOnline = true, ttlMs = 300000, metadata) {
18
+ await this.transport.updatePresence(localNodeId, ticket, isOnline, ttlMs, metadata);
19
+ }
20
+ async setOffline(localNodeId) {
21
+ await this.transport.setOffline(localNodeId);
22
+ }
23
+ forceReconnectSnapshot() {
24
+ this.transport.forceReconnectSnapshot();
25
+ }
26
+ }
@@ -0,0 +1,47 @@
1
+ import { Signaling } from './Signaling';
2
+ import { Connection } from './Connection';
3
+ import { Client } from './Client';
4
+ import { JoinRoomOptions, RoomMember } from './types';
5
+ import { RtdbPresence } from './bridges/RtdbPresence';
6
+ export declare class RoomManager {
7
+ private signaling;
8
+ private client;
9
+ private readonly heartbeatStops;
10
+ private browserRooms;
11
+ private rtdbPresence;
12
+ constructor(client: Client, signaling: Signaling);
13
+ private getBrowserRooms;
14
+ /**
15
+ * Phase 2: called by the runtime after RTDB presence is initialised
16
+ * (same RtdbPresence instance used for device presence in Phase 1).
17
+ */
18
+ setRtdbPresence(rtdbPresence: RtdbPresence): void;
19
+ private usesDirectRoomBackend;
20
+ private get roomBackendTag();
21
+ private get appTag();
22
+ private invokeRoomCallable;
23
+ private isAlreadyExistsError;
24
+ private getMembersInternal;
25
+ private parseMemberDocuments;
26
+ private parseRoomMemberDocument;
27
+ /**
28
+ * Creates a new room (or joins the special 'demo' room if specified)
29
+ */
30
+ createRoom(): Promise<string>;
31
+ /**
32
+ * Joins a room by ID.
33
+ */
34
+ joinRoom(roomId: string, options?: JoinRoomOptions): Promise<Connection[]>;
35
+ leaveRoom(roomId: string): Promise<void>;
36
+ getRoomMembers(roomId: string): Promise<RoomMember[]>;
37
+ watchRoom(roomId: string, callback: (members: RoomMember[]) => void): () => void;
38
+ /**
39
+ * Register this device as an online room member in RTDB and store a
40
+ * cleanup callback in heartbeatStops (keyed by roomId) so that
41
+ * stopHeartbeat() / leaveRoom() can trigger graceful offline marking.
42
+ */
43
+ private registerRtdbRoomMember;
44
+ private markRtdbRoomMemberOffline;
45
+ private startLegacyHeartbeat;
46
+ stopHeartbeat(roomId?: string): void;
47
+ }
@@ -0,0 +1,376 @@
1
+ import { getAuthHost } from '../auth/host';
2
+ import { BrowserFirestoreRooms } from './bridges/BrowserFirestoreRooms';
3
+ export class RoomManager {
4
+ constructor(client, signaling) {
5
+ // Phase 2: RTDB replaces the 5-minute Firestore heartbeat interval.
6
+ // heartbeatStops is kept for graceful cleanup but now holds RTDB unregister
7
+ // callbacks rather than setInterval handles.
8
+ this.heartbeatStops = new Map();
9
+ this.browserRooms = null;
10
+ this.rtdbPresence = null;
11
+ this.client = client;
12
+ this.signaling = signaling;
13
+ }
14
+ getBrowserRooms() {
15
+ if (!this.browserRooms) {
16
+ this.browserRooms = new BrowserFirestoreRooms({
17
+ app: getAuthHost().getApp(),
18
+ getRoomBackendTag: () => this.client.getRoomBackendTag(),
19
+ });
20
+ }
21
+ // Phase 2: wire RTDB presence into the room watcher if available.
22
+ if (this.rtdbPresence && this.browserRooms) {
23
+ this.browserRooms.setRtdbPresence(this.rtdbPresence);
24
+ }
25
+ return this.browserRooms;
26
+ }
27
+ /**
28
+ * Phase 2: called by the runtime after RTDB presence is initialised
29
+ * (same RtdbPresence instance used for device presence in Phase 1).
30
+ */
31
+ setRtdbPresence(rtdbPresence) {
32
+ this.rtdbPresence = rtdbPresence;
33
+ // If browserRooms already exists, wire it in immediately.
34
+ if (this.browserRooms) {
35
+ this.browserRooms.setRtdbPresence(rtdbPresence);
36
+ }
37
+ }
38
+ usesDirectRoomBackend() {
39
+ return this.client.usesDirectRoomBackend();
40
+ }
41
+ get roomBackendTag() {
42
+ return this.client.getRoomBackendTag();
43
+ }
44
+ get appTag() {
45
+ return this.signaling.tag;
46
+ }
47
+ async invokeRoomCallable(name, payload) {
48
+ const { getFunctions, httpsCallable } = await import('firebase/functions');
49
+ const functions = getFunctions(getAuthHost().getApp());
50
+ const callable = httpsCallable(functions, name);
51
+ const result = await callable(payload);
52
+ return result.data;
53
+ }
54
+ isAlreadyExistsError(error) {
55
+ if (!error || typeof error !== 'object') {
56
+ return false;
57
+ }
58
+ const maybeCode = 'code' in error && typeof error.code === 'string' ? error.code : '';
59
+ const maybeMessage = 'message' in error && typeof error.message === 'string' ? error.message : '';
60
+ return maybeCode.includes('already-exists') || maybeMessage.toLowerCase().includes('already exists');
61
+ }
62
+ async getMembersInternal(roomId, myNodeId) {
63
+ if (!this.client.wasmClient)
64
+ throw new Error('WASM Client not loaded');
65
+ const memberId = myNodeId ?? await this.client.getNodeId();
66
+ const membersStr = await this.client.wasmClient.get_members(roomId, memberId, this.roomBackendTag);
67
+ return this.parseMemberDocuments(membersStr);
68
+ }
69
+ parseMemberDocuments(membersStr) {
70
+ const parsed = JSON.parse(membersStr);
71
+ return parsed.map((doc) => this.parseRoomMemberDocument(doc));
72
+ }
73
+ parseRoomMemberDocument(doc) {
74
+ const fields = doc.fields;
75
+ return {
76
+ nodeId: fields.nodeId?.stringValue || doc.name.split('/').pop(),
77
+ userId: fields.userId?.stringValue,
78
+ ticket: fields.ticket?.stringValue,
79
+ joinedAt: fields.joinedAt?.integerValue ? parseInt(fields.joinedAt.integerValue) : undefined,
80
+ lastSeenAt: fields.lastSeenAt?.integerValue ? parseInt(fields.lastSeenAt.integerValue) : undefined,
81
+ expiresAt: fields.expiresAt?.integerValue ? parseInt(fields.expiresAt.integerValue) : undefined,
82
+ };
83
+ }
84
+ /**
85
+ * Creates a new room (or joins the special 'demo' room if specified)
86
+ */
87
+ async createRoom() {
88
+ await this.client.ensureAuthenticated('room-op');
89
+ if (!this.client.wasmClient)
90
+ throw new Error("WASM Client not loaded");
91
+ const myTicket = await this.client.getTicket();
92
+ const myNodeId = await this.client.getNodeId();
93
+ const userId = this.signaling.currentUser?.id ?? myNodeId ?? 'anonymous';
94
+ if (!this.usesDirectRoomBackend()) {
95
+ await this.client.refreshHostedAppSettings();
96
+ if (this.client.roomCreationMode === 'server-only') {
97
+ throw new Error('This app uses server-only room creation. Create the room on your backend with your sk_live_... key, then call joinRoom(roomId) on the client.');
98
+ }
99
+ let roomId = '';
100
+ let created = false;
101
+ for (let i = 0; i < 3; i += 1) {
102
+ const bytes = new Uint8Array(6);
103
+ crypto.getRandomValues(bytes);
104
+ roomId = Array.from(bytes, b => b.toString(36).padStart(2, '0')).join('').substring(0, 6).toUpperCase();
105
+ try {
106
+ await this.invokeRoomCallable('createClientRoom', {
107
+ appTag: this.appTag,
108
+ roomId,
109
+ nodeId: myNodeId,
110
+ ticket: myTicket,
111
+ });
112
+ created = true;
113
+ break;
114
+ }
115
+ catch (error) {
116
+ if (this.isAlreadyExistsError(error)) {
117
+ continue;
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+ if (!created) {
123
+ throw new Error('Failed to create room after multiple attempts (ID collisions).');
124
+ }
125
+ await this.registerRtdbRoomMember(roomId, myNodeId);
126
+ return roomId;
127
+ }
128
+ // Generate 6-char random ID
129
+ // Try up to 3 times to account for collisions
130
+ let roomId = '';
131
+ let created = false;
132
+ for (let i = 0; i < 3; i++) {
133
+ const bytes = new Uint8Array(6);
134
+ crypto.getRandomValues(bytes);
135
+ roomId = Array.from(bytes, b => b.toString(36).padStart(2, '0')).join('').substring(0, 6).toUpperCase();
136
+ const maxMembers = this.client.appLimits.maxMembersPerRoom > 0
137
+ ? this.client.appLimits.maxMembersPerRoom
138
+ : undefined;
139
+ created = await this.client.wasmClient.create_room(roomId, userId, myTicket, myNodeId, this.roomBackendTag, maxMembers);
140
+ if (created) {
141
+ console.log(`Room created: ${roomId}`);
142
+ break;
143
+ }
144
+ else {
145
+ console.log(`Collision for roomId ${roomId}, retrying...`);
146
+ }
147
+ }
148
+ if (!created) {
149
+ throw new Error("Failed to create room after multiple attempts (ID collisions).");
150
+ }
151
+ await this.registerRtdbRoomMember(roomId, myNodeId);
152
+ return roomId;
153
+ }
154
+ /**
155
+ * Joins a room by ID.
156
+ */
157
+ async joinRoom(roomId, options = {}) {
158
+ await this.client.ensureAuthenticated('room-op');
159
+ if (!this.client.wasmClient)
160
+ throw new Error("WASM Client not loaded");
161
+ if (typeof roomId !== 'string' || roomId.length === 0 || roomId.length > 64) {
162
+ throw new Error('roomId must be a non-empty string of at most 64 characters');
163
+ }
164
+ if (roomId !== 'demo' && !/^[a-zA-Z0-9_-]+$/.test(roomId)) {
165
+ throw new Error('roomId must contain only alphanumeric characters, hyphens, or underscores');
166
+ }
167
+ const myTicket = await this.client.getTicket();
168
+ const myNodeId = await this.client.getNodeId();
169
+ const userId = this.signaling.currentUser?.id ?? myNodeId ?? 'anonymous';
170
+ const normalizedId = roomId.toUpperCase();
171
+ const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
172
+ console.log('[RoomManager] joinRoom request', {
173
+ roomId,
174
+ normalizedId,
175
+ targetRoomId,
176
+ tag: this.roomBackendTag,
177
+ userId,
178
+ myNodeId,
179
+ hasTicket: !!myTicket,
180
+ ticketLength: myTicket?.length || 0,
181
+ });
182
+ try {
183
+ if (this.usesDirectRoomBackend()) {
184
+ await this.client.wasmClient.join_room(targetRoomId, userId, myTicket, myNodeId, this.roomBackendTag);
185
+ }
186
+ else {
187
+ await this.client.refreshHostedAppSettings();
188
+ await this.invokeRoomCallable('joinClientRoom', {
189
+ appTag: this.appTag,
190
+ roomId: targetRoomId,
191
+ nodeId: myNodeId,
192
+ ticket: myTicket,
193
+ });
194
+ }
195
+ console.log(`Joined room: ${targetRoomId}`);
196
+ }
197
+ catch (e) {
198
+ console.error("Join Room failed: ", e);
199
+ throw e;
200
+ }
201
+ // BOOTSTRAP: Connect to existing members
202
+ const members = await this.getMembersInternal(targetRoomId, myNodeId);
203
+ const connections = [];
204
+ if (options.bootstrapPeers !== false) {
205
+ const connectionPromises = members.map(async (member) => {
206
+ // Check expiration
207
+ let isExpired = false;
208
+ const now = Date.now();
209
+ if (member.expiresAt) {
210
+ if (member.expiresAt < now)
211
+ isExpired = true;
212
+ }
213
+ else {
214
+ const lastSeen = member.lastSeenAt || member.joinedAt || 0;
215
+ if (now - lastSeen > 5 * 60 * 1000)
216
+ isExpired = true;
217
+ }
218
+ if (isExpired)
219
+ return null;
220
+ if (member.ticket) {
221
+ try {
222
+ console.log(`Bootstrapping: Connecting to peer ${member.nodeId}...`);
223
+ // Use 5s timeout for bootstrapping
224
+ const conn = await this.client.connect(member.ticket, 5000);
225
+ conn.send({ type: 'chat', text: `Use ${userId} joined the room.` });
226
+ return conn;
227
+ }
228
+ catch (e) {
229
+ console.warn(`Failed to connect to peer ${member.nodeId}:`, e);
230
+ return null;
231
+ }
232
+ }
233
+ return null;
234
+ });
235
+ const results = await Promise.allSettled(connectionPromises);
236
+ results.forEach(result => {
237
+ if (result.status === 'fulfilled' && result.value) {
238
+ connections.push(result.value);
239
+ }
240
+ });
241
+ }
242
+ // Phase 2: Register RTDB presence (replaces 5-minute Firestore heartbeat).
243
+ await this.registerRtdbRoomMember(targetRoomId, myNodeId);
244
+ return connections;
245
+ }
246
+ async leaveRoom(roomId) {
247
+ if (!this.usesDirectRoomBackend()) {
248
+ await this.client.ensureAuthenticated('room-op');
249
+ }
250
+ const myNodeId = await this.client.getNodeId();
251
+ if (!myNodeId)
252
+ return;
253
+ console.log(`Leaving room ${roomId}...`);
254
+ // Phase 2: mark offline in RTDB immediately on graceful leave.
255
+ await this.markRtdbRoomMemberOffline(roomId, myNodeId);
256
+ this.stopHeartbeat(roomId);
257
+ const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
258
+ try {
259
+ if (this.usesDirectRoomBackend()) {
260
+ await this.client.wasmClient.leave_room(targetRoomId, myNodeId, this.roomBackendTag);
261
+ }
262
+ else {
263
+ await this.invokeRoomCallable('leaveClientRoom', {
264
+ appTag: this.appTag,
265
+ roomId: targetRoomId,
266
+ nodeId: myNodeId,
267
+ });
268
+ }
269
+ console.log(`Left room: ${targetRoomId}`);
270
+ }
271
+ catch (e) {
272
+ console.warn("Leave room failed:", e);
273
+ throw e;
274
+ }
275
+ }
276
+ async getRoomMembers(roomId) {
277
+ await this.client.ensureAuthenticated('room-op');
278
+ const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
279
+ return this.getMembersInternal(targetRoomId);
280
+ }
281
+ watchRoom(roomId, callback) {
282
+ let cancelled = false;
283
+ let stopWatching = null;
284
+ void (async () => {
285
+ try {
286
+ await this.client.ensureAuthenticated('room-op');
287
+ if (cancelled) {
288
+ return;
289
+ }
290
+ const targetRoomId = roomId === 'demo' ? 'demo' : roomId;
291
+ const myNodeId = await this.client.getNodeId();
292
+ if (cancelled) {
293
+ return;
294
+ }
295
+ stopWatching = this.getBrowserRooms().watchMembers(targetRoomId, myNodeId, callback);
296
+ if (cancelled) {
297
+ stopWatching();
298
+ stopWatching = null;
299
+ }
300
+ }
301
+ catch (error) {
302
+ if (!cancelled) {
303
+ console.warn('[RoomManager] watchRoom listener failed:', error);
304
+ }
305
+ }
306
+ })();
307
+ return () => {
308
+ cancelled = true;
309
+ stopWatching?.();
310
+ stopWatching = null;
311
+ };
312
+ }
313
+ // --- Phase 2: RTDB room member presence ---
314
+ /**
315
+ * Register this device as an online room member in RTDB and store a
316
+ * cleanup callback in heartbeatStops (keyed by roomId) so that
317
+ * stopHeartbeat() / leaveRoom() can trigger graceful offline marking.
318
+ */
319
+ async registerRtdbRoomMember(roomId, memberId) {
320
+ this.stopHeartbeat(roomId);
321
+ if (this.rtdbPresence) {
322
+ const appTag = this.roomBackendTag;
323
+ try {
324
+ await this.rtdbPresence.registerRoomMember(appTag, roomId, memberId);
325
+ }
326
+ catch (err) {
327
+ console.warn('[RoomManager] RTDB room member register failed:', err);
328
+ // Fall through — room join already succeeded in Firestore.
329
+ }
330
+ // Store a no-op stop handle (actual cleanup happens in markRtdbRoomMemberOffline).
331
+ this.heartbeatStops.set(roomId, () => {
332
+ void this.markRtdbRoomMemberOffline(roomId, memberId);
333
+ });
334
+ }
335
+ else {
336
+ // Fallback: legacy 5-minute Firestore heartbeat.
337
+ this.startLegacyHeartbeat(roomId, memberId);
338
+ }
339
+ }
340
+ async markRtdbRoomMemberOffline(roomId, memberId) {
341
+ if (!this.rtdbPresence)
342
+ return;
343
+ const appTag = this.roomBackendTag;
344
+ try {
345
+ await this.rtdbPresence.markRoomMemberOffline(appTag, roomId, memberId);
346
+ }
347
+ catch (err) {
348
+ console.warn('[RoomManager] RTDB markRoomMemberOffline failed:', err);
349
+ }
350
+ }
351
+ // --- Legacy fallback heartbeat (used when RTDB is not available) ---
352
+ startLegacyHeartbeat(roomId, memberId) {
353
+ const interval = setInterval(() => {
354
+ if (this.client.wasmClient) {
355
+ this.client.wasmClient.heartbeat_tick(roomId, memberId, this.roomBackendTag).catch(console.error);
356
+ }
357
+ }, 300000); // 5 minutes — liveness/expiry only; real-time member awareness via listener
358
+ this.heartbeatStops.set(roomId, () => clearInterval(interval));
359
+ }
360
+ stopHeartbeat(roomId) {
361
+ if (typeof roomId === 'string') {
362
+ const stop = this.heartbeatStops.get(roomId);
363
+ if (stop) {
364
+ stop();
365
+ this.heartbeatStops.delete(roomId);
366
+ console.log(`Heartbeat stopped for room ${roomId}.`);
367
+ }
368
+ return;
369
+ }
370
+ for (const [activeRoomId, stop] of this.heartbeatStops.entries()) {
371
+ stop();
372
+ console.log(`Heartbeat stopped for room ${activeRoomId}.`);
373
+ }
374
+ this.heartbeatStops.clear();
375
+ }
376
+ }
@@ -0,0 +1,39 @@
1
+ import { ISignalingBackend, Device, SignalingOptions, RuntimeIdentity, BackendConnectionState } from './types';
2
+ export declare class Signaling {
3
+ appTag: string;
4
+ private backend;
5
+ constructor(options: SignalingOptions, backend: ISignalingBackend);
6
+ get tag(): string;
7
+ waitForAuth(): Promise<void>;
8
+ get currentUser(): RuntimeIdentity | null;
9
+ onAuthChange(callback: (user: RuntimeIdentity | null) => void): () => void;
10
+ signInAnonymously(): Promise<void>;
11
+ signInWithPluto(): Promise<void>;
12
+ signOut(): Promise<void>;
13
+ stopAuthScopedActivity(): Promise<void>;
14
+ getTurnCredentials(): Promise<any>;
15
+ updatePresence(localNodeId: string, ticketStr: string, isOnline?: boolean, ttlMs?: number, metadata?: string): Promise<void>;
16
+ setOffline(localNodeId: string): Promise<void>;
17
+ cleanupStaleDevices(): Promise<void>;
18
+ sendMessage(targetId: string, payload: string, state?: string, replyPayload?: string): Promise<string>;
19
+ pollMessages(targetId: string): Promise<import("./types").SignalingEnvelope[]>;
20
+ subscribeSessions(localDeviceId: string, callback: (session: import('./types').SignalingSession) => void): Promise<() => void>;
21
+ getLocalDeviceId(): Promise<string | null>;
22
+ searchDevices(excludeNodeId?: string): Promise<Device[]>;
23
+ updateDevice(deviceId: string, updates: {
24
+ deviceName?: string;
25
+ capabilities?: Device['capabilities'];
26
+ metadata?: string;
27
+ }): Promise<void>;
28
+ deleteDevice(deviceId: string): Promise<void>;
29
+ onDevicesChange(callback: (devices: Device[]) => void, excludeNodeId?: string): () => void;
30
+ startAutoConnect(userId: string, localDeviceId: string): void;
31
+ notifyDisconnectRequested(remoteNodeId: string): void;
32
+ startPresenceLoop(userId: string, localNodeId: string, deviceName: string, ticket: string, metadata?: string): void;
33
+ forceReconnectSnapshot(): void;
34
+ isConnected(nodeId: string): Promise<boolean>;
35
+ getConnectionStates(): Promise<BackendConnectionState[]>;
36
+ onConnectionStateChange(callback: (state: BackendConnectionState) => void): (() => void) | Promise<() => void>;
37
+ supportsRustPeerLifecycleProjection(): boolean;
38
+ setWasmClient(client: any): void;
39
+ }
@@ -0,0 +1,147 @@
1
+ import { resolveAppTag } from './config';
2
+ import { getAuthHost } from '../auth/host';
3
+ export class Signaling {
4
+ constructor(options, backend) {
5
+ this.appTag = resolveAppTag(options);
6
+ this.backend = backend;
7
+ }
8
+ get tag() {
9
+ return this.appTag;
10
+ }
11
+ async waitForAuth() {
12
+ return this.backend.waitForAuth();
13
+ }
14
+ get currentUser() {
15
+ return this.backend.currentUser;
16
+ }
17
+ onAuthChange(callback) {
18
+ return this.backend.onAuthChange(callback);
19
+ }
20
+ async signInAnonymously() {
21
+ await this.backend.signInAnonymously();
22
+ }
23
+ async signInWithPluto() {
24
+ if (typeof this.backend.signInWithPluto === 'function') {
25
+ await this.backend.signInWithPluto();
26
+ return;
27
+ }
28
+ await getAuthHost().signInWithPluto();
29
+ }
30
+ async signOut() {
31
+ await this.backend.signOut();
32
+ }
33
+ async stopAuthScopedActivity() {
34
+ if (typeof this.backend.stopAuthScopedActivity === 'function') {
35
+ await this.backend.stopAuthScopedActivity();
36
+ }
37
+ }
38
+ async getTurnCredentials() {
39
+ return this.backend.getTurnCredentials();
40
+ }
41
+ // --- Presence ---
42
+ async updatePresence(localNodeId, ticketStr, isOnline = true, ttlMs = 5 * 60 * 1000, metadata) {
43
+ await this.backend.updatePresence(localNodeId, ticketStr, isOnline, ttlMs, metadata);
44
+ }
45
+ async setOffline(localNodeId) {
46
+ await this.backend.setOffline(localNodeId);
47
+ }
48
+ async cleanupStaleDevices() {
49
+ await this.backend.cleanupStaleDevices();
50
+ }
51
+ async sendMessage(targetId, payload, state, replyPayload) {
52
+ return this.backend.sendMessage(targetId, payload, state, replyPayload);
53
+ }
54
+ async pollMessages(targetId) {
55
+ return this.backend.pollMessages(targetId);
56
+ }
57
+ async subscribeSessions(localDeviceId, callback) {
58
+ const stream = await this.backend.subscribeSessions(localDeviceId);
59
+ const reader = stream.getReader();
60
+ let isCancelled = false;
61
+ const poll = async () => {
62
+ try {
63
+ while (!isCancelled) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ if (value) {
68
+ for (const event of value) {
69
+ if ('session' in event && event.session) {
70
+ callback(event.session);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ catch (err) {
77
+ if (!isCancelled)
78
+ console.error("[Signaling] Session stream error:", err);
79
+ }
80
+ };
81
+ poll();
82
+ return () => {
83
+ isCancelled = true;
84
+ reader.cancel();
85
+ };
86
+ }
87
+ async getLocalDeviceId() {
88
+ if (typeof this.backend.getLocalDeviceId !== 'function') {
89
+ return null;
90
+ }
91
+ return this.backend.getLocalDeviceId();
92
+ }
93
+ // --- Discovery ---
94
+ async searchDevices(excludeNodeId) {
95
+ return this.backend.searchDevices(excludeNodeId);
96
+ }
97
+ async updateDevice(deviceId, updates) {
98
+ await this.backend.updateDevice(deviceId, updates);
99
+ }
100
+ async deleteDevice(deviceId) {
101
+ await this.backend.deleteDevice(deviceId);
102
+ }
103
+ onDevicesChange(callback, excludeNodeId) {
104
+ return this.backend.onDevicesChange(callback, excludeNodeId);
105
+ }
106
+ startAutoConnect(userId, localDeviceId) {
107
+ this.backend.startAutoConnect(userId, localDeviceId);
108
+ }
109
+ notifyDisconnectRequested(remoteNodeId) {
110
+ if (typeof this.backend.notifyDisconnectRequested === 'function') {
111
+ this.backend.notifyDisconnectRequested(remoteNodeId);
112
+ }
113
+ }
114
+ startPresenceLoop(userId, localNodeId, deviceName, ticket, metadata) {
115
+ this.backend.startPresenceLoop(userId, localNodeId, deviceName, ticket, metadata);
116
+ }
117
+ forceReconnectSnapshot() {
118
+ this.backend.forceReconnectSnapshot();
119
+ }
120
+ async isConnected(nodeId) {
121
+ return this.backend.isConnected(nodeId);
122
+ }
123
+ async getConnectionStates() {
124
+ if (typeof this.backend.getConnectionStates === 'function') {
125
+ return this.backend.getConnectionStates();
126
+ }
127
+ return [];
128
+ }
129
+ onConnectionStateChange(callback) {
130
+ if (typeof this.backend.onConnectionStateChange === 'function') {
131
+ return this.backend.onConnectionStateChange(callback);
132
+ }
133
+ return () => { };
134
+ }
135
+ supportsRustPeerLifecycleProjection() {
136
+ return typeof this.backend.listPeerSessions === 'function'
137
+ || typeof this.backend.getPeerSession === 'function'
138
+ || typeof this.backend.waitForSettledPeer === 'function'
139
+ || typeof this.backend.getConnectionStates === 'function';
140
+ }
141
+ setWasmClient(client) {
142
+ const backendAny = this.backend;
143
+ if (backendAny && typeof backendAny.setWasmClient === 'function') {
144
+ backendAny.setWasmClient(client);
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,19 @@
1
+ import type { BackendConnectionState, Device, DeviceStatusSnapshot, ManagedConnectionRecord, PeerSessionSnapshot, ResolvedPeerIdentity } from '../types';
2
+ export declare function pickString(raw: Record<string, any>, ...keys: string[]): string | undefined;
3
+ export declare function pickBoolean(raw: Record<string, any>, ...keys: string[]): boolean | undefined;
4
+ export declare function pickNumber(raw: Record<string, any>, ...keys: string[]): number | undefined;
5
+ export declare function isPersonalSpaceMode(options: {
6
+ discoveryMode?: string;
7
+ apiKey?: string;
8
+ spaceKey?: string;
9
+ }): boolean;
10
+ export declare function matchesDiscoveryTag(device: Pick<Device, 'tag' | 'appTag'>, appTag: string, personalMode: boolean): boolean;
11
+ export declare function normalizeDeviceRecord(raw: unknown, fallbackId?: string): Device;
12
+ export declare function normalizeDeviceStatusSnapshotRecord(raw: unknown): DeviceStatusSnapshot;
13
+ export declare function normalizePeerSessionSnapshotRecord(raw: unknown): PeerSessionSnapshot;
14
+ export declare function normalizeManagedConnectionRecord(raw: unknown): ManagedConnectionRecord;
15
+ export declare function normalizeResolvedPeerIdentity(raw: unknown): ResolvedPeerIdentity;
16
+ export declare function mapPeerSessionStatusToBackendState(status: PeerSessionSnapshot['status']): BackendConnectionState['state'];
17
+ export declare function peerSessionToBackendConnectionState(snapshot: PeerSessionSnapshot): BackendConnectionState | null;
18
+ export declare function normalizeConnectionStateSnapshot(raw: unknown): BackendConnectionState | null;
19
+ export declare function sameConnectionState(left: BackendConnectionState | undefined, right: BackendConnectionState): boolean;