@syncular/server-hono 0.0.6-159 → 0.0.6-168

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 (51) 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/__tests__/sync-maintenance.test.ts +15 -2
  42. package/src/blobs.ts +360 -34
  43. package/src/console/gateway.ts +335 -288
  44. package/src/console/route-descriptor.ts +22 -0
  45. package/src/console/routes.ts +327 -248
  46. package/src/console/schema-errors.ts +23 -0
  47. package/src/console/schemas.ts +1 -1
  48. package/src/console/types.ts +32 -0
  49. package/src/create-server.ts +13 -10
  50. package/src/proxy/routes.ts +73 -9
  51. package/src/routes.ts +449 -396
@@ -0,0 +1,23 @@
1
+ const BENIGN_CONSOLE_SCHEMA_ERROR_SUBSTRINGS = [
2
+ 'driver has already been destroyed',
3
+ ];
4
+
5
+ export function isBenignConsoleSchemaError(error: unknown): boolean {
6
+ const visited = new Set<Error>();
7
+ let current: unknown = error;
8
+
9
+ while (current instanceof Error && !visited.has(current)) {
10
+ visited.add(current);
11
+ const message = current.message.toLowerCase();
12
+ if (
13
+ BENIGN_CONSOLE_SCHEMA_ERROR_SUBSTRINGS.some((substring) =>
14
+ message.includes(substring)
15
+ )
16
+ ) {
17
+ return true;
18
+ }
19
+ current = current.cause;
20
+ }
21
+
22
+ return false;
23
+ }
@@ -286,7 +286,7 @@ export type ConsoleApiKeyBulkRevokeResponse = z.infer<
286
286
 
287
287
  export const ConsolePaginationQuerySchema = z.object({
288
288
  limit: z.coerce.number().int().min(1).max(100).default(50),
289
- offset: z.coerce.number().int().min(0).default(0),
289
+ offset: z.coerce.number().int().min(0).max(10_000).default(0),
290
290
  });
291
291
 
