@syncular/server-hono 0.0.6-158 → 0.0.6-165
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/blobs.d.ts +10 -4
- package/dist/blobs.d.ts.map +1 -1
- package/dist/blobs.js +260 -26
- package/dist/blobs.js.map +1 -1
- package/dist/console/gateway.d.ts +4 -0
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +97 -60
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/route-descriptor.d.ts +6 -0
- package/dist/console/route-descriptor.d.ts.map +1 -0
- package/dist/console/route-descriptor.js +16 -0
- package/dist/console/route-descriptor.js.map +1 -0
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +153 -108
- package/dist/console/routes.js.map +1 -1
- package/dist/console/schema-errors.d.ts +2 -0
- package/dist/console/schema-errors.d.ts.map +1 -0
- package/dist/console/schema-errors.js +17 -0
- package/dist/console/schema-errors.js.map +1 -0
- package/dist/console/schemas.js +1 -1
- package/dist/console/schemas.js.map +1 -1
- package/dist/console/types.d.ts +32 -0
- package/dist/console/types.d.ts.map +1 -1
- package/dist/create-server.d.ts.map +1 -1
- package/dist/create-server.js +13 -10
- package/dist/create-server.js.map +1 -1
- package/dist/proxy/routes.d.ts +10 -0
- package/dist/proxy/routes.d.ts.map +1 -1
- package/dist/proxy/routes.js +57 -6
- package/dist/proxy/routes.js.map +1 -1
- package/dist/routes.d.ts +21 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +338 -352
- package/dist/routes.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/blob-routes.test.ts +286 -18
- package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
- package/src/__tests__/console-routes.test.ts +30 -1
- package/src/__tests__/create-server.test.ts +237 -1
- package/src/__tests__/pull-chunk-storage.test.ts +98 -0
- package/src/blobs.ts +360 -34
- package/src/console/gateway.ts +335 -288
- package/src/console/route-descriptor.ts +22 -0
- package/src/console/routes.ts +327 -248
- package/src/console/schema-errors.ts +23 -0
- package/src/console/schemas.ts +1 -1
- package/src/console/types.ts +32 -0
- package/src/create-server.ts +13 -10
- package/src/proxy/routes.ts +73 -9
- package/src/routes.ts +449 -396
package/src/routes.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
distributionSyncMetric,
|
|
15
15
|
ErrorResponseSchema,
|
|
16
16
|
logSyncEvent,
|
|
17
|
+
ScopeValuesSchema,
|
|
17
18
|
SyncCombinedRequestSchema,
|
|
18
19
|
SyncCombinedResponseSchema,
|
|
19
20
|
SyncPushRequestSchema,
|
|
@@ -38,10 +39,13 @@ import {
|
|
|
38
39
|
maybePruneSync,
|
|
39
40
|
type PruneOptions,
|
|
40
41
|
type PullResult,
|
|
42
|
+
parseJsonValue,
|
|
41
43
|
pull,
|
|
42
44
|
pushCommit,
|
|
43
45
|
readSnapshotChunk,
|
|
44
46
|
recordClientCursor,
|
|
47
|
+
resolveEffectiveScopesForSubscriptions,
|
|
48
|
+
scopesToSnapshotChunkScopeKey,
|
|
45
49
|
} from '@syncular/server';
|
|
46
50
|
import type { Context, MiddlewareHandler } from 'hono';
|
|
47
51
|
import { Hono } from 'hono';
|
|
@@ -55,6 +59,7 @@ import {
|
|
|
55
59
|
sql,
|
|
56
60
|
} from 'kysely';
|
|
57
61
|
import { z } from 'zod';
|
|
62
|
+
import { isBenignConsoleSchemaError } from './console/schema-errors';
|
|
58
63
|
import {
|
|
59
64
|
createRateLimiter,
|
|
60
65
|
DEFAULT_SYNC_RATE_LIMITS,
|
|
@@ -96,6 +101,27 @@ export interface SyncWebSocketConfig {
|
|
|
96
101
|
* Default: 3
|
|
97
102
|
*/
|
|
98
103
|
maxConnectionsPerClient?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Maximum inbound websocket message size in bytes.
|
|
106
|
+
* Default: 1 MiB.
|
|
107
|
+
*/
|
|
108
|
+
maxMessageBytes?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Maximum inbound websocket messages allowed per connection within one window.
|
|
111
|
+
* Default: 120 messages.
|
|
112
|
+
* Set to 0 or a negative value to disable rate limiting.
|
|
113
|
+
*/
|
|
114
|
+
maxMessagesPerWindow?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Window size in milliseconds for inbound websocket message rate limiting.
|
|
117
|
+
* Default: 10000 ms.
|
|
118
|
+
*/
|
|
119
|
+
messageRateWindowMs?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Optional list of allowed websocket origins.
|
|
122
|
+
* Use '*' to allow all origins.
|
|
123
|
+
*/
|
|
124
|
+
allowedOrigins?: string[] | '*';
|
|
99
125
|
}
|
|
100
126
|
|
|
101
127
|
export interface SyncRoutesConfigWithRateLimit {
|
|
@@ -231,6 +257,9 @@ export interface CreateSyncRoutesOptions<
|
|
|
231
257
|
const snapshotChunkParamsSchema = z.object({
|
|
232
258
|
chunkId: z.string().min(1),
|
|
233
259
|
});
|
|
260
|
+
const snapshotChunkQuerySchema = z.object({
|
|
261
|
+
scopes: z.string().optional(),
|
|
262
|
+
});
|
|
234
263
|
|
|
235
264
|
const auditCommitListQuerySchema = z.object({
|
|
236
265
|
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
@@ -278,6 +307,7 @@ const auditCommitDetailResponseSchema = z.object({
|
|
|
278
307
|
});
|
|
279
308
|
|
|
280
309
|
const DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES = 128 * 1024;
|
|
310
|
+
const SNAPSHOT_SCOPES_HEADER = 'x-syncular-snapshot-scopes';
|
|
281
311
|
|
|
282
312
|
type TraceContext = {
|
|
283
313
|
traceId: string | null;
|
|
@@ -484,6 +514,18 @@ function countPullRows(response: PullResult['response']): number {
|
|
|
484
514
|
}, 0);
|
|
485
515
|
}
|
|
486
516
|
|
|
517
|
+
function readSnapshotScopeValues(
|
|
518
|
+
c: Context,
|
|
519
|
+
queryScopes: string | undefined
|
|
520
|
+
): Record<string, string | string[]> | null {
|
|
521
|
+
const rawValue = queryScopes ?? c.req.header(SNAPSHOT_SCOPES_HEADER);
|
|
522
|
+
if (!rawValue) return null;
|
|
523
|
+
const parsed = parseJsonValue(rawValue);
|
|
524
|
+
const validated = ScopeValuesSchema.safeParse(parsed);
|
|
525
|
+
if (!validated.success) return null;
|
|
526
|
+
return validated.data;
|
|
527
|
+
}
|
|
528
|
+
|
|
487
529
|
function encodePayloadSnapshot(value: unknown, maxBytes: number): string {
|
|
488
530
|
try {
|
|
489
531
|
const serialized = JSON.stringify(value);
|
|
@@ -575,6 +617,9 @@ export function createSyncRoutes<
|
|
|
575
617
|
Promise.resolve())
|
|
576
618
|
: Promise.resolve();
|
|
577
619
|
const consoleSchemaReady = consoleSchemaReadyBase.catch((error) => {
|
|
620
|
+
if (isBenignConsoleSchemaError(error)) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
578
623
|
logSyncEvent({
|
|
579
624
|
event: 'sync.console_schema_ready_failed',
|
|
580
625
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -835,6 +880,178 @@ export function createSyncRoutes<
|
|
|
835
880
|
});
|
|
836
881
|
};
|
|
837
882
|
|
|
883
|
+
type PushRequestBody = Omit<
|
|
884
|
+
z.infer<typeof SyncPushRequestSchema>,
|
|
885
|
+
'clientId'
|
|
886
|
+
>;
|
|
887
|
+
|
|
888
|
+
type PushExecutionContext = {
|
|
889
|
+
auth: Auth;
|
|
890
|
+
clientId: string;
|
|
891
|
+
partitionId: string;
|
|
892
|
+
requestId: string;
|
|
893
|
+
traceContext: TraceContext;
|
|
894
|
+
transportPath: 'direct' | 'relay';
|
|
895
|
+
syncPath: 'http-combined' | 'ws-push';
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
async function executePushCommitWithSideEffects(
|
|
899
|
+
ctx: PushExecutionContext,
|
|
900
|
+
pushBody: PushRequestBody,
|
|
901
|
+
execOptions: { countConflictsMetric?: boolean } = {}
|
|
902
|
+
): Promise<Awaited<ReturnType<typeof pushCommit>>> {
|
|
903
|
+
const timer = createSyncTimer();
|
|
904
|
+
const pushOps = pushBody.operations ?? [];
|
|
905
|
+
|
|
906
|
+
const pushed = await pushCommit({
|
|
907
|
+
db: options.db,
|
|
908
|
+
dialect: options.dialect,
|
|
909
|
+
handlers: handlerRegistry,
|
|
910
|
+
plugins: options.plugins,
|
|
911
|
+
auth: ctx.auth,
|
|
912
|
+
request: {
|
|
913
|
+
clientId: ctx.clientId,
|
|
914
|
+
clientCommitId: pushBody.clientCommitId,
|
|
915
|
+
operations: pushBody.operations,
|
|
916
|
+
schemaVersion: pushBody.schemaVersion,
|
|
917
|
+
},
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const pushDurationMs = timer();
|
|
921
|
+
|
|
922
|
+
logSyncEvent({
|
|
923
|
+
event: 'sync.push',
|
|
924
|
+
userId: ctx.auth.actorId,
|
|
925
|
+
durationMs: pushDurationMs,
|
|
926
|
+
operationCount: pushOps.length,
|
|
927
|
+
status: pushed.response.status,
|
|
928
|
+
commitSeq: pushed.response.commitSeq,
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
recordRequestEventInBackground(() => ({
|
|
932
|
+
partitionId: ctx.partitionId,
|
|
933
|
+
requestId: ctx.requestId,
|
|
934
|
+
traceId: ctx.traceContext.traceId,
|
|
935
|
+
spanId: ctx.traceContext.spanId,
|
|
936
|
+
eventType: 'push',
|
|
937
|
+
syncPath: ctx.syncPath,
|
|
938
|
+
actorId: ctx.auth.actorId,
|
|
939
|
+
clientId: ctx.clientId,
|
|
940
|
+
transportPath: ctx.transportPath,
|
|
941
|
+
statusCode: 200,
|
|
942
|
+
outcome: pushed.response.status,
|
|
943
|
+
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
944
|
+
durationMs: pushDurationMs,
|
|
945
|
+
errorCode: firstPushErrorCode(pushed.response.results),
|
|
946
|
+
commitSeq: pushed.response.commitSeq,
|
|
947
|
+
operationCount: pushOps.length,
|
|
948
|
+
tables: pushed.affectedTables,
|
|
949
|
+
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
950
|
+
? {
|
|
951
|
+
request: {
|
|
952
|
+
clientId: ctx.clientId,
|
|
953
|
+
clientCommitId: pushBody.clientCommitId,
|
|
954
|
+
schemaVersion: pushBody.schemaVersion,
|
|
955
|
+
operations: pushBody.operations,
|
|
956
|
+
},
|
|
957
|
+
response: pushed.response,
|
|
958
|
+
}
|
|
959
|
+
: null,
|
|
960
|
+
}));
|
|
961
|
+
|
|
962
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
963
|
+
partitionId: ctx.partitionId,
|
|
964
|
+
requestId: ctx.requestId,
|
|
965
|
+
traceId: ctx.traceContext.traceId,
|
|
966
|
+
spanId: ctx.traceContext.spanId,
|
|
967
|
+
actorId: ctx.auth.actorId,
|
|
968
|
+
clientId: ctx.clientId,
|
|
969
|
+
transportPath: ctx.transportPath,
|
|
970
|
+
syncPath: ctx.syncPath,
|
|
971
|
+
outcome: pushed.response.status,
|
|
972
|
+
statusCode: 200,
|
|
973
|
+
durationMs: pushDurationMs,
|
|
974
|
+
commitSeq: pushed.response.commitSeq ?? null,
|
|
975
|
+
operationCount: pushOps.length,
|
|
976
|
+
tables: pushed.affectedTables,
|
|
977
|
+
}));
|
|
978
|
+
|
|
979
|
+
if (execOptions.countConflictsMetric === true) {
|
|
980
|
+
const detectedConflicts = pushed.response.results.reduce(
|
|
981
|
+
(count, result) => count + (result.status === 'conflict' ? 1 : 0),
|
|
982
|
+
0
|
|
983
|
+
);
|
|
984
|
+
if (detectedConflicts > 0) {
|
|
985
|
+
countSyncMetric('sync.conflicts.detected', detectedConflicts, {
|
|
986
|
+
attributes: {
|
|
987
|
+
syncPath: ctx.syncPath,
|
|
988
|
+
transportPath: ctx.transportPath,
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (
|
|
995
|
+
wsConnectionManager &&
|
|
996
|
+
pushed.response.ok === true &&
|
|
997
|
+
pushed.response.status === 'applied' &&
|
|
998
|
+
typeof pushed.response.commitSeq === 'number'
|
|
999
|
+
) {
|
|
1000
|
+
const scopeKeys = applyPartitionToScopeKeys(
|
|
1001
|
+
ctx.partitionId,
|
|
1002
|
+
pushed.scopeKeys
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
if (scopeKeys.length > 0) {
|
|
1006
|
+
wsConnectionManager.notifyScopeKeys(
|
|
1007
|
+
scopeKeys,
|
|
1008
|
+
pushed.response.commitSeq,
|
|
1009
|
+
{
|
|
1010
|
+
excludeClientIds: [ctx.clientId],
|
|
1011
|
+
changes: pushed.emittedChanges,
|
|
1012
|
+
actorId: pushed.commitActorId ?? ctx.auth.actorId,
|
|
1013
|
+
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
1014
|
+
}
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
if (realtimeBroadcaster) {
|
|
1018
|
+
realtimeBroadcaster
|
|
1019
|
+
.publish({
|
|
1020
|
+
type: 'commit',
|
|
1021
|
+
commitSeq: pushed.response.commitSeq,
|
|
1022
|
+
partitionId: ctx.partitionId,
|
|
1023
|
+
scopeKeys,
|
|
1024
|
+
sourceInstanceId: instanceId,
|
|
1025
|
+
})
|
|
1026
|
+
.catch((error) => {
|
|
1027
|
+
logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
|
|
1028
|
+
event: 'sync.realtime.broadcast_publish_failed',
|
|
1029
|
+
userId: ctx.auth.actorId,
|
|
1030
|
+
clientId: ctx.clientId,
|
|
1031
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (
|
|
1039
|
+
pushed.response.ok === true &&
|
|
1040
|
+
pushed.response.status === 'applied' &&
|
|
1041
|
+
typeof pushed.response.commitSeq === 'number'
|
|
1042
|
+
) {
|
|
1043
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
1044
|
+
partitionId: ctx.partitionId,
|
|
1045
|
+
commitSeq: pushed.response.commitSeq,
|
|
1046
|
+
actorId: ctx.auth.actorId,
|
|
1047
|
+
clientId: ctx.clientId,
|
|
1048
|
+
affectedTables: pushed.affectedTables,
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return pushed;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
838
1055
|
const authCache = new WeakMap<Context, Promise<Auth | null>>();
|
|
839
1056
|
const getAuth = (c: Context): Promise<Auth | null> => {
|
|
840
1057
|
const cached = authCache.get(c);
|
|
@@ -1143,8 +1360,8 @@ export function createSyncRoutes<
|
|
|
1143
1360
|
op: change.op,
|
|
1144
1361
|
rowVersion:
|
|
1145
1362
|
change.row_version === null ? null : Number(change.row_version),
|
|
1146
|
-
rowJson:
|
|
1147
|
-
scopes:
|
|
1363
|
+
rowJson: parseJsonValue(change.row_json),
|
|
1364
|
+
scopes: parseJsonValue(change.scopes),
|
|
1148
1365
|
})),
|
|
1149
1366
|
},
|
|
1150
1367
|
200
|
|
@@ -1191,6 +1408,7 @@ export function createSyncRoutes<
|
|
|
1191
1408
|
const auth = await getAuth(c);
|
|
1192
1409
|
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1193
1410
|
const partitionId = auth.partitionId ?? 'default';
|
|
1411
|
+
const transportPath = readTransportPath(c);
|
|
1194
1412
|
|
|
1195
1413
|
const body = c.req.valid('json');
|
|
1196
1414
|
const clientId = body.clientId;
|
|
@@ -1215,142 +1433,18 @@ export function createSyncRoutes<
|
|
|
1215
1433
|
400
|
|
1216
1434
|
);
|
|
1217
1435
|
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
const pushed = await pushCommit({
|
|
1222
|
-
db: options.db,
|
|
1223
|
-
dialect: options.dialect,
|
|
1224
|
-
handlers: handlerRegistry,
|
|
1225
|
-
plugins: options.plugins,
|
|
1226
|
-
auth,
|
|
1227
|
-
request: {
|
|
1436
|
+
const pushed = await executePushCommitWithSideEffects(
|
|
1437
|
+
{
|
|
1438
|
+
auth,
|
|
1228
1439
|
clientId,
|
|
1229
|
-
clientCommitId: pushBody.clientCommitId,
|
|
1230
|
-
operations: pushBody.operations,
|
|
1231
|
-
schemaVersion: pushBody.schemaVersion,
|
|
1232
|
-
},
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
const pushDurationMs = timer();
|
|
1236
|
-
|
|
1237
|
-
logSyncEvent({
|
|
1238
|
-
event: 'sync.push',
|
|
1239
|
-
userId: auth.actorId,
|
|
1240
|
-
durationMs: pushDurationMs,
|
|
1241
|
-
operationCount: pushOps.length,
|
|
1242
|
-
status: pushed.response.status,
|
|
1243
|
-
commitSeq: pushed.response.commitSeq,
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
recordRequestEventInBackground(() => ({
|
|
1247
|
-
partitionId,
|
|
1248
|
-
requestId,
|
|
1249
|
-
traceId: traceContext.traceId,
|
|
1250
|
-
spanId: traceContext.spanId,
|
|
1251
|
-
eventType: 'push',
|
|
1252
|
-
syncPath: 'http-combined',
|
|
1253
|
-
actorId: auth.actorId,
|
|
1254
|
-
clientId,
|
|
1255
|
-
transportPath: readTransportPath(c),
|
|
1256
|
-
statusCode: 200,
|
|
1257
|
-
outcome: pushed.response.status,
|
|
1258
|
-
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
1259
|
-
durationMs: pushDurationMs,
|
|
1260
|
-
errorCode: firstPushErrorCode(pushed.response.results),
|
|
1261
|
-
commitSeq: pushed.response.commitSeq,
|
|
1262
|
-
operationCount: pushOps.length,
|
|
1263
|
-
tables: pushed.affectedTables,
|
|
1264
|
-
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
1265
|
-
? {
|
|
1266
|
-
request: {
|
|
1267
|
-
clientId,
|
|
1268
|
-
clientCommitId: pushBody.clientCommitId,
|
|
1269
|
-
schemaVersion: pushBody.schemaVersion,
|
|
1270
|
-
operations: pushBody.operations,
|
|
1271
|
-
},
|
|
1272
|
-
response: pushed.response,
|
|
1273
|
-
}
|
|
1274
|
-
: null,
|
|
1275
|
-
}));
|
|
1276
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
1277
|
-
partitionId,
|
|
1278
|
-
requestId,
|
|
1279
|
-
traceId: traceContext.traceId,
|
|
1280
|
-
spanId: traceContext.spanId,
|
|
1281
|
-
actorId: auth.actorId,
|
|
1282
|
-
clientId,
|
|
1283
|
-
transportPath: readTransportPath(c),
|
|
1284
|
-
syncPath: 'http-combined',
|
|
1285
|
-
outcome: pushed.response.status,
|
|
1286
|
-
statusCode: 200,
|
|
1287
|
-
durationMs: pushDurationMs,
|
|
1288
|
-
commitSeq: pushed.response.commitSeq ?? null,
|
|
1289
|
-
operationCount: pushOps.length,
|
|
1290
|
-
tables: pushed.affectedTables,
|
|
1291
|
-
}));
|
|
1292
|
-
|
|
1293
|
-
// WS notifications
|
|
1294
|
-
if (
|
|
1295
|
-
wsConnectionManager &&
|
|
1296
|
-
pushed.response.ok === true &&
|
|
1297
|
-
pushed.response.status === 'applied' &&
|
|
1298
|
-
typeof pushed.response.commitSeq === 'number'
|
|
1299
|
-
) {
|
|
1300
|
-
const scopeKeys = applyPartitionToScopeKeys(
|
|
1301
|
-
partitionId,
|
|
1302
|
-
pushed.scopeKeys
|
|
1303
|
-
);
|
|
1304
|
-
if (scopeKeys.length > 0) {
|
|
1305
|
-
wsConnectionManager.notifyScopeKeys(
|
|
1306
|
-
scopeKeys,
|
|
1307
|
-
pushed.response.commitSeq,
|
|
1308
|
-
{
|
|
1309
|
-
excludeClientIds: [clientId],
|
|
1310
|
-
changes: pushed.emittedChanges,
|
|
1311
|
-
actorId: pushed.commitActorId ?? auth.actorId,
|
|
1312
|
-
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
1313
|
-
}
|
|
1314
|
-
);
|
|
1315
|
-
|
|
1316
|
-
if (realtimeBroadcaster) {
|
|
1317
|
-
realtimeBroadcaster
|
|
1318
|
-
.publish({
|
|
1319
|
-
type: 'commit',
|
|
1320
|
-
commitSeq: pushed.response.commitSeq,
|
|
1321
|
-
partitionId,
|
|
1322
|
-
scopeKeys,
|
|
1323
|
-
sourceInstanceId: instanceId,
|
|
1324
|
-
})
|
|
1325
|
-
.catch((error) => {
|
|
1326
|
-
logAsyncFailureOnce(
|
|
1327
|
-
'sync.realtime.broadcast_publish_failed',
|
|
1328
|
-
{
|
|
1329
|
-
event: 'sync.realtime.broadcast_publish_failed',
|
|
1330
|
-
userId: auth.actorId,
|
|
1331
|
-
clientId,
|
|
1332
|
-
error:
|
|
1333
|
-
error instanceof Error ? error.message : String(error),
|
|
1334
|
-
}
|
|
1335
|
-
);
|
|
1336
|
-
});
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
if (
|
|
1342
|
-
pushed.response.ok === true &&
|
|
1343
|
-
pushed.response.status === 'applied' &&
|
|
1344
|
-
typeof pushed.response.commitSeq === 'number'
|
|
1345
|
-
) {
|
|
1346
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
1347
1440
|
partitionId,
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1441
|
+
requestId,
|
|
1442
|
+
traceContext,
|
|
1443
|
+
transportPath,
|
|
1444
|
+
syncPath: 'http-combined',
|
|
1445
|
+
},
|
|
1446
|
+
pushBody
|
|
1447
|
+
);
|
|
1354
1448
|
|
|
1355
1449
|
pushResponse = pushed.response;
|
|
1356
1450
|
}
|
|
@@ -1513,7 +1607,7 @@ export function createSyncRoutes<
|
|
|
1513
1607
|
syncPath: 'http-combined',
|
|
1514
1608
|
actorId: auth.actorId,
|
|
1515
1609
|
clientId,
|
|
1516
|
-
transportPath
|
|
1610
|
+
transportPath,
|
|
1517
1611
|
statusCode: 200,
|
|
1518
1612
|
outcome: 'applied',
|
|
1519
1613
|
responseStatus: normalizeResponseStatus(200, 'applied'),
|
|
@@ -1535,7 +1629,7 @@ export function createSyncRoutes<
|
|
|
1535
1629
|
spanId: traceContext.spanId,
|
|
1536
1630
|
actorId: auth.actorId,
|
|
1537
1631
|
clientId,
|
|
1538
|
-
transportPath
|
|
1632
|
+
transportPath,
|
|
1539
1633
|
syncPath: 'http-combined',
|
|
1540
1634
|
outcome: 'applied',
|
|
1541
1635
|
statusCode: 200,
|
|
@@ -1609,10 +1703,13 @@ export function createSyncRoutes<
|
|
|
1609
1703
|
},
|
|
1610
1704
|
}),
|
|
1611
1705
|
zValidator('param', snapshotChunkParamsSchema),
|
|
1706
|
+
zValidator('query', snapshotChunkQuerySchema),
|
|
1612
1707
|
async (c) => {
|
|
1613
1708
|
const auth = await getAuth(c);
|
|
1614
1709
|
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1615
1710
|
const partitionId = auth.partitionId ?? 'default';
|
|
1711
|
+
const query = c.req.valid('query');
|
|
1712
|
+
const requestedChunkScopes = readSnapshotScopeValues(c, query.scopes);
|
|
1616
1713
|
|
|
1617
1714
|
const { chunkId } = c.req.valid('param');
|
|
1618
1715
|
|
|
@@ -1629,9 +1726,40 @@ export function createSyncRoutes<
|
|
|
1629
1726
|
return c.json({ error: 'NOT_FOUND' }, 404);
|
|
1630
1727
|
}
|
|
1631
1728
|
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1729
|
+
if (requestedChunkScopes) {
|
|
1730
|
+
try {
|
|
1731
|
+
const resolved = await resolveEffectiveScopesForSubscriptions({
|
|
1732
|
+
db: options.db,
|
|
1733
|
+
auth,
|
|
1734
|
+
subscriptions: [
|
|
1735
|
+
{
|
|
1736
|
+
id: 'snapshot-chunk-authz',
|
|
1737
|
+
table: chunk.scope,
|
|
1738
|
+
scopes: requestedChunkScopes,
|
|
1739
|
+
cursor: 0,
|
|
1740
|
+
},
|
|
1741
|
+
],
|
|
1742
|
+
handlers: handlerRegistry,
|
|
1743
|
+
scopeCache: options.scopeCache,
|
|
1744
|
+
});
|
|
1745
|
+
const scopeAuth = resolved[0];
|
|
1746
|
+
if (!scopeAuth || scopeAuth.status !== 'active') {
|
|
1747
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const scopeKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
|
|
1751
|
+
scopeAuth.scopes
|
|
1752
|
+
)}`;
|
|
1753
|
+
if (scopeKey !== chunk.scopeKey) {
|
|
1754
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1755
|
+
}
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
if (error instanceof InvalidSubscriptionScopeError) {
|
|
1758
|
+
return c.json({ error: 'FORBIDDEN' }, 403);
|
|
1759
|
+
}
|
|
1760
|
+
throw error;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1635
1763
|
|
|
1636
1764
|
const etag = `"sha256:${chunk.sha256}"`;
|
|
1637
1765
|
const ifNoneMatch = c.req.header('if-none-match');
|
|
@@ -1672,6 +1800,9 @@ export function createSyncRoutes<
|
|
|
1672
1800
|
routes.get('/realtime', async (c) => {
|
|
1673
1801
|
const auth = await getAuth(c);
|
|
1674
1802
|
if (!auth) return c.json({ error: 'UNAUTHENTICATED' }, 401);
|
|
1803
|
+
if (!isWebSocketOriginAllowed(c, websocketConfig.allowedOrigins)) {
|
|
1804
|
+
return c.json({ error: 'FORBIDDEN_ORIGIN' }, 403);
|
|
1805
|
+
}
|
|
1675
1806
|
const partitionId = auth.partitionId ?? 'default';
|
|
1676
1807
|
|
|
1677
1808
|
const clientId = c.req.query('clientId');
|
|
@@ -1733,6 +1864,11 @@ export function createSyncRoutes<
|
|
|
1733
1864
|
const maxConnectionsTotal = websocketConfig.maxConnectionsTotal ?? 5000;
|
|
1734
1865
|
const maxConnectionsPerClient =
|
|
1735
1866
|
websocketConfig.maxConnectionsPerClient ?? 3;
|
|
1867
|
+
const maxMessageBytes = websocketConfig.maxMessageBytes ?? 1024 * 1024;
|
|
1868
|
+
const maxMessagesPerWindow = websocketConfig.maxMessagesPerWindow ?? 120;
|
|
1869
|
+
const messageRateWindowMs = websocketConfig.messageRateWindowMs ?? 10000;
|
|
1870
|
+
let messageRateWindowStartedAtMs = Date.now();
|
|
1871
|
+
let messageRateWindowCount = 0;
|
|
1736
1872
|
|
|
1737
1873
|
if (
|
|
1738
1874
|
maxConnectionsTotal > 0 &&
|
|
@@ -1790,6 +1926,35 @@ export function createSyncRoutes<
|
|
|
1790
1926
|
});
|
|
1791
1927
|
};
|
|
1792
1928
|
|
|
1929
|
+
const teardownRealtimeConnection = (args: {
|
|
1930
|
+
reason: 'closed' | 'error';
|
|
1931
|
+
action: 'realtime_disconnected' | 'realtime_error';
|
|
1932
|
+
}) => {
|
|
1933
|
+
unregister?.();
|
|
1934
|
+
unregister = null;
|
|
1935
|
+
connRef = null;
|
|
1936
|
+
finishRealtimeSession(args.reason);
|
|
1937
|
+
logSyncEvent({
|
|
1938
|
+
event: 'sync.realtime.disconnect',
|
|
1939
|
+
userId: auth.actorId,
|
|
1940
|
+
});
|
|
1941
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
1942
|
+
action: args.action,
|
|
1943
|
+
actorId: auth.actorId,
|
|
1944
|
+
clientId,
|
|
1945
|
+
partitionId,
|
|
1946
|
+
}));
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
const logPresenceRejected = (scopeKey: string) => {
|
|
1950
|
+
logSyncEvent({
|
|
1951
|
+
event: 'sync.realtime.presence.rejected',
|
|
1952
|
+
userId: auth.actorId,
|
|
1953
|
+
reason: 'scope_not_authorized',
|
|
1954
|
+
scopeKey,
|
|
1955
|
+
});
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1793
1958
|
const upgradeWebSocket = websocketConfig.upgradeWebSocket;
|
|
1794
1959
|
if (!upgradeWebSocket) {
|
|
1795
1960
|
return c.json({ error: 'WEBSOCKET_NOT_CONFIGURED' }, 500);
|
|
@@ -1830,40 +1995,41 @@ export function createSyncRoutes<
|
|
|
1830
1995
|
}));
|
|
1831
1996
|
},
|
|
1832
1997
|
onClose(_evt, _ws) {
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
connRef = null;
|
|
1836
|
-
finishRealtimeSession('closed');
|
|
1837
|
-
logSyncEvent({
|
|
1838
|
-
event: 'sync.realtime.disconnect',
|
|
1839
|
-
userId: auth.actorId,
|
|
1840
|
-
});
|
|
1841
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
1998
|
+
teardownRealtimeConnection({
|
|
1999
|
+
reason: 'closed',
|
|
1842
2000
|
action: 'realtime_disconnected',
|
|
1843
|
-
|
|
1844
|
-
clientId,
|
|
1845
|
-
partitionId,
|
|
1846
|
-
}));
|
|
2001
|
+
});
|
|
1847
2002
|
},
|
|
1848
2003
|
onError(_evt, _ws) {
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
connRef = null;
|
|
1852
|
-
finishRealtimeSession('error');
|
|
1853
|
-
logSyncEvent({
|
|
1854
|
-
event: 'sync.realtime.disconnect',
|
|
1855
|
-
userId: auth.actorId,
|
|
1856
|
-
});
|
|
1857
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'client_update', () => ({
|
|
2004
|
+
teardownRealtimeConnection({
|
|
2005
|
+
reason: 'error',
|
|
1858
2006
|
action: 'realtime_error',
|
|
1859
|
-
|
|
1860
|
-
clientId,
|
|
1861
|
-
partitionId,
|
|
1862
|
-
}));
|
|
2007
|
+
});
|
|
1863
2008
|
},
|
|
1864
2009
|
onMessage(evt, _ws) {
|
|
1865
2010
|
if (!connRef) return;
|
|
1866
2011
|
try {
|
|
2012
|
+
const messageBytes = measureWebSocketMessageBytes(evt.data);
|
|
2013
|
+
if (messageBytes > maxMessageBytes) {
|
|
2014
|
+
connRef.sendError(
|
|
2015
|
+
`WebSocket message exceeds max size (${maxMessageBytes} bytes)`
|
|
2016
|
+
);
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
if (maxMessagesPerWindow > 0 && messageRateWindowMs > 0) {
|
|
2020
|
+
const nowMs = Date.now();
|
|
2021
|
+
if (nowMs - messageRateWindowStartedAtMs >= messageRateWindowMs) {
|
|
2022
|
+
messageRateWindowStartedAtMs = nowMs;
|
|
2023
|
+
messageRateWindowCount = 0;
|
|
2024
|
+
}
|
|
2025
|
+
messageRateWindowCount += 1;
|
|
2026
|
+
if (messageRateWindowCount > maxMessagesPerWindow) {
|
|
2027
|
+
connRef.sendError(
|
|
2028
|
+
`WebSocket message rate exceeded (${maxMessagesPerWindow}/${messageRateWindowMs}ms)`
|
|
2029
|
+
);
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
1867
2033
|
const raw =
|
|
1868
2034
|
typeof evt.data === 'string' ? evt.data : String(evt.data);
|
|
1869
2035
|
const msg = JSON.parse(raw);
|
|
@@ -1891,12 +2057,7 @@ export function createSyncRoutes<
|
|
|
1891
2057
|
msg.metadata
|
|
1892
2058
|
)
|
|
1893
2059
|
) {
|
|
1894
|
-
|
|
1895
|
-
event: 'sync.realtime.presence.rejected',
|
|
1896
|
-
userId: auth.actorId,
|
|
1897
|
-
reason: 'scope_not_authorized',
|
|
1898
|
-
scopeKey,
|
|
1899
|
-
});
|
|
2060
|
+
logPresenceRejected(scopeKey);
|
|
1900
2061
|
return;
|
|
1901
2062
|
}
|
|
1902
2063
|
// Send presence snapshot back to the joining client
|
|
@@ -1924,12 +2085,7 @@ export function createSyncRoutes<
|
|
|
1924
2085
|
scopeKey
|
|
1925
2086
|
)
|
|
1926
2087
|
) {
|
|
1927
|
-
|
|
1928
|
-
event: 'sync.realtime.presence.rejected',
|
|
1929
|
-
userId: auth.actorId,
|
|
1930
|
-
reason: 'scope_not_authorized',
|
|
1931
|
-
scopeKey,
|
|
1932
|
-
});
|
|
2088
|
+
logPresenceRejected(scopeKey);
|
|
1933
2089
|
}
|
|
1934
2090
|
break;
|
|
1935
2091
|
}
|
|
@@ -1941,8 +2097,6 @@ export function createSyncRoutes<
|
|
|
1941
2097
|
});
|
|
1942
2098
|
}
|
|
1943
2099
|
|
|
1944
|
-
return routes;
|
|
1945
|
-
|
|
1946
2100
|
async function handleRealtimeEvent(event: SyncRealtimeEvent): Promise<void> {
|
|
1947
2101
|
if (!wsConnectionManager) return;
|
|
1948
2102
|
if (event.type !== 'commit') return;
|
|
@@ -1959,6 +2113,58 @@ export function createSyncRoutes<
|
|
|
1959
2113
|
wsConnectionManager.notifyScopeKeys(scopeKeys, commitSeq);
|
|
1960
2114
|
}
|
|
1961
2115
|
|
|
2116
|
+
const recordWsPushFailure = (args: {
|
|
2117
|
+
partitionId: string;
|
|
2118
|
+
requestId: string;
|
|
2119
|
+
traceContext: TraceContext;
|
|
2120
|
+
actorId: string;
|
|
2121
|
+
clientId: string;
|
|
2122
|
+
transportPath: 'direct' | 'relay';
|
|
2123
|
+
statusCode: number;
|
|
2124
|
+
outcome: 'rejected' | 'error';
|
|
2125
|
+
durationMs: number;
|
|
2126
|
+
errorCode: string;
|
|
2127
|
+
errorMessage: string;
|
|
2128
|
+
operationCount?: number | null;
|
|
2129
|
+
payloadSnapshot?: RequestPayloadSnapshot | null;
|
|
2130
|
+
}): void => {
|
|
2131
|
+
recordRequestEventInBackground(() => ({
|
|
2132
|
+
partitionId: args.partitionId,
|
|
2133
|
+
requestId: args.requestId,
|
|
2134
|
+
traceId: args.traceContext.traceId,
|
|
2135
|
+
spanId: args.traceContext.spanId,
|
|
2136
|
+
eventType: 'push',
|
|
2137
|
+
syncPath: 'ws-push',
|
|
2138
|
+
actorId: args.actorId,
|
|
2139
|
+
clientId: args.clientId,
|
|
2140
|
+
transportPath: args.transportPath,
|
|
2141
|
+
statusCode: args.statusCode,
|
|
2142
|
+
outcome: args.outcome,
|
|
2143
|
+
responseStatus: normalizeResponseStatus(args.statusCode, args.outcome),
|
|
2144
|
+
durationMs: args.durationMs,
|
|
2145
|
+
errorCode: args.errorCode,
|
|
2146
|
+
errorMessage: args.errorMessage,
|
|
2147
|
+
operationCount: args.operationCount ?? null,
|
|
2148
|
+
payloadSnapshot: args.payloadSnapshot ?? null,
|
|
2149
|
+
}));
|
|
2150
|
+
|
|
2151
|
+
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
2152
|
+
partitionId: args.partitionId,
|
|
2153
|
+
requestId: args.requestId,
|
|
2154
|
+
traceId: args.traceContext.traceId,
|
|
2155
|
+
spanId: args.traceContext.spanId,
|
|
2156
|
+
actorId: args.actorId,
|
|
2157
|
+
clientId: args.clientId,
|
|
2158
|
+
transportPath: args.transportPath,
|
|
2159
|
+
syncPath: 'ws-push',
|
|
2160
|
+
outcome: args.outcome,
|
|
2161
|
+
statusCode: args.statusCode,
|
|
2162
|
+
durationMs: args.durationMs,
|
|
2163
|
+
operationCount: args.operationCount ?? null,
|
|
2164
|
+
errorCode: args.errorCode,
|
|
2165
|
+
}));
|
|
2166
|
+
};
|
|
2167
|
+
|
|
1962
2168
|
async function handleWsPush(
|
|
1963
2169
|
msg: Record<string, unknown>,
|
|
1964
2170
|
conn: WebSocketConnection,
|
|
@@ -1979,30 +2185,25 @@ export function createSyncRoutes<
|
|
|
1979
2185
|
);
|
|
1980
2186
|
if (!parsed.success) {
|
|
1981
2187
|
const invalidDurationMs = timer();
|
|
2188
|
+
const errorMessage = 'Invalid push payload';
|
|
1982
2189
|
conn.sendPushResponse({
|
|
1983
2190
|
requestId,
|
|
1984
2191
|
ok: false,
|
|
1985
2192
|
status: 'rejected',
|
|
1986
|
-
results: [
|
|
1987
|
-
{ opIndex: 0, status: 'error', error: 'Invalid push payload' },
|
|
1988
|
-
],
|
|
2193
|
+
results: [{ opIndex: 0, status: 'error', error: errorMessage }],
|
|
1989
2194
|
});
|
|
1990
|
-
|
|
2195
|
+
recordWsPushFailure({
|
|
1991
2196
|
partitionId,
|
|
1992
2197
|
requestId,
|
|
1993
|
-
traceId: traceContext.traceId,
|
|
1994
|
-
spanId: traceContext.spanId,
|
|
1995
|
-
eventType: 'push',
|
|
1996
|
-
syncPath: 'ws-push',
|
|
1997
2198
|
actorId,
|
|
1998
2199
|
clientId,
|
|
1999
2200
|
transportPath: conn.transportPath,
|
|
2000
2201
|
statusCode: 400,
|
|
2001
2202
|
outcome: 'rejected',
|
|
2002
|
-
responseStatus: normalizeResponseStatus(400, 'rejected'),
|
|
2003
2203
|
durationMs: invalidDurationMs,
|
|
2004
2204
|
errorCode: 'INVALID_PUSH_PAYLOAD',
|
|
2005
|
-
errorMessage
|
|
2205
|
+
errorMessage,
|
|
2206
|
+
traceContext,
|
|
2006
2207
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
2007
2208
|
? {
|
|
2008
2209
|
request: msg,
|
|
@@ -2013,27 +2214,14 @@ export function createSyncRoutes<
|
|
|
2013
2214
|
},
|
|
2014
2215
|
}
|
|
2015
2216
|
: null,
|
|
2016
|
-
})
|
|
2017
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
2018
|
-
partitionId,
|
|
2019
|
-
requestId,
|
|
2020
|
-
traceId: traceContext.traceId,
|
|
2021
|
-
spanId: traceContext.spanId,
|
|
2022
|
-
actorId,
|
|
2023
|
-
clientId,
|
|
2024
|
-
transportPath: conn.transportPath,
|
|
2025
|
-
syncPath: 'ws-push',
|
|
2026
|
-
outcome: 'rejected',
|
|
2027
|
-
statusCode: 400,
|
|
2028
|
-
durationMs: invalidDurationMs,
|
|
2029
|
-
errorCode: 'INVALID_PUSH_PAYLOAD',
|
|
2030
|
-
}));
|
|
2217
|
+
});
|
|
2031
2218
|
return;
|
|
2032
2219
|
}
|
|
2033
2220
|
|
|
2034
2221
|
const pushOps = parsed.data.operations ?? [];
|
|
2035
2222
|
if (pushOps.length > maxOperationsPerPush) {
|
|
2036
2223
|
const rejectedDurationMs = timer();
|
|
2224
|
+
const errorMessage = `Maximum ${maxOperationsPerPush} operations per push`;
|
|
2037
2225
|
conn.sendPushResponse({
|
|
2038
2226
|
requestId,
|
|
2039
2227
|
ok: false,
|
|
@@ -2042,26 +2230,22 @@ export function createSyncRoutes<
|
|
|
2042
2230
|
{
|
|
2043
2231
|
opIndex: 0,
|
|
2044
2232
|
status: 'error',
|
|
2045
|
-
error:
|
|
2233
|
+
error: errorMessage,
|
|
2046
2234
|
},
|
|
2047
2235
|
],
|
|
2048
2236
|
});
|
|
2049
|
-
|
|
2237
|
+
recordWsPushFailure({
|
|
2050
2238
|
partitionId,
|
|
2051
2239
|
requestId,
|
|
2052
|
-
traceId: traceContext.traceId,
|
|
2053
|
-
spanId: traceContext.spanId,
|
|
2054
|
-
eventType: 'push',
|
|
2055
|
-
syncPath: 'ws-push',
|
|
2056
2240
|
actorId,
|
|
2057
2241
|
clientId,
|
|
2058
2242
|
transportPath: conn.transportPath,
|
|
2059
2243
|
statusCode: 400,
|
|
2060
2244
|
outcome: 'rejected',
|
|
2061
|
-
responseStatus: normalizeResponseStatus(400, 'rejected'),
|
|
2062
2245
|
durationMs: rejectedDurationMs,
|
|
2063
2246
|
errorCode: 'MAX_OPERATIONS_EXCEEDED',
|
|
2064
|
-
errorMessage
|
|
2247
|
+
errorMessage,
|
|
2248
|
+
traceContext,
|
|
2065
2249
|
operationCount: pushOps.length,
|
|
2066
2250
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
2067
2251
|
? {
|
|
@@ -2078,167 +2262,27 @@ export function createSyncRoutes<
|
|
|
2078
2262
|
},
|
|
2079
2263
|
}
|
|
2080
2264
|
: null,
|
|
2081
|
-
})
|
|
2082
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
2083
|
-
partitionId,
|
|
2084
|
-
requestId,
|
|
2085
|
-
traceId: traceContext.traceId,
|
|
2086
|
-
spanId: traceContext.spanId,
|
|
2087
|
-
actorId,
|
|
2088
|
-
clientId,
|
|
2089
|
-
transportPath: conn.transportPath,
|
|
2090
|
-
syncPath: 'ws-push',
|
|
2091
|
-
outcome: 'rejected',
|
|
2092
|
-
statusCode: 400,
|
|
2093
|
-
durationMs: rejectedDurationMs,
|
|
2094
|
-
operationCount: pushOps.length,
|
|
2095
|
-
errorCode: 'MAX_OPERATIONS_EXCEEDED',
|
|
2096
|
-
}));
|
|
2265
|
+
});
|
|
2097
2266
|
return;
|
|
2098
2267
|
}
|
|
2099
2268
|
|
|
2100
|
-
const pushed = await
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
handlers: handlerRegistry,
|
|
2104
|
-
plugins: options.plugins,
|
|
2105
|
-
auth,
|
|
2106
|
-
request: {
|
|
2269
|
+
const pushed = await executePushCommitWithSideEffects(
|
|
2270
|
+
{
|
|
2271
|
+
auth,
|
|
2107
2272
|
clientId,
|
|
2273
|
+
partitionId,
|
|
2274
|
+
requestId,
|
|
2275
|
+
traceContext,
|
|
2276
|
+
transportPath: conn.transportPath,
|
|
2277
|
+
syncPath: 'ws-push',
|
|
2278
|
+
},
|
|
2279
|
+
{
|
|
2108
2280
|
clientCommitId: parsed.data.clientCommitId,
|
|
2109
2281
|
operations: parsed.data.operations,
|
|
2110
2282
|
schemaVersion: parsed.data.schemaVersion,
|
|
2111
2283
|
},
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
const pushDurationMs = timer();
|
|
2115
|
-
|
|
2116
|
-
logSyncEvent({
|
|
2117
|
-
event: 'sync.push',
|
|
2118
|
-
userId: actorId,
|
|
2119
|
-
durationMs: pushDurationMs,
|
|
2120
|
-
operationCount: pushOps.length,
|
|
2121
|
-
status: pushed.response.status,
|
|
2122
|
-
commitSeq: pushed.response.commitSeq,
|
|
2123
|
-
});
|
|
2124
|
-
|
|
2125
|
-
recordRequestEventInBackground(() => ({
|
|
2126
|
-
partitionId,
|
|
2127
|
-
requestId,
|
|
2128
|
-
traceId: traceContext.traceId,
|
|
2129
|
-
spanId: traceContext.spanId,
|
|
2130
|
-
eventType: 'push',
|
|
2131
|
-
syncPath: 'ws-push',
|
|
2132
|
-
actorId,
|
|
2133
|
-
clientId,
|
|
2134
|
-
transportPath: conn.transportPath,
|
|
2135
|
-
statusCode: 200,
|
|
2136
|
-
outcome: pushed.response.status,
|
|
2137
|
-
responseStatus: normalizeResponseStatus(200, pushed.response.status),
|
|
2138
|
-
durationMs: pushDurationMs,
|
|
2139
|
-
errorCode: firstPushErrorCode(pushed.response.results),
|
|
2140
|
-
commitSeq: pushed.response.commitSeq,
|
|
2141
|
-
operationCount: pushOps.length,
|
|
2142
|
-
tables: pushed.affectedTables,
|
|
2143
|
-
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
2144
|
-
? {
|
|
2145
|
-
request: {
|
|
2146
|
-
clientId,
|
|
2147
|
-
clientCommitId: parsed.data.clientCommitId,
|
|
2148
|
-
schemaVersion: parsed.data.schemaVersion,
|
|
2149
|
-
operations: parsed.data.operations,
|
|
2150
|
-
},
|
|
2151
|
-
response: pushed.response,
|
|
2152
|
-
}
|
|
2153
|
-
: null,
|
|
2154
|
-
}));
|
|
2155
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
2156
|
-
partitionId,
|
|
2157
|
-
requestId,
|
|
2158
|
-
traceId: traceContext.traceId,
|
|
2159
|
-
spanId: traceContext.spanId,
|
|
2160
|
-
actorId,
|
|
2161
|
-
clientId,
|
|
2162
|
-
transportPath: conn.transportPath,
|
|
2163
|
-
syncPath: 'ws-push',
|
|
2164
|
-
outcome: pushed.response.status,
|
|
2165
|
-
statusCode: 200,
|
|
2166
|
-
durationMs: pushDurationMs,
|
|
2167
|
-
commitSeq: pushed.response.commitSeq ?? null,
|
|
2168
|
-
operationCount: pushOps.length,
|
|
2169
|
-
tables: pushed.affectedTables,
|
|
2170
|
-
}));
|
|
2171
|
-
|
|
2172
|
-
const detectedConflicts = pushed.response.results.reduce(
|
|
2173
|
-
(count, result) => count + (result.status === 'conflict' ? 1 : 0),
|
|
2174
|
-
0
|
|
2284
|
+
{ countConflictsMetric: true }
|
|
2175
2285
|
);
|
|
2176
|
-
if (detectedConflicts > 0) {
|
|
2177
|
-
countSyncMetric('sync.conflicts.detected', detectedConflicts, {
|
|
2178
|
-
attributes: {
|
|
2179
|
-
syncPath: 'ws-push',
|
|
2180
|
-
transportPath: conn.transportPath,
|
|
2181
|
-
},
|
|
2182
|
-
});
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
// WS notifications to other clients
|
|
2186
|
-
if (
|
|
2187
|
-
wsConnectionManager &&
|
|
2188
|
-
pushed.response.ok === true &&
|
|
2189
|
-
pushed.response.status === 'applied' &&
|
|
2190
|
-
typeof pushed.response.commitSeq === 'number'
|
|
2191
|
-
) {
|
|
2192
|
-
const scopeKeys = applyPartitionToScopeKeys(
|
|
2193
|
-
partitionId,
|
|
2194
|
-
pushed.scopeKeys
|
|
2195
|
-
);
|
|
2196
|
-
if (scopeKeys.length > 0) {
|
|
2197
|
-
wsConnectionManager.notifyScopeKeys(
|
|
2198
|
-
scopeKeys,
|
|
2199
|
-
pushed.response.commitSeq,
|
|
2200
|
-
{
|
|
2201
|
-
excludeClientIds: [clientId],
|
|
2202
|
-
changes: pushed.emittedChanges,
|
|
2203
|
-
actorId: pushed.commitActorId ?? actorId,
|
|
2204
|
-
createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
|
|
2205
|
-
}
|
|
2206
|
-
);
|
|
2207
|
-
|
|
2208
|
-
if (realtimeBroadcaster) {
|
|
2209
|
-
realtimeBroadcaster
|
|
2210
|
-
.publish({
|
|
2211
|
-
type: 'commit',
|
|
2212
|
-
commitSeq: pushed.response.commitSeq,
|
|
2213
|
-
partitionId,
|
|
2214
|
-
scopeKeys,
|
|
2215
|
-
sourceInstanceId: instanceId,
|
|
2216
|
-
})
|
|
2217
|
-
.catch((error) => {
|
|
2218
|
-
logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
|
|
2219
|
-
event: 'sync.realtime.broadcast_publish_failed',
|
|
2220
|
-
userId: actorId,
|
|
2221
|
-
clientId,
|
|
2222
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2223
|
-
});
|
|
2224
|
-
});
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
if (
|
|
2230
|
-
pushed.response.ok === true &&
|
|
2231
|
-
pushed.response.status === 'applied' &&
|
|
2232
|
-
typeof pushed.response.commitSeq === 'number'
|
|
2233
|
-
) {
|
|
2234
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
|
|
2235
|
-
partitionId,
|
|
2236
|
-
commitSeq: pushed.response.commitSeq,
|
|
2237
|
-
actorId,
|
|
2238
|
-
clientId,
|
|
2239
|
-
affectedTables: pushed.affectedTables,
|
|
2240
|
-
}));
|
|
2241
|
-
}
|
|
2242
2286
|
|
|
2243
2287
|
triggerAutoMaintenance({
|
|
2244
2288
|
actorId,
|
|
@@ -2264,22 +2308,18 @@ export function createSyncRoutes<
|
|
|
2264
2308
|
});
|
|
2265
2309
|
const message =
|
|
2266
2310
|
err instanceof Error ? err.message : 'Internal server error';
|
|
2267
|
-
|
|
2311
|
+
recordWsPushFailure({
|
|
2268
2312
|
partitionId,
|
|
2269
2313
|
requestId,
|
|
2270
|
-
traceId: traceContext.traceId,
|
|
2271
|
-
spanId: traceContext.spanId,
|
|
2272
|
-
eventType: 'push',
|
|
2273
|
-
syncPath: 'ws-push',
|
|
2274
2314
|
actorId,
|
|
2275
2315
|
clientId,
|
|
2276
2316
|
transportPath: conn.transportPath,
|
|
2277
2317
|
statusCode: 500,
|
|
2278
2318
|
outcome: 'error',
|
|
2279
|
-
responseStatus: normalizeResponseStatus(500, 'error'),
|
|
2280
2319
|
durationMs: failedDurationMs,
|
|
2281
2320
|
errorCode: 'INTERNAL_SERVER_ERROR',
|
|
2282
2321
|
errorMessage: message,
|
|
2322
|
+
traceContext,
|
|
2283
2323
|
payloadSnapshot: shouldCaptureRequestPayloadSnapshots
|
|
2284
2324
|
? {
|
|
2285
2325
|
request: msg,
|
|
@@ -2291,21 +2331,7 @@ export function createSyncRoutes<
|
|
|
2291
2331
|
},
|
|
2292
2332
|
}
|
|
2293
2333
|
: null,
|
|
2294
|
-
})
|
|
2295
|
-
emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
|
|
2296
|
-
partitionId,
|
|
2297
|
-
requestId,
|
|
2298
|
-
traceId: traceContext.traceId,
|
|
2299
|
-
spanId: traceContext.spanId,
|
|
2300
|
-
actorId,
|
|
2301
|
-
clientId,
|
|
2302
|
-
transportPath: conn.transportPath,
|
|
2303
|
-
syncPath: 'ws-push',
|
|
2304
|
-
outcome: 'error',
|
|
2305
|
-
statusCode: 500,
|
|
2306
|
-
durationMs: failedDurationMs,
|
|
2307
|
-
errorCode: 'INTERNAL_SERVER_ERROR',
|
|
2308
|
-
}));
|
|
2334
|
+
});
|
|
2309
2335
|
conn.sendPushResponse({
|
|
2310
2336
|
requestId,
|
|
2311
2337
|
ok: false,
|
|
@@ -2314,6 +2340,8 @@ export function createSyncRoutes<
|
|
|
2314
2340
|
});
|
|
2315
2341
|
}
|
|
2316
2342
|
}
|
|
2343
|
+
|
|
2344
|
+
return routes;
|
|
2317
2345
|
}
|
|
2318
2346
|
|
|
2319
2347
|
export function getSyncWebSocketConnectionManager(
|
|
@@ -2332,6 +2360,40 @@ function clampInt(value: number, min: number, max: number): number {
|
|
|
2332
2360
|
return Math.max(min, Math.min(max, value));
|
|
2333
2361
|
}
|
|
2334
2362
|
|
|
2363
|
+
function isWebSocketOriginAllowed(
|
|
2364
|
+
c: Context,
|
|
2365
|
+
allowedOrigins?: string[] | '*'
|
|
2366
|
+
): boolean {
|
|
2367
|
+
if (!allowedOrigins) return true;
|
|
2368
|
+
if (allowedOrigins === '*') return true;
|
|
2369
|
+
|
|
2370
|
+
const origin = c.req.header('origin');
|
|
2371
|
+
if (!origin) return false;
|
|
2372
|
+
|
|
2373
|
+
try {
|
|
2374
|
+
const normalizedOrigin = new URL(origin).origin;
|
|
2375
|
+
return allowedOrigins.includes(normalizedOrigin);
|
|
2376
|
+
} catch {
|
|
2377
|
+
return false;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function measureWebSocketMessageBytes(data: unknown): number {
|
|
2382
|
+
if (typeof data === 'string') {
|
|
2383
|
+
return new TextEncoder().encode(data).byteLength;
|
|
2384
|
+
}
|
|
2385
|
+
if (data instanceof ArrayBuffer) {
|
|
2386
|
+
return data.byteLength;
|
|
2387
|
+
}
|
|
2388
|
+
if (ArrayBuffer.isView(data)) {
|
|
2389
|
+
return data.byteLength;
|
|
2390
|
+
}
|
|
2391
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
2392
|
+
return data.size;
|
|
2393
|
+
}
|
|
2394
|
+
return new TextEncoder().encode(String(data)).byteLength;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2335
2397
|
function readTransportPath(
|
|
2336
2398
|
c: Context,
|
|
2337
2399
|
queryValue?: string | null
|
|
@@ -2348,15 +2410,6 @@ function readTransportPath(
|
|
|
2348
2410
|
return 'direct';
|
|
2349
2411
|
}
|
|
2350
2412
|
|
|
2351
|
-
function parseJsonColumn(value: unknown): unknown {
|
|
2352
|
-
if (typeof value !== 'string') return value;
|
|
2353
|
-
try {
|
|
2354
|
-
return JSON.parse(value);
|
|
2355
|
-
} catch {
|
|
2356
|
-
return value;
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
2413
|
function scopeValuesToScopeKeys(scopes: unknown): string[] {
|
|
2361
2414
|
if (!scopes || typeof scopes !== 'object') return [];
|
|
2362
2415
|
const scopeKeys = new Set<string>();
|