@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/README.md +10 -0
- package/dist/index.cjs +1459 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +302 -9
- package/dist/index.d.ts +302 -9
- package/dist/index.js +1286 -39
- 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.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -27,6 +30,140 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
32
|
|
|
33
|
+
// ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.8_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
|
|
34
|
+
var getImportMetaUrl, importMetaUrl;
|
|
35
|
+
var init_cjs_shims = __esm({
|
|
36
|
+
"../../node_modules/.pnpm/tsup@8.5.1_jiti@2.7.0_postcss@8.5.8_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js"() {
|
|
37
|
+
"use strict";
|
|
38
|
+
getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
39
|
+
importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// src/store/server-backup.ts
|
|
44
|
+
var server_backup_exports = {};
|
|
45
|
+
__export(server_backup_exports, {
|
|
46
|
+
buildServerBackup: () => buildServerBackup,
|
|
47
|
+
parseServerBackup: () => parseServerBackup
|
|
48
|
+
});
|
|
49
|
+
function encodeSection(name, content) {
|
|
50
|
+
const nameBytes = new TextEncoder().encode(name);
|
|
51
|
+
const header = new Uint8Array(8);
|
|
52
|
+
const dv = new DataView(header.buffer);
|
|
53
|
+
dv.setUint32(0, nameBytes.length, true);
|
|
54
|
+
dv.setUint32(4, content.length, true);
|
|
55
|
+
const result = new Uint8Array(header.length + nameBytes.length + content.length);
|
|
56
|
+
result.set(header, 0);
|
|
57
|
+
result.set(nameBytes, 8);
|
|
58
|
+
result.set(content, 8 + nameBytes.length);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function encodeJsonSection(name, data) {
|
|
62
|
+
return encodeSection(name, new TextEncoder().encode(JSON.stringify(data)));
|
|
63
|
+
}
|
|
64
|
+
function parseSections(data) {
|
|
65
|
+
const sections = [];
|
|
66
|
+
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
67
|
+
let offset = 0;
|
|
68
|
+
while (offset + 8 <= data.byteLength) {
|
|
69
|
+
const nameLen = dv.getUint32(offset, true);
|
|
70
|
+
const contentLen = dv.getUint32(offset + 4, true);
|
|
71
|
+
offset += 8;
|
|
72
|
+
if (offset + nameLen + contentLen > data.byteLength) break;
|
|
73
|
+
const name = new TextDecoder().decode(data.slice(offset, offset + nameLen));
|
|
74
|
+
offset += nameLen;
|
|
75
|
+
const content = data.slice(offset, offset + contentLen);
|
|
76
|
+
offset += contentLen;
|
|
77
|
+
sections.push({ name, content });
|
|
78
|
+
}
|
|
79
|
+
return sections;
|
|
80
|
+
}
|
|
81
|
+
function findSection(sections, name) {
|
|
82
|
+
for (const s of sections) {
|
|
83
|
+
if (s.name === name) return s.content;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function parseJsonSection(sections, name) {
|
|
88
|
+
const content = findSection(sections, name);
|
|
89
|
+
if (!content) return null;
|
|
90
|
+
return JSON.parse(new TextDecoder().decode(content));
|
|
91
|
+
}
|
|
92
|
+
async function computeSha256(data) {
|
|
93
|
+
const digestInput = new Uint8Array(data);
|
|
94
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", digestInput);
|
|
95
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
96
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
97
|
+
}
|
|
98
|
+
async function buildServerBackup(nodeId, operations2, versionVector) {
|
|
99
|
+
const sections = [];
|
|
100
|
+
let allContentForChecksum = new Uint8Array(0);
|
|
101
|
+
const addSection = (name, data) => {
|
|
102
|
+
sections.push(data);
|
|
103
|
+
const newLen = allContentForChecksum.length + data.length;
|
|
104
|
+
const combined = new Uint8Array(newLen);
|
|
105
|
+
combined.set(allContentForChecksum, 0);
|
|
106
|
+
combined.set(data, allContentForChecksum.length);
|
|
107
|
+
allContentForChecksum = combined;
|
|
108
|
+
};
|
|
109
|
+
const vvObj = {};
|
|
110
|
+
for (const [nid, seq] of versionVector) {
|
|
111
|
+
vvObj[nid] = seq;
|
|
112
|
+
}
|
|
113
|
+
addSection("version_vector", encodeJsonSection("version_vector", vvObj));
|
|
114
|
+
const opLines = `${operations2.map((op) => JSON.stringify(op)).join("\n")}
|
|
115
|
+
`;
|
|
116
|
+
addSection("operations", encodeSection("operations", new TextEncoder().encode(opLines)));
|
|
117
|
+
const checksumHex = await computeSha256(allContentForChecksum);
|
|
118
|
+
const manifest = {
|
|
119
|
+
version: BACKUP_VERSION,
|
|
120
|
+
createdAt: Date.now(),
|
|
121
|
+
nodeId,
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
operationCount: operations2.length,
|
|
124
|
+
collections: [],
|
|
125
|
+
includesRecords: false,
|
|
126
|
+
checksum: checksumHex
|
|
127
|
+
};
|
|
128
|
+
const manifestSection = encodeJsonSection("manifest", manifest);
|
|
129
|
+
const checksumSection = encodeSection("checksum", new TextEncoder().encode(checksumHex));
|
|
130
|
+
const totalLen = manifestSection.length + allContentForChecksum.length + checksumSection.length;
|
|
131
|
+
const result = new Uint8Array(totalLen);
|
|
132
|
+
let pos = 0;
|
|
133
|
+
result.set(manifestSection, pos);
|
|
134
|
+
pos += manifestSection.length;
|
|
135
|
+
result.set(allContentForChecksum, pos);
|
|
136
|
+
pos += allContentForChecksum.length;
|
|
137
|
+
result.set(checksumSection, pos);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
function parseServerBackup(data) {
|
|
141
|
+
const sections = parseSections(data);
|
|
142
|
+
const opsContent = findSection(sections, "operations");
|
|
143
|
+
let operations2 = [];
|
|
144
|
+
if (opsContent) {
|
|
145
|
+
const text3 = new TextDecoder().decode(opsContent);
|
|
146
|
+
const lines = text3.trim().split("\n").filter((l) => l.length > 0);
|
|
147
|
+
operations2 = lines.map((line) => JSON.parse(line));
|
|
148
|
+
}
|
|
149
|
+
const vvData = parseJsonSection(sections, "version_vector");
|
|
150
|
+
const versionVector = /* @__PURE__ */ new Map();
|
|
151
|
+
if (vvData) {
|
|
152
|
+
for (const [nid, seq] of Object.entries(vvData)) {
|
|
153
|
+
versionVector.set(nid, seq);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { operations: operations2, versionVector };
|
|
157
|
+
}
|
|
158
|
+
var BACKUP_VERSION;
|
|
159
|
+
var init_server_backup = __esm({
|
|
160
|
+
"src/store/server-backup.ts"() {
|
|
161
|
+
"use strict";
|
|
162
|
+
init_cjs_shims();
|
|
163
|
+
BACKUP_VERSION = 1;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
30
167
|
// src/index.ts
|
|
31
168
|
var index_exports = {};
|
|
32
169
|
__export(index_exports, {
|
|
@@ -42,21 +179,81 @@ __export(index_exports, {
|
|
|
42
179
|
SqliteServerStore: () => SqliteServerStore,
|
|
43
180
|
TokenAuthProvider: () => TokenAuthProvider,
|
|
44
181
|
WsServerTransport: () => WsServerTransport,
|
|
182
|
+
createDefaultLogger: () => createDefaultLogger,
|
|
183
|
+
createJsonLogger: () => createJsonLogger,
|
|
45
184
|
createKoraServer: () => createKoraServer,
|
|
46
185
|
createPostgresServerStore: () => createPostgresServerStore,
|
|
186
|
+
createPrettyLogger: () => createPrettyLogger,
|
|
47
187
|
createProductionServer: () => createProductionServer,
|
|
188
|
+
createSilentLogger: () => createSilentLogger,
|
|
48
189
|
createSqliteServerStore: () => createSqliteServerStore
|
|
49
190
|
});
|
|
50
191
|
module.exports = __toCommonJS(index_exports);
|
|
192
|
+
init_cjs_shims();
|
|
51
193
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
194
|
+
// src/logging/structured-logger.ts
|
|
195
|
+
init_cjs_shims();
|
|
196
|
+
function defaultJsonSerializer(entry) {
|
|
197
|
+
return JSON.stringify(entry);
|
|
198
|
+
}
|
|
199
|
+
function defaultPrettySerializer(entry) {
|
|
200
|
+
const time = new Date(entry.timestamp).toISOString().slice(11, 23);
|
|
201
|
+
const levelTag = { info: " INFO", warn: " WARN", error: "ERROR" }[entry.level];
|
|
202
|
+
const node = entry.nodeId ? ` [${entry.nodeId.slice(0, 8)}]` : "";
|
|
203
|
+
const session = entry.sessionId ? ` [session:${entry.sessionId.slice(0, 8)}]` : "";
|
|
204
|
+
const dur = entry.duration !== void 0 ? ` +${entry.duration}ms` : "";
|
|
205
|
+
const count3 = entry.count !== void 0 ? ` (${entry.count})` : "";
|
|
206
|
+
const err = entry.error ? ` \u2014 ${entry.error}` : "";
|
|
207
|
+
return `${time} ${levelTag}${node}${session} ${entry.event}${count3}${dur}${err}`;
|
|
208
|
+
}
|
|
209
|
+
function createJsonLogger(writer) {
|
|
210
|
+
const out = writer ?? {
|
|
211
|
+
info: (s) => process.stdout.write(`${s}
|
|
212
|
+
`),
|
|
213
|
+
error: (s) => process.stderr.write(`${s}
|
|
214
|
+
`)
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
log(entry) {
|
|
218
|
+
const line = defaultJsonSerializer(entry);
|
|
219
|
+
if (entry.level === "error") {
|
|
220
|
+
out.error(line);
|
|
221
|
+
} else {
|
|
222
|
+
out.info(line);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function createPrettyLogger(writer) {
|
|
228
|
+
const out = writer ?? { write: (s) => process.stdout.write(`${s}
|
|
229
|
+
`) };
|
|
230
|
+
return {
|
|
231
|
+
log(entry) {
|
|
232
|
+
out.write(defaultPrettySerializer(entry));
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function createSilentLogger() {
|
|
237
|
+
return { log() {
|
|
238
|
+
} };
|
|
239
|
+
}
|
|
240
|
+
function createDefaultLogger() {
|
|
241
|
+
switch (process.env.NODE_ENV) {
|
|
242
|
+
case "production":
|
|
243
|
+
return createJsonLogger();
|
|
244
|
+
case "test":
|
|
245
|
+
return createSilentLogger();
|
|
246
|
+
default:
|
|
247
|
+
return createPrettyLogger();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
55
250
|
|
|
56
251
|
// src/store/memory-server-store.ts
|
|
252
|
+
init_cjs_shims();
|
|
57
253
|
var import_core = require("@korajs/core");
|
|
58
254
|
|
|
59
255
|
// src/store/materialization.ts
|
|
256
|
+
init_cjs_shims();
|
|
60
257
|
function fieldTypeToSql(descriptor, dialect) {
|
|
61
258
|
switch (descriptor.kind) {
|
|
62
259
|
case "string":
|
|
@@ -212,6 +409,9 @@ var MemoryServerStore = class {
|
|
|
212
409
|
getNodeId() {
|
|
213
410
|
return this.nodeId;
|
|
214
411
|
}
|
|
412
|
+
getSchema() {
|
|
413
|
+
return this.schema;
|
|
414
|
+
}
|
|
215
415
|
async setSchema(schema) {
|
|
216
416
|
this.assertOpen();
|
|
217
417
|
this.schema = schema;
|
|
@@ -361,6 +561,49 @@ var MemoryServerStore = class {
|
|
|
361
561
|
async close() {
|
|
362
562
|
this.closed = true;
|
|
363
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* Wipes all in-memory state. For tests and E2E isolation only.
|
|
566
|
+
*/
|
|
567
|
+
resetForTests() {
|
|
568
|
+
this.assertOpen();
|
|
569
|
+
this.operations.length = 0;
|
|
570
|
+
this.operationIndex.clear();
|
|
571
|
+
this.versionVector.clear();
|
|
572
|
+
this.materializedRecords.clear();
|
|
573
|
+
this.schema = null;
|
|
574
|
+
}
|
|
575
|
+
async exportBackup() {
|
|
576
|
+
this.assertOpen();
|
|
577
|
+
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
578
|
+
return buildServerBackup2(this.nodeId, this.operations, this.versionVector);
|
|
579
|
+
}
|
|
580
|
+
async importBackup(data, merge) {
|
|
581
|
+
this.assertOpen();
|
|
582
|
+
const { parseServerBackup: parseServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
583
|
+
const { operations: operations2, versionVector } = parseServerBackup2(data);
|
|
584
|
+
if (merge) {
|
|
585
|
+
let restored = 0;
|
|
586
|
+
for (const op of operations2) {
|
|
587
|
+
const result = await this.applyRemoteOperation(op);
|
|
588
|
+
if (result === "applied") restored++;
|
|
589
|
+
}
|
|
590
|
+
return { operationsRestored: restored, success: true };
|
|
591
|
+
}
|
|
592
|
+
this.operations.length = 0;
|
|
593
|
+
this.operationIndex.clear();
|
|
594
|
+
this.versionVector.clear();
|
|
595
|
+
for (const [nid, seq] of versionVector) {
|
|
596
|
+
this.versionVector.set(nid, seq);
|
|
597
|
+
}
|
|
598
|
+
for (const op of operations2) {
|
|
599
|
+
this.operations.push(op);
|
|
600
|
+
this.operationIndex.set(op.id, op);
|
|
601
|
+
if (this.schema?.collections[op.collection]) {
|
|
602
|
+
this.rebuildMaterializedRecord(op.collection, op.recordId);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { operationsRestored: operations2.length, success: true };
|
|
606
|
+
}
|
|
364
607
|
// --- Testing helpers (not on interface) ---
|
|
365
608
|
/**
|
|
366
609
|
* Get all stored operations (for test assertions).
|
|
@@ -494,10 +737,12 @@ var MemoryServerStore = class {
|
|
|
494
737
|
};
|
|
495
738
|
|
|
496
739
|
// src/store/postgres-server-store.ts
|
|
740
|
+
init_cjs_shims();
|
|
497
741
|
var import_core2 = require("@korajs/core");
|
|
498
742
|
var import_drizzle_orm = require("drizzle-orm");
|
|
499
743
|
|
|
500
744
|
// src/store/drizzle-pg-schema.ts
|
|
745
|
+
init_cjs_shims();
|
|
501
746
|
var import_pg_core = require("drizzle-orm/pg-core");
|
|
502
747
|
var pgOperations = (0, import_pg_core.pgTable)(
|
|
503
748
|
"operations",
|
|
@@ -552,6 +797,9 @@ var PostgresServerStore = class {
|
|
|
552
797
|
getNodeId() {
|
|
553
798
|
return this.nodeId;
|
|
554
799
|
}
|
|
800
|
+
getSchema() {
|
|
801
|
+
return this.schema;
|
|
802
|
+
}
|
|
555
803
|
async setSchema(schema) {
|
|
556
804
|
this.assertOpen();
|
|
557
805
|
await this.ready;
|
|
@@ -680,6 +928,45 @@ var PostgresServerStore = class {
|
|
|
680
928
|
async close() {
|
|
681
929
|
this.closed = true;
|
|
682
930
|
}
|
|
931
|
+
async exportBackup() {
|
|
932
|
+
this.assertOpen();
|
|
933
|
+
await this.ready;
|
|
934
|
+
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
935
|
+
const rows = await this.db.select().from(pgOperations).orderBy((0, import_drizzle_orm.asc)(pgOperations.sequenceNumber));
|
|
936
|
+
const operations2 = rows.map((row) => this.deserializeOperation(row));
|
|
937
|
+
return buildServerBackup2(this.nodeId, operations2, this.versionVector);
|
|
938
|
+
}
|
|
939
|
+
async importBackup(data, merge) {
|
|
940
|
+
this.assertOpen();
|
|
941
|
+
await this.ready;
|
|
942
|
+
const { parseServerBackup: parseServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
943
|
+
const { operations: operations2, versionVector } = parseServerBackup2(data);
|
|
944
|
+
if (merge) {
|
|
945
|
+
let restored = 0;
|
|
946
|
+
for (const op of operations2) {
|
|
947
|
+
const result = await this.applyRemoteOperation(op);
|
|
948
|
+
if (result === "applied") restored++;
|
|
949
|
+
}
|
|
950
|
+
return { operationsRestored: restored, success: true };
|
|
951
|
+
}
|
|
952
|
+
const now = Date.now();
|
|
953
|
+
await this.db.transaction(async (tx) => {
|
|
954
|
+
await tx.delete(pgOperations);
|
|
955
|
+
await tx.delete(pgSyncState);
|
|
956
|
+
for (const [nid, seq] of versionVector) {
|
|
957
|
+
await tx.insert(pgSyncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: now }).onConflictDoNothing({ target: pgSyncState.nodeId });
|
|
958
|
+
}
|
|
959
|
+
for (const op of operations2) {
|
|
960
|
+
const row = this.serializeOperation(op, now);
|
|
961
|
+
await tx.insert(pgOperations).values(row).onConflictDoNothing({ target: pgOperations.id });
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
this.versionVector.clear();
|
|
965
|
+
for (const [nid, seq] of versionVector) {
|
|
966
|
+
this.versionVector.set(nid, seq);
|
|
967
|
+
}
|
|
968
|
+
return { operationsRestored: operations2.length, success: true };
|
|
969
|
+
}
|
|
683
970
|
// ---------------------------------------------------------------------------
|
|
684
971
|
// Materialization internals
|
|
685
972
|
// ---------------------------------------------------------------------------
|
|
@@ -1034,11 +1321,13 @@ async function loadPostgresDeps() {
|
|
|
1034
1321
|
}
|
|
1035
1322
|
|
|
1036
1323
|
// src/store/sqlite-server-store.ts
|
|
1324
|
+
init_cjs_shims();
|
|
1037
1325
|
var import_node_module = require("module");
|
|
1038
1326
|
var import_core3 = require("@korajs/core");
|
|
1039
1327
|
var import_drizzle_orm2 = require("drizzle-orm");
|
|
1040
1328
|
|
|
1041
1329
|
// src/store/drizzle-schema.ts
|
|
1330
|
+
init_cjs_shims();
|
|
1042
1331
|
var import_sqlite_core = require("drizzle-orm/sqlite-core");
|
|
1043
1332
|
var operations = (0, import_sqlite_core.sqliteTable)(
|
|
1044
1333
|
"operations",
|
|
@@ -1097,6 +1386,9 @@ var SqliteServerStore = class {
|
|
|
1097
1386
|
getNodeId() {
|
|
1098
1387
|
return this.nodeId;
|
|
1099
1388
|
}
|
|
1389
|
+
getSchema() {
|
|
1390
|
+
return this.schema;
|
|
1391
|
+
}
|
|
1100
1392
|
async setSchema(schema) {
|
|
1101
1393
|
this.assertOpen();
|
|
1102
1394
|
this.schema = schema;
|
|
@@ -1210,6 +1502,39 @@ var SqliteServerStore = class {
|
|
|
1210
1502
|
async close() {
|
|
1211
1503
|
this.closed = true;
|
|
1212
1504
|
}
|
|
1505
|
+
async exportBackup() {
|
|
1506
|
+
this.assertOpen();
|
|
1507
|
+
const { buildServerBackup: buildServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1508
|
+
const rows = this.db.select().from(operations).all();
|
|
1509
|
+
const deserialized = rows.map((row) => this.deserializeOperation(row));
|
|
1510
|
+
const vv = this.getVersionVector();
|
|
1511
|
+
return buildServerBackup2(this.nodeId, deserialized, vv);
|
|
1512
|
+
}
|
|
1513
|
+
async importBackup(data, merge) {
|
|
1514
|
+
this.assertOpen();
|
|
1515
|
+
const { parseServerBackup: parseServerBackup2 } = await Promise.resolve().then(() => (init_server_backup(), server_backup_exports));
|
|
1516
|
+
const { operations: ops, versionVector } = parseServerBackup2(data);
|
|
1517
|
+
if (merge) {
|
|
1518
|
+
let restored = 0;
|
|
1519
|
+
for (const op of ops) {
|
|
1520
|
+
const result = await this.applyRemoteOperation(op);
|
|
1521
|
+
if (result === "applied") restored++;
|
|
1522
|
+
}
|
|
1523
|
+
return { operationsRestored: restored, success: true };
|
|
1524
|
+
}
|
|
1525
|
+
this.db.transaction((tx) => {
|
|
1526
|
+
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM operations"));
|
|
1527
|
+
tx.run(import_drizzle_orm2.sql.raw("DELETE FROM sync_state"));
|
|
1528
|
+
for (const [nid, seq] of versionVector) {
|
|
1529
|
+
tx.insert(syncState).values({ nodeId: nid, maxSequenceNumber: seq, lastSeenAt: Date.now() }).run();
|
|
1530
|
+
}
|
|
1531
|
+
for (const op of ops) {
|
|
1532
|
+
const row = this.serializeOperation(op, Date.now());
|
|
1533
|
+
tx.insert(operations).values(row).run();
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
return { operationsRestored: ops.length, success: true };
|
|
1537
|
+
}
|
|
1213
1538
|
// ---------------------------------------------------------------------------
|
|
1214
1539
|
// Materialization internals
|
|
1215
1540
|
// ---------------------------------------------------------------------------
|
|
@@ -1543,6 +1868,7 @@ function createSqliteServerStore(options) {
|
|
|
1543
1868
|
}
|
|
1544
1869
|
|
|
1545
1870
|
// src/transport/http-server-transport.ts
|
|
1871
|
+
init_cjs_shims();
|
|
1546
1872
|
var import_core4 = require("@korajs/core");
|
|
1547
1873
|
var HttpServerTransport = class {
|
|
1548
1874
|
serializer;
|
|
@@ -1624,6 +1950,7 @@ var HttpServerTransport = class {
|
|
|
1624
1950
|
};
|
|
1625
1951
|
|
|
1626
1952
|
// src/transport/ws-server-transport.ts
|
|
1953
|
+
init_cjs_shims();
|
|
1627
1954
|
var import_core5 = require("@korajs/core");
|
|
1628
1955
|
var import_sync = require("@korajs/sync");
|
|
1629
1956
|
var WS_OPEN = 1;
|
|
@@ -1687,11 +2014,220 @@ var WsServerTransport = class {
|
|
|
1687
2014
|
};
|
|
1688
2015
|
|
|
1689
2016
|
// src/session/client-session.ts
|
|
1690
|
-
|
|
2017
|
+
init_cjs_shims();
|
|
2018
|
+
var import_core8 = require("@korajs/core");
|
|
1691
2019
|
var import_internal = require("@korajs/core/internal");
|
|
1692
2020
|
var import_sync2 = require("@korajs/sync");
|
|
1693
2021
|
|
|
2022
|
+
// src/apply/apply-server-operation.ts
|
|
2023
|
+
init_cjs_shims();
|
|
2024
|
+
var import_merge2 = require("@korajs/merge");
|
|
2025
|
+
|
|
2026
|
+
// src/constraints/operation-constraint-validator.ts
|
|
2027
|
+
init_cjs_shims();
|
|
2028
|
+
var import_merge = require("@korajs/merge");
|
|
2029
|
+
|
|
2030
|
+
// src/constraints/server-constraint-context.ts
|
|
2031
|
+
init_cjs_shims();
|
|
2032
|
+
function createServerConstraintContext(store) {
|
|
2033
|
+
return {
|
|
2034
|
+
async queryRecords(collection, where) {
|
|
2035
|
+
const rows = await store.queryCollection(collection, { where });
|
|
2036
|
+
return rows.map((row) => ({ ...row }));
|
|
2037
|
+
},
|
|
2038
|
+
async countRecords(collection, where) {
|
|
2039
|
+
return store.countCollection(collection, where);
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// src/constraints/operation-constraint-validator.ts
|
|
2045
|
+
async function validateIncomingOperationConstraints(store, op, schema) {
|
|
2046
|
+
if (!schema) {
|
|
2047
|
+
return { valid: true };
|
|
2048
|
+
}
|
|
2049
|
+
if (op.type === "delete") {
|
|
2050
|
+
return { valid: true };
|
|
2051
|
+
}
|
|
2052
|
+
const collectionDef = schema.collections[op.collection];
|
|
2053
|
+
if (!collectionDef || collectionDef.constraints.length === 0) {
|
|
2054
|
+
return { valid: true };
|
|
2055
|
+
}
|
|
2056
|
+
const candidate = await projectCandidateRecord(store, op);
|
|
2057
|
+
if (candidate === null) {
|
|
2058
|
+
return { valid: true };
|
|
2059
|
+
}
|
|
2060
|
+
const ctx = createServerConstraintContext(store);
|
|
2061
|
+
const violations = await (0, import_merge.checkConstraints)(
|
|
2062
|
+
candidate,
|
|
2063
|
+
op.recordId,
|
|
2064
|
+
op.collection,
|
|
2065
|
+
collectionDef,
|
|
2066
|
+
ctx
|
|
2067
|
+
);
|
|
2068
|
+
if (violations.length === 0) {
|
|
2069
|
+
return { valid: true };
|
|
2070
|
+
}
|
|
2071
|
+
const first = violations[0];
|
|
2072
|
+
if (first === void 0) {
|
|
2073
|
+
return { valid: true };
|
|
2074
|
+
}
|
|
2075
|
+
return {
|
|
2076
|
+
valid: false,
|
|
2077
|
+
code: "CONSTRAINT_VIOLATION",
|
|
2078
|
+
message: first.message
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
async function projectCandidateRecord(store, op) {
|
|
2082
|
+
if (op.data === null) {
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
const existing = await store.findRecord(op.collection, op.recordId);
|
|
2086
|
+
const base = existing ? { ...existing } : {};
|
|
2087
|
+
if (op.type === "insert") {
|
|
2088
|
+
return { ...op.data, id: op.recordId };
|
|
2089
|
+
}
|
|
2090
|
+
return { ...base, ...op.data, id: op.recordId };
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// src/constraints/server-referential-context.ts
|
|
2094
|
+
init_cjs_shims();
|
|
2095
|
+
function createServerReferentialContext(store) {
|
|
2096
|
+
return {
|
|
2097
|
+
async queryRecords(collection, where) {
|
|
2098
|
+
const rows = await store.queryCollection(collection, { where });
|
|
2099
|
+
return rows.map((row) => ({ ...row }));
|
|
2100
|
+
},
|
|
2101
|
+
async recordExists(collection, recordId) {
|
|
2102
|
+
const row = await store.findRecord(collection, recordId);
|
|
2103
|
+
return row !== null && row._deleted !== 1;
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/apply/server-side-effect-operation.ts
|
|
2109
|
+
init_cjs_shims();
|
|
2110
|
+
var import_core6 = require("@korajs/core");
|
|
2111
|
+
async function createServerSideEffectOperation(store, parentOp, effect, schemaVersion, sequenceNumber) {
|
|
2112
|
+
const nodeId = store.getNodeId();
|
|
2113
|
+
const clock = new import_core6.HybridLogicalClock(nodeId);
|
|
2114
|
+
clock.receive(parentOp.timestamp);
|
|
2115
|
+
return (0, import_core6.createOperation)(
|
|
2116
|
+
{
|
|
2117
|
+
nodeId,
|
|
2118
|
+
type: effect.type === "delete" ? "delete" : "update",
|
|
2119
|
+
collection: effect.collection,
|
|
2120
|
+
recordId: effect.recordId,
|
|
2121
|
+
data: effect.data,
|
|
2122
|
+
previousData: effect.previousData,
|
|
2123
|
+
sequenceNumber,
|
|
2124
|
+
causalDeps: [parentOp.id],
|
|
2125
|
+
schemaVersion
|
|
2126
|
+
},
|
|
2127
|
+
clock
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
function nextServerSequenceNumber(store) {
|
|
2131
|
+
const nodeId = store.getNodeId();
|
|
2132
|
+
const current = store.getVersionVector().get(nodeId) ?? 0;
|
|
2133
|
+
return current + 1;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// src/apply/apply-server-operation.ts
|
|
2137
|
+
async function applyServerOperation(store, op, relationLookup) {
|
|
2138
|
+
const schema = store.getSchema();
|
|
2139
|
+
const lookup = relationLookup ?? (schema ? (0, import_merge2.buildMergeRelationLookup)(schema) : /* @__PURE__ */ new Map());
|
|
2140
|
+
const constraintCheck = await validateIncomingOperationConstraints(store, op, schema);
|
|
2141
|
+
if (!constraintCheck.valid) {
|
|
2142
|
+
return {
|
|
2143
|
+
result: "skipped",
|
|
2144
|
+
appliedOperations: [],
|
|
2145
|
+
rejection: {
|
|
2146
|
+
code: constraintCheck.code ?? "CONSTRAINT_VIOLATION",
|
|
2147
|
+
message: constraintCheck.message ?? `Operation "${op.id}" violates a schema constraint`
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
if (op.type === "delete" && schema) {
|
|
2152
|
+
const refCtx = createServerReferentialContext(store);
|
|
2153
|
+
const referential = await (0, import_merge2.checkReferentialIntegrityOnDelete)(op, schema, refCtx, lookup);
|
|
2154
|
+
if (!referential.allowed) {
|
|
2155
|
+
return {
|
|
2156
|
+
result: "skipped",
|
|
2157
|
+
appliedOperations: [],
|
|
2158
|
+
rejection: {
|
|
2159
|
+
code: "REFERENTIAL_INTEGRITY",
|
|
2160
|
+
message: `Operation "${op.id}" violates referential integrity on "${op.collection}"`
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
const primaryResult = await store.applyRemoteOperation(op);
|
|
2165
|
+
if (primaryResult !== "applied") {
|
|
2166
|
+
return { result: primaryResult, appliedOperations: [] };
|
|
2167
|
+
}
|
|
2168
|
+
const appliedOperations = [op];
|
|
2169
|
+
let serverSeq = nextServerSequenceNumber(store);
|
|
2170
|
+
for (const effect of referential.sideEffectOps) {
|
|
2171
|
+
const sideOp = await createServerSideEffectOperation(
|
|
2172
|
+
store,
|
|
2173
|
+
op,
|
|
2174
|
+
effect,
|
|
2175
|
+
op.schemaVersion,
|
|
2176
|
+
serverSeq
|
|
2177
|
+
);
|
|
2178
|
+
serverSeq += 1;
|
|
2179
|
+
const sideResult = await store.applyRemoteOperation(sideOp);
|
|
2180
|
+
if (sideResult === "applied") {
|
|
2181
|
+
appliedOperations.push(sideOp);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return { result: "applied", appliedOperations };
|
|
2185
|
+
}
|
|
2186
|
+
const result = await store.applyRemoteOperation(op);
|
|
2187
|
+
return {
|
|
2188
|
+
result,
|
|
2189
|
+
appliedOperations: result === "applied" ? [op] : []
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// src/scopes/resolve-session-scopes.ts
|
|
2194
|
+
init_cjs_shims();
|
|
2195
|
+
var import_core7 = require("@korajs/core");
|
|
2196
|
+
function resolveSessionScopes(schema, options) {
|
|
2197
|
+
const { handshakeScope, authScopes, scopeValues } = options;
|
|
2198
|
+
let resolved;
|
|
2199
|
+
if (schema && scopeValues && Object.keys(scopeValues).length > 0) {
|
|
2200
|
+
resolved = (0, import_core7.buildScopeMap)(schema, scopeValues);
|
|
2201
|
+
} else if (handshakeScope) {
|
|
2202
|
+
resolved = { ...handshakeScope };
|
|
2203
|
+
} else if (authScopes) {
|
|
2204
|
+
resolved = { ...authScopes };
|
|
2205
|
+
}
|
|
2206
|
+
if (schema && !resolved && scopeValues) {
|
|
2207
|
+
resolved = (0, import_core7.buildScopeMap)(schema, scopeValues);
|
|
2208
|
+
}
|
|
2209
|
+
if (authScopes) {
|
|
2210
|
+
resolved = mergeScopeMaps(resolved, authScopes, "auth");
|
|
2211
|
+
}
|
|
2212
|
+
if (handshakeScope) {
|
|
2213
|
+
resolved = mergeScopeMaps(resolved, handshakeScope, "handshake");
|
|
2214
|
+
}
|
|
2215
|
+
return resolved && Object.keys(resolved).length > 0 ? resolved : void 0;
|
|
2216
|
+
}
|
|
2217
|
+
function mergeScopeMaps(base, overlay, source) {
|
|
2218
|
+
const merged = { ...base ?? {} };
|
|
2219
|
+
for (const [collection, overlayScope] of Object.entries(overlay)) {
|
|
2220
|
+
if (source === "auth") {
|
|
2221
|
+
merged[collection] = { ...merged[collection] ?? {}, ...overlayScope };
|
|
2222
|
+
} else {
|
|
2223
|
+
merged[collection] = { ...overlayScope ?? {}, ...merged[collection] ?? {} };
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return merged;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
1694
2229
|
// src/scopes/server-scope-filter.ts
|
|
2230
|
+
init_cjs_shims();
|
|
1695
2231
|
function operationMatchesScopes(op, scopes) {
|
|
1696
2232
|
if (!scopes) return true;
|
|
1697
2233
|
const collectionScope = scopes[op.collection];
|
|
@@ -1721,6 +2257,57 @@ function asRecord(value) {
|
|
|
1721
2257
|
return value;
|
|
1722
2258
|
}
|
|
1723
2259
|
|
|
2260
|
+
// src/session/operation-validation.ts
|
|
2261
|
+
init_cjs_shims();
|
|
2262
|
+
var SERVER_MAX_TIMESTAMP_FUTURE_MS = 6e4;
|
|
2263
|
+
function isOperationTimestampValid(op, now = Date.now()) {
|
|
2264
|
+
return op.timestamp.wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
// src/session/session-operation-limits.ts
|
|
2268
|
+
init_cjs_shims();
|
|
2269
|
+
var DEFAULT_MAX_OPERATION_BYTES = 256 * 1024;
|
|
2270
|
+
var DEFAULT_MAX_OPS_PER_MINUTE = 600;
|
|
2271
|
+
function measureOperationBytes(op) {
|
|
2272
|
+
return Buffer.byteLength(JSON.stringify(op), "utf8");
|
|
2273
|
+
}
|
|
2274
|
+
function validateOperationSize(op, maxBytes = DEFAULT_MAX_OPERATION_BYTES) {
|
|
2275
|
+
const bytes = measureOperationBytes(op);
|
|
2276
|
+
if (bytes <= maxBytes) {
|
|
2277
|
+
return { valid: true, bytes };
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
valid: false,
|
|
2281
|
+
bytes,
|
|
2282
|
+
message: `Operation "${op.id}" exceeds max size (${String(bytes)} > ${String(maxBytes)} bytes)`
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
var SessionRateLimiter = class {
|
|
2286
|
+
constructor(maxOpsPerMinute = DEFAULT_MAX_OPS_PER_MINUTE) {
|
|
2287
|
+
this.maxOpsPerMinute = maxOpsPerMinute;
|
|
2288
|
+
}
|
|
2289
|
+
maxOpsPerMinute;
|
|
2290
|
+
windowStartMs = Date.now();
|
|
2291
|
+
count = 0;
|
|
2292
|
+
get limit() {
|
|
2293
|
+
return this.maxOpsPerMinute;
|
|
2294
|
+
}
|
|
2295
|
+
/** Record N operations and return false when the limit is exceeded. */
|
|
2296
|
+
allow(count3 = 1) {
|
|
2297
|
+
const now = Date.now();
|
|
2298
|
+
if (now - this.windowStartMs >= 6e4) {
|
|
2299
|
+
this.windowStartMs = now;
|
|
2300
|
+
this.count = 0;
|
|
2301
|
+
}
|
|
2302
|
+
this.count += count3;
|
|
2303
|
+
return this.count <= this.maxOpsPerMinute;
|
|
2304
|
+
}
|
|
2305
|
+
reset() {
|
|
2306
|
+
this.windowStartMs = Date.now();
|
|
2307
|
+
this.count = 0;
|
|
2308
|
+
}
|
|
2309
|
+
};
|
|
2310
|
+
|
|
1724
2311
|
// src/session/client-session.ts
|
|
1725
2312
|
var DEFAULT_BATCH_SIZE = 100;
|
|
1726
2313
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
@@ -1728,6 +2315,8 @@ var ClientSession = class {
|
|
|
1728
2315
|
state = "connected";
|
|
1729
2316
|
clientNodeId = null;
|
|
1730
2317
|
authContext = null;
|
|
2318
|
+
syncQuerySubsets = [];
|
|
2319
|
+
resumeDeltaCursor = null;
|
|
1731
2320
|
sessionId;
|
|
1732
2321
|
transport;
|
|
1733
2322
|
store;
|
|
@@ -1736,9 +2325,14 @@ var ClientSession = class {
|
|
|
1736
2325
|
emitter;
|
|
1737
2326
|
batchSize;
|
|
1738
2327
|
schemaVersion;
|
|
2328
|
+
supportedSchemaVersions;
|
|
1739
2329
|
onRelay;
|
|
1740
2330
|
onAwarenessUpdate;
|
|
2331
|
+
onYjsDocUpdate;
|
|
1741
2332
|
onClose;
|
|
2333
|
+
maxOperationBytes;
|
|
2334
|
+
maxOpsPerMinute;
|
|
2335
|
+
rateLimiter;
|
|
1742
2336
|
constructor(options) {
|
|
1743
2337
|
this.sessionId = options.sessionId;
|
|
1744
2338
|
this.transport = options.transport;
|
|
@@ -1748,15 +2342,24 @@ var ClientSession = class {
|
|
|
1748
2342
|
this.emitter = options.emitter ?? null;
|
|
1749
2343
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
1750
2344
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
2345
|
+
const supported = options.supportedSchemaVersions;
|
|
2346
|
+
this.supportedSchemaVersions = supported ?? {
|
|
2347
|
+
min: this.schemaVersion,
|
|
2348
|
+
max: this.schemaVersion
|
|
2349
|
+
};
|
|
1751
2350
|
this.onRelay = options.onRelay ?? null;
|
|
1752
2351
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2352
|
+
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
1753
2353
|
this.onClose = options.onClose ?? null;
|
|
2354
|
+
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2355
|
+
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
2356
|
+
this.rateLimiter = new SessionRateLimiter(this.maxOpsPerMinute);
|
|
1754
2357
|
}
|
|
1755
2358
|
/**
|
|
1756
2359
|
* Start handling messages from the client transport.
|
|
1757
2360
|
*/
|
|
1758
2361
|
start() {
|
|
1759
|
-
this.transport.onMessage((msg) => this.
|
|
2362
|
+
this.transport.onMessage((msg) => this.enqueueMessage(msg));
|
|
1760
2363
|
this.transport.onClose((_code, _reason) => this.handleTransportClose());
|
|
1761
2364
|
this.transport.onError((_err) => {
|
|
1762
2365
|
if (this.state !== "closed") {
|
|
@@ -1771,19 +2374,17 @@ var ClientSession = class {
|
|
|
1771
2374
|
relayOperations(operations2) {
|
|
1772
2375
|
if (this.state !== "streaming" || !this.transport.isConnected()) return;
|
|
1773
2376
|
if (operations2.length === 0) return;
|
|
1774
|
-
const visibleOperations = operations2.filter(
|
|
1775
|
-
(op) => operationMatchesScopes(op, this.authContext?.scopes)
|
|
1776
|
-
);
|
|
2377
|
+
const visibleOperations = operations2.filter((op) => this.operationVisibleToClient(op));
|
|
1777
2378
|
if (visibleOperations.length === 0) return;
|
|
1778
2379
|
const serializedOps = visibleOperations.map((op) => this.serializer.encodeOperation(op));
|
|
1779
2380
|
const msg = {
|
|
1780
2381
|
type: "operation-batch",
|
|
1781
|
-
messageId: (0,
|
|
2382
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1782
2383
|
operations: serializedOps,
|
|
1783
2384
|
isFinal: true,
|
|
1784
2385
|
batchIndex: 0
|
|
1785
2386
|
};
|
|
1786
|
-
this.
|
|
2387
|
+
this.sendToClient(msg);
|
|
1787
2388
|
}
|
|
1788
2389
|
/**
|
|
1789
2390
|
* Close this session.
|
|
@@ -1820,15 +2421,30 @@ var ClientSession = class {
|
|
|
1820
2421
|
return this.transport;
|
|
1821
2422
|
}
|
|
1822
2423
|
// --- Private protocol handlers ---
|
|
1823
|
-
|
|
2424
|
+
messageChain = Promise.resolve();
|
|
2425
|
+
/** Send to the client when the transport is still connected; no-op otherwise. */
|
|
2426
|
+
sendToClient(message) {
|
|
2427
|
+
if (!this.transport.isConnected()) {
|
|
2428
|
+
return false;
|
|
2429
|
+
}
|
|
2430
|
+
try {
|
|
2431
|
+
this.transport.send(message);
|
|
2432
|
+
return true;
|
|
2433
|
+
} catch {
|
|
2434
|
+
return false;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
enqueueMessage(message) {
|
|
2438
|
+
this.messageChain = this.messageChain.then(() => this.handleMessageAsync(message)).catch((error) => this.handleMessageFailure(error));
|
|
2439
|
+
}
|
|
2440
|
+
async handleMessageAsync(message) {
|
|
1824
2441
|
switch (message.type) {
|
|
1825
2442
|
case "handshake":
|
|
1826
|
-
this.handleHandshake(message);
|
|
2443
|
+
await this.handleHandshake(message);
|
|
1827
2444
|
break;
|
|
1828
2445
|
case "operation-batch":
|
|
1829
|
-
this.handleOperationBatch(message);
|
|
2446
|
+
await this.handleOperationBatch(message);
|
|
1830
2447
|
break;
|
|
1831
|
-
// Acknowledgments from clients are noted but no action needed on server
|
|
1832
2448
|
case "acknowledgment":
|
|
1833
2449
|
break;
|
|
1834
2450
|
case "error":
|
|
@@ -1836,8 +2452,16 @@ var ClientSession = class {
|
|
|
1836
2452
|
case "awareness-update":
|
|
1837
2453
|
this.handleAwarenessUpdate(message);
|
|
1838
2454
|
break;
|
|
2455
|
+
case "yjs-doc-update":
|
|
2456
|
+
this.handleYjsDocUpdate(message);
|
|
2457
|
+
break;
|
|
1839
2458
|
}
|
|
1840
2459
|
}
|
|
2460
|
+
handleMessageFailure(error) {
|
|
2461
|
+
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2462
|
+
this.sendError("SYNC_ERROR", reason, true);
|
|
2463
|
+
this.close(reason);
|
|
2464
|
+
}
|
|
1841
2465
|
async handleHandshake(msg) {
|
|
1842
2466
|
if (this.state !== "connected") {
|
|
1843
2467
|
this.sendError("DUPLICATE_HANDSHAKE", "Handshake already completed", false);
|
|
@@ -1855,25 +2479,46 @@ var ClientSession = class {
|
|
|
1855
2479
|
this.authContext = context;
|
|
1856
2480
|
this.state = "authenticated";
|
|
1857
2481
|
}
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
2482
|
+
const resolvedScopes = resolveSessionScopes(this.store.getSchema(), {
|
|
2483
|
+
handshakeScope: msg.syncScope,
|
|
2484
|
+
authScopes: this.authContext?.scopes
|
|
2485
|
+
});
|
|
2486
|
+
if (resolvedScopes) {
|
|
1865
2487
|
if (this.authContext) {
|
|
1866
|
-
this.authContext = { ...this.authContext, scopes:
|
|
2488
|
+
this.authContext = { ...this.authContext, scopes: resolvedScopes };
|
|
1867
2489
|
} else {
|
|
1868
|
-
this.authContext = { userId: msg.nodeId, scopes:
|
|
2490
|
+
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
1869
2491
|
}
|
|
1870
2492
|
}
|
|
2493
|
+
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2494
|
+
this.syncQuerySubsets = (0, import_sync2.dedupeQuerySubsets)(msg.syncQueries);
|
|
2495
|
+
} else {
|
|
2496
|
+
this.syncQuerySubsets = [];
|
|
2497
|
+
}
|
|
2498
|
+
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync2.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
1871
2499
|
const serverVector = this.store.getVersionVector();
|
|
1872
2500
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
1873
2501
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2502
|
+
if (!(0, import_sync2.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2503
|
+
const { min, max } = this.supportedSchemaVersions;
|
|
2504
|
+
const response2 = {
|
|
2505
|
+
type: "handshake-response",
|
|
2506
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
2507
|
+
nodeId: this.store.getNodeId(),
|
|
2508
|
+
versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
|
|
2509
|
+
schemaVersion: this.schemaVersion,
|
|
2510
|
+
accepted: false,
|
|
2511
|
+
rejectReason: `${import_sync2.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
|
|
2512
|
+
supportedSchemaMin: min,
|
|
2513
|
+
supportedSchemaMax: max
|
|
2514
|
+
};
|
|
2515
|
+
this.sendToClient(response2);
|
|
2516
|
+
this.close("schema version mismatch");
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
1874
2519
|
const response = {
|
|
1875
2520
|
type: "handshake-response",
|
|
1876
|
-
messageId: (0,
|
|
2521
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1877
2522
|
nodeId: this.store.getNodeId(),
|
|
1878
2523
|
versionVector: (0, import_sync2.versionVectorToWire)(serverVector),
|
|
1879
2524
|
schemaVersion: this.schemaVersion,
|
|
@@ -1883,7 +2528,7 @@ var ClientSession = class {
|
|
|
1883
2528
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
1884
2529
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
1885
2530
|
};
|
|
1886
|
-
this.
|
|
2531
|
+
this.sendToClient(response);
|
|
1887
2532
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
1888
2533
|
this.state = "syncing";
|
|
1889
2534
|
const clientVector = (0, import_sync2.wireToVersionVector)(msg.versionVector);
|
|
@@ -1895,13 +2540,45 @@ var ClientSession = class {
|
|
|
1895
2540
|
const applied = [];
|
|
1896
2541
|
const rejected = [];
|
|
1897
2542
|
for (const op of operations2) {
|
|
1898
|
-
if (!
|
|
2543
|
+
if (!this.operationVisibleToClient(op)) {
|
|
1899
2544
|
rejected.push(op);
|
|
1900
2545
|
continue;
|
|
1901
2546
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
2547
|
+
if (!isOperationTimestampValid(op)) {
|
|
2548
|
+
this.sendError(
|
|
2549
|
+
"INVALID_TIMESTAMP",
|
|
2550
|
+
`Operation "${op.id}" timestamp is too far in the future`,
|
|
2551
|
+
false
|
|
2552
|
+
);
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
if (!this.rateLimiter.allow(1)) {
|
|
2556
|
+
this.sendError(
|
|
2557
|
+
"RATE_LIMIT",
|
|
2558
|
+
`Session exceeded operation rate limit (${String(this.maxOpsPerMinute)} ops/min)`,
|
|
2559
|
+
true
|
|
2560
|
+
);
|
|
2561
|
+
continue;
|
|
2562
|
+
}
|
|
2563
|
+
const sizeCheck = validateOperationSize(op, this.maxOperationBytes);
|
|
2564
|
+
if (!sizeCheck.valid) {
|
|
2565
|
+
this.sendError(
|
|
2566
|
+
"OPERATION_TOO_LARGE",
|
|
2567
|
+
sizeCheck.message ?? `Operation "${op.id}" is too large`,
|
|
2568
|
+
false
|
|
2569
|
+
);
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
try {
|
|
2573
|
+
const applyResult = await applyServerOperation(this.store, op);
|
|
2574
|
+
if (applyResult.rejection) {
|
|
2575
|
+
this.sendError(applyResult.rejection.code, applyResult.rejection.message, false);
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
if (applyResult.result === "applied") {
|
|
2579
|
+
applied.push(...applyResult.appliedOperations);
|
|
2580
|
+
}
|
|
2581
|
+
} catch {
|
|
1905
2582
|
}
|
|
1906
2583
|
}
|
|
1907
2584
|
if (rejected.length > 0) {
|
|
@@ -1923,11 +2600,11 @@ var ClientSession = class {
|
|
|
1923
2600
|
const lastOp = operations2[operations2.length - 1];
|
|
1924
2601
|
const ack = {
|
|
1925
2602
|
type: "acknowledgment",
|
|
1926
|
-
messageId: (0,
|
|
2603
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1927
2604
|
acknowledgedMessageId: msg.messageId,
|
|
1928
2605
|
lastSequenceNumber: lastOp ? lastOp.sequenceNumber : 0
|
|
1929
2606
|
};
|
|
1930
|
-
this.
|
|
2607
|
+
this.sendToClient(ack);
|
|
1931
2608
|
if (applied.length > 0) {
|
|
1932
2609
|
this.onRelay?.(this.sessionId, applied);
|
|
1933
2610
|
}
|
|
@@ -1939,35 +2616,52 @@ var ClientSession = class {
|
|
|
1939
2616
|
const clientSeq = clientVector.get(nodeId) ?? 0;
|
|
1940
2617
|
if (serverSeq > clientSeq) {
|
|
1941
2618
|
const ops = await this.store.getOperationRange(nodeId, clientSeq + 1, serverSeq);
|
|
1942
|
-
const visible = ops.filter((op) =>
|
|
2619
|
+
const visible = ops.filter((op) => this.operationVisibleToClient(op));
|
|
1943
2620
|
missing.push(...visible);
|
|
1944
2621
|
}
|
|
1945
2622
|
}
|
|
1946
2623
|
if (missing.length === 0) {
|
|
1947
2624
|
const emptyBatch = {
|
|
1948
2625
|
type: "operation-batch",
|
|
1949
|
-
messageId: (0,
|
|
2626
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1950
2627
|
operations: [],
|
|
1951
2628
|
isFinal: true,
|
|
1952
|
-
batchIndex: 0
|
|
2629
|
+
batchIndex: 0,
|
|
2630
|
+
totalBatches: 1
|
|
1953
2631
|
};
|
|
1954
|
-
this.
|
|
2632
|
+
this.sendToClient(emptyBatch);
|
|
1955
2633
|
return;
|
|
1956
2634
|
}
|
|
1957
2635
|
const sorted = (0, import_internal.topologicalSort)(missing);
|
|
1958
|
-
const
|
|
2636
|
+
const afterCursor = (0, import_sync2.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
|
|
2637
|
+
const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
|
|
2638
|
+
if (afterCursor.length === 0) {
|
|
2639
|
+
const emptyBatch = {
|
|
2640
|
+
type: "operation-batch",
|
|
2641
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
2642
|
+
operations: [],
|
|
2643
|
+
isFinal: true,
|
|
2644
|
+
batchIndex: this.resumeDeltaCursor?.batchIndex ?? 0,
|
|
2645
|
+
totalBatches: 1
|
|
2646
|
+
};
|
|
2647
|
+
this.sendToClient(emptyBatch);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
1959
2650
|
for (let i = 0; i < totalBatches; i++) {
|
|
1960
2651
|
const start = i * this.batchSize;
|
|
1961
|
-
const batchOps =
|
|
2652
|
+
const batchOps = afterCursor.slice(start, start + this.batchSize);
|
|
1962
2653
|
const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
|
|
2654
|
+
const batchCursor = (0, import_sync2.createDeltaCursorFromBatch)(batchOps, i);
|
|
1963
2655
|
const batchMsg = {
|
|
1964
2656
|
type: "operation-batch",
|
|
1965
|
-
messageId: (0,
|
|
2657
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1966
2658
|
operations: serializedOps,
|
|
1967
2659
|
isFinal: i === totalBatches - 1,
|
|
1968
|
-
batchIndex: i
|
|
2660
|
+
batchIndex: i,
|
|
2661
|
+
totalBatches,
|
|
2662
|
+
...batchCursor ? { cursor: (0, import_sync2.encodeDeltaCursor)(batchCursor) } : {}
|
|
1969
2663
|
};
|
|
1970
|
-
this.
|
|
2664
|
+
this.sendToClient(batchMsg);
|
|
1971
2665
|
this.emitter?.emit({
|
|
1972
2666
|
type: "sync:sent",
|
|
1973
2667
|
operations: batchOps,
|
|
@@ -1975,18 +2669,27 @@ var ClientSession = class {
|
|
|
1975
2669
|
});
|
|
1976
2670
|
}
|
|
1977
2671
|
}
|
|
2672
|
+
operationVisibleToClient(op) {
|
|
2673
|
+
if (!operationMatchesScopes(op, this.authContext?.scopes)) {
|
|
2674
|
+
return false;
|
|
2675
|
+
}
|
|
2676
|
+
return (0, import_sync2.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
|
|
2677
|
+
}
|
|
1978
2678
|
handleAwarenessUpdate(msg) {
|
|
1979
2679
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
1980
2680
|
}
|
|
2681
|
+
handleYjsDocUpdate(msg) {
|
|
2682
|
+
this.onYjsDocUpdate?.(this.sessionId, msg);
|
|
2683
|
+
}
|
|
1981
2684
|
sendError(code, message, retriable) {
|
|
1982
2685
|
const errorMsg = {
|
|
1983
2686
|
type: "error",
|
|
1984
|
-
messageId: (0,
|
|
2687
|
+
messageId: (0, import_core8.generateUUIDv7)(),
|
|
1985
2688
|
code,
|
|
1986
2689
|
message,
|
|
1987
2690
|
retriable
|
|
1988
2691
|
};
|
|
1989
|
-
this.
|
|
2692
|
+
this.sendToClient(errorMsg);
|
|
1990
2693
|
}
|
|
1991
2694
|
setSerializerWireFormat(format) {
|
|
1992
2695
|
if (typeof this.serializer.setWireFormat === "function") {
|
|
@@ -2008,11 +2711,14 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2008
2711
|
}
|
|
2009
2712
|
|
|
2010
2713
|
// src/server/kora-sync-server.ts
|
|
2011
|
-
|
|
2714
|
+
init_cjs_shims();
|
|
2715
|
+
var import_core10 = require("@korajs/core");
|
|
2716
|
+
var import_internal2 = require("@korajs/core/internal");
|
|
2012
2717
|
var import_sync3 = require("@korajs/sync");
|
|
2013
2718
|
|
|
2014
2719
|
// src/awareness/awareness-relay.ts
|
|
2015
|
-
|
|
2720
|
+
init_cjs_shims();
|
|
2721
|
+
var import_core9 = require("@korajs/core");
|
|
2016
2722
|
var AwarenessRelay = class {
|
|
2017
2723
|
clients = /* @__PURE__ */ new Map();
|
|
2018
2724
|
/**
|
|
@@ -2041,7 +2747,7 @@ var AwarenessRelay = class {
|
|
|
2041
2747
|
if (hasStates) {
|
|
2042
2748
|
const catchUpMsg = {
|
|
2043
2749
|
type: "awareness-update",
|
|
2044
|
-
messageId: (0,
|
|
2750
|
+
messageId: (0, import_core9.generateUUIDv7)(),
|
|
2045
2751
|
clientId: 0,
|
|
2046
2752
|
// Server-sourced
|
|
2047
2753
|
states: existingStates
|
|
@@ -2064,7 +2770,7 @@ var AwarenessRelay = class {
|
|
|
2064
2770
|
};
|
|
2065
2771
|
const msg = {
|
|
2066
2772
|
type: "awareness-update",
|
|
2067
|
-
messageId: (0,
|
|
2773
|
+
messageId: (0, import_core9.generateUUIDv7)(),
|
|
2068
2774
|
clientId: client.clientId,
|
|
2069
2775
|
states: removalStates
|
|
2070
2776
|
};
|
|
@@ -2108,6 +2814,158 @@ var AwarenessRelay = class {
|
|
|
2108
2814
|
}
|
|
2109
2815
|
};
|
|
2110
2816
|
|
|
2817
|
+
// src/diagnostics/server-metrics-collector.ts
|
|
2818
|
+
init_cjs_shims();
|
|
2819
|
+
function estimateByteSize(operations2) {
|
|
2820
|
+
let total = 0;
|
|
2821
|
+
for (const op of operations2) {
|
|
2822
|
+
total += JSON.stringify(op).length;
|
|
2823
|
+
}
|
|
2824
|
+
return total;
|
|
2825
|
+
}
|
|
2826
|
+
var ServerMetricsCollector = class {
|
|
2827
|
+
startedAt = Date.now();
|
|
2828
|
+
peakConnections = 0;
|
|
2829
|
+
connectionsTotal = 0;
|
|
2830
|
+
operationsReceived = 0;
|
|
2831
|
+
operationsSent = 0;
|
|
2832
|
+
bytesReceived = 0;
|
|
2833
|
+
bytesSent = 0;
|
|
2834
|
+
errorCount = 0;
|
|
2835
|
+
schemaVersion = 1;
|
|
2836
|
+
clientMetrics = /* @__PURE__ */ new Map();
|
|
2837
|
+
/** Record a new client connection. */
|
|
2838
|
+
recordConnection(sessionId) {
|
|
2839
|
+
this.connectionsTotal++;
|
|
2840
|
+
this.clientMetrics.set(sessionId, {
|
|
2841
|
+
sessionId,
|
|
2842
|
+
nodeId: null,
|
|
2843
|
+
state: "connected",
|
|
2844
|
+
connectedAt: Date.now(),
|
|
2845
|
+
operationsReceived: 0,
|
|
2846
|
+
operationsSent: 0,
|
|
2847
|
+
authContext: null
|
|
2848
|
+
});
|
|
2849
|
+
this.peakConnections = Math.max(this.peakConnections, this.clientMetrics.size);
|
|
2850
|
+
}
|
|
2851
|
+
/** Record a client disconnection. */
|
|
2852
|
+
recordDisconnection(sessionId) {
|
|
2853
|
+
this.clientMetrics.delete(sessionId);
|
|
2854
|
+
}
|
|
2855
|
+
/** Update the node ID after a handshake completes. */
|
|
2856
|
+
recordHandshake(sessionId, nodeId) {
|
|
2857
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2858
|
+
if (client) {
|
|
2859
|
+
client.nodeId = nodeId;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
/** Update session state. */
|
|
2863
|
+
updateSessionState(sessionId, state) {
|
|
2864
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2865
|
+
if (client) {
|
|
2866
|
+
client.state = state;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
/** Record authentication context for a session. */
|
|
2870
|
+
recordAuth(sessionId, authContext) {
|
|
2871
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2872
|
+
if (client) {
|
|
2873
|
+
client.authContext = authContext;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
/** Record operations received from a client. */
|
|
2877
|
+
recordReceived(sessionId, count3, byteSize) {
|
|
2878
|
+
this.operationsReceived += count3;
|
|
2879
|
+
this.bytesReceived += byteSize;
|
|
2880
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2881
|
+
if (client) {
|
|
2882
|
+
client.operationsReceived += count3;
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
/** Record operations sent to a client. */
|
|
2886
|
+
recordSent(sessionId, count3, byteSize) {
|
|
2887
|
+
this.operationsSent += count3;
|
|
2888
|
+
this.bytesSent += byteSize;
|
|
2889
|
+
const client = this.clientMetrics.get(sessionId);
|
|
2890
|
+
if (client) {
|
|
2891
|
+
client.operationsSent += count3;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
/** Record an error. */
|
|
2895
|
+
recordError() {
|
|
2896
|
+
this.errorCount++;
|
|
2897
|
+
}
|
|
2898
|
+
/** Set the schema version. */
|
|
2899
|
+
setSchemaVersion(version) {
|
|
2900
|
+
this.schemaVersion = version;
|
|
2901
|
+
}
|
|
2902
|
+
/** Return a full snapshot of current metrics. */
|
|
2903
|
+
getSnapshot(totalOperations) {
|
|
2904
|
+
return {
|
|
2905
|
+
connectedClients: this.clientMetrics.size,
|
|
2906
|
+
connectedNodeIds: Array.from(this.clientMetrics.values()).filter((c) => c.nodeId !== null).map((c) => c.nodeId),
|
|
2907
|
+
peakConnections: this.peakConnections,
|
|
2908
|
+
connectionsTotal: this.connectionsTotal,
|
|
2909
|
+
operationsReceived: this.operationsReceived,
|
|
2910
|
+
operationsSent: this.operationsSent,
|
|
2911
|
+
bytesReceived: this.bytesReceived,
|
|
2912
|
+
bytesSent: this.bytesSent,
|
|
2913
|
+
clients: Array.from(this.clientMetrics.values()),
|
|
2914
|
+
uptime: Date.now() - this.startedAt,
|
|
2915
|
+
totalOperations,
|
|
2916
|
+
errorCount: this.errorCount,
|
|
2917
|
+
schemaVersion: this.schemaVersion
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
/** Reset all metrics. */
|
|
2921
|
+
reset() {
|
|
2922
|
+
this.startedAt = Date.now();
|
|
2923
|
+
this.peakConnections = 0;
|
|
2924
|
+
this.connectionsTotal = 0;
|
|
2925
|
+
this.operationsReceived = 0;
|
|
2926
|
+
this.operationsSent = 0;
|
|
2927
|
+
this.bytesReceived = 0;
|
|
2928
|
+
this.bytesSent = 0;
|
|
2929
|
+
this.errorCount = 0;
|
|
2930
|
+
this.clientMetrics.clear();
|
|
2931
|
+
}
|
|
2932
|
+
};
|
|
2933
|
+
|
|
2934
|
+
// src/richtext/yjs-doc-relay.ts
|
|
2935
|
+
init_cjs_shims();
|
|
2936
|
+
var YjsDocRelay = class {
|
|
2937
|
+
clients = /* @__PURE__ */ new Map();
|
|
2938
|
+
addClient(sessionId, transport) {
|
|
2939
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
2940
|
+
}
|
|
2941
|
+
removeClient(sessionId) {
|
|
2942
|
+
this.clients.delete(sessionId);
|
|
2943
|
+
}
|
|
2944
|
+
handleUpdate(sourceSessionId, message) {
|
|
2945
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
this.broadcastExcept(sourceSessionId, message);
|
|
2949
|
+
}
|
|
2950
|
+
getClientCount() {
|
|
2951
|
+
return this.clients.size;
|
|
2952
|
+
}
|
|
2953
|
+
clear() {
|
|
2954
|
+
this.clients.clear();
|
|
2955
|
+
}
|
|
2956
|
+
broadcastExcept(excludeSessionId, message) {
|
|
2957
|
+
for (const [, client] of this.clients) {
|
|
2958
|
+
if (client.sessionId === excludeSessionId) {
|
|
2959
|
+
continue;
|
|
2960
|
+
}
|
|
2961
|
+
if (!client.transport.isConnected()) {
|
|
2962
|
+
continue;
|
|
2963
|
+
}
|
|
2964
|
+
client.transport.send(message);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
|
|
2111
2969
|
// src/server/kora-sync-server.ts
|
|
2112
2970
|
var DEFAULT_MAX_CONNECTIONS = 0;
|
|
2113
2971
|
var DEFAULT_BATCH_SIZE2 = 100;
|
|
@@ -2122,13 +2980,18 @@ var KoraSyncServer = class {
|
|
|
2122
2980
|
maxConnections;
|
|
2123
2981
|
batchSize;
|
|
2124
2982
|
schemaVersion;
|
|
2983
|
+
supportedSchemaVersions;
|
|
2125
2984
|
port;
|
|
2126
2985
|
host;
|
|
2127
2986
|
path;
|
|
2987
|
+
logger;
|
|
2988
|
+
metrics;
|
|
2128
2989
|
awarenessRelay = new AwarenessRelay();
|
|
2990
|
+
yjsDocRelay = new YjsDocRelay();
|
|
2129
2991
|
sessions = /* @__PURE__ */ new Map();
|
|
2130
2992
|
httpClients = /* @__PURE__ */ new Map();
|
|
2131
2993
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
2994
|
+
serverVersion = "0.4.0";
|
|
2132
2995
|
wsServer = null;
|
|
2133
2996
|
running = false;
|
|
2134
2997
|
constructor(config) {
|
|
@@ -2139,9 +3002,80 @@ var KoraSyncServer = class {
|
|
|
2139
3002
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
2140
3003
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
2141
3004
|
this.schemaVersion = config.schemaVersion ?? DEFAULT_SCHEMA_VERSION2;
|
|
3005
|
+
this.supportedSchemaVersions = config.supportedSchemaVersions ?? {
|
|
3006
|
+
min: this.schemaVersion,
|
|
3007
|
+
max: this.schemaVersion
|
|
3008
|
+
};
|
|
2142
3009
|
this.port = config.port;
|
|
2143
3010
|
this.host = config.host ?? DEFAULT_HOST;
|
|
2144
3011
|
this.path = config.path ?? DEFAULT_PATH;
|
|
3012
|
+
this.logger = config.logger ?? createDefaultLogger();
|
|
3013
|
+
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
3014
|
+
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3015
|
+
if (!this.emitter) {
|
|
3016
|
+
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* Subscribe to session-level events for metrics collection and logging.
|
|
3021
|
+
* Called when a new session is created.
|
|
3022
|
+
*/
|
|
3023
|
+
attachSessionEvents(sessionId, sessionEmitter) {
|
|
3024
|
+
sessionEmitter.on("sync:connected", (event) => {
|
|
3025
|
+
this.metrics.recordHandshake(sessionId, event.nodeId);
|
|
3026
|
+
this.logger.log({
|
|
3027
|
+
timestamp: Date.now(),
|
|
3028
|
+
level: "info",
|
|
3029
|
+
event: "session.handshake",
|
|
3030
|
+
sessionId,
|
|
3031
|
+
nodeId: event.nodeId
|
|
3032
|
+
});
|
|
3033
|
+
});
|
|
3034
|
+
sessionEmitter.on("sync:received", (event) => {
|
|
3035
|
+
const byteSize = estimateByteSize(event.operations);
|
|
3036
|
+
this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
|
|
3037
|
+
this.logger.log({
|
|
3038
|
+
timestamp: Date.now(),
|
|
3039
|
+
level: "info",
|
|
3040
|
+
event: "operations.received",
|
|
3041
|
+
sessionId,
|
|
3042
|
+
count: event.batchSize,
|
|
3043
|
+
bytes: byteSize
|
|
3044
|
+
});
|
|
3045
|
+
});
|
|
3046
|
+
sessionEmitter.on("sync:sent", (event) => {
|
|
3047
|
+
const byteSize = estimateByteSize(event.operations);
|
|
3048
|
+
this.metrics.recordSent(sessionId, event.batchSize, byteSize);
|
|
3049
|
+
this.logger.log({
|
|
3050
|
+
timestamp: Date.now(),
|
|
3051
|
+
level: "info",
|
|
3052
|
+
event: "operations.sent",
|
|
3053
|
+
sessionId,
|
|
3054
|
+
count: event.batchSize,
|
|
3055
|
+
bytes: byteSize
|
|
3056
|
+
});
|
|
3057
|
+
});
|
|
3058
|
+
sessionEmitter.on("sync:disconnected", (event) => {
|
|
3059
|
+
this.logger.log({
|
|
3060
|
+
timestamp: Date.now(),
|
|
3061
|
+
level: "info",
|
|
3062
|
+
event: "session.disconnected",
|
|
3063
|
+
sessionId,
|
|
3064
|
+
details: { reason: event.reason }
|
|
3065
|
+
});
|
|
3066
|
+
});
|
|
3067
|
+
}
|
|
3068
|
+
/**
|
|
3069
|
+
* Get the metrics collector for external access (e.g., HTTP endpoints).
|
|
3070
|
+
*/
|
|
3071
|
+
getMetricsCollector() {
|
|
3072
|
+
return this.metrics;
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Get the logger for external access (e.g., event streaming).
|
|
3076
|
+
*/
|
|
3077
|
+
getLogger() {
|
|
3078
|
+
return this.logger;
|
|
2145
3079
|
}
|
|
2146
3080
|
/**
|
|
2147
3081
|
* Start the WebSocket server in standalone mode.
|
|
@@ -2150,10 +3084,10 @@ var KoraSyncServer = class {
|
|
|
2150
3084
|
*/
|
|
2151
3085
|
async start(wsServerImpl) {
|
|
2152
3086
|
if (this.running) {
|
|
2153
|
-
throw new
|
|
3087
|
+
throw new import_core10.SyncError("Server is already running", { port: this.port });
|
|
2154
3088
|
}
|
|
2155
3089
|
if (!wsServerImpl && this.port === void 0) {
|
|
2156
|
-
throw new
|
|
3090
|
+
throw new import_core10.SyncError(
|
|
2157
3091
|
"Port is required for standalone mode. Provide port in config or use handleConnection() for attach mode.",
|
|
2158
3092
|
{}
|
|
2159
3093
|
);
|
|
@@ -2182,12 +3116,25 @@ var KoraSyncServer = class {
|
|
|
2182
3116
|
this.handleConnection(transport);
|
|
2183
3117
|
});
|
|
2184
3118
|
this.running = true;
|
|
3119
|
+
this.logger.log({
|
|
3120
|
+
timestamp: Date.now(),
|
|
3121
|
+
level: "info",
|
|
3122
|
+
event: "server.started",
|
|
3123
|
+
details: { port: this.port, host: this.host, path: this.path }
|
|
3124
|
+
});
|
|
2185
3125
|
}
|
|
2186
3126
|
/**
|
|
2187
3127
|
* Stop the server. Closes all sessions and the WebSocket server.
|
|
2188
3128
|
*/
|
|
2189
3129
|
async stop() {
|
|
3130
|
+
this.logger.log({
|
|
3131
|
+
timestamp: Date.now(),
|
|
3132
|
+
level: "info",
|
|
3133
|
+
event: "server.stopping",
|
|
3134
|
+
details: { connectedClients: this.sessions.size }
|
|
3135
|
+
});
|
|
2190
3136
|
this.awarenessRelay.clear();
|
|
3137
|
+
this.yjsDocRelay.clear();
|
|
2191
3138
|
for (const session of this.sessions.values()) {
|
|
2192
3139
|
session.close("server shutting down");
|
|
2193
3140
|
}
|
|
@@ -2201,6 +3148,11 @@ var KoraSyncServer = class {
|
|
|
2201
3148
|
this.wsServer = null;
|
|
2202
3149
|
}
|
|
2203
3150
|
this.running = false;
|
|
3151
|
+
this.logger.log({
|
|
3152
|
+
timestamp: Date.now(),
|
|
3153
|
+
level: "info",
|
|
3154
|
+
event: "server.stopped"
|
|
3155
|
+
});
|
|
2204
3156
|
}
|
|
2205
3157
|
/**
|
|
2206
3158
|
* Handle one HTTP sync request for a long-polling client.
|
|
@@ -2244,50 +3196,125 @@ var KoraSyncServer = class {
|
|
|
2244
3196
|
if (this.maxConnections > 0 && this.sessions.size >= this.maxConnections) {
|
|
2245
3197
|
transport.send({
|
|
2246
3198
|
type: "error",
|
|
2247
|
-
messageId: (0,
|
|
3199
|
+
messageId: (0, import_core10.generateUUIDv7)(),
|
|
2248
3200
|
code: "MAX_CONNECTIONS",
|
|
2249
3201
|
message: `Server has reached maximum connections (${this.maxConnections})`,
|
|
2250
3202
|
retriable: true
|
|
2251
3203
|
});
|
|
2252
3204
|
transport.close(4029, "max connections reached");
|
|
2253
|
-
|
|
3205
|
+
this.metrics.recordError();
|
|
3206
|
+
this.logger.log({
|
|
3207
|
+
timestamp: Date.now(),
|
|
3208
|
+
level: "warn",
|
|
3209
|
+
event: "connection.rejected",
|
|
3210
|
+
details: { reason: "max_connections", max: this.maxConnections }
|
|
3211
|
+
});
|
|
3212
|
+
throw new import_core10.SyncError("Maximum connections reached", {
|
|
2254
3213
|
current: this.sessions.size,
|
|
2255
3214
|
max: this.maxConnections
|
|
2256
3215
|
});
|
|
2257
3216
|
}
|
|
2258
|
-
const sessionId = (0,
|
|
3217
|
+
const sessionId = (0, import_core10.generateUUIDv7)();
|
|
3218
|
+
this.metrics.recordConnection(sessionId);
|
|
3219
|
+
const sessionEmitter = new import_internal2.SimpleEventEmitter();
|
|
3220
|
+
sessionEmitter.on("sync:connected", (event) => {
|
|
3221
|
+
this.metrics.recordHandshake(sessionId, event.nodeId);
|
|
3222
|
+
this.metrics.updateSessionState(sessionId, "authenticated");
|
|
3223
|
+
this.logger.log({
|
|
3224
|
+
timestamp: Date.now(),
|
|
3225
|
+
level: "info",
|
|
3226
|
+
event: "session.handshake",
|
|
3227
|
+
sessionId,
|
|
3228
|
+
nodeId: event.nodeId
|
|
3229
|
+
});
|
|
3230
|
+
});
|
|
3231
|
+
sessionEmitter.on("sync:received", (event) => {
|
|
3232
|
+
const byteSize = estimateOperationByteSize(event.operations);
|
|
3233
|
+
this.metrics.recordReceived(sessionId, event.batchSize, byteSize);
|
|
3234
|
+
this.logger.log({
|
|
3235
|
+
timestamp: Date.now(),
|
|
3236
|
+
level: "info",
|
|
3237
|
+
event: "operations.received",
|
|
3238
|
+
sessionId,
|
|
3239
|
+
count: event.batchSize,
|
|
3240
|
+
bytes: byteSize
|
|
3241
|
+
});
|
|
3242
|
+
});
|
|
3243
|
+
sessionEmitter.on("sync:sent", (event) => {
|
|
3244
|
+
const byteSize = estimateOperationByteSize(event.operations);
|
|
3245
|
+
this.metrics.recordSent(sessionId, event.batchSize, byteSize);
|
|
3246
|
+
this.logger.log({
|
|
3247
|
+
timestamp: Date.now(),
|
|
3248
|
+
level: "info",
|
|
3249
|
+
event: "operations.sent",
|
|
3250
|
+
sessionId,
|
|
3251
|
+
count: event.batchSize,
|
|
3252
|
+
bytes: byteSize
|
|
3253
|
+
});
|
|
3254
|
+
});
|
|
3255
|
+
sessionEmitter.on("sync:disconnected", () => {
|
|
3256
|
+
this.logger.log({
|
|
3257
|
+
timestamp: Date.now(),
|
|
3258
|
+
level: "info",
|
|
3259
|
+
event: "session.disconnected",
|
|
3260
|
+
sessionId
|
|
3261
|
+
});
|
|
3262
|
+
});
|
|
2259
3263
|
const session = new ClientSession({
|
|
2260
3264
|
sessionId,
|
|
2261
3265
|
transport,
|
|
2262
3266
|
store: this.store,
|
|
2263
3267
|
auth: this.auth ?? void 0,
|
|
2264
3268
|
serializer: this.serializer,
|
|
2265
|
-
emitter:
|
|
3269
|
+
emitter: sessionEmitter,
|
|
2266
3270
|
batchSize: this.batchSize,
|
|
2267
3271
|
schemaVersion: this.schemaVersion,
|
|
3272
|
+
supportedSchemaVersions: this.supportedSchemaVersions,
|
|
2268
3273
|
onRelay: (sourceSessionId, operations2) => {
|
|
2269
3274
|
this.handleRelay(sourceSessionId, operations2);
|
|
2270
3275
|
},
|
|
2271
3276
|
onAwarenessUpdate: (sourceSessionId, message) => {
|
|
2272
3277
|
this.handleAwarenessRelay(sourceSessionId, message);
|
|
2273
3278
|
},
|
|
3279
|
+
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3280
|
+
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3281
|
+
},
|
|
2274
3282
|
onClose: (sid) => {
|
|
2275
3283
|
this.handleSessionClose(sid);
|
|
2276
3284
|
}
|
|
2277
3285
|
});
|
|
2278
3286
|
this.sessions.set(sessionId, session);
|
|
3287
|
+
this.yjsDocRelay.addClient(sessionId, transport);
|
|
2279
3288
|
session.start();
|
|
3289
|
+
this.logger.log({
|
|
3290
|
+
timestamp: Date.now(),
|
|
3291
|
+
level: "info",
|
|
3292
|
+
event: "session.connected",
|
|
3293
|
+
sessionId,
|
|
3294
|
+
details: { totalSessions: this.sessions.size }
|
|
3295
|
+
});
|
|
2280
3296
|
return sessionId;
|
|
2281
3297
|
}
|
|
2282
3298
|
/**
|
|
2283
3299
|
* Get the current server status.
|
|
2284
3300
|
*/
|
|
2285
3301
|
async getStatus() {
|
|
3302
|
+
const totalOps = await this.store.getOperationCount();
|
|
3303
|
+
const snapshot = this.metrics.getSnapshot(totalOps);
|
|
2286
3304
|
return {
|
|
2287
3305
|
running: this.running,
|
|
2288
|
-
connectedClients:
|
|
3306
|
+
connectedClients: snapshot.connectedClients,
|
|
2289
3307
|
port: this.port ?? null,
|
|
2290
|
-
totalOperations:
|
|
3308
|
+
totalOperations: snapshot.totalOperations,
|
|
3309
|
+
uptime: snapshot.uptime,
|
|
3310
|
+
version: this.serverVersion,
|
|
3311
|
+
schemaVersion: this.schemaVersion,
|
|
3312
|
+
connectedNodeIds: snapshot.connectedNodeIds,
|
|
3313
|
+
peakConnections: snapshot.peakConnections,
|
|
3314
|
+
connectionsTotal: snapshot.connectionsTotal,
|
|
3315
|
+
operationsReceived: snapshot.operationsReceived,
|
|
3316
|
+
operationsSent: snapshot.operationsSent,
|
|
3317
|
+
errorCount: snapshot.errorCount
|
|
2291
3318
|
};
|
|
2292
3319
|
}
|
|
2293
3320
|
/**
|
|
@@ -2298,13 +3325,31 @@ var KoraSyncServer = class {
|
|
|
2298
3325
|
}
|
|
2299
3326
|
// --- Private ---
|
|
2300
3327
|
handleRelay(sourceSessionId, operations2) {
|
|
3328
|
+
const targetCount = this.sessions.size - 1;
|
|
3329
|
+
const byteSize = estimateOperationByteSize(operations2);
|
|
3330
|
+
this.metrics.recordSent(
|
|
3331
|
+
sourceSessionId,
|
|
3332
|
+
operations2.length * targetCount,
|
|
3333
|
+
byteSize * targetCount
|
|
3334
|
+
);
|
|
3335
|
+
this.logger.log({
|
|
3336
|
+
timestamp: Date.now(),
|
|
3337
|
+
level: "info",
|
|
3338
|
+
event: "operations.relayed",
|
|
3339
|
+
sessionId: sourceSessionId,
|
|
3340
|
+
count: operations2.length,
|
|
3341
|
+
bytes: byteSize * targetCount,
|
|
3342
|
+
details: { targetSessions: targetCount }
|
|
3343
|
+
});
|
|
2301
3344
|
for (const [sessionId, session] of this.sessions) {
|
|
2302
3345
|
if (sessionId === sourceSessionId) continue;
|
|
2303
3346
|
session.relayOperations(operations2);
|
|
2304
3347
|
}
|
|
2305
3348
|
}
|
|
2306
3349
|
handleSessionClose(sessionId) {
|
|
3350
|
+
this.metrics.recordDisconnection(sessionId);
|
|
2307
3351
|
this.awarenessRelay.removeClient(sessionId);
|
|
3352
|
+
this.yjsDocRelay.removeClient(sessionId);
|
|
2308
3353
|
this.sessions.delete(sessionId);
|
|
2309
3354
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
2310
3355
|
if (clientId) {
|
|
@@ -2321,6 +3366,12 @@ var KoraSyncServer = class {
|
|
|
2321
3366
|
this.awarenessRelay.addClient(sourceSessionId, message.clientId, transport);
|
|
2322
3367
|
this.awarenessRelay.handleUpdate(sourceSessionId, message);
|
|
2323
3368
|
}
|
|
3369
|
+
handleYjsDocRelay(sourceSessionId, message) {
|
|
3370
|
+
if (!this.sessions.has(sourceSessionId)) {
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
this.yjsDocRelay.handleUpdate(sourceSessionId, message);
|
|
3374
|
+
}
|
|
2324
3375
|
getOrCreateHttpClient(clientId) {
|
|
2325
3376
|
const existing = this.httpClients.get(clientId);
|
|
2326
3377
|
if (existing) {
|
|
@@ -2334,6 +3385,13 @@ var KoraSyncServer = class {
|
|
|
2334
3385
|
return client;
|
|
2335
3386
|
}
|
|
2336
3387
|
};
|
|
3388
|
+
function estimateOperationByteSize(operations2) {
|
|
3389
|
+
let total = 0;
|
|
3390
|
+
for (const op of operations2) {
|
|
3391
|
+
total += JSON.stringify(op).length;
|
|
3392
|
+
}
|
|
3393
|
+
return total;
|
|
3394
|
+
}
|
|
2337
3395
|
function normalizeHttpBody(body, contentType) {
|
|
2338
3396
|
if (body instanceof Uint8Array) {
|
|
2339
3397
|
return body;
|
|
@@ -2345,6 +3403,7 @@ function normalizeHttpBody(body, contentType) {
|
|
|
2345
3403
|
}
|
|
2346
3404
|
|
|
2347
3405
|
// src/auth/no-auth.ts
|
|
3406
|
+
init_cjs_shims();
|
|
2348
3407
|
var NoAuthProvider = class {
|
|
2349
3408
|
async authenticate(_token) {
|
|
2350
3409
|
return { userId: "anonymous" };
|
|
@@ -2352,6 +3411,7 @@ var NoAuthProvider = class {
|
|
|
2352
3411
|
};
|
|
2353
3412
|
|
|
2354
3413
|
// src/auth/token-auth.ts
|
|
3414
|
+
init_cjs_shims();
|
|
2355
3415
|
var TokenAuthProvider = class {
|
|
2356
3416
|
validate;
|
|
2357
3417
|
constructor(options) {
|
|
@@ -2363,6 +3423,7 @@ var TokenAuthProvider = class {
|
|
|
2363
3423
|
};
|
|
2364
3424
|
|
|
2365
3425
|
// src/auth/kora-auth-provider.ts
|
|
3426
|
+
init_cjs_shims();
|
|
2366
3427
|
var KoraAuthProvider = class {
|
|
2367
3428
|
tokenValidator;
|
|
2368
3429
|
userLookup;
|
|
@@ -2375,7 +3436,7 @@ var KoraAuthProvider = class {
|
|
|
2375
3436
|
this.resolveScopes = options.resolveScopes;
|
|
2376
3437
|
}
|
|
2377
3438
|
async authenticate(token) {
|
|
2378
|
-
const payload = this.tokenValidator.validateToken(token);
|
|
3439
|
+
const payload = this.tokenValidator.validateTokenWithRevocation ? await this.tokenValidator.validateTokenWithRevocation(token) : this.tokenValidator.validateToken(token);
|
|
2379
3440
|
if (payload === null) {
|
|
2380
3441
|
return null;
|
|
2381
3442
|
}
|
|
@@ -2403,6 +3464,7 @@ var KoraAuthProvider = class {
|
|
|
2403
3464
|
};
|
|
2404
3465
|
|
|
2405
3466
|
// src/auth/mixed-auth-provider.ts
|
|
3467
|
+
init_cjs_shims();
|
|
2406
3468
|
var MixedAuthProvider = class {
|
|
2407
3469
|
primary;
|
|
2408
3470
|
anonymousScopes;
|
|
@@ -2427,11 +3489,13 @@ var MixedAuthProvider = class {
|
|
|
2427
3489
|
};
|
|
2428
3490
|
|
|
2429
3491
|
// src/server/create-server.ts
|
|
3492
|
+
init_cjs_shims();
|
|
2430
3493
|
function createKoraServer(config) {
|
|
2431
3494
|
return new KoraSyncServer(config);
|
|
2432
3495
|
}
|
|
2433
3496
|
|
|
2434
3497
|
// src/server/production-server.ts
|
|
3498
|
+
init_cjs_shims();
|
|
2435
3499
|
var MIME_TYPES = {
|
|
2436
3500
|
".html": "text/html",
|
|
2437
3501
|
".js": "text/javascript",
|
|
@@ -2452,9 +3516,143 @@ function createProductionServer(config) {
|
|
|
2452
3516
|
const syncPath = config.syncPath ?? "/kora-sync";
|
|
2453
3517
|
const syncServer = new KoraSyncServer({
|
|
2454
3518
|
store: config.store,
|
|
3519
|
+
enableDashboard: true,
|
|
2455
3520
|
...config.syncOptions
|
|
2456
3521
|
});
|
|
2457
3522
|
let httpServer = null;
|
|
3523
|
+
function getOperationalToken(kind) {
|
|
3524
|
+
const auth = config.operationalAuth;
|
|
3525
|
+
if (!auth) return void 0;
|
|
3526
|
+
if (kind === "metrics") return auth.metricsToken || auth.adminToken;
|
|
3527
|
+
if (kind === "backup") return auth.backupToken || auth.adminToken;
|
|
3528
|
+
return auth.adminToken;
|
|
3529
|
+
}
|
|
3530
|
+
function extractRequestToken(req) {
|
|
3531
|
+
const authorization = req.headers.authorization;
|
|
3532
|
+
if (authorization?.startsWith("Bearer ")) {
|
|
3533
|
+
return authorization.slice("Bearer ".length).trim();
|
|
3534
|
+
}
|
|
3535
|
+
const headerNames = ["x-kora-admin-token", "x-kora-metrics-token", "x-kora-backup-token"];
|
|
3536
|
+
for (const name of headerNames) {
|
|
3537
|
+
const value = req.headers[name];
|
|
3538
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
3539
|
+
if (Array.isArray(value) && typeof value[0] === "string" && value[0].length > 0) {
|
|
3540
|
+
return value[0];
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
return null;
|
|
3544
|
+
}
|
|
3545
|
+
function isOperationalRequestAllowed(req, kind) {
|
|
3546
|
+
const expected = getOperationalToken(kind);
|
|
3547
|
+
if (!expected) return true;
|
|
3548
|
+
return extractRequestToken(req) === expected;
|
|
3549
|
+
}
|
|
3550
|
+
function rejectUnauthorized(res) {
|
|
3551
|
+
res.writeHead(401, {
|
|
3552
|
+
"Content-Type": "application/json",
|
|
3553
|
+
"WWW-Authenticate": 'Bearer realm="kora"'
|
|
3554
|
+
});
|
|
3555
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
3556
|
+
}
|
|
3557
|
+
function formatPrometheusMetrics() {
|
|
3558
|
+
const status = syncServer.getMetricsCollector().getSnapshot(0);
|
|
3559
|
+
const lines = [
|
|
3560
|
+
"# HELP kora_connected_clients Current number of connected clients",
|
|
3561
|
+
"# TYPE kora_connected_clients gauge",
|
|
3562
|
+
`kora_connected_clients ${status.connectedClients}`,
|
|
3563
|
+
"",
|
|
3564
|
+
"# HELP kora_peak_connections Peak number of simultaneous connections since server start",
|
|
3565
|
+
"# TYPE kora_peak_connections gauge",
|
|
3566
|
+
`kora_peak_connections ${status.peakConnections}`,
|
|
3567
|
+
"",
|
|
3568
|
+
"# HELP kora_connections_total Total number of connections handled since server start",
|
|
3569
|
+
"# TYPE kora_connections_total counter",
|
|
3570
|
+
`kora_connections_total ${status.connectionsTotal}`,
|
|
3571
|
+
"",
|
|
3572
|
+
"# HELP kora_operations_received_total Total operations received from clients",
|
|
3573
|
+
"# TYPE kora_operations_received_total counter",
|
|
3574
|
+
`kora_operations_received_total ${status.operationsReceived}`,
|
|
3575
|
+
"",
|
|
3576
|
+
"# HELP kora_operations_sent_total Total operations sent to clients",
|
|
3577
|
+
"# TYPE kora_operations_sent_total counter",
|
|
3578
|
+
`kora_operations_sent_total ${status.operationsSent}`,
|
|
3579
|
+
"",
|
|
3580
|
+
"# HELP kora_bytes_received_total Total bytes received from clients",
|
|
3581
|
+
"# TYPE kora_bytes_received_total counter",
|
|
3582
|
+
`kora_bytes_received_total ${status.bytesReceived}`,
|
|
3583
|
+
"",
|
|
3584
|
+
"# HELP kora_bytes_sent_total Total bytes sent to clients",
|
|
3585
|
+
"# TYPE kora_bytes_sent_total counter",
|
|
3586
|
+
`kora_bytes_sent_total ${status.bytesSent}`,
|
|
3587
|
+
"",
|
|
3588
|
+
"# HELP kora_errors_total Total errors since server start",
|
|
3589
|
+
"# TYPE kora_errors_total counter",
|
|
3590
|
+
`kora_errors_total ${status.errorCount}`,
|
|
3591
|
+
"",
|
|
3592
|
+
"# HELP kora_uptime_seconds Server uptime in seconds",
|
|
3593
|
+
"# TYPE kora_uptime_seconds gauge",
|
|
3594
|
+
`kora_uptime_seconds ${Math.floor(status.uptime / 1e3)}`,
|
|
3595
|
+
"",
|
|
3596
|
+
"# HELP kora_schema_version Schema version the server expects",
|
|
3597
|
+
"# TYPE kora_schema_version gauge",
|
|
3598
|
+
`kora_schema_version ${status.schemaVersion}`,
|
|
3599
|
+
""
|
|
3600
|
+
];
|
|
3601
|
+
return lines.join("\n");
|
|
3602
|
+
}
|
|
3603
|
+
function readBodyBuffer(req) {
|
|
3604
|
+
return new Promise((resolve) => {
|
|
3605
|
+
const chunks = [];
|
|
3606
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3607
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3608
|
+
});
|
|
3609
|
+
}
|
|
3610
|
+
async function readJsonBody(req) {
|
|
3611
|
+
const buffer = await readBodyBuffer(req);
|
|
3612
|
+
if (buffer.byteLength === 0) return void 0;
|
|
3613
|
+
try {
|
|
3614
|
+
return JSON.parse(buffer.toString("utf8"));
|
|
3615
|
+
} catch {
|
|
3616
|
+
return void 0;
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
function matchesRoutePrefix(pathname, prefix) {
|
|
3620
|
+
const normalizedPrefix = normalizeRoutePath(prefix);
|
|
3621
|
+
return pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`);
|
|
3622
|
+
}
|
|
3623
|
+
function normalizeRoutePath(path) {
|
|
3624
|
+
const prefixed = path.startsWith("/") ? path : `/${path}`;
|
|
3625
|
+
return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : prefixed;
|
|
3626
|
+
}
|
|
3627
|
+
function getClientIp(req) {
|
|
3628
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
3629
|
+
if (typeof forwarded === "string" && forwarded.length > 0) {
|
|
3630
|
+
return forwarded.split(",")[0]?.trim();
|
|
3631
|
+
}
|
|
3632
|
+
return req.socket.remoteAddress;
|
|
3633
|
+
}
|
|
3634
|
+
function getQuery(url) {
|
|
3635
|
+
const query = {};
|
|
3636
|
+
for (const [key, value] of url.searchParams) {
|
|
3637
|
+
const existing = query[key];
|
|
3638
|
+
if (existing === void 0) {
|
|
3639
|
+
query[key] = value;
|
|
3640
|
+
} else if (Array.isArray(existing)) {
|
|
3641
|
+
existing.push(value);
|
|
3642
|
+
} else {
|
|
3643
|
+
query[key] = [existing, value];
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
return query;
|
|
3647
|
+
}
|
|
3648
|
+
function writeJsonResponse(res, result) {
|
|
3649
|
+
const headers = {
|
|
3650
|
+
"Content-Type": "application/json",
|
|
3651
|
+
...result.headers ?? {}
|
|
3652
|
+
};
|
|
3653
|
+
res.writeHead(result.status, headers);
|
|
3654
|
+
res.end(JSON.stringify(result.body ?? null));
|
|
3655
|
+
}
|
|
2458
3656
|
return {
|
|
2459
3657
|
async start() {
|
|
2460
3658
|
const { createServer } = await import("http");
|
|
@@ -2462,13 +3660,137 @@ function createProductionServer(config) {
|
|
|
2462
3660
|
const { extname, join, resolve } = await import("path");
|
|
2463
3661
|
const { WebSocketServer } = await import("ws");
|
|
2464
3662
|
const distDir = resolve(staticDir);
|
|
2465
|
-
httpServer = createServer((req, res) => {
|
|
3663
|
+
httpServer = createServer(async (req, res) => {
|
|
2466
3664
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
2467
3665
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
2468
3666
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
2469
3667
|
if (url.pathname === "/health") {
|
|
3668
|
+
const status = await syncServer.getStatus();
|
|
2470
3669
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2471
|
-
res.end(
|
|
3670
|
+
res.end(
|
|
3671
|
+
JSON.stringify({
|
|
3672
|
+
status: "ok",
|
|
3673
|
+
version: status.version,
|
|
3674
|
+
uptime: status.uptime,
|
|
3675
|
+
connectedClients: status.connectedClients,
|
|
3676
|
+
totalOperations: status.totalOperations,
|
|
3677
|
+
timestamp: Date.now()
|
|
3678
|
+
})
|
|
3679
|
+
);
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
if (url.pathname === "/__kora/status") {
|
|
3683
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3684
|
+
rejectUnauthorized(res);
|
|
3685
|
+
return;
|
|
3686
|
+
}
|
|
3687
|
+
const status = await syncServer.getStatus();
|
|
3688
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3689
|
+
res.end(JSON.stringify(status, null, 2));
|
|
3690
|
+
return;
|
|
3691
|
+
}
|
|
3692
|
+
if (url.pathname === "/__kora/metrics") {
|
|
3693
|
+
if (!isOperationalRequestAllowed(req, "metrics")) {
|
|
3694
|
+
rejectUnauthorized(res);
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
|
3698
|
+
res.end(formatPrometheusMetrics());
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
if (url.pathname === "/__kora/events") {
|
|
3702
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3703
|
+
rejectUnauthorized(res);
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3706
|
+
res.writeHead(200, {
|
|
3707
|
+
"Content-Type": "text/event-stream",
|
|
3708
|
+
"Cache-Control": "no-cache",
|
|
3709
|
+
Connection: "keep-alive",
|
|
3710
|
+
"X-Accel-Buffering": "no"
|
|
3711
|
+
});
|
|
3712
|
+
const status = await syncServer.getStatus();
|
|
3713
|
+
res.write(`event: status
|
|
3714
|
+
data: ${JSON.stringify(status)}
|
|
3715
|
+
|
|
3716
|
+
`);
|
|
3717
|
+
const interval = setInterval(async () => {
|
|
3718
|
+
try {
|
|
3719
|
+
const s = await syncServer.getStatus();
|
|
3720
|
+
res.write(`event: status
|
|
3721
|
+
data: ${JSON.stringify(s)}
|
|
3722
|
+
|
|
3723
|
+
`);
|
|
3724
|
+
} catch {
|
|
3725
|
+
}
|
|
3726
|
+
}, 2e3);
|
|
3727
|
+
req.on("close", () => {
|
|
3728
|
+
clearInterval(interval);
|
|
3729
|
+
});
|
|
3730
|
+
return;
|
|
3731
|
+
}
|
|
3732
|
+
if (url.pathname === "/__kora" || url.pathname === "/__kora/") {
|
|
3733
|
+
if (!isOperationalRequestAllowed(req, "admin")) {
|
|
3734
|
+
rejectUnauthorized(res);
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
const status = await syncServer.getStatus();
|
|
3738
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
3739
|
+
res.end(renderDashboardHtml(status));
|
|
3740
|
+
return;
|
|
3741
|
+
}
|
|
3742
|
+
if (url.pathname === "/__kora/backup/export" && req.method === "POST") {
|
|
3743
|
+
if (!isOperationalRequestAllowed(req, "backup")) {
|
|
3744
|
+
rejectUnauthorized(res);
|
|
3745
|
+
return;
|
|
3746
|
+
}
|
|
3747
|
+
try {
|
|
3748
|
+
const backup = await config.store.exportBackup();
|
|
3749
|
+
res.writeHead(200, {
|
|
3750
|
+
"Content-Type": "application/octet-stream",
|
|
3751
|
+
"Content-Disposition": `attachment; filename="kora-backup-${Date.now()}.kora"`,
|
|
3752
|
+
"Content-Length": String(backup.byteLength)
|
|
3753
|
+
});
|
|
3754
|
+
res.end(Buffer.from(backup));
|
|
3755
|
+
} catch (error) {
|
|
3756
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3757
|
+
res.end(JSON.stringify({ error: "Backup failed", message: error.message }));
|
|
3758
|
+
}
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
if (url.pathname === "/__kora/backup/import" && req.method === "POST") {
|
|
3762
|
+
if (!isOperationalRequestAllowed(req, "backup")) {
|
|
3763
|
+
rejectUnauthorized(res);
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
try {
|
|
3767
|
+
const body = await readBodyBuffer(req);
|
|
3768
|
+
const merge = url.searchParams.get("merge") === "true";
|
|
3769
|
+
const result = await config.store.importBackup(
|
|
3770
|
+
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
|
3771
|
+
merge
|
|
3772
|
+
);
|
|
3773
|
+
res.writeHead(result.success ? 200 : 400, { "Content-Type": "application/json" });
|
|
3774
|
+
res.end(JSON.stringify(result));
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3777
|
+
res.end(JSON.stringify({ error: "Restore failed", message: error.message }));
|
|
3778
|
+
}
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
const customRoute = config.httpRoutes?.find(
|
|
3782
|
+
(route) => matchesRoutePrefix(url.pathname, route.path)
|
|
3783
|
+
);
|
|
3784
|
+
if (customRoute) {
|
|
3785
|
+
const result = await customRoute.handle({
|
|
3786
|
+
method: req.method ?? "GET",
|
|
3787
|
+
path: url.pathname,
|
|
3788
|
+
body: await readJsonBody(req),
|
|
3789
|
+
headers: req.headers,
|
|
3790
|
+
query: getQuery(url),
|
|
3791
|
+
ip: getClientIp(req)
|
|
3792
|
+
});
|
|
3793
|
+
writeJsonResponse(res, result);
|
|
2472
3794
|
return;
|
|
2473
3795
|
}
|
|
2474
3796
|
let filePath = join(distDir, url.pathname);
|
|
@@ -2530,6 +3852,82 @@ function createProductionServer(config) {
|
|
|
2530
3852
|
}
|
|
2531
3853
|
};
|
|
2532
3854
|
}
|
|
3855
|
+
function renderDashboardHtml(status) {
|
|
3856
|
+
const version = status.version;
|
|
3857
|
+
const uptime = formatUptime(status.uptime);
|
|
3858
|
+
return `<!DOCTYPE html>
|
|
3859
|
+
<html lang="en">
|
|
3860
|
+
<head>
|
|
3861
|
+
<meta charset="UTF-8">
|
|
3862
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3863
|
+
<title>Kora Dashboard</title>
|
|
3864
|
+
<style>
|
|
3865
|
+
* { box-sizing: border-box; margin: 0; padding: 0 }
|
|
3866
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem }
|
|
3867
|
+
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem }
|
|
3868
|
+
.subtitle { color: #64748b; margin-bottom: 2rem; font-size: 0.875rem }
|
|
3869
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem }
|
|
3870
|
+
.card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; padding: 1.25rem }
|
|
3871
|
+
.card .label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 0.5rem }
|
|
3872
|
+
.card .value { font-size: 1.75rem; font-weight: 700; color: #38bdf8 }
|
|
3873
|
+
.card .value.green { color: #4ade80 }
|
|
3874
|
+
.card .value.red { color: #f87171 }
|
|
3875
|
+
.card .value.yellow { color: #fbbf24 }
|
|
3876
|
+
.section-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; margin-top: 1.5rem }
|
|
3877
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.875rem }
|
|
3878
|
+
th { text-align: left; padding: 0.5rem 0.75rem; color: #64748b; font-weight: 500; border-bottom: 1px solid #334155 }
|
|
3879
|
+
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #1e293b }
|
|
3880
|
+
.status-dot { display: inline-block; width: 0.5rem; height: 0.5rem; border-radius: 50%; margin-right: 0.375rem }
|
|
3881
|
+
.status-dot.running { background: #4ade80 }
|
|
3882
|
+
.status-dot.stopped { background: #f87171 }
|
|
3883
|
+
</style>
|
|
3884
|
+
</head>
|
|
3885
|
+
<body>
|
|
3886
|
+
<h1>Kora Sync Server</h1>
|
|
3887
|
+
<p class="subtitle">v${version} · <span class="status-dot running"></span>Running</p>
|
|
3888
|
+
<div class="grid">
|
|
3889
|
+
<div class="card"><div class="label">Uptime</div><div class="value">${uptime}</div></div>
|
|
3890
|
+
<div class="card"><div class="label">Connected Clients</div><div class="value" id="connectedClients">${status.connectedClients}</div></div>
|
|
3891
|
+
<div class="card"><div class="label">Total Operations</div><div class="value" id="totalOperations">${status.totalOperations}</div></div>
|
|
3892
|
+
<div class="card"><div class="label">Peak Connections</div><div class="value green" id="peakConnections">${status.peakConnections}</div></div>
|
|
3893
|
+
<div class="card"><div class="label">Ops Received</div><div class="value" id="opsReceived">${status.operationsReceived}</div></div>
|
|
3894
|
+
<div class="card"><div class="label">Ops Sent</div><div class="value" id="opsSent">${status.operationsSent}</div></div>
|
|
3895
|
+
<div class="card"><div class="label">Errors</div><div class="value ${status.errorCount > 0 ? "red" : "green"}" id="errors">${status.errorCount}</div></div>
|
|
3896
|
+
<div class="card"><div class="label">Schema Version</div><div class="value">${status.schemaVersion}</div></div>
|
|
3897
|
+
</div>
|
|
3898
|
+
<script>
|
|
3899
|
+
(function() {
|
|
3900
|
+
const es = new EventSource('/__kora/events');
|
|
3901
|
+
es.addEventListener('status', (e) => {
|
|
3902
|
+
const s = JSON.parse(e.data);
|
|
3903
|
+
for (const [id, val] of Object.entries({
|
|
3904
|
+
connectedClients: s.connectedClients,
|
|
3905
|
+
totalOperations: s.totalOperations,
|
|
3906
|
+
peakConnections: s.peakConnections,
|
|
3907
|
+
opsReceived: s.operationsReceived,
|
|
3908
|
+
opsSent: s.operationsSent,
|
|
3909
|
+
errors: s.errorCount,
|
|
3910
|
+
})) {
|
|
3911
|
+
const el = document.getElementById(id);
|
|
3912
|
+
if (el) { el.textContent = String(val); el.className = 'value' + (id === 'errors' && val > 0 ? ' red' : id === 'errors' ? ' green' : ''); }
|
|
3913
|
+
}
|
|
3914
|
+
});
|
|
3915
|
+
es.onerror = () => { setTimeout(() => document.location.reload(), 5000); };
|
|
3916
|
+
})();
|
|
3917
|
+
</script>
|
|
3918
|
+
</body>
|
|
3919
|
+
</html>`;
|
|
3920
|
+
}
|
|
3921
|
+
function formatUptime(ms) {
|
|
3922
|
+
const seconds = Math.floor(ms / 1e3);
|
|
3923
|
+
const minutes = Math.floor(seconds / 60);
|
|
3924
|
+
const hours = Math.floor(minutes / 60);
|
|
3925
|
+
const parts = [];
|
|
3926
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
3927
|
+
if (minutes % 60 > 0) parts.push(`${minutes % 60}m`);
|
|
3928
|
+
parts.push(`${seconds % 60}s`);
|
|
3929
|
+
return parts.join(" ");
|
|
3930
|
+
}
|
|
2533
3931
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2534
3932
|
0 && (module.exports = {
|
|
2535
3933
|
AwarenessRelay,
|
|
@@ -2544,9 +3942,13 @@ function createProductionServer(config) {
|
|
|
2544
3942
|
SqliteServerStore,
|
|
2545
3943
|
TokenAuthProvider,
|
|
2546
3944
|
WsServerTransport,
|
|
3945
|
+
createDefaultLogger,
|
|
3946
|
+
createJsonLogger,
|
|
2547
3947
|
createKoraServer,
|
|
2548
3948
|
createPostgresServerStore,
|
|
3949
|
+
createPrettyLogger,
|
|
2549
3950
|
createProductionServer,
|
|
3951
|
+
createSilentLogger,
|
|
2550
3952
|
createSqliteServerStore
|
|
2551
3953
|
});
|
|
2552
3954
|
//# sourceMappingURL=index.cjs.map
|