@sylphx/lens-server 1.11.2 → 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.
@@ -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
+ }