@sylphx/lens-server 2.3.1 → 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 {
@@ -313,8 +353,17 @@ class LensServerImpl {
313
353
  }
314
354
  this.queries = queries;
315
355
  this.mutations = mutations;
316
- this.entities = config.entities ?? {};
317
356
  this.resolverMap = config.resolvers ? toResolverMap(config.resolvers) : undefined;
357
+ const entities = { ...config.entities ?? {} };
358
+ if (config.resolvers) {
359
+ for (const resolver of config.resolvers) {
360
+ const entityName = resolver.entity._name;
361
+ if (entityName && !entities[entityName]) {
362
+ entities[entityName] = resolver.entity;
363
+ }
364
+ }
365
+ }
366
+ this.entities = entities;
318
367
  this.contextFactory = config.context ?? (() => ({}));
319
368
  this.version = config.version ?? "1.0.0";
320
369
  this.logger = config.logger ?? noopLogger;
@@ -381,13 +430,17 @@ class LensServerImpl {
381
430
  let cancelled = false;
382
431
  let currentState;
383
432
  let lastEmittedResult;
433
+ let lastEmittedHash;
384
434
  const cleanups = [];
385
435
  const emitIfChanged = (data) => {
386
436
  if (cancelled)
387
437
  return;
388
- if (valuesEqual(data, lastEmittedResult))
438
+ const dataHash = hashValue(data);
439
+ if (lastEmittedHash !== undefined && valuesEqual(data, lastEmittedResult, dataHash, lastEmittedHash)) {
389
440
  return;
441
+ }
390
442
  lastEmittedResult = data;
443
+ lastEmittedHash = dataHash;
391
444
  observer.next?.({ data });
392
445
  };
393
446
  (async () => {
@@ -424,26 +477,28 @@ class LensServerImpl {
424
477
  return;
425
478
  }
426
479
  let emitProcessing = false;
427
- const emitQueue = [];
480
+ const MAX_EMIT_QUEUE_SIZE = 100;
481
+ const emitQueue = new RingBuffer(MAX_EMIT_QUEUE_SIZE);
428
482
  const processEmitQueue = async () => {
429
483
  if (emitProcessing || cancelled)
430
484
  return;
431
485
  emitProcessing = true;
432
- while (emitQueue.length > 0 && !cancelled) {
433
- const command = emitQueue.shift();
486
+ let command = emitQueue.dequeue();
487
+ while (command !== null && !cancelled) {
434
488
  currentState = this.applyEmitCommand(command, currentState);
435
489
  const fieldEmitFactory = isQuery ? this.createFieldEmitFactory(() => currentState, (state) => {
436
490
  currentState = state;
437
491
  }, emitIfChanged, select, context, onCleanup) : undefined;
438
492
  const processed = isQuery ? await this.processQueryResult(path, currentState, select, context, onCleanup, fieldEmitFactory) : currentState;
439
493
  emitIfChanged(processed);
494
+ command = emitQueue.dequeue();
440
495
  }
441
496
  emitProcessing = false;
442
497
  };
443
498
  const emitHandler = (command) => {
444
499
  if (cancelled)
445
500
  return;
446
- emitQueue.push(command);
501
+ emitQueue.enqueue(command);
447
502
  processEmitQueue().catch((err) => {
448
503
  if (!cancelled) {
449
504
  observer.next?.({ error: err instanceof Error ? err : new Error(String(err)) });
@@ -481,6 +536,9 @@ class LensServerImpl {
481
536
  currentState = value;
482
537
  const processed = isQuery ? await this.processQueryResult(path, value, select, context, onCleanup, createFieldEmit) : value;
483
538
  emitIfChanged(processed);
539
+ if (!isQuery && !cancelled) {
540
+ observer.complete?.();
541
+ }
484
542
  }
485
543
  });
486
544
  } catch (error) {
@@ -678,10 +736,11 @@ class LensServerImpl {
678
736
  let loader = this.loaders.get(loaderKey);
679
737
  if (!loader) {
680
738
  loader = new DataLoader(async (parents) => {
739
+ const context = tryUseContext() ?? {};
681
740
  const results = [];
682
741
  for (const parent of parents) {
683
742
  try {
684
- const result = await resolverDef.resolveField(fieldName, parent, {}, {});
743
+ const result = await resolverDef.resolveField(fieldName, parent, {}, context);
685
744
  results.push(result);
686
745
  } catch {
687
746
  results.push(null);
@@ -912,20 +971,106 @@ function createSSEHandler(config = {}) {
912
971
 
913
972
  // src/handlers/http.ts
914
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
+ }
915
1007
  function createHTTPHandler(server, options = {}) {
916
- const { pathPrefix = "", cors } = options;
917
- const corsHeaders = {
918
- "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",
919
1024
  "Access-Control-Allow-Methods": cors?.methods?.join(", ") ?? "GET, POST, OPTIONS",
920
1025
  "Access-Control-Allow-Headers": cors?.headers?.join(", ") ?? "Content-Type, Authorization"
921
1026
  };
1027
+ if (allowedOrigin) {
1028
+ baseHeaders["Access-Control-Allow-Origin"] = allowedOrigin;
1029
+ }
922
1030
  const handler = async (request) => {
923
1031
  const url = new URL(request.url);
924
1032
  const pathname = url.pathname;
925
1033
  if (request.method === "OPTIONS") {
926
1034
  return new Response(null, {
927
1035
  status: 204,
928
- 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
+ }
929
1074
  });
930
1075
  }
931
1076
  const metadataPath = `${pathPrefix}/__lens/metadata`;
@@ -933,21 +1078,32 @@ function createHTTPHandler(server, options = {}) {
933
1078
  return new Response(JSON.stringify(server.getMetadata()), {
934
1079
  headers: {
935
1080
  "Content-Type": "application/json",
936
- ...corsHeaders
1081
+ ...baseHeaders
937
1082
  }
938
1083
  });
939
1084
  }
940
1085
  const operationPath = pathPrefix || "/";
941
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
+ }
942
1099
  try {
943
- const body = await request.json();
944
1100
  const operationPath2 = body.operation ?? body.path;
945
1101
  if (!operationPath2) {
946
1102
  return new Response(JSON.stringify({ error: "Missing operation path" }), {
947
1103
  status: 400,
948
1104
  headers: {
949
1105
  "Content-Type": "application/json",
950
- ...corsHeaders
1106
+ ...baseHeaders
951
1107
  }
952
1108
  });
953
1109
  }
@@ -956,26 +1112,27 @@ function createHTTPHandler(server, options = {}) {
956
1112
  input: body.input
957
1113
  }));
958
1114
  if (result2.error) {
959
- return new Response(JSON.stringify({ error: result2.error.message }), {
1115
+ return new Response(JSON.stringify({ error: sanitize(result2.error) }), {
960
1116
  status: 500,
961
1117
  headers: {
962
1118
  "Content-Type": "application/json",
963
- ...corsHeaders
1119
+ ...baseHeaders
964
1120
  }
965
1121
  });
966
1122
  }
967
1123
  return new Response(JSON.stringify({ data: result2.data }), {
968
1124
  headers: {
969
1125
  "Content-Type": "application/json",
970
- ...corsHeaders
1126
+ ...baseHeaders
971
1127
  }
972
1128
  });
973
1129
  } catch (error) {
974
- 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) }), {
975
1132
  status: 500,
976
1133
  headers: {
977
1134
  "Content-Type": "application/json",
978
- ...corsHeaders
1135
+ ...baseHeaders
979
1136
  }
980
1137
  });
981
1138
  }
@@ -984,7 +1141,7 @@ function createHTTPHandler(server, options = {}) {
984
1141
  status: 404,
985
1142
  headers: {
986
1143
  "Content-Type": "application/json",
987
- ...corsHeaders
1144
+ ...baseHeaders
988
1145
  }
989
1146
  });
990
1147
  };
@@ -1022,7 +1179,13 @@ function createHandler(server, options = {}) {
1022
1179
  return result;
1023
1180
  }
1024
1181
  // src/handlers/framework.ts
1025
- 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
+ }
1026
1189
  function createServerClientProxy(server) {
1027
1190
  function createProxy(path) {
1028
1191
  return new Proxy(() => {}, {
@@ -1036,7 +1199,7 @@ function createServerClientProxy(server) {
1036
1199
  },
1037
1200
  async apply(_, __, args) {
1038
1201
  const input = args[0];
1039
- const result = await firstValueFrom2(server.execute({ path, input }));
1202
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1040
1203
  if (result.error) {
1041
1204
  throw result.error;
1042
1205
  }
@@ -1050,7 +1213,7 @@ async function handleWebQuery(server, path, url) {
1050
1213
  try {
1051
1214
  const inputParam = url.searchParams.get("input");
1052
1215
  const input = inputParam ? JSON.parse(inputParam) : undefined;
1053
- const result = await firstValueFrom2(server.execute({ path, input }));
1216
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1054
1217
  if (result.error) {
1055
1218
  return Response.json({ error: result.error.message }, { status: 400 });
1056
1219
  }
@@ -1063,7 +1226,7 @@ async function handleWebMutation(server, path, request) {
1063
1226
  try {
1064
1227
  const body = await request.json();
1065
1228
  const input = body.input;
1066
- const result = await firstValueFrom2(server.execute({ path, input }));
1229
+ const result = await resolveExecuteResult(server.execute({ path, input }));
1067
1230
  if (result.error) {
1068
1231
  return Response.json({ error: result.error.message }, { status: 400 });
1069
1232
  }
@@ -1140,10 +1303,38 @@ import {
1140
1303
  } from "@sylphx/lens-core";
1141
1304
  function createWSHandler(server, options = {}) {
1142
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
+ }
1143
1329
  const connections = new Map;
1144
1330
  const wsToConnection = new WeakMap;
1145
1331
  let connectionCounter = 0;
1146
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
+ }
1147
1338
  const clientId = `client_${++connectionCounter}`;
1148
1339
  const conn = {
1149
1340
  id: clientId,
@@ -1169,6 +1360,28 @@ function createWSHandler(server, options = {}) {
1169
1360
  };
1170
1361
  }
1171
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
+ }
1172
1385
  try {
1173
1386
  const message = JSON.parse(data);
1174
1387
  switch (message.type) {
@@ -1212,6 +1425,18 @@ function createWSHandler(server, options = {}) {
1212
1425
  }
1213
1426
  async function handleSubscribe(conn, message) {
1214
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
+ }
1215
1440
  let result;
1216
1441
  try {
1217
1442
  result = await firstValueFrom3(server.execute({ path: operation, input }));
@@ -1479,6 +1704,7 @@ function createWSHandler(server, options = {}) {
1479
1704
  }
1480
1705
  }
1481
1706
  connections.delete(conn.id);
1707
+ clientMessageTimestamps.delete(conn.id);
1482
1708
  server.removeClient(conn.id, subscriptionCount);
1483
1709
  }
1484
1710
  function extractEntities(data) {
@@ -2242,17 +2468,139 @@ function coalescePatches(patches) {
2242
2468
  function estimatePatchSize(patch) {
2243
2469
  return JSON.stringify(patch).length;
2244
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
+ }
2245
2590
  export {
2246
2591
  useContext,
2247
2592
  tryUseContext,
2593
+ toBasicLogger,
2248
2594
  runWithContextAsync,
2249
2595
  runWithContext,
2250
2596
  router,
2251
2597
  query,
2598
+ prettyOutput,
2252
2599
  optimisticPlugin,
2253
2600
  opLog,
2254
2601
  mutation,
2255
2602
  memoryStorage,
2603
+ jsonOutput,
2256
2604
  isOptimisticPlugin,
2257
2605
  isOpLogPlugin,
2258
2606
  hasContext,
@@ -2262,6 +2610,7 @@ export {
2262
2610
  extendContext,
2263
2611
  estimatePatchSize,
2264
2612
  createWSHandler,
2613
+ createStructuredLogger,
2265
2614
  createServerClientProxy,
2266
2615
  createSSEHandler,
2267
2616
  createPluginManager,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-server",
3
- "version": "2.3.1",
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 });