@sylphx/lens-server 1.11.3 → 2.1.0

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.
@@ -1,73 +1,96 @@
1
1
  /**
2
- * @sylphx/lens-server - SSE Transport Adapter
2
+ * @sylphx/lens-server - SSE Handler
3
3
  *
4
- * Thin transport adapter for Server-Sent Events.
5
- * Connects SSE streams to GraphStateManager.
4
+ * Pure transport handler for Server-Sent Events.
5
+ * No state management - just handles SSE connection lifecycle and message sending.
6
6
  */
7
7
 
8
- import type { GraphStateManager, StateClient } from "../state/graph-state-manager.js";
9
-
10
8
  // =============================================================================
11
9
  // Types
12
10
  // =============================================================================
13
11
 
14
12
  /** SSE handler configuration */
15
13
  export interface SSEHandlerConfig {
16
- /** GraphStateManager instance (required) */
17
- stateManager: GraphStateManager;
18
14
  /** Heartbeat interval in ms (default: 30000) */
19
15
  heartbeatInterval?: number;
16
+ /** Called when a client connects */
17
+ onConnect?: (client: SSEClient) => void;
18
+ /** Called when a client disconnects */
19
+ onDisconnect?: (clientId: string) => void;
20
20
  }
21
21
 
22
- /** SSE client info */
23
- export interface SSEClientInfo {
22
+ /** SSE client handle for sending messages */
23
+ export interface SSEClient {
24
+ /** Unique client ID */
24
25
  id: string;
25
- connectedAt: number;
26
+ /** Send a message to this client */
27
+ send: (message: unknown) => void;
28
+ /** Send a named event to this client */
29
+ sendEvent: (event: string, data: unknown) => void;
30
+ /** Close this client's connection */
31
+ close: () => void;
26
32
  }
27
33
 
28
34
  // =============================================================================
29
- // SSE Handler (Transport Adapter)
35
+ // SSE Handler
30
36
  // =============================================================================
31
37
 
32
38
  /**
33
- * SSE transport adapter for GraphStateManager.
39
+ * Pure SSE transport handler.
34
40
  *
35
- * This is a thin adapter that:
36
- * - Creates SSE connections
37
- * - Registers clients with GraphStateManager
38
- * - Forwards updates to SSE streams
41
+ * This handler ONLY manages:
42
+ * - SSE connection lifecycle
43
+ * - Message sending to clients
44
+ * - Heartbeat keepalive
39
45
  *
40
- * All state/subscription logic is handled by GraphStateManager.
46
+ * It does NOT know about:
47
+ * - State management
48
+ * - Subscriptions
49
+ * - Plugins
41
50
  *
42
51
  * @example
43
52
  * ```typescript
44
- * const stateManager = new GraphStateManager();
45
- * const sse = new SSEHandler({ stateManager });
53
+ * const sse = new SSEHandler({
54
+ * onConnect: (client) => {
55
+ * console.log('Client connected:', client.id);
56
+ * // Register with your state management here
57
+ * },
58
+ * onDisconnect: (clientId) => {
59
+ * console.log('Client disconnected:', clientId);
60
+ * // Cleanup your state management here
61
+ * },
62
+ * });
46
63
  *
47
64
  * // Handle SSE connection
48
65
  * app.get('/events', (req) => sse.handleConnection(req));
49
66
  *
50
- * // Subscribe via separate endpoint or message
51
- * stateManager.subscribe(clientId, "Post", "123", "*");
67
+ * // Send message to specific client
68
+ * sse.send(clientId, { type: 'update', data: {...} });
52
69
  * ```
53
70
  */
54
71
  export class SSEHandler {
55
- private stateManager: GraphStateManager;
56
72
  private heartbeatInterval: number;
73
+ private onConnectCallback: ((client: SSEClient) => void) | undefined;
74
+ private onDisconnectCallback: ((clientId: string) => void) | undefined;
57
75
  private clients = new Map<
58
76
  string,
59
- { controller: ReadableStreamDefaultController; heartbeat: ReturnType<typeof setInterval> }
77
+ {
78
+ controller: ReadableStreamDefaultController;
79
+ heartbeat: ReturnType<typeof setInterval>;
80
+ encoder: TextEncoder;
81
+ }
60
82
  >();
61
83
  private clientCounter = 0;
62
84
 
63
- constructor(config: SSEHandlerConfig) {
64
- this.stateManager = config.stateManager;
85
+ constructor(config: SSEHandlerConfig = {}) {
65
86
  this.heartbeatInterval = config.heartbeatInterval ?? 30000;
87
+ this.onConnectCallback = config.onConnect;
88
+ this.onDisconnectCallback = config.onDisconnect;
66
89
  }
67
90
 
68
91
  /**
69
- * Handle new SSE connection
70
- * Returns a Response with SSE stream
92
+ * Handle new SSE connection.
93
+ * Returns a Response with SSE stream.
71
94
  */
72
95
  handleConnection(_req?: Request): Response {
73
96
  const clientId = `sse_${++this.clientCounter}_${Date.now()}`;
@@ -75,26 +98,6 @@ export class SSEHandler {
75
98
 
76
99
  const stream = new ReadableStream({
77
100
  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
101
  // Setup heartbeat
99
102
  const heartbeat = setInterval(() => {
100
103
  try {
@@ -105,7 +108,21 @@ export class SSEHandler {
105
108
  }, this.heartbeatInterval);
106
109
 
107
110
  // Track client
108
- this.clients.set(clientId, { controller, heartbeat });
111
+ this.clients.set(clientId, { controller, heartbeat, encoder });
112
+
113
+ // Send connected event
114
+ controller.enqueue(
115
+ encoder.encode(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`),
116
+ );
117
+
118
+ // Create client handle and notify
119
+ const client: SSEClient = {
120
+ id: clientId,
121
+ send: (message: unknown) => this.send(clientId, message),
122
+ sendEvent: (event: string, data: unknown) => this.sendEvent(clientId, event, data),
123
+ close: () => this.closeClient(clientId),
124
+ };
125
+ this.onConnectCallback?.(client);
109
126
  },
110
127
  cancel: () => {
111
128
  this.removeClient(clientId);
@@ -123,19 +140,62 @@ export class SSEHandler {
123
140
  }
124
141
 
125
142
  /**
126
- * Remove client and cleanup
143
+ * Send a message to a specific client.
144
+ */
145
+ send(clientId: string, message: unknown): boolean {
146
+ const client = this.clients.get(clientId);
147
+ if (!client) return false;
148
+
149
+ try {
150
+ const data = `data: ${JSON.stringify(message)}\n\n`;
151
+ client.controller.enqueue(client.encoder.encode(data));
152
+ return true;
153
+ } catch {
154
+ this.removeClient(clientId);
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Send a named event to a specific client.
161
+ */
162
+ sendEvent(clientId: string, event: string, data: unknown): boolean {
163
+ const client = this.clients.get(clientId);
164
+ if (!client) return false;
165
+
166
+ try {
167
+ const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
168
+ client.controller.enqueue(client.encoder.encode(message));
169
+ return true;
170
+ } catch {
171
+ this.removeClient(clientId);
172
+ return false;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Broadcast a message to all connected clients.
178
+ */
179
+ broadcast(message: unknown): void {
180
+ for (const clientId of this.clients.keys()) {
181
+ this.send(clientId, message);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Remove client and cleanup.
127
187
  */
128
188
  private removeClient(clientId: string): void {
129
189
  const client = this.clients.get(clientId);
130
190
  if (client) {
131
191
  clearInterval(client.heartbeat);
132
192
  this.clients.delete(clientId);
193
+ this.onDisconnectCallback?.(clientId);
133
194
  }
134
- this.stateManager.removeClient(clientId);
135
195
  }
136
196
 
137
197
  /**
138
- * Close specific client connection
198
+ * Close specific client connection.
139
199
  */
140
200
  closeClient(clientId: string): void {
141
201
  const client = this.clients.get(clientId);
@@ -150,21 +210,28 @@ export class SSEHandler {
150
210
  }
151
211
 
152
212
  /**
153
- * Get connected client count
213
+ * Get connected client count.
154
214
  */
155
215
  getClientCount(): number {
156
216
  return this.clients.size;
157
217
  }
158
218
 
159
219
  /**
160
- * Get connected client IDs
220
+ * Get connected client IDs.
161
221
  */
162
222
  getClientIds(): string[] {
163
223
  return Array.from(this.clients.keys());
164
224
  }
165
225
 
166
226
  /**
167
- * Close all connections
227
+ * Check if a client is connected.
228
+ */
229
+ hasClient(clientId: string): boolean {
230
+ return this.clients.has(clientId);
231
+ }
232
+
233
+ /**
234
+ * Close all connections.
168
235
  */
169
236
  closeAll(): void {
170
237
  for (const clientId of this.clients.keys()) {
@@ -178,8 +245,8 @@ export class SSEHandler {
178
245
  // =============================================================================
179
246
 
180
247
  /**
181
- * Create SSE handler (transport adapter)
248
+ * Create SSE handler (pure transport).
182
249
  */
183
- export function createSSEHandler(config: SSEHandlerConfig): SSEHandler {
250
+ export function createSSEHandler(config: SSEHandlerConfig = {}): SSEHandler {
184
251
  return new SSEHandler(config);
185
252
  }
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * @sylphx/lens-server - State Management
3
3
  *
4
- * Server-side state management for reactive data sync.
4
+ * State management is handled by the storage layer.
5
+ * See: packages/server/src/storage/
6
+ *
7
+ * Available storage adapters:
8
+ * - memoryStorage() - In-memory (default)
9
+ * - redisStorage() - Redis via ioredis
10
+ * - upstashStorage() - Upstash Redis HTTP
11
+ * - vercelKVStorage() - Vercel KV
5
12
  */
6
13
 
7
- export {
8
- createGraphStateManager,
9
- type EntityKey,
10
- GraphStateManager,
11
- type GraphStateManagerConfig,
12
- type StateClient,
13
- type StateFullMessage,
14
- type StateUpdateMessage,
15
- type Subscription,
16
- } from "./graph-state-manager.js";
14
+ // No exports - state management moved to storage layer
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @sylphx/lens-server - Storage
3
+ *
4
+ * Storage adapters for opLog plugin.
5
+ *
6
+ * Built-in:
7
+ * - `memoryStorage()` - In-memory (default, for long-running servers)
8
+ *
9
+ * External packages (install separately):
10
+ * - `@sylphx/lens-storage-redis` - Redis via ioredis
11
+ * - `@sylphx/lens-storage-upstash` - Upstash Redis HTTP (serverless/edge)
12
+ * - `@sylphx/lens-storage-vercel-kv` - Vercel KV (Next.js/Vercel)
13
+ */
14
+
15
+ // In-memory (default)
16
+ export { memoryStorage } from "./memory.js";
17
+
18
+ // Types (for implementing custom storage adapters)
19
+ export {
20
+ DEFAULT_STORAGE_CONFIG,
21
+ type EmitResult,
22
+ type OpLogStorage,
23
+ type OpLogStorageConfig,
24
+ type StoredEntityState,
25
+ type StoredPatchEntry,
26
+ } from "./types.js";
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @sylphx/lens-server - Memory Storage
3
+ *
4
+ * In-memory storage adapter for opLog plugin.
5
+ * Default storage for long-running servers.
6
+ *
7
+ * Features:
8
+ * - O(1) state and version lookups
9
+ * - Bounded patch history per entity
10
+ * - Automatic cleanup of old patches
11
+ *
12
+ * Memory: O(entities × maxPatchesPerEntity)
13
+ */
14
+
15
+ import type { PatchOperation } from "@sylphx/lens-core";
16
+ import {
17
+ DEFAULT_STORAGE_CONFIG,
18
+ type EmitResult,
19
+ type OpLogStorage,
20
+ type OpLogStorageConfig,
21
+ type StoredPatchEntry,
22
+ } from "./types.js";
23
+
24
+ /**
25
+ * Entity key for internal storage.
26
+ */
27
+ type EntityKey = string;
28
+
29
+ /**
30
+ * Internal entity state.
31
+ */
32
+ interface EntityState {
33
+ data: Record<string, unknown>;
34
+ version: number;
35
+ patches: StoredPatchEntry[];
36
+ }
37
+
38
+ /**
39
+ * Create entity key from entity type and ID.
40
+ */
41
+ function makeKey(entity: string, entityId: string): EntityKey {
42
+ return `${entity}:${entityId}`;
43
+ }
44
+
45
+ /**
46
+ * Compute JSON Patch operations between two states.
47
+ */
48
+ function computePatch(
49
+ oldState: Record<string, unknown>,
50
+ newState: Record<string, unknown>,
51
+ ): PatchOperation[] {
52
+ const patch: PatchOperation[] = [];
53
+ const oldKeys = new Set(Object.keys(oldState));
54
+ const newKeys = new Set(Object.keys(newState));
55
+
56
+ // Additions and replacements
57
+ for (const key of newKeys) {
58
+ const oldValue = oldState[key];
59
+ const newValue = newState[key];
60
+
61
+ if (!oldKeys.has(key)) {
62
+ // New field
63
+ patch.push({ op: "add", path: `/${key}`, value: newValue });
64
+ } else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
65
+ // Changed field
66
+ patch.push({ op: "replace", path: `/${key}`, value: newValue });
67
+ }
68
+ }
69
+
70
+ // Deletions
71
+ for (const key of oldKeys) {
72
+ if (!newKeys.has(key)) {
73
+ patch.push({ op: "remove", path: `/${key}` });
74
+ }
75
+ }
76
+
77
+ return patch;
78
+ }
79
+
80
+ /**
81
+ * Hash entity state for change detection.
82
+ */
83
+ function hashState(state: Record<string, unknown>): string {
84
+ return JSON.stringify(state);
85
+ }
86
+
87
+ /**
88
+ * Create an in-memory storage adapter.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const storage = memoryStorage();
93
+ *
94
+ * // Or with custom config
95
+ * const storage = memoryStorage({
96
+ * maxPatchesPerEntity: 500,
97
+ * maxPatchAge: 60000,
98
+ * });
99
+ * ```
100
+ */
101
+ export function memoryStorage(config: OpLogStorageConfig = {}): OpLogStorage {
102
+ const cfg = { ...DEFAULT_STORAGE_CONFIG, ...config };
103
+ const entities = new Map<EntityKey, EntityState>();
104
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
105
+
106
+ // Start cleanup timer
107
+ if (cfg.cleanupInterval > 0) {
108
+ cleanupTimer = setInterval(() => cleanup(), cfg.cleanupInterval);
109
+ }
110
+
111
+ /**
112
+ * Cleanup old patches based on age.
113
+ */
114
+ function cleanup(): void {
115
+ const now = Date.now();
116
+ const minTimestamp = now - cfg.maxPatchAge;
117
+
118
+ for (const state of entities.values()) {
119
+ // Remove patches older than maxPatchAge
120
+ state.patches = state.patches.filter((p) => p.timestamp >= minTimestamp);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Trim patches to maxPatchesPerEntity.
126
+ */
127
+ function trimPatches(state: EntityState): void {
128
+ if (state.patches.length > cfg.maxPatchesPerEntity) {
129
+ // Remove oldest patches
130
+ state.patches = state.patches.slice(-cfg.maxPatchesPerEntity);
131
+ }
132
+ }
133
+
134
+ return {
135
+ async emit(entity, entityId, data): Promise<EmitResult> {
136
+ const key = makeKey(entity, entityId);
137
+ const existing = entities.get(key);
138
+ const now = Date.now();
139
+
140
+ if (!existing) {
141
+ // First emit - no previous state
142
+ const newState: EntityState = {
143
+ data: { ...data },
144
+ version: 1,
145
+ patches: [],
146
+ };
147
+
148
+ // No patch for first emit (full state is sent instead)
149
+ entities.set(key, newState);
150
+
151
+ return {
152
+ version: 1,
153
+ patch: null,
154
+ changed: true,
155
+ };
156
+ }
157
+
158
+ // Check if state actually changed
159
+ const oldHash = hashState(existing.data);
160
+ const newHash = hashState(data);
161
+
162
+ if (oldHash === newHash) {
163
+ return {
164
+ version: existing.version,
165
+ patch: null,
166
+ changed: false,
167
+ };
168
+ }
169
+
170
+ // Compute patch
171
+ const patch = computePatch(existing.data, data);
172
+
173
+ // Update state
174
+ const newVersion = existing.version + 1;
175
+ existing.data = { ...data };
176
+ existing.version = newVersion;
177
+
178
+ // Append patch to log
179
+ if (patch.length > 0) {
180
+ existing.patches.push({
181
+ version: newVersion,
182
+ patch,
183
+ timestamp: now,
184
+ });
185
+ trimPatches(existing);
186
+ }
187
+
188
+ return {
189
+ version: newVersion,
190
+ patch: patch.length > 0 ? patch : null,
191
+ changed: true,
192
+ };
193
+ },
194
+
195
+ async getState(entity, entityId): Promise<Record<string, unknown> | null> {
196
+ const key = makeKey(entity, entityId);
197
+ const state = entities.get(key);
198
+ return state ? { ...state.data } : null;
199
+ },
200
+
201
+ async getVersion(entity, entityId): Promise<number> {
202
+ const key = makeKey(entity, entityId);
203
+ const state = entities.get(key);
204
+ return state?.version ?? 0;
205
+ },
206
+
207
+ async getLatestPatch(entity, entityId): Promise<PatchOperation[] | null> {
208
+ const key = makeKey(entity, entityId);
209
+ const state = entities.get(key);
210
+
211
+ if (!state || state.patches.length === 0) {
212
+ return null;
213
+ }
214
+
215
+ return state.patches[state.patches.length - 1].patch;
216
+ },
217
+
218
+ async getPatchesSince(entity, entityId, sinceVersion): Promise<PatchOperation[][] | null> {
219
+ const key = makeKey(entity, entityId);
220
+ const state = entities.get(key);
221
+
222
+ if (!state) {
223
+ return sinceVersion === 0 ? [] : null;
224
+ }
225
+
226
+ // Already up to date
227
+ if (sinceVersion >= state.version) {
228
+ return [];
229
+ }
230
+
231
+ // Find patches since the given version
232
+ const relevantPatches = state.patches.filter((p) => p.version > sinceVersion);
233
+
234
+ if (relevantPatches.length === 0) {
235
+ // No patches in log - version too old
236
+ return null;
237
+ }
238
+
239
+ // Verify continuity
240
+ relevantPatches.sort((a, b) => a.version - b.version);
241
+
242
+ // First patch must be sinceVersion + 1
243
+ if (relevantPatches[0].version !== sinceVersion + 1) {
244
+ return null;
245
+ }
246
+
247
+ // Check for gaps
248
+ for (let i = 1; i < relevantPatches.length; i++) {
249
+ if (relevantPatches[i].version !== relevantPatches[i - 1].version + 1) {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ return relevantPatches.map((p) => p.patch);
255
+ },
256
+
257
+ async has(entity, entityId): Promise<boolean> {
258
+ const key = makeKey(entity, entityId);
259
+ return entities.has(key);
260
+ },
261
+
262
+ async delete(entity, entityId): Promise<void> {
263
+ const key = makeKey(entity, entityId);
264
+ entities.delete(key);
265
+ },
266
+
267
+ async clear(): Promise<void> {
268
+ entities.clear();
269
+ },
270
+
271
+ async dispose(): Promise<void> {
272
+ if (cleanupTimer) {
273
+ clearInterval(cleanupTimer);
274
+ cleanupTimer = null;
275
+ }
276
+ entities.clear();
277
+ },
278
+ };
279
+ }