@sylphx/lens-server 2.3.2 → 2.4.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.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
  };
@@ -1149,10 +1297,38 @@ import {
1149
1297
  } from "@sylphx/lens-core";
1150
1298
  function createWSHandler(server, options = {}) {
1151
1299
  const { logger = {} } = options;
1300
+ const maxMessageSize = options.maxMessageSize ?? 1024 * 1024;
1301
+ const maxSubscriptionsPerClient = options.maxSubscriptionsPerClient ?? 100;
1302
+ const maxConnections = options.maxConnections ?? 1e4;
1303
+ const rateLimitMaxMessages = options.rateLimit?.maxMessages ?? 100;
1304
+ const rateLimitWindowMs = options.rateLimit?.windowMs ?? 1000;
1305
+ const clientMessageTimestamps = new Map;
1306
+ function isRateLimited(clientId) {
1307
+ const now = Date.now();
1308
+ const windowStart = now - rateLimitWindowMs;
1309
+ let timestamps = clientMessageTimestamps.get(clientId);
1310
+ if (!timestamps) {
1311
+ timestamps = [];
1312
+ clientMessageTimestamps.set(clientId, timestamps);
1313
+ }
1314
+ while (timestamps.length > 0 && timestamps[0] < windowStart) {
1315
+ timestamps.shift();
1316
+ }
1317
+ if (timestamps.length >= rateLimitMaxMessages) {
1318
+ return true;
1319
+ }
1320
+ timestamps.push(now);
1321
+ return false;
1322
+ }
1152
1323
  const connections = new Map;
1153
1324
  const wsToConnection = new WeakMap;
1154
1325
  let connectionCounter = 0;
1155
1326
  async function handleConnection(ws) {
1327
+ if (connections.size >= maxConnections) {
1328
+ logger.warn?.(`Connection limit reached (${maxConnections}), rejecting new connection`);
1329
+ ws.close(1013, "Server at capacity");
1330
+ return;
1331
+ }
1156
1332
  const clientId = `client_${++connectionCounter}`;
1157
1333
  const conn = {
1158
1334
  id: clientId,
@@ -1178,6 +1354,28 @@ function createWSHandler(server, options = {}) {
1178
1354
  };
1179
1355
  }
1180
1356
  function handleMessage(conn, data) {
1357
+ if (data.length > maxMessageSize) {
1358
+ logger.warn?.(`Message too large (${data.length} bytes > ${maxMessageSize}), rejecting`);
1359
+ conn.ws.send(JSON.stringify({
1360
+ type: "error",
1361
+ error: {
1362
+ code: "MESSAGE_TOO_LARGE",
1363
+ message: `Message exceeds ${maxMessageSize} byte limit`
1364
+ }
1365
+ }));
1366
+ return;
1367
+ }
1368
+ if (isRateLimited(conn.id)) {
1369
+ logger.warn?.(`Rate limit exceeded for client ${conn.id}`);
1370
+ conn.ws.send(JSON.stringify({
1371
+ type: "error",
1372
+ error: {
1373
+ code: "RATE_LIMITED",
1374
+ message: `Rate limit exceeded: max ${rateLimitMaxMessages} messages per ${rateLimitWindowMs}ms`
1375
+ }
1376
+ }));
1377
+ return;
1378
+ }
1181
1379
  try {
1182
1380
  const message = JSON.parse(data);
1183
1381
  switch (message.type) {
@@ -1221,6 +1419,18 @@ function createWSHandler(server, options = {}) {
1221
1419
  }
1222
1420
  async function handleSubscribe(conn, message) {
1223
1421
  const { id, operation, input, fields } = message;
1422
+ if (!conn.subscriptions.has(id) && conn.subscriptions.size >= maxSubscriptionsPerClient) {
1423
+ logger.warn?.(`Subscription limit reached for client ${conn.id} (${maxSubscriptionsPerClient}), rejecting`);
1424
+ conn.ws.send(JSON.stringify({
1425
+ type: "error",
1426
+ id,
1427
+ error: {
1428
+ code: "SUBSCRIPTION_LIMIT",
1429
+ message: `Maximum ${maxSubscriptionsPerClient} subscriptions per client`
1430
+ }
1431
+ }));
1432
+ return;
1433
+ }
1224
1434
  let result;
1225
1435
  try {
1226
1436
  result = await firstValueFrom3(server.execute({ path: operation, input }));
@@ -1488,6 +1698,7 @@ function createWSHandler(server, options = {}) {
1488
1698
  }
1489
1699
  }
1490
1700
  connections.delete(conn.id);
1701
+ clientMessageTimestamps.delete(conn.id);
1491
1702
  server.removeClient(conn.id, subscriptionCount);
1492
1703
  }
1493
1704
  function extractEntities(data) {
@@ -2251,17 +2462,139 @@ function coalescePatches(patches) {
2251
2462
  function estimatePatchSize(patch) {
2252
2463
  return JSON.stringify(patch).length;
2253
2464
  }
2465
+ // src/logging/structured-logger.ts
2466
+ var LOG_LEVEL_PRIORITY = {
2467
+ debug: 0,
2468
+ info: 1,
2469
+ warn: 2,
2470
+ error: 3,
2471
+ fatal: 4
2472
+ };
2473
+ var jsonOutput = {
2474
+ write(entry) {
2475
+ console.log(JSON.stringify(entry));
2476
+ }
2477
+ };
2478
+ var prettyOutput = {
2479
+ write(entry) {
2480
+ const { timestamp, level, message, ...rest } = entry;
2481
+ const color = {
2482
+ debug: "\x1B[36m",
2483
+ info: "\x1B[32m",
2484
+ warn: "\x1B[33m",
2485
+ error: "\x1B[31m",
2486
+ fatal: "\x1B[35m"
2487
+ }[level];
2488
+ const reset = "\x1B[0m";
2489
+ const contextStr = Object.keys(rest).length > 0 ? ` ${JSON.stringify(rest)}` : "";
2490
+ console.log(`${timestamp} ${color}[${level.toUpperCase()}]${reset} ${message}${contextStr}`);
2491
+ }
2492
+ };
2493
+ function createStructuredLogger(options = {}) {
2494
+ const {
2495
+ service = "lens",
2496
+ level: minLevel = "info",
2497
+ includeStackTrace = false,
2498
+ output = jsonOutput,
2499
+ defaultContext = {}
2500
+ } = options;
2501
+ const minPriority = LOG_LEVEL_PRIORITY[minLevel];
2502
+ function shouldLog(level) {
2503
+ return LOG_LEVEL_PRIORITY[level] >= minPriority;
2504
+ }
2505
+ function log(level, message, context = {}) {
2506
+ if (!shouldLog(level))
2507
+ return;
2508
+ const error = context.error instanceof Error ? context.error : undefined;
2509
+ const errorContext = {};
2510
+ if (error) {
2511
+ errorContext.errorType = error.name;
2512
+ errorContext.errorMessage = error.message;
2513
+ if (includeStackTrace && error.stack) {
2514
+ errorContext.stack = error.stack;
2515
+ }
2516
+ }
2517
+ const entry = {
2518
+ timestamp: new Date().toISOString(),
2519
+ level,
2520
+ message,
2521
+ service,
2522
+ ...defaultContext,
2523
+ ...context,
2524
+ ...errorContext
2525
+ };
2526
+ if ("error" in entry && entry.error instanceof Error) {
2527
+ delete entry.error;
2528
+ }
2529
+ output.write(entry);
2530
+ }
2531
+ return {
2532
+ debug: (message, context) => log("debug", message, context),
2533
+ info: (message, context) => log("info", message, context),
2534
+ warn: (message, context) => log("warn", message, context),
2535
+ error: (message, context) => log("error", message, context),
2536
+ fatal: (message, context) => log("fatal", message, context),
2537
+ child: (childContext) => {
2538
+ return createStructuredLogger({
2539
+ ...options,
2540
+ defaultContext: { ...defaultContext, ...childContext }
2541
+ });
2542
+ },
2543
+ request: (requestId, message, context) => {
2544
+ log("info", message, { requestId, ...context });
2545
+ },
2546
+ startOperation: (operation, context) => {
2547
+ const startTime = Date.now();
2548
+ const requestId = context?.requestId;
2549
+ log("debug", `Operation started: ${operation}`, { operation, ...context });
2550
+ return (result) => {
2551
+ const durationMs = Date.now() - startTime;
2552
+ if (result?.error) {
2553
+ log("error", `Operation failed: ${operation}`, {
2554
+ operation,
2555
+ requestId,
2556
+ durationMs,
2557
+ error: result.error
2558
+ });
2559
+ } else {
2560
+ log("info", `Operation completed: ${operation}`, {
2561
+ operation,
2562
+ requestId,
2563
+ durationMs
2564
+ });
2565
+ }
2566
+ };
2567
+ }
2568
+ };
2569
+ }
2570
+ function toBasicLogger(structuredLogger) {
2571
+ return {
2572
+ info: (message, ...args) => {
2573
+ structuredLogger.info(message, args.length > 0 ? { args } : undefined);
2574
+ },
2575
+ warn: (message, ...args) => {
2576
+ structuredLogger.warn(message, args.length > 0 ? { args } : undefined);
2577
+ },
2578
+ error: (message, ...args) => {
2579
+ const error = args.find((arg) => arg instanceof Error);
2580
+ structuredLogger.error(message, error ? { error } : args.length > 0 ? { args } : undefined);
2581
+ }
2582
+ };
2583
+ }
2254
2584
  export {
2255
2585
  useContext,
2256
2586
  tryUseContext,
2587
+ toBasicLogger,
2257
2588
  runWithContextAsync,
2258
2589
  runWithContext,
2259
2590
  router,
2260
2591
  query,
2592
+ prettyOutput,
2261
2593
  optimisticPlugin,
2262
2594
  opLog,
2263
2595
  mutation,
2264
2596
  memoryStorage,
2597
+ jsonOutput,
2265
2598
  isOptimisticPlugin,
2266
2599
  isOpLogPlugin,
2267
2600
  hasContext,
@@ -2271,6 +2604,7 @@ export {
2271
2604
  extendContext,
2272
2605
  estimatePatchSize,
2273
2606
  createWSHandler,
2607
+ createStructuredLogger,
2274
2608
  createServerClientProxy,
2275
2609
  createSSEHandler,
2276
2610
  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.1",
4
4
  "description": "Server runtime for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",