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.
- package/README.md +106 -0
- package/dist/OpenRTC.d.ts +71 -0
- package/dist/OpenRTC.js +130 -0
- package/dist/api/MediaTransport.d.ts +7 -0
- package/dist/api/MediaTransport.js +262 -0
- package/dist/api/PlutoPeerConnection.d.ts +50 -0
- package/dist/api/PlutoPeerConnection.js +434 -0
- package/dist/api/PlutoWebSocket.d.ts +24 -0
- package/dist/api/PlutoWebSocket.js +113 -0
- package/dist/api/PlutoWebTransport.d.ts +29 -0
- package/dist/api/PlutoWebTransport.js +105 -0
- package/dist/auth/host.d.ts +73 -0
- package/dist/auth/host.js +513 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/internal.d.ts +1 -0
- package/dist/auth/internal.js +1 -0
- package/dist/compat/index.d.ts +20 -0
- package/dist/compat/index.js +20 -0
- package/dist/core/Client.d.ts +352 -0
- package/dist/core/Client.js +3689 -0
- package/dist/core/Connection.d.ts +67 -0
- package/dist/core/Connection.js +404 -0
- package/dist/core/IEngineBridge.d.ts +18 -0
- package/dist/core/IEngineBridge.js +1 -0
- package/dist/core/ISignalingTransport.d.ts +13 -0
- package/dist/core/ISignalingTransport.js +1 -0
- package/dist/core/PeerLifecycleManager.d.ts +43 -0
- package/dist/core/PeerLifecycleManager.js +330 -0
- package/dist/core/PeerProjectionStore.d.ts +43 -0
- package/dist/core/PeerProjectionStore.js +327 -0
- package/dist/core/PlutoRTC.d.ts +13 -0
- package/dist/core/PlutoRTC.js +26 -0
- package/dist/core/Room.d.ts +47 -0
- package/dist/core/Room.js +376 -0
- package/dist/core/Signaling.d.ts +39 -0
- package/dist/core/Signaling.js +147 -0
- package/dist/core/bridges/BridgeCodecs.d.ts +19 -0
- package/dist/core/bridges/BridgeCodecs.js +317 -0
- package/dist/core/bridges/BrowserFirestoreRooms.d.ts +36 -0
- package/dist/core/bridges/BrowserFirestoreRooms.js +169 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.d.ts +111 -0
- package/dist/core/bridges/BrowserFirestoreSignaling.js +1010 -0
- package/dist/core/bridges/NativeIpcBridge.d.ts +156 -0
- package/dist/core/bridges/NativeIpcBridge.js +1472 -0
- package/dist/core/bridges/RtdbPresence.d.ts +122 -0
- package/dist/core/bridges/RtdbPresence.js +201 -0
- package/dist/core/bridges/TauriBridge.d.ts +8 -0
- package/dist/core/bridges/TauriBridge.js +7 -0
- package/dist/core/bridges/WasmBridge.d.ts +233 -0
- package/dist/core/bridges/WasmBridge.js +1811 -0
- package/dist/core/config.d.ts +31 -0
- package/dist/core/config.js +138 -0
- package/dist/core/transports/ITransport.d.ts +12 -0
- package/dist/core/transports/ITransport.js +1 -0
- package/dist/core/transports/LocalDaemonBridge.d.ts +41 -0
- package/dist/core/transports/LocalDaemonBridge.js +227 -0
- package/dist/core/transports/TauriSignaling.d.ts +28 -0
- package/dist/core/transports/TauriSignaling.js +373 -0
- package/dist/core/transports/TransportFactory.d.ts +9 -0
- package/dist/core/transports/TransportFactory.js +59 -0
- package/dist/core/transports/WasmSignaling.d.ts +78 -0
- package/dist/core/transports/WasmSignaling.js +522 -0
- package/dist/core/transports/WebRTCTransport.d.ts +23 -0
- package/dist/core/transports/WebRTCTransport.js +132 -0
- package/dist/core/trust.d.ts +23 -0
- package/dist/core/trust.js +99 -0
- package/dist/core/types.d.ts +559 -0
- package/dist/core/types.js +19 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/moq/MoQMediaTransport.d.ts +6 -0
- package/dist/moq/MoQMediaTransport.js +106 -0
- package/dist/moq/MoQPublisher.d.ts +22 -0
- package/dist/moq/MoQPublisher.js +179 -0
- package/dist/moq/MoQSession.d.ts +35 -0
- package/dist/moq/MoQSession.js +166 -0
- package/dist/moq/MoQSubscriber.d.ts +19 -0
- package/dist/moq/MoQSubscriber.js +235 -0
- package/dist/moq/common.d.ts +47 -0
- package/dist/moq/common.js +304 -0
- package/dist/moq/messages.d.ts +105 -0
- package/dist/moq/messages.js +367 -0
- package/dist/presets/plutoHosted.d.ts +13 -0
- package/dist/presets/plutoHosted.js +18 -0
- package/dist/runtime/DelegatingRuntimeAdapter.d.ts +68 -0
- package/dist/runtime/DelegatingRuntimeAdapter.js +250 -0
- package/dist/runtime/IpcRuntimeAdapter.d.ts +9 -0
- package/dist/runtime/IpcRuntimeAdapter.js +13 -0
- package/dist/runtime/RuntimeClient.d.ts +257 -0
- package/dist/runtime/RuntimeClient.js +1484 -0
- package/dist/runtime/TauriRuntimeAdapter.d.ts +1 -0
- package/dist/runtime/TauriRuntimeAdapter.js +1 -0
- package/dist/runtime/WasmRuntimeAdapter.d.ts +6 -0
- package/dist/runtime/WasmRuntimeAdapter.js +11 -0
- package/dist/runtime/factories.d.ts +24 -0
- package/dist/runtime/factories.js +66 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/ipc.d.ts +8 -0
- package/dist/runtime/ipc.js +65 -0
- package/dist/runtime/ipcContract.d.ts +46 -0
- package/dist/runtime/ipcContract.js +1 -0
- package/dist/runtime/tauri.d.ts +16 -0
- package/dist/runtime/tauri.js +22 -0
- package/dist/runtime/types.d.ts +110 -0
- package/dist/runtime/types.js +1 -0
- package/dist/transports/TauriTransport.d.ts +17 -0
- package/dist/transports/TauriTransport.js +49 -0
- package/dist/transports/WebTransport.d.ts +23 -0
- package/dist/transports/WebTransport.js +71 -0
- package/dist/utils/debug.d.ts +4 -0
- package/dist/utils/debug.js +33 -0
- package/dist/utils/runtime.d.ts +1 -0
- package/dist/utils/runtime.js +10 -0
- package/package.json +97 -0
- package/wasm/README.md +64 -0
- package/wasm/openrtc.d.ts +284 -0
- package/wasm/openrtc.js +2132 -0
- package/wasm/openrtc_bg.wasm +0 -0
- package/wasm/openrtc_bg.wasm.d.ts +110 -0
- package/wasm/package.json +16 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { getFunctions, httpsCallable } from 'firebase/functions';
|
|
2
|
+
import { collection, deleteDoc, doc, getDoc, getDocFromServer, getDocs, getFirestore, limit, onSnapshot, query, serverTimestamp, setDoc, updateDoc, where, } from 'firebase/firestore';
|
|
3
|
+
import { deriveSpaceNamespace } from '../config';
|
|
4
|
+
const DEVICE_STALE_HEARTBEAT_MS = 300000;
|
|
5
|
+
const DEVICE_OFFLINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
6
|
+
const CREATE_SIGNALING_SESSION_FUNCTION = 'createSignalingSession';
|
|
7
|
+
function decodeBase64UrlJsonForLogs(payload) {
|
|
8
|
+
try {
|
|
9
|
+
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
10
|
+
const paddingLen = (4 - (normalized.length % 4)) % 4;
|
|
11
|
+
const padded = normalized + '='.repeat(paddingLen);
|
|
12
|
+
if (typeof atob === 'function') {
|
|
13
|
+
return JSON.parse(atob(padded));
|
|
14
|
+
}
|
|
15
|
+
const globalBuffer = globalThis.Buffer;
|
|
16
|
+
if (globalBuffer) {
|
|
17
|
+
return JSON.parse(globalBuffer.from(padded, 'base64').toString('utf8'));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function fingerprintForLogs(value) {
|
|
26
|
+
if (!value) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
let hash = 0x811c9dc5;
|
|
30
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
31
|
+
hash ^= value.charCodeAt(i);
|
|
32
|
+
hash = Math.imul(hash, 0x01000193);
|
|
33
|
+
}
|
|
34
|
+
return `fp-${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
35
|
+
}
|
|
36
|
+
function summarizeTicketForLogs(ticket) {
|
|
37
|
+
const trimmed = typeof ticket === 'string' ? ticket.trim() : '';
|
|
38
|
+
if (!trimmed) {
|
|
39
|
+
return {
|
|
40
|
+
present: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const dotIndex = trimmed.lastIndexOf('.');
|
|
44
|
+
const irohTicket = dotIndex >= 0 ? trimmed.slice(0, dotIndex) : trimmed;
|
|
45
|
+
const tokenSuffix = dotIndex >= 0 ? trimmed.slice(dotIndex + 1) : null;
|
|
46
|
+
const decoded = tokenSuffix ? decodeBase64UrlJsonForLogs(tokenSuffix) : null;
|
|
47
|
+
const token = decoded && typeof decoded.t === 'string' && decoded.t.trim().length > 0
|
|
48
|
+
? decoded.t.trim()
|
|
49
|
+
: null;
|
|
50
|
+
const scope = decoded && typeof decoded.s === 'string' && decoded.s.trim().length > 0
|
|
51
|
+
? decoded.s.trim()
|
|
52
|
+
: null;
|
|
53
|
+
const maxConnections = decoded && typeof decoded.m === 'number' && Number.isFinite(decoded.m)
|
|
54
|
+
? decoded.m
|
|
55
|
+
: null;
|
|
56
|
+
return {
|
|
57
|
+
present: true,
|
|
58
|
+
irohFingerprint: fingerprintForLogs(irohTicket),
|
|
59
|
+
tokenFingerprint: fingerprintForLogs(token),
|
|
60
|
+
tokenSuffixFingerprint: fingerprintForLogs(tokenSuffix),
|
|
61
|
+
scope,
|
|
62
|
+
maxConnections,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export class BrowserFirestoreSignaling {
|
|
66
|
+
constructor(options) {
|
|
67
|
+
this.options = options;
|
|
68
|
+
this.deviceListeners = new Map();
|
|
69
|
+
this.devicesById = new Map();
|
|
70
|
+
this.staleDeviceOfflineWrites = new Set();
|
|
71
|
+
this.nextListenerIdSeed = { value: 1 };
|
|
72
|
+
this.activeDeviceScopeKey = null;
|
|
73
|
+
this.activeDeviceUnsubscribe = null;
|
|
74
|
+
this.deviceWatchGeneration = 0;
|
|
75
|
+
this.namespaceRootPromise = null;
|
|
76
|
+
// Phase 1: RTDB presence overlay.
|
|
77
|
+
// deviceTicketCache: one-time getDocs snapshot of Firestore device docs (tickets/metadata).
|
|
78
|
+
// rtdbPresenceMap: live online/offline boolean map from RTDB, updated in real-time.
|
|
79
|
+
// knownTicketVersions: last seen ticketVersion per deviceId; if RTDB pushes a new version
|
|
80
|
+
// we re-fetch that device's Firestore doc to get the updated ticket.
|
|
81
|
+
this.rtdbPresence = null;
|
|
82
|
+
this.rtdbScope = null;
|
|
83
|
+
this.rtdbPresenceMap = {};
|
|
84
|
+
this.rtdbUnsubscribe = null;
|
|
85
|
+
this.knownTicketVersions = new Map();
|
|
86
|
+
this.firestore = getFirestore(options.app);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Wire in the RTDB presence instance and scope. Called by WasmBridge after
|
|
90
|
+
* auth is established. Must be called before the first onDevicesChange call
|
|
91
|
+
* for RTDB presence to take effect. If not called, falls back to the legacy
|
|
92
|
+
* Firestore onSnapshot watcher (no regression for callers that don't upgrade).
|
|
93
|
+
*/
|
|
94
|
+
setRtdbPresence(rtdbPresence, scope) {
|
|
95
|
+
// If scope changed, tear down existing RTDB subscription.
|
|
96
|
+
if (this.rtdbUnsubscribe) {
|
|
97
|
+
this.rtdbUnsubscribe();
|
|
98
|
+
this.rtdbUnsubscribe = null;
|
|
99
|
+
}
|
|
100
|
+
this.rtdbPresence = rtdbPresence;
|
|
101
|
+
this.rtdbScope = scope;
|
|
102
|
+
this.rtdbPresenceMap = {};
|
|
103
|
+
this.knownTicketVersions.clear();
|
|
104
|
+
}
|
|
105
|
+
/** Stop RTDB subscription and clear scope. Called on sign-out or scope change. */
|
|
106
|
+
stopRtdbPresence() {
|
|
107
|
+
this.rtdbUnsubscribe?.();
|
|
108
|
+
this.rtdbUnsubscribe = null;
|
|
109
|
+
this.rtdbPresence = null;
|
|
110
|
+
this.rtdbScope = null;
|
|
111
|
+
this.rtdbPresenceMap = {};
|
|
112
|
+
this.knownTicketVersions.clear();
|
|
113
|
+
}
|
|
114
|
+
async searchDevices(excludeNodeId) {
|
|
115
|
+
const snapshot = await this.readCurrentDeviceSnapshot();
|
|
116
|
+
const devices = snapshot.filter((device) => device.online);
|
|
117
|
+
if (!excludeNodeId) {
|
|
118
|
+
return devices;
|
|
119
|
+
}
|
|
120
|
+
return this.filterDevices(devices, excludeNodeId);
|
|
121
|
+
}
|
|
122
|
+
async subscribeDevices() {
|
|
123
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
124
|
+
if (!collectionRef) {
|
|
125
|
+
return new ReadableStream({
|
|
126
|
+
start(controller) {
|
|
127
|
+
controller.close();
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
let unsubscribe = null;
|
|
132
|
+
return new ReadableStream({
|
|
133
|
+
start: (controller) => {
|
|
134
|
+
unsubscribe = onSnapshot(collectionRef, (snapshot) => {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const events = [];
|
|
137
|
+
for (const change of snapshot.docChanges()) {
|
|
138
|
+
const device = this.toDevice(change.doc, now);
|
|
139
|
+
if (change.type === 'removed') {
|
|
140
|
+
events.push({ type: 'removed', deviceId: change.doc.id });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!device) {
|
|
144
|
+
events.push({ type: 'removed', deviceId: change.doc.id });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
events.push({
|
|
148
|
+
type: change.type === 'added' ? 'added' : 'modified',
|
|
149
|
+
device,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (events.length > 0) {
|
|
153
|
+
controller.enqueue(events);
|
|
154
|
+
}
|
|
155
|
+
}, (error) => controller.error(error));
|
|
156
|
+
},
|
|
157
|
+
cancel: () => {
|
|
158
|
+
unsubscribe?.();
|
|
159
|
+
unsubscribe = null;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
onDevicesChange(callback, excludeNodeId) {
|
|
164
|
+
const listenerId = this.nextListenerIdSeed.value++;
|
|
165
|
+
this.deviceListeners.set(listenerId, { callback, excludeNodeId });
|
|
166
|
+
this.emitDevicesToListener(listenerId);
|
|
167
|
+
void this.ensureDeviceWatch();
|
|
168
|
+
return () => {
|
|
169
|
+
this.deviceListeners.delete(listenerId);
|
|
170
|
+
if (this.deviceListeners.size === 0) {
|
|
171
|
+
this.stopDeviceWatch({ clearDevices: true });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async handleScopeChange() {
|
|
176
|
+
const hasListeners = this.deviceListeners.size > 0;
|
|
177
|
+
this.stopDeviceWatch({ clearDevices: true });
|
|
178
|
+
if (hasListeners) {
|
|
179
|
+
await this.ensureDeviceWatch();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
stopAuthScopedActivity() {
|
|
183
|
+
this.stopRtdbPresence();
|
|
184
|
+
this.stopDeviceWatch({ clearDevices: true });
|
|
185
|
+
}
|
|
186
|
+
async updatePresence(localNodeId, ticketStr, isOnline, ttlMs, metadata, deviceNameOverride) {
|
|
187
|
+
const deviceRef = await this.resolveOrCreateLocalDeviceRef();
|
|
188
|
+
if (!deviceRef) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const deviceId = this.options.getLocalDeviceId();
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
const deviceName = deviceNameOverride?.trim() || this.options.getDefaultDeviceName();
|
|
194
|
+
const userId = this.options.getCurrentUserId();
|
|
195
|
+
const expiresAt = isOnline
|
|
196
|
+
? now + Math.max(0, Math.trunc(ttlMs))
|
|
197
|
+
: now + DEVICE_OFFLINE_RETENTION_MS;
|
|
198
|
+
const existing = await getDoc(deviceRef).catch(() => null);
|
|
199
|
+
const payload = {
|
|
200
|
+
appTag: this.options.appTag,
|
|
201
|
+
tag: this.options.appTag,
|
|
202
|
+
kind: 'device',
|
|
203
|
+
deviceId,
|
|
204
|
+
deviceName,
|
|
205
|
+
nodeId: localNodeId,
|
|
206
|
+
platformType: 'web',
|
|
207
|
+
online: isOnline,
|
|
208
|
+
ticket: ticketStr,
|
|
209
|
+
metadata: metadata ?? null,
|
|
210
|
+
capabilities: {
|
|
211
|
+
canHost: false,
|
|
212
|
+
canSync: false,
|
|
213
|
+
readOnly: false,
|
|
214
|
+
},
|
|
215
|
+
lastSeenAt: serverTimestamp(),
|
|
216
|
+
updatedAt: serverTimestamp(),
|
|
217
|
+
expiresAt,
|
|
218
|
+
};
|
|
219
|
+
if (userId) {
|
|
220
|
+
payload.userId = userId;
|
|
221
|
+
}
|
|
222
|
+
if (!existing?.exists()) {
|
|
223
|
+
payload.createdAt = serverTimestamp();
|
|
224
|
+
}
|
|
225
|
+
await setDoc(deviceRef, payload, { merge: true });
|
|
226
|
+
}
|
|
227
|
+
async setOffline(localNodeId) {
|
|
228
|
+
const deviceRef = await this.resolveOrCreateLocalDeviceRef();
|
|
229
|
+
if (!deviceRef) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await setDoc(deviceRef, {
|
|
233
|
+
appTag: this.options.appTag,
|
|
234
|
+
tag: this.options.appTag,
|
|
235
|
+
kind: 'device',
|
|
236
|
+
deviceId: this.options.getLocalDeviceId(),
|
|
237
|
+
userId: this.options.getCurrentUserId(),
|
|
238
|
+
deviceName: this.options.getDefaultDeviceName(),
|
|
239
|
+
nodeId: localNodeId,
|
|
240
|
+
platformType: 'web',
|
|
241
|
+
online: false,
|
|
242
|
+
updatedAt: serverTimestamp(),
|
|
243
|
+
lastSeenAt: serverTimestamp(),
|
|
244
|
+
expiresAt: Date.now() + DEVICE_OFFLINE_RETENTION_MS,
|
|
245
|
+
}, { merge: true });
|
|
246
|
+
}
|
|
247
|
+
async cleanupStaleDevices() {
|
|
248
|
+
const devicesCollection = await this.getDevicesCollectionRef();
|
|
249
|
+
if (!devicesCollection) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const snapshot = await getDocs(devicesCollection);
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
await Promise.allSettled(snapshot.docs.map(async (document) => {
|
|
255
|
+
const raw = document.data();
|
|
256
|
+
const expired = this.isDeviceExpired(raw, now);
|
|
257
|
+
const isOnline = raw.online === true;
|
|
258
|
+
if (expired && !isOnline) {
|
|
259
|
+
await deleteDoc(document.ref);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const lastSeenMs = this.toMillis(raw.updatedAt) ??
|
|
263
|
+
this.toMillis(raw.lastSeenAt) ??
|
|
264
|
+
0;
|
|
265
|
+
if (!isOnline || !lastSeenMs || lastSeenMs + DEVICE_STALE_HEARTBEAT_MS > now) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await setDoc(document.ref, {
|
|
269
|
+
online: false,
|
|
270
|
+
updatedAt: serverTimestamp(),
|
|
271
|
+
expiresAt: now + DEVICE_OFFLINE_RETENTION_MS,
|
|
272
|
+
}, { merge: true });
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
async updateDevice(deviceId, updates) {
|
|
276
|
+
const deviceRef = await this.resolveDeviceRef(deviceId);
|
|
277
|
+
if (!deviceRef) {
|
|
278
|
+
throw new Error('updateDevice requires a known device record');
|
|
279
|
+
}
|
|
280
|
+
const payload = {
|
|
281
|
+
updatedAt: serverTimestamp(),
|
|
282
|
+
};
|
|
283
|
+
if (typeof updates.deviceName === 'string') {
|
|
284
|
+
payload.deviceName = updates.deviceName;
|
|
285
|
+
}
|
|
286
|
+
if (updates.capabilities) {
|
|
287
|
+
payload.capabilities = {
|
|
288
|
+
canHost: updates.capabilities.canHost ??
|
|
289
|
+
updates.capabilities.can_host ??
|
|
290
|
+
false,
|
|
291
|
+
canSync: updates.capabilities.canSync ??
|
|
292
|
+
updates.capabilities.can_sync ??
|
|
293
|
+
false,
|
|
294
|
+
readOnly: updates.capabilities.readOnly ??
|
|
295
|
+
updates.capabilities.read_only ??
|
|
296
|
+
false,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (typeof updates.metadata === 'string') {
|
|
300
|
+
payload.metadata = updates.metadata;
|
|
301
|
+
}
|
|
302
|
+
await setDoc(deviceRef, payload, { merge: true });
|
|
303
|
+
}
|
|
304
|
+
async deleteDevice(deviceId) {
|
|
305
|
+
const deviceRef = await this.resolveDeviceRef(deviceId);
|
|
306
|
+
if (!deviceRef) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await deleteDoc(deviceRef);
|
|
310
|
+
}
|
|
311
|
+
async createSession(session) {
|
|
312
|
+
if (this.shouldUseHostedSessionCallable()) {
|
|
313
|
+
await this.createHostedSession(session);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const sessionsCollection = await this.getSessionsCollectionRef();
|
|
317
|
+
if (!sessionsCollection) {
|
|
318
|
+
throw new Error('createSession requires an active browser signaling namespace');
|
|
319
|
+
}
|
|
320
|
+
const sessionRef = doc(sessionsCollection, session.connectionId);
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
// 10-minute TTL: Firestore native TTL policy will delete this doc once
|
|
323
|
+
// the session has either completed or was abandoned (e.g. client crash).
|
|
324
|
+
const SESSION_TTL_MS = 10 * 60 * 1000;
|
|
325
|
+
await setDoc(sessionRef, {
|
|
326
|
+
appTag: session.appTag ?? this.options.appTag,
|
|
327
|
+
connectionId: session.connectionId,
|
|
328
|
+
initiator: session.initiator ?? this.options.getCurrentUserId(),
|
|
329
|
+
target: session.target ?? this.options.getCurrentUserId(),
|
|
330
|
+
initiatorDeviceId: session.initiatorDeviceId,
|
|
331
|
+
targetDeviceId: session.targetDeviceId,
|
|
332
|
+
connectionType: session.connectionType ?? null,
|
|
333
|
+
state: session.state,
|
|
334
|
+
offer: session.offer ? JSON.stringify(session.offer) : null,
|
|
335
|
+
offerE2ee: session.offerE2ee ? JSON.stringify(session.offerE2ee) : null,
|
|
336
|
+
answer: session.answer ? JSON.stringify(session.answer) : null,
|
|
337
|
+
answerE2ee: session.answerE2ee ? JSON.stringify(session.answerE2ee) : null,
|
|
338
|
+
iceCandidates: Array.isArray(session.iceCandidates)
|
|
339
|
+
? JSON.stringify(session.iceCandidates)
|
|
340
|
+
: null,
|
|
341
|
+
initiatorNodeId: session.initiatorNodeId ?? null,
|
|
342
|
+
targetNodeId: session.targetNodeId ?? null,
|
|
343
|
+
initiatorEndpointAddr: session.initiatorEndpointAddr ?? null,
|
|
344
|
+
targetEndpointAddr: session.targetEndpointAddr ?? null,
|
|
345
|
+
intent: session.intent ?? null,
|
|
346
|
+
createdAt: session.createdAt ?? now,
|
|
347
|
+
updatedAt: now,
|
|
348
|
+
expiresAt: now + SESSION_TTL_MS,
|
|
349
|
+
}, { merge: true });
|
|
350
|
+
}
|
|
351
|
+
shouldUseHostedSessionCallable() {
|
|
352
|
+
return (this.options.discoveryMode ?? 'personal') !== 'personal' && !!this.options.getCurrentUserId();
|
|
353
|
+
}
|
|
354
|
+
async createHostedSession(session) {
|
|
355
|
+
const callable = httpsCallable(getFunctions(this.options.app), CREATE_SIGNALING_SESSION_FUNCTION);
|
|
356
|
+
await callable({
|
|
357
|
+
...session,
|
|
358
|
+
appTag: session.appTag ?? this.options.appTag,
|
|
359
|
+
initiator: session.initiator ?? this.options.getCurrentUserId() ?? undefined,
|
|
360
|
+
target: session.target ?? this.options.getCurrentUserId() ?? undefined,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async updateSession(sessionId, updateData) {
|
|
364
|
+
const sessionsCollection = await this.getSessionsCollectionRef();
|
|
365
|
+
if (!sessionsCollection) {
|
|
366
|
+
throw new Error('updateSession requires an active browser signaling namespace');
|
|
367
|
+
}
|
|
368
|
+
const sessionRef = doc(sessionsCollection, this.parseScopedId(sessionId));
|
|
369
|
+
const payload = {};
|
|
370
|
+
if (updateData && typeof updateData === 'object') {
|
|
371
|
+
for (const [key, value] of Object.entries(updateData)) {
|
|
372
|
+
if (value === undefined) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (value === null ||
|
|
376
|
+
typeof value === 'string' ||
|
|
377
|
+
typeof value === 'number' ||
|
|
378
|
+
typeof value === 'boolean') {
|
|
379
|
+
payload[key] = value;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
payload[key] = JSON.stringify(value);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (Object.keys(payload).length === 0) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
payload.updatedAt = Date.now();
|
|
389
|
+
await updateDoc(sessionRef, payload);
|
|
390
|
+
}
|
|
391
|
+
async sendMessage(targetId, payload, state, replyPayload) {
|
|
392
|
+
const messagesCollection = await this.getMessagesCollectionRef();
|
|
393
|
+
if (!messagesCollection) {
|
|
394
|
+
throw new Error('sendMessage requires an active browser signaling namespace');
|
|
395
|
+
}
|
|
396
|
+
const messageId = this.createDocumentId();
|
|
397
|
+
const messageRef = doc(messagesCollection, messageId);
|
|
398
|
+
const senderId = this.getMessageSenderId();
|
|
399
|
+
const messageTimestamp = Date.now();
|
|
400
|
+
// 24-hour TTL: messages are short-lived polling fallback docs.
|
|
401
|
+
// Firestore native TTL policy deletes them if the target never polls.
|
|
402
|
+
const MESSAGE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
403
|
+
await setDoc(messageRef, {
|
|
404
|
+
appTag: this.options.appTag,
|
|
405
|
+
senderId,
|
|
406
|
+
targetId,
|
|
407
|
+
payload,
|
|
408
|
+
state: state ?? null,
|
|
409
|
+
replyPayload: replyPayload ?? null,
|
|
410
|
+
timestamp: messageTimestamp,
|
|
411
|
+
expiresAt: messageTimestamp + MESSAGE_TTL_MS,
|
|
412
|
+
});
|
|
413
|
+
return messageRef.path;
|
|
414
|
+
}
|
|
415
|
+
async pollMessages(targetId) {
|
|
416
|
+
const messagesCollection = await this.getMessagesCollectionRef();
|
|
417
|
+
if (!messagesCollection) {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
const snapshot = await getDocs(query(messagesCollection, where('targetId', '==', targetId), limit(50)));
|
|
421
|
+
const envelopes = [];
|
|
422
|
+
for (const document of snapshot.docs) {
|
|
423
|
+
const raw = document.data();
|
|
424
|
+
const senderId = typeof raw.senderId === 'string' ? raw.senderId : '';
|
|
425
|
+
const envelopeTargetId = typeof raw.targetId === 'string' ? raw.targetId : '';
|
|
426
|
+
const payload = typeof raw.payload === 'string' ? raw.payload : '';
|
|
427
|
+
if (!senderId || !envelopeTargetId || !payload) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
envelopes.push({
|
|
431
|
+
senderId,
|
|
432
|
+
targetId: envelopeTargetId,
|
|
433
|
+
payload,
|
|
434
|
+
state: typeof raw.state === 'string' ? raw.state : undefined,
|
|
435
|
+
replyPayload: typeof raw.replyPayload === 'string' ? raw.replyPayload : undefined,
|
|
436
|
+
timestamp: this.toMillis(raw.timestamp),
|
|
437
|
+
_documentId: document.id,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
envelopes.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
|
|
441
|
+
await Promise.allSettled(envelopes.map(async (envelope) => {
|
|
442
|
+
await deleteDoc(doc(messagesCollection, envelope._documentId));
|
|
443
|
+
}));
|
|
444
|
+
return envelopes.map(({ _documentId, ...envelope }) => envelope);
|
|
445
|
+
}
|
|
446
|
+
async subscribeSessions(localDeviceId) {
|
|
447
|
+
const sessionsCollection = await this.getSessionsCollectionRef();
|
|
448
|
+
const targetDeviceId = this.parseScopedId(localDeviceId);
|
|
449
|
+
if (!sessionsCollection || !targetDeviceId) {
|
|
450
|
+
return new ReadableStream({
|
|
451
|
+
start(controller) {
|
|
452
|
+
controller.close();
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
let unsubscribeInitiator = null;
|
|
457
|
+
let unsubscribeTarget = null;
|
|
458
|
+
let closed = false;
|
|
459
|
+
return new ReadableStream({
|
|
460
|
+
start: (controller) => {
|
|
461
|
+
const memberships = {
|
|
462
|
+
initiator: new Set(),
|
|
463
|
+
target: new Set(),
|
|
464
|
+
};
|
|
465
|
+
const sessions = new Map();
|
|
466
|
+
const applyChanges = (source, snapshot) => {
|
|
467
|
+
if (closed) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const events = [];
|
|
471
|
+
for (const change of snapshot.docChanges()) {
|
|
472
|
+
const id = change.doc.id;
|
|
473
|
+
if (change.type === 'removed') {
|
|
474
|
+
memberships[source].delete(id);
|
|
475
|
+
const stillTracked = memberships.initiator.has(id) || memberships.target.has(id);
|
|
476
|
+
if (!stillTracked && sessions.delete(id)) {
|
|
477
|
+
events.push({ type: 'removed', sessionId: id });
|
|
478
|
+
}
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const parsed = this.toSession(change.doc.id, change.doc.data());
|
|
482
|
+
if (!parsed) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
memberships[source].add(id);
|
|
486
|
+
const existed = sessions.has(id);
|
|
487
|
+
sessions.set(id, parsed);
|
|
488
|
+
events.push({
|
|
489
|
+
type: existed ? 'modified' : 'added',
|
|
490
|
+
session: parsed,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (events.length > 0) {
|
|
494
|
+
controller.enqueue(events);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
unsubscribeInitiator = onSnapshot(query(sessionsCollection, where('initiatorDeviceId', '==', targetDeviceId)), (snapshot) => applyChanges('initiator', snapshot), (error) => {
|
|
498
|
+
if (!closed) {
|
|
499
|
+
controller.error(error);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
unsubscribeTarget = onSnapshot(query(sessionsCollection, where('targetDeviceId', '==', targetDeviceId)), (snapshot) => applyChanges('target', snapshot), (error) => {
|
|
503
|
+
if (!closed) {
|
|
504
|
+
controller.error(error);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
cancel: () => {
|
|
509
|
+
closed = true;
|
|
510
|
+
unsubscribeInitiator?.();
|
|
511
|
+
unsubscribeTarget?.();
|
|
512
|
+
unsubscribeInitiator = null;
|
|
513
|
+
unsubscribeTarget = null;
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
async ensureDeviceWatch() {
|
|
518
|
+
if (this.deviceListeners.size === 0) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
522
|
+
const scopeKey = collectionRef?.path ?? null;
|
|
523
|
+
if (!collectionRef || !scopeKey) {
|
|
524
|
+
this.devicesById.clear();
|
|
525
|
+
this.emitDevicesToAll();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (this.activeDeviceUnsubscribe && this.activeDeviceScopeKey === scopeKey) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.stopDeviceWatch({ clearDevices: true });
|
|
532
|
+
// Phase 1: if RTDB presence is configured, use getDocs (one-time cache)
|
|
533
|
+
// for Firestore tickets + RTDB for the live online/offline overlay.
|
|
534
|
+
if (this.rtdbPresence && this.rtdbScope) {
|
|
535
|
+
await this.ensureDeviceWatchRtdb(collectionRef, scopeKey);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Fallback: legacy Firestore onSnapshot watcher (no RTDB).
|
|
539
|
+
const generation = ++this.deviceWatchGeneration;
|
|
540
|
+
this.activeDeviceScopeKey = scopeKey;
|
|
541
|
+
this.activeDeviceUnsubscribe = onSnapshot(collectionRef, (snapshot) => {
|
|
542
|
+
if (generation !== this.deviceWatchGeneration) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
this.devicesById.clear();
|
|
547
|
+
for (const document of snapshot.docs) {
|
|
548
|
+
const device = this.toDevice(document, now);
|
|
549
|
+
if (!device) {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
this.devicesById.set(device.deviceId, device);
|
|
553
|
+
}
|
|
554
|
+
this.emitDevicesToAll();
|
|
555
|
+
}, (error) => {
|
|
556
|
+
if (generation !== this.deviceWatchGeneration) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
console.warn('[BrowserFirestoreSignaling] device watch failed:', error);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* RTDB-backed device watch (Phase 1).
|
|
564
|
+
*
|
|
565
|
+
* 1. One-time getDocs() to populate devicesById with Firestore tickets.
|
|
566
|
+
* 2. Subscribe to RTDB for the live online/offline boolean overlay.
|
|
567
|
+
* 3. When RTDB fires, merge online state into devicesById and emit.
|
|
568
|
+
* 4. If ticketVersion changed for a device, re-fetch its Firestore doc.
|
|
569
|
+
*/
|
|
570
|
+
async ensureDeviceWatchRtdb(collectionRef, scopeKey) {
|
|
571
|
+
const generation = ++this.deviceWatchGeneration;
|
|
572
|
+
this.activeDeviceScopeKey = scopeKey;
|
|
573
|
+
// Step 1: one-time Firestore fetch to warm the ticket cache.
|
|
574
|
+
const snapshot = await getDocs(collectionRef);
|
|
575
|
+
if (generation !== this.deviceWatchGeneration) {
|
|
576
|
+
return; // scope changed while we were fetching
|
|
577
|
+
}
|
|
578
|
+
const now = Date.now();
|
|
579
|
+
this.devicesById.clear();
|
|
580
|
+
for (const document of snapshot.docs) {
|
|
581
|
+
const device = this.toDevice(document, now);
|
|
582
|
+
if (device) {
|
|
583
|
+
this.devicesById.set(device.deviceId, device);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Step 2: subscribe to RTDB for live online state.
|
|
587
|
+
const rtdb = this.rtdbPresence;
|
|
588
|
+
const scope = this.rtdbScope;
|
|
589
|
+
if (this.rtdbUnsubscribe) {
|
|
590
|
+
this.rtdbUnsubscribe();
|
|
591
|
+
}
|
|
592
|
+
this.rtdbUnsubscribe = rtdb.onPresenceChange(scope, (presenceMap) => {
|
|
593
|
+
if (generation !== this.deviceWatchGeneration) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.rtdbPresenceMap = presenceMap;
|
|
597
|
+
// Check for ticket version changes and re-fetch those devices.
|
|
598
|
+
const refetchIds = [];
|
|
599
|
+
for (const [deviceId, state] of Object.entries(presenceMap)) {
|
|
600
|
+
const knownVersion = this.knownTicketVersions.get(deviceId) ?? -1;
|
|
601
|
+
if (state.ticketVersion !== knownVersion) {
|
|
602
|
+
this.knownTicketVersions.set(deviceId, state.ticketVersion);
|
|
603
|
+
if (knownVersion !== -1) {
|
|
604
|
+
// Version changed after initial load — ticket is stale.
|
|
605
|
+
refetchIds.push(deviceId);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Apply RTDB online/offline overlay to devicesById.
|
|
610
|
+
this.applyRtdbOverlay();
|
|
611
|
+
// Re-fetch stale tickets in the background, then re-emit.
|
|
612
|
+
if (refetchIds.length > 0) {
|
|
613
|
+
void this.refetchDeviceTickets(refetchIds, collectionRef, generation);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
// No separate activeDeviceUnsubscribe — we manage rtdbUnsubscribe directly.
|
|
617
|
+
// Set a no-op so stopDeviceWatch() doesn't break.
|
|
618
|
+
this.activeDeviceUnsubscribe = () => {
|
|
619
|
+
this.rtdbUnsubscribe?.();
|
|
620
|
+
this.rtdbUnsubscribe = null;
|
|
621
|
+
};
|
|
622
|
+
// Initial emit with current Firestore state (RTDB callback will follow shortly).
|
|
623
|
+
this.emitDevicesToAll();
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Merge RTDB online/offline map into devicesById, then emit.
|
|
627
|
+
* Devices present in Firestore cache but absent from RTDB are shown as-is
|
|
628
|
+
* (their Firestore `online` field is the fallback).
|
|
629
|
+
*/
|
|
630
|
+
applyRtdbOverlay() {
|
|
631
|
+
for (const [deviceId, device] of this.devicesById) {
|
|
632
|
+
const rtdbState = this.rtdbPresenceMap[deviceId];
|
|
633
|
+
if (!rtdbState)
|
|
634
|
+
continue;
|
|
635
|
+
if (device.online !== rtdbState.online) {
|
|
636
|
+
// Shallow-clone so listeners always get a new object reference.
|
|
637
|
+
this.devicesById.set(deviceId, { ...device, online: rtdbState.online });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
this.emitDevicesToAll();
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Re-fetch specific device docs from Firestore (ticket version changed).
|
|
644
|
+
* Updates devicesById and re-emits.
|
|
645
|
+
*/
|
|
646
|
+
async refetchDeviceTickets(deviceIds, collectionRef, generation) {
|
|
647
|
+
await Promise.allSettled(deviceIds.map(async (deviceId) => {
|
|
648
|
+
try {
|
|
649
|
+
const deviceRef = doc(collectionRef, deviceId);
|
|
650
|
+
const snap = await getDoc(deviceRef);
|
|
651
|
+
if (generation !== this.deviceWatchGeneration)
|
|
652
|
+
return;
|
|
653
|
+
if (!snap.exists()) {
|
|
654
|
+
this.devicesById.delete(deviceId);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const fresh = this.deviceFromSnapshot(deviceId, typeof snap.data === 'function'
|
|
658
|
+
? snap.data()
|
|
659
|
+
: null, Date.now());
|
|
660
|
+
if (fresh) {
|
|
661
|
+
// Apply current RTDB online state to the freshly fetched doc.
|
|
662
|
+
const rtdbState = this.rtdbPresenceMap[deviceId];
|
|
663
|
+
this.devicesById.set(deviceId, rtdbState
|
|
664
|
+
? { ...fresh, online: rtdbState.online }
|
|
665
|
+
: fresh);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
this.devicesById.delete(deviceId);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
console.warn('[BrowserFirestoreSignaling] refetch device ticket failed:', err);
|
|
673
|
+
}
|
|
674
|
+
}));
|
|
675
|
+
if (generation === this.deviceWatchGeneration) {
|
|
676
|
+
this.emitDevicesToAll();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async refreshDeviceForAutoConnect(deviceId) {
|
|
680
|
+
const trimmedDeviceId = deviceId.trim();
|
|
681
|
+
if (!trimmedDeviceId) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
685
|
+
if (!collectionRef) {
|
|
686
|
+
return this.devicesById.get(trimmedDeviceId) ?? null;
|
|
687
|
+
}
|
|
688
|
+
const deviceRef = doc(collectionRef, trimmedDeviceId);
|
|
689
|
+
const previous = this.devicesById.get(trimmedDeviceId) ?? null;
|
|
690
|
+
let source = 'server';
|
|
691
|
+
let snap = null;
|
|
692
|
+
try {
|
|
693
|
+
snap = await getDocFromServer(deviceRef);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
source = 'cache';
|
|
697
|
+
console.warn('[BrowserFirestoreSignaling][auto-connect][refresh] getDocFromServer failed; falling back to getDoc', {
|
|
698
|
+
deviceId: trimmedDeviceId,
|
|
699
|
+
error,
|
|
700
|
+
});
|
|
701
|
+
snap = await getDoc(deviceRef).catch(() => null);
|
|
702
|
+
}
|
|
703
|
+
if (!snap?.exists()) {
|
|
704
|
+
this.devicesById.delete(trimmedDeviceId);
|
|
705
|
+
console.info('[BrowserFirestoreSignaling][auto-connect][refresh] device doc missing during refresh', {
|
|
706
|
+
deviceId: trimmedDeviceId,
|
|
707
|
+
source,
|
|
708
|
+
previousTicket: summarizeTicketForLogs(previous?.ticket),
|
|
709
|
+
});
|
|
710
|
+
this.emitDevicesToAll();
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const raw = typeof snap.data === 'function'
|
|
714
|
+
? snap.data()
|
|
715
|
+
: null;
|
|
716
|
+
const fresh = this.deviceFromSnapshot(trimmedDeviceId, raw, Date.now());
|
|
717
|
+
if (!fresh) {
|
|
718
|
+
this.devicesById.delete(trimmedDeviceId);
|
|
719
|
+
console.info('[BrowserFirestoreSignaling][auto-connect][refresh] refreshed device doc was filtered out', {
|
|
720
|
+
deviceId: trimmedDeviceId,
|
|
721
|
+
source,
|
|
722
|
+
previousTicket: summarizeTicketForLogs(previous?.ticket),
|
|
723
|
+
});
|
|
724
|
+
this.emitDevicesToAll();
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const rtdbState = this.rtdbPresenceMap[trimmedDeviceId];
|
|
728
|
+
const refreshed = rtdbState ? { ...fresh, online: rtdbState.online } : fresh;
|
|
729
|
+
this.devicesById.set(trimmedDeviceId, refreshed);
|
|
730
|
+
const ticketChanged = (previous?.ticket ?? null) !== (refreshed.ticket ?? null);
|
|
731
|
+
console.info('[BrowserFirestoreSignaling][auto-connect][refresh]', {
|
|
732
|
+
deviceId: trimmedDeviceId,
|
|
733
|
+
source,
|
|
734
|
+
ticketChanged,
|
|
735
|
+
previousTicket: summarizeTicketForLogs(previous?.ticket),
|
|
736
|
+
refreshedTicket: summarizeTicketForLogs(refreshed.ticket),
|
|
737
|
+
previousNodeId: previous?.nodeId ?? null,
|
|
738
|
+
refreshedNodeId: refreshed.nodeId ?? null,
|
|
739
|
+
});
|
|
740
|
+
this.emitDevicesToAll();
|
|
741
|
+
return refreshed;
|
|
742
|
+
}
|
|
743
|
+
stopDeviceWatch(options) {
|
|
744
|
+
this.deviceWatchGeneration += 1;
|
|
745
|
+
this.activeDeviceUnsubscribe?.();
|
|
746
|
+
this.activeDeviceUnsubscribe = null;
|
|
747
|
+
this.activeDeviceScopeKey = null;
|
|
748
|
+
if (options?.clearDevices) {
|
|
749
|
+
this.devicesById.clear();
|
|
750
|
+
this.emitDevicesToAll();
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
emitDevicesToAll() {
|
|
754
|
+
for (const listenerId of this.deviceListeners.keys()) {
|
|
755
|
+
this.emitDevicesToListener(listenerId);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
emitDevicesToListener(listenerId) {
|
|
759
|
+
const listener = this.deviceListeners.get(listenerId);
|
|
760
|
+
if (!listener) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const devices = Array.from(this.devicesById.values());
|
|
764
|
+
const filtered = listener.excludeNodeId
|
|
765
|
+
? this.filterDevices(devices, listener.excludeNodeId)
|
|
766
|
+
: devices;
|
|
767
|
+
listener.callback(filtered);
|
|
768
|
+
}
|
|
769
|
+
filterDevices(devices, excludeNodeId) {
|
|
770
|
+
return devices.filter((device) => device.ticket !== excludeNodeId &&
|
|
771
|
+
device.deviceId !== excludeNodeId &&
|
|
772
|
+
device.nodeId !== excludeNodeId);
|
|
773
|
+
}
|
|
774
|
+
async readCurrentDeviceSnapshot() {
|
|
775
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
776
|
+
if (!collectionRef) {
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
const snapshot = await getDocs(collectionRef);
|
|
780
|
+
const now = Date.now();
|
|
781
|
+
const devices = [];
|
|
782
|
+
for (const document of snapshot.docs) {
|
|
783
|
+
const raw = document.data();
|
|
784
|
+
if (this.isDeviceExpired(raw, now)) {
|
|
785
|
+
if (raw.online === true) {
|
|
786
|
+
await this.markStaleDeviceOffline(document, raw, now);
|
|
787
|
+
}
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
const device = this.options.normalizeDevice({
|
|
791
|
+
...raw,
|
|
792
|
+
deviceId: raw.deviceId ?? document.id,
|
|
793
|
+
}, document.id);
|
|
794
|
+
if (!device) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (!device.deviceId || !this.options.matchesTag(device)) {
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
devices.push(device);
|
|
801
|
+
}
|
|
802
|
+
return devices;
|
|
803
|
+
}
|
|
804
|
+
toDevice(document, now) {
|
|
805
|
+
return this.deviceFromSnapshot(document.id, document.data(), now, document);
|
|
806
|
+
}
|
|
807
|
+
deviceFromSnapshot(fallbackId, raw, now, document) {
|
|
808
|
+
if (!raw) {
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
if (this.isDeviceExpired(raw, now)) {
|
|
812
|
+
if (raw.online === true && document) {
|
|
813
|
+
void this.markStaleDeviceOffline(document, raw, now);
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
const device = this.options.normalizeDevice({
|
|
818
|
+
...raw,
|
|
819
|
+
deviceId: raw.deviceId ?? fallbackId,
|
|
820
|
+
}, fallbackId);
|
|
821
|
+
if (!device.deviceId || !this.options.matchesTag(device)) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
return device;
|
|
825
|
+
}
|
|
826
|
+
async markStaleDeviceOffline(document, raw, now) {
|
|
827
|
+
const deviceId = typeof raw.deviceId === 'string' && raw.deviceId.trim().length > 0
|
|
828
|
+
? raw.deviceId
|
|
829
|
+
: document.id;
|
|
830
|
+
const key = document.ref?.path || deviceId;
|
|
831
|
+
if (!key || this.staleDeviceOfflineWrites.has(key)) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
this.staleDeviceOfflineWrites.add(key);
|
|
835
|
+
try {
|
|
836
|
+
const deviceRef = await this.resolveDeviceRef(deviceId);
|
|
837
|
+
if (!deviceRef) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
await setDoc(deviceRef, {
|
|
841
|
+
online: false,
|
|
842
|
+
updatedAt: serverTimestamp(),
|
|
843
|
+
expiresAt: now + DEVICE_OFFLINE_RETENTION_MS,
|
|
844
|
+
}, { merge: true });
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
console.warn('[BrowserFirestoreSignaling] failed to mark stale device offline:', error);
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
this.staleDeviceOfflineWrites.delete(key);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
toSession(id, raw) {
|
|
854
|
+
const initiatorDeviceId = typeof raw.initiatorDeviceId === 'string' ? raw.initiatorDeviceId : '';
|
|
855
|
+
const targetDeviceId = typeof raw.targetDeviceId === 'string' ? raw.targetDeviceId : '';
|
|
856
|
+
if (!initiatorDeviceId && !targetDeviceId) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
connectionId: typeof raw.connectionId === 'string' ? raw.connectionId : id,
|
|
861
|
+
initiator: typeof raw.initiator === 'string' ? raw.initiator : '',
|
|
862
|
+
target: typeof raw.target === 'string' ? raw.target : '',
|
|
863
|
+
initiatorDeviceId,
|
|
864
|
+
targetDeviceId,
|
|
865
|
+
connectionType: typeof raw.connectionType === 'string' ? raw.connectionType : undefined,
|
|
866
|
+
offer: this.parseMaybeJson(raw.offer),
|
|
867
|
+
offerE2ee: this.parseMaybeJson(raw.offerE2ee),
|
|
868
|
+
answer: this.parseMaybeJson(raw.answer),
|
|
869
|
+
answerE2ee: this.parseMaybeJson(raw.answerE2ee),
|
|
870
|
+
iceCandidates: Array.isArray(raw.iceCandidates) ? raw.iceCandidates : [],
|
|
871
|
+
initiatorNodeId: typeof raw.initiatorNodeId === 'string' ? raw.initiatorNodeId : undefined,
|
|
872
|
+
targetNodeId: typeof raw.targetNodeId === 'string' ? raw.targetNodeId : undefined,
|
|
873
|
+
initiatorEndpointAddr: typeof raw.initiatorEndpointAddr === 'string'
|
|
874
|
+
? raw.initiatorEndpointAddr
|
|
875
|
+
: undefined,
|
|
876
|
+
targetEndpointAddr: typeof raw.targetEndpointAddr === 'string'
|
|
877
|
+
? raw.targetEndpointAddr
|
|
878
|
+
: undefined,
|
|
879
|
+
intent: typeof raw.intent === 'string' ? raw.intent : undefined,
|
|
880
|
+
appTag: typeof raw.appTag === 'string' ? raw.appTag : this.options.appTag,
|
|
881
|
+
createdAt: this.toMillis(raw.createdAt),
|
|
882
|
+
expiresAt: this.toMillis(raw.expiresAt),
|
|
883
|
+
state: typeof raw.state === 'string' ? raw.state : '',
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
parseMaybeJson(value) {
|
|
887
|
+
if (typeof value !== 'string') {
|
|
888
|
+
return value;
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
return JSON.parse(value);
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return value;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
isDeviceExpired(raw, now) {
|
|
898
|
+
const expiresAtMs = this.toMillis(raw.expiresAt);
|
|
899
|
+
if (typeof expiresAtMs === 'number') {
|
|
900
|
+
return expiresAtMs <= now;
|
|
901
|
+
}
|
|
902
|
+
const updatedAtMs = this.toMillis(raw.updatedAt);
|
|
903
|
+
if (typeof updatedAtMs === 'number') {
|
|
904
|
+
return updatedAtMs + DEVICE_STALE_HEARTBEAT_MS <= now;
|
|
905
|
+
}
|
|
906
|
+
const lastSeenAtMs = this.toMillis(raw.lastSeenAt);
|
|
907
|
+
if (typeof lastSeenAtMs === 'number') {
|
|
908
|
+
return lastSeenAtMs + DEVICE_STALE_HEARTBEAT_MS <= now;
|
|
909
|
+
}
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
toMillis(value) {
|
|
913
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
914
|
+
return value;
|
|
915
|
+
}
|
|
916
|
+
if (typeof value === 'string') {
|
|
917
|
+
const parsed = Date.parse(value);
|
|
918
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
919
|
+
}
|
|
920
|
+
if (value instanceof Date) {
|
|
921
|
+
return value.getTime();
|
|
922
|
+
}
|
|
923
|
+
if (value &&
|
|
924
|
+
typeof value === 'object' &&
|
|
925
|
+
typeof value.toMillis === 'function') {
|
|
926
|
+
return value.toMillis();
|
|
927
|
+
}
|
|
928
|
+
return undefined;
|
|
929
|
+
}
|
|
930
|
+
async getDevicesCollectionRef() {
|
|
931
|
+
const path = await this.getDevicesCollectionPath();
|
|
932
|
+
return path ? collection(this.firestore, path) : null;
|
|
933
|
+
}
|
|
934
|
+
async getSessionsCollectionRef() {
|
|
935
|
+
const namespaceRoot = await this.getNamespaceRoot();
|
|
936
|
+
return namespaceRoot ? collection(this.firestore, `${namespaceRoot}/sessions`) : null;
|
|
937
|
+
}
|
|
938
|
+
async getMessagesCollectionRef() {
|
|
939
|
+
const namespaceRoot = await this.getNamespaceRoot();
|
|
940
|
+
return namespaceRoot ? collection(this.firestore, `${namespaceRoot}/messages`) : null;
|
|
941
|
+
}
|
|
942
|
+
async getDevicesCollectionPath() {
|
|
943
|
+
const namespaceRoot = await this.getNamespaceRoot();
|
|
944
|
+
if (!namespaceRoot) {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
if (this.usesPersonalSpaceMode()) {
|
|
948
|
+
return `${namespaceRoot}/devices`;
|
|
949
|
+
}
|
|
950
|
+
const userId = this.options.getDiscoveryScopeId();
|
|
951
|
+
if (!userId) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
return `${namespaceRoot}/users/${userId}/devices`;
|
|
955
|
+
}
|
|
956
|
+
async getNamespaceRoot() {
|
|
957
|
+
if (this.usesPersonalSpaceMode()) {
|
|
958
|
+
if (!this.namespaceRootPromise) {
|
|
959
|
+
const apiKey = this.options.apiKey?.trim() ?? '';
|
|
960
|
+
const spaceKey = this.options.spaceKey?.trim() ?? '';
|
|
961
|
+
this.namespaceRootPromise = deriveSpaceNamespace(apiKey, spaceKey).then((namespace) => `spaces/${namespace}`);
|
|
962
|
+
}
|
|
963
|
+
return this.namespaceRootPromise;
|
|
964
|
+
}
|
|
965
|
+
return `apps/${this.options.appTag}`;
|
|
966
|
+
}
|
|
967
|
+
usesPersonalSpaceMode() {
|
|
968
|
+
return (this.options.discoveryMode ?? 'personal') === 'personal'
|
|
969
|
+
&& typeof this.options.apiKey === 'string'
|
|
970
|
+
&& this.options.apiKey.trim().length > 0
|
|
971
|
+
&& typeof this.options.spaceKey === 'string'
|
|
972
|
+
&& this.options.spaceKey.trim().length > 0;
|
|
973
|
+
}
|
|
974
|
+
parseScopedId(value) {
|
|
975
|
+
const parts = value.split('::');
|
|
976
|
+
return parts.length === 2 ? parts[1] : value;
|
|
977
|
+
}
|
|
978
|
+
getMessageSenderId() {
|
|
979
|
+
return this.options.getCurrentUserId() ?? this.options.getLocalDeviceId();
|
|
980
|
+
}
|
|
981
|
+
createDocumentId() {
|
|
982
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
983
|
+
}
|
|
984
|
+
async resolveOrCreateLocalDeviceRef() {
|
|
985
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
986
|
+
if (!collectionRef) {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
return doc(collectionRef, this.options.getLocalDeviceId());
|
|
990
|
+
}
|
|
991
|
+
async resolveDeviceRef(deviceId) {
|
|
992
|
+
const collectionRef = await this.getDevicesCollectionRef();
|
|
993
|
+
if (!collectionRef) {
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
const directRef = doc(collectionRef, deviceId);
|
|
997
|
+
if ((await getDoc(directRef)).exists()) {
|
|
998
|
+
return directRef;
|
|
999
|
+
}
|
|
1000
|
+
const byDeviceId = await getDocs(query(collectionRef, where('deviceId', '==', deviceId), limit(1)));
|
|
1001
|
+
if (!byDeviceId.empty) {
|
|
1002
|
+
return byDeviceId.docs[0].ref;
|
|
1003
|
+
}
|
|
1004
|
+
const byNodeId = await getDocs(query(collectionRef, where('nodeId', '==', deviceId), limit(1)));
|
|
1005
|
+
if (!byNodeId.empty) {
|
|
1006
|
+
return byNodeId.docs[0].ref;
|
|
1007
|
+
}
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
}
|