@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.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}`);