@korajs/sync 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1506 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +515 -4
- package/dist/index.d.ts +515 -4
- package/dist/index.js +1471 -49
- package/dist/index.js.map +1 -1
- package/dist/internal.d.cts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/transport-B-BIguq9.d.cts +385 -0
- package/dist/transport-B-BIguq9.d.ts +385 -0
- package/package.json +3 -3
- package/dist/transport-B5EFsr5F.d.cts +0 -215
- package/dist/transport-B5EFsr5F.d.ts +0 -215
package/dist/index.cjs
CHANGED
|
@@ -30,30 +30,45 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
AwarenessManager: () => AwarenessManager,
|
|
33
34
|
ChaosTransport: () => ChaosTransport,
|
|
34
35
|
ConnectionMonitor: () => ConnectionMonitor,
|
|
36
|
+
DecryptionError: () => DecryptionError,
|
|
37
|
+
EncryptionError: () => EncryptionError,
|
|
35
38
|
HttpLongPollingTransport: () => HttpLongPollingTransport,
|
|
39
|
+
InvalidScopeError: () => InvalidScopeError,
|
|
36
40
|
JsonMessageSerializer: () => JsonMessageSerializer,
|
|
41
|
+
KeyDerivationError: () => KeyDerivationError,
|
|
37
42
|
NegotiatedMessageSerializer: () => NegotiatedMessageSerializer,
|
|
38
43
|
OutboundQueue: () => OutboundQueue,
|
|
39
44
|
ProtobufMessageSerializer: () => ProtobufMessageSerializer,
|
|
40
45
|
ReconnectionManager: () => ReconnectionManager,
|
|
41
46
|
SYNC_STATES: () => SYNC_STATES,
|
|
42
47
|
SYNC_STATUSES: () => SYNC_STATUSES,
|
|
48
|
+
ScopeViolationError: () => ScopeViolationError,
|
|
49
|
+
SyncEncryptor: () => SyncEncryptor,
|
|
43
50
|
SyncEngine: () => SyncEngine,
|
|
44
51
|
WebSocketTransport: () => WebSocketTransport,
|
|
52
|
+
deriveKey: () => deriveKey,
|
|
53
|
+
deriveVersionedKey: () => deriveVersionedKey,
|
|
54
|
+
filterOperationsByScope: () => filterOperationsByScope,
|
|
55
|
+
generateSalt: () => generateSalt,
|
|
45
56
|
isAcknowledgmentMessage: () => isAcknowledgmentMessage,
|
|
57
|
+
isAwarenessUpdateMessage: () => isAwarenessUpdateMessage,
|
|
58
|
+
isEncryptedPayload: () => isEncryptedPayload,
|
|
46
59
|
isErrorMessage: () => isErrorMessage,
|
|
47
60
|
isHandshakeMessage: () => isHandshakeMessage,
|
|
48
61
|
isHandshakeResponseMessage: () => isHandshakeResponseMessage,
|
|
49
62
|
isOperationBatchMessage: () => isOperationBatchMessage,
|
|
50
63
|
isSyncMessage: () => isSyncMessage,
|
|
64
|
+
operationMatchesScope: () => operationMatchesScope,
|
|
51
65
|
versionVectorToWire: () => versionVectorToWire,
|
|
52
66
|
wireToVersionVector: () => wireToVersionVector
|
|
53
67
|
});
|
|
54
68
|
module.exports = __toCommonJS(index_exports);
|
|
55
69
|
|
|
56
70
|
// src/types.ts
|
|
71
|
+
var import_core = require("@korajs/core");
|
|
57
72
|
var SYNC_STATES = [
|
|
58
73
|
"disconnected",
|
|
59
74
|
"connecting",
|
|
@@ -63,6 +78,63 @@ var SYNC_STATES = [
|
|
|
63
78
|
"error"
|
|
64
79
|
];
|
|
65
80
|
var SYNC_STATUSES = ["connected", "syncing", "synced", "offline", "error"];
|
|
81
|
+
var ScopeViolationError = class extends import_core.KoraError {
|
|
82
|
+
constructor(operationId, collection, scope, message) {
|
|
83
|
+
super(
|
|
84
|
+
message ?? `Operation "${operationId}" in collection "${collection}" violates sync scope`,
|
|
85
|
+
"SCOPE_VIOLATION",
|
|
86
|
+
{ operationId, collection, scope }
|
|
87
|
+
);
|
|
88
|
+
this.operationId = operationId;
|
|
89
|
+
this.collection = collection;
|
|
90
|
+
this.scope = scope;
|
|
91
|
+
this.name = "ScopeViolationError";
|
|
92
|
+
}
|
|
93
|
+
operationId;
|
|
94
|
+
collection;
|
|
95
|
+
scope;
|
|
96
|
+
};
|
|
97
|
+
var InvalidScopeError = class extends import_core.KoraError {
|
|
98
|
+
constructor(message, context) {
|
|
99
|
+
super(message, "INVALID_SCOPE", context);
|
|
100
|
+
this.name = "InvalidScopeError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// src/scopes/scope-filter.ts
|
|
105
|
+
function operationMatchesScope(op, scopeMap) {
|
|
106
|
+
if (!scopeMap) return true;
|
|
107
|
+
const collectionScope = scopeMap[op.collection];
|
|
108
|
+
if (!collectionScope) return false;
|
|
109
|
+
if (Object.keys(collectionScope).length === 0) return true;
|
|
110
|
+
const snapshot = buildSnapshot(op);
|
|
111
|
+
if (!snapshot) return false;
|
|
112
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
113
|
+
if (snapshot[field] !== expected) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function filterOperationsByScope(operations, scopeMap) {
|
|
120
|
+
if (!scopeMap) return operations;
|
|
121
|
+
return operations.filter((op) => operationMatchesScope(op, scopeMap));
|
|
122
|
+
}
|
|
123
|
+
function buildSnapshot(op) {
|
|
124
|
+
const previous = asRecord(op.previousData);
|
|
125
|
+
const next = asRecord(op.data);
|
|
126
|
+
if (!previous && !next) return null;
|
|
127
|
+
return {
|
|
128
|
+
...previous ?? {},
|
|
129
|
+
...next ?? {}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function asRecord(value) {
|
|
133
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
66
138
|
|
|
67
139
|
// src/protocol/messages.ts
|
|
68
140
|
function isSyncMessage(value) {
|
|
@@ -80,6 +152,8 @@ function isSyncMessage(value) {
|
|
|
80
152
|
return isAcknowledgmentMessage(value);
|
|
81
153
|
case "error":
|
|
82
154
|
return isErrorMessage(value);
|
|
155
|
+
case "awareness-update":
|
|
156
|
+
return isAwarenessUpdateMessage(value);
|
|
83
157
|
default:
|
|
84
158
|
return false;
|
|
85
159
|
}
|
|
@@ -109,9 +183,14 @@ function isErrorMessage(value) {
|
|
|
109
183
|
const msg = value;
|
|
110
184
|
return msg.type === "error" && typeof msg.messageId === "string" && typeof msg.code === "string" && typeof msg.message === "string" && typeof msg.retriable === "boolean";
|
|
111
185
|
}
|
|
186
|
+
function isAwarenessUpdateMessage(value) {
|
|
187
|
+
if (typeof value !== "object" || value === null) return false;
|
|
188
|
+
const msg = value;
|
|
189
|
+
return msg.type === "awareness-update" && typeof msg.messageId === "string" && typeof msg.clientId === "number" && typeof msg.states === "object" && msg.states !== null && !Array.isArray(msg.states);
|
|
190
|
+
}
|
|
112
191
|
|
|
113
192
|
// src/protocol/serializer.ts
|
|
114
|
-
var
|
|
193
|
+
var import_core2 = require("@korajs/core");
|
|
115
194
|
var import_minimal = __toESM(require("protobufjs/minimal"), 1);
|
|
116
195
|
var { Reader, Writer } = import_minimal.default;
|
|
117
196
|
function versionVectorToWire(vector) {
|
|
@@ -134,12 +213,12 @@ var JsonMessageSerializer = class {
|
|
|
134
213
|
try {
|
|
135
214
|
parsed = JSON.parse(text);
|
|
136
215
|
} catch {
|
|
137
|
-
throw new
|
|
216
|
+
throw new import_core2.SyncError("Failed to decode sync message: invalid JSON", {
|
|
138
217
|
dataLength: text.length
|
|
139
218
|
});
|
|
140
219
|
}
|
|
141
220
|
if (!isSyncMessage(parsed)) {
|
|
142
|
-
throw new
|
|
221
|
+
throw new import_core2.SyncError("Failed to decode sync message: invalid message structure", {
|
|
143
222
|
receivedType: typeof parsed === "object" && parsed !== null ? parsed.type : typeof parsed
|
|
144
223
|
});
|
|
145
224
|
}
|
|
@@ -161,7 +240,10 @@ var JsonMessageSerializer = class {
|
|
|
161
240
|
},
|
|
162
241
|
sequenceNumber: op.sequenceNumber,
|
|
163
242
|
causalDeps: [...op.causalDeps],
|
|
164
|
-
schemaVersion: op.schemaVersion
|
|
243
|
+
schemaVersion: op.schemaVersion,
|
|
244
|
+
...op.atomicOps !== void 0 ? { atomicOps: op.atomicOps } : {},
|
|
245
|
+
...op.transactionId !== void 0 ? { transactionId: op.transactionId } : {},
|
|
246
|
+
...op.mutationName !== void 0 ? { mutationName: op.mutationName } : {}
|
|
165
247
|
};
|
|
166
248
|
}
|
|
167
249
|
decodeOperation(serialized) {
|
|
@@ -180,7 +262,10 @@ var JsonMessageSerializer = class {
|
|
|
180
262
|
},
|
|
181
263
|
sequenceNumber: serialized.sequenceNumber,
|
|
182
264
|
causalDeps: [...serialized.causalDeps],
|
|
183
|
-
schemaVersion: serialized.schemaVersion
|
|
265
|
+
schemaVersion: serialized.schemaVersion,
|
|
266
|
+
...serialized.atomicOps !== void 0 ? { atomicOps: serialized.atomicOps } : {},
|
|
267
|
+
...serialized.transactionId !== void 0 ? { transactionId: serialized.transactionId } : {},
|
|
268
|
+
...serialized.mutationName !== void 0 ? { mutationName: serialized.mutationName } : {}
|
|
184
269
|
};
|
|
185
270
|
}
|
|
186
271
|
};
|
|
@@ -244,7 +329,10 @@ function toProtoEnvelope(message) {
|
|
|
244
329
|
type: message.type,
|
|
245
330
|
messageId: message.messageId,
|
|
246
331
|
nodeId: message.nodeId,
|
|
247
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
332
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
333
|
+
key,
|
|
334
|
+
value
|
|
335
|
+
})),
|
|
248
336
|
schemaVersion: message.schemaVersion,
|
|
249
337
|
authToken: message.authToken,
|
|
250
338
|
supportedWireFormats: message.supportedWireFormats
|
|
@@ -254,7 +342,10 @@ function toProtoEnvelope(message) {
|
|
|
254
342
|
type: message.type,
|
|
255
343
|
messageId: message.messageId,
|
|
256
344
|
nodeId: message.nodeId,
|
|
257
|
-
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
345
|
+
versionVector: Object.entries(message.versionVector).map(([key, value]) => ({
|
|
346
|
+
key,
|
|
347
|
+
value
|
|
348
|
+
})),
|
|
258
349
|
schemaVersion: message.schemaVersion,
|
|
259
350
|
accepted: message.accepted,
|
|
260
351
|
rejectReason: message.rejectReason,
|
|
@@ -283,6 +374,11 @@ function toProtoEnvelope(message) {
|
|
|
283
374
|
errorMessage: message.message,
|
|
284
375
|
retriable: message.retriable
|
|
285
376
|
};
|
|
377
|
+
case "awareness-update":
|
|
378
|
+
return {
|
|
379
|
+
type: message.type,
|
|
380
|
+
messageId: message.messageId
|
|
381
|
+
};
|
|
286
382
|
}
|
|
287
383
|
}
|
|
288
384
|
function fromProtoEnvelope(envelope) {
|
|
@@ -292,7 +388,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
292
388
|
type: "handshake",
|
|
293
389
|
messageId: envelope.messageId,
|
|
294
390
|
nodeId: envelope.nodeId ?? "",
|
|
295
|
-
versionVector: Object.fromEntries(
|
|
391
|
+
versionVector: Object.fromEntries(
|
|
392
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
393
|
+
),
|
|
296
394
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
297
395
|
authToken: envelope.authToken,
|
|
298
396
|
supportedWireFormats: envelope.supportedWireFormats?.filter(
|
|
@@ -304,7 +402,9 @@ function fromProtoEnvelope(envelope) {
|
|
|
304
402
|
type: "handshake-response",
|
|
305
403
|
messageId: envelope.messageId,
|
|
306
404
|
nodeId: envelope.nodeId ?? "",
|
|
307
|
-
versionVector: Object.fromEntries(
|
|
405
|
+
versionVector: Object.fromEntries(
|
|
406
|
+
(envelope.versionVector ?? []).map((entry) => [entry.key, entry.value])
|
|
407
|
+
),
|
|
308
408
|
schemaVersion: envelope.schemaVersion ?? 0,
|
|
309
409
|
accepted: envelope.accepted ?? false,
|
|
310
410
|
rejectReason: envelope.rejectReason,
|
|
@@ -334,19 +434,39 @@ function fromProtoEnvelope(envelope) {
|
|
|
334
434
|
retriable: envelope.retriable ?? false
|
|
335
435
|
};
|
|
336
436
|
default:
|
|
337
|
-
throw new
|
|
437
|
+
throw new import_core2.SyncError("Failed to decode sync message: unknown protobuf type", {
|
|
338
438
|
type: envelope.type
|
|
339
439
|
});
|
|
340
440
|
}
|
|
341
441
|
}
|
|
342
442
|
function serializeProtoOperation(operation) {
|
|
443
|
+
const hasMetadata = operation.transactionId !== void 0 || operation.mutationName !== void 0;
|
|
444
|
+
let dataJson = "";
|
|
445
|
+
if (operation.data !== null) {
|
|
446
|
+
const dataPayload = { ...operation.data };
|
|
447
|
+
if (operation.atomicOps !== void 0 && Object.keys(operation.atomicOps).length > 0) {
|
|
448
|
+
dataPayload.__kora_atomic_ops__ = operation.atomicOps;
|
|
449
|
+
}
|
|
450
|
+
if (operation.transactionId !== void 0) {
|
|
451
|
+
dataPayload.__kora_tx_id__ = operation.transactionId;
|
|
452
|
+
}
|
|
453
|
+
if (operation.mutationName !== void 0) {
|
|
454
|
+
dataPayload.__kora_mutation__ = operation.mutationName;
|
|
455
|
+
}
|
|
456
|
+
dataJson = JSON.stringify(dataPayload);
|
|
457
|
+
} else if (hasMetadata) {
|
|
458
|
+
const meta = {};
|
|
459
|
+
if (operation.transactionId !== void 0) meta.__kora_tx_id__ = operation.transactionId;
|
|
460
|
+
if (operation.mutationName !== void 0) meta.__kora_mutation__ = operation.mutationName;
|
|
461
|
+
dataJson = JSON.stringify(meta);
|
|
462
|
+
}
|
|
343
463
|
return {
|
|
344
464
|
id: operation.id,
|
|
345
465
|
nodeId: operation.nodeId,
|
|
346
466
|
type: operation.type,
|
|
347
467
|
collection: operation.collection,
|
|
348
468
|
recordId: operation.recordId,
|
|
349
|
-
dataJson
|
|
469
|
+
dataJson,
|
|
350
470
|
previousDataJson: operation.previousData === null ? "" : JSON.stringify(operation.previousData),
|
|
351
471
|
timestamp: {
|
|
352
472
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -361,13 +481,31 @@ function serializeProtoOperation(operation) {
|
|
|
361
481
|
};
|
|
362
482
|
}
|
|
363
483
|
function deserializeProtoOperation(operation) {
|
|
484
|
+
let data = null;
|
|
485
|
+
let atomicOps;
|
|
486
|
+
let transactionId;
|
|
487
|
+
let mutationName;
|
|
488
|
+
if (operation.hasData || operation.dataJson.length > 0) {
|
|
489
|
+
const parsed = JSON.parse(operation.dataJson);
|
|
490
|
+
if ("__kora_atomic_ops__" in parsed) {
|
|
491
|
+
atomicOps = parsed.__kora_atomic_ops__;
|
|
492
|
+
}
|
|
493
|
+
if ("__kora_tx_id__" in parsed) {
|
|
494
|
+
transactionId = parsed.__kora_tx_id__;
|
|
495
|
+
}
|
|
496
|
+
if ("__kora_mutation__" in parsed) {
|
|
497
|
+
mutationName = parsed.__kora_mutation__;
|
|
498
|
+
}
|
|
499
|
+
const { __kora_atomic_ops__: _a, __kora_tx_id__: _t, __kora_mutation__: _m, ...rest } = parsed;
|
|
500
|
+
data = operation.hasData && Object.keys(rest).length > 0 ? rest : null;
|
|
501
|
+
}
|
|
364
502
|
return {
|
|
365
503
|
id: operation.id,
|
|
366
504
|
nodeId: operation.nodeId,
|
|
367
505
|
type: operation.type,
|
|
368
506
|
collection: operation.collection,
|
|
369
507
|
recordId: operation.recordId,
|
|
370
|
-
data
|
|
508
|
+
data,
|
|
371
509
|
previousData: operation.hasPreviousData ? JSON.parse(operation.previousDataJson) : null,
|
|
372
510
|
timestamp: {
|
|
373
511
|
wallTime: operation.timestamp.wallTime,
|
|
@@ -376,7 +514,10 @@ function deserializeProtoOperation(operation) {
|
|
|
376
514
|
},
|
|
377
515
|
sequenceNumber: operation.sequenceNumber,
|
|
378
516
|
causalDeps: [...operation.causalDeps],
|
|
379
|
-
schemaVersion: operation.schemaVersion
|
|
517
|
+
schemaVersion: operation.schemaVersion,
|
|
518
|
+
...atomicOps !== void 0 ? { atomicOps } : {},
|
|
519
|
+
...transactionId !== void 0 ? { transactionId } : {},
|
|
520
|
+
...mutationName !== void 0 ? { mutationName } : {}
|
|
380
521
|
};
|
|
381
522
|
}
|
|
382
523
|
function decodeTextPayload(data) {
|
|
@@ -393,7 +534,7 @@ function toBytes(data) {
|
|
|
393
534
|
if (data instanceof ArrayBuffer) {
|
|
394
535
|
return new Uint8Array(data);
|
|
395
536
|
}
|
|
396
|
-
throw new
|
|
537
|
+
throw new import_core2.SyncError("Unsupported sync payload type", { receivedType: typeof data });
|
|
397
538
|
}
|
|
398
539
|
function encodeEnvelope(envelope) {
|
|
399
540
|
const writer = Writer.create();
|
|
@@ -407,12 +548,14 @@ function encodeEnvelope(envelope) {
|
|
|
407
548
|
writer.ldelim();
|
|
408
549
|
}
|
|
409
550
|
if (envelope.schemaVersion !== void 0) writer.uint32(40).int32(envelope.schemaVersion);
|
|
410
|
-
if (envelope.authToken && envelope.authToken.length > 0)
|
|
551
|
+
if (envelope.authToken && envelope.authToken.length > 0)
|
|
552
|
+
writer.uint32(50).string(envelope.authToken);
|
|
411
553
|
for (const format of envelope.supportedWireFormats ?? []) {
|
|
412
554
|
writer.uint32(58).string(format);
|
|
413
555
|
}
|
|
414
556
|
if (envelope.accepted !== void 0) writer.uint32(64).bool(envelope.accepted);
|
|
415
|
-
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
557
|
+
if (envelope.rejectReason && envelope.rejectReason.length > 0)
|
|
558
|
+
writer.uint32(74).string(envelope.rejectReason);
|
|
416
559
|
if (envelope.selectedWireFormat && envelope.selectedWireFormat.length > 0) {
|
|
417
560
|
writer.uint32(82).string(envelope.selectedWireFormat);
|
|
418
561
|
}
|
|
@@ -426,8 +569,10 @@ function encodeEnvelope(envelope) {
|
|
|
426
569
|
if (envelope.acknowledgedMessageId && envelope.acknowledgedMessageId.length > 0) {
|
|
427
570
|
writer.uint32(114).string(envelope.acknowledgedMessageId);
|
|
428
571
|
}
|
|
429
|
-
if (envelope.lastSequenceNumber !== void 0)
|
|
430
|
-
|
|
572
|
+
if (envelope.lastSequenceNumber !== void 0)
|
|
573
|
+
writer.uint32(120).int64(envelope.lastSequenceNumber);
|
|
574
|
+
if (envelope.errorCode && envelope.errorCode.length > 0)
|
|
575
|
+
writer.uint32(130).string(envelope.errorCode);
|
|
431
576
|
if (envelope.errorMessage && envelope.errorMessage.length > 0) {
|
|
432
577
|
writer.uint32(138).string(envelope.errorMessage);
|
|
433
578
|
}
|
|
@@ -450,7 +595,10 @@ function decodeEnvelope(bytes) {
|
|
|
450
595
|
envelope.nodeId = reader.string();
|
|
451
596
|
break;
|
|
452
597
|
case 4:
|
|
453
|
-
envelope.versionVector = [
|
|
598
|
+
envelope.versionVector = [
|
|
599
|
+
...envelope.versionVector ?? [],
|
|
600
|
+
decodeVectorEntry(reader, reader.uint32())
|
|
601
|
+
];
|
|
454
602
|
break;
|
|
455
603
|
case 5:
|
|
456
604
|
envelope.schemaVersion = reader.int32();
|
|
@@ -471,7 +619,10 @@ function decodeEnvelope(bytes) {
|
|
|
471
619
|
envelope.selectedWireFormat = reader.string();
|
|
472
620
|
break;
|
|
473
621
|
case 11:
|
|
474
|
-
envelope.operations = [
|
|
622
|
+
envelope.operations = [
|
|
623
|
+
...envelope.operations ?? [],
|
|
624
|
+
decodeProtoOperation(reader, reader.uint32())
|
|
625
|
+
];
|
|
475
626
|
break;
|
|
476
627
|
case 12:
|
|
477
628
|
envelope.isFinal = reader.bool();
|
|
@@ -627,13 +778,13 @@ function longToNumber(value) {
|
|
|
627
778
|
if (typeof value === "object" && value !== null && "toNumber" in value && typeof value.toNumber === "function") {
|
|
628
779
|
return value.toNumber();
|
|
629
780
|
}
|
|
630
|
-
throw new
|
|
781
|
+
throw new import_core2.SyncError("Failed to decode int64 value", {
|
|
631
782
|
receivedType: typeof value
|
|
632
783
|
});
|
|
633
784
|
}
|
|
634
785
|
|
|
635
786
|
// src/transport/websocket-transport.ts
|
|
636
|
-
var
|
|
787
|
+
var import_core3 = require("@korajs/core");
|
|
637
788
|
var WS_OPEN = 1;
|
|
638
789
|
var WebSocketTransport = class {
|
|
639
790
|
ws = null;
|
|
@@ -656,7 +807,7 @@ var WebSocketTransport = class {
|
|
|
656
807
|
}
|
|
657
808
|
async connect(url, options) {
|
|
658
809
|
if (!this.WebSocketImpl) {
|
|
659
|
-
throw new
|
|
810
|
+
throw new import_core3.SyncError("WebSocket is not available in this environment", {
|
|
660
811
|
hint: "Provide a WebSocketImpl option or use a polyfill"
|
|
661
812
|
});
|
|
662
813
|
}
|
|
@@ -670,7 +821,7 @@ var WebSocketTransport = class {
|
|
|
670
821
|
};
|
|
671
822
|
const timer = setTimeout(() => {
|
|
672
823
|
settle(() => {
|
|
673
|
-
const err = new
|
|
824
|
+
const err = new import_core3.SyncError("WebSocket connection timed out", {
|
|
674
825
|
url,
|
|
675
826
|
timeout: this.connectTimeout
|
|
676
827
|
});
|
|
@@ -702,7 +853,7 @@ var WebSocketTransport = class {
|
|
|
702
853
|
const message = this.serializer.decode(event.data);
|
|
703
854
|
this.messageHandler?.(message);
|
|
704
855
|
} catch {
|
|
705
|
-
this.errorHandler?.(new
|
|
856
|
+
this.errorHandler?.(new import_core3.SyncError("Failed to decode incoming message"));
|
|
706
857
|
}
|
|
707
858
|
};
|
|
708
859
|
ws.onclose = (event) => {
|
|
@@ -710,7 +861,7 @@ var WebSocketTransport = class {
|
|
|
710
861
|
this.closeHandler?.(event.reason || `WebSocket closed with code ${event.code}`);
|
|
711
862
|
};
|
|
712
863
|
ws.onerror = (event) => {
|
|
713
|
-
const err = new
|
|
864
|
+
const err = new import_core3.SyncError("WebSocket error", {
|
|
714
865
|
url
|
|
715
866
|
});
|
|
716
867
|
this.errorHandler?.(err);
|
|
@@ -722,7 +873,7 @@ var WebSocketTransport = class {
|
|
|
722
873
|
} catch (err) {
|
|
723
874
|
settle(
|
|
724
875
|
() => reject(
|
|
725
|
-
err instanceof
|
|
876
|
+
err instanceof import_core3.SyncError ? err : new import_core3.SyncError("Failed to create WebSocket", {
|
|
726
877
|
url,
|
|
727
878
|
error: String(err)
|
|
728
879
|
})
|
|
@@ -740,7 +891,7 @@ var WebSocketTransport = class {
|
|
|
740
891
|
}
|
|
741
892
|
send(message) {
|
|
742
893
|
if (!this.ws || this.ws.readyState !== WS_OPEN) {
|
|
743
|
-
throw new
|
|
894
|
+
throw new import_core3.SyncError("Cannot send message: WebSocket is not connected", {
|
|
744
895
|
messageType: message.type
|
|
745
896
|
});
|
|
746
897
|
}
|
|
@@ -762,7 +913,7 @@ var WebSocketTransport = class {
|
|
|
762
913
|
};
|
|
763
914
|
|
|
764
915
|
// src/transport/http-long-polling-transport.ts
|
|
765
|
-
var
|
|
916
|
+
var import_core4 = require("@korajs/core");
|
|
766
917
|
var DEFAULT_RETRY_DELAY_MS = 250;
|
|
767
918
|
var HttpLongPollingTransport = class {
|
|
768
919
|
serializer;
|
|
@@ -788,7 +939,7 @@ var HttpLongPollingTransport = class {
|
|
|
788
939
|
}
|
|
789
940
|
async connect(url, options) {
|
|
790
941
|
if (this.connected) {
|
|
791
|
-
throw new
|
|
942
|
+
throw new import_core4.SyncError("HTTP long-poll transport already connected", { url });
|
|
792
943
|
}
|
|
793
944
|
this.url = normalizeHttpUrl(url);
|
|
794
945
|
this.authToken = options?.authToken;
|
|
@@ -818,7 +969,7 @@ var HttpLongPollingTransport = class {
|
|
|
818
969
|
}
|
|
819
970
|
send(message) {
|
|
820
971
|
if (!this.connected) {
|
|
821
|
-
throw new
|
|
972
|
+
throw new import_core4.SyncError("Cannot send message: HTTP long-poll transport is not connected", {
|
|
822
973
|
messageType: message.type
|
|
823
974
|
});
|
|
824
975
|
}
|
|
@@ -882,7 +1033,7 @@ var HttpLongPollingTransport = class {
|
|
|
882
1033
|
body: requestBody
|
|
883
1034
|
});
|
|
884
1035
|
if (!response.ok) {
|
|
885
|
-
throw new
|
|
1036
|
+
throw new import_core4.SyncError("HTTP transport send failed", {
|
|
886
1037
|
status: response.status,
|
|
887
1038
|
messageType: message.type
|
|
888
1039
|
});
|
|
@@ -904,7 +1055,7 @@ var HttpLongPollingTransport = class {
|
|
|
904
1055
|
continue;
|
|
905
1056
|
}
|
|
906
1057
|
if (!response.ok) {
|
|
907
|
-
throw new
|
|
1058
|
+
throw new import_core4.SyncError("HTTP long-poll request failed", {
|
|
908
1059
|
status: response.status
|
|
909
1060
|
});
|
|
910
1061
|
}
|
|
@@ -1080,9 +1231,656 @@ var ChaosTransport = class {
|
|
|
1080
1231
|
};
|
|
1081
1232
|
|
|
1082
1233
|
// src/engine/sync-engine.ts
|
|
1083
|
-
var
|
|
1234
|
+
var import_core5 = require("@korajs/core");
|
|
1084
1235
|
var import_internal2 = require("@korajs/core/internal");
|
|
1085
1236
|
|
|
1237
|
+
// src/diagnostics/bandwidth-estimator.ts
|
|
1238
|
+
var BandwidthEstimator = class {
|
|
1239
|
+
maxSamples;
|
|
1240
|
+
timeSource;
|
|
1241
|
+
samples = [];
|
|
1242
|
+
/**
|
|
1243
|
+
* @param maxSamples - Maximum number of samples to retain. Defaults to 20.
|
|
1244
|
+
* @param timeSource - Injectable time source for deterministic testing.
|
|
1245
|
+
*/
|
|
1246
|
+
constructor(maxSamples = 20, timeSource) {
|
|
1247
|
+
this.maxSamples = maxSamples;
|
|
1248
|
+
this.timeSource = timeSource ?? { now: () => Date.now() };
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Record a transfer of `bytes` that took `durationMs` to complete.
|
|
1252
|
+
* Samples with zero or negative duration are ignored to avoid division errors.
|
|
1253
|
+
*/
|
|
1254
|
+
recordTransfer(bytes, durationMs) {
|
|
1255
|
+
if (durationMs <= 0 || bytes <= 0) return;
|
|
1256
|
+
this.samples.push({
|
|
1257
|
+
bytes,
|
|
1258
|
+
durationMs,
|
|
1259
|
+
timestamp: this.timeSource.now()
|
|
1260
|
+
});
|
|
1261
|
+
while (this.samples.length > this.maxSamples) {
|
|
1262
|
+
this.samples.shift();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Estimate current effective bandwidth in bytes per second.
|
|
1267
|
+
*
|
|
1268
|
+
* Uses exponential weighting so recent samples have more influence.
|
|
1269
|
+
* Returns null if fewer than 2 samples exist (not enough data).
|
|
1270
|
+
*/
|
|
1271
|
+
estimate() {
|
|
1272
|
+
if (this.samples.length < 2) return null;
|
|
1273
|
+
let weightedSum = 0;
|
|
1274
|
+
let totalWeight = 0;
|
|
1275
|
+
const decayFactor = 0.9;
|
|
1276
|
+
for (let i = this.samples.length - 1; i >= 0; i--) {
|
|
1277
|
+
const sample = this.samples[i];
|
|
1278
|
+
const bytesPerSec = sample.bytes / sample.durationMs * 1e3;
|
|
1279
|
+
const age = this.samples.length - 1 - i;
|
|
1280
|
+
const weight = decayFactor ** age;
|
|
1281
|
+
weightedSum += bytesPerSec * weight;
|
|
1282
|
+
totalWeight += weight;
|
|
1283
|
+
}
|
|
1284
|
+
if (totalWeight === 0) return null;
|
|
1285
|
+
return Math.round(weightedSum / totalWeight);
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Reset all recorded samples.
|
|
1289
|
+
*/
|
|
1290
|
+
reset() {
|
|
1291
|
+
this.samples.length = 0;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Get the number of samples currently stored.
|
|
1295
|
+
*/
|
|
1296
|
+
sampleCount() {
|
|
1297
|
+
return this.samples.length;
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
// src/diagnostics/percentile.ts
|
|
1302
|
+
var SlidingWindowPercentile = class {
|
|
1303
|
+
samples;
|
|
1304
|
+
maxSize;
|
|
1305
|
+
writeIndex = 0;
|
|
1306
|
+
count = 0;
|
|
1307
|
+
/**
|
|
1308
|
+
* @param maxSize - Maximum number of samples in the sliding window.
|
|
1309
|
+
* Older samples are overwritten when the buffer is full.
|
|
1310
|
+
*/
|
|
1311
|
+
constructor(maxSize) {
|
|
1312
|
+
if (maxSize < 1) {
|
|
1313
|
+
throw new Error(`SlidingWindowPercentile maxSize must be >= 1, got ${maxSize}`);
|
|
1314
|
+
}
|
|
1315
|
+
this.maxSize = maxSize;
|
|
1316
|
+
this.samples = new Array(maxSize);
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Add a sample to the sliding window.
|
|
1320
|
+
* If the window is full, the oldest sample is overwritten.
|
|
1321
|
+
*/
|
|
1322
|
+
addSample(value) {
|
|
1323
|
+
this.samples[this.writeIndex] = value;
|
|
1324
|
+
this.writeIndex = (this.writeIndex + 1) % this.maxSize;
|
|
1325
|
+
if (this.count < this.maxSize) {
|
|
1326
|
+
this.count++;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Compute a percentile value from the current window.
|
|
1331
|
+
*
|
|
1332
|
+
* Uses the nearest-rank method: the percentile value is the smallest
|
|
1333
|
+
* value in the dataset such that at least p% of the data is <= that value.
|
|
1334
|
+
*
|
|
1335
|
+
* @param p - Percentile to compute (0-100). E.g., 50 for median, 95 for p95.
|
|
1336
|
+
* @returns The percentile value, or 0 if no samples have been recorded.
|
|
1337
|
+
*/
|
|
1338
|
+
percentile(p) {
|
|
1339
|
+
if (this.count === 0) return 0;
|
|
1340
|
+
const sorted = this.samples.slice(0, this.count).sort((a, b) => a - b);
|
|
1341
|
+
const rank = Math.ceil(p / 100 * sorted.length) - 1;
|
|
1342
|
+
const index = Math.max(0, Math.min(rank, sorted.length - 1));
|
|
1343
|
+
return sorted[index];
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Get the most recently added sample, or 0 if no samples exist.
|
|
1347
|
+
*/
|
|
1348
|
+
latest() {
|
|
1349
|
+
if (this.count === 0) return 0;
|
|
1350
|
+
const lastIndex = (this.writeIndex - 1 + this.maxSize) % this.maxSize;
|
|
1351
|
+
return this.samples[lastIndex];
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Get the number of samples currently in the window.
|
|
1355
|
+
*/
|
|
1356
|
+
size() {
|
|
1357
|
+
return this.count;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Reset the sliding window, clearing all samples.
|
|
1361
|
+
*/
|
|
1362
|
+
reset() {
|
|
1363
|
+
this.writeIndex = 0;
|
|
1364
|
+
this.count = 0;
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
// src/diagnostics/metrics-collector.ts
|
|
1369
|
+
var SyncMetricsCollector = class {
|
|
1370
|
+
rttWindow;
|
|
1371
|
+
inboundBandwidth;
|
|
1372
|
+
outboundBandwidth;
|
|
1373
|
+
timeSource;
|
|
1374
|
+
diagnosticsInterval;
|
|
1375
|
+
// Connection
|
|
1376
|
+
connectedAt = null;
|
|
1377
|
+
disconnectedAt = null;
|
|
1378
|
+
reconnectAttempts = 0;
|
|
1379
|
+
// Throughput
|
|
1380
|
+
operationsSent = 0;
|
|
1381
|
+
operationsReceived = 0;
|
|
1382
|
+
bytesSent = 0;
|
|
1383
|
+
bytesReceived = 0;
|
|
1384
|
+
// Queue
|
|
1385
|
+
pendingOperations = 0;
|
|
1386
|
+
outboundQueueSize = 0;
|
|
1387
|
+
// Sync progress
|
|
1388
|
+
lastSyncedAt = null;
|
|
1389
|
+
syncStartTime = null;
|
|
1390
|
+
syncDuration = null;
|
|
1391
|
+
initialSyncComplete = false;
|
|
1392
|
+
initialSyncTotalBatches = 0;
|
|
1393
|
+
initialSyncReceivedBatches = 0;
|
|
1394
|
+
// Errors
|
|
1395
|
+
lastError = null;
|
|
1396
|
+
errorCount = 0;
|
|
1397
|
+
// Status
|
|
1398
|
+
currentStatus = "offline";
|
|
1399
|
+
currentQuality = "offline";
|
|
1400
|
+
// Periodic emission
|
|
1401
|
+
emitter = null;
|
|
1402
|
+
periodicTimer = null;
|
|
1403
|
+
constructor(config) {
|
|
1404
|
+
this.rttWindow = new SlidingWindowPercentile(config?.rttWindowSize ?? 100);
|
|
1405
|
+
this.inboundBandwidth = new BandwidthEstimator(
|
|
1406
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1407
|
+
config?.timeSource
|
|
1408
|
+
);
|
|
1409
|
+
this.outboundBandwidth = new BandwidthEstimator(
|
|
1410
|
+
config?.bandwidthWindowSize ?? 20,
|
|
1411
|
+
config?.timeSource
|
|
1412
|
+
);
|
|
1413
|
+
this.timeSource = config?.timeSource ?? { now: () => Date.now() };
|
|
1414
|
+
this.diagnosticsInterval = config?.diagnosticsInterval ?? 5e3;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Attach an event emitter for periodic diagnostics emission.
|
|
1418
|
+
* Starts emitting `sync:diagnostics` events at the configured interval
|
|
1419
|
+
* while connected.
|
|
1420
|
+
*/
|
|
1421
|
+
attachEmitter(emitter) {
|
|
1422
|
+
this.emitter = emitter;
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Record that a connection has been established.
|
|
1426
|
+
*/
|
|
1427
|
+
recordConnected() {
|
|
1428
|
+
this.connectedAt = this.timeSource.now();
|
|
1429
|
+
this.reconnectAttempts = 0;
|
|
1430
|
+
this.startPeriodicEmission();
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Record that the connection has been lost.
|
|
1434
|
+
*/
|
|
1435
|
+
recordDisconnected() {
|
|
1436
|
+
this.disconnectedAt = this.timeSource.now();
|
|
1437
|
+
this.stopPeriodicEmission();
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Record a reconnection attempt.
|
|
1441
|
+
*/
|
|
1442
|
+
recordReconnectAttempt() {
|
|
1443
|
+
this.reconnectAttempts++;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Record a round-trip time measurement.
|
|
1447
|
+
*/
|
|
1448
|
+
recordRtt(ms) {
|
|
1449
|
+
this.rttWindow.addSample(ms);
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Record operations sent with their serialized byte size.
|
|
1453
|
+
*/
|
|
1454
|
+
recordSent(operationCount, byteSize, durationMs) {
|
|
1455
|
+
this.operationsSent += operationCount;
|
|
1456
|
+
this.bytesSent += byteSize;
|
|
1457
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1458
|
+
this.outboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1459
|
+
this.emitBandwidthEvent("out");
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Record operations received with their serialized byte size.
|
|
1464
|
+
*/
|
|
1465
|
+
recordReceived(operationCount, byteSize, durationMs) {
|
|
1466
|
+
this.operationsReceived += operationCount;
|
|
1467
|
+
this.bytesReceived += byteSize;
|
|
1468
|
+
if (durationMs > 0 && byteSize > 0) {
|
|
1469
|
+
this.inboundBandwidth.recordTransfer(byteSize, durationMs);
|
|
1470
|
+
this.emitBandwidthEvent("in");
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Update the pending operations count and estimated outbound queue size.
|
|
1475
|
+
*/
|
|
1476
|
+
updateQueue(pendingOps, estimatedBytes) {
|
|
1477
|
+
this.pendingOperations = pendingOps;
|
|
1478
|
+
this.outboundQueueSize = estimatedBytes;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Record that sync has started (for measuring sync duration).
|
|
1482
|
+
*/
|
|
1483
|
+
recordSyncStarted() {
|
|
1484
|
+
this.syncStartTime = this.timeSource.now();
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Record that a sync cycle has completed.
|
|
1488
|
+
*/
|
|
1489
|
+
recordSyncCompleted() {
|
|
1490
|
+
if (this.syncStartTime !== null) {
|
|
1491
|
+
this.syncDuration = this.timeSource.now() - this.syncStartTime;
|
|
1492
|
+
this.syncStartTime = null;
|
|
1493
|
+
}
|
|
1494
|
+
this.lastSyncedAt = this.timeSource.now();
|
|
1495
|
+
if (!this.initialSyncComplete) {
|
|
1496
|
+
this.initialSyncComplete = true;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Update initial sync progress.
|
|
1501
|
+
* @param receivedBatches - Number of delta batches received so far.
|
|
1502
|
+
* @param totalBatches - Estimated total batches (0 if unknown).
|
|
1503
|
+
*/
|
|
1504
|
+
updateInitialSyncProgress(receivedBatches, totalBatches) {
|
|
1505
|
+
this.initialSyncReceivedBatches = receivedBatches;
|
|
1506
|
+
this.initialSyncTotalBatches = totalBatches;
|
|
1507
|
+
const progress = totalBatches > 0 ? Math.min(1, receivedBatches / totalBatches) : receivedBatches > 0 ? 0.5 : 0;
|
|
1508
|
+
this.emitter?.emit({
|
|
1509
|
+
type: "sync:initial-sync-progress",
|
|
1510
|
+
progress,
|
|
1511
|
+
totalBatches,
|
|
1512
|
+
receivedBatches
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Record an error.
|
|
1517
|
+
*/
|
|
1518
|
+
recordError(message) {
|
|
1519
|
+
this.lastError = message;
|
|
1520
|
+
this.errorCount++;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Update the current developer-facing status.
|
|
1524
|
+
*/
|
|
1525
|
+
updateStatus(status) {
|
|
1526
|
+
this.currentStatus = status;
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Update the connection quality.
|
|
1530
|
+
*/
|
|
1531
|
+
updateQuality(quality) {
|
|
1532
|
+
this.currentQuality = quality;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Compute and return a full diagnostics snapshot.
|
|
1536
|
+
*/
|
|
1537
|
+
getSnapshot() {
|
|
1538
|
+
return {
|
|
1539
|
+
// Connection
|
|
1540
|
+
status: this.currentStatus,
|
|
1541
|
+
connectedAt: this.connectedAt,
|
|
1542
|
+
disconnectedAt: this.disconnectedAt,
|
|
1543
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
1544
|
+
// Latency
|
|
1545
|
+
rttMs: this.rttWindow.latest(),
|
|
1546
|
+
rttP50Ms: this.rttWindow.percentile(50),
|
|
1547
|
+
rttP95Ms: this.rttWindow.percentile(95),
|
|
1548
|
+
rttP99Ms: this.rttWindow.percentile(99),
|
|
1549
|
+
// Throughput
|
|
1550
|
+
operationsSent: this.operationsSent,
|
|
1551
|
+
operationsReceived: this.operationsReceived,
|
|
1552
|
+
bytesSent: this.bytesSent,
|
|
1553
|
+
bytesReceived: this.bytesReceived,
|
|
1554
|
+
// Queue
|
|
1555
|
+
pendingOperations: this.pendingOperations,
|
|
1556
|
+
outboundQueueSize: this.outboundQueueSize,
|
|
1557
|
+
// Sync Progress
|
|
1558
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
1559
|
+
syncDuration: this.syncDuration,
|
|
1560
|
+
initialSyncComplete: this.initialSyncComplete,
|
|
1561
|
+
initialSyncProgress: this.computeInitialSyncProgress(),
|
|
1562
|
+
// Errors
|
|
1563
|
+
lastError: this.lastError,
|
|
1564
|
+
errorCount: this.errorCount,
|
|
1565
|
+
// Connection Quality
|
|
1566
|
+
quality: this.currentQuality,
|
|
1567
|
+
effectiveBandwidth: this.computeEffectiveBandwidth()
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Assess connection quality from current metrics.
|
|
1572
|
+
*
|
|
1573
|
+
* Quality is derived from RTT percentiles, error rate, and bandwidth:
|
|
1574
|
+
* - excellent: p95 < 100ms, no errors
|
|
1575
|
+
* - good: p95 < 300ms, errorCount <= 1
|
|
1576
|
+
* - fair: p95 < 1000ms, errorCount <= 3
|
|
1577
|
+
* - poor: p95 >= 1000ms or errorCount > 3
|
|
1578
|
+
* - offline: no connection
|
|
1579
|
+
*/
|
|
1580
|
+
assessQuality() {
|
|
1581
|
+
if (this.currentStatus === "offline") return "offline";
|
|
1582
|
+
const p95 = this.rttWindow.percentile(95);
|
|
1583
|
+
const hasSamples = this.rttWindow.size() > 0;
|
|
1584
|
+
if (!hasSamples) {
|
|
1585
|
+
if (this.errorCount > 3) return "poor";
|
|
1586
|
+
if (this.errorCount > 0) return "fair";
|
|
1587
|
+
return "good";
|
|
1588
|
+
}
|
|
1589
|
+
if (this.errorCount > 3) return "poor";
|
|
1590
|
+
if (p95 < 100 && this.errorCount === 0) return "excellent";
|
|
1591
|
+
if (p95 < 300 && this.errorCount <= 1) return "good";
|
|
1592
|
+
if (p95 < 1e3 && this.errorCount <= 3) return "fair";
|
|
1593
|
+
return "poor";
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Reset all collected metrics. Call when starting a new session.
|
|
1597
|
+
*/
|
|
1598
|
+
reset() {
|
|
1599
|
+
this.rttWindow.reset();
|
|
1600
|
+
this.inboundBandwidth.reset();
|
|
1601
|
+
this.outboundBandwidth.reset();
|
|
1602
|
+
this.connectedAt = null;
|
|
1603
|
+
this.disconnectedAt = null;
|
|
1604
|
+
this.reconnectAttempts = 0;
|
|
1605
|
+
this.operationsSent = 0;
|
|
1606
|
+
this.operationsReceived = 0;
|
|
1607
|
+
this.bytesSent = 0;
|
|
1608
|
+
this.bytesReceived = 0;
|
|
1609
|
+
this.pendingOperations = 0;
|
|
1610
|
+
this.outboundQueueSize = 0;
|
|
1611
|
+
this.lastSyncedAt = null;
|
|
1612
|
+
this.syncStartTime = null;
|
|
1613
|
+
this.syncDuration = null;
|
|
1614
|
+
this.initialSyncComplete = false;
|
|
1615
|
+
this.initialSyncTotalBatches = 0;
|
|
1616
|
+
this.initialSyncReceivedBatches = 0;
|
|
1617
|
+
this.lastError = null;
|
|
1618
|
+
this.errorCount = 0;
|
|
1619
|
+
this.currentStatus = "offline";
|
|
1620
|
+
this.currentQuality = "offline";
|
|
1621
|
+
this.stopPeriodicEmission();
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Stop periodic emission and clean up resources.
|
|
1625
|
+
*/
|
|
1626
|
+
dispose() {
|
|
1627
|
+
this.stopPeriodicEmission();
|
|
1628
|
+
this.emitter = null;
|
|
1629
|
+
}
|
|
1630
|
+
// --- Private helpers ---
|
|
1631
|
+
computeInitialSyncProgress() {
|
|
1632
|
+
if (this.initialSyncComplete) return 1;
|
|
1633
|
+
if (this.initialSyncTotalBatches > 0) {
|
|
1634
|
+
return Math.min(1, this.initialSyncReceivedBatches / this.initialSyncTotalBatches);
|
|
1635
|
+
}
|
|
1636
|
+
return this.initialSyncReceivedBatches > 0 ? 0.5 : 0;
|
|
1637
|
+
}
|
|
1638
|
+
computeEffectiveBandwidth() {
|
|
1639
|
+
const inbound = this.inboundBandwidth.estimate();
|
|
1640
|
+
const outbound = this.outboundBandwidth.estimate();
|
|
1641
|
+
if (inbound === null && outbound === null) return null;
|
|
1642
|
+
if (inbound === null) return outbound;
|
|
1643
|
+
if (outbound === null) return inbound;
|
|
1644
|
+
return Math.min(inbound, outbound);
|
|
1645
|
+
}
|
|
1646
|
+
startPeriodicEmission() {
|
|
1647
|
+
if (this.periodicTimer !== null) return;
|
|
1648
|
+
if (!this.emitter) return;
|
|
1649
|
+
this.periodicTimer = setInterval(() => {
|
|
1650
|
+
this.emitDiagnostics();
|
|
1651
|
+
}, this.diagnosticsInterval);
|
|
1652
|
+
}
|
|
1653
|
+
stopPeriodicEmission() {
|
|
1654
|
+
if (this.periodicTimer !== null) {
|
|
1655
|
+
clearInterval(this.periodicTimer);
|
|
1656
|
+
this.periodicTimer = null;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
emitDiagnostics() {
|
|
1660
|
+
this.emitter?.emit({
|
|
1661
|
+
type: "sync:diagnostics",
|
|
1662
|
+
diagnostics: this.getSnapshot()
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
emitBandwidthEvent(direction) {
|
|
1666
|
+
const estimator = direction === "in" ? this.inboundBandwidth : this.outboundBandwidth;
|
|
1667
|
+
const bps = estimator.estimate();
|
|
1668
|
+
if (bps !== null) {
|
|
1669
|
+
this.emitter?.emit({
|
|
1670
|
+
type: "sync:bandwidth",
|
|
1671
|
+
bytesPerSecond: bps,
|
|
1672
|
+
direction
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
// src/awareness/awareness-manager.ts
|
|
1679
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1680
|
+
var nextLocalClientId = 1;
|
|
1681
|
+
var AwarenessManager = class {
|
|
1682
|
+
/** Unique client ID for this instance */
|
|
1683
|
+
clientId;
|
|
1684
|
+
localState = null;
|
|
1685
|
+
remoteStates = /* @__PURE__ */ new Map();
|
|
1686
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1687
|
+
emitter;
|
|
1688
|
+
timeoutMs;
|
|
1689
|
+
// Track when we last received an update from each remote client.
|
|
1690
|
+
// Used for timeout-based cleanup as a safety net in case the server
|
|
1691
|
+
// does not send an explicit removal on disconnect.
|
|
1692
|
+
lastUpdated = /* @__PURE__ */ new Map();
|
|
1693
|
+
cleanupTimer = null;
|
|
1694
|
+
sendHandler = null;
|
|
1695
|
+
destroyed = false;
|
|
1696
|
+
constructor(options) {
|
|
1697
|
+
this.clientId = options?.clientId ?? nextLocalClientId++;
|
|
1698
|
+
this.emitter = options?.emitter ?? null;
|
|
1699
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Set the local user's awareness state and broadcast to peers.
|
|
1703
|
+
*
|
|
1704
|
+
* @param state - The awareness state to share. Pass null to clear presence.
|
|
1705
|
+
*/
|
|
1706
|
+
setLocalState(state) {
|
|
1707
|
+
if (this.destroyed) return;
|
|
1708
|
+
this.localState = state;
|
|
1709
|
+
const message = {
|
|
1710
|
+
type: "awareness",
|
|
1711
|
+
clientId: this.clientId,
|
|
1712
|
+
states: { [this.clientId]: state }
|
|
1713
|
+
};
|
|
1714
|
+
this.sendHandler?.(message);
|
|
1715
|
+
this.emitAwarenessEvent();
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Get the local awareness state.
|
|
1719
|
+
*/
|
|
1720
|
+
getLocalState() {
|
|
1721
|
+
return this.localState;
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Get all known awareness states (local + remote).
|
|
1725
|
+
* Returns a new Map on each call.
|
|
1726
|
+
*/
|
|
1727
|
+
getStates() {
|
|
1728
|
+
const result = /* @__PURE__ */ new Map();
|
|
1729
|
+
if (this.localState) {
|
|
1730
|
+
result.set(this.clientId, this.localState);
|
|
1731
|
+
}
|
|
1732
|
+
for (const [id, state] of this.remoteStates) {
|
|
1733
|
+
result.set(id, state);
|
|
1734
|
+
}
|
|
1735
|
+
return result;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Handle an incoming awareness message from the transport.
|
|
1739
|
+
* This processes remote state updates and notifies listeners.
|
|
1740
|
+
*/
|
|
1741
|
+
handleRemoteMessage(message) {
|
|
1742
|
+
if (this.destroyed) return;
|
|
1743
|
+
const added = [];
|
|
1744
|
+
const updated = [];
|
|
1745
|
+
const removed = [];
|
|
1746
|
+
const now = Date.now();
|
|
1747
|
+
for (const [clientIdStr, state] of Object.entries(message.states)) {
|
|
1748
|
+
const clientId = Number(clientIdStr);
|
|
1749
|
+
if (clientId === this.clientId) continue;
|
|
1750
|
+
if (state === null) {
|
|
1751
|
+
if (this.remoteStates.has(clientId)) {
|
|
1752
|
+
this.remoteStates.delete(clientId);
|
|
1753
|
+
this.lastUpdated.delete(clientId);
|
|
1754
|
+
removed.push(clientId);
|
|
1755
|
+
}
|
|
1756
|
+
} else {
|
|
1757
|
+
if (this.remoteStates.has(clientId)) {
|
|
1758
|
+
this.remoteStates.set(clientId, state);
|
|
1759
|
+
this.lastUpdated.set(clientId, now);
|
|
1760
|
+
updated.push(clientId);
|
|
1761
|
+
} else {
|
|
1762
|
+
this.remoteStates.set(clientId, state);
|
|
1763
|
+
this.lastUpdated.set(clientId, now);
|
|
1764
|
+
added.push(clientId);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (added.length > 0 || updated.length > 0 || removed.length > 0) {
|
|
1769
|
+
const change = { added, updated, removed };
|
|
1770
|
+
this.notifyListeners(change);
|
|
1771
|
+
this.emitAwarenessEvent();
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Remove a specific remote client's awareness state.
|
|
1776
|
+
* Called when the server notifies that a client has disconnected.
|
|
1777
|
+
*/
|
|
1778
|
+
removeClient(clientId) {
|
|
1779
|
+
if (this.destroyed) return;
|
|
1780
|
+
if (!this.remoteStates.has(clientId)) return;
|
|
1781
|
+
this.remoteStates.delete(clientId);
|
|
1782
|
+
this.lastUpdated.delete(clientId);
|
|
1783
|
+
const change = {
|
|
1784
|
+
added: [],
|
|
1785
|
+
updated: [],
|
|
1786
|
+
removed: [clientId]
|
|
1787
|
+
};
|
|
1788
|
+
this.notifyListeners(change);
|
|
1789
|
+
this.emitAwarenessEvent();
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Register a handler for sending awareness messages through the transport.
|
|
1793
|
+
* The sync engine calls this to wire outgoing awareness messages to the transport.
|
|
1794
|
+
*/
|
|
1795
|
+
onSend(handler) {
|
|
1796
|
+
this.sendHandler = handler;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Register a listener for awareness state changes.
|
|
1800
|
+
* Returns an unsubscribe function.
|
|
1801
|
+
*/
|
|
1802
|
+
on(_event, listener) {
|
|
1803
|
+
this.listeners.add(listener);
|
|
1804
|
+
return () => {
|
|
1805
|
+
this.listeners.delete(listener);
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Remove a specific change listener.
|
|
1810
|
+
*/
|
|
1811
|
+
off(_event, listener) {
|
|
1812
|
+
this.listeners.delete(listener);
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Start the cleanup timer that removes stale remote states.
|
|
1816
|
+
* Called when the sync engine transitions to streaming state.
|
|
1817
|
+
*/
|
|
1818
|
+
startCleanupTimer() {
|
|
1819
|
+
if (this.cleanupTimer) return;
|
|
1820
|
+
this.cleanupTimer = setInterval(() => {
|
|
1821
|
+
this.cleanupStaleStates();
|
|
1822
|
+
}, this.timeoutMs);
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Stop the cleanup timer.
|
|
1826
|
+
*/
|
|
1827
|
+
stopCleanupTimer() {
|
|
1828
|
+
if (this.cleanupTimer) {
|
|
1829
|
+
clearInterval(this.cleanupTimer);
|
|
1830
|
+
this.cleanupTimer = null;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Clean up all resources. After calling destroy(), the manager
|
|
1835
|
+
* will no longer send or receive awareness updates.
|
|
1836
|
+
* Broadcasts removal of local state before shutting down.
|
|
1837
|
+
*/
|
|
1838
|
+
destroy() {
|
|
1839
|
+
if (this.destroyed) return;
|
|
1840
|
+
this.destroyed = true;
|
|
1841
|
+
if (this.localState) {
|
|
1842
|
+
const message = {
|
|
1843
|
+
type: "awareness",
|
|
1844
|
+
clientId: this.clientId,
|
|
1845
|
+
states: { [this.clientId]: null }
|
|
1846
|
+
};
|
|
1847
|
+
this.sendHandler?.(message);
|
|
1848
|
+
}
|
|
1849
|
+
this.localState = null;
|
|
1850
|
+
this.remoteStates.clear();
|
|
1851
|
+
this.lastUpdated.clear();
|
|
1852
|
+
this.listeners.clear();
|
|
1853
|
+
this.sendHandler = null;
|
|
1854
|
+
this.stopCleanupTimer();
|
|
1855
|
+
}
|
|
1856
|
+
// --- Private ---
|
|
1857
|
+
cleanupStaleStates() {
|
|
1858
|
+
const now = Date.now();
|
|
1859
|
+
const removed = [];
|
|
1860
|
+
for (const [clientId, lastTime] of this.lastUpdated) {
|
|
1861
|
+
if (now - lastTime > this.timeoutMs) {
|
|
1862
|
+
this.remoteStates.delete(clientId);
|
|
1863
|
+
this.lastUpdated.delete(clientId);
|
|
1864
|
+
removed.push(clientId);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (removed.length > 0) {
|
|
1868
|
+
const change = { added: [], updated: [], removed };
|
|
1869
|
+
this.notifyListeners(change);
|
|
1870
|
+
this.emitAwarenessEvent();
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
notifyListeners(change) {
|
|
1874
|
+
for (const listener of this.listeners) {
|
|
1875
|
+
listener(change);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
emitAwarenessEvent() {
|
|
1879
|
+
const states = this.getStates();
|
|
1880
|
+
this.emitter?.emit({ type: "awareness:updated", states });
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1086
1884
|
// src/engine/memory-queue-storage.ts
|
|
1087
1885
|
var MemoryQueueStorage = class {
|
|
1088
1886
|
operations = /* @__PURE__ */ new Map();
|
|
@@ -1240,14 +2038,26 @@ var SyncEngine = class {
|
|
|
1240
2038
|
emitter;
|
|
1241
2039
|
outboundQueue;
|
|
1242
2040
|
batchSize;
|
|
2041
|
+
encryptor;
|
|
2042
|
+
awarenessManager;
|
|
2043
|
+
metricsCollector;
|
|
1243
2044
|
remoteVector = /* @__PURE__ */ new Map();
|
|
1244
2045
|
lastSyncedAt = null;
|
|
2046
|
+
lastSuccessfulPush = null;
|
|
2047
|
+
lastSuccessfulPull = null;
|
|
2048
|
+
conflictCount = 0;
|
|
1245
2049
|
currentBatch = null;
|
|
1246
2050
|
reconnecting = false;
|
|
1247
2051
|
// Track delta exchange state
|
|
1248
2052
|
deltaBatchesReceived = 0;
|
|
1249
2053
|
deltaReceiveComplete = false;
|
|
1250
2054
|
deltaSendComplete = false;
|
|
2055
|
+
/**
|
|
2056
|
+
* The effective scope for this sync session.
|
|
2057
|
+
* Starts as the configured scopeMap. After handshake, may be replaced
|
|
2058
|
+
* with the server-accepted scope (server is authoritative).
|
|
2059
|
+
*/
|
|
2060
|
+
activeScope;
|
|
1251
2061
|
constructor(options) {
|
|
1252
2062
|
this.transport = options.transport;
|
|
1253
2063
|
this.store = options.store;
|
|
@@ -1255,15 +2065,34 @@ var SyncEngine = class {
|
|
|
1255
2065
|
this.serializer = options.serializer ?? new NegotiatedMessageSerializer("json");
|
|
1256
2066
|
this.emitter = options.emitter ?? null;
|
|
1257
2067
|
this.batchSize = options.config.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2068
|
+
this.encryptor = options.encryptor ?? null;
|
|
2069
|
+
this.activeScope = options.config.scopeMap;
|
|
1258
2070
|
const queueStorage = options.queueStorage ?? new MemoryQueueStorage();
|
|
1259
2071
|
this.outboundQueue = new OutboundQueue(queueStorage);
|
|
2072
|
+
this.metricsCollector = new SyncMetricsCollector(options.metricsConfig);
|
|
2073
|
+
if (this.emitter) {
|
|
2074
|
+
this.metricsCollector.attachEmitter(this.emitter);
|
|
2075
|
+
}
|
|
2076
|
+
this.awarenessManager = new AwarenessManager({
|
|
2077
|
+
emitter: this.emitter ?? void 0
|
|
2078
|
+
});
|
|
2079
|
+
this.awarenessManager.onSend((message) => {
|
|
2080
|
+
if (this.state !== "streaming") return;
|
|
2081
|
+
const wireMessage = {
|
|
2082
|
+
type: "awareness-update",
|
|
2083
|
+
messageId: generateMessageId(),
|
|
2084
|
+
clientId: message.clientId,
|
|
2085
|
+
states: awarenessStatesToWire(message.states)
|
|
2086
|
+
};
|
|
2087
|
+
this.transport.send(wireMessage);
|
|
2088
|
+
});
|
|
1260
2089
|
}
|
|
1261
2090
|
/**
|
|
1262
2091
|
* Start the sync engine: connect → handshake → delta exchange → streaming.
|
|
1263
2092
|
*/
|
|
1264
2093
|
async start() {
|
|
1265
2094
|
if (this.state !== "disconnected") {
|
|
1266
|
-
throw new
|
|
2095
|
+
throw new import_core5.SyncError("Cannot start sync engine: not in disconnected state", {
|
|
1267
2096
|
currentState: this.state
|
|
1268
2097
|
});
|
|
1269
2098
|
}
|
|
@@ -1284,7 +2113,8 @@ var SyncEngine = class {
|
|
|
1284
2113
|
versionVector: versionVectorToWire(localVector),
|
|
1285
2114
|
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
1286
2115
|
authToken,
|
|
1287
|
-
supportedWireFormats: ["json", "protobuf"]
|
|
2116
|
+
supportedWireFormats: ["json", "protobuf"],
|
|
2117
|
+
...this.config.scopeMap ? { syncScope: this.config.scopeMap } : {}
|
|
1288
2118
|
};
|
|
1289
2119
|
this.transport.send(handshake);
|
|
1290
2120
|
} catch (err) {
|
|
@@ -1297,6 +2127,7 @@ var SyncEngine = class {
|
|
|
1297
2127
|
*/
|
|
1298
2128
|
async stop() {
|
|
1299
2129
|
if (this.state === "disconnected") return;
|
|
2130
|
+
this.awarenessManager.stopCleanupTimer();
|
|
1300
2131
|
if (this.currentBatch) {
|
|
1301
2132
|
this.outboundQueue.returnBatch(this.currentBatch.batchId);
|
|
1302
2133
|
this.currentBatch = null;
|
|
@@ -1315,8 +2146,14 @@ var SyncEngine = class {
|
|
|
1315
2146
|
/**
|
|
1316
2147
|
* Push a local operation to the outbound queue.
|
|
1317
2148
|
* If streaming, flushes immediately.
|
|
2149
|
+
*
|
|
2150
|
+
* Operations outside the configured sync scope are silently skipped
|
|
2151
|
+
* because they should remain local-only and not be sent to the server.
|
|
1318
2152
|
*/
|
|
1319
2153
|
async pushOperation(op) {
|
|
2154
|
+
if (!operationMatchesScope(op, this.activeScope)) {
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
1320
2157
|
await this.outboundQueue.enqueue(op);
|
|
1321
2158
|
if (this.state === "streaming") {
|
|
1322
2159
|
this.flushQueue();
|
|
@@ -1336,27 +2173,64 @@ var SyncEngine = class {
|
|
|
1336
2173
|
*/
|
|
1337
2174
|
getStatus() {
|
|
1338
2175
|
const pendingOperations = this.outboundQueue.totalPending;
|
|
2176
|
+
const base = {
|
|
2177
|
+
pendingOperations,
|
|
2178
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2179
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2180
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2181
|
+
conflicts: this.conflictCount
|
|
2182
|
+
};
|
|
1339
2183
|
switch (this.state) {
|
|
1340
2184
|
case "disconnected":
|
|
1341
|
-
return { status: "offline"
|
|
2185
|
+
return { ...base, status: "offline" };
|
|
1342
2186
|
case "connecting":
|
|
1343
2187
|
case "handshaking":
|
|
1344
2188
|
case "syncing":
|
|
1345
|
-
return {
|
|
1346
|
-
status: this.reconnecting ? "offline" : "syncing",
|
|
1347
|
-
pendingOperations,
|
|
1348
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1349
|
-
};
|
|
2189
|
+
return { ...base, status: this.reconnecting ? "offline" : "syncing" };
|
|
1350
2190
|
case "streaming":
|
|
1351
|
-
return {
|
|
1352
|
-
status: pendingOperations > 0 ? "syncing" : "synced",
|
|
1353
|
-
pendingOperations,
|
|
1354
|
-
lastSyncedAt: this.lastSyncedAt
|
|
1355
|
-
};
|
|
2191
|
+
return { ...base, status: pendingOperations > 0 ? "syncing" : "synced" };
|
|
1356
2192
|
case "error":
|
|
1357
|
-
return { status: "error"
|
|
2193
|
+
return { ...base, status: "error" };
|
|
1358
2194
|
}
|
|
1359
2195
|
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Record a merge conflict. Called by the merge-aware sync store
|
|
2198
|
+
* to increment the conflict counter for status reporting.
|
|
2199
|
+
*/
|
|
2200
|
+
recordConflict() {
|
|
2201
|
+
this.conflictCount++;
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Force an immediate reconnection attempt. If the engine is disconnected
|
|
2205
|
+
* or in error state, restarts the sync. If already connected, no-op.
|
|
2206
|
+
*/
|
|
2207
|
+
async retryNow() {
|
|
2208
|
+
if (this.state === "disconnected" || this.state === "error") {
|
|
2209
|
+
this.reconnecting = false;
|
|
2210
|
+
await this.start();
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Export a diagnostics snapshot for debugging and support tickets.
|
|
2215
|
+
* Contains connection state, timing info, and queue metrics.
|
|
2216
|
+
*/
|
|
2217
|
+
exportDiagnostics() {
|
|
2218
|
+
return {
|
|
2219
|
+
state: this.state,
|
|
2220
|
+
status: this.getStatus(),
|
|
2221
|
+
nodeId: this.store.getNodeId(),
|
|
2222
|
+
url: this.config.url,
|
|
2223
|
+
schemaVersion: this.config.schemaVersion ?? DEFAULT_SCHEMA_VERSION,
|
|
2224
|
+
lastSyncedAt: this.lastSyncedAt,
|
|
2225
|
+
lastSuccessfulPush: this.lastSuccessfulPush,
|
|
2226
|
+
lastSuccessfulPull: this.lastSuccessfulPull,
|
|
2227
|
+
conflicts: this.conflictCount,
|
|
2228
|
+
pendingOperations: this.outboundQueue.totalPending,
|
|
2229
|
+
hasInFlightBatch: this.currentBatch !== null,
|
|
2230
|
+
reconnecting: this.reconnecting,
|
|
2231
|
+
timestamp: Date.now()
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
1360
2234
|
/**
|
|
1361
2235
|
* Get the current internal state (for testing).
|
|
1362
2236
|
*/
|
|
@@ -1369,6 +2243,36 @@ var SyncEngine = class {
|
|
|
1369
2243
|
getOutboundQueue() {
|
|
1370
2244
|
return this.outboundQueue;
|
|
1371
2245
|
}
|
|
2246
|
+
/**
|
|
2247
|
+
* Update the sync scope map. Takes effect on the next connection attempt.
|
|
2248
|
+
*
|
|
2249
|
+
* When the scope changes (e.g., user switches organization), call this method
|
|
2250
|
+
* then reconnect. The new scope will be sent in the handshake, and the server
|
|
2251
|
+
* will send back data matching the new scope.
|
|
2252
|
+
*
|
|
2253
|
+
* Data that no longer matches the new scope is NOT deleted locally.
|
|
2254
|
+
* It simply stops being synced.
|
|
2255
|
+
*
|
|
2256
|
+
* @param scopeMap - New per-collection scope filters, or undefined to remove scope
|
|
2257
|
+
*/
|
|
2258
|
+
updateScope(scopeMap) {
|
|
2259
|
+
this.activeScope = scopeMap;
|
|
2260
|
+
this.config.scopeMap = scopeMap;
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Get the currently active scope map. Returns undefined if no scope is configured.
|
|
2264
|
+
*/
|
|
2265
|
+
getActiveScope() {
|
|
2266
|
+
return this.activeScope;
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* Get the awareness manager for collaborative presence.
|
|
2270
|
+
* Use this to set local presence, observe remote collaborators,
|
|
2271
|
+
* and track cursor positions in richtext fields.
|
|
2272
|
+
*/
|
|
2273
|
+
getAwarenessManager() {
|
|
2274
|
+
return this.awarenessManager;
|
|
2275
|
+
}
|
|
1372
2276
|
// --- Private methods ---
|
|
1373
2277
|
handleMessage(message) {
|
|
1374
2278
|
switch (message.type) {
|
|
@@ -1384,6 +2288,9 @@ var SyncEngine = class {
|
|
|
1384
2288
|
case "error":
|
|
1385
2289
|
this.handleError(message);
|
|
1386
2290
|
break;
|
|
2291
|
+
case "awareness-update":
|
|
2292
|
+
this.handleAwarenessUpdate(message);
|
|
2293
|
+
break;
|
|
1387
2294
|
}
|
|
1388
2295
|
}
|
|
1389
2296
|
handleHandshakeResponse(msg) {
|
|
@@ -1401,7 +2308,13 @@ var SyncEngine = class {
|
|
|
1401
2308
|
if (msg.selectedWireFormat) {
|
|
1402
2309
|
this.setSerializerWireFormat(msg.selectedWireFormat);
|
|
1403
2310
|
}
|
|
2311
|
+
if (msg.acceptedScope) {
|
|
2312
|
+
this.activeScope = msg.acceptedScope;
|
|
2313
|
+
}
|
|
1404
2314
|
this.emitter?.emit({ type: "sync:connected", nodeId: this.store.getNodeId() });
|
|
2315
|
+
this.metricsCollector.recordConnected();
|
|
2316
|
+
this.metricsCollector.updateStatus("syncing");
|
|
2317
|
+
this.metricsCollector.recordSyncStarted();
|
|
1405
2318
|
this.transitionTo("syncing");
|
|
1406
2319
|
this.deltaBatchesReceived = 0;
|
|
1407
2320
|
this.deltaReceiveComplete = false;
|
|
@@ -1410,7 +2323,8 @@ var SyncEngine = class {
|
|
|
1410
2323
|
}
|
|
1411
2324
|
async sendDelta() {
|
|
1412
2325
|
const localVector = this.store.getVersionVector();
|
|
1413
|
-
const
|
|
2326
|
+
const allMissingOps = await this.collectDelta(localVector, this.remoteVector);
|
|
2327
|
+
const missingOps = allMissingOps.filter((op) => operationMatchesScope(op, this.activeScope));
|
|
1414
2328
|
if (missingOps.length === 0) {
|
|
1415
2329
|
const emptyBatch = {
|
|
1416
2330
|
type: "operation-batch",
|
|
@@ -1429,7 +2343,8 @@ var SyncEngine = class {
|
|
|
1429
2343
|
for (let i = 0; i < totalBatches; i++) {
|
|
1430
2344
|
const start = i * this.batchSize;
|
|
1431
2345
|
const batchOps = sorted.slice(start, start + this.batchSize);
|
|
1432
|
-
const
|
|
2346
|
+
const opsToSerialize = this.encryptor ? await this.encryptor.encryptBatch(batchOps) : batchOps;
|
|
2347
|
+
const serializedOps = opsToSerialize.map((op) => this.serializer.encodeOperation(op));
|
|
1433
2348
|
const batchMsg = {
|
|
1434
2349
|
type: "operation-batch",
|
|
1435
2350
|
messageId: generateMessageId(),
|
|
@@ -1459,15 +2374,18 @@ var SyncEngine = class {
|
|
|
1459
2374
|
return missing;
|
|
1460
2375
|
}
|
|
1461
2376
|
async handleOperationBatch(msg) {
|
|
1462
|
-
const
|
|
1463
|
-
|
|
2377
|
+
const deserialized = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
2378
|
+
const operations = this.encryptor ? await this.encryptor.decryptBatch(deserialized) : deserialized;
|
|
2379
|
+
const inScopeOps = operations.filter((op) => operationMatchesScope(op, this.activeScope));
|
|
2380
|
+
for (const op of inScopeOps) {
|
|
1464
2381
|
await this.store.applyRemoteOperation(op);
|
|
1465
2382
|
}
|
|
1466
|
-
if (
|
|
2383
|
+
if (inScopeOps.length > 0) {
|
|
2384
|
+
this.lastSuccessfulPull = Date.now();
|
|
1467
2385
|
this.emitter?.emit({
|
|
1468
2386
|
type: "sync:received",
|
|
1469
|
-
operations,
|
|
1470
|
-
batchSize:
|
|
2387
|
+
operations: inScopeOps,
|
|
2388
|
+
batchSize: inScopeOps.length
|
|
1471
2389
|
});
|
|
1472
2390
|
}
|
|
1473
2391
|
const lastOp = operations[operations.length - 1];
|
|
@@ -1494,7 +2412,9 @@ var SyncEngine = class {
|
|
|
1494
2412
|
if (this.currentBatch) {
|
|
1495
2413
|
this.outboundQueue.acknowledge(this.currentBatch.batchId);
|
|
1496
2414
|
this.currentBatch = null;
|
|
1497
|
-
|
|
2415
|
+
const now = Date.now();
|
|
2416
|
+
this.lastSyncedAt = now;
|
|
2417
|
+
this.lastSuccessfulPush = now;
|
|
1498
2418
|
}
|
|
1499
2419
|
if (this.state === "streaming" && this.outboundQueue.hasOperations) {
|
|
1500
2420
|
this.flushQueue();
|
|
@@ -1512,6 +2432,11 @@ var SyncEngine = class {
|
|
|
1512
2432
|
if (this.deltaSendComplete && this.deltaReceiveComplete) {
|
|
1513
2433
|
this.lastSyncedAt = Date.now();
|
|
1514
2434
|
this.transitionTo("streaming");
|
|
2435
|
+
this.awarenessManager.startCleanupTimer();
|
|
2436
|
+
const localState = this.awarenessManager.getLocalState();
|
|
2437
|
+
if (localState) {
|
|
2438
|
+
this.awarenessManager.setLocalState(localState);
|
|
2439
|
+
}
|
|
1515
2440
|
if (this.outboundQueue.hasOperations) {
|
|
1516
2441
|
this.flushQueue();
|
|
1517
2442
|
}
|
|
@@ -1523,20 +2448,57 @@ var SyncEngine = class {
|
|
|
1523
2448
|
const batch = this.outboundQueue.takeBatch(this.batchSize);
|
|
1524
2449
|
if (!batch) return;
|
|
1525
2450
|
this.currentBatch = batch;
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
2451
|
+
if (this.encryptor) {
|
|
2452
|
+
this.encryptor.encryptBatch(batch.operations).then(
|
|
2453
|
+
(encrypted) => {
|
|
2454
|
+
const serializedOps = encrypted.map((op) => this.serializer.encodeOperation(op));
|
|
2455
|
+
const batchMsg = {
|
|
2456
|
+
type: "operation-batch",
|
|
2457
|
+
messageId: generateMessageId(),
|
|
2458
|
+
operations: serializedOps,
|
|
2459
|
+
isFinal: true,
|
|
2460
|
+
batchIndex: 0
|
|
2461
|
+
};
|
|
2462
|
+
this.transport.send(batchMsg);
|
|
2463
|
+
this.emitter?.emit({
|
|
2464
|
+
type: "sync:sent",
|
|
2465
|
+
operations: batch.operations,
|
|
2466
|
+
batchSize: batch.operations.length
|
|
2467
|
+
});
|
|
2468
|
+
},
|
|
2469
|
+
(err) => {
|
|
2470
|
+
this.outboundQueue.returnBatch(batch.batchId);
|
|
2471
|
+
this.currentBatch = null;
|
|
2472
|
+
this.emitter?.emit({
|
|
2473
|
+
type: "sync:disconnected",
|
|
2474
|
+
reason: err instanceof Error ? err.message : "Encryption failed"
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
);
|
|
2478
|
+
} else {
|
|
2479
|
+
const serializedOps = batch.operations.map((op) => this.serializer.encodeOperation(op));
|
|
2480
|
+
const batchMsg = {
|
|
2481
|
+
type: "operation-batch",
|
|
2482
|
+
messageId: generateMessageId(),
|
|
2483
|
+
operations: serializedOps,
|
|
2484
|
+
isFinal: true,
|
|
2485
|
+
batchIndex: 0
|
|
2486
|
+
};
|
|
2487
|
+
this.transport.send(batchMsg);
|
|
2488
|
+
this.emitter?.emit({
|
|
2489
|
+
type: "sync:sent",
|
|
2490
|
+
operations: batch.operations,
|
|
2491
|
+
batchSize: batch.operations.length
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
handleAwarenessUpdate(msg) {
|
|
2496
|
+
const awarenessMessage = {
|
|
2497
|
+
type: "awareness",
|
|
2498
|
+
clientId: msg.clientId,
|
|
2499
|
+
states: wireToAwarenessStates(msg.states)
|
|
1533
2500
|
};
|
|
1534
|
-
this.
|
|
1535
|
-
this.emitter?.emit({
|
|
1536
|
-
type: "sync:sent",
|
|
1537
|
-
operations: batch.operations,
|
|
1538
|
-
batchSize: batch.operations.length
|
|
1539
|
-
});
|
|
2501
|
+
this.awarenessManager.handleRemoteMessage(awarenessMessage);
|
|
1540
2502
|
}
|
|
1541
2503
|
handleTransportClose(reason) {
|
|
1542
2504
|
if (this.currentBatch) {
|
|
@@ -1558,7 +2520,7 @@ var SyncEngine = class {
|
|
|
1558
2520
|
transitionTo(newState) {
|
|
1559
2521
|
const validTargets = VALID_TRANSITIONS[this.state];
|
|
1560
2522
|
if (!validTargets.includes(newState)) {
|
|
1561
|
-
throw new
|
|
2523
|
+
throw new import_core5.SyncError(`Invalid sync state transition: ${this.state} \u2192 ${newState}`, {
|
|
1562
2524
|
from: this.state,
|
|
1563
2525
|
to: newState
|
|
1564
2526
|
});
|
|
@@ -1571,6 +2533,40 @@ var SyncEngine = class {
|
|
|
1571
2533
|
}
|
|
1572
2534
|
}
|
|
1573
2535
|
};
|
|
2536
|
+
function awarenessStatesToWire(states) {
|
|
2537
|
+
const wire = {};
|
|
2538
|
+
for (const [clientId, state] of Object.entries(states)) {
|
|
2539
|
+
if (state === null) {
|
|
2540
|
+
wire[clientId] = null;
|
|
2541
|
+
} else {
|
|
2542
|
+
const wireState = {
|
|
2543
|
+
user: { ...state.user }
|
|
2544
|
+
};
|
|
2545
|
+
if (state.cursor) {
|
|
2546
|
+
wireState.cursor = { ...state.cursor };
|
|
2547
|
+
}
|
|
2548
|
+
wire[clientId] = wireState;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
return wire;
|
|
2552
|
+
}
|
|
2553
|
+
function wireToAwarenessStates(wire) {
|
|
2554
|
+
const states = {};
|
|
2555
|
+
for (const [clientId, wireState] of Object.entries(wire)) {
|
|
2556
|
+
if (wireState === null) {
|
|
2557
|
+
states[Number(clientId)] = null;
|
|
2558
|
+
} else {
|
|
2559
|
+
const state = {
|
|
2560
|
+
user: { ...wireState.user }
|
|
2561
|
+
};
|
|
2562
|
+
if (wireState.cursor) {
|
|
2563
|
+
state.cursor = { ...wireState.cursor };
|
|
2564
|
+
}
|
|
2565
|
+
states[Number(clientId)] = state;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
return states;
|
|
2569
|
+
}
|
|
1574
2570
|
|
|
1575
2571
|
// src/engine/connection-monitor.ts
|
|
1576
2572
|
var ConnectionMonitor = class {
|
|
@@ -1763,26 +2759,466 @@ var ReconnectionManager = class {
|
|
|
1763
2759
|
});
|
|
1764
2760
|
}
|
|
1765
2761
|
};
|
|
2762
|
+
|
|
2763
|
+
// src/encryption/sync-encryptor.ts
|
|
2764
|
+
var import_core7 = require("@korajs/core");
|
|
2765
|
+
|
|
2766
|
+
// src/encryption/key-derivation.ts
|
|
2767
|
+
var import_core6 = require("@korajs/core");
|
|
2768
|
+
var KeyDerivationError = class extends import_core6.SyncError {
|
|
2769
|
+
constructor(message, context) {
|
|
2770
|
+
super(message, { ...context, errorType: "KEY_DERIVATION_ERROR" });
|
|
2771
|
+
this.name = "KeyDerivationError";
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
var SALT_LENGTH = 32;
|
|
2775
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
2776
|
+
var DERIVED_KEY_LENGTH = 256;
|
|
2777
|
+
function assertCryptoAvailable() {
|
|
2778
|
+
if (typeof globalThis.crypto === "undefined" || typeof globalThis.crypto.subtle === "undefined") {
|
|
2779
|
+
throw new KeyDerivationError(
|
|
2780
|
+
"Web Crypto API (crypto.subtle) is not available in this environment. Sync encryption requires crypto.subtle, which is available in modern browsers and Node.js 20+."
|
|
2781
|
+
);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
function generateSalt() {
|
|
2785
|
+
if (typeof globalThis.crypto === "undefined") {
|
|
2786
|
+
throw new KeyDerivationError(
|
|
2787
|
+
"Web Crypto API (crypto) is not available. Cannot generate random salt."
|
|
2788
|
+
);
|
|
2789
|
+
}
|
|
2790
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
|
2791
|
+
}
|
|
2792
|
+
async function deriveKey(passphrase, salt) {
|
|
2793
|
+
assertCryptoAvailable();
|
|
2794
|
+
if (passphrase.length === 0) {
|
|
2795
|
+
throw new KeyDerivationError(
|
|
2796
|
+
"Passphrase must not be empty. Provide a non-empty string for encryption key derivation."
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
const usedSalt = salt ?? generateSalt();
|
|
2800
|
+
try {
|
|
2801
|
+
const passphraseBytes = new TextEncoder().encode(passphrase);
|
|
2802
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
2803
|
+
"raw",
|
|
2804
|
+
passphraseBytes,
|
|
2805
|
+
"PBKDF2",
|
|
2806
|
+
false,
|
|
2807
|
+
["deriveBits", "deriveKey"]
|
|
2808
|
+
);
|
|
2809
|
+
const derivedKey = await globalThis.crypto.subtle.deriveKey(
|
|
2810
|
+
{
|
|
2811
|
+
name: "PBKDF2",
|
|
2812
|
+
salt: usedSalt,
|
|
2813
|
+
iterations: PBKDF2_ITERATIONS,
|
|
2814
|
+
hash: "SHA-256"
|
|
2815
|
+
},
|
|
2816
|
+
baseKey,
|
|
2817
|
+
{ name: "AES-GCM", length: DERIVED_KEY_LENGTH },
|
|
2818
|
+
// extractable: true so the derived key can be exported for testing/debugging
|
|
2819
|
+
true,
|
|
2820
|
+
["encrypt", "decrypt"]
|
|
2821
|
+
);
|
|
2822
|
+
return { key: derivedKey, salt: usedSalt };
|
|
2823
|
+
} catch (cause) {
|
|
2824
|
+
if (cause instanceof KeyDerivationError) {
|
|
2825
|
+
throw cause;
|
|
2826
|
+
}
|
|
2827
|
+
throw new KeyDerivationError(
|
|
2828
|
+
"Failed to derive encryption key from passphrase using PBKDF2. Ensure the runtime supports PBKDF2 with SHA-256.",
|
|
2829
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
async function deriveVersionedKey(passphrase, version, salt) {
|
|
2834
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
2835
|
+
throw new KeyDerivationError(`Key version must be a positive integer, received: ${version}`, {
|
|
2836
|
+
version
|
|
2837
|
+
});
|
|
2838
|
+
}
|
|
2839
|
+
const { key, salt: usedSalt } = await deriveKey(passphrase, salt);
|
|
2840
|
+
return { version, key, salt: usedSalt };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// src/encryption/sync-encryptor.ts
|
|
2844
|
+
var EncryptionError = class extends import_core7.SyncError {
|
|
2845
|
+
constructor(message, context) {
|
|
2846
|
+
super(message, { ...context, errorType: "ENCRYPTION_ERROR" });
|
|
2847
|
+
this.name = "EncryptionError";
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
var DecryptionError = class extends import_core7.SyncError {
|
|
2851
|
+
constructor(message, context) {
|
|
2852
|
+
super(message, { ...context, errorType: "DECRYPTION_ERROR" });
|
|
2853
|
+
this.name = "DecryptionError";
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
var IV_LENGTH = 12;
|
|
2857
|
+
var ENCRYPTED_MARKER = "__kora_e2e_encrypted";
|
|
2858
|
+
var SyncEncryptor = class _SyncEncryptor {
|
|
2859
|
+
/**
|
|
2860
|
+
* Map of key version -> VersionedKey. The current (latest) version is used
|
|
2861
|
+
* for encryption. All versions are available for decryption (key rotation).
|
|
2862
|
+
*/
|
|
2863
|
+
keys;
|
|
2864
|
+
/** The current key version used for encryption. */
|
|
2865
|
+
currentVersion;
|
|
2866
|
+
constructor(keys, currentVersion) {
|
|
2867
|
+
this.keys = keys;
|
|
2868
|
+
this.currentVersion = currentVersion;
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Creates a SyncEncryptor from a {@link SyncEncryptionConfig}.
|
|
2872
|
+
*
|
|
2873
|
+
* Derives the encryption key from the passphrase using PBKDF2. The key
|
|
2874
|
+
* derivation is async because it uses the Web Crypto API.
|
|
2875
|
+
*
|
|
2876
|
+
* @param config - Encryption configuration with passphrase
|
|
2877
|
+
* @param salt - Optional salt for deterministic key derivation (mainly for testing).
|
|
2878
|
+
* If omitted, a random salt is generated.
|
|
2879
|
+
* @returns A configured SyncEncryptor instance
|
|
2880
|
+
* @throws {EncryptionError} If configuration is invalid
|
|
2881
|
+
* @throws {KeyDerivationError} If key derivation fails
|
|
2882
|
+
*/
|
|
2883
|
+
static async create(config, salt) {
|
|
2884
|
+
if (!config.enabled) {
|
|
2885
|
+
throw new EncryptionError(
|
|
2886
|
+
"Cannot create SyncEncryptor with encryption disabled. Set enabled: true in the encryption config."
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
const passphrase = typeof config.key === "function" ? await config.key() : config.key;
|
|
2890
|
+
if (passphrase.length === 0) {
|
|
2891
|
+
throw new EncryptionError(
|
|
2892
|
+
"Encryption key/passphrase must not be empty. Provide a non-empty string or key provider function."
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
const versionedKey = await deriveVersionedKey(passphrase, 1, salt);
|
|
2896
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2897
|
+
keys.set(1, versionedKey);
|
|
2898
|
+
return new _SyncEncryptor(keys, 1);
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Creates a SyncEncryptor from pre-derived versioned keys.
|
|
2902
|
+
*
|
|
2903
|
+
* Use this when you need to support multiple key versions for key rotation,
|
|
2904
|
+
* or when you have already derived the keys externally.
|
|
2905
|
+
*
|
|
2906
|
+
* @param versionedKeys - Array of versioned keys. The highest version is used for encryption.
|
|
2907
|
+
* @returns A configured SyncEncryptor instance
|
|
2908
|
+
* @throws {EncryptionError} If no keys are provided
|
|
2909
|
+
*/
|
|
2910
|
+
static fromKeys(versionedKeys) {
|
|
2911
|
+
if (versionedKeys.length === 0) {
|
|
2912
|
+
throw new EncryptionError("At least one versioned key must be provided.");
|
|
2913
|
+
}
|
|
2914
|
+
const keys = /* @__PURE__ */ new Map();
|
|
2915
|
+
let maxVersion = 0;
|
|
2916
|
+
for (const vk of versionedKeys) {
|
|
2917
|
+
keys.set(vk.version, vk);
|
|
2918
|
+
if (vk.version > maxVersion) {
|
|
2919
|
+
maxVersion = vk.version;
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
return new _SyncEncryptor(keys, maxVersion);
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Add a new key version for key rotation.
|
|
2926
|
+
*
|
|
2927
|
+
* After adding, the new key becomes the current version used for encryption
|
|
2928
|
+
* if its version number is higher than the current version. Previously-versioned
|
|
2929
|
+
* keys remain available for decrypting older operations.
|
|
2930
|
+
*
|
|
2931
|
+
* @param key - The new versioned key to add
|
|
2932
|
+
* @throws {EncryptionError} If the key version already exists
|
|
2933
|
+
*/
|
|
2934
|
+
addKey(key) {
|
|
2935
|
+
if (this.keys.has(key.version)) {
|
|
2936
|
+
throw new EncryptionError(
|
|
2937
|
+
`Key version ${key.version} already exists. Use a higher version number for rotation.`,
|
|
2938
|
+
{ existingVersion: key.version }
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
this.keys.set(key.version, key);
|
|
2942
|
+
if (key.version > this.currentVersion) {
|
|
2943
|
+
this.currentVersion = key.version;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
/**
|
|
2947
|
+
* Get the current encryption key version number.
|
|
2948
|
+
*/
|
|
2949
|
+
getCurrentKeyVersion() {
|
|
2950
|
+
return this.currentVersion;
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Encrypt an operation's `data` and `previousData` fields.
|
|
2954
|
+
*
|
|
2955
|
+
* Returns a new Operation with encrypted field values. The original
|
|
2956
|
+
* operation is not mutated (operations are immutable).
|
|
2957
|
+
*
|
|
2958
|
+
* Fields that are null (e.g., delete operations) remain null.
|
|
2959
|
+
*
|
|
2960
|
+
* @param operation - The operation to encrypt
|
|
2961
|
+
* @returns A new operation with encrypted data fields
|
|
2962
|
+
* @throws {EncryptionError} If encryption fails
|
|
2963
|
+
*/
|
|
2964
|
+
async encryptOperation(operation) {
|
|
2965
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
2966
|
+
this.encryptField(operation.data, operation.id, "data"),
|
|
2967
|
+
this.encryptField(operation.previousData, operation.id, "previousData")
|
|
2968
|
+
]);
|
|
2969
|
+
return {
|
|
2970
|
+
...operation,
|
|
2971
|
+
data: encryptedData,
|
|
2972
|
+
previousData: encryptedPreviousData
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
2975
|
+
/**
|
|
2976
|
+
* Decrypt an operation's `data` and `previousData` fields.
|
|
2977
|
+
*
|
|
2978
|
+
* Returns a new Operation with the original field values restored.
|
|
2979
|
+
* The original operation is not mutated.
|
|
2980
|
+
*
|
|
2981
|
+
* If a field is null or not encrypted (no marker), it passes through unchanged.
|
|
2982
|
+
* This enables mixed plaintext/encrypted operations during migration.
|
|
2983
|
+
*
|
|
2984
|
+
* @param operation - The operation to decrypt
|
|
2985
|
+
* @returns A new operation with decrypted data fields
|
|
2986
|
+
* @throws {DecryptionError} If decryption fails (wrong key, tampered data, unsupported version)
|
|
2987
|
+
*/
|
|
2988
|
+
async decryptOperation(operation) {
|
|
2989
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
2990
|
+
this.decryptField(operation.data, operation.id, "data"),
|
|
2991
|
+
this.decryptField(operation.previousData, operation.id, "previousData")
|
|
2992
|
+
]);
|
|
2993
|
+
return {
|
|
2994
|
+
...operation,
|
|
2995
|
+
data: decryptedData,
|
|
2996
|
+
previousData: decryptedPreviousData
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Encrypt a serialized operation's `data` and `previousData` fields.
|
|
3001
|
+
*
|
|
3002
|
+
* Same as {@link encryptOperation} but works with the wire-format
|
|
3003
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
3004
|
+
*
|
|
3005
|
+
* @param serialized - The serialized operation to encrypt
|
|
3006
|
+
* @returns A new serialized operation with encrypted data fields
|
|
3007
|
+
* @throws {EncryptionError} If encryption fails
|
|
3008
|
+
*/
|
|
3009
|
+
async encryptSerializedOperation(serialized) {
|
|
3010
|
+
const [encryptedData, encryptedPreviousData] = await Promise.all([
|
|
3011
|
+
this.encryptField(serialized.data, serialized.id, "data"),
|
|
3012
|
+
this.encryptField(serialized.previousData, serialized.id, "previousData")
|
|
3013
|
+
]);
|
|
3014
|
+
return {
|
|
3015
|
+
...serialized,
|
|
3016
|
+
data: encryptedData,
|
|
3017
|
+
previousData: encryptedPreviousData
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Decrypt a serialized operation's `data` and `previousData` fields.
|
|
3022
|
+
*
|
|
3023
|
+
* Same as {@link decryptOperation} but works with the wire-format
|
|
3024
|
+
* {@link SerializedOperation} type used by the serializer.
|
|
3025
|
+
*
|
|
3026
|
+
* @param serialized - The serialized operation to decrypt
|
|
3027
|
+
* @returns A new serialized operation with decrypted data fields
|
|
3028
|
+
* @throws {DecryptionError} If decryption fails
|
|
3029
|
+
*/
|
|
3030
|
+
async decryptSerializedOperation(serialized) {
|
|
3031
|
+
const [decryptedData, decryptedPreviousData] = await Promise.all([
|
|
3032
|
+
this.decryptField(serialized.data, serialized.id, "data"),
|
|
3033
|
+
this.decryptField(serialized.previousData, serialized.id, "previousData")
|
|
3034
|
+
]);
|
|
3035
|
+
return {
|
|
3036
|
+
...serialized,
|
|
3037
|
+
data: decryptedData,
|
|
3038
|
+
previousData: decryptedPreviousData
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Encrypt a batch of operations in parallel.
|
|
3043
|
+
*
|
|
3044
|
+
* @param operations - Operations to encrypt
|
|
3045
|
+
* @returns New operations with encrypted data fields
|
|
3046
|
+
*/
|
|
3047
|
+
async encryptBatch(operations) {
|
|
3048
|
+
return Promise.all(operations.map((op) => this.encryptOperation(op)));
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Decrypt a batch of operations in parallel.
|
|
3052
|
+
*
|
|
3053
|
+
* @param operations - Operations to decrypt
|
|
3054
|
+
* @returns New operations with decrypted data fields
|
|
3055
|
+
*/
|
|
3056
|
+
async decryptBatch(operations) {
|
|
3057
|
+
return Promise.all(operations.map((op) => this.decryptOperation(op)));
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* Check if a field value contains an encrypted payload.
|
|
3061
|
+
*
|
|
3062
|
+
* @param field - An operation's `data` or `previousData` field
|
|
3063
|
+
* @returns true if the field contains an encrypted payload
|
|
3064
|
+
*/
|
|
3065
|
+
static isEncryptedPayload(field) {
|
|
3066
|
+
return isEncryptedPayload(field);
|
|
3067
|
+
}
|
|
3068
|
+
// --- Private helpers ---
|
|
3069
|
+
async encryptField(field, operationId, fieldName) {
|
|
3070
|
+
if (field === null) {
|
|
3071
|
+
return null;
|
|
3072
|
+
}
|
|
3073
|
+
const currentKey = this.keys.get(this.currentVersion);
|
|
3074
|
+
if (!currentKey) {
|
|
3075
|
+
throw new EncryptionError(
|
|
3076
|
+
`Current encryption key version ${this.currentVersion} not found.`,
|
|
3077
|
+
{ operationId, fieldName }
|
|
3078
|
+
);
|
|
3079
|
+
}
|
|
3080
|
+
try {
|
|
3081
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(field));
|
|
3082
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
3083
|
+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
|
|
3084
|
+
{ name: "AES-GCM", iv },
|
|
3085
|
+
currentKey.key,
|
|
3086
|
+
plaintext
|
|
3087
|
+
);
|
|
3088
|
+
const payload = {
|
|
3089
|
+
v: this.currentVersion,
|
|
3090
|
+
iv: toBase64(iv),
|
|
3091
|
+
ct: toBase64(new Uint8Array(ciphertextBuffer)),
|
|
3092
|
+
alg: "aes-256-gcm"
|
|
3093
|
+
};
|
|
3094
|
+
return {
|
|
3095
|
+
[ENCRYPTED_MARKER]: true,
|
|
3096
|
+
...payload
|
|
3097
|
+
};
|
|
3098
|
+
} catch (cause) {
|
|
3099
|
+
if (cause instanceof EncryptionError) {
|
|
3100
|
+
throw cause;
|
|
3101
|
+
}
|
|
3102
|
+
throw new EncryptionError(
|
|
3103
|
+
`Failed to encrypt operation ${fieldName} field. Ensure the encryption key is valid and crypto.subtle is available.`,
|
|
3104
|
+
{
|
|
3105
|
+
operationId,
|
|
3106
|
+
fieldName,
|
|
3107
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3108
|
+
}
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
async decryptField(field, operationId, fieldName) {
|
|
3113
|
+
if (field === null) {
|
|
3114
|
+
return null;
|
|
3115
|
+
}
|
|
3116
|
+
if (!isEncryptedPayload(field)) {
|
|
3117
|
+
return field;
|
|
3118
|
+
}
|
|
3119
|
+
const payload = field;
|
|
3120
|
+
const keyVersion = payload.v;
|
|
3121
|
+
const key = this.keys.get(keyVersion);
|
|
3122
|
+
if (!key) {
|
|
3123
|
+
throw new DecryptionError(
|
|
3124
|
+
`No encryption key available for version ${keyVersion}. This operation was encrypted with a key that is not registered. If you rotated keys, ensure all previous key versions are provided.`,
|
|
3125
|
+
{ operationId, fieldName, keyVersion, availableVersions: [...this.keys.keys()] }
|
|
3126
|
+
);
|
|
3127
|
+
}
|
|
3128
|
+
if (payload.alg !== "aes-256-gcm") {
|
|
3129
|
+
throw new DecryptionError(
|
|
3130
|
+
`Unsupported encryption algorithm: "${payload.alg}". Only "aes-256-gcm" is supported. Update your @korajs/sync package.`,
|
|
3131
|
+
{ operationId, fieldName, algorithm: payload.alg }
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
try {
|
|
3135
|
+
const iv = fromBase64(payload.iv);
|
|
3136
|
+
const ciphertext = fromBase64(payload.ct);
|
|
3137
|
+
const plaintextBuffer = await globalThis.crypto.subtle.decrypt(
|
|
3138
|
+
{ name: "AES-GCM", iv },
|
|
3139
|
+
key.key,
|
|
3140
|
+
ciphertext
|
|
3141
|
+
);
|
|
3142
|
+
const json = new TextDecoder().decode(plaintextBuffer);
|
|
3143
|
+
const parsed = JSON.parse(json);
|
|
3144
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
3145
|
+
throw new DecryptionError(`Decrypted ${fieldName} is not a valid record object.`, {
|
|
3146
|
+
operationId,
|
|
3147
|
+
fieldName
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
return parsed;
|
|
3151
|
+
} catch (cause) {
|
|
3152
|
+
if (cause instanceof DecryptionError) {
|
|
3153
|
+
throw cause;
|
|
3154
|
+
}
|
|
3155
|
+
throw new DecryptionError(
|
|
3156
|
+
`Failed to decrypt operation ${fieldName} field. This may indicate a wrong encryption key, tampered ciphertext, or corrupted data.`,
|
|
3157
|
+
{
|
|
3158
|
+
operationId,
|
|
3159
|
+
fieldName,
|
|
3160
|
+
keyVersion,
|
|
3161
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
3162
|
+
}
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
};
|
|
3167
|
+
function isEncryptedPayload(field) {
|
|
3168
|
+
if (field === null || typeof field !== "object") {
|
|
3169
|
+
return false;
|
|
3170
|
+
}
|
|
3171
|
+
return field[ENCRYPTED_MARKER] === true && typeof field.v === "number" && typeof field.iv === "string" && typeof field.ct === "string" && typeof field.alg === "string";
|
|
3172
|
+
}
|
|
3173
|
+
function toBase64(bytes) {
|
|
3174
|
+
let binary = "";
|
|
3175
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
3176
|
+
binary += String.fromCharCode(bytes[i]);
|
|
3177
|
+
}
|
|
3178
|
+
return btoa(binary);
|
|
3179
|
+
}
|
|
3180
|
+
function fromBase64(str) {
|
|
3181
|
+
const binary = atob(str);
|
|
3182
|
+
const bytes = new Uint8Array(binary.length);
|
|
3183
|
+
for (let i = 0; i < binary.length; i++) {
|
|
3184
|
+
bytes[i] = binary.charCodeAt(i);
|
|
3185
|
+
}
|
|
3186
|
+
return bytes;
|
|
3187
|
+
}
|
|
1766
3188
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1767
3189
|
0 && (module.exports = {
|
|
3190
|
+
AwarenessManager,
|
|
1768
3191
|
ChaosTransport,
|
|
1769
3192
|
ConnectionMonitor,
|
|
3193
|
+
DecryptionError,
|
|
3194
|
+
EncryptionError,
|
|
1770
3195
|
HttpLongPollingTransport,
|
|
3196
|
+
InvalidScopeError,
|
|
1771
3197
|
JsonMessageSerializer,
|
|
3198
|
+
KeyDerivationError,
|
|
1772
3199
|
NegotiatedMessageSerializer,
|
|
1773
3200
|
OutboundQueue,
|
|
1774
3201
|
ProtobufMessageSerializer,
|
|
1775
3202
|
ReconnectionManager,
|
|
1776
3203
|
SYNC_STATES,
|
|
1777
3204
|
SYNC_STATUSES,
|
|
3205
|
+
ScopeViolationError,
|
|
3206
|
+
SyncEncryptor,
|
|
1778
3207
|
SyncEngine,
|
|
1779
3208
|
WebSocketTransport,
|
|
3209
|
+
deriveKey,
|
|
3210
|
+
deriveVersionedKey,
|
|
3211
|
+
filterOperationsByScope,
|
|
3212
|
+
generateSalt,
|
|
1780
3213
|
isAcknowledgmentMessage,
|
|
3214
|
+
isAwarenessUpdateMessage,
|
|
3215
|
+
isEncryptedPayload,
|
|
1781
3216
|
isErrorMessage,
|
|
1782
3217
|
isHandshakeMessage,
|
|
1783
3218
|
isHandshakeResponseMessage,
|
|
1784
3219
|
isOperationBatchMessage,
|
|
1785
3220
|
isSyncMessage,
|
|
3221
|
+
operationMatchesScope,
|
|
1786
3222
|
versionVectorToWire,
|
|
1787
3223
|
wireToVersionVector
|
|
1788
3224
|
});
|