@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
@@ -2,13 +2,14 @@ import type { Context } from 'hono';
2
2
  import { Hono } from 'hono';
3
3
  import { cors } from 'hono/cors';
4
4
  import type { UpgradeWebSocket } from 'hono/ws';
5
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
5
+ import { resolver, validator as zValidator } from 'hono-openapi';
6
6
  import { z } from 'zod';
7
7
  import {
8
8
  closeUnauthenticatedSocket,
9
9
  parseBearerToken,
10
10
  parseWebSocketAuthToken,
11
11
  } from './live-auth';
12
+ import { describeConsoleGatewayRoute } from './route-descriptor';
12
13
  import type {
13
14
  ConsoleApiKey,
14
15
  ConsoleApiKeyBulkRevokeResponse,
@@ -87,6 +88,10 @@ export interface CreateConsoleGatewayRoutesOptions {
87
88
  upgradeWebSocket?: UpgradeWebSocket;
88
89
  heartbeatIntervalMs?: number;
89
90
  createWebSocket?: (url: string) => ConsoleGatewayDownstreamSocket;
91
+ maxMessageBytes?: number;
92
+ maxMessagesPerWindow?: number;
93
+ messageRateWindowMs?: number;
94
+ allowedOrigins?: string[] | '*';
90
95
  };
91
96
  }
92
97
 
