@sylphx/lens-server 2.3.2 → 2.4.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.
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ function extendContext(current, extension) {
37
37
  import {
38
38
  createEmit,
39
39
  flattenRouter,
40
+ hashValue,
40
41
  isEntityDef,
41
42
  isMutationDef,
42
43
  isQueryDef,
@@ -285,6 +286,45 @@ function extractNestedInputs(select, prefix = "") {
285
286
  function isAsyncIterable(value) {
286
287
  return value != null && typeof value === "object" && Symbol.asyncIterator in value;
287
288
  }
289
+
290
+ class RingBuffer {
291
+ capacity;
292
+ buffer;
293
+ head = 0;
294
+ tail = 0;
295
+ count = 0;
296
+ constructor(capacity) {
297
+ this.capacity = capacity;
298
+ this.buffer = new Array(capacity).fill(null);
299
+ }
300
+ get size() {
301
+ return this.count;
302
+ }
303
+ get isEmpty() {
304
+ return this.count === 0;
305
+ }
306
+ enqueue(item) {
307
+ let dropped = false;
308
+ if (this.count >= this.capacity) {
309
+ this.head = (this.head + 1) % this.capacity;
310
+ this.count--;
311
+ dropped = true;
312
+ }
313
+ this.buffer[this.tail] = item;
314
+ this.tail = (this.tail + 1) % this.capacity;
315
+ this.count++;
316
+ return dropped;
317
+ }
318
+ dequeue() {
319
+ if (this.count === 0)
320
+ return null;
321
+ const item = this.buffer[this.head];
322
+ this.buffer[this.head] = null;
323
+ this.head = (this.head + 1) % this.capacity;
324
+ this.count--;
325
+ return item;
326
+ }
327
+ }
288
328
  var noopLogger = {};
289
329
 
290
330
  class LensServerImpl {
@@ -390,13 +430,17 @@ class LensServerImpl {
390
430
  let cancelled = false;
391
431
  let currentState;
392
432
  let lastEmittedResult;
433
+ let lastEmittedHash;
393
434
  const cleanups = [];
394
435
  const emitIfChanged = (data) => {
395
436
  if (cancelled)
396
437
  return;
397
- if (valuesEqual(data, lastEmittedResult))
438
+ const dataHash = hashValue(data);
439
+ if (lastEmittedHash !== undefined && valuesEqual(data, lastEmittedResult, dataHash, lastEmittedHash)) {
398
440
  return;
441
+ }
399
442
  lastEmittedResult = data;
443
+ lastEmittedHash = dataHash;
400
444
  observer.next?.({ data });
401
445
  };
402
446
  (async () => {
@@ -433,26 +477,28 @@ class LensServerImpl {
433
477
  return;
434
478
  }
435
479
  let emitProcessing = false;
436
- const emitQueue = [];
480
+ const MAX_EMIT_QUEUE_SIZE = 100;
481
+ const emitQueue = new RingBuffer(MAX_EMIT_QUEUE_SIZE);
437
482
  const processEmitQueue = async () => {
438
483
  if (emitProcessing || cancelled)
439
484
  return;
440
485
  emitProcessing = true;
441
- while (emitQueue.length > 0 && !cancelled) {
442
- const command = emitQueue.shift();
486
+ let command = emitQueue.dequeue();
487
+ while (command !== null && !cancelled) {
443
488
  currentState = this.applyEmitCommand(command, currentState);
444
489
  const fieldEmitFactory = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
445
490
  currentState = state;
446
491
  }, emitIfChanged, select, context, onCleanup) : undefined;
447
492
  const processed = isQuery ? await this.processQueryResult(path, currentState, select, context, onCleanup, fieldEmitFactory) : currentState;
448
493
  emitIfChanged(processed);
494
+ command = emitQueue.dequeue();
449
495
  }
450
496
  emitProcessing = false;
451
497
  };
452
498
  const emitHandler = (command) => {
453
499
  if (cancelled)
454
500
  return;
455
- emitQueue.push(command);
501
+ emitQueue.enqueue(command);
456
502
  processEmitQueue().catch((err) => {
457
503
  if (!cancelled) {
458
504
  observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
@@ -490,6 +536,9 @@ class LensServerImpl {
490
536
  currentState = value;
491
537
  const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
492
538
  emitIfChanged(processed);
539
+ if (!isQuery && !cancelled) {
540
+ observer.complete?.();
541
+ }
493
542
  }
494
543
  });
495
544
  } catch (error) {
@@ -687,10 +736,11 @@ class LensServerImpl {
687
736
  let loader = this.loaders.get(loaderKey);
688
737
  if (!loader) {
689
738
  loader = new DataLoader(async (parents) => {
739
+ const context = tryUseContext() ?? {};
690
740
  const results = [];
691
741
  for (const parent of parents) {
692
742
  try {
693
- const result = await resolverDef.resolveField(fieldName, parent, {}, {});
743
+ const result = await resolverDef.resolveField(fieldName, parent, {}, context);
694
744
  results.push(result);
695
745
  } catch {
696
746
  results.push(null);
@@ -921,20 +971,106 @@ function createSSEHandler(config = {}) {
921
971
 
922
972
  // src/handlers/http.ts
923
973
  import { firstValueFrom } from "@sylphx/lens-core";
974
+ function sanitizeError(error, isDevelopment) {
975
+ if (isDevelopment) {
976
+ return error.message;
977
+ }
978
+ const message = error.message;
979
+ const safePatterns = [
980
+ /^Invalid input:/,
981
+ /^Missing operation/,
982
+ /^Not found/,
983
+ /^Unauthorized/,
984
+ /^Forbidden/,
985
+ /^Bad request/,
986
+ /^Validation failed/
987
+ ];
988
+ if (safePatterns.some((pattern) => pattern.test(message))) {
989
+ return message;
990
+ }
991
+ const sensitivePatterns = [
992
+ /\/[^\s]+\.(ts|js|json)/,
993
+ /at\s+[^\s]+\s+\(/,
994
+ /ENOENT|EACCES|ECONNREFUSED/,
995
+ /SELECT|INSERT|UPDATE|DELETE|FROM|WHERE/i,
996
+ /password|secret|token|key|auth/i
997
+ ];
998
+ if (sensitivePatterns.some((pattern) => pattern.test(message))) {
999
+ return "An internal error occurred";
1000
+ }
1001
+ if (message.length < 100 && !message.includes(`
1002
+ `)) {
1003
+ return message;
1004
+ }
1005
+ return "An internal error occurred";
1006
+ }
924
1007
  function createHTTPHandler(server, options = {}) {
925
- const { pathPrefix = "", cors } = options;
926
- const corsHeaders = {
927
- "Access-Control-Allow-Origin": cors?.origin ? Array.isArray(cors.origin) ? cors.origin.join(", ") : cors.origin : "*",
1008
+ const { pathPrefix = "", cors, errors, health } = options;
1009
+ const isDevelopment = errors?.development ?? false;
1010
+ const healthEnabled = health?.enabled !== false;
1011
+ const healthPath = health?.path ?? "/__lens/health";
1012
+ const startTime = Date.now();
1013
+ const sanitize = (error) => {
1014
+ if (errors?.sanitize) {
1015
+ return errors.sanitize(error);
1016
+ }
1017
+ return sanitizeError(error, isDevelopment);
1018
+ };
1019
+ const allowedOrigin = cors?.origin ? Array.isArray(cors.origin) ? cors.origin.join(", ") : cors.origin : isDevelopment ? "*" : "";
1020
+ const baseHeaders = {
1021
+ "Content-Type": "application/json",
1022
+ "X-Content-Type-Options": "nosniff",
1023
+ "X-Frame-Options": "DENY",
928
1024
  "Access-Control-Allow-Methods": cors?.methods?.join(", ") ?? "GET, POST, OPTIONS",
929
1025
  "Access-Control-Allow-Headers": cors?.headers?.join(", ") ?? "Content-Type, Authorization"
930
1026
  };
1027
+ if (allowedOrigin) {
1028
+ baseHeaders["Access-Control-Allow-Origin"] = allowedOrigin;
1029
+ }
931
1030
  const handler = async (request) => {
932
1031
  const url = new URL(request.url);
933
1032
  const pathname = url.pathname;
934
1033
  if (request.method === "OPTIONS") {
935
1034
  return new Response(null, {
936
1035
  status: 204,
937
- headers: corsHeaders
1036
+ headers: baseHeaders
1037
+ });
1038
+ }
1039
+ const fullHealthPath = `${pathPrefix}${healthPath}`;
1040
+ if (healthEnabled && request.method === "GET" && pathname === fullHealthPath) {
1041
+ const metadata = server.getMetadata();
1042
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
1043
+ let customChecks = {};
1044
+ let hasFailure = false;
1045
+ if (health?.checks) {
1046
+ try {
1047
+ customChecks = await health.checks();
1048
+ hasFailure = Object.values(customChecks).some((c) => c.status === "fail");
1049
+ } catch (error) {
1050
+ customChecks.healthCheck = {
1051
+ status: "fail",
1052
+ message: error instanceof Error ? error.message : "Health check failed"
1053
+ };
1054
+ hasFailure = true;
1055
+ }
1056
+ }
1057
+ const response = {
1058
+ status: hasFailure ? "degraded" : "healthy",
1059
+ service: "lens-server",
1060
+ version: metadata.version,
1061
+ uptime: uptimeSeconds,
1062
+ timestamp: new Date().toISOString()
1063
+ };
1064
+ if (Object.keys(customChecks).length > 0) {
1065
+ response.checks = customChecks;
1066
+ }
1067
+ return new Response(JSON.stringify(response), {
1068
+ status: hasFailure ? 503 : 200,
1069
+ headers: {
1070
+ "Content-Type": "application/json",
1071
+ "Cache-Control": "no-cache, no-store, must-revalidate",
1072
+ ...baseHeaders
1073
+ }
938
1074
  });
939
1075
  }
940
1076
  const metadataPath = `${pathPrefix}/__lens/metadata`;
@@ -942,21 +1078,32 @@ function createHTTPHandler(server, options = {}) {
942
1078
  return new Response(JSON.stringify(server.getMetadata()), {
943
1079
  headers: {
944
1080
  "Content-Type": "application/json",
945
- ...corsHeaders
1081
+ ...baseHeaders
946
1082
  }
947
1083
  });
948
1084
  }
949
1085
  const operationPath = pathPrefix || "/";
950
1086
  if (request.method === "POST" && (pathname === operationPath || pathname === `${pathPrefix}/`)) {
1087
+ let body;
1088
+ try {
1089
+ body = await request.json();
1090
+ } catch {
1091
+ return new Response(JSON.stringify({ error: "Invalid JSON in request body" }), {
1092
+ status: 400,
1093
+ headers: {
1094
+ "Content-Type": "application/json",
1095
+ ...baseHeaders
1096
+ }
1097
+ });
1098
+ }
951
1099
  try {
952
- const body = await request.json();
953
1100
  const operationPath2 = body.operation ?? body.path;
954
1101
  if (!operationPath2) {
955
1102
  return new Response(JSON.stringify({ error: "Missing operation path" }), {
956
1103
  status: 400,
957
1104
  headers: {
958
1105
  "Content-Type": "application/json",
959
- ...corsHeaders
1106
+ ...baseHeaders
960
1107
  }
961
1108
  });
962
1109
  }
@@ -965,26 +1112,27 @@ function createHTTPHandler(server, options = {}) {
965
1112
  input: body.input
966
1113
  }));
967
1114
  if (result2.error) {
968
- return new Response(JSON.stringify({ error: result2.error.message }), {
1115
+ return new Response(JSON.stringify({ error: sanitize(result2.error) }), {
969
1116
  status: 500,
970
1117
  headers: {
971
1118
  "Content-Type": "application/json",
972
- ...corsHeaders
1119
+ ...baseHeaders
973
1120
  }
974
1121
  });
975
1122
  }
976
1123
  return new Response(JSON.stringify({ data: result2.data }), {
977
1124
  headers: {
978
1125
  "Content-Type": "application/json",
979
- ...corsHeaders
1126
+ ...baseHeaders
980
1127
  }
981
1128
  });
982
1129
  } catch (error) {
983
- return new Response(JSON.stringify({ error: String(error) }), {
1130
+ const err = error instanceof Error ? error : new Error(String(error));
1131
+ return new Response(JSON.stringify({ error: sanitize(err) }), {
984
1132
  status: 500,
985
1133
  headers: {
986
1134
  "Content-Type": "application/json",
987
- ...corsHeaders
1135
+ ...baseHeaders
988
1136
  }
989
1137
  });
990
1138
  }
@@ -993,7 +1141,7 @@ function createHTTPHandler(server, options = {}) {
993
1141
  status: 404,
994
1142
  headers: {
995
1143
  "Content-Type": "application/json",
996
- ...corsHeaders
1144
+ ...baseHeaders
997
1145
  }
998
1146
  });
999
1147
  };
@@ -1031,7 +1179,13 @@ function createHandler(server, options = {}) {
1031
1179
  return result;
1032
1180
  }
1033
1181
  // src/handlers/framework.ts
1034
- import { firstValueFrom as firstValueFrom2 } from "@sylphx/lens-core";
1182
+ import { firstValueFrom as firstValueFrom2, isObservable } from "@sylphx/lens-core";
1183
+ async function resolveExecuteResult(result) {
1184
+ if (isObservable(result)) {
1185
+ return firstValueFrom2(result);
1186
+ }
1187
+ return result;
1188
+ }
1035
1189
  function createServerClientProxy(server) {
1036
1190
  function createProxy(path) {
1037
1191
  return new Proxy(() => {}, {
@@ -1045,7 +1199,7 @@ function createServerClientProxy(server) {
1045
1199
  },
1046
1200
  async apply(_, __, args) {
1047
1201
  const input = args[0];
1048
- const result = await firstValueFrom2(server.execute({ path, input }));
1202
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1049
1203
  if (result.error) {
1050
1204
  throw result.error;
1051
1205
  }
@@ -1059,7 +1213,7 @@ async function handleWebQuery(server, path, url) {
1059
1213
  try {
1060
1214
  const inputParam = url.searchParams.get("input");
1061
1215
  const input = inputParam ? JSON.parse(inputParam) : undefined;
1062
- const result = await firstValueFrom2(server.execute({ path, input }));
1216
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1063
1217
  if (result.error) {
1064
1218
  return Response.json({ error: result.error.message }, { status: 400 });
1065
1219
  }
@@ -1072,7 +1226,7 @@ async function handleWebMutation(server, path, request) {
1072
1226
  try {
1073
1227
  const body = await request.json();
1074
1228
  const input = body.input;
1075
- const result = await firstValueFrom2(server.execute({ path, input }));
1229
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1076
1230
  if (result.error) {
1077
1231
  return Response.json({ error: result.error.message }, { status: 400 });
1078
1232
  }
@@ -1149,10 +1303,38 @@ import {
1149
1303
  } from "@sylphx/lens-core";
1150
1304
  function createWSHandler(server, options = {}) {
1151
1305
  const { logger = {} } = options;
1306
+ const maxMessageSize = options.maxMessageSize ?? 1024 * 1024;
1307
+ const maxSubscriptionsPerClient = options.maxSubscriptionsPerClient ?? 100;
1308
+ const maxConnections = options.maxConnections ?? 1e4;
1309
+ const rateLimitMaxMessages = options.rateLimit?.maxMessages ?? 100;
1310
+ const rateLimitWindowMs = options.rateLimit?.windowMs ?? 1000;
1311
+ const clientMessageTimestamps = new Map;
1312
+ function isRateLimited(clientId) {
1313
+ const now = Date.now();
1314
+ const windowStart = now - rateLimitWindowMs;
1315
+ let timestamps = clientMessageTimestamps.get(clientId);
1316
+ if (!timestamps) {
1317
+ timestamps = [];
1318
+ clientMessageTimestamps.set(clientId, timestamps);
1319
+ }
1320
+ while (timestamps.length > 0 && timestamps[0] < windowStart) {
1321
+ timestamps.shift();
1322
+ }
1323
+ if (timestamps.length >= rateLimitMaxMessages) {
1324
+ return true;
1325
+ }
1326
+ timestamps.push(now);
1327
+ return false;
1328
+ }
1152
1329
  const connections = new Map;
1153
1330
  const wsToConnection = new WeakMap;
1154
1331
  let connectionCounter = 0;
1155
1332
  async function handleConnection(ws) {
1333
+ if (connections.size >= maxConnections) {
1334
+ logger.warn?.(`Connection limit reached (${maxConnections}), rejecting new connection`);
1335
+ ws.close(1013, "Server at capacity");
1336
+ return;
1337
+ }
1156
1338
  const clientId = `client_${++connectionCounter}`;
1157
1339
  const conn = {
1158
1340
  id: clientId,
@@ -1178,6 +1360,28 @@ function createWSHandler(server, options = {}) {
1178
1360
  };
1179
1361
  }
1180
1362
  function handleMessage(conn, data) {
1363
+ if (data.length > maxMessageSize) {
1364
+ logger.warn?.(`Message too large (${data.length} bytes > ${maxMessageSize}), rejecting`);
1365
+ conn.ws.send(JSON.stringify({
1366
+ type: "error",
1367
+ error: {
1368
+ code: "MESSAGE_TOO_LARGE",
1369
+ message: `Message exceeds ${maxMessageSize} byte limit`
1370
+ }
1371
+ }));
1372
+ return;
1373
+ }
1374
+ if (isRateLimited(conn.id)) {
1375
+ logger.warn?.(`Rate limit exceeded for client ${conn.id}`);
1376
+ conn.ws.send(JSON.stringify({
1377
+ type: "error",
1378
+ error: {
1379
+ code: "RATE_LIMITED",
1380
+ message: `Rate limit exceeded: max ${rateLimitMaxMessages} messages per ${rateLimitWindowMs}ms`
1381
+ }
1382
+ }));
1383
+ return;
1384
+ }
1181
1385
  try {
1182
1386
  const message = JSON.parse(data);
1183
1387
  switch (message.type) {
@@ -1221,6 +1425,18 @@ function createWSHandler(server, options = {}) {
1221
1425
  }
1222
1426
  async function handleSubscribe(conn, message) {
1223
1427
  const { id, operation, input, fields } = message;
1428
+ if (!conn.subscriptions.has(id) && conn.subscriptions.size >= maxSubscriptionsPerClient) {
1429
+ logger.warn?.(`Subscription limit reached for client ${conn.id} (${maxSubscriptionsPerClient}), rejecting`);
1430
+ conn.ws.send(JSON.stringify({
1431
+ type: "error",
1432
+ id,
1433
+ error: {
1434
+ code: "SUBSCRIPTION_LIMIT",
1435
+ message: `Maximum ${maxSubscriptionsPerClient} subscriptions per client`
1436
+ }
1437
+ }));
1438
+ return;
1439
+ }
1224
1440
  let result;
1225
1441
  try {
1226
1442
  result = await firstValueFrom3(server.execute({ path: operation, input }));
@@ -1488,6 +1704,7 @@ function createWSHandler(server, options = {}) {
1488
1704
  }
1489
1705
  }
1490
1706
  connections.delete(conn.id);
1707
+ clientMessageTimestamps.delete(conn.id);
1491
1708
  server.removeClient(conn.id, subscriptionCount);
1492
1709
  }
1493
1710
  function extractEntities(data) {
@@ -2251,17 +2468,139 @@ function coalescePatches(patches) {
2251
2468
  function estimatePatchSize(patch) {
2252
2469
  return JSON.stringify(patch).length;
2253
2470
  }
2471
+ // src/logging/structured-logger.ts
2472
+ var LOG_LEVEL_PRIORITY = {
2473
+ debug: 0,
2474
+ info: 1,
2475
+ warn: 2,
2476
+ error: 3,
2477
+ fatal: 4
2478
+ };
2479
+ var jsonOutput = {
2480
+ write(entry) {
2481
+ console.log(JSON.stringify(entry));
2482
+ }
2483
+ };
2484
+ var prettyOutput = {
2485
+ write(entry) {
2486
+ const { timestamp, level, message, ...rest } = entry;
2487
+ const color = {
2488
+ debug: "\x1B[36m",
2489
+ info: "\x1B[32m",
2490
+ warn: "\x1B[33m",
2491
+ error: "\x1B[31m",
2492
+ fatal: "\x1B[35m"
2493
+ }[level];
2494
+ const reset = "\x1B[0m";
2495
+ const contextStr = Object.keys(rest).length > 0 ? ` ${JSON.stringify(rest)}` : "";
2496
+ console.log(`${timestamp} ${color}[${level.toUpperCase()}]${reset} ${message}${contextStr}`);
2497
+ }
2498
+ };
2499
+ function createStructuredLogger(options = {}) {
2500
+ const {
2501
+ service = "lens",
2502
+ level: minLevel = "info",
2503
+ includeStackTrace = false,
2504
+ output = jsonOutput,
2505
+ defaultContext = {}
2506
+ } = options;
2507
+ const minPriority = LOG_LEVEL_PRIORITY[minLevel];
2508
+ function shouldLog(level) {
2509
+ return LOG_LEVEL_PRIORITY[level] >= minPriority;
2510
+ }
2511
+ function log(level, message, context = {}) {
2512
+ if (!shouldLog(level))
2513
+ return;
2514
+ const error = context.error instanceof Error ? context.error : undefined;
2515
+ const errorContext = {};
2516
+ if (error) {
2517
+ errorContext.errorType = error.name;
2518
+ errorContext.errorMessage = error.message;
2519
+ if (includeStackTrace && error.stack) {
2520
+ errorContext.stack = error.stack;
2521
+ }
2522
+ }
2523
+ const entry = {
2524
+ timestamp: new Date().toISOString(),
2525
+ level,
2526
+ message,
2527
+ service,
2528
+ ...defaultContext,
2529
+ ...context,
2530
+ ...errorContext
2531
+ };
2532
+ if ("error" in entry && entry.error instanceof Error) {
2533
+ delete entry.error;
2534
+ }
2535
+ output.write(entry);
2536
+ }
2537
+ return {
2538
+ debug: (message, context) => log("debug", message, context),
2539
+ info: (message, context) => log("info", message, context),
2540
+ warn: (message, context) => log("warn", message, context),
2541
+ error: (message, context) => log("error", message, context),
2542
+ fatal: (message, context) => log("fatal", message, context),
2543
+ child: (childContext) => {
2544
+ return createStructuredLogger({
2545
+ ...options,
2546
+ defaultContext: { ...defaultContext, ...childContext }
2547
+ });
2548
+ },
2549
+ request: (requestId, message, context) => {
2550
+ log("info", message, { requestId, ...context });
2551
+ },
2552
+ startOperation: (operation, context) => {
2553
+ const startTime = Date.now();
2554
+ const requestId = context?.requestId;
2555
+ log("debug", `Operation started: ${operation}`, { operation, ...context });
2556
+ return (result) => {
2557
+ const durationMs = Date.now() - startTime;
2558
+ if (result?.error) {
2559
+ log("error", `Operation failed: ${operation}`, {
2560
+ operation,
2561
+ requestId,
2562
+ durationMs,
2563
+ error: result.error
2564
+ });
2565
+ } else {
2566
+ log("info", `Operation completed: ${operation}`, {
2567
+ operation,
2568
+ requestId,
2569
+ durationMs
2570
+ });
2571
+ }
2572
+ };
2573
+ }
2574
+ };
2575
+ }
2576
+ function toBasicLogger(structuredLogger) {
2577
+ return {
2578
+ info: (message, ...args) => {
2579
+ structuredLogger.info(message, args.length > 0 ? { args } : undefined);
2580
+ },
2581
+ warn: (message, ...args) => {
2582
+ structuredLogger.warn(message, args.length > 0 ? { args } : undefined);
2583
+ },
2584
+ error: (message, ...args) => {
2585
+ const error = args.find((arg) => arg instanceof Error);
2586
+ structuredLogger.error(message, error ? { error } : args.length > 0 ? { args } : undefined);
2587
+ }
2588
+ };
2589
+ }
2254
2590
  export {
2255
2591
  useContext,
2256
2592
  tryUseContext,
2593
+ toBasicLogger,
2257
2594
  runWithContextAsync,
2258
2595
  runWithContext,
2259
2596
  router,
2260
2597
  query,
2598
+ prettyOutput,
2261
2599
  optimisticPlugin,
2262
2600
  opLog,
2263
2601
  mutation,
2264
2602
  memoryStorage,
2603
+ jsonOutput,
2265
2604
  isOptimisticPlugin,
2266
2605
  isOpLogPlugin,
2267
2606
  hasContext,
@@ -2271,6 +2610,7 @@ export {
2271
2610
  extendContext,
2272
2611
  estimatePatchSize,
2273
2612
  createWSHandler,
2613
+ createStructuredLogger,
2274
2614
  createServerClientProxy,
2275
2615
  createSSEHandler,
2276
2616
  createPluginManager,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.3.2",
3
+ "version": "2.4.0",
4
4
  "description": "Server runtime for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -37,8 +37,21 @@
37
37
  * ```
38
38
  */
39
39
 
40
- import { firstValueFrom } from "@sylphx/lens-core";
40
+ import { firstValueFrom, isObservable } from "@sylphx/lens-core";
41
41
  import type { LensServer } from "../server/create.js";
42
+ import type { LensResult } from "../server/types.js";
43
+
44
+ /**
45
+ * Helper to resolve server.execute() result which may be Observable or Promise.
46
+ * This provides backwards compatibility for test mocks that return Promises.
47
+ */
48
+ async function resolveExecuteResult<T>(result: unknown): Promise<LensResult<T>> {
49
+ if (isObservable<LensResult<T>>(result)) {
50
+ return firstValueFrom(result);
51
+ }
52
+ // Handle Promise or direct value (for backwards compatibility with test mocks)
53
+ return result as Promise<LensResult<T>>;
54
+ }
42
55
 
43
56
  // =============================================================================
44
57
  // Server Client Proxy
@@ -75,7 +88,7 @@ export function createServerClientProxy(server: LensServer): unknown {
75
88
  },
76
89
  async apply(_, __, args) {
77
90
  const input = args[0];
78
- const result = await firstValueFrom(server.execute({ path, input }));
91
+ const result = await resolveExecuteResult(server.execute({ path, input }));
79
92
 
80
93
  if (result.error) {
81
94
  throw result.error;
@@ -113,7 +126,7 @@ export async function handleWebQuery(
113
126
  const inputParam = url.searchParams.get("input");
114
127
  const input = inputParam ? JSON.parse(inputParam) : undefined;
115
128
 
116
- const result = await firstValueFrom(server.execute({ path, input }));
129
+ const result = await resolveExecuteResult(server.execute({ path, input }));
117
130
 
118
131
  if (result.error) {
119
132
  return Response.json({ error: result.error.message }, { status: 400 });
@@ -148,7 +161,7 @@ export async function handleWebMutation(
148
161
  const body = (await request.json()) as { input?: unknown };
149
162
  const input = body.input;
150
163
 
151
- const result = await firstValueFrom(server.execute({ path, input }));
164
+ const result = await resolveExecuteResult(server.execute({ path, input }));
152
165
 
153
166
  if (result.error) {
154
167
  return Response.json({ error: result.error.message }, { status: 400 });