@syncular/server-hono 0.0.2-2 → 0.0.3-12

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