292
292
  export const ConsolePartitionQuerySchema = z.object({
@@ -84,6 +84,13 @@ export interface ConsoleMaintenanceOptions {
84
84
  * Default: 5000.
85
85
  */
86
86
  operationEventsMaxRows?: number;
87
+ /**
88
+ * Max rows to scan per source (commits/events) for `/timeline` requests.
89
+ * Prevents unbounded memory growth on large histories.
90
+ * Set to 0 to disable the guard.
91
+ * Default: 10000.
92
+ */
93
+ timelineScanMaxRows?: number;
87
94
  }
88
95
 
89
96
  export interface ConsoleBlobObject {
@@ -176,6 +183,31 @@ export interface CreateConsoleRoutesOptions<
176
183
  * Heartbeat interval in milliseconds. Default: 30000
177
184
  */
178
185
  heartbeatIntervalMs?: number;
186
+ /**
187
+ * Maximum inbound websocket message size in bytes.
188
+ * Messages above this limit are rejected and the connection is closed.
189
+ * Default: 1048576 (1 MiB)
190
+ */
191
+ maxMessageBytes?: number;
192
+ /**
193
+ * Maximum inbound websocket messages allowed per connection within one window.
194
+ * Set to 0 to disable rate limiting.
195
+ * Default: 120
196
+ */
197
+ maxMessagesPerWindow?: number;
198
+ /**
199
+ * Window size in milliseconds for inbound websocket message rate limiting.
200
+ * Ignored when maxMessagesPerWindow is 0.
201
+ * Default: 10000 (10s)
202
+ */
203
+ messageRateWindowMs?: number;
204
+ /**
205
+ * Optional list of allowed websocket origins.
206
+ * - undefined: allow all origins
207
+ * - '*': allow all origins
208
+ * - string[]: exact origin match (scheme + host + port)
209
+ */
210
+ allowedOrigins?: string[] | '*';
179
211
  };
180
212
  /**
181
213
  * Optional console schema readiness promise.
@@ -21,6 +21,7 @@ import {
21
21
  createConsoleRoutes,
22
22
  createTokenAuthenticator,
23
23
  } from './console/routes';
24
+ import { isBenignConsoleSchemaError } from './console/schema-errors';
24
25
  import type {
25
26
  ConsoleEventEmitter,
26
27
  ConsoleSharedOptions,
@@ -140,7 +141,12 @@ export function createSyncServer<
140
141
  : undefined;
141
142
  const consoleSchemaReady =
142
143
  isConsoleEnabled && dialect.ensureConsoleSchema
143
- ? dialect.ensureConsoleSchema(db)
144
+ ? dialect.ensureConsoleSchema(db).catch((error) => {
145
+ if (isBenignConsoleSchemaError(error)) {
146
+ return;
147
+ }
148
+ throw error;
149
+ })
144
150
  : undefined;
145
151
 
146
152
  // Create sync routes
@@ -159,17 +165,9 @@ export function createSyncServer<
159
165
  ...routes,
160
166
  websocket: upgradeWebSocket
161
167
  ? {
168
+ ...routes?.websocket,
162
169
  enabled: true,
163
170
  upgradeWebSocket,
164
- ...(routes?.websocket?.heartbeatIntervalMs !== undefined && {
165
- heartbeatIntervalMs: routes.websocket.heartbeatIntervalMs,
166
- }),
167
- ...(routes?.websocket?.maxConnectionsTotal !== undefined && {
168
- maxConnectionsTotal: routes.websocket.maxConnectionsTotal,
169
- }),
170
- ...(routes?.websocket?.maxConnectionsPerClient !== undefined && {
171
- maxConnectionsPerClient: routes.websocket.maxConnectionsPerClient,
172
- }),
173
171
  }
174
172
  : { enabled: false },
175
173
  },
@@ -196,6 +194,11 @@ export function createSyncServer<
196
194
  websocket: {
197
195
  enabled: true,
198
196
  upgradeWebSocket,
197
+ heartbeatIntervalMs: routes?.websocket?.heartbeatIntervalMs,
198
+ maxMessageBytes: routes?.websocket?.maxMessageBytes,
199
+ maxMessagesPerWindow: routes?.websocket?.maxMessagesPerWindow,
200
+ messageRateWindowMs: routes?.websocket?.messageRateWindowMs,
201
+ allowedOrigins: routes?.websocket?.allowedOrigins,
199
202
  },
200
203
  }),
201
204
  });
@@ -58,6 +58,16 @@ interface CreateProxyRoutesConfig<DB extends SyncCoreDb = SyncCoreDb> {
58
58
  maxConnections?: number;
59
59
  /** Idle connection timeout in ms (default: 30000) */
60
60
  idleTimeoutMs?: number;
61
+ /**
62
+ * Maximum inbound websocket message size in bytes.
63
+ * Default: 1 MiB.
64
+ */
65
+ maxMessageBytes?: number;
66
+ /**
67
+ * Optional list of allowed websocket origins.
68
+ * Use '*' to allow all origins.
69
+ */
70
+ allowedOrigins?: string[] | '*';
61
71
  }
62
72
 
63
73
  /**
@@ -90,6 +100,7 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
90
100
  config: CreateProxyRoutesConfig<DB>
91
101
  ): Hono {
92
102
  const app = new Hono();
103
+ const maxMessageBytes = config.maxMessageBytes ?? 1024 * 1024;
93
104
 
94
105
  const manager = new ProxyConnectionManager({
95
106
  db: config.db,
@@ -104,6 +115,10 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
104
115
 
105
116
  // WebSocket upgrade endpoint - using regular route since WebSocket doesn't fit OpenAPI well
106
117
  app.get('/', async (c) => {
118
+ if (!isWebSocketOriginAllowed(c, config.allowedOrigins)) {
119
+ return c.json({ error: 'FORBIDDEN_ORIGIN' }, 403);
120
+ }
121
+
107
122
  // Authenticate before upgrade
108
123
  const auth = await config.authenticate(c);
109
124
  if (!auth) {
@@ -132,10 +147,19 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
132
147
 
133
148
  async onMessage(evt, ws) {
134
149
  try {
135
- const data =
136
- typeof evt.data === 'string'
137
- ? evt.data
138
- : new TextDecoder().decode(evt.data as ArrayBuffer);
150
+ const messageBytes = measureWebSocketMessageBytes(evt.data);
151
+ if (messageBytes > maxMessageBytes) {
152
+ ws.send(
153
+ JSON.stringify({
154
+ type: 'error',
155
+ error: `Message exceeds max size (${maxMessageBytes} bytes)`,
156
+ })
157
+ );
158
+ ws.close(1009, 'Message too large');
159
+ return;
160
+ }
161
+
162
+ const data = decodeWebSocketData(evt.data);
139
163
 
140
164
  const message = JSON.parse(data);
141
165
 
@@ -172,11 +196,7 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
172
196
  } catch (err) {
173
197
  // Send error response if we can parse the message ID
174
198
  try {
175
- const parsed = JSON.parse(
176
- typeof evt.data === 'string'
177
- ? evt.data
178
- : new TextDecoder().decode(evt.data as ArrayBuffer)
179
- );
199
+ const parsed = JSON.parse(decodeWebSocketData(evt.data));
180
200
  if (parsed.id) {
181
201
  ws.send(
182
202
  JSON.stringify({
@@ -213,6 +233,50 @@ export function createProxyRoutes<DB extends SyncCoreDb>(
213
233
  return app;
214
234
  }
215
235
 
236
+ function isWebSocketOriginAllowed(
237
+ c: Context,
238
+ allowedOrigins?: string[] | '*'
239
+ ): boolean {
240
+ if (!allowedOrigins) return true;
241
+ if (allowedOrigins === '*') return true;
242
+
243
+ const origin = c.req.header('origin');
244
+ if (!origin) return false;
245
+
246
+ try {
247
+ const normalizedOrigin = new URL(origin).origin;
248
+ return allowedOrigins.includes(normalizedOrigin);
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ function measureWebSocketMessageBytes(data: unknown): number {
255
+ if (typeof data === 'string') {
256
+ return new TextEncoder().encode(data).byteLength;
257
+ }
258
+ if (data instanceof ArrayBuffer) {
259
+ return data.byteLength;
260
+ }
261
+ if (ArrayBuffer.isView(data)) {
262
+ return data.byteLength;
263
+ }
264
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
265
+ return data.size;
266
+ }
267
+ return new TextEncoder().encode(String(data)).byteLength;
268
+ }
269
+
270
+ function decodeWebSocketData(data: unknown): string {
271
+ if (typeof data === 'string') return data;
272
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
273
+ if (ArrayBuffer.isView(data)) {
274
+ const view = data as Uint8Array;
275
+ return new TextDecoder().decode(view);
276
+ }
277
+ return String(data);
278
+ }
279
+
216
280
  /**
217
281
  * Get the ProxyConnectionManager from a proxy routes instance.
218
282
  */