@korajs/server 1.0.0-beta.0 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +431 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +155 -3
- package/dist/index.d.ts +155 -3
- package/dist/index.js +416 -11
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -2067,6 +2067,7 @@ init_cjs_shims();
|
|
|
2067
2067
|
var import_core10 = require("@korajs/core");
|
|
2068
2068
|
var import_internal = require("@korajs/core/internal");
|
|
2069
2069
|
var import_sync2 = require("@korajs/sync");
|
|
2070
|
+
var import_sync3 = require("@korajs/sync");
|
|
2070
2071
|
|
|
2071
2072
|
// src/apply/apply-server-operation.ts
|
|
2072
2073
|
init_cjs_shims();
|
|
@@ -2239,6 +2240,14 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2239
2240
|
};
|
|
2240
2241
|
}
|
|
2241
2242
|
|
|
2243
|
+
// src/auth/no-auth.ts
|
|
2244
|
+
init_cjs_shims();
|
|
2245
|
+
var NoAuthProvider = class {
|
|
2246
|
+
async authenticate(_token) {
|
|
2247
|
+
return { userId: "anonymous" };
|
|
2248
|
+
}
|
|
2249
|
+
};
|
|
2250
|
+
|
|
2242
2251
|
// src/scopes/resolve-session-scopes.ts
|
|
2243
2252
|
init_cjs_shims();
|
|
2244
2253
|
var import_core8 = require("@korajs/core");
|
|
@@ -2368,6 +2377,25 @@ var SessionRateLimiter = class {
|
|
|
2368
2377
|
// src/session/client-session.ts
|
|
2369
2378
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2370
2379
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2380
|
+
var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
|
|
2381
|
+
function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
2382
|
+
if (!auth || auth instanceof NoAuthProvider) {
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
if (resolvedScopes) {
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
if (!schema || Object.keys(schema.collections).length === 0) {
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
if (warnedUnscopedProviders.has(auth)) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
warnedUnscopedProviders.add(auth);
|
|
2395
|
+
console.warn(
|
|
2396
|
+
"[kora] An authenticated session resolved to no sync scopes, so every user will sync every other user's data. Return per-user sync scopes from your auth provider (for example KoraAuthProvider's resolveScopes) to isolate tenants. Note: declaring sync rules in your schema is not enough on its own \u2014 the per-user values come from the auth provider. (This warning is expected for single-tenant apps where all authenticated users are meant to share the same data.)"
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2371
2399
|
var ClientSession = class {
|
|
2372
2400
|
state = "connected";
|
|
2373
2401
|
clientNodeId = null;
|
|
@@ -2386,6 +2414,9 @@ var ClientSession = class {
|
|
|
2386
2414
|
onRelay;
|
|
2387
2415
|
onAwarenessUpdate;
|
|
2388
2416
|
onYjsDocUpdate;
|
|
2417
|
+
onBlobChunkRequest;
|
|
2418
|
+
onBlobChunkResponse;
|
|
2419
|
+
persistBlobChunk;
|
|
2389
2420
|
onClose;
|
|
2390
2421
|
maxOperationBytes;
|
|
2391
2422
|
maxOpsPerMinute;
|
|
@@ -2395,7 +2426,7 @@ var ClientSession = class {
|
|
|
2395
2426
|
this.transport = options.transport;
|
|
2396
2427
|
this.store = options.store;
|
|
2397
2428
|
this.auth = options.auth ?? null;
|
|
2398
|
-
this.serializer = options.serializer ?? new
|
|
2429
|
+
this.serializer = options.serializer ?? new import_sync3.NegotiatedMessageSerializer("json");
|
|
2399
2430
|
this.emitter = options.emitter ?? null;
|
|
2400
2431
|
this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
2401
2432
|
this.schemaVersion = options.schemaVersion ?? DEFAULT_SCHEMA_VERSION;
|
|
@@ -2407,6 +2438,9 @@ var ClientSession = class {
|
|
|
2407
2438
|
this.onRelay = options.onRelay ?? null;
|
|
2408
2439
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2409
2440
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
2441
|
+
this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
|
|
2442
|
+
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2443
|
+
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2410
2444
|
this.onClose = options.onClose ?? null;
|
|
2411
2445
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2412
2446
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
@@ -2512,8 +2546,33 @@ var ClientSession = class {
|
|
|
2512
2546
|
case "yjs-doc-update":
|
|
2513
2547
|
this.handleYjsDocUpdate(message);
|
|
2514
2548
|
break;
|
|
2549
|
+
case "blob-chunk-request":
|
|
2550
|
+
this.onBlobChunkRequest?.(this.sessionId, message);
|
|
2551
|
+
break;
|
|
2552
|
+
case "blob-chunk-response":
|
|
2553
|
+
this.onBlobChunkResponse?.(this.sessionId, message);
|
|
2554
|
+
break;
|
|
2555
|
+
case "blob-chunk-push":
|
|
2556
|
+
await this.handleBlobChunkPush(message);
|
|
2557
|
+
break;
|
|
2515
2558
|
}
|
|
2516
2559
|
}
|
|
2560
|
+
/**
|
|
2561
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
2562
|
+
* verified to hash to the declared hash before storing, so a corrupt or
|
|
2563
|
+
* mislabeled upload is rejected rather than served later as trusted content.
|
|
2564
|
+
*/
|
|
2565
|
+
async handleBlobChunkPush(message) {
|
|
2566
|
+
if (!this.persistBlobChunk) {
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
const bytes = (0, import_sync2.decodeBlobChunkBytes)(message.bytes);
|
|
2570
|
+
const actual = await (0, import_core10.hashBlob)(bytes);
|
|
2571
|
+
if (actual !== message.hash) {
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
await this.persistBlobChunk(message.hash, bytes);
|
|
2575
|
+
}
|
|
2517
2576
|
handleMessageFailure(error) {
|
|
2518
2577
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
2519
2578
|
this.sendError("SYNC_ERROR", reason, true);
|
|
@@ -2547,25 +2606,26 @@ var ClientSession = class {
|
|
|
2547
2606
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2548
2607
|
}
|
|
2549
2608
|
}
|
|
2609
|
+
warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
|
|
2550
2610
|
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2551
|
-
this.syncQuerySubsets = (0,
|
|
2611
|
+
this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
|
|
2552
2612
|
} else {
|
|
2553
2613
|
this.syncQuerySubsets = [];
|
|
2554
2614
|
}
|
|
2555
|
-
this.resumeDeltaCursor = msg.deltaCursor ? (0,
|
|
2615
|
+
this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync3.decodeDeltaCursor)(msg.deltaCursor) : null;
|
|
2556
2616
|
const serverVector = this.store.getVersionVector();
|
|
2557
2617
|
const selectedWireFormat = selectWireFormat(msg.supportedWireFormats);
|
|
2558
2618
|
this.setSerializerWireFormat(selectedWireFormat);
|
|
2559
|
-
if (!(0,
|
|
2619
|
+
if (!(0, import_sync3.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
|
|
2560
2620
|
const { min, max } = this.supportedSchemaVersions;
|
|
2561
2621
|
const response2 = {
|
|
2562
2622
|
type: "handshake-response",
|
|
2563
2623
|
messageId: (0, import_core10.generateUUIDv7)(),
|
|
2564
2624
|
nodeId: this.store.getNodeId(),
|
|
2565
|
-
versionVector: (0,
|
|
2625
|
+
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2566
2626
|
schemaVersion: this.schemaVersion,
|
|
2567
2627
|
accepted: false,
|
|
2568
|
-
rejectReason: `${
|
|
2628
|
+
rejectReason: `${import_sync3.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
|
|
2569
2629
|
supportedSchemaMin: min,
|
|
2570
2630
|
supportedSchemaMax: max,
|
|
2571
2631
|
serverTime: Date.now()
|
|
@@ -2578,11 +2638,14 @@ var ClientSession = class {
|
|
|
2578
2638
|
type: "handshake-response",
|
|
2579
2639
|
messageId: (0, import_core10.generateUUIDv7)(),
|
|
2580
2640
|
nodeId: this.store.getNodeId(),
|
|
2581
|
-
versionVector: (0,
|
|
2641
|
+
versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
|
|
2582
2642
|
schemaVersion: this.schemaVersion,
|
|
2583
2643
|
accepted: true,
|
|
2584
2644
|
selectedWireFormat,
|
|
2585
2645
|
serverTime: Date.now(),
|
|
2646
|
+
// Advertise central blob storage so the client uploads the bytes behind
|
|
2647
|
+
// its blob fields, keeping them available after the author goes offline.
|
|
2648
|
+
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
2586
2649
|
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2587
2650
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
2588
2651
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
@@ -2590,7 +2653,7 @@ var ClientSession = class {
|
|
|
2590
2653
|
this.sendToClient(response);
|
|
2591
2654
|
this.emitter?.emit({ type: "sync:connected", nodeId: msg.nodeId });
|
|
2592
2655
|
this.state = "syncing";
|
|
2593
|
-
const clientVector = (0,
|
|
2656
|
+
const clientVector = (0, import_sync3.wireToVersionVector)(msg.versionVector);
|
|
2594
2657
|
await this.sendDelta(clientVector);
|
|
2595
2658
|
this.state = "streaming";
|
|
2596
2659
|
}
|
|
@@ -2692,7 +2755,7 @@ var ClientSession = class {
|
|
|
2692
2755
|
return;
|
|
2693
2756
|
}
|
|
2694
2757
|
const sorted = (0, import_internal.topologicalSort)(missing);
|
|
2695
|
-
const afterCursor = (0,
|
|
2758
|
+
const afterCursor = (0, import_sync3.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
|
|
2696
2759
|
const totalBatches = Math.ceil(afterCursor.length / this.batchSize);
|
|
2697
2760
|
if (afterCursor.length === 0) {
|
|
2698
2761
|
const emptyBatch = {
|
|
@@ -2710,7 +2773,7 @@ var ClientSession = class {
|
|
|
2710
2773
|
const start = i * this.batchSize;
|
|
2711
2774
|
const batchOps = afterCursor.slice(start, start + this.batchSize);
|
|
2712
2775
|
const serializedOps = batchOps.map((op) => this.serializer.encodeOperation(op));
|
|
2713
|
-
const batchCursor = (0,
|
|
2776
|
+
const batchCursor = (0, import_sync3.createDeltaCursorFromBatch)(batchOps, i);
|
|
2714
2777
|
const batchMsg = {
|
|
2715
2778
|
type: "operation-batch",
|
|
2716
2779
|
messageId: (0, import_core10.generateUUIDv7)(),
|
|
@@ -2718,7 +2781,7 @@ var ClientSession = class {
|
|
|
2718
2781
|
isFinal: i === totalBatches - 1,
|
|
2719
2782
|
batchIndex: i,
|
|
2720
2783
|
totalBatches,
|
|
2721
|
-
...batchCursor ? { cursor: (0,
|
|
2784
|
+
...batchCursor ? { cursor: (0, import_sync3.encodeDeltaCursor)(batchCursor) } : {}
|
|
2722
2785
|
};
|
|
2723
2786
|
this.sendToClient(batchMsg);
|
|
2724
2787
|
this.emitter?.emit({
|
|
@@ -2732,7 +2795,7 @@ var ClientSession = class {
|
|
|
2732
2795
|
if (!operationMatchesScopes(op, this.authContext?.scopes)) {
|
|
2733
2796
|
return false;
|
|
2734
2797
|
}
|
|
2735
|
-
return (0,
|
|
2798
|
+
return (0, import_sync3.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
|
|
2736
2799
|
}
|
|
2737
2800
|
handleAwarenessUpdate(msg) {
|
|
2738
2801
|
this.onAwarenessUpdate?.(this.sessionId, msg);
|
|
@@ -2773,7 +2836,7 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2773
2836
|
init_cjs_shims();
|
|
2774
2837
|
var import_core12 = require("@korajs/core");
|
|
2775
2838
|
var import_internal2 = require("@korajs/core/internal");
|
|
2776
|
-
var
|
|
2839
|
+
var import_sync5 = require("@korajs/sync");
|
|
2777
2840
|
|
|
2778
2841
|
// src/awareness/awareness-relay.ts
|
|
2779
2842
|
init_cjs_shims();
|
|
@@ -2990,6 +3053,114 @@ var ServerMetricsCollector = class {
|
|
|
2990
3053
|
}
|
|
2991
3054
|
};
|
|
2992
3055
|
|
|
3056
|
+
// src/richtext/blob-chunk-relay.ts
|
|
3057
|
+
init_cjs_shims();
|
|
3058
|
+
var import_sync4 = require("@korajs/sync");
|
|
3059
|
+
var BlobChunkRelay = class {
|
|
3060
|
+
clients = /* @__PURE__ */ new Map();
|
|
3061
|
+
/** requestId -> the session that originated the request (to route the answer back). */
|
|
3062
|
+
pending = /* @__PURE__ */ new Map();
|
|
3063
|
+
resolveBlobChunk;
|
|
3064
|
+
constructor(resolveBlobChunk) {
|
|
3065
|
+
this.resolveBlobChunk = resolveBlobChunk ?? null;
|
|
3066
|
+
}
|
|
3067
|
+
addClient(sessionId, transport) {
|
|
3068
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
3069
|
+
}
|
|
3070
|
+
removeClient(sessionId) {
|
|
3071
|
+
this.clients.delete(sessionId);
|
|
3072
|
+
for (const [requestId, originSessionId] of this.pending) {
|
|
3073
|
+
if (originSessionId === sessionId) {
|
|
3074
|
+
this.pending.delete(requestId);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
getClientCount() {
|
|
3079
|
+
return this.clients.size;
|
|
3080
|
+
}
|
|
3081
|
+
getPendingCount() {
|
|
3082
|
+
return this.pending.size;
|
|
3083
|
+
}
|
|
3084
|
+
clear() {
|
|
3085
|
+
this.clients.clear();
|
|
3086
|
+
this.pending.clear();
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Handle an inbound chunk request from a session. Tries the server's own
|
|
3090
|
+
* store first (if configured), otherwise forwards the request to peer
|
|
3091
|
+
* sessions and remembers who to route the answer back to.
|
|
3092
|
+
*/
|
|
3093
|
+
handleRequest(sourceSessionId, message) {
|
|
3094
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
if (this.resolveBlobChunk) {
|
|
3098
|
+
void this.resolveBlobChunk(message.hash).then(
|
|
3099
|
+
(bytes) => {
|
|
3100
|
+
if (bytes !== null) {
|
|
3101
|
+
this.sendResponseTo(sourceSessionId, message.requestId, (0, import_sync4.encodeBlobChunkBytes)(bytes));
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3105
|
+
},
|
|
3106
|
+
() => {
|
|
3107
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3108
|
+
}
|
|
3109
|
+
);
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* Handle an inbound chunk response from a peer. Routes it back to the session
|
|
3116
|
+
* that originally requested it. A "not held" answer (bytes === null) is
|
|
3117
|
+
* ignored so a peer without the chunk does not preempt one that has it; the
|
|
3118
|
+
* requester's own per-request timeout bounds the wait.
|
|
3119
|
+
*/
|
|
3120
|
+
handleResponse(sourceSessionId, message) {
|
|
3121
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (message.bytes === null) {
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
const originSessionId = this.pending.get(message.requestId);
|
|
3128
|
+
if (!originSessionId) {
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
this.pending.delete(message.requestId);
|
|
3132
|
+
this.sendResponseTo(originSessionId, message.requestId, message.bytes);
|
|
3133
|
+
}
|
|
3134
|
+
forwardRequestToPeers(sourceSessionId, message) {
|
|
3135
|
+
this.pending.set(message.requestId, sourceSessionId);
|
|
3136
|
+
for (const [, client] of this.clients) {
|
|
3137
|
+
if (client.sessionId === sourceSessionId) {
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (!client.transport.isConnected()) {
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
client.transport.send(message);
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
sendResponseTo(sessionId, requestId, bytes) {
|
|
3147
|
+
const client = this.clients.get(sessionId);
|
|
3148
|
+
if (!client || !client.transport.isConnected()) {
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
const response = {
|
|
3152
|
+
type: "blob-chunk-response",
|
|
3153
|
+
messageId: generateResponseId(requestId),
|
|
3154
|
+
requestId,
|
|
3155
|
+
bytes
|
|
3156
|
+
};
|
|
3157
|
+
client.transport.send(response);
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
function generateResponseId(requestId) {
|
|
3161
|
+
return `blob-resp-${requestId}`;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
2993
3164
|
// src/richtext/yjs-doc-relay.ts
|
|
2994
3165
|
init_cjs_shims();
|
|
2995
3166
|
var YjsDocRelay = class {
|
|
@@ -3047,6 +3218,8 @@ var KoraSyncServer = class {
|
|
|
3047
3218
|
metrics;
|
|
3048
3219
|
awarenessRelay = new AwarenessRelay();
|
|
3049
3220
|
yjsDocRelay = new YjsDocRelay();
|
|
3221
|
+
blobChunkRelay;
|
|
3222
|
+
persistBlobChunk;
|
|
3050
3223
|
sessions = /* @__PURE__ */ new Map();
|
|
3051
3224
|
httpClients = /* @__PURE__ */ new Map();
|
|
3052
3225
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
@@ -3056,7 +3229,7 @@ var KoraSyncServer = class {
|
|
|
3056
3229
|
constructor(config) {
|
|
3057
3230
|
this.store = config.store;
|
|
3058
3231
|
this.auth = config.auth ?? null;
|
|
3059
|
-
this.serializer = config.serializer ?? new
|
|
3232
|
+
this.serializer = config.serializer ?? new import_sync5.JsonMessageSerializer();
|
|
3060
3233
|
this.emitter = config.emitter ?? null;
|
|
3061
3234
|
this.maxConnections = config.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
|
|
3062
3235
|
this.batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE2;
|
|
@@ -3071,6 +3244,8 @@ var KoraSyncServer = class {
|
|
|
3071
3244
|
this.logger = config.logger ?? createDefaultLogger();
|
|
3072
3245
|
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
3073
3246
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3247
|
+
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3248
|
+
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
3074
3249
|
if (!this.emitter) {
|
|
3075
3250
|
this.emitter = new import_internal2.SimpleEventEmitter();
|
|
3076
3251
|
}
|
|
@@ -3194,6 +3369,7 @@ var KoraSyncServer = class {
|
|
|
3194
3369
|
});
|
|
3195
3370
|
this.awarenessRelay.clear();
|
|
3196
3371
|
this.yjsDocRelay.clear();
|
|
3372
|
+
this.blobChunkRelay.clear();
|
|
3197
3373
|
for (const session of this.sessions.values()) {
|
|
3198
3374
|
session.close("server shutting down");
|
|
3199
3375
|
}
|
|
@@ -3338,12 +3514,20 @@ var KoraSyncServer = class {
|
|
|
3338
3514
|
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3339
3515
|
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3340
3516
|
},
|
|
3517
|
+
onBlobChunkRequest: (sourceSessionId, message) => {
|
|
3518
|
+
this.blobChunkRelay.handleRequest(sourceSessionId, message);
|
|
3519
|
+
},
|
|
3520
|
+
onBlobChunkResponse: (sourceSessionId, message) => {
|
|
3521
|
+
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3522
|
+
},
|
|
3523
|
+
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
3341
3524
|
onClose: (sid) => {
|
|
3342
3525
|
this.handleSessionClose(sid);
|
|
3343
3526
|
}
|
|
3344
3527
|
});
|
|
3345
3528
|
this.sessions.set(sessionId, session);
|
|
3346
3529
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
3530
|
+
this.blobChunkRelay.addClient(sessionId, transport);
|
|
3347
3531
|
session.start();
|
|
3348
3532
|
this.logger.log({
|
|
3349
3533
|
timestamp: Date.now(),
|
|
@@ -3376,6 +3560,61 @@ var KoraSyncServer = class {
|
|
|
3376
3560
|
errorCount: snapshot.errorCount
|
|
3377
3561
|
};
|
|
3378
3562
|
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Apply a server-originated operation (for example one created by a custom
|
|
3565
|
+
* HTTP route) through the same validated pipeline that incoming client
|
|
3566
|
+
* operations use — Tier 2 constraints, referential integrity, and cascade
|
|
3567
|
+
* side effects — then relay every applied operation to connected clients.
|
|
3568
|
+
*
|
|
3569
|
+
* Because the operation did not come from a client session, it is relayed to
|
|
3570
|
+
* ALL sessions (there is no source session to exclude). Each session still
|
|
3571
|
+
* applies its own per-scope visibility filter in `relayOperations`, so a
|
|
3572
|
+
* client only receives the operation if it falls within that client's scope.
|
|
3573
|
+
*
|
|
3574
|
+
* @param op - A fully-formed, server-originated operation to apply
|
|
3575
|
+
* @returns The apply result, including any server-generated side-effect ops
|
|
3576
|
+
*/
|
|
3577
|
+
async applyLocalOperation(op) {
|
|
3578
|
+
const result = await applyServerOperation(this.store, op);
|
|
3579
|
+
if (result.result === "applied" && result.appliedOperations.length > 0) {
|
|
3580
|
+
for (const session of this.sessions.values()) {
|
|
3581
|
+
session.relayOperations(result.appliedOperations);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
return result;
|
|
3585
|
+
}
|
|
3586
|
+
/**
|
|
3587
|
+
* Collect every blob reference still reachable from live records on the server.
|
|
3588
|
+
*
|
|
3589
|
+
* This is the live set for garbage-collecting the server's central blob store:
|
|
3590
|
+
* pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
|
|
3591
|
+
* `@korajs/store` to reclaim bytes no record references any more. Only
|
|
3592
|
+
* collections that declare a `blob` field are scanned.
|
|
3593
|
+
*
|
|
3594
|
+
* @returns Every live blob reference across all collections
|
|
3595
|
+
*/
|
|
3596
|
+
async getLiveBlobRefs() {
|
|
3597
|
+
const schema = this.store.getSchema();
|
|
3598
|
+
if (!schema) {
|
|
3599
|
+
return [];
|
|
3600
|
+
}
|
|
3601
|
+
const refs = [];
|
|
3602
|
+
for (const [name, collection] of Object.entries(schema.collections)) {
|
|
3603
|
+
const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
|
|
3604
|
+
if (!hasBlobField) {
|
|
3605
|
+
continue;
|
|
3606
|
+
}
|
|
3607
|
+
const records = await this.store.materializeCollection(name);
|
|
3608
|
+
for (const record of records) {
|
|
3609
|
+
for (const value of Object.values(record)) {
|
|
3610
|
+
if ((0, import_core12.isBlobRef)(value)) {
|
|
3611
|
+
refs.push(value);
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
return refs;
|
|
3617
|
+
}
|
|
3379
3618
|
/**
|
|
3380
3619
|
* Get the number of currently connected clients.
|
|
3381
3620
|
*/
|
|
@@ -3409,6 +3648,7 @@ var KoraSyncServer = class {
|
|
|
3409
3648
|
this.metrics.recordDisconnection(sessionId);
|
|
3410
3649
|
this.awarenessRelay.removeClient(sessionId);
|
|
3411
3650
|
this.yjsDocRelay.removeClient(sessionId);
|
|
3651
|
+
this.blobChunkRelay.removeClient(sessionId);
|
|
3412
3652
|
this.sessions.delete(sessionId);
|
|
3413
3653
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
3414
3654
|
if (clientId) {
|
|
@@ -3461,14 +3701,6 @@ function normalizeHttpBody(body, contentType) {
|
|
|
3461
3701
|
return body;
|
|
3462
3702
|
}
|
|
3463
3703
|
|
|
3464
|
-
// src/auth/no-auth.ts
|
|
3465
|
-
init_cjs_shims();
|
|
3466
|
-
var NoAuthProvider = class {
|
|
3467
|
-
async authenticate(_token) {
|
|
3468
|
-
return { userId: "anonymous" };
|
|
3469
|
-
}
|
|
3470
|
-
};
|
|
3471
|
-
|
|
3472
3704
|
// src/auth/token-auth.ts
|
|
3473
3705
|
init_cjs_shims();
|
|
3474
3706
|
var TokenAuthProvider = class {
|
|
@@ -3555,6 +3787,162 @@ function createKoraServer(config) {
|
|
|
3555
3787
|
|
|
3556
3788
|
// src/server/production-server.ts
|
|
3557
3789
|
init_cjs_shims();
|
|
3790
|
+
|
|
3791
|
+
// src/server/route-context.ts
|
|
3792
|
+
init_cjs_shims();
|
|
3793
|
+
var import_core13 = require("@korajs/core");
|
|
3794
|
+
function recordMatchesScope(collection, record, scope) {
|
|
3795
|
+
const collectionScope = scope[collection];
|
|
3796
|
+
if (!collectionScope) {
|
|
3797
|
+
return false;
|
|
3798
|
+
}
|
|
3799
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3800
|
+
if (record[field] !== expected) {
|
|
3801
|
+
return false;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
return true;
|
|
3805
|
+
}
|
|
3806
|
+
function pickFields(record, keys) {
|
|
3807
|
+
const picked = {};
|
|
3808
|
+
for (const key of keys) {
|
|
3809
|
+
if (key in record) {
|
|
3810
|
+
picked[key] = record[key];
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
return picked;
|
|
3814
|
+
}
|
|
3815
|
+
async function buildRouteOperation(store, mutation) {
|
|
3816
|
+
const nodeId = store.getNodeId();
|
|
3817
|
+
const clock = new import_core13.HybridLogicalClock(nodeId);
|
|
3818
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3819
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3820
|
+
if (mutation.type === "insert") {
|
|
3821
|
+
return (0, import_core13.createOperation)(
|
|
3822
|
+
{
|
|
3823
|
+
nodeId,
|
|
3824
|
+
type: "insert",
|
|
3825
|
+
collection: mutation.collection,
|
|
3826
|
+
recordId: mutation.recordId ?? (0, import_core13.generateUUIDv7)(),
|
|
3827
|
+
data: mutation.data ?? {},
|
|
3828
|
+
previousData: null,
|
|
3829
|
+
sequenceNumber,
|
|
3830
|
+
causalDeps: [],
|
|
3831
|
+
schemaVersion
|
|
3832
|
+
},
|
|
3833
|
+
clock
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
3836
|
+
if (!mutation.recordId) {
|
|
3837
|
+
throw new RouteMutationError(
|
|
3838
|
+
"MISSING_RECORD_ID",
|
|
3839
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3840
|
+
);
|
|
3841
|
+
}
|
|
3842
|
+
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3843
|
+
if (mutation.type === "update") {
|
|
3844
|
+
const data = mutation.data ?? {};
|
|
3845
|
+
return (0, import_core13.createOperation)(
|
|
3846
|
+
{
|
|
3847
|
+
nodeId,
|
|
3848
|
+
type: "update",
|
|
3849
|
+
collection: mutation.collection,
|
|
3850
|
+
recordId: mutation.recordId,
|
|
3851
|
+
data,
|
|
3852
|
+
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3853
|
+
sequenceNumber,
|
|
3854
|
+
causalDeps: [],
|
|
3855
|
+
schemaVersion
|
|
3856
|
+
},
|
|
3857
|
+
clock
|
|
3858
|
+
);
|
|
3859
|
+
}
|
|
3860
|
+
return (0, import_core13.createOperation)(
|
|
3861
|
+
{
|
|
3862
|
+
nodeId,
|
|
3863
|
+
type: "delete",
|
|
3864
|
+
collection: mutation.collection,
|
|
3865
|
+
recordId: mutation.recordId,
|
|
3866
|
+
data: null,
|
|
3867
|
+
previousData: current ? { ...current } : null,
|
|
3868
|
+
sequenceNumber,
|
|
3869
|
+
causalDeps: [],
|
|
3870
|
+
schemaVersion
|
|
3871
|
+
},
|
|
3872
|
+
clock
|
|
3873
|
+
);
|
|
3874
|
+
}
|
|
3875
|
+
var RouteMutationError = class extends Error {
|
|
3876
|
+
constructor(code, message) {
|
|
3877
|
+
super(message);
|
|
3878
|
+
this.code = code;
|
|
3879
|
+
this.name = "RouteMutationError";
|
|
3880
|
+
}
|
|
3881
|
+
code;
|
|
3882
|
+
};
|
|
3883
|
+
function createRouteContext(server, store) {
|
|
3884
|
+
let mutationTail = Promise.resolve();
|
|
3885
|
+
function serialize(work) {
|
|
3886
|
+
const run = mutationTail.then(work, work);
|
|
3887
|
+
mutationTail = run.then(
|
|
3888
|
+
() => void 0,
|
|
3889
|
+
() => void 0
|
|
3890
|
+
);
|
|
3891
|
+
return run;
|
|
3892
|
+
}
|
|
3893
|
+
return {
|
|
3894
|
+
apply(mutation, options) {
|
|
3895
|
+
return serialize(async () => {
|
|
3896
|
+
let op;
|
|
3897
|
+
try {
|
|
3898
|
+
op = await buildRouteOperation(store, mutation);
|
|
3899
|
+
} catch (error) {
|
|
3900
|
+
if (error instanceof RouteMutationError) {
|
|
3901
|
+
return { ok: false, code: error.code, message: error.message };
|
|
3902
|
+
}
|
|
3903
|
+
throw error;
|
|
3904
|
+
}
|
|
3905
|
+
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3906
|
+
return {
|
|
3907
|
+
ok: false,
|
|
3908
|
+
code: "SCOPE_VIOLATION",
|
|
3909
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3910
|
+
};
|
|
3911
|
+
}
|
|
3912
|
+
const result = await server.applyLocalOperation(op);
|
|
3913
|
+
if (result.result !== "applied") {
|
|
3914
|
+
return {
|
|
3915
|
+
ok: false,
|
|
3916
|
+
code: result.rejection?.code ?? "NOT_APPLIED",
|
|
3917
|
+
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3921
|
+
return { ok: true, operation: op, record };
|
|
3922
|
+
});
|
|
3923
|
+
},
|
|
3924
|
+
async query(collection, options) {
|
|
3925
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
3926
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
3927
|
+
if (!scope) {
|
|
3928
|
+
return records;
|
|
3929
|
+
}
|
|
3930
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3931
|
+
},
|
|
3932
|
+
async findById(collection, id, options) {
|
|
3933
|
+
const record = await store.findRecord(collection, id);
|
|
3934
|
+
if (!record) {
|
|
3935
|
+
return null;
|
|
3936
|
+
}
|
|
3937
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3938
|
+
return null;
|
|
3939
|
+
}
|
|
3940
|
+
return record;
|
|
3941
|
+
}
|
|
3942
|
+
};
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
// src/server/production-server.ts
|
|
3558
3946
|
var MIME_TYPES = {
|
|
3559
3947
|
".html": "text/html",
|
|
3560
3948
|
".js": "text/javascript",
|
|
@@ -3578,6 +3966,7 @@ function createProductionServer(config) {
|
|
|
3578
3966
|
enableDashboard: true,
|
|
3579
3967
|
...config.syncOptions
|
|
3580
3968
|
});
|
|
3969
|
+
const routeContext = createRouteContext(syncServer, config.store);
|
|
3581
3970
|
let httpServer = null;
|
|
3582
3971
|
function getOperationalToken(kind) {
|
|
3583
3972
|
const auth = config.operationalAuth;
|
|
@@ -3664,6 +4053,10 @@ function createProductionServer(config) {
|
|
|
3664
4053
|
const chunks = [];
|
|
3665
4054
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3666
4055
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
4056
|
+
if (!req.readableFlowing) {
|
|
4057
|
+
req.resume();
|
|
4058
|
+
}
|
|
4059
|
+
req.on("error", () => resolve(Buffer.concat(chunks)));
|
|
3667
4060
|
});
|
|
3668
4061
|
}
|
|
3669
4062
|
async function readJsonBody(req) {
|
|
@@ -3720,6 +4113,19 @@ function createProductionServer(config) {
|
|
|
3720
4113
|
const { WebSocketServer } = await import("ws");
|
|
3721
4114
|
const distDir = resolve(staticDir);
|
|
3722
4115
|
httpServer = createServer(async (req, res) => {
|
|
4116
|
+
try {
|
|
4117
|
+
await handleRequest(req, res);
|
|
4118
|
+
} catch (error) {
|
|
4119
|
+
console.error("[kora] Unhandled error in HTTP request handler:", error);
|
|
4120
|
+
if (!res.headersSent) {
|
|
4121
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
4122
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
4123
|
+
} else if (!res.writableEnded) {
|
|
4124
|
+
res.end();
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
});
|
|
4128
|
+
async function handleRequest(req, res) {
|
|
3723
4129
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3724
4130
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
3725
4131
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3847,7 +4253,8 @@ data: ${JSON.stringify(s)}
|
|
|
3847
4253
|
body: await readJsonBody(req),
|
|
3848
4254
|
headers: req.headers,
|
|
3849
4255
|
query: getQuery(url),
|
|
3850
|
-
ip: getClientIp(req)
|
|
4256
|
+
ip: getClientIp(req),
|
|
4257
|
+
kora: routeContext
|
|
3851
4258
|
});
|
|
3852
4259
|
writeJsonResponse(res, result);
|
|
3853
4260
|
return;
|
|
@@ -3881,7 +4288,7 @@ data: ${JSON.stringify(s)}
|
|
|
3881
4288
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
3882
4289
|
res.writeHead(200, { "Content-Type": contentType });
|
|
3883
4290
|
createReadStream(filePath).pipe(res);
|
|
3884
|
-
}
|
|
4291
|
+
}
|
|
3885
4292
|
const wss = new WebSocketServer({ noServer: true });
|
|
3886
4293
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
3887
4294
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|