@affectively/dash 5.4.0 → 5.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -189
- package/dist/automerge_wasm_bg-4hg5vg2g.wasm +0 -0
- package/dist/engine/sqlite.d.ts +30 -0
- package/dist/engine/vec_extension.d.ts +2 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +53895 -0
- package/dist/middleware/errorHandler.d.ts +60 -0
- package/dist/{src/sync → sync}/AeonDurableSync.d.ts +8 -9
- package/dist/sync/AeonDurableSync.js +1984 -0
- package/dist/{src/sync → sync}/AutomergeProvider.d.ts +8 -8
- package/dist/sync/AutomergeProvider.js +4421 -0
- package/dist/sync/HybridProvider.d.ts +124 -0
- package/dist/sync/HybridProvider.js +8328 -0
- package/dist/sync/connection/WebRTCConnection.d.ts +23 -0
- package/dist/sync/connection/WebRTCConnection.js +59 -0
- package/dist/sync/index.d.ts +13 -0
- package/dist/sync/index.js +12773 -0
- package/dist/sync/provider/YjsSqliteProvider.d.ts +17 -0
- package/dist/sync/provider/YjsSqliteProvider.js +54 -0
- package/dist/sync/types.d.ts +74 -0
- package/dist/sync/webtransport/WebTransportProvider.d.ts +16 -0
- package/dist/sync/webtransport/WebTransportProvider.js +55 -0
- package/package.json +62 -70
- package/dist/src/api/firebase/auth/index.d.ts +0 -137
- package/dist/src/api/firebase/auth/index.js +0 -352
- package/dist/src/api/firebase/auth/providers.d.ts +0 -254
- package/dist/src/api/firebase/auth/providers.js +0 -518
- package/dist/src/api/firebase/database/index.d.ts +0 -108
- package/dist/src/api/firebase/database/index.js +0 -368
- package/dist/src/api/firebase/errors.d.ts +0 -15
- package/dist/src/api/firebase/errors.js +0 -215
- package/dist/src/api/firebase/firestore/data-types.d.ts +0 -116
- package/dist/src/api/firebase/firestore/data-types.js +0 -280
- package/dist/src/api/firebase/firestore/index.d.ts +0 -7
- package/dist/src/api/firebase/firestore/index.js +0 -13
- package/dist/src/api/firebase/firestore/listeners.d.ts +0 -20
- package/dist/src/api/firebase/firestore/listeners.js +0 -50
- package/dist/src/api/firebase/firestore/operations.d.ts +0 -123
- package/dist/src/api/firebase/firestore/operations.js +0 -490
- package/dist/src/api/firebase/firestore/query.d.ts +0 -118
- package/dist/src/api/firebase/firestore/query.js +0 -418
- package/dist/src/api/firebase/index.d.ts +0 -11
- package/dist/src/api/firebase/index.js +0 -17
- package/dist/src/api/firebase/storage/index.d.ts +0 -100
- package/dist/src/api/firebase/storage/index.js +0 -286
- package/dist/src/api/firebase/types.d.ts +0 -341
- package/dist/src/api/firebase/types.js +0 -4
- package/dist/src/auth/manager.d.ts +0 -182
- package/dist/src/auth/manager.js +0 -598
- package/dist/src/engine/ai.js +0 -76
- package/dist/src/engine/sqlite.d.ts +0 -353
- package/dist/src/engine/sqlite.js +0 -1328
- package/dist/src/engine/vec_extension.d.ts +0 -5
- package/dist/src/engine/vec_extension.js +0 -10
- package/dist/src/index.d.ts +0 -21
- package/dist/src/index.js +0 -26
- package/dist/src/mcp/server.js +0 -87
- package/dist/src/reactivity/signal.js +0 -31
- package/dist/src/schema/lens.d.ts +0 -29
- package/dist/src/schema/lens.js +0 -122
- package/dist/src/sync/AeonDurableSync.js +0 -67
- package/dist/src/sync/AutomergeProvider.js +0 -153
- package/dist/src/sync/aeon/config.d.ts +0 -21
- package/dist/src/sync/aeon/config.js +0 -14
- package/dist/src/sync/aeon/delta-adapter.d.ts +0 -62
- package/dist/src/sync/aeon/delta-adapter.js +0 -98
- package/dist/src/sync/aeon/index.d.ts +0 -18
- package/dist/src/sync/aeon/index.js +0 -19
- package/dist/src/sync/aeon/offline-adapter.d.ts +0 -110
- package/dist/src/sync/aeon/offline-adapter.js +0 -227
- package/dist/src/sync/aeon/presence-adapter.d.ts +0 -114
- package/dist/src/sync/aeon/presence-adapter.js +0 -157
- package/dist/src/sync/aeon/schema-adapter.d.ts +0 -95
- package/dist/src/sync/aeon/schema-adapter.js +0 -163
- package/dist/src/sync/backup.d.ts +0 -12
- package/dist/src/sync/backup.js +0 -44
- package/dist/src/sync/connection.d.ts +0 -20
- package/dist/src/sync/connection.js +0 -50
- package/dist/src/sync/d1-provider.d.ts +0 -103
- package/dist/src/sync/d1-provider.js +0 -418
- package/dist/src/sync/hybrid-provider.d.ts +0 -307
- package/dist/src/sync/hybrid-provider.js +0 -1353
- package/dist/src/sync/provider.d.ts +0 -11
- package/dist/src/sync/provider.js +0 -67
- package/dist/src/sync/types.d.ts +0 -32
- package/dist/src/sync/types.js +0 -4
- package/dist/src/sync/verify.d.ts +0 -1
- package/dist/src/sync/verify.js +0 -23
- package/dist/tsconfig.tsbuildinfo +0 -1
- /package/dist/{src/engine → engine}/ai.d.ts +0 -0
- /package/dist/{src/mcp → mcp}/server.d.ts +0 -0
- /package/dist/{src/reactivity → reactivity}/signal.d.ts +0 -0
|
@@ -1,1353 +0,0 @@
|
|
|
1
|
-
import * as Y from 'yjs';
|
|
2
|
-
import { Observable } from 'lib0/observable';
|
|
3
|
-
import * as encoding from 'lib0/encoding';
|
|
4
|
-
import * as decoding from 'lib0/decoding';
|
|
5
|
-
// @ts-ignore
|
|
6
|
-
import * as syncProtocol from 'y-protocols/sync';
|
|
7
|
-
// @ts-ignore
|
|
8
|
-
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
9
|
-
// Aeon integration
|
|
10
|
-
import { defaultAeonConfig } from './aeon/config.js';
|
|
11
|
-
import { DashDeltaAdapter } from './aeon/delta-adapter.js';
|
|
12
|
-
import { DashPresenceAdapter } from './aeon/presence-adapter.js';
|
|
13
|
-
import { DashOfflineAdapter } from './aeon/offline-adapter.js';
|
|
14
|
-
import { CompressionEngine, AdaptiveCompressionOptimizer, BatchTimingOptimizer, } from '@affectively/aeon';
|
|
15
|
-
// Message types
|
|
16
|
-
const MessageType = {
|
|
17
|
-
Sync: 0,
|
|
18
|
-
Awareness: 1,
|
|
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,
|
|
29
|
-
};
|
|
30
|
-
export class HybridProvider extends Observable {
|
|
31
|
-
doc;
|
|
32
|
-
ws = null;
|
|
33
|
-
wt = null;
|
|
34
|
-
connected = false;
|
|
35
|
-
url;
|
|
36
|
-
activeRelayUrl;
|
|
37
|
-
roomName;
|
|
38
|
-
awareness;
|
|
39
|
-
writer = null;
|
|
40
|
-
// "High Frequency" mode uses WebTransport for ephemeral data
|
|
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;
|
|
73
|
-
// Aeon integration
|
|
74
|
-
aeonConfig;
|
|
75
|
-
deltaAdapter = null;
|
|
76
|
-
presenceAdapter = null;
|
|
77
|
-
offlineAdapter = null;
|
|
78
|
-
constructor(url, roomName, doc, { awareness = new awarenessProtocol.Awareness(doc), aeonConfig = defaultAeonConfig, relayDiscovery = {}, relayPerformance = {}, relayPrivacy = {}, } = {}) {
|
|
79
|
-
super();
|
|
80
|
-
this.url = url;
|
|
81
|
-
this.activeRelayUrl = url;
|
|
82
|
-
this.roomName = roomName;
|
|
83
|
-
this.doc = doc;
|
|
84
|
-
this.awareness = awareness;
|
|
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();
|
|
118
|
-
// Initialize Aeon adapters
|
|
119
|
-
if (aeonConfig.enableDeltaSync) {
|
|
120
|
-
this.deltaAdapter = new DashDeltaAdapter(roomName, aeonConfig.deltaThreshold);
|
|
121
|
-
}
|
|
122
|
-
if (aeonConfig.enableRichPresence) {
|
|
123
|
-
this.presenceAdapter = new DashPresenceAdapter(roomName, awareness);
|
|
124
|
-
}
|
|
125
|
-
if (aeonConfig.enableOfflineQueue) {
|
|
126
|
-
this.offlineAdapter = new DashOfflineAdapter(doc, roomName, aeonConfig.maxOfflineQueueSize, aeonConfig.maxOfflineRetries);
|
|
127
|
-
}
|
|
128
|
-
this.doc.on('update', this.onDocUpdate.bind(this));
|
|
129
|
-
this.awareness.on('update', this.onAwarenessUpdate.bind(this));
|
|
130
|
-
this.connect();
|
|
131
|
-
}
|
|
132
|
-
connect() {
|
|
133
|
-
this.connectWebSocket();
|
|
134
|
-
}
|
|
135
|
-
connectWebSocket() {
|
|
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();
|
|
165
|
-
this.ws = new WebSocket(wsUrl);
|
|
166
|
-
this.ws.binaryType = 'arraybuffer';
|
|
167
|
-
this.ws.onopen = () => {
|
|
168
|
-
this.recordRelaySuccess(this.activeRelayUrl, Date.now() - connectionStartedAt);
|
|
169
|
-
this.connectionState = 'connected';
|
|
170
|
-
this.connected = true;
|
|
171
|
-
this.reconnectAttempts = 0; // Reset backoff on successful connection
|
|
172
|
-
this.emit('status', [{ status: 'connected' }]);
|
|
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
|
-
}
|
|
183
|
-
};
|
|
184
|
-
this.ws.onmessage = (event) => {
|
|
185
|
-
this.handleMessage(new Uint8Array(event.data)).catch((err) => {
|
|
186
|
-
console.error('[Dash] Failed to handle message:', err);
|
|
187
|
-
});
|
|
188
|
-
};
|
|
189
|
-
this.ws.onclose = (event) => {
|
|
190
|
-
if (event.code !== 1000) {
|
|
191
|
-
this.recordRelayFailure(this.activeRelayUrl);
|
|
192
|
-
}
|
|
193
|
-
this.connected = false;
|
|
194
|
-
this.connectionState = 'disconnected';
|
|
195
|
-
this.ws = null;
|
|
196
|
-
this.emit('status', [{ status: 'disconnected', code: event.code, reason: event.reason }]);
|
|
197
|
-
// Schedule reconnect with exponential backoff + jitter
|
|
198
|
-
this.scheduleReconnect();
|
|
199
|
-
};
|
|
200
|
-
this.ws.onerror = (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
|
|
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();
|
|
213
|
-
});
|
|
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
|
-
}
|
|
358
|
-
async getAuthToken() {
|
|
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
|
-
});
|
|
503
|
-
}
|
|
504
|
-
async enterHighFrequencyMode() {
|
|
505
|
-
if (this.highFrequencyMode)
|
|
506
|
-
return;
|
|
507
|
-
try {
|
|
508
|
-
// Connect WebTransport
|
|
509
|
-
// Just an example URL construction, depends on server routing
|
|
510
|
-
const wtUrl = this.activeRelayUrl + '/sync/' + this.roomName;
|
|
511
|
-
this.wt = new WebTransport(wtUrl);
|
|
512
|
-
await this.wt.ready;
|
|
513
|
-
const stream = await this.wt.createBidirectionalStream();
|
|
514
|
-
this.writer = stream.writable;
|
|
515
|
-
this.readLoop(stream.readable);
|
|
516
|
-
this.highFrequencyMode = true;
|
|
517
|
-
console.log('Upgraded to WebTransport (High Frequency Mode)');
|
|
518
|
-
}
|
|
519
|
-
catch (e) {
|
|
520
|
-
console.error('Failed to upgrade to WebTransport', e);
|
|
521
|
-
// Fallback: stay on WebSocket
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
sendSyncStep1() {
|
|
525
|
-
const encoder = encoding.createEncoder();
|
|
526
|
-
encoding.writeVarUint(encoder, 0); // MessageType.Sync
|
|
527
|
-
syncProtocol.writeSyncStep1(encoder, this.doc);
|
|
528
|
-
this.send(encoding.toUint8Array(encoder));
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* Read loop for WebTransport with dynamic buffer sizing
|
|
532
|
-
* Handles payloads larger than initial buffer
|
|
533
|
-
*/
|
|
534
|
-
async readLoop(readable) {
|
|
535
|
-
let reader;
|
|
536
|
-
const isByob = typeof ReadableStreamBYOBReader !== 'undefined';
|
|
537
|
-
try {
|
|
538
|
-
// @ts-ignore
|
|
539
|
-
reader = isByob ? readable.getReader({ mode: 'byob' }) : readable.getReader();
|
|
540
|
-
}
|
|
541
|
-
catch (e) {
|
|
542
|
-
reader = readable.getReader();
|
|
543
|
-
}
|
|
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;
|
|
550
|
-
try {
|
|
551
|
-
while (true) {
|
|
552
|
-
let result;
|
|
553
|
-
if (isByob && reader.read.length > 0) {
|
|
554
|
-
// BYOB Reader - pre-allocate buffer
|
|
555
|
-
result = await reader.read(new Uint8Array(buffer.buffer, 0, buffer.byteLength));
|
|
556
|
-
if (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
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
else {
|
|
573
|
-
// Default Reader
|
|
574
|
-
result = await reader.read();
|
|
575
|
-
}
|
|
576
|
-
if (result.done) {
|
|
577
|
-
console.log('[Dash] WebTransport stream ended gracefully');
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
if (result.value) {
|
|
581
|
-
this.handleMessage(result.value).catch((err) => {
|
|
582
|
-
console.error('[Dash] Failed to process WebTransport message:', err);
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
catch (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
|
-
}
|
|
597
|
-
}
|
|
598
|
-
finally {
|
|
599
|
-
this.highFrequencyMode = false;
|
|
600
|
-
this.writer = null;
|
|
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
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
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;
|
|
651
|
-
}
|
|
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
|
-
}]);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
/**
|
|
704
|
-
* Send message with error handling and offline queue fallback
|
|
705
|
-
* Never fails silently - either sends, queues, or throws
|
|
706
|
-
*/
|
|
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)
|
|
857
|
-
if (this.writer && this.highFrequencyMode) {
|
|
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
|
-
}
|
|
867
|
-
}
|
|
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') }]);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
onDocUpdate(update, origin) {
|
|
916
|
-
if (origin === this)
|
|
917
|
-
return;
|
|
918
|
-
// Check if we should queue (offline)
|
|
919
|
-
if (this.offlineAdapter?.shouldQueue()) {
|
|
920
|
-
this.offlineAdapter.queueUpdate(update);
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
// Use delta compression if enabled
|
|
924
|
-
if (this.deltaAdapter) {
|
|
925
|
-
const deltaPayload = this.deltaAdapter.wrapUpdate(update, origin);
|
|
926
|
-
const encoder = encoding.createEncoder();
|
|
927
|
-
encoding.writeVarUint(encoder, MessageType.Delta);
|
|
928
|
-
encoding.writeVarUint8Array(encoder, this.deltaAdapter.encodePayload(deltaPayload));
|
|
929
|
-
this.send(encoding.toUint8Array(encoder));
|
|
930
|
-
}
|
|
931
|
-
else {
|
|
932
|
-
// Standard Yjs sync
|
|
933
|
-
const encoder = encoding.createEncoder();
|
|
934
|
-
encoding.writeVarUint(encoder, MessageType.Sync);
|
|
935
|
-
syncProtocol.writeUpdate(encoder, update);
|
|
936
|
-
this.send(encoding.toUint8Array(encoder));
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
onAwarenessUpdate({ added, updated, removed }, origin) {
|
|
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
|
-
}
|
|
947
|
-
return;
|
|
948
|
-
}
|
|
949
|
-
const changedClients = added.concat(updated).concat(removed);
|
|
950
|
-
const encoder = encoding.createEncoder();
|
|
951
|
-
encoding.writeVarUint(encoder, 1); // MessageType.Awareness
|
|
952
|
-
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients));
|
|
953
|
-
this.send(encoding.toUint8Array(encoder));
|
|
954
|
-
}
|
|
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';
|
|
970
|
-
this.doc.off('update', this.onDocUpdate);
|
|
971
|
-
this.awareness.off('update', this.onAwarenessUpdate);
|
|
972
|
-
this.ws?.close();
|
|
973
|
-
this.wt?.close();
|
|
974
|
-
// Cleanup Aeon adapters
|
|
975
|
-
this.presenceAdapter?.destroy();
|
|
976
|
-
this.offlineAdapter?.destroy();
|
|
977
|
-
this.connected = false;
|
|
978
|
-
super.destroy();
|
|
979
|
-
}
|
|
980
|
-
// ============================================
|
|
981
|
-
// AEON ADAPTER ACCESSORS
|
|
982
|
-
// ============================================
|
|
983
|
-
/**
|
|
984
|
-
* Get the delta adapter for compression stats
|
|
985
|
-
*/
|
|
986
|
-
getDeltaAdapter() {
|
|
987
|
-
return this.deltaAdapter;
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* Get the presence adapter for rich presence features
|
|
991
|
-
*/
|
|
992
|
-
getPresenceAdapter() {
|
|
993
|
-
return this.presenceAdapter;
|
|
994
|
-
}
|
|
995
|
-
/**
|
|
996
|
-
* Get the offline adapter for queue management
|
|
997
|
-
*/
|
|
998
|
-
getOfflineAdapter() {
|
|
999
|
-
return this.offlineAdapter;
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* Get Aeon configuration
|
|
1003
|
-
*/
|
|
1004
|
-
getAeonConfig() {
|
|
1005
|
-
return this.aeonConfig;
|
|
1006
|
-
}
|
|
1007
|
-
/**
|
|
1008
|
-
* Process offline queue when back online
|
|
1009
|
-
*/
|
|
1010
|
-
async processOfflineQueue() {
|
|
1011
|
-
if (!this.offlineAdapter) {
|
|
1012
|
-
return { synced: 0, failed: 0 };
|
|
1013
|
-
}
|
|
1014
|
-
return this.offlineAdapter.processQueue(async (update) => {
|
|
1015
|
-
const encoder = encoding.createEncoder();
|
|
1016
|
-
if (this.deltaAdapter) {
|
|
1017
|
-
const deltaPayload = this.deltaAdapter.wrapUpdate(update);
|
|
1018
|
-
encoding.writeVarUint(encoder, MessageType.Delta);
|
|
1019
|
-
encoding.writeVarUint8Array(encoder, this.deltaAdapter.encodePayload(deltaPayload));
|
|
1020
|
-
}
|
|
1021
|
-
else {
|
|
1022
|
-
encoding.writeVarUint(encoder, MessageType.Sync);
|
|
1023
|
-
syncProtocol.writeUpdate(encoder, update);
|
|
1024
|
-
}
|
|
1025
|
-
await this.send(encoding.toUint8Array(encoder));
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
// ============================================
|
|
1029
|
-
// INTROSPECTION METHODS FOR DASH-STUDIO
|
|
1030
|
-
// ============================================
|
|
1031
|
-
/**
|
|
1032
|
-
* Get comprehensive connection status
|
|
1033
|
-
*/
|
|
1034
|
-
getConnectionStatus() {
|
|
1035
|
-
let wsState = 'closed';
|
|
1036
|
-
if (this.ws) {
|
|
1037
|
-
switch (this.ws.readyState) {
|
|
1038
|
-
case WebSocket.CONNECTING:
|
|
1039
|
-
wsState = 'connecting';
|
|
1040
|
-
break;
|
|
1041
|
-
case WebSocket.OPEN:
|
|
1042
|
-
wsState = 'open';
|
|
1043
|
-
break;
|
|
1044
|
-
case WebSocket.CLOSING:
|
|
1045
|
-
wsState = 'closing';
|
|
1046
|
-
break;
|
|
1047
|
-
case WebSocket.CLOSED:
|
|
1048
|
-
wsState = 'closed';
|
|
1049
|
-
break;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
return {
|
|
1053
|
-
connected: this.connected,
|
|
1054
|
-
connectionState: this.connectionState,
|
|
1055
|
-
roomName: this.roomName,
|
|
1056
|
-
url: this.activeRelayUrl,
|
|
1057
|
-
reconnectAttempts: this.reconnectAttempts,
|
|
1058
|
-
websocket: {
|
|
1059
|
-
state: wsState,
|
|
1060
|
-
connected: this.ws !== null && this.ws.readyState === WebSocket.OPEN,
|
|
1061
|
-
bufferedAmount: this.ws?.bufferedAmount ?? 0
|
|
1062
|
-
},
|
|
1063
|
-
webTransport: {
|
|
1064
|
-
connected: this.wt !== null,
|
|
1065
|
-
highFrequencyMode: this.highFrequencyMode
|
|
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
|
-
},
|
|
1091
|
-
transport: this.highFrequencyMode ? 'WebTransport' : 'WebSocket'
|
|
1092
|
-
};
|
|
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
|
-
}
|
|
1114
|
-
/**
|
|
1115
|
-
* Get all awareness states from connected peers
|
|
1116
|
-
*/
|
|
1117
|
-
getAwarenessStates() {
|
|
1118
|
-
const states = [];
|
|
1119
|
-
const awarenessStates = this.awareness.getStates();
|
|
1120
|
-
awarenessStates.forEach((state, clientId) => {
|
|
1121
|
-
states.push({
|
|
1122
|
-
clientId,
|
|
1123
|
-
state,
|
|
1124
|
-
isLocal: clientId === this.awareness.clientID
|
|
1125
|
-
});
|
|
1126
|
-
});
|
|
1127
|
-
return states;
|
|
1128
|
-
}
|
|
1129
|
-
/**
|
|
1130
|
-
* Get the local client ID
|
|
1131
|
-
*/
|
|
1132
|
-
getLocalClientId() {
|
|
1133
|
-
return this.awareness.clientID;
|
|
1134
|
-
}
|
|
1135
|
-
/**
|
|
1136
|
-
* Get connected peer count (excluding local)
|
|
1137
|
-
*/
|
|
1138
|
-
getPeerCount() {
|
|
1139
|
-
const states = this.awareness.getStates();
|
|
1140
|
-
return Math.max(0, states.size - 1); // Exclude self
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* Get document state information
|
|
1144
|
-
*/
|
|
1145
|
-
getDocumentState() {
|
|
1146
|
-
const stateVector = Y.encodeStateVector(this.doc);
|
|
1147
|
-
const update = Y.encodeStateAsUpdate(this.doc);
|
|
1148
|
-
// Get shared types info
|
|
1149
|
-
const sharedTypes = [];
|
|
1150
|
-
this.doc.share.forEach((type, name) => {
|
|
1151
|
-
let typeKind = 'unknown';
|
|
1152
|
-
let size = 0;
|
|
1153
|
-
if (type instanceof Y.Map) {
|
|
1154
|
-
typeKind = 'YMap';
|
|
1155
|
-
size = type.size;
|
|
1156
|
-
}
|
|
1157
|
-
else if (type instanceof Y.Array) {
|
|
1158
|
-
typeKind = 'YArray';
|
|
1159
|
-
size = type.length;
|
|
1160
|
-
}
|
|
1161
|
-
else if (type instanceof Y.Text) {
|
|
1162
|
-
typeKind = 'YText';
|
|
1163
|
-
size = type.length;
|
|
1164
|
-
}
|
|
1165
|
-
else if (type instanceof Y.XmlFragment) {
|
|
1166
|
-
typeKind = 'YXmlFragment';
|
|
1167
|
-
size = type.length;
|
|
1168
|
-
}
|
|
1169
|
-
sharedTypes.push({
|
|
1170
|
-
name,
|
|
1171
|
-
type: typeKind,
|
|
1172
|
-
size
|
|
1173
|
-
});
|
|
1174
|
-
});
|
|
1175
|
-
return {
|
|
1176
|
-
clientId: this.doc.clientID,
|
|
1177
|
-
guid: this.doc.guid,
|
|
1178
|
-
stateVectorSize: stateVector.byteLength,
|
|
1179
|
-
updateSize: update.byteLength,
|
|
1180
|
-
sharedTypes,
|
|
1181
|
-
transactionCount: 0, // Yjs doesn't expose this directly
|
|
1182
|
-
gcEnabled: this.doc.gc
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
/**
|
|
1186
|
-
* Get a snapshot of the Yjs document for inspection
|
|
1187
|
-
*/
|
|
1188
|
-
getDocumentSnapshot() {
|
|
1189
|
-
const content = {};
|
|
1190
|
-
this.doc.share.forEach((type, name) => {
|
|
1191
|
-
try {
|
|
1192
|
-
if (type instanceof Y.Map) {
|
|
1193
|
-
content[name] = type.toJSON();
|
|
1194
|
-
}
|
|
1195
|
-
else if (type instanceof Y.Array) {
|
|
1196
|
-
content[name] = type.toJSON();
|
|
1197
|
-
}
|
|
1198
|
-
else if (type instanceof Y.Text) {
|
|
1199
|
-
content[name] = type.toString();
|
|
1200
|
-
}
|
|
1201
|
-
else if (type instanceof Y.XmlFragment) {
|
|
1202
|
-
content[name] = type.toString();
|
|
1203
|
-
}
|
|
1204
|
-
else {
|
|
1205
|
-
content[name] = '[unsupported type]';
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
catch (e) {
|
|
1209
|
-
content[name] = '[error reading content]';
|
|
1210
|
-
}
|
|
1211
|
-
});
|
|
1212
|
-
return {
|
|
1213
|
-
timestamp: new Date().toISOString(),
|
|
1214
|
-
roomName: this.roomName,
|
|
1215
|
-
content
|
|
1216
|
-
};
|
|
1217
|
-
}
|
|
1218
|
-
/**
|
|
1219
|
-
* Set local awareness state for introspection (admin view)
|
|
1220
|
-
*/
|
|
1221
|
-
setLocalAwareness(state) {
|
|
1222
|
-
this.awareness.setLocalState(state);
|
|
1223
|
-
}
|
|
1224
|
-
/**
|
|
1225
|
-
* Get full provider status for debugging
|
|
1226
|
-
*/
|
|
1227
|
-
getProviderStatus() {
|
|
1228
|
-
return {
|
|
1229
|
-
connection: this.getConnectionStatus(),
|
|
1230
|
-
awareness: {
|
|
1231
|
-
localClientId: this.awareness.clientID,
|
|
1232
|
-
peerCount: this.getPeerCount(),
|
|
1233
|
-
states: this.getAwarenessStates()
|
|
1234
|
-
},
|
|
1235
|
-
document: this.getDocumentState(),
|
|
1236
|
-
aeon: this.getAeonStatus()
|
|
1237
|
-
};
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* Get Aeon-specific status
|
|
1241
|
-
*/
|
|
1242
|
-
getAeonStatus() {
|
|
1243
|
-
return {
|
|
1244
|
-
enabled: {
|
|
1245
|
-
deltaSync: this.aeonConfig.enableDeltaSync,
|
|
1246
|
-
richPresence: this.aeonConfig.enableRichPresence,
|
|
1247
|
-
offlineQueue: this.aeonConfig.enableOfflineQueue,
|
|
1248
|
-
},
|
|
1249
|
-
delta: this.deltaAdapter?.getStats() ?? null,
|
|
1250
|
-
offline: this.offlineAdapter?.getStats() ?? null,
|
|
1251
|
-
presence: this.presenceAdapter?.getStats() ?? null,
|
|
1252
|
-
compression: this.adaptiveCompression?.getStats() ?? null,
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
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
|
-
}
|