@syncular/server-hono 0.0.4-25 → 0.0.6-100

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.
Files changed (57) hide show
  1. package/README.md +6 -1
  2. package/dist/console/gateway.d.ts +3 -1
  3. package/dist/console/gateway.d.ts.map +1 -1
  4. package/dist/console/gateway.js +227 -42
  5. package/dist/console/gateway.js.map +1 -1
  6. package/dist/console/index.d.ts +2 -0
  7. package/dist/console/index.d.ts.map +1 -1
  8. package/dist/console/index.js +2 -0
  9. package/dist/console/index.js.map +1 -1
  10. package/dist/console/routes.d.ts +3 -97
  11. package/dist/console/routes.d.ts.map +1 -1
  12. package/dist/console/routes.js +516 -81
  13. package/dist/console/routes.js.map +1 -1
  14. package/dist/console/schemas.d.ts +29 -0
  15. package/dist/console/schemas.d.ts.map +1 -1
  16. package/dist/console/schemas.js +22 -0
  17. package/dist/console/schemas.js.map +1 -1
  18. package/dist/console/types.d.ts +175 -0
  19. package/dist/console/types.d.ts.map +1 -0
  20. package/dist/console/types.js +2 -0
  21. package/dist/console/types.js.map +1 -0
  22. package/dist/console/ui.d.ts +38 -0
  23. package/dist/console/ui.d.ts.map +1 -0
  24. package/dist/console/ui.js +43 -0
  25. package/dist/console/ui.js.map +1 -0
  26. package/dist/create-server.d.ts +17 -34
  27. package/dist/create-server.d.ts.map +1 -1
  28. package/dist/create-server.js +26 -26
  29. package/dist/create-server.js.map +1 -1
  30. package/dist/proxy/connection-manager.d.ts +3 -3
  31. package/dist/proxy/connection-manager.d.ts.map +1 -1
  32. package/dist/proxy/routes.d.ts +4 -4
  33. package/dist/proxy/routes.d.ts.map +1 -1
  34. package/dist/proxy/routes.js +1 -1
  35. package/dist/routes.d.ts +33 -9
  36. package/dist/routes.d.ts.map +1 -1
  37. package/dist/routes.js +153 -70
  38. package/dist/routes.js.map +1 -1
  39. package/package.json +21 -6
  40. package/src/__tests__/blob-routes.test.ts +424 -0
  41. package/src/__tests__/console-gateway-live-routes.test.ts +54 -3
  42. package/src/__tests__/console-routes.test.ts +161 -7
  43. package/src/__tests__/console-ui.test.ts +114 -0
  44. package/src/__tests__/create-server.test.ts +233 -10
  45. package/src/__tests__/pull-chunk-storage.test.ts +6 -2
  46. package/src/__tests__/realtime-bridge.test.ts +6 -2
  47. package/src/__tests__/sync-rate-limit-routing.test.ts +6 -2
  48. package/src/console/gateway.ts +286 -54
  49. package/src/console/index.ts +2 -0
  50. package/src/console/routes.ts +663 -199
  51. package/src/console/schemas.ts +29 -0
  52. package/src/console/types.ts +185 -0
  53. package/src/console/ui.ts +100 -0
  54. package/src/create-server.ts +56 -53
  55. package/src/proxy/connection-manager.ts +3 -3
  56. package/src/proxy/routes.ts +4 -4
  57. package/src/routes.ts +225 -96
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { createDatabase } from '@syncular/core';
2
3
  import {
3
4
  createServerHandler,
4
5
  ensureSyncSchema,
@@ -6,7 +7,7 @@ import {
6
7
  } from '@syncular/server';
7
8
  import { Hono } from 'hono';
8
9
  import type { Kysely } from 'kysely';
9
- import { createBunSqliteDb } from '../../../dialect-bun-sqlite/src';
10
+ import { createBunSqliteDialect } from '../../../dialect-bun-sqlite/src';
10
11
  import { createSqliteServerDialect } from '../../../server-dialect-sqlite/src';
11
12
  import { resetRateLimitStore } from '../rate-limit';
12
13
  import { createSyncRoutes } from '../routes';
@@ -31,7 +32,10 @@ describe('createSyncRoutes rate limit routing', () => {
31
32
  const dialect = createSqliteServerDialect();
32
33
 
33
34
  beforeEach(async () => {
34
- db = createBunSqliteDb<ServerDb>({ path: ':memory:' });
35
+ db = createDatabase<ServerDb>({
36
+ dialect: createBunSqliteDialect({ path: ':memory:' }),
37
+ family: 'sqlite',
38
+ });
35
39
  await ensureSyncSchema(db, dialect);
36
40
 
37
41
  await db.schema
@@ -4,7 +4,6 @@ import { cors } from 'hono/cors';
4
4
  import type { UpgradeWebSocket } from 'hono/ws';
5
5
  import { describeRoute, resolver, validator as zValidator } from 'hono-openapi';
6
6
  import { z } from 'zod';
7
- import type { ConsoleAuthResult } from './routes';
8
7
  import type {
9
8
  ConsoleApiKey,
10
9
  ConsoleApiKeyBulkRevokeResponse,
@@ -61,6 +60,7 @@ import {
61
60
  TimeseriesQuerySchema,
62
61
  TimeseriesStatsResponseSchema,
63
62
  } from './schemas';
63
+ import type { ConsoleAuthResult } from './types';
64
64
 
65
65
  export interface ConsoleGatewayInstance {
66
66
  instanceId: string;
@@ -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: {
@@ -1081,7 +1066,15 @@ export function createConsoleGatewayRoutes(
1081
1066
  cors({
1082
1067
  origin: corsOrigins === '*' ? '*' : corsOrigins,
1083
1068
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
1084
- allowHeaders: ['Content-Type', 'Authorization'],
1069
+ allowHeaders: [
1070
+ 'Content-Type',
1071
+ 'Authorization',
1072
+ 'X-Syncular-Transport-Path',
1073
+ 'Baggage',
1074
+ 'Sentry-Trace',
1075
+ 'Traceparent',
1076
+ 'Tracestate',
1077
+ ],
1085
1078
  credentials: true,
1086
1079
  })
1087
1080
  );
@@ -3028,14 +3021,16 @@ export function createConsoleGatewayRoutes(
3028
3021
  WebSocketLike,
3029
3022
  {
3030
3023
  downstreamSockets: ConsoleGatewayDownstreamSocket[];
3031
- heartbeatInterval: ReturnType<typeof setInterval>;
3024
+ heartbeatInterval: ReturnType<typeof setInterval> | null;
3025
+ authTimeout: ReturnType<typeof setTimeout> | null;
3026
+ isAuthenticated: boolean;
3032
3027
  }
3033
3028
  >();
3034
3029
 
3035
3030
  routes.get(
3036
3031
  '/events/live',
3037
3032
  upgradeWebSocket(async (c) => {
3038
- const auth = await options.authenticate(c);
3033
+ const initialAuth = await options.authenticate(c);
3039
3034
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
3040
3035
  const replaySince = c.req.query('since')?.trim() || undefined;
3041
3036
  const replayLimitRaw = c.req.query('replayLimit');
@@ -3054,10 +3049,43 @@ export function createConsoleGatewayRoutes(
3054
3049
  },
3055
3050
  });
3056
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
+
3057
3080
  const cleanup = (ws: WebSocketLike) => {
3058
3081
  const state = liveState.get(ws);
3059
3082
  if (!state) return;
3060
- clearInterval(state.heartbeatInterval);
3083
+ if (state.heartbeatInterval) {
3084
+ clearInterval(state.heartbeatInterval);
3085
+ }
3086
+ if (state.authTimeout) {
3087
+ clearTimeout(state.authTimeout);
3088
+ }
3061
3089
  for (const downstream of state.downstreamSockets) {
3062
3090
  try {
3063
3091
  downstream.close();
@@ -3070,14 +3098,6 @@ export function createConsoleGatewayRoutes(
3070
3098
 
3071
3099
  return {
3072
3100
  onOpen(_event, ws) {
3073
- if (!auth) {
3074
- ws.send(
3075
- JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' })
3076
- );
3077
- ws.close(4001, 'Unauthenticated');
3078
- return;
3079
- }
3080
-
3081
3101
  if (selectedInstances.length === 0) {
3082
3102
  ws.send(
3083
3103
  JSON.stringify({
@@ -3090,17 +3110,215 @@ export function createConsoleGatewayRoutes(
3090
3110
  return;
3091
3111
  }
3092
3112
 
3093
- 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
+ }
3094
3319
 
3095
3320
  for (const instance of selectedInstances) {
3096
3321
  const downstreamQuery = new URLSearchParams();
3097
- const downstreamToken = resolveForwardBearerToken({
3098
- c,
3099
- instance,
3100
- });
3101
- if (downstreamToken) {
3102
- downstreamQuery.set('token', downstreamToken);
3103
- }
3104
3322
  if (partitionId) {
3105
3323
  downstreamQuery.set('partitionId', partitionId);
3106
3324
  }
@@ -3117,6 +3335,24 @@ export function createConsoleGatewayRoutes(
3117
3335
  });
3118
3336
 
3119
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
+ }
3120
3356
 
3121
3357
  downstreamSocket.onmessage = (message: MessageEvent) => {
3122
3358
  if (typeof message.data !== 'string') {
@@ -3142,7 +3378,7 @@ export function createConsoleGatewayRoutes(
3142
3378
  ? { ...payload.data, instanceId: instance.instanceId }
3143
3379
  : { instanceId: instance.instanceId };
3144
3380
 
3145
- const event = {
3381
+ const liveEvent = {
3146
3382
  ...payload,
3147
3383
  data: payloadData,
3148
3384
  instanceId: instance.instanceId,
@@ -3151,7 +3387,7 @@ export function createConsoleGatewayRoutes(
3151
3387
  ? payload.timestamp
3152
3388
  : new Date().toISOString(),
3153
3389
  };
3154
- ws.send(JSON.stringify(event));
3390
+ ws.send(JSON.stringify(liveEvent));
3155
3391
  } catch {
3156
3392
  // Ignore malformed downstream events
3157
3393
  }
@@ -3171,7 +3407,7 @@ export function createConsoleGatewayRoutes(
3171
3407
  }
3172
3408
  };
3173
3409
 
3174
- downstreamSockets.push(downstreamSocket);
3410
+ current.downstreamSockets.push(downstreamSocket);
3175
3411
  }
3176
3412
 
3177
3413
  ws.send(
@@ -3194,11 +3430,7 @@ export function createConsoleGatewayRoutes(
3194
3430
  clearInterval(heartbeatInterval);
3195
3431
  }
3196
3432
  }, heartbeatIntervalMs);
3197
-
3198
- liveState.set(ws, {
3199
- downstreamSockets,
3200
- heartbeatInterval,
3201
- });
3433
+ current.heartbeatInterval = heartbeatInterval;
3202
3434
  },
3203
3435
  onClose(_event, ws) {
3204
3436
  cleanup(ws);
@@ -7,3 +7,5 @@
7
7
  export * from './gateway';
8
8
  export * from './routes';
9
9
  export * from './schemas';
10
+ export * from './types';
11
+ export * from './ui';