@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 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 import_sync2.NegotiatedMessageSerializer("json");
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, import_sync2.dedupeQuerySubsets)(msg.syncQueries);
2611
+ this.syncQuerySubsets = (0, import_sync3.dedupeQuerySubsets)(msg.syncQueries);
2552
2612
  } else {
2553
2613
  this.syncQuerySubsets = [];
2554
2614
  }
2555
- this.resumeDeltaCursor = msg.deltaCursor ? (0, import_sync2.decodeDeltaCursor)(msg.deltaCursor) : null;
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, import_sync2.isClientSchemaVersionSupported)(msg.schemaVersion, this.supportedSchemaVersions)) {
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, import_sync2.versionVectorToWire)(serverVector),
2625
+ versionVector: (0, import_sync3.versionVectorToWire)(serverVector),
2566
2626
  schemaVersion: this.schemaVersion,
2567
2627
  accepted: false,
2568
- rejectReason: `${import_sync2.SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
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, import_sync2.versionVectorToWire)(serverVector),
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, import_sync2.wireToVersionVector)(msg.versionVector);
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, import_sync2.sliceOperationsAfterCursor)(sorted, this.resumeDeltaCursor);
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, import_sync2.createDeltaCursorFromBatch)(batchOps, i);
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, import_sync2.encodeDeltaCursor)(batchCursor) } : {}
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, import_sync2.operationMatchesQuerySubsets)(op, this.syncQuerySubsets);
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 import_sync3 = require("@korajs/sync");
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 import_sync3.JsonMessageSerializer();
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}`);