@syncular/server-hono 0.0.1-60

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 (76) hide show
  1. package/dist/api-key-auth.d.ts +49 -0
  2. package/dist/api-key-auth.d.ts.map +1 -0
  3. package/dist/api-key-auth.js +110 -0
  4. package/dist/api-key-auth.js.map +1 -0
  5. package/dist/blobs.d.ts +69 -0
  6. package/dist/blobs.d.ts.map +1 -0
  7. package/dist/blobs.js +383 -0
  8. package/dist/blobs.js.map +1 -0
  9. package/dist/console/index.d.ts +8 -0
  10. package/dist/console/index.d.ts.map +1 -0
  11. package/dist/console/index.js +7 -0
  12. package/dist/console/index.js.map +1 -0
  13. package/dist/console/routes.d.ts +106 -0
  14. package/dist/console/routes.d.ts.map +1 -0
  15. package/dist/console/routes.js +1612 -0
  16. package/dist/console/routes.js.map +1 -0
  17. package/dist/console/schemas.d.ts +308 -0
  18. package/dist/console/schemas.d.ts.map +1 -0
  19. package/dist/console/schemas.js +201 -0
  20. package/dist/console/schemas.js.map +1 -0
  21. package/dist/create-server.d.ts +78 -0
  22. package/dist/create-server.d.ts.map +1 -0
  23. package/dist/create-server.js +99 -0
  24. package/dist/create-server.js.map +1 -0
  25. package/dist/index.d.ts +16 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +25 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/openapi.d.ts +45 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +59 -0
  32. package/dist/openapi.js.map +1 -0
  33. package/dist/proxy/connection-manager.d.ts +78 -0
  34. package/dist/proxy/connection-manager.d.ts.map +1 -0
  35. package/dist/proxy/connection-manager.js +251 -0
  36. package/dist/proxy/connection-manager.js.map +1 -0
  37. package/dist/proxy/index.d.ts +8 -0
  38. package/dist/proxy/index.d.ts.map +1 -0
  39. package/dist/proxy/index.js +8 -0
  40. package/dist/proxy/index.js.map +1 -0
  41. package/dist/proxy/routes.d.ts +74 -0
  42. package/dist/proxy/routes.d.ts.map +1 -0
  43. package/dist/proxy/routes.js +147 -0
  44. package/dist/proxy/routes.js.map +1 -0
  45. package/dist/rate-limit.d.ts +101 -0
  46. package/dist/rate-limit.d.ts.map +1 -0
  47. package/dist/rate-limit.js +186 -0
  48. package/dist/rate-limit.js.map +1 -0
  49. package/dist/routes.d.ts +126 -0
  50. package/dist/routes.d.ts.map +1 -0
  51. package/dist/routes.js +788 -0
  52. package/dist/routes.js.map +1 -0
  53. package/dist/ws.d.ts +230 -0
  54. package/dist/ws.d.ts.map +1 -0
  55. package/dist/ws.js +601 -0
  56. package/dist/ws.js.map +1 -0
  57. package/package.json +73 -0
  58. package/src/__tests__/create-server.test.ts +187 -0
  59. package/src/__tests__/pull-chunk-storage.test.ts +189 -0
  60. package/src/__tests__/rate-limit.test.ts +78 -0
  61. package/src/__tests__/realtime-bridge.test.ts +131 -0
  62. package/src/__tests__/ws-connection-manager.test.ts +176 -0
  63. package/src/api-key-auth.ts +179 -0
  64. package/src/blobs.ts +534 -0
  65. package/src/console/index.ts +17 -0
  66. package/src/console/routes.ts +2155 -0
  67. package/src/console/schemas.ts +299 -0
  68. package/src/create-server.ts +180 -0
  69. package/src/index.ts +42 -0
  70. package/src/openapi.ts +74 -0
  71. package/src/proxy/connection-manager.ts +340 -0
  72. package/src/proxy/index.ts +8 -0
  73. package/src/proxy/routes.ts +223 -0
  74. package/src/rate-limit.ts +321 -0
  75. package/src/routes.ts +1186 -0
  76. package/src/ws.ts +789 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * @syncular/server-hono - Proxy Connection Manager
