@syncular/server-hono 0.0.6-85 → 0.0.6-86
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/console/gateway.d.ts +2 -0
- package/dist/console/gateway.d.ts.map +1 -1
- package/dist/console/gateway.js +218 -41
- package/dist/console/gateway.js.map +1 -1
- package/dist/console/routes.d.ts.map +1 -1
- package/dist/console/routes.js +165 -37
- package/dist/console/routes.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
- package/src/console/gateway.ts +276 -52
- package/src/console/routes.ts +193 -41
package/src/console/routes.ts
CHANGED
|
@@ -2217,16 +2217,40 @@ export function createConsoleRoutes<
|
|
|
2217
2217
|
const wsState = new WeakMap<
|
|
2218
2218
|
WebSocketLike,
|
|
2219
2219
|
{
|
|
2220
|
-
listener: ConsoleEventListener;
|
|
2221
|
-
heartbeatInterval: ReturnType<typeof setInterval
|
|
2220
|
+
listener: ConsoleEventListener | null;
|
|
2221
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
2222
|
+
authTimeout: ReturnType<typeof setTimeout> | null;
|
|
2223
|
+
isAuthenticated: boolean;
|
|
2222
2224
|
}
|
|
2223
2225
|
>();
|
|
2224
2226
|
|
|
2227
|
+
const closeUnauthenticated = (ws: WebSocketLike) => {
|
|
2228
|
+
try {
|
|
2229
|
+
ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
|
|
2230
|
+
} catch {
|
|
2231
|
+
// ignore send errors
|
|
2232
|
+
}
|
|
2233
|
+
ws.close(4001, 'Unauthenticated');
|
|
2234
|
+
};
|
|
2235
|
+
|
|
2236
|
+
const cleanup = (ws: WebSocketLike) => {
|
|
2237
|
+
const state = wsState.get(ws);
|
|
2238
|
+
if (!state) return;
|
|
2239
|
+
if (state.listener) {
|
|
2240
|
+
emitter.removeListener(state.listener);
|
|
2241
|
+
}
|
|
2242
|
+
if (state.heartbeatInterval) {
|
|
2243
|
+
clearInterval(state.heartbeatInterval);
|
|
2244
|
+
}
|
|
2245
|
+
if (state.authTimeout) {
|
|
2246
|
+
clearTimeout(state.authTimeout);
|
|
2247
|
+
}
|
|
2248
|
+
wsState.delete(ws);
|
|
2249
|
+
};
|
|
2250
|
+
|
|
2225
2251
|
routes.get(
|
|
2226
2252
|
'/events/live',
|
|
2227
2253
|
upgradeWebSocket(async (c) => {
|
|
2228
|
-
// Auth check via query param (WebSocket doesn't support headers easily)
|
|
2229
|
-
const token = c.req.query('token');
|
|
2230
2254
|
const authHeader = c.req.header('Authorization');
|
|
2231
2255
|
const partitionId = c.req.query('partitionId')?.trim() || undefined;
|
|
2232
2256
|
const replaySince = c.req.query('since');
|
|
@@ -2241,25 +2265,173 @@ export function createConsoleRoutes<
|
|
|
2241
2265
|
req: {
|
|
2242
2266
|
header: (name: string) =>
|
|
2243
2267
|
name === 'Authorization' ? authHeader : undefined,
|
|
2244
|
-
query: (
|
|
2268
|
+
query: () => undefined,
|
|
2245
2269
|
},
|
|
2246
|
-
} as Context;
|
|
2270
|
+
} as unknown as Context;
|
|
2247
2271
|
|
|
2248
|
-
const
|
|
2272
|
+
const initialAuth = await options.authenticate(mockContext);
|
|
2273
|
+
|
|
2274
|
+
const authenticateWithBearer = async (token: string) => {
|
|
2275
|
+
const trimmedToken = token.trim();
|
|
2276
|
+
if (!trimmedToken) return null;
|
|
2277
|
+
const authContext = {
|
|
2278
|
+
req: {
|
|
2279
|
+
header: (name: string) =>
|
|
2280
|
+
name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
|
|
2281
|
+
query: () => undefined,
|
|
2282
|
+
},
|
|
2283
|
+
} as unknown as Context;
|
|
2284
|
+
return options.authenticate(authContext);
|
|
2285
|
+
};
|
|
2249
2286
|
|
|
2250
2287
|
return {
|
|
2251
2288
|
onOpen(_event, ws) {
|
|
2252
|
-
|
|
2289
|
+
const state = {
|
|
2290
|
+
listener: null,
|
|
2291
|
+
heartbeatInterval: null,
|
|
2292
|
+
authTimeout: null,
|
|
2293
|
+
isAuthenticated: false,
|
|
2294
|
+
} as {
|
|
2295
|
+
listener: ConsoleEventListener | null;
|
|
2296
|
+
heartbeatInterval: ReturnType<typeof setInterval> | null;
|
|
2297
|
+
authTimeout: ReturnType<typeof setTimeout> | null;
|
|
2298
|
+
isAuthenticated: boolean;
|
|
2299
|
+
};
|
|
2300
|
+
wsState.set(ws, state);
|
|
2301
|
+
|
|
2302
|
+
const startAuthenticatedSession = () => {
|
|
2303
|
+
if (state.isAuthenticated) return;
|
|
2304
|
+
state.isAuthenticated = true;
|
|
2305
|
+
if (state.authTimeout) {
|
|
2306
|
+
clearTimeout(state.authTimeout);
|
|
2307
|
+
state.authTimeout = null;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
const listener: ConsoleEventListener = (event) => {
|
|
2311
|
+
if (partitionId) {
|
|
2312
|
+
const eventPartitionId = event.data.partitionId;
|
|
2313
|
+
if (
|
|
2314
|
+
typeof eventPartitionId !== 'string' ||
|
|
2315
|
+
eventPartitionId !== partitionId
|
|
2316
|
+
) {
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
try {
|
|
2321
|
+
ws.send(JSON.stringify(event));
|
|
2322
|
+
} catch {
|
|
2323
|
+
// Connection closed
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
emitter.addListener(listener);
|
|
2328
|
+
state.listener = listener;
|
|
2329
|
+
|
|
2253
2330
|
ws.send(
|
|
2254
|
-
JSON.stringify({
|
|
2331
|
+
JSON.stringify({
|
|
2332
|
+
type: 'connected',
|
|
2333
|
+
timestamp: new Date().toISOString(),
|
|
2334
|
+
})
|
|
2255
2335
|
);
|
|
2256
|
-
|
|
2336
|
+
|
|
2337
|
+
const replayEvents = emitter.replay({
|
|
2338
|
+
since: replaySince,
|
|
2339
|
+
limit: replayLimit,
|
|
2340
|
+
partitionId,
|
|
2341
|
+
});
|
|
2342
|
+
for (const replayEvent of replayEvents) {
|
|
2343
|
+
try {
|
|
2344
|
+
ws.send(JSON.stringify(replayEvent));
|
|
2345
|
+
} catch {
|
|
2346
|
+
// Connection closed
|
|
2347
|
+
break;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const heartbeatInterval = setInterval(() => {
|
|
2352
|
+
try {
|
|
2353
|
+
ws.send(
|
|
2354
|
+
JSON.stringify({
|
|
2355
|
+
type: 'heartbeat',
|
|
2356
|
+
timestamp: new Date().toISOString(),
|
|
2357
|
+
})
|
|
2358
|
+
);
|
|
2359
|
+
} catch {
|
|
2360
|
+
clearInterval(heartbeatInterval);
|
|
2361
|
+
}
|
|
2362
|
+
}, heartbeatIntervalMs);
|
|
2363
|
+
state.heartbeatInterval = heartbeatInterval;
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
if (initialAuth) {
|
|
2367
|
+
startAuthenticatedSession();
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
state.authTimeout = setTimeout(() => {
|
|
2372
|
+
const current = wsState.get(ws);
|
|
2373
|
+
if (!current || current.isAuthenticated) {
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
closeUnauthenticated(ws);
|
|
2377
|
+
cleanup(ws);
|
|
2378
|
+
}, 5_000);
|
|
2379
|
+
},
|
|
2380
|
+
async onMessage(event, ws) {
|
|
2381
|
+
const state = wsState.get(ws);
|
|
2382
|
+
if (!state || state.isAuthenticated) {
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
if (typeof event.data !== 'string') {
|
|
2387
|
+
closeUnauthenticated(ws);
|
|
2388
|
+
cleanup(ws);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
let token = '';
|
|
2393
|
+
try {
|
|
2394
|
+
const parsed = JSON.parse(event.data) as {
|
|
2395
|
+
type?: unknown;
|
|
2396
|
+
token?: unknown;
|
|
2397
|
+
};
|
|
2398
|
+
if (
|
|
2399
|
+
parsed.type === 'auth' &&
|
|
2400
|
+
typeof parsed.token === 'string' &&
|
|
2401
|
+
parsed.token.trim().length > 0
|
|
2402
|
+
) {
|
|
2403
|
+
token = parsed.token;
|
|
2404
|
+
}
|
|
2405
|
+
} catch {
|
|
2406
|
+
// Ignore parse errors and close as unauthenticated below.
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (!token) {
|
|
2410
|
+
closeUnauthenticated(ws);
|
|
2411
|
+
cleanup(ws);
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
const auth = await authenticateWithBearer(token);
|
|
2416
|
+
const currentState = wsState.get(ws);
|
|
2417
|
+
if (!currentState || currentState.isAuthenticated) {
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (!auth) {
|
|
2421
|
+
closeUnauthenticated(ws);
|
|
2422
|
+
cleanup(ws);
|
|
2257
2423
|
return;
|
|
2258
2424
|
}
|
|
2259
2425
|
|
|
2260
|
-
|
|
2426
|
+
currentState.isAuthenticated = true;
|
|
2427
|
+
if (currentState.authTimeout) {
|
|
2428
|
+
clearTimeout(currentState.authTimeout);
|
|
2429
|
+
currentState.authTimeout = null;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const listener: ConsoleEventListener = (liveEvent) => {
|
|
2261
2433
|
if (partitionId) {
|
|
2262
|
-
const eventPartitionId =
|
|
2434
|
+
const eventPartitionId = liveEvent.data.partitionId;
|
|
2263
2435
|
if (
|
|
2264
2436
|
typeof eventPartitionId !== 'string' ||
|
|
2265
2437
|
eventPartitionId !== partitionId
|
|
@@ -2268,15 +2440,15 @@ export function createConsoleRoutes<
|
|
|
2268
2440
|
}
|
|
2269
2441
|
}
|
|
2270
2442
|
try {
|
|
2271
|
-
ws.send(JSON.stringify(
|
|
2443
|
+
ws.send(JSON.stringify(liveEvent));
|
|
2272
2444
|
} catch {
|
|
2273
2445
|
// Connection closed
|
|
2274
2446
|
}
|
|
2275
2447
|
};
|
|
2276
2448
|
|
|
2277
2449
|
emitter.addListener(listener);
|
|
2450
|
+
currentState.listener = listener;
|
|
2278
2451
|
|
|
2279
|
-
// Send connected message
|
|
2280
2452
|
ws.send(
|
|
2281
2453
|
JSON.stringify({
|
|
2282
2454
|
type: 'connected',
|
|
@@ -2298,7 +2470,6 @@ export function createConsoleRoutes<
|
|
|
2298
2470
|
}
|
|
2299
2471
|
}
|
|
2300
2472
|
|
|
2301
|
-
// Start heartbeat
|
|
2302
2473
|
const heartbeatInterval = setInterval(() => {
|
|
2303
2474
|
try {
|
|
2304
2475
|
ws.send(
|
|
@@ -2311,22 +2482,13 @@ export function createConsoleRoutes<
|
|
|
2311
2482
|
clearInterval(heartbeatInterval);
|
|
2312
2483
|
}
|
|
2313
2484
|
}, heartbeatIntervalMs);
|
|
2314
|
-
|
|
2315
|
-
wsState.set(ws, { listener, heartbeatInterval });
|
|
2485
|
+
currentState.heartbeatInterval = heartbeatInterval;
|
|
2316
2486
|
},
|
|
2317
2487
|
onClose(_event, ws) {
|
|
2318
|
-
|
|
2319
|
-
if (!state) return;
|
|
2320
|
-
emitter.removeListener(state.listener);
|
|
2321
|
-
clearInterval(state.heartbeatInterval);
|
|
2322
|
-
wsState.delete(ws);
|
|
2488
|
+
cleanup(ws);
|
|
2323
2489
|
},
|
|
2324
2490
|
onError(_event, ws) {
|
|
2325
|
-
|
|
2326
|
-
if (!state) return;
|
|
2327
|
-
emitter.removeListener(state.listener);
|
|
2328
|
-
clearInterval(state.heartbeatInterval);
|
|
2329
|
-
wsState.delete(ws);
|
|
2491
|
+
cleanup(ws);
|
|
2330
2492
|
},
|
|
2331
2493
|
};
|
|
2332
2494
|
})
|
|
@@ -3524,29 +3686,19 @@ async function hashApiKey(secretKey: string): Promise<string> {
|
|
|
3524
3686
|
export function createTokenAuthenticator(
|
|
3525
3687
|
token?: string
|
|
3526
3688
|
): (c: Context) => Promise<ConsoleAuthResult | null> {
|
|
3527
|
-
const expectedToken = token ?? process.env.SYNC_CONSOLE_TOKEN;
|
|
3689
|
+
const expectedToken = (token ?? process.env.SYNC_CONSOLE_TOKEN)?.trim() ?? '';
|
|
3528
3690
|
|
|
3529
3691
|
return async (c: Context) => {
|
|
3530
|
-
if (!expectedToken)
|
|
3531
|
-
// No token configured, allow all requests (not recommended for production)
|
|
3532
|
-
return { consoleUserId: 'anonymous' };
|
|
3533
|
-
}
|
|
3692
|
+
if (!expectedToken) return null;
|
|
3534
3693
|
|
|
3535
|
-
|
|
3536
|
-
const authHeader = c.req.header('Authorization');
|
|
3694
|
+
const authHeader = c.req.header('Authorization')?.trim();
|
|
3537
3695
|
if (authHeader?.startsWith('Bearer ')) {
|
|
3538
|
-
const bearerToken = authHeader.slice(7);
|
|
3696
|
+
const bearerToken = authHeader.slice(7).trim();
|
|
3539
3697
|
if (bearerToken === expectedToken) {
|
|
3540
3698
|
return { consoleUserId: 'token' };
|
|
3541
3699
|
}
|
|
3542
3700
|
}
|
|
3543
3701
|
|
|
3544
|
-
// Check query parameter
|
|
3545
|
-
const queryToken = c.req.query('token');
|
|
3546
|
-
if (queryToken === expectedToken) {
|
|
3547
|
-
return { consoleUserId: 'token' };
|
|
3548
|
-
}
|
|
3549
|
-
|
|
3550
3702
|
return null;
|
|
3551
3703
|
};
|
|
3552
3704
|
}
|