@syncular/server-hono 0.0.6-84 → 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.
@@ -71,9 +71,11 @@ export interface ConsoleGatewayInstance {
71
71
  }
72
72
 
73
73
  interface ConsoleGatewayDownstreamSocket {
74
+ onopen?: ((event: Event) => void) | null;
74
75
  onmessage: ((event: MessageEvent) => void) | null;
75
76
  onerror: ((event: Event) => void) | null;
76
77
  close: () => void;
78
+ send?: (data: string) => void;
77
79
  }
78
80
 
79
81
  export interface CreateConsoleGatewayRoutesOptions {
@@ -723,35 +725,18 @@ function resolveForwardAuthorization(args: {
723
725
  if (header) {
724
726
  return header;
725
727
  }
726
- const queryToken = args.c.req.query('token')?.trim();
727
- if (queryToken) {
728
- return `Bearer ${queryToken}`;
729
- }
730
728
  return null;
731
729
  }
732
730
 
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;
731
+ function parseBearerToken(
732
+ authHeader: string | null | undefined
733
+ ): string | null {
734
+ const value = authHeader?.trim();
735
+ if (!value?.startsWith('Bearer ')) {
736
+ return null;
752
737
  }
753
-
754
- return null;
738
+ const token = value.slice(7).trim();
739
+ return token.length > 0 ? token : null;
755
740
  }
756
741
 
757
742
  async function fetchDownstreamJson<T>(args: {
@@ -3036,14 +3021,16 @@ export function createConsoleGatewayRoutes(
3036
3021
  WebSocketLike,
3037
3022
  {
3038
3023
  downstreamSockets: ConsoleGatewayDownstreamSocket[];
3039
- heartbeatInterval: ReturnType<typeof setInterval>;
3024
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
3025
+ authTimeout: ReturnType<typeof setTimeout> | null;
3026
+ isAuthenticated: boolean;
3040
3027
  }
3041
3028
  >();
3042
3029
 
3043
3030
  routes.get(
3044
3031
  '/events/live',
3045
3032
  upgradeWebSocket(async (c) => {
3046
- const auth = await options.authenticate(c);
3033
+ const initialAuth = await options.authenticate(c);
3047
3034
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
3048
3035
  const replaySince = c.req.query('since')?.trim() || undefined;
3049
3036
  const replayLimitRaw = c.req.query('replayLimit');
@@ -3062,10 +3049,43 @@ export function createConsoleGatewayRoutes(
3062
3049
  },
3063
3050
  });
3064
3051
 
3052
+ const authenticateWithBearer = async (
3053
+ token: string
3054
+ ): Promise<ConsoleAuthResult | null> => {
3055
+ const trimmedToken = token.trim();
3056
+ if (!trimmedToken) {
3057
+ return null;
3058
+ }
3059
+ const authContext = {
3060
+ req: {
3061
+ header: (name: string) =>
3062
+ name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
3063
+ query: () => undefined,
3064
+ },
3065
+ } as unknown as Context;
3066
+ return options.authenticate(authContext);
3067
+ };
3068
+
3069
+ const closeUnauthenticated = (ws: WebSocketLike) => {
3070
+ try {
3071
+ ws.send(
3072
+ JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
3073
+ );
3074
+ } catch {
3075
+ // no-op
3076
+ }
3077
+ ws.close(4001, 'Unauthenticated');
3078
+ };
3079
+
3065
3080
  const cleanup = (ws: WebSocketLike) => {
3066
3081
  const state = liveState.get(ws);
3067
3082
  if (!state) return;
3068
- clearInterval(state.heartbeatInterval);
3083
+ if (state.heartbeatInterval) {
3084
+ clearInterval(state.heartbeatInterval);
3085
+ }
3086
+ if (state.authTimeout) {
3087
+ clearTimeout(state.authTimeout);
3088
+ }
3069
3089
  for (const downstream of state.downstreamSockets) {
3070
3090
  try {
3071
3091
  downstream.close();
@@ -3078,14 +3098,6 @@ export function createConsoleGatewayRoutes(
3078
3098
 
3079
3099
  return {
3080
3100
  onOpen(_event, ws) {
3081
- if (!auth) {
3082
- ws.send(
3083
- JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
3084
- );
3085
- ws.close(4001, 'Unauthenticated');
3086
- return;
3087
- }
3088
-
3089
3101
  if (selectedInstances.length === 0) {
3090
3102
  ws.send(
3091
3103
  JSON.stringify({
@@ -3098,17 +3110,215 @@ export function createConsoleGatewayRoutes(
3098
3110
  return;
3099
3111
  }
3100
3112
 
3101
- const downstreamSockets: ConsoleGatewayDownstreamSocket[] = [];
3113
+ const state: {
3114
+ downstreamSockets: ConsoleGatewayDownstreamSocket[];
3115
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
3116
+ authTimeout: ReturnType<typeof setTimeout> | null;
3117
+ isAuthenticated: boolean;
3118
+ } = {
3119
+ downstreamSockets: [],
3120
+ heartbeatInterval: null,
3121
+ authTimeout: null,
3122
+ isAuthenticated: false,
3123
+ };
3124
+ liveState.set(ws, state);
3125
+
3126
+ const startAuthenticatedSession = (
3127
+ upstreamBearerToken: string | null
3128
+ ) => {
3129
+ if (state.isAuthenticated) {
3130
+ return;
3131
+ }
3132
+ state.isAuthenticated = true;
3133
+ if (state.authTimeout) {
3134
+ clearTimeout(state.authTimeout);
3135
+ state.authTimeout = null;
3136
+ }
3137
+
3138
+ for (const instance of selectedInstances) {
3139
+ const downstreamQuery = new URLSearchParams();
3140
+ if (partitionId) {
3141
+ downstreamQuery.set('partitionId', partitionId);
3142
+ }
3143
+ if (replaySince) {
3144
+ downstreamQuery.set('since', replaySince);
3145
+ }
3146
+ downstreamQuery.set('replayLimit', String(replayLimit));
3147
+
3148
+ const downstreamUrl = buildConsoleEndpointUrl({
3149
+ instance,
3150
+ requestUrl: c.req.url,
3151
+ path: '/events/live',
3152
+ query: downstreamQuery,
3153
+ });
3154
+
3155
+ const downstreamSocket = createDownstreamSocket(downstreamUrl);
3156
+ const downstreamToken =
3157
+ instance.token?.trim() ?? upstreamBearerToken?.trim() ?? null;
3158
+ if (downstreamToken && downstreamSocket.send) {
3159
+ downstreamSocket.onopen = () => {
3160
+ try {
3161
+ downstreamSocket.send?.(
3162
+ JSON.stringify({
3163
+ type: 'auth',
3164
+ token: downstreamToken,
3165
+ })
3166
+ );
3167
+ } catch {
3168
+ // no-op
3169
+ }
3170
+ };
3171
+ }
3172
+
3173
+ downstreamSocket.onmessage = (message: MessageEvent) => {
3174
+ if (typeof message.data !== 'string') {
3175
+ return;
3176
+ }
3177
+ try {
3178
+ const payload = JSON.parse(message.data) as Record<
3179
+ string,
3180
+ unknown
3181
+ >;
3182
+ if (
3183
+ typeof payload.type === 'string' &&
3184
+ (payload.type === 'connected' ||
3185
+ payload.type === 'heartbeat')
3186
+ ) {
3187
+ return;
3188
+ }
3189
+
3190
+ const payloadData =
3191
+ payload.data &&
3192
+ typeof payload.data === 'object' &&
3193
+ !Array.isArray(payload.data)
3194
+ ? { ...payload.data, instanceId: instance.instanceId }
3195
+ : { instanceId: instance.instanceId };
3196
+
3197
+ const event = {
3198
+ ...payload,
3199
+ data: payloadData,
3200
+ instanceId: instance.instanceId,
3201
+ timestamp:
3202
+ typeof payload.timestamp === 'string'
3203
+ ? payload.timestamp
3204
+ : new Date().toISOString(),
3205
+ };
3206
+ ws.send(JSON.stringify(event));
3207
+ } catch {
3208
+ // Ignore malformed downstream events
3209
+ }
3210
+ };
3211
+
3212
+ downstreamSocket.onerror = () => {
3213
+ try {
3214
+ ws.send(
3215
+ JSON.stringify({
3216
+ type: 'instance_error',
3217
+ instanceId: instance.instanceId,
3218
+ timestamp: new Date().toISOString(),
3219
+ })
3220
+ );
3221
+ } catch {
3222
+ // ignore send errors
3223
+ }
3224
+ };
3225
+
3226
+ state.downstreamSockets.push(downstreamSocket);
3227
+ }
3228
+
3229
+ ws.send(
3230
+ JSON.stringify({
3231
+ type: 'connected',
3232
+ timestamp: new Date().toISOString(),
3233
+ instanceCount: selectedInstances.length,
3234
+ })
3235
+ );
3236
+
3237
+ const heartbeatInterval = setInterval(() => {
3238
+ try {
3239
+ ws.send(
3240
+ JSON.stringify({
3241
+ type: 'heartbeat',
3242
+ timestamp: new Date().toISOString(),
3243
+ })
3244
+ );
3245
+ } catch {
3246
+ clearInterval(heartbeatInterval);
3247
+ }
3248
+ }, heartbeatIntervalMs);
3249
+ state.heartbeatInterval = heartbeatInterval;
3250
+ };
3251
+
3252
+ if (initialAuth) {
3253
+ startAuthenticatedSession(
3254
+ parseBearerToken(c.req.header('Authorization'))
3255
+ );
3256
+ return;
3257
+ }
3258
+
3259
+ state.authTimeout = setTimeout(() => {
3260
+ const current = liveState.get(ws);
3261
+ if (!current || current.isAuthenticated) {
3262
+ return;
3263
+ }
3264
+ closeUnauthenticated(ws);
3265
+ cleanup(ws);
3266
+ }, 5_000);
3267
+ },
3268
+ async onMessage(event, ws) {
3269
+ const state = liveState.get(ws);
3270
+ if (!state || state.isAuthenticated) {
3271
+ return;
3272
+ }
3273
+
3274
+ if (typeof event.data !== 'string') {
3275
+ closeUnauthenticated(ws);
3276
+ cleanup(ws);
3277
+ return;
3278
+ }
3279
+
3280
+ let token = '';
3281
+ try {
3282
+ const parsed = JSON.parse(event.data) as {
3283
+ type?: unknown;
3284
+ token?: unknown;
3285
+ };
3286
+ if (
3287
+ parsed.type === 'auth' &&
3288
+ typeof parsed.token === 'string' &&
3289
+ parsed.token.trim().length > 0
3290
+ ) {
3291
+ token = parsed.token;
3292
+ }
3293
+ } catch {
3294
+ // Invalid auth message will be handled below.
3295
+ }
3296
+
3297
+ if (!token) {
3298
+ closeUnauthenticated(ws);
3299
+ cleanup(ws);
3300
+ return;
3301
+ }
3302
+
3303
+ const auth = await authenticateWithBearer(token);
3304
+ const current = liveState.get(ws);
3305
+ if (!current || current.isAuthenticated) {
3306
+ return;
3307
+ }
3308
+ if (!auth) {
3309
+ closeUnauthenticated(ws);
3310
+ cleanup(ws);
3311
+ return;
3312
+ }
3313
+
3314
+ current.isAuthenticated = true;
3315
+ if (current.authTimeout) {
3316
+ clearTimeout(current.authTimeout);
3317
+ current.authTimeout = null;
3318
+ }
3102
3319
 
3103
3320
  for (const instance of selectedInstances) {
3104
3321
  const downstreamQuery = new URLSearchParams();
3105
- const downstreamToken = resolveForwardBearerToken({
3106
- c,
3107
- instance,
3108
- });
3109
- if (downstreamToken) {
3110
- downstreamQuery.set('token', downstreamToken);
3111
- }
3112
3322
  if (partitionId) {
3113
3323
  downstreamQuery.set('partitionId', partitionId);
3114
3324
  }
@@ -3125,6 +3335,24 @@ export function createConsoleGatewayRoutes(
3125
3335
  });
3126
3336
 
3127
3337
  const downstreamSocket = createDownstreamSocket(downstreamUrl);
3338
+ const upstreamToken = token.trim();
3339
+ const downstreamToken =
3340
+ instance.token?.trim() ||
3341
+ (upstreamToken.length > 0 ? upstreamToken : null);
3342
+ if (downstreamToken && downstreamSocket.send) {
3343
+ downstreamSocket.onopen = () => {
3344
+ try {
3345
+ downstreamSocket.send?.(
3346
+ JSON.stringify({
3347
+ type: 'auth',
3348
+ token: downstreamToken,
3349
+ })
3350
+ );
3351
+ } catch {
3352
+ // no-op
3353
+ }
3354
+ };
3355
+ }
3128
3356
 
3129
3357
  downstreamSocket.onmessage = (message: MessageEvent) => {
3130
3358
  if (typeof message.data !== 'string') {
@@ -3150,7 +3378,7 @@ export function createConsoleGatewayRoutes(
3150
3378
  ? { ...payload.data, instanceId: instance.instanceId }
3151
3379
  : { instanceId: instance.instanceId };
3152
3380
 
3153
- const event = {
3381
+ const liveEvent = {
3154
3382
  ...payload,
3155
3383
  data: payloadData,
3156
3384
  instanceId: instance.instanceId,
@@ -3159,7 +3387,7 @@ export function createConsoleGatewayRoutes(
3159
3387
  ? payload.timestamp
3160
3388
  : new Date().toISOString(),
3161
3389
  };
3162
- ws.send(JSON.stringify(event));
3390
+ ws.send(JSON.stringify(liveEvent));
3163
3391
  } catch {
3164
3392
  // Ignore malformed downstream events
3165
3393
  }
@@ -3179,7 +3407,7 @@ export function createConsoleGatewayRoutes(
3179
3407
  }
3180
3408
  };
3181
3409
 
3182
- downstreamSockets.push(downstreamSocket);
3410
+ current.downstreamSockets.push(downstreamSocket);
3183
3411
  }
3184
3412
 
3185
3413
  ws.send(
@@ -3202,11 +3430,7 @@ export function createConsoleGatewayRoutes(
3202
3430
  clearInterval(heartbeatInterval);
3203
3431
  }
3204
3432
  }, heartbeatIntervalMs);
3205
-
3206
- liveState.set(ws, {
3207
- downstreamSockets,
3208
- heartbeatInterval,
3209
- });
3433
+ current.heartbeatInterval = heartbeatInterval;
3210
3434
  },
3211
3435
  onClose(_event, ws) {
3212
3436
  cleanup(ws);