@sylphx/lens-server 1.11.3 → 2.0.1
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/index.d.ts +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - WebSocket Handler
|
|
3
|
+
*
|
|
4
|
+
* Pure protocol handler for WebSocket connections.
|
|
5
|
+
* Translates WebSocket messages to server calls and delivers responses.
|
|
6
|
+
*
|
|
7
|
+
* All business logic (state management, diff computation, plugin hooks)
|
|
8
|
+
* is handled by the server. The handler is just a delivery mechanism.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* // Stateless mode (sends full data)
|
|
13
|
+
* const app = createApp({ router });
|
|
14
|
+
* const wsHandler = createWSHandler(app);
|
|
15
|
+
*
|
|
16
|
+
* // Stateful mode - add plugin at server level
|
|
17
|
+
* const app = createApp({
|
|
18
|
+
* router,
|
|
19
|
+
* plugins: [clientState()],
|
|
20
|
+
* });
|
|
21
|
+
* const wsHandler = createWSHandler(app);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ReconnectMessage, ReconnectSubscription } from "@sylphx/lens-core";
|
|
26
|
+
import type { LensServer, WebSocketLike } from "../server/create.js";
|
|
27
|
+
import type {
|
|
28
|
+
ClientConnection,
|
|
29
|
+
ClientMessage,
|
|
30
|
+
ClientSubscription,
|
|
31
|
+
HandshakeMessage,
|
|
32
|
+
MutationMessage,
|
|
33
|
+
QueryMessage,
|
|
34
|
+
SubscribeMessage,
|
|
35
|
+
UnsubscribeMessage,
|
|
36
|
+
UpdateFieldsMessage,
|
|
37
|
+
WSHandler,
|
|
38
|
+
WSHandlerOptions,
|
|
39
|
+
} from "./ws-types.js";
|
|
40
|
+
|
|
41
|
+
// Re-export types for external use
|
|
42
|
+
export type { WSHandler, WSHandlerOptions } from "./ws-types.js";
|
|
43
|
+
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// WebSocket Handler Factory
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a WebSocket handler from a Lens app.
|
|
50
|
+
*
|
|
51
|
+
* The handler is a pure protocol translator - all business logic is in the server.
|
|
52
|
+
* State management is controlled by server plugins (e.g., clientState).
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* import { createApp, createWSHandler, clientState } from '@sylphx/lens-server'
|
|
57
|
+
*
|
|
58
|
+
* // Stateless mode (default) - sends full data
|
|
59
|
+
* const app = createApp({ router });
|
|
60
|
+
* const wsHandler = createWSHandler(app);
|
|
61
|
+
*
|
|
62
|
+
* // Stateful mode - sends minimal diffs
|
|
63
|
+
* const appWithState = createApp({
|
|
64
|
+
* router,
|
|
65
|
+
* plugins: [clientState()],
|
|
66
|
+
* });
|
|
67
|
+
* const wsHandlerWithState = createWSHandler(appWithState);
|
|
68
|
+
*
|
|
69
|
+
* // Bun
|
|
70
|
+
* Bun.serve({
|
|
71
|
+
* port: 3000,
|
|
72
|
+
* fetch: httpHandler,
|
|
73
|
+
* websocket: wsHandler.handler,
|
|
74
|
+
* })
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function createWSHandler(server: LensServer, options: WSHandlerOptions = {}): WSHandler {
|
|
78
|
+
const { logger = {} } = options;
|
|
79
|
+
|
|
80
|
+
// Connection tracking
|
|
81
|
+
const connections = new Map<string, ClientConnection>();
|
|
82
|
+
const wsToConnection = new WeakMap<object, ClientConnection>();
|
|
83
|
+
let connectionCounter = 0;
|
|
84
|
+
|
|
85
|
+
// Handle new WebSocket connection
|
|
86
|
+
async function handleConnection(ws: WebSocketLike): Promise<void> {
|
|
87
|
+
const clientId = `client_${++connectionCounter}`;
|
|
88
|
+
|
|
89
|
+
const conn: ClientConnection = {
|
|
90
|
+
id: clientId,
|
|
91
|
+
ws,
|
|
92
|
+
subscriptions: new Map(),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
connections.set(clientId, conn);
|
|
96
|
+
wsToConnection.set(ws as object, conn);
|
|
97
|
+
|
|
98
|
+
// Register client with server (handles plugins + state manager)
|
|
99
|
+
const sendFn = (msg: unknown) => {
|
|
100
|
+
ws.send(JSON.stringify(msg));
|
|
101
|
+
};
|
|
102
|
+
const allowed = await server.addClient(clientId, sendFn);
|
|
103
|
+
if (!allowed) {
|
|
104
|
+
ws.close();
|
|
105
|
+
connections.delete(clientId);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Set up message and close handlers
|
|
110
|
+
ws.onmessage = (event) => {
|
|
111
|
+
handleMessage(conn, event.data as string);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onclose = () => {
|
|
115
|
+
handleDisconnect(conn);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Handle incoming message
|
|
120
|
+
function handleMessage(conn: ClientConnection, data: string): void {
|
|
121
|
+
try {
|
|
122
|
+
const message = JSON.parse(data) as ClientMessage;
|
|
123
|
+
|
|
124
|
+
switch (message.type) {
|
|
125
|
+
case "handshake":
|
|
126
|
+
handleHandshake(conn, message);
|
|
127
|
+
break;
|
|
128
|
+
case "subscribe":
|
|
129
|
+
handleSubscribe(conn, message);
|
|
130
|
+
break;
|
|
131
|
+
case "updateFields":
|
|
132
|
+
handleUpdateFields(conn, message);
|
|
133
|
+
break;
|
|
134
|
+
case "unsubscribe":
|
|
135
|
+
handleUnsubscribe(conn, message);
|
|
136
|
+
break;
|
|
137
|
+
case "query":
|
|
138
|
+
handleQuery(conn, message);
|
|
139
|
+
break;
|
|
140
|
+
case "mutation":
|
|
141
|
+
handleMutation(conn, message);
|
|
142
|
+
break;
|
|
143
|
+
case "reconnect":
|
|
144
|
+
handleReconnect(conn, message);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
conn.ws.send(
|
|
149
|
+
JSON.stringify({
|
|
150
|
+
type: "error",
|
|
151
|
+
error: { code: "PARSE_ERROR", message: String(error) },
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Handle handshake
|
|
158
|
+
function handleHandshake(conn: ClientConnection, message: HandshakeMessage): void {
|
|
159
|
+
const metadata = server.getMetadata();
|
|
160
|
+
conn.ws.send(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
type: "handshake",
|
|
163
|
+
id: message.id,
|
|
164
|
+
version: metadata.version,
|
|
165
|
+
operations: metadata.operations,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Handle subscribe
|
|
171
|
+
async function handleSubscribe(conn: ClientConnection, message: SubscribeMessage): Promise<void> {
|
|
172
|
+
const { id, operation, input, fields } = message;
|
|
173
|
+
|
|
174
|
+
// Execute query first to get data
|
|
175
|
+
let result: { data?: unknown; error?: Error };
|
|
176
|
+
try {
|
|
177
|
+
result = await server.execute({ path: operation, input });
|
|
178
|
+
|
|
179
|
+
if (result.error) {
|
|
180
|
+
conn.ws.send(
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
type: "error",
|
|
183
|
+
id,
|
|
184
|
+
error: { code: "EXECUTION_ERROR", message: result.error.message },
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
conn.ws.send(
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
type: "error",
|
|
193
|
+
id,
|
|
194
|
+
error: { code: "EXECUTION_ERROR", message: String(error) },
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Extract entities from result
|
|
201
|
+
const entities = result.data ? extractEntities(result.data) : [];
|
|
202
|
+
|
|
203
|
+
// Check for duplicate subscription ID - cleanup old one first
|
|
204
|
+
const existingSub = conn.subscriptions.get(id);
|
|
205
|
+
if (existingSub) {
|
|
206
|
+
// Cleanup old subscription
|
|
207
|
+
for (const cleanup of existingSub.cleanups) {
|
|
208
|
+
try {
|
|
209
|
+
cleanup();
|
|
210
|
+
} catch (e) {
|
|
211
|
+
logger.error?.("Cleanup error:", e);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Unsubscribe from server
|
|
215
|
+
server.unsubscribe({
|
|
216
|
+
clientId: conn.id,
|
|
217
|
+
subscriptionId: id,
|
|
218
|
+
operation: existingSub.operation,
|
|
219
|
+
entityKeys: Array.from(existingSub.entityKeys),
|
|
220
|
+
});
|
|
221
|
+
conn.subscriptions.delete(id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Create subscription tracking
|
|
225
|
+
const sub: ClientSubscription = {
|
|
226
|
+
id,
|
|
227
|
+
operation,
|
|
228
|
+
input,
|
|
229
|
+
fields,
|
|
230
|
+
entityKeys: new Set(entities.map(({ entity, entityId }) => `${entity}:${entityId}`)),
|
|
231
|
+
cleanups: [],
|
|
232
|
+
lastData: result.data,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Register subscriptions with server for each entity
|
|
236
|
+
for (const { entity, entityId, entityData } of entities) {
|
|
237
|
+
// Server handles plugin hooks and subscription tracking
|
|
238
|
+
const allowed = await server.subscribe({
|
|
239
|
+
clientId: conn.id,
|
|
240
|
+
subscriptionId: id,
|
|
241
|
+
operation,
|
|
242
|
+
input,
|
|
243
|
+
fields,
|
|
244
|
+
entity,
|
|
245
|
+
entityId,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!allowed) {
|
|
249
|
+
conn.ws.send(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
type: "error",
|
|
252
|
+
id,
|
|
253
|
+
error: { code: "SUBSCRIPTION_REJECTED", message: "Subscription rejected by plugin" },
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Send initial data through server (runs through plugin hooks)
|
|
260
|
+
await server.send(conn.id, id, entity, entityId, entityData, true);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
conn.subscriptions.set(id, sub);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Handle updateFields
|
|
267
|
+
// Note: This updates local tracking only. The server tracks fields via subscribe().
|
|
268
|
+
// Field updates affect future sends through the subscription context.
|
|
269
|
+
async function handleUpdateFields(
|
|
270
|
+
conn: ClientConnection,
|
|
271
|
+
message: UpdateFieldsMessage,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const sub = conn.subscriptions.get(message.id);
|
|
274
|
+
if (!sub) return;
|
|
275
|
+
|
|
276
|
+
const previousFields = sub.fields;
|
|
277
|
+
let newFields: string[] | "*";
|
|
278
|
+
|
|
279
|
+
// Handle upgrade to full subscription ("*")
|
|
280
|
+
if (message.addFields?.includes("*")) {
|
|
281
|
+
newFields = "*";
|
|
282
|
+
} else if (message.setFields !== undefined) {
|
|
283
|
+
// Handle downgrade from "*" to specific fields
|
|
284
|
+
newFields = message.setFields;
|
|
285
|
+
} else if (sub.fields === "*") {
|
|
286
|
+
// Already subscribing to all fields - no-op
|
|
287
|
+
return;
|
|
288
|
+
} else {
|
|
289
|
+
// Normal field add/remove
|
|
290
|
+
const fields = new Set(sub.fields);
|
|
291
|
+
|
|
292
|
+
if (message.addFields) {
|
|
293
|
+
for (const field of message.addFields) {
|
|
294
|
+
fields.add(field);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (message.removeFields) {
|
|
299
|
+
for (const field of message.removeFields) {
|
|
300
|
+
fields.delete(field);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
newFields = Array.from(fields);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Update adapter tracking
|
|
308
|
+
sub.fields = newFields;
|
|
309
|
+
|
|
310
|
+
// Notify server (runs plugin hooks)
|
|
311
|
+
for (const entityKey of sub.entityKeys) {
|
|
312
|
+
const [entity, entityId] = entityKey.split(":");
|
|
313
|
+
await server.updateFields({
|
|
314
|
+
clientId: conn.id,
|
|
315
|
+
subscriptionId: sub.id,
|
|
316
|
+
entity,
|
|
317
|
+
entityId,
|
|
318
|
+
fields: newFields,
|
|
319
|
+
previousFields,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Handle unsubscribe
|
|
325
|
+
function handleUnsubscribe(conn: ClientConnection, message: UnsubscribeMessage): void {
|
|
326
|
+
const sub = conn.subscriptions.get(message.id);
|
|
327
|
+
if (!sub) return;
|
|
328
|
+
|
|
329
|
+
// Cleanup
|
|
330
|
+
for (const cleanup of sub.cleanups) {
|
|
331
|
+
try {
|
|
332
|
+
cleanup();
|
|
333
|
+
} catch (e) {
|
|
334
|
+
logger.error?.("Cleanup error:", e);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
conn.subscriptions.delete(message.id);
|
|
339
|
+
|
|
340
|
+
// Server handles unsubscription (plugin hooks + state manager cleanup)
|
|
341
|
+
server.unsubscribe({
|
|
342
|
+
clientId: conn.id,
|
|
343
|
+
subscriptionId: message.id,
|
|
344
|
+
operation: sub.operation,
|
|
345
|
+
entityKeys: Array.from(sub.entityKeys),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Handle query
|
|
350
|
+
async function handleQuery(conn: ClientConnection, message: QueryMessage): Promise<void> {
|
|
351
|
+
try {
|
|
352
|
+
const result = await server.execute({
|
|
353
|
+
path: message.operation,
|
|
354
|
+
input: message.input,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (result.error) {
|
|
358
|
+
conn.ws.send(
|
|
359
|
+
JSON.stringify({
|
|
360
|
+
type: "error",
|
|
361
|
+
id: message.id,
|
|
362
|
+
error: { code: "EXECUTION_ERROR", message: result.error.message },
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Apply field selection if specified
|
|
369
|
+
const selected = message.fields ? applySelection(result.data, message.fields) : result.data;
|
|
370
|
+
|
|
371
|
+
conn.ws.send(
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
type: "result",
|
|
374
|
+
id: message.id,
|
|
375
|
+
data: selected,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
conn.ws.send(
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
type: "error",
|
|
382
|
+
id: message.id,
|
|
383
|
+
error: { code: "EXECUTION_ERROR", message: String(error) },
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Handle mutation
|
|
390
|
+
async function handleMutation(conn: ClientConnection, message: MutationMessage): Promise<void> {
|
|
391
|
+
try {
|
|
392
|
+
const result = await server.execute({
|
|
393
|
+
path: message.operation,
|
|
394
|
+
input: message.input,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (result.error) {
|
|
398
|
+
conn.ws.send(
|
|
399
|
+
JSON.stringify({
|
|
400
|
+
type: "error",
|
|
401
|
+
id: message.id,
|
|
402
|
+
error: { code: "EXECUTION_ERROR", message: result.error.message },
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Broadcast to all subscribers of affected entities
|
|
409
|
+
if (result.data) {
|
|
410
|
+
const entities = extractEntities(result.data);
|
|
411
|
+
for (const { entity, entityId, entityData } of entities) {
|
|
412
|
+
await server.broadcast(entity, entityId, entityData);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
conn.ws.send(
|
|
417
|
+
JSON.stringify({
|
|
418
|
+
type: "result",
|
|
419
|
+
id: message.id,
|
|
420
|
+
data: result.data,
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
conn.ws.send(
|
|
425
|
+
JSON.stringify({
|
|
426
|
+
type: "error",
|
|
427
|
+
id: message.id,
|
|
428
|
+
error: { code: "EXECUTION_ERROR", message: String(error) },
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Handle reconnect
|
|
435
|
+
async function handleReconnect(conn: ClientConnection, message: ReconnectMessage): Promise<void> {
|
|
436
|
+
const startTime = Date.now();
|
|
437
|
+
|
|
438
|
+
// Convert ReconnectMessage to ReconnectContext
|
|
439
|
+
const ctx = {
|
|
440
|
+
clientId: conn.id,
|
|
441
|
+
reconnectId: message.reconnectId,
|
|
442
|
+
subscriptions: message.subscriptions.map((sub: ReconnectSubscription) => {
|
|
443
|
+
const mapped: {
|
|
444
|
+
id: string;
|
|
445
|
+
entity: string;
|
|
446
|
+
entityId: string;
|
|
447
|
+
fields: string[] | "*";
|
|
448
|
+
version: number;
|
|
449
|
+
dataHash?: string;
|
|
450
|
+
input?: unknown;
|
|
451
|
+
} = {
|
|
452
|
+
id: sub.id,
|
|
453
|
+
entity: sub.entity,
|
|
454
|
+
entityId: sub.entityId,
|
|
455
|
+
fields: sub.fields,
|
|
456
|
+
version: sub.version,
|
|
457
|
+
};
|
|
458
|
+
if (sub.dataHash !== undefined) {
|
|
459
|
+
mapped.dataHash = sub.dataHash;
|
|
460
|
+
}
|
|
461
|
+
if (sub.input !== undefined) {
|
|
462
|
+
mapped.input = sub.input;
|
|
463
|
+
}
|
|
464
|
+
return mapped;
|
|
465
|
+
}),
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Check if server supports reconnection (via plugins)
|
|
469
|
+
const results = await server.handleReconnect(ctx);
|
|
470
|
+
|
|
471
|
+
if (results === null) {
|
|
472
|
+
conn.ws.send(
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
type: "error",
|
|
475
|
+
error: {
|
|
476
|
+
code: "RECONNECT_ERROR",
|
|
477
|
+
message: "State management not available for reconnection",
|
|
478
|
+
reconnectId: message.reconnectId,
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
// Re-establish subscriptions in adapter tracking
|
|
487
|
+
// (Server handles subscription registration via plugin hooks)
|
|
488
|
+
for (const sub of message.subscriptions) {
|
|
489
|
+
let clientSub = conn.subscriptions.get(sub.id);
|
|
490
|
+
if (!clientSub) {
|
|
491
|
+
clientSub = {
|
|
492
|
+
id: sub.id,
|
|
493
|
+
operation: "",
|
|
494
|
+
input: sub.input,
|
|
495
|
+
fields: sub.fields,
|
|
496
|
+
entityKeys: new Set([`${sub.entity}:${sub.entityId}`]),
|
|
497
|
+
cleanups: [],
|
|
498
|
+
lastData: null,
|
|
499
|
+
};
|
|
500
|
+
conn.subscriptions.set(sub.id, clientSub);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
conn.ws.send(
|
|
505
|
+
JSON.stringify({
|
|
506
|
+
type: "reconnect_ack",
|
|
507
|
+
results,
|
|
508
|
+
serverTime: Date.now(),
|
|
509
|
+
reconnectId: message.reconnectId,
|
|
510
|
+
processingTime: Date.now() - startTime,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
conn.ws.send(
|
|
515
|
+
JSON.stringify({
|
|
516
|
+
type: "error",
|
|
517
|
+
error: {
|
|
518
|
+
code: "RECONNECT_ERROR",
|
|
519
|
+
message: String(error),
|
|
520
|
+
reconnectId: message.reconnectId,
|
|
521
|
+
},
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Handle disconnect
|
|
528
|
+
function handleDisconnect(conn: ClientConnection): void {
|
|
529
|
+
const subscriptionCount = conn.subscriptions.size;
|
|
530
|
+
|
|
531
|
+
// Cleanup all subscriptions
|
|
532
|
+
for (const sub of conn.subscriptions.values()) {
|
|
533
|
+
for (const cleanup of sub.cleanups) {
|
|
534
|
+
try {
|
|
535
|
+
cleanup();
|
|
536
|
+
} catch (e) {
|
|
537
|
+
logger.error?.("Cleanup error:", e);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Remove connection
|
|
543
|
+
connections.delete(conn.id);
|
|
544
|
+
|
|
545
|
+
// Server handles removal (plugin hooks + state manager cleanup)
|
|
546
|
+
server.removeClient(conn.id, subscriptionCount);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Helper: Extract entities from data
|
|
550
|
+
function extractEntities(
|
|
551
|
+
data: unknown,
|
|
552
|
+
): Array<{ entity: string; entityId: string; entityData: Record<string, unknown> }> {
|
|
553
|
+
const results: Array<{
|
|
554
|
+
entity: string;
|
|
555
|
+
entityId: string;
|
|
556
|
+
entityData: Record<string, unknown>;
|
|
557
|
+
}> = [];
|
|
558
|
+
|
|
559
|
+
if (!data) return results;
|
|
560
|
+
|
|
561
|
+
if (Array.isArray(data)) {
|
|
562
|
+
for (const item of data) {
|
|
563
|
+
if (item && typeof item === "object" && "id" in item) {
|
|
564
|
+
const entityName = getEntityName(item);
|
|
565
|
+
results.push({
|
|
566
|
+
entity: entityName,
|
|
567
|
+
entityId: String((item as { id: unknown }).id),
|
|
568
|
+
entityData: item as Record<string, unknown>,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
} else if (typeof data === "object" && "id" in data) {
|
|
573
|
+
const entityName = getEntityName(data);
|
|
574
|
+
results.push({
|
|
575
|
+
entity: entityName,
|
|
576
|
+
entityId: String((data as { id: unknown }).id),
|
|
577
|
+
entityData: data as Record<string, unknown>,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return results;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Helper: Get entity name from data
|
|
585
|
+
function getEntityName(data: unknown): string {
|
|
586
|
+
if (!data || typeof data !== "object") return "unknown";
|
|
587
|
+
if ("__typename" in data) return String((data as { __typename: unknown }).__typename);
|
|
588
|
+
if ("_type" in data) return String((data as { _type: unknown })._type);
|
|
589
|
+
return "unknown";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Helper: Apply field selection
|
|
593
|
+
function applySelection(data: unknown, fields: string[] | "*"): unknown {
|
|
594
|
+
if (fields === "*" || !data) return data;
|
|
595
|
+
|
|
596
|
+
if (Array.isArray(data)) {
|
|
597
|
+
return data.map((item) => applySelectionToObject(item, fields));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return applySelectionToObject(data, fields);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function applySelectionToObject(data: unknown, fields: string[]): Record<string, unknown> | null {
|
|
604
|
+
if (!data || typeof data !== "object") return null;
|
|
605
|
+
|
|
606
|
+
const result: Record<string, unknown> = {};
|
|
607
|
+
const obj = data as Record<string, unknown>;
|
|
608
|
+
|
|
609
|
+
// Always include id
|
|
610
|
+
if ("id" in obj) {
|
|
611
|
+
result.id = obj.id;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
for (const field of fields) {
|
|
615
|
+
if (field in obj) {
|
|
616
|
+
result[field] = obj[field];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Create the handler
|
|
624
|
+
const handler: WSHandler = {
|
|
625
|
+
handleConnection,
|
|
626
|
+
|
|
627
|
+
handler: {
|
|
628
|
+
open(ws: unknown): void {
|
|
629
|
+
// For Bun, we need to handle connection via the fetch upgrade
|
|
630
|
+
// But if open is called, we can try to set up the connection
|
|
631
|
+
if (ws && typeof ws === "object" && "send" in ws) {
|
|
632
|
+
handleConnection(ws as WebSocketLike);
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
message(ws: unknown, message: string | Buffer): void {
|
|
637
|
+
const conn = wsToConnection.get(ws as object);
|
|
638
|
+
if (conn) {
|
|
639
|
+
handleMessage(conn, String(message));
|
|
640
|
+
} else if (ws && typeof ws === "object" && "send" in ws) {
|
|
641
|
+
// Connection not tracked yet, set it up
|
|
642
|
+
handleConnection(ws as WebSocketLike);
|
|
643
|
+
const newConn = wsToConnection.get(ws as object);
|
|
644
|
+
if (newConn) {
|
|
645
|
+
handleMessage(newConn, String(message));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
close(ws: unknown): void {
|
|
651
|
+
const conn = wsToConnection.get(ws as object);
|
|
652
|
+
if (conn) {
|
|
653
|
+
handleDisconnect(conn);
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
async close(): Promise<void> {
|
|
659
|
+
// Close all connections
|
|
660
|
+
for (const conn of connections.values()) {
|
|
661
|
+
handleDisconnect(conn);
|
|
662
|
+
conn.ws.close();
|
|
663
|
+
}
|
|
664
|
+
connections.clear();
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
return handler;
|
|
669
|
+
}
|