@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.
- 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/gateway.ts
CHANGED
|
@@ -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
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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);
|