@sylphx/lens-server 1.0.2

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,185 @@
1
+ /**
2
+ * @sylphx/lens-server - SSE Transport Adapter
3
+ *
4
+ * Thin transport adapter for Server-Sent Events.
5
+ * Connects SSE streams to GraphStateManager.
6
+ */
7
+
8
+ import type { GraphStateManager, StateClient } from "../state/graph-state-manager";
9
+
10
+ // =============================================================================
11
+ // Types
12
+ // =============================================================================
13
+
14
+ /** SSE handler configuration */
15
+ export interface SSEHandlerConfig {
16
+ /** GraphStateManager instance (required) */
17
+ stateManager: GraphStateManager;
18
+ /** Heartbeat interval in ms (default: 30000) */
19
+ heartbeatInterval?: number;
20
+ }
21
+
22
+ /** SSE client info */
23
+ export interface SSEClientInfo {
24
+ id: string;
25
+ connectedAt: number;
26
+ }
27
+
28
+ // =============================================================================
29
+ // SSE Handler (Transport Adapter)
30
+ // =============================================================================
31
+
32
+ /**
33
+ * SSE transport adapter for GraphStateManager.
34
+ *
35
+ * This is a thin adapter that:
36
+ * - Creates SSE connections
37
+ * - Registers clients with GraphStateManager
38
+ * - Forwards updates to SSE streams
39
+ *
40
+ * All state/subscription logic is handled by GraphStateManager.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * const stateManager = new GraphStateManager();
45
+ * const sse = new SSEHandler({ stateManager });
46
+ *
47
+ * // Handle SSE connection
48
+ * app.get('/events', (req) => sse.handleConnection(req));
49
+ *
50
+ * // Subscribe via separate endpoint or message
51
+ * stateManager.subscribe(clientId, "Post", "123", "*");
52
+ * ```
53
+ */
54
+ export class SSEHandler {
55
+ private stateManager: GraphStateManager;
56
+ private heartbeatInterval: number;
57
+ private clients = new Map<
58
+ string,
59
+ { controller: ReadableStreamDefaultController; heartbeat: ReturnType<typeof setInterval> }
60
+ >();
61
+ private clientCounter = 0;
62
+
63
+ constructor(config: SSEHandlerConfig) {
64
+ this.stateManager = config.stateManager;
65
+ this.heartbeatInterval = config.heartbeatInterval ?? 30000;
66
+ }
67
+
68
+ /**
69
+ * Handle new SSE connection
70
+ * Returns a Response with SSE stream
71
+ */
72
+ handleConnection(req?: Request): Response {
73
+ const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
74
+ const encoder = new TextEncoder();
75
+
76
+ const stream = new ReadableStream({
77
+ start: (controller) => {
78
+ // Register with GraphStateManager
79
+ const stateClient: StateClient = {
80
+ id: clientId,
81
+ send: (msg) => {
82
+ try {
83
+ const data = `data: ${JSON.stringify(msg)}\n\n`;
84
+ controller.enqueue(encoder.encode(data));
85
+ } catch {
86
+ // Connection closed
87
+ this.removeClient(clientId);
88
+ }
89
+ },
90
+ };
91
+ this.stateManager.addClient(stateClient);
92
+
93
+ // Send connected event
94
+ controller.enqueue(
95
+ encoder.encode(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`),
96
+ );
97
+
98
+ // Setup heartbeat
99
+ const heartbeat = setInterval(() => {
100
+ try {
101
+ controller.enqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`));
102
+ } catch {
103
+ this.removeClient(clientId);
104
+ }
105
+ }, this.heartbeatInterval);
106
+
107
+ // Track client
108
+ this.clients.set(clientId, { controller, heartbeat });
109
+ },
110
+ cancel: () => {
111
+ this.removeClient(clientId);
112
+ },
113
+ });
114
+
115
+ return new Response(stream, {
116
+ headers: {
117
+ "Content-Type": "text/event-stream",
118
+ "Cache-Control": "no-cache",
119
+ Connection: "keep-alive",
120
+ "Access-Control-Allow-Origin": "*",
121
+ },
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Remove client and cleanup
127
+ */
128
+ private removeClient(clientId: string): void {
129
+ const client = this.clients.get(clientId);
130
+ if (client) {
131
+ clearInterval(client.heartbeat);
132
+ this.clients.delete(clientId);
133
+ }
134
+ this.stateManager.removeClient(clientId);
135
+ }
136
+
137
+ /**
138
+ * Close specific client connection
139
+ */
140
+ closeClient(clientId: string): void {
141
+ const client = this.clients.get(clientId);
142
+ if (client) {
143
+ try {
144
+ client.controller.close();
145
+ } catch {
146
+ // Already closed
147
+ }
148
+ this.removeClient(clientId);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Get connected client count
154
+ */
155
+ getClientCount(): number {
156
+ return this.clients.size;
157
+ }
158
+
159
+ /**
160
+ * Get connected client IDs
161
+ */
162
+ getClientIds(): string[] {
163
+ return Array.from(this.clients.keys());
164
+ }
165
+
166
+ /**
167
+ * Close all connections
168
+ */
169
+ closeAll(): void {
170
+ for (const clientId of this.clients.keys()) {
171
+ this.closeClient(clientId);
172
+ }
173
+ }
174
+ }
175
+
176
+ // =============================================================================
177
+ // Factory
178
+ // =============================================================================
179
+
180
+ /**
181
+ * Create SSE handler (transport adapter)
182
+ */
183
+ export function createSSEHandler(config: SSEHandlerConfig): SSEHandler {
184
+ return new SSEHandler(config);
185
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Tests for GraphStateManager
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
6
+ import {
7
+ GraphStateManager,
8
+ type StateClient,
9
+ type StateUpdateMessage,
10
+ } from "./graph-state-manager";
11
+
12
+ describe("GraphStateManager", () => {
13
+ let manager: GraphStateManager;
14
+ let mockClient: StateClient & { messages: StateUpdateMessage[] };
15
+
16
+ beforeEach(() => {
17
+ manager = new GraphStateManager();
18
+ mockClient = {
19
+ id: "client-1",
20
+ messages: [],
21
+ send: mock((msg: StateUpdateMessage) => {
22
+ mockClient.messages.push(msg);
23
+ }),
24
+ };
25
+ manager.addClient(mockClient);
26
+ });
27
+
28
+ describe("client management", () => {
29
+ it("adds and removes clients", () => {
30
+ expect(manager.getStats().clients).toBe(1);
31
+
32
+ manager.removeClient("client-1");
33
+ expect(manager.getStats().clients).toBe(0);
34
+ });
35
+
36
+ it("handles removing non-existent client", () => {
37
+ expect(() => manager.removeClient("non-existent")).not.toThrow();
38
+ });
39
+ });
40
+
41
+ describe("subscription", () => {
42
+ it("subscribes client to entity", () => {
43
+ manager.subscribe("client-1", "Post", "123", ["title", "content"]);
44
+
45
+ expect(manager.hasSubscribers("Post", "123")).toBe(true);
46
+ });
47
+
48
+ it("unsubscribes client from entity", () => {
49
+ manager.subscribe("client-1", "Post", "123");
50
+ manager.unsubscribe("client-1", "Post", "123");
51
+
52
+ expect(manager.hasSubscribers("Post", "123")).toBe(false);
53
+ });
54
+
55
+ it("sends initial data when subscribing to existing state", () => {
56
+ // Emit data first
57
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
58
+
59
+ // Then subscribe
60
+ manager.subscribe("client-1", "Post", "123", ["title"]);
61
+
62
+ // Should receive initial data
63
+ expect(mockClient.messages.length).toBe(1);
64
+ expect(mockClient.messages[0]).toMatchObject({
65
+ type: "update",
66
+ entity: "Post",
67
+ id: "123",
68
+ });
69
+ expect(mockClient.messages[0].updates.title).toMatchObject({
70
+ strategy: "value",
71
+ data: "Hello",
72
+ });
73
+ });
74
+
75
+ it("subscribes to all fields with *", () => {
76
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
77
+ manager.subscribe("client-1", "Post", "123", "*");
78
+
79
+ expect(mockClient.messages.length).toBe(1);
80
+ expect(mockClient.messages[0].updates).toHaveProperty("title");
81
+ expect(mockClient.messages[0].updates).toHaveProperty("content");
82
+ });
83
+ });
84
+
85
+ describe("emit", () => {
86
+ it("updates canonical state", () => {
87
+ manager.emit("Post", "123", { title: "Hello" });
88
+
89
+ expect(manager.getState("Post", "123")).toEqual({ title: "Hello" });
90
+ });
91
+
92
+ it("merges partial updates by default", () => {
93
+ manager.emit("Post", "123", { title: "Hello" });
94
+ manager.emit("Post", "123", { content: "World" });
95
+
96
+ expect(manager.getState("Post", "123")).toEqual({
97
+ title: "Hello",
98
+ content: "World",
99
+ });
100
+ });
101
+
102
+ it("replaces state when replace option is true", () => {
103
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
104
+ manager.emit("Post", "123", { title: "New" }, { replace: true });
105
+
106
+ expect(manager.getState("Post", "123")).toEqual({ title: "New" });
107
+ });
108
+
109
+ it("pushes updates to subscribed clients", () => {
110
+ manager.subscribe("client-1", "Post", "123", "*");
111
+ mockClient.messages = []; // Clear initial subscription message
112
+
113
+ manager.emit("Post", "123", { title: "Hello" });
114
+
115
+ expect(mockClient.messages.length).toBe(1);
116
+ expect(mockClient.messages[0]).toMatchObject({
117
+ type: "update",
118
+ entity: "Post",
119
+ id: "123",
120
+ });
121
+ });
122
+
123
+ it("only sends updates for changed fields", () => {
124
+ manager.subscribe("client-1", "Post", "123", "*");
125
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
126
+ mockClient.messages = [];
127
+
128
+ // Emit same title, different content
129
+ manager.emit("Post", "123", { title: "Hello", content: "Updated" });
130
+
131
+ expect(mockClient.messages.length).toBe(1);
132
+ expect(mockClient.messages[0].updates).toHaveProperty("content");
133
+ expect(mockClient.messages[0].updates).not.toHaveProperty("title");
134
+ });
135
+
136
+ it("does not send if no fields changed", () => {
137
+ manager.subscribe("client-1", "Post", "123", "*");
138
+ manager.emit("Post", "123", { title: "Hello" });
139
+ mockClient.messages = [];
140
+
141
+ // Emit same data
142
+ manager.emit("Post", "123", { title: "Hello" });
143
+
144
+ expect(mockClient.messages.length).toBe(0);
145
+ });
146
+
147
+ it("only sends subscribed fields", () => {
148
+ manager.subscribe("client-1", "Post", "123", ["title"]);
149
+ mockClient.messages = [];
150
+
151
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
152
+
153
+ expect(mockClient.messages.length).toBe(1);
154
+ expect(mockClient.messages[0].updates).toHaveProperty("title");
155
+ expect(mockClient.messages[0].updates).not.toHaveProperty("content");
156
+ });
157
+
158
+ it("does not send to unsubscribed clients", () => {
159
+ const otherClient = {
160
+ id: "client-2",
161
+ messages: [] as StateUpdateMessage[],
162
+ send: mock((msg: StateUpdateMessage) => {
163
+ otherClient.messages.push(msg);
164
+ }),
165
+ };
166
+ manager.addClient(otherClient);
167
+
168
+ manager.subscribe("client-1", "Post", "123", "*");
169
+ // client-2 not subscribed
170
+
171
+ manager.emit("Post", "123", { title: "Hello" });
172
+
173
+ expect(mockClient.messages.length).toBe(1);
174
+ expect(otherClient.messages.length).toBe(0);
175
+ });
176
+ });
177
+
178
+ describe("update strategies", () => {
179
+ it("uses value strategy for short strings", () => {
180
+ manager.subscribe("client-1", "Post", "123", "*");
181
+ manager.emit("Post", "123", { title: "Hello" });
182
+ mockClient.messages = [];
183
+
184
+ manager.emit("Post", "123", { title: "World" });
185
+
186
+ expect(mockClient.messages[0].updates.title.strategy).toBe("value");
187
+ });
188
+
189
+ it("uses delta strategy for long strings with small changes", () => {
190
+ const longText = "A".repeat(200);
191
+ manager.subscribe("client-1", "Post", "123", "*");
192
+ manager.emit("Post", "123", { content: longText });
193
+ mockClient.messages = [];
194
+
195
+ manager.emit("Post", "123", { content: `${longText} appended` });
196
+
197
+ // Should use delta for efficient transfer
198
+ const update = mockClient.messages[0].updates.content;
199
+ expect(["delta", "value"]).toContain(update.strategy);
200
+ });
201
+
202
+ it("uses patch strategy for objects", () => {
203
+ manager.subscribe("client-1", "Post", "123", "*");
204
+ manager.emit("Post", "123", {
205
+ metadata: { views: 100, likes: 10, tags: ["a", "b"] },
206
+ });
207
+ mockClient.messages = [];
208
+
209
+ manager.emit("Post", "123", {
210
+ metadata: { views: 101, likes: 10, tags: ["a", "b"] },
211
+ });
212
+
213
+ const update = mockClient.messages[0].updates.metadata;
214
+ expect(["patch", "value"]).toContain(update.strategy);
215
+ });
216
+ });
217
+
218
+ describe("multiple clients", () => {
219
+ it("sends updates to all subscribed clients", () => {
220
+ const client2 = {
221
+ id: "client-2",
222
+ messages: [] as StateUpdateMessage[],
223
+ send: mock((msg: StateUpdateMessage) => {
224
+ client2.messages.push(msg);
225
+ }),
226
+ };
227
+ manager.addClient(client2);
228
+
229
+ manager.subscribe("client-1", "Post", "123", "*");
230
+ manager.subscribe("client-2", "Post", "123", "*");
231
+ mockClient.messages = [];
232
+ client2.messages = [];
233
+
234
+ manager.emit("Post", "123", { title: "Hello" });
235
+
236
+ expect(mockClient.messages.length).toBe(1);
237
+ expect(client2.messages.length).toBe(1);
238
+ });
239
+
240
+ it("tracks state independently per client", () => {
241
+ const client2 = {
242
+ id: "client-2",
243
+ messages: [] as StateUpdateMessage[],
244
+ send: mock((msg: StateUpdateMessage) => {
245
+ client2.messages.push(msg);
246
+ }),
247
+ };
248
+ manager.addClient(client2);
249
+
250
+ // Emit initial state
251
+ manager.emit("Post", "123", { title: "Hello", content: "World" });
252
+
253
+ // Subscribe clients at different times
254
+ manager.subscribe("client-1", "Post", "123", "*");
255
+ mockClient.messages = [];
256
+
257
+ // Emit update
258
+ manager.emit("Post", "123", { title: "Updated" });
259
+
260
+ // Now subscribe client-2 (should get current state)
261
+ manager.subscribe("client-2", "Post", "123", "*");
262
+
263
+ // client-1 got incremental update
264
+ expect(mockClient.messages.length).toBe(1);
265
+ expect(mockClient.messages[0].updates.title.data).toBe("Updated");
266
+
267
+ // client-2 got full current state
268
+ expect(client2.messages.length).toBe(1);
269
+ expect(client2.messages[0].updates.title.data).toBe("Updated");
270
+ expect(client2.messages[0].updates.content.data).toBe("World");
271
+ });
272
+ });
273
+
274
+ describe("cleanup", () => {
275
+ it("calls onEntityUnsubscribed when last client unsubscribes", () => {
276
+ const onUnsubscribe = mock(() => {});
277
+ const mgr = new GraphStateManager({
278
+ onEntityUnsubscribed: onUnsubscribe,
279
+ });
280
+
281
+ const client = {
282
+ id: "c1",
283
+ send: mock(() => {}),
284
+ };
285
+ mgr.addClient(client);
286
+ mgr.subscribe("c1", "Post", "123", "*");
287
+ mgr.unsubscribe("c1", "Post", "123");
288
+
289
+ expect(onUnsubscribe).toHaveBeenCalledWith("Post", "123");
290
+ });
291
+
292
+ it("cleans up subscriptions when client is removed", () => {
293
+ manager.subscribe("client-1", "Post", "123", "*");
294
+ manager.subscribe("client-1", "Post", "456", "*");
295
+
296
+ manager.removeClient("client-1");
297
+
298
+ expect(manager.hasSubscribers("Post", "123")).toBe(false);
299
+ expect(manager.hasSubscribers("Post", "456")).toBe(false);
300
+ });
301
+
302
+ it("clear() removes all state", () => {
303
+ manager.subscribe("client-1", "Post", "123", "*");
304
+ manager.emit("Post", "123", { title: "Hello" });
305
+
306
+ manager.clear();
307
+
308
+ expect(manager.getStats()).toEqual({
309
+ clients: 0,
310
+ entities: 0,
311
+ totalSubscriptions: 0,
312
+ });
313
+ });
314
+ });
315
+
316
+ describe("stats", () => {
317
+ it("returns correct stats", () => {
318
+ const client2 = { id: "client-2", send: mock(() => {}) };
319
+ manager.addClient(client2);
320
+
321
+ manager.emit("Post", "123", { title: "Hello" });
322
+ manager.emit("Post", "456", { title: "World" });
323
+
324
+ manager.subscribe("client-1", "Post", "123", "*");
325
+ manager.subscribe("client-1", "Post", "456", "*");
326
+ manager.subscribe("client-2", "Post", "123", "*");
327
+
328
+ const stats = manager.getStats();
329
+ expect(stats.clients).toBe(2);
330
+ expect(stats.entities).toBe(2);
331
+ expect(stats.totalSubscriptions).toBe(3);
332
+ });
333
+ });
334
+ });