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