@affectively/dash 5.3.0 → 5.4.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.
@@ -11,11 +11,21 @@ import { defaultAeonConfig } from './aeon/config.js';
11
11
  import { DashDeltaAdapter } from './aeon/delta-adapter.js';
12
12
  import { DashPresenceAdapter } from './aeon/presence-adapter.js';
13
13
  import { DashOfflineAdapter } from './aeon/offline-adapter.js';
14
+ import { CompressionEngine, AdaptiveCompressionOptimizer, BatchTimingOptimizer, } from '@affectively/aeon';
14
15
  // Message types
15
16
  const MessageType = {
16
17
  Sync: 0,
17
18
  Awareness: 1,
18
19
  Delta: 2, // New: Aeon delta-compressed sync
20
+ Compressed: 3,
21
+ Encrypted: 4,
22
+ Batch: 5,
23
+ Capabilities: 6,
24
+ };
25
+ const CapabilityFlag = {
26
+ Compression: 1,
27
+ Encryption: 2,
28
+ Batch: 4,
19
29
  };
20
30
  export class HybridProvider extends Observable {
21
31
  doc;
@@ -23,23 +33,88 @@ export class HybridProvider extends Observable {
23
33
  wt = null;
24
34
  connected = false;
25
35
  url;
36
+ activeRelayUrl;
26
37
  roomName;
27
38
  awareness;
28
39
  writer = null;
29
40
  // "High Frequency" mode uses WebTransport for ephemeral data
30
41
  highFrequencyMode = false;
42
+ // Connection state machine - prevents race conditions
43
+ connectionState = 'disconnected';
44
+ // Exponential backoff state
45
+ reconnectAttempts = 0;
46
+ maxReconnectDelay = 30000; // 30s max
47
+ baseReconnectDelay = 1000; // 1s base
48
+ reconnectTimeout = null;
49
+ // Relay discovery state
50
+ relayDiscovery;
51
+ discoveredRelayUrls = [];
52
+ relayDiscoveryCursor = 0;
53
+ lastRelayDiscoveryAt = null;
54
+ relayHealth = new Map();
55
+ // Performance pipeline (Aeon optimization)
56
+ relayPerformance;
57
+ compressionEngine = null;
58
+ adaptiveCompression = null;
59
+ batchTiming = null;
60
+ outboundQueue = [];
61
+ outboundQueueBytes = 0;
62
+ batchFlushTimeout = null;
63
+ flushingBatch = false;
64
+ lastCompressionRatio = null;
65
+ // Privacy pipeline (room-scoped encryption)
66
+ relayPrivacy;
67
+ roomCryptoKey = null;
68
+ roomCryptoKeyReady = null;
69
+ localCapabilitiesBitmask = 0;
70
+ peerCapabilities = new Map();
71
+ protocolVersion = 2;
72
+ lastCapabilityAdvertisedAt = null;
31
73
  // Aeon integration
32
74
  aeonConfig;
33
75
  deltaAdapter = null;
34
76
  presenceAdapter = null;
35
77
  offlineAdapter = null;
36
- constructor(url, roomName, doc, { awareness = new awarenessProtocol.Awareness(doc), aeonConfig = defaultAeonConfig, } = {}) {
78
+ constructor(url, roomName, doc, { awareness = new awarenessProtocol.Awareness(doc), aeonConfig = defaultAeonConfig, relayDiscovery = {}, relayPerformance = {}, relayPrivacy = {}, } = {}) {
37
79
  super();
38
80
  this.url = url;
81
+ this.activeRelayUrl = url;
39
82
  this.roomName = roomName;
40
83
  this.doc = doc;
41
84
  this.awareness = awareness;
42
85
  this.aeonConfig = aeonConfig;
86
+ this.relayDiscovery = {
87
+ enabled: false,
88
+ bootstrapUrls: [],
89
+ discoveryPath: '/discovery',
90
+ refreshIntervalMs: 60_000,
91
+ requestTimeoutMs: 2_500,
92
+ maxCandidates: 8,
93
+ dhtQueryEnabled: true,
94
+ ...relayDiscovery,
95
+ };
96
+ this.relayPerformance = {
97
+ enableAdaptiveCompression: true,
98
+ enableBatching: true,
99
+ compressionThresholdBytes: 512,
100
+ maxBatchDelayMs: 12,
101
+ maxBatchSizeBytes: 64 * 1024,
102
+ ...relayPerformance,
103
+ };
104
+ this.relayPrivacy = {
105
+ enabled: false,
106
+ ...relayPrivacy,
107
+ };
108
+ if (this.relayPerformance.enableAdaptiveCompression) {
109
+ this.compressionEngine = new CompressionEngine('gzip');
110
+ this.adaptiveCompression = new AdaptiveCompressionOptimizer();
111
+ this.batchTiming = new BatchTimingOptimizer();
112
+ this.seedAdaptiveNetworkProfile();
113
+ }
114
+ if (this.relayPrivacy.enabled) {
115
+ this.roomCryptoKeyReady = this.initializeRoomCryptoKey();
116
+ }
117
+ this.localCapabilitiesBitmask = this.computeLocalCapabilities();
43
118
  // Initialize Aeon adapters
44
119
  if (aeonConfig.enableDeltaSync) {
45
120
  this.deltaAdapter = new DashDeltaAdapter(roomName, aeonConfig.deltaThreshold);
@@ -58,42 +133,373 @@ export class HybridProvider extends Observable {
58
133
  this.connectWebSocket();
59
134
  }
60
135
  connectWebSocket() {
61
- // Protocol: switch http/https to ws/wss
62
- // Protocol: switch http/https to ws/wss
63
- // 1. Get Token
64
- // Ideally await this before connecting, but construction is sync.
65
- // We'll make connect async internally or fire-and-forget with token wait.
66
- this.getAuthToken().then(token => {
67
- const wsUrl = this.url.replace(/^http/, 'ws') + '/sync/' + this.roomName + '?token=' + token;
136
+ // Guard: prevent multiple simultaneous connection attempts
137
+ if (this.connectionState === 'connecting' || this.connectionState === 'connected') {
138
+ return;
139
+ }
140
+ this.connectionState = 'connecting';
141
+ this.emit('status', [{ status: 'connecting' }]);
142
+ const connectionPromise = Promise.all([
143
+ this.getAuthToken(),
144
+ this.resolveRelayUrl(),
145
+ ]);
146
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Auth token timeout (10s)')), 10000));
147
+ Promise.race([connectionPromise, timeoutPromise])
148
+ .then(result => {
149
+ if (!result) {
150
+ throw new Error('Connection setup timed out');
151
+ }
152
+ const [token, relayUrl] = result;
153
+ // Validate token was obtained
154
+ if (!token) {
155
+ throw new Error('Auth token is empty or undefined');
156
+ }
157
+ // Double-check we weren't destroyed while waiting
158
+ if (this.connectionState === 'disconnected') {
159
+ return;
160
+ }
161
+ this.activeRelayUrl = relayUrl;
162
+ this.peerCapabilities.clear();
163
+ const wsUrl = relayUrl.replace(/^http/, 'ws') + '/sync/' + this.roomName + '?token=' + token;
164
+ const connectionStartedAt = Date.now();
68
165
  this.ws = new WebSocket(wsUrl);
69
166
  this.ws.binaryType = 'arraybuffer';
70
167
  this.ws.onopen = () => {
168
+ this.recordRelaySuccess(this.activeRelayUrl, Date.now() - connectionStartedAt);
169
+ this.connectionState = 'connected';
71
170
  this.connected = true;
171
+ this.reconnectAttempts = 0; // Reset backoff on successful connection
72
172
  this.emit('status', [{ status: 'connected' }]);
73
173
  this.sendSyncStep1();
174
+ this.sendCapabilitiesAdvertisement(true).catch((err) => {
175
+ console.warn('[Dash] Failed to advertise capabilities:', err);
176
+ });
177
+ // Process any queued offline updates
178
+ if (this.offlineAdapter) {
179
+ this.processOfflineQueue().catch(err => {
180
+ console.warn('[Dash] Failed to process offline queue:', err);
181
+ });
182
+ }
74
183
  };
75
- // ... rest of event handlers
76
184
  this.ws.onmessage = (event) => {
77
- this.handleMessage(new Uint8Array(event.data));
185
+ this.handleMessage(new Uint8Array(event.data)).catch((err) => {
186
+ console.error('[Dash] Failed to handle message:', err);
187
+ });
78
188
  };
79
- this.ws.onclose = () => {
189
+ this.ws.onclose = (event) => {
190
+ if (event.code !== 1000) {
191
+ this.recordRelayFailure(this.activeRelayUrl);
192
+ }
80
193
  this.connected = false;
194
+ this.connectionState = 'disconnected';
81
195
  this.ws = null;
82
- this.emit('status', [{ status: 'disconnected' }]);
83
- // Reconnect logic
84
- setTimeout(() => this.connectWebSocket(), 3000);
196
+ this.emit('status', [{ status: 'disconnected', code: event.code, reason: event.reason }]);
197
+ // Schedule reconnect with exponential backoff + jitter
198
+ this.scheduleReconnect();
85
199
  };
86
200
  this.ws.onerror = (err) => {
87
- console.error('WebSocket error', err);
201
+ this.recordRelayFailure(this.activeRelayUrl);
202
+ console.error('[Dash] WebSocket error:', err);
203
+ this.emit('error', [{ type: 'websocket', error: err }]);
204
+ // Note: onerror is always followed by onclose, so reconnect happens there
88
205
  };
206
+ })
207
+ .catch(err => {
208
+ console.error('[Dash] Connection failed:', err.message);
209
+ this.connectionState = 'disconnected';
210
+ this.emit('error', [{ type: 'auth', error: err }]);
211
+ // Schedule reconnect even on auth failure (token might refresh)
212
+ this.scheduleReconnect();
89
213
  });
90
214
  }
215
+ /**
216
+ * Schedule reconnection with exponential backoff + jitter
217
+ * Prevents thundering herd on network flapping
218
+ */
219
+ scheduleReconnect() {
220
+ // Clear any existing timeout
221
+ if (this.reconnectTimeout) {
222
+ clearTimeout(this.reconnectTimeout);
223
+ }
224
+ this.reconnectAttempts++;
225
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (max)
226
+ const exponentialDelay = Math.min(this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
227
+ // Add jitter: ±25% to prevent thundering herd
228
+ const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
229
+ const delay = Math.round(exponentialDelay + jitter);
230
+ console.log(`[Dash] Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
231
+ this.connectionState = 'reconnecting';
232
+ this.emit('status', [{ status: 'reconnecting', attempt: this.reconnectAttempts, delay }]);
233
+ this.reconnectTimeout = setTimeout(() => {
234
+ this.connectionState = 'disconnected'; // Reset for new attempt
235
+ this.connectWebSocket();
236
+ }, delay);
237
+ }
238
+ seedAdaptiveNetworkProfile() {
239
+ if (!this.adaptiveCompression || typeof navigator === 'undefined') {
240
+ return;
241
+ }
242
+ const nav = navigator;
243
+ const downlinkMbps = nav.connection?.downlink ?? 5;
244
+ const rtt = nav.connection?.rtt ?? 50;
245
+ this.adaptiveCompression.updateNetworkConditions(Math.round(downlinkMbps * 1000), rtt, true);
246
+ this.batchTiming?.recordNetworkMeasurement(rtt, downlinkMbps);
247
+ }
248
+ async initializeRoomCryptoKey() {
249
+ if (!this.relayPrivacy.enabled) {
250
+ this.roomCryptoKey = null;
251
+ return;
252
+ }
253
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
254
+ console.warn('[Dash] crypto.subtle unavailable; disabling relay privacy');
255
+ this.relayPrivacy.enabled = false;
256
+ this.roomCryptoKey = null;
257
+ this.localCapabilitiesBitmask = this.computeLocalCapabilities();
258
+ return;
259
+ }
260
+ const source = this.relayPrivacy.roomKey;
261
+ if (!source) {
262
+ console.warn('[Dash] relayPrivacy.enabled requires roomKey; disabling privacy');
263
+ this.relayPrivacy.enabled = false;
264
+ this.roomCryptoKey = null;
265
+ this.localCapabilitiesBitmask = this.computeLocalCapabilities();
266
+ return;
267
+ }
268
+ const keyBytes = await deriveRoomKeyBytes(source);
269
+ this.roomCryptoKey = await crypto.subtle.importKey('raw', toArrayBuffer(keyBytes), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
270
+ this.localCapabilitiesBitmask = this.computeLocalCapabilities();
271
+ }
272
+ async getRoomCryptoKey() {
273
+ if (!this.relayPrivacy.enabled) {
274
+ return null;
275
+ }
276
+ if (this.roomCryptoKey) {
277
+ return this.roomCryptoKey;
278
+ }
279
+ if (!this.roomCryptoKeyReady) {
280
+ this.roomCryptoKeyReady = this.initializeRoomCryptoKey();
281
+ }
282
+ await this.roomCryptoKeyReady;
283
+ return this.roomCryptoKey;
284
+ }
285
+ computeLocalCapabilities() {
286
+ let bitmask = 0;
287
+ if (this.relayPerformance.enableAdaptiveCompression) {
288
+ bitmask |= CapabilityFlag.Compression;
289
+ }
290
+ if (this.relayPrivacy.enabled) {
291
+ bitmask |= CapabilityFlag.Encryption;
292
+ }
293
+ if (this.relayPerformance.enableBatching) {
294
+ bitmask |= CapabilityFlag.Batch;
295
+ }
296
+ return bitmask;
297
+ }
298
+ getKnownRemoteClientIds() {
299
+ const ids = [];
300
+ this.awareness.getStates().forEach((_state, clientId) => {
301
+ if (clientId !== this.awareness.clientID) {
302
+ ids.push(clientId);
303
+ }
304
+ });
305
+ return ids;
306
+ }
307
+ isCapabilityEnabled(flag) {
308
+ if ((this.localCapabilitiesBitmask & flag) === 0) {
309
+ return false;
310
+ }
311
+ const remoteIds = this.getKnownRemoteClientIds();
312
+ if (remoteIds.length === 0) {
313
+ return false;
314
+ }
315
+ return remoteIds.every((clientId) => {
316
+ const capabilities = this.peerCapabilities.get(clientId) ?? 0;
317
+ return (capabilities & flag) !== 0;
318
+ });
319
+ }
320
+ capabilityListFromBitmask(bitmask) {
321
+ const list = [];
322
+ if ((bitmask & CapabilityFlag.Compression) !== 0)
323
+ list.push('compression');
324
+ if ((bitmask & CapabilityFlag.Encryption) !== 0)
325
+ list.push('encryption');
326
+ if ((bitmask & CapabilityFlag.Batch) !== 0)
327
+ list.push('batch');
328
+ return list;
329
+ }
330
+ async sendCapabilitiesAdvertisement(force = false) {
331
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
332
+ return;
333
+ }
334
+ const now = Date.now();
335
+ if (!force && this.lastCapabilityAdvertisedAt && now - this.lastCapabilityAdvertisedAt < 5_000) {
336
+ return;
337
+ }
338
+ const encoder = encoding.createEncoder();
339
+ encoding.writeVarUint(encoder, MessageType.Capabilities);
340
+ encoding.writeVarUint(encoder, this.protocolVersion);
341
+ encoding.writeVarUint(encoder, this.awareness.clientID);
342
+ encoding.writeVarUint(encoder, this.localCapabilitiesBitmask);
343
+ const payload = encoding.toUint8Array(encoder);
344
+ await this.sendSingleFrame(payload, []);
345
+ this.lastCapabilityAdvertisedAt = now;
346
+ }
347
+ reconcilePeerCapabilities(removed = []) {
348
+ for (const clientId of removed) {
349
+ this.peerCapabilities.delete(clientId);
350
+ }
351
+ const livePeers = new Set(this.getKnownRemoteClientIds());
352
+ for (const clientId of this.peerCapabilities.keys()) {
353
+ if (!livePeers.has(clientId)) {
354
+ this.peerCapabilities.delete(clientId);
355
+ }
356
+ }
357
+ }
91
358
  async getAuthToken() {
92
- // Lazy load auth to avoid circular dependency issues if any
93
- const { auth } = await import('../auth/manager.js');
94
- // For MVP, we assume the Relay allows any DID to connect if they sign it.
95
- // In prod, you'd know the Relay's DID.
96
- return auth.issueRoomToken("did:web:relay.buley.dev", this.roomName);
359
+ try {
360
+ // Lazy load auth to avoid circular dependency issues if any
361
+ const { auth } = await import('../auth/manager.js');
362
+ // For MVP, we assume the Relay allows any DID to connect if they sign it.
363
+ // In prod, you'd know the Relay's DID.
364
+ const token = await auth.issueRoomToken("did:web:relay.buley.dev", this.roomName);
365
+ return token || 'dash-anonymous-token';
366
+ }
367
+ catch {
368
+ // Graceful fallback for environments that don't initialize auth yet.
369
+ return 'dash-anonymous-token';
370
+ }
371
+ }
372
+ async resolveRelayUrl() {
373
+ if (!this.relayDiscovery.enabled) {
374
+ return this.activeRelayUrl;
375
+ }
376
+ const refreshMs = this.relayDiscovery.refreshIntervalMs ?? 60_000;
377
+ const shouldRefresh = this.discoveredRelayUrls.length === 0 ||
378
+ this.lastRelayDiscoveryAt === null ||
379
+ Date.now() - this.lastRelayDiscoveryAt > refreshMs;
380
+ if (shouldRefresh) {
381
+ this.discoveredRelayUrls = await this.discoverRelayUrls();
382
+ this.relayDiscoveryCursor = 0;
383
+ this.lastRelayDiscoveryAt = Date.now();
384
+ }
385
+ if (this.discoveredRelayUrls.length === 0) {
386
+ return this.activeRelayUrl;
387
+ }
388
+ const rankedRelays = this.rankRelayUrls(this.discoveredRelayUrls);
389
+ const idx = this.relayDiscoveryCursor % rankedRelays.length;
390
+ this.relayDiscoveryCursor += 1;
391
+ return rankedRelays[idx];
392
+ }
393
+ async discoverRelayUrls() {
394
+ const seedUrls = [
395
+ this.activeRelayUrl,
396
+ ...(this.relayDiscovery.bootstrapUrls ?? []),
397
+ ];
398
+ const uniqueSeeds = dedupeRelayUrls(seedUrls);
399
+ const discoveryPath = (this.relayDiscovery.discoveryPath || '/discovery').replace(/\/+$/, '');
400
+ const targetPeerId = normalizePeerId(this.relayDiscovery.targetPeerId || this.roomName);
401
+ const discoveryRequests = uniqueSeeds.flatMap(seed => {
402
+ const requests = [
403
+ this.fetchRelayCandidates(`${seed}${discoveryPath}/bootstrap`),
404
+ ];
405
+ if (this.relayDiscovery.dhtQueryEnabled !== false) {
406
+ requests.push(this.fetchRelayCandidates(`${seed}${discoveryPath}/peers?target=${encodeURIComponent(targetPeerId)}&k=${this.relayDiscovery.maxCandidates ?? 8}`));
407
+ }
408
+ return requests;
409
+ });
410
+ const results = await Promise.allSettled(discoveryRequests);
411
+ const discovered = [this.activeRelayUrl];
412
+ for (const result of results) {
413
+ if (result.status === 'fulfilled') {
414
+ discovered.push(...result.value);
415
+ }
416
+ }
417
+ return dedupeRelayUrls(discovered).slice(0, this.relayDiscovery.maxCandidates ?? 8);
418
+ }
419
+ async fetchRelayCandidates(url) {
420
+ const timeoutMs = this.relayDiscovery.requestTimeoutMs ?? 2_500;
421
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
422
+ const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
423
+ const startedAt = Date.now();
424
+ try {
425
+ const response = await fetch(url, {
426
+ method: 'GET',
427
+ signal: controller?.signal,
428
+ });
429
+ if (!response.ok) {
430
+ const seedRelay = getRelayOriginFromDiscoveryUrl(url);
431
+ if (seedRelay)
432
+ this.recordRelayFailure(seedRelay);
433
+ return [];
434
+ }
435
+ const payload = await response.json();
436
+ const seedRelay = getRelayOriginFromDiscoveryUrl(url);
437
+ if (seedRelay) {
438
+ this.recordRelaySuccess(seedRelay, Date.now() - startedAt);
439
+ }
440
+ return extractRelayUrls(payload);
441
+ }
442
+ catch {
443
+ const seedRelay = getRelayOriginFromDiscoveryUrl(url);
444
+ if (seedRelay)
445
+ this.recordRelayFailure(seedRelay);
446
+ return [];
447
+ }
448
+ finally {
449
+ if (timeout) {
450
+ clearTimeout(timeout);
451
+ }
452
+ }
453
+ }
454
+ rankRelayUrls(relays) {
455
+ return [...relays].sort((left, right) => this.scoreRelay(left) - this.scoreRelay(right));
456
+ }
457
+ scoreRelay(relayUrl) {
458
+ const health = this.relayHealth.get(relayUrl);
459
+ if (!health) {
460
+ return 500;
461
+ }
462
+ const failurePenalty = health.failures * 250;
463
+ const successBonus = Math.min(100, health.successes * 10);
464
+ return health.avgLatencyMs + failurePenalty - successBonus;
465
+ }
466
+ recordRelaySuccess(relayUrl, latencyMs) {
467
+ const current = this.relayHealth.get(relayUrl);
468
+ if (!current) {
469
+ this.relayHealth.set(relayUrl, {
470
+ successes: 1,
471
+ failures: 0,
472
+ avgLatencyMs: Math.max(1, latencyMs),
473
+ lastUpdatedAt: Date.now(),
474
+ });
475
+ return;
476
+ }
477
+ const nextSuccesses = current.successes + 1;
478
+ const nextAvg = Math.round((current.avgLatencyMs * current.successes + latencyMs) / nextSuccesses);
479
+ this.relayHealth.set(relayUrl, {
480
+ successes: nextSuccesses,
481
+ failures: current.failures,
482
+ avgLatencyMs: Math.max(1, nextAvg),
483
+ lastUpdatedAt: Date.now(),
484
+ });
485
+ }
486
+ recordRelayFailure(relayUrl) {
487
+ const current = this.relayHealth.get(relayUrl);
488
+ if (!current) {
489
+ this.relayHealth.set(relayUrl, {
490
+ successes: 0,
491
+ failures: 1,
492
+ avgLatencyMs: 800,
493
+ lastUpdatedAt: Date.now(),
494
+ });
495
+ return;
496
+ }
497
+ this.relayHealth.set(relayUrl, {
498
+ ...current,
499
+ failures: current.failures + 1,
500
+ avgLatencyMs: Math.min(5_000, current.avgLatencyMs + 100),
501
+ lastUpdatedAt: Date.now(),
502
+ });
97
503
  }
98
504
  async enterHighFrequencyMode() {
99
505
  if (this.highFrequencyMode)
@@ -101,7 +507,7 @@ export class HybridProvider extends Observable {
101
507
  try {
102
508
  // Connect WebTransport
103
509
  // Just an example URL construction, depends on server routing
104
- const wtUrl = this.url + '/sync/' + this.roomName;
510
+ const wtUrl = this.activeRelayUrl + '/sync/' + this.roomName;
105
511
  this.wt = new WebTransport(wtUrl);
106
512
  await this.wt.ready;
107
513
  const stream = await this.wt.createBidirectionalStream();
@@ -121,86 +527,389 @@ export class HybridProvider extends Observable {
121
527
  syncProtocol.writeSyncStep1(encoder, this.doc);
122
528
  this.send(encoding.toUint8Array(encoder));
123
529
  }
530
+ /**
531
+ * Read loop for WebTransport with dynamic buffer sizing
532
+ * Handles payloads larger than initial buffer
533
+ */
124
534
  async readLoop(readable) {
125
535
  let reader;
536
+ const isByob = typeof ReadableStreamBYOBReader !== 'undefined';
126
537
  try {
127
538
  // @ts-ignore
128
- reader = readable.getReader({ mode: 'byob' });
539
+ reader = isByob ? readable.getReader({ mode: 'byob' }) : readable.getReader();
129
540
  }
130
541
  catch (e) {
131
542
  reader = readable.getReader();
132
543
  }
133
- // Pre-allocate buffer (64KB) to emulate Zero-Copy / WASM Heap view
134
- // In a future update, this could be a view into `sqlite3.wasm.memory`.
135
- let buffer = new Uint8Array(65536);
544
+ // Start with 64KB, grow dynamically if needed (max 16MB)
545
+ const MIN_BUFFER_SIZE = 65536; // 64KB
546
+ const MAX_BUFFER_SIZE = 16777216; // 16MB
547
+ let bufferSize = MIN_BUFFER_SIZE;
548
+ let buffer = new Uint8Array(bufferSize);
549
+ let consecutiveLargeReads = 0;
136
550
  try {
137
551
  while (true) {
138
552
  let result;
139
- if (reader.readAtLeast) {
140
- // BYOB Reader
141
- // We pass the view. The reader detaches it and returns a new view (potentially same backing store).
553
+ if (isByob && reader.read.length > 0) {
554
+ // BYOB Reader - pre-allocate buffer
142
555
  result = await reader.read(new Uint8Array(buffer.buffer, 0, buffer.byteLength));
143
556
  if (result.value) {
144
- buffer = result.value; // Update our reference to the valid buffer
557
+ buffer = result.value;
558
+ // Dynamic buffer growth: if we're consistently filling the buffer, grow it
559
+ if (result.value.byteLength >= bufferSize * 0.9) {
560
+ consecutiveLargeReads++;
561
+ if (consecutiveLargeReads >= 3 && bufferSize < MAX_BUFFER_SIZE) {
562
+ bufferSize = Math.min(bufferSize * 2, MAX_BUFFER_SIZE);
563
+ buffer = new Uint8Array(bufferSize);
564
+ console.log(`[Dash] Grew WebTransport buffer to ${bufferSize / 1024}KB`);
565
+ }
566
+ }
567
+ else {
568
+ consecutiveLargeReads = 0;
569
+ }
145
570
  }
146
571
  }
147
572
  else {
148
573
  // Default Reader
149
574
  result = await reader.read();
150
575
  }
151
- if (result.done)
576
+ if (result.done) {
577
+ console.log('[Dash] WebTransport stream ended gracefully');
152
578
  break;
579
+ }
153
580
  if (result.value) {
154
- // Processing: In true zero-copy, we'd pass the offset/length to SQL directly.
155
- // Here we pass the view.
156
- this.handleMessage(result.value);
581
+ this.handleMessage(result.value).catch((err) => {
582
+ console.error('[Dash] Failed to process WebTransport message:', err);
583
+ });
157
584
  }
158
585
  }
159
586
  }
160
587
  catch (e) {
161
- console.error('WebTransport Read loop error', e);
588
+ // Check if this is a recoverable error
589
+ const errorMessage = e.message || '';
590
+ if (errorMessage.includes('aborted') || errorMessage.includes('closed')) {
591
+ console.log('[Dash] WebTransport connection closed');
592
+ }
593
+ else {
594
+ console.error('[Dash] WebTransport read loop error:', e);
595
+ this.emit('error', [{ type: 'webtransport', error: e }]);
596
+ }
162
597
  }
163
598
  finally {
164
599
  this.highFrequencyMode = false;
165
600
  this.writer = null;
166
601
  this.wt = null;
602
+ // If we were in high-frequency mode and it failed, fall back to WebSocket
603
+ if (this.connectionState === 'connected') {
604
+ console.log('[Dash] WebTransport closed, continuing with WebSocket');
605
+ }
167
606
  }
168
607
  }
169
- handleMessage(buf) {
170
- // Simple protocol: First byte is message type (0=Sync, 1=Awareness, 2=Delta)
171
- // In strict binary mode, we might need a more robust header.
172
- // For now assuming Y-protocol encoding.
173
- // Note: The Relay DO sends raw messages back.
174
- const decoder = decoding.createDecoder(buf);
175
- const messageType = decoding.readVarUint(decoder);
176
- switch (messageType) {
177
- case MessageType.Sync:
178
- syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), this.doc, this);
179
- break;
180
- case MessageType.Awareness:
181
- awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
182
- break;
183
- case MessageType.Delta:
184
- // Handle Aeon delta-compressed sync
185
- if (this.deltaAdapter) {
186
- const deltaPayloadBytes = decoding.readVarUint8Array(decoder);
187
- const deltaPayload = this.deltaAdapter.decodePayload(deltaPayloadBytes);
188
- const update = this.deltaAdapter.unwrapDelta(deltaPayload);
189
- Y.applyUpdate(this.doc, update, this);
608
+ /**
609
+ * Handle incoming message with protocol validation
610
+ * Never silently ignores malformed messages
611
+ */
612
+ async handleMessage(buf) {
613
+ // Validate minimum message size
614
+ if (buf.byteLength < 1) {
615
+ console.warn('[Dash] Received empty message, ignoring');
616
+ return;
617
+ }
618
+ try {
619
+ const decoder = decoding.createDecoder(buf);
620
+ // Check if we can read the message type (decoder has remaining bytes)
621
+ if (decoder.pos >= decoder.arr.length) {
622
+ console.warn('[Dash] Message too short to decode type');
623
+ return;
624
+ }
625
+ const messageType = decoding.readVarUint(decoder);
626
+ switch (messageType) {
627
+ case MessageType.Sync:
628
+ syncProtocol.readSyncMessage(decoder, encoding.createEncoder(), this.doc, this);
629
+ break;
630
+ case MessageType.Awareness:
631
+ awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
632
+ break;
633
+ case MessageType.Delta:
634
+ // Handle Aeon delta-compressed sync
635
+ if (this.deltaAdapter) {
636
+ const deltaPayloadBytes = decoding.readVarUint8Array(decoder);
637
+ const deltaPayload = this.deltaAdapter.decodePayload(deltaPayloadBytes);
638
+ const update = this.deltaAdapter.unwrapDelta(deltaPayload);
639
+ Y.applyUpdate(this.doc, update, this);
640
+ }
641
+ else {
642
+ console.warn('[Dash] Received Delta message but delta adapter not enabled');
643
+ }
644
+ break;
645
+ case MessageType.Compressed: {
646
+ const algorithmCode = decoding.readVarUint(decoder);
647
+ const compressedPayload = decoding.readVarUint8Array(decoder);
648
+ const decompressed = await this.decompressPayload(compressedPayload, algorithmCode === 1 ? 'gzip' : algorithmCode === 2 ? 'deflate' : 'none');
649
+ await this.handleMessage(decompressed);
650
+ break;
190
651
  }
191
- break;
652
+ case MessageType.Encrypted: {
653
+ const iv = decoding.readVarUint8Array(decoder);
654
+ const ciphertext = decoding.readVarUint8Array(decoder);
655
+ const plaintext = await this.decryptPayload(iv, ciphertext);
656
+ if (plaintext) {
657
+ await this.handleMessage(plaintext);
658
+ }
659
+ break;
660
+ }
661
+ case MessageType.Batch: {
662
+ const count = decoding.readVarUint(decoder);
663
+ for (let i = 0; i < count; i++) {
664
+ const frame = decoding.readVarUint8Array(decoder);
665
+ await this.handleMessage(frame);
666
+ }
667
+ break;
668
+ }
669
+ case MessageType.Capabilities: {
670
+ const remoteProtocolVersion = decoding.readVarUint(decoder);
671
+ const remoteClientId = decoding.readVarUint(decoder);
672
+ const remoteCapabilities = decoding.readVarUint(decoder);
673
+ this.peerCapabilities.set(remoteClientId, remoteCapabilities);
674
+ this.emit('status', [{
675
+ status: 'capabilities',
676
+ protocolVersion: remoteProtocolVersion,
677
+ clientId: remoteClientId,
678
+ capabilities: this.capabilityListFromBitmask(remoteCapabilities),
679
+ }]);
680
+ break;
681
+ }
682
+ default:
683
+ // CRITICAL: Don't silently ignore unknown message types
684
+ console.warn(`[Dash] Unknown message type: ${messageType}. Protocol mismatch possible.`);
685
+ this.emit('error', [{
686
+ type: 'protocol',
687
+ error: new Error(`Unknown message type: ${messageType}`),
688
+ messageType
689
+ }]);
690
+ break;
691
+ }
692
+ }
693
+ catch (err) {
694
+ // Don't let malformed messages crash the sync loop
695
+ console.error('[Dash] Failed to decode message:', err);
696
+ this.emit('error', [{
697
+ type: 'decode',
698
+ error: err,
699
+ bufferSize: buf.byteLength
700
+ }]);
192
701
  }
193
702
  }
703
+ /**
704
+ * Send message with error handling and offline queue fallback
705
+ * Never fails silently - either sends, queues, or throws
706
+ */
194
707
  async send(message) {
708
+ if (this.isCapabilityEnabled(CapabilityFlag.Batch)) {
709
+ this.enqueueOutbound(message);
710
+ return;
711
+ }
712
+ const processed = await this.applyOutboundPipeline(message);
713
+ await this.sendSingleFrame(processed, [message]);
714
+ }
715
+ enqueueOutbound(message) {
716
+ this.outboundQueue.push(message);
717
+ this.outboundQueueBytes += message.byteLength;
718
+ if (this.outboundQueueBytes >= (this.relayPerformance.maxBatchSizeBytes ?? 64 * 1024)) {
719
+ this.flushOutboundQueue().catch((err) => {
720
+ console.warn('[Dash] Failed to flush outbound queue:', err);
721
+ });
722
+ return;
723
+ }
724
+ this.scheduleBatchFlush();
725
+ }
726
+ scheduleBatchFlush() {
727
+ if (this.batchFlushTimeout || this.flushingBatch) {
728
+ return;
729
+ }
730
+ const maxDelay = this.relayPerformance.maxBatchDelayMs ?? 12;
731
+ const decision = this.batchTiming?.getSchedulingDecision(this.outboundQueueBytes, 'normal', false);
732
+ const delay = Math.max(0, Math.min(maxDelay, decision?.recommendedDelay ?? maxDelay));
733
+ this.batchFlushTimeout = setTimeout(() => {
734
+ this.batchFlushTimeout = null;
735
+ this.flushOutboundQueue().catch((err) => {
736
+ console.warn('[Dash] Failed to flush outbound queue:', err);
737
+ });
738
+ }, delay);
739
+ }
740
+ async flushOutboundQueue() {
741
+ if (this.flushingBatch || this.outboundQueue.length === 0) {
742
+ return;
743
+ }
744
+ this.flushingBatch = true;
745
+ try {
746
+ const batch = this.outboundQueue.splice(0, this.outboundQueue.length);
747
+ this.outboundQueueBytes = 0;
748
+ const frame = batch.length === 1 ? batch[0] : this.encodeBatchFrame(batch);
749
+ const processed = await this.applyOutboundPipeline(frame);
750
+ await this.sendSingleFrame(processed, batch);
751
+ }
752
+ finally {
753
+ this.flushingBatch = false;
754
+ }
755
+ }
756
+ encodeBatchFrame(messages) {
757
+ const encoder = encoding.createEncoder();
758
+ encoding.writeVarUint(encoder, MessageType.Batch);
759
+ encoding.writeVarUint(encoder, messages.length);
760
+ for (const message of messages) {
761
+ encoding.writeVarUint8Array(encoder, message);
762
+ }
763
+ return encoding.toUint8Array(encoder);
764
+ }
765
+ async applyOutboundPipeline(message) {
766
+ let payload = message;
767
+ payload = await this.maybeCompressPayload(payload);
768
+ payload = await this.maybeEncryptPayload(payload);
769
+ return payload;
770
+ }
771
+ async maybeCompressPayload(message) {
772
+ if (!this.compressionEngine ||
773
+ !this.relayPerformance.enableAdaptiveCompression ||
774
+ !this.isCapabilityEnabled(CapabilityFlag.Compression)) {
775
+ return message;
776
+ }
777
+ if (message.byteLength < (this.relayPerformance.compressionThresholdBytes ?? 512)) {
778
+ return message;
779
+ }
780
+ const recommendation = this.adaptiveCompression?.getRecommendedLevel();
781
+ if (recommendation && recommendation.recommendedLevel <= 3) {
782
+ return message;
783
+ }
784
+ const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
785
+ const compressed = await this.compressionEngine.compress(message);
786
+ const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt;
787
+ this.adaptiveCompression?.recordCompressionPerformance(recommendation?.recommendedLevel ?? 6, elapsed, compressed.compressionRatio);
788
+ this.adaptiveCompression?.applyRecommendation();
789
+ this.lastCompressionRatio = compressed.compressionRatio;
790
+ if (compressed.algorithm === 'none') {
791
+ return message;
792
+ }
793
+ // Keep payload uncompressed if ratio is poor.
794
+ if (compressed.compressedSize >= message.byteLength * 0.97) {
795
+ return message;
796
+ }
797
+ const encoder = encoding.createEncoder();
798
+ encoding.writeVarUint(encoder, MessageType.Compressed);
799
+ encoding.writeVarUint(encoder, compressed.algorithm === 'gzip' ? 1 : 2);
800
+ encoding.writeVarUint8Array(encoder, compressed.compressed);
801
+ return encoding.toUint8Array(encoder);
802
+ }
803
+ async decompressPayload(compressedPayload, algorithm) {
804
+ if (!this.compressionEngine || algorithm === 'none') {
805
+ return compressedPayload;
806
+ }
807
+ return this.compressionEngine.decompress({
808
+ id: `incoming-${Date.now()}-${Math.random().toString(36).slice(2)}`,
809
+ compressed: compressedPayload,
810
+ originalSize: compressedPayload.byteLength,
811
+ compressedSize: compressedPayload.byteLength,
812
+ compressionRatio: 0,
813
+ algorithm,
814
+ timestamp: Date.now(),
815
+ });
816
+ }
817
+ async maybeEncryptPayload(message) {
818
+ if (!this.relayPrivacy.enabled || !this.isCapabilityEnabled(CapabilityFlag.Encryption)) {
819
+ return message;
820
+ }
821
+ const key = await this.getRoomCryptoKey();
822
+ if (!key) {
823
+ return message;
824
+ }
825
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
826
+ return message;
827
+ }
828
+ const iv = crypto.getRandomValues(new Uint8Array(12));
829
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(message));
830
+ const encoder = encoding.createEncoder();
831
+ encoding.writeVarUint(encoder, MessageType.Encrypted);
832
+ encoding.writeVarUint8Array(encoder, iv);
833
+ encoding.writeVarUint8Array(encoder, new Uint8Array(encrypted));
834
+ return encoding.toUint8Array(encoder);
835
+ }
836
+ async decryptPayload(iv, ciphertext) {
837
+ const key = await this.getRoomCryptoKey();
838
+ if (!key) {
839
+ console.warn('[Dash] Received encrypted payload but no room key is configured');
840
+ return null;
841
+ }
842
+ if (typeof crypto === 'undefined' || !crypto.subtle) {
843
+ return null;
844
+ }
845
+ try {
846
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(ciphertext));
847
+ return new Uint8Array(decrypted);
848
+ }
849
+ catch (err) {
850
+ this.emit('error', [{ type: 'privacy', error: err }]);
851
+ console.warn('[Dash] Failed to decrypt relay payload:', err);
852
+ return null;
853
+ }
854
+ }
855
+ async sendSingleFrame(payload, originalMessages) {
856
+ // Try WebTransport first (high frequency mode)
195
857
  if (this.writer && this.highFrequencyMode) {
196
- // High Priority -> WebTransport
197
- const writer = this.writer.getWriter();
198
- await writer.write(message);
199
- writer.releaseLock();
858
+ try {
859
+ const writer = this.writer.getWriter();
860
+ await writer.write(payload);
861
+ writer.releaseLock();
862
+ return;
863
+ }
864
+ catch (err) {
865
+ console.warn('[Dash] WebTransport send failed, falling back to WebSocket:', err);
866
+ }
200
867
  }
201
- else if (this.ws && this.ws.readyState === WebSocket.OPEN) {
202
- // Default -> WebSocket
203
- this.ws.send(message);
868
+ // Try WebSocket
869
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
870
+ try {
871
+ if (this.ws.bufferedAmount > 1024 * 1024) {
872
+ console.warn('[Dash] WebSocket buffer full, queueing message');
873
+ for (const message of originalMessages) {
874
+ this.queueForOffline(message);
875
+ }
876
+ return;
877
+ }
878
+ this.ws.send(payload);
879
+ return;
880
+ }
881
+ catch (err) {
882
+ console.warn('[Dash] WebSocket send failed:', err);
883
+ }
884
+ }
885
+ // Neither transport available - queue for later
886
+ for (const message of originalMessages) {
887
+ this.queueForOffline(message);
888
+ }
889
+ }
890
+ /**
891
+ * Queue message for offline sync
892
+ */
893
+ queueForOffline(message) {
894
+ if (this.offlineAdapter) {
895
+ // Extract the update from the message for proper queuing
896
+ // The message includes protocol header, but offline adapter expects raw update
897
+ try {
898
+ const decoder = decoding.createDecoder(message);
899
+ const messageType = decoding.readVarUint(decoder);
900
+ if (messageType === MessageType.Sync || messageType === MessageType.Delta) {
901
+ const updateBytes = decoding.readVarUint8Array(decoder);
902
+ this.offlineAdapter.queueUpdate(updateBytes);
903
+ console.log('[Dash] Message queued for offline sync');
904
+ }
905
+ }
906
+ catch (err) {
907
+ console.error('[Dash] Failed to queue message:', err);
908
+ }
909
+ }
910
+ else {
911
+ console.warn('[Dash] Message dropped - no transport and no offline queue');
912
+ this.emit('error', [{ type: 'send', error: new Error('No transport available and offline queue disabled') }]);
204
913
  }
205
914
  }
206
915
  onDocUpdate(update, origin) {
@@ -228,8 +937,15 @@ export class HybridProvider extends Observable {
228
937
  }
229
938
  }
230
939
  onAwarenessUpdate({ added, updated, removed }, origin) {
231
- if (origin === this)
940
+ if (origin === this) {
941
+ this.reconcilePeerCapabilities(removed);
942
+ if (Array.isArray(added) && added.length > 0) {
943
+ this.sendCapabilitiesAdvertisement(false).catch((err) => {
944
+ console.warn('[Dash] Failed to re-advertise capabilities:', err);
945
+ });
946
+ }
232
947
  return;
948
+ }
233
949
  const changedClients = added.concat(updated).concat(removed);
234
950
  const encoder = encoding.createEncoder();
235
951
  encoding.writeVarUint(encoder, 1); // MessageType.Awareness
@@ -237,6 +953,20 @@ export class HybridProvider extends Observable {
237
953
  this.send(encoding.toUint8Array(encoder));
238
954
  }
239
955
  destroy() {
956
+ // Cancel any pending reconnect
957
+ if (this.reconnectTimeout) {
958
+ clearTimeout(this.reconnectTimeout);
959
+ this.reconnectTimeout = null;
960
+ }
961
+ if (this.batchFlushTimeout) {
962
+ clearTimeout(this.batchFlushTimeout);
963
+ this.batchFlushTimeout = null;
964
+ }
965
+ this.outboundQueue = [];
966
+ this.outboundQueueBytes = 0;
967
+ this.peerCapabilities.clear();
968
+ // Mark as disconnected to prevent reconnect attempts
969
+ this.connectionState = 'disconnected';
240
970
  this.doc.off('update', this.onDocUpdate);
241
971
  this.awareness.off('update', this.onAwarenessUpdate);
242
972
  this.ws?.close();
@@ -321,19 +1051,66 @@ export class HybridProvider extends Observable {
321
1051
  }
322
1052
  return {
323
1053
  connected: this.connected,
1054
+ connectionState: this.connectionState,
324
1055
  roomName: this.roomName,
325
- url: this.url,
1056
+ url: this.activeRelayUrl,
1057
+ reconnectAttempts: this.reconnectAttempts,
326
1058
  websocket: {
327
1059
  state: wsState,
328
- connected: this.ws !== null && this.ws.readyState === WebSocket.OPEN
1060
+ connected: this.ws !== null && this.ws.readyState === WebSocket.OPEN,
1061
+ bufferedAmount: this.ws?.bufferedAmount ?? 0
329
1062
  },
330
1063
  webTransport: {
331
1064
  connected: this.wt !== null,
332
1065
  highFrequencyMode: this.highFrequencyMode
333
1066
  },
1067
+ discovery: {
1068
+ enabled: this.relayDiscovery.enabled === true,
1069
+ knownRelays: this.discoveredRelayUrls.length,
1070
+ lastDiscoveryAt: this.lastRelayDiscoveryAt,
1071
+ targetPeerId: normalizePeerId(this.relayDiscovery.targetPeerId || this.roomName),
1072
+ },
1073
+ performance: {
1074
+ batching: this.relayPerformance.enableBatching === true,
1075
+ adaptiveCompression: this.relayPerformance.enableAdaptiveCompression === true,
1076
+ activeBatching: this.isCapabilityEnabled(CapabilityFlag.Batch),
1077
+ activeCompression: this.isCapabilityEnabled(CapabilityFlag.Compression),
1078
+ queuedFrames: this.outboundQueue.length,
1079
+ lastCompressionRatio: this.lastCompressionRatio,
1080
+ },
1081
+ privacy: {
1082
+ enabled: this.relayPrivacy.enabled === true,
1083
+ keyLoaded: this.roomCryptoKey !== null,
1084
+ activeEncryption: this.isCapabilityEnabled(CapabilityFlag.Encryption),
1085
+ },
1086
+ protocol: {
1087
+ version: this.protocolVersion,
1088
+ localCapabilities: this.capabilityListFromBitmask(this.localCapabilitiesBitmask),
1089
+ knownPeers: this.peerCapabilities.size,
1090
+ },
334
1091
  transport: this.highFrequencyMode ? 'WebTransport' : 'WebSocket'
335
1092
  };
336
1093
  }
1094
+ /**
1095
+ * Force immediate reconnection (resets backoff)
1096
+ * Use when user explicitly requests reconnect
1097
+ */
1098
+ forceReconnect() {
1099
+ if (this.reconnectTimeout) {
1100
+ clearTimeout(this.reconnectTimeout);
1101
+ this.reconnectTimeout = null;
1102
+ }
1103
+ // Close existing connections
1104
+ this.ws?.close();
1105
+ this.wt?.close();
1106
+ this.ws = null;
1107
+ this.wt = null;
1108
+ // Reset backoff
1109
+ this.reconnectAttempts = 0;
1110
+ this.connectionState = 'disconnected';
1111
+ // Connect immediately
1112
+ this.connectWebSocket();
1113
+ }
337
1114
  /**
338
1115
  * Get all awareness states from connected peers
339
1116
  */
@@ -472,6 +1249,105 @@ export class HybridProvider extends Observable {
472
1249
  delta: this.deltaAdapter?.getStats() ?? null,
473
1250
  offline: this.offlineAdapter?.getStats() ?? null,
474
1251
  presence: this.presenceAdapter?.getStats() ?? null,
1252
+ compression: this.adaptiveCompression?.getStats() ?? null,
475
1253
  };
476
1254
  }
477
1255
  }
1256
+ function normalizePeerId(value) {
1257
+ const trimmed = value.trim().toLowerCase();
1258
+ if (/^[0-9a-f]+$/i.test(trimmed)) {
1259
+ return trimmed;
1260
+ }
1261
+ let hash = 2166136261;
1262
+ for (let i = 0; i < trimmed.length; i++) {
1263
+ hash ^= trimmed.charCodeAt(i);
1264
+ hash = Math.imul(hash, 16777619);
1265
+ }
1266
+ return (hash >>> 0).toString(16).padStart(8, '0');
1267
+ }
1268
+ function normalizeRelayUrl(url) {
1269
+ try {
1270
+ const parsed = new URL(url);
1271
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1272
+ return null;
1273
+ }
1274
+ return parsed.origin;
1275
+ }
1276
+ catch {
1277
+ return null;
1278
+ }
1279
+ }
1280
+ function dedupeRelayUrls(urls) {
1281
+ const deduped = new Set();
1282
+ for (const url of urls) {
1283
+ const normalized = normalizeRelayUrl(url);
1284
+ if (normalized)
1285
+ deduped.add(normalized);
1286
+ }
1287
+ return Array.from(deduped);
1288
+ }
1289
+ function extractRelayUrls(payload) {
1290
+ if (!payload || typeof payload !== 'object') {
1291
+ return [];
1292
+ }
1293
+ const data = payload;
1294
+ const urls = [];
1295
+ const collect = (value) => {
1296
+ if (typeof value === 'string') {
1297
+ urls.push(value);
1298
+ return;
1299
+ }
1300
+ if (!value || typeof value !== 'object') {
1301
+ return;
1302
+ }
1303
+ const record = value;
1304
+ if (typeof record.relayUrl === 'string') {
1305
+ urls.push(record.relayUrl);
1306
+ }
1307
+ else if (typeof record.url === 'string') {
1308
+ urls.push(record.url);
1309
+ }
1310
+ };
1311
+ const candidates = ['bootstrapRelays', 'relays', 'peers'];
1312
+ for (const key of candidates) {
1313
+ const value = data[key];
1314
+ if (Array.isArray(value)) {
1315
+ for (const item of value) {
1316
+ collect(item);
1317
+ }
1318
+ }
1319
+ }
1320
+ return dedupeRelayUrls(urls);
1321
+ }
1322
+ function getRelayOriginFromDiscoveryUrl(url) {
1323
+ try {
1324
+ const parsed = new URL(url);
1325
+ return `${parsed.protocol}//${parsed.host}`;
1326
+ }
1327
+ catch {
1328
+ return null;
1329
+ }
1330
+ }
1331
+ async function deriveRoomKeyBytes(input) {
1332
+ if (input instanceof Uint8Array) {
1333
+ if (input.byteLength === 32) {
1334
+ return input;
1335
+ }
1336
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
1337
+ const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(input));
1338
+ return new Uint8Array(digest);
1339
+ }
1340
+ return input;
1341
+ }
1342
+ const encoded = new TextEncoder().encode(input);
1343
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
1344
+ const digest = await crypto.subtle.digest('SHA-256', toArrayBuffer(encoded));
1345
+ return new Uint8Array(digest);
1346
+ }
1347
+ return encoded;
1348
+ }
1349
+ function toArrayBuffer(data) {
1350
+ const clone = new Uint8Array(data.byteLength);
1351
+ clone.set(data);
1352
+ return clone.buffer;
1353
+ }