@syncular/server-hono 0.0.1-100

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 (77) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +80 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +100 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +884 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +572 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/sync-rate-limit-routing.test.ts +181 -0
  63. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  64. package/src/api-key-auth.ts +179 -0
  65. package/src/blobs.ts +534 -0
  66. package/src/console/index.ts +17 -0
  67. package/src/console/routes.ts +2155 -0
  68. package/src/console/schemas.ts +299 -0
  69. package/src/create-server.ts +186 -0
  70. package/src/index.ts +42 -0
  71. package/src/openapi.ts +74 -0
  72. package/src/proxy/connection-manager.ts +340 -0
  73. package/src/proxy/index.ts +8 -0
  74. package/src/proxy/routes.ts +223 -0
  75. package/src/rate-limit.ts +321 -0
  76. package/src/routes.ts +1305 -0
  77. package/src/ws.ts +789 -0
package/dist/routes.js ADDED
@@ -0,0 +1,884 @@
1
+ /**
2
+ * @syncular/server-hono - Sync routes for Hono
3
+ *
4
+ * Provides:
5
+ * - POST / (combined push + pull in one round-trip)
6
+ * - GET /snapshot-chunks/:chunkId (download encoded snapshot chunks)
7
+ * - GET /realtime (optional WebSocket "wake up" notifications)
8
+ */
9
+ import { captureSyncException, createSyncTimer, ErrorResponseSchema, logSyncEvent, SyncCombinedRequestSchema, SyncCombinedResponseSchema, SyncPushRequestSchema, } from '@syncular/core';
10
+ import { InvalidSubscriptionScopeError, pull, pushCommit, readSnapshotChunk, recordClientCursor, TableRegistry, } from '@syncular/server';
11
+ import { Hono } from 'hono';
12
+ import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
13
+ import { sql, } from 'kysely';
14
+ import { z } from 'zod';
15
+ import { createRateLimiter, DEFAULT_SYNC_RATE_LIMITS, } from './rate-limit.js';
16
+ import { createWebSocketConnection, WebSocketConnectionManager, } from './ws.js';
17
+ /**
18
+ * WeakMaps for storing Hono-instance-specific data without augmenting the type.
19
+ */
20
+ const wsConnectionManagerMap = new WeakMap();
21
+ const realtimeUnsubscribeMap = new WeakMap();
22
+ // ============================================================================
23
+ // Route Schemas
24
+ // ============================================================================
25
+ const snapshotChunkParamsSchema = z.object({
26
+ chunkId: z.string().min(1),
27
+ });
28
+ export function createSyncRoutes(options) {
29
+ const routes = new Hono();
30
+ routes.onError((error, c) => {
31
+ captureSyncException(error, {
32
+ event: 'sync.route.unhandled',
33
+ method: c.req.method,
34
+ path: c.req.path,
35
+ });
36
+ return c.text('Internal Server Error', 500);
37
+ });
38
+ const handlerRegistry = new TableRegistry();
39
+ for (const handler of options.handlers) {
40
+ handlerRegistry.register(handler);
41
+ }
42
+ const config = options.sync ?? {};
43
+ const maxPullLimitCommits = config.maxPullLimitCommits ?? 100;
44
+ const maxSubscriptionsPerPull = config.maxSubscriptionsPerPull ?? 200;
45
+ const maxPullLimitSnapshotRows = config.maxPullLimitSnapshotRows ?? 5000;
46
+ const maxPullMaxSnapshotPages = config.maxPullMaxSnapshotPages ?? 10;
47
+ const maxOperationsPerPush = config.maxOperationsPerPush ?? 200;
48
+ // -------------------------------------------------------------------------
49
+ // Optional WebSocket manager (scope-key based wake-ups)
50
+ // -------------------------------------------------------------------------
51
+ const websocketConfig = config.websocket;
52
+ if (websocketConfig?.enabled && !websocketConfig.upgradeWebSocket) {
53
+ throw new Error('sync.websocket.enabled requires sync.websocket.upgradeWebSocket');
54
+ }
55
+ const wsConnectionManager = websocketConfig?.enabled
56
+ ? (options.wsConnectionManager ??
57
+ new WebSocketConnectionManager({
58
+ heartbeatIntervalMs: websocketConfig.heartbeatIntervalMs ?? 30_000,
59
+ }))
60
+ : null;
61
+ if (wsConnectionManager) {
62
+ wsConnectionManagerMap.set(routes, wsConnectionManager);
63
+ }
64
+ // -------------------------------------------------------------------------
65
+ // Multi-instance realtime broadcaster (optional)
66
+ // -------------------------------------------------------------------------
67
+ const realtimeBroadcaster = config.realtime?.broadcaster ?? null;
68
+ const instanceId = config.realtime?.instanceId ??
69
+ (typeof crypto !== 'undefined' && 'randomUUID' in crypto
70
+ ? crypto.randomUUID()
71
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
72
+ const loggedAsyncFailureKeys = new Set();
73
+ const logAsyncFailureOnce = (key, event) => {
74
+ if (loggedAsyncFailureKeys.has(key))
75
+ return;
76
+ loggedAsyncFailureKeys.add(key);
77
+ logSyncEvent(event);
78
+ };
79
+ if (wsConnectionManager && realtimeBroadcaster) {
80
+ const unsubscribe = realtimeBroadcaster.subscribe((event) => {
81
+ void handleRealtimeEvent(event).catch((error) => {
82
+ logAsyncFailureOnce('sync.realtime.broadcast_delivery_failed', {
83
+ event: 'sync.realtime.broadcast_delivery_failed',
84
+ error: error instanceof Error ? error.message : String(error),
85
+ sourceEventType: event.type,
86
+ });
87
+ });
88
+ });
89
+ realtimeUnsubscribeMap.set(routes, unsubscribe);
90
+ }
91
+ const recordRequestEvent = async (event) => {
92
+ const tablesValue = options.dialect.arrayToDb(event.tables ?? []);
93
+ await sql `
94
+ INSERT INTO sync_request_events (
95
+ event_type, actor_id, client_id, status_code, outcome,
96
+ duration_ms, commit_seq, operation_count, row_count,
97
+ tables, error_message, transport_path
98
+ ) VALUES (
99
+ ${event.eventType}, ${event.actorId}, ${event.clientId},
100
+ ${event.statusCode}, ${event.outcome}, ${event.durationMs},
101
+ ${event.commitSeq ?? null}, ${event.operationCount ?? null},
102
+ ${event.rowCount ?? null}, ${tablesValue}, ${event.errorMessage ?? null},
103
+ ${event.transportPath}
104
+ )
105
+ `.execute(options.db);
106
+ };
107
+ const recordRequestEventInBackground = (event) => {
108
+ void recordRequestEvent(event).catch((error) => {
109
+ logAsyncFailureOnce('sync.request_event_record_failed', {
110
+ event: 'sync.request_event_record_failed',
111
+ userId: event.actorId,
112
+ clientId: event.clientId,
113
+ requestEventType: event.eventType,
114
+ error: error instanceof Error ? error.message : String(error),
115
+ });
116
+ });
117
+ };
118
+ const authCache = new WeakMap();
119
+ const getAuth = (c) => {
120
+ const cached = authCache.get(c);
121
+ if (cached)
122
+ return cached;
123
+ const pending = options.authenticate(c);
124
+ authCache.set(c, pending);
125
+ return pending;
126
+ };
127
+ // -------------------------------------------------------------------------
128
+ // Rate limiting (optional)
129
+ // -------------------------------------------------------------------------
130
+ const rateLimitConfig = config.rateLimit;
131
+ if (rateLimitConfig !== false) {
132
+ const pullRateLimit = rateLimitConfig?.pull ?? DEFAULT_SYNC_RATE_LIMITS.pull;
133
+ const pushRateLimit = rateLimitConfig?.push ?? DEFAULT_SYNC_RATE_LIMITS.push;
134
+ const createAuthBasedRateLimiter = (limitConfig) => {
135
+ if (limitConfig === false || !limitConfig)
136
+ return null;
137
+ return createRateLimiter({
138
+ ...limitConfig,
139
+ keyGenerator: async (c) => {
140
+ const auth = await getAuth(c);
141
+ return auth?.actorId ?? null;
142
+ },
143
+ });
144
+ };
145
+ const pullLimiter = createAuthBasedRateLimiter(pullRateLimit);
146
+ const pushLimiter = createAuthBasedRateLimiter(pushRateLimit);
147
+ const syncRateLimiter = async (c, next) => {
148
+ if (!pullLimiter && !pushLimiter)
149
+ return next();
150
+ let shouldApplyPull = pullLimiter !== null;
151
+ let shouldApplyPush = pushLimiter !== null;
152
+ if (pullLimiter && pushLimiter && c.req.method === 'POST') {
153
+ try {
154
+ const parsed = await c.req.raw.clone().json();
155
+ if (parsed !== null && typeof parsed === 'object') {
156
+ shouldApplyPull = Reflect.get(parsed, 'pull') !== undefined;
157
+ shouldApplyPush = Reflect.get(parsed, 'push') !== undefined;
158
+ }
159
+ }
160
+ catch {
161
+ // Keep default behavior and apply both limiters when payload parsing fails.
162
+ }
163
+ }
164
+ if (pullLimiter && shouldApplyPull && pushLimiter && shouldApplyPush) {
165
+ return pullLimiter(c, async () => {
166
+ const pushResult = await pushLimiter(c, next);
167
+ if (pushResult instanceof Response) {
168
+ c.res = pushResult;
169
+ }
170
+ });
171
+ }
172
+ if (pullLimiter && shouldApplyPull) {
173
+ return pullLimiter(c, next);
174
+ }
175
+ if (pushLimiter && shouldApplyPush) {
176
+ return pushLimiter(c, next);
177
+ }
178
+ return next();
179
+ };
180
+ routes.use('/', syncRateLimiter);
181
+ }
182
+ // -------------------------------------------------------------------------
183
+ // GET /health
184
+ // -------------------------------------------------------------------------
185
+ routes.get('/health', (c) => {
186
+ return c.json({
187
+ status: 'healthy',
188
+ timestamp: new Date().toISOString(),
189
+ });
190
+ });
191
+ // -------------------------------------------------------------------------
192
+ // POST / (combined push + pull in one round-trip)
193
+ // -------------------------------------------------------------------------
194
+ routes.post('/', describeRoute({
195
+ tags: ['sync'],
196
+ summary: 'Combined push and pull',
197
+ description: 'Perform push and/or pull in a single request to reduce round-trips',
198
+ responses: {
199
+ 200: {
200
+ description: 'Combined sync response',
201
+ content: {
202
+ 'application/json': {
203
+ schema: resolver(SyncCombinedResponseSchema),
204
+ },
205
+ },
206
+ },
207
+ 400: {
208
+ description: 'Invalid request',
209
+ content: {
210
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
211
+ },
212
+ },
213
+ 401: {
214
+ description: 'Unauthenticated',
215
+ content: {
216
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
217
+ },
218
+ },
219
+ },
220
+ }), zValidator('json', SyncCombinedRequestSchema), async (c) => {
221
+ const auth = await getAuth(c);
222
+ if (!auth)
223
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
224
+ const partitionId = auth.partitionId ?? 'default';
225
+ const body = c.req.valid('json');
226
+ const clientId = body.clientId;
227
+ let pushResponse;
228
+ let pullResponse;
229
+ // --- Push phase ---
230
+ if (body.push) {
231
+ const pushOps = body.push.operations ?? [];
232
+ if (pushOps.length > maxOperationsPerPush) {
233
+ return c.json({
234
+ error: 'TOO_MANY_OPERATIONS',
235
+ message: `Maximum ${maxOperationsPerPush} operations per push`,
236
+ }, 400);
237
+ }
238
+ const timer = createSyncTimer();
239
+ const pushed = await pushCommit({
240
+ db: options.db,
241
+ dialect: options.dialect,
242
+ shapes: handlerRegistry,
243
+ actorId: auth.actorId,
244
+ partitionId,
245
+ request: {
246
+ clientId,
247
+ clientCommitId: body.push.clientCommitId,
248
+ operations: body.push.operations,
249
+ schemaVersion: body.push.schemaVersion,
250
+ },
251
+ });
252
+ const pushDurationMs = timer();
253
+ logSyncEvent({
254
+ event: 'sync.push',
255
+ userId: auth.actorId,
256
+ durationMs: pushDurationMs,
257
+ operationCount: pushOps.length,
258
+ status: pushed.response.status,
259
+ commitSeq: pushed.response.commitSeq,
260
+ });
261
+ recordRequestEventInBackground({
262
+ eventType: 'push',
263
+ actorId: auth.actorId,
264
+ clientId,
265
+ transportPath: readTransportPath(c),
266
+ statusCode: 200,
267
+ outcome: pushed.response.status,
268
+ durationMs: pushDurationMs,
269
+ commitSeq: pushed.response.commitSeq,
270
+ operationCount: pushOps.length,
271
+ tables: pushed.affectedTables,
272
+ });
273
+ // WS notifications
274
+ if (wsConnectionManager &&
275
+ pushed.response.ok === true &&
276
+ pushed.response.status === 'applied' &&
277
+ typeof pushed.response.commitSeq === 'number') {
278
+ const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
279
+ if (scopeKeys.length > 0) {
280
+ wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
281
+ excludeClientIds: [clientId],
282
+ changes: pushed.emittedChanges,
283
+ });
284
+ if (realtimeBroadcaster) {
285
+ realtimeBroadcaster
286
+ .publish({
287
+ type: 'commit',
288
+ commitSeq: pushed.response.commitSeq,
289
+ partitionId,
290
+ scopeKeys,
291
+ sourceInstanceId: instanceId,
292
+ })
293
+ .catch((error) => {
294
+ logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
295
+ event: 'sync.realtime.broadcast_publish_failed',
296
+ userId: auth.actorId,
297
+ clientId,
298
+ error: error instanceof Error ? error.message : String(error),
299
+ });
300
+ });
301
+ }
302
+ }
303
+ }
304
+ pushResponse = pushed.response;
305
+ }
306
+ // --- Pull phase ---
307
+ if (body.pull) {
308
+ if (body.pull.subscriptions.length > maxSubscriptionsPerPull) {
309
+ return c.json({
310
+ error: 'INVALID_REQUEST',
311
+ message: `Too many subscriptions (max ${maxSubscriptionsPerPull})`,
312
+ }, 400);
313
+ }
314
+ const seenSubscriptionIds = new Set();
315
+ for (const sub of body.pull.subscriptions) {
316
+ const id = sub.id;
317
+ if (seenSubscriptionIds.has(id)) {
318
+ return c.json({
319
+ error: 'INVALID_REQUEST',
320
+ message: `Duplicate subscription id: ${id}`,
321
+ }, 400);
322
+ }
323
+ seenSubscriptionIds.add(id);
324
+ }
325
+ const request = {
326
+ clientId,
327
+ limitCommits: clampInt(body.pull.limitCommits ?? 50, 1, maxPullLimitCommits),
328
+ limitSnapshotRows: clampInt(body.pull.limitSnapshotRows ?? 1000, 1, maxPullLimitSnapshotRows),
329
+ maxSnapshotPages: clampInt(body.pull.maxSnapshotPages ?? 1, 1, maxPullMaxSnapshotPages),
330
+ dedupeRows: body.pull.dedupeRows === true,
331
+ subscriptions: body.pull.subscriptions.map((sub) => ({
332
+ id: sub.id,
333
+ shape: sub.shape,
334
+ scopes: (sub.scopes ?? {}),
335
+ params: sub.params,
336
+ cursor: Math.max(-1, sub.cursor),
337
+ bootstrapState: sub.bootstrapState ?? null,
338
+ })),
339
+ };
340
+ const timer = createSyncTimer();
341
+ let pullResult;
342
+ try {
343
+ pullResult = await pull({
344
+ db: options.db,
345
+ dialect: options.dialect,
346
+ shapes: handlerRegistry,
347
+ actorId: auth.actorId,
348
+ partitionId,
349
+ request,
350
+ chunkStorage: options.chunkStorage,
351
+ });
352
+ }
353
+ catch (err) {
354
+ if (err instanceof InvalidSubscriptionScopeError) {
355
+ return c.json({ error: 'INVALID_SUBSCRIPTION', message: err.message }, 400);
356
+ }
357
+ throw err;
358
+ }
359
+ // Fire-and-forget bookkeeping
360
+ void recordClientCursor(options.db, options.dialect, {
361
+ partitionId,
362
+ clientId,
363
+ actorId: auth.actorId,
364
+ cursor: pullResult.clientCursor,
365
+ effectiveScopes: pullResult.effectiveScopes,
366
+ }).catch((error) => {
367
+ logAsyncFailureOnce('sync.client_cursor_record_failed', {
368
+ event: 'sync.client_cursor_record_failed',
369
+ userId: auth.actorId,
370
+ clientId,
371
+ error: error instanceof Error ? error.message : String(error),
372
+ });
373
+ });
374
+ wsConnectionManager?.updateClientScopeKeys(clientId, applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(pullResult.effectiveScopes)));
375
+ const pullDurationMs = timer();
376
+ logSyncEvent({
377
+ event: 'sync.pull',
378
+ userId: auth.actorId,
379
+ durationMs: pullDurationMs,
380
+ subscriptionCount: pullResult.response.subscriptions.length,
381
+ clientCursor: pullResult.clientCursor,
382
+ });
383
+ recordRequestEventInBackground({
384
+ eventType: 'pull',
385
+ actorId: auth.actorId,
386
+ clientId,
387
+ transportPath: readTransportPath(c),
388
+ statusCode: 200,
389
+ outcome: 'applied',
390
+ durationMs: pullDurationMs,
391
+ });
392
+ pullResponse = pullResult.response;
393
+ }
394
+ return c.json({
395
+ ok: true,
396
+ ...(pushResponse ? { push: pushResponse } : {}),
397
+ ...(pullResponse ? { pull: pullResponse } : {}),
398
+ }, 200);
399
+ });
400
+ // -------------------------------------------------------------------------
401
+ // GET /snapshot-chunks/:chunkId
402
+ // -------------------------------------------------------------------------
403
+ routes.get('/snapshot-chunks/:chunkId', describeRoute({
404
+ tags: ['sync'],
405
+ summary: 'Download snapshot chunk',
406
+ description: 'Download an encoded bootstrap snapshot chunk',
407
+ responses: {
408
+ 200: {
409
+ description: 'Snapshot chunk data (gzip-compressed framed JSON rows)',
410
+ content: {
411
+ 'application/octet-stream': {
412
+ schema: resolver(z.string()),
413
+ },
414
+ },
415
+ },
416
+ 304: {
417
+ description: 'Not modified (cached)',
418
+ },
419
+ 401: {
420
+ description: 'Unauthenticated',
421
+ content: {
422
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
423
+ },
424
+ },
425
+ 403: {
426
+ description: 'Forbidden',
427
+ content: {
428
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
429
+ },
430
+ },
431
+ 404: {
432
+ description: 'Not found',
433
+ content: {
434
+ 'application/json': { schema: resolver(ErrorResponseSchema) },
435
+ },
436
+ },
437
+ },
438
+ }), zValidator('param', snapshotChunkParamsSchema), async (c) => {
439
+ const auth = await getAuth(c);
440
+ if (!auth)
441
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
442
+ const partitionId = auth.partitionId ?? 'default';
443
+ const { chunkId } = c.req.valid('param');
444
+ const chunk = await readSnapshotChunk(options.db, chunkId, {
445
+ chunkStorage: options.chunkStorage,
446
+ });
447
+ if (!chunk)
448
+ return c.json({ error: 'NOT_FOUND' }, 404);
449
+ if (chunk.partitionId !== partitionId) {
450
+ return c.json({ error: 'FORBIDDEN' }, 403);
451
+ }
452
+ const nowIso = new Date().toISOString();
453
+ if (chunk.expiresAt <= nowIso) {
454
+ return c.json({ error: 'NOT_FOUND' }, 404);
455
+ }
456
+ // Note: Snapshot chunks are created during authorized pull requests
457
+ // and have opaque IDs that expire. Additional authorization is handled
458
+ // at the pull layer via shape-level resolveScopes.
459
+ const etag = `"sha256:${chunk.sha256}"`;
460
+ const ifNoneMatch = c.req.header('if-none-match');
461
+ if (ifNoneMatch && ifNoneMatch === etag) {
462
+ return new Response(null, {
463
+ status: 304,
464
+ headers: {
465
+ ETag: etag,
466
+ 'Cache-Control': 'private, max-age=0',
467
+ Vary: 'Authorization',
468
+ },
469
+ });
470
+ }
471
+ return new Response(chunk.body, {
472
+ status: 200,
473
+ headers: {
474
+ 'Content-Type': 'application/octet-stream',
475
+ 'Content-Encoding': 'gzip',
476
+ 'Content-Length': String(chunk.byteLength),
477
+ ETag: etag,
478
+ 'Cache-Control': 'private, max-age=0',
479
+ Vary: 'Authorization',
480
+ 'X-Sync-Chunk-Id': chunk.chunkId,
481
+ 'X-Sync-Chunk-Sha256': chunk.sha256,
482
+ 'X-Sync-Chunk-Encoding': chunk.encoding,
483
+ 'X-Sync-Chunk-Compression': chunk.compression,
484
+ },
485
+ });
486
+ });
487
+ // -------------------------------------------------------------------------
488
+ // GET /realtime (optional WebSocket wake-ups)
489
+ // -------------------------------------------------------------------------
490
+ if (wsConnectionManager && websocketConfig?.enabled) {
491
+ routes.get('/realtime', async (c) => {
492
+ const auth = await getAuth(c);
493
+ if (!auth)
494
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
495
+ const partitionId = auth.partitionId ?? 'default';
496
+ const clientId = c.req.query('clientId');
497
+ if (!clientId || typeof clientId !== 'string') {
498
+ return c.json({
499
+ error: 'INVALID_REQUEST',
500
+ message: 'clientId query param is required',
501
+ }, 400);
502
+ }
503
+ const realtimeTransportPath = readTransportPath(c, c.req.query('transportPath'));
504
+ // Load last-known effective scopes for this client (best-effort).
505
+ // Keeps /realtime lightweight and avoids sending large subscription payloads over the URL.
506
+ let initialScopeKeys = [];
507
+ try {
508
+ const cursorsQ = options.db.selectFrom('sync_client_cursors');
509
+ const row = await cursorsQ
510
+ .selectAll()
511
+ .where(sql `partition_id = ${partitionId}`)
512
+ .where(sql `client_id = ${clientId}`)
513
+ .executeTakeFirst();
514
+ if (row && row.actor_id !== auth.actorId) {
515
+ return c.json({ error: 'FORBIDDEN' }, 403);
516
+ }
517
+ const raw = row?.effective_scopes;
518
+ let parsed = raw;
519
+ if (typeof raw === 'string') {
520
+ try {
521
+ parsed = JSON.parse(raw);
522
+ }
523
+ catch {
524
+ parsed = null;
525
+ }
526
+ }
527
+ initialScopeKeys = applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(parsed));
528
+ }
529
+ catch {
530
+ // ignore; realtime is best-effort
531
+ }
532
+ const maxConnectionsTotal = websocketConfig.maxConnectionsTotal ?? 5000;
533
+ const maxConnectionsPerClient = websocketConfig.maxConnectionsPerClient ?? 3;
534
+ if (maxConnectionsTotal > 0 &&
535
+ wsConnectionManager.getTotalConnections() >= maxConnectionsTotal) {
536
+ logSyncEvent({
537
+ event: 'sync.realtime.rejected',
538
+ userId: auth.actorId,
539
+ reason: 'max_total',
540
+ });
541
+ return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_TOTAL' }, 429);
542
+ }
543
+ if (maxConnectionsPerClient > 0 &&
544
+ wsConnectionManager.getConnectionCount(clientId) >=
545
+ maxConnectionsPerClient) {
546
+ logSyncEvent({
547
+ event: 'sync.realtime.rejected',
548
+ userId: auth.actorId,
549
+ reason: 'max_per_client',
550
+ });
551
+ return c.json({ error: 'WEBSOCKET_CONNECTION_LIMIT_CLIENT' }, 429);
552
+ }
553
+ logSyncEvent({ event: 'sync.realtime.connect', userId: auth.actorId });
554
+ let unregister = null;
555
+ let connRef = null;
556
+ const upgradeWebSocket = websocketConfig.upgradeWebSocket;
557
+ if (!upgradeWebSocket) {
558
+ return c.json({ error: 'WEBSOCKET_NOT_CONFIGURED' }, 500);
559
+ }
560
+ return upgradeWebSocket(c, {
561
+ onOpen(_evt, ws) {
562
+ const conn = createWebSocketConnection(ws, {
563
+ actorId: auth.actorId,
564
+ clientId,
565
+ transportPath: realtimeTransportPath,
566
+ });
567
+ connRef = conn;
568
+ unregister = wsConnectionManager.register(conn, initialScopeKeys);
569
+ conn.sendHeartbeat();
570
+ },
571
+ onClose(_evt, _ws) {
572
+ unregister?.();
573
+ unregister = null;
574
+ connRef = null;
575
+ logSyncEvent({
576
+ event: 'sync.realtime.disconnect',
577
+ userId: auth.actorId,
578
+ });
579
+ },
580
+ onError(_evt, _ws) {
581
+ unregister?.();
582
+ unregister = null;
583
+ connRef = null;
584
+ logSyncEvent({
585
+ event: 'sync.realtime.disconnect',
586
+ userId: auth.actorId,
587
+ });
588
+ },
589
+ onMessage(evt, _ws) {
590
+ if (!connRef)
591
+ return;
592
+ try {
593
+ const raw = typeof evt.data === 'string' ? evt.data : String(evt.data);
594
+ const msg = JSON.parse(raw);
595
+ if (!msg || typeof msg !== 'object')
596
+ return;
597
+ if (msg.type === 'push') {
598
+ void handleWsPush(msg, connRef, auth.actorId, partitionId, clientId);
599
+ return;
600
+ }
601
+ if (msg.type !== 'presence' || !msg.scopeKey)
602
+ return;
603
+ const scopeKey = normalizeScopeKeyForPartition(partitionId, String(msg.scopeKey));
604
+ if (!scopeKey)
605
+ return;
606
+ switch (msg.action) {
607
+ case 'join':
608
+ if (!wsConnectionManager.joinPresence(clientId, scopeKey, msg.metadata)) {
609
+ logSyncEvent({
610
+ event: 'sync.realtime.presence.rejected',
611
+ userId: auth.actorId,
612
+ reason: 'scope_not_authorized',
613
+ scopeKey,
614
+ });
615
+ return;
616
+ }
617
+ // Send presence snapshot back to the joining client
618
+ {
619
+ const entries = wsConnectionManager.getPresence(scopeKey);
620
+ connRef.sendPresence({
621
+ action: 'snapshot',
622
+ scopeKey,
623
+ entries,
624
+ });
625
+ }
626
+ break;
627
+ case 'leave':
628
+ wsConnectionManager.leavePresence(clientId, scopeKey);
629
+ break;
630
+ case 'update':
631
+ if (!wsConnectionManager.updatePresenceMetadata(clientId, scopeKey, msg.metadata ?? {}) &&
632
+ !wsConnectionManager.isClientSubscribedToScopeKey(clientId, scopeKey)) {
633
+ logSyncEvent({
634
+ event: 'sync.realtime.presence.rejected',
635
+ userId: auth.actorId,
636
+ reason: 'scope_not_authorized',
637
+ scopeKey,
638
+ });
639
+ }
640
+ break;
641
+ }
642
+ }
643
+ catch {
644
+ // Ignore malformed messages
645
+ }
646
+ },
647
+ });
648
+ });
649
+ }
650
+ return routes;
651
+ async function handleRealtimeEvent(event) {
652
+ if (!wsConnectionManager)
653
+ return;
654
+ if (event.type !== 'commit')
655
+ return;
656
+ if (event.sourceInstanceId && event.sourceInstanceId === instanceId)
657
+ return;
658
+ const commitSeq = event.commitSeq;
659
+ const partitionId = event.partitionId ?? 'default';
660
+ const scopeKeys = event.scopeKeys && event.scopeKeys.length > 0
661
+ ? event.scopeKeys
662
+ : await readCommitScopeKeys(options.db, commitSeq, partitionId);
663
+ if (scopeKeys.length === 0)
664
+ return;
665
+ wsConnectionManager.notifyScopeKeys(scopeKeys, commitSeq);
666
+ }
667
+ async function handleWsPush(msg, conn, actorId, partitionId, clientId) {
668
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : '';
669
+ if (!requestId)
670
+ return;
671
+ try {
672
+ // Validate the push payload
673
+ const parsed = SyncPushRequestSchema.omit({ clientId: true }).safeParse(msg);
674
+ if (!parsed.success) {
675
+ conn.sendPushResponse({
676
+ requestId,
677
+ ok: false,
678
+ status: 'rejected',
679
+ results: [
680
+ { opIndex: 0, status: 'error', error: 'Invalid push payload' },
681
+ ],
682
+ });
683
+ return;
684
+ }
685
+ const pushOps = parsed.data.operations ?? [];
686
+ if (pushOps.length > maxOperationsPerPush) {
687
+ conn.sendPushResponse({
688
+ requestId,
689
+ ok: false,
690
+ status: 'rejected',
691
+ results: [
692
+ {
693
+ opIndex: 0,
694
+ status: 'error',
695
+ error: `Maximum ${maxOperationsPerPush} operations per push`,
696
+ },
697
+ ],
698
+ });
699
+ return;
700
+ }
701
+ const timer = createSyncTimer();
702
+ const pushed = await pushCommit({
703
+ db: options.db,
704
+ dialect: options.dialect,
705
+ shapes: handlerRegistry,
706
+ actorId,
707
+ partitionId,
708
+ request: {
709
+ clientId,
710
+ clientCommitId: parsed.data.clientCommitId,
711
+ operations: parsed.data.operations,
712
+ schemaVersion: parsed.data.schemaVersion,
713
+ },
714
+ });
715
+ const pushDurationMs = timer();
716
+ logSyncEvent({
717
+ event: 'sync.push',
718
+ userId: actorId,
719
+ durationMs: pushDurationMs,
720
+ operationCount: pushOps.length,
721
+ status: pushed.response.status,
722
+ commitSeq: pushed.response.commitSeq,
723
+ });
724
+ recordRequestEventInBackground({
725
+ eventType: 'push',
726
+ actorId,
727
+ clientId,
728
+ transportPath: conn.transportPath,
729
+ statusCode: 200,
730
+ outcome: pushed.response.status,
731
+ durationMs: pushDurationMs,
732
+ commitSeq: pushed.response.commitSeq,
733
+ operationCount: pushOps.length,
734
+ tables: pushed.affectedTables,
735
+ });
736
+ // WS notifications to other clients
737
+ if (wsConnectionManager &&
738
+ pushed.response.ok === true &&
739
+ pushed.response.status === 'applied' &&
740
+ typeof pushed.response.commitSeq === 'number') {
741
+ const scopeKeys = applyPartitionToScopeKeys(partitionId, pushed.scopeKeys);
742
+ if (scopeKeys.length > 0) {
743
+ wsConnectionManager.notifyScopeKeys(scopeKeys, pushed.response.commitSeq, {
744
+ excludeClientIds: [clientId],
745
+ changes: pushed.emittedChanges,
746
+ });
747
+ if (realtimeBroadcaster) {
748
+ realtimeBroadcaster
749
+ .publish({
750
+ type: 'commit',
751
+ commitSeq: pushed.response.commitSeq,
752
+ partitionId,
753
+ scopeKeys,
754
+ sourceInstanceId: instanceId,
755
+ })
756
+ .catch((error) => {
757
+ logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
758
+ event: 'sync.realtime.broadcast_publish_failed',
759
+ userId: actorId,
760
+ clientId,
761
+ error: error instanceof Error ? error.message : String(error),
762
+ });
763
+ });
764
+ }
765
+ }
766
+ }
767
+ conn.sendPushResponse({
768
+ requestId,
769
+ ok: pushed.response.ok,
770
+ status: pushed.response.status,
771
+ commitSeq: pushed.response.commitSeq,
772
+ results: pushed.response.results,
773
+ });
774
+ }
775
+ catch (err) {
776
+ captureSyncException(err, {
777
+ event: 'sync.realtime.push_failed',
778
+ requestId,
779
+ clientId,
780
+ actorId,
781
+ partitionId,
782
+ });
783
+ const message = err instanceof Error ? err.message : 'Internal server error';
784
+ conn.sendPushResponse({
785
+ requestId,
786
+ ok: false,
787
+ status: 'rejected',
788
+ results: [{ opIndex: 0, status: 'error', error: message }],
789
+ });
790
+ }
791
+ }
792
+ }
793
+ export function getSyncWebSocketConnectionManager(routes) {
794
+ return wsConnectionManagerMap.get(routes);
795
+ }
796
+ export function getSyncRealtimeUnsubscribe(routes) {
797
+ return realtimeUnsubscribeMap.get(routes);
798
+ }
799
+ function clampInt(value, min, max) {
800
+ return Math.max(min, Math.min(max, value));
801
+ }
802
+ function readTransportPath(c, queryValue) {
803
+ if (queryValue === 'relay' || queryValue === 'direct') {
804
+ return queryValue;
805
+ }
806
+ const headerValue = c.req.header('x-syncular-transport-path');
807
+ if (headerValue === 'relay' || headerValue === 'direct') {
808
+ return headerValue;
809
+ }
810
+ return 'direct';
811
+ }
812
+ function scopeValuesToScopeKeys(scopes) {
813
+ if (!scopes || typeof scopes !== 'object')
814
+ return [];
815
+ const scopeKeys = new Set();
816
+ for (const [key, value] of Object.entries(scopes)) {
817
+ if (!value)
818
+ continue;
819
+ const prefix = key.replace(/_id$/, '');
820
+ if (Array.isArray(value)) {
821
+ for (const v of value) {
822
+ if (typeof v !== 'string')
823
+ continue;
824
+ if (!v)
825
+ continue;
826
+ scopeKeys.add(`${prefix}:${v}`);
827
+ }
828
+ continue;
829
+ }
830
+ if (typeof value === 'string') {
831
+ if (!value)
832
+ continue;
833
+ scopeKeys.add(`${prefix}:${value}`);
834
+ continue;
835
+ }
836
+ // Best-effort: stringify scalars.
837
+ if (typeof value === 'number' || typeof value === 'bigint') {
838
+ scopeKeys.add(`${prefix}:${String(value)}`);
839
+ }
840
+ }
841
+ return Array.from(scopeKeys);
842
+ }
843
+ function partitionScopeKey(partitionId, scopeKey) {
844
+ return `${partitionId}::${scopeKey}`;
845
+ }
846
+ function applyPartitionToScopeKeys(partitionId, scopeKeys) {
847
+ const prefixed = new Set();
848
+ for (const scopeKey of scopeKeys) {
849
+ if (!scopeKey)
850
+ continue;
851
+ if (scopeKey.startsWith(`${partitionId}::`)) {
852
+ prefixed.add(scopeKey);
853
+ continue;
854
+ }
855
+ prefixed.add(partitionScopeKey(partitionId, scopeKey));
856
+ }
857
+ return Array.from(prefixed);
858
+ }
859
+ function normalizeScopeKeyForPartition(partitionId, scopeKey) {
860
+ if (scopeKey.startsWith(`${partitionId}::`))
861
+ return scopeKey;
862
+ if (scopeKey.includes('::'))
863
+ return '';
864
+ return partitionScopeKey(partitionId, scopeKey);
865
+ }
866
+ async function readCommitScopeKeys(db, commitSeq, partitionId) {
867
+ // Read scopes from the JSONB column and convert to scope strings
868
+ const rowsResult = await sql `
869
+ select scopes
870
+ from ${sql.table('sync_changes')}
871
+ where commit_seq = ${commitSeq}
872
+ and partition_id = ${partitionId}
873
+ `.execute(db);
874
+ const rows = rowsResult.rows;
875
+ const scopeKeys = new Set();
876
+ for (const row of rows) {
877
+ const scopes = typeof row.scopes === 'string' ? JSON.parse(row.scopes) : row.scopes;
878
+ for (const k of applyPartitionToScopeKeys(partitionId, scopeValuesToScopeKeys(scopes))) {
879
+ scopeKeys.add(k);
880
+ }
881
+ }
882
+ return Array.from(scopeKeys);
883
+ }
884
+ //# sourceMappingURL=routes.js.map