@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.js
CHANGED
|
@@ -1861,8 +1861,9 @@ var WsServerTransport = class {
|
|
|
1861
1861
|
};
|
|
1862
1862
|
|
|
1863
1863
|
// src/session/client-session.ts
|
|
1864
|
-
import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
|
|
1864
|
+
import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
|
|
1865
1865
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1866
|
+
import { decodeBlobChunkBytes } from "@korajs/sync";
|
|
1866
1867
|
import {
|
|
1867
1868
|
NegotiatedMessageSerializer,
|
|
1868
1869
|
SCHEMA_MISMATCH_PREFIX,
|
|
@@ -2043,6 +2044,13 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
2043
2044
|
};
|
|
2044
2045
|
}
|
|
2045
2046
|
|
|
2047
|
+
// src/auth/no-auth.ts
|
|
2048
|
+
var NoAuthProvider = class {
|
|
2049
|
+
async authenticate(_token) {
|
|
2050
|
+
return { userId: "anonymous" };
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2046
2054
|
// src/scopes/resolve-session-scopes.ts
|
|
2047
2055
|
import { buildScopeMap } from "@korajs/core";
|
|
2048
2056
|
function resolveSessionScopes(schema, options) {
|
|
@@ -2168,6 +2176,25 @@ var SessionRateLimiter = class {
|
|
|
2168
2176
|
// src/session/client-session.ts
|
|
2169
2177
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2170
2178
|
var DEFAULT_SCHEMA_VERSION = 1;
|
|
2179
|
+
var warnedUnscopedProviders = /* @__PURE__ */ new WeakSet();
|
|
2180
|
+
function warnIfMultiTenantWithoutScopes(auth, resolvedScopes, schema) {
|
|
2181
|
+
if (!auth || auth instanceof NoAuthProvider) {
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
if (resolvedScopes) {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (!schema || Object.keys(schema.collections).length === 0) {
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
if (warnedUnscopedProviders.has(auth)) {
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
warnedUnscopedProviders.add(auth);
|
|
2194
|
+
console.warn(
|
|
2195
|
+
"[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.)"
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2171
2198
|
var ClientSession = class {
|
|
2172
2199
|
state = "connected";
|
|
2173
2200
|
clientNodeId = null;
|
|
@@ -2186,6 +2213,9 @@ var ClientSession = class {
|
|
|
2186
2213
|
onRelay;
|
|
2187
2214
|
onAwarenessUpdate;
|
|
2188
2215
|
onYjsDocUpdate;
|
|
2216
|
+
onBlobChunkRequest;
|
|
2217
|
+
onBlobChunkResponse;
|
|
2218
|
+
persistBlobChunk;
|
|
2189
2219
|
onClose;
|
|
2190
2220
|
maxOperationBytes;
|
|
2191
2221
|
maxOpsPerMinute;
|
|
@@ -2207,6 +2237,9 @@ var ClientSession = class {
|
|
|
2207
2237
|
this.onRelay = options.onRelay ?? null;
|
|
2208
2238
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2209
2239
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
2240
|
+
this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
|
|
2241
|
+
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2242
|
+
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2210
2243
|
this.onClose = options.onClose ?? null;
|
|
2211
2244
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2212
2245
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
@@ -2312,7 +2345,32 @@ var ClientSession = class {
|
|
|
2312
2345
|
case "yjs-doc-update":
|
|
2313
2346
|
this.handleYjsDocUpdate(message);
|
|
2314
2347
|
break;
|
|
2348
|
+
case "blob-chunk-request":
|
|
2349
|
+
this.onBlobChunkRequest?.(this.sessionId, message);
|
|
2350
|
+
break;
|
|
2351
|
+
case "blob-chunk-response":
|
|
2352
|
+
this.onBlobChunkResponse?.(this.sessionId, message);
|
|
2353
|
+
break;
|
|
2354
|
+
case "blob-chunk-push":
|
|
2355
|
+
await this.handleBlobChunkPush(message);
|
|
2356
|
+
break;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Persist a client-uploaded blob chunk (or manifest) centrally. The bytes are
|
|
2361
|
+
* verified to hash to the declared hash before storing, so a corrupt or
|
|
2362
|
+
* mislabeled upload is rejected rather than served later as trusted content.
|
|
2363
|
+
*/
|
|
2364
|
+
async handleBlobChunkPush(message) {
|
|
2365
|
+
if (!this.persistBlobChunk) {
|
|
2366
|
+
return;
|
|
2315
2367
|
}
|
|
2368
|
+
const bytes = decodeBlobChunkBytes(message.bytes);
|
|
2369
|
+
const actual = await hashBlob(bytes);
|
|
2370
|
+
if (actual !== message.hash) {
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
await this.persistBlobChunk(message.hash, bytes);
|
|
2316
2374
|
}
|
|
2317
2375
|
handleMessageFailure(error) {
|
|
2318
2376
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
@@ -2347,6 +2405,7 @@ var ClientSession = class {
|
|
|
2347
2405
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2348
2406
|
}
|
|
2349
2407
|
}
|
|
2408
|
+
warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
|
|
2350
2409
|
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2351
2410
|
this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
|
|
2352
2411
|
} else {
|
|
@@ -2383,6 +2442,9 @@ var ClientSession = class {
|
|
|
2383
2442
|
accepted: true,
|
|
2384
2443
|
selectedWireFormat,
|
|
2385
2444
|
serverTime: Date.now(),
|
|
2445
|
+
// Advertise central blob storage so the client uploads the bytes behind
|
|
2446
|
+
// its blob fields, keeping them available after the author goes offline.
|
|
2447
|
+
...this.persistBlobChunk ? { blobStorageEnabled: true } : {},
|
|
2386
2448
|
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2387
2449
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
2388
2450
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
@@ -2570,7 +2632,7 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2570
2632
|
}
|
|
2571
2633
|
|
|
2572
2634
|
// src/server/kora-sync-server.ts
|
|
2573
|
-
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
|
|
2635
|
+
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef } from "@korajs/core";
|
|
2574
2636
|
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
2575
2637
|
import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
|
|
2576
2638
|
|
|
@@ -2787,6 +2849,115 @@ var ServerMetricsCollector = class {
|
|
|
2787
2849
|
}
|
|
2788
2850
|
};
|
|
2789
2851
|
|
|
2852
|
+
// src/richtext/blob-chunk-relay.ts
|
|
2853
|
+
import {
|
|
2854
|
+
encodeBlobChunkBytes
|
|
2855
|
+
} from "@korajs/sync";
|
|
2856
|
+
var BlobChunkRelay = class {
|
|
2857
|
+
clients = /* @__PURE__ */ new Map();
|
|
2858
|
+
/** requestId -> the session that originated the request (to route the answer back). */
|
|
2859
|
+
pending = /* @__PURE__ */ new Map();
|
|
2860
|
+
resolveBlobChunk;
|
|
2861
|
+
constructor(resolveBlobChunk) {
|
|
2862
|
+
this.resolveBlobChunk = resolveBlobChunk ?? null;
|
|
2863
|
+
}
|
|
2864
|
+
addClient(sessionId, transport) {
|
|
2865
|
+
this.clients.set(sessionId, { sessionId, transport });
|
|
2866
|
+
}
|
|
2867
|
+
removeClient(sessionId) {
|
|
2868
|
+
this.clients.delete(sessionId);
|
|
2869
|
+
for (const [requestId, originSessionId] of this.pending) {
|
|
2870
|
+
if (originSessionId === sessionId) {
|
|
2871
|
+
this.pending.delete(requestId);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
getClientCount() {
|
|
2876
|
+
return this.clients.size;
|
|
2877
|
+
}
|
|
2878
|
+
getPendingCount() {
|
|
2879
|
+
return this.pending.size;
|
|
2880
|
+
}
|
|
2881
|
+
clear() {
|
|
2882
|
+
this.clients.clear();
|
|
2883
|
+
this.pending.clear();
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* Handle an inbound chunk request from a session. Tries the server's own
|
|
2887
|
+
* store first (if configured), otherwise forwards the request to peer
|
|
2888
|
+
* sessions and remembers who to route the answer back to.
|
|
2889
|
+
*/
|
|
2890
|
+
handleRequest(sourceSessionId, message) {
|
|
2891
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
if (this.resolveBlobChunk) {
|
|
2895
|
+
void this.resolveBlobChunk(message.hash).then(
|
|
2896
|
+
(bytes) => {
|
|
2897
|
+
if (bytes !== null) {
|
|
2898
|
+
this.sendResponseTo(sourceSessionId, message.requestId, encodeBlobChunkBytes(bytes));
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2902
|
+
},
|
|
2903
|
+
() => {
|
|
2904
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2905
|
+
}
|
|
2906
|
+
);
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
this.forwardRequestToPeers(sourceSessionId, message);
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Handle an inbound chunk response from a peer. Routes it back to the session
|
|
2913
|
+
* that originally requested it. A "not held" answer (bytes === null) is
|
|
2914
|
+
* ignored so a peer without the chunk does not preempt one that has it; the
|
|
2915
|
+
* requester's own per-request timeout bounds the wait.
|
|
2916
|
+
*/
|
|
2917
|
+
handleResponse(sourceSessionId, message) {
|
|
2918
|
+
if (!this.clients.has(sourceSessionId)) {
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (message.bytes === null) {
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
const originSessionId = this.pending.get(message.requestId);
|
|
2925
|
+
if (!originSessionId) {
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
this.pending.delete(message.requestId);
|
|
2929
|
+
this.sendResponseTo(originSessionId, message.requestId, message.bytes);
|
|
2930
|
+
}
|
|
2931
|
+
forwardRequestToPeers(sourceSessionId, message) {
|
|
2932
|
+
this.pending.set(message.requestId, sourceSessionId);
|
|
2933
|
+
for (const [, client] of this.clients) {
|
|
2934
|
+
if (client.sessionId === sourceSessionId) {
|
|
2935
|
+
continue;
|
|
2936
|
+
}
|
|
2937
|
+
if (!client.transport.isConnected()) {
|
|
2938
|
+
continue;
|
|
2939
|
+
}
|
|
2940
|
+
client.transport.send(message);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
sendResponseTo(sessionId, requestId, bytes) {
|
|
2944
|
+
const client = this.clients.get(sessionId);
|
|
2945
|
+
if (!client || !client.transport.isConnected()) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const response = {
|
|
2949
|
+
type: "blob-chunk-response",
|
|
2950
|
+
messageId: generateResponseId(requestId),
|
|
2951
|
+
requestId,
|
|
2952
|
+
bytes
|
|
2953
|
+
};
|
|
2954
|
+
client.transport.send(response);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
function generateResponseId(requestId) {
|
|
2958
|
+
return `blob-resp-${requestId}`;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2790
2961
|
// src/richtext/yjs-doc-relay.ts
|
|
2791
2962
|
var YjsDocRelay = class {
|
|
2792
2963
|
clients = /* @__PURE__ */ new Map();
|
|
@@ -2843,6 +3014,8 @@ var KoraSyncServer = class {
|
|
|
2843
3014
|
metrics;
|
|
2844
3015
|
awarenessRelay = new AwarenessRelay();
|
|
2845
3016
|
yjsDocRelay = new YjsDocRelay();
|
|
3017
|
+
blobChunkRelay;
|
|
3018
|
+
persistBlobChunk;
|
|
2846
3019
|
sessions = /* @__PURE__ */ new Map();
|
|
2847
3020
|
httpClients = /* @__PURE__ */ new Map();
|
|
2848
3021
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
@@ -2867,6 +3040,8 @@ var KoraSyncServer = class {
|
|
|
2867
3040
|
this.logger = config.logger ?? createDefaultLogger();
|
|
2868
3041
|
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
2869
3042
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3043
|
+
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3044
|
+
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
2870
3045
|
if (!this.emitter) {
|
|
2871
3046
|
this.emitter = new SimpleEventEmitter();
|
|
2872
3047
|
}
|
|
@@ -2990,6 +3165,7 @@ var KoraSyncServer = class {
|
|
|
2990
3165
|
});
|
|
2991
3166
|
this.awarenessRelay.clear();
|
|
2992
3167
|
this.yjsDocRelay.clear();
|
|
3168
|
+
this.blobChunkRelay.clear();
|
|
2993
3169
|
for (const session of this.sessions.values()) {
|
|
2994
3170
|
session.close("server shutting down");
|
|
2995
3171
|
}
|
|
@@ -3134,12 +3310,20 @@ var KoraSyncServer = class {
|
|
|
3134
3310
|
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3135
3311
|
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3136
3312
|
},
|
|
3313
|
+
onBlobChunkRequest: (sourceSessionId, message) => {
|
|
3314
|
+
this.blobChunkRelay.handleRequest(sourceSessionId, message);
|
|
3315
|
+
},
|
|
3316
|
+
onBlobChunkResponse: (sourceSessionId, message) => {
|
|
3317
|
+
this.blobChunkRelay.handleResponse(sourceSessionId, message);
|
|
3318
|
+
},
|
|
3319
|
+
...this.persistBlobChunk ? { persistBlobChunk: this.persistBlobChunk } : {},
|
|
3137
3320
|
onClose: (sid) => {
|
|
3138
3321
|
this.handleSessionClose(sid);
|
|
3139
3322
|
}
|
|
3140
3323
|
});
|
|
3141
3324
|
this.sessions.set(sessionId, session);
|
|
3142
3325
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
3326
|
+
this.blobChunkRelay.addClient(sessionId, transport);
|
|
3143
3327
|
session.start();
|
|
3144
3328
|
this.logger.log({
|
|
3145
3329
|
timestamp: Date.now(),
|
|
@@ -3172,6 +3356,61 @@ var KoraSyncServer = class {
|
|
|
3172
3356
|
errorCount: snapshot.errorCount
|
|
3173
3357
|
};
|
|
3174
3358
|
}
|
|
3359
|
+
/**
|
|
3360
|
+
* Apply a server-originated operation (for example one created by a custom
|
|
3361
|
+
* HTTP route) through the same validated pipeline that incoming client
|
|
3362
|
+
* operations use — Tier 2 constraints, referential integrity, and cascade
|
|
3363
|
+
* side effects — then relay every applied operation to connected clients.
|
|
3364
|
+
*
|
|
3365
|
+
* Because the operation did not come from a client session, it is relayed to
|
|
3366
|
+
* ALL sessions (there is no source session to exclude). Each session still
|
|
3367
|
+
* applies its own per-scope visibility filter in `relayOperations`, so a
|
|
3368
|
+
* client only receives the operation if it falls within that client's scope.
|
|
3369
|
+
*
|
|
3370
|
+
* @param op - A fully-formed, server-originated operation to apply
|
|
3371
|
+
* @returns The apply result, including any server-generated side-effect ops
|
|
3372
|
+
*/
|
|
3373
|
+
async applyLocalOperation(op) {
|
|
3374
|
+
const result = await applyServerOperation(this.store, op);
|
|
3375
|
+
if (result.result === "applied" && result.appliedOperations.length > 0) {
|
|
3376
|
+
for (const session of this.sessions.values()) {
|
|
3377
|
+
session.relayOperations(result.appliedOperations);
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return result;
|
|
3381
|
+
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Collect every blob reference still reachable from live records on the server.
|
|
3384
|
+
*
|
|
3385
|
+
* This is the live set for garbage-collecting the server's central blob store:
|
|
3386
|
+
* pass the result to `collectBlobGarbage(blobStore, liveRefs)` from
|
|
3387
|
+
* `@korajs/store` to reclaim bytes no record references any more. Only
|
|
3388
|
+
* collections that declare a `blob` field are scanned.
|
|
3389
|
+
*
|
|
3390
|
+
* @returns Every live blob reference across all collections
|
|
3391
|
+
*/
|
|
3392
|
+
async getLiveBlobRefs() {
|
|
3393
|
+
const schema = this.store.getSchema();
|
|
3394
|
+
if (!schema) {
|
|
3395
|
+
return [];
|
|
3396
|
+
}
|
|
3397
|
+
const refs = [];
|
|
3398
|
+
for (const [name, collection] of Object.entries(schema.collections)) {
|
|
3399
|
+
const hasBlobField = Object.values(collection.fields).some((field) => field.kind === "blob");
|
|
3400
|
+
if (!hasBlobField) {
|
|
3401
|
+
continue;
|
|
3402
|
+
}
|
|
3403
|
+
const records = await this.store.materializeCollection(name);
|
|
3404
|
+
for (const record of records) {
|
|
3405
|
+
for (const value of Object.values(record)) {
|
|
3406
|
+
if (isBlobRef(value)) {
|
|
3407
|
+
refs.push(value);
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
return refs;
|
|
3413
|
+
}
|
|
3175
3414
|
/**
|
|
3176
3415
|
* Get the number of currently connected clients.
|
|
3177
3416
|
*/
|
|
@@ -3205,6 +3444,7 @@ var KoraSyncServer = class {
|
|
|
3205
3444
|
this.metrics.recordDisconnection(sessionId);
|
|
3206
3445
|
this.awarenessRelay.removeClient(sessionId);
|
|
3207
3446
|
this.yjsDocRelay.removeClient(sessionId);
|
|
3447
|
+
this.blobChunkRelay.removeClient(sessionId);
|
|
3208
3448
|
this.sessions.delete(sessionId);
|
|
3209
3449
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
3210
3450
|
if (clientId) {
|
|
@@ -3257,13 +3497,6 @@ function normalizeHttpBody(body, contentType) {
|
|
|
3257
3497
|
return body;
|
|
3258
3498
|
}
|
|
3259
3499
|
|
|
3260
|
-
// src/auth/no-auth.ts
|
|
3261
|
-
var NoAuthProvider = class {
|
|
3262
|
-
async authenticate(_token) {
|
|
3263
|
-
return { userId: "anonymous" };
|
|
3264
|
-
}
|
|
3265
|
-
};
|
|
3266
|
-
|
|
3267
3500
|
// src/auth/token-auth.ts
|
|
3268
3501
|
var TokenAuthProvider = class {
|
|
3269
3502
|
validate;
|
|
@@ -3344,6 +3577,159 @@ function createKoraServer(config) {
|
|
|
3344
3577
|
return new KoraSyncServer(config);
|
|
3345
3578
|
}
|
|
3346
3579
|
|
|
3580
|
+
// src/server/route-context.ts
|
|
3581
|
+
import { HybridLogicalClock as HybridLogicalClock2, createOperation as createOperation2, generateUUIDv7 as generateUUIDv77 } from "@korajs/core";
|
|
3582
|
+
function recordMatchesScope(collection, record, scope) {
|
|
3583
|
+
const collectionScope = scope[collection];
|
|
3584
|
+
if (!collectionScope) {
|
|
3585
|
+
return false;
|
|
3586
|
+
}
|
|
3587
|
+
for (const [field, expected] of Object.entries(collectionScope)) {
|
|
3588
|
+
if (record[field] !== expected) {
|
|
3589
|
+
return false;
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
return true;
|
|
3593
|
+
}
|
|
3594
|
+
function pickFields(record, keys) {
|
|
3595
|
+
const picked = {};
|
|
3596
|
+
for (const key of keys) {
|
|
3597
|
+
if (key in record) {
|
|
3598
|
+
picked[key] = record[key];
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
return picked;
|
|
3602
|
+
}
|
|
3603
|
+
async function buildRouteOperation(store, mutation) {
|
|
3604
|
+
const nodeId = store.getNodeId();
|
|
3605
|
+
const clock = new HybridLogicalClock2(nodeId);
|
|
3606
|
+
const schemaVersion = store.getSchema()?.version ?? 1;
|
|
3607
|
+
const sequenceNumber = nextServerSequenceNumber(store);
|
|
3608
|
+
if (mutation.type === "insert") {
|
|
3609
|
+
return createOperation2(
|
|
3610
|
+
{
|
|
3611
|
+
nodeId,
|
|
3612
|
+
type: "insert",
|
|
3613
|
+
collection: mutation.collection,
|
|
3614
|
+
recordId: mutation.recordId ?? generateUUIDv77(),
|
|
3615
|
+
data: mutation.data ?? {},
|
|
3616
|
+
previousData: null,
|
|
3617
|
+
sequenceNumber,
|
|
3618
|
+
causalDeps: [],
|
|
3619
|
+
schemaVersion
|
|
3620
|
+
},
|
|
3621
|
+
clock
|
|
3622
|
+
);
|
|
3623
|
+
}
|
|
3624
|
+
if (!mutation.recordId) {
|
|
3625
|
+
throw new RouteMutationError(
|
|
3626
|
+
"MISSING_RECORD_ID",
|
|
3627
|
+
`A ${mutation.type} mutation on "${mutation.collection}" requires a recordId.`
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
const current = await store.findRecord(mutation.collection, mutation.recordId);
|
|
3631
|
+
if (mutation.type === "update") {
|
|
3632
|
+
const data = mutation.data ?? {};
|
|
3633
|
+
return createOperation2(
|
|
3634
|
+
{
|
|
3635
|
+
nodeId,
|
|
3636
|
+
type: "update",
|
|
3637
|
+
collection: mutation.collection,
|
|
3638
|
+
recordId: mutation.recordId,
|
|
3639
|
+
data,
|
|
3640
|
+
previousData: current ? pickFields(current, Object.keys(data)) : {},
|
|
3641
|
+
sequenceNumber,
|
|
3642
|
+
causalDeps: [],
|
|
3643
|
+
schemaVersion
|
|
3644
|
+
},
|
|
3645
|
+
clock
|
|
3646
|
+
);
|
|
3647
|
+
}
|
|
3648
|
+
return createOperation2(
|
|
3649
|
+
{
|
|
3650
|
+
nodeId,
|
|
3651
|
+
type: "delete",
|
|
3652
|
+
collection: mutation.collection,
|
|
3653
|
+
recordId: mutation.recordId,
|
|
3654
|
+
data: null,
|
|
3655
|
+
previousData: current ? { ...current } : null,
|
|
3656
|
+
sequenceNumber,
|
|
3657
|
+
causalDeps: [],
|
|
3658
|
+
schemaVersion
|
|
3659
|
+
},
|
|
3660
|
+
clock
|
|
3661
|
+
);
|
|
3662
|
+
}
|
|
3663
|
+
var RouteMutationError = class extends Error {
|
|
3664
|
+
constructor(code, message) {
|
|
3665
|
+
super(message);
|
|
3666
|
+
this.code = code;
|
|
3667
|
+
this.name = "RouteMutationError";
|
|
3668
|
+
}
|
|
3669
|
+
code;
|
|
3670
|
+
};
|
|
3671
|
+
function createRouteContext(server, store) {
|
|
3672
|
+
let mutationTail = Promise.resolve();
|
|
3673
|
+
function serialize(work) {
|
|
3674
|
+
const run = mutationTail.then(work, work);
|
|
3675
|
+
mutationTail = run.then(
|
|
3676
|
+
() => void 0,
|
|
3677
|
+
() => void 0
|
|
3678
|
+
);
|
|
3679
|
+
return run;
|
|
3680
|
+
}
|
|
3681
|
+
return {
|
|
3682
|
+
apply(mutation, options) {
|
|
3683
|
+
return serialize(async () => {
|
|
3684
|
+
let op;
|
|
3685
|
+
try {
|
|
3686
|
+
op = await buildRouteOperation(store, mutation);
|
|
3687
|
+
} catch (error) {
|
|
3688
|
+
if (error instanceof RouteMutationError) {
|
|
3689
|
+
return { ok: false, code: error.code, message: error.message };
|
|
3690
|
+
}
|
|
3691
|
+
throw error;
|
|
3692
|
+
}
|
|
3693
|
+
if (options?.scope && !operationMatchesScopes(op, options.scope)) {
|
|
3694
|
+
return {
|
|
3695
|
+
ok: false,
|
|
3696
|
+
code: "SCOPE_VIOLATION",
|
|
3697
|
+
message: `Mutation on "${mutation.collection}" is outside the provided scope.`
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
const result = await server.applyLocalOperation(op);
|
|
3701
|
+
if (result.result !== "applied") {
|
|
3702
|
+
return {
|
|
3703
|
+
ok: false,
|
|
3704
|
+
code: result.rejection?.code ?? "NOT_APPLIED",
|
|
3705
|
+
message: result.rejection?.message ?? `Operation on "${mutation.collection}" was not applied (${result.result}).`
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
const record = await store.findRecord(mutation.collection, op.recordId);
|
|
3709
|
+
return { ok: true, operation: op, record };
|
|
3710
|
+
});
|
|
3711
|
+
},
|
|
3712
|
+
async query(collection, options) {
|
|
3713
|
+
const { scope, ...queryOptions } = options ?? {};
|
|
3714
|
+
const records = await store.queryCollection(collection, queryOptions);
|
|
3715
|
+
if (!scope) {
|
|
3716
|
+
return records;
|
|
3717
|
+
}
|
|
3718
|
+
return records.filter((record) => recordMatchesScope(collection, record, scope));
|
|
3719
|
+
},
|
|
3720
|
+
async findById(collection, id, options) {
|
|
3721
|
+
const record = await store.findRecord(collection, id);
|
|
3722
|
+
if (!record) {
|
|
3723
|
+
return null;
|
|
3724
|
+
}
|
|
3725
|
+
if (options?.scope && !recordMatchesScope(collection, record, options.scope)) {
|
|
3726
|
+
return null;
|
|
3727
|
+
}
|
|
3728
|
+
return record;
|
|
3729
|
+
}
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3347
3733
|
// src/server/production-server.ts
|
|
3348
3734
|
var MIME_TYPES = {
|
|
3349
3735
|
".html": "text/html",
|
|
@@ -3368,6 +3754,7 @@ function createProductionServer(config) {
|
|
|
3368
3754
|
enableDashboard: true,
|
|
3369
3755
|
...config.syncOptions
|
|
3370
3756
|
});
|
|
3757
|
+
const routeContext = createRouteContext(syncServer, config.store);
|
|
3371
3758
|
let httpServer = null;
|
|
3372
3759
|
function getOperationalToken(kind) {
|
|
3373
3760
|
const auth = config.operationalAuth;
|
|
@@ -3454,6 +3841,10 @@ function createProductionServer(config) {
|
|
|
3454
3841
|
const chunks = [];
|
|
3455
3842
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3456
3843
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3844
|
+
if (!req.readableFlowing) {
|
|
3845
|
+
req.resume();
|
|
3846
|
+
}
|
|
3847
|
+
req.on("error", () => resolve(Buffer.concat(chunks)));
|
|
3457
3848
|
});
|
|
3458
3849
|
}
|
|
3459
3850
|
async function readJsonBody(req) {
|
|
@@ -3510,6 +3901,19 @@ function createProductionServer(config) {
|
|
|
3510
3901
|
const { WebSocketServer } = await import("ws");
|
|
3511
3902
|
const distDir = resolve(staticDir);
|
|
3512
3903
|
httpServer = createServer(async (req, res) => {
|
|
3904
|
+
try {
|
|
3905
|
+
await handleRequest(req, res);
|
|
3906
|
+
} catch (error) {
|
|
3907
|
+
console.error("[kora] Unhandled error in HTTP request handler:", error);
|
|
3908
|
+
if (!res.headersSent) {
|
|
3909
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3910
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
3911
|
+
} else if (!res.writableEnded) {
|
|
3912
|
+
res.end();
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
async function handleRequest(req, res) {
|
|
3513
3917
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3514
3918
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
3515
3919
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3637,7 +4041,8 @@ data: ${JSON.stringify(s)}
|
|
|
3637
4041
|
body: await readJsonBody(req),
|
|
3638
4042
|
headers: req.headers,
|
|
3639
4043
|
query: getQuery(url),
|
|
3640
|
-
ip: getClientIp(req)
|
|
4044
|
+
ip: getClientIp(req),
|
|
4045
|
+
kora: routeContext
|
|
3641
4046
|
});
|
|
3642
4047
|
writeJsonResponse(res, result);
|
|
3643
4048
|
return;
|
|
@@ -3671,7 +4076,7 @@ data: ${JSON.stringify(s)}
|
|
|
3671
4076
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
3672
4077
|
res.writeHead(200, { "Content-Type": contentType });
|
|
3673
4078
|
createReadStream(filePath).pipe(res);
|
|
3674
|
-
}
|
|
4079
|
+
}
|
|
3675
4080
|
const wss = new WebSocketServer({ noServer: true });
|
|
3676
4081
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
3677
4082
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|