@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.
- package/dist/src/engine/sqlite.d.ts +7 -0
- package/dist/src/engine/sqlite.js +3 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +2 -0
- package/dist/src/sync/AeonDurableSync.d.ts +26 -0
- package/dist/src/sync/AeonDurableSync.js +67 -0
- package/dist/src/sync/AutomergeProvider.d.ts +45 -0
- package/dist/src/sync/AutomergeProvider.js +153 -0
- package/dist/src/sync/d1-provider.d.ts +8 -2
- package/dist/src/sync/d1-provider.js +94 -21
- package/dist/src/sync/hybrid-provider.d.ts +136 -1
- package/dist/src/sync/hybrid-provider.js +942 -66
- package/dist/src/sync/types.d.ts +32 -0
- package/dist/src/sync/types.js +4 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +14 -1
|
@@ -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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.
|
|
67
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
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.
|
|
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;
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
+
}
|