@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.
Files changed (50) hide show
  1. package/dist/blobs.d.ts +10 -4
  2. package/dist/blobs.d.ts.map +1 -1
  3. package/dist/blobs.js +260 -26
  4. package/dist/blobs.js.map +1 -1
  5. package/dist/console/gateway.d.ts +4 -0
  6. package/dist/console/gateway.d.ts.map +1 -1
  7. package/dist/console/gateway.js +97 -60
  8. package/dist/console/gateway.js.map +1 -1
  9. package/dist/console/route-descriptor.d.ts +6 -0
  10. package/dist/console/route-descriptor.d.ts.map +1 -0
  11. package/dist/console/route-descriptor.js +16 -0
  12. package/dist/console/route-descriptor.js.map +1 -0
  13. package/dist/console/routes.d.ts.map +1 -1
  14. package/dist/console/routes.js +153 -108
  15. package/dist/console/routes.js.map +1 -1
  16. package/dist/console/schema-errors.d.ts +2 -0
  17. package/dist/console/schema-errors.d.ts.map +1 -0
  18. package/dist/console/schema-errors.js +17 -0
  19. package/dist/console/schema-errors.js.map +1 -0
  20. package/dist/console/schemas.js +1 -1
  21. package/dist/console/schemas.js.map +1 -1
  22. package/dist/console/types.d.ts +32 -0
  23. package/dist/console/types.d.ts.map +1 -1
  24. package/dist/create-server.d.ts.map +1 -1
  25. package/dist/create-server.js +13 -10
  26. package/dist/create-server.js.map +1 -1
  27. package/dist/proxy/routes.d.ts +10 -0
  28. package/dist/proxy/routes.d.ts.map +1 -1
  29. package/dist/proxy/routes.js +57 -6
  30. package/dist/proxy/routes.js.map +1 -1
  31. package/dist/routes.d.ts +21 -0
  32. package/dist/routes.d.ts.map +1 -1
  33. package/dist/routes.js +338 -352
  34. package/dist/routes.js.map +1 -1
  35. package/package.json +7 -6
  36. package/src/__tests__/blob-routes.test.ts +286 -18
  37. package/src/__tests__/console-gateway-live-routes.test.ts +61 -1
  38. package/src/__tests__/console-routes.test.ts +30 -1
  39. package/src/__tests__/create-server.test.ts +237 -1
  40. package/src/__tests__/pull-chunk-storage.test.ts +98 -0
  41. package/src/blobs.ts +360 -34
  42. package/src/console/gateway.ts +335 -288
  43. package/src/console/route-descriptor.ts +22 -0
  44. package/src/console/routes.ts +327 -248
  45. package/src/console/schema-errors.ts +23 -0
  46. package/src/console/schemas.ts +1 -1
  47. package/src/console/types.ts +32 -0
  48. package/src/create-server.ts +13 -10
  49. package/src/proxy/routes.ts +73 -9
  50. 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: parseJsonColumn(change.row_json),
1147
- scopes: parseJsonColumn(change.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
- const timer = createSyncTimer();
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
- commitSeq: pushed.response.commitSeq,
1349
- actorId: auth.actorId,
1350
- clientId,
1351
- affectedTables: pushed.affectedTables,
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: readTransportPath(c),
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: readTransportPath(c),
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
- // Note: Snapshot chunks are created during authorized pull requests
1633
- // and have opaque IDs that expire. Additional authorization is handled
1634
- // at the pull layer via table-level resolveScopes.
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
- unregister?.();
1834
- unregister = null;
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
- actorId: auth.actorId,
1844
- clientId,
1845
- partitionId,
1846
- }));
2001
+ });
1847
2002
  },
1848
2003
  onError(_evt, _ws) {
1849
- unregister?.();
1850
- unregister = null;
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
- actorId: auth.actorId,
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
- logSyncEvent({
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
- logSyncEvent({
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
- recordRequestEventInBackground(() => ({
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: 'Invalid push payload',
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: `Maximum ${maxOperationsPerPush} operations per push`,
2233
+ error: errorMessage,
2046
2234
  },
2047
2235
  ],
2048
2236
  });
2049
- recordRequestEventInBackground(() => ({
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: `Maximum ${maxOperationsPerPush} operations per push`,
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 pushCommit({
2101
- db: options.db,
2102
- dialect: options.dialect,
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
- recordRequestEventInBackground(() => ({
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>();