@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.
@@ -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: (name: string) => (name === 'token' ? token : undefined),
2268
+ query: () => undefined,
2245
2269
  },
2246
- } as Context;
2270
+ } as unknown as Context;
2247
2271
 
2248
- const auth = await options.authenticate(mockContext);
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
- if (!auth) {
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({ type: 'error', message: 'UNAUTHENTICATED' })
2331
+ JSON.stringify({
2332
+ type: 'connected',
2333
+ timestamp: new Date().toISOString(),
2334
+ })
2255
2335
  );
2256
- ws.close(4001, 'Unauthenticated');
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
- const listener: ConsoleEventListener = (event) => {
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 = event.data.partitionId;
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(event));
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
- const state = wsState.get(ws);
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
- const state = wsState.get(ws);
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
- // Check Authorization header
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
  }