@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
@@ -18,16 +18,18 @@
18
18
  import { logSyncEvent } from '@syncular/core';
19
19
  import type { SqlFamily, SyncCoreDb, SyncServerAuth } from '@syncular/server';
20
20
  import {
21
+ coerceNumber,
21
22
  compactChanges,
22
23
  computePruneWatermarkCommitSeq,
23
24
  notifyExternalDataChange,
25
+ parseJsonValue,
24
26
  pruneSync,
25
27
  readSyncStats,
26
28
  } from '@syncular/server';
27
29
  import type { Context } from 'hono';
28
30
  import { Hono } from 'hono';
29
31
  import { cors } from 'hono/cors';
30
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
32
+ import { resolver, validator as zValidator } from 'hono-openapi';
31
33
  import { type Generated, type Kysely, type Selectable, sql } from 'kysely';
32
34
  import { z } from 'zod';
33
35
  import {
@@ -35,6 +37,8 @@ import {
35
37
  parseBearerToken,
36
38
  parseWebSocketAuthToken,
37
39
  } from './live-auth';
40
+ import { describeConsoleRoute } from './route-descriptor';
41
+ import { isBenignConsoleSchemaError } from './schema-errors';
38
42
  import {
39
43
  type ApiKeyType,
40
44
  ApiKeyTypeSchema,
@@ -171,18 +175,6 @@ export function createConsoleEventEmitter(options?: {
171
175
  };
172
176
  }
173
177
 
174
- function coerceNumber(value: unknown): number | null {
175
- if (value === null || value === undefined) return null;
176
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
177
- if (typeof value === 'bigint')
178
- return Number.isFinite(Number(value)) ? Number(value) : null;
179
- if (typeof value === 'string') {
180
- const n = Number(value);
181
- return Number.isFinite(n) ? n : null;
182
- }
183
- return null;
184
- }
185
-
186
178
  function parseDate(value: string | null | undefined): number | null {
187
179
  if (!value) return null;
188
180
  const parsed = Date.parse(value);
@@ -198,14 +190,18 @@ function includesSearchTerm(
198
190
  return value.toLowerCase().includes(searchTerm);
199
191
  }
200
192
 
201
- function parseJsonValue(value: unknown): unknown {
202
- if (value === null || value === undefined) return null;
203
- if (typeof value !== 'string') return value;
204
- try {
205
- return JSON.parse(value);
206
- } catch {
207
- return value;
193
+ function parseJsonRecord(value: unknown): Record<string, unknown> {
194
+ const parsed = parseJsonValue(value);
195
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
196
+ return {};
208
197
  }
198
+ return parsed as Record<string, unknown>;
199
+ }
200
+
201
+ function parseJsonStringArray(value: unknown): string[] {
202
+ const parsed = parseJsonValue(value);
203
+ if (!Array.isArray(parsed)) return [];
204
+ return parsed.filter((entry): entry is string => typeof entry === 'string');
209
205
  }
210
206
 
211
207
  function parseScopesSummary(
@@ -400,6 +396,7 @@ const DEFAULT_REQUEST_EVENTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
400
396
  const DEFAULT_REQUEST_EVENTS_MAX_ROWS = 10_000;
401
397
  const DEFAULT_OPERATION_EVENTS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
402
398
  const DEFAULT_OPERATION_EVENTS_MAX_ROWS = 5_000;
399
+ const DEFAULT_TIMELINE_SCAN_MAX_ROWS = 10_000;
403
400
  const DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
404
401
 
405
402
  function readNonNegativeInteger(
@@ -533,6 +530,10 @@ export function createConsoleRoutes<
533
530
  options.maintenance?.operationEventsMaxRows,
534
531
  DEFAULT_OPERATION_EVENTS_MAX_ROWS
535
532
  );
533
+ const timelineScanMaxRows = readNonNegativeInteger(
534
+ options.maintenance?.timelineScanMaxRows,
535
+ DEFAULT_TIMELINE_SCAN_MAX_ROWS
536
+ );
536
537
  const autoEventsPruneIntervalMs = readNonNegativeInteger(
537
538
  options.maintenance?.autoPruneIntervalMs,
538
539
  DEFAULT_AUTO_EVENTS_PRUNE_INTERVAL_MS
@@ -545,6 +546,9 @@ export function createConsoleRoutes<
545
546
  options.dialect.ensureConsoleSchema?.(options.db) ??
546
547
  Promise.resolve()
547
548
  ).catch((err) => {
549
+ if (isBenignConsoleSchemaError(err)) {
550
+ return;
551
+ }
548
552
  console.error('[console] Failed to ensure console schema:', err);
549
553
  throw err;
550
554
  });
@@ -958,8 +962,7 @@ export function createConsoleRoutes<
958
962
 
959
963
  routes.get(
960
964
  '/stats',
961
- describeRoute({
962
- tags: ['console'],
965
+ describeConsoleRoute({
963
966
  summary: 'Get sync statistics',
964
967
  responses: {
965
968
  200: {
@@ -999,8 +1002,7 @@ export function createConsoleRoutes<
999
1002
 
1000
1003
  routes.get(
1001
1004
  '/stats/timeseries',
1002
- describeRoute({
1003
- tags: ['console'],
1005
+ describeConsoleRoute({
1004
1006
  summary: 'Get time-series statistics',
1005
1007
  responses: {
1006
1008
  200: {
@@ -1195,8 +1197,7 @@ export function createConsoleRoutes<
1195
1197
 
1196
1198
  routes.get(
1197
1199
  '/stats/latency',
1198
- describeRoute({
1199
- tags: ['console'],
1200
+ describeConsoleRoute({
1200
1201
  summary: 'Get latency percentiles',
1201
1202
  responses: {
1202
1203
  200: {
@@ -1306,8 +1307,7 @@ export function createConsoleRoutes<
1306
1307
 
1307
1308
  routes.get(
1308
1309
  '/timeline',
1309
- describeRoute({
1310
- tags: ['console'],
1310
+ describeConsoleRoute({
1311
1311
  summary: 'List timeline items',
1312
1312
  responses: {
1313
1313
  200: {
@@ -1350,6 +1350,9 @@ export function createConsoleRoutes<
1350
1350
  const items: ConsoleTimelineItem[] = [];
1351
1351
  const normalizedSearchTerm = search?.trim().toLowerCase() || null;
1352
1352
  const normalizedTable = table?.trim() || null;
1353
+ const timelineSourceScanLimit =
1354
+ timelineScanMaxRows > 0 ? timelineScanMaxRows : null;
1355
+ let timelineTruncated = false;
1353
1356
 
1354
1357
  if (
1355
1358
  view !== 'events' &&
@@ -1386,8 +1389,29 @@ export function createConsoleRoutes<
1386
1389
  commitsQuery = commitsQuery.where('created_at', '<=', to);
1387
1390
  }
1388
1391
 
1389
- const commitRows = await commitsQuery.execute();
1390
- for (const row of commitRows) {
1392
+ let commitsQueryWithOrdering = commitsQuery.orderBy(
1393
+ 'created_at',
1394
+ 'desc'
1395
+ );
1396
+ if (timelineSourceScanLimit !== null) {
1397
+ commitsQueryWithOrdering = commitsQueryWithOrdering.limit(
1398
+ timelineSourceScanLimit + 1
1399
+ );
1400
+ }
1401
+
1402
+ const commitRows = await commitsQueryWithOrdering.execute();
1403
+ const scannedCommitRows =
1404
+ timelineSourceScanLimit === null
1405
+ ? commitRows
1406
+ : commitRows.slice(0, timelineSourceScanLimit);
1407
+ if (
1408
+ timelineSourceScanLimit !== null &&
1409
+ commitRows.length > timelineSourceScanLimit
1410
+ ) {
1411
+ timelineTruncated = true;
1412
+ }
1413
+
1414
+ for (const row of scannedCommitRows) {
1391
1415
  const commit: ConsoleCommitListItem = {
1392
1416
  commitSeq: coerceNumber(row.commit_seq) ?? 0,
1393
1417
  actorId: row.actor_id ?? '',
@@ -1440,8 +1464,26 @@ export function createConsoleRoutes<
1440
1464
  eventsQuery = eventsQuery.where('created_at', '<=', to);
1441
1465
  }
1442
1466
 
1443
- const eventRows = await eventsQuery.execute();
1444
- for (const row of eventRows) {
1467
+ let eventsQueryWithOrdering = eventsQuery.orderBy('created_at', 'desc');
1468
+ if (timelineSourceScanLimit !== null) {
1469
+ eventsQueryWithOrdering = eventsQueryWithOrdering.limit(
1470
+ timelineSourceScanLimit + 1
1471
+ );
1472
+ }
1473
+
1474
+ const eventRows = await eventsQueryWithOrdering.execute();
1475
+ const scannedEventRows =
1476
+ timelineSourceScanLimit === null
1477
+ ? eventRows
1478
+ : eventRows.slice(0, timelineSourceScanLimit);
1479
+ if (
1480
+ timelineSourceScanLimit !== null &&
1481
+ eventRows.length > timelineSourceScanLimit
1482
+ ) {
1483
+ timelineTruncated = true;
1484
+ }
1485
+
1486
+ for (const row of scannedEventRows) {
1445
1487
  const event = mapRequestEvent(row);
1446
1488
 
1447
1489
  items.push({
@@ -1525,6 +1567,12 @@ export function createConsoleRoutes<
1525
1567
  };
1526
1568
 
1527
1569
  c.header('X-Total-Count', String(total));
1570
+ if (timelineTruncated) {
1571
+ c.header('X-Timeline-Truncated', 'true');
1572
+ if (timelineSourceScanLimit !== null) {
1573
+ c.header('X-Timeline-Scan-Limit', String(timelineSourceScanLimit));
1574
+ }
1575
+ }
1528
1576
  return c.json(response, 200);
1529
1577
  }
1530
1578
  );
@@ -1535,8 +1583,7 @@ export function createConsoleRoutes<
1535
1583
 
1536
1584
  routes.get(
1537
1585
  '/commits',
1538
- describeRoute({
1539
- tags: ['console'],
1586
+ describeConsoleRoute({
1540
1587
  summary: 'List commits',
1541
1588
  responses: {
1542
1589
  200: {
@@ -1621,8 +1668,7 @@ export function createConsoleRoutes<
1621
1668
 
1622
1669
  routes.get(
1623
1670
  '/commits/:seq',
1624
- describeRoute({
1625
- tags: ['console'],
1671
+ describeConsoleRoute({
1626
1672
  summary: 'Get commit details',
1627
1673
  responses: {
1628
1674
  200: {
@@ -1706,21 +1752,9 @@ export function createConsoleRoutes<
1706
1752
  table: row.table ?? '',
1707
1753
  rowId: row.row_id ?? '',
1708
1754
  op: row.op === 'delete' ? 'delete' : 'upsert',
1709
- rowJson:
1710
- typeof row.row_json === 'string'
1711
- ? (() => {
1712
- try {
1713
- return JSON.parse(row.row_json);
1714
- } catch {
1715
- return row.row_json;
1716
- }
1717
- })()
1718
- : row.row_json,
1755
+ rowJson: parseJsonValue(row.row_json),
1719
1756
  rowVersion: coerceNumber(row.row_version),
1720
- scopes:
1721
- typeof row.scopes === 'string'
1722
- ? JSON.parse(row.scopes || '{}')
1723
- : (row.scopes ?? {}),
1757
+ scopes: parseJsonRecord(row.scopes),
1724
1758
  }));
1725
1759
 
1726
1760
  const commit: ConsoleCommitDetail = {
@@ -1730,11 +1764,7 @@ export function createConsoleRoutes<
1730
1764
  clientCommitId: commitRow.client_commit_id ?? '',
1731
1765
  createdAt: commitRow.created_at ?? '',
1732
1766
  changeCount: coerceNumber(commitRow.change_count) ?? 0,
1733
- affectedTables: Array.isArray(commitRow.affected_tables)
1734
- ? commitRow.affected_tables
1735
- : typeof commitRow.affected_tables === 'string'
1736
- ? JSON.parse(commitRow.affected_tables || '[]')
1737
- : [],
1767
+ affectedTables: parseJsonStringArray(commitRow.affected_tables),
1738
1768
  changes,
1739
1769
  };
1740
1770
 
@@ -1748,8 +1778,7 @@ export function createConsoleRoutes<
1748
1778
 
1749
1779
  routes.get(
1750
1780
  '/clients',
1751
- describeRoute({
1752
- tags: ['console'],
1781
+ describeConsoleRoute({
1753
1782
  summary: 'List clients',
1754
1783
  responses: {
1755
1784
  200: {
@@ -1918,8 +1947,7 @@ export function createConsoleRoutes<
1918
1947
 
1919
1948
  routes.get(
1920
1949
  '/handlers',
1921
- describeRoute({
1922
- tags: ['console'],
1950
+ describeConsoleRoute({
1923
1951
  summary: 'List registered handlers',
1924
1952
  responses: {
1925
1953
  200: {
@@ -1953,8 +1981,7 @@ export function createConsoleRoutes<
1953
1981
 
1954
1982
  routes.get(
1955
1983
  '/operations',
1956
- describeRoute({
1957
- tags: ['console'],
1984
+ describeConsoleRoute({
1958
1985
  summary: 'List operation audit events',
1959
1986
  responses: {
1960
1987
  200: {
@@ -2027,8 +2054,7 @@ export function createConsoleRoutes<
2027
2054
 
2028
2055
  routes.post(
2029
2056
  '/prune/preview',
2030
- describeRoute({
2031
- tags: ['console'],
2057
+ describeConsoleRoute({
2032
2058
  summary: 'Preview pruning',
2033
2059
  responses: {
2034
2060
  200: {
@@ -2075,8 +2101,7 @@ export function createConsoleRoutes<
2075
2101
 
2076
2102
  routes.post(
2077
2103
  '/prune',
2078
- describeRoute({
2079
- tags: ['console'],
2104
+ describeConsoleRoute({
2080
2105
  summary: 'Trigger pruning',
2081
2106
  responses: {
2082
2107
  200: {
@@ -2131,8 +2156,7 @@ export function createConsoleRoutes<
2131
2156
 
2132
2157
  routes.post(
2133
2158
  '/compact',
2134
- describeRoute({
2135
- tags: ['console'],
2159
+ describeConsoleRoute({
2136
2160
  summary: 'Trigger compaction',
2137
2161
  responses: {
2138
2162
  200: {
@@ -2194,8 +2218,7 @@ export function createConsoleRoutes<
2194
2218
 
2195
2219
  routes.post(
2196
2220
  '/notify-data-change',
2197
- describeRoute({
2198
- tags: ['console'],
2221
+ describeConsoleRoute({
2199
2222
  summary: 'Notify external data change',
2200
2223
  description:
2201
2224
  'Creates a synthetic commit to force re-bootstrap for affected tables. ' +
@@ -2267,8 +2290,7 @@ export function createConsoleRoutes<
2267
2290
 
2268
2291
  routes.delete(
2269
2292
  '/clients/:id',
2270
- describeRoute({
2271
- tags: ['console'],
2293
+ describeConsoleRoute({
2272
2294
  summary: 'Evict client',
2273
2295
  responses: {
2274
2296
  200: {
@@ -2335,8 +2357,7 @@ export function createConsoleRoutes<
2335
2357
 
2336
2358
  routes.get(
2337
2359
  '/events',
2338
- describeRoute({
2339
- tags: ['console'],
2360
+ describeConsoleRoute({
2340
2361
  summary: 'List request events',
2341
2362
  responses: {
2342
2363
  200: {
@@ -2448,6 +2469,9 @@ export function createConsoleRoutes<
2448
2469
  const emitter = options.eventEmitter;
2449
2470
  const upgradeWebSocket = options.websocket.upgradeWebSocket;
2450
2471
  const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
2472
+ const maxMessageBytes = options.websocket.maxMessageBytes ?? 1024 * 1024;
2473
+ const maxMessagesPerWindow = options.websocket.maxMessagesPerWindow ?? 120;
2474
+ const messageRateWindowMs = options.websocket.messageRateWindowMs ?? 10000;
2451
2475
 
2452
2476
  type WebSocketLike = {
2453
2477
  send: (data: string) => void;
@@ -2462,6 +2486,8 @@ export function createConsoleRoutes<
2462
2486
  authTimeout: ReturnType<typeof setTimeout> | null;
2463
2487
  isAuthenticated: boolean;
2464
2488
  startAuthenticatedSession: (() => void) | null;
2489
+ messageRateWindowStart: number;
2490
+ messageRateWindowCount: number;
2465
2491
  }
2466
2492
  >();
2467
2493
 
@@ -2480,179 +2506,212 @@ export function createConsoleRoutes<
2480
2506
  wsState.delete(ws);
2481
2507
  };
2482
2508
 
2483
- routes.get(
2484
- '/events/live',
2485
- upgradeWebSocket(async (c) => {
2486
- const authHeader = c.req.header('Authorization');
2487
- const partitionId = c.req.query('partitionId')?.trim() || undefined;
2488
- const replaySince = c.req.query('since');
2489
- const replayLimitRaw = c.req.query('replayLimit');
2490
- const replayLimitNumber = replayLimitRaw
2491
- ? Number.parseInt(replayLimitRaw, 10)
2492
- : Number.NaN;
2493
- const replayLimit = Number.isFinite(replayLimitNumber)
2494
- ? Math.max(1, Math.min(500, replayLimitNumber))
2495
- : 100;
2496
- const mockContext = {
2509
+ const liveEventsWebSocketRoute = upgradeWebSocket(async (c) => {
2510
+ const authHeader = c.req.header('Authorization');
2511
+ const partitionId = c.req.query('partitionId')?.trim() || undefined;
2512
+ const replaySince = c.req.query('since');
2513
+ const replayLimitRaw = c.req.query('replayLimit');
2514
+ const replayLimitNumber = replayLimitRaw
2515
+ ? Number.parseInt(replayLimitRaw, 10)
2516
+ : Number.NaN;
2517
+ const replayLimit = Number.isFinite(replayLimitNumber)
2518
+ ? Math.max(1, Math.min(500, replayLimitNumber))
2519
+ : 100;
2520
+ const mockContext = {
2521
+ req: {
2522
+ header: (name: string) =>
2523
+ name === 'Authorization' ? authHeader : undefined,
2524
+ query: () => undefined,
2525
+ },
2526
+ } as unknown as Context;
2527
+
2528
+ const initialAuth = await options.authenticate(mockContext);
2529
+
2530
+ const authenticateWithBearer = async (token: string) => {
2531
+ const trimmedToken = token.trim();
2532
+ if (!trimmedToken) return null;
2533
+ const authContext = {
2497
2534
  req: {
2498
2535
  header: (name: string) =>
2499
- name === 'Authorization' ? authHeader : undefined,
2536
+ name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2500
2537
  query: () => undefined,
2501
2538
  },
2502
2539
  } as unknown as Context;
2540
+ return options.authenticate(authContext);
2541
+ };
2503
2542
 
2504
- const initialAuth = await options.authenticate(mockContext);
2505
-
2506
- const authenticateWithBearer = async (token: string) => {
2507
- const trimmedToken = token.trim();
2508
- if (!trimmedToken) return null;
2509
- const authContext = {
2510
- req: {
2511
- header: (name: string) =>
2512
- name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2513
- query: () => undefined,
2514
- },
2515
- } as unknown as Context;
2516
- return options.authenticate(authContext);
2517
- };
2518
-
2519
- return {
2520
- onOpen(_event, ws) {
2521
- const state: {
2522
- listener: ConsoleEventListener | null;
2523
- heartbeatInterval: ReturnType<typeof setInterval> | null;
2524
- authTimeout: ReturnType<typeof setTimeout> | null;
2525
- isAuthenticated: boolean;
2526
- startAuthenticatedSession: (() => void) | null;
2527
- } = {
2528
- listener: null,
2529
- heartbeatInterval: null,
2530
- authTimeout: null,
2531
- isAuthenticated: false,
2532
- startAuthenticatedSession: null,
2533
- };
2534
- wsState.set(ws, state);
2535
-
2536
- const startAuthenticatedSession = () => {
2537
- if (state.isAuthenticated) return;
2538
- state.isAuthenticated = true;
2539
- if (state.authTimeout) {
2540
- clearTimeout(state.authTimeout);
2541
- state.authTimeout = null;
2542
- }
2543
+ return {
2544
+ onOpen(_event, ws) {
2545
+ const state: {
2546
+ listener: ConsoleEventListener | null;
2547
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
2548
+ authTimeout: ReturnType<typeof setTimeout> | null;
2549
+ isAuthenticated: boolean;
2550
+ startAuthenticatedSession: (() => void) | null;
2551
+ messageRateWindowStart: number;
2552
+ messageRateWindowCount: number;
2553
+ } = {
2554
+ listener: null,
2555
+ heartbeatInterval: null,
2556
+ authTimeout: null,
2557
+ isAuthenticated: false,
2558
+ startAuthenticatedSession: null,
2559
+ messageRateWindowStart: Date.now(),
2560
+ messageRateWindowCount: 0,
2561
+ };
2562
+ wsState.set(ws, state);
2563
+
2564
+ const startAuthenticatedSession = () => {
2565
+ if (state.isAuthenticated) return;
2566
+ state.isAuthenticated = true;
2567
+ if (state.authTimeout) {
2568
+ clearTimeout(state.authTimeout);
2569
+ state.authTimeout = null;
2570
+ }
2543
2571
 
2544
- const listener: ConsoleEventListener = (event) => {
2545
- if (partitionId) {
2546
- const eventPartitionId = event.data.partitionId;
2547
- if (
2548
- typeof eventPartitionId !== 'string' ||
2549
- eventPartitionId !== partitionId
2550
- ) {
2551
- return;
2552
- }
2553
- }
2554
- try {
2555
- ws.send(JSON.stringify(event));
2556
- } catch {
2557
- // Connection closed
2558
- }
2559
- };
2560
-
2561
- emitter.addListener(listener);
2562
- state.listener = listener;
2563
-
2564
- ws.send(
2565
- JSON.stringify({
2566
- type: 'connected',
2567
- timestamp: new Date().toISOString(),
2568
- })
2569
- );
2570
-
2571
- const replayEvents = emitter.replay({
2572
- since: replaySince,
2573
- limit: replayLimit,
2574
- partitionId,
2575
- });
2576
- for (const replayEvent of replayEvents) {
2577
- try {
2578
- ws.send(JSON.stringify(replayEvent));
2579
- } catch {
2580
- // Connection closed
2581
- break;
2572
+ const listener: ConsoleEventListener = (event) => {
2573
+ if (partitionId) {
2574
+ const eventPartitionId = event.data.partitionId;
2575
+ if (
2576
+ typeof eventPartitionId !== 'string' ||
2577
+ eventPartitionId !== partitionId
2578
+ ) {
2579
+ return;
2582
2580
  }
2583
2581
  }
2584
-
2585
- const heartbeatInterval = setInterval(() => {
2586
- try {
2587
- ws.send(
2588
- JSON.stringify({
2589
- type: 'heartbeat',
2590
- timestamp: new Date().toISOString(),
2591
- })
2592
- );
2593
- } catch {
2594
- clearInterval(heartbeatInterval);
2595
- }
2596
- }, heartbeatIntervalMs);
2597
- state.heartbeatInterval = heartbeatInterval;
2582
+ try {
2583
+ ws.send(JSON.stringify(event));
2584
+ } catch {
2585
+ // Connection closed
2586
+ }
2598
2587
  };
2599
- state.startAuthenticatedSession = startAuthenticatedSession;
2600
2588
 
2601
- if (initialAuth) {
2602
- startAuthenticatedSession();
2603
- return;
2604
- }
2589
+ emitter.addListener(listener);
2590
+ state.listener = listener;
2605
2591
 
2606
- state.authTimeout = setTimeout(() => {
2607
- const current = wsState.get(ws);
2608
- if (!current || current.isAuthenticated) {
2609
- return;
2592
+ ws.send(
2593
+ JSON.stringify({
2594
+ type: 'connected',
2595
+ timestamp: new Date().toISOString(),
2596
+ })
2597
+ );
2598
+
2599
+ const replayEvents = emitter.replay({
2600
+ since: replaySince,
2601
+ limit: replayLimit,
2602
+ partitionId,
2603
+ });
2604
+ for (const replayEvent of replayEvents) {
2605
+ try {
2606
+ ws.send(JSON.stringify(replayEvent));
2607
+ } catch {
2608
+ // Connection closed
2609
+ break;
2610
2610
  }
2611
- closeUnauthenticatedSocket(ws);
2612
- cleanup(ws);
2613
- }, 5_000);
2614
- },
2615
- async onMessage(event, ws) {
2616
- const state = wsState.get(ws);
2617
- if (!state || state.isAuthenticated) {
2618
- return;
2619
2611
  }
2620
2612
 
2621
- if (typeof event.data !== 'string') {
2622
- closeUnauthenticatedSocket(ws);
2623
- cleanup(ws);
2624
- return;
2625
- }
2613
+ const heartbeatInterval = setInterval(() => {
2614
+ try {
2615
+ ws.send(
2616
+ JSON.stringify({
2617
+ type: 'heartbeat',
2618
+ timestamp: new Date().toISOString(),
2619
+ })
2620
+ );
2621
+ } catch {
2622
+ clearInterval(heartbeatInterval);
2623
+ }
2624
+ }, heartbeatIntervalMs);
2625
+ state.heartbeatInterval = heartbeatInterval;
2626
+ };
2627
+ state.startAuthenticatedSession = startAuthenticatedSession;
2626
2628
 
2627
- const token = parseWebSocketAuthToken(event.data);
2629
+ if (initialAuth) {
2630
+ startAuthenticatedSession();
2631
+ return;
2632
+ }
2628
2633
 
2629
- if (!token) {
2630
- closeUnauthenticatedSocket(ws);
2631
- cleanup(ws);
2634
+ state.authTimeout = setTimeout(() => {
2635
+ const current = wsState.get(ws);
2636
+ if (!current || current.isAuthenticated) {
2632
2637
  return;
2633
2638
  }
2639
+ closeUnauthenticatedSocket(ws);
2640
+ cleanup(ws);
2641
+ }, 5_000);
2642
+ },
2643
+ async onMessage(event, ws) {
2644
+ const state = wsState.get(ws);
2645
+ if (!state) {
2646
+ return;
2647
+ }
2634
2648
 
2635
- const auth = await authenticateWithBearer(token);
2636
- const currentState = wsState.get(ws);
2637
- if (!currentState || currentState.isAuthenticated) {
2638
- return;
2649
+ const messageBytes = measureWebSocketMessageBytes(event.data);
2650
+ if (messageBytes > maxMessageBytes) {
2651
+ ws.close(1009, 'message too large');
2652
+ cleanup(ws);
2653
+ return;
2654
+ }
2655
+
2656
+ if (maxMessagesPerWindow > 0 && messageRateWindowMs > 0) {
2657
+ const nowMs = Date.now();
2658
+ if (nowMs - state.messageRateWindowStart >= messageRateWindowMs) {
2659
+ state.messageRateWindowStart = nowMs;
2660
+ state.messageRateWindowCount = 0;
2639
2661
  }
2640
- if (!auth) {
2641
- closeUnauthenticatedSocket(ws);
2662
+ state.messageRateWindowCount += 1;
2663
+ if (state.messageRateWindowCount > maxMessagesPerWindow) {
2664
+ ws.close(1008, 'message rate exceeded');
2642
2665
  cleanup(ws);
2643
2666
  return;
2644
2667
  }
2645
- currentState.startAuthenticatedSession?.();
2646
- },
2647
- onClose(_event, ws) {
2668
+ }
2669
+
2670
+ if (state.isAuthenticated) {
2671
+ return;
2672
+ }
2673
+
2674
+ if (typeof event.data !== 'string') {
2675
+ closeUnauthenticatedSocket(ws);
2648
2676
  cleanup(ws);
2649
- },
2650
- onError(_event, ws) {
2677
+ return;
2678
+ }
2679
+
2680
+ const token = parseWebSocketAuthToken(event.data);
2681
+
2682
+ if (!token) {
2683
+ closeUnauthenticatedSocket(ws);
2651
2684
  cleanup(ws);
2652
- },
2653
- };
2654
- })
2655
- );
2685
+ return;
2686
+ }
2687
+
2688
+ const auth = await authenticateWithBearer(token);
2689
+ const currentState = wsState.get(ws);
2690
+ if (!currentState || currentState.isAuthenticated) {
2691
+ return;
2692
+ }
2693
+ if (!auth) {
2694
+ closeUnauthenticatedSocket(ws);
2695
+ cleanup(ws);
2696
+ return;
2697
+ }
2698
+ currentState.startAuthenticatedSession?.();
2699
+ },
2700
+ onClose(_event, ws) {
2701
+ cleanup(ws);
2702
+ },
2703
+ onError(_event, ws) {
2704
+ cleanup(ws);
2705
+ },
2706
+ };
2707
+ });
2708
+
2709
+ routes.get('/events/live', async (c, next) => {
2710
+ if (!isWebSocketOriginAllowed(c, options.websocket?.allowedOrigins)) {
2711
+ return c.json({ error: 'FORBIDDEN_ORIGIN' }, 403);
2712
+ }
2713
+ return liveEventsWebSocketRoute(c, next);
2714
+ });
2656
2715
  }
2657
2716
 
2658
2717
  // -------------------------------------------------------------------------
@@ -2661,8 +2720,7 @@ export function createConsoleRoutes<
2661
2720
 
2662
2721
  routes.get(
2663
2722
  '/events/:id',
2664
- describeRoute({
2665
- tags: ['console'],
2723
+ describeConsoleRoute({
2666
2724
  summary: 'Get event details',
2667
2725
  responses: {
2668
2726
  200: {
@@ -2724,8 +2782,7 @@ export function createConsoleRoutes<
2724
2782
 
2725
2783
  routes.get(
2726
2784
  '/events/:id/payload',
2727
- describeRoute({
2728
- tags: ['console'],
2785
+ describeConsoleRoute({
2729
2786
  summary: 'Get event payload snapshot',
2730
2787
  responses: {
2731
2788
  200: {
@@ -2823,8 +2880,7 @@ export function createConsoleRoutes<
2823
2880
 
2824
2881
  routes.delete(
2825
2882
  '/events',
2826
- describeRoute({
2827
- tags: ['console'],
2883
+ describeConsoleRoute({
2828
2884
  summary: 'Clear all events',
2829
2885
  responses: {
2830
2886
  200: {
@@ -2867,8 +2923,7 @@ export function createConsoleRoutes<
2867
2923
 
2868
2924
  routes.post(
2869
2925
  '/events/prune',
2870
- describeRoute({
2871
- tags: ['console'],
2926
+ describeConsoleRoute({
2872
2927
  summary: 'Prune old events',
2873
2928
  responses: {
2874
2929
  200: {
@@ -2911,8 +2966,7 @@ export function createConsoleRoutes<
2911
2966
 
2912
2967
  routes.get(
2913
2968
  '/api-keys',
2914
- describeRoute({
2915
- tags: ['console'],
2969
+ describeConsoleRoute({
2916
2970
  summary: 'List API keys',
2917
2971
  responses: {
2918
2972
  200: {
@@ -3040,8 +3094,7 @@ export function createConsoleRoutes<
3040
3094
 
3041
3095
  routes.post(
3042
3096
  '/api-keys',
3043
- describeRoute({
3044
- tags: ['console'],
3097
+ describeConsoleRoute({
3045
3098
  summary: 'Create API key',
3046
3099
  responses: {
3047
3100
  201: {
@@ -3140,8 +3193,7 @@ export function createConsoleRoutes<
3140
3193
 
3141
3194
  routes.get(
3142
3195
  '/api-keys/:id',
3143
- describeRoute({
3144
- tags: ['console'],
3196
+ describeConsoleRoute({
3145
3197
  summary: 'Get API key',
3146
3198
  responses: {
3147
3199
  200: {
@@ -3212,8 +3264,7 @@ export function createConsoleRoutes<
3212
3264
 
3213
3265
  routes.delete(
3214
3266
  '/api-keys/:id',
3215
- describeRoute({
3216
- tags: ['console'],
3267
+ describeConsoleRoute({
3217
3268
  summary: 'Revoke API key',
3218
3269
  responses: {
3219
3270
  200: {
@@ -3263,8 +3314,7 @@ export function createConsoleRoutes<
3263
3314
 
3264
3315
  routes.post(
3265
3316
  '/api-keys/bulk-revoke',
3266
- describeRoute({
3267
- tags: ['console'],
3317
+ describeConsoleRoute({
3268
3318
  summary: 'Bulk revoke API keys',
3269
3319
  responses: {
3270
3320
  200: {
@@ -3370,8 +3420,7 @@ export function createConsoleRoutes<
3370
3420
 
3371
3421
  routes.post(
3372
3422
  '/api-keys/:id/rotate/stage',
3373
- describeRoute({
3374
- tags: ['console'],
3423
+ describeConsoleRoute({
3375
3424
  summary: 'Stage rotate API key',
3376
3425
  responses: {
3377
3426
  200: {
@@ -3478,8 +3527,7 @@ export function createConsoleRoutes<
3478
3527
 
3479
3528
  routes.post(
3480
3529
  '/api-keys/:id/rotate',
3481
- describeRoute({
3482
- tags: ['console'],
3530
+ describeConsoleRoute({
3483
3531
  summary: 'Rotate API key',
3484
3532
  responses: {
3485
3533
  200: {
@@ -3597,8 +3645,7 @@ export function createConsoleRoutes<
3597
3645
 
3598
3646
  routes.get(
3599
3647
  '/storage',
3600
- describeRoute({
3601
- tags: ['console'],
3648
+ describeConsoleRoute({
3602
3649
  summary: 'List storage items',
3603
3650
  responses: {
3604
3651
  200: {
@@ -3650,8 +3697,7 @@ export function createConsoleRoutes<
3650
3697
 
3651
3698
  routes.get(
3652
3699
  '/storage/:key{.+}/download',
3653
- describeRoute({
3654
- tags: ['console'],
3700
+ describeConsoleRoute({
3655
3701
  summary: 'Download a storage item',
3656
3702
  responses: {
3657
3703
  200: { description: 'Storage item contents' },
@@ -3701,8 +3747,7 @@ export function createConsoleRoutes<
3701
3747
 
3702
3748
  routes.delete(
3703
3749
  '/storage/:key{.+}',
3704
- describeRoute({
3705
- tags: ['console'],
3750
+ describeConsoleRoute({
3706
3751
  summary: 'Delete a storage item',
3707
3752
  responses: {
3708
3753
  200: {
@@ -3735,6 +3780,40 @@ export function createConsoleRoutes<
3735
3780
  return routes;
3736
3781
  }
3737
3782
 
3783
+ function isWebSocketOriginAllowed(
3784
+ c: Context,
3785
+ allowedOrigins?: string[] | '*'
3786
+ ): boolean {
3787
+ if (!allowedOrigins) return true;
3788
+ if (allowedOrigins === '*') return true;
3789
+
3790
+ const origin = c.req.header('origin');
3791
+ if (!origin) return false;
3792
+
3793
+ try {
3794
+ const normalizedOrigin = new URL(origin).origin;
3795
+ return allowedOrigins.includes(normalizedOrigin);
3796
+ } catch {
3797
+ return false;
3798
+ }
3799
+ }
3800
+
3801
+ function measureWebSocketMessageBytes(data: unknown): number {
3802
+ if (typeof data === 'string') {
3803
+ return new TextEncoder().encode(data).byteLength;
3804
+ }
3805
+ if (data instanceof ArrayBuffer) {
3806
+ return data.byteLength;
3807
+ }
3808
+ if (ArrayBuffer.isView(data)) {
3809
+ return data.byteLength;
3810
+ }
3811
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
3812
+ return data.size;
3813
+ }
3814
+ return new TextEncoder().encode(String(data)).byteLength;
3815
+ }
3816
+
3738
3817
  // ===========================================================================
3739
3818
  // API Key Utilities
3740
3819
  // ===========================================================================