@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.
- package/dist/api-key-auth.d.ts +49 -0
- package/dist/api-key-auth.d.ts.map +1 -0
- package/dist/api-key-auth.js +110 -0
- package/dist/api-key-auth.js.map +1 -0
- package/dist/blobs.d.ts +69 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +383 -0
- package/dist/blobs.js.map +1 -0
- package/dist/console/index.d.ts +8 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +7 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/routes.d.ts +106 -0
- package/dist/console/routes.d.ts.map +1 -0
- package/dist/console/routes.js +1612 -0
- package/dist/console/routes.js.map +1 -0
- package/dist/console/schemas.d.ts +308 -0
- package/dist/console/schemas.d.ts.map +1 -0
- package/dist/console/schemas.js +201 -0
- package/dist/console/schemas.js.map +1 -0
- package/dist/create-server.d.ts +78 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +99 -0
- package/dist/create-server.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/openapi.d.ts +45 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +59 -0
- package/dist/openapi.js.map +1 -0
- package/dist/proxy/connection-manager.d.ts +78 -0
- package/dist/proxy/connection-manager.d.ts.map +1 -0
- package/dist/proxy/connection-manager.js +251 -0
- package/dist/proxy/connection-manager.js.map +1 -0
- package/dist/proxy/index.d.ts +8 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +8 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/routes.d.ts +74 -0
- package/dist/proxy/routes.d.ts.map +1 -0
- package/dist/proxy/routes.js +147 -0
- package/dist/proxy/routes.js.map +1 -0
- package/dist/rate-limit.d.ts +101 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +186 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/routes.d.ts +126 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +788 -0
- package/dist/routes.js.map +1 -0
- package/dist/ws.d.ts +230 -0
- package/dist/ws.d.ts.map +1 -0
- package/dist/ws.js +601 -0
- package/dist/ws.js.map +1 -0
- package/package.json +73 -0
- package/src/__tests__/create-server.test.ts +187 -0
- package/src/__tests__/pull-chunk-storage.test.ts +189 -0
- package/src/__tests__/rate-limit.test.ts +78 -0
- package/src/__tests__/realtime-bridge.test.ts +131 -0
- package/src/__tests__/ws-connection-manager.test.ts +176 -0
- package/src/api-key-auth.ts +179 -0
- package/src/blobs.ts +534 -0
- package/src/console/index.ts +17 -0
- package/src/console/routes.ts +2155 -0
- package/src/console/schemas.ts +299 -0
- package/src/create-server.ts +180 -0
- package/src/index.ts +42 -0
- package/src/openapi.ts +74 -0
- package/src/proxy/connection-manager.ts +340 -0
- package/src/proxy/index.ts +8 -0
- package/src/proxy/routes.ts +223 -0
- package/src/rate-limit.ts +321 -0
- package/src/routes.ts +1186 -0
- 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,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
|
+
}
|