@xnetjs/runtime 0.0.1
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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/index.d.ts +1020 -0
- package/dist/index.js +3061 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3061 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
import { getSigningPublicKeyFromPrivate, sign, verify } from "@xnetjs/crypto";
|
|
3
|
+
import { MemoryNodeStorageAdapter, NodeStore } from "@xnetjs/data";
|
|
4
|
+
import { createMainThreadBridgeSync } from "@xnetjs/data-bridge";
|
|
5
|
+
import { UndoManager } from "@xnetjs/history";
|
|
6
|
+
import { PluginRegistry } from "@xnetjs/plugins";
|
|
7
|
+
|
|
8
|
+
// src/sync/sync-manager.ts
|
|
9
|
+
import {
|
|
10
|
+
createSyncLifecycleState,
|
|
11
|
+
resolveSyncReplicationPolicy,
|
|
12
|
+
signYjsUpdate,
|
|
13
|
+
verifyYjsEnvelopeV1
|
|
14
|
+
} from "@xnetjs/sync";
|
|
15
|
+
import {
|
|
16
|
+
Awareness,
|
|
17
|
+
applyAwarenessUpdate,
|
|
18
|
+
encodeAwarenessUpdate,
|
|
19
|
+
removeAwarenessStates
|
|
20
|
+
} from "y-protocols/awareness";
|
|
21
|
+
import * as Y2 from "yjs";
|
|
22
|
+
|
|
23
|
+
// src/sync/blob-sync.ts
|
|
24
|
+
var BLOB_SYNC_ROOM = "xnet-blob-sync";
|
|
25
|
+
var MAX_INLINE_SIZE = 256 * 1024;
|
|
26
|
+
function createBlobSyncProvider(config) {
|
|
27
|
+
const { blobStore, connection, onBlobReceived } = config;
|
|
28
|
+
let cleanup = null;
|
|
29
|
+
const pendingRequests = /* @__PURE__ */ new Set();
|
|
30
|
+
function toBase643(data) {
|
|
31
|
+
let binary = "";
|
|
32
|
+
for (let i = 0; i < data.length; i++) {
|
|
33
|
+
binary += String.fromCharCode(data[i]);
|
|
34
|
+
}
|
|
35
|
+
return btoa(binary);
|
|
36
|
+
}
|
|
37
|
+
function fromBase642(str) {
|
|
38
|
+
const binary = atob(str);
|
|
39
|
+
const bytes = new Uint8Array(binary.length);
|
|
40
|
+
for (let i = 0; i < binary.length; i++) {
|
|
41
|
+
bytes[i] = binary.charCodeAt(i);
|
|
42
|
+
}
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
async function handleMessage(data) {
|
|
46
|
+
const msg = data;
|
|
47
|
+
switch (msg.type) {
|
|
48
|
+
case "blob-want": {
|
|
49
|
+
for (const cid of msg.cids) {
|
|
50
|
+
const blobData = await blobStore.get(cid);
|
|
51
|
+
if (blobData) {
|
|
52
|
+
if (blobData.byteLength <= MAX_INLINE_SIZE) {
|
|
53
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
54
|
+
type: "blob-data",
|
|
55
|
+
cid,
|
|
56
|
+
data: toBase643(blobData)
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
60
|
+
type: "blob-data",
|
|
61
|
+
cid,
|
|
62
|
+
data: toBase643(blobData)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
67
|
+
type: "blob-not-found",
|
|
68
|
+
cid
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "blob-data": {
|
|
75
|
+
const blobData = fromBase642(msg.data);
|
|
76
|
+
await blobStore.put(blobData);
|
|
77
|
+
pendingRequests.delete(msg.cid);
|
|
78
|
+
onBlobReceived?.(msg.cid);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "blob-not-found": {
|
|
82
|
+
pendingRequests.delete(msg.cid);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "blob-have": {
|
|
86
|
+
const needed = [];
|
|
87
|
+
for (const cid of msg.cids) {
|
|
88
|
+
if (!await blobStore.has(cid)) {
|
|
89
|
+
needed.push(cid);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (needed.length > 0) {
|
|
93
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
94
|
+
type: "blob-want",
|
|
95
|
+
cids: needed
|
|
96
|
+
});
|
|
97
|
+
for (const cid of needed) {
|
|
98
|
+
pendingRequests.add(cid);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
start() {
|
|
107
|
+
if (cleanup) return;
|
|
108
|
+
cleanup = connection.joinRoom(BLOB_SYNC_ROOM, (data) => {
|
|
109
|
+
handleMessage(data);
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
stop() {
|
|
113
|
+
if (cleanup) {
|
|
114
|
+
cleanup();
|
|
115
|
+
cleanup = null;
|
|
116
|
+
}
|
|
117
|
+
pendingRequests.clear();
|
|
118
|
+
},
|
|
119
|
+
async requestBlobs(cids) {
|
|
120
|
+
const missing = [];
|
|
121
|
+
for (const cid of cids) {
|
|
122
|
+
if (!await blobStore.has(cid)) {
|
|
123
|
+
missing.push(cid);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
128
|
+
type: "blob-want",
|
|
129
|
+
cids: missing
|
|
130
|
+
});
|
|
131
|
+
for (const cid of missing) {
|
|
132
|
+
pendingRequests.add(cid);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
announceHave(cids) {
|
|
137
|
+
if (cids.length > 0) {
|
|
138
|
+
connection.publish(BLOB_SYNC_ROOM, {
|
|
139
|
+
type: "blob-have",
|
|
140
|
+
cids
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
get pendingCount() {
|
|
145
|
+
return pendingRequests.size;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/sync/connection-manager.ts
|
|
151
|
+
function log(...args) {
|
|
152
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
153
|
+
console.log("[ConnectionManager]", ...args);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function createConnectionManager(config) {
|
|
157
|
+
let ws = null;
|
|
158
|
+
let status = "disconnected";
|
|
159
|
+
let reconnectAttempts = 0;
|
|
160
|
+
let reconnectTimer = null;
|
|
161
|
+
let connectTimer = null;
|
|
162
|
+
let destroyed = false;
|
|
163
|
+
let connectInProgress = false;
|
|
164
|
+
let policyViolation = false;
|
|
165
|
+
const reconnectDelay = config.reconnectDelay ?? 2e3;
|
|
166
|
+
const maxReconnectDelay = config.maxReconnectDelay ?? 3e4;
|
|
167
|
+
const rateLimitBackoffMs = config.rateLimitBackoffMs ?? 15e3;
|
|
168
|
+
const maxReconnects = config.maxReconnects ?? Infinity;
|
|
169
|
+
const connectTimeout = config.connectTimeout ?? 1e4;
|
|
170
|
+
const initialConnectTimeout = config.initialConnectTimeout ?? (connectTimeout > 0 ? Math.min(connectTimeout, 6e3) : 0);
|
|
171
|
+
function clearConnectTimer() {
|
|
172
|
+
if (connectTimer) {
|
|
173
|
+
clearTimeout(connectTimer);
|
|
174
|
+
connectTimer = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function abandonSocket(socket) {
|
|
178
|
+
if (!socket) return;
|
|
179
|
+
socket.onopen = null;
|
|
180
|
+
socket.onmessage = null;
|
|
181
|
+
socket.onclose = null;
|
|
182
|
+
socket.onerror = null;
|
|
183
|
+
try {
|
|
184
|
+
socket.close();
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function onConnectTimeout() {
|
|
189
|
+
connectTimer = null;
|
|
190
|
+
if (status !== "connecting") return;
|
|
191
|
+
log("WebSocket connect timeout after", connectTimeout, "ms");
|
|
192
|
+
connectInProgress = false;
|
|
193
|
+
const stalled = ws;
|
|
194
|
+
ws = null;
|
|
195
|
+
abandonSocket(stalled);
|
|
196
|
+
setStatus("error");
|
|
197
|
+
scheduleReconnect();
|
|
198
|
+
}
|
|
199
|
+
function armConnectTimeout() {
|
|
200
|
+
const timeout = reconnectAttempts === 0 ? initialConnectTimeout : connectTimeout;
|
|
201
|
+
if (timeout <= 0 || !Number.isFinite(timeout)) return;
|
|
202
|
+
connectTimer = setTimeout(onConnectTimeout, timeout);
|
|
203
|
+
}
|
|
204
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
205
|
+
const statusListeners = /* @__PURE__ */ new Set();
|
|
206
|
+
const messageListeners = /* @__PURE__ */ new Set();
|
|
207
|
+
const pendingSubscriptions = /* @__PURE__ */ new Map();
|
|
208
|
+
function setStatus(s) {
|
|
209
|
+
log("Status changed:", status, "->", s);
|
|
210
|
+
status = s;
|
|
211
|
+
for (const handler of statusListeners) {
|
|
212
|
+
try {
|
|
213
|
+
handler(s);
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function send(msg) {
|
|
219
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
220
|
+
log("Sending:", msg);
|
|
221
|
+
ws.send(JSON.stringify(msg));
|
|
222
|
+
} else {
|
|
223
|
+
log("Cannot send, WebSocket not open. readyState:", ws?.readyState);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function handleMessage(event) {
|
|
227
|
+
try {
|
|
228
|
+
const msg = JSON.parse(event.data);
|
|
229
|
+
if (msg.type === "pong") return;
|
|
230
|
+
log("Received message:", msg.type, msg.topic ? `topic=${msg.topic}` : "");
|
|
231
|
+
if (msg.type === "subscribed" && Array.isArray(msg.topics)) {
|
|
232
|
+
for (const topic of msg.topics) {
|
|
233
|
+
const pending = pendingSubscriptions.get(topic);
|
|
234
|
+
if (pending) {
|
|
235
|
+
log("Subscription confirmed for room:", topic);
|
|
236
|
+
pending.resolve();
|
|
237
|
+
pendingSubscriptions.delete(topic);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (msg.type === "publish" && msg.topic) {
|
|
243
|
+
const handlers = rooms.get(msg.topic);
|
|
244
|
+
if (handlers) {
|
|
245
|
+
log("Dispatching to", handlers.size, "handler(s) for room:", msg.topic);
|
|
246
|
+
for (const handler of handlers) {
|
|
247
|
+
try {
|
|
248
|
+
handler(msg.data);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
log("Handler error:", err);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
log("No handlers for room:", msg.topic);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (msg && typeof msg === "object") {
|
|
259
|
+
for (const handler of messageListeners) {
|
|
260
|
+
try {
|
|
261
|
+
handler(msg);
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
log("Failed to parse message:", err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function resolveAuthToken() {
|
|
271
|
+
let token = config.ucanToken ?? "";
|
|
272
|
+
if (!token && config.getUCANToken) {
|
|
273
|
+
try {
|
|
274
|
+
token = await config.getUCANToken();
|
|
275
|
+
} catch {
|
|
276
|
+
token = "";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return token;
|
|
280
|
+
}
|
|
281
|
+
function buildProtocols(token) {
|
|
282
|
+
const protocols = ["xnet-sync.v1"];
|
|
283
|
+
if (token && !/[\s,]/.test(token)) {
|
|
284
|
+
protocols.push(`xnet-auth.${token}`);
|
|
285
|
+
}
|
|
286
|
+
return protocols;
|
|
287
|
+
}
|
|
288
|
+
function hubConfigured() {
|
|
289
|
+
return Boolean(config.url) && config.url.trim().length > 0;
|
|
290
|
+
}
|
|
291
|
+
function shouldSkipConnect() {
|
|
292
|
+
if (destroyed) {
|
|
293
|
+
log("doConnect called but manager is destroyed");
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (connectInProgress) {
|
|
297
|
+
log("doConnect called but connection already in progress");
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (!hubConfigured()) {
|
|
301
|
+
log("No hub URL configured \u2014 staying offline");
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
async function doConnect() {
|
|
307
|
+
if (shouldSkipConnect()) return;
|
|
308
|
+
connectInProgress = true;
|
|
309
|
+
log("Connecting to:", config.url);
|
|
310
|
+
setStatus("connecting");
|
|
311
|
+
try {
|
|
312
|
+
const token = await resolveAuthToken();
|
|
313
|
+
ws = new WebSocket(config.url, buildProtocols(token));
|
|
314
|
+
armConnectTimeout();
|
|
315
|
+
ws.onopen = () => {
|
|
316
|
+
clearConnectTimer();
|
|
317
|
+
connectInProgress = false;
|
|
318
|
+
log("WebSocket connected");
|
|
319
|
+
setStatus("connected");
|
|
320
|
+
reconnectAttempts = 0;
|
|
321
|
+
policyViolation = false;
|
|
322
|
+
if (rooms.size > 0) {
|
|
323
|
+
const roomList = Array.from(rooms.keys());
|
|
324
|
+
log("Re-subscribing to", roomList.length, "room(s):", roomList);
|
|
325
|
+
send({ type: "subscribe", topics: roomList });
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
ws.onmessage = handleMessage;
|
|
329
|
+
ws.onclose = (event) => {
|
|
330
|
+
clearConnectTimer();
|
|
331
|
+
connectInProgress = false;
|
|
332
|
+
log("WebSocket closed, code:", event.code, "reason:", event.reason || "(none)");
|
|
333
|
+
ws = null;
|
|
334
|
+
policyViolation = event.code === 1008;
|
|
335
|
+
setStatus("disconnected");
|
|
336
|
+
scheduleReconnect();
|
|
337
|
+
};
|
|
338
|
+
ws.onerror = (event) => {
|
|
339
|
+
clearConnectTimer();
|
|
340
|
+
connectInProgress = false;
|
|
341
|
+
log("WebSocket error:", event);
|
|
342
|
+
setStatus("error");
|
|
343
|
+
};
|
|
344
|
+
} catch (err) {
|
|
345
|
+
clearConnectTimer();
|
|
346
|
+
connectInProgress = false;
|
|
347
|
+
log("Failed to create WebSocket:", err);
|
|
348
|
+
setStatus("error");
|
|
349
|
+
scheduleReconnect();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function scheduleReconnect() {
|
|
353
|
+
if (destroyed || reconnectAttempts >= maxReconnects) return;
|
|
354
|
+
if (reconnectTimer) return;
|
|
355
|
+
reconnectAttempts++;
|
|
356
|
+
let backoff;
|
|
357
|
+
if (policyViolation) {
|
|
358
|
+
policyViolation = false;
|
|
359
|
+
backoff = rateLimitBackoffMs + Math.floor(Math.random() * rateLimitBackoffMs * 0.5);
|
|
360
|
+
} else {
|
|
361
|
+
backoff = Math.min(reconnectDelay * 2 ** (reconnectAttempts - 1), maxReconnectDelay);
|
|
362
|
+
}
|
|
363
|
+
reconnectTimer = setTimeout(() => {
|
|
364
|
+
reconnectTimer = null;
|
|
365
|
+
void doConnect();
|
|
366
|
+
}, backoff);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
get status() {
|
|
370
|
+
return status;
|
|
371
|
+
},
|
|
372
|
+
get roomCount() {
|
|
373
|
+
return rooms.size;
|
|
374
|
+
},
|
|
375
|
+
connect() {
|
|
376
|
+
destroyed = false;
|
|
377
|
+
void doConnect();
|
|
378
|
+
},
|
|
379
|
+
disconnect() {
|
|
380
|
+
destroyed = true;
|
|
381
|
+
clearConnectTimer();
|
|
382
|
+
if (reconnectTimer) {
|
|
383
|
+
clearTimeout(reconnectTimer);
|
|
384
|
+
reconnectTimer = null;
|
|
385
|
+
}
|
|
386
|
+
if (ws) {
|
|
387
|
+
if (rooms.size > 0) {
|
|
388
|
+
send({ type: "unsubscribe", topics: Array.from(rooms.keys()) });
|
|
389
|
+
}
|
|
390
|
+
ws.close(1e3, "Client disconnect");
|
|
391
|
+
ws = null;
|
|
392
|
+
}
|
|
393
|
+
setStatus("disconnected");
|
|
394
|
+
},
|
|
395
|
+
joinRoom(room, handler) {
|
|
396
|
+
log("Joining room:", room);
|
|
397
|
+
let handlers = rooms.get(room);
|
|
398
|
+
if (!handlers) {
|
|
399
|
+
handlers = /* @__PURE__ */ new Set();
|
|
400
|
+
rooms.set(room, handlers);
|
|
401
|
+
log("New room subscription, sending subscribe message");
|
|
402
|
+
send({ type: "subscribe", topics: [room] });
|
|
403
|
+
}
|
|
404
|
+
handlers.add(handler);
|
|
405
|
+
log("Room", room, "now has", handlers.size, "handler(s)");
|
|
406
|
+
return () => {
|
|
407
|
+
log("Leaving room:", room);
|
|
408
|
+
handlers.delete(handler);
|
|
409
|
+
if (handlers.size === 0) {
|
|
410
|
+
rooms.delete(room);
|
|
411
|
+
pendingSubscriptions.delete(room);
|
|
412
|
+
send({ type: "unsubscribe", topics: [room] });
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
},
|
|
416
|
+
joinRoomAsync(room, handler) {
|
|
417
|
+
log("Joining room async:", room);
|
|
418
|
+
let handlers = rooms.get(room);
|
|
419
|
+
const isNewRoom = !handlers;
|
|
420
|
+
if (!handlers) {
|
|
421
|
+
handlers = /* @__PURE__ */ new Set();
|
|
422
|
+
rooms.set(room, handlers);
|
|
423
|
+
}
|
|
424
|
+
handlers.add(handler);
|
|
425
|
+
log("Room", room, "now has", handlers.size, "handler(s)");
|
|
426
|
+
let ready;
|
|
427
|
+
if (isNewRoom && status === "connected") {
|
|
428
|
+
log("New room subscription (async), sending subscribe message");
|
|
429
|
+
ready = new Promise((resolve, _reject) => {
|
|
430
|
+
pendingSubscriptions.set(room, { resolve, reject: () => resolve() });
|
|
431
|
+
send({ type: "subscribe", topics: [room] });
|
|
432
|
+
setTimeout(() => {
|
|
433
|
+
if (pendingSubscriptions.has(room)) {
|
|
434
|
+
pendingSubscriptions.delete(room);
|
|
435
|
+
log("Subscription confirmation timeout for room:", room, "- proceeding anyway");
|
|
436
|
+
resolve();
|
|
437
|
+
}
|
|
438
|
+
}, 5e3);
|
|
439
|
+
});
|
|
440
|
+
} else {
|
|
441
|
+
if (isNewRoom) {
|
|
442
|
+
log("New room added but not connected, will subscribe when connected");
|
|
443
|
+
}
|
|
444
|
+
ready = Promise.resolve();
|
|
445
|
+
}
|
|
446
|
+
const unsubscribe = () => {
|
|
447
|
+
log("Leaving room:", room);
|
|
448
|
+
handlers.delete(handler);
|
|
449
|
+
if (handlers.size === 0) {
|
|
450
|
+
rooms.delete(room);
|
|
451
|
+
pendingSubscriptions.delete(room);
|
|
452
|
+
send({ type: "unsubscribe", topics: [room] });
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
return { unsubscribe, ready };
|
|
456
|
+
},
|
|
457
|
+
leaveRoom(room) {
|
|
458
|
+
rooms.delete(room);
|
|
459
|
+
send({ type: "unsubscribe", topics: [room] });
|
|
460
|
+
},
|
|
461
|
+
publish(room, data) {
|
|
462
|
+
log("Publishing to room:", room, "type:", data.type);
|
|
463
|
+
send({ type: "publish", topic: room, data });
|
|
464
|
+
},
|
|
465
|
+
sendRaw(message) {
|
|
466
|
+
send(message);
|
|
467
|
+
},
|
|
468
|
+
onMessage(handler) {
|
|
469
|
+
messageListeners.add(handler);
|
|
470
|
+
return () => messageListeners.delete(handler);
|
|
471
|
+
},
|
|
472
|
+
onStatus(handler) {
|
|
473
|
+
statusListeners.add(handler);
|
|
474
|
+
return () => statusListeners.delete(handler);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function createMultiHubConnectionManager(config) {
|
|
479
|
+
const managers = dedupeHubConfigs(config.hubs).map((hub) => createConnectionManager(hub));
|
|
480
|
+
const statusListeners = /* @__PURE__ */ new Set();
|
|
481
|
+
const messageListeners = /* @__PURE__ */ new Set();
|
|
482
|
+
const rooms = /* @__PURE__ */ new Map();
|
|
483
|
+
let status = aggregateConnectionStatus(managers.map((manager) => manager.status));
|
|
484
|
+
managers.forEach((manager) => {
|
|
485
|
+
manager.onStatus(() => {
|
|
486
|
+
emitAggregateStatus();
|
|
487
|
+
});
|
|
488
|
+
manager.onMessage((message) => {
|
|
489
|
+
for (const handler of messageListeners) {
|
|
490
|
+
try {
|
|
491
|
+
handler(message);
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
function emitAggregateStatus() {
|
|
498
|
+
const nextStatus = aggregateConnectionStatus(managers.map((manager) => manager.status));
|
|
499
|
+
if (nextStatus === status) return;
|
|
500
|
+
status = nextStatus;
|
|
501
|
+
for (const handler of statusListeners) {
|
|
502
|
+
try {
|
|
503
|
+
handler(nextStatus);
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function ensureRoom(room) {
|
|
509
|
+
const existing = rooms.get(room);
|
|
510
|
+
if (existing) {
|
|
511
|
+
return {
|
|
512
|
+
ready: existing.ready,
|
|
513
|
+
unsubscribe: existing.unsubscribe
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
517
|
+
const dispatch = (data) => {
|
|
518
|
+
for (const handler of handlers) {
|
|
519
|
+
try {
|
|
520
|
+
handler(data);
|
|
521
|
+
} catch {
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const subscriptions = managers.map((manager) => manager.joinRoomAsync(room, dispatch));
|
|
526
|
+
const ready = Promise.all(subscriptions.map((subscription) => subscription.ready)).then(
|
|
527
|
+
() => void 0
|
|
528
|
+
);
|
|
529
|
+
const unsubscribe = () => {
|
|
530
|
+
for (const subscription of subscriptions) {
|
|
531
|
+
subscription.unsubscribe();
|
|
532
|
+
}
|
|
533
|
+
rooms.delete(room);
|
|
534
|
+
};
|
|
535
|
+
rooms.set(room, {
|
|
536
|
+
handlers,
|
|
537
|
+
ready,
|
|
538
|
+
unsubscribe
|
|
539
|
+
});
|
|
540
|
+
return { ready, unsubscribe };
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
get status() {
|
|
544
|
+
return status;
|
|
545
|
+
},
|
|
546
|
+
get roomCount() {
|
|
547
|
+
return rooms.size;
|
|
548
|
+
},
|
|
549
|
+
connect() {
|
|
550
|
+
for (const manager of managers) {
|
|
551
|
+
manager.connect();
|
|
552
|
+
}
|
|
553
|
+
emitAggregateStatus();
|
|
554
|
+
},
|
|
555
|
+
disconnect() {
|
|
556
|
+
for (const manager of managers) {
|
|
557
|
+
manager.disconnect();
|
|
558
|
+
}
|
|
559
|
+
emitAggregateStatus();
|
|
560
|
+
},
|
|
561
|
+
joinRoom(room, handler) {
|
|
562
|
+
const { unsubscribe } = ensureRoom(room);
|
|
563
|
+
const current = rooms.get(room);
|
|
564
|
+
current?.handlers.add(handler);
|
|
565
|
+
return () => {
|
|
566
|
+
const latest = rooms.get(room);
|
|
567
|
+
if (!latest) return;
|
|
568
|
+
latest.handlers.delete(handler);
|
|
569
|
+
if (latest.handlers.size === 0) {
|
|
570
|
+
unsubscribe();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
},
|
|
574
|
+
joinRoomAsync(room, handler) {
|
|
575
|
+
const { ready, unsubscribe } = ensureRoom(room);
|
|
576
|
+
const current = rooms.get(room);
|
|
577
|
+
current?.handlers.add(handler);
|
|
578
|
+
return {
|
|
579
|
+
ready,
|
|
580
|
+
unsubscribe: () => {
|
|
581
|
+
const latest = rooms.get(room);
|
|
582
|
+
if (!latest) return;
|
|
583
|
+
latest.handlers.delete(handler);
|
|
584
|
+
if (latest.handlers.size === 0) {
|
|
585
|
+
unsubscribe();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
leaveRoom(room) {
|
|
591
|
+
const current = rooms.get(room);
|
|
592
|
+
current?.unsubscribe();
|
|
593
|
+
},
|
|
594
|
+
publish(room, data) {
|
|
595
|
+
for (const manager of managers) {
|
|
596
|
+
manager.publish(room, data);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
sendRaw(message) {
|
|
600
|
+
for (const manager of managers) {
|
|
601
|
+
manager.sendRaw(message);
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
onMessage(handler) {
|
|
605
|
+
messageListeners.add(handler);
|
|
606
|
+
return () => messageListeners.delete(handler);
|
|
607
|
+
},
|
|
608
|
+
onStatus(handler) {
|
|
609
|
+
statusListeners.add(handler);
|
|
610
|
+
return () => statusListeners.delete(handler);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function dedupeHubConfigs(configs) {
|
|
615
|
+
const seen = /* @__PURE__ */ new Set();
|
|
616
|
+
return configs.filter((config) => {
|
|
617
|
+
if (!config.url || seen.has(config.url)) return false;
|
|
618
|
+
seen.add(config.url);
|
|
619
|
+
return true;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function aggregateConnectionStatus(statuses) {
|
|
623
|
+
if (statuses.includes("connected")) return "connected";
|
|
624
|
+
if (statuses.includes("connecting")) return "connecting";
|
|
625
|
+
if (statuses.length > 0 && statuses.every((status) => status === "error")) return "error";
|
|
626
|
+
if (statuses.includes("error")) return "error";
|
|
627
|
+
return "disconnected";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/sync/meta-bridge.ts
|
|
631
|
+
var METABRIDGE_ORIGIN = "metabridge";
|
|
632
|
+
var METABRIDGE_SEED_ORIGIN = "metabridge-seed";
|
|
633
|
+
function createMetaBridge(store, options) {
|
|
634
|
+
const warnOnExternal = options?.warnOnExternalMetaChanges ?? true;
|
|
635
|
+
function writePropertiesToMeta(doc, properties, origin) {
|
|
636
|
+
const metaMap = doc.getMap("meta");
|
|
637
|
+
doc.transact(() => {
|
|
638
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
639
|
+
if (key.startsWith("_")) continue;
|
|
640
|
+
metaMap.set(key, value);
|
|
641
|
+
}
|
|
642
|
+
}, origin);
|
|
643
|
+
}
|
|
644
|
+
function setupMetaMonitor(doc, nodeId) {
|
|
645
|
+
if (!warnOnExternal) return () => {
|
|
646
|
+
};
|
|
647
|
+
const metaMap = doc.getMap("meta");
|
|
648
|
+
const observer = (event, transaction) => {
|
|
649
|
+
const origin = transaction.origin;
|
|
650
|
+
if (origin === METABRIDGE_ORIGIN || origin === METABRIDGE_SEED_ORIGIN) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (origin === null || origin === "local") {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const changedKeys = Array.from(event.changes.keys.keys());
|
|
657
|
+
console.warn(
|
|
658
|
+
`[MetaBridge] Meta map change from non-MetaBridge source (BLOCKED from NodeStore):`,
|
|
659
|
+
{
|
|
660
|
+
nodeId,
|
|
661
|
+
keys: changedKeys,
|
|
662
|
+
origin,
|
|
663
|
+
hint: "Property changes should go through mutate(), not Y.Doc meta map"
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
};
|
|
667
|
+
metaMap.observe(observer);
|
|
668
|
+
return () => metaMap.unobserve(observer);
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
observe(nodeId, doc) {
|
|
672
|
+
const unsubscribeStore = store.subscribe((event) => {
|
|
673
|
+
if (event.change.payload.nodeId !== nodeId) return;
|
|
674
|
+
const properties = event.change.payload.properties;
|
|
675
|
+
if (!properties || Object.keys(properties).length === 0) return;
|
|
676
|
+
writePropertiesToMeta(doc, properties, METABRIDGE_ORIGIN);
|
|
677
|
+
});
|
|
678
|
+
const unsubscribeMonitor = setupMetaMonitor(doc, nodeId);
|
|
679
|
+
return () => {
|
|
680
|
+
unsubscribeStore();
|
|
681
|
+
unsubscribeMonitor();
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
async seed(nodeId, doc) {
|
|
685
|
+
const node = await store.get(nodeId);
|
|
686
|
+
if (!node) return;
|
|
687
|
+
const properties = node.properties;
|
|
688
|
+
if (!properties || Object.keys(properties).length === 0) return;
|
|
689
|
+
writePropertiesToMeta(doc, properties, METABRIDGE_SEED_ORIGIN);
|
|
690
|
+
},
|
|
691
|
+
// Backward compatibility alias
|
|
692
|
+
async applyNow(nodeId, doc) {
|
|
693
|
+
return this.seed(nodeId, doc);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/sync/node-pool.ts
|
|
699
|
+
import * as Y from "yjs";
|
|
700
|
+
function createNodePool(config) {
|
|
701
|
+
const entries = /* @__PURE__ */ new Map();
|
|
702
|
+
const persistTimers = /* @__PURE__ */ new Map();
|
|
703
|
+
const maxWarm = config.maxWarm ?? 50;
|
|
704
|
+
const persistDelay = config.persistDelay ?? 2e3;
|
|
705
|
+
async function loadDoc(nodeId) {
|
|
706
|
+
const doc = new Y.Doc({ guid: nodeId, gc: false });
|
|
707
|
+
const content = await config.storage.getDocumentContent(nodeId);
|
|
708
|
+
if (content && content.length > 0) {
|
|
709
|
+
Y.applyUpdate(doc, content);
|
|
710
|
+
}
|
|
711
|
+
return doc;
|
|
712
|
+
}
|
|
713
|
+
function schedulePersist(nodeId) {
|
|
714
|
+
const existing = persistTimers.get(nodeId);
|
|
715
|
+
if (existing) clearTimeout(existing);
|
|
716
|
+
persistTimers.set(
|
|
717
|
+
nodeId,
|
|
718
|
+
setTimeout(async () => {
|
|
719
|
+
persistTimers.delete(nodeId);
|
|
720
|
+
const entry = entries.get(nodeId);
|
|
721
|
+
if (entry && entry.dirty) {
|
|
722
|
+
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
723
|
+
await config.storage.setDocumentContent(nodeId, content);
|
|
724
|
+
entry.dirty = false;
|
|
725
|
+
}
|
|
726
|
+
}, persistDelay)
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
async function evictIfNeeded() {
|
|
730
|
+
let warmCount = 0;
|
|
731
|
+
const warmEntries = [];
|
|
732
|
+
for (const [id, entry] of entries) {
|
|
733
|
+
if (entry.state === "warm") {
|
|
734
|
+
warmCount++;
|
|
735
|
+
warmEntries.push([id, entry]);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (warmCount <= maxWarm) return;
|
|
739
|
+
warmEntries.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
740
|
+
const toEvict = warmEntries.slice(0, warmCount - maxWarm);
|
|
741
|
+
await Promise.all(
|
|
742
|
+
toEvict.map(async ([id, entry]) => {
|
|
743
|
+
try {
|
|
744
|
+
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
745
|
+
await config.storage.setDocumentContent(id, content);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
console.error(`[NodePool] Failed to persist document ${id} during eviction:`, err);
|
|
748
|
+
}
|
|
749
|
+
if (entry.unobserveMeta) {
|
|
750
|
+
entry.unobserveMeta();
|
|
751
|
+
}
|
|
752
|
+
config.onDocEvict?.(id, entry.doc);
|
|
753
|
+
entry.doc.destroy();
|
|
754
|
+
entries.delete(id);
|
|
755
|
+
})
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
async acquire(nodeId) {
|
|
760
|
+
let entry = entries.get(nodeId);
|
|
761
|
+
if (entry) {
|
|
762
|
+
entry.refCount++;
|
|
763
|
+
entry.state = "active";
|
|
764
|
+
entry.lastAccess = Date.now();
|
|
765
|
+
return entry.doc;
|
|
766
|
+
}
|
|
767
|
+
const doc = await loadDoc(nodeId);
|
|
768
|
+
doc.on("update", () => {
|
|
769
|
+
const e = entries.get(nodeId);
|
|
770
|
+
if (e) {
|
|
771
|
+
e.dirty = true;
|
|
772
|
+
schedulePersist(nodeId);
|
|
773
|
+
config.onDocUpdate?.(nodeId, doc);
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
const unobserveMeta = config.metaBridge.observe(nodeId, doc);
|
|
777
|
+
entry = {
|
|
778
|
+
doc,
|
|
779
|
+
state: "active",
|
|
780
|
+
refCount: 1,
|
|
781
|
+
lastAccess: Date.now(),
|
|
782
|
+
dirty: false,
|
|
783
|
+
unobserveMeta
|
|
784
|
+
};
|
|
785
|
+
entries.set(nodeId, entry);
|
|
786
|
+
return doc;
|
|
787
|
+
},
|
|
788
|
+
release(nodeId) {
|
|
789
|
+
const entry = entries.get(nodeId);
|
|
790
|
+
if (!entry) return;
|
|
791
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
792
|
+
if (entry.refCount === 0) {
|
|
793
|
+
entry.state = "warm";
|
|
794
|
+
entry.lastAccess = Date.now();
|
|
795
|
+
void evictIfNeeded();
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
has(nodeId) {
|
|
799
|
+
return entries.has(nodeId);
|
|
800
|
+
},
|
|
801
|
+
getState(nodeId) {
|
|
802
|
+
return entries.get(nodeId)?.state ?? null;
|
|
803
|
+
},
|
|
804
|
+
get size() {
|
|
805
|
+
return entries.size;
|
|
806
|
+
},
|
|
807
|
+
async flushAll() {
|
|
808
|
+
for (const timer of persistTimers.values()) {
|
|
809
|
+
clearTimeout(timer);
|
|
810
|
+
}
|
|
811
|
+
persistTimers.clear();
|
|
812
|
+
const promises = [];
|
|
813
|
+
for (const [id, entry] of entries) {
|
|
814
|
+
if (entry.dirty) {
|
|
815
|
+
const content = Y.encodeStateAsUpdate(entry.doc);
|
|
816
|
+
promises.push(
|
|
817
|
+
config.storage.setDocumentContent(id, content).then(() => {
|
|
818
|
+
entry.dirty = false;
|
|
819
|
+
})
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
await Promise.all(promises);
|
|
824
|
+
},
|
|
825
|
+
async destroy() {
|
|
826
|
+
await this.flushAll();
|
|
827
|
+
for (const [, entry] of entries) {
|
|
828
|
+
if (entry.unobserveMeta) entry.unobserveMeta();
|
|
829
|
+
entry.doc.destroy();
|
|
830
|
+
}
|
|
831
|
+
entries.clear();
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/sync/node-store-sync-provider.ts
|
|
837
|
+
import { base64ToBytes, bytesToBase64 } from "@xnetjs/crypto";
|
|
838
|
+
var KNOWN_CHANGE_TYPES = /* @__PURE__ */ new Set(["node-change"]);
|
|
839
|
+
var MAX_SENDS_PER_WINDOW = 40;
|
|
840
|
+
var SEND_WINDOW_MS = 1e3;
|
|
841
|
+
var SYNC_RESPONSE_TIMEOUT_MS = 4e3;
|
|
842
|
+
var STRUCTURAL_REJECTION_CODES = /* @__PURE__ */ new Set(["INVALID_HASH", "INVALID_SIGNATURE", "INVALID_CHANGE"]);
|
|
843
|
+
var MAX_STRUCTURAL_REJECTIONS = 5;
|
|
844
|
+
var NodeStoreSyncProvider = class {
|
|
845
|
+
constructor(store, room) {
|
|
846
|
+
this.store = store;
|
|
847
|
+
this.room = room;
|
|
848
|
+
}
|
|
849
|
+
/** Confirmed, persisted high-water mark (advanced from the hub's response). */
|
|
850
|
+
lastSyncedLamport = 0;
|
|
851
|
+
/** Optimistic in-memory cursor: lamport of the last change actually sent. */
|
|
852
|
+
pushedThrough = 0;
|
|
853
|
+
cursorLoaded = false;
|
|
854
|
+
connection = null;
|
|
855
|
+
roomCleanup = null;
|
|
856
|
+
statusCleanup = null;
|
|
857
|
+
messageCleanup = null;
|
|
858
|
+
storeCleanup = null;
|
|
859
|
+
unknownChangeTypeListeners = /* @__PURE__ */ new Set();
|
|
860
|
+
// Throttled send queue.
|
|
861
|
+
sendQueue = [];
|
|
862
|
+
queuedHashes = /* @__PURE__ */ new Set();
|
|
863
|
+
sendTimer = null;
|
|
864
|
+
sentInWindow = 0;
|
|
865
|
+
// Request-sync-first: resolver for the in-flight node-sync-response wait.
|
|
866
|
+
syncResponseResolver = null;
|
|
867
|
+
// Protocol-skew circuit breaker state. `structuralRejections` counts
|
|
868
|
+
// consecutive structural node-errors (reset on forward progress); once it
|
|
869
|
+
// trips, `outboundHalted` stops all pushes until the next reconnect.
|
|
870
|
+
structuralRejections = 0;
|
|
871
|
+
outboundHalted = false;
|
|
872
|
+
// Resolver for an in-flight node-clear ("reset my data") round-trip.
|
|
873
|
+
clearResolver = null;
|
|
874
|
+
// One-shot: emit a performance mark when the first remote change BEGINS
|
|
875
|
+
// applying to the local store (marked just before the awaited write, so it
|
|
876
|
+
// captures when the inbound write burst starts contending with reads on the
|
|
877
|
+
// single SQLite worker). Makes that contention visible on the boot timeline
|
|
878
|
+
// and in DevTools (exploration 0212).
|
|
879
|
+
firstRemoteApplyMarked = false;
|
|
880
|
+
/**
|
|
881
|
+
* Mark the first remote apply once. Platform-agnostic and defensive: a
|
|
882
|
+
* missing `performance` global, or a throw, is a no-op — instrumentation
|
|
883
|
+
* must never break sync.
|
|
884
|
+
*/
|
|
885
|
+
markFirstRemoteApply() {
|
|
886
|
+
if (this.firstRemoteApplyMarked) return;
|
|
887
|
+
this.firstRemoteApplyMarked = true;
|
|
888
|
+
try {
|
|
889
|
+
performance?.mark?.("xnet:sync:first-remote-apply");
|
|
890
|
+
} catch {
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Subscribe to unknown change type events.
|
|
895
|
+
* These are changes received from peers with types this version doesn't know how to process.
|
|
896
|
+
* The changes are still stored in the change log for forward compatibility.
|
|
897
|
+
*
|
|
898
|
+
* @param listener - Callback invoked when an unknown change type is received
|
|
899
|
+
* @returns Unsubscribe function
|
|
900
|
+
*/
|
|
901
|
+
onUnknownChangeType(listener) {
|
|
902
|
+
this.unknownChangeTypeListeners.add(listener);
|
|
903
|
+
return () => {
|
|
904
|
+
this.unknownChangeTypeListeners.delete(listener);
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
emitUnknownChangeType(change, peerId) {
|
|
908
|
+
for (const listener of this.unknownChangeTypeListeners) {
|
|
909
|
+
try {
|
|
910
|
+
listener(change, peerId);
|
|
911
|
+
} catch (err) {
|
|
912
|
+
console.error("Error in unknown change type listener:", err);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
attach(connection) {
|
|
917
|
+
this.connection = connection;
|
|
918
|
+
this.roomCleanup = connection.joinRoom(this.room, (data) => {
|
|
919
|
+
this.handleRoomMessage(data);
|
|
920
|
+
});
|
|
921
|
+
this.messageCleanup = connection.onMessage((message) => {
|
|
922
|
+
this.handleDirectMessage(message);
|
|
923
|
+
});
|
|
924
|
+
this.statusCleanup = connection.onStatus((status) => {
|
|
925
|
+
if (status === "connected") {
|
|
926
|
+
void this.onConnected();
|
|
927
|
+
} else {
|
|
928
|
+
this.onDisconnected();
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
this.storeCleanup = this.store.subscribe((event) => {
|
|
932
|
+
if (event.isRemote) return;
|
|
933
|
+
this.enqueueChange(event.change);
|
|
934
|
+
});
|
|
935
|
+
if (connection.status === "connected") {
|
|
936
|
+
void this.onConnected();
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
detach() {
|
|
940
|
+
this.roomCleanup?.();
|
|
941
|
+
this.statusCleanup?.();
|
|
942
|
+
this.messageCleanup?.();
|
|
943
|
+
this.storeCleanup?.();
|
|
944
|
+
this.roomCleanup = null;
|
|
945
|
+
this.statusCleanup = null;
|
|
946
|
+
this.messageCleanup = null;
|
|
947
|
+
this.storeCleanup = null;
|
|
948
|
+
this.connection = null;
|
|
949
|
+
this.clearSendQueue();
|
|
950
|
+
this.resolveSyncResponse();
|
|
951
|
+
this.resolveClear(0);
|
|
952
|
+
}
|
|
953
|
+
// ─── Connect lifecycle ──────────────────────────────────────────────────
|
|
954
|
+
/**
|
|
955
|
+
* On connect: load the persisted cursor, ask the hub for its high-water mark
|
|
956
|
+
* FIRST, then push only the changes the hub is actually missing. Combined
|
|
957
|
+
* with the persisted cursor, a normal reload of an in-sync workspace pushes
|
|
958
|
+
* nothing (exploration 0206).
|
|
959
|
+
*/
|
|
960
|
+
async onConnected() {
|
|
961
|
+
this.outboundHalted = false;
|
|
962
|
+
this.structuralRejections = 0;
|
|
963
|
+
await this.ensureCursorLoaded();
|
|
964
|
+
if (!this.connection || this.connection.status !== "connected") return;
|
|
965
|
+
this.requestSync();
|
|
966
|
+
await this.waitForSyncResponse();
|
|
967
|
+
await this.syncLocalChanges();
|
|
968
|
+
}
|
|
969
|
+
onDisconnected() {
|
|
970
|
+
this.clearSendQueue();
|
|
971
|
+
this.resolveSyncResponse();
|
|
972
|
+
}
|
|
973
|
+
async ensureCursorLoaded() {
|
|
974
|
+
if (this.cursorLoaded) return;
|
|
975
|
+
try {
|
|
976
|
+
const stored = await this.store.getSyncCursor(this.room);
|
|
977
|
+
this.lastSyncedLamport = Math.max(this.lastSyncedLamport, stored);
|
|
978
|
+
this.pushedThrough = Math.max(this.pushedThrough, this.lastSyncedLamport);
|
|
979
|
+
} catch (err) {
|
|
980
|
+
console.warn("[NodeStoreSync] failed to load sync cursor; replaying from 0:", err);
|
|
981
|
+
}
|
|
982
|
+
this.cursorLoaded = true;
|
|
983
|
+
}
|
|
984
|
+
waitForSyncResponse() {
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
this.syncResponseResolver = resolve;
|
|
987
|
+
setTimeout(() => this.resolveSyncResponse(), SYNC_RESPONSE_TIMEOUT_MS);
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
resolveSyncResponse() {
|
|
991
|
+
const resolve = this.syncResponseResolver;
|
|
992
|
+
this.syncResponseResolver = null;
|
|
993
|
+
resolve?.();
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Ask the hub to wipe every stored change for this room ("reset my data"),
|
|
997
|
+
* then reset the local sync cursor so a later sync re-pulls from scratch.
|
|
998
|
+
* Resolves with the number of changes the hub removed (0 on timeout or when
|
|
999
|
+
* offline). Pairs with a local wipe + reload for a full reset.
|
|
1000
|
+
*/
|
|
1001
|
+
async clearRoom() {
|
|
1002
|
+
if (!this.connection || this.connection.status !== "connected") return 0;
|
|
1003
|
+
const cleared = await new Promise((resolve) => {
|
|
1004
|
+
this.clearResolver = resolve;
|
|
1005
|
+
this.connection?.sendRaw({ type: "node-clear", room: this.room });
|
|
1006
|
+
setTimeout(() => this.resolveClear(0), SYNC_RESPONSE_TIMEOUT_MS);
|
|
1007
|
+
});
|
|
1008
|
+
this.lastSyncedLamport = 0;
|
|
1009
|
+
this.pushedThrough = 0;
|
|
1010
|
+
this.cursorLoaded = true;
|
|
1011
|
+
try {
|
|
1012
|
+
await this.store.setSyncCursor(this.room, 0);
|
|
1013
|
+
} catch {
|
|
1014
|
+
}
|
|
1015
|
+
return cleared;
|
|
1016
|
+
}
|
|
1017
|
+
resolveClear(cleared) {
|
|
1018
|
+
const resolve = this.clearResolver;
|
|
1019
|
+
this.clearResolver = null;
|
|
1020
|
+
resolve?.(cleared);
|
|
1021
|
+
}
|
|
1022
|
+
// ─── Inbound ────────────────────────────────────────────────────────────
|
|
1023
|
+
handleRoomMessage(data) {
|
|
1024
|
+
if (data.type === "node-change") {
|
|
1025
|
+
const change = data.change;
|
|
1026
|
+
void this.handleRemoteChange(change);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
handleDirectMessage(message) {
|
|
1030
|
+
if (message.type === "node-cleared") {
|
|
1031
|
+
if (message.room === this.room) {
|
|
1032
|
+
this.resolveClear(typeof message.cleared === "number" ? message.cleared : 0);
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (message.type === "node-error") {
|
|
1037
|
+
const code = typeof message.code === "string" ? message.code : "UNKNOWN";
|
|
1038
|
+
console.warn(
|
|
1039
|
+
"[NodeStoreSync] hub rejected a node change:",
|
|
1040
|
+
code,
|
|
1041
|
+
message.error ?? message.message ?? ""
|
|
1042
|
+
);
|
|
1043
|
+
if (STRUCTURAL_REJECTION_CODES.has(code)) {
|
|
1044
|
+
this.recordStructuralRejection(code, message);
|
|
1045
|
+
}
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (message.type !== "node-sync-response") return;
|
|
1049
|
+
const response = message;
|
|
1050
|
+
if (response.room !== this.room) return;
|
|
1051
|
+
void this.handleSyncResponse(response);
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Trip the circuit breaker after enough consecutive structural rejections.
|
|
1055
|
+
*
|
|
1056
|
+
* Structural rejections (bad hash/signature/shape) are not transient: the
|
|
1057
|
+
* same change re-sent is rejected again, and — when it's a protocol/build
|
|
1058
|
+
* skew — so is every other local change. Rather than re-flood the hub forever
|
|
1059
|
+
* (the symptom that motivated this), we stop pushing, drop the queue, and log
|
|
1060
|
+
* ONE actionable error. A reconnect clears the breaker (the hub may have been
|
|
1061
|
+
* upgraded) via {@link onConnected}, and any forward progress resets the
|
|
1062
|
+
* counter so sparse one-off rejections never accumulate to a false trip.
|
|
1063
|
+
*/
|
|
1064
|
+
recordStructuralRejection(code, message) {
|
|
1065
|
+
this.structuralRejections += 1;
|
|
1066
|
+
if (this.outboundHalted || this.structuralRejections < MAX_STRUCTURAL_REJECTIONS) return;
|
|
1067
|
+
this.outboundHalted = true;
|
|
1068
|
+
this.clearSendQueue();
|
|
1069
|
+
console.error(
|
|
1070
|
+
`[NodeStoreSync] Pausing outbound sync after ${this.structuralRejections} consecutive "${code}" rejections. Local changes are valid but the hub keeps rejecting them \u2014 this usually means the hub is on an incompatible @xnetjs/sync build (protocol/hash skew). Outbound sync resumes on reconnect. Hub said: ${message.error ?? message.message ?? "(no detail)"}`
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
requestSync() {
|
|
1074
|
+
if (!this.connection) return;
|
|
1075
|
+
this.connection.sendRaw({
|
|
1076
|
+
type: "node-sync-request",
|
|
1077
|
+
room: this.room,
|
|
1078
|
+
sinceLamport: this.lastSyncedLamport
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
async handleRemoteChange(serialized, peerId = "unknown") {
|
|
1082
|
+
const change = this.deserializeChange(serialized);
|
|
1083
|
+
if (serialized.lamportTime > this.lastSyncedLamport) {
|
|
1084
|
+
this.lastSyncedLamport = serialized.lamportTime;
|
|
1085
|
+
}
|
|
1086
|
+
if (!KNOWN_CHANGE_TYPES.has(change.type)) {
|
|
1087
|
+
console.warn(
|
|
1088
|
+
`Received unknown change type "${change.type}" from peer ${peerId}. Change stored but not processed (forward compatibility).`
|
|
1089
|
+
);
|
|
1090
|
+
this.emitUnknownChangeType(change, peerId);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
this.markFirstRemoteApply();
|
|
1095
|
+
await this.store.applyRemoteChange(change);
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
console.warn(
|
|
1098
|
+
`[NodeStoreSync] skipping un-appliable remote change for node ${change.payload?.nodeId}:`,
|
|
1099
|
+
err instanceof Error ? err.message : err
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
async handleSyncResponse(response, peerId = "hub") {
|
|
1104
|
+
if (response.changes.length > 0) {
|
|
1105
|
+
const allChanges = response.changes.map((s) => this.deserializeChange(s));
|
|
1106
|
+
const knownChanges = [];
|
|
1107
|
+
for (const change of allChanges) {
|
|
1108
|
+
if (KNOWN_CHANGE_TYPES.has(change.type)) {
|
|
1109
|
+
knownChanges.push(change);
|
|
1110
|
+
} else {
|
|
1111
|
+
console.warn(
|
|
1112
|
+
`Received unknown change type "${change.type}" from ${peerId}. Change stored but not processed (forward compatibility).`
|
|
1113
|
+
);
|
|
1114
|
+
this.emitUnknownChangeType(change, peerId);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (knownChanges.length > 0) {
|
|
1118
|
+
this.markFirstRemoteApply();
|
|
1119
|
+
await this.store.applyRemoteChanges(knownChanges);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (response.highWaterMark > this.lastSyncedLamport) {
|
|
1123
|
+
this.lastSyncedLamport = response.highWaterMark;
|
|
1124
|
+
this.pushedThrough = Math.max(this.pushedThrough, this.lastSyncedLamport);
|
|
1125
|
+
this.structuralRejections = 0;
|
|
1126
|
+
try {
|
|
1127
|
+
await this.store.setSyncCursor(this.room, this.lastSyncedLamport);
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
console.warn("[NodeStoreSync] failed to persist sync cursor:", err);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
this.resolveSyncResponse();
|
|
1133
|
+
}
|
|
1134
|
+
// ─── Outbound (throttled) ────────────────────────────────────────────────
|
|
1135
|
+
/** Enqueue every local change since the last confirmed send, then drain. */
|
|
1136
|
+
async syncLocalChanges() {
|
|
1137
|
+
if (this.outboundHalted) return;
|
|
1138
|
+
if (!this.connection || this.connection.status !== "connected") return;
|
|
1139
|
+
const changes = await this.store.getChangesSince(this.pushedThrough);
|
|
1140
|
+
if (changes.length === 0) return;
|
|
1141
|
+
changes.sort((a, b) => a.lamport - b.lamport || a.authorDID.localeCompare(b.authorDID));
|
|
1142
|
+
for (const change of changes) {
|
|
1143
|
+
this.enqueueChange(change);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
/** Queue a change for throttled broadcast (deduped by hash). */
|
|
1147
|
+
enqueueChange(change) {
|
|
1148
|
+
if (this.outboundHalted) return;
|
|
1149
|
+
if (change.lamport <= this.pushedThrough) return;
|
|
1150
|
+
if (this.queuedHashes.has(change.hash)) return;
|
|
1151
|
+
this.queuedHashes.add(change.hash);
|
|
1152
|
+
this.sendQueue.push(change);
|
|
1153
|
+
this.scheduleDrain(0);
|
|
1154
|
+
}
|
|
1155
|
+
scheduleDrain(delayMs) {
|
|
1156
|
+
if (this.sendTimer) return;
|
|
1157
|
+
this.sendTimer = setTimeout(() => {
|
|
1158
|
+
this.sendTimer = null;
|
|
1159
|
+
this.drain();
|
|
1160
|
+
}, delayMs);
|
|
1161
|
+
}
|
|
1162
|
+
drain() {
|
|
1163
|
+
if (this.outboundHalted) return;
|
|
1164
|
+
if (!this.connection || this.connection.status !== "connected") return;
|
|
1165
|
+
this.sentInWindow = 0;
|
|
1166
|
+
while (this.sendQueue.length > 0 && this.sentInWindow < MAX_SENDS_PER_WINDOW) {
|
|
1167
|
+
const change = this.sendQueue.shift();
|
|
1168
|
+
this.queuedHashes.delete(change.hash);
|
|
1169
|
+
this.publishChange(change);
|
|
1170
|
+
this.sentInWindow += 1;
|
|
1171
|
+
}
|
|
1172
|
+
if (this.sendQueue.length > 0) {
|
|
1173
|
+
this.scheduleDrain(SEND_WINDOW_MS);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
publishChange(change) {
|
|
1177
|
+
if (!this.connection) return;
|
|
1178
|
+
this.connection.publish(this.room, {
|
|
1179
|
+
type: "node-change",
|
|
1180
|
+
room: this.room,
|
|
1181
|
+
change: this.serializeChange(change)
|
|
1182
|
+
});
|
|
1183
|
+
this.pushedThrough = Math.max(this.pushedThrough, change.lamport);
|
|
1184
|
+
}
|
|
1185
|
+
clearSendQueue() {
|
|
1186
|
+
if (this.sendTimer) {
|
|
1187
|
+
clearTimeout(this.sendTimer);
|
|
1188
|
+
this.sendTimer = null;
|
|
1189
|
+
}
|
|
1190
|
+
this.sendQueue = [];
|
|
1191
|
+
this.queuedHashes.clear();
|
|
1192
|
+
this.sentInWindow = 0;
|
|
1193
|
+
}
|
|
1194
|
+
serializeChange(change) {
|
|
1195
|
+
return {
|
|
1196
|
+
id: change.id,
|
|
1197
|
+
type: change.type,
|
|
1198
|
+
hash: change.hash,
|
|
1199
|
+
room: this.room,
|
|
1200
|
+
nodeId: change.payload.nodeId,
|
|
1201
|
+
schemaId: change.payload.schemaId,
|
|
1202
|
+
lamportTime: change.lamport,
|
|
1203
|
+
lamportAuthor: change.authorDID,
|
|
1204
|
+
authorDid: change.authorDID,
|
|
1205
|
+
wallTime: change.wallTime,
|
|
1206
|
+
parentHash: change.parentHash,
|
|
1207
|
+
payload: change.payload,
|
|
1208
|
+
signatureB64: bytesToBase64(change.signature),
|
|
1209
|
+
// protocolVersion is part of the hashed fields — dropping it makes
|
|
1210
|
+
// every relayed change fail verifyChangeHash on the receiving side
|
|
1211
|
+
protocolVersion: change.protocolVersion,
|
|
1212
|
+
batchId: change.batchId,
|
|
1213
|
+
batchIndex: change.batchIndex,
|
|
1214
|
+
batchSize: change.batchSize
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
deserializeChange(serialized) {
|
|
1218
|
+
const payload = serialized.payload && !serialized.payload.schemaId && serialized.schemaId ? { ...serialized.payload, schemaId: serialized.schemaId } : serialized.payload;
|
|
1219
|
+
return {
|
|
1220
|
+
id: serialized.id,
|
|
1221
|
+
type: serialized.type,
|
|
1222
|
+
hash: serialized.hash,
|
|
1223
|
+
parentHash: serialized.parentHash,
|
|
1224
|
+
authorDID: serialized.authorDid,
|
|
1225
|
+
signature: base64ToBytes(serialized.signatureB64),
|
|
1226
|
+
wallTime: serialized.wallTime,
|
|
1227
|
+
lamport: serialized.lamportTime,
|
|
1228
|
+
payload,
|
|
1229
|
+
protocolVersion: serialized.protocolVersion,
|
|
1230
|
+
batchId: serialized.batchId,
|
|
1231
|
+
batchIndex: serialized.batchIndex,
|
|
1232
|
+
batchSize: serialized.batchSize
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
// src/sync/offline-queue.ts
|
|
1238
|
+
function toBase64(data) {
|
|
1239
|
+
let binary = "";
|
|
1240
|
+
for (let i = 0; i < data.length; i++) {
|
|
1241
|
+
binary += String.fromCharCode(data[i]);
|
|
1242
|
+
}
|
|
1243
|
+
return btoa(binary);
|
|
1244
|
+
}
|
|
1245
|
+
var SAVE_DEBOUNCE_MS = 100;
|
|
1246
|
+
function createOfflineQueue(config) {
|
|
1247
|
+
const storageKey = config.storageKey ?? "_xnet_offline_queue";
|
|
1248
|
+
const maxSize = config.maxSize ?? 1e3;
|
|
1249
|
+
let entries = [];
|
|
1250
|
+
let storageNodeReady = false;
|
|
1251
|
+
const encoder = new TextEncoder();
|
|
1252
|
+
const decoder = new TextDecoder();
|
|
1253
|
+
let saveTimer = null;
|
|
1254
|
+
let saveResolvers = [];
|
|
1255
|
+
const ensureStorageNode = async () => {
|
|
1256
|
+
if (storageNodeReady) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
const existing = await config.storage.getNode(storageKey);
|
|
1260
|
+
if (existing) {
|
|
1261
|
+
storageNodeReady = true;
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const now = Date.now();
|
|
1265
|
+
const systemDid = "did:key:offline-queue";
|
|
1266
|
+
const node = {
|
|
1267
|
+
id: storageKey,
|
|
1268
|
+
schemaId: "xnet://xnet.system/OfflineQueueState",
|
|
1269
|
+
properties: {},
|
|
1270
|
+
timestamps: {},
|
|
1271
|
+
deleted: true,
|
|
1272
|
+
deletedAt: {
|
|
1273
|
+
lamport: 0,
|
|
1274
|
+
author: systemDid,
|
|
1275
|
+
wallTime: now
|
|
1276
|
+
},
|
|
1277
|
+
createdAt: now,
|
|
1278
|
+
createdBy: systemDid,
|
|
1279
|
+
updatedAt: now,
|
|
1280
|
+
updatedBy: systemDid
|
|
1281
|
+
};
|
|
1282
|
+
await config.storage.setNode(node);
|
|
1283
|
+
storageNodeReady = true;
|
|
1284
|
+
};
|
|
1285
|
+
const debouncedSave = () => {
|
|
1286
|
+
return new Promise((resolve) => {
|
|
1287
|
+
saveResolvers.push(resolve);
|
|
1288
|
+
if (saveTimer) {
|
|
1289
|
+
clearTimeout(saveTimer);
|
|
1290
|
+
}
|
|
1291
|
+
saveTimer = setTimeout(async () => {
|
|
1292
|
+
saveTimer = null;
|
|
1293
|
+
const resolvers = saveResolvers;
|
|
1294
|
+
saveResolvers = [];
|
|
1295
|
+
try {
|
|
1296
|
+
await ensureStorageNode();
|
|
1297
|
+
const json = JSON.stringify(entries);
|
|
1298
|
+
const bytes = encoder.encode(json);
|
|
1299
|
+
await config.storage.setDocumentContent(storageKey, bytes);
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
console.warn("[OfflineQueue] Failed to persist:", err);
|
|
1302
|
+
}
|
|
1303
|
+
resolvers.forEach((r) => r());
|
|
1304
|
+
}, SAVE_DEBOUNCE_MS);
|
|
1305
|
+
});
|
|
1306
|
+
};
|
|
1307
|
+
return {
|
|
1308
|
+
async enqueue(nodeId, update, clientId) {
|
|
1309
|
+
entries.push({
|
|
1310
|
+
nodeId,
|
|
1311
|
+
update: toBase64(update),
|
|
1312
|
+
clientId,
|
|
1313
|
+
queuedAt: Date.now()
|
|
1314
|
+
});
|
|
1315
|
+
if (entries.length > maxSize) {
|
|
1316
|
+
entries = entries.slice(entries.length - maxSize);
|
|
1317
|
+
}
|
|
1318
|
+
await debouncedSave();
|
|
1319
|
+
},
|
|
1320
|
+
async drain(handler) {
|
|
1321
|
+
let drained = 0;
|
|
1322
|
+
while (entries.length > 0) {
|
|
1323
|
+
const entry = entries[0];
|
|
1324
|
+
try {
|
|
1325
|
+
await handler(entry);
|
|
1326
|
+
entries.shift();
|
|
1327
|
+
drained++;
|
|
1328
|
+
} catch {
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (drained > 0) {
|
|
1333
|
+
await this.save();
|
|
1334
|
+
}
|
|
1335
|
+
return drained;
|
|
1336
|
+
},
|
|
1337
|
+
get size() {
|
|
1338
|
+
return entries.length;
|
|
1339
|
+
},
|
|
1340
|
+
async load() {
|
|
1341
|
+
try {
|
|
1342
|
+
const content = await config.storage.getDocumentContent(storageKey);
|
|
1343
|
+
if (content && content.length > 0) {
|
|
1344
|
+
const json = decoder.decode(content);
|
|
1345
|
+
const parsed = JSON.parse(json);
|
|
1346
|
+
if (Array.isArray(parsed)) {
|
|
1347
|
+
entries = parsed;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
} catch {
|
|
1351
|
+
entries = [];
|
|
1352
|
+
}
|
|
1353
|
+
},
|
|
1354
|
+
async save() {
|
|
1355
|
+
if (saveTimer) {
|
|
1356
|
+
clearTimeout(saveTimer);
|
|
1357
|
+
saveTimer = null;
|
|
1358
|
+
const resolvers = saveResolvers;
|
|
1359
|
+
saveResolvers = [];
|
|
1360
|
+
resolvers.forEach((r) => r());
|
|
1361
|
+
}
|
|
1362
|
+
try {
|
|
1363
|
+
await ensureStorageNode();
|
|
1364
|
+
const json = JSON.stringify(entries);
|
|
1365
|
+
const bytes = encoder.encode(json);
|
|
1366
|
+
await config.storage.setDocumentContent(storageKey, bytes);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
console.warn("[OfflineQueue] Failed to persist:", err);
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
async clear() {
|
|
1372
|
+
entries = [];
|
|
1373
|
+
await this.save();
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/sync/registry.ts
|
|
1379
|
+
function createRegistry(config) {
|
|
1380
|
+
const trackTTL = config.trackTTL ?? 7 * 24 * 60 * 60 * 1e3;
|
|
1381
|
+
const storageKey = config.storageKey ?? "_xnet_tracked_nodes";
|
|
1382
|
+
const tracked = /* @__PURE__ */ new Map();
|
|
1383
|
+
return {
|
|
1384
|
+
track(nodeId, schemaId) {
|
|
1385
|
+
const existing = tracked.get(nodeId);
|
|
1386
|
+
if (existing) {
|
|
1387
|
+
existing.lastOpened = Date.now();
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
tracked.set(nodeId, {
|
|
1391
|
+
nodeId,
|
|
1392
|
+
schemaId,
|
|
1393
|
+
lastOpened: Date.now(),
|
|
1394
|
+
lastSynced: 0,
|
|
1395
|
+
pinned: false
|
|
1396
|
+
});
|
|
1397
|
+
},
|
|
1398
|
+
untrack(nodeId) {
|
|
1399
|
+
tracked.delete(nodeId);
|
|
1400
|
+
},
|
|
1401
|
+
pin(nodeId) {
|
|
1402
|
+
const entry = tracked.get(nodeId);
|
|
1403
|
+
if (entry) entry.pinned = true;
|
|
1404
|
+
},
|
|
1405
|
+
unpin(nodeId) {
|
|
1406
|
+
const entry = tracked.get(nodeId);
|
|
1407
|
+
if (entry) entry.pinned = false;
|
|
1408
|
+
},
|
|
1409
|
+
touch(nodeId) {
|
|
1410
|
+
const entry = tracked.get(nodeId);
|
|
1411
|
+
if (entry) entry.lastOpened = Date.now();
|
|
1412
|
+
},
|
|
1413
|
+
markSynced(nodeId) {
|
|
1414
|
+
const entry = tracked.get(nodeId);
|
|
1415
|
+
if (entry) entry.lastSynced = Date.now();
|
|
1416
|
+
},
|
|
1417
|
+
getTracked() {
|
|
1418
|
+
const now = Date.now();
|
|
1419
|
+
const result = [];
|
|
1420
|
+
for (const entry of tracked.values()) {
|
|
1421
|
+
if (entry.pinned || now - entry.lastOpened < trackTTL) {
|
|
1422
|
+
result.push(entry);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return result;
|
|
1426
|
+
},
|
|
1427
|
+
isTracked(nodeId) {
|
|
1428
|
+
const entry = tracked.get(nodeId);
|
|
1429
|
+
if (!entry) return false;
|
|
1430
|
+
return entry.pinned || Date.now() - entry.lastOpened < trackTTL;
|
|
1431
|
+
},
|
|
1432
|
+
async load() {
|
|
1433
|
+
try {
|
|
1434
|
+
const entries = await config.storage.get(storageKey);
|
|
1435
|
+
if (entries) {
|
|
1436
|
+
for (const entry of entries) {
|
|
1437
|
+
tracked.set(entry.nodeId, entry);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
} catch {
|
|
1441
|
+
}
|
|
1442
|
+
},
|
|
1443
|
+
async save() {
|
|
1444
|
+
const entries = Array.from(tracked.values());
|
|
1445
|
+
try {
|
|
1446
|
+
await config.storage.set(storageKey, entries);
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
console.warn("[Registry] Failed to persist:", err);
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
prune() {
|
|
1452
|
+
const now = Date.now();
|
|
1453
|
+
let pruned = 0;
|
|
1454
|
+
for (const [id, entry] of tracked) {
|
|
1455
|
+
if (!entry.pinned && now - entry.lastOpened >= trackTTL) {
|
|
1456
|
+
tracked.delete(id);
|
|
1457
|
+
pruned++;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return pruned;
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// src/sync/sync-manager.ts
|
|
1466
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
1467
|
+
console.log("[SyncManager] Module loaded from source!");
|
|
1468
|
+
}
|
|
1469
|
+
function log2(...args) {
|
|
1470
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
1471
|
+
console.log("[SyncManager]", ...args);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
function createRegistryStorageAdapter(storage) {
|
|
1475
|
+
const encoder = new TextEncoder();
|
|
1476
|
+
const decoder = new TextDecoder();
|
|
1477
|
+
return {
|
|
1478
|
+
async get(key) {
|
|
1479
|
+
try {
|
|
1480
|
+
const content = await storage.getDocumentContent(key);
|
|
1481
|
+
if (!content || content.length === 0) return null;
|
|
1482
|
+
const json = decoder.decode(content);
|
|
1483
|
+
return JSON.parse(json);
|
|
1484
|
+
} catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
},
|
|
1488
|
+
async set(key, entries) {
|
|
1489
|
+
const json = JSON.stringify(entries);
|
|
1490
|
+
const bytes = encoder.encode(json);
|
|
1491
|
+
await storage.setDocumentContent(key, bytes);
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
function serializeEnvelope(envelope) {
|
|
1496
|
+
let update = "";
|
|
1497
|
+
for (let i = 0; i < envelope.update.length; i++) {
|
|
1498
|
+
update += String.fromCharCode(envelope.update[i]);
|
|
1499
|
+
}
|
|
1500
|
+
let signature = "";
|
|
1501
|
+
for (let i = 0; i < envelope.signature.length; i++) {
|
|
1502
|
+
signature += String.fromCharCode(envelope.signature[i]);
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
update: btoa(update),
|
|
1506
|
+
authorDID: envelope.authorDID,
|
|
1507
|
+
signature: btoa(signature),
|
|
1508
|
+
timestamp: envelope.timestamp,
|
|
1509
|
+
clientId: envelope.clientId
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function deserializeEnvelope(data) {
|
|
1513
|
+
try {
|
|
1514
|
+
const update = data.update;
|
|
1515
|
+
const signature = data.signature;
|
|
1516
|
+
return {
|
|
1517
|
+
update: Uint8Array.from(atob(update), (char) => char.charCodeAt(0)),
|
|
1518
|
+
authorDID: data.authorDID,
|
|
1519
|
+
signature: Uint8Array.from(atob(signature), (char) => char.charCodeAt(0)),
|
|
1520
|
+
timestamp: data.timestamp,
|
|
1521
|
+
clientId: data.clientId
|
|
1522
|
+
};
|
|
1523
|
+
} catch {
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function hasEnvelope(data) {
|
|
1528
|
+
return typeof data.envelope === "object" && data.envelope !== null && "update" in data.envelope && "authorDID" in data.envelope && "signature" in data.envelope;
|
|
1529
|
+
}
|
|
1530
|
+
function extractBlobCids(doc) {
|
|
1531
|
+
const cids = [];
|
|
1532
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1533
|
+
for (const name of ["default", "prosemirror", ""]) {
|
|
1534
|
+
let fragment;
|
|
1535
|
+
try {
|
|
1536
|
+
fragment = doc.getXmlFragment(name);
|
|
1537
|
+
} catch {
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
if (!fragment || fragment.length === 0) continue;
|
|
1541
|
+
walkXmlFragment(fragment, (node) => {
|
|
1542
|
+
if (node instanceof Y2.XmlElement) {
|
|
1543
|
+
const cid = node.getAttribute("cid");
|
|
1544
|
+
if (typeof cid === "string" && cid.length > 0 && !seen.has(cid)) {
|
|
1545
|
+
seen.add(cid);
|
|
1546
|
+
cids.push(cid);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
const meta = doc.getMap("meta");
|
|
1552
|
+
if (meta) {
|
|
1553
|
+
walkYMap(meta, (value) => {
|
|
1554
|
+
if (typeof value === "object" && value !== null && "cid" in value) {
|
|
1555
|
+
const cid = value.cid;
|
|
1556
|
+
if (typeof cid === "string" && cid.length > 0 && !seen.has(cid)) {
|
|
1557
|
+
seen.add(cid);
|
|
1558
|
+
cids.push(cid);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
return cids;
|
|
1564
|
+
}
|
|
1565
|
+
function walkXmlFragment(node, visitor) {
|
|
1566
|
+
for (let i = 0; i < node.length; i++) {
|
|
1567
|
+
const child = node.get(i);
|
|
1568
|
+
if (child instanceof Y2.XmlElement) {
|
|
1569
|
+
visitor(child);
|
|
1570
|
+
walkXmlFragment(child, visitor);
|
|
1571
|
+
} else if (child instanceof Y2.XmlText) {
|
|
1572
|
+
visitor(child);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function walkYMap(map, visitor) {
|
|
1577
|
+
map.forEach((value) => {
|
|
1578
|
+
if (value instanceof Y2.Map) {
|
|
1579
|
+
walkYMap(value, visitor);
|
|
1580
|
+
} else if (Array.isArray(value)) {
|
|
1581
|
+
for (const item of value) {
|
|
1582
|
+
visitor(item);
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
visitor(value);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
function resolveSignalingUrls(primaryUrl, additionalUrls) {
|
|
1590
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1591
|
+
return [primaryUrl, ...additionalUrls ?? []].map((url) => url.trim()).filter((url) => {
|
|
1592
|
+
if (!url || seen.has(url)) return false;
|
|
1593
|
+
seen.add(url);
|
|
1594
|
+
return true;
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
function createSyncManager(config) {
|
|
1598
|
+
const metaBridge = createMetaBridge(config.nodeStore);
|
|
1599
|
+
const pool = createNodePool({
|
|
1600
|
+
storage: config.storage,
|
|
1601
|
+
metaBridge,
|
|
1602
|
+
maxWarm: config.poolSize ?? 50,
|
|
1603
|
+
onDocUpdate: config.onDocUpdate,
|
|
1604
|
+
onDocEvict: (nodeId, doc) => {
|
|
1605
|
+
broadcastDocs.delete(nodeId);
|
|
1606
|
+
config.onDocEvict?.(nodeId, doc);
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
const registry = createRegistry({
|
|
1610
|
+
storage: createRegistryStorageAdapter(config.storage),
|
|
1611
|
+
trackTTL: config.trackTTL
|
|
1612
|
+
});
|
|
1613
|
+
const REGISTRY_SAVE_DEBOUNCE_MS = 2e3;
|
|
1614
|
+
let registrySaveTimer = null;
|
|
1615
|
+
function scheduleRegistrySave() {
|
|
1616
|
+
if (lifecycleInput.stopped) return;
|
|
1617
|
+
if (registrySaveTimer) return;
|
|
1618
|
+
registrySaveTimer = setTimeout(() => {
|
|
1619
|
+
registrySaveTimer = null;
|
|
1620
|
+
void registry.save();
|
|
1621
|
+
}, REGISTRY_SAVE_DEBOUNCE_MS);
|
|
1622
|
+
}
|
|
1623
|
+
function flushRegistrySave() {
|
|
1624
|
+
if (registrySaveTimer) {
|
|
1625
|
+
clearTimeout(registrySaveTimer);
|
|
1626
|
+
registrySaveTimer = null;
|
|
1627
|
+
}
|
|
1628
|
+
if (lifecycleInput.stopped) return;
|
|
1629
|
+
void registry.save();
|
|
1630
|
+
}
|
|
1631
|
+
const handleVisibilityChange = () => {
|
|
1632
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
1633
|
+
flushRegistrySave();
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
const handlePageHide = () => {
|
|
1637
|
+
flushRegistrySave();
|
|
1638
|
+
};
|
|
1639
|
+
let persistenceListenersAttached = false;
|
|
1640
|
+
function attachPersistenceListeners() {
|
|
1641
|
+
if (persistenceListenersAttached) return;
|
|
1642
|
+
if (typeof document === "undefined" || typeof addEventListener !== "function") return;
|
|
1643
|
+
persistenceListenersAttached = true;
|
|
1644
|
+
addEventListener("visibilitychange", handleVisibilityChange);
|
|
1645
|
+
addEventListener("pagehide", handlePageHide);
|
|
1646
|
+
}
|
|
1647
|
+
function detachPersistenceListeners() {
|
|
1648
|
+
if (!persistenceListenersAttached) return;
|
|
1649
|
+
persistenceListenersAttached = false;
|
|
1650
|
+
try {
|
|
1651
|
+
removeEventListener("visibilitychange", handleVisibilityChange);
|
|
1652
|
+
removeEventListener("pagehide", handlePageHide);
|
|
1653
|
+
} catch {
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const signalingUrls = resolveSignalingUrls(config.signalingUrl, config.signalingUrls);
|
|
1657
|
+
const connection = signalingUrls.length > 1 ? createMultiHubConnectionManager({
|
|
1658
|
+
hubs: signalingUrls.map((url) => ({
|
|
1659
|
+
url,
|
|
1660
|
+
ucanToken: config.ucanToken,
|
|
1661
|
+
getUCANToken: config.getUCANToken
|
|
1662
|
+
}))
|
|
1663
|
+
}) : createConnectionManager({
|
|
1664
|
+
url: signalingUrls[0] ?? config.signalingUrl,
|
|
1665
|
+
ucanToken: config.ucanToken,
|
|
1666
|
+
getUCANToken: config.getUCANToken
|
|
1667
|
+
});
|
|
1668
|
+
const replicationPolicy = resolveSyncReplicationPolicy(config.replication);
|
|
1669
|
+
const offlineQueue = createOfflineQueue({
|
|
1670
|
+
storage: config.storage
|
|
1671
|
+
});
|
|
1672
|
+
const nodeSyncProvider = config.nodeSyncRoom ? new NodeStoreSyncProvider(config.nodeStore, config.nodeSyncRoom) : null;
|
|
1673
|
+
let blobSync = null;
|
|
1674
|
+
if (config.blobStore) {
|
|
1675
|
+
const underlyingStore = config.blobStore;
|
|
1676
|
+
const announcingStore = {
|
|
1677
|
+
get: (cid) => underlyingStore.get(cid),
|
|
1678
|
+
has: (cid) => underlyingStore.has(cid),
|
|
1679
|
+
async put(data) {
|
|
1680
|
+
const cid = await underlyingStore.put(data);
|
|
1681
|
+
if (blobSync) {
|
|
1682
|
+
blobSync.announceHave([cid]);
|
|
1683
|
+
}
|
|
1684
|
+
return cid;
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
blobSync = createBlobSyncProvider({
|
|
1688
|
+
blobStore: announcingStore,
|
|
1689
|
+
connection
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
const roomCleanups = /* @__PURE__ */ new Map();
|
|
1693
|
+
const awarenessMap = /* @__PURE__ */ new Map();
|
|
1694
|
+
const awarenessSnapshots = /* @__PURE__ */ new Map();
|
|
1695
|
+
const awarenessSnapshotListeners = /* @__PURE__ */ new Map();
|
|
1696
|
+
const broadcastDocs = /* @__PURE__ */ new Set();
|
|
1697
|
+
const peerId = Math.random().toString(36).slice(2, 10);
|
|
1698
|
+
const statusListeners = /* @__PURE__ */ new Set();
|
|
1699
|
+
const lifecycleListeners = /* @__PURE__ */ new Set();
|
|
1700
|
+
const verificationFailureListeners = /* @__PURE__ */ new Set();
|
|
1701
|
+
const reconciliationListeners = /* @__PURE__ */ new Set();
|
|
1702
|
+
let status = connection.status;
|
|
1703
|
+
let lifecycleInput = {
|
|
1704
|
+
started: false,
|
|
1705
|
+
stopped: false,
|
|
1706
|
+
localReady: false,
|
|
1707
|
+
everConnected: false,
|
|
1708
|
+
connectionStatus: status,
|
|
1709
|
+
replaying: false
|
|
1710
|
+
};
|
|
1711
|
+
let lifecycle = createSyncLifecycleState(lifecycleInput);
|
|
1712
|
+
let connectedRecovery = null;
|
|
1713
|
+
let lastVerificationFailure = null;
|
|
1714
|
+
let lastReconciliationReport = null;
|
|
1715
|
+
function toBase643(data) {
|
|
1716
|
+
let binary = "";
|
|
1717
|
+
for (let i = 0; i < data.length; i++) {
|
|
1718
|
+
binary += String.fromCharCode(data[i]);
|
|
1719
|
+
}
|
|
1720
|
+
return btoa(binary);
|
|
1721
|
+
}
|
|
1722
|
+
function fromBase642(str) {
|
|
1723
|
+
const binary = atob(str);
|
|
1724
|
+
const bytes = new Uint8Array(binary.length);
|
|
1725
|
+
for (let i = 0; i < binary.length; i++) {
|
|
1726
|
+
bytes[i] = binary.charCodeAt(i);
|
|
1727
|
+
}
|
|
1728
|
+
return bytes;
|
|
1729
|
+
}
|
|
1730
|
+
function createOutgoingSyncPayload(update, clientId) {
|
|
1731
|
+
if (config.signingKey && config.authorDID) {
|
|
1732
|
+
return {
|
|
1733
|
+
envelope: serializeEnvelope(
|
|
1734
|
+
signYjsUpdate(update, config.authorDID, config.signingKey, clientId)
|
|
1735
|
+
)
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
if (replicationPolicy.allowUnsignedReplication) {
|
|
1739
|
+
return { update: toBase643(update) };
|
|
1740
|
+
}
|
|
1741
|
+
console.warn(
|
|
1742
|
+
"[SyncManager] Dropping Yjs replication payload because signed replication is required and no signing identity is configured."
|
|
1743
|
+
);
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
function verifyIncomingUpdate(nodeId, data) {
|
|
1747
|
+
if (hasEnvelope(data)) {
|
|
1748
|
+
const envelope = deserializeEnvelope(data.envelope);
|
|
1749
|
+
if (!envelope) {
|
|
1750
|
+
lastVerificationFailure = {
|
|
1751
|
+
nodeId,
|
|
1752
|
+
sender: typeof data.from === "string" ? data.from : null,
|
|
1753
|
+
reason: "invalid_envelope",
|
|
1754
|
+
at: Date.now()
|
|
1755
|
+
};
|
|
1756
|
+
emitVerificationFailure(lastVerificationFailure);
|
|
1757
|
+
return null;
|
|
1758
|
+
}
|
|
1759
|
+
const verification = verifyYjsEnvelopeV1(envelope);
|
|
1760
|
+
if (!verification.valid) {
|
|
1761
|
+
lastVerificationFailure = {
|
|
1762
|
+
nodeId,
|
|
1763
|
+
sender: typeof data.from === "string" ? data.from : null,
|
|
1764
|
+
reason: verification.reason ?? "invalid_signature",
|
|
1765
|
+
at: Date.now()
|
|
1766
|
+
};
|
|
1767
|
+
emitVerificationFailure(lastVerificationFailure);
|
|
1768
|
+
return null;
|
|
1769
|
+
}
|
|
1770
|
+
return envelope.update;
|
|
1771
|
+
}
|
|
1772
|
+
if (replicationPolicy.requireSignedReplication) {
|
|
1773
|
+
lastVerificationFailure = {
|
|
1774
|
+
nodeId,
|
|
1775
|
+
sender: typeof data.from === "string" ? data.from : null,
|
|
1776
|
+
reason: "missing_envelope",
|
|
1777
|
+
at: Date.now()
|
|
1778
|
+
};
|
|
1779
|
+
emitVerificationFailure(lastVerificationFailure);
|
|
1780
|
+
return null;
|
|
1781
|
+
}
|
|
1782
|
+
if (typeof data.update !== "string") {
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
return fromBase642(data.update);
|
|
1786
|
+
}
|
|
1787
|
+
function emitStatus(nextStatus) {
|
|
1788
|
+
for (const handler of statusListeners) {
|
|
1789
|
+
try {
|
|
1790
|
+
handler(nextStatus);
|
|
1791
|
+
} catch {
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function emitLifecycle(nextLifecycle) {
|
|
1796
|
+
for (const handler of lifecycleListeners) {
|
|
1797
|
+
try {
|
|
1798
|
+
handler(nextLifecycle);
|
|
1799
|
+
} catch {
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
function emitVerificationFailure(failure) {
|
|
1804
|
+
for (const handler of verificationFailureListeners) {
|
|
1805
|
+
try {
|
|
1806
|
+
handler(failure);
|
|
1807
|
+
} catch {
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
function emitReconciliationReport(report) {
|
|
1812
|
+
for (const handler of reconciliationListeners) {
|
|
1813
|
+
try {
|
|
1814
|
+
handler(report);
|
|
1815
|
+
} catch {
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
function updateLifecycle(patch = {}) {
|
|
1820
|
+
lifecycleInput = {
|
|
1821
|
+
...lifecycleInput,
|
|
1822
|
+
...patch
|
|
1823
|
+
};
|
|
1824
|
+
const nextLifecycle = createSyncLifecycleState(lifecycleInput, lifecycle);
|
|
1825
|
+
const changed = nextLifecycle.phase !== lifecycle.phase || nextLifecycle.connectionStatus !== lifecycle.connectionStatus || nextLifecycle.replaying !== lifecycle.replaying || nextLifecycle.lastTransitionAt !== lifecycle.lastTransitionAt;
|
|
1826
|
+
lifecycle = nextLifecycle;
|
|
1827
|
+
if (changed) {
|
|
1828
|
+
emitLifecycle(nextLifecycle);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
function clearRemotePresence() {
|
|
1832
|
+
for (const [nodeId, awareness] of awarenessMap) {
|
|
1833
|
+
const remoteClientIds = Array.from(awareness.getStates().keys()).filter(
|
|
1834
|
+
(clientId) => clientId !== awareness.clientID
|
|
1835
|
+
);
|
|
1836
|
+
if (remoteClientIds.length > 0) {
|
|
1837
|
+
removeAwarenessStates(awareness, remoteClientIds, "connection-lost");
|
|
1838
|
+
}
|
|
1839
|
+
if (awarenessSnapshots.has(nodeId)) {
|
|
1840
|
+
emitAwarenessSnapshot(nodeId, []);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
async function joinNodeRoom(nodeId) {
|
|
1845
|
+
if (roomCleanups.has(nodeId)) {
|
|
1846
|
+
log2("Already joined room for node:", nodeId);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
const room = `xnet-doc-${nodeId}`;
|
|
1850
|
+
log2("Joining room:", room);
|
|
1851
|
+
const { unsubscribe, ready } = connection.joinRoomAsync(room, (data) => {
|
|
1852
|
+
handleSyncMessage(nodeId, data);
|
|
1853
|
+
});
|
|
1854
|
+
roomCleanups.set(nodeId, unsubscribe);
|
|
1855
|
+
await ready;
|
|
1856
|
+
if (pool.has(nodeId)) {
|
|
1857
|
+
log2("Doc already in pool, sending initial sync-step1");
|
|
1858
|
+
const doc = await pool.acquire(nodeId);
|
|
1859
|
+
getOrCreateAwareness(nodeId, doc);
|
|
1860
|
+
const sv = Y2.encodeStateVector(doc);
|
|
1861
|
+
log2("Sending sync-step1 for node:", nodeId, "SV size:", sv.length);
|
|
1862
|
+
connection.publish(room, {
|
|
1863
|
+
type: "sync-step1",
|
|
1864
|
+
from: peerId,
|
|
1865
|
+
sv: toBase643(sv)
|
|
1866
|
+
});
|
|
1867
|
+
pool.release(nodeId);
|
|
1868
|
+
} else {
|
|
1869
|
+
log2("Doc not in pool yet for node:", nodeId);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
function leaveNodeRoom(nodeId) {
|
|
1873
|
+
const cleanup = roomCleanups.get(nodeId);
|
|
1874
|
+
if (cleanup) {
|
|
1875
|
+
cleanup();
|
|
1876
|
+
roomCleanups.delete(nodeId);
|
|
1877
|
+
}
|
|
1878
|
+
const awareness = awarenessMap.get(nodeId);
|
|
1879
|
+
if (awareness) {
|
|
1880
|
+
removeAwarenessStates(awareness, [awareness.clientID], "local");
|
|
1881
|
+
}
|
|
1882
|
+
awarenessMap.delete(nodeId);
|
|
1883
|
+
awarenessSnapshots.delete(nodeId);
|
|
1884
|
+
awarenessSnapshotListeners.delete(nodeId);
|
|
1885
|
+
broadcastDocs.delete(nodeId);
|
|
1886
|
+
}
|
|
1887
|
+
function getOrCreateAwareness(nodeId, doc) {
|
|
1888
|
+
const existing = awarenessMap.get(nodeId);
|
|
1889
|
+
if (existing) return existing;
|
|
1890
|
+
const awareness = new Awareness(doc);
|
|
1891
|
+
awarenessMap.set(nodeId, awareness);
|
|
1892
|
+
awareness.on(
|
|
1893
|
+
"update",
|
|
1894
|
+
({ added, updated, removed }, origin) => {
|
|
1895
|
+
if (origin === "remote") return;
|
|
1896
|
+
const changed = [...added, ...updated, ...removed];
|
|
1897
|
+
if (changed.length === 0) return;
|
|
1898
|
+
if (connection.status !== "connected") return;
|
|
1899
|
+
const room = `xnet-doc-${nodeId}`;
|
|
1900
|
+
const update = encodeAwarenessUpdate(awareness, changed);
|
|
1901
|
+
connection.publish(room, {
|
|
1902
|
+
type: "awareness",
|
|
1903
|
+
from: peerId,
|
|
1904
|
+
update: toBase643(update)
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
);
|
|
1908
|
+
return awareness;
|
|
1909
|
+
}
|
|
1910
|
+
function emitAwarenessSnapshot(nodeId, users) {
|
|
1911
|
+
awarenessSnapshots.set(nodeId, users);
|
|
1912
|
+
const listeners = awarenessSnapshotListeners.get(nodeId);
|
|
1913
|
+
if (!listeners) return;
|
|
1914
|
+
for (const handler of listeners) {
|
|
1915
|
+
try {
|
|
1916
|
+
handler(users);
|
|
1917
|
+
} catch {
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
async function handleSyncMessage(nodeId, data) {
|
|
1922
|
+
if (data.from === peerId) {
|
|
1923
|
+
log2("Ignoring own message");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
log2("Received message for node:", nodeId, "type:", data.type, "from:", data.from);
|
|
1927
|
+
const doc = await pool.acquire(nodeId);
|
|
1928
|
+
const room = `xnet-doc-${nodeId}`;
|
|
1929
|
+
try {
|
|
1930
|
+
switch (data.type) {
|
|
1931
|
+
case "sync-step1": {
|
|
1932
|
+
const remoteSV = fromBase642(data.sv);
|
|
1933
|
+
const diff = Y2.encodeStateAsUpdate(doc, remoteSV);
|
|
1934
|
+
log2(
|
|
1935
|
+
"Received sync-step1, remote SV size:",
|
|
1936
|
+
remoteSV.length,
|
|
1937
|
+
"sending diff size:",
|
|
1938
|
+
diff.length
|
|
1939
|
+
);
|
|
1940
|
+
const payload = createOutgoingSyncPayload(diff, doc.clientID);
|
|
1941
|
+
if (!payload) {
|
|
1942
|
+
break;
|
|
1943
|
+
}
|
|
1944
|
+
connection.publish(room, {
|
|
1945
|
+
type: "sync-step2",
|
|
1946
|
+
from: peerId,
|
|
1947
|
+
to: data.from,
|
|
1948
|
+
...payload
|
|
1949
|
+
});
|
|
1950
|
+
break;
|
|
1951
|
+
}
|
|
1952
|
+
case "sync-step2": {
|
|
1953
|
+
if (data.to && data.to !== peerId) {
|
|
1954
|
+
log2("Ignoring sync-step2 addressed to different peer:", data.to);
|
|
1955
|
+
break;
|
|
1956
|
+
}
|
|
1957
|
+
const update = verifyIncomingUpdate(nodeId, data);
|
|
1958
|
+
if (!update) {
|
|
1959
|
+
log2("Rejected sync-step2 for node:", nodeId, "last failure:", lastVerificationFailure);
|
|
1960
|
+
break;
|
|
1961
|
+
}
|
|
1962
|
+
log2("Received sync-step2, applying update size:", update.length);
|
|
1963
|
+
log2("Doc state before update - meta keys:", doc.getMap("meta").size);
|
|
1964
|
+
Y2.applyUpdate(doc, update, "remote");
|
|
1965
|
+
log2("Doc state after update - meta keys:", doc.getMap("meta").size);
|
|
1966
|
+
registry.markSynced(nodeId);
|
|
1967
|
+
log2("Marked node as synced:", nodeId);
|
|
1968
|
+
break;
|
|
1969
|
+
}
|
|
1970
|
+
case "sync-update": {
|
|
1971
|
+
const update = verifyIncomingUpdate(nodeId, data);
|
|
1972
|
+
if (!update) {
|
|
1973
|
+
log2("Rejected sync-update for node:", nodeId, "last failure:", lastVerificationFailure);
|
|
1974
|
+
break;
|
|
1975
|
+
}
|
|
1976
|
+
log2("Received sync-update, size:", update.length);
|
|
1977
|
+
Y2.applyUpdate(doc, update, "remote");
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1980
|
+
case "awareness": {
|
|
1981
|
+
const update = fromBase642(data.update);
|
|
1982
|
+
const awareness = getOrCreateAwareness(nodeId, doc);
|
|
1983
|
+
applyAwarenessUpdate(awareness, update, "remote");
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1986
|
+
case "awareness-snapshot": {
|
|
1987
|
+
const users = Array.isArray(data.users) ? data.users : [];
|
|
1988
|
+
emitAwarenessSnapshot(nodeId, users);
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
} finally {
|
|
1993
|
+
pool.release(nodeId);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
function setupDocBroadcast(nodeId, doc) {
|
|
1997
|
+
if (broadcastDocs.has(nodeId)) return;
|
|
1998
|
+
broadcastDocs.add(nodeId);
|
|
1999
|
+
const room = `xnet-doc-${nodeId}`;
|
|
2000
|
+
doc.on("update", (update, origin) => {
|
|
2001
|
+
if (origin === "remote") return;
|
|
2002
|
+
if (connection.status === "connected") {
|
|
2003
|
+
const payload = createOutgoingSyncPayload(update, doc.clientID);
|
|
2004
|
+
if (payload) {
|
|
2005
|
+
connection.publish(room, {
|
|
2006
|
+
type: "sync-update",
|
|
2007
|
+
from: peerId,
|
|
2008
|
+
...payload
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
} else {
|
|
2012
|
+
void offlineQueue.enqueue(nodeId, update, doc.clientID);
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
async function drainOfflineQueue() {
|
|
2017
|
+
if (offlineQueue.size === 0) return 0;
|
|
2018
|
+
updateLifecycle({ replaying: true });
|
|
2019
|
+
try {
|
|
2020
|
+
return await offlineQueue.drain(async (entry) => {
|
|
2021
|
+
const room = `xnet-doc-${entry.nodeId}`;
|
|
2022
|
+
if (replicationPolicy.requireSignedReplication) {
|
|
2023
|
+
const payload = createOutgoingSyncPayload(fromBase642(entry.update), entry.clientId ?? 0);
|
|
2024
|
+
if (!payload) {
|
|
2025
|
+
throw new Error("signed replication required");
|
|
2026
|
+
}
|
|
2027
|
+
connection.publish(room, {
|
|
2028
|
+
type: "sync-update",
|
|
2029
|
+
from: peerId,
|
|
2030
|
+
...payload
|
|
2031
|
+
});
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
connection.publish(room, {
|
|
2035
|
+
type: "sync-update",
|
|
2036
|
+
from: peerId,
|
|
2037
|
+
update: entry.update
|
|
2038
|
+
});
|
|
2039
|
+
});
|
|
2040
|
+
} finally {
|
|
2041
|
+
updateLifecycle({ replaying: false });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
function sendSyncStep1(nodeId, doc) {
|
|
2045
|
+
const room = `xnet-doc-${nodeId}`;
|
|
2046
|
+
const sv = Y2.encodeStateVector(doc);
|
|
2047
|
+
log2(
|
|
2048
|
+
"sendSyncStep1 for node:",
|
|
2049
|
+
nodeId,
|
|
2050
|
+
"SV size:",
|
|
2051
|
+
sv.length,
|
|
2052
|
+
"connection status:",
|
|
2053
|
+
connection.status
|
|
2054
|
+
);
|
|
2055
|
+
connection.publish(room, {
|
|
2056
|
+
type: "sync-step1",
|
|
2057
|
+
from: peerId,
|
|
2058
|
+
sv: toBase643(sv)
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
async function reconcileTrackedRooms(options = {}) {
|
|
2062
|
+
const nodeIds = options.nodeIds ?? Array.from(roomCleanups.keys());
|
|
2063
|
+
const replayedOfflineChanges = await drainOfflineQueue();
|
|
2064
|
+
const repairedNodeIds = [];
|
|
2065
|
+
const skippedNodeIds = [];
|
|
2066
|
+
for (const nodeId of nodeIds) {
|
|
2067
|
+
if (!pool.has(nodeId)) {
|
|
2068
|
+
skippedNodeIds.push(nodeId);
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
const doc = await pool.acquire(nodeId);
|
|
2072
|
+
try {
|
|
2073
|
+
sendSyncStep1(nodeId, doc);
|
|
2074
|
+
repairedNodeIds.push(nodeId);
|
|
2075
|
+
} finally {
|
|
2076
|
+
pool.release(nodeId);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
lastReconciliationReport = {
|
|
2080
|
+
reason: options.reason ?? "manual",
|
|
2081
|
+
replayedOfflineChanges,
|
|
2082
|
+
repairedNodeIds,
|
|
2083
|
+
skippedNodeIds,
|
|
2084
|
+
at: Date.now()
|
|
2085
|
+
};
|
|
2086
|
+
emitReconciliationReport(lastReconciliationReport);
|
|
2087
|
+
return lastReconciliationReport;
|
|
2088
|
+
}
|
|
2089
|
+
async function handleConnected() {
|
|
2090
|
+
if (connectedRecovery) {
|
|
2091
|
+
await connectedRecovery;
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
connectedRecovery = reconcileTrackedRooms({ reason: "reconnect" }).finally(() => {
|
|
2095
|
+
connectedRecovery = null;
|
|
2096
|
+
});
|
|
2097
|
+
await connectedRecovery;
|
|
2098
|
+
}
|
|
2099
|
+
connection.onStatus((nextStatus) => {
|
|
2100
|
+
if (status !== nextStatus) {
|
|
2101
|
+
status = nextStatus;
|
|
2102
|
+
emitStatus(nextStatus);
|
|
2103
|
+
}
|
|
2104
|
+
const everConnected = lifecycleInput.everConnected || nextStatus === "connected";
|
|
2105
|
+
updateLifecycle({
|
|
2106
|
+
connectionStatus: nextStatus,
|
|
2107
|
+
everConnected
|
|
2108
|
+
});
|
|
2109
|
+
if (lifecycleInput.started && !lifecycleInput.stopped && (nextStatus === "disconnected" || nextStatus === "error")) {
|
|
2110
|
+
clearRemotePresence();
|
|
2111
|
+
}
|
|
2112
|
+
if (nextStatus === "connected") {
|
|
2113
|
+
void handleConnected();
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
return {
|
|
2117
|
+
async start() {
|
|
2118
|
+
log2("Starting SyncManager...");
|
|
2119
|
+
updateLifecycle({
|
|
2120
|
+
started: true,
|
|
2121
|
+
stopped: false,
|
|
2122
|
+
localReady: false,
|
|
2123
|
+
everConnected: false,
|
|
2124
|
+
connectionStatus: status,
|
|
2125
|
+
replaying: false
|
|
2126
|
+
});
|
|
2127
|
+
await registry.load();
|
|
2128
|
+
if (lifecycleInput.stopped) return;
|
|
2129
|
+
log2("Registry loaded, tracked nodes:", registry.getTracked().length);
|
|
2130
|
+
attachPersistenceListeners();
|
|
2131
|
+
await offlineQueue.load();
|
|
2132
|
+
log2("Offline queue loaded, size:", offlineQueue.size);
|
|
2133
|
+
updateLifecycle({ localReady: true });
|
|
2134
|
+
log2("Connecting to signaling server...");
|
|
2135
|
+
connection.connect();
|
|
2136
|
+
nodeSyncProvider?.attach(connection);
|
|
2137
|
+
blobSync?.start();
|
|
2138
|
+
const tracked = registry.getTracked();
|
|
2139
|
+
log2("Joining rooms for", tracked.length, "tracked nodes");
|
|
2140
|
+
for (const entry of tracked) {
|
|
2141
|
+
void joinNodeRoom(entry.nodeId).catch((err) => {
|
|
2142
|
+
log2("Background room join failed during start for node:", entry.nodeId, err);
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
log2("SyncManager started");
|
|
2146
|
+
},
|
|
2147
|
+
async stop() {
|
|
2148
|
+
updateLifecycle({
|
|
2149
|
+
stopped: true,
|
|
2150
|
+
replaying: false
|
|
2151
|
+
});
|
|
2152
|
+
blobSync?.stop();
|
|
2153
|
+
nodeSyncProvider?.detach();
|
|
2154
|
+
for (const nodeId of Array.from(roomCleanups.keys())) {
|
|
2155
|
+
leaveNodeRoom(nodeId);
|
|
2156
|
+
}
|
|
2157
|
+
detachPersistenceListeners();
|
|
2158
|
+
if (registrySaveTimer) {
|
|
2159
|
+
clearTimeout(registrySaveTimer);
|
|
2160
|
+
registrySaveTimer = null;
|
|
2161
|
+
}
|
|
2162
|
+
connection.disconnect();
|
|
2163
|
+
await pool.flushAll();
|
|
2164
|
+
registry.prune();
|
|
2165
|
+
await registry.save();
|
|
2166
|
+
await offlineQueue.save();
|
|
2167
|
+
await pool.destroy();
|
|
2168
|
+
},
|
|
2169
|
+
track(nodeId, schemaId) {
|
|
2170
|
+
registry.track(nodeId, schemaId);
|
|
2171
|
+
scheduleRegistrySave();
|
|
2172
|
+
joinNodeRoom(nodeId).catch((err) => {
|
|
2173
|
+
log2("Error joining room for track:", err);
|
|
2174
|
+
});
|
|
2175
|
+
},
|
|
2176
|
+
untrack(nodeId) {
|
|
2177
|
+
registry.untrack(nodeId);
|
|
2178
|
+
scheduleRegistrySave();
|
|
2179
|
+
leaveNodeRoom(nodeId);
|
|
2180
|
+
},
|
|
2181
|
+
async acquire(nodeId) {
|
|
2182
|
+
log2("Acquiring doc for node:", nodeId);
|
|
2183
|
+
registry.touch(nodeId);
|
|
2184
|
+
scheduleRegistrySave();
|
|
2185
|
+
const doc = await pool.acquire(nodeId);
|
|
2186
|
+
log2("Doc acquired from pool, guid:", doc.guid, "meta keys:", doc.getMap("meta").size);
|
|
2187
|
+
setupDocBroadcast(nodeId, doc);
|
|
2188
|
+
getOrCreateAwareness(nodeId, doc);
|
|
2189
|
+
if (!roomCleanups.has(nodeId)) {
|
|
2190
|
+
void joinNodeRoom(nodeId).catch((err) => {
|
|
2191
|
+
log2("Background room join failed for node:", nodeId, err);
|
|
2192
|
+
});
|
|
2193
|
+
} else if (connection.status === "connected") {
|
|
2194
|
+
sendSyncStep1(nodeId, doc);
|
|
2195
|
+
}
|
|
2196
|
+
if (blobSync) {
|
|
2197
|
+
const cids = extractBlobCids(doc);
|
|
2198
|
+
if (cids.length > 0) {
|
|
2199
|
+
blobSync.requestBlobs(cids);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
return doc;
|
|
2203
|
+
},
|
|
2204
|
+
release(nodeId) {
|
|
2205
|
+
pool.release(nodeId);
|
|
2206
|
+
},
|
|
2207
|
+
async clearHubData() {
|
|
2208
|
+
if (!nodeSyncProvider) return 0;
|
|
2209
|
+
return nodeSyncProvider.clearRoom();
|
|
2210
|
+
},
|
|
2211
|
+
getAwareness(nodeId) {
|
|
2212
|
+
return awarenessMap.get(nodeId) ?? null;
|
|
2213
|
+
},
|
|
2214
|
+
onAwarenessSnapshot(nodeId, handler) {
|
|
2215
|
+
const listeners = awarenessSnapshotListeners.get(nodeId) ?? /* @__PURE__ */ new Set();
|
|
2216
|
+
listeners.add(handler);
|
|
2217
|
+
awarenessSnapshotListeners.set(nodeId, listeners);
|
|
2218
|
+
const existing = awarenessSnapshots.get(nodeId);
|
|
2219
|
+
if (existing) {
|
|
2220
|
+
handler(existing);
|
|
2221
|
+
}
|
|
2222
|
+
return () => {
|
|
2223
|
+
const current = awarenessSnapshotListeners.get(nodeId);
|
|
2224
|
+
if (!current) return;
|
|
2225
|
+
current.delete(handler);
|
|
2226
|
+
if (current.size === 0) {
|
|
2227
|
+
awarenessSnapshotListeners.delete(nodeId);
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
},
|
|
2231
|
+
async requestBlobs(cids) {
|
|
2232
|
+
if (blobSync) {
|
|
2233
|
+
await blobSync.requestBlobs(cids);
|
|
2234
|
+
}
|
|
2235
|
+
},
|
|
2236
|
+
announceBlobs(cids) {
|
|
2237
|
+
if (blobSync) {
|
|
2238
|
+
blobSync.announceHave(cids);
|
|
2239
|
+
}
|
|
2240
|
+
},
|
|
2241
|
+
reconcile(options) {
|
|
2242
|
+
return reconcileTrackedRooms(options);
|
|
2243
|
+
},
|
|
2244
|
+
get status() {
|
|
2245
|
+
return status;
|
|
2246
|
+
},
|
|
2247
|
+
get lifecycle() {
|
|
2248
|
+
return lifecycle;
|
|
2249
|
+
},
|
|
2250
|
+
get connection() {
|
|
2251
|
+
return connection;
|
|
2252
|
+
},
|
|
2253
|
+
get poolSize() {
|
|
2254
|
+
return pool.size;
|
|
2255
|
+
},
|
|
2256
|
+
get trackedCount() {
|
|
2257
|
+
return registry.getTracked().length;
|
|
2258
|
+
},
|
|
2259
|
+
get queueSize() {
|
|
2260
|
+
return offlineQueue.size;
|
|
2261
|
+
},
|
|
2262
|
+
get pendingBlobCount() {
|
|
2263
|
+
return blobSync?.pendingCount ?? 0;
|
|
2264
|
+
},
|
|
2265
|
+
get lastVerificationFailure() {
|
|
2266
|
+
return lastVerificationFailure;
|
|
2267
|
+
},
|
|
2268
|
+
get lastReconciliationReport() {
|
|
2269
|
+
return lastReconciliationReport;
|
|
2270
|
+
},
|
|
2271
|
+
on(event, handler) {
|
|
2272
|
+
if (event === "status") {
|
|
2273
|
+
const statusHandler = handler;
|
|
2274
|
+
statusListeners.add(statusHandler);
|
|
2275
|
+
return () => statusListeners.delete(statusHandler);
|
|
2276
|
+
}
|
|
2277
|
+
if (event === "lifecycle") {
|
|
2278
|
+
const lifecycleHandler = handler;
|
|
2279
|
+
lifecycleListeners.add(lifecycleHandler);
|
|
2280
|
+
return () => lifecycleListeners.delete(lifecycleHandler);
|
|
2281
|
+
}
|
|
2282
|
+
if (event === "verification-failure") {
|
|
2283
|
+
const verificationHandler = handler;
|
|
2284
|
+
verificationFailureListeners.add(verificationHandler);
|
|
2285
|
+
return () => verificationFailureListeners.delete(verificationHandler);
|
|
2286
|
+
}
|
|
2287
|
+
if (event === "reconciliation") {
|
|
2288
|
+
const reconciliationHandler = handler;
|
|
2289
|
+
reconciliationListeners.add(reconciliationHandler);
|
|
2290
|
+
return () => reconciliationListeners.delete(reconciliationHandler);
|
|
2291
|
+
}
|
|
2292
|
+
return () => {
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
// src/client.ts
|
|
2299
|
+
function permissiveDecision(input) {
|
|
2300
|
+
return {
|
|
2301
|
+
allowed: true,
|
|
2302
|
+
action: input.action,
|
|
2303
|
+
subject: input.subject,
|
|
2304
|
+
resource: input.nodeId,
|
|
2305
|
+
roles: [],
|
|
2306
|
+
grants: [],
|
|
2307
|
+
reasons: [],
|
|
2308
|
+
cached: false,
|
|
2309
|
+
evaluatedAt: 0,
|
|
2310
|
+
duration: 0
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
function reportCrash(telemetry, err, codeNamespace) {
|
|
2314
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), { codeNamespace });
|
|
2315
|
+
}
|
|
2316
|
+
function buildNodeStore(options, storage) {
|
|
2317
|
+
return new NodeStore({
|
|
2318
|
+
storage,
|
|
2319
|
+
authorDID: options.authorDID,
|
|
2320
|
+
signingKey: options.signingKey,
|
|
2321
|
+
changeSigner: options.changeSigner,
|
|
2322
|
+
authEvaluator: options.authEvaluator,
|
|
2323
|
+
nodeContentCipher: options.nodeContentCipher,
|
|
2324
|
+
auth: options.auth,
|
|
2325
|
+
schemaLookup: options.schemaLookup,
|
|
2326
|
+
propertyLookup: options.propertyLookup,
|
|
2327
|
+
lensRegistry: options.lensRegistry,
|
|
2328
|
+
telemetry: options.telemetry
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
function buildSyncManager(store, storage, options) {
|
|
2332
|
+
const sync = options.sync;
|
|
2333
|
+
if (!sync) return null;
|
|
2334
|
+
const signalingUrl = sync.signalingUrl ?? sync.signalingUrls?.[0] ?? "ws://localhost:4444";
|
|
2335
|
+
return createSyncManager({
|
|
2336
|
+
nodeStore: store,
|
|
2337
|
+
storage,
|
|
2338
|
+
signalingUrl,
|
|
2339
|
+
authorDID: options.authorDID,
|
|
2340
|
+
signingKey: options.signingKey,
|
|
2341
|
+
signalingUrls: sync.signalingUrls,
|
|
2342
|
+
replication: sync.replication,
|
|
2343
|
+
blobStore: sync.blobStore,
|
|
2344
|
+
nodeSyncRoom: sync.nodeSyncRoom,
|
|
2345
|
+
ucanToken: sync.ucanToken,
|
|
2346
|
+
getUCANToken: sync.getUCANToken,
|
|
2347
|
+
poolSize: sync.poolSize,
|
|
2348
|
+
trackTTL: sync.trackTTL,
|
|
2349
|
+
onDocUpdate: sync.onDocUpdate,
|
|
2350
|
+
onDocEvict: sync.onDocEvict
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
function startSyncManager(syncManager, bridge, sync, telemetry) {
|
|
2354
|
+
const managed = bridge;
|
|
2355
|
+
managed.setSyncManager?.(syncManager);
|
|
2356
|
+
if (sync.autoStart === false) return;
|
|
2357
|
+
syncManager.start().catch((err) => reportCrash(telemetry, err, "runtime.syncManager.start"));
|
|
2358
|
+
}
|
|
2359
|
+
function buildPlugins(store, options) {
|
|
2360
|
+
const plugins = options.plugins;
|
|
2361
|
+
if (!plugins) return null;
|
|
2362
|
+
const registry = new PluginRegistry(store, plugins.platform ?? "web");
|
|
2363
|
+
if (plugins.autoLoad !== false) {
|
|
2364
|
+
registry.loadFromStore().catch((err) => reportCrash(options.telemetry, err, "runtime.plugins.loadFromStore"));
|
|
2365
|
+
}
|
|
2366
|
+
return registry;
|
|
2367
|
+
}
|
|
2368
|
+
function buildUndo(store, options) {
|
|
2369
|
+
const undo = options.undo;
|
|
2370
|
+
if (!undo) return null;
|
|
2371
|
+
const manager = new UndoManager(
|
|
2372
|
+
store,
|
|
2373
|
+
options.authorDID,
|
|
2374
|
+
{ localOnly: undo.localOnly ?? true, maxStackSize: undo.maxStackSize ?? 200 },
|
|
2375
|
+
options.telemetry
|
|
2376
|
+
);
|
|
2377
|
+
manager.start();
|
|
2378
|
+
return manager;
|
|
2379
|
+
}
|
|
2380
|
+
async function deactivateAllPlugins(registry) {
|
|
2381
|
+
for (const plugin of registry.getAll()) {
|
|
2382
|
+
if (plugin.status === "active") {
|
|
2383
|
+
await registry.deactivate(plugin.manifest.id).catch(() => {
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
async function createXNetClient(options) {
|
|
2389
|
+
const { authorDID, signingKey, identity, telemetry, dataBridge, bridgeOptions, sync } = options;
|
|
2390
|
+
if (!authorDID || !signingKey) {
|
|
2391
|
+
throw new Error("createXNetClient requires both authorDID and signingKey.");
|
|
2392
|
+
}
|
|
2393
|
+
const storage = options.nodeStorage ?? new MemoryNodeStorageAdapter();
|
|
2394
|
+
if ("open" in storage && typeof storage.open === "function") {
|
|
2395
|
+
await storage.open();
|
|
2396
|
+
}
|
|
2397
|
+
const store = buildNodeStore(options, storage);
|
|
2398
|
+
await store.initialize();
|
|
2399
|
+
const bridgeCreatedInternally = !dataBridge;
|
|
2400
|
+
const bridge = dataBridge ?? createMainThreadBridgeSync(store, bridgeOptions);
|
|
2401
|
+
const syncManager = buildSyncManager(store, storage, options);
|
|
2402
|
+
if (syncManager && sync) startSyncManager(syncManager, bridge, sync, telemetry);
|
|
2403
|
+
const pluginRegistry = buildPlugins(store, options);
|
|
2404
|
+
const undoManager = buildUndo(store, options);
|
|
2405
|
+
const publicKey = getSigningPublicKeyFromPrivate(signingKey);
|
|
2406
|
+
let destroyed = false;
|
|
2407
|
+
const runtimeStatus = {
|
|
2408
|
+
phase: "ready",
|
|
2409
|
+
bridgeMode: bridgeCreatedInternally ? "main-thread" : "custom",
|
|
2410
|
+
syncEnabled: syncManager !== null
|
|
2411
|
+
};
|
|
2412
|
+
const client = {
|
|
2413
|
+
store,
|
|
2414
|
+
bridge,
|
|
2415
|
+
syncManager,
|
|
2416
|
+
plugins: pluginRegistry,
|
|
2417
|
+
undo: undoManager,
|
|
2418
|
+
identity,
|
|
2419
|
+
authorDID,
|
|
2420
|
+
get status() {
|
|
2421
|
+
return bridge.status;
|
|
2422
|
+
},
|
|
2423
|
+
runtimeStatus,
|
|
2424
|
+
query: bridge.query.bind(bridge),
|
|
2425
|
+
async fetch(schema, queryOptions) {
|
|
2426
|
+
const subscription = bridge.query(schema, queryOptions);
|
|
2427
|
+
const immediate = subscription.getSnapshot();
|
|
2428
|
+
if (immediate !== null) return immediate;
|
|
2429
|
+
return new Promise((resolve) => {
|
|
2430
|
+
const unsubscribe = subscription.subscribe(() => {
|
|
2431
|
+
const snapshot = subscription.getSnapshot();
|
|
2432
|
+
if (snapshot !== null) {
|
|
2433
|
+
unsubscribe();
|
|
2434
|
+
resolve(snapshot);
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
});
|
|
2438
|
+
},
|
|
2439
|
+
get(nodeId) {
|
|
2440
|
+
return store.get(nodeId);
|
|
2441
|
+
},
|
|
2442
|
+
mutate: {
|
|
2443
|
+
create: bridge.create.bind(bridge),
|
|
2444
|
+
update: bridge.update.bind(bridge),
|
|
2445
|
+
delete: bridge.delete.bind(bridge),
|
|
2446
|
+
restore: bridge.restore.bind(bridge),
|
|
2447
|
+
bulkWrite: bridge.bulkWrite.bind(bridge),
|
|
2448
|
+
transaction(operations) {
|
|
2449
|
+
if (typeof bridge.transaction !== "function") {
|
|
2450
|
+
throw new Error("The active DataBridge does not support transactions.");
|
|
2451
|
+
}
|
|
2452
|
+
return bridge.transaction(operations);
|
|
2453
|
+
}
|
|
2454
|
+
},
|
|
2455
|
+
auth: options.auth ?? null,
|
|
2456
|
+
async can(input) {
|
|
2457
|
+
if (options.authEvaluator) return options.authEvaluator.can(input);
|
|
2458
|
+
return permissiveDecision(input);
|
|
2459
|
+
},
|
|
2460
|
+
node: {
|
|
2461
|
+
async acquire(nodeId) {
|
|
2462
|
+
if (typeof bridge.acquireDoc !== "function") {
|
|
2463
|
+
throw new Error(
|
|
2464
|
+
"node.acquire() requires a doc-capable bridge with a SyncManager. Enable `sync` or supply a bridge that implements acquireDoc()."
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
return bridge.acquireDoc(nodeId);
|
|
2468
|
+
},
|
|
2469
|
+
release(nodeId) {
|
|
2470
|
+
bridge.releaseDoc?.(nodeId);
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
sign(message) {
|
|
2474
|
+
return sign(message, signingKey);
|
|
2475
|
+
},
|
|
2476
|
+
verify(message, signature, key) {
|
|
2477
|
+
return verify(message, signature, key ?? publicKey);
|
|
2478
|
+
},
|
|
2479
|
+
on(event, handler) {
|
|
2480
|
+
return bridge.on(event, handler);
|
|
2481
|
+
},
|
|
2482
|
+
async destroy() {
|
|
2483
|
+
if (destroyed) return;
|
|
2484
|
+
destroyed = true;
|
|
2485
|
+
runtimeStatus.phase = "destroyed";
|
|
2486
|
+
undoManager?.stop();
|
|
2487
|
+
if (pluginRegistry) {
|
|
2488
|
+
await deactivateAllPlugins(pluginRegistry);
|
|
2489
|
+
}
|
|
2490
|
+
if (syncManager) {
|
|
2491
|
+
await syncManager.stop().catch(() => {
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
const managedBridge = bridge;
|
|
2495
|
+
managedBridge.setSyncManager?.(null);
|
|
2496
|
+
if (bridgeCreatedInternally) {
|
|
2497
|
+
bridge.destroy();
|
|
2498
|
+
}
|
|
2499
|
+
if ("close" in storage && typeof storage.close === "function") {
|
|
2500
|
+
await storage.close();
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
return client;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// src/live-query.ts
|
|
2508
|
+
function liveQuery(client, schema, options) {
|
|
2509
|
+
const subscription = client.query(schema, options);
|
|
2510
|
+
const runs = /* @__PURE__ */ new Set();
|
|
2511
|
+
let unsubscribeBridge = null;
|
|
2512
|
+
const read = () => subscription.getSnapshot();
|
|
2513
|
+
const ensureBridgeSubscription = () => {
|
|
2514
|
+
if (unsubscribeBridge) return;
|
|
2515
|
+
unsubscribeBridge = subscription.subscribe(() => {
|
|
2516
|
+
const value = read();
|
|
2517
|
+
for (const run of runs) run(value);
|
|
2518
|
+
});
|
|
2519
|
+
};
|
|
2520
|
+
return {
|
|
2521
|
+
subscribe(run) {
|
|
2522
|
+
runs.add(run);
|
|
2523
|
+
ensureBridgeSubscription();
|
|
2524
|
+
run(read());
|
|
2525
|
+
return () => {
|
|
2526
|
+
runs.delete(run);
|
|
2527
|
+
if (runs.size === 0 && unsubscribeBridge) {
|
|
2528
|
+
unsubscribeBridge();
|
|
2529
|
+
unsubscribeBridge = null;
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
},
|
|
2533
|
+
get: read,
|
|
2534
|
+
destroy() {
|
|
2535
|
+
if (unsubscribeBridge) {
|
|
2536
|
+
unsubscribeBridge();
|
|
2537
|
+
unsubscribeBridge = null;
|
|
2538
|
+
}
|
|
2539
|
+
runs.clear();
|
|
2540
|
+
}
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// src/sync/WebSocketSyncProvider.ts
|
|
2545
|
+
import { resolveSyncReplicationPolicy as resolveSyncReplicationPolicy2, signYjsUpdate as signYjsUpdate2, verifyYjsEnvelopeV1 as verifyYjsEnvelopeV12 } from "@xnetjs/sync";
|
|
2546
|
+
import {
|
|
2547
|
+
Awareness as Awareness2,
|
|
2548
|
+
applyAwarenessUpdate as applyAwarenessUpdate2,
|
|
2549
|
+
encodeAwarenessUpdate as encodeAwarenessUpdate2,
|
|
2550
|
+
removeAwarenessStates as removeAwarenessStates2
|
|
2551
|
+
} from "y-protocols/awareness";
|
|
2552
|
+
import * as Y3 from "yjs";
|
|
2553
|
+
function log3(provider, ...args) {
|
|
2554
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
2555
|
+
console.log(`[WSSyncProvider:${provider.room}]`, ...args);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
function toBase642(data) {
|
|
2559
|
+
if (typeof Buffer !== "undefined") {
|
|
2560
|
+
return Buffer.from(data).toString("base64");
|
|
2561
|
+
}
|
|
2562
|
+
let binary = "";
|
|
2563
|
+
for (let i = 0; i < data.length; i++) {
|
|
2564
|
+
binary += String.fromCharCode(data[i]);
|
|
2565
|
+
}
|
|
2566
|
+
return btoa(binary);
|
|
2567
|
+
}
|
|
2568
|
+
function fromBase64(str) {
|
|
2569
|
+
if (typeof Buffer !== "undefined") {
|
|
2570
|
+
return new Uint8Array(Buffer.from(str, "base64"));
|
|
2571
|
+
}
|
|
2572
|
+
const binary = atob(str);
|
|
2573
|
+
const bytes = new Uint8Array(binary.length);
|
|
2574
|
+
for (let i = 0; i < binary.length; i++) {
|
|
2575
|
+
bytes[i] = binary.charCodeAt(i);
|
|
2576
|
+
}
|
|
2577
|
+
return bytes;
|
|
2578
|
+
}
|
|
2579
|
+
function serializeEnvelope2(envelope) {
|
|
2580
|
+
return {
|
|
2581
|
+
update: toBase642(envelope.update),
|
|
2582
|
+
authorDID: envelope.authorDID,
|
|
2583
|
+
signature: toBase642(envelope.signature),
|
|
2584
|
+
timestamp: envelope.timestamp,
|
|
2585
|
+
clientId: envelope.clientId
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
function deserializeEnvelope2(data) {
|
|
2589
|
+
try {
|
|
2590
|
+
return {
|
|
2591
|
+
update: fromBase64(data.update),
|
|
2592
|
+
authorDID: data.authorDID,
|
|
2593
|
+
signature: fromBase64(data.signature),
|
|
2594
|
+
timestamp: data.timestamp,
|
|
2595
|
+
clientId: data.clientId
|
|
2596
|
+
};
|
|
2597
|
+
} catch {
|
|
2598
|
+
return null;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
function hasEnvelope2(data) {
|
|
2602
|
+
return typeof data.envelope === "object" && data.envelope !== null && "update" in data.envelope && "authorDID" in data.envelope && "signature" in data.envelope;
|
|
2603
|
+
}
|
|
2604
|
+
var WebSocketSyncProvider = class {
|
|
2605
|
+
doc;
|
|
2606
|
+
room;
|
|
2607
|
+
url;
|
|
2608
|
+
awareness;
|
|
2609
|
+
ws = null;
|
|
2610
|
+
reconnectDelay;
|
|
2611
|
+
maxReconnectAttempts;
|
|
2612
|
+
reconnectAttempts = 0;
|
|
2613
|
+
reconnectTimer = null;
|
|
2614
|
+
destroyed = false;
|
|
2615
|
+
connected = false;
|
|
2616
|
+
synced = false;
|
|
2617
|
+
peerId;
|
|
2618
|
+
remotePeerIds = /* @__PURE__ */ new Set();
|
|
2619
|
+
eventHandlers = /* @__PURE__ */ new Map();
|
|
2620
|
+
options;
|
|
2621
|
+
replicationPolicy;
|
|
2622
|
+
constructor(doc, options) {
|
|
2623
|
+
this.doc = doc;
|
|
2624
|
+
this.room = options.room;
|
|
2625
|
+
this.url = options.url;
|
|
2626
|
+
this.options = options;
|
|
2627
|
+
this.reconnectDelay = options.reconnectDelay ?? 2e3;
|
|
2628
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? Infinity;
|
|
2629
|
+
this.replicationPolicy = resolveSyncReplicationPolicy2(options.replication);
|
|
2630
|
+
this.peerId = Math.random().toString(36).slice(2, 10);
|
|
2631
|
+
this.awareness = new Awareness2(doc);
|
|
2632
|
+
this.doc.on("update", this._onDocUpdate);
|
|
2633
|
+
this.awareness.on("update", this._onAwarenessUpdate);
|
|
2634
|
+
this._connect();
|
|
2635
|
+
}
|
|
2636
|
+
get isConnected() {
|
|
2637
|
+
return this.connected;
|
|
2638
|
+
}
|
|
2639
|
+
get isSynced() {
|
|
2640
|
+
return this.synced;
|
|
2641
|
+
}
|
|
2642
|
+
/** Helper to get XML fragment length for debug logging */
|
|
2643
|
+
_getFragmentLength() {
|
|
2644
|
+
try {
|
|
2645
|
+
const fragment = this.doc.getXmlFragment("default");
|
|
2646
|
+
return fragment?.length ?? 0;
|
|
2647
|
+
} catch {
|
|
2648
|
+
return 0;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
on(event, handler) {
|
|
2652
|
+
if (!this.eventHandlers.has(event)) {
|
|
2653
|
+
this.eventHandlers.set(event, /* @__PURE__ */ new Set());
|
|
2654
|
+
}
|
|
2655
|
+
this.eventHandlers.get(event).add(handler);
|
|
2656
|
+
}
|
|
2657
|
+
off(event, handler) {
|
|
2658
|
+
this.eventHandlers.get(event)?.delete(handler);
|
|
2659
|
+
}
|
|
2660
|
+
emit(event, data) {
|
|
2661
|
+
this.eventHandlers.get(event)?.forEach((handler) => handler(data));
|
|
2662
|
+
}
|
|
2663
|
+
destroy() {
|
|
2664
|
+
this.destroyed = true;
|
|
2665
|
+
this.doc.off("update", this._onDocUpdate);
|
|
2666
|
+
this.awareness.off("update", this._onAwarenessUpdate);
|
|
2667
|
+
removeAwarenessStates2(this.awareness, [this.doc.clientID], this);
|
|
2668
|
+
if (this.reconnectTimer) {
|
|
2669
|
+
clearTimeout(this.reconnectTimer);
|
|
2670
|
+
this.reconnectTimer = null;
|
|
2671
|
+
}
|
|
2672
|
+
if (this.ws) {
|
|
2673
|
+
this._send({ type: "unsubscribe", topics: [this.room] });
|
|
2674
|
+
this.ws.close();
|
|
2675
|
+
this.ws = null;
|
|
2676
|
+
}
|
|
2677
|
+
this.connected = false;
|
|
2678
|
+
this.emit("status", { connected: false });
|
|
2679
|
+
this.eventHandlers.clear();
|
|
2680
|
+
}
|
|
2681
|
+
_connect() {
|
|
2682
|
+
if (this.destroyed) return;
|
|
2683
|
+
try {
|
|
2684
|
+
this.ws = new WebSocket(this.url);
|
|
2685
|
+
this.ws.onopen = () => {
|
|
2686
|
+
log3(this, "WebSocket connected to", this.url);
|
|
2687
|
+
this.connected = true;
|
|
2688
|
+
this.reconnectAttempts = 0;
|
|
2689
|
+
this.emit("status", { connected: true });
|
|
2690
|
+
log3(this, "Subscribing to room:", this.room);
|
|
2691
|
+
this._send({ type: "subscribe", topics: [this.room] });
|
|
2692
|
+
const sv = Y3.encodeStateVector(this.doc);
|
|
2693
|
+
log3(this, "Sending sync-step1, state vector size:", sv.length);
|
|
2694
|
+
this._publish({
|
|
2695
|
+
type: "sync-step1",
|
|
2696
|
+
from: this.peerId,
|
|
2697
|
+
sv: toBase642(sv)
|
|
2698
|
+
});
|
|
2699
|
+
const awarenessUpdate = encodeAwarenessUpdate2(this.awareness, [this.doc.clientID]);
|
|
2700
|
+
log3(this, "Sending initial awareness update");
|
|
2701
|
+
this._publish({
|
|
2702
|
+
type: "awareness",
|
|
2703
|
+
from: this.peerId,
|
|
2704
|
+
update: toBase642(awarenessUpdate)
|
|
2705
|
+
});
|
|
2706
|
+
};
|
|
2707
|
+
this.ws.onmessage = (event) => {
|
|
2708
|
+
try {
|
|
2709
|
+
const msg = JSON.parse(event.data);
|
|
2710
|
+
if (msg.type === "publish" && msg.topic === this.room) {
|
|
2711
|
+
this._handleSyncMessage(msg.data);
|
|
2712
|
+
} else if (msg.type === "pong") {
|
|
2713
|
+
}
|
|
2714
|
+
} catch {
|
|
2715
|
+
}
|
|
2716
|
+
};
|
|
2717
|
+
this.ws.onclose = (event) => {
|
|
2718
|
+
log3(this, "WebSocket closed, code:", event.code, "reason:", event.reason || "(none)");
|
|
2719
|
+
this.connected = false;
|
|
2720
|
+
this.synced = false;
|
|
2721
|
+
this.remotePeerIds.clear();
|
|
2722
|
+
this.emit("peers", { count: 0 });
|
|
2723
|
+
this.emit("status", { connected: false });
|
|
2724
|
+
this._scheduleReconnect();
|
|
2725
|
+
};
|
|
2726
|
+
this.ws.onerror = (event) => {
|
|
2727
|
+
log3(this, "WebSocket error:", event);
|
|
2728
|
+
};
|
|
2729
|
+
} catch {
|
|
2730
|
+
this._scheduleReconnect();
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
_scheduleReconnect() {
|
|
2734
|
+
if (this.destroyed) return;
|
|
2735
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
|
2736
|
+
this.reconnectAttempts++;
|
|
2737
|
+
this.reconnectTimer = setTimeout(() => {
|
|
2738
|
+
this._connect();
|
|
2739
|
+
}, this.reconnectDelay);
|
|
2740
|
+
}
|
|
2741
|
+
_send(msg) {
|
|
2742
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
2743
|
+
this.ws.send(JSON.stringify(msg));
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
_publish(data) {
|
|
2747
|
+
this._send({
|
|
2748
|
+
type: "publish",
|
|
2749
|
+
topic: this.room,
|
|
2750
|
+
data
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
_createOutgoingPayload(update) {
|
|
2754
|
+
if (this.options.authorDID && this.options.signingKey) {
|
|
2755
|
+
return {
|
|
2756
|
+
envelope: serializeEnvelope2(
|
|
2757
|
+
signYjsUpdate2(update, this.options.authorDID, this.options.signingKey, this.doc.clientID)
|
|
2758
|
+
)
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
if (this.replicationPolicy.allowUnsignedReplication) {
|
|
2762
|
+
return { update: toBase642(update) };
|
|
2763
|
+
}
|
|
2764
|
+
console.warn(
|
|
2765
|
+
"[WebSocketSyncProvider] Signed replication is required, but no signing identity was provided."
|
|
2766
|
+
);
|
|
2767
|
+
return null;
|
|
2768
|
+
}
|
|
2769
|
+
_extractIncomingUpdate(data) {
|
|
2770
|
+
if (hasEnvelope2(data)) {
|
|
2771
|
+
const envelope = deserializeEnvelope2(data.envelope);
|
|
2772
|
+
if (!envelope) {
|
|
2773
|
+
return null;
|
|
2774
|
+
}
|
|
2775
|
+
const verification = verifyYjsEnvelopeV12(envelope);
|
|
2776
|
+
if (!verification.valid) {
|
|
2777
|
+
return null;
|
|
2778
|
+
}
|
|
2779
|
+
return envelope.update;
|
|
2780
|
+
}
|
|
2781
|
+
if (this.replicationPolicy.requireSignedReplication) {
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
if (typeof data.update !== "string") {
|
|
2785
|
+
return null;
|
|
2786
|
+
}
|
|
2787
|
+
return fromBase64(data.update);
|
|
2788
|
+
}
|
|
2789
|
+
/** Handle incoming sync messages from other peers */
|
|
2790
|
+
_handleSyncMessage(data) {
|
|
2791
|
+
if (!data || data.from === this.peerId) return;
|
|
2792
|
+
if (data.from && typeof data.from === "string") {
|
|
2793
|
+
const hadPeer = this.remotePeerIds.has(data.from);
|
|
2794
|
+
this.remotePeerIds.add(data.from);
|
|
2795
|
+
if (!hadPeer) {
|
|
2796
|
+
this.emit("peers", { count: this.remotePeerIds.size });
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
log3(this, "Received message type:", data.type, "from:", data.from);
|
|
2800
|
+
switch (data.type) {
|
|
2801
|
+
case "sync-step1": {
|
|
2802
|
+
const remoteSV = fromBase64(data.sv);
|
|
2803
|
+
const diff = Y3.encodeStateAsUpdate(this.doc, remoteSV);
|
|
2804
|
+
log3(
|
|
2805
|
+
this,
|
|
2806
|
+
"Received sync-step1 from peer, their SV size:",
|
|
2807
|
+
remoteSV.length,
|
|
2808
|
+
"our diff size:",
|
|
2809
|
+
diff.length
|
|
2810
|
+
);
|
|
2811
|
+
log3(this, "Sending sync-step2 to peer:", data.from, "update size:", diff.length);
|
|
2812
|
+
const payload = this._createOutgoingPayload(diff);
|
|
2813
|
+
if (payload) {
|
|
2814
|
+
this._publish({
|
|
2815
|
+
type: "sync-step2",
|
|
2816
|
+
from: this.peerId,
|
|
2817
|
+
to: data.from,
|
|
2818
|
+
...payload
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
if (!this.synced) {
|
|
2822
|
+
const sv = Y3.encodeStateVector(this.doc);
|
|
2823
|
+
log3(this, "Sending our sync-step1 in response, SV size:", sv.length);
|
|
2824
|
+
this._publish({
|
|
2825
|
+
type: "sync-step1",
|
|
2826
|
+
from: this.peerId,
|
|
2827
|
+
sv: toBase642(sv)
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
const awarenessUpdate = encodeAwarenessUpdate2(this.awareness, [this.doc.clientID]);
|
|
2831
|
+
this._publish({
|
|
2832
|
+
type: "awareness",
|
|
2833
|
+
from: this.peerId,
|
|
2834
|
+
update: toBase642(awarenessUpdate)
|
|
2835
|
+
});
|
|
2836
|
+
break;
|
|
2837
|
+
}
|
|
2838
|
+
case "sync-step2": {
|
|
2839
|
+
if (data.to && data.to !== this.peerId) {
|
|
2840
|
+
log3(this, "Ignoring sync-step2 addressed to different peer:", data.to);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const update = this._extractIncomingUpdate(data);
|
|
2844
|
+
if (!update) {
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
log3(
|
|
2848
|
+
this,
|
|
2849
|
+
"Received sync-step2, applying update size:",
|
|
2850
|
+
update.length,
|
|
2851
|
+
"doc content before:",
|
|
2852
|
+
this.doc.getMap("meta").size,
|
|
2853
|
+
"keys"
|
|
2854
|
+
);
|
|
2855
|
+
Y3.applyUpdate(this.doc, update, this);
|
|
2856
|
+
log3(
|
|
2857
|
+
this,
|
|
2858
|
+
"After applying update, doc content:",
|
|
2859
|
+
this.doc.getMap("meta").size,
|
|
2860
|
+
"keys, XML fragment length:",
|
|
2861
|
+
this._getFragmentLength()
|
|
2862
|
+
);
|
|
2863
|
+
if (!this.synced) {
|
|
2864
|
+
this.synced = true;
|
|
2865
|
+
log3(this, "Sync complete! Emitting synced event");
|
|
2866
|
+
this.emit("synced", { synced: true });
|
|
2867
|
+
}
|
|
2868
|
+
break;
|
|
2869
|
+
}
|
|
2870
|
+
case "sync-update": {
|
|
2871
|
+
const update = this._extractIncomingUpdate(data);
|
|
2872
|
+
if (!update) {
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2875
|
+
log3(this, "Received sync-update, size:", update.length);
|
|
2876
|
+
Y3.applyUpdate(this.doc, update, this);
|
|
2877
|
+
break;
|
|
2878
|
+
}
|
|
2879
|
+
case "awareness": {
|
|
2880
|
+
const update = fromBase64(data.update);
|
|
2881
|
+
log3(this, "Received awareness update");
|
|
2882
|
+
applyAwarenessUpdate2(this.awareness, update, this);
|
|
2883
|
+
break;
|
|
2884
|
+
}
|
|
2885
|
+
case "awareness-snapshot": {
|
|
2886
|
+
const users = Array.isArray(data.users) ? data.users : [];
|
|
2887
|
+
this.emit("awareness-snapshot", users);
|
|
2888
|
+
break;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
/** Broadcast local doc updates to peers */
|
|
2893
|
+
_onDocUpdate = (update, origin) => {
|
|
2894
|
+
if (origin === this) return;
|
|
2895
|
+
if (this.connected) {
|
|
2896
|
+
const payload = this._createOutgoingPayload(update);
|
|
2897
|
+
if (payload) {
|
|
2898
|
+
this._publish({
|
|
2899
|
+
type: "sync-update",
|
|
2900
|
+
from: this.peerId,
|
|
2901
|
+
...payload
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
/** Broadcast local awareness changes to peers */
|
|
2907
|
+
_onAwarenessUpdate = ({ added, updated, removed }, origin) => {
|
|
2908
|
+
if (origin === this) return;
|
|
2909
|
+
const changedClients = [...added, ...updated, ...removed];
|
|
2910
|
+
if (changedClients.length === 0) return;
|
|
2911
|
+
if (this.connected) {
|
|
2912
|
+
const update = encodeAwarenessUpdate2(this.awareness, changedClients);
|
|
2913
|
+
this._publish({
|
|
2914
|
+
type: "awareness",
|
|
2915
|
+
from: this.peerId,
|
|
2916
|
+
update: toBase642(update)
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
// src/sync/InitialSyncManager.ts
|
|
2923
|
+
function createInitialSyncManager() {
|
|
2924
|
+
let progress = {
|
|
2925
|
+
phase: "connecting",
|
|
2926
|
+
roomsTotal: 0,
|
|
2927
|
+
roomsSynced: 0,
|
|
2928
|
+
bytesReceived: 0
|
|
2929
|
+
};
|
|
2930
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2931
|
+
let syncedRoomIds = /* @__PURE__ */ new Set();
|
|
2932
|
+
function notify() {
|
|
2933
|
+
const snapshot = { ...progress };
|
|
2934
|
+
for (const listener of listeners) {
|
|
2935
|
+
listener(snapshot);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
return {
|
|
2939
|
+
onProgress(listener) {
|
|
2940
|
+
listeners.add(listener);
|
|
2941
|
+
listener({ ...progress });
|
|
2942
|
+
return () => {
|
|
2943
|
+
listeners.delete(listener);
|
|
2944
|
+
};
|
|
2945
|
+
},
|
|
2946
|
+
handleMessage(msg) {
|
|
2947
|
+
switch (msg.type) {
|
|
2948
|
+
case "initial-sync":
|
|
2949
|
+
progress.phase = "syncing";
|
|
2950
|
+
if (msg.update) {
|
|
2951
|
+
progress.bytesReceived += msg.update.byteLength;
|
|
2952
|
+
}
|
|
2953
|
+
if (msg.room) {
|
|
2954
|
+
syncedRoomIds.add(msg.room);
|
|
2955
|
+
progress.roomsSynced = syncedRoomIds.size;
|
|
2956
|
+
}
|
|
2957
|
+
if (msg.roomCount != null) {
|
|
2958
|
+
progress.roomsTotal = msg.roomCount;
|
|
2959
|
+
}
|
|
2960
|
+
notify();
|
|
2961
|
+
break;
|
|
2962
|
+
case "node-changes":
|
|
2963
|
+
if (msg.changes) {
|
|
2964
|
+
progress.bytesReceived += JSON.stringify(msg.changes).length;
|
|
2965
|
+
}
|
|
2966
|
+
notify();
|
|
2967
|
+
break;
|
|
2968
|
+
case "initial-sync-complete":
|
|
2969
|
+
progress.phase = "complete";
|
|
2970
|
+
progress.roomsTotal = msg.roomCount ?? syncedRoomIds.size;
|
|
2971
|
+
progress.roomsSynced = syncedRoomIds.size;
|
|
2972
|
+
notify();
|
|
2973
|
+
break;
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
getProgress() {
|
|
2977
|
+
return { ...progress };
|
|
2978
|
+
},
|
|
2979
|
+
start() {
|
|
2980
|
+
syncedRoomIds = /* @__PURE__ */ new Set();
|
|
2981
|
+
progress = {
|
|
2982
|
+
phase: "connecting",
|
|
2983
|
+
roomsTotal: 0,
|
|
2984
|
+
roomsSynced: 0,
|
|
2985
|
+
bytesReceived: 0
|
|
2986
|
+
};
|
|
2987
|
+
notify();
|
|
2988
|
+
},
|
|
2989
|
+
setError(error) {
|
|
2990
|
+
progress.phase = "error";
|
|
2991
|
+
progress.error = error;
|
|
2992
|
+
notify();
|
|
2993
|
+
},
|
|
2994
|
+
reset() {
|
|
2995
|
+
syncedRoomIds = /* @__PURE__ */ new Set();
|
|
2996
|
+
progress = {
|
|
2997
|
+
phase: "connecting",
|
|
2998
|
+
roomsTotal: 0,
|
|
2999
|
+
roomsSynced: 0,
|
|
3000
|
+
bytesReceived: 0
|
|
3001
|
+
};
|
|
3002
|
+
listeners.clear();
|
|
3003
|
+
}
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// src/protocol.ts
|
|
3008
|
+
import { CURRENT_PROTOCOL_VERSION } from "@xnetjs/sync";
|
|
3009
|
+
var XNET_SCHEMA_VERSION = "1.0.0";
|
|
3010
|
+
var XNET_SYNC_ENVELOPE_VERSION = 2;
|
|
3011
|
+
var XNET_AWARENESS_VERSION = 1;
|
|
3012
|
+
var XNET_DATA_MODEL_VERSION = 1;
|
|
3013
|
+
var XNET_UCAN_PROFILE = "1.0";
|
|
3014
|
+
var XNET_PROTOCOL_VERSION = {
|
|
3015
|
+
id: "xnet/1.0",
|
|
3016
|
+
dataModel: XNET_DATA_MODEL_VERSION,
|
|
3017
|
+
change: CURRENT_PROTOCOL_VERSION,
|
|
3018
|
+
syncEnvelope: XNET_SYNC_ENVELOPE_VERSION,
|
|
3019
|
+
awareness: XNET_AWARENESS_VERSION,
|
|
3020
|
+
schema: XNET_SCHEMA_VERSION,
|
|
3021
|
+
cryptoLevel: 0,
|
|
3022
|
+
ucan: XNET_UCAN_PROFILE
|
|
3023
|
+
};
|
|
3024
|
+
var XNET_SUPPORTED_PROTOCOL_VERSIONS = [XNET_PROTOCOL_VERSION.id];
|
|
3025
|
+
function negotiateProtocolVersion(ours, theirs) {
|
|
3026
|
+
const offered = new Set(theirs);
|
|
3027
|
+
for (const version of ours) {
|
|
3028
|
+
if (offered.has(version)) {
|
|
3029
|
+
return version;
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return null;
|
|
3033
|
+
}
|
|
3034
|
+
function isProtocolCompatible(theirs) {
|
|
3035
|
+
return negotiateProtocolVersion(XNET_SUPPORTED_PROTOCOL_VERSIONS, theirs) !== null;
|
|
3036
|
+
}
|
|
3037
|
+
export {
|
|
3038
|
+
METABRIDGE_ORIGIN,
|
|
3039
|
+
METABRIDGE_SEED_ORIGIN,
|
|
3040
|
+
NodeStoreSyncProvider,
|
|
3041
|
+
WebSocketSyncProvider,
|
|
3042
|
+
XNET_AWARENESS_VERSION,
|
|
3043
|
+
XNET_DATA_MODEL_VERSION,
|
|
3044
|
+
XNET_PROTOCOL_VERSION,
|
|
3045
|
+
XNET_SCHEMA_VERSION,
|
|
3046
|
+
XNET_SUPPORTED_PROTOCOL_VERSIONS,
|
|
3047
|
+
XNET_SYNC_ENVELOPE_VERSION,
|
|
3048
|
+
XNET_UCAN_PROFILE,
|
|
3049
|
+
createConnectionManager,
|
|
3050
|
+
createInitialSyncManager,
|
|
3051
|
+
createMetaBridge,
|
|
3052
|
+
createMultiHubConnectionManager,
|
|
3053
|
+
createNodePool,
|
|
3054
|
+
createOfflineQueue,
|
|
3055
|
+
createRegistry,
|
|
3056
|
+
createSyncManager,
|
|
3057
|
+
createXNetClient,
|
|
3058
|
+
isProtocolCompatible,
|
|
3059
|
+
liveQuery,
|
|
3060
|
+
negotiateProtocolVersion
|
|
3061
|
+
};
|