@syncular/server-hono 0.0.6-96 → 0.2.0

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 (104) hide show
  1. package/README.md +5 -23
  2. package/dist/admin-page.d.ts +14 -0
  3. package/dist/admin-page.js +217 -0
  4. package/dist/admin.d.ts +39 -0
  5. package/dist/admin.js +142 -0
  6. package/dist/index.d.ts +16 -14
  7. package/dist/index.js +134 -23
  8. package/package.json +19 -53
  9. package/src/admin-page.ts +217 -0
  10. package/src/admin.ts +193 -0
  11. package/src/index.ts +175 -31
  12. package/dist/api-key-auth.d.ts +0 -49
  13. package/dist/api-key-auth.d.ts.map +0 -1
  14. package/dist/api-key-auth.js +0 -110
  15. package/dist/api-key-auth.js.map +0 -1
  16. package/dist/blobs.d.ts +0 -69
  17. package/dist/blobs.d.ts.map +0 -1
  18. package/dist/blobs.js +0 -383
  19. package/dist/blobs.js.map +0 -1
  20. package/dist/console/gateway.d.ts +0 -33
  21. package/dist/console/gateway.d.ts.map +0 -1
  22. package/dist/console/gateway.js +0 -2534
  23. package/dist/console/gateway.js.map +0 -1
  24. package/dist/console/index.d.ts +0 -11
  25. package/dist/console/index.d.ts.map +0 -1
  26. package/dist/console/index.js +0 -11
  27. package/dist/console/index.js.map +0 -1
  28. package/dist/console/routes.d.ts +0 -33
  29. package/dist/console/routes.d.ts.map +0 -1
  30. package/dist/console/routes.js +0 -3029
  31. package/dist/console/routes.js.map +0 -1
  32. package/dist/console/schemas.d.ts +0 -490
  33. package/dist/console/schemas.d.ts.map +0 -1
  34. package/dist/console/schemas.js +0 -303
  35. package/dist/console/schemas.js.map +0 -1
  36. package/dist/console/types.d.ts +0 -175
  37. package/dist/console/types.d.ts.map +0 -1
  38. package/dist/console/types.js +0 -2
  39. package/dist/console/types.js.map +0 -1
  40. package/dist/console/ui.d.ts +0 -38
  41. package/dist/console/ui.d.ts.map +0 -1
  42. package/dist/console/ui.js +0 -43
  43. package/dist/console/ui.js.map +0 -1
  44. package/dist/create-server.d.ts +0 -68
  45. package/dist/create-server.d.ts.map +0 -1
  46. package/dist/create-server.js +0 -110
  47. package/dist/create-server.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
  50. package/dist/openapi.d.ts +0 -45
  51. package/dist/openapi.d.ts.map +0 -1
  52. package/dist/openapi.js +0 -59
  53. package/dist/openapi.js.map +0 -1
  54. package/dist/proxy/connection-manager.d.ts +0 -78
  55. package/dist/proxy/connection-manager.d.ts.map +0 -1
  56. package/dist/proxy/connection-manager.js +0 -251
  57. package/dist/proxy/connection-manager.js.map +0 -1
  58. package/dist/proxy/index.d.ts +0 -8
  59. package/dist/proxy/index.d.ts.map +0 -1
  60. package/dist/proxy/index.js +0 -8
  61. package/dist/proxy/index.js.map +0 -1
  62. package/dist/proxy/routes.d.ts +0 -74
  63. package/dist/proxy/routes.d.ts.map +0 -1
  64. package/dist/proxy/routes.js +0 -147
  65. package/dist/proxy/routes.js.map +0 -1
  66. package/dist/rate-limit.d.ts +0 -101
  67. package/dist/rate-limit.d.ts.map +0 -1
  68. package/dist/rate-limit.js +0 -186
  69. package/dist/rate-limit.js.map +0 -1
  70. package/dist/routes.d.ts +0 -161
  71. package/dist/routes.d.ts.map +0 -1
  72. package/dist/routes.js +0 -1493
  73. package/dist/routes.js.map +0 -1
  74. package/dist/ws.d.ts +0 -235
  75. package/dist/ws.d.ts.map +0 -1
  76. package/dist/ws.js +0 -614
  77. package/dist/ws.js.map +0 -1
  78. package/src/__tests__/blob-routes.test.ts +0 -424
  79. package/src/__tests__/console-gateway-live-routes.test.ts +0 -297
  80. package/src/__tests__/console-gateway-routes.test.ts +0 -1364
  81. package/src/__tests__/console-routes.test.ts +0 -1626
  82. package/src/__tests__/console-ui.test.ts +0 -114
  83. package/src/__tests__/create-server.test.ts +0 -485
  84. package/src/__tests__/pull-chunk-storage.test.ts +0 -576
  85. package/src/__tests__/rate-limit.test.ts +0 -78
  86. package/src/__tests__/realtime-bridge.test.ts +0 -135
  87. package/src/__tests__/sync-rate-limit-routing.test.ts +0 -185
  88. package/src/__tests__/ws-connection-manager.test.ts +0 -176
  89. package/src/api-key-auth.ts +0 -179
  90. package/src/blobs.ts +0 -534
  91. package/src/console/gateway.ts +0 -3603
  92. package/src/console/index.ts +0 -11
  93. package/src/console/routes.ts +0 -3936
  94. package/src/console/schemas.ts +0 -434
  95. package/src/console/types.ts +0 -185
  96. package/src/console/ui.ts +0 -100
  97. package/src/create-server.ts +0 -207
  98. package/src/openapi.ts +0 -74
  99. package/src/proxy/connection-manager.ts +0 -340
  100. package/src/proxy/index.ts +0 -8
  101. package/src/proxy/routes.ts +0 -223
  102. package/src/rate-limit.ts +0 -321
  103. package/src/routes.ts +0 -2059
  104. package/src/ws.ts +0 -802