@@ -1268,8 +1273,7 @@ export function createConsoleGatewayRoutes(
1268
1273
 
1269
1274
  routes.get(
1270
1275
  '/instances',
1271
- describeRoute({
1272
- tags: ['console-gateway'],
1276
+ describeConsoleGatewayRoute({
1273
1277
  summary: 'List configured downstream console instances',
1274
1278
  responses: {
1275
1279
  200: {
@@ -1306,8 +1310,7 @@ export function createConsoleGatewayRoutes(
1306
1310
 
1307
1311
  routes.get(
1308
1312
  '/instances/health',
1309
- describeRoute({
1310
- tags: ['console-gateway'],
1313
+ describeConsoleGatewayRoute({
1311
1314
  summary: 'Probe downstream console health by instance',
1312
1315
  responses: {
1313
1316
  200: {
@@ -1366,8 +1369,7 @@ export function createConsoleGatewayRoutes(
1366
1369
 
1367
1370
  routes.get(
1368
1371
  '/handlers',
1369
- describeRoute({
1370
- tags: ['console-gateway'],
1372
+ describeConsoleGatewayRoute({
1371
1373
  summary:
1372
1374
  'List handlers for a single target instance (requires instance selection)',
1373
1375
  responses: {
@@ -1398,8 +1400,7 @@ export function createConsoleGatewayRoutes(
1398
1400
 
1399
1401
  routes.post(
1400
1402
  '/prune/preview',
1401
- describeRoute({
1402
- tags: ['console-gateway'],
1403
+ describeConsoleGatewayRoute({
1403
1404
  summary:
1404
1405
  'Preview prune on a single target instance (requires instance selection)',
1405
1406
  responses: {
@@ -1430,8 +1431,7 @@ export function createConsoleGatewayRoutes(
1430
1431
 
1431
1432
  routes.post(
1432
1433
  '/prune',
1433
- describeRoute({
1434
- tags: ['console-gateway'],
1434
+ describeConsoleGatewayRoute({
1435
1435
  summary:
1436
1436
  'Trigger prune on a single target instance (requires instance selection)',
1437
1437
  responses: {
@@ -1462,8 +1462,7 @@ export function createConsoleGatewayRoutes(
1462
1462
 
1463
1463
  routes.post(
1464
1464
  '/compact',
1465
- describeRoute({
1466
- tags: ['console-gateway'],
1465
+ describeConsoleGatewayRoute({
1467
1466
  summary:
1468
1467
  'Trigger compaction on a single target instance (requires instance selection)',
1469
1468
  responses: {
@@ -1494,8 +1493,7 @@ export function createConsoleGatewayRoutes(
1494
1493
 
1495
1494
  routes.post(
1496
1495
  '/notify-data-change',
1497
- describeRoute({
1498
- tags: ['console-gateway'],
1496
+ describeConsoleGatewayRoute({
1499
1497
  summary:
1500
1498
  'Notify data change on a single target instance (requires instance selection)',
1501
1499
  responses: {
@@ -1529,8 +1527,7 @@ export function createConsoleGatewayRoutes(
1529
1527
 
1530
1528
  routes.delete(
1531
1529
  '/clients/:id',
1532
- describeRoute({
1533
- tags: ['console-gateway'],
1530
+ describeConsoleGatewayRoute({
1534
1531
  summary:
1535
1532
  'Evict client on a single target instance (requires instance selection)',
1536
1533
  responses: {
@@ -1563,8 +1560,7 @@ export function createConsoleGatewayRoutes(
1563
1560
 
1564
1561
  routes.delete(
1565
1562
  '/events',
1566
- describeRoute({
1567
- tags: ['console-gateway'],
1563
+ describeConsoleGatewayRoute({
1568
1564
  summary:
1569
1565
  'Clear request events on a single target instance (requires instance selection)',
1570
1566
  responses: {
@@ -1595,8 +1591,7 @@ export function createConsoleGatewayRoutes(
1595
1591
 
1596
1592
  routes.post(
1597
1593
  '/events/prune',
1598
- describeRoute({
1599
- tags: ['console-gateway'],
1594
+ describeConsoleGatewayRoute({
1600
1595
  summary:
1601
1596
  'Prune request events on a single target instance (requires instance selection)',
1602
1597
  responses: {
@@ -1627,8 +1622,7 @@ export function createConsoleGatewayRoutes(
1627
1622
 
1628
1623
  routes.get(
1629
1624
  '/api-keys',
1630
- describeRoute({
1631
- tags: ['console-gateway'],
1625
+ describeConsoleGatewayRoute({
1632
1626
  summary:
1633
1627
  'List API keys for a single target instance (requires instance selection)',
1634
1628
  responses: {
@@ -1663,8 +1657,7 @@ export function createConsoleGatewayRoutes(
1663
1657
 
1664
1658
  routes.post(
1665
1659
  '/api-keys',
1666
- describeRoute({
1667
- tags: ['console-gateway'],
1660
+ describeConsoleGatewayRoute({
1668
1661
  summary:
1669
1662
  'Create API key on a single target instance (requires instance selection)',
1670
1663
  responses: {
@@ -1698,8 +1691,7 @@ export function createConsoleGatewayRoutes(
1698
1691
 
1699
1692
  routes.get(
1700
1693
  '/api-keys/:id',
1701
- describeRoute({
1702
- tags: ['console-gateway'],
1694
+ describeConsoleGatewayRoute({
1703
1695
  summary:
1704
1696
  'Get API key from a single target instance (requires instance selection)',
1705
1697
  responses: {
@@ -1732,8 +1724,7 @@ export function createConsoleGatewayRoutes(
1732
1724
 
1733
1725
  routes.delete(
1734
1726
  '/api-keys/:id',
1735
- describeRoute({
1736
- tags: ['console-gateway'],
1727
+ describeConsoleGatewayRoute({
1737
1728
  summary:
1738
1729
  'Revoke API key on a single target instance (requires instance selection)',
1739
1730
  responses: {
@@ -1766,8 +1757,7 @@ export function createConsoleGatewayRoutes(
1766
1757
 
1767
1758
  routes.post(
1768
1759
  '/api-keys/bulk-revoke',
1769
- describeRoute({
1770
- tags: ['console-gateway'],
1760
+ describeConsoleGatewayRoute({
1771
1761
  summary:
1772
1762
  'Bulk revoke API keys on a single target instance (requires instance selection)',
1773
1763
  responses: {
@@ -1801,8 +1791,7 @@ export function createConsoleGatewayRoutes(
1801
1791
 
1802
1792
  routes.post(
1803
1793
  '/api-keys/:id/rotate/stage',
1804
- describeRoute({
1805
- tags: ['console-gateway'],
1794
+ describeConsoleGatewayRoute({
1806
1795
  summary:
1807
1796
  'Stage-rotate API key on a single target instance (requires instance selection)',
1808
1797
  responses: {
@@ -1835,8 +1824,7 @@ export function createConsoleGatewayRoutes(
1835
1824
 
1836
1825
  routes.post(
1837
1826
  '/api-keys/:id/rotate',
1838
- describeRoute({
1839
- tags: ['console-gateway'],
1827
+ describeConsoleGatewayRoute({
1840
1828
  summary:
1841
1829
  'Rotate API key on a single target instance (requires instance selection)',
1842
1830
  responses: {
@@ -1869,8 +1857,7 @@ export function createConsoleGatewayRoutes(
1869
1857
 
1870
1858
  routes.get(
1871
1859
  '/stats',
1872
- describeRoute({
1873
- tags: ['console-gateway'],
1860
+ describeConsoleGatewayRoute({
1874
1861
  summary: 'Get merged sync stats across instances',
1875
1862
  responses: {
1876
1863
  200: {
@@ -1950,8 +1937,7 @@ export function createConsoleGatewayRoutes(
1950
1937
 
1951
1938
  routes.get(
1952
1939
  '/stats/timeseries',
1953
- describeRoute({
1954
- tags: ['console-gateway'],
1940
+ describeConsoleGatewayRoute({
1955
1941
  summary: 'Get merged time-series stats across instances',
1956
1942
  responses: {
1957
1943
  200: {
@@ -2003,8 +1989,7 @@ export function createConsoleGatewayRoutes(
2003
1989
 
2004
1990
  routes.get(
2005
1991
  '/stats/latency',
2006
- describeRoute({
2007
- tags: ['console-gateway'],
1992
+ describeConsoleGatewayRoute({
2008
1993
  summary: 'Get merged latency stats across instances',
2009
1994
  responses: {
2010
1995
  200: {
@@ -2057,8 +2042,7 @@ export function createConsoleGatewayRoutes(
2057
2042
 
2058
2043
  routes.get(
2059
2044
  '/commits',
2060
- describeRoute({
2061
- tags: ['console-gateway'],
2045
+ describeConsoleGatewayRoute({
2062
2046
  summary: 'List merged commits across instances',
2063
2047
  responses: {
2064
2048
  200: {
@@ -2137,8 +2121,7 @@ export function createConsoleGatewayRoutes(
2137
2121
 
2138
2122
  routes.get(
2139
2123
  '/commits/:seq',
2140
- describeRoute({
2141
- tags: ['console-gateway'],
2124
+ describeConsoleGatewayRoute({
2142
2125
  summary: 'Get merged commit detail by federated id',
2143
2126
  responses: {
2144
2127
  200: {
@@ -2208,8 +2191,7 @@ export function createConsoleGatewayRoutes(
2208
2191
 
2209
2192
  routes.get(
2210
2193
  '/clients',
2211
- describeRoute({
2212
- tags: ['console-gateway'],
2194
+ describeConsoleGatewayRoute({
2213
2195
  summary: 'List merged clients across instances',
2214
2196
  responses: {
2215
2197
  200: {
@@ -2285,8 +2267,7 @@ export function createConsoleGatewayRoutes(
2285
2267
 
2286
2268
  routes.get(
2287
2269
  '/timeline',
2288
- describeRoute({
2289
- tags: ['console-gateway'],
2270
+ describeConsoleGatewayRoute({
2290
2271
  summary: 'List merged timeline items across instances',
2291
2272
  responses: {
2292
2273
  200: {
@@ -2382,8 +2363,7 @@ export function createConsoleGatewayRoutes(
2382
2363
 
2383
2364
  routes.get(
2384
2365
  '/operations',
2385
- describeRoute({
2386
- tags: ['console-gateway'],
2366
+ describeConsoleGatewayRoute({
2387
2367
  summary: 'List merged operation events across instances',
2388
2368
  responses: {
2389
2369
  200: {
@@ -2463,8 +2443,7 @@ export function createConsoleGatewayRoutes(
2463
2443
 
2464
2444
  routes.get(
2465
2445
  '/events',
2466
- describeRoute({
2467
- tags: ['console-gateway'],
2446
+ describeConsoleGatewayRoute({
2468
2447
  summary: 'List merged request events across instances',
2469
2448
  responses: {
2470
2449
  200: {
@@ -2548,6 +2527,9 @@ export function createConsoleGatewayRoutes(
2548
2527
  ) {
2549
2528
  const upgradeWebSocket = options.websocket.upgradeWebSocket;
2550
2529
  const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
2530
+ const maxMessageBytes = options.websocket.maxMessageBytes ?? 1024 * 1024;
2531
+ const maxMessagesPerWindow = options.websocket.maxMessagesPerWindow ?? 120;
2532
+ const messageRateWindowMs = options.websocket.messageRateWindowMs ?? 10000;
2551
2533
  const createDownstreamSocket =
2552
2534
  options.websocket.createWebSocket ??
2553
2535
  ((url: string): ConsoleGatewayDownstreamSocket => new WebSocket(url));
@@ -2565,288 +2547,320 @@ export function createConsoleGatewayRoutes(
2565
2547
  authTimeout: ReturnType<typeof setTimeout> | null;
2566
2548
  isAuthenticated: boolean;
2567
2549
  startAuthenticatedSession: ((token: string | null) => void) | null;
2550
+ messageRateWindowStart: number;
2551
+ messageRateWindowCount: number;
2568
2552
  }
2569
2553
  >();
2570
2554
 
2571
- routes.get(
2572
- '/events/live',
2573
- upgradeWebSocket(async (c) => {
2574
- const initialAuth = await options.authenticate(c);
2575
- const partitionId = c.req.query('partitionId')?.trim() || undefined;
2576
- const replaySince = c.req.query('since')?.trim() || undefined;
2577
- const replayLimitRaw = c.req.query('replayLimit');
2578
- const replayLimitNumber = replayLimitRaw
2579
- ? Number.parseInt(replayLimitRaw, 10)
2580
- : Number.NaN;
2581
- const replayLimit = Number.isFinite(replayLimitNumber)
2582
- ? Math.max(1, Math.min(500, replayLimitNumber))
2583
- : 100;
2584
-
2585
- const selectedInstances = selectInstances({
2586
- instances,
2587
- query: {
2588
- instanceId: c.req.query('instanceId') ?? undefined,
2589
- instanceIds: c.req.query('instanceIds') ?? undefined,
2555
+ const liveEventsWebSocketRoute = upgradeWebSocket(async (c) => {
2556
+ const initialAuth = await options.authenticate(c);
2557
+ const partitionId = c.req.query('partitionId')?.trim() || undefined;
2558
+ const replaySince = c.req.query('since')?.trim() || undefined;
2559
+ const replayLimitRaw = c.req.query('replayLimit');
2560
+ const replayLimitNumber = replayLimitRaw
2561
+ ? Number.parseInt(replayLimitRaw, 10)
2562
+ : Number.NaN;
2563
+ const replayLimit = Number.isFinite(replayLimitNumber)
2564
+ ? Math.max(1, Math.min(500, replayLimitNumber))
2565
+ : 100;
2566
+
2567
+ const selectedInstances = selectInstances({
2568
+ instances,
2569
+ query: {
2570
+ instanceId: c.req.query('instanceId') ?? undefined,
2571
+ instanceIds: c.req.query('instanceIds') ?? undefined,
2572
+ },
2573
+ });
2574
+
2575
+ const authenticateWithBearer = async (
2576
+ token: string
2577
+ ): Promise<ConsoleAuthResult | null> => {
2578
+ const trimmedToken = token.trim();
2579
+ if (!trimmedToken) {
2580
+ return null;
2581
+ }
2582
+ const authContext = {
2583
+ req: {
2584
+ header: (name: string) =>
2585
+ name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2586
+ query: () => undefined,
2590
2587
  },
2591
- });
2588
+ } as unknown as Context;
2589
+ return options.authenticate(authContext);
2590
+ };
2592
2591
 
2593
- const authenticateWithBearer = async (
2594
- token: string
2595
- ): Promise<ConsoleAuthResult | null> => {
2596
- const trimmedToken = token.trim();
2597
- if (!trimmedToken) {
2598
- return null;
2592
+ const cleanup = (ws: WebSocketLike) => {
2593
+ const state = liveState.get(ws);
2594
+ if (!state) return;
2595
+ if (state.heartbeatInterval) {
2596
+ clearInterval(state.heartbeatInterval);
2597
+ }
2598
+ if (state.authTimeout) {
2599
+ clearTimeout(state.authTimeout);
2600
+ }
2601
+ for (const downstream of state.downstreamSockets) {
2602
+ try {
2603
+ downstream.close();
2604
+ } catch {
2605
+ // no-op
2599
2606
  }
2600
- const authContext = {
2601
- req: {
2602
- header: (name: string) =>
2603
- name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2604
- query: () => undefined,
2605
- },
2606
- } as unknown as Context;
2607
- return options.authenticate(authContext);
2608
- };
2607
+ }
2608
+ liveState.delete(ws);
2609
+ };
2609
2610
 
2610
- const cleanup = (ws: WebSocketLike) => {
2611
- const state = liveState.get(ws);
2612
- if (!state) return;
2613
- if (state.heartbeatInterval) {
2614
- clearInterval(state.heartbeatInterval);
2615
- }
2616
- if (state.authTimeout) {
2617
- clearTimeout(state.authTimeout);
2618
- }
2619
- for (const downstream of state.downstreamSockets) {
2620
- try {
2621
- downstream.close();
2622
- } catch {
2623
- // no-op
2624
- }
2611
+ return {
2612
+ onOpen(_event, ws) {
2613
+ if (selectedInstances.length === 0) {
2614
+ ws.send(
2615
+ JSON.stringify({
2616
+ type: 'error',
2617
+ message:
2618
+ 'No enabled instances matched the provided instance filter.',
2619
+ })
2620
+ );
2621
+ ws.close(4004, 'No instances selected');
2622
+ return;
2625
2623
  }
2626
- liveState.delete(ws);
2627
- };
2628
-
2629
- return {
2630
- onOpen(_event, ws) {
2631
- if (selectedInstances.length === 0) {
2632
- ws.send(
2633
- JSON.stringify({
2634
- type: 'error',
2635
- message:
2636
- 'No enabled instances matched the provided instance filter.',
2637
- })
2638
- );
2639
- ws.close(4004, 'No instances selected');
2624
+
2625
+ const state: {
2626
+ downstreamSockets: ConsoleGatewayDownstreamSocket[];
2627
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
2628
+ authTimeout: ReturnType<typeof setTimeout> | null;
2629
+ isAuthenticated: boolean;
2630
+ startAuthenticatedSession: ((token: string | null) => void) | null;
2631
+ messageRateWindowStart: number;
2632
+ messageRateWindowCount: number;
2633
+ } = {
2634
+ downstreamSockets: [],
2635
+ heartbeatInterval: null,
2636
+ authTimeout: null,
2637
+ isAuthenticated: false,
2638
+ startAuthenticatedSession: null,
2639
+ messageRateWindowStart: Date.now(),
2640
+ messageRateWindowCount: 0,
2641
+ };
2642
+ liveState.set(ws, state);
2643
+
2644
+ const startAuthenticatedSession = (
2645
+ upstreamBearerToken: string | null
2646
+ ) => {
2647
+ if (state.isAuthenticated) {
2640
2648
  return;
2641
2649
  }
2650
+ state.isAuthenticated = true;
2651
+ if (state.authTimeout) {
2652
+ clearTimeout(state.authTimeout);
2653
+ state.authTimeout = null;
2654
+ }
2642
2655
 
2643
- const state: {
2644
- downstreamSockets: ConsoleGatewayDownstreamSocket[];
2645
- heartbeatInterval: ReturnType<typeof setInterval> | null;
2646
- authTimeout: ReturnType<typeof setTimeout> | null;
2647
- isAuthenticated: boolean;
2648
- startAuthenticatedSession:
2649
- | ((token: string | null) => void)
2650
- | null;
2651
- } = {
2652
- downstreamSockets: [],
2653
- heartbeatInterval: null,
2654
- authTimeout: null,
2655
- isAuthenticated: false,
2656
- startAuthenticatedSession: null,
2657
- };
2658
- liveState.set(ws, state);
2659
-
2660
- const startAuthenticatedSession = (
2661
- upstreamBearerToken: string | null
2662
- ) => {
2663
- if (state.isAuthenticated) {
2664
- return;
2656
+ for (const instance of selectedInstances) {
2657
+ const downstreamQuery = new URLSearchParams();
2658
+ if (partitionId) {
2659
+ downstreamQuery.set('partitionId', partitionId);
2665
2660
  }
2666
- state.isAuthenticated = true;
2667
- if (state.authTimeout) {
2668
- clearTimeout(state.authTimeout);
2669
- state.authTimeout = null;
2661
+ if (replaySince) {
2662
+ downstreamQuery.set('since', replaySince);
2670
2663
  }
2671
-
2672
- for (const instance of selectedInstances) {
2673
- const downstreamQuery = new URLSearchParams();
2674
- if (partitionId) {
2675
- downstreamQuery.set('partitionId', partitionId);
2676
- }
2677
- if (replaySince) {
2678
- downstreamQuery.set('since', replaySince);
2679
- }
2680
- downstreamQuery.set('replayLimit', String(replayLimit));
2681
-
2682
- const downstreamUrl = buildConsoleEndpointUrl({
2683
- instance,
2684
- requestUrl: c.req.url,
2685
- path: '/events/live',
2686
- query: downstreamQuery,
2687
- });
2688
-
2689
- const downstreamSocket = createDownstreamSocket(downstreamUrl);
2690
- const downstreamToken =
2691
- instance.token?.trim() ?? upstreamBearerToken?.trim() ?? null;
2692
- if (downstreamToken && downstreamSocket.send) {
2693
- downstreamSocket.onopen = () => {
2694
- try {
2695
- downstreamSocket.send?.(
2696
- JSON.stringify({
2697
- type: 'auth',
2698
- token: downstreamToken,
2699
- })
2700
- );
2701
- } catch {
2702
- // no-op
2703
- }
2704
- };
2705
- }
2706
-
2707
- downstreamSocket.onmessage = (message: MessageEvent) => {
2708
- if (typeof message.data !== 'string') {
2709
- return;
2710
- }
2664
+ downstreamQuery.set('replayLimit', String(replayLimit));
2665
+
2666
+ const downstreamUrl = buildConsoleEndpointUrl({
2667
+ instance,
2668
+ requestUrl: c.req.url,
2669
+ path: '/events/live',
2670
+ query: downstreamQuery,
2671
+ });
2672
+
2673
+ const downstreamSocket = createDownstreamSocket(downstreamUrl);
2674
+ const downstreamToken =
2675
+ instance.token?.trim() ?? upstreamBearerToken?.trim() ?? null;
2676
+ if (downstreamToken && downstreamSocket.send) {
2677
+ downstreamSocket.onopen = () => {
2711
2678
  try {
2712
- const payload = JSON.parse(message.data) as Record<
2713
- string,
2714
- unknown
2715
- >;
2716
- if (
2717
- typeof payload.type === 'string' &&
2718
- (payload.type === 'connected' ||
2719
- payload.type === 'heartbeat')
2720
- ) {
2721
- return;
2722
- }
2723
-
2724
- const payloadData =
2725
- payload.data &&
2726
- typeof payload.data === 'object' &&
2727
- !Array.isArray(payload.data)
2728
- ? { ...payload.data, instanceId: instance.instanceId }
2729
- : { instanceId: instance.instanceId };
2730
-
2731
- const event = {
2732
- ...payload,
2733
- data: payloadData,
2734
- instanceId: instance.instanceId,
2735
- timestamp:
2736
- typeof payload.timestamp === 'string'
2737
- ? payload.timestamp
2738
- : new Date().toISOString(),
2739
- };
2740
- ws.send(JSON.stringify(event));
2741
- } catch {
2742
- // Ignore malformed downstream events
2743
- }
2744
- };
2745
-
2746
- downstreamSocket.onerror = () => {
2747
- try {
2748
- ws.send(
2679
+ downstreamSocket.send?.(
2749
2680
  JSON.stringify({
2750
- type: 'instance_error',
2751
- instanceId: instance.instanceId,
2752
- timestamp: new Date().toISOString(),
2681
+ type: 'auth',
2682
+ token: downstreamToken,
2753
2683
  })
2754
2684
  );
2755
2685
  } catch {
2756
- // ignore send errors
2686
+ // no-op
2757
2687
  }
2758
2688
  };
2759
-
2760
- state.downstreamSockets.push(downstreamSocket);
2761
2689
  }
2762
2690
 
2763
- ws.send(
2764
- JSON.stringify({
2765
- type: 'connected',
2766
- timestamp: new Date().toISOString(),
2767
- instanceCount: selectedInstances.length,
2768
- })
2769
- );
2691
+ downstreamSocket.onmessage = (message: MessageEvent) => {
2692
+ if (typeof message.data !== 'string') {
2693
+ return;
2694
+ }
2695
+ try {
2696
+ const payload = JSON.parse(message.data) as Record<
2697
+ string,
2698
+ unknown
2699
+ >;
2700
+ if (
2701
+ typeof payload.type === 'string' &&
2702
+ (payload.type === 'connected' ||
2703
+ payload.type === 'heartbeat')
2704
+ ) {
2705
+ return;
2706
+ }
2707
+
2708
+ const payloadData =
2709
+ payload.data &&
2710
+ typeof payload.data === 'object' &&
2711
+ !Array.isArray(payload.data)
2712
+ ? { ...payload.data, instanceId: instance.instanceId }
2713
+ : { instanceId: instance.instanceId };
2714
+
2715
+ const event = {
2716
+ ...payload,
2717
+ data: payloadData,
2718
+ instanceId: instance.instanceId,
2719
+ timestamp:
2720
+ typeof payload.timestamp === 'string'
2721
+ ? payload.timestamp
2722
+ : new Date().toISOString(),
2723
+ };
2724
+ ws.send(JSON.stringify(event));
2725
+ } catch {
2726
+ // Ignore malformed downstream events
2727
+ }
2728
+ };
2770
2729
 
2771
- const heartbeatInterval = setInterval(() => {
2730
+ downstreamSocket.onerror = () => {
2772
2731
  try {
2773
2732
  ws.send(
2774
2733
  JSON.stringify({
2775
- type: 'heartbeat',
2734
+ type: 'instance_error',
2735
+ instanceId: instance.instanceId,
2776
2736
  timestamp: new Date().toISOString(),
2777
2737
  })
2778
2738
  );
2779
2739
  } catch {
2780
- clearInterval(heartbeatInterval);
2740
+ // ignore send errors
2781
2741
  }
2782
- }, heartbeatIntervalMs);
2783
- state.heartbeatInterval = heartbeatInterval;
2784
- };
2785
- state.startAuthenticatedSession = startAuthenticatedSession;
2786
-
2787
- if (initialAuth) {
2788
- startAuthenticatedSession(
2789
- parseBearerToken(c.req.header('Authorization'))
2790
- );
2791
- return;
2742
+ };
2743
+
2744
+ state.downstreamSockets.push(downstreamSocket);
2792
2745
  }
2793
2746
 
2794
- state.authTimeout = setTimeout(() => {
2795
- const current = liveState.get(ws);
2796
- if (!current || current.isAuthenticated) {
2797
- return;
2747
+ ws.send(
2748
+ JSON.stringify({
2749
+ type: 'connected',
2750
+ timestamp: new Date().toISOString(),
2751
+ instanceCount: selectedInstances.length,
2752
+ })
2753
+ );
2754
+
2755
+ const heartbeatInterval = setInterval(() => {
2756
+ try {
2757
+ ws.send(
2758
+ JSON.stringify({
2759
+ type: 'heartbeat',
2760
+ timestamp: new Date().toISOString(),
2761
+ })
2762
+ );
2763
+ } catch {
2764
+ clearInterval(heartbeatInterval);
2798
2765
  }
2799
- closeUnauthenticatedSocket(ws);
2800
- cleanup(ws);
2801
- }, 5_000);
2802
- },
2803
- async onMessage(event, ws) {
2804
- const state = liveState.get(ws);
2805
- if (!state || state.isAuthenticated) {
2806
- return;
2807
- }
2766
+ }, heartbeatIntervalMs);
2767
+ state.heartbeatInterval = heartbeatInterval;
2768
+ };
2769
+ state.startAuthenticatedSession = startAuthenticatedSession;
2770
+
2771
+ if (initialAuth) {
2772
+ startAuthenticatedSession(
2773
+ parseBearerToken(c.req.header('Authorization'))
2774
+ );
2775
+ return;
2776
+ }
2808
2777
 
2809
- if (typeof event.data !== 'string') {
2810
- closeUnauthenticatedSocket(ws);
2811
- cleanup(ws);
2778
+ state.authTimeout = setTimeout(() => {
2779
+ const current = liveState.get(ws);
2780
+ if (!current || current.isAuthenticated) {
2812
2781
  return;
2813
2782
  }
2783
+ closeUnauthenticatedSocket(ws);
2784
+ cleanup(ws);
2785
+ }, 5_000);
2786
+ },
2787
+ async onMessage(event, ws) {
2788
+ const state = liveState.get(ws);
2789
+ if (!state) {
2790
+ return;
2791
+ }
2814
2792
 
2815
- const token = parseWebSocketAuthToken(event.data);
2793
+ const messageBytes = measureWebSocketMessageBytes(event.data);
2794
+ if (messageBytes > maxMessageBytes) {
2795
+ ws.close(1009, 'message too large');
2796
+ cleanup(ws);
2797
+ return;
2798
+ }
2816
2799
 
2817
- if (!token) {
2818
- closeUnauthenticatedSocket(ws);
2819
- cleanup(ws);
2820
- return;
2800
+ if (maxMessagesPerWindow > 0 && messageRateWindowMs > 0) {
2801
+ const nowMs = Date.now();
2802
+ if (nowMs - state.messageRateWindowStart >= messageRateWindowMs) {
2803
+ state.messageRateWindowStart = nowMs;
2804
+ state.messageRateWindowCount = 0;
2821
2805
  }
2822
-
2823
- const auth = await authenticateWithBearer(token);
2824
- const current = liveState.get(ws);
2825
- if (!current || current.isAuthenticated) {
2826
- return;
2827
- }
2828
- if (!auth) {
2829
- closeUnauthenticatedSocket(ws);
2806
+ state.messageRateWindowCount += 1;
2807
+ if (state.messageRateWindowCount > maxMessagesPerWindow) {
2808
+ ws.close(1008, 'message rate exceeded');
2830
2809
  cleanup(ws);
2831
2810
  return;
2832
2811
  }
2833
- current.startAuthenticatedSession?.(token);
2834
- },
2835
- onClose(_event, ws) {
2812
+ }
2813
+
2814
+ if (state.isAuthenticated) {
2815
+ return;
2816
+ }
2817
+
2818
+ if (typeof event.data !== 'string') {
2819
+ closeUnauthenticatedSocket(ws);
2836
2820
  cleanup(ws);
2837
- },
2838
- onError(_event, ws) {
2821
+ return;
2822
+ }
2823
+
2824
+ const token = parseWebSocketAuthToken(event.data);
2825
+
2826
+ if (!token) {
2827
+ closeUnauthenticatedSocket(ws);
2839
2828
  cleanup(ws);
2840
- },
2841
- };
2842
- })
2843
- );
2829
+ return;
2830
+ }
2831
+
2832
+ const auth = await authenticateWithBearer(token);
2833
+ const current = liveState.get(ws);
2834
+ if (!current || current.isAuthenticated) {
2835
+ return;
2836
+ }
2837
+ if (!auth) {
2838
+ closeUnauthenticatedSocket(ws);
2839
+ cleanup(ws);
2840
+ return;
2841
+ }
2842
+ current.startAuthenticatedSession?.(token);
2843
+ },
2844
+ onClose(_event, ws) {
2845
+ cleanup(ws);
2846
+ },
2847
+ onError(_event, ws) {
2848
+ cleanup(ws);
2849
+ },
2850
+ };
2851
+ });
2852
+
2853
+ routes.get('/events/live', async (c, next) => {
2854
+ if (!isWebSocketOriginAllowed(c, options.websocket?.allowedOrigins)) {
2855
+ return c.json({ error: 'FORBIDDEN_ORIGIN' }, 403);
2856
+ }
2857
+ return liveEventsWebSocketRoute(c, next);
2858
+ });
2844
2859
  }
2845
2860
 
2846
2861
  routes.get(
2847
2862
  '/events/:id',
2848
- describeRoute({
2849
- tags: ['console-gateway'],
2863
+ describeConsoleGatewayRoute({
2850
2864
  summary: 'Get merged event detail by federated id',
2851
2865
  responses: {
2852
2866
  200: {
@@ -2920,8 +2934,7 @@ export function createConsoleGatewayRoutes(
2920
2934
 
2921
2935
  routes.get(
2922
2936
  '/events/:id/payload',
2923
- describeRoute({
2924
- tags: ['console-gateway'],
2937
+ describeConsoleGatewayRoute({
2925
2938
  summary: 'Get merged event payload by federated id',
2926
2939
  responses: {
2927
2940
  200: {
@@ -2995,3 +3008,37 @@ export function createConsoleGatewayRoutes(
2995
3008
 
2996
3009
  return routes;
2997
3010
  }
3011
+
3012
+ function isWebSocketOriginAllowed(
3013
+ c: Context,
3014
+ allowedOrigins?: string[] | '*'
3015
+ ): boolean {
3016
+ if (!allowedOrigins) return true;
3017
+ if (allowedOrigins === '*') return true;
3018
+
3019
+ const origin = c.req.header('origin');
3020
+ if (!origin) return false;
3021
+
3022
+ try {
3023
+ const normalizedOrigin = new URL(origin).origin;
3024
+ return allowedOrigins.includes(normalizedOrigin);
3025
+ } catch {
3026
+ return false;
3027
+ }
3028
+ }
3029
+
3030
+ function measureWebSocketMessageBytes(data: unknown): number {
3031
+ if (typeof data === 'string') {
3032
+ return new TextEncoder().encode(data).byteLength;
3033
+ }
3034
+ if (data instanceof ArrayBuffer) {
3035
+ return data.byteLength;
3036
+ }
3037
+ if (ArrayBuffer.isView(data)) {
3038
+ return data.byteLength;
3039
+ }
3040
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
3041
+ return data.size;
3042
+ }
3043
+ return new TextEncoder().encode(String(data)).byteLength;
3044
+ }