@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
package/README.md CHANGED
@@ -8,6 +8,12 @@ Hono adapter for `@syncular/server`. Provides push/pull routes, WebSocket wake-u
8
8
  npm install @syncular/server-hono hono
9
9
  ```
10
10
 
11
+ If you want to serve the Console UI with `mountConsoleUi`, also install:
12
+
13
+ ```bash
14
+ npm install @syncular/console
15
+ ```
16
+
11
17
  ## Documentation
12
18
 
13
19
  - Hono adapter: https://syncular.dev/docs/server/hono-adapter
@@ -20,4 +26,3 @@ npm install @syncular/server-hono hono
20
26
  - Issues: https://github.com/syncular/syncular/issues
21
27
 
22
28
  > Status: Alpha. APIs and storage layouts may change between releases.
23
-
@@ -1,7 +1,7 @@
1
1
  import type { Context } from 'hono';
2
2
  import { Hono } from 'hono';
3
3
  import type { UpgradeWebSocket } from 'hono/ws';
4
- import type { ConsoleAuthResult } from './routes';
4
+ import type { ConsoleAuthResult } from './types';
5
5
  export interface ConsoleGatewayInstance {
6
6
  instanceId: string;
7
7
  label?: string;
@@ -10,9 +10,11 @@ export interface ConsoleGatewayInstance {
10
10
  enabled?: boolean;
11
11
  }
12
12
  interface ConsoleGatewayDownstreamSocket {
13
+ onopen?: ((event: Event) => void) | null;
13
14
  onmessage: ((event: MessageEvent) => void) | null;
14
15
  onerror: ((event: Event) => void) | null;
15
16
  close: () => void;
17
+ send?: (data: string) => void;
16
18
  }
17
19
  export interface CreateConsoleGatewayRoutesOptions {
18
20
  instances: ConsoleGatewayInstance[];
@@ -1 +1 @@
1
- {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../src/console/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAGhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AA0DlD,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,UAAU,8BAA8B;IACtC,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAClD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACzC,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED,MAAM,WAAW,iCAAiC;IAChD,SAAS,EAAE,sBAAsB,EAAE,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAChE,WAAW,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,SAAS,CAAC,EAAE;QACV,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;QACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC;QAC7B,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,8BAA8B,CAAC;KACnE,CAAC;CACH;AAq9BD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,IAAI,CA0vEN"}
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../src/console/gateway.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AACpC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AA2DhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjD,MAAM,WAAW,sBAAsB;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,UAAU,8BAA8B;IACtC,MAAM,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACzC,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAClD,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACzC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,iCAAiC;IAChD,SAAS,EAAE,sBAAsB,EAAE,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAChE,WAAW,CAAC,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,SAAS,CAAC,EAAE;QACV,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;QACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC;QAC7B,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,8BAA8B,CAAC;KACnE,CAAC;CACH;AAo8BD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,IAAI,CAi/EN"}
@@ -471,28 +471,15 @@ function resolveForwardAuthorization(args) {
471
471
  if (header) {
472
472
  return header;
473
473
  }
474
- const queryToken = args.c.req.query('token')?.trim();
475
- if (queryToken) {
476
- return `Bearer ${queryToken}`;
477
- }
478
474
  return null;
479
475
  }
480
- function resolveForwardBearerToken(args) {
481
- if (args.instance.token) {
482
- return args.instance.token;
483
- }
484
- const authHeader = args.c.req.header('Authorization')?.trim();
485
- if (authHeader?.startsWith('Bearer ')) {
486
- const token = authHeader.slice(7).trim();
487
- if (token.length > 0) {
488
- return token;
489
- }
490
- }
491
- const queryToken = args.c.req.query('token')?.trim();
492
- if (queryToken) {
493
- return queryToken;
476
+ function parseBearerToken(authHeader) {
477
+ const value = authHeader?.trim();
478
+ if (!value?.startsWith('Bearer ')) {
479
+ return null;
494
480
  }
495
- return null;
481
+ const token = value.slice(7).trim();
482
+ return token.length > 0 ? token : null;
496
483
  }
497
484
  async function fetchDownstreamJson(args) {
498
485
  const url = buildConsoleEndpointUrl({
@@ -743,7 +730,15 @@ export function createConsoleGatewayRoutes(options) {
743
730
  routes.use('*', cors({
744
731
  origin: corsOrigins === '*' ? '*' : corsOrigins,
745
732
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
746
- allowHeaders: ['Content-Type', 'Authorization'],
733
+ allowHeaders: [
734
+ 'Content-Type',
735
+ 'Authorization',
736
+ 'X-Syncular-Transport-Path',
737
+ 'Baggage',
738
+ 'Sentry-Trace',
739
+ 'Traceparent',
740
+ 'Tracestate',
741
+ ],
747
742
  credentials: true,
748
743
  }));
749
744
  routes.get('/instances', describeRoute({
@@ -2087,7 +2082,7 @@ export function createConsoleGatewayRoutes(options) {
2087
2082
  ((url) => new WebSocket(url));
2088
2083
  const liveState = new WeakMap();
2089
2084
  routes.get('/events/live', upgradeWebSocket(async (c) => {
2090
- const auth = await options.authenticate(c);
2085
+ const initialAuth = await options.authenticate(c);
2091
2086
  const partitionId = c.req.query('partitionId')?.trim() || undefined;
2092
2087
  const replaySince = c.req.query('since')?.trim() || undefined;
2093
2088
  const replayLimitRaw = c.req.query('replayLimit');
@@ -2104,11 +2099,38 @@ export function createConsoleGatewayRoutes(options) {
2104
2099
  instanceIds: c.req.query('instanceIds') ?? undefined,
2105
2100
  },
2106
2101
  });
2102
+ const authenticateWithBearer = async (token) => {
2103
+ const trimmedToken = token.trim();
2104
+ if (!trimmedToken) {
2105
+ return null;
2106
+ }
2107
+ const authContext = {
2108
+ req: {
2109
+ header: (name) => name === 'Authorization' ? `Bearer ${trimmedToken}` : undefined,
2110
+ query: () => undefined,
2111
+ },
2112
+ };
2113
+ return options.authenticate(authContext);
2114
+ };
2115
+ const closeUnauthenticated = (ws) => {
2116
+ try {
2117
+ ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
2118
+ }
2119
+ catch {
2120
+ // no-op
2121
+ }
2122
+ ws.close(4001, 'Unauthenticated');
2123
+ };
2107
2124
  const cleanup = (ws) => {
2108
2125
  const state = liveState.get(ws);
2109
2126
  if (!state)
2110
2127
  return;
2111
- clearInterval(state.heartbeatInterval);
2128
+ if (state.heartbeatInterval) {
2129
+ clearInterval(state.heartbeatInterval);
2130
+ }
2131
+ if (state.authTimeout) {
2132
+ clearTimeout(state.authTimeout);
2133
+ }
2112
2134
  for (const downstream of state.downstreamSockets) {
2113
2135
  try {
2114
2136
  downstream.close();
@@ -2121,11 +2143,6 @@ export function createConsoleGatewayRoutes(options) {
2121
2143
  };
2122
2144
  return {
2123
2145
  onOpen(_event, ws) {
2124
- if (!auth) {
2125
- ws.send(JSON.stringify({ type: 'error', message: 'UNAUTHENTICATED' }));
2126
- ws.close(4001, 'Unauthenticated');
2127
- return;
2128
- }
2129
2146
  if (selectedInstances.length === 0) {
2130
2147
  ws.send(JSON.stringify({
2131
2148
  type: 'error',
@@ -2134,16 +2151,171 @@ export function createConsoleGatewayRoutes(options) {
2134
2151
  ws.close(4004, 'No instances selected');
2135
2152
  return;
2136
2153
  }
2137
- const downstreamSockets = [];
2154
+ const state = {
2155
+ downstreamSockets: [],
2156
+ heartbeatInterval: null,
2157
+ authTimeout: null,
2158
+ isAuthenticated: false,
2159
+ };
2160
+ liveState.set(ws, state);
2161
+ const startAuthenticatedSession = (upstreamBearerToken) => {
2162
+ if (state.isAuthenticated) {
2163
+ return;
2164
+ }
2165
+ state.isAuthenticated = true;
2166
+ if (state.authTimeout) {
2167
+ clearTimeout(state.authTimeout);
2168
+ state.authTimeout = null;
2169
+ }
2170
+ for (const instance of selectedInstances) {
2171
+ const downstreamQuery = new URLSearchParams();
2172
+ if (partitionId) {
2173
+ downstreamQuery.set('partitionId', partitionId);
2174
+ }
2175
+ if (replaySince) {
2176
+ downstreamQuery.set('since', replaySince);
2177
+ }
2178
+ downstreamQuery.set('replayLimit', String(replayLimit));
2179
+ const downstreamUrl = buildConsoleEndpointUrl({
2180
+ instance,
2181
+ requestUrl: c.req.url,
2182
+ path: '/events/live',
2183
+ query: downstreamQuery,
2184
+ });
2185
+ const downstreamSocket = createDownstreamSocket(downstreamUrl);
2186
+ const downstreamToken = instance.token?.trim() ?? upstreamBearerToken?.trim() ?? null;
2187
+ if (downstreamToken && downstreamSocket.send) {
2188
+ downstreamSocket.onopen = () => {
2189
+ try {
2190
+ downstreamSocket.send?.(JSON.stringify({
2191
+ type: 'auth',
2192
+ token: downstreamToken,
2193
+ }));
2194
+ }
2195
+ catch {
2196
+ // no-op
2197
+ }
2198
+ };
2199
+ }
2200
+ downstreamSocket.onmessage = (message) => {
2201
+ if (typeof message.data !== 'string') {
2202
+ return;
2203
+ }
2204
+ try {
2205
+ const payload = JSON.parse(message.data);
2206
+ if (typeof payload.type === 'string' &&
2207
+ (payload.type === 'connected' ||
2208
+ payload.type === 'heartbeat')) {
2209
+ return;
2210
+ }
2211
+ const payloadData = payload.data &&
2212
+ typeof payload.data === 'object' &&
2213
+ !Array.isArray(payload.data)
2214
+ ? { ...payload.data, instanceId: instance.instanceId }
2215
+ : { instanceId: instance.instanceId };
2216
+ const event = {
2217
+ ...payload,
2218
+ data: payloadData,
2219
+ instanceId: instance.instanceId,
2220
+ timestamp: typeof payload.timestamp === 'string'
2221
+ ? payload.timestamp
2222
+ : new Date().toISOString(),
2223
+ };
2224
+ ws.send(JSON.stringify(event));
2225
+ }
2226
+ catch {
2227
+ // Ignore malformed downstream events
2228
+ }
2229
+ };
2230
+ downstreamSocket.onerror = () => {
2231
+ try {
2232
+ ws.send(JSON.stringify({
2233
+ type: 'instance_error',
2234
+ instanceId: instance.instanceId,
2235
+ timestamp: new Date().toISOString(),
2236
+ }));
2237
+ }
2238
+ catch {
2239
+ // ignore send errors
2240
+ }
2241
+ };
2242
+ state.downstreamSockets.push(downstreamSocket);
2243
+ }
2244
+ ws.send(JSON.stringify({
2245
+ type: 'connected',
2246
+ timestamp: new Date().toISOString(),
2247
+ instanceCount: selectedInstances.length,
2248
+ }));
2249
+ const heartbeatInterval = setInterval(() => {
2250
+ try {
2251
+ ws.send(JSON.stringify({
2252
+ type: 'heartbeat',
2253
+ timestamp: new Date().toISOString(),
2254
+ }));
2255
+ }
2256
+ catch {
2257
+ clearInterval(heartbeatInterval);
2258
+ }
2259
+ }, heartbeatIntervalMs);
2260
+ state.heartbeatInterval = heartbeatInterval;
2261
+ };
2262
+ if (initialAuth) {
2263
+ startAuthenticatedSession(parseBearerToken(c.req.header('Authorization')));
2264
+ return;
2265
+ }
2266
+ state.authTimeout = setTimeout(() => {
2267
+ const current = liveState.get(ws);
2268
+ if (!current || current.isAuthenticated) {
2269
+ return;
2270
+ }
2271
+ closeUnauthenticated(ws);
2272
+ cleanup(ws);
2273
+ }, 5_000);
2274
+ },
2275
+ async onMessage(event, ws) {
2276
+ const state = liveState.get(ws);
2277
+ if (!state || state.isAuthenticated) {
2278
+ return;
2279
+ }
2280
+ if (typeof event.data !== 'string') {
2281
+ closeUnauthenticated(ws);
2282
+ cleanup(ws);
2283
+ return;
2284
+ }
2285
+ let token = '';
2286
+ try {
2287
+ const parsed = JSON.parse(event.data);
2288
+ if (parsed.type === 'auth' &&
2289
+ typeof parsed.token === 'string' &&
2290
+ parsed.token.trim().length > 0) {
2291
+ token = parsed.token;
2292
+ }
2293
+ }
2294
+ catch {
2295
+ // Invalid auth message will be handled below.
2296
+ }
2297
+ if (!token) {
2298
+ closeUnauthenticated(ws);
2299
+ cleanup(ws);
2300
+ return;
2301
+ }
2302
+ const auth = await authenticateWithBearer(token);
2303
+ const current = liveState.get(ws);
2304
+ if (!current || current.isAuthenticated) {
2305
+ return;
2306
+ }
2307
+ if (!auth) {
2308
+ closeUnauthenticated(ws);
2309
+ cleanup(ws);
2310
+ return;
2311
+ }
2312
+ current.isAuthenticated = true;
2313
+ if (current.authTimeout) {
2314
+ clearTimeout(current.authTimeout);
2315
+ current.authTimeout = null;
2316
+ }
2138
2317
  for (const instance of selectedInstances) {
2139
2318
  const downstreamQuery = new URLSearchParams();
2140
- const downstreamToken = resolveForwardBearerToken({
2141
- c,
2142
- instance,
2143
- });
2144
- if (downstreamToken) {
2145
- downstreamQuery.set('token', downstreamToken);
2146
- }
2147
2319
  if (partitionId) {
2148
2320
  downstreamQuery.set('partitionId', partitionId);
2149
2321
  }
@@ -2158,6 +2330,22 @@ export function createConsoleGatewayRoutes(options) {
2158
2330
  query: downstreamQuery,
2159
2331
  });
2160
2332
  const downstreamSocket = createDownstreamSocket(downstreamUrl);
2333
+ const upstreamToken = token.trim();
2334
+ const downstreamToken = instance.token?.trim() ||
2335
+ (upstreamToken.length > 0 ? upstreamToken : null);
2336
+ if (downstreamToken && downstreamSocket.send) {
2337
+ downstreamSocket.onopen = () => {
2338
+ try {
2339
+ downstreamSocket.send?.(JSON.stringify({
2340
+ type: 'auth',
2341
+ token: downstreamToken,
2342
+ }));
2343
+ }
2344
+ catch {
2345
+ // no-op
2346
+ }
2347
+ };
2348
+ }
2161
2349
  downstreamSocket.onmessage = (message) => {
2162
2350
  if (typeof message.data !== 'string') {
2163
2351
  return;
@@ -2174,7 +2362,7 @@ export function createConsoleGatewayRoutes(options) {
2174
2362
  !Array.isArray(payload.data)
2175
2363
  ? { ...payload.data, instanceId: instance.instanceId }
2176
2364
  : { instanceId: instance.instanceId };
2177
- const event = {
2365
+ const liveEvent = {
2178
2366
  ...payload,
2179
2367
  data: payloadData,
2180
2368
  instanceId: instance.instanceId,
@@ -2182,7 +2370,7 @@ export function createConsoleGatewayRoutes(options) {
2182
2370
  ? payload.timestamp
2183
2371
  : new Date().toISOString(),
2184
2372
  };
2185
- ws.send(JSON.stringify(event));
2373
+ ws.send(JSON.stringify(liveEvent));
2186
2374
  }
2187
2375
  catch {
2188
2376
  // Ignore malformed downstream events
@@ -2200,7 +2388,7 @@ export function createConsoleGatewayRoutes(options) {
2200
2388
  // ignore send errors
2201
2389
  }
2202
2390
  };
2203
- downstreamSockets.push(downstreamSocket);
2391
+ current.downstreamSockets.push(downstreamSocket);
2204
2392
  }
2205
2393
  ws.send(JSON.stringify({
2206
2394
  type: 'connected',
@@ -2218,10 +2406,7 @@ export function createConsoleGatewayRoutes(options) {
2218
2406
  clearInterval(heartbeatInterval);
2219
2407
  }
2220
2408
  }, heartbeatIntervalMs);
2221
- liveState.set(ws, {
2222
- downstreamSockets,
2223
- heartbeatInterval,
2224
- });
2409
+ current.heartbeatInterval = heartbeatInterval;
2225
2410
  },
2226
2411
  onClose(_event, ws) {
2227
2412
  cleanup(ws);