@@ -1,3603 +0,0 @@
1
- import type { Context } from 'hono';
2
- import { Hono } from 'hono';
3
- import { cors } from 'hono/cors';
4
- import type { UpgradeWebSocket } from 'hono/ws';
5
- import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
6
- import { z } from 'zod';
7
- import type {
8
- ConsoleApiKey,
9
- ConsoleApiKeyBulkRevokeResponse,
10
- ConsoleApiKeyCreateResponse,
11
- ConsoleClearEventsResult,
12
- ConsoleClient,
13
- ConsoleCommitListItem,
14
- ConsoleCompactResult,
15
- ConsoleEvictResult,
16
- ConsoleOperationEvent,
17
- ConsolePaginatedResponse,
18
- ConsolePruneEventsResult,
19
- ConsolePrunePreview,
20
- ConsolePruneResult,
21
- ConsoleRequestEvent,
22
- ConsoleTimelineItem,
23
- LatencyPercentiles,
24
- LatencyStatsResponse,
25
- SyncStats,
26
- TimeseriesBucket,
27
- TimeseriesStatsResponse,
28
- } from './schemas';
29
- import {
30
- ApiKeyTypeSchema,
31
- ConsoleApiKeyBulkRevokeRequestSchema,
32
- ConsoleApiKeyBulkRevokeResponseSchema,
33
- ConsoleApiKeyCreateRequestSchema,
34
- ConsoleApiKeyCreateResponseSchema,
35
- ConsoleApiKeyRevokeResponseSchema,
36
- ConsoleApiKeySchema,
37
- ConsoleClearEventsResultSchema,
38
- ConsoleClientSchema,
39
- ConsoleCommitDetailSchema,
40
- ConsoleCommitListItemSchema,
41
- ConsoleCompactResultSchema,
42
- ConsoleEvictResultSchema,
43
- ConsoleHandlerSchema,
44
- ConsoleOperationEventSchema,
45
- ConsoleOperationsQuerySchema,
46
- ConsolePaginatedResponseSchema,
47
- ConsolePaginationQuerySchema,
48
- ConsolePartitionedPaginationQuerySchema,
49
- ConsolePartitionQuerySchema,
50
- ConsolePruneEventsResultSchema,
51
- ConsolePrunePreviewSchema,
52
- ConsolePruneResultSchema,
53
- ConsoleRequestEventSchema,
54
- ConsoleRequestPayloadSchema,
55
- ConsoleTimelineItemSchema,
56
- ConsoleTimelineQuerySchema,
57
- LatencyQuerySchema,
58
- LatencyStatsResponseSchema,
59
- SyncStatsSchema,
60
- TimeseriesQuerySchema,
61
- TimeseriesStatsResponseSchema,
62
- } from './schemas';
63
- import type { ConsoleAuthResult } from './types';
64
-
65
- export interface ConsoleGatewayInstance {
66
- instanceId: string;
67
- label?: string;
68
- baseUrl: string;
69
- token?: string;
70
- enabled?: boolean;
71
- }
72
-
73
- interface ConsoleGatewayDownstreamSocket {
74
- onopen?: ((event: Event) => void) | null;
75
- onmessage: ((event: MessageEvent) => void) | null;
76
- onerror: ((event: Event) => void) | null;
77
- close: () => void;
78
- send?: (data: string) => void;
79
- }
80
-
81
- export interface CreateConsoleGatewayRoutesOptions {
82
- instances: ConsoleGatewayInstance[];
83
- authenticate: (c: Context) => Promise<ConsoleAuthResult | null>;
84
- corsOrigins?: string[] | '*';
85
- fetchImpl?: typeof fetch;
86
- websocket?: {
87
- enabled?: boolean;
88
- upgradeWebSocket?: UpgradeWebSocket;
89
- heartbeatIntervalMs?: number;
90
- createWebSocket?: (url: string) => ConsoleGatewayDownstreamSocket;
91
- };
92
- }
93
-
94
- interface GatewayFailure {
95
- instanceId: string;
96
- reason: string;
97
- status?: number;
98
- }
99
-
100
- const GatewayFailureSchema = z.object({
101
- instanceId: z.string(),
102
- reason: z.string(),
103
- status: z.number().int().optional(),
104
- });
105
-
106
- const GatewayMetadataSchema = z.object({
107
- partial: z.boolean(),
108
- failedInstances: z.array(GatewayFailureSchema),
109
- });
110
-
111
- const GatewayInstanceSchema = z.object({
112
- instanceId: z.string(),
113
- label: z.string(),
114
- baseUrl: z.string(),
115
- enabled: z.boolean(),
116
- });
117
-
118
- const GatewayInstancesResponseSchema = z.object({
119
- items: z.array(GatewayInstanceSchema),
120
- });
121
-
122
- const GatewayInstanceHealthSchema = GatewayInstanceSchema.extend({
123
- healthy: z.boolean(),
124
- status: z.number().int().optional(),
125
- reason: z.string().optional(),
126
- responseTimeMs: z.number().int().nonnegative(),
127
- checkedAt: z.string(),
128
- });
129
-
130
- const GatewayInstancesHealthResponseSchema = z.object({
131
- items: z.array(GatewayInstanceHealthSchema),
132
- partial: GatewayMetadataSchema.shape.partial,
133
- failedInstances: GatewayMetadataSchema.shape.failedInstances,
134
- });
135
-
136
- const GatewayInstanceFilterSchema = z.object({
137
- instanceId: z.string().min(1).optional(),
138
- instanceIds: z.string().min(1).optional(),
139
- });
140
-
141
- const GatewayStatsQuerySchema = ConsolePartitionQuerySchema.extend(
142
- GatewayInstanceFilterSchema.shape
143
- );
144
-
145
- const GatewayTimeseriesQuerySchema = TimeseriesQuerySchema.extend(
146
- GatewayInstanceFilterSchema.shape
147
- );
148
-
149
- const GatewayLatencyQuerySchema = LatencyQuerySchema.extend(
150
- GatewayInstanceFilterSchema.shape
151
- );
152
-
153
- const GatewaySingleInstanceQuerySchema = GatewayInstanceFilterSchema;
154
-
155
- const GatewaySingleInstancePartitionQuerySchema =
156
- ConsolePartitionQuerySchema.extend(GatewayInstanceFilterSchema.shape);
157
-
158
- const GatewayApiKeyStatusSchema = z.enum(['active', 'revoked', 'expiring']);
159
-
160
- const GatewayApiKeysQuerySchema = ConsolePaginationQuerySchema.extend({
161
- ...GatewayInstanceFilterSchema.shape,
162
- type: ApiKeyTypeSchema.optional(),
163
- status: GatewayApiKeyStatusSchema.optional(),
164
- expiresWithinDays: z.coerce.number().int().min(1).max(365).optional(),
165
- });
166
-
167
- const GatewayPaginatedQuerySchema =
168
- ConsolePartitionedPaginationQuerySchema.extend(
169
- GatewayInstanceFilterSchema.shape
170
- );
171
-
172
- const GatewayTimelineQuerySchema = ConsoleTimelineQuerySchema.extend(
173
- GatewayInstanceFilterSchema.shape
174
- );
175
-
176
- const GatewayOperationsQuerySchema = ConsoleOperationsQuerySchema.extend(
177
- GatewayInstanceFilterSchema.shape
178
- );
179
-
180
- const GatewayEventsQuerySchema = ConsolePartitionedPaginationQuerySchema.extend(
181
- {
182
- ...GatewayInstanceFilterSchema.shape,
183
- eventType: z.enum(['push', 'pull']).optional(),
184
- actorId: z.string().optional(),
185
- clientId: z.string().optional(),
186
- requestId: z.string().optional(),
187
- traceId: z.string().optional(),
188
- outcome: z.string().optional(),
189
- }
190
- );
191
-
192
- const GatewayEventPathParamSchema = z.object({
193
- id: z.string().min(1),
194
- });
195
-
196
- const GatewayCommitPathParamSchema = z.object({
197
- seq: z.string().min(1),
198
- });
199
-
200
- const GatewayClientPathParamSchema = z.object({
201
- id: z.string().min(1),
202
- });
203
-
204
- const GatewayApiKeyPathParamSchema = z.object({
205
- id: z.string().min(1),
206
- });
207
-
208
- const GatewayNotifyDataChangeRequestSchema = z.object({
209
- tables: z.array(z.string().min(1)).min(1),
210
- partitionId: z.string().optional(),
211
- });
212
-
213
- const GatewayNotifyDataChangeResponseSchema = z.object({
214
- commitSeq: z.number(),
215
- tables: z.array(z.string()),
216
- deletedChunks: z.number(),
217
- });
218
-
219
- const GatewayHandlersResponseSchema = z.object({
220
- items: z.array(ConsoleHandlerSchema),
221
- });
222
-
223
- const GatewayCommitItemSchema = ConsoleCommitListItemSchema.extend({
224
- instanceId: z.string(),
225
- federatedCommitId: z.string(),
226
- });
227
-
228
- const GatewayCommitDetailSchema = ConsoleCommitDetailSchema.extend({
229
- instanceId: z.string(),
230
- federatedCommitId: z.string(),
231
- localCommitSeq: z.number().int(),
232
- });
233
-
234
- const GatewayClientItemSchema = ConsoleClientSchema.extend({
235
- instanceId: z.string(),
236
- federatedClientId: z.string(),
237
- });
238
-
239
- const GatewayTimelineItemSchema = ConsoleTimelineItemSchema.extend({
240
- instanceId: z.string(),
241
- federatedTimelineId: z.string(),
242
- localCommitSeq: z.number().int().nullable(),
243
- localEventId: z.number().int().nullable(),
244
- });
245
-
246
- const GatewayOperationItemSchema = ConsoleOperationEventSchema.extend({
247
- instanceId: z.string(),
248
- federatedOperationId: z.string(),
249
- localOperationId: z.number().int(),
250
- });
251
-
252
- const GatewayEventItemSchema = ConsoleRequestEventSchema.extend({
253
- instanceId: z.string(),
254
- federatedEventId: z.string(),
255
- localEventId: z.number().int(),
256
- });
257
-
258
- const GatewayEventPayloadSchema = ConsoleRequestPayloadSchema.extend({
259
- instanceId: z.string(),
260
- federatedEventId: z.string(),
261
- localEventId: z.number().int(),
262
- });
263
-
264
- const GatewayStatsResponseSchema = SyncStatsSchema.extend({
265
- maxCommitSeqByInstance: z.record(z.string(), z.number().int()),
266
- minCommitSeqByInstance: z.record(z.string(), z.number().int()),
267
- partial: GatewayMetadataSchema.shape.partial,
268
- failedInstances: GatewayMetadataSchema.shape.failedInstances,
269
- });
270
-
271
- const GatewayTimeseriesResponseSchema = TimeseriesStatsResponseSchema.extend({
272
- partial: GatewayMetadataSchema.shape.partial,
273
- failedInstances: GatewayMetadataSchema.shape.failedInstances,
274
- });
275
-
276
- const GatewayLatencyResponseSchema = LatencyStatsResponseSchema.extend({
277
- partial: GatewayMetadataSchema.shape.partial,
278
- failedInstances: GatewayMetadataSchema.shape.failedInstances,
279
- });
280
-
281
- const GatewayPaginatedResponseSchema = <T extends z.ZodTypeAny>(
282
- itemSchema: T
283
- ) =>
284
- ConsolePaginatedResponseSchema(itemSchema).extend({
285
- partial: GatewayMetadataSchema.shape.partial,
286
- failedInstances: GatewayMetadataSchema.shape.failedInstances,
287
- });
288
-
289
- function toErrorMessage(error: unknown): string {
290
- if (error instanceof Error && error.message.trim().length > 0) {
291
- return error.message;
292
- }
293
- if (typeof error === 'string' && error.trim().length > 0) {
294
- return error;
295
- }
296
- return 'Request failed';
297
- }
298
-
299
- function resolveBaseUrl(baseUrl: string, requestUrl: string): URL {
300
- try {
301
- return new URL(baseUrl);
302
- } catch {
303
- return new URL(baseUrl, requestUrl);
304
- }
305
- }
306
-
307
- function normalizeInstances(
308
- instances: ConsoleGatewayInstance[]
309
- ): ConsoleGatewayInstance[] {
310
- if (instances.length === 0) {
311
- throw new Error('Console gateway requires at least one instance');
312
- }
313
-
314
- const seen = new Set<string>();
315
- return instances.map((instance) => {
316
- const normalizedInstanceId = instance.instanceId.trim();
317
- if (!normalizedInstanceId) {
318
- throw new Error('Console gateway instanceId cannot be empty');
319
- }
320
- if (seen.has(normalizedInstanceId)) {
321
- throw new Error(
322
- `Duplicate console gateway instanceId: ${normalizedInstanceId}`
323
- );
324
- }
325
- seen.add(normalizedInstanceId);
326
-
327
- const normalizedBaseUrl = instance.baseUrl.trim();
328
- if (!normalizedBaseUrl) {
329
- throw new Error(
330
- `Console gateway baseUrl cannot be empty for instance: ${normalizedInstanceId}`
331
- );
332
- }
333
-
334
- return {
335
- instanceId: normalizedInstanceId,
336
- label: instance.label?.trim() || normalizedInstanceId,
337
- baseUrl: normalizedBaseUrl,
338
- token: instance.token?.trim(),
339
- enabled: instance.enabled ?? true,
340
- };
341
- });
342
- }
343
-
344
- function parseRequestedInstanceIds(query: {
345
- instanceId?: string;
346
- instanceIds?: string;
347
- }): Set<string> {
348
- const ids = new Set<string>();
349
- const single = query.instanceId?.trim();
350
- if (single) {
351
- ids.add(single);
352
- }
353
-
354
- const multi = query.instanceIds
355
- ?.split(',')
356
- .map((value) => value.trim())
357
- .filter((value) => value.length > 0);
358
- for (const value of multi ?? []) {
359
- ids.add(value);
360
- }
361
-
362
- return ids;
363
- }
364
-
365
- function selectInstances(args: {
366
- instances: ConsoleGatewayInstance[];
367
- query: { instanceId?: string; instanceIds?: string };
368
- }): ConsoleGatewayInstance[] {
369
- const enabledInstances = args.instances.filter(
370
- (instance) => instance.enabled
371
- );
372
- const requestedIds = parseRequestedInstanceIds(args.query);
373
- if (requestedIds.size === 0) {
374
- return enabledInstances;
375
- }
376
- return enabledInstances.filter((instance) =>
377
- requestedIds.has(instance.instanceId)
378
- );
379
- }
380
-
381
- function findInstanceById(args: {
382
- instances: ConsoleGatewayInstance[];
383
- instanceId: string;
384
- }): ConsoleGatewayInstance | null {
385
- const instance = args.instances.find(
386
- (candidate) =>
387
- candidate.instanceId === args.instanceId && Boolean(candidate.enabled)
388
- );
389
- return instance ?? null;
390
- }
391
-
392
- function parseFederatedNumericId(value: string): {
393
- instanceId: string;
394
- localId: number;
395
- } | null {
396
- const separatorIndex = value.indexOf(':');
397
- if (separatorIndex <= 0 || separatorIndex >= value.length - 1) {
398
- return null;
399
- }
400
-
401
- const instanceId = value.slice(0, separatorIndex).trim();
402
- const localIdRaw = value.slice(separatorIndex + 1).trim();
403
- const localId = Number(localIdRaw);
404
- if (!instanceId || !Number.isInteger(localId) || localId <= 0) {
405
- return null;
406
- }
407
-
408
- return { instanceId, localId };
409
- }
410
-
411
- function parseLocalNumericId(value: string): number | null {
412
- const normalized = value.trim();
413
- if (!normalized) return null;
414
- const parsed = Number(normalized);
415
- if (!Number.isInteger(parsed) || parsed <= 0) return null;
416
- return parsed;
417
- }
418
-
419
- function resolveEventTarget(args: {
420
- id: string;
421
- instances: ConsoleGatewayInstance[];
422
- query: { instanceId?: string; instanceIds?: string };
423
- }):
424
- | { ok: true; instance: ConsoleGatewayInstance; localEventId: number }
425
- | { ok: false; status: 400 | 404; error: string; message?: string } {
426
- const federated = parseFederatedNumericId(args.id);
427
- if (federated) {
428
- const instance = findInstanceById({
429
- instances: args.instances,
430
- instanceId: federated.instanceId,
431
- });
432
- if (!instance) {
433
- return {
434
- ok: false,
435
- status: 404,
436
- error: 'NOT_FOUND',
437
- message: 'Instance not found',
438
- };
439
- }
440
- return { ok: true, instance, localEventId: federated.localId };
441
- }
442
-
443
- const localEventId = parseLocalNumericId(args.id);
444
- if (localEventId === null) {
445
- return {
446
- ok: false,
447
- status: 400,
448
- error: 'INVALID_FEDERATED_ID',
449
- message:
450
- 'Expected either "<instanceId>:<eventId>" or "<eventId>" with an explicit instance filter.',
451
- };
452
- }
453
-
454
- const selectedInstances = selectInstances({
455
- instances: args.instances,
456
- query: args.query,
457
- });
458
- if (selectedInstances.length === 0) {
459
- return {
460
- ok: false,
461
- status: 400,
462
- error: 'NO_INSTANCES_SELECTED',
463
- message: 'No enabled instances matched the provided instance filter.',
464
- };
465
- }
466
- if (selectedInstances.length > 1) {
467
- return {
468
- ok: false,
469
- status: 400,
470
- error: 'AMBIGUOUS_EVENT_ID',
471
- message:
472
- 'Local event IDs are ambiguous across multiple instances. Use "<instanceId>:<eventId>" or select one instance.',
473
- };
474
- }
475
-
476
- const instance = selectedInstances[0];
477
- if (!instance) {
478
- return {
479
- ok: false,
480
- status: 400,
481
- error: 'NO_INSTANCES_SELECTED',
482
- message: 'No enabled instances matched the provided instance filter.',
483
- };
484
- }
485
-
486
- return { ok: true, instance, localEventId };
487
- }
488
-
489
- function resolveCommitTarget(args: {
490
- seq: string;
491
- instances: ConsoleGatewayInstance[];
492
- query: { instanceId?: string; instanceIds?: string };
493
- }):
494
- | { ok: true; instance: ConsoleGatewayInstance; localCommitSeq: number }
495
- | { ok: false; status: 400 | 404; error: string; message?: string } {
496
- const federated = parseFederatedNumericId(args.seq);
497
- if (federated) {
498
- const instance = findInstanceById({
499
- instances: args.instances,
500
- instanceId: federated.instanceId,
501
- });
502
- if (!instance) {
503
- return {
504
- ok: false,
505
- status: 404,
506
- error: 'NOT_FOUND',
507
- message: 'Instance not found',
508
- };
509
- }
510
- return { ok: true, instance, localCommitSeq: federated.localId };
511
- }
512
-
513
- const localCommitSeq = parseLocalNumericId(args.seq);
514
- if (localCommitSeq === null) {
515
- return {
516
- ok: false,
517
- status: 400,
518
- error: 'INVALID_FEDERATED_ID',
519
- message:
520
- 'Expected either "<instanceId>:<commitSeq>" or "<commitSeq>" with an explicit instance filter.',
521
- };
522
- }
523
-
524
- const selectedInstances = selectInstances({
525
- instances: args.instances,
526
- query: args.query,
527
- });
528
- if (selectedInstances.length === 0) {
529
- return {
530
- ok: false,
531
- status: 400,
532
- error: 'NO_INSTANCES_SELECTED',
533
- message: 'No enabled instances matched the provided instance filter.',
534
- };
535
- }
536
- if (selectedInstances.length > 1) {
537
- return {
538
- ok: false,
539
- status: 400,
540
- error: 'AMBIGUOUS_COMMIT_ID',
541
- message:
542
- 'Local commit IDs are ambiguous across multiple instances. Use "<instanceId>:<commitSeq>" or select one instance.',
543
- };
544
- }
545
-
546
- const instance = selectedInstances[0];
547
- if (!instance) {
548
- return {
549
- ok: false,
550
- status: 400,
551
- error: 'NO_INSTANCES_SELECTED',
552
- message: 'No enabled instances matched the provided instance filter.',
553
- };
554
- }
555
-
556
- return { ok: true, instance, localCommitSeq };
557
- }
558
-
559
- function resolveSingleInstanceTarget(args: {
560
- instances: ConsoleGatewayInstance[];
561
- query: { instanceId?: string; instanceIds?: string };
562
- }):
563
- | { ok: true; instance: ConsoleGatewayInstance }
564
- | { ok: false; status: 400; error: string; message: string } {
565
- const selectedInstances = selectInstances(args);
566
- if (selectedInstances.length === 0) {
567
- return {
568
- ok: false,
569
- status: 400,
570
- error: 'NO_INSTANCES_SELECTED',
571
- message: 'No enabled instances matched the provided instance filter.',
572
- };
573
- }
574
-
575
- if (selectedInstances.length > 1) {
576
- return {
577
- ok: false,
578
- status: 400,
579
- error: 'INSTANCE_REQUIRED',
580
- message:
581
- 'This endpoint requires exactly one target instance. Provide `instanceId` or a single-value `instanceIds` filter.',
582
- };
583
- }
584
-
585
- const instance = selectedInstances[0];
586
- if (!instance) {
587
- return {
588
- ok: false,
589
- status: 400,
590
- error: 'NO_INSTANCES_SELECTED',
591
- message: 'No enabled instances matched the provided instance filter.',
592
- };
593
- }
594
-
595
- return { ok: true, instance };
596
- }
597
-
598
- function minNullable(values: Array<number | null>): number | null {
599
- const filtered = values.filter((value): value is number => value !== null);
600
- if (filtered.length === 0) return null;
601
- return Math.min(...filtered);
602
- }
603
-
604
- function maxNullable(values: Array<number | null>): number | null {
605
- const filtered = values.filter((value): value is number => value !== null);
606
- if (filtered.length === 0) return null;
607
- return Math.max(...filtered);
608
- }
609
-
610
- function compareIsoDesc(a: string, b: string): number {
611
- const aMs = Date.parse(a);
612
- const bMs = Date.parse(b);
613
- if (!Number.isFinite(aMs) && !Number.isFinite(bMs)) return 0;
614
- if (!Number.isFinite(aMs)) return 1;
615
- if (!Number.isFinite(bMs)) return -1;
616
- return bMs - aMs;
617
- }
618
-
619
- interface TimeseriesBucketAccumulator {
620
- pushCount: number;
621
- pullCount: number;
622
- errorCount: number;
623
- latencySum: number;
624
- eventCount: number;
625
- }
626
-
627
- function createTimeseriesBucketAccumulator(): TimeseriesBucketAccumulator {
628
- return {
629
- pushCount: 0,
630
- pullCount: 0,
631
- errorCount: 0,
632
- latencySum: 0,
633
- eventCount: 0,
634
- };
635
- }
636
-
637
- function mergeTimeseriesBuckets(
638
- responses: TimeseriesStatsResponse[]
639
- ): TimeseriesBucket[] {
640
- const bucketMap = new Map<string, TimeseriesBucketAccumulator>();
641
-
642
- for (const response of responses) {
643
- for (const bucket of response.buckets) {
644
- const existing =
645
- bucketMap.get(bucket.timestamp) ?? createTimeseriesBucketAccumulator();
646
- existing.pushCount += bucket.pushCount;
647
- existing.pullCount += bucket.pullCount;
648
- existing.errorCount += bucket.errorCount;
649
-
650
- const bucketEventCount = bucket.pushCount + bucket.pullCount;
651
- if (bucketEventCount > 0) {
652
- existing.latencySum += bucket.avgLatencyMs * bucketEventCount;
653
- existing.eventCount += bucketEventCount;
654
- }
655
-
656
- bucketMap.set(bucket.timestamp, existing);
657
- }
658
- }
659
-
660
- return Array.from(bucketMap.entries())
661
- .sort(([a], [b]) => a.localeCompare(b))
662
- .map(([timestamp, bucket]) => ({
663
- timestamp,
664
- pushCount: bucket.pushCount,
665
- pullCount: bucket.pullCount,
666
- errorCount: bucket.errorCount,
667
- avgLatencyMs:
668
- bucket.eventCount > 0 ? bucket.latencySum / bucket.eventCount : 0,
669
- }));
670
- }
671
-
672
- function averagePercentiles(values: LatencyPercentiles[]): LatencyPercentiles {
673
- if (values.length === 0) {
674
- return { p50: 0, p90: 0, p99: 0 };
675
- }
676
-
677
- return {
678
- p50: values.reduce((acc, value) => acc + value.p50, 0) / values.length,
679
- p90: values.reduce((acc, value) => acc + value.p90, 0) / values.length,
680
- p99: values.reduce((acc, value) => acc + value.p99, 0) / values.length,
681
- };
682
- }
683
-
684
- function sanitizeForwardQueryParams(query: URLSearchParams): URLSearchParams {
685
- const sanitized = new URLSearchParams(query);
686
- sanitized.delete('instanceId');
687
- sanitized.delete('instanceIds');
688
- return sanitized;
689
- }
690
-
691
- function withPaging(
692
- params: URLSearchParams,
693
- paging: { limit: number; offset: number }
694
- ): URLSearchParams {
695
- const next = new URLSearchParams(params);
696
- next.set('limit', String(paging.limit));
697
- next.set('offset', String(paging.offset));
698
- return next;
699
- }
700
-
701
- function buildConsoleEndpointUrl(args: {
702
- instance: ConsoleGatewayInstance;
703
- requestUrl: string;
704
- path: string;
705
- query?: URLSearchParams;
706
- }): string {
707
- const baseUrl = resolveBaseUrl(args.instance.baseUrl, args.requestUrl);
708
- const basePath = baseUrl.pathname.endsWith('/')
709
- ? baseUrl.pathname.slice(0, -1)
710
- : baseUrl.pathname;
711
- const suffix = args.path.startsWith('/') ? args.path : `/${args.path}`;
712
- baseUrl.pathname = `${basePath}/console${suffix}`;
713
- baseUrl.search = args.query?.toString() ?? '';
714
- return baseUrl.toString();
715
- }
716
-
717
- function resolveForwardAuthorization(args: {
718
- c: Context;
719
- instance: ConsoleGatewayInstance;
720
- }): string | null {
721
- if (args.instance.token) {
722
- return `Bearer ${args.instance.token}`;
723
- }
724
- const header = args.c.req.header('Authorization')?.trim();
725
- if (header) {
726
- return header;
727
- }
728
- return null;
729
- }
730
-
731
- function parseBearerToken(
732
- authHeader: string | null | undefined
733
- ): string | null {
734
- const value = authHeader?.trim();
735
- if (!value?.startsWith('Bearer ')) {
736
- return null;
737
- }
738
- const token = value.slice(7).trim();
739
- return token.length > 0 ? token : null;
740
- }
741
-
742
- async function fetchDownstreamJson<T>(args: {
743
- c: Context;
744
- instance: ConsoleGatewayInstance;
745
- path: string;
746
- query?: URLSearchParams;
747
- schema: z.ZodType<T>;
748
- fetchImpl: typeof fetch;
749
- }): Promise<{ ok: true; data: T } | { ok: false; failure: GatewayFailure }> {
750
- const url = buildConsoleEndpointUrl({
751
- instance: args.instance,
752
- requestUrl: args.c.req.url,
753
- path: args.path,
754
- query: args.query,
755
- });
756
-
757
- const headers = new Headers();
758
- headers.set('Accept', 'application/json');
759
- const authorization = resolveForwardAuthorization({
760
- c: args.c,
761
- instance: args.instance,
762
- });
763
- if (authorization) {
764
- headers.set('Authorization', authorization);
765
- }
766
-
767
- try {
768
- const response = await args.fetchImpl(url, {
769
- method: 'GET',
770
- headers,
771
- });
772
-
773
- if (!response.ok) {
774
- return {
775
- ok: false,
776
- failure: {
777
- instanceId: args.instance.instanceId,
778
- reason: `HTTP ${response.status}`,
779
- status: response.status,
780
- },
781
- };
782
- }
783
-
784
- const payload = await response.json();
785
- const parsed = args.schema.safeParse(payload);
786
- if (!parsed.success) {
787
- return {
788
- ok: false,
789
- failure: {
790
- instanceId: args.instance.instanceId,
791
- reason: 'Invalid response payload',
792
- },
793
- };
794
- }
795
-
796
- return { ok: true, data: parsed.data };
797
- } catch (error) {
798
- return {
799
- ok: false,
800
- failure: {
801
- instanceId: args.instance.instanceId,
802
- reason: toErrorMessage(error),
803
- },
804
- };
805
- }
806
- }
807
-
808
- async function parseDownstreamBody(response: Response): Promise<unknown> {
809
- const text = await response.text();
810
- if (!text.trim()) {
811
- return null;
812
- }
813
- try {
814
- return JSON.parse(text) as unknown;
815
- } catch {
816
- return text;
817
- }
818
- }
819
-
820
- function normalizeDownstreamError(args: {
821
- body: unknown;
822
- status: number;
823
- instanceId: string;
824
- }): Record<string, unknown> {
825
- if (args.body && typeof args.body === 'object' && !Array.isArray(args.body)) {
826
- return {
827
- ...(args.body as Record<string, unknown>),
828
- instanceId: args.instanceId,
829
- };
830
- }
831
-
832
- if (typeof args.body === 'string' && args.body.trim().length > 0) {
833
- return {
834
- error: 'DOWNSTREAM_ERROR',
835
- message: args.body,
836
- instanceId: args.instanceId,
837
- };
838
- }
839
-
840
- return {
841
- error: 'DOWNSTREAM_ERROR',
842
- status: args.status,
843
- instanceId: args.instanceId,
844
- };
845
- }
846
-
847
- async function forwardDownstreamJsonRequest<T>(args: {
848
- c: Context;
849
- instance: ConsoleGatewayInstance;
850
- method: 'GET' | 'POST' | 'DELETE';
851
- path: string;
852
- query?: URLSearchParams;
853
- body?: unknown;
854
- responseSchema: z.ZodType<T>;
855
- fetchImpl: typeof fetch;
856
- }): Promise<
857
- | { ok: true; data: T; status: number }
858
- | { ok: false; status: number; body: Record<string, unknown> }
859
- > {
860
- const url = buildConsoleEndpointUrl({
861
- instance: args.instance,
862
- requestUrl: args.c.req.url,
863
- path: args.path,
864
- query: args.query,
865
- });
866
-
867
- const headers = new Headers();
868
- headers.set('Accept', 'application/json');
869
- const authorization = resolveForwardAuthorization({
870
- c: args.c,
871
- instance: args.instance,
872
- });
873
- if (authorization) {
874
- headers.set('Authorization', authorization);
875
- }
876
-
877
- let requestBody: string | undefined;
878
- if (args.body !== undefined) {
879
- headers.set('Content-Type', 'application/json');
880
- requestBody = JSON.stringify(args.body);
881
- }
882
-
883
- try {
884
- const response = await args.fetchImpl(url, {
885
- method: args.method,
886
- headers,
887
- ...(requestBody !== undefined ? { body: requestBody } : {}),
888
- });
889
-
890
- const payload = await parseDownstreamBody(response);
891
- if (!response.ok) {
892
- return {
893
- ok: false,
894
- status: response.status,
895
- body: normalizeDownstreamError({
896
- body: payload,
897
- status: response.status,
898
- instanceId: args.instance.instanceId,
899
- }),
900
- };
901
- }
902
-
903
- const parsed = args.responseSchema.safeParse(payload);
904
- if (!parsed.success) {
905
- return {
906
- ok: false,
907
- status: 502,
908
- body: {
909
- error: 'INVALID_DOWNSTREAM_RESPONSE',
910
- message: 'Downstream response failed validation.',
911
- instanceId: args.instance.instanceId,
912
- },
913
- };
914
- }
915
-
916
- return {
917
- ok: true,
918
- data: parsed.data,
919
- status: response.status,
920
- };
921
- } catch (error) {
922
- return {
923
- ok: false,
924
- status: 502,
925
- body: {
926
- error: 'DOWNSTREAM_UNAVAILABLE',
927
- message: toErrorMessage(error),
928
- instanceId: args.instance.instanceId,
929
- },
930
- };
931
- }
932
- }
933
-
934
- async function fetchDownstreamPaged<T>(args: {
935
- c: Context;
936
- instance: ConsoleGatewayInstance;
937
- path: string;
938
- query: URLSearchParams;
939
- targetCount: number;
940
- schema: z.ZodType<ConsolePaginatedResponse<T>>;
941
- fetchImpl: typeof fetch;
942
- }): Promise<
943
- | { ok: true; items: T[]; total: number }
944
- | { ok: false; failure: GatewayFailure }
945
- > {
946
- const items: T[] = [];
947
- let total: number | null = null;
948
- let localOffset = 0;
949
- let pageCount = 0;
950
-
951
- while (
952
- items.length < args.targetCount &&
953
- (total === null || localOffset < total) &&
954
- pageCount < 100
955
- ) {
956
- const limit = Math.min(100, Math.max(1, args.targetCount - items.length));
957
- const pagedQuery = withPaging(args.query, { limit, offset: localOffset });
958
- const result = await fetchDownstreamJson({
959
- c: args.c,
960
- instance: args.instance,
961
- path: args.path,
962
- query: pagedQuery,
963
- schema: args.schema,
964
- fetchImpl: args.fetchImpl,
965
- });
966
-
967
- if (!result.ok) {
968
- return result;
969
- }
970
-
971
- const page = result.data;
972
- total = page.total;
973
- items.push(...page.items);
974
- localOffset += page.items.length;
975
- pageCount += 1;
976
-
977
- if (page.items.length === 0) {
978
- break;
979
- }
980
- }
981
-
982
- return {
983
- ok: true,
984
- items,
985
- total: total ?? items.length,
986
- };
987
- }
988
-
989
- async function checkDownstreamInstanceHealth(args: {
990
- c: Context;
991
- instance: ConsoleGatewayInstance;
992
- fetchImpl: typeof fetch;
993
- }): Promise<z.infer<typeof GatewayInstanceHealthSchema>> {
994
- const startedAt = Date.now();
995
- const result = await fetchDownstreamJson({
996
- c: args.c,
997
- instance: args.instance,
998
- path: '/stats',
999
- schema: SyncStatsSchema,
1000
- fetchImpl: args.fetchImpl,
1001
- });
1002
-
1003
- const responseTimeMs = Math.max(0, Date.now() - startedAt);
1004
- const checkedAt = new Date().toISOString();
1005
- const base = {
1006
- instanceId: args.instance.instanceId,
1007
- label: args.instance.label ?? args.instance.instanceId,
1008
- baseUrl: args.instance.baseUrl,
1009
- enabled: args.instance.enabled ?? true,
1010
- responseTimeMs,
1011
- checkedAt,
1012
- };
1013
-
1014
- if (result.ok) {
1015
- return {
1016
- ...base,
1017
- healthy: true,
1018
- status: 200,
1019
- };
1020
- }
1021
-
1022
- return {
1023
- ...base,
1024
- healthy: false,
1025
- status: result.failure.status,
1026
- reason: result.failure.reason,
1027
- };
1028
- }
1029
-
1030
- function unauthorizedResponse(c: Context): Response {
1031
- return c.json({ error: 'UNAUTHORIZED' }, 401);
1032
- }
1033
-
1034
- function jsonResponse(payload: unknown, status: number): Response {
1035
- return new Response(JSON.stringify(payload), {
1036
- status,
1037
- headers: {
1038
- 'content-type': 'application/json; charset=utf-8',
1039
- },
1040
- });
1041
- }
1042
-
1043
- function allInstancesFailedResponse(
1044
- c: Context,
1045
- failedInstances: GatewayFailure[]
1046
- ): Response {
1047
- return c.json(
1048
- {
1049
- error: 'DOWNSTREAM_UNAVAILABLE',
1050
- failedInstances,
1051
- },
1052
- 502
1053
- );
1054
- }
1055
-
1056
- export function createConsoleGatewayRoutes(
1057
- options: CreateConsoleGatewayRoutesOptions
1058
- ): Hono {
1059
- const routes = new Hono();
1060
- const instances = normalizeInstances(options.instances);
1061
- const fetchImpl = options.fetchImpl ?? fetch;
1062
- const corsOrigins = options.corsOrigins ?? '*';
1063
-
1064
- routes.use(
1065
- '*',
1066
- cors({
1067
- origin: corsOrigins === '*' ? '*' : corsOrigins,
1068
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
1069
- allowHeaders: [
1070
- 'Content-Type',
1071
- 'Authorization',
1072
- 'X-Syncular-Transport-Path',
1073
- 'Baggage',
1074
- 'Sentry-Trace',
1075
- 'Traceparent',
1076
- 'Tracestate',
1077
- ],
1078
- credentials: true,
1079
- })
1080
- );
1081
-
1082
- routes.get(
1083
- '/instances',
1084
- describeRoute({
1085
- tags: ['console-gateway'],
1086
- summary: 'List configured downstream console instances',
1087
- responses: {
1088
- 200: {
1089
- description: 'Configured instances',
1090
- content: {
1091
- 'application/json': {
1092
- schema: resolver(GatewayInstancesResponseSchema),
1093
- },
1094
- },
1095
- },
1096
- 401: {
1097
- description: 'Unauthenticated',
1098
- content: {
1099
- 'application/json': {
1100
- schema: resolver(z.object({ error: z.string() })),
1101
- },
1102
- },
1103
- },
1104
- },
1105
- }),
1106
- async (c) => {
1107
- const auth = await options.authenticate(c);
1108
- if (!auth) {
1109
- return unauthorizedResponse(c);
1110
- }
1111
-
1112
- return c.json({
1113
- items: instances.map((instance) => ({
1114
- instanceId: instance.instanceId,
1115
- label: instance.label ?? instance.instanceId,
1116
- baseUrl: instance.baseUrl,
1117
- enabled: instance.enabled ?? true,
1118
- })),
1119
- });
1120
- }
1121
- );
1122
-
1123
- routes.get(
1124
- '/instances/health',
1125
- describeRoute({
1126
- tags: ['console-gateway'],
1127
- summary: 'Probe downstream console health by instance',
1128
- responses: {
1129
- 200: {
1130
- description: 'Per-instance health results',
1131
- content: {
1132
- 'application/json': {
1133
- schema: resolver(GatewayInstancesHealthResponseSchema),
1134
- },
1135
- },
1136
- },
1137
- 401: {
1138
- description: 'Unauthenticated',
1139
- content: {
1140
- 'application/json': {
1141
- schema: resolver(z.object({ error: z.string() })),
1142
- },
1143
- },
1144
- },
1145
- },
1146
- }),
1147
- zValidator('query', GatewayInstanceFilterSchema),
1148
- async (c) => {
1149
- const auth = await options.authenticate(c);
1150
- if (!auth) {
1151
- return unauthorizedResponse(c);
1152
- }
1153
-
1154
- const query = c.req.valid('query');
1155
- const selectedInstances = selectInstances({ instances, query });
1156
- if (selectedInstances.length === 0) {
1157
- return c.json(
1158
- {
1159
- error: 'NO_INSTANCES_SELECTED',
1160
- message:
1161
- 'No enabled instances matched the provided instance filter.',
1162
- },
1163
- 400
1164
- );
1165
- }
1166
-
1167
- const items = await Promise.all(
1168
- selectedInstances.map((instance) =>
1169
- checkDownstreamInstanceHealth({
1170
- c,
1171
- instance,
1172
- fetchImpl,
1173
- })
1174
- )
1175
- );
1176
-
1177
- const failedInstances = items
1178
- .filter((item) => !item.healthy)
1179
- .map((item) => ({
1180
- instanceId: item.instanceId,
1181
- reason: item.reason ?? 'Health probe failed',
1182
- ...(item.status !== undefined ? { status: item.status } : {}),
1183
- }));
1184
-
1185
- return c.json({
1186
- items,
1187
- partial: failedInstances.length > 0,
1188
- failedInstances,
1189
- });
1190
- }
1191
- );
1192
-
1193
- routes.get(
1194
- '/handlers',
1195
- describeRoute({
1196
- tags: ['console-gateway'],
1197
- summary:
1198
- 'List handlers for a single target instance (requires instance selection)',
1199
- responses: {
1200
- 200: {
1201
- description: 'Handlers',
1202
- content: {
1203
- 'application/json': {
1204
- schema: resolver(GatewayHandlersResponseSchema),
1205
- },
1206
- },
1207
- },
1208
- },
1209
- }),
1210
- zValidator('query', GatewaySingleInstanceQuerySchema),
1211
- async (c) => {
1212
- const auth = await options.authenticate(c);
1213
- if (!auth) {
1214
- return unauthorizedResponse(c);
1215
- }
1216
-
1217
- const query = c.req.valid('query');
1218
- const target = resolveSingleInstanceTarget({ instances, query });
1219
- if (!target.ok) {
1220
- return c.json(
1221
- {
1222
- error: target.error,
1223
- message: target.message,
1224
- },
1225
- target.status
1226
- );
1227
- }
1228
-
1229
- const forwardQuery = sanitizeForwardQueryParams(
1230
- new URL(c.req.url).searchParams
1231
- );
1232
- const result = await forwardDownstreamJsonRequest({
1233
- c,
1234
- instance: target.instance,
1235
- method: 'GET',
1236
- path: '/handlers',
1237
- query: forwardQuery,
1238
- responseSchema: GatewayHandlersResponseSchema,
1239
- fetchImpl,
1240
- });
1241
-
1242
- if (!result.ok) {
1243
- return jsonResponse(result.body, result.status);
1244
- }
1245
-
1246
- return jsonResponse(result.data, result.status);
1247
- }
1248
- );
1249
-
1250
- routes.post(
1251
- '/prune/preview',
1252
- describeRoute({
1253
- tags: ['console-gateway'],
1254
- summary:
1255
- 'Preview prune on a single target instance (requires instance selection)',
1256
- responses: {
1257
- 200: {
1258
- description: 'Prune preview',
1259
- content: {
1260
- 'application/json': {
1261
- schema: resolver(ConsolePrunePreviewSchema),
1262
- },
1263
- },
1264
- },
1265
- },
1266
- }),
1267
- zValidator('query', GatewaySingleInstanceQuerySchema),
1268
- async (c) => {
1269
- const auth = await options.authenticate(c);
1270
- if (!auth) {
1271
- return unauthorizedResponse(c);
1272
- }
1273
-
1274
- const query = c.req.valid('query');
1275
- const target = resolveSingleInstanceTarget({ instances, query });
1276
- if (!target.ok) {
1277
- return c.json(
1278
- {
1279
- error: target.error,
1280
- message: target.message,
1281
- },
1282
- target.status
1283
- );
1284
- }
1285
-
1286
- const forwardQuery = sanitizeForwardQueryParams(
1287
- new URL(c.req.url).searchParams
1288
- );
1289
- const result = await forwardDownstreamJsonRequest<ConsolePrunePreview>({
1290
- c,
1291
- instance: target.instance,
1292
- method: 'POST',
1293
- path: '/prune/preview',
1294
- query: forwardQuery,
1295
- responseSchema: ConsolePrunePreviewSchema,
1296
- fetchImpl,
1297
- });
1298
-
1299
- if (!result.ok) {
1300
- return jsonResponse(result.body, result.status);
1301
- }
1302
-
1303
- return jsonResponse(result.data, result.status);
1304
- }
1305
- );
1306
-
1307
- routes.post(
1308
- '/prune',
1309
- describeRoute({
1310
- tags: ['console-gateway'],
1311
- summary:
1312
- 'Trigger prune on a single target instance (requires instance selection)',
1313
- responses: {
1314
- 200: {
1315
- description: 'Prune result',
1316
- content: {
1317
- 'application/json': {
1318
- schema: resolver(ConsolePruneResultSchema),
1319
- },
1320
- },
1321
- },
1322
- },
1323
- }),
1324
- zValidator('query', GatewaySingleInstanceQuerySchema),
1325
- async (c) => {
1326
- const auth = await options.authenticate(c);
1327
- if (!auth) {
1328
- return unauthorizedResponse(c);
1329
- }
1330
-
1331
- const query = c.req.valid('query');
1332
- const target = resolveSingleInstanceTarget({ instances, query });
1333
- if (!target.ok) {
1334
- return c.json(
1335
- {
1336
- error: target.error,
1337
- message: target.message,
1338
- },
1339
- target.status
1340
- );
1341
- }
1342
-
1343
- const forwardQuery = sanitizeForwardQueryParams(
1344
- new URL(c.req.url).searchParams
1345
- );
1346
- const result = await forwardDownstreamJsonRequest<ConsolePruneResult>({
1347
- c,
1348
- instance: target.instance,
1349
- method: 'POST',
1350
- path: '/prune',
1351
- query: forwardQuery,
1352
- responseSchema: ConsolePruneResultSchema,
1353
- fetchImpl,
1354
- });
1355
-
1356
- if (!result.ok) {
1357
- return jsonResponse(result.body, result.status);
1358
- }
1359
-
1360
- return jsonResponse(result.data, result.status);
1361
- }
1362
- );
1363
-
1364
- routes.post(
1365
- '/compact',
1366
- describeRoute({
1367
- tags: ['console-gateway'],
1368
- summary:
1369
- 'Trigger compaction on a single target instance (requires instance selection)',
1370
- responses: {
1371
- 200: {
1372
- description: 'Compaction result',
1373
- content: {
1374
- 'application/json': {
1375
- schema: resolver(ConsoleCompactResultSchema),
1376
- },
1377
- },
1378
- },
1379
- },
1380
- }),
1381
- zValidator('query', GatewaySingleInstanceQuerySchema),
1382
- async (c) => {
1383
- const auth = await options.authenticate(c);
1384
- if (!auth) {
1385
- return unauthorizedResponse(c);
1386
- }
1387
-
1388
- const query = c.req.valid('query');
1389
- const target = resolveSingleInstanceTarget({ instances, query });
1390
- if (!target.ok) {
1391
- return c.json(
1392
- {
1393
- error: target.error,
1394
- message: target.message,
1395
- },
1396
- target.status
1397
- );
1398
- }
1399
-
1400
- const forwardQuery = sanitizeForwardQueryParams(
1401
- new URL(c.req.url).searchParams
1402
- );
1403
- const result = await forwardDownstreamJsonRequest<ConsoleCompactResult>({
1404
- c,
1405
- instance: target.instance,
1406
- method: 'POST',
1407
- path: '/compact',
1408
- query: forwardQuery,
1409
- responseSchema: ConsoleCompactResultSchema,
1410
- fetchImpl,
1411
- });
1412
-
1413
- if (!result.ok) {
1414
- return jsonResponse(result.body, result.status);
1415
- }
1416
-
1417
- return jsonResponse(result.data, result.status);
1418
- }
1419
- );
1420
-
1421
- routes.post(
1422
- '/notify-data-change',
1423
- describeRoute({
1424
- tags: ['console-gateway'],
1425
- summary:
1426
- 'Notify data change on a single target instance (requires instance selection)',
1427
- responses: {
1428
- 200: {
1429
- description: 'Notification result',
1430
- content: {
1431
- 'application/json': {
1432
- schema: resolver(GatewayNotifyDataChangeResponseSchema),
1433
- },
1434
- },
1435
- },
1436
- },
1437
- }),
1438
- zValidator('query', GatewaySingleInstanceQuerySchema),
1439
- zValidator('json', GatewayNotifyDataChangeRequestSchema),
1440
- async (c) => {
1441
- const auth = await options.authenticate(c);
1442
- if (!auth) {
1443
- return unauthorizedResponse(c);
1444
- }
1445
-
1446
- const query = c.req.valid('query');
1447
- const body = c.req.valid('json');
1448
- const target = resolveSingleInstanceTarget({ instances, query });
1449
- if (!target.ok) {
1450
- return c.json(
1451
- {
1452
- error: target.error,
1453
- message: target.message,
1454
- },
1455
- target.status
1456
- );
1457
- }
1458
-
1459
- const forwardQuery = sanitizeForwardQueryParams(
1460
- new URL(c.req.url).searchParams
1461
- );
1462
- const result = await forwardDownstreamJsonRequest({
1463
- c,
1464
- instance: target.instance,
1465
- method: 'POST',
1466
- path: '/notify-data-change',
1467
- query: forwardQuery,
1468
- body,
1469
- responseSchema: GatewayNotifyDataChangeResponseSchema,
1470
- fetchImpl,
1471
- });
1472
-
1473
- if (!result.ok) {
1474
- return jsonResponse(result.body, result.status);
1475
- }
1476
-
1477
- return jsonResponse(result.data, result.status);
1478
- }
1479
- );
1480
-
1481
- routes.delete(
1482
- '/clients/:id',
1483
- describeRoute({
1484
- tags: ['console-gateway'],
1485
- summary:
1486
- 'Evict client on a single target instance (requires instance selection)',
1487
- responses: {
1488
- 200: {
1489
- description: 'Evict result',
1490
- content: {
1491
- 'application/json': {
1492
- schema: resolver(ConsoleEvictResultSchema),
1493
- },
1494
- },
1495
- },
1496
- },
1497
- }),
1498
- zValidator('param', GatewayClientPathParamSchema),
1499
- zValidator('query', GatewaySingleInstancePartitionQuerySchema),
1500
- async (c) => {
1501
- const auth = await options.authenticate(c);
1502
- if (!auth) {
1503
- return unauthorizedResponse(c);
1504
- }
1505
-
1506
- const { id } = c.req.valid('param');
1507
- const query = c.req.valid('query');
1508
- const target = resolveSingleInstanceTarget({ instances, query });
1509
- if (!target.ok) {
1510
- return c.json(
1511
- {
1512
- error: target.error,
1513
- message: target.message,
1514
- },
1515
- target.status
1516
- );
1517
- }
1518
-
1519
- const forwardQuery = sanitizeForwardQueryParams(
1520
- new URL(c.req.url).searchParams
1521
- );
1522
- const result = await forwardDownstreamJsonRequest<ConsoleEvictResult>({
1523
- c,
1524
- instance: target.instance,
1525
- method: 'DELETE',
1526
- path: `/clients/${encodeURIComponent(id)}`,
1527
- query: forwardQuery,
1528
- responseSchema: ConsoleEvictResultSchema,
1529
- fetchImpl,
1530
- });
1531
-
1532
- if (!result.ok) {
1533
- return jsonResponse(result.body, result.status);
1534
- }
1535
-
1536
- return jsonResponse(result.data, result.status);
1537
- }
1538
- );
1539
-
1540
- routes.delete(
1541
- '/events',
1542
- describeRoute({
1543
- tags: ['console-gateway'],
1544
- summary:
1545
- 'Clear request events on a single target instance (requires instance selection)',
1546
- responses: {
1547
- 200: {
1548
- description: 'Clear result',
1549
- content: {
1550
- 'application/json': {
1551
- schema: resolver(ConsoleClearEventsResultSchema),
1552
- },
1553
- },
1554
- },
1555
- },
1556
- }),
1557
- zValidator('query', GatewaySingleInstanceQuerySchema),
1558
- async (c) => {
1559
- const auth = await options.authenticate(c);
1560
- if (!auth) {
1561
- return unauthorizedResponse(c);
1562
- }
1563
-
1564
- const query = c.req.valid('query');
1565
- const target = resolveSingleInstanceTarget({ instances, query });
1566
- if (!target.ok) {
1567
- return c.json(
1568
- {
1569
- error: target.error,
1570
- message: target.message,
1571
- },
1572
- target.status
1573
- );
1574
- }
1575
-
1576
- const forwardQuery = sanitizeForwardQueryParams(
1577
- new URL(c.req.url).searchParams
1578
- );
1579
- const result =
1580
- await forwardDownstreamJsonRequest<ConsoleClearEventsResult>({
1581
- c,
1582
- instance: target.instance,
1583
- method: 'DELETE',
1584
- path: '/events',
1585
- query: forwardQuery,
1586
- responseSchema: ConsoleClearEventsResultSchema,
1587
- fetchImpl,
1588
- });
1589
-
1590
- if (!result.ok) {
1591
- return jsonResponse(result.body, result.status);
1592
- }
1593
-
1594
- return jsonResponse(result.data, result.status);
1595
- }
1596
- );
1597
-
1598
- routes.post(
1599
- '/events/prune',
1600
- describeRoute({
1601
- tags: ['console-gateway'],
1602
- summary:
1603
- 'Prune request events on a single target instance (requires instance selection)',
1604
- responses: {
1605
- 200: {
1606
- description: 'Prune events result',
1607
- content: {
1608
- 'application/json': {
1609
- schema: resolver(ConsolePruneEventsResultSchema),
1610
- },
1611
- },
1612
- },
1613
- },
1614
- }),
1615
- zValidator('query', GatewaySingleInstanceQuerySchema),
1616
- async (c) => {
1617
- const auth = await options.authenticate(c);
1618
- if (!auth) {
1619
- return unauthorizedResponse(c);
1620
- }
1621
-
1622
- const query = c.req.valid('query');
1623
- const target = resolveSingleInstanceTarget({ instances, query });
1624
- if (!target.ok) {
1625
- return c.json(
1626
- {
1627
- error: target.error,
1628
- message: target.message,
1629
- },
1630
- target.status
1631
- );
1632
- }
1633
-
1634
- const forwardQuery = sanitizeForwardQueryParams(
1635
- new URL(c.req.url).searchParams
1636
- );
1637
- const result =
1638
- await forwardDownstreamJsonRequest<ConsolePruneEventsResult>({
1639
- c,
1640
- instance: target.instance,
1641
- method: 'POST',
1642
- path: '/events/prune',
1643
- query: forwardQuery,
1644
- responseSchema: ConsolePruneEventsResultSchema,
1645
- fetchImpl,
1646
- });
1647
-
1648
- if (!result.ok) {
1649
- return jsonResponse(result.body, result.status);
1650
- }
1651
-
1652
- return jsonResponse(result.data, result.status);
1653
- }
1654
- );
1655
-
1656
- routes.get(
1657
- '/api-keys',
1658
- describeRoute({
1659
- tags: ['console-gateway'],
1660
- summary:
1661
- 'List API keys for a single target instance (requires instance selection)',
1662
- responses: {
1663
- 200: {
1664
- description: 'Paginated API key list',
1665
- content: {
1666
- 'application/json': {
1667
- schema: resolver(
1668
- ConsolePaginatedResponseSchema(ConsoleApiKeySchema)
1669
- ),
1670
- },
1671
- },
1672
- },
1673
- },
1674
- }),
1675
- zValidator('query', GatewayApiKeysQuerySchema),
1676
- async (c) => {
1677
- const auth = await options.authenticate(c);
1678
- if (!auth) {
1679
- return unauthorizedResponse(c);
1680
- }
1681
-
1682
- const query = c.req.valid('query');
1683
- const target = resolveSingleInstanceTarget({ instances, query });
1684
- if (!target.ok) {
1685
- return c.json(
1686
- {
1687
- error: target.error,
1688
- message: target.message,
1689
- },
1690
- target.status
1691
- );
1692
- }
1693
-
1694
- const forwardQuery = sanitizeForwardQueryParams(
1695
- new URL(c.req.url).searchParams
1696
- );
1697
- const result = await forwardDownstreamJsonRequest<
1698
- ConsolePaginatedResponse<ConsoleApiKey>
1699
- >({
1700
- c,
1701
- instance: target.instance,
1702
- method: 'GET',
1703
- path: '/api-keys',
1704
- query: forwardQuery,
1705
- responseSchema: ConsolePaginatedResponseSchema(ConsoleApiKeySchema),
1706
- fetchImpl,
1707
- });
1708
-
1709
- if (!result.ok) {
1710
- return jsonResponse(result.body, result.status);
1711
- }
1712
-
1713
- return jsonResponse(result.data, result.status);
1714
- }
1715
- );
1716
-
1717
- routes.post(
1718
- '/api-keys',
1719
- describeRoute({
1720
- tags: ['console-gateway'],
1721
- summary:
1722
- 'Create API key on a single target instance (requires instance selection)',
1723
- responses: {
1724
- 201: {
1725
- description: 'Created API key',
1726
- content: {
1727
- 'application/json': {
1728
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
1729
- },
1730
- },
1731
- },
1732
- },
1733
- }),
1734
- zValidator('query', GatewaySingleInstanceQuerySchema),
1735
- zValidator('json', ConsoleApiKeyCreateRequestSchema),
1736
- async (c) => {
1737
- const auth = await options.authenticate(c);
1738
- if (!auth) {
1739
- return unauthorizedResponse(c);
1740
- }
1741
-
1742
- const query = c.req.valid('query');
1743
- const body = c.req.valid('json');
1744
- const target = resolveSingleInstanceTarget({ instances, query });
1745
- if (!target.ok) {
1746
- return c.json(
1747
- {
1748
- error: target.error,
1749
- message: target.message,
1750
- },
1751
- target.status
1752
- );
1753
- }
1754
-
1755
- const forwardQuery = sanitizeForwardQueryParams(
1756
- new URL(c.req.url).searchParams
1757
- );
1758
- const result =
1759
- await forwardDownstreamJsonRequest<ConsoleApiKeyCreateResponse>({
1760
- c,
1761
- instance: target.instance,
1762
- method: 'POST',
1763
- path: '/api-keys',
1764
- query: forwardQuery,
1765
- body,
1766
- responseSchema: ConsoleApiKeyCreateResponseSchema,
1767
- fetchImpl,
1768
- });
1769
-
1770
- if (!result.ok) {
1771
- return jsonResponse(result.body, result.status);
1772
- }
1773
-
1774
- return jsonResponse(result.data, result.status);
1775
- }
1776
- );
1777
-
1778
- routes.get(
1779
- '/api-keys/:id',
1780
- describeRoute({
1781
- tags: ['console-gateway'],
1782
- summary:
1783
- 'Get API key from a single target instance (requires instance selection)',
1784
- responses: {
1785
- 200: {
1786
- description: 'API key details',
1787
- content: {
1788
- 'application/json': {
1789
- schema: resolver(ConsoleApiKeySchema),
1790
- },
1791
- },
1792
- },
1793
- },
1794
- }),
1795
- zValidator('param', GatewayApiKeyPathParamSchema),
1796
- zValidator('query', GatewaySingleInstanceQuerySchema),
1797
- async (c) => {
1798
- const auth = await options.authenticate(c);
1799
- if (!auth) {
1800
- return unauthorizedResponse(c);
1801
- }
1802
-
1803
- const { id } = c.req.valid('param');
1804
- const query = c.req.valid('query');
1805
- const target = resolveSingleInstanceTarget({ instances, query });
1806
- if (!target.ok) {
1807
- return c.json(
1808
- {
1809
- error: target.error,
1810
- message: target.message,
1811
- },
1812
- target.status
1813
- );
1814
- }
1815
-
1816
- const forwardQuery = sanitizeForwardQueryParams(
1817
- new URL(c.req.url).searchParams
1818
- );
1819
- const result = await forwardDownstreamJsonRequest<ConsoleApiKey>({
1820
- c,
1821
- instance: target.instance,
1822
- method: 'GET',
1823
- path: `/api-keys/${encodeURIComponent(id)}`,
1824
- query: forwardQuery,
1825
- responseSchema: ConsoleApiKeySchema,
1826
- fetchImpl,
1827
- });
1828
-
1829
- if (!result.ok) {
1830
- return jsonResponse(result.body, result.status);
1831
- }
1832
-
1833
- return jsonResponse(result.data, result.status);
1834
- }
1835
- );
1836
-
1837
- routes.delete(
1838
- '/api-keys/:id',
1839
- describeRoute({
1840
- tags: ['console-gateway'],
1841
- summary:
1842
- 'Revoke API key on a single target instance (requires instance selection)',
1843
- responses: {
1844
- 200: {
1845
- description: 'Revoke result',
1846
- content: {
1847
- 'application/json': {
1848
- schema: resolver(ConsoleApiKeyRevokeResponseSchema),
1849
- },
1850
- },
1851
- },
1852
- },
1853
- }),
1854
- zValidator('param', GatewayApiKeyPathParamSchema),
1855
- zValidator('query', GatewaySingleInstanceQuerySchema),
1856
- async (c) => {
1857
- const auth = await options.authenticate(c);
1858
- if (!auth) {
1859
- return unauthorizedResponse(c);
1860
- }
1861
-
1862
- const { id } = c.req.valid('param');
1863
- const query = c.req.valid('query');
1864
- const target = resolveSingleInstanceTarget({ instances, query });
1865
- if (!target.ok) {
1866
- return c.json(
1867
- {
1868
- error: target.error,
1869
- message: target.message,
1870
- },
1871
- target.status
1872
- );
1873
- }
1874
-
1875
- const forwardQuery = sanitizeForwardQueryParams(
1876
- new URL(c.req.url).searchParams
1877
- );
1878
- const result = await forwardDownstreamJsonRequest<{ revoked: boolean }>({
1879
- c,
1880
- instance: target.instance,
1881
- method: 'DELETE',
1882
- path: `/api-keys/${encodeURIComponent(id)}`,
1883
- query: forwardQuery,
1884
- responseSchema: ConsoleApiKeyRevokeResponseSchema,
1885
- fetchImpl,
1886
- });
1887
-
1888
- if (!result.ok) {
1889
- return jsonResponse(result.body, result.status);
1890
- }
1891
-
1892
- return jsonResponse(result.data, result.status);
1893
- }
1894
- );
1895
-
1896
- routes.post(
1897
- '/api-keys/bulk-revoke',
1898
- describeRoute({
1899
- tags: ['console-gateway'],
1900
- summary:
1901
- 'Bulk revoke API keys on a single target instance (requires instance selection)',
1902
- responses: {
1903
- 200: {
1904
- description: 'Bulk revoke result',
1905
- content: {
1906
- 'application/json': {
1907
- schema: resolver(ConsoleApiKeyBulkRevokeResponseSchema),
1908
- },
1909
- },
1910
- },
1911
- },
1912
- }),
1913
- zValidator('query', GatewaySingleInstanceQuerySchema),
1914
- zValidator('json', ConsoleApiKeyBulkRevokeRequestSchema),
1915
- async (c) => {
1916
- const auth = await options.authenticate(c);
1917
- if (!auth) {
1918
- return unauthorizedResponse(c);
1919
- }
1920
-
1921
- const query = c.req.valid('query');
1922
- const body = c.req.valid('json');
1923
- const target = resolveSingleInstanceTarget({ instances, query });
1924
- if (!target.ok) {
1925
- return c.json(
1926
- {
1927
- error: target.error,
1928
- message: target.message,
1929
- },
1930
- target.status
1931
- );
1932
- }
1933
-
1934
- const forwardQuery = sanitizeForwardQueryParams(
1935
- new URL(c.req.url).searchParams
1936
- );
1937
- const result =
1938
- await forwardDownstreamJsonRequest<ConsoleApiKeyBulkRevokeResponse>({
1939
- c,
1940
- instance: target.instance,
1941
- method: 'POST',
1942
- path: '/api-keys/bulk-revoke',
1943
- query: forwardQuery,
1944
- body,
1945
- responseSchema: ConsoleApiKeyBulkRevokeResponseSchema,
1946
- fetchImpl,
1947
- });
1948
-
1949
- if (!result.ok) {
1950
- return jsonResponse(result.body, result.status);
1951
- }
1952
-
1953
- return jsonResponse(result.data, result.status);
1954
- }
1955
- );
1956
-
1957
- routes.post(
1958
- '/api-keys/:id/rotate/stage',
1959
- describeRoute({
1960
- tags: ['console-gateway'],
1961
- summary:
1962
- 'Stage-rotate API key on a single target instance (requires instance selection)',
1963
- responses: {
1964
- 200: {
1965
- description: 'Staged API key replacement',
1966
- content: {
1967
- 'application/json': {
1968
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
1969
- },
1970
- },
1971
- },
1972
- },
1973
- }),
1974
- zValidator('param', GatewayApiKeyPathParamSchema),
1975
- zValidator('query', GatewaySingleInstanceQuerySchema),
1976
- async (c) => {
1977
- const auth = await options.authenticate(c);
1978
- if (!auth) {
1979
- return unauthorizedResponse(c);
1980
- }
1981
-
1982
- const { id } = c.req.valid('param');
1983
- const query = c.req.valid('query');
1984
- const target = resolveSingleInstanceTarget({ instances, query });
1985
- if (!target.ok) {
1986
- return c.json(
1987
- {
1988
- error: target.error,
1989
- message: target.message,
1990
- },
1991
- target.status
1992
- );
1993
- }
1994
-
1995
- const forwardQuery = sanitizeForwardQueryParams(
1996
- new URL(c.req.url).searchParams
1997
- );
1998
- const result =
1999
- await forwardDownstreamJsonRequest<ConsoleApiKeyCreateResponse>({
2000
- c,
2001
- instance: target.instance,
2002
- method: 'POST',
2003
- path: `/api-keys/${encodeURIComponent(id)}/rotate/stage`,
2004
- query: forwardQuery,
2005
- responseSchema: ConsoleApiKeyCreateResponseSchema,
2006
- fetchImpl,
2007
- });
2008
-
2009
- if (!result.ok) {
2010
- return jsonResponse(result.body, result.status);
2011
- }
2012
-
2013
- return jsonResponse(result.data, result.status);
2014
- }
2015
- );
2016
-
2017
- routes.post(
2018
- '/api-keys/:id/rotate',
2019
- describeRoute({
2020
- tags: ['console-gateway'],
2021
- summary:
2022
- 'Rotate API key on a single target instance (requires instance selection)',
2023
- responses: {
2024
- 200: {
2025
- description: 'Rotated API key',
2026
- content: {
2027
- 'application/json': {
2028
- schema: resolver(ConsoleApiKeyCreateResponseSchema),
2029
- },
2030
- },
2031
- },
2032
- },
2033
- }),
2034
- zValidator('param', GatewayApiKeyPathParamSchema),
2035
- zValidator('query', GatewaySingleInstanceQuerySchema),
2036
- async (c) => {
2037
- const auth = await options.authenticate(c);
2038
- if (!auth) {
2039
- return unauthorizedResponse(c);
2040
- }
2041
-
2042
- const { id } = c.req.valid('param');
2043
- const query = c.req.valid('query');
2044
- const target = resolveSingleInstanceTarget({ instances, query });
2045
- if (!target.ok) {
2046
- return c.json(
2047
- {
2048
- error: target.error,
2049
- message: target.message,
2050
- },
2051
- target.status
2052
- );
2053
- }
2054
-
2055
- const forwardQuery = sanitizeForwardQueryParams(
2056
- new URL(c.req.url).searchParams
2057
- );
2058
- const result =
2059
- await forwardDownstreamJsonRequest<ConsoleApiKeyCreateResponse>({
2060
- c,
2061
- instance: target.instance,
2062
- method: 'POST',
2063
- path: `/api-keys/${encodeURIComponent(id)}/rotate`,
2064
- query: forwardQuery,
2065
- responseSchema: ConsoleApiKeyCreateResponseSchema,
2066
- fetchImpl,
2067
- });
2068
-
2069
- if (!result.ok) {
2070
- return jsonResponse(result.body, result.status);
2071
- }
2072
-
2073
- return jsonResponse(result.data, result.status);
2074
- }
2075
- );
2076
-
2077
- routes.get(
2078
- '/stats',
2079
- describeRoute({
2080
- tags: ['console-gateway'],
2081
- summary: 'Get merged sync stats across instances',
2082
- responses: {
2083
- 200: {
2084
- description: 'Merged stats',
2085
- content: {
2086
- 'application/json': {
2087
- schema: resolver(GatewayStatsResponseSchema),
2088
- },
2089
- },
2090
- },
2091
- },
2092
- }),
2093
- zValidator('query', GatewayStatsQuerySchema),
2094
- async (c) => {
2095
- const auth = await options.authenticate(c);
2096
- if (!auth) {
2097
- return unauthorizedResponse(c);
2098
- }
2099
-
2100
- const query = c.req.valid('query');
2101
- const selectedInstances = selectInstances({ instances, query });
2102
- if (selectedInstances.length === 0) {
2103
- return c.json(
2104
- {
2105
- error: 'NO_INSTANCES_SELECTED',
2106
- message:
2107
- 'No enabled instances matched the provided instance filter.',
2108
- },
2109
- 400
2110
- );
2111
- }
2112
-
2113
- const forwardQuery = sanitizeForwardQueryParams(
2114
- new URL(c.req.url).searchParams
2115
- );
2116
-
2117
- const results = await Promise.all(
2118
- selectedInstances.map((instance) =>
2119
- fetchDownstreamJson({
2120
- c,
2121
- instance,
2122
- path: '/stats',
2123
- query: forwardQuery,
2124
- schema: SyncStatsSchema,
2125
- fetchImpl,
2126
- })
2127
- )
2128
- );
2129
-
2130
- const failedInstances = results
2131
- .filter(
2132
- (result): result is { ok: false; failure: GatewayFailure } =>
2133
- !result.ok
2134
- )
2135
- .map((result) => result.failure);
2136
- const successfulResults = results.filter(
2137
- (result): result is { ok: true; data: SyncStats } => result.ok
2138
- );
2139
-
2140
- if (successfulResults.length === 0) {
2141
- return allInstancesFailedResponse(c, failedInstances);
2142
- }
2143
-
2144
- const statsByInstance = new Map<string, SyncStats>();
2145
- for (let i = 0; i < selectedInstances.length; i++) {
2146
- const result = results[i];
2147
- if (!result || !result.ok) continue;
2148
- const instance = selectedInstances[i];
2149
- if (!instance) continue;
2150
- statsByInstance.set(instance.instanceId, result.data);
2151
- }
2152
-
2153
- const statsValues = Array.from(statsByInstance.values());
2154
- const sum = (selector: (stats: SyncStats) => number): number =>
2155
- statsValues.reduce((acc, stats) => acc + selector(stats), 0);
2156
-
2157
- const minCommitSeqByInstance: Record<string, number> = {};
2158
- const maxCommitSeqByInstance: Record<string, number> = {};
2159
- for (const [instanceId, stats] of statsByInstance.entries()) {
2160
- minCommitSeqByInstance[instanceId] = stats.minCommitSeq;
2161
- maxCommitSeqByInstance[instanceId] = stats.maxCommitSeq;
2162
- }
2163
-
2164
- return c.json({
2165
- commitCount: sum((stats) => stats.commitCount),
2166
- changeCount: sum((stats) => stats.changeCount),
2167
- minCommitSeq: Math.min(
2168
- ...statsValues.map((stats) => stats.minCommitSeq)
2169
- ),
2170
- maxCommitSeq: Math.max(
2171
- ...statsValues.map((stats) => stats.maxCommitSeq)
2172
- ),
2173
- clientCount: sum((stats) => stats.clientCount),
2174
- activeClientCount: sum((stats) => stats.activeClientCount),
2175
- minActiveClientCursor: minNullable(
2176
- statsValues.map((stats) => stats.minActiveClientCursor)
2177
- ),
2178
- maxActiveClientCursor: maxNullable(
2179
- statsValues.map((stats) => stats.maxActiveClientCursor)
2180
- ),
2181
- minCommitSeqByInstance,
2182
- maxCommitSeqByInstance,
2183
- partial: failedInstances.length > 0,
2184
- failedInstances,
2185
- });
2186
- }
2187
- );
2188
-
2189
- routes.get(
2190
- '/stats/timeseries',
2191
- describeRoute({
2192
- tags: ['console-gateway'],
2193
- summary: 'Get merged time-series stats across instances',
2194
- responses: {
2195
- 200: {
2196
- description: 'Merged time-series stats',
2197
- content: {
2198
- 'application/json': {
2199
- schema: resolver(GatewayTimeseriesResponseSchema),
2200
- },
2201
- },
2202
- },
2203
- },
2204
- }),
2205
- zValidator('query', GatewayTimeseriesQuerySchema),
2206
- async (c) => {
2207
- const auth = await options.authenticate(c);
2208
- if (!auth) {
2209
- return unauthorizedResponse(c);
2210
- }
2211
-
2212
- const query = c.req.valid('query');
2213
- const selectedInstances = selectInstances({ instances, query });
2214
- if (selectedInstances.length === 0) {
2215
- return c.json(
2216
- {
2217
- error: 'NO_INSTANCES_SELECTED',
2218
- message:
2219
- 'No enabled instances matched the provided instance filter.',
2220
- },
2221
- 400
2222
- );
2223
- }
2224
-
2225
- const forwardQuery = sanitizeForwardQueryParams(
2226
- new URL(c.req.url).searchParams
2227
- );
2228
-
2229
- const results = await Promise.all(
2230
- selectedInstances.map((instance) =>
2231
- fetchDownstreamJson({
2232
- c,
2233
- instance,
2234
- path: '/stats/timeseries',
2235
- query: forwardQuery,
2236
- schema: TimeseriesStatsResponseSchema,
2237
- fetchImpl,
2238
- })
2239
- )
2240
- );
2241
-
2242
- const failedInstances = results
2243
- .filter(
2244
- (result): result is { ok: false; failure: GatewayFailure } =>
2245
- !result.ok
2246
- )
2247
- .map((result) => result.failure);
2248
- const successfulResults = results.filter(
2249
- (result): result is { ok: true; data: TimeseriesStatsResponse } =>
2250
- result.ok
2251
- );
2252
-
2253
- if (successfulResults.length === 0) {
2254
- return allInstancesFailedResponse(c, failedInstances);
2255
- }
2256
-
2257
- return c.json({
2258
- buckets: mergeTimeseriesBuckets(
2259
- successfulResults.map((result) => result.data)
2260
- ),
2261
- interval: query.interval,
2262
- range: query.range,
2263
- partial: failedInstances.length > 0,
2264
- failedInstances,
2265
- });
2266
- }
2267
- );
2268
-
2269
- routes.get(
2270
- '/stats/latency',
2271
- describeRoute({
2272
- tags: ['console-gateway'],
2273
- summary: 'Get merged latency stats across instances',
2274
- responses: {
2275
- 200: {
2276
- description: 'Merged latency stats',
2277
- content: {
2278
- 'application/json': {
2279
- schema: resolver(GatewayLatencyResponseSchema),
2280
- },
2281
- },
2282
- },
2283
- },
2284
- }),
2285
- zValidator('query', GatewayLatencyQuerySchema),
2286
- async (c) => {
2287
- const auth = await options.authenticate(c);
2288
- if (!auth) {
2289
- return unauthorizedResponse(c);
2290
- }
2291
-
2292
- const query = c.req.valid('query');
2293
- const selectedInstances = selectInstances({ instances, query });
2294
- if (selectedInstances.length === 0) {
2295
- return c.json(
2296
- {
2297
- error: 'NO_INSTANCES_SELECTED',
2298
- message:
2299
- 'No enabled instances matched the provided instance filter.',
2300
- },
2301
- 400
2302
- );
2303
- }
2304
-
2305
- const forwardQuery = sanitizeForwardQueryParams(
2306
- new URL(c.req.url).searchParams
2307
- );
2308
-
2309
- const results = await Promise.all(
2310
- selectedInstances.map((instance) =>
2311
- fetchDownstreamJson({
2312
- c,
2313
- instance,
2314
- path: '/stats/latency',
2315
- query: forwardQuery,
2316
- schema: LatencyStatsResponseSchema,
2317
- fetchImpl,
2318
- })
2319
- )
2320
- );
2321
-
2322
- const failedInstances = results
2323
- .filter(
2324
- (result): result is { ok: false; failure: GatewayFailure } =>
2325
- !result.ok
2326
- )
2327
- .map((result) => result.failure);
2328
- const successfulResults = results.filter(
2329
- (result): result is { ok: true; data: LatencyStatsResponse } =>
2330
- result.ok
2331
- );
2332
-
2333
- if (successfulResults.length === 0) {
2334
- return allInstancesFailedResponse(c, failedInstances);
2335
- }
2336
-
2337
- return c.json({
2338
- push: averagePercentiles(
2339
- successfulResults.map((result) => result.data.push)
2340
- ),
2341
- pull: averagePercentiles(
2342
- successfulResults.map((result) => result.data.pull)
2343
- ),
2344
- range: query.range,
2345
- partial: failedInstances.length > 0,
2346
- failedInstances,
2347
- });
2348
- }
2349
- );
2350
-
2351
- routes.get(
2352
- '/commits',
2353
- describeRoute({
2354
- tags: ['console-gateway'],
2355
- summary: 'List merged commits across instances',
2356
- responses: {
2357
- 200: {
2358
- description: 'Merged commits',
2359
- content: {
2360
- 'application/json': {
2361
- schema: resolver(
2362
- GatewayPaginatedResponseSchema(GatewayCommitItemSchema)
2363
- ),
2364
- },
2365
- },
2366
- },
2367
- },
2368
- }),
2369
- zValidator('query', GatewayPaginatedQuerySchema),
2370
- async (c) => {
2371
- const auth = await options.authenticate(c);
2372
- if (!auth) {
2373
- return unauthorizedResponse(c);
2374
- }
2375
-
2376
- const query = c.req.valid('query');
2377
- const selectedInstances = selectInstances({ instances, query });
2378
- if (selectedInstances.length === 0) {
2379
- return c.json(
2380
- {
2381
- error: 'NO_INSTANCES_SELECTED',
2382
- message:
2383
- 'No enabled instances matched the provided instance filter.',
2384
- },
2385
- 400
2386
- );
2387
- }
2388
-
2389
- const targetCount = query.offset + query.limit;
2390
- const forwardQuery = sanitizeForwardQueryParams(
2391
- new URL(c.req.url).searchParams
2392
- );
2393
- forwardQuery.delete('limit');
2394
- forwardQuery.delete('offset');
2395
- const pageSchema = ConsolePaginatedResponseSchema(
2396
- ConsoleCommitListItemSchema
2397
- );
2398
-
2399
- const results = await Promise.all(
2400
- selectedInstances.map((instance) =>
2401
- fetchDownstreamPaged({
2402
- c,
2403
- instance,
2404
- path: '/commits',
2405
- query: forwardQuery,
2406
- targetCount,
2407
- schema: pageSchema,
2408
- fetchImpl,
2409
- })
2410
- )
2411
- );
2412
-
2413
- const failedInstances = results
2414
- .filter(
2415
- (result): result is { ok: false; failure: GatewayFailure } =>
2416
- !result.ok
2417
- )
2418
- .map((result) => result.failure);
2419
- const successful = results
2420
- .map((result, index) => ({
2421
- result,
2422
- instance: selectedInstances[index],
2423
- }))
2424
- .filter(
2425
- (
2426
- entry
2427
- ): entry is {
2428
- result: { ok: true; items: ConsoleCommitListItem[]; total: number };
2429
- instance: ConsoleGatewayInstance;
2430
- } => Boolean(entry.instance) && entry.result.ok
2431
- );
2432
-
2433
- if (successful.length === 0) {
2434
- return allInstancesFailedResponse(c, failedInstances);
2435
- }
2436
-
2437
- const merged = successful
2438
- .flatMap(({ result, instance }) =>
2439
- result.items.map((commit) => ({
2440
- ...commit,
2441
- instanceId: instance.instanceId,
2442
- federatedCommitId: `${instance.instanceId}:${commit.commitSeq}`,
2443
- }))
2444
- )
2445
- .sort((a, b) => {
2446
- const byTime = compareIsoDesc(a.createdAt, b.createdAt);
2447
- if (byTime !== 0) return byTime;
2448
- const byInstance = a.instanceId.localeCompare(b.instanceId);
2449
- if (byInstance !== 0) return byInstance;
2450
- return b.commitSeq - a.commitSeq;
2451
- });
2452
-
2453
- return c.json({
2454
- items: merged.slice(query.offset, query.offset + query.limit),
2455
- total: successful.reduce((acc, entry) => acc + entry.result.total, 0),
2456
- offset: query.offset,
2457
- limit: query.limit,
2458
- partial: failedInstances.length > 0,
2459
- failedInstances,
2460
- });
2461
- }
2462
- );
2463
-
2464
- routes.get(
2465
- '/commits/:seq',
2466
- describeRoute({
2467
- tags: ['console-gateway'],
2468
- summary: 'Get merged commit detail by federated id',
2469
- responses: {
2470
- 200: {
2471
- description: 'Commit detail',
2472
- content: {
2473
- 'application/json': {
2474
- schema: resolver(GatewayCommitDetailSchema),
2475
- },
2476
- },
2477
- },
2478
- },
2479
- }),
2480
- zValidator('param', GatewayCommitPathParamSchema),
2481
- zValidator(
2482
- 'query',
2483
- ConsolePartitionQuerySchema.extend(GatewayInstanceFilterSchema.shape)
2484
- ),
2485
- async (c) => {
2486
- const auth = await options.authenticate(c);
2487
- if (!auth) {
2488
- return unauthorizedResponse(c);
2489
- }
2490
-
2491
- const { seq } = c.req.valid('param');
2492
- const query = c.req.valid('query');
2493
- const target = resolveCommitTarget({ seq, instances, query });
2494
- if (!target.ok) {
2495
- return c.json(
2496
- {
2497
- error: target.error,
2498
- ...(target.message ? { message: target.message } : {}),
2499
- },
2500
- target.status
2501
- );
2502
- }
2503
-
2504
- const forwardQuery = sanitizeForwardQueryParams(
2505
- new URL(c.req.url).searchParams
2506
- );
2507
- const result = await fetchDownstreamJson({
2508
- c,
2509
- instance: target.instance,
2510
- path: `/commits/${target.localCommitSeq}`,
2511
- query: forwardQuery,
2512
- schema: ConsoleCommitDetailSchema,
2513
- fetchImpl,
2514
- });
2515
-
2516
- if (!result.ok) {
2517
- if (result.failure.status === 404) {
2518
- return c.json({ error: 'NOT_FOUND' }, 404);
2519
- }
2520
- return c.json(
2521
- {
2522
- error: 'DOWNSTREAM_UNAVAILABLE',
2523
- failedInstances: [result.failure],
2524
- },
2525
- 502
2526
- );
2527
- }
2528
-
2529
- return c.json({
2530
- ...result.data,
2531
- instanceId: target.instance.instanceId,
2532
- federatedCommitId: `${target.instance.instanceId}:${result.data.commitSeq}`,
2533
- localCommitSeq: result.data.commitSeq,
2534
- });
2535
- }
2536
- );
2537
-
2538
- routes.get(
2539
- '/clients',
2540
- describeRoute({
2541
- tags: ['console-gateway'],
2542
- summary: 'List merged clients across instances',
2543
- responses: {
2544
- 200: {
2545
- description: 'Merged clients',
2546
- content: {
2547
- 'application/json': {
2548
- schema: resolver(
2549
- GatewayPaginatedResponseSchema(GatewayClientItemSchema)
2550
- ),
2551
- },
2552
- },
2553
- },
2554
- },
2555
- }),
2556
- zValidator('query', GatewayPaginatedQuerySchema),
2557
- async (c) => {
2558
- const auth = await options.authenticate(c);
2559
- if (!auth) {
2560
- return unauthorizedResponse(c);
2561
- }
2562
-
2563
- const query = c.req.valid('query');
2564
- const selectedInstances = selectInstances({ instances, query });
2565
- if (selectedInstances.length === 0) {
2566
- return c.json(
2567
- {
2568
- error: 'NO_INSTANCES_SELECTED',
2569
- message:
2570
- 'No enabled instances matched the provided instance filter.',
2571
- },
2572
- 400
2573
- );
2574
- }
2575
-
2576
- const targetCount = query.offset + query.limit;
2577
- const forwardQuery = sanitizeForwardQueryParams(
2578
- new URL(c.req.url).searchParams
2579
- );
2580
- forwardQuery.delete('limit');
2581
- forwardQuery.delete('offset');
2582
- const pageSchema = ConsolePaginatedResponseSchema(ConsoleClientSchema);
2583
-
2584
- const results = await Promise.all(
2585
- selectedInstances.map((instance) =>
2586
- fetchDownstreamPaged({
2587
- c,
2588
- instance,
2589
- path: '/clients',
2590
- query: forwardQuery,
2591
- targetCount,
2592
- schema: pageSchema,
2593
- fetchImpl,
2594
- })
2595
- )
2596
- );
2597
-
2598
- const failedInstances = results
2599
- .filter(
2600
- (result): result is { ok: false; failure: GatewayFailure } =>
2601
- !result.ok
2602
- )
2603
- .map((result) => result.failure);
2604
- const successful = results
2605
- .map((result, index) => ({
2606
- result,
2607
- instance: selectedInstances[index],
2608
- }))
2609
- .filter(
2610
- (
2611
- entry
2612
- ): entry is {
2613
- result: { ok: true; items: ConsoleClient[]; total: number };
2614
- instance: ConsoleGatewayInstance;
2615
- } => Boolean(entry.instance) && entry.result.ok
2616
- );
2617
-
2618
- if (successful.length === 0) {
2619
- return allInstancesFailedResponse(c, failedInstances);
2620
- }
2621
-
2622
- const merged = successful
2623
- .flatMap(({ result, instance }) =>
2624
- result.items.map((client) => ({
2625
- ...client,
2626
- instanceId: instance.instanceId,
2627
- federatedClientId: `${instance.instanceId}:${client.clientId}`,
2628
- }))
2629
- )
2630
- .sort((a, b) => {
2631
- const byTime = compareIsoDesc(a.updatedAt, b.updatedAt);
2632
- if (byTime !== 0) return byTime;
2633
- const byInstance = a.instanceId.localeCompare(b.instanceId);
2634
- if (byInstance !== 0) return byInstance;
2635
- return a.clientId.localeCompare(b.clientId);
2636
- });
2637
-
2638
- return c.json({
2639
- items: merged.slice(query.offset, query.offset + query.limit),
2640
- total: successful.reduce((acc, entry) => acc + entry.result.total, 0),
2641
- offset: query.offset,
2642
- limit: query.limit,
2643
- partial: failedInstances.length > 0,
2644
- failedInstances,
2645
- });
2646
- }
2647
- );
2648
-
2649
- routes.get(
2650
- '/timeline',
2651
- describeRoute({
2652
- tags: ['console-gateway'],
2653
- summary: 'List merged timeline items across instances',
2654
- responses: {
2655
- 200: {
2656
- description: 'Merged timeline',
2657
- content: {
2658
- 'application/json': {
2659
- schema: resolver(
2660
- GatewayPaginatedResponseSchema(GatewayTimelineItemSchema)
2661
- ),
2662
- },
2663
- },
2664
- },
2665
- },
2666
- }),
2667
- zValidator('query', GatewayTimelineQuerySchema),
2668
- async (c) => {
2669
- const auth = await options.authenticate(c);
2670
- if (!auth) {
2671
- return unauthorizedResponse(c);
2672
- }
2673
-
2674
- const query = c.req.valid('query');
2675
- const selectedInstances = selectInstances({ instances, query });
2676
- if (selectedInstances.length === 0) {
2677
- return c.json(
2678
- {
2679
- error: 'NO_INSTANCES_SELECTED',
2680
- message:
2681
- 'No enabled instances matched the provided instance filter.',
2682
- },
2683
- 400
2684
- );
2685
- }
2686
-
2687
- const targetCount = query.offset + query.limit;
2688
- const forwardQuery = sanitizeForwardQueryParams(
2689
- new URL(c.req.url).searchParams
2690
- );
2691
- forwardQuery.delete('limit');
2692
- forwardQuery.delete('offset');
2693
- const pageSchema = ConsolePaginatedResponseSchema(
2694
- ConsoleTimelineItemSchema
2695
- );
2696
-
2697
- const results = await Promise.all(
2698
- selectedInstances.map((instance) =>
2699
- fetchDownstreamPaged({
2700
- c,
2701
- instance,
2702
- path: '/timeline',
2703
- query: forwardQuery,
2704
- targetCount,
2705
- schema: pageSchema,
2706
- fetchImpl,
2707
- })
2708
- )
2709
- );
2710
-
2711
- const failedInstances = results
2712
- .filter(
2713
- (result): result is { ok: false; failure: GatewayFailure } =>
2714
- !result.ok
2715
- )
2716
- .map((result) => result.failure);
2717
- const successful = results
2718
- .map((result, index) => ({
2719
- result,
2720
- instance: selectedInstances[index],
2721
- }))
2722
- .filter(
2723
- (
2724
- entry
2725
- ): entry is {
2726
- result: { ok: true; items: ConsoleTimelineItem[]; total: number };
2727
- instance: ConsoleGatewayInstance;
2728
- } => Boolean(entry.instance) && entry.result.ok
2729
- );
2730
-
2731
- if (successful.length === 0) {
2732
- return allInstancesFailedResponse(c, failedInstances);
2733
- }
2734
-
2735
- const merged = successful
2736
- .flatMap(({ result, instance }) =>
2737
- result.items.map((item) => {
2738
- const localCommitSeq =
2739
- item.type === 'commit' ? (item.commit?.commitSeq ?? null) : null;
2740
- const localEventId =
2741
- item.type === 'event' ? (item.event?.eventId ?? null) : null;
2742
- const localIdSegment =
2743
- item.type === 'commit'
2744
- ? String(localCommitSeq ?? 'unknown')
2745
- : String(localEventId ?? 'unknown');
2746
-
2747
- return {
2748
- ...item,
2749
- instanceId: instance.instanceId,
2750
- federatedTimelineId: `${instance.instanceId}:${item.type}:${localIdSegment}`,
2751
- localCommitSeq,
2752
- localEventId,
2753
- };
2754
- })
2755
- )
2756
- .sort((a, b) => {
2757
- const byTime = compareIsoDesc(a.timestamp, b.timestamp);
2758
- if (byTime !== 0) return byTime;
2759
- const byInstance = a.instanceId.localeCompare(b.instanceId);
2760
- if (byInstance !== 0) return byInstance;
2761
- const aLocalId = a.localCommitSeq ?? a.localEventId ?? 0;
2762
- const bLocalId = b.localCommitSeq ?? b.localEventId ?? 0;
2763
- return bLocalId - aLocalId;
2764
- });
2765
-
2766
- return c.json({
2767
- items: merged.slice(query.offset, query.offset + query.limit),
2768
- total: successful.reduce((acc, entry) => acc + entry.result.total, 0),
2769
- offset: query.offset,
2770
- limit: query.limit,
2771
- partial: failedInstances.length > 0,
2772
- failedInstances,
2773
- });
2774
- }
2775
- );
2776
-
2777
- routes.get(
2778
- '/operations',
2779
- describeRoute({
2780
- tags: ['console-gateway'],
2781
- summary: 'List merged operation events across instances',
2782
- responses: {
2783
- 200: {
2784
- description: 'Merged operations',
2785
- content: {
2786
- 'application/json': {
2787
- schema: resolver(
2788
- GatewayPaginatedResponseSchema(GatewayOperationItemSchema)
2789
- ),
2790
- },
2791
- },
2792
- },
2793
- },
2794
- }),
2795
- zValidator('query', GatewayOperationsQuerySchema),
2796
- async (c) => {
2797
- const auth = await options.authenticate(c);
2798
- if (!auth) {
2799
- return unauthorizedResponse(c);
2800
- }
2801
-
2802
- const query = c.req.valid('query');
2803
- const selectedInstances = selectInstances({ instances, query });
2804
- if (selectedInstances.length === 0) {
2805
- return c.json(
2806
- {
2807
- error: 'NO_INSTANCES_SELECTED',
2808
- message:
2809
- 'No enabled instances matched the provided instance filter.',
2810
- },
2811
- 400
2812
- );
2813
- }
2814
-
2815
- const targetCount = query.offset + query.limit;
2816
- const forwardQuery = sanitizeForwardQueryParams(
2817
- new URL(c.req.url).searchParams
2818
- );
2819
- forwardQuery.delete('limit');
2820
- forwardQuery.delete('offset');
2821
- const pageSchema = ConsolePaginatedResponseSchema(
2822
- ConsoleOperationEventSchema
2823
- );
2824
-
2825
- const results = await Promise.all(
2826
- selectedInstances.map((instance) =>
2827
- fetchDownstreamPaged({
2828
- c,
2829
- instance,
2830
- path: '/operations',
2831
- query: forwardQuery,
2832
- targetCount,
2833
- schema: pageSchema,
2834
- fetchImpl,
2835
- })
2836
- )
2837
- );
2838
-
2839
- const failedInstances = results
2840
- .filter(
2841
- (result): result is { ok: false; failure: GatewayFailure } =>
2842
- !result.ok
2843
- )
2844
- .map((result) => result.failure);
2845
- const successful = results
2846
- .map((result, index) => ({
2847
- result,
2848
- instance: selectedInstances[index],
2849
- }))
2850
- .filter(
2851
- (
2852
- entry
2853
- ): entry is {
2854
- result: { ok: true; items: ConsoleOperationEvent[]; total: number };
2855
- instance: ConsoleGatewayInstance;
2856
- } => Boolean(entry.instance) && entry.result.ok
2857
- );
2858
-
2859
- if (successful.length === 0) {
2860
- return allInstancesFailedResponse(c, failedInstances);
2861
- }
2862
-
2863
- const merged = successful
2864
- .flatMap(({ result, instance }) =>
2865
- result.items.map((operation) => ({
2866
- ...operation,
2867
- instanceId: instance.instanceId,
2868
- federatedOperationId: `${instance.instanceId}:${operation.operationId}`,
2869
- localOperationId: operation.operationId,
2870
- }))
2871
- )
2872
- .sort((a, b) => {
2873
- const byTime = compareIsoDesc(a.createdAt, b.createdAt);
2874
- if (byTime !== 0) return byTime;
2875
- const byInstance = a.instanceId.localeCompare(b.instanceId);
2876
- if (byInstance !== 0) return byInstance;
2877
- return b.localOperationId - a.localOperationId;
2878
- });
2879
-
2880
- return c.json({
2881
- items: merged.slice(query.offset, query.offset + query.limit),
2882
- total: successful.reduce((acc, entry) => acc + entry.result.total, 0),
2883
- offset: query.offset,
2884
- limit: query.limit,
2885
- partial: failedInstances.length > 0,
2886
- failedInstances,
2887
- });
2888
- }
2889
- );
2890
-
2891
- routes.get(
2892
- '/events',
2893
- describeRoute({
2894
- tags: ['console-gateway'],
2895
- summary: 'List merged request events across instances',
2896
- responses: {
2897
- 200: {
2898
- description: 'Merged events',
2899
- content: {
2900
- 'application/json': {
2901
- schema: resolver(
2902
- GatewayPaginatedResponseSchema(GatewayEventItemSchema)
2903
- ),
2904
- },
2905
- },
2906
- },
2907
- },
2908
- }),
2909
- zValidator('query', GatewayEventsQuerySchema),
2910
- async (c) => {
2911
- const auth = await options.authenticate(c);
2912
- if (!auth) {
2913
- return unauthorizedResponse(c);
2914
- }
2915
-
2916
- const query = c.req.valid('query');
2917
- const selectedInstances = selectInstances({ instances, query });
2918
- if (selectedInstances.length === 0) {
2919
- return c.json(
2920
- {
2921
- error: 'NO_INSTANCES_SELECTED',
2922
- message:
2923
- 'No enabled instances matched the provided instance filter.',
2924
- },
2925
- 400
2926
- );
2927
- }
2928
-
2929
- const targetCount = query.offset + query.limit;
2930
- const forwardQuery = sanitizeForwardQueryParams(
2931
- new URL(c.req.url).searchParams
2932
- );
2933
- forwardQuery.delete('limit');
2934
- forwardQuery.delete('offset');
2935
- const pageSchema = ConsolePaginatedResponseSchema(
2936
- ConsoleRequestEventSchema
2937
- );
2938
-
2939
- const results = await Promise.all(
2940
- selectedInstances.map((instance) =>
2941
- fetchDownstreamPaged({
2942
- c,
2943
- instance,
2944
- path: '/events',
2945
- query: forwardQuery,
2946
- targetCount,
2947
- schema: pageSchema,
2948
- fetchImpl,
2949
- })
2950
- )
2951
- );
2952
-
2953
- const failedInstances = results
2954
- .filter(
2955
- (result): result is { ok: false; failure: GatewayFailure } =>
2956
- !result.ok
2957
- )
2958
- .map((result) => result.failure);
2959
- const successful = results
2960
- .map((result, index) => ({
2961
- result,
2962
- instance: selectedInstances[index],
2963
- }))
2964
- .filter(
2965
- (
2966
- entry
2967
- ): entry is {
2968
- result: { ok: true; items: ConsoleRequestEvent[]; total: number };
2969
- instance: ConsoleGatewayInstance;
2970
- } => Boolean(entry.instance) && entry.result.ok
2971
- );
2972
-
2973
- if (successful.length === 0) {
2974
- return allInstancesFailedResponse(c, failedInstances);
2975
- }
2976
-
2977
- const merged = successful
2978
- .flatMap(({ result, instance }) =>
2979
- result.items.map((event) => ({
2980
- ...event,
2981
- instanceId: instance.instanceId,
2982
- federatedEventId: `${instance.instanceId}:${event.eventId}`,
2983
- localEventId: event.eventId,
2984
- }))
2985
- )
2986
- .sort((a, b) => {
2987
- const byTime = compareIsoDesc(a.createdAt, b.createdAt);
2988
- if (byTime !== 0) return byTime;
2989
- const byInstance = a.instanceId.localeCompare(b.instanceId);
2990
- if (byInstance !== 0) return byInstance;
2991
- return b.localEventId - a.localEventId;
2992
- });
2993
-
2994
- return c.json({
2995
- items: merged.slice(query.offset, query.offset + query.limit),
2996
- total: successful.reduce((acc, entry) => acc + entry.result.total, 0),
2997
- offset: query.offset,
2998
- limit: query.limit,
2999
- partial: failedInstances.length > 0,
3000
- failedInstances,
3001
- });
3002
- }
3003
- );
3004
-
3005
- if (
3006
- options.websocket?.enabled &&
3007
- options.websocket?.upgradeWebSocket !== undefined
3008
- ) {
3009
- const upgradeWebSocket = options.websocket.upgradeWebSocket;
3010
- const heartbeatIntervalMs = options.websocket.heartbeatIntervalMs ?? 30000;
3011
- const createDownstreamSocket =
3012
- options.websocket.createWebSocket ??
3013
- ((url: string): ConsoleGatewayDownstreamSocket => new WebSocket(url));
3014
-
3015
- type WebSocketLike = {
3016
- send: (data: string) => void;
3017
- close: (code?: number, reason?: string) => void;
3018
- };
3019
-
3020
- const liveState = new WeakMap<
3021
- WebSocketLike,
3022
- {
3023
- downstreamSockets: ConsoleGatewayDownstreamSocket[];
3024
- heartbeatInterval: ReturnType<typeof setInterval> | null;
3025
- authTimeout: ReturnType<typeof setTimeout> | null;
3026
- isAuthenticated: boolean;
3027
- }
3028
- >();
3029
-
3030
- routes.get(
3031
- '/events/live',
3032
- upgradeWebSocket(async (c) => {
3033
- const initialAuth = await options.authenticate(c);
3034
- const partitionId = c.req.query('partitionId')?.trim() || undefined;
3035
- const replaySince = c.req.query('since')?.trim() || undefined;
3036
- const replayLimitRaw = c.req.query('replayLimit');
3037
- const replayLimitNumber = replayLimitRaw
3038
- ? Number.parseInt(replayLimitRaw, 10)
3039
- : Number.NaN;
3040
- const replayLimit = Number.isFinite(replayLimitNumber)
3041
- ? Math.max(1, Math.min(500, replayLimitNumber))
3042
- : 100;
3043
-
3044
- const selectedInstances = selectInstances({
3045
- instances,
3046
- query: {
3047
- instanceId: c.req.query('instanceId') ?? undefined,
3048
- instanceIds: c.req.query('instanceIds') ?? undefined,
3049
- },
3050
- });
3051
-
3052
- const authenticateWithBearer = async (
3053
- token: string
3054
- ): Promise<ConsoleAuthResult | null> => {
3055
- const trimmedToken = token.trim();
3056
- if (!trimmedToken) {
3057
- return null;
3058
- }
3059
- const authContext = {
3060
- req: {
3061
- header: (name: string) =>
3062
- name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
3063
- query: () => undefined,
3064
- },
3065
- } as unknown as Context;
3066
- return options.authenticate(authContext);
3067
- };
3068
-
3069
- const closeUnauthenticated = (ws: WebSocketLike) => {
3070
- try {
3071
- ws.send(
3072
- JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
3073
- );
3074
- } catch {
3075
- // no-op
3076
- }
3077
- ws.close(4001, 'Unauthenticated');
3078
- };
3079
-
3080
- const cleanup = (ws: WebSocketLike) => {
3081
- const state = liveState.get(ws);
3082
- if (!state) return;
3083
- if (state.heartbeatInterval) {
3084
- clearInterval(state.heartbeatInterval);
3085
- }
3086
- if (state.authTimeout) {
3087
- clearTimeout(state.authTimeout);
3088
- }
3089
- for (const downstream of state.downstreamSockets) {
3090
- try {
3091
- downstream.close();
3092
- } catch {
3093
- // no-op
3094
- }
3095
- }
3096
- liveState.delete(ws);
3097
- };
3098
-
3099
- return {
3100
- onOpen(_event, ws) {
3101
- if (selectedInstances.length === 0) {
3102
- ws.send(
3103
- JSON.stringify({
3104
- type: 'error',
3105
- message:
3106
- 'No enabled instances matched the provided instance filter.',
3107
- })
3108
- );
3109
- ws.close(4004, 'No instances selected');
3110
- return;
3111
- }
3112
-
3113
- const state: {
3114
- downstreamSockets: ConsoleGatewayDownstreamSocket[];
3115
- heartbeatInterval: ReturnType<typeof setInterval> | null;
3116
- authTimeout: ReturnType<typeof setTimeout> | null;
3117
- isAuthenticated: boolean;
3118
- } = {
3119
- downstreamSockets: [],
3120
- heartbeatInterval: null,
3121
- authTimeout: null,
3122
- isAuthenticated: false,
3123
- };
3124
- liveState.set(ws, state);
3125
-
3126
- const startAuthenticatedSession = (
3127
- upstreamBearerToken: string | null
3128
- ) => {
3129
- if (state.isAuthenticated) {
3130
- return;
3131
- }
3132
- state.isAuthenticated = true;
3133
- if (state.authTimeout) {
3134
- clearTimeout(state.authTimeout);
3135
- state.authTimeout = null;
3136
- }
3137
-
3138
- for (const instance of selectedInstances) {
3139
- const downstreamQuery = new URLSearchParams();
3140
- if (partitionId) {
3141
- downstreamQuery.set('partitionId', partitionId);
3142
- }
3143
- if (replaySince) {
3144
- downstreamQuery.set('since', replaySince);
3145
- }
3146
- downstreamQuery.set('replayLimit', String(replayLimit));
3147
-
3148
- const downstreamUrl = buildConsoleEndpointUrl({
3149
- instance,
3150
- requestUrl: c.req.url,
3151
- path: '/events/live',
3152
- query: downstreamQuery,
3153
- });
3154
-
3155
- const downstreamSocket = createDownstreamSocket(downstreamUrl);
3156
- const downstreamToken =
3157
- instance.token?.trim() ?? upstreamBearerToken?.trim() ?? null;
3158
- if (downstreamToken && downstreamSocket.send) {
3159
- downstreamSocket.onopen = () => {
3160
- try {
3161
- downstreamSocket.send?.(
3162
- JSON.stringify({
3163
- type: 'auth',
3164
- token: downstreamToken,
3165
- })
3166
- );
3167
- } catch {
3168
- // no-op
3169
- }
3170
- };
3171
- }
3172
-
3173
- downstreamSocket.onmessage = (message: MessageEvent) => {
3174
- if (typeof message.data !== 'string') {
3175
- return;
3176
- }
3177
- try {
3178
- const payload = JSON.parse(message.data) as Record<
3179
- string,
3180
- unknown
3181
- >;
3182
- if (
3183
- typeof payload.type === 'string' &&
3184
- (payload.type === 'connected' ||
3185
- payload.type === 'heartbeat')
3186
- ) {
3187
- return;
3188
- }
3189
-
3190
- const payloadData =
3191
- payload.data &&
3192
- typeof payload.data === 'object' &&
3193
- !Array.isArray(payload.data)
3194
- ? { ...payload.data, instanceId: instance.instanceId }
3195
- : { instanceId: instance.instanceId };
3196
-
3197
- const event = {
3198
- ...payload,
3199
- data: payloadData,
3200
- instanceId: instance.instanceId,
3201
- timestamp:
3202
- typeof payload.timestamp === 'string'
3203
- ? payload.timestamp
3204
- : new Date().toISOString(),
3205
- };
3206
- ws.send(JSON.stringify(event));
3207
- } catch {
3208
- // Ignore malformed downstream events
3209
- }
3210
- };
3211
-
3212
- downstreamSocket.onerror = () => {
3213
- try {
3214
- ws.send(
3215
- JSON.stringify({
3216
- type: 'instance_error',
3217
- instanceId: instance.instanceId,
3218
- timestamp: new Date().toISOString(),
3219
- })
3220
- );
3221
- } catch {
3222
- // ignore send errors
3223
- }
3224
- };
3225
-
3226
- state.downstreamSockets.push(downstreamSocket);
3227
- }
3228
-
3229
- ws.send(
3230
- JSON.stringify({
3231
- type: 'connected',
3232
- timestamp: new Date().toISOString(),
3233
- instanceCount: selectedInstances.length,
3234
- })
3235
- );
3236
-
3237
- const heartbeatInterval = setInterval(() => {
3238
- try {
3239
- ws.send(
3240
- JSON.stringify({
3241
- type: 'heartbeat',
3242
- timestamp: new Date().toISOString(),
3243
- })
3244
- );
3245
- } catch {
3246
- clearInterval(heartbeatInterval);
3247
- }
3248
- }, heartbeatIntervalMs);
3249
- state.heartbeatInterval = heartbeatInterval;
3250
- };
3251
-
3252
- if (initialAuth) {
3253
- startAuthenticatedSession(
3254
- parseBearerToken(c.req.header('Authorization'))
3255
- );
3256
- return;
3257
- }
3258
-
3259
- state.authTimeout = setTimeout(() => {
3260
- const current = liveState.get(ws);
3261
- if (!current || current.isAuthenticated) {
3262
- return;
3263
- }
3264
- closeUnauthenticated(ws);
3265
- cleanup(ws);
3266
- }, 5_000);
3267
- },
3268
- async onMessage(event, ws) {
3269
- const state = liveState.get(ws);
3270
- if (!state || state.isAuthenticated) {
3271
- return;
3272
- }
3273
-
3274
- if (typeof event.data !== 'string') {
3275
- closeUnauthenticated(ws);
3276
- cleanup(ws);
3277
- return;
3278
- }
3279
-
3280
- let token = '';
3281
- try {
3282
- const parsed = JSON.parse(event.data) as {
3283
- type?: unknown;
3284
- token?: unknown;
3285
- };
3286
- if (
3287
- parsed.type === 'auth' &&
3288
- typeof parsed.token === 'string' &&
3289
- parsed.token.trim().length > 0
3290
- ) {
3291
- token = parsed.token;
3292
- }
3293
- } catch {
3294
- // Invalid auth message will be handled below.
3295
- }
3296
-
3297
- if (!token) {
3298
- closeUnauthenticated(ws);
3299
- cleanup(ws);
3300
- return;
3301
- }
3302
-
3303
- const auth = await authenticateWithBearer(token);
3304
- const current = liveState.get(ws);
3305
- if (!current || current.isAuthenticated) {
3306
- return;
3307
- }
3308
- if (!auth) {
3309
- closeUnauthenticated(ws);
3310
- cleanup(ws);
3311
- return;
3312
- }
3313
-
3314
- current.isAuthenticated = true;
3315
- if (current.authTimeout) {
3316
- clearTimeout(current.authTimeout);
3317
- current.authTimeout = null;
3318
- }
3319
-
3320
- for (const instance of selectedInstances) {
3321
- const downstreamQuery = new URLSearchParams();
3322
- if (partitionId) {
3323
- downstreamQuery.set('partitionId', partitionId);
3324
- }
3325
- if (replaySince) {
3326
- downstreamQuery.set('since', replaySince);
3327
- }
3328
- downstreamQuery.set('replayLimit', String(replayLimit));
3329
-
3330
- const downstreamUrl = buildConsoleEndpointUrl({
3331
- instance,
3332
- requestUrl: c.req.url,
3333
- path: '/events/live',
3334
- query: downstreamQuery,
3335
- });
3336
-
3337
- const downstreamSocket = createDownstreamSocket(downstreamUrl);
3338
- const upstreamToken = token.trim();
3339
- const downstreamToken =
3340
- instance.token?.trim() ||
3341
- (upstreamToken.length > 0 ? upstreamToken : null);
3342
- if (downstreamToken && downstreamSocket.send) {
3343
- downstreamSocket.onopen = () => {
3344
- try {
3345
- downstreamSocket.send?.(
3346
- JSON.stringify({
3347
- type: 'auth',
3348
- token: downstreamToken,
3349
- })
3350
- );
3351
- } catch {
3352
- // no-op
3353
- }
3354
- };
3355
- }
3356
-
3357
- downstreamSocket.onmessage = (message: MessageEvent) => {
3358
- if (typeof message.data !== 'string') {
3359
- return;
3360
- }
3361
- try {
3362
- const payload = JSON.parse(message.data) as Record<
3363
- string,
3364
- unknown
3365
- >;
3366
- if (
3367
- typeof payload.type === 'string' &&
3368
- (payload.type === 'connected' ||
3369
- payload.type === 'heartbeat')
3370
- ) {
3371
- return;
3372
- }
3373
-
3374
- const payloadData =
3375
- payload.data &&
3376
- typeof payload.data === 'object' &&
3377
- !Array.isArray(payload.data)
3378
- ? { ...payload.data, instanceId: instance.instanceId }
3379
- : { instanceId: instance.instanceId };
3380
-
3381
- const liveEvent = {
3382
- ...payload,
3383
- data: payloadData,
3384
- instanceId: instance.instanceId,
3385
- timestamp:
3386
- typeof payload.timestamp === 'string'
3387
- ? payload.timestamp
3388
- : new Date().toISOString(),
3389
- };
3390
- ws.send(JSON.stringify(liveEvent));
3391
- } catch {
3392
- // Ignore malformed downstream events
3393
- }
3394
- };
3395
-
3396
- downstreamSocket.onerror = () => {
3397
- try {
3398
- ws.send(
3399
- JSON.stringify({
3400
- type: 'instance_error',
3401
- instanceId: instance.instanceId,
3402
- timestamp: new Date().toISOString(),
3403
- })
3404
- );
3405
- } catch {
3406
- // ignore send errors
3407
- }
3408
- };
3409
-
3410
- current.downstreamSockets.push(downstreamSocket);
3411
- }
3412
-
3413
- ws.send(
3414
- JSON.stringify({
3415
- type: 'connected',
3416
- timestamp: new Date().toISOString(),
3417
- instanceCount: selectedInstances.length,
3418
- })
3419
- );
3420
-
3421
- const heartbeatInterval = setInterval(() => {
3422
- try {
3423
- ws.send(
3424
- JSON.stringify({
3425
- type: 'heartbeat',
3426
- timestamp: new Date().toISOString(),
3427
- })
3428
- );
3429
- } catch {
3430
- clearInterval(heartbeatInterval);
3431
- }
3432
- }, heartbeatIntervalMs);
3433
- current.heartbeatInterval = heartbeatInterval;
3434
- },
3435
- onClose(_event, ws) {
3436
- cleanup(ws);
3437
- },
3438
- onError(_event, ws) {
3439
- cleanup(ws);
3440
- },
3441
- };
3442
- })
3443
- );
3444
- }
3445
-
3446
- routes.get(
3447
- '/events/:id',
3448
- describeRoute({
3449
- tags: ['console-gateway'],
3450
- summary: 'Get merged event detail by federated id',
3451
- responses: {
3452
- 200: {
3453
- description: 'Event detail',
3454
- content: {
3455
- 'application/json': {
3456
- schema: resolver(GatewayEventItemSchema),
3457
- },
3458
- },
3459
- },
3460
- },
3461
- }),
3462
- zValidator('param', GatewayEventPathParamSchema),
3463
- zValidator(
3464
- 'query',
3465
- ConsolePartitionQuerySchema.extend(GatewayInstanceFilterSchema.shape)
3466
- ),
3467
- async (c) => {
3468
- const auth = await options.authenticate(c);
3469
- if (!auth) {
3470
- return unauthorizedResponse(c);
3471
- }
3472
-
3473
- const { id } = c.req.valid('param');
3474
- const query = c.req.valid('query');
3475
- const target = resolveEventTarget({
3476
- id,
3477
- instances,
3478
- query,
3479
- });
3480
- if (!target.ok) {
3481
- return c.json(
3482
- {
3483
- error: target.error,
3484
- ...(target.message ? { message: target.message } : {}),
3485
- },
3486
- target.status
3487
- );
3488
- }
3489
-
3490
- const forwardQuery = sanitizeForwardQueryParams(
3491
- new URL(c.req.url).searchParams
3492
- );
3493
- const result = await fetchDownstreamJson({
3494
- c,
3495
- instance: target.instance,
3496
- path: `/events/${target.localEventId}`,
3497
- query: forwardQuery,
3498
- schema: ConsoleRequestEventSchema,
3499
- fetchImpl,
3500
- });
3501
-
3502
- if (!result.ok) {
3503
- if (result.failure.status === 404) {
3504
- return c.json({ error: 'NOT_FOUND' }, 404);
3505
- }
3506
- return c.json(
3507
- {
3508
- error: 'DOWNSTREAM_UNAVAILABLE',
3509
- failedInstances: [result.failure],
3510
- },
3511
- 502
3512
- );
3513
- }
3514
-
3515
- return c.json({
3516
- ...result.data,
3517
- instanceId: target.instance.instanceId,
3518
- federatedEventId: `${target.instance.instanceId}:${result.data.eventId}`,
3519
- localEventId: result.data.eventId,
3520
- });
3521
- }
3522
- );
3523
-
3524
- routes.get(
3525
- '/events/:id/payload',
3526
- describeRoute({
3527
- tags: ['console-gateway'],
3528
- summary: 'Get merged event payload by federated id',
3529
- responses: {
3530
- 200: {
3531
- description: 'Event payload',
3532
- content: {
3533
- 'application/json': {
3534
- schema: resolver(GatewayEventPayloadSchema),
3535
- },
3536
- },
3537
- },
3538
- },
3539
- }),
3540
- zValidator('param', GatewayEventPathParamSchema),
3541
- zValidator(
3542
- 'query',
3543
- ConsolePartitionQuerySchema.extend(GatewayInstanceFilterSchema.shape)
3544
- ),
3545
- async (c) => {
3546
- const auth = await options.authenticate(c);
3547
- if (!auth) {
3548
- return unauthorizedResponse(c);
3549
- }
3550
-
3551
- const { id } = c.req.valid('param');
3552
- const query = c.req.valid('query');
3553
- const target = resolveEventTarget({
3554
- id,
3555
- instances,
3556
- query,
3557
- });
3558
- if (!target.ok) {
3559
- return c.json(
3560
- {
3561
- error: target.error,
3562
- ...(target.message ? { message: target.message } : {}),
3563
- },
3564
- target.status
3565
- );
3566
- }
3567
-
3568
- const forwardQuery = sanitizeForwardQueryParams(
3569
- new URL(c.req.url).searchParams
3570
- );
3571
- const result = await fetchDownstreamJson({
3572
- c,
3573
- instance: target.instance,
3574
- path: `/events/${target.localEventId}/payload`,
3575
- query: forwardQuery,
3576
- schema: ConsoleRequestPayloadSchema,
3577
- fetchImpl,
3578
- });
3579
-
3580
- if (!result.ok) {
3581
- if (result.failure.status === 404) {
3582
- return c.json({ error: 'NOT_FOUND' }, 404);
3583
- }
3584
- return c.json(
3585
- {
3586
- error: 'DOWNSTREAM_UNAVAILABLE',
3587
- failedInstances: [result.failure],
3588
- },
3589
- 502
3590
- );
3591
- }
3592
-
3593
- return c.json({
3594
- ...result.data,
3595
- instanceId: target.instance.instanceId,
3596
- federatedEventId: `${target.instance.instanceId}:${target.localEventId}`,
3597
- localEventId: target.localEventId,
3598
- });
3599
- }
3600
- );
3601
-
3602
- return routes;
3603
- }