@korajs/server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
 
@@ -157,6 +213,9 @@ var MemoryServerStore = class {
157
213
  getNodeId() {
158
214
  return this.nodeId;
159
215
  }
216
+ getSchema() {
217
+ return this.schema;
218
+ }
160
219
  async setSchema(schema) {
161
220
  this.assertOpen();
162
221
  this.schema = schema;
@@ -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).
@@ -497,6 +599,9 @@ var PostgresServerStore = class {
497
599
  getNodeId() {
498
600
  return this.nodeId;
499
601
  }
602
+ getSchema() {
603
+ return this.schema;
604
+ }
500
605
  async setSchema(schema) {
501
606
  this.assertOpen();
502
607
  await this.ready;
@@ -625,6 +730,45 @@ var PostgresServerStore = class {
625
730
  async close() {
626
731
  this.closed = true;
627
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
+ }
628
772
  // ---------------------------------------------------------------------------
629
773
  // Materialization internals
630
774
  // ---------------------------------------------------------------------------
@@ -1042,6 +1186,9 @@ var SqliteServerStore = class {
1042
1186
  getNodeId() {
1043
1187
  return this.nodeId;
1044
1188
  }
1189
+ getSchema() {
1190
+ return this.schema;
1191
+ }
1045
1192
  async setSchema(schema) {
1046
1193
  this.assertOpen();
1047
1194
  this.schema = schema;
@@ -1155,6 +1302,39 @@ var SqliteServerStore = class {
1155
1302
  async close() {
1156
1303
  this.closed = true;
1157
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
+ }
1158
1338
  // ---------------------------------------------------------------------------
1159
1339
  // Materialization internals
1160
1340
  // ---------------------------------------------------------------------------
@@ -1634,7 +1814,220 @@ var WsServerTransport = class {
1634
1814
  // src/session/client-session.ts
1635
1815
  import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
1636
1816
  import { topologicalSort } from "@korajs/core/internal";
1637
- import { NegotiatedMessageSerializer, versionVectorToWire, wireToVersionVector } from "@korajs/sync";
1817
+ import {
1818
+ NegotiatedMessageSerializer,
1819
+ SCHEMA_MISMATCH_PREFIX,
1820
+ createDeltaCursorFromBatch,
1821
+ decodeDeltaCursor,
1822
+ dedupeQuerySubsets,
1823
+ encodeDeltaCursor,
1824
+ isClientSchemaVersionSupported,
1825
+ operationMatchesQuerySubsets,
1826
+ sliceOperationsAfterCursor,
1827
+ versionVectorToWire,
1828
+ wireToVersionVector
1829
+ } from "@korajs/sync";
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
+ }
1638
2031
 
1639
2032
  // src/scopes/server-scope-filter.ts
1640
2033
  function operationMatchesScopes(op, scopes) {
@@ -1666,6 +2059,55 @@ function asRecord(value) {
1666
2059
  return value;
1667
2060
  }
1668
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
+
1669
2111
  // src/session/client-session.ts
1670
2112
  var DEFAULT_BATCH_SIZE = 100;
1671
2113
  var DEFAULT_SCHEMA_VERSION = 1;
@@ -1673,6 +2115,8 @@ var ClientSession = class {
1673
2115
  state = "connected";
1674
2116
  clientNodeId = null;
1675
2117
  authContext = null;
2118
+ syncQuerySubsets = [];
2119
+ resumeDeltaCursor = null;
1676
2120
  sessionId;
1677
2121
  transport;
1678
2122
  store;
@@ -1681,9 +2125,14 @@ var ClientSession = class {
1681
2125
  emitter;
1682
2126
  batchSize;
1683
2127
  schemaVersion;
2128
+ supportedSchemaVersions;
1684
2129
  onRelay;
1685
2130
  onAwarenessUpdate;
2131
+ onYjsDocUpdate;
1686
2132
  onClose;
2133
+ maxOperationBytes;
2134
+ maxOpsPerMinute;
2135
+ rateLimiter;
1687
2136
  constructor(options) {
1688
2137
  this.sessionId = options.sessionId;
1689
2138
  this.transport = options.transport;
@@ -1693,15 +2142,24 @@ var ClientSession = class {
1693
2142
  this.emitter = options.emitter ?? null;
1694
2143
  this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
1695
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
+ };
1696
2150
  this.onRelay = options.onRelay ?? null;
1697
2151
  this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
2152
+ this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
1698
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);
1699
2157
  }
1700
2158
  /**
1701
2159
  * Start handling messages from the client transport.
1702
2160
  */
1703
2161
  start() {
1704
- this.transport.onMessage((msg) => this.handleMessage(msg));
2162
+ this.transport.onMessage((msg) => this.enqueueMessage(msg));
1705
2163
  this.transport.onClose((_code, _reason) => this.handleTransportClose());
1706
2164
  this.transport.onError((_err) => {
1707
2165
  if (this.state !== "closed") {
@@ -1716,9 +2174,7 @@ var ClientSession = class {
1716
2174
  relayOperations(operations2) {
1717
2175
  if (this.state !== "streaming" || !this.transport.isConnected()) return;
1718
2176
  if (operations2.length === 0) return;
1719
- const visibleOperations = operations2.filter(
1720
- (op) => operationMatchesScopes(op, this.authContext?.scopes)
1721
- );
2177
+ const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
1722
2178
  if (visibleOperations.length === 0) return;
1723
2179
  const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
1724
2180
  const msg = {
@@ -1728,7 +2184,7 @@ var ClientSession = class {
1728
2184
  isFinal: true,
1729
2185
  batchIndex: 0
1730
2186
  };
1731
- this.transport.send(msg);
2187
+ this.sendToClient(msg);
1732
2188
  }
1733
2189
  /**
1734
2190
  * Close this session.
@@ -1765,15 +2221,30 @@ var ClientSession = class {
1765
2221
  return this.transport;
1766
2222
  }
1767
2223
  // --- Private protocol handlers ---
1768
- handleMessage(message) {
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) {
1769
2241
  switch (message.type) {
1770
2242
  case "handshake":
1771
- this.handleHandshake(message);
2243
+ await this.handleHandshake(message);
1772
2244
  break;
1773
2245
  case "operation-batch":
1774
- this.handleOperationBatch(message);
2246
+ await this.handleOperationBatch(message);
1775
2247
  break;
1776
- // Acknowledgments from clients are noted but no action needed on server
1777
2248
  case "acknowledgment":
1778
2249
  break;
1779
2250
  case "error":
@@ -1781,8 +2252,16 @@ var ClientSession = class {
1781
2252
  case "awareness-update":
1782
2253
  this.handleAwarenessUpdate(message);
1783
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,22 +2279,43 @@ var ClientSession = class {
1800
2279
  this.authContext = context;
1801
2280
  this.state = "authenticated";
1802
2281
  }
1803
- if (msg.syncScope) {
1804
- const mergedScopes = { ...msg.syncScope };
1805
- if (this.authContext?.scopes) {
1806
- for (const [collection, authScope] of Object.entries(this.authContext.scopes)) {
1807
- mergedScopes[collection] = { ...mergedScopes[collection] ?? {}, ...authScope };
1808
- }
1809
- }
2282
+ const resolvedScopes = resolveSessionScopes(this.store.getSchema(), {
2283
+ handshakeScope: msg.syncScope,
2284
+ authScopes: this.authContext?.scopes
2285
+ });
2286
+ if (resolvedScopes) {
1810
2287
  if (this.authContext) {
1811
- this.authContext = { ...this.authContext, scopes: mergedScopes };
2288
+ this.authContext = { ...this.authContext, scopes: resolvedScopes };
1812
2289
  } else {
1813
- this.authContext = { userId: msg.nodeId, scopes: mergedScopes };
2290
+ this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
1814
2291
  }
1815
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;
1816
2299
  const serverVector = this.store.getVersionVector();
1817
2300
  const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
1818
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
+ }
1819
2319
  const response = {
1820
2320
  type: "handshake-response",
1821
2321
  messageId: generateUUIDv74(),
@@ -1828,7 +2328,7 @@ var ClientSession = class {
1828
2328
  // This may differ from what the client requested if auth scopes are narrower.
1829
2329
  ...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
1830
2330
  };
1831
- this.transport.send(response);
2331
+ this.sendToClient(response);
1832
2332
  this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
1833
2333
  this.state = "syncing";
1834
2334
  const clientVector = wireToVersionVector(msg.versionVector);
@@ -1840,13 +2340,45 @@ var ClientSession = class {
1840
2340
  const applied = [];
1841
2341
  const rejected = [];
1842
2342
  for (const op of operations2) {
1843
- if (!operationMatchesScopes(op, this.authContext?.scopes)) {
2343
+ if (!this.operationVisibleToClient(op)) {
1844
2344
  rejected.push(op);
1845
2345
  continue;
1846
2346
  }
1847
- const result = await this.store.applyRemoteOperation(op);
1848
- if (result === "applied") {
1849
- applied.push(op);
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 {
1850
2382
  }
1851
2383
  }
1852
2384
  if (rejected.length > 0) {
@@ -1872,7 +2404,7 @@ var ClientSession = class {
1872
2404
  acknowledgedMessageId: msg.messageId,
1873
2405
  lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
1874
2406
  };
1875
- this.transport.send(ack);
2407
+ this.sendToClient(ack);
1876
2408
  if (applied.length > 0) {
1877
2409
  this.onRelay?.(this.sessionId, applied);
1878
2410
  }
@@ -1884,7 +2416,7 @@ var ClientSession = class {
1884
2416
  const clientSeq = clientVector.get(nodeId) ?? 0;
1885
2417
  if (serverSeq > clientSeq) {
1886
2418
  const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
1887
- const visible = ops.filter((op) => operationMatchesScopes(op, this.authContext?.scopes));
2419
+ const visible = ops.filter((op) => this.operationVisibleToClient(op));
1888
2420
  missing.push(...visible);
1889
2421
  }
1890
2422
  }
@@ -1894,25 +2426,42 @@ var ClientSession = class {
1894
2426
  messageId: generateUUIDv74(),
1895
2427
  operations: [],
1896
2428
  isFinal: true,
1897
- batchIndex: 0
2429
+ batchIndex: 0,
2430
+ totalBatches: 1
1898
2431
  };
1899
- this.transport.send(emptyBatch);
2432
+ this.sendToClient(emptyBatch);
1900
2433
  return;
1901
2434
  }
1902
2435
  const sorted = topologicalSort(missing);
1903
- const totalBatches = Math.ceil(sorted.length / this.batchSize);
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
+ }
1904
2450
  for (let i = 0; i < totalBatches; i++) {
1905
2451
  const start = i * this.batchSize;
1906
- const batchOps = sorted.slice(start, start + this.batchSize);
2452
+ const batchOps = afterCursor.slice(start, start + this.batchSize);
1907
2453
  const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
2454
+ const batchCursor = createDeltaCursorFromBatch(batchOps, i);
1908
2455
  const batchMsg = {
1909
2456
  type: "operation-batch",
1910
2457
  messageId: generateUUIDv74(),
1911
2458
  operations: serializedOps,
1912
2459
  isFinal: i === totalBatches - 1,
1913
- batchIndex: i
2460
+ batchIndex: i,
2461
+ totalBatches,
2462
+ ...batchCursor ? { cursor: encodeDeltaCursor(batchCursor) } : {}
1914
2463
  };
1915
- this.transport.send(batchMsg);
2464
+ this.sendToClient(batchMsg);
1916
2465
  this.emitter?.emit({
1917
2466
  type: "sync:sent",
1918
2467
  operations: batchOps,
@@ -1920,9 +2469,18 @@ var ClientSession = class {
1920
2469
  });
1921
2470
  }
1922
2471
  }
2472
+ operationVisibleToClient(op) {
2473
+ if (!operationMatchesScopes(op, this.authContext?.scopes)) {
2474
+ return false;
2475
+ }
2476
+ return operationMatchesQuerySubsets(op, this.syncQuerySubsets);
2477
+ }
1923
2478
  handleAwarenessUpdate(msg) {
1924
2479
  this.onAwarenessUpdate?.(this.sessionId, msg);
1925
2480
  }
2481
+ handleYjsDocUpdate(msg) {
2482
+ this.onYjsDocUpdate?.(this.sessionId, msg);
2483
+ }
1926
2484
  sendError(code, message, retriable) {
1927
2485
  const errorMsg = {
1928
2486
  type: "error",
@@ -1931,7 +2489,7 @@ var ClientSession = class {
1931
2489
  message,
1932
2490
  retriable
1933
2491
  };
1934
- this.transport.send(errorMsg);
2492
+ this.sendToClient(errorMsg);
1935
2493
  }
1936
2494
  setSerializerWireFormat(format) {
1937
2495
  if (typeof this.serializer.setWireFormat === "function") {
@@ -1954,6 +2512,7 @@ function selectWireFormat(supportedWireFormats) {
1954
2512
 
1955
2513
  // src/server/kora-sync-server.ts
1956
2514
  import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
2515
+ import { SimpleEventEmitter } from "@korajs/core/internal";
1957
2516
  import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
1958
2517
 
1959
2518
  // src/awareness/awareness-relay.ts
@@ -2053,6 +2612,156 @@ var AwarenessRelay = class {
2053
2612
  }
2054
2613
  };
2055
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
+
2056
2765
  // src/server/kora-sync-server.ts
2057
2766
  var DEFAULT_MAX_CONNECTIONS = 0;
2058
2767
  var DEFAULT_BATCH_SIZE2 = 100;
@@ -2067,13 +2776,18 @@ var KoraSyncServer = class {
2067
2776
  maxConnections;
2068
2777
  batchSize;
2069
2778
  schemaVersion;
2779
+ supportedSchemaVersions;
2070
2780
  port;
2071
2781
  host;
2072
2782
  path;
2783
+ logger;
2784
+ metrics;
2073
2785
  awarenessRelay = new AwarenessRelay();
2786
+ yjsDocRelay = new YjsDocRelay();
2074
2787
  sessions = /* @__PURE__ */ new Map();
2075
2788
  httpClients = /* @__PURE__ */ new Map();
2076
2789
  httpSessionToClient = /* @__PURE__ */ new Map();
2790
+ serverVersion = "0.4.0";
2077
2791
  wsServer = null;
2078
2792
  running = false;
2079
2793
  constructor(config) {
@@ -2084,9 +2798,80 @@ var KoraSyncServer = class {
2084
2798
  this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
2085
2799
  this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
2086
2800
  this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
2801
+ this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
2802
+ min: this.schemaVersion,
2803
+ max: this.schemaVersion
2804
+ };
2087
2805
  this.port = config.port;
2088
2806
  this.host = config.host ?? DEFAULT_HOST;
2089
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;
2090
2875
  }
2091
2876
  /**
2092
2877
  * Start the WebSocket server in standalone mode.
@@ -2127,12 +2912,25 @@ var KoraSyncServer = class {
2127
2912
  this.handleConnection(transport);
2128
2913
  });
2129
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
+ });
2130
2921
  }
2131
2922
  /**
2132
2923
  * Stop the server. Closes all sessions and the WebSocket server.
2133
2924
  */
2134
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
+ });
2135
2932
  this.awarenessRelay.clear();
2933
+ this.yjsDocRelay.clear();
2136
2934
  for (const session of this.sessions.values()) {
2137
2935
  session.close("server shutting down");
2138
2936
  }
@@ -2146,6 +2944,11 @@ var KoraSyncServer = class {
2146
2944
  this.wsServer = null;
2147
2945
  }
2148
2946
  this.running = false;
2947
+ this.logger.log({
2948
+ timestamp: Date.now(),
2949
+ level: "info",
2950
+ event: "server.stopped"
2951
+ });
2149
2952
  }
2150
2953
  /**
2151
2954
  * Handle one HTTP sync request for a long-polling client.
@@ -2195,44 +2998,119 @@ var KoraSyncServer = class {
2195
2998
  retriable: true
2196
2999
  });
2197
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
+ });
2198
3008
  throw new SyncError4("Maximum connections reached", {
2199
3009
  current: this.sessions.size,
2200
3010
  max: this.maxConnections
2201
3011
  });
2202
3012
  }
2203
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
+ });
2204
3059
  const session = new ClientSession({
2205
3060
  sessionId,
2206
3061
  transport,
2207
3062
  store: this.store,
2208
3063
  auth: this.auth ?? void 0,
2209
3064
  serializer: this.serializer,
2210
- emitter: this.emitter ?? void 0,
3065
+ emitter: sessionEmitter,
2211
3066
  batchSize: this.batchSize,
2212
3067
  schemaVersion: this.schemaVersion,
3068
+ supportedSchemaVersions: this.supportedSchemaVersions,
2213
3069
  onRelay: (sourceSessionId, operations2) => {
2214
3070
  this.handleRelay(sourceSessionId, operations2);
2215
3071
  },
2216
3072
  onAwarenessUpdate: (sourceSessionId, message) => {
2217
3073
  this.handleAwarenessRelay(sourceSessionId, message);
2218
3074
  },
3075
+ onYjsDocUpdate: (sourceSessionId, message) => {
3076
+ this.handleYjsDocRelay(sourceSessionId, message);
3077
+ },
2219
3078
  onClose: (sid) => {
2220
3079
  this.handleSessionClose(sid);
2221
3080
  }
2222
3081
  });
2223
3082
  this.sessions.set(sessionId, session);
3083
+ this.yjsDocRelay.addClient(sessionId, transport);
2224
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
+ });
2225
3092
  return sessionId;
2226
3093
  }
2227
3094
  /**
2228
3095
  * Get the current server status.
2229
3096
  */
2230
3097
  async getStatus() {
3098
+ const totalOps = await this.store.getOperationCount();
3099
+ const snapshot = this.metrics.getSnapshot(totalOps);
2231
3100
  return {
2232
3101
  running: this.running,
2233
- connectedClients: this.sessions.size,
3102
+ connectedClients: snapshot.connectedClients,
2234
3103
  port: this.port ?? null,
2235
- totalOperations: await this.store.getOperationCount()
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
2236
3114
  };
2237
3115
  }
2238
3116
  /**
@@ -2243,13 +3121,31 @@ var KoraSyncServer = class {
2243
3121
  }
2244
3122
  // --- Private ---
2245
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
+ });
2246
3140
  for (const [sessionId, session] of this.sessions) {
2247
3141
  if (sessionId === sourceSessionId) continue;
2248
3142
  session.relayOperations(operations2);
2249
3143
  }
2250
3144
  }
2251
3145
  handleSessionClose(sessionId) {
3146
+ this.metrics.recordDisconnection(sessionId);
2252
3147
  this.awarenessRelay.removeClient(sessionId);
3148
+ this.yjsDocRelay.removeClient(sessionId);
2253
3149
  this.sessions.delete(sessionId);
2254
3150
  const clientId = this.httpSessionToClient.get(sessionId);
2255
3151
  if (clientId) {
@@ -2266,6 +3162,12 @@ var KoraSyncServer = class {
2266
3162
  this.awarenessRelay.addClient(sourceSessionId, message.clientId, transport);
2267
3163
  this.awarenessRelay.handleUpdate(sourceSessionId, message);
2268
3164
  }
3165
+ handleYjsDocRelay(sourceSessionId, message) {
3166
+ if (!this.sessions.has(sourceSessionId)) {
3167
+ return;
3168
+ }
3169
+ this.yjsDocRelay.handleUpdate(sourceSessionId, message);
3170
+ }
2269
3171
  getOrCreateHttpClient(clientId) {
2270
3172
  const existing = this.httpClients.get(clientId);
2271
3173
  if (existing) {
@@ -2279,6 +3181,13 @@ var KoraSyncServer = class {
2279
3181
  return client;
2280
3182
  }
2281
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
+ }
2282
3191
  function normalizeHttpBody(body, contentType) {
2283
3192
  if (body instanceof Uint8Array) {
2284
3193
  return body;
@@ -2320,7 +3229,7 @@ var KoraAuthProvider = class {
2320
3229
  this.resolveScopes = options.resolveScopes;
2321
3230
  }
2322
3231
  async authenticate(token) {
2323
- const payload = this.tokenValidator.validateToken(token);
3232
+ const payload = this.tokenValidator.validateTokenWithRevocation ? await this.tokenValidator.validateTokenWithRevocation(token) : this.tokenValidator.validateToken(token);
2324
3233
  if (payload === null) {
2325
3234
  return null;
2326
3235
  }
@@ -2397,9 +3306,143 @@ function createProductionServer(config) {
2397
3306
  const syncPath = config.syncPath ?? "/kora-sync";
2398
3307
  const syncServer = new KoraSyncServer({
2399
3308
  store: config.store,
3309
+ enableDashboard: true,
2400
3310
  ...config.syncOptions
2401
3311
  });
2402
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
+ }
2403
3446
  return {
2404
3447
  async start() {
2405
3448
  const { createServer } = await import("http");
@@ -2407,13 +3450,137 @@ function createProductionServer(config) {
2407
3450
  const { extname, join, resolve } = await import("path");
2408
3451
  const { WebSocketServer } = await import("ws");
2409
3452
  const distDir = resolve(staticDir);
2410
- httpServer = createServer((req, res) => {
3453
+ httpServer = createServer(async (req, res) => {
2411
3454
  res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
2412
3455
  res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
2413
3456
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
2414
3457
  if (url.pathname === "/health") {
3458
+ const status = await syncServer.getStatus();
3459
+ res.writeHead(200, { "Content-Type": "application/json" });
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();
2415
3478
  res.writeHead(200, { "Content-Type": "application/json" });
2416
- res.end(JSON.stringify({ status: "ok", timestamp: Date.now() }));
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);
2417
3584
  return;
2418
3585
  }
2419
3586
  let filePath = join(distDir, url.pathname);
@@ -2475,6 +3642,82 @@ function createProductionServer(config) {
2475
3642
  }
2476
3643
  };
2477
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} &middot; <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
+ }
2478
3721
  export {
2479
3722
  AwarenessRelay,
2480
3723
  ClientSession,
@@ -2488,9 +3731,13 @@ export {
2488
3731
  SqliteServerStore,
2489
3732
  TokenAuthProvider,
2490
3733
  WsServerTransport,
3734
+ createDefaultLogger,
3735
+ createJsonLogger,
2491
3736
  createKoraServer,
2492
3737
  createPostgresServerStore,
3738
+ createPrettyLogger,
2493
3739
  createProductionServer,
3740
+ createSilentLogger,
2494
3741
  createSqliteServerStore
2495
3742
  };
2496
3743
  //# sourceMappingURL=index.js.map