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,3689 @@
1
+ import { Connection } from './Connection';
2
+ import { Signaling } from './Signaling';
3
+ import { RoomManager } from './Room';
4
+ import { MoQSession } from '../moq/MoQSession';
5
+ import { TransportFactory } from './transports/TransportFactory';
6
+ import { PeerProjectionStore } from './PeerProjectionStore';
7
+ import { getAuthHost } from '../auth/host';
8
+ import { openRtcDebug, openRtcDebugInfo, openRtcDebugLog } from '../utils/debug';
9
+ import { createTransportTrustChallenge, getLocalTrustedIdentity, parseTrustedDeviceMetadata, signTransportTrustChallenge, verifyTransportTrustChallenge, } from './trust';
10
+ // Import the generated OpenRTC WASM package from ../../wasm/openrtc.js
11
+ // @ts-ignore
12
+ import initWasm, { WasmClient, start } from '../../wasm/openrtc.js';
13
+ function splitCompoundTicket(rawTicket) {
14
+ const dotIndex = rawTicket.lastIndexOf('.');
15
+ return {
16
+ irohTicket: dotIndex >= 0 ? rawTicket.slice(0, dotIndex) : rawTicket,
17
+ tokenSuffix: dotIndex >= 0 ? rawTicket.slice(dotIndex + 1) : null,
18
+ };
19
+ }
20
+ function decodeCompoundTicketPayload(tokenSuffix) {
21
+ try {
22
+ const padded = tokenSuffix.replace(/-/g, '+').replace(/_/g, '/');
23
+ const paddingLen = (4 - (padded.length % 4)) % 4;
24
+ return JSON.parse(atob(padded + '='.repeat(paddingLen)));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ // Ensure WebRTC and other configs exist
31
+ import { DEFAULT_FREE_LIMITS, DEFAULT_WEBRTC_CONFIG, DEFAULT_MOQ_CONFIG, DEFAULT_ICE_SERVERS, } from './types';
32
+ import { deriveSpaceNamespace, resolveAppTag, resolveAuthMode, resolveProjectId, resolveStorageNamespace } from './config';
33
+ function withTimeout(promise, timeoutMs) {
34
+ return Promise.race([
35
+ promise,
36
+ new Promise((_, reject) => {
37
+ setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
38
+ }),
39
+ ]);
40
+ }
41
+ async function pollUntilReady(check, attempts, intervalMs) {
42
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
43
+ const ready = await check().catch(() => false);
44
+ if (ready) {
45
+ return true;
46
+ }
47
+ if (attempt < attempts - 1) {
48
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ class SharedIncomingStreamHub {
54
+ constructor(wasmClient) {
55
+ this.wasmClient = wasmClient;
56
+ this.clients = new Set();
57
+ this.listenLoopTask = null;
58
+ }
59
+ addClient(client) {
60
+ if (this.clients.has(client)) {
61
+ return;
62
+ }
63
+ this.clients.add(client);
64
+ this.ensureListening();
65
+ }
66
+ removeClient(client) {
67
+ this.clients.delete(client);
68
+ }
69
+ ensureListening() {
70
+ if (this.listenLoopTask) {
71
+ return;
72
+ }
73
+ this.listenLoopTask = this.listenLoop().finally(() => {
74
+ this.listenLoopTask = null;
75
+ if (this.clients.size > 0) {
76
+ this.ensureListening();
77
+ }
78
+ });
79
+ }
80
+ nextRetryDelayMs(attempt) {
81
+ const backoff = SharedIncomingStreamHub.RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
82
+ return Math.min(SharedIncomingStreamHub.RETRY_MAX_DELAY_MS, backoff);
83
+ }
84
+ getPrimaryClient() {
85
+ const iterator = this.clients.values().next();
86
+ return iterator.done ? null : iterator.value;
87
+ }
88
+ getChannelTargetClients(channelId) {
89
+ return Array.from(this.clients)
90
+ .reverse()
91
+ .filter((client) => client.hasIncomingChannelListener(channelId));
92
+ }
93
+ async dispatchIncoming(rawIncoming) {
94
+ const parserClient = this.getPrimaryClient();
95
+ if (!parserClient) {
96
+ return;
97
+ }
98
+ const inspectChannelEnvelope = Array.from(this.clients).some((client) => Boolean(client.hasAnyIncomingChannelListeners()));
99
+ const listenerEvent = await parserClient.normalizeIncomingStreamEvent(rawIncoming, {
100
+ inspectChannelEnvelope,
101
+ });
102
+ const channelId = listenerEvent.channel?.channelId?.trim();
103
+ if (channelId) {
104
+ for (const client of this.getChannelTargetClients(channelId)) {
105
+ const handled = Boolean(client.dispatchIncomingChannelListeners(listenerEvent));
106
+ if (handled) {
107
+ return;
108
+ }
109
+ }
110
+ }
111
+ const primaryClient = this.getPrimaryClient();
112
+ if (!primaryClient) {
113
+ return;
114
+ }
115
+ const handledByGenericListeners = Boolean(primaryClient.dispatchIncomingGenericListeners(listenerEvent));
116
+ if (handledByGenericListeners) {
117
+ return;
118
+ }
119
+ await primaryClient.handleUnhandledIncomingStreamEvent(listenerEvent);
120
+ }
121
+ async listenLoop() {
122
+ let attempt = 0;
123
+ while (true) {
124
+ if (this.clients.size === 0) {
125
+ return;
126
+ }
127
+ let reader = null;
128
+ try {
129
+ const streams = await this.wasmClient.incoming_streams();
130
+ const activeReader = streams.getReader();
131
+ reader = activeReader;
132
+ attempt = 0;
133
+ while (this.clients.size > 0) {
134
+ const { done, value } = await activeReader.read();
135
+ if (done) {
136
+ throw new Error('incoming stream reader closed unexpectedly');
137
+ }
138
+ if (!value) {
139
+ continue;
140
+ }
141
+ console.debug('[Client][INCOMING] Received stream from WASM node', {
142
+ type: value?.type || 'unknown',
143
+ endpointId: value?.endpointId || value?.endpoint_id || 'unknown',
144
+ sharedHubClients: this.clients.size,
145
+ });
146
+ try {
147
+ await this.dispatchIncoming(value);
148
+ }
149
+ catch (error) {
150
+ const incoming = value;
151
+ const streamType = typeof incoming.type === 'string' ? incoming.type : 'bi';
152
+ const remoteNodeId = typeof incoming.endpointId === 'string'
153
+ ? incoming.endpointId
154
+ : typeof incoming.endpoint_id === 'string'
155
+ ? incoming.endpoint_id
156
+ : 'unknown';
157
+ console.error('[Client][INCOMING] Failed to handle incoming stream via shared hub', {
158
+ remoteNodeId,
159
+ type: streamType,
160
+ error,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ catch (error) {
166
+ if (this.clients.size === 0) {
167
+ return;
168
+ }
169
+ attempt += 1;
170
+ const delayMs = this.nextRetryDelayMs(attempt);
171
+ console.warn('[Client][INCOMING] Shared listen loop restarting after stream failure', {
172
+ attempt,
173
+ delayMs,
174
+ error,
175
+ });
176
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
177
+ }
178
+ finally {
179
+ if (reader) {
180
+ try {
181
+ reader.releaseLock();
182
+ }
183
+ catch {
184
+ // no-op
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ SharedIncomingStreamHub.RETRY_MIN_DELAY_MS = 250;
192
+ SharedIncomingStreamHub.RETRY_MAX_DELAY_MS = 2000;
193
+ export class Client {
194
+ constructor(options) {
195
+ this.wasmLoaded = false;
196
+ this.wasmClient = null;
197
+ this.initPromise = null;
198
+ // node is kept as a dummy for legacy compat if needed, but pointed to null
199
+ this.node = null;
200
+ this.connections = new Map();
201
+ this.connectionListeners = [];
202
+ this.disconnectionListeners = [];
203
+ this.messageListeners = [];
204
+ this.roomRequestListeners = [];
205
+ this.streamListeners = [];
206
+ this.channelStreamListeners = new Map();
207
+ this.registeredChannels = new Map();
208
+ this.announcedConnections = new Set();
209
+ this.peerLifecycle = new PeerProjectionStore();
210
+ this.knownDevicesById = new Map();
211
+ this.knownDeviceIdsByNodeId = new Map();
212
+ this.connectionTrustStates = new Map();
213
+ this.connectionChannelIds = new Map();
214
+ this.managedPresenceGrant = null;
215
+ /** Token extracted from a compound ticket during connect(), keyed by remote node ID. */
216
+ this.pendingTokenByNodeId = new Map();
217
+ this.localNodeId = '';
218
+ this.listening = false;
219
+ this.listenLoopTask = null;
220
+ this.appLimits = { ...DEFAULT_FREE_LIMITS };
221
+ // Prevent GC
222
+ this.pendingConnection = null;
223
+ this.authSyncUnsubscribe = null;
224
+ this.backendConnectionStateUnsubscribe = null;
225
+ this.authRefreshInterval = null;
226
+ this.deviceId = null;
227
+ this.lastWasmAuthSyncUserId = null;
228
+ this.lastWasmAuthSyncToken = null;
229
+ // Presence loop
230
+ this.presenceInterval = null;
231
+ this.hostedAppSettingsLoaded = false;
232
+ // --- Shared MoQ Session ---
233
+ this._moqSession = null;
234
+ this._moqState = 'none';
235
+ this.moqReadyListeners = [];
236
+ this.moqFailedListeners = [];
237
+ this.moqObjectListeners = [];
238
+ this.roomCreationMode = 'client-open';
239
+ const appTag = resolveAppTag(options);
240
+ const authMode = resolveAuthMode(options);
241
+ this.options = {
242
+ strictMode: true,
243
+ disableIrohFallback: false,
244
+ transportPriority: ['webrtc', 'moq', 'iroh'],
245
+ ...options,
246
+ authMode,
247
+ };
248
+ this.effectiveRoomBackendTag = appTag;
249
+ this.roomCreationMode = (this.options.discoveryMode ?? 'personal') === 'personal'
250
+ ? 'client-open'
251
+ : 'client-auth';
252
+ // Normalizing Transport Configs
253
+ if (this.options.transports) {
254
+ // ... (transport normalization logic remains same)
255
+ // WebRTC Logic
256
+ if (this.options.transports.webrtc === true) {
257
+ this.options.transports.webrtc = { ...DEFAULT_WEBRTC_CONFIG };
258
+ }
259
+ else if (this.options.transports.webrtc === false) {
260
+ this.options.transports.webrtc = undefined;
261
+ }
262
+ // MoQ Logic
263
+ if (this.options.transports.moq === true) {
264
+ this.options.transports.moq = { ...DEFAULT_MOQ_CONFIG };
265
+ }
266
+ else if (this.options.transports.moq === false) {
267
+ this.options.transports.moq = undefined;
268
+ }
269
+ // Iroh Logic
270
+ if (this.options.transports.iroh === true) {
271
+ this.options.transports.iroh = {};
272
+ }
273
+ else if (this.options.transports.iroh === false) {
274
+ this.options.transports.iroh = undefined;
275
+ }
276
+ }
277
+ const irohTransport = this.options.transports?.iroh;
278
+ const configuredPersistenceMode = this.options.nodeIdPersistence ??
279
+ (typeof irohTransport === 'object' ? irohTransport.persistenceMode : undefined) ??
280
+ 'persistent';
281
+ // Initialize TS wrappers that will use the loaded WASM underneath
282
+ const sigOptions = {
283
+ apiKey: this.options.apiKey,
284
+ allowAnonymousHostedDefaults: this.options.allowAnonymousHostedDefaults,
285
+ authMode: this.options.authMode,
286
+ projectId: this.options.projectId,
287
+ storagePrefix: this.options.storagePrefix,
288
+ nodeIdPersistence: configuredPersistenceMode,
289
+ };
290
+ let backend = null;
291
+ // Placeholder backend until the host app injects a concrete runtime bridge.
292
+ const proxyBackend = new Proxy({}, {
293
+ get(target, prop) {
294
+ if (backend)
295
+ return backend[prop];
296
+ return undefined;
297
+ }
298
+ });
299
+ this.signaling = new Signaling(sigOptions, proxyBackend);
300
+ this.rooms = new RoomManager(this, this.signaling);
301
+ this.registerChannel({
302
+ id: Client.DEFAULT_PEER_CHANNEL_ID,
303
+ kind: 'system',
304
+ ownership: 'shared',
305
+ peerModel: 'device-first',
306
+ routing: 'session-default',
307
+ readiness: 'settled-peer',
308
+ promotionPolicy: 'eligible',
309
+ signalingPolicy: 'hosted',
310
+ description: 'Default trusted device/runtime traffic',
311
+ });
312
+ this.registerChannel({
313
+ id: Client.SHARE_FILE_CHANNEL_ID,
314
+ kind: 'share',
315
+ ownership: 'shared',
316
+ peerModel: 'anonymous-ticket',
317
+ routing: 'stream-envelope',
318
+ readiness: 'transport-only',
319
+ promotionPolicy: 'never',
320
+ signalingPolicy: 'ticket-only',
321
+ description: 'Explicit file transfer channel for share/ticket traffic',
322
+ });
323
+ this.registerChannel({
324
+ id: Client.SHARE_CONTROL_CHANNEL_ID,
325
+ kind: 'share',
326
+ ownership: 'shared',
327
+ peerModel: 'anonymous-ticket',
328
+ routing: 'stream-envelope',
329
+ readiness: 'transport-only',
330
+ promotionPolicy: 'never',
331
+ signalingPolicy: 'ticket-only',
332
+ description: 'Explicit control channel for share/ticket traffic',
333
+ });
334
+ }
335
+ sleep(ms) {
336
+ return new Promise((resolve) => setTimeout(resolve, ms));
337
+ }
338
+ get apiKey() {
339
+ return this.options.apiKey;
340
+ }
341
+ get discoveryMode() {
342
+ return this.options.discoveryMode ?? 'personal';
343
+ }
344
+ usesTicketOnlySignalingMode() {
345
+ return this.options.signalingMode === 'ticket-only';
346
+ }
347
+ usesDirectRoomBackend() {
348
+ return this.effectiveRoomBackendTag.startsWith('space::')
349
+ || ((this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal')
350
+ && typeof this.options.spaceKey === 'string'
351
+ && this.options.spaceKey.trim().length > 0);
352
+ }
353
+ getRoomBackendTag() {
354
+ return this.effectiveRoomBackendTag;
355
+ }
356
+ async refreshHostedAppSettings(force = false) {
357
+ if (this.usesTicketOnlySignalingMode()) {
358
+ return;
359
+ }
360
+ if (this.hostedAppSettingsLoaded && !force) {
361
+ return;
362
+ }
363
+ try {
364
+ const { getFunctions, httpsCallable } = await import('firebase/functions');
365
+ const functions = getFunctions(getAuthHost().getApp());
366
+ const validate = httpsCallable(functions, 'validateApiKey');
367
+ const result = await validate({ apiKey: this.apiKey });
368
+ const data = result.data;
369
+ if (data.warning) {
370
+ console.warn('[pluto-rtc]', data.warning);
371
+ }
372
+ this.appLimits = data.limits;
373
+ if (data.discoveryMode) {
374
+ this.options.discoveryMode = data.discoveryMode;
375
+ }
376
+ this.roomCreationMode = data.roomCreationMode
377
+ ?? ((this.options.discoveryMode ?? 'personal') === 'personal' ? 'client-open' : 'client-auth');
378
+ this.hostedAppSettingsLoaded = true;
379
+ }
380
+ catch (e) {
381
+ console.warn('[pluto-rtc] Could not fetch hosted app settings, using local defaults.', e);
382
+ }
383
+ }
384
+ // --- Dependency Injection Point ---
385
+ // The wrapper (PlutoRTC) or the main app will call this upon instantiation
386
+ // to provide the correctly environment-gated backend implementation.
387
+ setSignalingBackend(backend) {
388
+ // Here we can swap the proxy out or reset the signaling instance
389
+ this.backendConnectionStateUnsubscribe?.();
390
+ this.backendConnectionStateUnsubscribe = null;
391
+ this.signaling = new Signaling(this.options, backend);
392
+ this.rooms = new RoomManager(this, this.signaling);
393
+ // Check for SSO token after the real backend is injected
394
+ backend.checkForSSOToken();
395
+ openRtcDebug('[Client] subscribing to backend connection state changes', {
396
+ backendType: backend?.constructor?.name ?? 'unknown',
397
+ hasGetConnectionStates: typeof backend.getConnectionStates === 'function',
398
+ hasOnConnectionStateChange: typeof backend.onConnectionStateChange === 'function',
399
+ });
400
+ const maybeUnsubscribe = this.signaling.onConnectionStateChange((state) => {
401
+ this.applyBackendConnectionState(state);
402
+ });
403
+ if (typeof maybeUnsubscribe === 'function') {
404
+ this.backendConnectionStateUnsubscribe = maybeUnsubscribe;
405
+ openRtcDebug('[Client] backend connection state subscription established synchronously');
406
+ }
407
+ else if (maybeUnsubscribe && typeof maybeUnsubscribe.then === 'function') {
408
+ void maybeUnsubscribe
409
+ .then((unsubscribe) => {
410
+ this.backendConnectionStateUnsubscribe = unsubscribe;
411
+ openRtcDebug('[Client] backend connection state subscription established asynchronously');
412
+ })
413
+ .catch((error) => {
414
+ console.warn('[Client] Failed to subscribe to backend connection state changes:', error);
415
+ });
416
+ }
417
+ }
418
+ // --- Helpers ---
419
+ get currentUser() {
420
+ return this.signaling.currentUser;
421
+ }
422
+ onAuthChange(callback) {
423
+ return this.signaling.onAuthChange(callback);
424
+ }
425
+ async signInAnonymously() {
426
+ return this.signaling.signInAnonymously();
427
+ }
428
+ async signInWithPluto() {
429
+ return this.signaling.signInWithPluto();
430
+ }
431
+ async signOut() {
432
+ this.listening = false;
433
+ await this.signaling.stopAuthScopedActivity();
434
+ return this.signaling.signOut();
435
+ }
436
+ getSignalingTelemetry() {
437
+ return {
438
+ transport: 'runtime-bridge',
439
+ wasmLoaded: this.wasmLoaded,
440
+ };
441
+ }
442
+ getProtocolCapabilities() {
443
+ return TransportFactory.getProtocolCapabilities();
444
+ }
445
+ async getRuntimeStatus() {
446
+ let runtime = 'unknown';
447
+ if (typeof window !== 'undefined') {
448
+ runtime = window.__TAURI__ ? 'tauri' : 'browser';
449
+ }
450
+ return {
451
+ runtime,
452
+ wasmLoaded: this.wasmLoaded,
453
+ localNodeId: this.localNodeId || undefined,
454
+ userId: this.signaling.currentUser?.id,
455
+ };
456
+ }
457
+ async ensureWasmRuntimeForP2P() {
458
+ if (this.wasmClient && this.localNodeId) {
459
+ return;
460
+ }
461
+ await this.init();
462
+ if (!this.wasmClient || !this.localNodeId) {
463
+ throw new Error('Node not initialized');
464
+ }
465
+ }
466
+ async ensureSignalingReadyForDiscovery(reason) {
467
+ if (this.usesTicketOnlySignalingMode()) {
468
+ return;
469
+ }
470
+ if (!this.wasmLoaded) {
471
+ await this.init();
472
+ }
473
+ try {
474
+ await this.ensureAuthenticated('discovery');
475
+ }
476
+ catch (error) {
477
+ console.warn(`[Client][AUTH] ensureAuthenticated failed for ${reason}:`, error);
478
+ }
479
+ try {
480
+ if (this.wasmClient) {
481
+ await this.syncAuthTokenToWasm('auth-change');
482
+ }
483
+ }
484
+ catch (error) {
485
+ console.warn(`[Client][WASM-AUTH] Token sync failed for ${reason}:`, error);
486
+ }
487
+ }
488
+ requiresManagedTransportTrust() {
489
+ return !this.usesTicketOnlySignalingMode()
490
+ && this.discoveryMode !== 'personal'
491
+ && !!this.currentUser?.id;
492
+ }
493
+ getRegisteredChannel(channelId) {
494
+ const normalized = typeof channelId === 'string' ? channelId.trim() : '';
495
+ if (!normalized) {
496
+ return null;
497
+ }
498
+ return this.registeredChannels.get(normalized) ?? null;
499
+ }
500
+ isChannelPromotionEligible(channelId) {
501
+ const descriptor = this.getRegisteredChannel(channelId);
502
+ if (!descriptor) {
503
+ return true;
504
+ }
505
+ return descriptor.promotionPolicy !== 'never';
506
+ }
507
+ getConnectionChannelId(connectionId) {
508
+ const normalized = typeof connectionId === 'string' ? connectionId.trim() : '';
509
+ if (!normalized) {
510
+ return null;
511
+ }
512
+ return this.connectionChannelIds.get(normalized) ?? null;
513
+ }
514
+ resolvePromotionEligibility(identifiers) {
515
+ const directChannelId = this.getConnectionChannelId(identifiers.connectionId);
516
+ if (directChannelId) {
517
+ return this.isChannelPromotionEligible(directChannelId);
518
+ }
519
+ const normalizedNodeId = typeof identifiers.nodeId === 'string' ? identifiers.nodeId.trim() : '';
520
+ for (const connection of this.connections.values()) {
521
+ if (identifiers.connectionId && connection.id === identifiers.connectionId) {
522
+ return this.isChannelPromotionEligible(this.getConnectionChannelId(connection.id));
523
+ }
524
+ if (normalizedNodeId && connection.remoteNodeId === normalizedNodeId) {
525
+ return this.isChannelPromotionEligible(this.getConnectionChannelId(connection.id));
526
+ }
527
+ }
528
+ return true;
529
+ }
530
+ assignConnectionChannel(connection, channelId) {
531
+ const normalizedChannelId = typeof channelId === 'string' ? channelId.trim() : '';
532
+ if (!normalizedChannelId) {
533
+ return;
534
+ }
535
+ this.connectionChannelIds.set(connection.id, normalizedChannelId);
536
+ const trustState = this.connectionTrustStates.get(connection.id);
537
+ if (trustState && !this.isChannelPromotionEligible(normalizedChannelId)) {
538
+ trustState.required = false;
539
+ trustState.verified = true;
540
+ trustState.failed = false;
541
+ trustState.failureReason = undefined;
542
+ trustState.issuedChallenge = null;
543
+ if (!trustState.verifiedDeviceId) {
544
+ trustState.verifiedDeviceId =
545
+ trustState.expectedDeviceId
546
+ || this.knownDeviceIdsByNodeId.get(connection.remoteNodeId)
547
+ || null;
548
+ }
549
+ }
550
+ if (!this.hasRustPeerLifecycleProjection()) {
551
+ const existing = this.connections.get(connection.id);
552
+ if (existing) {
553
+ this.applyConnectionTrustProjection(existing);
554
+ }
555
+ }
556
+ else {
557
+ void this.refreshPeerSnapshot().catch(() => { });
558
+ }
559
+ }
560
+ rememberKnownDevice(device) {
561
+ if (!device?.deviceId) {
562
+ return;
563
+ }
564
+ this.knownDevicesById.set(device.deviceId, device);
565
+ if (typeof device.nodeId === 'string' && device.nodeId.trim()) {
566
+ this.knownDeviceIdsByNodeId.set(device.nodeId.trim(), device.deviceId);
567
+ }
568
+ }
569
+ getKnownDevice(deviceId, remoteNodeId) {
570
+ const directDeviceId = typeof deviceId === 'string' ? deviceId.trim() : '';
571
+ if (directDeviceId) {
572
+ const direct = this.knownDevicesById.get(directDeviceId);
573
+ if (direct) {
574
+ return direct;
575
+ }
576
+ }
577
+ const normalizedNodeId = typeof remoteNodeId === 'string' ? remoteNodeId.trim() : '';
578
+ if (normalizedNodeId) {
579
+ const mappedDeviceId = this.knownDeviceIdsByNodeId.get(normalizedNodeId);
580
+ if (mappedDeviceId) {
581
+ return this.knownDevicesById.get(mappedDeviceId) ?? null;
582
+ }
583
+ }
584
+ return null;
585
+ }
586
+ async refreshKnownDeviceCache() {
587
+ if (!this.requiresManagedTransportTrust()) {
588
+ return;
589
+ }
590
+ const devices = await this.searchDevices().catch(() => []);
591
+ devices.forEach((device) => this.rememberKnownDevice(device));
592
+ }
593
+ initializeConnectionTrust(connection, expectedDeviceId) {
594
+ const existing = this.connectionTrustStates.get(connection.id);
595
+ if (existing) {
596
+ if (expectedDeviceId && !existing.expectedDeviceId) {
597
+ existing.expectedDeviceId = expectedDeviceId;
598
+ }
599
+ return existing;
600
+ }
601
+ const promotionEligible = this.resolvePromotionEligibility({
602
+ connectionId: connection.id,
603
+ nodeId: connection.remoteNodeId,
604
+ deviceId: expectedDeviceId ?? undefined,
605
+ });
606
+ const required = promotionEligible && this.requiresManagedTransportTrust();
607
+ const initialState = {
608
+ required,
609
+ verified: !required,
610
+ failed: false,
611
+ expectedDeviceId: expectedDeviceId
612
+ || this.knownDeviceIdsByNodeId.get(connection.remoteNodeId)
613
+ || null,
614
+ verifiedDeviceId: !required
615
+ ? expectedDeviceId || this.knownDeviceIdsByNodeId.get(connection.remoteNodeId) || null
616
+ : null,
617
+ issuedChallenge: required ? createTransportTrustChallenge() : null,
618
+ };
619
+ this.connectionTrustStates.set(connection.id, initialState);
620
+ return initialState;
621
+ }
622
+ getConnectionTrustState(identifiers) {
623
+ const directConnectionId = typeof identifiers.connectionId === 'string' ? identifiers.connectionId.trim() : '';
624
+ if (directConnectionId) {
625
+ return this.connectionTrustStates.get(directConnectionId) ?? null;
626
+ }
627
+ for (const [connectionId, state] of this.connectionTrustStates.entries()) {
628
+ if ((state.verifiedDeviceId
629
+ && (state.verifiedDeviceId === identifiers.deviceId
630
+ || state.verifiedDeviceId === identifiers.deviceIdHint))
631
+ || (state.expectedDeviceId
632
+ && (state.expectedDeviceId === identifiers.deviceId
633
+ || state.expectedDeviceId === identifiers.deviceIdHint))) {
634
+ return this.connectionTrustStates.get(connectionId) ?? null;
635
+ }
636
+ }
637
+ return null;
638
+ }
639
+ getConnectionTrustProjection(identifiers) {
640
+ const state = this.getConnectionTrustState(identifiers);
641
+ if (!state || !state.required) {
642
+ return null;
643
+ }
644
+ const verified = state.verified && !state.failed;
645
+ if (verified) {
646
+ return {
647
+ routable: true,
648
+ protocolState: 'routable',
649
+ health: 'healthy',
650
+ deviceId: state.verifiedDeviceId ?? state.expectedDeviceId ?? identifiers.deviceId ?? null,
651
+ };
652
+ }
653
+ return {
654
+ routable: false,
655
+ protocolState: 'unverified',
656
+ health: state.failed ? 'stale' : 'unknown',
657
+ error: state.failureReason ?? 'trusted-device-proof-pending',
658
+ deviceId: state.expectedDeviceId ?? identifiers.deviceId ?? identifiers.deviceIdHint ?? null,
659
+ };
660
+ }
661
+ applyConnectionTrustProjection(connection) {
662
+ if (this.hasRustPeerLifecycleProjection()) {
663
+ return;
664
+ }
665
+ const promotionEligible = this.resolvePromotionEligibility({
666
+ connectionId: connection.id,
667
+ nodeId: connection.remoteNodeId,
668
+ });
669
+ const trustProjection = this.getConnectionTrustProjection({
670
+ connectionId: connection.id,
671
+ nodeId: connection.remoteNodeId,
672
+ });
673
+ if (!trustProjection) {
674
+ this.syncConnectionPeerLifecycle(connection);
675
+ return;
676
+ }
677
+ this.peerLifecycle.upsert({
678
+ peerId: trustProjection.deviceId ?? undefined,
679
+ deviceId: trustProjection.deviceId ?? undefined,
680
+ deviceIdHint: trustProjection.deviceId ?? undefined,
681
+ nodeId: connection.deviceId,
682
+ connectionId: connection.id,
683
+ status: connection.isClosed ? 'closed' : 'connected',
684
+ health: trustProjection.health,
685
+ transportState: connection.isClosed ? 'closed' : 'connected',
686
+ protocolState: trustProjection.protocolState,
687
+ routable: trustProjection.routable,
688
+ promotionEligible,
689
+ error: trustProjection.error,
690
+ lastSeenAt: Date.now(),
691
+ });
692
+ }
693
+ async buildLocalTransportTrustPayload(connection) {
694
+ const identity = await getLocalTrustedIdentity().catch(() => null);
695
+ const localDeviceId = (await this.getLocalDeviceId().catch(() => null))
696
+ || this.deviceId
697
+ || this.options.localDeviceId
698
+ || null;
699
+ if (!identity || !localDeviceId) {
700
+ return null;
701
+ }
702
+ const trustState = this.initializeConnectionTrust(connection);
703
+ return {
704
+ version: 1,
705
+ deviceId: localDeviceId,
706
+ identityFingerprint: identity.fingerprint,
707
+ challenge: trustState.issuedChallenge ?? undefined,
708
+ };
709
+ }
710
+ async sendTransportHandshake(connection, action, challengeToSign) {
711
+ // Attach session token on the outgoing 'hello' if we have one for this peer.
712
+ const sessionToken = action === 'hello'
713
+ ? (this.pendingTokenByNodeId.get(connection.remoteNodeId) ?? undefined)
714
+ : undefined;
715
+ if (sessionToken) {
716
+ // Token has been picked up — remove from pending map.
717
+ this.pendingTokenByNodeId.delete(connection.remoteNodeId);
718
+ }
719
+ const localPayload = await this.buildLocalTransportTrustPayload(connection);
720
+ if (!localPayload) {
721
+ if (this.requiresManagedTransportTrust()) {
722
+ const state = this.initializeConnectionTrust(connection);
723
+ state.failed = true;
724
+ state.failureReason = 'local-trusted-identity-unavailable';
725
+ }
726
+ await connection.send({
727
+ type: 'handshake',
728
+ action,
729
+ moq: this.moqState === 'ready',
730
+ channelId: this.getConnectionChannelId(connection.id) ?? undefined,
731
+ ...(sessionToken ? { sessionToken } : {}),
732
+ });
733
+ return;
734
+ }
735
+ if (challengeToSign) {
736
+ const identity = await getLocalTrustedIdentity().catch(() => null);
737
+ if (!identity) {
738
+ throw new Error('Local trusted identity unavailable for handshake proof');
739
+ }
740
+ localPayload.signedChallenge = signTransportTrustChallenge(challengeToSign, identity.secretKey);
741
+ }
742
+ await connection.send({
743
+ type: 'handshake',
744
+ action,
745
+ moq: this.moqState === 'ready',
746
+ channelId: this.getConnectionChannelId(connection.id) ?? undefined,
747
+ transportTrust: localPayload,
748
+ ...(sessionToken ? { sessionToken } : {}),
749
+ });
750
+ }
751
+ async verifyRemoteTransportTrust(connection, payload) {
752
+ const trustState = this.initializeConnectionTrust(connection);
753
+ if (!trustState.required) {
754
+ return {
755
+ verified: true,
756
+ deviceId: payload?.deviceId ?? trustState.expectedDeviceId ?? null,
757
+ };
758
+ }
759
+ if (!payload) {
760
+ return { verified: false, reason: 'missing-transport-trust-payload' };
761
+ }
762
+ const claimedDeviceId = typeof payload.deviceId === 'string' ? payload.deviceId.trim() : '';
763
+ if (!claimedDeviceId) {
764
+ return { verified: false, reason: 'missing-remote-device-id' };
765
+ }
766
+ if (trustState.expectedDeviceId && trustState.expectedDeviceId !== claimedDeviceId) {
767
+ return {
768
+ verified: false,
769
+ reason: `expected-device-mismatch:${trustState.expectedDeviceId}:${claimedDeviceId}`,
770
+ };
771
+ }
772
+ let knownDevice = this.getKnownDevice(claimedDeviceId, connection.remoteNodeId);
773
+ if (!knownDevice) {
774
+ await this.refreshKnownDeviceCache();
775
+ knownDevice = this.getKnownDevice(claimedDeviceId, connection.remoteNodeId);
776
+ }
777
+ if (!knownDevice) {
778
+ return { verified: false, reason: `unknown-trusted-device:${claimedDeviceId}` };
779
+ }
780
+ const trustedMetadata = parseTrustedDeviceMetadata(knownDevice.metadata);
781
+ if (!trustedMetadata) {
782
+ return { verified: false, reason: `missing-trusted-device-metadata:${claimedDeviceId}` };
783
+ }
784
+ if (payload.identityFingerprint
785
+ && trustedMetadata.identityFingerprint !== payload.identityFingerprint) {
786
+ return {
787
+ verified: false,
788
+ reason: `trusted-device-fingerprint-mismatch:${claimedDeviceId}`,
789
+ };
790
+ }
791
+ const expectedChallenge = trustState.issuedChallenge;
792
+ if (!expectedChallenge || !payload.signedChallenge) {
793
+ return { verified: false, reason: 'missing-signed-transport-challenge' };
794
+ }
795
+ const verified = verifyTransportTrustChallenge(expectedChallenge, payload.signedChallenge, trustedMetadata.identityPublicKey);
796
+ if (!verified) {
797
+ return { verified: false, reason: `invalid-transport-proof:${claimedDeviceId}` };
798
+ }
799
+ return { verified: true, deviceId: claimedDeviceId };
800
+ }
801
+ async enforceConnectionTrustFailure(connection, reason) {
802
+ const trustState = this.initializeConnectionTrust(connection);
803
+ trustState.failed = true;
804
+ trustState.verified = false;
805
+ trustState.failureReason = reason;
806
+ this.applyConnectionTrustProjection(connection);
807
+ console.warn('[Client][TRUST] Disconnecting unverified connection', {
808
+ connectionId: connection.id,
809
+ remoteNodeId: connection.remoteNodeId,
810
+ reason,
811
+ });
812
+ await Promise.allSettled([
813
+ connection.disconnect(),
814
+ this.disconnectNode(connection.remoteNodeId).catch(() => undefined),
815
+ ]);
816
+ }
817
+ markConnectionTrustVerified(connection, verifiedDeviceId) {
818
+ const trustState = this.initializeConnectionTrust(connection, verifiedDeviceId);
819
+ trustState.verified = true;
820
+ trustState.failed = false;
821
+ trustState.failureReason = undefined;
822
+ trustState.verifiedDeviceId =
823
+ verifiedDeviceId
824
+ || trustState.verifiedDeviceId
825
+ || trustState.expectedDeviceId
826
+ || null;
827
+ if (verifiedDeviceId && !trustState.expectedDeviceId) {
828
+ trustState.expectedDeviceId = verifiedDeviceId;
829
+ }
830
+ this.applyConnectionTrustProjection(connection);
831
+ }
832
+ async searchDevices() {
833
+ await this.ensureSignalingReadyForDiscovery('search');
834
+ const devices = await this.signaling.searchDevices();
835
+ devices.forEach((device) => {
836
+ this.rememberKnownDevice(device);
837
+ this.peerLifecycle.upsertDevice(device);
838
+ });
839
+ return devices;
840
+ }
841
+ async updateDevice(deviceId, updates) {
842
+ await this.ensureSignalingReadyForDiscovery('search');
843
+ await this.signaling.updateDevice(deviceId, updates);
844
+ }
845
+ async deleteDevice(deviceId) {
846
+ await this.ensureSignalingReadyForDiscovery('search');
847
+ await this.signaling.deleteDevice(deviceId);
848
+ }
849
+ onDevicesChange(callback) {
850
+ let active = true;
851
+ let innerUnsubscribe = null;
852
+ void (async () => {
853
+ await this.ensureSignalingReadyForDiscovery('watch');
854
+ if (!active) {
855
+ return;
856
+ }
857
+ innerUnsubscribe = this.signaling.onDevicesChange((devices) => {
858
+ devices.forEach((device) => {
859
+ this.rememberKnownDevice(device);
860
+ this.peerLifecycle.upsertDevice(device);
861
+ });
862
+ callback(devices);
863
+ });
864
+ })().catch((error) => {
865
+ console.warn('[Client] Failed to initialize onDevicesChange listener:', error);
866
+ if (active) {
867
+ callback([]);
868
+ }
869
+ });
870
+ return () => {
871
+ active = false;
872
+ if (innerUnsubscribe) {
873
+ innerUnsubscribe();
874
+ innerUnsubscribe = null;
875
+ }
876
+ };
877
+ }
878
+ async subscribeSessions(localDeviceId, callback) {
879
+ await this.ensureSignalingReadyForDiscovery('sessions');
880
+ return this.signaling.subscribeSessions(localDeviceId, callback);
881
+ }
882
+ async startScanningLoop(options) {
883
+ const autoConnect = options.autoConnect ?? false;
884
+ const handledSessions = new Set();
885
+ let stopped = false;
886
+ let unsubscribeDevices = null;
887
+ let unsubscribeSessions = null;
888
+ let effectiveLocalDeviceId = options.localDeviceId;
889
+ try {
890
+ const resolvedLocalDeviceId = await this.signaling.getLocalDeviceId();
891
+ if (resolvedLocalDeviceId) {
892
+ effectiveLocalDeviceId = resolvedLocalDeviceId;
893
+ }
894
+ }
895
+ catch {
896
+ // Fall back to caller-provided localDeviceId.
897
+ }
898
+ unsubscribeDevices = this.onDevicesChange((devices) => {
899
+ if (stopped)
900
+ return;
901
+ options.onDevices?.(devices);
902
+ });
903
+ if (options.onIncomingSession) {
904
+ unsubscribeSessions = await this.subscribeSessions(effectiveLocalDeviceId, async (session) => {
905
+ if (stopped)
906
+ return;
907
+ const sessionId = session.sessionId || session.connectionId;
908
+ if (!sessionId || handledSessions.has(sessionId))
909
+ return;
910
+ if (session.targetDeviceId !== effectiveLocalDeviceId)
911
+ return;
912
+ if (session.initiatorDeviceId === effectiveLocalDeviceId)
913
+ return;
914
+ if (session.state === 'connected' || session.state === 'closed' || session.state === 'failed')
915
+ return;
916
+ handledSessions.add(sessionId);
917
+ try {
918
+ if (options.onIncomingSession) {
919
+ await options.onIncomingSession(session);
920
+ return;
921
+ }
922
+ const runtimeConnected = this.getPeerState(session.initiatorDeviceId)?.status === 'connected';
923
+ if (runtimeConnected || options.isConnected?.(session.initiatorDeviceId)) {
924
+ const isProtected = this.peerLifecycle.hasScopes(session.initiatorDeviceId) ||
925
+ (options.hasProtectedConnectionIntent?.(session.initiatorDeviceId) ?? false);
926
+ if (!isProtected) {
927
+ await this.forceDisconnectPeer(session.initiatorDeviceId).catch(async () => {
928
+ await options.cleanupStaleConnection?.(session.initiatorDeviceId);
929
+ });
930
+ }
931
+ }
932
+ if (session.initiatorNodeId) {
933
+ await options.allowIncomingNodeId?.(session.initiatorNodeId);
934
+ }
935
+ const result = await options.acceptIncomingSession?.(sessionId, session.initiatorDeviceId);
936
+ if (!result?.success) {
937
+ throw new Error(result?.error || 'Failed to accept incoming signaling session');
938
+ }
939
+ await options.onIncomingSessionAccepted?.(session);
940
+ }
941
+ catch (error) {
942
+ handledSessions.delete(sessionId);
943
+ throw error;
944
+ }
945
+ });
946
+ }
947
+ return {
948
+ stop: () => {
949
+ stopped = true;
950
+ if (unsubscribeDevices) {
951
+ unsubscribeDevices();
952
+ unsubscribeDevices = null;
953
+ }
954
+ if (unsubscribeSessions) {
955
+ unsubscribeSessions();
956
+ unsubscribeSessions = null;
957
+ }
958
+ },
959
+ };
960
+ }
961
+ async connectDeviceWithRetry(device, hooks, options) {
962
+ if (!options.localDeviceId) {
963
+ return {
964
+ success: false,
965
+ skipped: 'missing-local-device-id',
966
+ attempts: 0,
967
+ error: 'Missing local device ID',
968
+ };
969
+ }
970
+ const maxRetries = options.maxRetries ?? 3;
971
+ const initialBackoffMs = options.initialBackoffMs ?? 2000;
972
+ const connectionState = await hooks.getConnectionState?.(device.deviceId);
973
+ if (connectionState === 'connecting') {
974
+ return {
975
+ success: true,
976
+ skipped: 'already-connecting',
977
+ attempts: 0,
978
+ };
979
+ }
980
+ if (connectionState === 'connected' && hooks.forceDisconnect) {
981
+ await hooks.forceDisconnect(device.deviceId);
982
+ await hooks.refreshState?.();
983
+ }
984
+ const attemptConnect = async () => {
985
+ const hasDirectIdentity = !!(typeof device.nodeId === 'string' && device.nodeId.trim().length > 0) ||
986
+ !!(typeof device.ticket === 'string' && device.ticket.trim().length > 0);
987
+ if (hasDirectIdentity) {
988
+ await hooks.directConnect(device);
989
+ await hooks.onConnected?.(device);
990
+ return;
991
+ }
992
+ const signalingResult = await hooks.signalConnect({
993
+ localDeviceId: options.localDeviceId,
994
+ targetDeviceId: device.deviceId,
995
+ targetUserId: options.targetUserId,
996
+ device,
997
+ });
998
+ if (!signalingResult.success) {
999
+ throw new Error(signalingResult.error || 'Signaling failed');
1000
+ }
1001
+ };
1002
+ let attempt = 0;
1003
+ let lastError = undefined;
1004
+ while (attempt <= maxRetries) {
1005
+ try {
1006
+ await attemptConnect();
1007
+ return {
1008
+ success: true,
1009
+ attempts: attempt,
1010
+ };
1011
+ }
1012
+ catch (error) {
1013
+ lastError = error;
1014
+ if (attempt === maxRetries) {
1015
+ break;
1016
+ }
1017
+ const delayMs = initialBackoffMs * Math.pow(2, attempt);
1018
+ hooks.onRetry?.(device, attempt + 1, delayMs);
1019
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1020
+ attempt += 1;
1021
+ }
1022
+ }
1023
+ return {
1024
+ success: false,
1025
+ attempts: maxRetries + 1,
1026
+ error: lastError instanceof Error ? lastError.message : 'Connection failed',
1027
+ };
1028
+ }
1029
+ async bootstrapRuntime(options) {
1030
+ const initializeClient = options.initializeClient ?? true;
1031
+ if (options.platform === 'web') {
1032
+ await options.initializeWeb?.();
1033
+ if (initializeClient) {
1034
+ await this.init();
1035
+ }
1036
+ return { ready: true, mode: 'web' };
1037
+ }
1038
+ const quickPollAttempts = options.quickPollAttempts ?? 5;
1039
+ const quickPollIntervalMs = options.quickPollIntervalMs ?? 300;
1040
+ const retryPollAttempts = options.retryPollAttempts ?? 8;
1041
+ const retryPollIntervalMs = options.retryPollIntervalMs ?? 300;
1042
+ const nativeInitEventTimeoutMs = options.nativeInitEventTimeoutMs ?? 4000;
1043
+ if (!options.isNativeReady) {
1044
+ console.warn('[Client][INIT] No isNativeReady callback provided for native platform; treating runtime as not ready');
1045
+ return { ready: false, mode: 'native' };
1046
+ }
1047
+ const isReadyCallable = options.isNativeReady;
1048
+ const quickReady = await pollUntilReady(isReadyCallable, quickPollAttempts, quickPollIntervalMs);
1049
+ if (!quickReady) {
1050
+ await options.retryNativeInit?.();
1051
+ const [eventReady, pollReadyAfterRetry] = await Promise.all([
1052
+ options.waitForNativeInitEvent?.(nativeInitEventTimeoutMs) ?? Promise.resolve(false),
1053
+ pollUntilReady(isReadyCallable, retryPollAttempts, retryPollIntervalMs),
1054
+ ]);
1055
+ const ready = eventReady || pollReadyAfterRetry;
1056
+ if (!ready) {
1057
+ return { ready: false, mode: 'native' };
1058
+ }
1059
+ }
1060
+ if (initializeClient) {
1061
+ await this.init();
1062
+ }
1063
+ return { ready: true, mode: 'native' };
1064
+ }
1065
+ updateTransportConfig(transports) {
1066
+ if (transports) {
1067
+ // Merge with existing
1068
+ this.options.transports = { ...(this.options.transports || {}), ...transports };
1069
+ }
1070
+ }
1071
+ /**
1072
+ * Bootstraps the WASM module and Rust signaling.
1073
+ * Must be called before connect or createRoom.
1074
+ */
1075
+ async init() {
1076
+ if (this.initPromise)
1077
+ return this.initPromise;
1078
+ if (this.wasmLoaded && this.wasmClient && this.localNodeId)
1079
+ return;
1080
+ // Note: The specific transport implementations (like TauriBridge)
1081
+ // may bypass this entirely if they rely strictly on IPC.
1082
+ this.initPromise = (async () => {
1083
+ try {
1084
+ // Initialize WASM
1085
+ openRtcDebugLog('[Client][INIT] Loading WASM module...');
1086
+ await initWasm();
1087
+ start();
1088
+ this.wasmLoaded = true;
1089
+ openRtcDebugLog('[Client][INIT] WASM module loaded');
1090
+ // Instantiate the Rust WasmClient.
1091
+ // In Personal Mode (apiKey + spaceKey), encode the space namespace into the appTag
1092
+ // using a "space::{hash}" prefix so the Rust room backend routes to spaces/{hash}/rooms/.
1093
+ const projectId = resolveProjectId(this.options);
1094
+ const apiKey = typeof this.options.apiKey === 'string' ? this.options.apiKey.trim() : '';
1095
+ const spaceKey = typeof this.options.spaceKey === 'string' ? this.options.spaceKey.trim() : '';
1096
+ const isPersonalMode = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') && !!apiKey && !!spaceKey;
1097
+ let wasmAppTag = resolveAppTag(this.options);
1098
+ if (isPersonalMode) {
1099
+ const spaceNamespaceId = await deriveSpaceNamespace(apiKey, spaceKey);
1100
+ wasmAppTag = `space::${spaceNamespaceId}`;
1101
+ openRtcDebugLog(`[Client][INIT] Personal Mode — WASM rooms routed to spaces/${spaceNamespaceId.slice(0, 8)}…`);
1102
+ }
1103
+ this.effectiveRoomBackendTag = wasmAppTag;
1104
+ this.wasmClient = new WasmClient(projectId, wasmAppTag);
1105
+ openRtcDebugLog('PlutoRTC WASM Core Initialized');
1106
+ this.signaling.setWasmClient(this.wasmClient);
1107
+ if (!this.usesTicketOnlySignalingMode()) {
1108
+ await this.ensureAuthenticated('init');
1109
+ await this.syncAuthTokenToWasm('init');
1110
+ this.installAuthTokenSync();
1111
+ }
1112
+ const storageKey = `iroh_secret_key_${resolveStorageNamespace(this.options)}`;
1113
+ let secretKeyBytes = undefined;
1114
+ const persistenceMode = this.options.nodeIdPersistence ??
1115
+ (typeof this.options.transports?.iroh === 'object'
1116
+ ? this.options.transports.iroh.persistenceMode
1117
+ : undefined) ??
1118
+ 'persistent';
1119
+ // 1. Manual User Specified Key
1120
+ if (this.options.secretKey) {
1121
+ const match = this.options.secretKey.match(/.{1,2}/g);
1122
+ if (match) {
1123
+ secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
1124
+ }
1125
+ }
1126
+ // 2. Persistent Mode (Default) - Try Load
1127
+ else if (persistenceMode === 'persistent') {
1128
+ const storedKeyHex = typeof localStorage !== 'undefined' ? localStorage.getItem(storageKey) : null;
1129
+ if (storedKeyHex) {
1130
+ const match = storedKeyHex.match(/.{1,2}/g);
1131
+ if (match) {
1132
+ secretKeyBytes = new Uint8Array(match.map(byte => parseInt(byte, 16)));
1133
+ }
1134
+ }
1135
+ }
1136
+ // 3. Ephemeral Mode - secretKeyBytes remains undefined (generates new)
1137
+ // Canonical device identity is always sourced from signaling bridge state.
1138
+ this.deviceId = await this.signaling.getLocalDeviceId().catch(() => null);
1139
+ const initIrohStartedAt = Date.now();
1140
+ openRtcDebugLog(`[Client][INIT][IROH] start persistence=${persistenceMode} hasSecretKey=${!!secretKeyBytes} secretKeyLen=${secretKeyBytes?.length || 0} hasStoredDeviceId=${!!this.deviceId}`);
1141
+ const pendingTimer = setInterval(() => {
1142
+ const elapsed = Date.now() - initIrohStartedAt;
1143
+ console.warn(`[Client][INIT][IROH] pending elapsedMs=${elapsed} hasWasmClient=${!!this.wasmClient}`);
1144
+ }, 5000);
1145
+ try {
1146
+ this.localNodeId = await withTimeout(this.wasmClient.init_iroh(secretKeyBytes), 20000);
1147
+ }
1148
+ catch (error) {
1149
+ const elapsed = Date.now() - initIrohStartedAt;
1150
+ throw new Error(`[Client] init_iroh stalled or failed: ${error?.message || error}; elapsed_ms=${elapsed}; persistence=${persistenceMode}; has_secret_key=${!!secretKeyBytes}`);
1151
+ }
1152
+ finally {
1153
+ clearInterval(pendingTimer);
1154
+ }
1155
+ openRtcDebugLog(`[Client][INIT][IROH] complete elapsedMs=${Date.now() - initIrohStartedAt} nodeId=${this.localNodeId}`);
1156
+ if (!this.localNodeId) {
1157
+ throw new Error('[Client] init_iroh returned empty node id');
1158
+ }
1159
+ if (!this.deviceId) {
1160
+ this.deviceId = await this.signaling.getLocalDeviceId().catch(() => null);
1161
+ }
1162
+ this.node = { initialized: true, localNodeId: this.localNodeId };
1163
+ if (this.localNodeId) {
1164
+ if (persistenceMode === 'persistent' && !this.options.secretKey && typeof localStorage !== 'undefined') {
1165
+ const keyToPersist = secretKeyBytes
1166
+ ?? (typeof this.wasmClient?.iroh_secret_key === 'function'
1167
+ ? await this.wasmClient.iroh_secret_key().catch(() => undefined)
1168
+ : undefined);
1169
+ if (keyToPersist && keyToPersist.length > 0) {
1170
+ const hex = Array.from(keyToPersist)
1171
+ .map((byte) => Number(byte).toString(16).padStart(2, '0'))
1172
+ .join('');
1173
+ localStorage.setItem(storageKey, hex);
1174
+ openRtcDebugLog(`[Client][INIT][IROH] persisted secret key bytes=${keyToPersist.length} nodeId=${this.localNodeId}`);
1175
+ }
1176
+ else {
1177
+ console.warn(`[Client][INIT][IROH] unable to persist secret key for nodeId=${this.localNodeId}`);
1178
+ }
1179
+ }
1180
+ }
1181
+ // Initialize shared MoQ session if enabled
1182
+ if (this.options.transports?.moq) {
1183
+ this.initMoQ();
1184
+ }
1185
+ // Automatic TURN credentials
1186
+ // Trigger if: useTurn OR privacyMode OR strictMode
1187
+ // Note: Constructor normalizes `webrtc: true` to an object, so casting is safe.
1188
+ const webrtcConfig = this.options.transports?.webrtc || DEFAULT_WEBRTC_CONFIG;
1189
+ const shouldFetchTurn = webrtcConfig?.useTurn ||
1190
+ webrtcConfig?.privacyMode ||
1191
+ this.options.strictMode;
1192
+ if (shouldFetchTurn) {
1193
+ try {
1194
+ const turnData = await this.signaling.getTurnCredentials();
1195
+ if (turnData && turnData.iceServers) {
1196
+ let servers = Array.isArray(turnData.iceServers) ? turnData.iceServers : [turnData.iceServers];
1197
+ // Filter STUN if privacyMode OR strictMode is enabled
1198
+ // Use the webrtcConfig reference we cast earlier
1199
+ if (webrtcConfig?.privacyMode || this.options.strictMode) {
1200
+ servers = servers.map((server) => {
1201
+ if (typeof server.urls === 'string') {
1202
+ return server.urls.startsWith('stun:') ? null : server;
1203
+ }
1204
+ else if (Array.isArray(server.urls)) {
1205
+ const filteredUrls = server.urls.filter((url) => !url.startsWith('stun:'));
1206
+ return filteredUrls.length > 0 ? { ...server, urls: filteredUrls } : null;
1207
+ }
1208
+ return server;
1209
+ }).filter((s) => s !== null);
1210
+ openRtcDebugLog(`[Client] Strict / Privacy Mode: Filtered STUN servers. Remaining=${servers.length}`);
1211
+ }
1212
+ else {
1213
+ openRtcDebugLog(`[Client] Automatically fetched ${servers.length} TURN servers.`);
1214
+ }
1215
+ // Merge into existing config
1216
+ if (!this.options.transports)
1217
+ this.options.transports = {};
1218
+ if (!this.options.transports.webrtc)
1219
+ this.options.transports.webrtc = {};
1220
+ // Ensure webrtc is an object (it is if we are here, but TS might complain)
1221
+ // It should be normalized by constructor.
1222
+ const currentConfig = this.options.transports.webrtc || DEFAULT_WEBRTC_CONFIG;
1223
+ if (!currentConfig.iceServers) {
1224
+ currentConfig.iceServers = [];
1225
+ }
1226
+ // If Strict Mode, we must ALSO filter the user-provided servers!
1227
+ if (this.options.strictMode) {
1228
+ currentConfig.iceServers = currentConfig.iceServers
1229
+ .map((server) => {
1230
+ if (typeof server.urls === 'string') {
1231
+ return server.urls.startsWith('stun:') ? null : server;
1232
+ }
1233
+ else if (Array.isArray(server.urls)) {
1234
+ const filteredUrls = server.urls.filter((url) => !url.startsWith('stun:'));
1235
+ return filteredUrls.length > 0 ? { ...server, urls: filteredUrls } : null;
1236
+ }
1237
+ return server;
1238
+ }).filter(Boolean);
1239
+ }
1240
+ // RTCConfiguration.iceServers is technically RTCIceServer[], so we can push
1241
+ currentConfig.iceServers.push(...servers);
1242
+ }
1243
+ }
1244
+ catch (e) {
1245
+ console.warn("[Client] Failed to auto-fetch TURN credentials:", e);
1246
+ }
1247
+ }
1248
+ // Fallback to Default STUN if no config provided and not in strict/privacy mode
1249
+ if (!this.options.strictMode && !webrtcConfig?.privacyMode) {
1250
+ // Only proceed if transports.webrtc is actually enabled/defined
1251
+ const config = this.options.transports?.webrtc;
1252
+ if (config) {
1253
+ // Check if iceServers is missing or empty
1254
+ if (!config.iceServers || (Array.isArray(config.iceServers) && config.iceServers.length === 0)) {
1255
+ openRtcDebugLog('[Client] No ICE servers provided. Using default public STUN servers.');
1256
+ config.iceServers = [...DEFAULT_ICE_SERVERS];
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+ catch (e) {
1262
+ console.error("Failed to initialize PlutoRTC:", e);
1263
+ throw e;
1264
+ }
1265
+ })();
1266
+ return this.initPromise;
1267
+ }
1268
+ async ensureAuthenticated(reason) {
1269
+ if (this.usesTicketOnlySignalingMode()) {
1270
+ return;
1271
+ }
1272
+ const authMode = (this.options.authMode ?? 'anonymous');
1273
+ const shouldLog = reason !== 'discovery';
1274
+ // Personal Mode with spaceKey is auth-free: discovery uses sha256(apiKey+spaceKey) namespace,
1275
+ // not Firebase user identity. Skip all auth checks entirely.
1276
+ const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
1277
+ typeof this.options.spaceKey === 'string' &&
1278
+ this.options.spaceKey.trim().length > 0;
1279
+ if (isPersonalWithSpaceKey) {
1280
+ return;
1281
+ }
1282
+ try {
1283
+ await withTimeout(this.signaling.waitForAuth(), 5000);
1284
+ }
1285
+ catch (error) {
1286
+ if (shouldLog) {
1287
+ console.warn(`[Client][AUTH] waitForAuth timed out (${reason})`, error);
1288
+ }
1289
+ }
1290
+ if (this.signaling.currentUser) {
1291
+ if (shouldLog) {
1292
+ openRtcDebugLog(`[Client][AUTH] Authenticated (${reason}) as ${this.signaling.currentUser.id}`);
1293
+ }
1294
+ return;
1295
+ }
1296
+ if (authMode === 'external' || authMode === 'required') {
1297
+ throw new Error(`[Client][AUTH] ${authMode} auth mode requires host-provided auth before ${reason}`);
1298
+ }
1299
+ if (shouldLog) {
1300
+ openRtcDebugLog(`[Client][AUTH] No user (${reason}); attempting anonymous sign-in...`);
1301
+ }
1302
+ await withTimeout(this.signaling.signInAnonymously(), 8000);
1303
+ try {
1304
+ await withTimeout(this.signaling.waitForAuth(), 5000);
1305
+ }
1306
+ catch {
1307
+ // If auth state listener is slow, we still verify currentUser below.
1308
+ }
1309
+ if (!this.signaling.currentUser) {
1310
+ throw new Error('[Client][AUTH] Anonymous sign-in did not produce an authenticated user');
1311
+ }
1312
+ if (shouldLog) {
1313
+ openRtcDebugLog(`[Client][AUTH] Anonymous sign-in success (${reason})`);
1314
+ }
1315
+ }
1316
+ installAuthTokenSync() {
1317
+ if (this.authSyncUnsubscribe) {
1318
+ return;
1319
+ }
1320
+ this.authSyncUnsubscribe = this.signaling.onAuthChange(() => {
1321
+ this.syncAuthTokenToWasm('auth-change').catch((error) => {
1322
+ console.warn('[Client][WASM-AUTH] Failed syncing auth token on auth change:', error);
1323
+ });
1324
+ });
1325
+ if (!this.authRefreshInterval) {
1326
+ this.authRefreshInterval = setInterval(() => {
1327
+ this.syncAuthTokenToWasm('periodic-refresh', true).catch((error) => {
1328
+ console.warn('[Client][WASM-AUTH] Periodic auth refresh sync failed:', error);
1329
+ });
1330
+ }, 10 * 60 * 1000);
1331
+ }
1332
+ }
1333
+ async syncAuthTokenToWasm(reason, forceRefresh = false) {
1334
+ if (!this.wasmClient || typeof this.wasmClient.set_auth_token !== 'function') {
1335
+ console.warn('[Client][WASM-AUTH] WasmClient.set_auth_token is unavailable');
1336
+ return;
1337
+ }
1338
+ const user = this.signaling.currentUser;
1339
+ const token = user && user.getToken ? await user.getToken(forceRefresh) : null;
1340
+ const hasToken = !!token;
1341
+ const tokenLen = token?.length || 0;
1342
+ const userId = user?.id || null;
1343
+ if (this.lastWasmAuthSyncUserId === userId && this.lastWasmAuthSyncToken === token) {
1344
+ return;
1345
+ }
1346
+ this.lastWasmAuthSyncUserId = userId;
1347
+ this.lastWasmAuthSyncToken = token;
1348
+ openRtcDebugLog(`[Client][WASM-AUTH] Sync token (${reason}) hasToken=${hasToken} tokenLen=${tokenLen} uid=${userId || 'none'} forceRefresh=${forceRefresh}`);
1349
+ this.wasmClient.set_auth_token(token);
1350
+ }
1351
+ // --- Shared MoQ Session ---
1352
+ get moqState() { return this._moqState; }
1353
+ get moq() {
1354
+ if (this._moqState !== 'ready' || !this._moqSession)
1355
+ return null;
1356
+ return {
1357
+ session: this._moqSession,
1358
+ publisher: this._moqSession.publisher,
1359
+ subscriber: this._moqSession.subscriber
1360
+ };
1361
+ }
1362
+ onMoQReady(cb) { this.moqReadyListeners.push(cb); }
1363
+ onMoQFailed(cb) { this.moqFailedListeners.push(cb); }
1364
+ onMoQObject(callback) {
1365
+ this.moqObjectListeners.push(callback);
1366
+ }
1367
+ async initMoQ() {
1368
+ if (this._moqState !== 'none')
1369
+ return;
1370
+ this._moqState = 'connecting';
1371
+ const moqConfig = this.options.transports?.moq || DEFAULT_MOQ_CONFIG;
1372
+ const relayUrl = moqConfig.relayUrl;
1373
+ try {
1374
+ // @ts-ignore — WebTransport may not be in all TS lib targets
1375
+ const wt = new WebTransport(relayUrl);
1376
+ await wt.ready;
1377
+ this._moqSession = new MoQSession(wt, (ns, name, obj, meta) => {
1378
+ // 1. Dispatch to internal listeners/watchers
1379
+ this.moqObjectListeners.forEach(cb => cb(ns, name, obj, meta));
1380
+ // 2. Dispatch to Connection (for Chat/Signaling/Data)
1381
+ // Only for 'data/' tracks to avoid injecting raw media into Connection frame parser
1382
+ if (name.startsWith('data/') && this.localNodeId) {
1383
+ const connId = this.getDeterministicConnectionId(ns);
1384
+ const conn = this.connections.get(connId);
1385
+ if (conn) {
1386
+ conn.injectData(obj);
1387
+ }
1388
+ }
1389
+ });
1390
+ await this._moqSession.init();
1391
+ await this._moqSession.waitForSetup();
1392
+ // Announce our namespace once
1393
+ await this._moqSession.publisher.announce(this.localNodeId);
1394
+ // Create Data Tracks
1395
+ await this.createMoQDataTracks();
1396
+ this._moqState = 'ready';
1397
+ openRtcDebugLog(`[Client] MoQ Session Ready: Pub/Sub via ${relayUrl}`);
1398
+ this.moqReadyListeners.forEach(cb => cb());
1399
+ }
1400
+ catch (e) {
1401
+ console.warn("[Client] MoQ init failed:", e);
1402
+ this._moqState = 'failed';
1403
+ this.moqFailedListeners.forEach(cb => cb());
1404
+ }
1405
+ }
1406
+ async connect(ticket, timeoutMs = 20000, expectedDeviceId, channelId) {
1407
+ await this.ensureWasmRuntimeForP2P();
1408
+ if (!ticket || !ticket.trim()) {
1409
+ throw new Error('connect requires a non-empty endpoint ticket');
1410
+ }
1411
+ // Split compound ticket: "<iroh_ticket>.<base64url_token_payload>"
1412
+ // We must split on the LAST `.` because endpoint tickets can contain
1413
+ // dots (for example through embedded relay URL data).
1414
+ const rawTicket = ticket.trim();
1415
+ const { irohTicket, tokenSuffix } = splitCompoundTicket(rawTicket);
1416
+ let nodeId;
1417
+ try {
1418
+ nodeId = await this.wasmClient.endpoint_id_from_ticket(irohTicket);
1419
+ }
1420
+ catch (error) {
1421
+ throw new Error(`connect requires a valid endpoint ticket: ${error?.message || error}`);
1422
+ }
1423
+ // Store extracted token so sendTransportHandshake can include it.
1424
+ let compoundTicketToken = null;
1425
+ if (tokenSuffix) {
1426
+ const decoded = decodeCompoundTicketPayload(tokenSuffix);
1427
+ if (typeof decoded?.t === 'string') {
1428
+ compoundTicketToken = decoded.t;
1429
+ this.pendingTokenByNodeId.set(nodeId, decoded.t);
1430
+ }
1431
+ }
1432
+ // Check if already connected
1433
+ const connId = this.getDeterministicConnectionId(nodeId);
1434
+ const existing = this.connections.get(connId);
1435
+ if (existing) {
1436
+ if (expectedDeviceId) {
1437
+ this.initializeConnectionTrust(existing, expectedDeviceId);
1438
+ }
1439
+ return existing;
1440
+ }
1441
+ // Initiate connection
1442
+ try {
1443
+ openRtcDebugLog(`Initiating connection to ${nodeId}...`);
1444
+ this.pendingConnection = await this.wasmClient.connect(irohTicket);
1445
+ }
1446
+ catch (e) {
1447
+ console.warn("Connect trigger failed:", e);
1448
+ }
1449
+ // Retry open_bi loop
1450
+ let biStream = null;
1451
+ let retries = 0;
1452
+ const interval = 500;
1453
+ const maxRetries = Math.ceil(timeoutMs / interval);
1454
+ if (compoundTicketToken
1455
+ && this.wasmClient
1456
+ && typeof this.wasmClient.present_session_token_to_host === 'function') {
1457
+ let presented = false;
1458
+ let presentationError = null;
1459
+ let presentationRetries = 0;
1460
+ let approvedScope = null;
1461
+ while (presentationRetries < maxRetries) {
1462
+ try {
1463
+ approvedScope = await this.wasmClient.present_session_token_to_host(nodeId, compoundTicketToken);
1464
+ console.info('[Client][AUTH] host approved session token', {
1465
+ nodeId,
1466
+ approvalScope: approvedScope || null,
1467
+ nextStep: 'open-main-stream-and-register-connection',
1468
+ });
1469
+ presented = true;
1470
+ break;
1471
+ }
1472
+ catch (error) {
1473
+ presentationError = error;
1474
+ presentationRetries++;
1475
+ await new Promise(r => setTimeout(r, interval));
1476
+ }
1477
+ }
1478
+ if (!presented) {
1479
+ throw new Error(`Failed to present session token to ${nodeId} after ${timeoutMs} ms: ${presentationError?.message || presentationError}`);
1480
+ }
1481
+ }
1482
+ while (retries < maxRetries) {
1483
+ try {
1484
+ biStream = await this.wasmClient.open_bi(nodeId);
1485
+ break;
1486
+ }
1487
+ catch (e) {
1488
+ retries++;
1489
+ await new Promise(r => setTimeout(r, interval));
1490
+ }
1491
+ }
1492
+ if (!biStream) {
1493
+ throw new Error(`Failed to establish connection to ${nodeId} after ${timeoutMs} ms`);
1494
+ }
1495
+ const connection = new Connection(connId, this.localNodeId, // Local
1496
+ nodeId, // Remote
1497
+ biStream.send.getWriter(), biStream.recv.getReader(), {
1498
+ rtcConfig: this.getRTCConfig(),
1499
+ transportContext: {
1500
+ priorities: this.options.transportPriority || ['webrtc', 'moq', 'iroh'],
1501
+ disableIrohFallback: this.options.disableIrohFallback || false,
1502
+ isMoQReady: () => this.moqState === 'ready',
1503
+ sendMoQ: (data) => this.sendMoQData(nodeId, data)
1504
+ }
1505
+ });
1506
+ this.assignConnectionChannel(connection, channelId);
1507
+ this.initializeConnectionTrust(connection, expectedDeviceId);
1508
+ this.registerConnection(connection);
1509
+ if (compoundTicketToken) {
1510
+ console.info('[Client][AUTH] token-approved connection registered locally', {
1511
+ nodeId,
1512
+ connectionId: connection.id,
1513
+ localNodeId: connection.localNodeId,
1514
+ remoteNodeId: connection.remoteNodeId,
1515
+ });
1516
+ }
1517
+ this.announceConnectionWhenStable(connection);
1518
+ void this.sendTransportHandshake(connection, 'hello').catch((error) => {
1519
+ console.warn('[Client][TRUST] Failed to send outgoing handshake', {
1520
+ connectionId: connection.id,
1521
+ remoteNodeId: connection.remoteNodeId,
1522
+ error,
1523
+ });
1524
+ });
1525
+ return connection;
1526
+ }
1527
+ /**
1528
+ * Low-level dial helper.
1529
+ * Ensures the ticket is connected and returns only connection identity metadata.
1530
+ */
1531
+ async connectRaw(ticket, timeoutMs = 20000) {
1532
+ const connection = await this.connect(ticket, timeoutMs);
1533
+ return {
1534
+ connectionId: connection.id,
1535
+ localNodeId: connection.localNodeId,
1536
+ remoteNodeId: connection.deviceId,
1537
+ };
1538
+ }
1539
+ /**
1540
+ * Start core-managed auto-connect for the authenticated user.
1541
+ * This is intentionally low-level: callers decide when to start/stop and
1542
+ * what higher-level protocol to run on top of the resulting connection.
1543
+ */
1544
+ async startAutoConnect(localDeviceId) {
1545
+ await this.ensureSignalingReadyForDiscovery('watch');
1546
+ const userId = this.currentUser?.id;
1547
+ if (!userId) {
1548
+ throw new Error('startAutoConnect requires an authenticated user');
1549
+ }
1550
+ const effectiveLocalDeviceId = localDeviceId ||
1551
+ (await this.getLocalDeviceId()) ||
1552
+ this.deviceId;
1553
+ if (!effectiveLocalDeviceId) {
1554
+ throw new Error('startAutoConnect requires a local device identity');
1555
+ }
1556
+ this.signaling.startAutoConnect(userId, effectiveLocalDeviceId);
1557
+ }
1558
+ // --- Multiplexing API ---
1559
+ async openBi(peerId) {
1560
+ await this.ensureWasmRuntimeForP2P();
1561
+ const bi = typeof this.wasmClient.open_peer_bi === 'function'
1562
+ ? await this.wasmClient.open_peer_bi(peerId, 5000)
1563
+ : await this.wasmClient.open_bi(peerId);
1564
+ return {
1565
+ readable: bi.recv,
1566
+ writable: bi.send
1567
+ };
1568
+ }
1569
+ async openUni(peerId) {
1570
+ await this.ensureWasmRuntimeForP2P();
1571
+ if (typeof this.wasmClient.open_peer_uni === 'function') {
1572
+ return await this.wasmClient.open_peer_uni(peerId, 5000);
1573
+ }
1574
+ return await this.wasmClient.open_uni(peerId);
1575
+ }
1576
+ // --- Listener Management ---
1577
+ getConnections() {
1578
+ return Array.from(this.connections.values());
1579
+ }
1580
+ watchPeerStates(callback) {
1581
+ return this.peerLifecycle.watch(callback);
1582
+ }
1583
+ getPeerState(id) {
1584
+ return this.peerLifecycle.get(id);
1585
+ }
1586
+ listConnectedPeers() {
1587
+ return this.peerLifecycle.listConnected();
1588
+ }
1589
+ isPeerPromotionEligible(identifiers) {
1590
+ return this.resolvePromotionEligibility(identifiers);
1591
+ }
1592
+ addPeerScope(id, scope = 'persistent') {
1593
+ if (this.wasmClient && typeof this.wasmClient.add_peer_scope === 'function') {
1594
+ void this.wasmClient.add_peer_scope(id, scope).then(() => {
1595
+ void this.refreshPeerSnapshot();
1596
+ }).catch(() => { });
1597
+ return this.peerLifecycle.get(id);
1598
+ }
1599
+ return this.peerLifecycle.addScope(id, scope);
1600
+ }
1601
+ releasePeerScope(id, scope) {
1602
+ if (this.wasmClient && typeof this.wasmClient.release_peer_scope === 'function') {
1603
+ void this.wasmClient.release_peer_scope(id, scope ?? null).then(() => {
1604
+ void this.refreshPeerSnapshot();
1605
+ }).catch(() => { });
1606
+ return this.peerLifecycle.get(id);
1607
+ }
1608
+ return this.peerLifecycle.releaseScope(id, scope);
1609
+ }
1610
+ getPeerScopes(id) {
1611
+ return this.peerLifecycle.get(id)?.scopes ?? [];
1612
+ }
1613
+ isSamePeer(a, b) {
1614
+ const left = this.peerLifecycle.get(a);
1615
+ const right = this.peerLifecycle.get(b);
1616
+ if (left && right) {
1617
+ return left.peerId === right.peerId;
1618
+ }
1619
+ return false;
1620
+ }
1621
+ async refreshPeerSnapshot() {
1622
+ const hasRustProjection = this.hasRustPeerLifecycleProjection();
1623
+ let hasRustPeerSnapshots = false;
1624
+ if (this.wasmClient && typeof this.wasmClient.peer_sessions === 'function') {
1625
+ const sessions = (await this.wasmClient.peer_sessions());
1626
+ if (Array.isArray(sessions)) {
1627
+ hasRustPeerSnapshots = true;
1628
+ sessions.forEach((session) => {
1629
+ const activeConnectionId = session.activeConnectionId || session.candidateConnectionIds?.[0];
1630
+ const promotionEligible = this.resolvePromotionEligibility({
1631
+ connectionId: activeConnectionId || undefined,
1632
+ deviceId: session.deviceId ?? undefined,
1633
+ deviceIdHint: session.deviceIdHint ?? undefined,
1634
+ nodeId: session.nodeId ?? undefined,
1635
+ });
1636
+ const trustProjection = this.getConnectionTrustProjection({
1637
+ connectionId: activeConnectionId || undefined,
1638
+ deviceId: session.deviceId ?? undefined,
1639
+ deviceIdHint: session.deviceIdHint ?? undefined,
1640
+ nodeId: session.nodeId ?? undefined,
1641
+ });
1642
+ const settledReady = trustProjection
1643
+ ? trustProjection.routable
1644
+ : !!session.settledReady;
1645
+ this.peerLifecycle.upsert({
1646
+ deviceId: (trustProjection?.deviceId ?? session.deviceId) ?? undefined,
1647
+ deviceIdHint: session.deviceIdHint,
1648
+ nodeId: session.nodeId,
1649
+ connectionId: activeConnectionId || undefined,
1650
+ status: this.mapRustPeerStatus(session.status),
1651
+ health: trustProjection?.health ?? this.mapRustPeerHealth(session.health),
1652
+ transportState: activeConnectionId
1653
+ ? 'connected'
1654
+ : session.status === 'connecting'
1655
+ ? 'connecting'
1656
+ : 'closed',
1657
+ protocolState: trustProjection?.protocolState
1658
+ ?? (settledReady
1659
+ ? 'routable'
1660
+ : activeConnectionId
1661
+ ? 'transport-only'
1662
+ : session.status === 'connecting'
1663
+ ? 'connecting'
1664
+ : 'closed'),
1665
+ routable: settledReady,
1666
+ promotionEligible,
1667
+ scopes: session.scopes ?? [],
1668
+ error: trustProjection?.error ?? session.error ?? undefined,
1669
+ lastSeenAt: session.lastSeenAtMs,
1670
+ });
1671
+ });
1672
+ }
1673
+ }
1674
+ else if (this.wasmClient && typeof this.wasmClient.peer_snapshots === 'function') {
1675
+ const snapshots = (await this.wasmClient.peer_snapshots());
1676
+ if (Array.isArray(snapshots)) {
1677
+ hasRustPeerSnapshots = true;
1678
+ snapshots.forEach((snapshot) => {
1679
+ const promotionEligible = this.resolvePromotionEligibility({
1680
+ connectionId: snapshot.connectionIds?.[0],
1681
+ deviceId: snapshot.deviceId ?? undefined,
1682
+ deviceIdHint: snapshot.deviceIdHint ?? undefined,
1683
+ nodeId: snapshot.nodeId ?? undefined,
1684
+ });
1685
+ this.peerLifecycle.upsert({
1686
+ deviceId: snapshot.deviceId,
1687
+ deviceIdHint: snapshot.deviceIdHint,
1688
+ nodeId: snapshot.nodeId,
1689
+ connectionId: snapshot.connectionIds?.[0],
1690
+ status: this.mapRustPeerStatus(snapshot.status),
1691
+ health: this.mapRustPeerHealth(snapshot.health),
1692
+ promotionEligible,
1693
+ scopes: snapshot.scopes ?? [],
1694
+ error: snapshot.error ?? undefined,
1695
+ lastSeenAt: snapshot.lastSeenAtMs,
1696
+ });
1697
+ });
1698
+ }
1699
+ }
1700
+ const devices = await this.searchDevices().catch(() => []);
1701
+ devices.forEach((device) => {
1702
+ this.peerLifecycle.upsertDevice(device);
1703
+ });
1704
+ if (!hasRustProjection) {
1705
+ this.connections.forEach((connection) => {
1706
+ this.syncConnectionPeerLifecycle(connection);
1707
+ });
1708
+ }
1709
+ const backendStates = await this.signaling.getConnectionStates().catch(() => []);
1710
+ backendStates.forEach((state) => {
1711
+ this.applyBackendConnectionState(state);
1712
+ });
1713
+ if (hasRustProjection && !hasRustPeerSnapshots && backendStates.length === 0) {
1714
+ return this.peerLifecycle.list();
1715
+ }
1716
+ return this.peerLifecycle.list();
1717
+ }
1718
+ async connectPeer(options) {
1719
+ const scope = options.scope ?? 'persistent';
1720
+ if (!options.ticket) {
1721
+ throw new Error('connectPeer requires a ticket.');
1722
+ }
1723
+ const connection = await this.connect(options.ticket, 20000, options.deviceId, options.channelId);
1724
+ const promotionEligible = this.resolvePromotionEligibility({
1725
+ connectionId: connection.id,
1726
+ nodeId: connection.remoteNodeId,
1727
+ deviceId: options.deviceId ?? undefined,
1728
+ });
1729
+ const peerId = options.deviceId ||
1730
+ connection.remoteNodeId ||
1731
+ connection.deviceId ||
1732
+ connection.id;
1733
+ if (this.hasRustPeerLifecycleProjection()) {
1734
+ void this.refreshPeerSnapshot().catch(() => { });
1735
+ }
1736
+ else {
1737
+ this.peerLifecycle.upsert({
1738
+ peerId,
1739
+ deviceId: options.deviceId,
1740
+ nodeId: connection.deviceId,
1741
+ connectionId: connection.id,
1742
+ ticket: options.ticket,
1743
+ status: 'connected',
1744
+ health: 'healthy',
1745
+ promotionEligible,
1746
+ scopes: [scope],
1747
+ lastSeenAt: Date.now(),
1748
+ });
1749
+ }
1750
+ void this.addPeerScope(peerId, scope);
1751
+ return connection;
1752
+ }
1753
+ async disconnectPeer(id, scope = 'persistent') {
1754
+ const nextState = this.peerLifecycle.releaseScope(id, scope);
1755
+ if (nextState && nextState.scopes.length > 0) {
1756
+ return;
1757
+ }
1758
+ await this.forceDisconnectPeer(id);
1759
+ }
1760
+ async forceDisconnectPeer(id) {
1761
+ const peer = this.peerLifecycle.releaseScope(id);
1762
+ const candidates = this.getPeerCandidates(peer?.peerId || id);
1763
+ await Promise.allSettled(Array.from(candidates).map(async (candidate) => {
1764
+ const connectionMatches = Array.from(this.connections.values()).filter((connection) => {
1765
+ const connectionId = connection.id.trim().toLowerCase();
1766
+ const remoteNodeId = connection.deviceId.trim().toLowerCase();
1767
+ return candidate === remoteNodeId || candidate === connectionId || connectionId.includes(candidate);
1768
+ });
1769
+ await Promise.allSettled(connectionMatches.map((connection) => connection.disconnect()));
1770
+ const nodeId = peer?.nodeId || (connectionMatches[0]?.deviceId ?? null);
1771
+ if (nodeId && candidate === nodeId.trim().toLowerCase()) {
1772
+ await this.disconnectNode(nodeId).catch(() => { });
1773
+ }
1774
+ }));
1775
+ if (this.hasRustPeerLifecycleProjection()) {
1776
+ await this.refreshPeerSnapshot().catch(() => { });
1777
+ }
1778
+ else {
1779
+ this.peerLifecycle.updateHealth(id, 'stale', 'closed');
1780
+ }
1781
+ }
1782
+ async getPeerHealth(id) {
1783
+ const peer = this.peerLifecycle.get(id);
1784
+ if (!peer) {
1785
+ return 'unknown';
1786
+ }
1787
+ return peer.health;
1788
+ }
1789
+ mapRustPeerStatus(status) {
1790
+ switch ((status || '').toLowerCase()) {
1791
+ case 'connected':
1792
+ return 'connected';
1793
+ case 'connecting':
1794
+ case 'pending':
1795
+ return 'connecting';
1796
+ case 'failed':
1797
+ return 'failed';
1798
+ case 'closing':
1799
+ case 'closed':
1800
+ return 'closed';
1801
+ default:
1802
+ return 'disconnected';
1803
+ }
1804
+ }
1805
+ mapRustPeerHealth(health) {
1806
+ switch ((health || '').toLowerCase()) {
1807
+ case 'healthy':
1808
+ return 'healthy';
1809
+ case 'suspect':
1810
+ return 'suspect';
1811
+ case 'stale':
1812
+ return 'stale';
1813
+ default:
1814
+ return 'unknown';
1815
+ }
1816
+ }
1817
+ async getTicket() {
1818
+ await this.ensureWasmRuntimeForP2P();
1819
+ const config = this.getRTCConfig();
1820
+ const irohConfig = this.options.transports?.iroh;
1821
+ // Use safe ticket (Relay Only) if:
1822
+ // 1. Strict Mode is enabled
1823
+ // 2. WebRTC Privacy Mode is enabled (Legacy/Compat)
1824
+ // 3. Iroh is explicitly configured with relayOnly: true
1825
+ const irohRelayOnly = typeof irohConfig === 'object' && irohConfig.relayOnly;
1826
+ if (this.options.strictMode || config?.privacyMode || irohRelayOnly) {
1827
+ return await this.wasmClient.endpoint_ticket();
1828
+ }
1829
+ return await this.wasmClient.endpoint_ticket();
1830
+ }
1831
+ async getNodeId() {
1832
+ await this.ensureWasmRuntimeForP2P();
1833
+ return this.localNodeId;
1834
+ }
1835
+ async getNodeIdFromTicket(ticket) {
1836
+ const trimmed = ticket.trim();
1837
+ if (!trimmed)
1838
+ return null;
1839
+ // Strip only the compound-ticket suffix; raw iroh tickets can contain dots.
1840
+ const { irohTicket } = splitCompoundTicket(trimmed);
1841
+ if (!this.wasmClient || typeof this.wasmClient.endpoint_id_from_ticket !== 'function') {
1842
+ return null;
1843
+ }
1844
+ try {
1845
+ return await this.wasmClient.endpoint_id_from_ticket(irohTicket);
1846
+ }
1847
+ catch {
1848
+ return null;
1849
+ }
1850
+ }
1851
+ // -----------------------------------------------------------------------
1852
+ // Session grant helpers — the Rust wasm client owns token registration,
1853
+ // validation, and revoke semantics for short-lived share-style admission.
1854
+ // -----------------------------------------------------------------------
1855
+ /**
1856
+ * Generate a random session token, register it, and return a compound
1857
+ * ticket string with the token suffix appended.
1858
+ *
1859
+ * @param scope Logical label (e.g. "share"). Used for bulk-revoke.
1860
+ * @param maxConnections 0 = unlimited.
1861
+ */
1862
+ async getEndpointTicketWithToken(grantScope, maxConnections = 0) {
1863
+ await this.ensureWasmRuntimeForP2P();
1864
+ if (this.wasmClient && typeof this.wasmClient.endpoint_ticket_with_token === 'function') {
1865
+ return await this.wasmClient.endpoint_ticket_with_token(grantScope, maxConnections);
1866
+ }
1867
+ const irohTicket = await this.getTicket();
1868
+ return this.buildCompoundTicketWithToken(irohTicket, grantScope, maxConnections);
1869
+ }
1870
+ /**
1871
+ * Build a compound ticket from an externally-provided iroh ticket string.
1872
+ * Generates a session token, registers it with the Rust wasm client when available,
1873
+ * and appends the token
1874
+ * payload as a base64url suffix separated by `.`.
1875
+ *
1876
+ * Used by RuntimeClient.getTicketWithToken() on native desktop where the
1877
+ * iroh ticket comes from the Rust backend (via Tauri IPC) rather than the
1878
+ * WASM node running in the webview.
1879
+ */
1880
+ buildCompoundTicketWithToken(irohTicket, grantScope, maxConnections = 0) {
1881
+ const token = Client.generateSessionToken();
1882
+ if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
1883
+ this.wasmClient.register_session_token(token, grantScope, maxConnections);
1884
+ }
1885
+ return Client.buildCompoundTicketWithPayload(irohTicket, token, grantScope, maxConnections);
1886
+ }
1887
+ setManagedPresenceTicket(ticket) {
1888
+ const trimmed = ticket?.trim() || '';
1889
+ if (!trimmed) {
1890
+ this.managedPresenceGrant = null;
1891
+ return;
1892
+ }
1893
+ const { irohTicket, tokenSuffix } = splitCompoundTicket(trimmed);
1894
+ const payload = tokenSuffix ? decodeCompoundTicketPayload(tokenSuffix) : null;
1895
+ if (!payload?.t || !payload?.s) {
1896
+ throw new Error('Managed presence ticket must be a compound ticket with an embedded token payload');
1897
+ }
1898
+ this.managedPresenceGrant = {
1899
+ scope: payload.s,
1900
+ token: payload.t,
1901
+ maxConnections: typeof payload.m === 'number' ? payload.m : 0,
1902
+ compoundTicket: trimmed,
1903
+ irohTicket,
1904
+ };
1905
+ if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
1906
+ this.wasmClient.register_session_token(payload.t, payload.s, typeof payload.m === 'number' ? payload.m : 0);
1907
+ }
1908
+ }
1909
+ clearManagedPresenceTicket() {
1910
+ this.managedPresenceGrant = null;
1911
+ }
1912
+ /** Register an externally-generated session token with the Rust wasm client. */
1913
+ registerSessionToken(token, grantScope, maxConnections) {
1914
+ if (this.wasmClient && typeof this.wasmClient.register_session_token === 'function') {
1915
+ this.wasmClient.register_session_token(token, grantScope, maxConnections);
1916
+ }
1917
+ }
1918
+ /** Revoke a specific session token. */
1919
+ revokeSessionToken(token) {
1920
+ if (this.wasmClient && typeof this.wasmClient.revoke_session_token === 'function') {
1921
+ this.wasmClient.revoke_session_token(token);
1922
+ }
1923
+ }
1924
+ /** Revoke all tokens that match the given scope. */
1925
+ async revokeTokensByScope(grantScope) {
1926
+ await this.ensureWasmRuntimeForP2P();
1927
+ if (!this.wasmClient || typeof this.wasmClient.revoke_tokens_by_scope !== 'function') {
1928
+ return [];
1929
+ }
1930
+ const affectedConnectionIds = await this.wasmClient.revoke_tokens_by_scope(grantScope);
1931
+ if (!Array.isArray(affectedConnectionIds) || affectedConnectionIds.length === 0) {
1932
+ return [];
1933
+ }
1934
+ const liveConnections = affectedConnectionIds
1935
+ .map((connectionId) => this.connections.get(connectionId))
1936
+ .filter((connection) => !!connection);
1937
+ await Promise.allSettled(liveConnections.map((connection) => connection.disconnect()));
1938
+ return affectedConnectionIds;
1939
+ }
1940
+ static generateSessionToken() {
1941
+ const bytes = new Uint8Array(24);
1942
+ crypto.getRandomValues(bytes);
1943
+ // base64url, no padding
1944
+ return btoa(String.fromCharCode(...bytes))
1945
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1946
+ }
1947
+ static encodeSessionTokenPayload(token, grantScope, maxConnections) {
1948
+ return btoa(JSON.stringify({ t: token, s: grantScope, m: maxConnections }))
1949
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1950
+ }
1951
+ static buildCompoundTicketWithPayload(irohTicket, token, grantScope, maxConnections) {
1952
+ return `${irohTicket}.${Client.encodeSessionTokenPayload(token, grantScope, maxConnections)}`;
1953
+ }
1954
+ async clearSessionTokens() {
1955
+ await this.ensureWasmRuntimeForP2P();
1956
+ this.clearManagedPresenceTicket();
1957
+ if (this.wasmClient && typeof this.wasmClient.clear_session_tokens === 'function') {
1958
+ this.wasmClient.clear_session_tokens();
1959
+ }
1960
+ }
1961
+ async resolvePresenceTicket(ticketOverride) {
1962
+ const explicitTicket = ticketOverride?.trim();
1963
+ if (explicitTicket) {
1964
+ return explicitTicket;
1965
+ }
1966
+ if (this.managedPresenceGrant) {
1967
+ return this.refreshManagedPresenceTicketIfNeeded();
1968
+ }
1969
+ const configuredTicket = this.options.ticket?.trim();
1970
+ if (configuredTicket) {
1971
+ return configuredTicket;
1972
+ }
1973
+ return this.getTicket();
1974
+ }
1975
+ async refreshManagedPresenceTicketIfNeeded() {
1976
+ const grant = this.managedPresenceGrant;
1977
+ if (!grant) {
1978
+ return this.getTicket();
1979
+ }
1980
+ let nextIrohTicket = grant.irohTicket;
1981
+ try {
1982
+ nextIrohTicket = await this.getTicket();
1983
+ }
1984
+ catch {
1985
+ return grant.compoundTicket;
1986
+ }
1987
+ if (nextIrohTicket === grant.irohTicket) {
1988
+ return grant.compoundTicket;
1989
+ }
1990
+ grant.irohTicket = nextIrohTicket;
1991
+ grant.compoundTicket = Client.buildCompoundTicketWithPayload(nextIrohTicket, grant.token, grant.scope, grant.maxConnections);
1992
+ console.info('[Client][PRESENCE] rebuilt managed compound ticket after endpoint change', {
1993
+ grantScope: grant.scope,
1994
+ });
1995
+ return grant.compoundTicket;
1996
+ }
1997
+ async validateIncomingSessionToken(token, connectionId) {
1998
+ if (!this.wasmClient || typeof this.wasmClient.validate_session_token !== 'function') {
1999
+ return { ok: true };
2000
+ }
2001
+ try {
2002
+ let scope = '';
2003
+ if (connectionId &&
2004
+ typeof this.wasmClient.validate_session_token_for_connection === 'function') {
2005
+ scope = await this.wasmClient.validate_session_token_for_connection(token, connectionId);
2006
+ }
2007
+ else {
2008
+ scope = this.wasmClient.validate_session_token(token);
2009
+ }
2010
+ return { ok: true, scope: scope || undefined };
2011
+ }
2012
+ catch (error) {
2013
+ return {
2014
+ ok: false,
2015
+ reason: error?.message || `${error}`,
2016
+ };
2017
+ }
2018
+ }
2019
+ /**
2020
+ * Start the signaling-backed presence loop owned by the Rust core.
2021
+ * A ticket can be provided explicitly, otherwise the current endpoint ticket is used.
2022
+ */
2023
+ async startPresenceLoop(options) {
2024
+ await this.ensureWasmRuntimeForP2P();
2025
+ if (!this.localNodeId) {
2026
+ throw new Error('startPresenceLoop requires an initialized node');
2027
+ }
2028
+ const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
2029
+ typeof this.options.spaceKey === 'string' &&
2030
+ this.options.spaceKey.trim().length > 0;
2031
+ // In personal mode there's no Firebase user — use the node ID as the
2032
+ // synthetic scope id so the Rust core can publish into the shared
2033
+ // spaces/{namespaceId}/devices collection without auth.
2034
+ const userId = this.currentUser?.id ?? (isPersonalWithSpaceKey ? this.localNodeId : null);
2035
+ if (!userId) {
2036
+ throw new Error('startPresenceLoop requires an authenticated user');
2037
+ }
2038
+ const ticket = await this.resolvePresenceTicket(options?.ticket);
2039
+ if (!ticket || !ticket.trim()) {
2040
+ throw new Error('startPresenceLoop requires a valid endpoint ticket');
2041
+ }
2042
+ this.signaling.startPresenceLoop(userId, this.localNodeId, options?.deviceName || this.options.deviceName || 'Unknown Device', ticket, options?.metadata);
2043
+ }
2044
+ /**
2045
+ * Mark local presence offline in the signaling backend.
2046
+ */
2047
+ async setOffline() {
2048
+ if (!this.localNodeId) {
2049
+ return;
2050
+ }
2051
+ await this.signaling.setOffline(this.localNodeId);
2052
+ }
2053
+ /**
2054
+ * Check raw connectivity against a remote node id.
2055
+ */
2056
+ async isConnected(nodeId) {
2057
+ await this.ensureWasmRuntimeForP2P();
2058
+ if (!nodeId || !nodeId.trim()) {
2059
+ return false;
2060
+ }
2061
+ if (this.wasmClient && typeof this.wasmClient.is_connected === 'function') {
2062
+ return this.wasmClient.is_connected(nodeId);
2063
+ }
2064
+ return this.signaling.isConnected(nodeId);
2065
+ }
2066
+ /**
2067
+ * Disconnect a remote node from the local connection manager.
2068
+ */
2069
+ async disconnectNode(nodeId) {
2070
+ if (!nodeId || !nodeId.trim()) {
2071
+ return;
2072
+ }
2073
+ const matchingConnections = Array.from(this.connections.values()).filter((connection) => connection.deviceId === nodeId);
2074
+ await Promise.allSettled(matchingConnections.map((connection) => connection.disconnect()));
2075
+ if (this.wasmClient && typeof this.wasmClient.disconnect === 'function') {
2076
+ try {
2077
+ await this.wasmClient.disconnect(nodeId);
2078
+ }
2079
+ catch {
2080
+ // Best-effort at core-level disconnect; local manager disconnect already applied.
2081
+ }
2082
+ }
2083
+ this.signaling.notifyDisconnectRequested(nodeId);
2084
+ }
2085
+ async getLocalDeviceId() {
2086
+ return this.signaling.getLocalDeviceId();
2087
+ }
2088
+ /**
2089
+ * Route a raw message received on a native iroh connection through token
2090
+ * validation. Called by RuntimeClient when NativeIpcBridge fires an
2091
+ * `iroh://message` event for an incoming native connection.
2092
+ *
2093
+ * If we have a TS Connection object for this peer (e.g. the client also
2094
+ * called connect()), route through handleMessage. Otherwise, for connections
2095
+ * that were accepted purely by the Rust protocol handler, perform token
2096
+ * validation directly.
2097
+ *
2098
+ * @returns true if the connection should be rejected (caller should disconnect)
2099
+ */
2100
+ async acceptNativeMessage(connectionId, remoteNodeId, msg) {
2101
+ if (!remoteNodeId)
2102
+ return false;
2103
+ // Try to find an existing TS Connection object (bidirectional case).
2104
+ const connId = this.getDeterministicConnectionId(remoteNodeId);
2105
+ const conn = this.connections.get(connId);
2106
+ if (conn) {
2107
+ void this.handleMessage(conn, msg);
2108
+ return false;
2109
+ }
2110
+ // No TS Connection — this is an incoming-only native connection.
2111
+ // Validate incoming grant material if the runtime exposes a validator.
2112
+ if (msg &&
2113
+ typeof msg === 'object' &&
2114
+ msg.type === 'handshake' &&
2115
+ (msg.action === 'hello' || !msg.action)) {
2116
+ const result = await this.validateIncomingSessionToken(msg.sessionToken ?? '', connectionId ?? undefined);
2117
+ if (!result.ok) {
2118
+ console.warn('[Client][SESSION-TOKEN] Rejecting native connection — invalid session token', {
2119
+ remoteNodeId,
2120
+ reason: result.reason,
2121
+ hasToken: !!msg.sessionToken,
2122
+ });
2123
+ return true; // caller should disconnect
2124
+ }
2125
+ // Valid token — Rust already accepted; nothing more to do here.
2126
+ }
2127
+ return false;
2128
+ }
2129
+ async startListening() {
2130
+ if (this.listening)
2131
+ return;
2132
+ // Always ensure runtime is ready for listening/discovery
2133
+ await this.ensureWasmRuntimeForP2P();
2134
+ this.listening = true;
2135
+ const sharedHub = this.getSharedIncomingStreamHub();
2136
+ if (sharedHub) {
2137
+ sharedHub.addClient(this);
2138
+ }
2139
+ else if (!this.listenLoopTask) {
2140
+ this.listenLoopTask = this.listenLoop().finally(() => {
2141
+ this.listenLoopTask = null;
2142
+ });
2143
+ }
2144
+ const userId = this.signaling.currentUser?.id;
2145
+ if (userId && this.localNodeId) {
2146
+ const ticket = await this.resolvePresenceTicket();
2147
+ this.signaling.startPresenceLoop(userId, this.localNodeId, this.options.deviceName || 'Unknown Device', ticket, '' // metadata
2148
+ );
2149
+ }
2150
+ }
2151
+ stopListening() {
2152
+ this.listening = false;
2153
+ this.getSharedIncomingStreamHub()?.removeClient(this);
2154
+ if (this.localNodeId) {
2155
+ this.signaling.setOffline(this.localNodeId);
2156
+ }
2157
+ }
2158
+ getSharedIncomingStreamHub() {
2159
+ if (!this.wasmClient || typeof this.wasmClient.incoming_streams !== 'function') {
2160
+ return null;
2161
+ }
2162
+ const cacheKey = this.wasmClient;
2163
+ let hub = Client.incomingStreamHubs.get(cacheKey);
2164
+ if (!hub) {
2165
+ hub = new SharedIncomingStreamHub(this.wasmClient);
2166
+ Client.incomingStreamHubs.set(cacheKey, hub);
2167
+ }
2168
+ return hub;
2169
+ }
2170
+ nextIncomingStreamRetryDelayMs(attempt) {
2171
+ const backoff = Client.INCOMING_STREAM_RETRY_MIN_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
2172
+ return Math.min(Client.INCOMING_STREAM_RETRY_MAX_DELAY_MS, backoff);
2173
+ }
2174
+ /**
2175
+ * Force an immediate presence update and reconnect snapshot.
2176
+ */
2177
+ forceReconnectSnapshot() {
2178
+ this.signaling.forceReconnectSnapshot();
2179
+ }
2180
+ async updatePresenceInternal() {
2181
+ if (this.localNodeId) {
2182
+ const ticket = await this.resolvePresenceTicket();
2183
+ await this.updatePresence(true, this.options.deviceTTL, undefined, ticket);
2184
+ }
2185
+ }
2186
+ async listenLoop() {
2187
+ let attempt = 0;
2188
+ while (this.listening) {
2189
+ if (!this.wasmClient || typeof this.wasmClient.incoming_streams !== 'function') {
2190
+ console.warn('[Client][INCOMING] incoming_streams is unavailable; stopping listen loop');
2191
+ return;
2192
+ }
2193
+ let reader = null;
2194
+ try {
2195
+ const streams = await this.wasmClient.incoming_streams();
2196
+ reader = streams.getReader();
2197
+ const activeReader = reader;
2198
+ attempt = 0;
2199
+ while (this.listening) {
2200
+ const { done, value } = await activeReader.read();
2201
+ if (done) {
2202
+ throw new Error('incoming stream reader closed unexpectedly');
2203
+ }
2204
+ if (!value) {
2205
+ continue;
2206
+ }
2207
+ console.debug('[Client][INCOMING] Received stream from WASM node', {
2208
+ type: value?.type || 'unknown',
2209
+ endpointId: value?.endpointId || value?.endpoint_id || 'unknown',
2210
+ });
2211
+ try {
2212
+ await this.handleIncomingStream(value);
2213
+ }
2214
+ catch (error) {
2215
+ const incoming = value;
2216
+ const streamType = typeof incoming.type === 'string' ? incoming.type : 'bi';
2217
+ const remoteNodeId = typeof incoming.endpointId === 'string'
2218
+ ? incoming.endpointId
2219
+ : typeof incoming.endpoint_id === 'string'
2220
+ ? incoming.endpoint_id
2221
+ : 'unknown';
2222
+ console.error('[Client][INCOMING] Failed to handle incoming stream', {
2223
+ remoteNodeId,
2224
+ type: streamType,
2225
+ error,
2226
+ });
2227
+ }
2228
+ }
2229
+ }
2230
+ catch (error) {
2231
+ if (!this.listening) {
2232
+ break;
2233
+ }
2234
+ attempt += 1;
2235
+ const delayMs = this.nextIncomingStreamRetryDelayMs(attempt);
2236
+ console.warn('[Client][INCOMING] Listen loop restarting after stream failure', {
2237
+ attempt,
2238
+ delayMs,
2239
+ error,
2240
+ });
2241
+ await this.sleep(delayMs);
2242
+ }
2243
+ finally {
2244
+ if (reader) {
2245
+ // Avoid canceling the wasm-backed incoming-stream reader here.
2246
+ // The underlying wasm-streams source uses a one-shot cancel
2247
+ // closure, and teardown paths (especially in React dev remounts)
2248
+ // can end up invoking that callback more than once.
2249
+ try {
2250
+ reader.releaseLock();
2251
+ }
2252
+ catch {
2253
+ // no-op
2254
+ }
2255
+ }
2256
+ }
2257
+ }
2258
+ }
2259
+ prependChunkToReadable(readable, firstChunk) {
2260
+ const sourceReader = readable.getReader();
2261
+ let emittedFirst = false;
2262
+ return new ReadableStream({
2263
+ async pull(controller) {
2264
+ if (!emittedFirst) {
2265
+ emittedFirst = true;
2266
+ controller.enqueue(firstChunk);
2267
+ return;
2268
+ }
2269
+ const { done, value } = await sourceReader.read();
2270
+ if (done) {
2271
+ controller.close();
2272
+ sourceReader.releaseLock();
2273
+ return;
2274
+ }
2275
+ if (value)
2276
+ controller.enqueue(value);
2277
+ },
2278
+ async cancel(reason) {
2279
+ await sourceReader.cancel(reason).catch(() => undefined);
2280
+ },
2281
+ });
2282
+ }
2283
+ async cancelUnhandledIncomingUniStream(stream, remoteNodeId) {
2284
+ if (!stream || typeof stream !== 'object') {
2285
+ return;
2286
+ }
2287
+ try {
2288
+ const readable = stream;
2289
+ if (typeof readable.cancel === 'function') {
2290
+ await readable.cancel('unhandled-incoming-uni');
2291
+ openRtcDebugLog('[Client][INCOMING] Cancelled unhandled uni stream', {
2292
+ remoteNodeId,
2293
+ });
2294
+ return;
2295
+ }
2296
+ if (typeof readable.getReader === 'function') {
2297
+ const reader = readable.getReader();
2298
+ try {
2299
+ await reader.cancel('unhandled-incoming-uni');
2300
+ }
2301
+ finally {
2302
+ try {
2303
+ reader.releaseLock();
2304
+ }
2305
+ catch {
2306
+ // no-op
2307
+ }
2308
+ }
2309
+ openRtcDebugLog('[Client][INCOMING] Cancelled unhandled uni stream via reader', {
2310
+ remoteNodeId,
2311
+ });
2312
+ }
2313
+ }
2314
+ catch (error) {
2315
+ console.warn('[Client][INCOMING] Failed to cancel unhandled uni stream', {
2316
+ remoteNodeId,
2317
+ error,
2318
+ });
2319
+ }
2320
+ }
2321
+ prependBytesToReader(sourceReader, prefix) {
2322
+ let emittedPrefix = prefix.byteLength === 0;
2323
+ return new ReadableStream({
2324
+ async pull(controller) {
2325
+ if (!emittedPrefix) {
2326
+ emittedPrefix = true;
2327
+ controller.enqueue(prefix);
2328
+ return;
2329
+ }
2330
+ const { done, value } = await sourceReader.read();
2331
+ if (done) {
2332
+ controller.close();
2333
+ return;
2334
+ }
2335
+ if (value) {
2336
+ controller.enqueue(value);
2337
+ }
2338
+ },
2339
+ async cancel(reason) {
2340
+ await sourceReader.cancel(reason).catch(() => undefined);
2341
+ },
2342
+ });
2343
+ }
2344
+ concatBytes(parts) {
2345
+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
2346
+ const merged = new Uint8Array(total);
2347
+ let offset = 0;
2348
+ for (const part of parts) {
2349
+ merged.set(part, offset);
2350
+ offset += part.byteLength;
2351
+ }
2352
+ return merged;
2353
+ }
2354
+ encodeChannelEnvelope(channelId, metadata) {
2355
+ const normalizedChannelId = channelId.trim();
2356
+ if (!normalizedChannelId) {
2357
+ throw new Error('Channel envelope requires a non-empty channel id.');
2358
+ }
2359
+ const encoder = new TextEncoder();
2360
+ const channelBytes = encoder.encode(normalizedChannelId);
2361
+ const metadataBytes = metadata ? encoder.encode(JSON.stringify(metadata)) : new Uint8Array(0);
2362
+ const header = new Uint8Array(Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelBytes.byteLength + metadataBytes.byteLength);
2363
+ const view = new DataView(header.buffer);
2364
+ header[0] = Client.CHANNEL_ENVELOPE_MAGIC;
2365
+ header[1] = Client.CHANNEL_ENVELOPE_VERSION;
2366
+ view.setUint16(2, channelBytes.byteLength, false);
2367
+ view.setUint16(4, metadataBytes.byteLength, false);
2368
+ header.set(channelBytes, Client.CHANNEL_ENVELOPE_HEADER_BYTES);
2369
+ header.set(metadataBytes, Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelBytes.byteLength);
2370
+ return header;
2371
+ }
2372
+ wrapChannelWritable(writable, channelId, metadata) {
2373
+ const envelope = this.encodeChannelEnvelope(channelId, metadata);
2374
+ const writer = writable.getWriter();
2375
+ let envelopeWritten = false;
2376
+ const ensureEnvelope = async () => {
2377
+ if (envelopeWritten) {
2378
+ return;
2379
+ }
2380
+ envelopeWritten = true;
2381
+ await writer.write(envelope);
2382
+ };
2383
+ return new WritableStream({
2384
+ write: async (chunk) => {
2385
+ await ensureEnvelope();
2386
+ await writer.write(chunk);
2387
+ },
2388
+ close: async () => {
2389
+ await ensureEnvelope();
2390
+ try {
2391
+ await writer.close();
2392
+ }
2393
+ finally {
2394
+ writer.releaseLock();
2395
+ }
2396
+ },
2397
+ abort: async (reason) => {
2398
+ try {
2399
+ await writer.abort(reason);
2400
+ }
2401
+ finally {
2402
+ writer.releaseLock();
2403
+ }
2404
+ },
2405
+ });
2406
+ }
2407
+ async extractIncomingChannelEnvelope(stream, remoteNodeId) {
2408
+ if (!this.isBiStreamLike(stream)) {
2409
+ return { stream, channel: null };
2410
+ }
2411
+ let reader;
2412
+ try {
2413
+ reader = stream.recv.getReader();
2414
+ }
2415
+ catch (error) {
2416
+ console.warn('[Client][CHANNEL] Incoming stream recv reader unavailable; skipping channel envelope inspection', {
2417
+ remoteNodeId,
2418
+ error,
2419
+ });
2420
+ return { stream, channel: null };
2421
+ }
2422
+ const chunks = [];
2423
+ let bufferedLength = 0;
2424
+ try {
2425
+ while (bufferedLength < Client.CHANNEL_ENVELOPE_HEADER_BYTES) {
2426
+ const { done, value } = await reader.read();
2427
+ if (done) {
2428
+ break;
2429
+ }
2430
+ if (!value || value.byteLength === 0) {
2431
+ continue;
2432
+ }
2433
+ const chunk = new Uint8Array(value);
2434
+ chunks.push(chunk);
2435
+ bufferedLength += chunk.byteLength;
2436
+ }
2437
+ }
2438
+ catch (error) {
2439
+ console.warn('[Client][CHANNEL] Failed reading incoming channel envelope prefix', {
2440
+ remoteNodeId,
2441
+ error,
2442
+ });
2443
+ reader.releaseLock();
2444
+ return { stream, channel: null };
2445
+ }
2446
+ const buffered = this.concatBytes(chunks);
2447
+ if (buffered.byteLength < Client.CHANNEL_ENVELOPE_HEADER_BYTES ||
2448
+ buffered[0] !== Client.CHANNEL_ENVELOPE_MAGIC ||
2449
+ buffered[1] !== Client.CHANNEL_ENVELOPE_VERSION) {
2450
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
2451
+ return { stream: rebuilt, channel: null };
2452
+ }
2453
+ const view = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength);
2454
+ const channelLength = view.getUint16(2, false);
2455
+ const metadataLength = view.getUint16(4, false);
2456
+ const totalEnvelopeBytes = Client.CHANNEL_ENVELOPE_HEADER_BYTES + channelLength + metadataLength;
2457
+ let extendedBuffered = buffered;
2458
+ if (extendedBuffered.byteLength < totalEnvelopeBytes) {
2459
+ try {
2460
+ while (extendedBuffered.byteLength < totalEnvelopeBytes) {
2461
+ const { done, value } = await reader.read();
2462
+ if (done) {
2463
+ break;
2464
+ }
2465
+ if (!value || value.byteLength === 0) {
2466
+ continue;
2467
+ }
2468
+ extendedBuffered = this.concatBytes([extendedBuffered, new Uint8Array(value)]);
2469
+ }
2470
+ }
2471
+ catch (error) {
2472
+ console.warn('[Client][CHANNEL] Failed extending incoming channel envelope read', {
2473
+ remoteNodeId,
2474
+ error,
2475
+ });
2476
+ }
2477
+ }
2478
+ if (extendedBuffered.byteLength < totalEnvelopeBytes || channelLength === 0) {
2479
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
2480
+ return { stream: rebuilt, channel: null };
2481
+ }
2482
+ const channelStart = Client.CHANNEL_ENVELOPE_HEADER_BYTES;
2483
+ const channelEnd = channelStart + channelLength;
2484
+ const metadataEnd = channelEnd + metadataLength;
2485
+ const decoder = new TextDecoder();
2486
+ const channelId = decoder.decode(extendedBuffered.slice(channelStart, channelEnd)).trim();
2487
+ let metadata = null;
2488
+ if (metadataLength > 0) {
2489
+ try {
2490
+ const parsed = JSON.parse(decoder.decode(extendedBuffered.slice(channelEnd, metadataEnd)));
2491
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2492
+ metadata = parsed;
2493
+ }
2494
+ }
2495
+ catch (error) {
2496
+ console.warn('[Client][CHANNEL] Failed to parse incoming channel metadata', {
2497
+ remoteNodeId,
2498
+ channelId,
2499
+ error,
2500
+ });
2501
+ }
2502
+ }
2503
+ if (!channelId) {
2504
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
2505
+ return { stream: rebuilt, channel: null };
2506
+ }
2507
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered.slice(totalEnvelopeBytes)));
2508
+ return {
2509
+ stream: rebuilt,
2510
+ channel: {
2511
+ channelId,
2512
+ metadata,
2513
+ },
2514
+ };
2515
+ }
2516
+ isBiStreamLike(stream) {
2517
+ try {
2518
+ const candidate = stream;
2519
+ return !!candidate
2520
+ && !!candidate.send
2521
+ && typeof candidate.send.getWriter === 'function'
2522
+ && !!candidate.recv
2523
+ && typeof candidate.recv.getReader === 'function';
2524
+ }
2525
+ catch {
2526
+ return false;
2527
+ }
2528
+ }
2529
+ materializeIncomingBiStream(stream, fallbackEndpointId) {
2530
+ const candidate = stream;
2531
+ if (!candidate) {
2532
+ return null;
2533
+ }
2534
+ try {
2535
+ const send = candidate.send;
2536
+ const recv = candidate.recv;
2537
+ if (!send || typeof send.getWriter !== 'function') {
2538
+ return null;
2539
+ }
2540
+ if (!recv || typeof recv.getReader !== 'function') {
2541
+ return null;
2542
+ }
2543
+ const endpointId = typeof candidate.endpoint_id === 'string' && candidate.endpoint_id.trim().length > 0
2544
+ ? candidate.endpoint_id
2545
+ : fallbackEndpointId;
2546
+ return {
2547
+ send,
2548
+ recv,
2549
+ endpoint_id: endpointId,
2550
+ };
2551
+ }
2552
+ catch {
2553
+ return null;
2554
+ }
2555
+ }
2556
+ rebuildBiIncomingStream(stream, recv) {
2557
+ return {
2558
+ send: stream.send,
2559
+ recv,
2560
+ endpoint_id: typeof stream.endpoint_id === 'string' ? stream.endpoint_id : '',
2561
+ };
2562
+ }
2563
+ async classifyBiIncomingStream(stream) {
2564
+ if (!this.isBiStreamLike(stream)) {
2565
+ return { stream, protocolHint: 'unknown' };
2566
+ }
2567
+ const reader = stream.recv.getReader();
2568
+ let firstRead;
2569
+ try {
2570
+ firstRead = await reader.read();
2571
+ }
2572
+ catch {
2573
+ reader.releaseLock();
2574
+ return { stream, protocolHint: 'unknown' };
2575
+ }
2576
+ reader.releaseLock();
2577
+ if (firstRead.done || !firstRead.value || firstRead.value.byteLength === 0) {
2578
+ console.debug('[Client][INCOMING] classifyBiIncomingStream: empty read', {
2579
+ done: firstRead.done,
2580
+ hasValue: !!firstRead.value,
2581
+ byteLength: firstRead.value?.byteLength,
2582
+ });
2583
+ return { stream, protocolHint: 'unknown' };
2584
+ }
2585
+ const firstChunk = new Uint8Array(firstRead.value);
2586
+ const firstByte = firstChunk[0];
2587
+ console.debug('[Client][INCOMING] classifyBiIncomingStream: first byte', {
2588
+ firstByte: '0x' + firstByte.toString(16),
2589
+ EXPLICIT_PROTOCOL_BYTE: '0x' + Client.EXPLICIT_PROTOCOL_BYTE.toString(16),
2590
+ isExplicit: firstByte === Client.EXPLICIT_PROTOCOL_BYTE,
2591
+ chunkLength: firstChunk.length,
2592
+ });
2593
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependChunkToReadable(stream.recv, firstChunk));
2594
+ if (firstByte === Client.EXPLICIT_PROTOCOL_BYTE) {
2595
+ return { stream: rebuilt, protocolHint: 'explicit' };
2596
+ }
2597
+ return { stream: rebuilt, protocolHint: 'control' };
2598
+ }
2599
+ async detectNativeMainStream(stream, remoteNodeId) {
2600
+ if (!this.isBiStreamLike(stream)) {
2601
+ return { handled: false, stream, protocolHint: 'unknown' };
2602
+ }
2603
+ const reader = stream.recv.getReader();
2604
+ const chunks = [];
2605
+ let bufferedLength = 0;
2606
+ const requiredPrefixBytes = 1 + 4;
2607
+ let reachedEof = false;
2608
+ try {
2609
+ while (bufferedLength < requiredPrefixBytes) {
2610
+ const { done, value } = await reader.read();
2611
+ if (done) {
2612
+ reachedEof = true;
2613
+ break;
2614
+ }
2615
+ if (!value || value.byteLength === 0) {
2616
+ continue;
2617
+ }
2618
+ chunks.push(new Uint8Array(value));
2619
+ bufferedLength += value.byteLength;
2620
+ }
2621
+ }
2622
+ catch (error) {
2623
+ console.warn('[Client][NATIVE-MAIN] Failed reading incoming prefix', {
2624
+ remoteNodeId,
2625
+ error,
2626
+ });
2627
+ reader.releaseLock();
2628
+ return { handled: false, stream, protocolHint: 'control' };
2629
+ }
2630
+ const buffered = this.concatBytes(chunks);
2631
+ if (buffered.byteLength < requiredPrefixBytes || buffered[0] !== Client.NATIVE_MAIN_PROTOCOL_BYTE) {
2632
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
2633
+ return {
2634
+ handled: false,
2635
+ stream: rebuilt,
2636
+ protocolHint: buffered[0] === Client.EXPLICIT_PROTOCOL_BYTE ? 'explicit' : 'control',
2637
+ };
2638
+ }
2639
+ const labelLen = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength).getUint32(1, false);
2640
+ if (labelLen === 0 || labelLen > Client.NATIVE_MAIN_MAX_LABEL_BYTES) {
2641
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, buffered));
2642
+ return { handled: false, stream: rebuilt, protocolHint: 'control' };
2643
+ }
2644
+ const totalPrefixBytes = 1 + 4 + labelLen;
2645
+ let extendedBuffered = buffered;
2646
+ if (extendedBuffered.byteLength < totalPrefixBytes && !reachedEof) {
2647
+ const extraChunks = [extendedBuffered];
2648
+ let currentLength = extendedBuffered.byteLength;
2649
+ try {
2650
+ while (currentLength < totalPrefixBytes) {
2651
+ const { done, value } = await reader.read();
2652
+ if (done) {
2653
+ reachedEof = true;
2654
+ break;
2655
+ }
2656
+ if (!value || value.byteLength === 0) {
2657
+ continue;
2658
+ }
2659
+ const chunk = new Uint8Array(value);
2660
+ extraChunks.push(chunk);
2661
+ currentLength += chunk.byteLength;
2662
+ }
2663
+ }
2664
+ catch (error) {
2665
+ console.warn('[Client][NATIVE-MAIN] Failed extending prefix read', {
2666
+ remoteNodeId,
2667
+ error,
2668
+ });
2669
+ }
2670
+ extendedBuffered = this.concatBytes(extraChunks);
2671
+ }
2672
+ if (extendedBuffered.byteLength < totalPrefixBytes) {
2673
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
2674
+ return { handled: false, stream: rebuilt, protocolHint: 'control' };
2675
+ }
2676
+ const labelBytes = extendedBuffered.slice(5, totalPrefixBytes);
2677
+ const label = new TextDecoder().decode(labelBytes);
2678
+ if (label !== Client.NATIVE_MAIN_LABEL) {
2679
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, extendedBuffered));
2680
+ return { handled: false, stream: rebuilt, protocolHint: 'control' };
2681
+ }
2682
+ const remainder = extendedBuffered.slice(totalPrefixBytes);
2683
+ const rebuilt = this.rebuildBiIncomingStream(stream, this.prependBytesToReader(reader, remainder));
2684
+ openRtcDebugInfo('[Client][NATIVE-MAIN] Detected desktop native main stream', {
2685
+ remoteNodeId,
2686
+ label,
2687
+ });
2688
+ return {
2689
+ handled: true,
2690
+ stream: rebuilt,
2691
+ protocolHint: 'native-main',
2692
+ };
2693
+ }
2694
+ async resolveLocalHandshakePayload() {
2695
+ const localDeviceId = (await this.getLocalDeviceId().catch(() => null)) ||
2696
+ this.deviceId ||
2697
+ this.options.localDeviceId;
2698
+ if (!localDeviceId) {
2699
+ throw new Error('Local device id unavailable for native handshake response');
2700
+ }
2701
+ return {
2702
+ deviceId: localDeviceId,
2703
+ deviceName: this.options.deviceName || 'Browser Device',
2704
+ platformType: 'web',
2705
+ capabilities: {
2706
+ canHost: false,
2707
+ canSync: true,
2708
+ readOnly: false,
2709
+ },
2710
+ protocolVersion: Client.NATIVE_MAIN_PROTOCOL_VERSION,
2711
+ timestamp: Date.now(),
2712
+ };
2713
+ }
2714
+ getDeterministicConnectionId(remoteNodeId) {
2715
+ if (!this.localNodeId) {
2716
+ return `native-main-${remoteNodeId}`;
2717
+ }
2718
+ return this.localNodeId <= remoteNodeId
2719
+ ? `${this.localNodeId}-${remoteNodeId}`
2720
+ : `${remoteNodeId}-${this.localNodeId}`;
2721
+ }
2722
+ getNativeMainConnectionId(remoteNodeId) {
2723
+ return this.getDeterministicConnectionId(remoteNodeId);
2724
+ }
2725
+ hasRustPeerLifecycleProjection() {
2726
+ if (this.signaling && typeof this.signaling.supportsRustPeerLifecycleProjection === 'function'
2727
+ && this.signaling.supportsRustPeerLifecycleProjection()) {
2728
+ return true;
2729
+ }
2730
+ return !!(this.wasmClient &&
2731
+ (typeof this.wasmClient.peer_sessions === 'function'
2732
+ || typeof this.wasmClient.peer_snapshots === 'function'));
2733
+ }
2734
+ shouldProjectNativeMainLifecycle() {
2735
+ return !this.hasRustPeerLifecycleProjection();
2736
+ }
2737
+ emitNativeMainConnectionState(remoteNodeId, state, deviceId) {
2738
+ if (!this.shouldProjectNativeMainLifecycle()) {
2739
+ return;
2740
+ }
2741
+ const connectionId = this.getNativeMainConnectionId(remoteNodeId);
2742
+ const previous = this.peerLifecycle && typeof this.peerLifecycle.get === 'function'
2743
+ ? this.peerLifecycle.get(connectionId)
2744
+ : undefined;
2745
+ const next = {
2746
+ connectionId,
2747
+ deviceId,
2748
+ remoteNodeId,
2749
+ state,
2750
+ transportState: state === 'connected' ? 'connected' : 'closed',
2751
+ protocolState: state === 'connected' ? 'routable' : 'closed',
2752
+ routable: state === 'connected',
2753
+ updatedAt: Date.now(),
2754
+ };
2755
+ const unchanged = previous
2756
+ && previous.status === next.state
2757
+ && previous.deviceId === next.deviceId
2758
+ && previous.nodeId === next.remoteNodeId
2759
+ && previous.transportState === next.transportState
2760
+ && previous.protocolState === next.protocolState
2761
+ && previous.routable === next.routable;
2762
+ this.peerLifecycle.upsert({
2763
+ deviceId: next.deviceId,
2764
+ nodeId: next.remoteNodeId,
2765
+ connectionId: next.connectionId,
2766
+ status: next.state === 'connected' ? 'connected' : 'closed',
2767
+ health: next.state === 'connected' ? 'healthy' : 'stale',
2768
+ transportState: next.transportState,
2769
+ protocolState: next.protocolState,
2770
+ routable: next.routable,
2771
+ lastSeenAt: next.updatedAt,
2772
+ });
2773
+ if (unchanged || typeof window === 'undefined') {
2774
+ return;
2775
+ }
2776
+ window.dispatchEvent(new CustomEvent(Client.CONNECTION_STATE_CHANGED_EVENT, {
2777
+ detail: {
2778
+ connectionId: next.connectionId,
2779
+ deviceId: next.deviceId ?? null,
2780
+ remoteNodeId: next.remoteNodeId,
2781
+ state: next.state,
2782
+ transportState: next.transportState,
2783
+ protocolState: next.protocolState,
2784
+ routable: next.routable,
2785
+ updatedAt: next.updatedAt,
2786
+ },
2787
+ }));
2788
+ }
2789
+ applyBackendConnectionState(state) {
2790
+ if (!state?.connectionId) {
2791
+ return;
2792
+ }
2793
+ if (!state.deviceId && !state.deviceIdHint && !state.remoteNodeId) {
2794
+ // Raw connection-state events can arrive before Rust has resolved the
2795
+ // authoritative peer identity. Keep those events diagnostic-only
2796
+ // until the runtime exposes at least one stable identifier.
2797
+ return;
2798
+ }
2799
+ openRtcDebug('[Client] applyBackendConnectionState', {
2800
+ connectionId: state.connectionId,
2801
+ deviceId: state.deviceId ?? null,
2802
+ deviceIdHint: state.deviceIdHint ?? null,
2803
+ remoteNodeId: state.remoteNodeId ?? null,
2804
+ state: state.state,
2805
+ transportState: state.transportState ?? null,
2806
+ protocolState: state.protocolState ?? null,
2807
+ routable: state.routable ?? null,
2808
+ updatedAt: state.updatedAt ?? null,
2809
+ error: state.error ?? null,
2810
+ });
2811
+ const trustProjection = this.getConnectionTrustProjection({
2812
+ connectionId: state.connectionId,
2813
+ deviceId: state.deviceId ?? undefined,
2814
+ deviceIdHint: state.deviceIdHint ?? undefined,
2815
+ nodeId: state.remoteNodeId ?? undefined,
2816
+ });
2817
+ const effectiveRoutable = trustProjection ? trustProjection.routable : state.routable;
2818
+ const effectiveProtocolState = trustProjection?.protocolState ?? state.protocolState;
2819
+ const effectiveError = trustProjection?.error ?? state.error;
2820
+ const effectiveHealth = trustProjection?.health
2821
+ ?? (state.routable || state.readinessState === 'routable'
2822
+ ? 'healthy'
2823
+ : state.state === 'failed' || state.state === 'closed'
2824
+ ? 'stale'
2825
+ : 'unknown');
2826
+ this.peerLifecycle.upsert({
2827
+ deviceId: (trustProjection?.deviceId ?? state.deviceId) ?? undefined,
2828
+ deviceIdHint: state.deviceIdHint ?? undefined,
2829
+ nodeId: state.remoteNodeId ?? undefined,
2830
+ connectionId: state.connectionId,
2831
+ status: state.state === 'connected'
2832
+ ? 'connected'
2833
+ : state.state === 'connecting' || state.state === 'new'
2834
+ ? 'connecting'
2835
+ : state.state === 'failed'
2836
+ ? 'failed'
2837
+ : state.state === 'closed'
2838
+ ? 'closed'
2839
+ : 'disconnected',
2840
+ health: effectiveHealth,
2841
+ transportState: state.transportState,
2842
+ protocolState: effectiveProtocolState,
2843
+ routable: effectiveRoutable,
2844
+ error: effectiveError,
2845
+ lastSeenAt: state.updatedAt ?? Date.now(),
2846
+ });
2847
+ }
2848
+ encodeNativeMainFrame(message) {
2849
+ const payload = new TextEncoder().encode(JSON.stringify(message));
2850
+ const labelBytes = new TextEncoder().encode(Client.NATIVE_MAIN_LABEL);
2851
+ const envelope = new Uint8Array(1 + 4 + labelBytes.byteLength + 4 + payload.byteLength);
2852
+ const view = new DataView(envelope.buffer);
2853
+ let offset = 0;
2854
+ envelope[offset] = Client.NATIVE_MAIN_PROTOCOL_BYTE;
2855
+ offset += 1;
2856
+ view.setUint32(offset, labelBytes.byteLength, false);
2857
+ offset += 4;
2858
+ envelope.set(labelBytes, offset);
2859
+ offset += labelBytes.byteLength;
2860
+ view.setUint32(offset, payload.byteLength, false);
2861
+ offset += 4;
2862
+ envelope.set(payload, offset);
2863
+ return envelope;
2864
+ }
2865
+ async writeNativeMainMessage(writer, message) {
2866
+ await writer.write(this.encodeNativeMainFrame(message));
2867
+ }
2868
+ async handleNativeHandshakeRequest(writer, remoteNodeId, request) {
2869
+ const localPayload = await this.resolveLocalHandshakePayload();
2870
+ const remoteDeviceId = request.payload && typeof request.payload.deviceId === 'string'
2871
+ ? request.payload.deviceId
2872
+ : undefined;
2873
+ const response = {
2874
+ channel: 'handshake',
2875
+ action: 'response',
2876
+ requestId: request.requestId,
2877
+ payload: {
2878
+ accepted: true,
2879
+ ...localPayload,
2880
+ },
2881
+ timestamp: Date.now(),
2882
+ };
2883
+ await this.writeNativeMainMessage(writer, response);
2884
+ this.emitNativeMainConnectionState(remoteNodeId, 'connected', remoteDeviceId);
2885
+ openRtcDebugInfo('[Client][NATIVE-MAIN] Responded to desktop handshake', {
2886
+ remoteNodeId,
2887
+ requestId: request.requestId ?? null,
2888
+ localDeviceId: localPayload.deviceId,
2889
+ });
2890
+ }
2891
+ async handleNativeSessionTokenRequest(writer, remoteNodeId, request) {
2892
+ const token = request.payload && typeof request.payload.token === 'string'
2893
+ ? request.payload.token.trim()
2894
+ : '';
2895
+ const connectionId = this.getNativeMainConnectionId(remoteNodeId);
2896
+ const result = await this.validateIncomingSessionToken(token, connectionId);
2897
+ const response = result.ok
2898
+ ? {
2899
+ channel: 'session-token',
2900
+ action: 'response',
2901
+ requestId: request.requestId,
2902
+ payload: {
2903
+ approved: true,
2904
+ scope: result.scope ?? null,
2905
+ connectionId,
2906
+ },
2907
+ timestamp: Date.now(),
2908
+ }
2909
+ : {
2910
+ channel: 'session-token',
2911
+ action: 'response',
2912
+ requestId: request.requestId,
2913
+ payload: {
2914
+ approved: false,
2915
+ reason: result.reason ?? 'session-token-rejected',
2916
+ connectionId,
2917
+ },
2918
+ timestamp: Date.now(),
2919
+ };
2920
+ await this.writeNativeMainMessage(writer, response);
2921
+ if (result.ok) {
2922
+ console.info('[Client][AUTH] host approved managed session token', {
2923
+ remoteNodeId,
2924
+ connectionId,
2925
+ scope: result.scope ?? null,
2926
+ });
2927
+ return;
2928
+ }
2929
+ console.warn('[Client][AUTH] host rejected managed session token', {
2930
+ remoteNodeId,
2931
+ connectionId,
2932
+ reason: result.reason ?? 'session-token-rejected',
2933
+ hasToken: token.length > 0,
2934
+ });
2935
+ }
2936
+ async handleNativeMainStream(stream, remoteNodeId) {
2937
+ if (!this.isBiStreamLike(stream)) {
2938
+ return;
2939
+ }
2940
+ const reader = stream.recv.getReader();
2941
+ const writer = stream.send.getWriter();
2942
+ const chunks = [];
2943
+ let buffered = new Uint8Array(0);
2944
+ try {
2945
+ while (true) {
2946
+ while (buffered.byteLength < 4) {
2947
+ const { done, value } = await reader.read();
2948
+ if (done) {
2949
+ if (this.shouldProjectNativeMainLifecycle()) {
2950
+ this.emitNativeMainConnectionState(remoteNodeId, 'closed');
2951
+ }
2952
+ else {
2953
+ void this.refreshPeerSnapshot().catch(() => { });
2954
+ }
2955
+ openRtcDebugInfo('[Client][NATIVE-MAIN] Remote closed native main stream', {
2956
+ remoteNodeId,
2957
+ });
2958
+ return;
2959
+ }
2960
+ if (value && value.byteLength > 0) {
2961
+ chunks.push(new Uint8Array(value));
2962
+ buffered = this.concatBytes(chunks);
2963
+ }
2964
+ }
2965
+ const view = new DataView(buffered.buffer, buffered.byteOffset, buffered.byteLength);
2966
+ const messageLen = view.getUint32(0, false);
2967
+ if (messageLen === 0) {
2968
+ buffered = buffered.slice(4);
2969
+ chunks.length = 0;
2970
+ if (buffered.byteLength > 0) {
2971
+ chunks.push(buffered);
2972
+ }
2973
+ continue;
2974
+ }
2975
+ while (buffered.byteLength < 4 + messageLen) {
2976
+ const { done, value } = await reader.read();
2977
+ if (done) {
2978
+ if (this.shouldProjectNativeMainLifecycle()) {
2979
+ this.emitNativeMainConnectionState(remoteNodeId, 'closed');
2980
+ }
2981
+ else {
2982
+ void this.refreshPeerSnapshot().catch(() => { });
2983
+ }
2984
+ console.warn('[Client][NATIVE-MAIN] Native main stream ended mid-frame', {
2985
+ remoteNodeId,
2986
+ messageLen,
2987
+ bufferedBytes: buffered.byteLength,
2988
+ });
2989
+ return;
2990
+ }
2991
+ if (value && value.byteLength > 0) {
2992
+ chunks.length = 0;
2993
+ buffered = this.concatBytes([buffered, new Uint8Array(value)]);
2994
+ }
2995
+ }
2996
+ const payloadBytes = buffered.slice(4, 4 + messageLen);
2997
+ buffered = buffered.slice(4 + messageLen);
2998
+ chunks.length = 0;
2999
+ if (buffered.byteLength > 0) {
3000
+ chunks.push(buffered);
3001
+ }
3002
+ let message;
3003
+ try {
3004
+ message = JSON.parse(new TextDecoder().decode(payloadBytes));
3005
+ }
3006
+ catch (error) {
3007
+ console.warn('[Client][NATIVE-MAIN] Failed to parse native main message', {
3008
+ remoteNodeId,
3009
+ error,
3010
+ });
3011
+ continue;
3012
+ }
3013
+ openRtcDebugInfo('[Client][NATIVE-MAIN] Received native main message', {
3014
+ remoteNodeId,
3015
+ channel: message.channel,
3016
+ action: message.action,
3017
+ requestId: message.requestId ?? null,
3018
+ });
3019
+ if (message.channel === 'handshake' && message.action === 'request') {
3020
+ await this.handleNativeHandshakeRequest(writer, remoteNodeId, message);
3021
+ continue;
3022
+ }
3023
+ if (message.channel === 'session-token' && message.action === 'request') {
3024
+ await this.handleNativeSessionTokenRequest(writer, remoteNodeId, message);
3025
+ }
3026
+ }
3027
+ }
3028
+ catch (error) {
3029
+ if (this.shouldProjectNativeMainLifecycle()) {
3030
+ this.emitNativeMainConnectionState(remoteNodeId, 'closed');
3031
+ }
3032
+ else {
3033
+ void this.refreshPeerSnapshot().catch(() => { });
3034
+ }
3035
+ console.error('[Client][NATIVE-MAIN] Native main stream handler failed', {
3036
+ remoteNodeId,
3037
+ error,
3038
+ });
3039
+ }
3040
+ finally {
3041
+ writer.releaseLock();
3042
+ reader.releaseLock();
3043
+ }
3044
+ }
3045
+ async isCoreConnectionHealthy(remoteNodeId) {
3046
+ if (!remoteNodeId)
3047
+ return false;
3048
+ if (this.wasmClient && typeof this.wasmClient.is_connected === 'function') {
3049
+ try {
3050
+ return !!(await this.wasmClient.is_connected(remoteNodeId));
3051
+ }
3052
+ catch {
3053
+ // fall through
3054
+ }
3055
+ }
3056
+ try {
3057
+ return await this.signaling.isConnected(remoteNodeId);
3058
+ }
3059
+ catch {
3060
+ return false;
3061
+ }
3062
+ }
3063
+ async waitForStableConnection(connection) {
3064
+ const trustState = this.initializeConnectionTrust(connection);
3065
+ if (this.wasmClient && typeof this.wasmClient.wait_for_settled_peer === 'function') {
3066
+ try {
3067
+ const settled = await this.wasmClient.wait_for_settled_peer(connection.deviceId, Client.CONNECTION_STABLE_TIMEOUT_MS);
3068
+ if (settled?.settledReady && (!trustState.required || trustState.verified)) {
3069
+ return true;
3070
+ }
3071
+ if (trustState.required && trustState.failed) {
3072
+ return false;
3073
+ }
3074
+ if (!this.usesTicketOnlySignalingMode()) {
3075
+ return false;
3076
+ }
3077
+ }
3078
+ catch {
3079
+ // Fall back to the legacy transport-only stability gate below.
3080
+ }
3081
+ }
3082
+ const startedAt = Date.now();
3083
+ let stableSince = null;
3084
+ while (Date.now() - startedAt < Client.CONNECTION_STABLE_TIMEOUT_MS) {
3085
+ if (connection.isClosed)
3086
+ return false;
3087
+ if (trustState.required && trustState.failed) {
3088
+ return false;
3089
+ }
3090
+ const healthy = await this.isCoreConnectionHealthy(connection.deviceId);
3091
+ if (healthy) {
3092
+ if (stableSince === null)
3093
+ stableSince = Date.now();
3094
+ if (Date.now() - stableSince >= Client.CONNECTION_STABLE_WINDOW_MS
3095
+ && (!trustState.required || trustState.verified)) {
3096
+ return true;
3097
+ }
3098
+ }
3099
+ else {
3100
+ stableSince = null;
3101
+ }
3102
+ await new Promise((resolve) => setTimeout(resolve, Client.CONNECTION_STABLE_POLL_MS));
3103
+ }
3104
+ return false;
3105
+ }
3106
+ announceConnectionWhenStable(connection) {
3107
+ void (async () => {
3108
+ const stable = await this.waitForStableConnection(connection);
3109
+ if (!stable) {
3110
+ if (!connection.isClosed) {
3111
+ console.warn(`[Client] Suppressing unstable connection ${connection.id}`);
3112
+ }
3113
+ const trustState = this.connectionTrustStates.get(connection.id);
3114
+ if (trustState?.required && !connection.isClosed) {
3115
+ await this.enforceConnectionTrustFailure(connection, trustState.failureReason ?? 'trusted-device-proof-timeout').catch(() => undefined);
3116
+ }
3117
+ return;
3118
+ }
3119
+ if (this.announcedConnections.has(connection.id))
3120
+ return;
3121
+ this.announcedConnections.add(connection.id);
3122
+ this.connectionListeners.forEach((cb) => cb(connection));
3123
+ })();
3124
+ }
3125
+ async normalizeIncomingStreamEvent(incoming, options) {
3126
+ // Compatibility check: OLD behavior returned BiStream directly?
3127
+ // New behavior returns { type, stream, endpointId }
3128
+ // Normalize
3129
+ let type = 'bi';
3130
+ let stream = incoming;
3131
+ let endpointId = '';
3132
+ let channel = null;
3133
+ if ('type' in incoming) {
3134
+ // New structure
3135
+ type = incoming.type;
3136
+ stream = incoming.stream;
3137
+ endpointId = incoming.endpointId;
3138
+ }
3139
+ else {
3140
+ endpointId = incoming.endpoint_id ?? '';
3141
+ }
3142
+ console.debug('[Client][INCOMING] handleIncomingStream called', {
3143
+ hasType: 'type' in incoming,
3144
+ type,
3145
+ endpointId,
3146
+ });
3147
+ let protocolHint = 'unknown';
3148
+ if (type === 'bi') {
3149
+ const materializedStream = this.materializeIncomingBiStream(stream, endpointId);
3150
+ if (!materializedStream || !this.isBiStreamLike(materializedStream)) {
3151
+ let hasSend = false;
3152
+ let hasRecv = false;
3153
+ try {
3154
+ hasSend = !!stream?.send;
3155
+ }
3156
+ catch { }
3157
+ try {
3158
+ hasRecv = !!stream?.recv;
3159
+ }
3160
+ catch { }
3161
+ console.error('[Client][INCOMING] Invalid bidirectional stream payload', {
3162
+ remoteNodeId: endpointId,
3163
+ hasSend,
3164
+ hasRecv,
3165
+ });
3166
+ return {
3167
+ type: 'bi',
3168
+ stream,
3169
+ remoteNodeId: endpointId,
3170
+ protocolHint: 'unknown',
3171
+ channel: null,
3172
+ invalid: true,
3173
+ };
3174
+ }
3175
+ stream = materializedStream;
3176
+ if (options?.inspectChannelEnvelope) {
3177
+ const channelEnvelope = await this.extractIncomingChannelEnvelope(stream, endpointId);
3178
+ stream = channelEnvelope.stream;
3179
+ channel = channelEnvelope.channel;
3180
+ }
3181
+ const classified = await this.classifyBiIncomingStream(stream);
3182
+ stream = classified.stream;
3183
+ protocolHint = classified.protocolHint;
3184
+ if (protocolHint !== 'explicit') {
3185
+ const nativeMain = await this.detectNativeMainStream(stream, endpointId);
3186
+ stream = nativeMain.stream;
3187
+ if (nativeMain.protocolHint === 'native-main') {
3188
+ protocolHint = 'native-main';
3189
+ }
3190
+ }
3191
+ }
3192
+ return {
3193
+ type: type,
3194
+ stream,
3195
+ remoteNodeId: endpointId,
3196
+ protocolHint,
3197
+ channel,
3198
+ };
3199
+ }
3200
+ hasIncomingChannelListener(channelId) {
3201
+ const normalizedChannelId = channelId.trim();
3202
+ if (!normalizedChannelId) {
3203
+ return false;
3204
+ }
3205
+ return (this.channelStreamListeners?.get(normalizedChannelId)?.size ?? 0) > 0;
3206
+ }
3207
+ hasAnyIncomingChannelListeners() {
3208
+ if (!this.channelStreamListeners) {
3209
+ return false;
3210
+ }
3211
+ for (const listeners of this.channelStreamListeners.values()) {
3212
+ if (listeners.size > 0) {
3213
+ return true;
3214
+ }
3215
+ }
3216
+ return false;
3217
+ }
3218
+ dispatchIncomingChannelListeners(listenerEvent) {
3219
+ const channelId = listenerEvent.channel?.channelId?.trim();
3220
+ if (!channelId) {
3221
+ return false;
3222
+ }
3223
+ const listeners = this.channelStreamListeners?.get(channelId);
3224
+ if (!listeners) {
3225
+ return false;
3226
+ }
3227
+ for (const callback of listeners) {
3228
+ if (callback(listenerEvent) === true) {
3229
+ return true;
3230
+ }
3231
+ }
3232
+ return false;
3233
+ }
3234
+ dispatchIncomingGenericListeners(listenerEvent) {
3235
+ let handled = false;
3236
+ for (const callback of this.streamListeners ?? []) {
3237
+ handled = callback(listenerEvent) === true || handled;
3238
+ }
3239
+ return handled;
3240
+ }
3241
+ async handleUnhandledIncomingStreamEvent(listenerEvent) {
3242
+ const { type, stream, remoteNodeId, protocolHint } = listenerEvent;
3243
+ if (listenerEvent.invalid) {
3244
+ return;
3245
+ }
3246
+ if (type === 'uni') {
3247
+ await this.cancelUnhandledIncomingUniStream(stream, remoteNodeId);
3248
+ return;
3249
+ }
3250
+ if (type === 'bi' && protocolHint === 'native-main') {
3251
+ openRtcDebugInfo('[Client][INCOMING] Handling native desktop main stream', {
3252
+ remoteNodeId,
3253
+ });
3254
+ void this.handleNativeMainStream(stream, remoteNodeId).catch((error) => {
3255
+ console.error('[Client][NATIVE-MAIN] Background handler crashed', {
3256
+ remoteNodeId,
3257
+ error,
3258
+ });
3259
+ });
3260
+ return;
3261
+ }
3262
+ // Legacy Connection handling for FIRST Bidirectional Stream
3263
+ // If we don't have a connection for this peer yet, create one using this stream.
3264
+ // This assumes the first stream is the "main" signaling/control channel.
3265
+ if (type === 'bi' && protocolHint !== 'explicit') {
3266
+ const remoteId = remoteNodeId;
3267
+ const connId = this.getDeterministicConnectionId(remoteId);
3268
+ if (!this.connections.has(connId)) {
3269
+ // New connection!
3270
+ const biStream = stream;
3271
+ if (!this.isBiStreamLike(biStream)) {
3272
+ console.error('[Client][INCOMING] Unable to construct connection from invalid bi stream', {
3273
+ remoteNodeId: remoteId,
3274
+ });
3275
+ return;
3276
+ }
3277
+ const connection = new Connection(connId, this.localNodeId, remoteId, biStream.send.getWriter(), biStream.recv.getReader(), {
3278
+ rtcConfig: this.getRTCConfig(),
3279
+ transportContext: {
3280
+ priorities: this.options.transportPriority || ['webrtc', 'moq', 'iroh'],
3281
+ disableIrohFallback: this.options.disableIrohFallback || false,
3282
+ isMoQReady: () => this.moqState === 'ready',
3283
+ sendMoQ: (data) => this.sendMoQData(remoteId, data)
3284
+ }
3285
+ });
3286
+ this.initializeConnectionTrust(connection, this.knownDeviceIdsByNodeId.get(remoteId) ?? null);
3287
+ this.registerConnection(connection);
3288
+ this.announceConnectionWhenStable(connection);
3289
+ void this.sendTransportHandshake(connection, 'hello').catch((error) => {
3290
+ console.warn('[Client][TRUST] Failed to send incoming handshake', {
3291
+ connectionId: connection.id,
3292
+ remoteNodeId: connection.remoteNodeId,
3293
+ error,
3294
+ });
3295
+ });
3296
+ }
3297
+ }
3298
+ }
3299
+ async handleIncomingStream(incoming) {
3300
+ const listenerEvent = await this.normalizeIncomingStreamEvent(incoming, {
3301
+ inspectChannelEnvelope: this.hasAnyIncomingChannelListeners(),
3302
+ });
3303
+ const handledByChannel = this.dispatchIncomingChannelListeners(listenerEvent);
3304
+ if (handledByChannel) {
3305
+ return;
3306
+ }
3307
+ const handledByGenericListeners = this.dispatchIncomingGenericListeners(listenerEvent);
3308
+ if (handledByGenericListeners) {
3309
+ return;
3310
+ }
3311
+ await this.handleUnhandledIncomingStreamEvent(listenerEvent);
3312
+ }
3313
+ registerConnection(conn) {
3314
+ this.connections.set(conn.id, conn);
3315
+ if (!this.hasRustPeerLifecycleProjection()) {
3316
+ this.applyConnectionTrustProjection(conn);
3317
+ }
3318
+ else {
3319
+ void this.refreshPeerSnapshot().catch(() => { });
3320
+ }
3321
+ conn.onMessage((msg) => {
3322
+ void this.handleMessage(conn, msg);
3323
+ });
3324
+ conn.onDisconnect(() => {
3325
+ this.connections.delete(conn.id);
3326
+ this.connectionTrustStates.delete(conn.id);
3327
+ this.connectionChannelIds.delete(conn.id);
3328
+ // Reflect the transport close immediately so peer-state watchers do
3329
+ // not remain stuck on a stale connected snapshot while the Rust
3330
+ // projection catches up.
3331
+ this.peerLifecycle.removeConnection(conn.id);
3332
+ this.peerLifecycle.updateHealth(conn.id, 'stale', 'closed');
3333
+ if (this.hasRustPeerLifecycleProjection()) {
3334
+ void this.refreshPeerSnapshot().catch(() => { });
3335
+ }
3336
+ const announced = this.announcedConnections.delete(conn.id);
3337
+ if (announced) {
3338
+ this.disconnectionListeners.forEach(cb => cb(conn));
3339
+ }
3340
+ });
3341
+ }
3342
+ syncConnectionPeerLifecycle(conn) {
3343
+ const promotionEligible = this.resolvePromotionEligibility({
3344
+ connectionId: conn.id,
3345
+ nodeId: conn.remoteNodeId,
3346
+ });
3347
+ const trustProjection = this.getConnectionTrustProjection({
3348
+ connectionId: conn.id,
3349
+ nodeId: conn.remoteNodeId,
3350
+ });
3351
+ if (trustProjection) {
3352
+ this.peerLifecycle.upsert({
3353
+ peerId: trustProjection.deviceId ?? undefined,
3354
+ deviceId: trustProjection.deviceId ?? undefined,
3355
+ deviceIdHint: trustProjection.deviceId ?? undefined,
3356
+ nodeId: conn.deviceId,
3357
+ connectionId: conn.id,
3358
+ status: conn.isClosed ? 'closed' : 'connected',
3359
+ health: trustProjection.health,
3360
+ transportState: conn.isClosed ? 'closed' : 'connected',
3361
+ protocolState: trustProjection.protocolState,
3362
+ routable: trustProjection.routable,
3363
+ promotionEligible,
3364
+ error: trustProjection.error,
3365
+ lastSeenAt: Date.now(),
3366
+ });
3367
+ return;
3368
+ }
3369
+ const existing = this.peerLifecycle.get(conn.id) ||
3370
+ this.peerLifecycle
3371
+ .list()
3372
+ .find((peer) => peer.connectionIds.includes(conn.id) || peer.nodeId === conn.deviceId);
3373
+ const peerId = existing?.peerId || existing?.deviceId || existing?.deviceIdHint;
3374
+ const deviceId = existing?.deviceId;
3375
+ const deviceIdHint = existing?.deviceIdHint;
3376
+ // Do not synthesize a public peer identity from transport metadata.
3377
+ if (!peerId && !deviceId && !deviceIdHint) {
3378
+ return;
3379
+ }
3380
+ this.peerLifecycle.upsert({
3381
+ peerId,
3382
+ deviceId,
3383
+ deviceIdHint,
3384
+ nodeId: conn.deviceId,
3385
+ connectionId: conn.id,
3386
+ status: conn.isClosed ? 'closed' : 'connected',
3387
+ health: conn.isClosed ? 'stale' : 'healthy',
3388
+ promotionEligible,
3389
+ lastSeenAt: Date.now(),
3390
+ });
3391
+ }
3392
+ getPeerCandidates(id) {
3393
+ const peer = this.peerLifecycle.get(id);
3394
+ const candidates = new Set();
3395
+ const values = [
3396
+ id,
3397
+ peer?.peerId,
3398
+ peer?.deviceId,
3399
+ peer?.nodeId,
3400
+ peer?.connectionId,
3401
+ ...(peer?.connectionIds ?? []),
3402
+ ];
3403
+ values.forEach((value) => {
3404
+ if (!value)
3405
+ return;
3406
+ const normalized = value.trim().toLowerCase();
3407
+ if (normalized) {
3408
+ candidates.add(normalized);
3409
+ }
3410
+ });
3411
+ return candidates;
3412
+ }
3413
+ // --- Listeners ---
3414
+ async updatePresence(isOnline = true, ttlMs = 300000, metadata, ticketOverride) {
3415
+ if (!this.wasmClient)
3416
+ return;
3417
+ const userId = this.currentUser?.id;
3418
+ const isPersonalWithSpaceKey = (this.options.discoveryMode === undefined || this.options.discoveryMode === 'personal') &&
3419
+ typeof this.options.spaceKey === 'string' &&
3420
+ this.options.spaceKey.trim().length > 0;
3421
+ if (!userId && !isPersonalWithSpaceKey)
3422
+ return;
3423
+ const nodeId = this.localNodeId;
3424
+ if (!nodeId)
3425
+ return;
3426
+ const ticket = await this.resolvePresenceTicket(ticketOverride);
3427
+ if (!ticket || !ticket.trim()) {
3428
+ throw new Error('updatePresence requires a valid endpoint ticket');
3429
+ }
3430
+ const name = this.options.deviceName || "Browser Device";
3431
+ // Merge deviceId into metadata if not present
3432
+ let finalMetadata = metadata;
3433
+ const currentDeviceId = (await this.getLocalDeviceId()) || this.deviceId;
3434
+ if (!currentDeviceId) {
3435
+ throw new Error('updatePresence requires a stable local device ID');
3436
+ }
3437
+ try {
3438
+ const metaObj = metadata ? JSON.parse(metadata) : {};
3439
+ if (!metaObj.deviceId) {
3440
+ metaObj.deviceId = currentDeviceId;
3441
+ finalMetadata = JSON.stringify(metaObj);
3442
+ }
3443
+ }
3444
+ catch {
3445
+ finalMetadata = JSON.stringify({
3446
+ deviceId: currentDeviceId,
3447
+ rawMetadata: metadata,
3448
+ });
3449
+ }
3450
+ await this.signaling.updatePresence(nodeId, ticket, isOnline, ttlMs, finalMetadata);
3451
+ }
3452
+ onConnection(callback) {
3453
+ this.connectionListeners.push(callback);
3454
+ return () => {
3455
+ this.connectionListeners = this.connectionListeners.filter((cb) => cb !== callback);
3456
+ };
3457
+ }
3458
+ onDisconnection(callback) {
3459
+ this.disconnectionListeners.push(callback);
3460
+ return () => {
3461
+ this.disconnectionListeners = this.disconnectionListeners.filter((cb) => cb !== callback);
3462
+ };
3463
+ }
3464
+ onMessage(callback) {
3465
+ this.messageListeners.push(callback);
3466
+ return () => {
3467
+ this.messageListeners = this.messageListeners.filter((cb) => cb !== callback);
3468
+ };
3469
+ }
3470
+ // New: listen for raw headers
3471
+ onIncomingStream(callback) {
3472
+ this.streamListeners.push(callback);
3473
+ return () => {
3474
+ this.streamListeners = this.streamListeners.filter((cb) => cb !== callback);
3475
+ };
3476
+ }
3477
+ registerChannel(descriptor) {
3478
+ const channelId = descriptor.id.trim();
3479
+ if (!channelId) {
3480
+ throw new Error('Channel registration requires a non-empty channel id.');
3481
+ }
3482
+ this.registeredChannels.set(channelId, {
3483
+ ...descriptor,
3484
+ id: channelId,
3485
+ });
3486
+ }
3487
+ unregisterChannel(channelId) {
3488
+ const normalizedChannelId = channelId.trim();
3489
+ if (!normalizedChannelId) {
3490
+ return;
3491
+ }
3492
+ this.registeredChannels.delete(normalizedChannelId);
3493
+ this.channelStreamListeners.delete(normalizedChannelId);
3494
+ }
3495
+ listChannels() {
3496
+ return Array.from(this.registeredChannels.values());
3497
+ }
3498
+ onIncomingChannelStream(channelId, callback) {
3499
+ const normalizedChannelId = channelId.trim();
3500
+ if (!normalizedChannelId) {
3501
+ throw new Error('Channel stream listener requires a non-empty channel id.');
3502
+ }
3503
+ const listeners = this.channelStreamListeners.get(normalizedChannelId) ?? new Set();
3504
+ listeners.add(callback);
3505
+ this.channelStreamListeners.set(normalizedChannelId, listeners);
3506
+ return () => {
3507
+ const existing = this.channelStreamListeners.get(normalizedChannelId);
3508
+ if (!existing) {
3509
+ return;
3510
+ }
3511
+ existing.delete(callback);
3512
+ if (existing.size === 0) {
3513
+ this.channelStreamListeners.delete(normalizedChannelId);
3514
+ }
3515
+ };
3516
+ }
3517
+ onRoomJoinRequest(callback) {
3518
+ this.roomRequestListeners.push(callback);
3519
+ return () => {
3520
+ this.roomRequestListeners = this.roomRequestListeners.filter((cb) => cb !== callback);
3521
+ };
3522
+ }
3523
+ // --- Signaling (for WebRTC Upgrade) ---
3524
+ // Deprecated: Connection handles its own signaling via #pluto-signal messages.
3525
+ // Keeping hooks if user wants to send custom signals (e.g. app specific)
3526
+ // --- MoQ Data Strategy ---
3527
+ async createMoQDataTracks() {
3528
+ if (!this._moqSession || this._moqState !== 'ready')
3529
+ return;
3530
+ try {
3531
+ // 1. Broadcast Track
3532
+ await this._moqSession.publisher.createTrack(this.localNodeId, "data/broadcast");
3533
+ openRtcDebugLog('[Client] MoQ Data Track created: data / broadcast');
3534
+ }
3535
+ catch (e) {
3536
+ console.warn("Failed to create MoQ broadcast track", e);
3537
+ }
3538
+ }
3539
+ /**
3540
+ * Send data via MoQ.
3541
+ * @param peerId Target Node ID (for 1:1) or null (for Broadcast)
3542
+ * @param data Payload
3543
+ */
3544
+ async sendMoQData(peerId, data) {
3545
+ if (this._moqState !== 'ready' || !this.moq)
3546
+ throw new Error("MoQ not ready");
3547
+ const trackName = peerId ? `data / ${peerId} ` : `data / broadcast`;
3548
+ // Ensure track exists (create on demand for 1:1)
3549
+ // Check if we are already publishing this track?
3550
+ // MoQPublisher.createTrack checks internally and returns existing if so.
3551
+ // We'll trust createTrack's cache logic we added earlier.
3552
+ const trackPub = await this.moq.publisher.createTrack(this.localNodeId, trackName);
3553
+ if (!trackPub)
3554
+ return; // Should not happen if logic works or tracks exist
3555
+ // Push data (single object group)
3556
+ // We need a timestamp?
3557
+ const timestamp = Date.now();
3558
+ await trackPub.push(data, { isKey: true, timestamp });
3559
+ // Do NOT close track for 1:1, keep it open for session?
3560
+ // Actually, if we keep opening tracks, we might hit limits?
3561
+ // MoQPublisher caches tracks. It's fine.
3562
+ }
3563
+ async handleMessage(conn, msg) {
3564
+ // Intercept Handshake to detect MoQ support
3565
+ if (msg && typeof msg === 'object' && msg.type === 'handshake') {
3566
+ const handshake = msg;
3567
+ // Session grant gate: Rust/wasm validation decides whether the hello is allowed.
3568
+ if (handshake.action === 'hello' || !handshake.action) {
3569
+ const result = await this.validateIncomingSessionToken(handshake.sessionToken ?? '', conn.id);
3570
+ if (!result.ok) {
3571
+ console.warn('[Client][SESSION-TOKEN] Rejecting connection — invalid session token', {
3572
+ connectionId: conn.id,
3573
+ remoteNodeId: conn.remoteNodeId,
3574
+ reason: result.reason,
3575
+ hasToken: !!handshake.sessionToken,
3576
+ });
3577
+ void conn.disconnect();
3578
+ return;
3579
+ }
3580
+ }
3581
+ if (msg.moq && this.moqState === 'ready') {
3582
+ openRtcDebugLog(`[Client] Peer ${conn.deviceId.substring(0, 6)} supports MoQ. Upgrading...`);
3583
+ conn.peerMoQReady = true;
3584
+ // Subscribe to Peer's tracks
3585
+ if (this.moq) {
3586
+ const peerId = conn.deviceId;
3587
+ // Subscribe to their broadcast and DM track
3588
+ // Format: [PeerID]/data/broadcast and [PeerID]/data/[MyNodeID]
3589
+ this.moq.subscriber.subscribe(peerId, "data/broadcast");
3590
+ this.moq.subscriber.subscribe(peerId, `data / ${this.localNodeId} `);
3591
+ }
3592
+ // If we are Responder, we should reply with Handshake containing moq: true (if not already sent)
3593
+ // But connect() sends it initially.
3594
+ // If we received an inbound connection, we haven't sent Handshake yet!
3595
+ // We should send a handshake reply on incoming connections?
3596
+ // The current implementation of accept (handleIncomingStream -> Connection) doesn't auto-send handshake.
3597
+ // We should probably reply.
3598
+ // To avoid loops: assume if we initiated, we sent handshake.
3599
+ // If we accepted, we respond.
3600
+ // Or just always reply if we haven't?
3601
+ }
3602
+ // Assign channel from handshake if present
3603
+ this.assignConnectionChannel(conn, handshake.channelId ?? null);
3604
+ if (handshake.transportTrust || this.requiresManagedTransportTrust()) {
3605
+ void (async () => {
3606
+ const action = handshake.action ?? 'hello';
3607
+ const payload = handshake.transportTrust ?? null;
3608
+ const trustState = this.initializeConnectionTrust(conn);
3609
+ if (payload?.deviceId
3610
+ && !trustState.expectedDeviceId
3611
+ && this.getKnownDevice(payload.deviceId, conn.remoteNodeId)) {
3612
+ trustState.expectedDeviceId = payload.deviceId;
3613
+ }
3614
+ if (action === 'hello') {
3615
+ const responseChallenge = typeof payload?.challenge === 'string' ? payload.challenge : null;
3616
+ if (!responseChallenge && trustState.required) {
3617
+ await this.enforceConnectionTrustFailure(conn, 'missing-transport-trust-challenge');
3618
+ return;
3619
+ }
3620
+ await this.sendTransportHandshake(conn, 'response', responseChallenge);
3621
+ }
3622
+ else if (action === 'response' || action === 'ack') {
3623
+ const verification = await this.verifyRemoteTransportTrust(conn, payload);
3624
+ if (!verification.verified) {
3625
+ if (trustState.required) {
3626
+ await this.enforceConnectionTrustFailure(conn, verification.reason ?? 'remote-transport-proof-invalid');
3627
+ }
3628
+ return;
3629
+ }
3630
+ this.markConnectionTrustVerified(conn, verification.deviceId);
3631
+ if (action === 'response' && typeof payload?.challenge === 'string') {
3632
+ await this.sendTransportHandshake(conn, 'ack', payload.challenge);
3633
+ }
3634
+ }
3635
+ })().catch((error) => {
3636
+ console.warn('[Client][TRUST] Handshake processing failed', {
3637
+ connectionId: conn.id,
3638
+ remoteNodeId: conn.remoteNodeId,
3639
+ error,
3640
+ });
3641
+ });
3642
+ }
3643
+ else if ((handshake.action === 'hello' || !handshake.action) && handshake.sessionToken) {
3644
+ // Token-gated connection without transport trust (e.g. share session).
3645
+ // Send a response handshake so the peer knows it was accepted.
3646
+ void this.sendTransportHandshake(conn, 'response').catch((error) => {
3647
+ console.warn('[Client][SESSION-TOKEN] Failed to send response handshake', {
3648
+ connectionId: conn.id,
3649
+ error,
3650
+ });
3651
+ });
3652
+ }
3653
+ }
3654
+ // We no longer intercept #signal here, Connection does it before emitting message event.
3655
+ this.messageListeners.forEach(cb => cb(conn, msg));
3656
+ }
3657
+ getRTCConfig() {
3658
+ const config = this.options.transports?.webrtc || undefined;
3659
+ // If strict mode is on, we might need to synthesize a config if it's missing?
3660
+ // But usually transports are defined.
3661
+ if (!config)
3662
+ return undefined;
3663
+ if (this.options.strictMode || config.privacyMode) {
3664
+ return {
3665
+ ...config,
3666
+ iceTransportPolicy: 'relay'
3667
+ };
3668
+ }
3669
+ return config;
3670
+ }
3671
+ }
3672
+ Client.CHANNEL_ENVELOPE_MAGIC = 0x7f;
3673
+ Client.CHANNEL_ENVELOPE_VERSION = 0x01;
3674
+ Client.CHANNEL_ENVELOPE_HEADER_BYTES = 1 + 1 + 2 + 2;
3675
+ Client.EXPLICIT_PROTOCOL_BYTE = 0x02;
3676
+ Client.NATIVE_MAIN_PROTOCOL_BYTE = 0x00;
3677
+ Client.NATIVE_MAIN_LABEL = 'main';
3678
+ Client.NATIVE_MAIN_MAX_LABEL_BYTES = 64;
3679
+ Client.NATIVE_MAIN_PROTOCOL_VERSION = 1;
3680
+ Client.CONNECTION_STATE_CHANGED_EVENT = 'connection-state-changed';
3681
+ Client.INCOMING_STREAM_RETRY_MIN_DELAY_MS = 250;
3682
+ Client.INCOMING_STREAM_RETRY_MAX_DELAY_MS = 2000;
3683
+ Client.DEFAULT_PEER_CHANNEL_ID = 'system/peer-default';
3684
+ Client.SHARE_FILE_CHANNEL_ID = 'share/explicit-file';
3685
+ Client.SHARE_CONTROL_CHANNEL_ID = 'share/explicit-control';
3686
+ Client.incomingStreamHubs = new WeakMap();
3687
+ Client.CONNECTION_STABLE_WINDOW_MS = 600;
3688
+ Client.CONNECTION_STABLE_TIMEOUT_MS = 2000;
3689
+ Client.CONNECTION_STABLE_POLL_MS = 120;