@korajs/server 0.6.1 → 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/LICENSE +21 -0
- package/dist/index.cjs +527 -61
- 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 +477 -13
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -58,6 +58,7 @@ function createDefaultLogger() {
|
|
|
58
58
|
import { generateUUIDv7 } from "@korajs/core";
|
|
59
59
|
|
|
60
60
|
// src/store/materialization.ts
|
|
61
|
+
import { StorageError } from "@korajs/core";
|
|
61
62
|
function fieldTypeToSql(descriptor, dialect) {
|
|
62
63
|
switch (descriptor.kind) {
|
|
63
64
|
case "string":
|
|
@@ -153,6 +154,52 @@ function replayOperationsForRecord(ops) {
|
|
|
153
154
|
}
|
|
154
155
|
return deleted ? null : record;
|
|
155
156
|
}
|
|
157
|
+
var BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
158
|
+
var BASE64_REVERSE = new Map(
|
|
159
|
+
Array.from(BASE64_ALPHABET, (char, index3) => [char, index3])
|
|
160
|
+
);
|
|
161
|
+
function base64ToBytes(base64) {
|
|
162
|
+
const cleaned = base64.replace(/=+$/, "");
|
|
163
|
+
const out = new Uint8Array(Math.floor(cleaned.length * 6 / 8));
|
|
164
|
+
let buffer = 0;
|
|
165
|
+
let bits = 0;
|
|
166
|
+
let index3 = 0;
|
|
167
|
+
for (const char of cleaned) {
|
|
168
|
+
const value = BASE64_REVERSE.get(char);
|
|
169
|
+
if (value === void 0) {
|
|
170
|
+
throw new StorageError(`Invalid base64 character "${char}" in tagged richtext value`, {
|
|
171
|
+
char
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
buffer = buffer << 6 | value;
|
|
175
|
+
bits += 6;
|
|
176
|
+
if (bits >= 8) {
|
|
177
|
+
bits -= 8;
|
|
178
|
+
out[index3] = buffer >> bits & 255;
|
|
179
|
+
index3 += 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function decodeRichtextColumnValue(value) {
|
|
185
|
+
if (typeof value !== "object" || value === null || ArrayBuffer.isView(value)) {
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
const record = value;
|
|
189
|
+
if (Object.keys(record).length === 1 && typeof record.$koraBytes === "string") {
|
|
190
|
+
return base64ToBytes(record.$koraBytes);
|
|
191
|
+
}
|
|
192
|
+
const keys = Object.keys(record);
|
|
193
|
+
if (keys.length > 0 && keys.every((k, i) => k === String(i) && typeof record[k] === "number")) {
|
|
194
|
+
const bytes = new Uint8Array(keys.length);
|
|
195
|
+
for (let i = 0; i < keys.length; i++) {
|
|
196
|
+
const byte = record[String(i)];
|
|
197
|
+
bytes[i] = typeof byte === "number" ? byte & 255 : 0;
|
|
198
|
+
}
|
|
199
|
+
return bytes;
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
156
203
|
function serializeFieldValue(value, descriptor) {
|
|
157
204
|
if (value === null || value === void 0) return null;
|
|
158
205
|
switch (descriptor.kind) {
|
|
@@ -160,6 +207,8 @@ function serializeFieldValue(value, descriptor) {
|
|
|
160
207
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
161
208
|
case "boolean":
|
|
162
209
|
return value ? 1 : 0;
|
|
210
|
+
case "richtext":
|
|
211
|
+
return decodeRichtextColumnValue(value);
|
|
163
212
|
default:
|
|
164
213
|
return value;
|
|
165
214
|
}
|
|
@@ -1812,8 +1861,9 @@ var WsServerTransport = class {
|
|
|
1812
1861
|
};
|
|
1813
1862
|
|
|
1814
1863
|
// src/session/client-session.ts
|
|
1815
|
-
import { generateUUIDv7 as generateUUIDv74 } from "@korajs/core";
|
|
1864
|
+
import { generateUUIDv7 as generateUUIDv74, hashBlob } from "@korajs/core";
|
|
1816
1865
|
import { topologicalSort } from "@korajs/core/internal";
|
|
1866
|
+
import { decodeBlobChunkBytes } from "@korajs/sync";
|
|
1817
1867
|
import {
|
|
1818
1868
|
NegotiatedMessageSerializer,
|
|
1819
1869
|
SCHEMA_MISMATCH_PREFIX,
|
|
@@ -1994,6 +2044,13 @@ async function applyServerOperation(store, op, relationLookup) {
|
|
|
1994
2044
|
};
|
|
1995
2045
|
}
|
|
1996
2046
|
|
|
2047
|
+
// src/auth/no-auth.ts
|
|
2048
|
+
var NoAuthProvider = class {
|
|
2049
|
+
async authenticate(_token) {
|
|
2050
|
+
return { userId: "anonymous" };
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
|
|
1997
2054
|
// src/scopes/resolve-session-scopes.ts
|
|
1998
2055
|
import { buildScopeMap } from "@korajs/core";
|
|
1999
2056
|
function resolveSessionScopes(schema, options) {
|
|
@@ -2060,9 +2117,17 @@ function asRecord(value) {
|
|
|
2060
2117
|
}
|
|
2061
2118
|
|
|
2062
2119
|
// src/session/operation-validation.ts
|
|
2120
|
+
import { MAX_LOGICAL } from "@korajs/core";
|
|
2063
2121
|
var SERVER_MAX_TIMESTAMP_FUTURE_MS = 6e4;
|
|
2064
2122
|
function isOperationTimestampValid(op, now = Date.now()) {
|
|
2065
|
-
|
|
2123
|
+
const { wallTime, logical } = op.timestamp;
|
|
2124
|
+
if (!Number.isInteger(wallTime) || wallTime < 0) {
|
|
2125
|
+
return false;
|
|
2126
|
+
}
|
|
2127
|
+
if (!Number.isInteger(logical) || logical < 0 || logical > MAX_LOGICAL) {
|
|
2128
|
+
return false;
|
|
2129
|
+
}
|
|
2130
|
+
return wallTime <= now + SERVER_MAX_TIMESTAMP_FUTURE_MS;
|
|
2066
2131
|
}
|
|
2067
2132
|
|
|
2068
2133
|
// src/session/session-operation-limits.ts
|
|
@@ -2111,6 +2176,25 @@ var SessionRateLimiter = class {
|
|
|
2111
2176
|
// src/session/client-session.ts
|
|
2112
2177
|
var DEFAULT_BATCH_SIZE = 100;
|
|
2113
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
|
+
}
|
|
2114
2198
|
var ClientSession = class {
|
|
2115
2199
|
state = "connected";
|
|
2116
2200
|
clientNodeId = null;
|
|
@@ -2129,6 +2213,9 @@ var ClientSession = class {
|
|
|
2129
2213
|
onRelay;
|
|
2130
2214
|
onAwarenessUpdate;
|
|
2131
2215
|
onYjsDocUpdate;
|
|
2216
|
+
onBlobChunkRequest;
|
|
2217
|
+
onBlobChunkResponse;
|
|
2218
|
+
persistBlobChunk;
|
|
2132
2219
|
onClose;
|
|
2133
2220
|
maxOperationBytes;
|
|
2134
2221
|
maxOpsPerMinute;
|
|
@@ -2150,6 +2237,9 @@ var ClientSession = class {
|
|
|
2150
2237
|
this.onRelay = options.onRelay ?? null;
|
|
2151
2238
|
this.onAwarenessUpdate = options.onAwarenessUpdate ?? null;
|
|
2152
2239
|
this.onYjsDocUpdate = options.onYjsDocUpdate ?? null;
|
|
2240
|
+
this.onBlobChunkRequest = options.onBlobChunkRequest ?? null;
|
|
2241
|
+
this.onBlobChunkResponse = options.onBlobChunkResponse ?? null;
|
|
2242
|
+
this.persistBlobChunk = options.persistBlobChunk ?? null;
|
|
2153
2243
|
this.onClose = options.onClose ?? null;
|
|
2154
2244
|
this.maxOperationBytes = options.maxOperationBytes ?? DEFAULT_MAX_OPERATION_BYTES;
|
|
2155
2245
|
this.maxOpsPerMinute = options.maxOpsPerMinute ?? DEFAULT_MAX_OPS_PER_MINUTE;
|
|
@@ -2255,7 +2345,32 @@ var ClientSession = class {
|
|
|
2255
2345
|
case "yjs-doc-update":
|
|
2256
2346
|
this.handleYjsDocUpdate(message);
|
|
2257
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;
|
|
2367
|
+
}
|
|
2368
|
+
const bytes = decodeBlobChunkBytes(message.bytes);
|
|
2369
|
+
const actual = await hashBlob(bytes);
|
|
2370
|
+
if (actual !== message.hash) {
|
|
2371
|
+
return;
|
|
2258
2372
|
}
|
|
2373
|
+
await this.persistBlobChunk(message.hash, bytes);
|
|
2259
2374
|
}
|
|
2260
2375
|
handleMessageFailure(error) {
|
|
2261
2376
|
const reason = error instanceof Error ? error.message : "Message handling failed";
|
|
@@ -2290,6 +2405,7 @@ var ClientSession = class {
|
|
|
2290
2405
|
this.authContext = { userId: msg.nodeId, scopes: resolvedScopes };
|
|
2291
2406
|
}
|
|
2292
2407
|
}
|
|
2408
|
+
warnIfMultiTenantWithoutScopes(this.auth, resolvedScopes, this.store.getSchema());
|
|
2293
2409
|
if (msg.syncQueries && msg.syncQueries.length > 0) {
|
|
2294
2410
|
this.syncQuerySubsets = dedupeQuerySubsets(msg.syncQueries);
|
|
2295
2411
|
} else {
|
|
@@ -2310,7 +2426,8 @@ var ClientSession = class {
|
|
|
2310
2426
|
accepted: false,
|
|
2311
2427
|
rejectReason: `${SCHEMA_MISMATCH_PREFIX}: client schema version ${msg.schemaVersion} not in supported range [${min}, ${max}]`,
|
|
2312
2428
|
supportedSchemaMin: min,
|
|
2313
|
-
supportedSchemaMax: max
|
|
2429
|
+
supportedSchemaMax: max,
|
|
2430
|
+
serverTime: Date.now()
|
|
2314
2431
|
};
|
|
2315
2432
|
this.sendToClient(response2);
|
|
2316
2433
|
this.close("schema version mismatch");
|
|
@@ -2324,6 +2441,10 @@ var ClientSession = class {
|
|
|
2324
2441
|
schemaVersion: this.schemaVersion,
|
|
2325
2442
|
accepted: true,
|
|
2326
2443
|
selectedWireFormat,
|
|
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 } : {},
|
|
2327
2448
|
// Confirm the accepted scope so the client knows what data will be synced.
|
|
2328
2449
|
// This may differ from what the client requested if auth scopes are narrower.
|
|
2329
2450
|
...this.authContext?.scopes ? { acceptedScope: this.authContext.scopes } : {}
|
|
@@ -2511,7 +2632,7 @@ function selectWireFormat(supportedWireFormats) {
|
|
|
2511
2632
|
}
|
|
2512
2633
|
|
|
2513
2634
|
// src/server/kora-sync-server.ts
|
|
2514
|
-
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76 } from "@korajs/core";
|
|
2635
|
+
import { SyncError as SyncError4, generateUUIDv7 as generateUUIDv76, isBlobRef } from "@korajs/core";
|
|
2515
2636
|
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
2516
2637
|
import { JsonMessageSerializer as JsonMessageSerializer2 } from "@korajs/sync";
|
|
2517
2638
|
|
|
@@ -2728,6 +2849,115 @@ var ServerMetricsCollector = class {
|
|
|
2728
2849
|
}
|
|
2729
2850
|
};
|
|
2730
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
|
+
|
|
2731
2961
|
// src/richtext/yjs-doc-relay.ts
|
|
2732
2962
|
var YjsDocRelay = class {
|
|
2733
2963
|
clients = /* @__PURE__ */ new Map();
|
|
@@ -2784,6 +3014,8 @@ var KoraSyncServer = class {
|
|
|
2784
3014
|
metrics;
|
|
2785
3015
|
awarenessRelay = new AwarenessRelay();
|
|
2786
3016
|
yjsDocRelay = new YjsDocRelay();
|
|
3017
|
+
blobChunkRelay;
|
|
3018
|
+
persistBlobChunk;
|
|
2787
3019
|
sessions = /* @__PURE__ */ new Map();
|
|
2788
3020
|
httpClients = /* @__PURE__ */ new Map();
|
|
2789
3021
|
httpSessionToClient = /* @__PURE__ */ new Map();
|
|
@@ -2808,6 +3040,8 @@ var KoraSyncServer = class {
|
|
|
2808
3040
|
this.logger = config.logger ?? createDefaultLogger();
|
|
2809
3041
|
this.metrics = config.metricsCollector ?? new ServerMetricsCollector();
|
|
2810
3042
|
this.metrics.setSchemaVersion(this.schemaVersion);
|
|
3043
|
+
this.blobChunkRelay = new BlobChunkRelay(config.resolveBlobChunk);
|
|
3044
|
+
this.persistBlobChunk = config.persistBlobChunk ?? null;
|
|
2811
3045
|
if (!this.emitter) {
|
|
2812
3046
|
this.emitter = new SimpleEventEmitter();
|
|
2813
3047
|
}
|
|
@@ -2931,6 +3165,7 @@ var KoraSyncServer = class {
|
|
|
2931
3165
|
});
|
|
2932
3166
|
this.awarenessRelay.clear();
|
|
2933
3167
|
this.yjsDocRelay.clear();
|
|
3168
|
+
this.blobChunkRelay.clear();
|
|
2934
3169
|
for (const session of this.sessions.values()) {
|
|
2935
3170
|
session.close("server shutting down");
|
|
2936
3171
|
}
|
|
@@ -3075,12 +3310,20 @@ var KoraSyncServer = class {
|
|
|
3075
3310
|
onYjsDocUpdate: (sourceSessionId, message) => {
|
|
3076
3311
|
this.handleYjsDocRelay(sourceSessionId, message);
|
|
3077
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 } : {},
|
|
3078
3320
|
onClose: (sid) => {
|
|
3079
3321
|
this.handleSessionClose(sid);
|
|
3080
3322
|
}
|
|
3081
3323
|
});
|
|
3082
3324
|
this.sessions.set(sessionId, session);
|
|
3083
3325
|
this.yjsDocRelay.addClient(sessionId, transport);
|
|
3326
|
+
this.blobChunkRelay.addClient(sessionId, transport);
|
|
3084
3327
|
session.start();
|
|
3085
3328
|
this.logger.log({
|
|
3086
3329
|
timestamp: Date.now(),
|
|
@@ -3113,6 +3356,61 @@ var KoraSyncServer = class {
|
|
|
3113
3356
|
errorCount: snapshot.errorCount
|
|
3114
3357
|
};
|
|
3115
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
|
+
}
|
|
3116
3414
|
/**
|
|
3117
3415
|
* Get the number of currently connected clients.
|
|
3118
3416
|
*/
|
|
@@ -3146,6 +3444,7 @@ var KoraSyncServer = class {
|
|
|
3146
3444
|
this.metrics.recordDisconnection(sessionId);
|
|
3147
3445
|
this.awarenessRelay.removeClient(sessionId);
|
|
3148
3446
|
this.yjsDocRelay.removeClient(sessionId);
|
|
3447
|
+
this.blobChunkRelay.removeClient(sessionId);
|
|
3149
3448
|
this.sessions.delete(sessionId);
|
|
3150
3449
|
const clientId = this.httpSessionToClient.get(sessionId);
|
|
3151
3450
|
if (clientId) {
|
|
@@ -3198,13 +3497,6 @@ function normalizeHttpBody(body, contentType) {
|
|
|
3198
3497
|
return body;
|
|
3199
3498
|
}
|
|
3200
3499
|
|
|
3201
|
-
// src/auth/no-auth.ts
|
|
3202
|
-
var NoAuthProvider = class {
|
|
3203
|
-
async authenticate(_token) {
|
|
3204
|
-
return { userId: "anonymous" };
|
|
3205
|
-
}
|
|
3206
|
-
};
|
|
3207
|
-
|
|
3208
3500
|
// src/auth/token-auth.ts
|
|
3209
3501
|
var TokenAuthProvider = class {
|
|
3210
3502
|
validate;
|
|
@@ -3285,6 +3577,159 @@ function createKoraServer(config) {
|
|
|
3285
3577
|
return new KoraSyncServer(config);
|
|
3286
3578
|
}
|
|
3287
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
|
+
|
|
3288
3733
|
// src/server/production-server.ts
|
|
3289
3734
|
var MIME_TYPES = {
|
|
3290
3735
|
".html": "text/html",
|
|
@@ -3309,6 +3754,7 @@ function createProductionServer(config) {
|
|
|
3309
3754
|
enableDashboard: true,
|
|
3310
3755
|
...config.syncOptions
|
|
3311
3756
|
});
|
|
3757
|
+
const routeContext = createRouteContext(syncServer, config.store);
|
|
3312
3758
|
let httpServer = null;
|
|
3313
3759
|
function getOperationalToken(kind) {
|
|
3314
3760
|
const auth = config.operationalAuth;
|
|
@@ -3395,6 +3841,10 @@ function createProductionServer(config) {
|
|
|
3395
3841
|
const chunks = [];
|
|
3396
3842
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
3397
3843
|
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3844
|
+
if (!req.readableFlowing) {
|
|
3845
|
+
req.resume();
|
|
3846
|
+
}
|
|
3847
|
+
req.on("error", () => resolve(Buffer.concat(chunks)));
|
|
3398
3848
|
});
|
|
3399
3849
|
}
|
|
3400
3850
|
async function readJsonBody(req) {
|
|
@@ -3451,6 +3901,19 @@ function createProductionServer(config) {
|
|
|
3451
3901
|
const { WebSocketServer } = await import("ws");
|
|
3452
3902
|
const distDir = resolve(staticDir);
|
|
3453
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) {
|
|
3454
3917
|
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3455
3918
|
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
3456
3919
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
@@ -3578,7 +4041,8 @@ data: ${JSON.stringify(s)}
|
|
|
3578
4041
|
body: await readJsonBody(req),
|
|
3579
4042
|
headers: req.headers,
|
|
3580
4043
|
query: getQuery(url),
|
|
3581
|
-
ip: getClientIp(req)
|
|
4044
|
+
ip: getClientIp(req),
|
|
4045
|
+
kora: routeContext
|
|
3582
4046
|
});
|
|
3583
4047
|
writeJsonResponse(res, result);
|
|
3584
4048
|
return;
|
|
@@ -3612,7 +4076,7 @@ data: ${JSON.stringify(s)}
|
|
|
3612
4076
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
3613
4077
|
res.writeHead(200, { "Content-Type": contentType });
|
|
3614
4078
|
createReadStream(filePath).pipe(res);
|
|
3615
|
-
}
|
|
4079
|
+
}
|
|
3616
4080
|
const wss = new WebSocketServer({ noServer: true });
|
|
3617
4081
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
3618
4082
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|