@korajs/server 0.3.3 → 0.5.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/README.md +43 -0
- package/dist/index.cjs +1691 -113
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +442 -9
- package/dist/index.d.ts +442 -9
- package/dist/index.js +1517 -100
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +1 -1
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/server-backup-MOICK6MI.js +116 -0
- package/dist/server-backup-MOICK6MI.js.map +1 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
+
// src/logging/structured-logger.ts
|
|
2
|
+
function defaultJsonSerializer(entry) {
|
|
3
|
+
return JSON.stringify(entry);
|
|
4
|
+
}
|
|
5
|
+
function defaultPrettySerializer(entry) {
|
|
6
|
+
const time = new Date(entry.timestamp).toISOString().slice(11, 23);
|
|
7
|
+
const levelTag = { info: " INFO", warn: " WARN", error: "ERROR" }[entry.level];
|
|
8
|
+
const node = entry.nodeId ? ` [${entry.nodeId.slice(0, 8)}]` : "";
|
|
9
|
+
const session = entry.sessionId ? ` [session:${entry.sessionId.slice(0, 8)}]` : "";
|
|
10
|
+
const dur = entry.duration !== void 0 ? ` +${entry.duration}ms` : "";
|
|
11
|
+
const count3 = entry.count !== void 0 ? ` (${entry.count})` : "";
|
|
12
|
+
const err = entry.error ? ` \u2014 ${entry.error}` : "";
|
|
13
|
+
return `${time} ${levelTag}${node}${session} ${entry.event}${count3}${dur}${err}`;
|
|
14
|
+
}
|
|
15
|
+
function createJsonLogger(writer) {
|
|
16
|
+
const out = writer ?? {
|
|
17
|
+
info: (s) => process.stdout.write(`${s}
|
|
18
|
+
`),
|
|
19
|
+
error: (s) => process.stderr.write(`${s}
|
|
20
|
+
`)
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
log(entry) {
|
|
24
|
+
const line = defaultJsonSerializer(entry);
|
|
25
|
+
if (entry.level === "error") {
|
|
26
|
+
out.error(line);
|
|
27
|
+
} else {
|
|
28
|
+
out.info(line);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function createPrettyLogger(writer) {
|
|
34
|
+
const out = writer ?? { write: (s) => process.stdout.write(`${s}
|
|
35
|
+
`) };
|
|
36
|
+
return {
|
|
37
|
+
log(entry) {
|
|
38
|
+
out.write(defaultPrettySerializer(entry));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function createSilentLogger() {
|
|
43
|
+
return { log() {
|
|
44
|
+
} };
|
|
45
|
+
}
|
|
46
|
+
function createDefaultLogger() {
|
|
47
|
+
switch (process.env.NODE_ENV) {
|
|
48
|
+
case "production":
|
|
49
|
+
return createJsonLogger();
|
|
50
|
+
case "test":
|
|
51
|
+
return createSilentLogger();
|
|
52
|
+
default:
|
|
53
|
+
return createPrettyLogger();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
1
57
|
// src/store/memory-server-store.ts
|
|
2
58
|
import { generateUUIDv7 } from "@korajs/core";
|
|
3
59
|
|
|
@@ -63,9 +119,7 @@ ALTER TABLE ${name} ADD COLUMN ${colDef}`);
|
|
|
63
119
|
`CREATE INDEX IF NOT EXISTS idx_${name}_${indexField} ON ${name} (${indexField})`
|
|
64
120
|
);
|
|
65
121
|
}
|
|
66
|
-
statements.push(
|
|
67
|
-
`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`
|
|
68
|
-
);
|
|
122
|
+
statements.push(`CREATE INDEX IF NOT EXISTS idx_${name}__deleted ON ${name} (_deleted)`);
|
|
69
123
|
return statements;
|
|
70
124
|
}
|
|
71
125
|
function generateAllCollectionDDL(schema, dialect) {
|
|
@@ -159,6 +213,9 @@ var MemoryServerStore = class {
|
|
|
159
213
|
getNodeId() {
|
|
160
214
|
return this.nodeId;
|
|
161
215
|
}
|
|
216
|
+
getSchema() {
|
|
217
|
+
return this.schema;
|
|
218
|
+
}
|
|
162
219
|
async setSchema(schema) {
|
|
163
220
|
this.assertOpen();
|
|
164
221
|
this.schema = schema;
|
|
@@ -180,7 +237,7 @@ var MemoryServerStore = class {
|
|
|
180
237
|
if (op.sequenceNumber > currentSeq) {
|
|
181
238
|
this.versionVector.set(op.nodeId, op.sequenceNumber);
|
|
182
239
|
}
|
|
183
|
-
if (this.schema
|
|
240
|
+
if (this.schema?.collections[op.collection]) {
|
|
184
241
|
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
185
242
|
}
|
|
186
243
|
return "applied";
|
|
@@ -197,7 +254,7 @@ var MemoryServerStore = class {
|
|
|
197
254
|
}
|
|
198
255
|
async materializeCollection(collection) {
|
|
199
256
|
this.assertOpen();
|
|
200
|
-
if (this.schema
|
|
257
|
+
if (this.schema?.collections[collection]) {
|
|
201
258
|
return this.queryCollection(collection);
|
|
202
259
|
}
|
|
203
260
|
return this.materializeFromOps(collection);
|
|
@@ -206,13 +263,14 @@ var MemoryServerStore = class {
|
|
|
206
263
|
this.assertOpen();
|
|
207
264
|
this.assertSchema();
|
|
208
265
|
this.assertCollection(collection);
|
|
266
|
+
const schema = this.schema;
|
|
209
267
|
if (options?.where) {
|
|
210
268
|
for (const key of Object.keys(options.where)) {
|
|
211
|
-
validateFieldName(collection, key,
|
|
269
|
+
validateFieldName(collection, key, schema);
|
|
212
270
|
}
|
|
213
271
|
}
|
|
214
272
|
if (options?.orderBy) {
|
|
215
|
-
validateFieldName(collection, options.orderBy,
|
|
273
|
+
validateFieldName(collection, options.orderBy, schema);
|
|
216
274
|
}
|
|
217
275
|
const collectionMap = this.materializedRecords.get(collection);
|
|
218
276
|
if (!collectionMap) return [];
|
|
@@ -279,9 +337,10 @@ var MemoryServerStore = class {
|
|
|
279
337
|
this.assertOpen();
|
|
280
338
|
this.assertSchema();
|
|
281
339
|
this.assertCollection(collection);
|
|
340
|
+
const schema = this.schema;
|
|
282
341
|
if (where) {
|
|
283
342
|
for (const key of Object.keys(where)) {
|
|
284
|
-
validateFieldName(collection, key,
|
|
343
|
+
validateFieldName(collection, key, schema);
|
|
285
344
|
}
|
|
286
345
|
}
|
|
287
346
|
const collectionMap = this.materializedRecords.get(collection);
|
|
@@ -306,6 +365,49 @@ var MemoryServerStore = class {
|
|
|
306
365
|
async close() {
|
|
307
366
|
this.closed = true;
|
|
308
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Wipes all in-memory state. For tests and E2E isolation only.
|
|
370
|
+
*/
|
|
371
|
+
resetForTests() {
|
|
372
|
+
this.assertOpen();
|
|
373
|
+
this.operations.length = 0;
|
|
374
|
+
this.operationIndex.clear();
|
|
375
|
+
this.versionVector.clear();
|
|
376
|
+
this.materializedRecords.clear();
|
|
377
|
+
this.schema = null;
|
|
378
|
+
}
|
|
379
|
+
async exportBackup() {
|
|
380
|
+
this.assertOpen();
|
|
381
|
+
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
382
|
+
return buildServerBackup(this.nodeId, this.operations, this.versionVector);
|
|
383
|
+
}
|
|
384
|
+
async importBackup(data, merge) {
|
|
385
|
+
this.assertOpen();
|
|
386
|
+
const { parseServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
387
|
+
const { operations: operations2, versionVector } = parseServerBackup(data);
|
|
388
|
+
if (merge) {
|
|
389
|
+
let restored = 0;
|
|
390
|
+
for (const op of operations2) {
|
|
391
|
+
const result = await this.applyRemoteOperation(op);
|
|
392
|
+
if (result === "applied") restored++;
|
|
393
|
+
}
|
|
394
|
+
return { operationsRestored: restored, success: true };
|
|
395
|
+
}
|
|
396
|
+
this.operations.length = 0;
|
|
397
|
+
this.operationIndex.clear();
|
|
398
|
+
this.versionVector.clear();
|
|
399
|
+
for (const [nid, seq] of versionVector) {
|
|
400
|
+
this.versionVector.set(nid, seq);
|
|
401
|
+
}
|
|
402
|
+
for (const op of operations2) {
|
|
403
|
+
this.operations.push(op);
|
|
404
|
+
this.operationIndex.set(op.id, op);
|
|
405
|
+
if (this.schema?.collections[op.collection]) {
|
|
406
|
+
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return { operationsRestored: operations2.length, success: true };
|
|
410
|
+
}
|
|
309
411
|
// --- Testing helpers (not on interface) ---
|
|
310
412
|
/**
|
|
311
413
|
* Get all stored operations (for test assertions).
|
|
@@ -317,7 +419,7 @@ var MemoryServerStore = class {
|
|
|
317
419
|
// Materialization internals
|
|
318
420
|
// ---------------------------------------------------------------------------
|
|
319
421
|
rebuildMaterializedRecord(collection, recordId) {
|
|
320
|
-
const collectionDef = this.schema
|
|
422
|
+
const collectionDef = this.schema?.collections[collection];
|
|
321
423
|
if (!collectionDef) return;
|
|
322
424
|
let collectionMap = this.materializedRecords.get(collection);
|
|
323
425
|
if (!collectionMap) {
|
|
@@ -325,8 +427,10 @@ var MemoryServerStore = class {
|
|
|
325
427
|
this.materializedRecords.set(collection, collectionMap);
|
|
326
428
|
}
|
|
327
429
|
const recordOps = this.operations.filter((op) => op.collection === collection && op.recordId === recordId).sort((a, b) => {
|
|
328
|
-
if (a.timestamp.wallTime !== b.timestamp.wallTime)
|
|
329
|
-
|
|
430
|
+
if (a.timestamp.wallTime !== b.timestamp.wallTime)
|
|
431
|
+
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
432
|
+
if (a.timestamp.logical !== b.timestamp.logical)
|
|
433
|
+
return a.timestamp.logical - b.timestamp.logical;
|
|
330
434
|
return a.sequenceNumber - b.sequenceNumber;
|
|
331
435
|
});
|
|
332
436
|
const parsedOps = recordOps.map((op) => ({
|
|
@@ -378,8 +482,10 @@ var MemoryServerStore = class {
|
|
|
378
482
|
// ---------------------------------------------------------------------------
|
|
379
483
|
materializeFromOps(collection) {
|
|
380
484
|
const collectionOps = this.operations.filter((op) => op.collection === collection).sort((a, b) => {
|
|
381
|
-
if (a.timestamp.wallTime !== b.timestamp.wallTime)
|
|
382
|
-
|
|
485
|
+
if (a.timestamp.wallTime !== b.timestamp.wallTime)
|
|
486
|
+
return a.timestamp.wallTime - b.timestamp.wallTime;
|
|
487
|
+
if (a.timestamp.logical !== b.timestamp.logical)
|
|
488
|
+
return a.timestamp.logical - b.timestamp.logical;
|
|
383
489
|
return a.sequenceNumber - b.sequenceNumber;
|
|
384
490
|
});
|
|
385
491
|
const records = /* @__PURE__ */ new Map();
|
|
@@ -425,9 +531,10 @@ var MemoryServerStore = class {
|
|
|
425
531
|
}
|
|
426
532
|
}
|
|
427
533
|
assertCollection(collection) {
|
|
428
|
-
|
|
534
|
+
const schema = this.schema;
|
|
535
|
+
if (!schema.collections[collection]) {
|
|
429
536
|
throw new Error(
|
|
430
|
-
`Unknown collection "${collection}". Available: ${Object.keys(
|
|
537
|
+
`Unknown collection "${collection}". Available: ${Object.keys(schema.collections).join(", ")}`
|
|
431
538
|
);
|
|
432
539
|
}
|
|
433
540
|
}
|
|
@@ -492,6 +599,9 @@ var PostgresServerStore = class {
|
|
|
492
599
|
getNodeId() {
|
|
493
600
|
return this.nodeId;
|
|
494
601
|
}
|
|
602
|
+
getSchema() {
|
|
603
|
+
return this.schema;
|
|
604
|
+
}
|
|
495
605
|
async setSchema(schema) {
|
|
496
606
|
this.assertOpen();
|
|
497
607
|
await this.ready;
|
|
@@ -537,7 +647,7 @@ var PostgresServerStore = class {
|
|
|
537
647
|
lastSeenAt: sql`${now}`
|
|
538
648
|
}
|
|
539
649
|
});
|
|
540
|
-
if (this.schema
|
|
650
|
+
if (this.schema?.collections[op.collection]) {
|
|
541
651
|
await this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
542
652
|
}
|
|
543
653
|
});
|
|
@@ -551,10 +661,7 @@ var PostgresServerStore = class {
|
|
|
551
661
|
this.assertOpen();
|
|
552
662
|
await this.ready;
|
|
553
663
|
const rows = await this.db.select().from(pgOperations).where(
|
|
554
|
-
and(
|
|
555
|
-
eq(pgOperations.nodeId, nodeId),
|
|
556
|
-
between(pgOperations.sequenceNumber, fromSeq, toSeq)
|
|
557
|
-
)
|
|
664
|
+
and(eq(pgOperations.nodeId, nodeId), between(pgOperations.sequenceNumber, fromSeq, toSeq))
|
|
558
665
|
).orderBy(asc(pgOperations.sequenceNumber));
|
|
559
666
|
return rows.map((row) => this.deserializeOperation(row));
|
|
560
667
|
}
|
|
@@ -567,7 +674,7 @@ var PostgresServerStore = class {
|
|
|
567
674
|
async materializeCollection(collection) {
|
|
568
675
|
this.assertOpen();
|
|
569
676
|
await this.ready;
|
|
570
|
-
if (this.schema
|
|
677
|
+
if (this.schema?.collections[collection]) {
|
|
571
678
|
return this.queryCollection(collection);
|
|
572
679
|
}
|
|
573
680
|
return this.materializeFromOpsLog(collection);
|
|
@@ -577,14 +684,15 @@ var PostgresServerStore = class {
|
|
|
577
684
|
await this.ready;
|
|
578
685
|
this.assertSchema();
|
|
579
686
|
this.assertCollection(collection);
|
|
580
|
-
const
|
|
687
|
+
const schema = this.schema;
|
|
688
|
+
const collectionDef = schema.collections[collection];
|
|
581
689
|
if (options?.where) {
|
|
582
690
|
for (const key of Object.keys(options.where)) {
|
|
583
|
-
validateFieldName(collection, key,
|
|
691
|
+
validateFieldName(collection, key, schema);
|
|
584
692
|
}
|
|
585
693
|
}
|
|
586
694
|
if (options?.orderBy) {
|
|
587
|
-
validateFieldName(collection, options.orderBy,
|
|
695
|
+
validateFieldName(collection, options.orderBy, schema);
|
|
588
696
|
}
|
|
589
697
|
const query = this.buildSelectQuery(collection, options);
|
|
590
698
|
const rows = await this.db.execute(query);
|
|
@@ -595,7 +703,8 @@ var PostgresServerStore = class {
|
|
|
595
703
|
await this.ready;
|
|
596
704
|
this.assertSchema();
|
|
597
705
|
this.assertCollection(collection);
|
|
598
|
-
const
|
|
706
|
+
const schema = this.schema;
|
|
707
|
+
const collectionDef = schema.collections[collection];
|
|
599
708
|
const query = sql`SELECT * FROM ${sql.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
600
709
|
const rows = await this.db.execute(query);
|
|
601
710
|
if (rows.length === 0) return null;
|
|
@@ -606,9 +715,10 @@ var PostgresServerStore = class {
|
|
|
606
715
|
await this.ready;
|
|
607
716
|
this.assertSchema();
|
|
608
717
|
this.assertCollection(collection);
|
|
718
|
+
const schema = this.schema;
|
|
609
719
|
if (where) {
|
|
610
720
|
for (const key of Object.keys(where)) {
|
|
611
|
-
validateFieldName(collection, key,
|
|
721
|
+
validateFieldName(collection, key, schema);
|
|
612
722
|
}
|
|
613
723
|
}
|
|
614
724
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
@@ -620,6 +730,45 @@ var PostgresServerStore = class {
|
|
|
620
730
|
async close() {
|
|
621
731
|
this.closed = true;
|
|
622
732
|
}
|
|
733
|
+
async exportBackup() {
|
|
734
|
+
this.assertOpen();
|
|
735
|
+
await this.ready;
|
|
736
|
+
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
737
|
+
const rows = await this.db.select().from(pgOperations).orderBy(asc(pgOperations.sequenceNumber));
|
|
738
|
+
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
739
|
+
return buildServerBackup(this.nodeId, operations2, this.versionVector);
|
|
740
|
+
}
|
|
741
|
+
async importBackup(data, merge) {
|
|
742
|
+
this.assertOpen();
|
|
743
|
+
await this.ready;
|
|
744
|
+
const { parseServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
745
|
+
const { operations: operations2, versionVector } = parseServerBackup(data);
|
|
746
|
+
if (merge) {
|
|
747
|
+
let restored = 0;
|
|
748
|
+
for (const op of operations2) {
|
|
749
|
+
const result = await this.applyRemoteOperation(op);
|
|
750
|
+
if (result === "applied") restored++;
|
|
751
|
+
}
|
|
752
|
+
return { operationsRestored: restored, success: true };
|
|
753
|
+
}
|
|
754
|
+
const now = Date.now();
|
|
755
|
+
await this.db.transaction(async (tx) => {
|
|
756
|
+
await tx.delete(pgOperations);
|
|
757
|
+
await tx.delete(pgSyncState);
|
|
758
|
+
for (const [nid, seq] of versionVector) {
|
|
759
|
+
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
760
|
+
}
|
|
761
|
+
for (const op of operations2) {
|
|
762
|
+
const row = this.serializeOperation(op, now);
|
|
763
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
this.versionVector.clear();
|
|
767
|
+
for (const [nid, seq] of versionVector) {
|
|
768
|
+
this.versionVector.set(nid, seq);
|
|
769
|
+
}
|
|
770
|
+
return { operationsRestored: operations2.length, success: true };
|
|
771
|
+
}
|
|
623
772
|
// ---------------------------------------------------------------------------
|
|
624
773
|
// Materialization internals
|
|
625
774
|
// ---------------------------------------------------------------------------
|
|
@@ -628,18 +777,13 @@ var PostgresServerStore = class {
|
|
|
628
777
|
* all operations for that record.
|
|
629
778
|
*/
|
|
630
779
|
async rebuildMaterializedRecord(txOrDb, collection, recordId) {
|
|
631
|
-
const collectionDef = this.schema
|
|
780
|
+
const collectionDef = this.schema?.collections[collection];
|
|
632
781
|
if (!collectionDef) return;
|
|
633
782
|
const ops = await txOrDb.select({
|
|
634
783
|
type: pgOperations.type,
|
|
635
784
|
data: pgOperations.data,
|
|
636
785
|
wallTime: pgOperations.wallTime
|
|
637
|
-
}).from(pgOperations).where(
|
|
638
|
-
and(
|
|
639
|
-
eq(pgOperations.collection, collection),
|
|
640
|
-
eq(pgOperations.recordId, recordId)
|
|
641
|
-
)
|
|
642
|
-
).orderBy(
|
|
786
|
+
}).from(pgOperations).where(and(eq(pgOperations.collection, collection), eq(pgOperations.recordId, recordId))).orderBy(
|
|
643
787
|
asc(pgOperations.wallTime),
|
|
644
788
|
asc(pgOperations.logical),
|
|
645
789
|
asc(pgOperations.sequenceNumber)
|
|
@@ -709,7 +853,7 @@ var PostgresServerStore = class {
|
|
|
709
853
|
* Backfill a single collection's materialized table from operations.
|
|
710
854
|
*/
|
|
711
855
|
async backfillCollection(collectionName) {
|
|
712
|
-
const collectionDef = this.schema
|
|
856
|
+
const collectionDef = this.schema?.collections[collectionName];
|
|
713
857
|
if (!collectionDef) return;
|
|
714
858
|
const allOps = await this.db.select({
|
|
715
859
|
recordId: pgOperations.recordId,
|
|
@@ -766,9 +910,7 @@ var PostgresServerStore = class {
|
|
|
766
910
|
options?.where ?? {},
|
|
767
911
|
options?.includeDeleted ?? false
|
|
768
912
|
);
|
|
769
|
-
const parts = [
|
|
770
|
-
sql`SELECT * FROM ${sql.raw(collection)} WHERE ${whereClause}`
|
|
771
|
-
];
|
|
913
|
+
const parts = [sql`SELECT * FROM ${sql.raw(collection)} WHERE ${whereClause}`];
|
|
772
914
|
if (options?.orderBy) {
|
|
773
915
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
774
916
|
parts.push(sql.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
@@ -881,12 +1023,8 @@ var PostgresServerStore = class {
|
|
|
881
1023
|
await this.db.execute(
|
|
882
1024
|
sql`CREATE INDEX IF NOT EXISTS idx_node_seq ON operations (node_id, sequence_number)`
|
|
883
1025
|
);
|
|
884
|
-
await this.db.execute(
|
|
885
|
-
|
|
886
|
-
);
|
|
887
|
-
await this.db.execute(
|
|
888
|
-
sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`
|
|
889
|
-
);
|
|
1026
|
+
await this.db.execute(sql`CREATE INDEX IF NOT EXISTS idx_collection ON operations (collection)`);
|
|
1027
|
+
await this.db.execute(sql`CREATE INDEX IF NOT EXISTS idx_received ON operations (received_at)`);
|
|
890
1028
|
await this.db.execute(
|
|
891
1029
|
sql`CREATE INDEX IF NOT EXISTS idx_collection_record ON operations (collection, record_id)`
|
|
892
1030
|
);
|
|
@@ -954,9 +1092,10 @@ var PostgresServerStore = class {
|
|
|
954
1092
|
}
|
|
955
1093
|
}
|
|
956
1094
|
assertCollection(collection) {
|
|
957
|
-
|
|
1095
|
+
const schema = this.schema;
|
|
1096
|
+
if (!schema.collections[collection]) {
|
|
958
1097
|
throw new Error(
|
|
959
|
-
`Unknown collection "${collection}". Available: ${Object.keys(
|
|
1098
|
+
`Unknown collection "${collection}". Available: ${Object.keys(schema.collections).join(", ")}`
|
|
960
1099
|
);
|
|
961
1100
|
}
|
|
962
1101
|
}
|
|
@@ -1047,6 +1186,9 @@ var SqliteServerStore = class {
|
|
|
1047
1186
|
getNodeId() {
|
|
1048
1187
|
return this.nodeId;
|
|
1049
1188
|
}
|
|
1189
|
+
getSchema() {
|
|
1190
|
+
return this.schema;
|
|
1191
|
+
}
|
|
1050
1192
|
async setSchema(schema) {
|
|
1051
1193
|
this.assertOpen();
|
|
1052
1194
|
this.schema = schema;
|
|
@@ -1089,7 +1231,7 @@ var SqliteServerStore = class {
|
|
|
1089
1231
|
lastSeenAt: sql2`${now}`
|
|
1090
1232
|
}
|
|
1091
1233
|
}).run();
|
|
1092
|
-
if (this.schema
|
|
1234
|
+
if (this.schema?.collections[op.collection]) {
|
|
1093
1235
|
this.rebuildMaterializedRecord(tx, op.collection, op.recordId);
|
|
1094
1236
|
}
|
|
1095
1237
|
return "applied";
|
|
@@ -1108,7 +1250,7 @@ var SqliteServerStore = class {
|
|
|
1108
1250
|
}
|
|
1109
1251
|
async materializeCollection(collection) {
|
|
1110
1252
|
this.assertOpen();
|
|
1111
|
-
if (this.schema
|
|
1253
|
+
if (this.schema?.collections[collection]) {
|
|
1112
1254
|
return this.queryCollection(collection);
|
|
1113
1255
|
}
|
|
1114
1256
|
return this.materializeFromOpsLog(collection);
|
|
@@ -1117,14 +1259,15 @@ var SqliteServerStore = class {
|
|
|
1117
1259
|
this.assertOpen();
|
|
1118
1260
|
this.assertSchema();
|
|
1119
1261
|
this.assertCollection(collection);
|
|
1120
|
-
const
|
|
1262
|
+
const schema = this.schema;
|
|
1263
|
+
const collectionDef = schema.collections[collection];
|
|
1121
1264
|
if (options?.where) {
|
|
1122
1265
|
for (const key of Object.keys(options.where)) {
|
|
1123
|
-
validateFieldName(collection, key,
|
|
1266
|
+
validateFieldName(collection, key, schema);
|
|
1124
1267
|
}
|
|
1125
1268
|
}
|
|
1126
1269
|
if (options?.orderBy) {
|
|
1127
|
-
validateFieldName(collection, options.orderBy,
|
|
1270
|
+
validateFieldName(collection, options.orderBy, schema);
|
|
1128
1271
|
}
|
|
1129
1272
|
const query = this.buildSelectQuery(collection, options);
|
|
1130
1273
|
const rows = this.db.all(query);
|
|
@@ -1134,7 +1277,8 @@ var SqliteServerStore = class {
|
|
|
1134
1277
|
this.assertOpen();
|
|
1135
1278
|
this.assertSchema();
|
|
1136
1279
|
this.assertCollection(collection);
|
|
1137
|
-
const
|
|
1280
|
+
const schema = this.schema;
|
|
1281
|
+
const collectionDef = schema.collections[collection];
|
|
1138
1282
|
const query = sql2`SELECT * FROM ${sql2.raw(collection)} WHERE id = ${id} AND _deleted = 0`;
|
|
1139
1283
|
const rows = this.db.all(query);
|
|
1140
1284
|
if (rows.length === 0) return null;
|
|
@@ -1144,9 +1288,10 @@ var SqliteServerStore = class {
|
|
|
1144
1288
|
this.assertOpen();
|
|
1145
1289
|
this.assertSchema();
|
|
1146
1290
|
this.assertCollection(collection);
|
|
1291
|
+
const schema = this.schema;
|
|
1147
1292
|
if (where) {
|
|
1148
1293
|
for (const key of Object.keys(where)) {
|
|
1149
|
-
validateFieldName(collection, key,
|
|
1294
|
+
validateFieldName(collection, key, schema);
|
|
1150
1295
|
}
|
|
1151
1296
|
}
|
|
1152
1297
|
const whereClause = this.buildWhereClause(where ?? {}, false);
|
|
@@ -1157,6 +1302,39 @@ var SqliteServerStore = class {
|
|
|
1157
1302
|
async close() {
|
|
1158
1303
|
this.closed = true;
|
|
1159
1304
|
}
|
|
1305
|
+
async exportBackup() {
|
|
1306
|
+
this.assertOpen();
|
|
1307
|
+
const { buildServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
1308
|
+
const rows = this.db.select().from(operations).all();
|
|
1309
|
+
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1310
|
+
const vv = this.getVersionVector();
|
|
1311
|
+
return buildServerBackup(this.nodeId, deserialized, vv);
|
|
1312
|
+
}
|
|
1313
|
+
async importBackup(data, merge) {
|
|
1314
|
+
this.assertOpen();
|
|
1315
|
+
const { parseServerBackup } = await import("./server-backup-MOICK6MI.js");
|
|
1316
|
+
const { operations: ops, versionVector } = parseServerBackup(data);
|
|
1317
|
+
if (merge) {
|
|
1318
|
+
let restored = 0;
|
|
1319
|
+
for (const op of ops) {
|
|
1320
|
+
const result = await this.applyRemoteOperation(op);
|
|
1321
|
+
if (result === "applied") restored++;
|
|
1322
|
+
}
|
|
1323
|
+
return { operationsRestored: restored, success: true };
|
|
1324
|
+
}
|
|
1325
|
+
this.db.transaction((tx) => {
|
|
1326
|
+
tx.run(sql2.raw("DELETE FROM operations"));
|
|
1327
|
+
tx.run(sql2.raw("DELETE FROM sync_state"));
|
|
1328
|
+
for (const [nid, seq] of versionVector) {
|
|
1329
|
+
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1330
|
+
}
|
|
1331
|
+
for (const op of ops) {
|
|
1332
|
+
const row = this.serializeOperation(op, Date.now());
|
|
1333
|
+
tx.insert(operations).values(row).run();
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
return { operationsRestored: ops.length, success: true };
|
|
1337
|
+
}
|
|
1160
1338
|
// ---------------------------------------------------------------------------
|
|
1161
1339
|
// Materialization internals
|
|
1162
1340
|
// ---------------------------------------------------------------------------
|
|
@@ -1166,18 +1344,13 @@ var SqliteServerStore = class {
|
|
|
1166
1344
|
* transaction for atomic dual-write.
|
|
1167
1345
|
*/
|
|
1168
1346
|
rebuildMaterializedRecord(txOrDb, collection, recordId) {
|
|
1169
|
-
const collectionDef = this.schema
|
|
1347
|
+
const collectionDef = this.schema?.collections[collection];
|
|
1170
1348
|
if (!collectionDef) return;
|
|
1171
1349
|
const ops = txOrDb.select({
|
|
1172
1350
|
type: operations.type,
|
|
1173
1351
|
data: operations.data,
|
|
1174
1352
|
wallTime: operations.wallTime
|
|
1175
|
-
}).from(operations).where(
|
|
1176
|
-
and2(
|
|
1177
|
-
eq2(operations.collection, collection),
|
|
1178
|
-
eq2(operations.recordId, recordId)
|
|
1179
|
-
)
|
|
1180
|
-
).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
|
|
1353
|
+
}).from(operations).where(and2(eq2(operations.collection, collection), eq2(operations.recordId, recordId))).orderBy(asc2(operations.wallTime), asc2(operations.logical), asc2(operations.sequenceNumber)).all();
|
|
1181
1354
|
const parsedOps = ops.map((op) => ({
|
|
1182
1355
|
type: op.type,
|
|
1183
1356
|
data: op.data !== null ? JSON.parse(op.data) : null
|
|
@@ -1246,7 +1419,7 @@ var SqliteServerStore = class {
|
|
|
1246
1419
|
* Backfill a single collection's materialized table from operations.
|
|
1247
1420
|
*/
|
|
1248
1421
|
backfillCollection(collectionName) {
|
|
1249
|
-
const collectionDef = this.schema
|
|
1422
|
+
const collectionDef = this.schema?.collections[collectionName];
|
|
1250
1423
|
if (!collectionDef) return;
|
|
1251
1424
|
const allOps = this.db.select({
|
|
1252
1425
|
recordId: operations.recordId,
|
|
@@ -1301,9 +1474,7 @@ var SqliteServerStore = class {
|
|
|
1301
1474
|
options?.where ?? {},
|
|
1302
1475
|
options?.includeDeleted ?? false
|
|
1303
1476
|
);
|
|
1304
|
-
const parts = [
|
|
1305
|
-
sql2`SELECT * FROM ${sql2.raw(collection)} WHERE ${whereClause}`
|
|
1306
|
-
];
|
|
1477
|
+
const parts = [sql2`SELECT * FROM ${sql2.raw(collection)} WHERE ${whereClause}`];
|
|
1307
1478
|
if (options?.orderBy) {
|
|
1308
1479
|
const dir = options.orderDirection === "desc" ? "DESC" : "ASC";
|
|
1309
1480
|
parts.push(sql2.raw(` ORDER BY ${options.orderBy} ${dir}`));
|
|
@@ -1478,9 +1649,10 @@ var SqliteServerStore = class {
|
|
|
1478
1649
|
}
|
|
1479
1650
|
}
|
|
1480
1651
|
assertCollection(collection) {
|
|
1481
|
-
|
|
1652
|
+
const schema = this.schema;
|
|
1653
|
+
if (!schema.collections[collection]) {
|
|
1482
1654
|
throw new Error(
|
|
1483
|
-
`Unknown collection "${collection}". Available: ${Object.keys(
|
|
1655
|
+
`Unknown collection "${collection}". Available: ${Object.keys(schema.collections).join(", ")}`
|
|
1484
1656
|
);
|
|
1485
1657
|
}
|
|
1486
1658
|
}
|
|
@@ -1644,10 +1816,219 @@ import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
|
|
|
1644
1816
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1645
1817
|
import {
|
|
1646
1818
|
NegotiatedMessageSerializer,
|
|
1819
|
+
SCHEMA_MISMATCH_PREFIX,
|
|
1820
|
+
createDeltaCursorFromBatch,
|
|
1821
|
+
decodeDeltaCursor,
|
|
1822
|
+
dedupeQuerySubsets,
|
|
1823
|
+
encodeDeltaCursor,
|
|
1824
|
+
isClientSchemaVersionSupported,
|
|
1825
|
+
operationMatchesQuerySubsets,
|
|
1826
|
+
sliceOperationsAfterCursor,
|
|
1647
1827
|
versionVectorToWire,
|
|
1648
1828
|
wireToVersionVector
|
|
1649
1829
|
} from "@korajs/sync";
|
|
1650
1830
|
|
|
1831
|
+
// src/apply/apply-server-operation.ts
|
|
1832
|
+
import { buildMergeRelationLookup, checkReferentialIntegrityOnDelete } from "@korajs/merge";
|
|
1833
|
+
|
|
1834
|
+
// src/constraints/operation-constraint-validator.ts
|
|
1835
|
+
import { checkConstraints } from "@korajs/merge";
|
|
1836
|
+
|
|
1837
|
+
// src/constraints/server-constraint-context.ts
|
|
1838
|
+
function createServerConstraintContext(store) {
|
|
1839
|
+
return {
|
|
1840
|
+
async queryRecords(collection, where) {
|
|
1841
|
+
const rows = await store.queryCollection(collection, { where });
|
|
1842
|
+
return rows.map((row) => ({ ...row }));
|
|
1843
|
+
},
|
|
1844
|
+
async countRecords(collection, where) {
|
|
1845
|
+
return store.countCollection(collection, where);
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// src/constraints/operation-constraint-validator.ts
|
|
1851
|
+
async function validateIncomingOperationConstraints(store, op, schema) {
|
|
1852
|
+
if (!schema) {
|
|
1853
|
+
return { valid: true };
|
|
1854
|
+
}
|
|
1855
|
+
if (op.type === "delete") {
|
|
1856
|
+
return { valid: true };
|
|
1857
|
+
}
|
|
1858
|
+
const collectionDef = schema.collections[op.collection];
|
|
1859
|
+
if (!collectionDef || collectionDef.constraints.length === 0) {
|
|
1860
|
+
return { valid: true };
|
|
1861
|
+
}
|
|
1862
|
+
const candidate = await projectCandidateRecord(store, op);
|
|
1863
|
+
if (candidate === null) {
|
|
1864
|
+
return { valid: true };
|
|
1865
|
+
}
|
|
1866
|
+
const ctx = createServerConstraintContext(store);
|
|
1867
|
+
const violations = await checkConstraints(
|
|
1868
|
+
candidate,
|
|
1869
|
+
op.recordId,
|
|
1870
|
+
op.collection,
|
|
1871
|
+
collectionDef,
|
|
1872
|
+
ctx
|
|
1873
|
+
);
|
|
1874
|
+
if (violations.length === 0) {
|
|
1875
|
+
return { valid: true };
|
|
1876
|
+
}
|
|
1877
|
+
const first = violations[0];
|
|
1878
|
+
if (first === void 0) {
|
|
1879
|
+
return { valid: true };
|
|
1880
|
+
}
|
|
1881
|
+
return {
|
|
1882
|
+
valid: false,
|
|
1883
|
+
code: "CONSTRAINT_VIOLATION",
|
|
1884
|
+
message: first.message
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
async function projectCandidateRecord(store, op) {
|
|
1888
|
+
if (op.data === null) {
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
const existing = await store.findRecord(op.collection, op.recordId);
|
|
1892
|
+
const base = existing ? { ...existing } : {};
|
|
1893
|
+
if (op.type === "insert") {
|
|
1894
|
+
return { ...op.data, id: op.recordId };
|
|
1895
|
+
}
|
|
1896
|
+
return { ...base, ...op.data, id: op.recordId };
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/constraints/server-referential-context.ts
|
|
1900
|
+
function createServerReferentialContext(store) {
|
|
1901
|
+
return {
|
|
1902
|
+
async queryRecords(collection, where) {
|
|
1903
|
+
const rows = await store.queryCollection(collection, { where });
|
|
1904
|
+
return rows.map((row) => ({ ...row }));
|
|
1905
|
+
},
|
|
1906
|
+
async recordExists(collection, recordId) {
|
|
1907
|
+
const row = await store.findRecord(collection, recordId);
|
|
1908
|
+
return row !== null && row._deleted !== 1;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// src/apply/server-side-effect-operation.ts
|
|
1914
|
+
import { HybridLogicalClock, createOperation } from "@korajs/core";
|
|
1915
|
+
async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
|
|
1916
|
+
const nodeId = store.getNodeId();
|
|
1917
|
+
const clock = new HybridLogicalClock(nodeId);
|
|
1918
|
+
clock.receive(parentOp.timestamp);
|
|
1919
|
+
return createOperation(
|
|
1920
|
+
{
|
|
1921
|
+
nodeId,
|
|
1922
|
+
type: effect.type === "delete" ? "delete" : "update",
|
|
1923
|
+
collection: effect.collection,
|
|
1924
|
+
recordId: effect.recordId,
|
|
1925
|
+
data: effect.data,
|
|
1926
|
+
previousData: effect.previousData,
|
|
1927
|
+
sequenceNumber,
|
|
1928
|
+
causalDeps: [parentOp.id],
|
|
1929
|
+
schemaVersion
|
|
1930
|
+
},
|
|
1931
|
+
clock
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
function nextServerSequenceNumber(store) {
|
|
1935
|
+
const nodeId = store.getNodeId();
|
|
1936
|
+
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
1937
|
+
return current + 1;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// src/apply/apply-server-operation.ts
|
|
1941
|
+
async function applyServerOperation(store, op, relationLookup) {
|
|
1942
|
+
const schema = store.getSchema();
|
|
1943
|
+
const lookup = relationLookup ?? (schema ? buildMergeRelationLookup(schema) : /* @__PURE__ */ new Map());
|
|
1944
|
+
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
1945
|
+
if (!constraintCheck.valid) {
|
|
1946
|
+
return {
|
|
1947
|
+
result: "skipped",
|
|
1948
|
+
appliedOperations: [],
|
|
1949
|
+
rejection: {
|
|
1950
|
+
code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
|
|
1951
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
if (op.type === "delete" && schema) {
|
|
1956
|
+
const refCtx = createServerReferentialContext(store);
|
|
1957
|
+
const referential = await checkReferentialIntegrityOnDelete(op, schema, refCtx, lookup);
|
|
1958
|
+
if (!referential.allowed) {
|
|
1959
|
+
return {
|
|
1960
|
+
result: "skipped",
|
|
1961
|
+
appliedOperations: [],
|
|
1962
|
+
rejection: {
|
|
1963
|
+
code: "REFERENTIAL_INTEGRITY",
|
|
1964
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
const primaryResult = await store.applyRemoteOperation(op);
|
|
1969
|
+
if (primaryResult !== "applied") {
|
|
1970
|
+
return { result: primaryResult, appliedOperations: [] };
|
|
1971
|
+
}
|
|
1972
|
+
const appliedOperations = [op];
|
|
1973
|
+
let serverSeq = nextServerSequenceNumber(store);
|
|
1974
|
+
for (const effect of referential.sideEffectOps) {
|
|
1975
|
+
const sideOp = await createServerSideEffectOperation(
|
|
1976
|
+
store,
|
|
1977
|
+
op,
|
|
1978
|
+
effect,
|
|
1979
|
+
op.schemaVersion,
|
|
1980
|
+
serverSeq
|
|
1981
|
+
);
|
|
1982
|
+
serverSeq += 1;
|
|
1983
|
+
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
1984
|
+
if (sideResult === "applied") {
|
|
1985
|
+
appliedOperations.push(sideOp);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return { result: "applied", appliedOperations };
|
|
1989
|
+
}
|
|
1990
|
+
const result = await store.applyRemoteOperation(op);
|
|
1991
|
+
return {
|
|
1992
|
+
result,
|
|
1993
|
+
appliedOperations: result === "applied" ? [op] : []
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/scopes/resolve-session-scopes.ts
|
|
1998
|
+
import { buildScopeMap } from "@korajs/core";
|
|
1999
|
+
function resolveSessionScopes(schema, options) {
|
|
2000
|
+
const { handshakeScope, authScopes, scopeValues } = options;
|
|
2001
|
+
let resolved;
|
|
2002
|
+
if (schema && scopeValues && Object.keys(scopeValues).length > 0) {
|
|
2003
|
+
resolved = buildScopeMap(schema, scopeValues);
|
|
2004
|
+
} else if (handshakeScope) {
|
|
2005
|
+
resolved = { ...handshakeScope };
|
|
2006
|
+
} else if (authScopes) {
|
|
2007
|
+
resolved = { ...authScopes };
|
|
2008
|
+
}
|
|
2009
|
+
if (schema && !resolved && scopeValues) {
|
|
2010
|
+
resolved = buildScopeMap(schema, scopeValues);
|
|
2011
|
+
}
|
|
2012
|
+
if (authScopes) {
|
|
2013
|
+
resolved = mergeScopeMaps(resolved, authScopes, "auth");
|
|
2014
|
+
}
|
|
2015
|
+
if (handshakeScope) {
|
|
2016
|
+
resolved = mergeScopeMaps(resolved, handshakeScope, "handshake");
|
|
2017
|
+
}
|
|
2018
|
+
return resolved && Object.keys(resolved).length > 0 ? resolved : void 0;
|
|
2019
|
+
}
|
|
2020
|
+
function mergeScopeMaps(base, overlay, source) {
|
|
2021
|
+
const merged = { ...base ?? {} };
|
|
2022
|
+
for (const [collection, overlayScope] of Object.entries(overlay)) {
|
|
2023
|
+
if (source === "auth") {
|
|
2024
|
+
merged[collection] = { ...merged[collection] ?? {}, ...overlayScope };
|
|
2025
|
+
} else {
|
|
2026
|
+
merged[collection] = { ...overlayScope ?? {}, ...merged[collection] ?? {} };
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return merged;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1651
2032
|
// src/scopes/server-scope-filter.ts
|
|
1652
2033
|
function operationMatchesScopes(op, scopes) {
|
|
1653
2034
|
if (!scopes) return true;
|
|
@@ -1678,6 +2059,55 @@ function asRecord(value) {
|
|
|
1678
2059
|
return value;
|
|
1679
2060
|
}
|
|
1680
2061
|
|
|
2062
|
+
// src/session/operation-validation.ts
|
|
2063
|
+
var SERVER_MAX_TIMESTAMP_FUTURE_MS = 6e4;
|
|
2064
|
+
function isOperationTimestampValid(op, now = Date.now()) {
|
|
2065
|
+
return op.timestamp.wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// src/session/session-operation-limits.ts
|
|
2069
|
+
var DEFAULT_MAX_OPERATION_BYTES = 256 * 1024;
|
|
2070
|
+
var DEFAULT_MAX_OPS_PER_MINUTE = 600;
|
|
2071
|
+
function measureOperationBytes(op) {
|
|
2072
|
+
return Buffer.byteLength(JSON.stringify(op), "utf8");
|
|
2073
|
+
}
|
|
2074
|
+
function validateOperationSize(op, maxBytes = DEFAULT_MAX_OPERATION_BYTES) {
|
|
2075
|
+
const bytes = measureOperationBytes(op);
|
|
2076
|
+
if (bytes <= maxBytes) {
|
|
2077
|
+
return { valid: true, bytes };
|
|
2078
|
+
}
|
|
2079
|
+
return {
|
|
2080
|
+
valid: false,
|
|
2081
|
+
bytes,
|
|
2082
|
+
message: `Operation "${op.id}" exceeds max size (${String(bytes)} > ${String(maxBytes)} bytes)`
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
var SessionRateLimiter = class {
|
|
2086
|
+
constructor(maxOpsPerMinute = DEFAULT_MAX_OPS_PER_MINUTE) {
|
|
2087
|
+
this.maxOpsPerMinute = maxOpsPerMinute;
|
|
2088
|
+
}
|
|
2089
|
+
maxOpsPerMinute;
|
|
2090
|
+
windowStartMs = Date.now();
|
|
2091
|
+
count = 0;
|
|
2092
|
+
get limit() {
|
|
2093
|
+
return this.maxOpsPerMinute;
|
|
2094
|
+
}
|
|
2095
|
+
/** Record N operations and return false when the limit is exceeded. */
|
|
2096
|
+
allow(count3 = 1) {
|
|
2097
|
+
const now = Date.now();
|
|
2098
|
+
if (now - this.windowStartMs >= 6e4) {
|
|
2099
|
+
this.windowStartMs = now;
|
|
2100
|
+
this.count = 0;
|
|
2101
|
+
}
|
|
2102
|
+
this.count += count3;
|
|
2103
|
+
return this.count <= this.maxOpsPerMinute;
|
|
2104
|
+
}
|
|
2105
|
+
reset() {
|
|
2106
|
+
this.windowStartMs = Date.now();
|
|
2107
|
+
this.count = 0;
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
|
|
1681
2111
|
// src/session/client-session.ts
|
|
1682
2112
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1683
2113
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
@@ -1685,6 +2115,8 @@ var ClientSession = class {
|
|
|
1685
2115
|
state = "connected";
|
|
1686
2116
|
clientNodeId = null;
|
|
1687
2117
|
authContext = null;
|
|
2118
|
+
syncQuerySubsets = [];
|
|
2119
|
+
resumeDeltaCursor = null;
|
|
1688
2120
|
sessionId;
|
|
1689
2121
|
transport;
|
|
1690
2122
|
store;
|
|
@@ -1693,8 +2125,14 @@ var ClientSession = class {
|
|
|
1693
2125
|
emitter;
|
|
1694
2126
|
batchSize;
|
|
1695
2127
|
schemaVersion;
|
|
2128
|
+
supportedSchemaVersions;
|
|
1696
2129
|
onRelay;
|
|
2130
|
+
onAwarenessUpdate;
|
|
2131
|
+
onYjsDocUpdate;
|
|
1697
2132
|
onClose;
|
|
2133
|
+
maxOperationBytes;
|
|
2134
|
+
maxOpsPerMinute;
|
|
2135
|
+
rateLimiter;
|
|
1698
2136
|
constructor(options) {
|
|
1699
2137
|
this.sessionId = options.sessionId;
|
|
1700
2138
|
this.transport = options.transport;
|
|
@@ -1704,14 +2142,24 @@ var ClientSession = class {
|
|
|
1704
2142
|
this.emitter = options.emitter ?? null;
|
|
1705
2143
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1706
2144
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2145
|
+
const supported = options.supportedSchemaVersions;
|
|
2146
|
+
this.supportedSchemaVersions = supported ?? {
|
|
2147
|
+
min: this.schemaVersion,
|
|
2148
|
+
max: this.schemaVersion
|
|
2149
|
+
};
|
|
1707
2150
|
this.onRelay = options.onRelay ?? null;
|
|
2151
|
+
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2152
|
+
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
1708
2153
|
this.onClose = options.onClose ?? null;
|
|
2154
|
+
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2155
|
+
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2156
|
+
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
1709
2157
|
}
|
|
1710
2158
|
/**
|
|
1711
2159
|
* Start handling messages from the client transport.
|
|
1712
2160
|
*/
|
|
1713
2161
|
start() {
|
|
1714
|
-
this.transport.onMessage((msg) => this.
|
|
2162
|
+
this.transport.onMessage((msg) => this.enqueueMessage(msg));
|
|
1715
2163
|
this.transport.onClose((_code, _reason) => this.handleTransportClose());
|
|
1716
2164
|
this.transport.onError((_err) => {
|
|
1717
2165
|
if (this.state !== "closed") {
|
|
@@ -1726,9 +2174,7 @@ var ClientSession = class {
|
|
|
1726
2174
|
relayOperations(operations2) {
|
|
1727
2175
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
1728
2176
|
if (operations2.length === 0) return;
|
|
1729
|
-
const visibleOperations = operations2.filter(
|
|
1730
|
-
(op) => operationMatchesScopes(op, this.authContext?.scopes)
|
|
1731
|
-
);
|
|
2177
|
+
const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
|
|
1732
2178
|
if (visibleOperations.length === 0) return;
|
|
1733
2179
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
1734
2180
|
const msg = {
|
|
@@ -1738,7 +2184,7 @@ var ClientSession = class {
|
|
|
1738
2184
|
isFinal: true,
|
|
1739
2185
|
batchIndex: 0
|
|
1740
2186
|
};
|
|
1741
|
-
this.
|
|
2187
|
+
this.sendToClient(msg);
|
|
1742
2188
|
}
|
|
1743
2189
|
/**
|
|
1744
2190
|
* Close this session.
|
|
@@ -1767,22 +2213,55 @@ var ClientSession = class {
|
|
|
1767
2213
|
isStreaming() {
|
|
1768
2214
|
return this.state === "streaming";
|
|
1769
2215
|
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Get the transport for this session.
|
|
2218
|
+
* Used by the awareness relay to send messages to this client.
|
|
2219
|
+
*/
|
|
2220
|
+
getTransport() {
|
|
2221
|
+
return this.transport;
|
|
2222
|
+
}
|
|
1770
2223
|
// --- Private protocol handlers ---
|
|
1771
|
-
|
|
2224
|
+
messageChain = Promise.resolve();
|
|
2225
|
+
/** Send to the client when the transport is still connected; no-op otherwise. */
|
|
2226
|
+
sendToClient(message) {
|
|
2227
|
+
if (!this.transport.isConnected()) {
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
try {
|
|
2231
|
+
this.transport.send(message);
|
|
2232
|
+
return true;
|
|
2233
|
+
} catch {
|
|
2234
|
+
return false;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
enqueueMessage(message) {
|
|
2238
|
+
this.messageChain = this.messageChain.then(() => this.handleMessageAsync(message)).catch((error) => this.handleMessageFailure(error));
|
|
2239
|
+
}
|
|
2240
|
+
async handleMessageAsync(message) {
|
|
1772
2241
|
switch (message.type) {
|
|
1773
2242
|
case "handshake":
|
|
1774
|
-
this.handleHandshake(message);
|
|
2243
|
+
await this.handleHandshake(message);
|
|
1775
2244
|
break;
|
|
1776
2245
|
case "operation-batch":
|
|
1777
|
-
this.handleOperationBatch(message);
|
|
2246
|
+
await this.handleOperationBatch(message);
|
|
1778
2247
|
break;
|
|
1779
|
-
// Acknowledgments from clients are noted but no action needed on server
|
|
1780
2248
|
case "acknowledgment":
|
|
1781
2249
|
break;
|
|
1782
2250
|
case "error":
|
|
1783
2251
|
break;
|
|
2252
|
+
case "awareness-update":
|
|
2253
|
+
this.handleAwarenessUpdate(message);
|
|
2254
|
+
break;
|
|
2255
|
+
case "yjs-doc-update":
|
|
2256
|
+
this.handleYjsDocUpdate(message);
|
|
2257
|
+
break;
|
|
1784
2258
|
}
|
|
1785
2259
|
}
|
|
2260
|
+
handleMessageFailure(error) {
|
|
2261
|
+
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2262
|
+
this.sendError("SYNC_ERROR", reason, true);
|
|
2263
|
+
this.close(reason);
|
|
2264
|
+
}
|
|
1786
2265
|
async handleHandshake(msg) {
|
|
1787
2266
|
if (this.state !== "connected") {
|
|
1788
2267
|
this.sendError("DUPLICATE_HANDSHAKE", "Handshake already completed", false);
|
|
@@ -1800,9 +2279,43 @@ var ClientSession = class {
|
|
|
1800
2279
|
this.authContext = context;
|
|
1801
2280
|
this.state = "authenticated";
|
|
1802
2281
|
}
|
|
2282
|
+
const resolvedScopes = resolveSessionScopes(this.store.getSchema(), {
|
|
2283
|
+
handshakeScope: msg.syncScope,
|
|
2284
|
+
authScopes: this.authContext?.scopes
|
|
2285
|
+
});
|
|
2286
|
+
if (resolvedScopes) {
|
|
2287
|
+
if (this.authContext) {
|
|
2288
|
+
this.authContext = { ...this.authContext, scopes: resolvedScopes };
|
|
2289
|
+
} else {
|
|
2290
|
+
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2294
|
+
this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
|
|
2295
|
+
} else {
|
|
2296
|
+
this.syncQuerySubsets = [];
|
|
2297
|
+
}
|
|
2298
|
+
this.resumeDeltaCursor = msg.deltaCursor ? decodeDeltaCursor(msg.deltaCursor) : null;
|
|
1803
2299
|
const serverVector = this.store.getVersionVector();
|
|
1804
2300
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
1805
2301
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2302
|
+
if (!isClientSchemaVersionSupported(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2303
|
+
const { min, max } = this.supportedSchemaVersions;
|
|
2304
|
+
const response2 = {
|
|
2305
|
+
type: "handshake-response",
|
|
2306
|
+
messageId: generateUUIDv74(),
|
|
2307
|
+
nodeId: this.store.getNodeId(),
|
|
2308
|
+
versionVector: versionVectorToWire(serverVector),
|
|
2309
|
+
schemaVersion: this.schemaVersion,
|
|
2310
|
+
accepted: false,
|
|
2311
|
+
rejectReason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
|
|
2312
|
+
supportedSchemaMin: min,
|
|
2313
|
+
supportedSchemaMax: max
|
|
2314
|
+
};
|
|
2315
|
+
this.sendToClient(response2);
|
|
2316
|
+
this.close("schema version mismatch");
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
1806
2319
|
const response = {
|
|
1807
2320
|
type: "handshake-response",
|
|
1808
2321
|
messageId: generateUUIDv74(),
|
|
@@ -1810,9 +2323,12 @@ var ClientSession = class {
|
|
|
1810
2323
|
versionVector: versionVectorToWire(serverVector),
|
|
1811
2324
|
schemaVersion: this.schemaVersion,
|
|
1812
2325
|
accepted: true,
|
|
1813
|
-
selectedWireFormat
|
|
2326
|
+
selectedWireFormat,
|
|
2327
|
+
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2328
|
+
// This may differ from what the client requested if auth scopes are narrower.
|
|
2329
|
+
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
1814
2330
|
};
|
|
1815
|
-
this.
|
|
2331
|
+
this.sendToClient(response);
|
|
1816
2332
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
1817
2333
|
this.state = "syncing";
|
|
1818
2334
|
const clientVector = wireToVersionVector(msg.versionVector);
|
|
@@ -1822,13 +2338,56 @@ var ClientSession = class {
|
|
|
1822
2338
|
async handleOperationBatch(msg) {
|
|
1823
2339
|
const operations2 = msg.operations.map((s) => this.serializer.decodeOperation(s));
|
|
1824
2340
|
const applied = [];
|
|
2341
|
+
const rejected = [];
|
|
1825
2342
|
for (const op of operations2) {
|
|
1826
|
-
if (!
|
|
2343
|
+
if (!this.operationVisibleToClient(op)) {
|
|
2344
|
+
rejected.push(op);
|
|
1827
2345
|
continue;
|
|
1828
2346
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2347
|
+
if (!isOperationTimestampValid(op)) {
|
|
2348
|
+
this.sendError(
|
|
2349
|
+
"INVALID_TIMESTAMP",
|
|
2350
|
+
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2351
|
+
false
|
|
2352
|
+
);
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (!this.rateLimiter.allow(1)) {
|
|
2356
|
+
this.sendError(
|
|
2357
|
+
"RATE_LIMIT",
|
|
2358
|
+
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2359
|
+
true
|
|
2360
|
+
);
|
|
2361
|
+
continue;
|
|
2362
|
+
}
|
|
2363
|
+
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
2364
|
+
if (!sizeCheck.valid) {
|
|
2365
|
+
this.sendError(
|
|
2366
|
+
"OPERATION_TOO_LARGE",
|
|
2367
|
+
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2368
|
+
false
|
|
2369
|
+
);
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
try {
|
|
2373
|
+
const applyResult = await applyServerOperation(this.store, op);
|
|
2374
|
+
if (applyResult.rejection) {
|
|
2375
|
+
this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
if (applyResult.result === "applied") {
|
|
2379
|
+
applied.push(...applyResult.appliedOperations);
|
|
2380
|
+
}
|
|
2381
|
+
} catch {
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
if (rejected.length > 0) {
|
|
2385
|
+
for (const op of rejected) {
|
|
2386
|
+
this.sendError(
|
|
2387
|
+
"SCOPE_VIOLATION",
|
|
2388
|
+
`Operation "${op.id}" in collection "${op.collection}" is outside the client's sync scope`,
|
|
2389
|
+
false
|
|
2390
|
+
);
|
|
1832
2391
|
}
|
|
1833
2392
|
}
|
|
1834
2393
|
if (operations2.length > 0) {
|
|
@@ -1845,7 +2404,7 @@ var ClientSession = class {
|
|
|
1845
2404
|
acknowledgedMessageId: msg.messageId,
|
|
1846
2405
|
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
1847
2406
|
};
|
|
1848
|
-
this.
|
|
2407
|
+
this.sendToClient(ack);
|
|
1849
2408
|
if (applied.length > 0) {
|
|
1850
2409
|
this.onRelay?.(this.sessionId, applied);
|
|
1851
2410
|
}
|
|
@@ -1857,7 +2416,7 @@ var ClientSession = class {
|
|
|
1857
2416
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
1858
2417
|
if (serverSeq > clientSeq) {
|
|
1859
2418
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
1860
|
-
const visible = ops.filter((op) =>
|
|
2419
|
+
const visible = ops.filter((op) => this.operationVisibleToClient(op));
|
|
1861
2420
|
missing.push(...visible);
|
|
1862
2421
|
}
|
|
1863
2422
|
}
|
|
@@ -1867,25 +2426,42 @@ var ClientSession = class {
|
|
|
1867
2426
|
messageId: generateUUIDv74(),
|
|
1868
2427
|
operations: [],
|
|
1869
2428
|
isFinal: true,
|
|
1870
|
-
batchIndex: 0
|
|
2429
|
+
batchIndex: 0,
|
|
2430
|
+
totalBatches: 1
|
|
1871
2431
|
};
|
|
1872
|
-
this.
|
|
2432
|
+
this.sendToClient(emptyBatch);
|
|
1873
2433
|
return;
|
|
1874
2434
|
}
|
|
1875
2435
|
const sorted = topologicalSort(missing);
|
|
1876
|
-
const
|
|
2436
|
+
const afterCursor = sliceOperationsAfterCursor(sorted, this.resumeDeltaCursor);
|
|
2437
|
+
const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
|
|
2438
|
+
if (afterCursor.length === 0) {
|
|
2439
|
+
const emptyBatch = {
|
|
2440
|
+
type: "operation-batch",
|
|
2441
|
+
messageId: generateUUIDv74(),
|
|
2442
|
+
operations: [],
|
|
2443
|
+
isFinal: true,
|
|
2444
|
+
batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
|
|
2445
|
+
totalBatches: 1
|
|
2446
|
+
};
|
|
2447
|
+
this.sendToClient(emptyBatch);
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
1877
2450
|
for (let i = 0; i < totalBatches; i++) {
|
|
1878
2451
|
const start = i * this.batchSize;
|
|
1879
|
-
const batchOps =
|
|
2452
|
+
const batchOps = afterCursor.slice(start, start + this.batchSize);
|
|
1880
2453
|
const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
|
|
2454
|
+
const batchCursor = createDeltaCursorFromBatch(batchOps, i);
|
|
1881
2455
|
const batchMsg = {
|
|
1882
2456
|
type: "operation-batch",
|
|
1883
2457
|
messageId: generateUUIDv74(),
|
|
1884
2458
|
operations: serializedOps,
|
|
1885
2459
|
isFinal: i === totalBatches - 1,
|
|
1886
|
-
batchIndex: i
|
|
2460
|
+
batchIndex: i,
|
|
2461
|
+
totalBatches,
|
|
2462
|
+
...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
|
|
1887
2463
|
};
|
|
1888
|
-
this.
|
|
2464
|
+
this.sendToClient(batchMsg);
|
|
1889
2465
|
this.emitter?.emit({
|
|
1890
2466
|
type: "sync:sent",
|
|
1891
2467
|
operations: batchOps,
|
|
@@ -1893,6 +2469,18 @@ var ClientSession = class {
|
|
|
1893
2469
|
});
|
|
1894
2470
|
}
|
|
1895
2471
|
}
|
|
2472
|
+
operationVisibleToClient(op) {
|
|
2473
|
+
if (!operationMatchesScopes(op, this.authContext?.scopes)) {
|
|
2474
|
+
return false;
|
|
2475
|
+
}
|
|
2476
|
+
return operationMatchesQuerySubsets(op, this.syncQuerySubsets);
|
|
2477
|
+
}
|
|
2478
|
+
handleAwarenessUpdate(msg) {
|
|
2479
|
+
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
2480
|
+
}
|
|
2481
|
+
handleYjsDocUpdate(msg) {
|
|
2482
|
+
this.onYjsDocUpdate?.(this.sessionId, msg);
|
|
2483
|
+
}
|
|
1896
2484
|
sendError(code, message, retriable) {
|
|
1897
2485
|
const errorMsg = {
|
|
1898
2486
|
type: "error",
|
|
@@ -1901,7 +2489,7 @@ var ClientSession = class {
|
|
|
1901
2489
|
message,
|
|
1902
2490
|
retriable
|
|
1903
2491
|
};
|
|
1904
|
-
this.
|
|
2492
|
+
this.sendToClient(errorMsg);
|
|
1905
2493
|
}
|
|
1906
2494
|
setSerializerWireFormat(format) {
|
|
1907
2495
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
@@ -1923,8 +2511,258 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
1923
2511
|
}
|
|
1924
2512
|
|
|
1925
2513
|
// src/server/kora-sync-server.ts
|
|
1926
|
-
import { SyncError as SyncError4, generateUUIDv7 as
|
|
2514
|
+
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
|
|
2515
|
+
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
1927
2516
|
import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
|
|
2517
|
+
|
|
2518
|
+
// src/awareness/awareness-relay.ts
|
|
2519
|
+
import { generateUUIDv7 as generateUUIDv75 } from "@korajs/core";
|
|
2520
|
+
var AwarenessRelay = class {
|
|
2521
|
+
clients = /* @__PURE__ */ new Map();
|
|
2522
|
+
/**
|
|
2523
|
+
* Register a client for awareness broadcasting.
|
|
2524
|
+
*
|
|
2525
|
+
* @param sessionId - Unique session identifier
|
|
2526
|
+
* @param clientId - Client-assigned awareness ID
|
|
2527
|
+
* @param transport - Transport for sending messages to this client
|
|
2528
|
+
*/
|
|
2529
|
+
addClient(sessionId, clientId, transport) {
|
|
2530
|
+
this.clients.set(sessionId, {
|
|
2531
|
+
sessionId,
|
|
2532
|
+
clientId,
|
|
2533
|
+
transport,
|
|
2534
|
+
state: null
|
|
2535
|
+
});
|
|
2536
|
+
const existingStates = {};
|
|
2537
|
+
let hasStates = false;
|
|
2538
|
+
for (const [, client] of this.clients) {
|
|
2539
|
+
if (client.sessionId === sessionId) continue;
|
|
2540
|
+
if (client.state) {
|
|
2541
|
+
existingStates[String(client.clientId)] = client.state;
|
|
2542
|
+
hasStates = true;
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
if (hasStates) {
|
|
2546
|
+
const catchUpMsg = {
|
|
2547
|
+
type: "awareness-update",
|
|
2548
|
+
messageId: generateUUIDv75(),
|
|
2549
|
+
clientId: 0,
|
|
2550
|
+
// Server-sourced
|
|
2551
|
+
states: existingStates
|
|
2552
|
+
};
|
|
2553
|
+
transport.send(catchUpMsg);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Remove a client and broadcast its removal to all remaining clients.
|
|
2558
|
+
*
|
|
2559
|
+
* @param sessionId - Session ID of the disconnecting client
|
|
2560
|
+
*/
|
|
2561
|
+
removeClient(sessionId) {
|
|
2562
|
+
const client = this.clients.get(sessionId);
|
|
2563
|
+
if (!client) return;
|
|
2564
|
+
this.clients.delete(sessionId);
|
|
2565
|
+
if (client.state === null) return;
|
|
2566
|
+
const removalStates = {
|
|
2567
|
+
[String(client.clientId)]: null
|
|
2568
|
+
};
|
|
2569
|
+
const msg = {
|
|
2570
|
+
type: "awareness-update",
|
|
2571
|
+
messageId: generateUUIDv75(),
|
|
2572
|
+
clientId: client.clientId,
|
|
2573
|
+
states: removalStates
|
|
2574
|
+
};
|
|
2575
|
+
this.broadcastExcept(sessionId, msg);
|
|
2576
|
+
}
|
|
2577
|
+
/**
|
|
2578
|
+
* Handle an incoming awareness update from a client.
|
|
2579
|
+
* Stores the state and relays to all other connected clients.
|
|
2580
|
+
*
|
|
2581
|
+
* @param sessionId - Session ID of the sending client
|
|
2582
|
+
* @param message - The awareness update message
|
|
2583
|
+
*/
|
|
2584
|
+
handleUpdate(sessionId, message) {
|
|
2585
|
+
const sender = this.clients.get(sessionId);
|
|
2586
|
+
if (!sender) return;
|
|
2587
|
+
const senderState = message.states[String(message.clientId)];
|
|
2588
|
+
if (senderState !== void 0) {
|
|
2589
|
+
sender.state = senderState;
|
|
2590
|
+
}
|
|
2591
|
+
this.broadcastExcept(sessionId, message);
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Get the number of registered awareness clients.
|
|
2595
|
+
*/
|
|
2596
|
+
getClientCount() {
|
|
2597
|
+
return this.clients.size;
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Remove all clients and clear all state.
|
|
2601
|
+
*/
|
|
2602
|
+
clear() {
|
|
2603
|
+
this.clients.clear();
|
|
2604
|
+
}
|
|
2605
|
+
// --- Private ---
|
|
2606
|
+
broadcastExcept(excludeSessionId, message) {
|
|
2607
|
+
for (const [, client] of this.clients) {
|
|
2608
|
+
if (client.sessionId === excludeSessionId) continue;
|
|
2609
|
+
if (!client.transport.isConnected()) continue;
|
|
2610
|
+
client.transport.send(message);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
// src/diagnostics/server-metrics-collector.ts
|
|
2616
|
+
function estimateByteSize(operations2) {
|
|
2617
|
+
let total = 0;
|
|
2618
|
+
for (const op of operations2) {
|
|
2619
|
+
total += JSON.stringify(op).length;
|
|
2620
|
+
}
|
|
2621
|
+
return total;
|
|
2622
|
+
}
|
|
2623
|
+
var ServerMetricsCollector = class {
|
|
2624
|
+
startedAt = Date.now();
|
|
2625
|
+
peakConnections = 0;
|
|
2626
|
+
connectionsTotal = 0;
|
|
2627
|
+
operationsReceived = 0;
|
|
2628
|
+
operationsSent = 0;
|
|
2629
|
+
bytesReceived = 0;
|
|
2630
|
+
bytesSent = 0;
|
|
2631
|
+
errorCount = 0;
|
|
2632
|
+
schemaVersion = 1;
|
|
2633
|
+
clientMetrics = /* @__PURE__ */ new Map();
|
|
2634
|
+
/** Record a new client connection. */
|
|
2635
|
+
recordConnection(sessionId) {
|
|
2636
|
+
this.connectionsTotal++;
|
|
2637
|
+
this.clientMetrics.set(sessionId, {
|
|
2638
|
+
sessionId,
|
|
2639
|
+
nodeId: null,
|
|
2640
|
+
state: "connected",
|
|
2641
|
+
connectedAt: Date.now(),
|
|
2642
|
+
operationsReceived: 0,
|
|
2643
|
+
operationsSent: 0,
|
|
2644
|
+
authContext: null
|
|
2645
|
+
});
|
|
2646
|
+
this.peakConnections = Math.max(this.peakConnections, this.clientMetrics.size);
|
|
2647
|
+
}
|
|
2648
|
+
/** Record a client disconnection. */
|
|
2649
|
+
recordDisconnection(sessionId) {
|
|
2650
|
+
this.clientMetrics.delete(sessionId);
|
|
2651
|
+
}
|
|
2652
|
+
/** Update the node ID after a handshake completes. */
|
|
2653
|
+
recordHandshake(sessionId, nodeId) {
|
|
2654
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2655
|
+
if (client) {
|
|
2656
|
+
client.nodeId = nodeId;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
/** Update session state. */
|
|
2660
|
+
updateSessionState(sessionId, state) {
|
|
2661
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2662
|
+
if (client) {
|
|
2663
|
+
client.state = state;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
/** Record authentication context for a session. */
|
|
2667
|
+
recordAuth(sessionId, authContext) {
|
|
2668
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2669
|
+
if (client) {
|
|
2670
|
+
client.authContext = authContext;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/** Record operations received from a client. */
|
|
2674
|
+
recordReceived(sessionId, count3, byteSize) {
|
|
2675
|
+
this.operationsReceived += count3;
|
|
2676
|
+
this.bytesReceived += byteSize;
|
|
2677
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2678
|
+
if (client) {
|
|
2679
|
+
client.operationsReceived += count3;
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
/** Record operations sent to a client. */
|
|
2683
|
+
recordSent(sessionId, count3, byteSize) {
|
|
2684
|
+
this.operationsSent += count3;
|
|
2685
|
+
this.bytesSent += byteSize;
|
|
2686
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2687
|
+
if (client) {
|
|
2688
|
+
client.operationsSent += count3;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
/** Record an error. */
|
|
2692
|
+
recordError() {
|
|
2693
|
+
this.errorCount++;
|
|
2694
|
+
}
|
|
2695
|
+
/** Set the schema version. */
|
|
2696
|
+
setSchemaVersion(version) {
|
|
2697
|
+
this.schemaVersion = version;
|
|
2698
|
+
}
|
|
2699
|
+
/** Return a full snapshot of current metrics. */
|
|
2700
|
+
getSnapshot(totalOperations) {
|
|
2701
|
+
return {
|
|
2702
|
+
connectedClients: this.clientMetrics.size,
|
|
2703
|
+
connectedNodeIds: Array.from(this.clientMetrics.values()).filter((c) => c.nodeId !== null).map((c) => c.nodeId),
|
|
2704
|
+
peakConnections: this.peakConnections,
|
|
2705
|
+
connectionsTotal: this.connectionsTotal,
|
|
2706
|
+
operationsReceived: this.operationsReceived,
|
|
2707
|
+
operationsSent: this.operationsSent,
|
|
2708
|
+
bytesReceived: this.bytesReceived,
|
|
2709
|
+
bytesSent: this.bytesSent,
|
|
2710
|
+
clients: Array.from(this.clientMetrics.values()),
|
|
2711
|
+
uptime: Date.now() - this.startedAt,
|
|
2712
|
+
totalOperations,
|
|
2713
|
+
errorCount: this.errorCount,
|
|
2714
|
+
schemaVersion: this.schemaVersion
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
/** Reset all metrics. */
|
|
2718
|
+
reset() {
|
|
2719
|
+
this.startedAt = Date.now();
|
|
2720
|
+
this.peakConnections = 0;
|
|
2721
|
+
this.connectionsTotal = 0;
|
|
2722
|
+
this.operationsReceived = 0;
|
|
2723
|
+
this.operationsSent = 0;
|
|
2724
|
+
this.bytesReceived = 0;
|
|
2725
|
+
this.bytesSent = 0;
|
|
2726
|
+
this.errorCount = 0;
|
|
2727
|
+
this.clientMetrics.clear();
|
|
2728
|
+
}
|
|
2729
|
+
};
|
|
2730
|
+
|
|
2731
|
+
// src/richtext/yjs-doc-relay.ts
|
|
2732
|
+
var YjsDocRelay = class {
|
|
2733
|
+
clients = /* @__PURE__ */ new Map();
|
|
2734
|
+
addClient(sessionId, transport) {
|
|
2735
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
2736
|
+
}
|
|
2737
|
+
removeClient(sessionId) {
|
|
2738
|
+
this.clients.delete(sessionId);
|
|
2739
|
+
}
|
|
2740
|
+
handleUpdate(sourceSessionId, message) {
|
|
2741
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
this.broadcastExcept(sourceSessionId, message);
|
|
2745
|
+
}
|
|
2746
|
+
getClientCount() {
|
|
2747
|
+
return this.clients.size;
|
|
2748
|
+
}
|
|
2749
|
+
clear() {
|
|
2750
|
+
this.clients.clear();
|
|
2751
|
+
}
|
|
2752
|
+
broadcastExcept(excludeSessionId, message) {
|
|
2753
|
+
for (const [, client] of this.clients) {
|
|
2754
|
+
if (client.sessionId === excludeSessionId) {
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
if (!client.transport.isConnected()) {
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
client.transport.send(message);
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
// src/server/kora-sync-server.ts
|
|
1928
2766
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
1929
2767
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
1930
2768
|
var DEFAULT_SCHEMA_VERSION2 = 1;
|
|
@@ -1938,12 +2776,18 @@ var KoraSyncServer = class {
|
|
|
1938
2776
|
maxConnections;
|
|
1939
2777
|
batchSize;
|
|
1940
2778
|
schemaVersion;
|
|
2779
|
+
supportedSchemaVersions;
|
|
1941
2780
|
port;
|
|
1942
2781
|
host;
|
|
1943
2782
|
path;
|
|
2783
|
+
logger;
|
|
2784
|
+
metrics;
|
|
2785
|
+
awarenessRelay = new AwarenessRelay();
|
|
2786
|
+
yjsDocRelay = new YjsDocRelay();
|
|
1944
2787
|
sessions = /* @__PURE__ */ new Map();
|
|
1945
2788
|
httpClients = /* @__PURE__ */ new Map();
|
|
1946
2789
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
2790
|
+
serverVersion = "0.4.0";
|
|
1947
2791
|
wsServer = null;
|
|
1948
2792
|
running = false;
|
|
1949
2793
|
constructor(config) {
|
|
@@ -1954,9 +2798,80 @@ var KoraSyncServer = class {
|
|
|
1954
2798
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
1955
2799
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
1956
2800
|
this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
2801
|
+
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
2802
|
+
min: this.schemaVersion,
|
|
2803
|
+
max: this.schemaVersion
|
|
2804
|
+
};
|
|
1957
2805
|
this.port = config.port;
|
|
1958
2806
|
this.host = config.host ?? DEFAULT_HOST;
|
|
1959
2807
|
this.path = config.path ?? DEFAULT_PATH;
|
|
2808
|
+
this.logger = config.logger ?? createDefaultLogger();
|
|
2809
|
+
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
2810
|
+
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
2811
|
+
if (!this.emitter) {
|
|
2812
|
+
this.emitter = new SimpleEventEmitter();
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Subscribe to session-level events for metrics collection and logging.
|
|
2817
|
+
* Called when a new session is created.
|
|
2818
|
+
*/
|
|
2819
|
+
attachSessionEvents(sessionId, sessionEmitter) {
|
|
2820
|
+
sessionEmitter.on("sync:connected", (event) => {
|
|
2821
|
+
this.metrics.recordHandshake(sessionId, event.nodeId);
|
|
2822
|
+
this.logger.log({
|
|
2823
|
+
timestamp: Date.now(),
|
|
2824
|
+
level: "info",
|
|
2825
|
+
event: "session.handshake",
|
|
2826
|
+
sessionId,
|
|
2827
|
+
nodeId: event.nodeId
|
|
2828
|
+
});
|
|
2829
|
+
});
|
|
2830
|
+
sessionEmitter.on("sync:received", (event) => {
|
|
2831
|
+
const byteSize = estimateByteSize(event.operations);
|
|
2832
|
+
this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
|
|
2833
|
+
this.logger.log({
|
|
2834
|
+
timestamp: Date.now(),
|
|
2835
|
+
level: "info",
|
|
2836
|
+
event: "operations.received",
|
|
2837
|
+
sessionId,
|
|
2838
|
+
count: event.batchSize,
|
|
2839
|
+
bytes: byteSize
|
|
2840
|
+
});
|
|
2841
|
+
});
|
|
2842
|
+
sessionEmitter.on("sync:sent", (event) => {
|
|
2843
|
+
const byteSize = estimateByteSize(event.operations);
|
|
2844
|
+
this.metrics.recordSent(sessionId, event.batchSize, byteSize);
|
|
2845
|
+
this.logger.log({
|
|
2846
|
+
timestamp: Date.now(),
|
|
2847
|
+
level: "info",
|
|
2848
|
+
event: "operations.sent",
|
|
2849
|
+
sessionId,
|
|
2850
|
+
count: event.batchSize,
|
|
2851
|
+
bytes: byteSize
|
|
2852
|
+
});
|
|
2853
|
+
});
|
|
2854
|
+
sessionEmitter.on("sync:disconnected", (event) => {
|
|
2855
|
+
this.logger.log({
|
|
2856
|
+
timestamp: Date.now(),
|
|
2857
|
+
level: "info",
|
|
2858
|
+
event: "session.disconnected",
|
|
2859
|
+
sessionId,
|
|
2860
|
+
details: { reason: event.reason }
|
|
2861
|
+
});
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
2866
|
+
*/
|
|
2867
|
+
getMetricsCollector() {
|
|
2868
|
+
return this.metrics;
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Get the logger for external access (e.g., event streaming).
|
|
2872
|
+
*/
|
|
2873
|
+
getLogger() {
|
|
2874
|
+
return this.logger;
|
|
1960
2875
|
}
|
|
1961
2876
|
/**
|
|
1962
2877
|
* Start the WebSocket server in standalone mode.
|
|
@@ -1997,11 +2912,25 @@ var KoraSyncServer = class {
|
|
|
1997
2912
|
this.handleConnection(transport);
|
|
1998
2913
|
});
|
|
1999
2914
|
this.running = true;
|
|
2915
|
+
this.logger.log({
|
|
2916
|
+
timestamp: Date.now(),
|
|
2917
|
+
level: "info",
|
|
2918
|
+
event: "server.started",
|
|
2919
|
+
details: { port: this.port, host: this.host, path: this.path }
|
|
2920
|
+
});
|
|
2000
2921
|
}
|
|
2001
2922
|
/**
|
|
2002
2923
|
* Stop the server. Closes all sessions and the WebSocket server.
|
|
2003
2924
|
*/
|
|
2004
2925
|
async stop() {
|
|
2926
|
+
this.logger.log({
|
|
2927
|
+
timestamp: Date.now(),
|
|
2928
|
+
level: "info",
|
|
2929
|
+
event: "server.stopping",
|
|
2930
|
+
details: { connectedClients: this.sessions.size }
|
|
2931
|
+
});
|
|
2932
|
+
this.awarenessRelay.clear();
|
|
2933
|
+
this.yjsDocRelay.clear();
|
|
2005
2934
|
for (const session of this.sessions.values()) {
|
|
2006
2935
|
session.close("server shutting down");
|
|
2007
2936
|
}
|
|
@@ -2015,6 +2944,11 @@ var KoraSyncServer = class {
|
|
|
2015
2944
|
this.wsServer = null;
|
|
2016
2945
|
}
|
|
2017
2946
|
this.running = false;
|
|
2947
|
+
this.logger.log({
|
|
2948
|
+
timestamp: Date.now(),
|
|
2949
|
+
level: "info",
|
|
2950
|
+
event: "server.stopped"
|
|
2951
|
+
});
|
|
2018
2952
|
}
|
|
2019
2953
|
/**
|
|
2020
2954
|
* Handle one HTTP sync request for a long-polling client.
|
|
@@ -2058,47 +2992,125 @@ var KoraSyncServer = class {
|
|
|
2058
2992
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
2059
2993
|
transport.send({
|
|
2060
2994
|
type: "error",
|
|
2061
|
-
messageId:
|
|
2995
|
+
messageId: generateUUIDv76(),
|
|
2062
2996
|
code: "MAX_CONNECTIONS",
|
|
2063
2997
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
2064
2998
|
retriable: true
|
|
2065
2999
|
});
|
|
2066
3000
|
transport.close(4029, "max connections reached");
|
|
3001
|
+
this.metrics.recordError();
|
|
3002
|
+
this.logger.log({
|
|
3003
|
+
timestamp: Date.now(),
|
|
3004
|
+
level: "warn",
|
|
3005
|
+
event: "connection.rejected",
|
|
3006
|
+
details: { reason: "max_connections", max: this.maxConnections }
|
|
3007
|
+
});
|
|
2067
3008
|
throw new SyncError4("Maximum connections reached", {
|
|
2068
3009
|
current: this.sessions.size,
|
|
2069
3010
|
max: this.maxConnections
|
|
2070
3011
|
});
|
|
2071
3012
|
}
|
|
2072
|
-
const sessionId =
|
|
3013
|
+
const sessionId = generateUUIDv76();
|
|
3014
|
+
this.metrics.recordConnection(sessionId);
|
|
3015
|
+
const sessionEmitter = new SimpleEventEmitter();
|
|
3016
|
+
sessionEmitter.on("sync:connected", (event) => {
|
|
3017
|
+
this.metrics.recordHandshake(sessionId, event.nodeId);
|
|
3018
|
+
this.metrics.updateSessionState(sessionId, "authenticated");
|
|
3019
|
+
this.logger.log({
|
|
3020
|
+
timestamp: Date.now(),
|
|
3021
|
+
level: "info",
|
|
3022
|
+
event: "session.handshake",
|
|
3023
|
+
sessionId,
|
|
3024
|
+
nodeId: event.nodeId
|
|
3025
|
+
});
|
|
3026
|
+
});
|
|
3027
|
+
sessionEmitter.on("sync:received", (event) => {
|
|
3028
|
+
const byteSize = estimateOperationByteSize(event.operations);
|
|
3029
|
+
this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
|
|
3030
|
+
this.logger.log({
|
|
3031
|
+
timestamp: Date.now(),
|
|
3032
|
+
level: "info",
|
|
3033
|
+
event: "operations.received",
|
|
3034
|
+
sessionId,
|
|
3035
|
+
count: event.batchSize,
|
|
3036
|
+
bytes: byteSize
|
|
3037
|
+
});
|
|
3038
|
+
});
|
|
3039
|
+
sessionEmitter.on("sync:sent", (event) => {
|
|
3040
|
+
const byteSize = estimateOperationByteSize(event.operations);
|
|
3041
|
+
this.metrics.recordSent(sessionId, event.batchSize, byteSize);
|
|
3042
|
+
this.logger.log({
|
|
3043
|
+
timestamp: Date.now(),
|
|
3044
|
+
level: "info",
|
|
3045
|
+
event: "operations.sent",
|
|
3046
|
+
sessionId,
|
|
3047
|
+
count: event.batchSize,
|
|
3048
|
+
bytes: byteSize
|
|
3049
|
+
});
|
|
3050
|
+
});
|
|
3051
|
+
sessionEmitter.on("sync:disconnected", () => {
|
|
3052
|
+
this.logger.log({
|
|
3053
|
+
timestamp: Date.now(),
|
|
3054
|
+
level: "info",
|
|
3055
|
+
event: "session.disconnected",
|
|
3056
|
+
sessionId
|
|
3057
|
+
});
|
|
3058
|
+
});
|
|
2073
3059
|
const session = new ClientSession({
|
|
2074
3060
|
sessionId,
|
|
2075
3061
|
transport,
|
|
2076
3062
|
store: this.store,
|
|
2077
3063
|
auth: this.auth ?? void 0,
|
|
2078
3064
|
serializer: this.serializer,
|
|
2079
|
-
emitter:
|
|
3065
|
+
emitter: sessionEmitter,
|
|
2080
3066
|
batchSize: this.batchSize,
|
|
2081
3067
|
schemaVersion: this.schemaVersion,
|
|
3068
|
+
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
2082
3069
|
onRelay: (sourceSessionId, operations2) => {
|
|
2083
3070
|
this.handleRelay(sourceSessionId, operations2);
|
|
2084
3071
|
},
|
|
3072
|
+
onAwarenessUpdate: (sourceSessionId, message) => {
|
|
3073
|
+
this.handleAwarenessRelay(sourceSessionId, message);
|
|
3074
|
+
},
|
|
3075
|
+
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3076
|
+
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3077
|
+
},
|
|
2085
3078
|
onClose: (sid) => {
|
|
2086
3079
|
this.handleSessionClose(sid);
|
|
2087
3080
|
}
|
|
2088
3081
|
});
|
|
2089
3082
|
this.sessions.set(sessionId, session);
|
|
3083
|
+
this.yjsDocRelay.addClient(sessionId, transport);
|
|
2090
3084
|
session.start();
|
|
3085
|
+
this.logger.log({
|
|
3086
|
+
timestamp: Date.now(),
|
|
3087
|
+
level: "info",
|
|
3088
|
+
event: "session.connected",
|
|
3089
|
+
sessionId,
|
|
3090
|
+
details: { totalSessions: this.sessions.size }
|
|
3091
|
+
});
|
|
2091
3092
|
return sessionId;
|
|
2092
3093
|
}
|
|
2093
3094
|
/**
|
|
2094
3095
|
* Get the current server status.
|
|
2095
3096
|
*/
|
|
2096
3097
|
async getStatus() {
|
|
3098
|
+
const totalOps = await this.store.getOperationCount();
|
|
3099
|
+
const snapshot = this.metrics.getSnapshot(totalOps);
|
|
2097
3100
|
return {
|
|
2098
3101
|
running: this.running,
|
|
2099
|
-
connectedClients:
|
|
3102
|
+
connectedClients: snapshot.connectedClients,
|
|
2100
3103
|
port: this.port ?? null,
|
|
2101
|
-
totalOperations:
|
|
3104
|
+
totalOperations: snapshot.totalOperations,
|
|
3105
|
+
uptime: snapshot.uptime,
|
|
3106
|
+
version: this.serverVersion,
|
|
3107
|
+
schemaVersion: this.schemaVersion,
|
|
3108
|
+
connectedNodeIds: snapshot.connectedNodeIds,
|
|
3109
|
+
peakConnections: snapshot.peakConnections,
|
|
3110
|
+
connectionsTotal: snapshot.connectionsTotal,
|
|
3111
|
+
operationsReceived: snapshot.operationsReceived,
|
|
3112
|
+
operationsSent: snapshot.operationsSent,
|
|
3113
|
+
errorCount: snapshot.errorCount
|
|
2102
3114
|
};
|
|
2103
3115
|
}
|
|
2104
3116
|
/**
|
|
@@ -2109,12 +3121,31 @@ var KoraSyncServer = class {
|
|
|
2109
3121
|
}
|
|
2110
3122
|
// --- Private ---
|
|
2111
3123
|
handleRelay(sourceSessionId, operations2) {
|
|
3124
|
+
const targetCount = this.sessions.size - 1;
|
|
3125
|
+
const byteSize = estimateOperationByteSize(operations2);
|
|
3126
|
+
this.metrics.recordSent(
|
|
3127
|
+
sourceSessionId,
|
|
3128
|
+
operations2.length * targetCount,
|
|
3129
|
+
byteSize * targetCount
|
|
3130
|
+
);
|
|
3131
|
+
this.logger.log({
|
|
3132
|
+
timestamp: Date.now(),
|
|
3133
|
+
level: "info",
|
|
3134
|
+
event: "operations.relayed",
|
|
3135
|
+
sessionId: sourceSessionId,
|
|
3136
|
+
count: operations2.length,
|
|
3137
|
+
bytes: byteSize * targetCount,
|
|
3138
|
+
details: { targetSessions: targetCount }
|
|
3139
|
+
});
|
|
2112
3140
|
for (const [sessionId, session] of this.sessions) {
|
|
2113
3141
|
if (sessionId === sourceSessionId) continue;
|
|
2114
3142
|
session.relayOperations(operations2);
|
|
2115
3143
|
}
|
|
2116
3144
|
}
|
|
2117
3145
|
handleSessionClose(sessionId) {
|
|
3146
|
+
this.metrics.recordDisconnection(sessionId);
|
|
3147
|
+
this.awarenessRelay.removeClient(sessionId);
|
|
3148
|
+
this.yjsDocRelay.removeClient(sessionId);
|
|
2118
3149
|
this.sessions.delete(sessionId);
|
|
2119
3150
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
2120
3151
|
if (clientId) {
|
|
@@ -2122,6 +3153,21 @@ var KoraSyncServer = class {
|
|
|
2122
3153
|
this.httpClients.delete(clientId);
|
|
2123
3154
|
}
|
|
2124
3155
|
}
|
|
3156
|
+
handleAwarenessRelay(sourceSessionId, message) {
|
|
3157
|
+
const session = this.sessions.get(sourceSessionId);
|
|
3158
|
+
if (!session) return;
|
|
3159
|
+
const transport = session.getTransport();
|
|
3160
|
+
if (!this.awarenessRelay.getClientCount() || !transport) {
|
|
3161
|
+
}
|
|
3162
|
+
this.awarenessRelay.addClient(sourceSessionId, message.clientId, transport);
|
|
3163
|
+
this.awarenessRelay.handleUpdate(sourceSessionId, message);
|
|
3164
|
+
}
|
|
3165
|
+
handleYjsDocRelay(sourceSessionId, message) {
|
|
3166
|
+
if (!this.sessions.has(sourceSessionId)) {
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
this.yjsDocRelay.handleUpdate(sourceSessionId, message);
|
|
3170
|
+
}
|
|
2125
3171
|
getOrCreateHttpClient(clientId) {
|
|
2126
3172
|
const existing = this.httpClients.get(clientId);
|
|
2127
3173
|
if (existing) {
|
|
@@ -2135,6 +3181,13 @@ var KoraSyncServer = class {
|
|
|
2135
3181
|
return client;
|
|
2136
3182
|
}
|
|
2137
3183
|
};
|
|
3184
|
+
function estimateOperationByteSize(operations2) {
|
|
3185
|
+
let total = 0;
|
|
3186
|
+
for (const op of operations2) {
|
|
3187
|
+
total += JSON.stringify(op).length;
|
|
3188
|
+
}
|
|
3189
|
+
return total;
|
|
3190
|
+
}
|
|
2138
3191
|
function normalizeHttpBody(body, contentType) {
|
|
2139
3192
|
if (body instanceof Uint8Array) {
|
|
2140
3193
|
return body;
|
|
@@ -2176,7 +3229,7 @@ var KoraAuthProvider = class {
|
|
|
2176
3229
|
this.resolveScopes = options.resolveScopes;
|
|
2177
3230
|
}
|
|
2178
3231
|
async authenticate(token) {
|
|
2179
|
-
const payload = this.tokenValidator.validateToken(token);
|
|
3232
|
+
const payload = this.tokenValidator.validateTokenWithRevocation ? await this.tokenValidator.validateTokenWithRevocation(token) : this.tokenValidator.validateToken(token);
|
|
2180
3233
|
if (payload === null) {
|
|
2181
3234
|
return null;
|
|
2182
3235
|
}
|
|
@@ -2203,6 +3256,30 @@ var KoraAuthProvider = class {
|
|
|
2203
3256
|
}
|
|
2204
3257
|
};
|
|
2205
3258
|
|
|
3259
|
+
// src/auth/mixed-auth-provider.ts
|
|
3260
|
+
var MixedAuthProvider = class {
|
|
3261
|
+
primary;
|
|
3262
|
+
anonymousScopes;
|
|
3263
|
+
anonymousPrefix;
|
|
3264
|
+
anonymousCounter = 0;
|
|
3265
|
+
constructor(options) {
|
|
3266
|
+
this.primary = options.primary;
|
|
3267
|
+
this.anonymousScopes = options.anonymousScopes;
|
|
3268
|
+
this.anonymousPrefix = options.anonymousPrefix ?? "anon";
|
|
3269
|
+
}
|
|
3270
|
+
async authenticate(token) {
|
|
3271
|
+
if (token) {
|
|
3272
|
+
const ctx = await this.primary.authenticate(token);
|
|
3273
|
+
if (ctx) return ctx;
|
|
3274
|
+
}
|
|
3275
|
+
this.anonymousCounter++;
|
|
3276
|
+
return {
|
|
3277
|
+
userId: `${this.anonymousPrefix}-${Date.now()}-${this.anonymousCounter}`,
|
|
3278
|
+
scopes: this.anonymousScopes
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
|
|
2206
3283
|
// src/server/create-server.ts
|
|
2207
3284
|
function createKoraServer(config) {
|
|
2208
3285
|
return new KoraSyncServer(config);
|
|
@@ -2229,9 +3306,143 @@ function createProductionServer(config) {
|
|
|
2229
3306
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
2230
3307
|
const syncServer = new KoraSyncServer({
|
|
2231
3308
|
store: config.store,
|
|
3309
|
+
enableDashboard: true,
|
|
2232
3310
|
...config.syncOptions
|
|
2233
3311
|
});
|
|
2234
3312
|
let httpServer = null;
|
|
3313
|
+
function getOperationalToken(kind) {
|
|
3314
|
+
const auth = config.operationalAuth;
|
|
3315
|
+
if (!auth) return void 0;
|
|
3316
|
+
if (kind === "metrics") return auth.metricsToken || auth.adminToken;
|
|
3317
|
+
if (kind === "backup") return auth.backupToken || auth.adminToken;
|
|
3318
|
+
return auth.adminToken;
|
|
3319
|
+
}
|
|
3320
|
+
function extractRequestToken(req) {
|
|
3321
|
+
const authorization = req.headers.authorization;
|
|
3322
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
3323
|
+
return authorization.slice("Bearer ".length).trim();
|
|
3324
|
+
}
|
|
3325
|
+
const headerNames = ["x-kora-admin-token", "x-kora-metrics-token", "x-kora-backup-token"];
|
|
3326
|
+
for (const name of headerNames) {
|
|
3327
|
+
const value = req.headers[name];
|
|
3328
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3329
|
+
if (Array.isArray(value) && typeof value[0] === "string" && value[0].length > 0) {
|
|
3330
|
+
return value[0];
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
return null;
|
|
3334
|
+
}
|
|
3335
|
+
function isOperationalRequestAllowed(req, kind) {
|
|
3336
|
+
const expected = getOperationalToken(kind);
|
|
3337
|
+
if (!expected) return true;
|
|
3338
|
+
return extractRequestToken(req) === expected;
|
|
3339
|
+
}
|
|
3340
|
+
function rejectUnauthorized(res) {
|
|
3341
|
+
res.writeHead(401, {
|
|
3342
|
+
"Content-Type": "application/json",
|
|
3343
|
+
"WWW-Authenticate": 'Bearer realm="kora"'
|
|
3344
|
+
});
|
|
3345
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
3346
|
+
}
|
|
3347
|
+
function formatPrometheusMetrics() {
|
|
3348
|
+
const status = syncServer.getMetricsCollector().getSnapshot(0);
|
|
3349
|
+
const lines = [
|
|
3350
|
+
"# HELP kora_connected_clients Current number of connected clients",
|
|
3351
|
+
"# TYPE kora_connected_clients gauge",
|
|
3352
|
+
`kora_connected_clients ${status.connectedClients}`,
|
|
3353
|
+
"",
|
|
3354
|
+
"# HELP kora_peak_connections Peak number of simultaneous connections since server start",
|
|
3355
|
+
"# TYPE kora_peak_connections gauge",
|
|
3356
|
+
`kora_peak_connections ${status.peakConnections}`,
|
|
3357
|
+
"",
|
|
3358
|
+
"# HELP kora_connections_total Total number of connections handled since server start",
|
|
3359
|
+
"# TYPE kora_connections_total counter",
|
|
3360
|
+
`kora_connections_total ${status.connectionsTotal}`,
|
|
3361
|
+
"",
|
|
3362
|
+
"# HELP kora_operations_received_total Total operations received from clients",
|
|
3363
|
+
"# TYPE kora_operations_received_total counter",
|
|
3364
|
+
`kora_operations_received_total ${status.operationsReceived}`,
|
|
3365
|
+
"",
|
|
3366
|
+
"# HELP kora_operations_sent_total Total operations sent to clients",
|
|
3367
|
+
"# TYPE kora_operations_sent_total counter",
|
|
3368
|
+
`kora_operations_sent_total ${status.operationsSent}`,
|
|
3369
|
+
"",
|
|
3370
|
+
"# HELP kora_bytes_received_total Total bytes received from clients",
|
|
3371
|
+
"# TYPE kora_bytes_received_total counter",
|
|
3372
|
+
`kora_bytes_received_total ${status.bytesReceived}`,
|
|
3373
|
+
"",
|
|
3374
|
+
"# HELP kora_bytes_sent_total Total bytes sent to clients",
|
|
3375
|
+
"# TYPE kora_bytes_sent_total counter",
|
|
3376
|
+
`kora_bytes_sent_total ${status.bytesSent}`,
|
|
3377
|
+
"",
|
|
3378
|
+
"# HELP kora_errors_total Total errors since server start",
|
|
3379
|
+
"# TYPE kora_errors_total counter",
|
|
3380
|
+
`kora_errors_total ${status.errorCount}`,
|
|
3381
|
+
"",
|
|
3382
|
+
"# HELP kora_uptime_seconds Server uptime in seconds",
|
|
3383
|
+
"# TYPE kora_uptime_seconds gauge",
|
|
3384
|
+
`kora_uptime_seconds ${Math.floor(status.uptime / 1e3)}`,
|
|
3385
|
+
"",
|
|
3386
|
+
"# HELP kora_schema_version Schema version the server expects",
|
|
3387
|
+
"# TYPE kora_schema_version gauge",
|
|
3388
|
+
`kora_schema_version ${status.schemaVersion}`,
|
|
3389
|
+
""
|
|
3390
|
+
];
|
|
3391
|
+
return lines.join("\n");
|
|
3392
|
+
}
|
|
3393
|
+
function readBodyBuffer(req) {
|
|
3394
|
+
return new Promise((resolve) => {
|
|
3395
|
+
const chunks = [];
|
|
3396
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3397
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
async function readJsonBody(req) {
|
|
3401
|
+
const buffer = await readBodyBuffer(req);
|
|
3402
|
+
if (buffer.byteLength === 0) return void 0;
|
|
3403
|
+
try {
|
|
3404
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
3405
|
+
} catch {
|
|
3406
|
+
return void 0;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
function matchesRoutePrefix(pathname, prefix) {
|
|
3410
|
+
const normalizedPrefix = normalizeRoutePath(prefix);
|
|
3411
|
+
return pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`);
|
|
3412
|
+
}
|
|
3413
|
+
function normalizeRoutePath(path) {
|
|
3414
|
+
const prefixed = path.startsWith("/") ? path : `/${path}`;
|
|
3415
|
+
return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : prefixed;
|
|
3416
|
+
}
|
|
3417
|
+
function getClientIp(req) {
|
|
3418
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
3419
|
+
if (typeof forwarded === "string" && forwarded.length > 0) {
|
|
3420
|
+
return forwarded.split(",")[0]?.trim();
|
|
3421
|
+
}
|
|
3422
|
+
return req.socket.remoteAddress;
|
|
3423
|
+
}
|
|
3424
|
+
function getQuery(url) {
|
|
3425
|
+
const query = {};
|
|
3426
|
+
for (const [key, value] of url.searchParams) {
|
|
3427
|
+
const existing = query[key];
|
|
3428
|
+
if (existing === void 0) {
|
|
3429
|
+
query[key] = value;
|
|
3430
|
+
} else if (Array.isArray(existing)) {
|
|
3431
|
+
existing.push(value);
|
|
3432
|
+
} else {
|
|
3433
|
+
query[key] = [existing, value];
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
return query;
|
|
3437
|
+
}
|
|
3438
|
+
function writeJsonResponse(res, result) {
|
|
3439
|
+
const headers = {
|
|
3440
|
+
"Content-Type": "application/json",
|
|
3441
|
+
...result.headers ?? {}
|
|
3442
|
+
};
|
|
3443
|
+
res.writeHead(result.status, headers);
|
|
3444
|
+
res.end(JSON.stringify(result.body ?? null));
|
|
3445
|
+
}
|
|
2235
3446
|
return {
|
|
2236
3447
|
async start() {
|
|
2237
3448
|
const { createServer } = await import("http");
|
|
@@ -2239,13 +3450,137 @@ function createProductionServer(config) {
|
|
|
2239
3450
|
const { extname, join, resolve } = await import("path");
|
|
2240
3451
|
const { WebSocketServer } = await import("ws");
|
|
2241
3452
|
const distDir = resolve(staticDir);
|
|
2242
|
-
httpServer = createServer((req, res) => {
|
|
3453
|
+
httpServer = createServer(async (req, res) => {
|
|
2243
3454
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
2244
3455
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
2245
3456
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
2246
3457
|
if (url.pathname === "/health") {
|
|
3458
|
+
const status = await syncServer.getStatus();
|
|
2247
3459
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2248
|
-
res.end(
|
|
3460
|
+
res.end(
|
|
3461
|
+
JSON.stringify({
|
|
3462
|
+
status: "ok",
|
|
3463
|
+
version: status.version,
|
|
3464
|
+
uptime: status.uptime,
|
|
3465
|
+
connectedClients: status.connectedClients,
|
|
3466
|
+
totalOperations: status.totalOperations,
|
|
3467
|
+
timestamp: Date.now()
|
|
3468
|
+
})
|
|
3469
|
+
);
|
|
3470
|
+
return;
|
|
3471
|
+
}
|
|
3472
|
+
if (url.pathname === "/__kora/status") {
|
|
3473
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3474
|
+
rejectUnauthorized(res);
|
|
3475
|
+
return;
|
|
3476
|
+
}
|
|
3477
|
+
const status = await syncServer.getStatus();
|
|
3478
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3479
|
+
res.end(JSON.stringify(status, null, 2));
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
if (url.pathname === "/__kora/metrics") {
|
|
3483
|
+
if (!isOperationalRequestAllowed(req, "metrics")) {
|
|
3484
|
+
rejectUnauthorized(res);
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
|
3488
|
+
res.end(formatPrometheusMetrics());
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
if (url.pathname === "/__kora/events") {
|
|
3492
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3493
|
+
rejectUnauthorized(res);
|
|
3494
|
+
return;
|
|
3495
|
+
}
|
|
3496
|
+
res.writeHead(200, {
|
|
3497
|
+
"Content-Type": "text/event-stream",
|
|
3498
|
+
"Cache-Control": "no-cache",
|
|
3499
|
+
Connection: "keep-alive",
|
|
3500
|
+
"X-Accel-Buffering": "no"
|
|
3501
|
+
});
|
|
3502
|
+
const status = await syncServer.getStatus();
|
|
3503
|
+
res.write(`event: status
|
|
3504
|
+
data: ${JSON.stringify(status)}
|
|
3505
|
+
|
|
3506
|
+
`);
|
|
3507
|
+
const interval = setInterval(async () => {
|
|
3508
|
+
try {
|
|
3509
|
+
const s = await syncServer.getStatus();
|
|
3510
|
+
res.write(`event: status
|
|
3511
|
+
data: ${JSON.stringify(s)}
|
|
3512
|
+
|
|
3513
|
+
`);
|
|
3514
|
+
} catch {
|
|
3515
|
+
}
|
|
3516
|
+
}, 2e3);
|
|
3517
|
+
req.on("close", () => {
|
|
3518
|
+
clearInterval(interval);
|
|
3519
|
+
});
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
if (url.pathname === "/__kora" || url.pathname === "/__kora/") {
|
|
3523
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3524
|
+
rejectUnauthorized(res);
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
const status = await syncServer.getStatus();
|
|
3528
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3529
|
+
res.end(renderDashboardHtml(status));
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
if (url.pathname === "/__kora/backup/export" && req.method === "POST") {
|
|
3533
|
+
if (!isOperationalRequestAllowed(req, "backup")) {
|
|
3534
|
+
rejectUnauthorized(res);
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
try {
|
|
3538
|
+
const backup = await config.store.exportBackup();
|
|
3539
|
+
res.writeHead(200, {
|
|
3540
|
+
"Content-Type": "application/octet-stream",
|
|
3541
|
+
"Content-Disposition": `attachment; filename="kora-backup-${Date.now()}.kora"`,
|
|
3542
|
+
"Content-Length": String(backup.byteLength)
|
|
3543
|
+
});
|
|
3544
|
+
res.end(Buffer.from(backup));
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3547
|
+
res.end(JSON.stringify({ error: "Backup failed", message: error.message }));
|
|
3548
|
+
}
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
if (url.pathname === "/__kora/backup/import" && req.method === "POST") {
|
|
3552
|
+
if (!isOperationalRequestAllowed(req, "backup")) {
|
|
3553
|
+
rejectUnauthorized(res);
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
try {
|
|
3557
|
+
const body = await readBodyBuffer(req);
|
|
3558
|
+
const merge = url.searchParams.get("merge") === "true";
|
|
3559
|
+
const result = await config.store.importBackup(
|
|
3560
|
+
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
|
3561
|
+
merge
|
|
3562
|
+
);
|
|
3563
|
+
res.writeHead(result.success ? 200 : 400, { "Content-Type": "application/json" });
|
|
3564
|
+
res.end(JSON.stringify(result));
|
|
3565
|
+
} catch (error) {
|
|
3566
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3567
|
+
res.end(JSON.stringify({ error: "Restore failed", message: error.message }));
|
|
3568
|
+
}
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
const customRoute = config.httpRoutes?.find(
|
|
3572
|
+
(route) => matchesRoutePrefix(url.pathname, route.path)
|
|
3573
|
+
);
|
|
3574
|
+
if (customRoute) {
|
|
3575
|
+
const result = await customRoute.handle({
|
|
3576
|
+
method: req.method ?? "GET",
|
|
3577
|
+
path: url.pathname,
|
|
3578
|
+
body: await readJsonBody(req),
|
|
3579
|
+
headers: req.headers,
|
|
3580
|
+
query: getQuery(url),
|
|
3581
|
+
ip: getClientIp(req)
|
|
3582
|
+
});
|
|
3583
|
+
writeJsonResponse(res, result);
|
|
2249
3584
|
return;
|
|
2250
3585
|
}
|
|
2251
3586
|
let filePath = join(distDir, url.pathname);
|
|
@@ -2291,7 +3626,7 @@ function createProductionServer(config) {
|
|
|
2291
3626
|
}
|
|
2292
3627
|
});
|
|
2293
3628
|
return new Promise((resolve2) => {
|
|
2294
|
-
httpServer
|
|
3629
|
+
httpServer?.listen(port, "0.0.0.0", () => {
|
|
2295
3630
|
resolve2(`http://localhost:${port}`);
|
|
2296
3631
|
});
|
|
2297
3632
|
});
|
|
@@ -2300,27 +3635,109 @@ function createProductionServer(config) {
|
|
|
2300
3635
|
await syncServer.stop();
|
|
2301
3636
|
if (httpServer) {
|
|
2302
3637
|
await new Promise((resolve) => {
|
|
2303
|
-
httpServer
|
|
3638
|
+
httpServer?.close(() => resolve());
|
|
2304
3639
|
});
|
|
2305
3640
|
httpServer = null;
|
|
2306
3641
|
}
|
|
2307
3642
|
}
|
|
2308
3643
|
};
|
|
2309
3644
|
}
|
|
3645
|
+
function renderDashboardHtml(status) {
|
|
3646
|
+
const version = status.version;
|
|
3647
|
+
const uptime = formatUptime(status.uptime);
|
|
3648
|
+
return `<!DOCTYPE html>
|
|
3649
|
+
<html lang="en">
|
|
3650
|
+
<head>
|
|
3651
|
+
<meta charset="UTF-8">
|
|
3652
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3653
|
+
<title>Kora Dashboard</title>
|
|
3654
|
+
<style>
|
|
3655
|
+
* { box-sizing: border-box; margin: 0; padding: 0 }
|
|
3656
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem }
|
|
3657
|
+
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem }
|
|
3658
|
+
.subtitle { color: #64748b; margin-bottom: 2rem; font-size: 0.875rem }
|
|
3659
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem }
|
|
3660
|
+
.card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1.25rem }
|
|
3661
|
+
.card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 0.5rem }
|
|
3662
|
+
.card .value { font-size: 1.75rem; font-weight: 700; color: #38bdf8 }
|
|
3663
|
+
.card .value.green { color: #4ade80 }
|
|
3664
|
+
.card .value.red { color: #f87171 }
|
|
3665
|
+
.card .value.yellow { color: #fbbf24 }
|
|
3666
|
+
.section-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; margin-top: 1.5rem }
|
|
3667
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.875rem }
|
|
3668
|
+
th { text-align: left; padding: 0.5rem 0.75rem; color: #64748b; font-weight: 500; border-bottom: 1px solid #334155 }
|
|
3669
|
+
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #1e293b }
|
|
3670
|
+
.status-dot { display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-right: 0.375rem }
|
|
3671
|
+
.status-dot.running { background: #4ade80 }
|
|
3672
|
+
.status-dot.stopped { background: #f87171 }
|
|
3673
|
+
</style>
|
|
3674
|
+
</head>
|
|
3675
|
+
<body>
|
|
3676
|
+
<h1>Kora Sync Server</h1>
|
|
3677
|
+
<p class="subtitle">v${version} · <span class="status-dot running"></span>Running</p>
|
|
3678
|
+
<div class="grid">
|
|
3679
|
+
<div class="card"><div class="label">Uptime</div><div class="value">${uptime}</div></div>
|
|
3680
|
+
<div class="card"><div class="label">Connected Clients</div><div class="value" id="connectedClients">${status.connectedClients}</div></div>
|
|
3681
|
+
<div class="card"><div class="label">Total Operations</div><div class="value" id="totalOperations">${status.totalOperations}</div></div>
|
|
3682
|
+
<div class="card"><div class="label">Peak Connections</div><div class="value green" id="peakConnections">${status.peakConnections}</div></div>
|
|
3683
|
+
<div class="card"><div class="label">Ops Received</div><div class="value" id="opsReceived">${status.operationsReceived}</div></div>
|
|
3684
|
+
<div class="card"><div class="label">Ops Sent</div><div class="value" id="opsSent">${status.operationsSent}</div></div>
|
|
3685
|
+
<div class="card"><div class="label">Errors</div><div class="value ${status.errorCount > 0 ? "red" : "green"}" id="errors">${status.errorCount}</div></div>
|
|
3686
|
+
<div class="card"><div class="label">Schema Version</div><div class="value">${status.schemaVersion}</div></div>
|
|
3687
|
+
</div>
|
|
3688
|
+
<script>
|
|
3689
|
+
(function() {
|
|
3690
|
+
const es = new EventSource('/__kora/events');
|
|
3691
|
+
es.addEventListener('status', (e) => {
|
|
3692
|
+
const s = JSON.parse(e.data);
|
|
3693
|
+
for (const [id, val] of Object.entries({
|
|
3694
|
+
connectedClients: s.connectedClients,
|
|
3695
|
+
totalOperations: s.totalOperations,
|
|
3696
|
+
peakConnections: s.peakConnections,
|
|
3697
|
+
opsReceived: s.operationsReceived,
|
|
3698
|
+
opsSent: s.operationsSent,
|
|
3699
|
+
errors: s.errorCount,
|
|
3700
|
+
})) {
|
|
3701
|
+
const el = document.getElementById(id);
|
|
3702
|
+
if (el) { el.textContent = String(val); el.className = 'value' + (id === 'errors' && val > 0 ? ' red' : id === 'errors' ? ' green' : ''); }
|
|
3703
|
+
}
|
|
3704
|
+
});
|
|
3705
|
+
es.onerror = () => { setTimeout(() => document.location.reload(), 5000); };
|
|
3706
|
+
})();
|
|
3707
|
+
</script>
|
|
3708
|
+
</body>
|
|
3709
|
+
</html>`;
|
|
3710
|
+
}
|
|
3711
|
+
function formatUptime(ms) {
|
|
3712
|
+
const seconds = Math.floor(ms / 1e3);
|
|
3713
|
+
const minutes = Math.floor(seconds / 60);
|
|
3714
|
+
const hours = Math.floor(minutes / 60);
|
|
3715
|
+
const parts = [];
|
|
3716
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
3717
|
+
if (minutes % 60 > 0) parts.push(`${minutes % 60}m`);
|
|
3718
|
+
parts.push(`${seconds % 60}s`);
|
|
3719
|
+
return parts.join(" ");
|
|
3720
|
+
}
|
|
2310
3721
|
export {
|
|
3722
|
+
AwarenessRelay,
|
|
2311
3723
|
ClientSession,
|
|
2312
3724
|
HttpServerTransport,
|
|
2313
3725
|
KoraAuthProvider,
|
|
2314
3726
|
KoraSyncServer,
|
|
2315
3727
|
MemoryServerStore,
|
|
3728
|
+
MixedAuthProvider,
|
|
2316
3729
|
NoAuthProvider,
|
|
2317
3730
|
PostgresServerStore,
|
|
2318
3731
|
SqliteServerStore,
|
|
2319
3732
|
TokenAuthProvider,
|
|
2320
3733
|
WsServerTransport,
|
|
3734
|
+
createDefaultLogger,
|
|
3735
|
+
createJsonLogger,
|
|
2321
3736
|
createKoraServer,
|
|
2322
3737
|
createPostgresServerStore,
|
|
3738
|
+
createPrettyLogger,
|
|
2323
3739
|
createProductionServer,
|
|
3740
|
+
createSilentLogger,
|
|
2324
3741
|
createSqliteServerStore
|
|
2325
3742
|
};
|
|
2326
3743
|
//# sourceMappingURL=index.js.map
|