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