3
+ *
4
+ * Manages WebSocket connections for the proxy.
5
+ */
6
+
7
+ import type {
8
+ ProxyHandshake,
9
+ ProxyMessage,
10
+ ProxyResponse,
11
+ } from '@syncular/core';
12
+ import type {
13
+ ExecuteProxyQueryResult,
14
+ ProxyTableRegistry,
15
+ ServerSyncDialect,
16
+ SyncCoreDb,
17
+ } from '@syncular/server';
18
+ import { executeProxyQuery } from '@syncular/server';
19
+ import type { WSContext } from 'hono/ws';
20
+ import type { Kysely, Transaction } from 'kysely';
21
+
22
+ export interface ProxyConnectionManagerConfig<
23
+ DB extends SyncCoreDb = SyncCoreDb,
24
+ > {
25
+ /** Database connection */
26
+ db: Kysely<DB>;
27
+ /** Server sync dialect */
28
+ dialect: ServerSyncDialect;
29
+ /** Proxy table registry for oplog generation */
30
+ shapes: ProxyTableRegistry;
31
+ /** Maximum concurrent connections (default: 100) */
32
+ maxConnections?: number;
33
+ /** Idle connection timeout in ms (default: 30000) */
34
+ idleTimeoutMs?: number;
35
+ }
36
+
37
+ interface ProxyConnectionState<DB extends SyncCoreDb> {
38
+ ws: WSContext;
39
+ actorId: string;
40
+ clientId: string;
41
+ transaction: Transaction<DB> | null;
42
+ lastActivity: number;
43
+ idleTimer: ReturnType<typeof setTimeout> | null;
44
+ /** Transaction promise resolve callback */
45
+ __resolveTransaction?: () => void;
46
+ /** Transaction promise reject callback */
47
+ __rejectTransaction?: (error: Error) => void;
48
+ }
49
+
50
+ /**
51
+ * Manages proxy WebSocket connections and their state.
52
+ */
53
+ export class ProxyConnectionManager<DB extends SyncCoreDb = SyncCoreDb> {
54
+ private connections = new Map<WSContext, ProxyConnectionState<DB>>();
55
+ private config: ProxyConnectionManagerConfig<DB>;
56
+ private idleTimeoutMs: number;
57
+ private maxConnections: number;
58
+
59
+ constructor(config: ProxyConnectionManagerConfig<DB>) {
60
+ this.config = config;
61
+ this.idleTimeoutMs = config.idleTimeoutMs ?? 30000;
62
+ this.maxConnections = config.maxConnections ?? 100;
63
+ }
64
+
65
+ /**
66
+ * Check if a new connection can be accepted.
67
+ */
68
+ canAccept(): boolean {
69
+ return this.connections.size < this.maxConnections;
70
+ }
71
+
72
+ /**
73
+ * Get the current connection count.
74
+ */
75
+ getConnectionCount(): number {
76
+ return this.connections.size;
77
+ }
78
+
79
+ /**
80
+ * Handle the handshake message and register the connection.
81
+ */
82
+ register(ws: WSContext, handshake: ProxyHandshake): ProxyConnectionState<DB> {
83
+ const state: ProxyConnectionState<DB> = {
84
+ ws,
85
+ actorId: handshake.actorId,
86
+ clientId: handshake.clientId,
87
+ transaction: null,
88
+ lastActivity: Date.now(),
89
+ idleTimer: null,
90
+ };
91
+
92
+ this.connections.set(ws, state);
93
+ this.resetIdleTimer(state);
94
+
95
+ return state;
96
+ }
97
+
98
+ /**
99
+ * Get the connection state for a WebSocket.
100
+ */
101
+ get(ws: WSContext): ProxyConnectionState<DB> | undefined {
102
+ return this.connections.get(ws);
103
+ }
104
+
105
+ /**
106
+ * Unregister and cleanup a connection.
107
+ */
108
+ async unregister(ws: WSContext): Promise<void> {
109
+ const state = this.connections.get(ws);
110
+ if (!state) return;
111
+
112
+ // Clear idle timer
113
+ if (state.idleTimer) {
114
+ clearTimeout(state.idleTimer);
115
+ }
116
+
117
+ // Rollback any pending transaction by rejecting the promise
118
+ if (state.transaction) {
119
+ const rejectTransaction = state.__rejectTransaction;
120
+ if (rejectTransaction) {
121
+ rejectTransaction(new Error('Connection closed'));
122
+ }
123
+ state.transaction = null;
124
+ state.__resolveTransaction = undefined;
125
+ state.__rejectTransaction = undefined;
126
+ }
127
+
128
+ this.connections.delete(ws);
129
+ }
130
+
131
+ /**
132
+ * Handle a proxy message and return the response.
133
+ */
134
+ async handleMessage(
135
+ ws: WSContext,
136
+ message: ProxyMessage
137
+ ): Promise<ProxyResponse> {
138
+ const state = this.connections.get(ws);
139
+ if (!state) {
140
+ return {
141
+ id: message.id,
142
+ type: 'error',
143
+ error: 'Connection not registered',
144
+ };
145
+ }
146
+
147
+ // Update activity and reset idle timer
148
+ state.lastActivity = Date.now();
149
+ this.resetIdleTimer(state);
150
+
151
+ try {
152
+ switch (message.type) {
153
+ case 'begin':
154
+ return await this.handleBegin(state, message);
155
+
156
+ case 'commit':
157
+ return await this.handleCommit(state, message);
158
+
159
+ case 'rollback':
160
+ return await this.handleRollback(state, message);
161
+
162
+ case 'query':
163
+ return await this.handleQuery(state, message);
164
+
165
+ default:
166
+ return {
167
+ id: message.id,
168
+ type: 'error',
169
+ error: `Unknown message type: ${message.type}`,
170
+ };
171
+ }
172
+ } catch (err) {
173
+ return {
174
+ id: message.id,
175
+ type: 'error',
176
+ error: err instanceof Error ? err.message : 'Unknown error',
177
+ };
178
+ }
179
+ }
180
+
181
+ private async handleBegin(
182
+ state: ProxyConnectionState<DB>,
183
+ message: ProxyMessage
184
+ ): Promise<ProxyResponse> {
185
+ if (state.transaction) {
186
+ return {
187
+ id: message.id,
188
+ type: 'error',
189
+ error: 'Transaction already in progress',
190
+ };
191
+ }
192
+
193
+ // Start a transaction and keep it open
194
+ // We use a workaround since Kysely doesn't expose raw transaction control
195
+ return new Promise((resolve) => {
196
+ this.config.db
197
+ .transaction()
198
+ .execute(async (trx) => {
199
+ state.transaction = trx;
200
+
201
+ // Wait for commit or rollback
202
+ return new Promise<void>((resolveTransaction, rejectTransaction) => {
203
+ state.__resolveTransaction = resolveTransaction;
204
+ state.__rejectTransaction = rejectTransaction;
205
+ resolve({
206
+ id: message.id,
207
+ type: 'result',
208
+ });
209
+ });
210
+ })
211
+ .catch(() => {
212
+ // Transaction was rolled back externally
213
+ });
214
+ });
215
+ }
216
+
217
+ private async handleCommit(
218
+ state: ProxyConnectionState<DB>,
219
+ message: ProxyMessage
220
+ ): Promise<ProxyResponse> {
221
+ if (!state.transaction) {
222
+ return {
223
+ id: message.id,
224
+ type: 'error',
225
+ error: 'No transaction in progress',
226
+ };
227
+ }
228
+
229
+ // Resolve the transaction promise to commit
230
+ const resolveTransaction = state.__resolveTransaction;
231
+ if (resolveTransaction) {
232
+ resolveTransaction();
233
+ }
234
+ state.transaction = null;
235
+ state.__resolveTransaction = undefined;
236
+ state.__rejectTransaction = undefined;
237
+
238
+ return {
239
+ id: message.id,
240
+ type: 'result',
241
+ };
242
+ }
243
+
244
+ private async handleRollback(
245
+ state: ProxyConnectionState<DB>,
246
+ message: ProxyMessage
247
+ ): Promise<ProxyResponse> {
248
+ if (!state.transaction) {
249
+ return {
250
+ id: message.id,
251
+ type: 'error',
252
+ error: 'No transaction in progress',
253
+ };
254
+ }
255
+
256
+ // Reject the transaction promise to trigger rollback
257
+ const rejectTransaction = state.__rejectTransaction;
258
+ if (rejectTransaction) {
259
+ rejectTransaction(new Error('Transaction rolled back'));
260
+ }
261
+ state.transaction = null;
262
+ state.__resolveTransaction = undefined;
263
+ state.__rejectTransaction = undefined;
264
+
265
+ return {
266
+ id: message.id,
267
+ type: 'result',
268
+ };
269
+ }
270
+
271
+ private async handleQuery(
272
+ state: ProxyConnectionState<DB>,
273
+ message: ProxyMessage
274
+ ): Promise<ProxyResponse> {
275
+ if (!message.sql) {
276
+ return {
277
+ id: message.id,
278
+ type: 'error',
279
+ error: 'Missing SQL query',
280
+ };
281
+ }
282
+
283
+ const db = state.transaction ?? this.config.db;
284
+
285
+ const result: ExecuteProxyQueryResult = await executeProxyQuery({
286
+ db,
287
+ dialect: this.config.dialect,
288
+ shapes: this.config.shapes,
289
+ ctx: {
290
+ actorId: state.actorId,
291
+ clientId: state.clientId,
292
+ },
293
+ sqlQuery: message.sql,
294
+ parameters: message.parameters ?? [],
295
+ });
296
+
297
+ return {
298
+ id: message.id,
299
+ type: 'result',
300
+ rows: result.rows,
301
+ rowCount: result.rowCount,
302
+ };
303
+ }
304
+
305
+ private resetIdleTimer(state: ProxyConnectionState<DB>): void {
306
+ if (state.idleTimer) {
307
+ clearTimeout(state.idleTimer);
308
+ }
309
+
310
+ if (this.idleTimeoutMs <= 0) return;
311
+
312
+ state.idleTimer = setTimeout(() => {
313
+ // Close idle connection
314
+ try {
315
+ state.ws.close(4000, 'Idle timeout');
316
+ } catch {
317
+ // Ignore close errors
318
+ }
319
+ this.unregister(state.ws);
320
+ }, this.idleTimeoutMs);
321
+ }
322
+
323
+ /**
324
+ * Close all connections.
325
+ */
326
+ async closeAll(): Promise<void> {
327
+ const promises: Promise<void>[] = [];
328
+
329
+ for (const [ws] of this.connections) {
330
+ try {
331
+ ws.close(1000, 'Server shutdown');
332
+ } catch {
333
+ // Ignore close errors
334
+ }
335
+ promises.push(this.unregister(ws));
336
+ }
337
+
338
+ await Promise.all(promises);
339
+ }
340
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @syncular/server-hono - Proxy Exports
3
+ *
4
+ * WebSocket endpoint for admin database proxy.
5
+ */
6
+
7
+ export * from './connection-manager';
8
+ export * from './routes';
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @syncular/server-hono - Proxy Routes
3
+ *
4
+ * WebSocket endpoint for database proxy.
5
+ */
6
+
7
+ import type {
8
+ ProxyHandshake,
9
+ ProxyHandshakeAck,
10
+ ProxyMessage,
11
+ ProxyResponse,
12
+ } from '@syncular/core';
13
+ import { logSyncEvent } from '@syncular/core';
14
+ import type {
15
+ ProxyTableRegistry,
16
+ ServerSyncDialect,
17
+ SyncCoreDb,
18
+ } from '@syncular/server';
19
+ import type { Context } from 'hono';
20
+ import { Hono } from 'hono';
21
+ import type { UpgradeWebSocket, WSContext } from 'hono/ws';
22
+ import type { Kysely } from 'kysely';
23
+ import { ProxyConnectionManager } from './connection-manager';
24
+
25
+ /**
26
+ * WeakMap for storing proxy connection manager per Hono instance.
27
+ */
28
+ interface ProxyConnectionManagerHandle {
29
+ canAccept(): boolean;
30
+ getConnectionCount(): number;
31
+ register(ws: WSContext, handshake: ProxyHandshake): unknown;
32
+ handleMessage(ws: WSContext, message: ProxyMessage): Promise<ProxyResponse>;
33
+ unregister(ws: WSContext): Promise<void>;
34
+ }
35
+
36
+ const proxyConnectionManagerMap = new WeakMap<
37
+ Hono,
38
+ ProxyConnectionManagerHandle
39
+ >();
40
+
41
+ interface ProxyAuthResult {
42
+ /** Actor ID for oplog tracking */
43
+ actorId: string;
44
+ }
45
+
46
+ interface CreateProxyRoutesConfig<DB extends SyncCoreDb = SyncCoreDb> {
47
+ /** Database connection */
48
+ db: Kysely<DB>;
49
+ /** Server sync dialect */
50
+ dialect: ServerSyncDialect;
51
+ /** Proxy table registry for oplog generation */
52
+ shapes: ProxyTableRegistry;
53
+ /** Authenticate the request and return actor info */
54
+ authenticate: (c: Context) => Promise<ProxyAuthResult | null>;
55
+ /** WebSocket upgrade function from Hono */
56
+ upgradeWebSocket: UpgradeWebSocket;
57
+ /** Maximum concurrent connections (default: 100) */
58
+ maxConnections?: number;
59
+ /** Idle connection timeout in ms (default: 30000) */
60
+ idleTimeoutMs?: number;
61
+ }
62
+
63
+ /**
64
+ * Create Hono routes for the proxy WebSocket endpoint.
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * import { Hono } from 'hono';
69
+ * import { createBunWebSocket } from 'hono/bun';
70
+ * import { createProxyRoutes } from '@syncular/server-hono/proxy';
71
+ *
72
+ * const { upgradeWebSocket, websocket } = createBunWebSocket();
73
+ *
74
+ * const app = new Hono();
75
+ *
76
+ * app.route('/proxy', createProxyRoutes({
77
+ * db,
78
+ * shapes: proxyTableRegistry,
79
+ * authenticate: async (c) => {
80
+ * // Verify admin auth
81
+ * return { actorId: 'admin:123' };
82
+ * },
83
+ * upgradeWebSocket,
84
+ * }));
85
+ *
86
+ * export default { fetch: app.fetch, websocket };
87
+ * ```
88
+ */
89
+ export function createProxyRoutes<DB extends SyncCoreDb>(
90
+ config: CreateProxyRoutesConfig<DB>
91
+ ): Hono {
92
+ const app = new Hono();
93
+
94
+ const manager = new ProxyConnectionManager({
95
+ db: config.db,
96
+ dialect: config.dialect,
97
+ shapes: config.shapes,
98
+ maxConnections: config.maxConnections,
99
+ idleTimeoutMs: config.idleTimeoutMs,
100
+ });
101
+
102
+ // Store manager for external access if needed
103
+ proxyConnectionManagerMap.set(app, manager);
104
+
105
+ // WebSocket upgrade endpoint - using regular route since WebSocket doesn't fit OpenAPI well
106
+ app.get('/', async (c) => {
107
+ // Authenticate before upgrade
108
+ const auth = await config.authenticate(c);
109
+ if (!auth) {
110
+ return c.json({ error: 'UNAUTHENTICATED' }, 401);
111
+ }
112
+
113
+ // Check connection limit
114
+ if (!manager.canAccept()) {
115
+ logSyncEvent({
116
+ event: 'proxy.rejected',
117
+ userId: auth.actorId,
118
+ reason: 'max_connections',
119
+ });
120
+ return c.json({ error: 'PROXY_CONNECTION_LIMIT' }, 429);
121
+ }
122
+
123
+ logSyncEvent({
124
+ event: 'proxy.connect',
125
+ userId: auth.actorId,
126
+ });
127
+
128
+ return config.upgradeWebSocket(c, {
129
+ onOpen(_evt, _ws) {
130
+ // Connection opened, wait for handshake message
131
+ },
132
+
133
+ async onMessage(evt, ws) {
134
+ try {
135
+ const data =
136
+ typeof evt.data === 'string'
137
+ ? evt.data
138
+ : new TextDecoder().decode(evt.data as ArrayBuffer);
139
+
140
+ const message = JSON.parse(data);
141
+
142
+ // Handle handshake
143
+ if (message.type === 'handshake') {
144
+ const handshake = message as ProxyHandshake;
145
+
146
+ // Validate that the handshake actor matches authenticated actor
147
+ if (handshake.actorId !== auth.actorId) {
148
+ const ack: ProxyHandshakeAck = {
149
+ type: 'handshake_ack',
150
+ ok: false,
151
+ error: 'Actor ID mismatch',
152
+ };
153
+ ws.send(JSON.stringify(ack));
154
+ ws.close(4001, 'Unauthorized');
155
+ return;
156
+ }
157
+
158
+ manager.register(ws, handshake);
159
+
160
+ const ack: ProxyHandshakeAck = {
161
+ type: 'handshake_ack',
162
+ ok: true,
163
+ };
164
+ ws.send(JSON.stringify(ack));
165
+ return;
166
+ }
167
+
168
+ // Handle proxy messages
169
+ const proxyMessage = message as ProxyMessage;
170
+ const response = await manager.handleMessage(ws, proxyMessage);
171
+ ws.send(JSON.stringify(response));
172
+ } catch (err) {
173
+ // Send error response if we can parse the message ID
174
+ try {
175
+ const parsed = JSON.parse(
176
+ typeof evt.data === 'string'
177
+ ? evt.data
178
+ : new TextDecoder().decode(evt.data as ArrayBuffer)
179
+ );
180
+ if (parsed.id) {
181
+ ws.send(
182
+ JSON.stringify({
183
+ id: parsed.id,
184
+ type: 'error',
185
+ error: err instanceof Error ? err.message : 'Unknown error',
186
+ })
187
+ );
188
+ }
189
+ } catch {
190
+ // Ignore parse errors
191
+ }
192
+ }
193
+ },
194
+
195
+ async onClose(_evt, ws) {
196
+ await manager.unregister(ws);
197
+ logSyncEvent({
198
+ event: 'proxy.disconnect',
199
+ userId: auth.actorId,
200
+ });
201
+ },
202
+
203
+ async onError(_evt, ws) {
204
+ await manager.unregister(ws);
205
+ logSyncEvent({
206
+ event: 'proxy.error',
207
+ userId: auth.actorId,
208
+ });
209
+ },
210
+ });
211
+ });
212
+
213
+ return app;
214
+ }
215
+
216
+ /**
217
+ * Get the ProxyConnectionManager from a proxy routes instance.
218
+ */
219
+ export function getProxyConnectionManager(
220
+ routes: Hono
221
+ ): ProxyConnectionManagerHandle | undefined {
222
+ return proxyConnectionManagerMap.get(routes);
223
+ }