@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.
@@ -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
- // Subscription Support (Plugin Passthrough)
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
  }
@@ -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
- * The server handles all business logic including state management (via plugins).
204
- * Handlers are pure protocol translators that call these methods.
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
- * Send data to a client for a specific subscription.
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
- * @param entityName - The entity type name
301
- * @param select - Selection object (if undefined, checks ALL fields)
302
- * @returns true if any selected field is a subscription
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
- hasAnySubscription(entityName: string, select?: SelectionObject): boolean;
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
  // =============================================================================
@@ -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));