@sylphx/lens-server 2.14.1 → 2.14.3
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 +50 -88
- package/dist/index.js +141 -131
- package/package.json +2 -2
- package/src/handlers/index.ts +2 -0
- package/src/handlers/ws-types.ts +27 -0
- package/src/handlers/ws.ts +210 -70
- package/src/index.ts +2 -0
- package/src/server/create.test.ts +0 -201
- package/src/server/create.ts +2 -180
- package/src/server/types.ts +16 -101
- package/src/sse/handler.ts +7 -0
package/src/server/create.ts
CHANGED
|
@@ -43,19 +43,10 @@ import {
|
|
|
43
43
|
valuesEqual,
|
|
44
44
|
} from "@sylphx/lens-core";
|
|
45
45
|
import { createContext, runWithContext } from "../context/index.js";
|
|
46
|
-
import {
|
|
47
|
-
createPluginManager,
|
|
48
|
-
type PluginManager,
|
|
49
|
-
type ReconnectContext,
|
|
50
|
-
type ReconnectHookResult,
|
|
51
|
-
type SubscribeContext,
|
|
52
|
-
type UnsubscribeContext,
|
|
53
|
-
type UpdateFieldsContext,
|
|
54
|
-
} from "../plugin/types.js";
|
|
46
|
+
import { createPluginManager, type PluginManager } from "../plugin/types.js";
|
|
55
47
|
import { DataLoader } from "./dataloader.js";
|
|
56
48
|
import { applySelection, extractNestedInputs } from "./selection.js";
|
|
57
49
|
import type {
|
|
58
|
-
ClientSendFn,
|
|
59
50
|
EntitiesMap,
|
|
60
51
|
EntitiesMetadata,
|
|
61
52
|
EntityFieldMetadata,
|
|
@@ -272,113 +263,6 @@ class LensServerImpl<
|
|
|
272
263
|
return resolverMap.size > 0 ? resolverMap : undefined;
|
|
273
264
|
}
|
|
274
265
|
|
|
275
|
-
// =========================================================================
|
|
276
|
-
// Subscription Detection (Deprecated - Use client-side with entities metadata)
|
|
277
|
-
// =========================================================================
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Check if any selected field (recursively) is a subscription.
|
|
281
|
-
*
|
|
282
|
-
* @deprecated Use client-side subscription detection with `getMetadata().entities` instead.
|
|
283
|
-
* The client should use the entities metadata to determine transport routing.
|
|
284
|
-
* This method remains for backwards compatibility but will be removed in a future version.
|
|
285
|
-
*
|
|
286
|
-
* @param entityName - The entity type name
|
|
287
|
-
* @param select - Selection object (if undefined, checks ALL fields)
|
|
288
|
-
* @param visited - Set of visited entity names (prevents infinite recursion)
|
|
289
|
-
* @returns true if any selected field is a subscription
|
|
290
|
-
*/
|
|
291
|
-
hasAnySubscription(
|
|
292
|
-
entityName: string,
|
|
293
|
-
select?: SelectionObject,
|
|
294
|
-
visited: Set<string> = new Set(),
|
|
295
|
-
): boolean {
|
|
296
|
-
// Prevent infinite recursion on circular references
|
|
297
|
-
if (visited.has(entityName)) return false;
|
|
298
|
-
visited.add(entityName);
|
|
299
|
-
|
|
300
|
-
const resolver = this.resolverMap?.get(entityName);
|
|
301
|
-
if (!resolver) return false;
|
|
302
|
-
|
|
303
|
-
// Determine which fields to check
|
|
304
|
-
const fieldsToCheck = select ? Object.keys(select) : (resolver.getFieldNames() as string[]);
|
|
305
|
-
|
|
306
|
-
for (const fieldName of fieldsToCheck) {
|
|
307
|
-
// Skip if field doesn't exist in resolver
|
|
308
|
-
if (!resolver.hasField(fieldName)) continue;
|
|
309
|
-
|
|
310
|
-
// Check if this field is a subscription
|
|
311
|
-
if (resolver.isSubscription(fieldName)) {
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Get nested selection for this field
|
|
316
|
-
const fieldSelect = select?.[fieldName];
|
|
317
|
-
const nestedSelect =
|
|
318
|
-
typeof fieldSelect === "object" && fieldSelect !== null && "select" in fieldSelect
|
|
319
|
-
? (fieldSelect as { select?: SelectionObject }).select
|
|
320
|
-
: undefined;
|
|
321
|
-
|
|
322
|
-
// For relation fields, recursively check the target entity
|
|
323
|
-
// We need to determine the target entity from the resolver's field definition
|
|
324
|
-
// For now, we use the selection to guide us - if there's nested selection,
|
|
325
|
-
// we try to find a matching entity resolver
|
|
326
|
-
if (nestedSelect || (typeof fieldSelect === "object" && fieldSelect !== null)) {
|
|
327
|
-
// Try to find target entity by checking all resolvers
|
|
328
|
-
// In a real scenario, we'd have field metadata linking to target entity
|
|
329
|
-
for (const [targetEntityName] of this.resolverMap ?? []) {
|
|
330
|
-
if (targetEntityName === entityName) continue; // Skip self
|
|
331
|
-
if (this.hasAnySubscription(targetEntityName, nestedSelect, visited)) {
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return false;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Check if an operation (and its return type's fields) requires streaming transport.
|
|
343
|
-
*
|
|
344
|
-
* @deprecated Use client-side transport detection with `getMetadata().entities` instead.
|
|
345
|
-
* The client should determine transport based on operation type from metadata
|
|
346
|
-
* and entity field modes. This method remains for backwards compatibility.
|
|
347
|
-
*
|
|
348
|
-
* Returns true if:
|
|
349
|
-
* 1. Operation resolver is async generator (yields values)
|
|
350
|
-
* 2. Operation resolver uses emit pattern
|
|
351
|
-
* 3. Any selected field in the return type is a subscription
|
|
352
|
-
*
|
|
353
|
-
* @param path - Operation path
|
|
354
|
-
* @param select - Selection object for return type fields
|
|
355
|
-
*/
|
|
356
|
-
requiresStreamingTransport(path: string, select?: SelectionObject): boolean {
|
|
357
|
-
const def = this.queries[path] ?? this.mutations[path];
|
|
358
|
-
if (!def) return false;
|
|
359
|
-
|
|
360
|
-
// Check 1: Operation-level subscription (async generator)
|
|
361
|
-
const resolverFn = def._resolve;
|
|
362
|
-
if (resolverFn) {
|
|
363
|
-
const fnName = resolverFn.constructor?.name;
|
|
364
|
-
if (fnName === "AsyncGeneratorFunction" || fnName === "GeneratorFunction") {
|
|
365
|
-
return true;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Check 2 & 3: Field-level subscriptions
|
|
370
|
-
// Get the return entity type from operation metadata
|
|
371
|
-
const returnType = def._output;
|
|
372
|
-
if (returnType && typeof returnType === "object" && "_name" in returnType) {
|
|
373
|
-
const entityName = (returnType as { _name: string })._name;
|
|
374
|
-
if (this.hasAnySubscription(entityName, select)) {
|
|
375
|
-
return true;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return false;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
266
|
// =========================================================================
|
|
383
267
|
// Core Methods
|
|
384
268
|
// =========================================================================
|
|
@@ -1125,71 +1009,9 @@ class LensServerImpl<
|
|
|
1125
1009
|
}
|
|
1126
1010
|
|
|
1127
1011
|
// =========================================================================
|
|
1128
|
-
//
|
|
1012
|
+
// Plugin Access
|
|
1129
1013
|
// =========================================================================
|
|
1130
1014
|
|
|
1131
|
-
async addClient(clientId: string, send: ClientSendFn): Promise<boolean> {
|
|
1132
|
-
const allowed = await this.pluginManager.runOnConnect({
|
|
1133
|
-
clientId,
|
|
1134
|
-
send: (msg) => send(msg as { type: string; id?: string; data?: unknown }),
|
|
1135
|
-
});
|
|
1136
|
-
return allowed;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
removeClient(clientId: string, subscriptionCount: number): void {
|
|
1140
|
-
this.pluginManager.runOnDisconnect({ clientId, subscriptionCount });
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
async subscribe(ctx: SubscribeContext): Promise<boolean> {
|
|
1144
|
-
return this.pluginManager.runOnSubscribe(ctx);
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
unsubscribe(ctx: UnsubscribeContext): void {
|
|
1148
|
-
this.pluginManager.runOnUnsubscribe(ctx);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
async broadcast(entity: string, entityId: string, data: Record<string, unknown>): Promise<void> {
|
|
1152
|
-
await this.pluginManager.runOnBroadcast({ entity, entityId, data });
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
async send(
|
|
1156
|
-
clientId: string,
|
|
1157
|
-
subscriptionId: string,
|
|
1158
|
-
entity: string,
|
|
1159
|
-
entityId: string,
|
|
1160
|
-
data: Record<string, unknown>,
|
|
1161
|
-
isInitial: boolean,
|
|
1162
|
-
): Promise<void> {
|
|
1163
|
-
const transformedData = await this.pluginManager.runBeforeSend({
|
|
1164
|
-
clientId,
|
|
1165
|
-
subscriptionId,
|
|
1166
|
-
entity,
|
|
1167
|
-
entityId,
|
|
1168
|
-
data,
|
|
1169
|
-
isInitial,
|
|
1170
|
-
fields: "*",
|
|
1171
|
-
});
|
|
1172
|
-
|
|
1173
|
-
await this.pluginManager.runAfterSend({
|
|
1174
|
-
clientId,
|
|
1175
|
-
subscriptionId,
|
|
1176
|
-
entity,
|
|
1177
|
-
entityId,
|
|
1178
|
-
data: transformedData,
|
|
1179
|
-
isInitial,
|
|
1180
|
-
fields: "*",
|
|
1181
|
-
timestamp: Date.now(),
|
|
1182
|
-
});
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
async handleReconnect(ctx: ReconnectContext): Promise<ReconnectHookResult[] | null> {
|
|
1186
|
-
return this.pluginManager.runOnReconnect(ctx);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
async updateFields(ctx: UpdateFieldsContext): Promise<void> {
|
|
1190
|
-
await this.pluginManager.runOnUpdateFields(ctx);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
1015
|
getPluginManager(): PluginManager {
|
|
1194
1016
|
return this.pluginManager;
|
|
1195
1017
|
}
|
package/src/server/types.ts
CHANGED
|
@@ -187,21 +187,17 @@ export interface WebSocketLike {
|
|
|
187
187
|
// =============================================================================
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* Lens server interface
|
|
190
|
+
* Lens server interface - Pure Executor
|
|
191
|
+
*
|
|
192
|
+
* The server is a pure operation executor. It receives operations and returns results.
|
|
193
|
+
* Runtime concerns (connections, transport, protocol) are handled by adapters/handlers.
|
|
191
194
|
*
|
|
192
195
|
* Core methods:
|
|
193
196
|
* - getMetadata() - Server metadata for transport handshake
|
|
194
|
-
* - execute() - Execute any operation
|
|
195
|
-
*
|
|
196
|
-
* Subscription support (used by adapters):
|
|
197
|
-
* - addClient() / removeClient() - Client connection management
|
|
198
|
-
* - subscribe() / unsubscribe() - Subscription lifecycle
|
|
199
|
-
* - send() - Send data to client (runs through plugin hooks)
|
|
200
|
-
* - broadcast() - Broadcast to all entity subscribers
|
|
201
|
-
* - handleReconnect() - Handle client reconnection
|
|
197
|
+
* - execute() - Execute any operation (returns Observable)
|
|
202
198
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
199
|
+
* For handlers that need plugin integration (WS, SSE with state management),
|
|
200
|
+
* use getPluginManager() to access plugin hooks directly.
|
|
205
201
|
*/
|
|
206
202
|
export interface LensServer {
|
|
207
203
|
/** Get server metadata for transport handshake */
|
|
@@ -221,99 +217,18 @@ export interface LensServer {
|
|
|
221
217
|
*/
|
|
222
218
|
execute(op: LensOperation): Observable<LensResult>;
|
|
223
219
|
|
|
224
|
-
// =========================================================================
|
|
225
|
-
// Subscription Support (Optional - used by WS/SSE handlers)
|
|
226
|
-
// =========================================================================
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Register a client connection.
|
|
230
|
-
* Call when a client connects via WebSocket/SSE.
|
|
231
|
-
*/
|
|
232
|
-
addClient(clientId: string, send: ClientSendFn): Promise<boolean>;
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Remove a client connection.
|
|
236
|
-
* Call when a client disconnects.
|
|
237
|
-
*/
|
|
238
|
-
removeClient(clientId: string, subscriptionCount: number): void;
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Subscribe a client to an entity.
|
|
242
|
-
* Runs plugin hooks and sets up state tracking (if clientState is enabled).
|
|
243
|
-
*/
|
|
244
|
-
subscribe(ctx: import("../plugin/types.js").SubscribeContext): Promise<boolean>;
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Unsubscribe a client from an entity.
|
|
248
|
-
* Runs plugin hooks and cleans up state tracking.
|
|
249
|
-
*/
|
|
250
|
-
unsubscribe(ctx: import("../plugin/types.js").UnsubscribeContext): void;
|
|
251
|
-
|
|
252
220
|
/**
|
|
253
|
-
*
|
|
254
|
-
* Runs through plugin hooks (beforeSend/afterSend) for optimization.
|
|
255
|
-
*/
|
|
256
|
-
send(
|
|
257
|
-
clientId: string,
|
|
258
|
-
subscriptionId: string,
|
|
259
|
-
entity: string,
|
|
260
|
-
entityId: string,
|
|
261
|
-
data: Record<string, unknown>,
|
|
262
|
-
isInitial: boolean,
|
|
263
|
-
): Promise<void>;
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Broadcast data to all subscribers of an entity.
|
|
267
|
-
* Runs through plugin hooks for each subscriber.
|
|
268
|
-
*/
|
|
269
|
-
broadcast(entity: string, entityId: string, data: Record<string, unknown>): Promise<void>;
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Handle a reconnection request from a client.
|
|
273
|
-
* Uses plugin hooks (onReconnect) for reconnection logic.
|
|
274
|
-
*/
|
|
275
|
-
handleReconnect(
|
|
276
|
-
ctx: import("../plugin/types.js").ReconnectContext,
|
|
277
|
-
): Promise<import("../plugin/types.js").ReconnectHookResult[] | null>;
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Update subscribed fields for a client's subscription.
|
|
281
|
-
* Runs plugin hooks (onUpdateFields) to sync state.
|
|
282
|
-
*/
|
|
283
|
-
updateFields(ctx: import("../plugin/types.js").UpdateFieldsContext): Promise<void>;
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Get the plugin manager for direct hook access.
|
|
287
|
-
*/
|
|
288
|
-
getPluginManager(): PluginManager;
|
|
289
|
-
|
|
290
|
-
// =========================================================================
|
|
291
|
-
// Subscription Detection (Deprecated - Use client-side with entities metadata)
|
|
292
|
-
// =========================================================================
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Check if any selected field (recursively) is a subscription.
|
|
296
|
-
*
|
|
297
|
-
* @deprecated Use client-side subscription detection with `getMetadata().entities` instead.
|
|
298
|
-
* The client should use the entities metadata to determine transport routing.
|
|
221
|
+
* Get the plugin manager for handlers that need plugin integration.
|
|
299
222
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
223
|
+
* Handlers should use this to call plugin hooks directly:
|
|
224
|
+
* - pluginManager.runOnConnect() - When client connects
|
|
225
|
+
* - pluginManager.runOnDisconnect() - When client disconnects
|
|
226
|
+
* - pluginManager.runOnSubscribe() - When client subscribes
|
|
227
|
+
* - pluginManager.runOnUnsubscribe() - When client unsubscribes
|
|
228
|
+
* - pluginManager.runOnReconnect() - For reconnection handling
|
|
229
|
+
* - etc.
|
|
303
230
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Check if an operation requires streaming transport.
|
|
308
|
-
*
|
|
309
|
-
* @deprecated Use client-side transport detection with `getMetadata().entities` instead.
|
|
310
|
-
* The client should determine transport based on operation type from metadata
|
|
311
|
-
* and entity field modes.
|
|
312
|
-
*
|
|
313
|
-
* @param path - Operation path
|
|
314
|
-
* @param select - Selection object for return type fields
|
|
315
|
-
*/
|
|
316
|
-
requiresStreamingTransport(path: string, select?: SelectionObject): boolean;
|
|
231
|
+
getPluginManager(): PluginManager;
|
|
317
232
|
}
|
|
318
233
|
|
|
319
234
|
// =============================================================================
|
package/src/sse/handler.ts
CHANGED
|
@@ -158,11 +158,18 @@ export class SSEHandler {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* Send a named event to a specific client.
|
|
161
|
+
* Event names are validated to prevent header injection attacks.
|
|
161
162
|
*/
|
|
162
163
|
sendEvent(clientId: string, event: string, data: unknown): boolean {
|
|
163
164
|
const client = this.clients.get(clientId);
|
|
164
165
|
if (!client) return false;
|
|
165
166
|
|
|
167
|
+
// Validate event name to prevent SSE header injection
|
|
168
|
+
// Event names must not contain newlines, carriage returns, or colons
|
|
169
|
+
if (/[\r\n:]/.test(event)) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
try {
|
|
167
174
|
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
168
175
|
client.controller.enqueue(client.encoder.encode(message));
|