@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,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - DataLoader
|
|
3
|
+
*
|
|
4
|
+
* Simple batching utility for N+1 prevention in field resolution.
|
|
5
|
+
* Batches multiple load calls within a single microtask.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DataLoader batches multiple load calls within a single microtask.
|
|
10
|
+
* Used internally for efficient field resolution.
|
|
11
|
+
*/
|
|
12
|
+
export class DataLoader<K, V> {
|
|
13
|
+
private batch: Map<K, { resolve: (v: V | null) => void; reject: (e: Error) => void }[]> =
|
|
14
|
+
new Map();
|
|
15
|
+
private scheduled = false;
|
|
16
|
+
|
|
17
|
+
constructor(private batchFn: (keys: K[]) => Promise<(V | null)[]>) {}
|
|
18
|
+
|
|
19
|
+
async load(key: K): Promise<V | null> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const existing = this.batch.get(key);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.push({ resolve, reject });
|
|
24
|
+
} else {
|
|
25
|
+
this.batch.set(key, [{ resolve, reject }]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!this.scheduled) {
|
|
29
|
+
this.scheduled = true;
|
|
30
|
+
queueMicrotask(() => this.flush());
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async flush(): Promise<void> {
|
|
36
|
+
this.scheduled = false;
|
|
37
|
+
const batch = this.batch;
|
|
38
|
+
this.batch = new Map();
|
|
39
|
+
|
|
40
|
+
const keys = Array.from(batch.keys());
|
|
41
|
+
if (keys.length === 0) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const results = await this.batchFn(keys);
|
|
45
|
+
let i = 0;
|
|
46
|
+
for (const [_key, callbacks] of batch) {
|
|
47
|
+
const result = results[i++];
|
|
48
|
+
for (const { resolve } of callbacks) {
|
|
49
|
+
resolve(result);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
for (const [, callbacks] of batch) {
|
|
54
|
+
for (const { reject } of callbacks) {
|
|
55
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Selection
|
|
3
|
+
*
|
|
4
|
+
* Field selection logic for query results.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SelectionObject } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Apply field selection to data.
|
|
11
|
+
* Recursively filters data to only include selected fields.
|
|
12
|
+
*
|
|
13
|
+
* @param data - The data to filter
|
|
14
|
+
* @param select - Selection object specifying which fields to include
|
|
15
|
+
* @returns Filtered data with only selected fields
|
|
16
|
+
*/
|
|
17
|
+
export function applySelection(data: unknown, select: SelectionObject): unknown {
|
|
18
|
+
if (!data) return data;
|
|
19
|
+
|
|
20
|
+
if (Array.isArray(data)) {
|
|
21
|
+
return data.map((item) => applySelection(item, select));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof data !== "object") return data;
|
|
25
|
+
|
|
26
|
+
const obj = data as Record<string, unknown>;
|
|
27
|
+
const result: Record<string, unknown> = {};
|
|
28
|
+
|
|
29
|
+
// Always include id
|
|
30
|
+
if ("id" in obj) result.id = obj.id;
|
|
31
|
+
|
|
32
|
+
for (const [key, value] of Object.entries(select)) {
|
|
33
|
+
if (!(key in obj)) continue;
|
|
34
|
+
|
|
35
|
+
if (value === true) {
|
|
36
|
+
result[key] = obj[key];
|
|
37
|
+
} else if (typeof value === "object" && value !== null) {
|
|
38
|
+
const nestedSelect = "select" in value ? value.select : value;
|
|
39
|
+
result[key] = applySelection(obj[key], nestedSelect as SelectionObject);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-server - Server Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for Lens server configuration and operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ContextValue,
|
|
9
|
+
EntityDef,
|
|
10
|
+
InferRouterContext,
|
|
11
|
+
MutationDef,
|
|
12
|
+
OptimisticDSL,
|
|
13
|
+
QueryDef,
|
|
14
|
+
Resolvers,
|
|
15
|
+
RouterDef,
|
|
16
|
+
} from "@sylphx/lens-core";
|
|
17
|
+
import type { PluginManager, ServerPlugin } from "../plugin/types.js";
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Selection Types
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
/** Selection object type for nested field selection */
|
|
24
|
+
export interface SelectionObject {
|
|
25
|
+
[key: string]: boolean | SelectionObject | { select: SelectionObject };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Map Types
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/** Entity map type */
|
|
33
|
+
export type EntitiesMap = Record<string, EntityDef<string, any>>;
|
|
34
|
+
|
|
35
|
+
/** Queries map type */
|
|
36
|
+
export type QueriesMap = Record<string, QueryDef<unknown, unknown>>;
|
|
37
|
+
|
|
38
|
+
/** Mutations map type */
|
|
39
|
+
export type MutationsMap = Record<string, MutationDef<unknown, unknown>>;
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Operation Metadata
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/** Operation metadata for handshake */
|
|
46
|
+
export interface OperationMeta {
|
|
47
|
+
type: "query" | "mutation" | "subscription";
|
|
48
|
+
optimistic?: OptimisticDSL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Nested operations structure for handshake */
|
|
52
|
+
export type OperationsMap = {
|
|
53
|
+
[key: string]: OperationMeta | OperationsMap;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Logger
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/** Logger interface */
|
|
61
|
+
export interface LensLogger {
|
|
62
|
+
info?: (message: string, ...args: unknown[]) => void;
|
|
63
|
+
warn?: (message: string, ...args: unknown[]) => void;
|
|
64
|
+
error?: (message: string, ...args: unknown[]) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Server Configuration
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
/** Server configuration */
|
|
72
|
+
export interface LensServerConfig<
|
|
73
|
+
TContext extends ContextValue = ContextValue,
|
|
74
|
+
TRouter extends RouterDef = RouterDef,
|
|
75
|
+
> {
|
|
76
|
+
/** Entity definitions */
|
|
77
|
+
entities?: EntitiesMap | undefined;
|
|
78
|
+
/** Router definition (namespaced operations) */
|
|
79
|
+
router?: TRouter | undefined;
|
|
80
|
+
/** Query definitions (flat) */
|
|
81
|
+
queries?: QueriesMap | undefined;
|
|
82
|
+
/** Mutation definitions (flat) */
|
|
83
|
+
mutations?: MutationsMap | undefined;
|
|
84
|
+
/** Field resolvers array */
|
|
85
|
+
resolvers?: Resolvers | undefined;
|
|
86
|
+
/** Logger for server messages (default: silent) */
|
|
87
|
+
logger?: LensLogger | undefined;
|
|
88
|
+
/** Context factory */
|
|
89
|
+
context?: ((req?: unknown) => TContext | Promise<TContext>) | undefined;
|
|
90
|
+
/** Server version */
|
|
91
|
+
version?: string | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Server-level plugins for subscription lifecycle and state management.
|
|
94
|
+
* Plugins are processed at the server level, not adapter level.
|
|
95
|
+
*/
|
|
96
|
+
plugins?: ServerPlugin[] | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =============================================================================
|
|
100
|
+
// Server Metadata
|
|
101
|
+
// =============================================================================
|
|
102
|
+
|
|
103
|
+
/** Server metadata for transport handshake */
|
|
104
|
+
export interface ServerMetadata {
|
|
105
|
+
version: string;
|
|
106
|
+
operations: OperationsMap;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// =============================================================================
|
|
110
|
+
// Operations
|
|
111
|
+
// =============================================================================
|
|
112
|
+
|
|
113
|
+
/** Operation for execution */
|
|
114
|
+
export interface LensOperation {
|
|
115
|
+
path: string;
|
|
116
|
+
input?: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Result from operation execution */
|
|
120
|
+
export interface LensResult<T = unknown> {
|
|
121
|
+
data?: T;
|
|
122
|
+
error?: Error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// =============================================================================
|
|
126
|
+
// Client Communication
|
|
127
|
+
// =============================================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Client send function type for subscription updates.
|
|
131
|
+
*/
|
|
132
|
+
export type ClientSendFn = (message: unknown) => void;
|
|
133
|
+
|
|
134
|
+
/** WebSocket interface for adapters */
|
|
135
|
+
export interface WebSocketLike {
|
|
136
|
+
send(data: string): void;
|
|
137
|
+
close(): void;
|
|
138
|
+
onmessage?: ((event: { data: string }) => void) | null;
|
|
139
|
+
onclose?: (() => void) | null;
|
|
140
|
+
onerror?: ((error: unknown) => void) | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Server Interface
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Lens server interface
|
|
149
|
+
*
|
|
150
|
+
* Core methods:
|
|
151
|
+
* - getMetadata() - Server metadata for transport handshake
|
|
152
|
+
* - execute() - Execute any operation
|
|
153
|
+
*
|
|
154
|
+
* Subscription support (used by adapters):
|
|
155
|
+
* - addClient() / removeClient() - Client connection management
|
|
156
|
+
* - subscribe() / unsubscribe() - Subscription lifecycle
|
|
157
|
+
* - send() - Send data to client (runs through plugin hooks)
|
|
158
|
+
* - broadcast() - Broadcast to all entity subscribers
|
|
159
|
+
* - handleReconnect() - Handle client reconnection
|
|
160
|
+
*
|
|
161
|
+
* The server handles all business logic including state management (via plugins).
|
|
162
|
+
* Handlers are pure protocol translators that call these methods.
|
|
163
|
+
*/
|
|
164
|
+
export interface LensServer {
|
|
165
|
+
/** Get server metadata for transport handshake */
|
|
166
|
+
getMetadata(): ServerMetadata;
|
|
167
|
+
/** Execute operation - auto-detects query vs mutation */
|
|
168
|
+
execute(op: LensOperation): Promise<LensResult>;
|
|
169
|
+
|
|
170
|
+
// =========================================================================
|
|
171
|
+
// Subscription Support (Optional - used by WS/SSE handlers)
|
|
172
|
+
// =========================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Register a client connection.
|
|
176
|
+
* Call when a client connects via WebSocket/SSE.
|
|
177
|
+
*/
|
|
178
|
+
addClient(clientId: string, send: ClientSendFn): Promise<boolean>;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove a client connection.
|
|
182
|
+
* Call when a client disconnects.
|
|
183
|
+
*/
|
|
184
|
+
removeClient(clientId: string, subscriptionCount: number): void;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Subscribe a client to an entity.
|
|
188
|
+
* Runs plugin hooks and sets up state tracking (if clientState is enabled).
|
|
189
|
+
*/
|
|
190
|
+
subscribe(ctx: import("../plugin/types.js").SubscribeContext): Promise<boolean>;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Unsubscribe a client from an entity.
|
|
194
|
+
* Runs plugin hooks and cleans up state tracking.
|
|
195
|
+
*/
|
|
196
|
+
unsubscribe(ctx: import("../plugin/types.js").UnsubscribeContext): void;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Send data to a client for a specific subscription.
|
|
200
|
+
* Runs through plugin hooks (beforeSend/afterSend) for optimization.
|
|
201
|
+
*/
|
|
202
|
+
send(
|
|
203
|
+
clientId: string,
|
|
204
|
+
subscriptionId: string,
|
|
205
|
+
entity: string,
|
|
206
|
+
entityId: string,
|
|
207
|
+
data: Record<string, unknown>,
|
|
208
|
+
isInitial: boolean,
|
|
209
|
+
): Promise<void>;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Broadcast data to all subscribers of an entity.
|
|
213
|
+
* Runs through plugin hooks for each subscriber.
|
|
214
|
+
*/
|
|
215
|
+
broadcast(entity: string, entityId: string, data: Record<string, unknown>): Promise<void>;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Handle a reconnection request from a client.
|
|
219
|
+
* Uses plugin hooks (onReconnect) for reconnection logic.
|
|
220
|
+
*/
|
|
221
|
+
handleReconnect(
|
|
222
|
+
ctx: import("../plugin/types.js").ReconnectContext,
|
|
223
|
+
): Promise<import("../plugin/types.js").ReconnectHookResult[] | null>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Update subscribed fields for a client's subscription.
|
|
227
|
+
* Runs plugin hooks (onUpdateFields) to sync state.
|
|
228
|
+
*/
|
|
229
|
+
updateFields(ctx: import("../plugin/types.js").UpdateFieldsContext): Promise<void>;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get the plugin manager for direct hook access.
|
|
233
|
+
*/
|
|
234
|
+
getPluginManager(): PluginManager;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// =============================================================================
|
|
238
|
+
// Type Inference
|
|
239
|
+
// =============================================================================
|
|
240
|
+
|
|
241
|
+
import type { FieldType } from "@sylphx/lens-core";
|
|
242
|
+
|
|
243
|
+
export type InferInput<T> =
|
|
244
|
+
T extends QueryDef<infer I, any> ? I : T extends MutationDef<infer I, any> ? I : never;
|
|
245
|
+
|
|
246
|
+
export type InferOutput<T> =
|
|
247
|
+
T extends QueryDef<any, infer O>
|
|
248
|
+
? O
|
|
249
|
+
: T extends MutationDef<any, infer O>
|
|
250
|
+
? O
|
|
251
|
+
: T extends FieldType<infer F>
|
|
252
|
+
? F
|
|
253
|
+
: never;
|
|
254
|
+
|
|
255
|
+
export type InferApi<T> = T extends { _types: infer Types } ? Types : never;
|
|
256
|
+
|
|
257
|
+
export type ServerConfigWithInferredContext<
|
|
258
|
+
TRouter extends RouterDef,
|
|
259
|
+
Q extends QueriesMap = QueriesMap,
|
|
260
|
+
M extends MutationsMap = MutationsMap,
|
|
261
|
+
> = {
|
|
262
|
+
router: TRouter;
|
|
263
|
+
entities?: EntitiesMap;
|
|
264
|
+
queries?: Q;
|
|
265
|
+
mutations?: M;
|
|
266
|
+
resolvers?: Resolvers;
|
|
267
|
+
logger?: LensLogger;
|
|
268
|
+
context?: () => InferRouterContext<TRouter> | Promise<InferRouterContext<TRouter>>;
|
|
269
|
+
version?: string;
|
|
270
|
+
/** Server-level plugins (clientState, etc.) */
|
|
271
|
+
plugins?: ServerPlugin[];
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export type ServerConfigLegacy<
|
|
275
|
+
TContext extends ContextValue,
|
|
276
|
+
Q extends QueriesMap = QueriesMap,
|
|
277
|
+
M extends MutationsMap = MutationsMap,
|
|
278
|
+
> = {
|
|
279
|
+
router?: RouterDef | undefined;
|
|
280
|
+
entities?: EntitiesMap;
|
|
281
|
+
queries?: Q;
|
|
282
|
+
mutations?: M;
|
|
283
|
+
resolvers?: Resolvers;
|
|
284
|
+
logger?: LensLogger;
|
|
285
|
+
context?: () => TContext | Promise<TContext>;
|
|
286
|
+
version?: string;
|
|
287
|
+
/** Server-level plugins (clientState, etc.) */
|
|
288
|
+
plugins?: ServerPlugin[];
|
|
289
|
+
};
|
package/src/sse/handler.ts
CHANGED
|
@@ -1,73 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @sylphx/lens-server - SSE
|
|
2
|
+
* @sylphx/lens-server - SSE Handler
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
23
|
-
export interface
|
|
22
|
+
/** SSE client handle for sending messages */
|
|
23
|
+
export interface SSEClient {
|
|
24
|
+
/** Unique client ID */
|
|
24
25
|
id: string;
|
|
25
|
-
|
|
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
|
|
35
|
+
// SSE Handler
|
|
30
36
|
// =============================================================================
|
|
31
37
|
|
|
32
38
|
/**
|
|
33
|
-
* SSE transport
|
|
39
|
+
* Pure SSE transport handler.
|
|
34
40
|
*
|
|
35
|
-
* This
|
|
36
|
-
* -
|
|
37
|
-
* -
|
|
38
|
-
* -
|
|
41
|
+
* This handler ONLY manages:
|
|
42
|
+
* - SSE connection lifecycle
|
|
43
|
+
* - Message sending to clients
|
|
44
|
+
* - Heartbeat keepalive
|
|
39
45
|
*
|
|
40
|
-
*
|
|
46
|
+
* It does NOT know about:
|
|
47
|
+
* - State management
|
|
48
|
+
* - Subscriptions
|
|
49
|
+
* - Plugins
|
|
41
50
|
*
|
|
42
51
|
* @example
|
|
43
52
|
* ```typescript
|
|
44
|
-
* const
|
|
45
|
-
*
|
|
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
|
-
* //
|
|
51
|
-
*
|
|
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
|
-
{
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|
package/src/state/index.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @sylphx/lens-server - State Management
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|