@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.d.ts +241 -23
- package/dist/index.js +363 -23
- package/package.json +1 -1
- package/src/handlers/framework.ts +17 -4
- package/src/handlers/http.test.ts +227 -2
- package/src/handlers/http.ts +223 -22
- package/src/handlers/index.ts +2 -0
- package/src/handlers/ws-types.ts +39 -0
- package/src/handlers/ws.test.ts +559 -0
- package/src/handlers/ws.ts +99 -0
- package/src/index.ts +21 -0
- package/src/logging/index.ts +20 -0
- package/src/logging/structured-logger.test.ts +367 -0
- package/src/logging/structured-logger.ts +335 -0
- package/src/server/create.test.ts +198 -0
- package/src/server/create.ts +78 -9
- package/src/server/types.ts +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
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.
|
|
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
|
|
927
|
-
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
1115
|
+
return new Response(JSON.stringify({ error: sanitize(result2.error) }), {
|
|
969
1116
|
status: 500,
|
|
970
1117
|
headers: {
|
|
971
1118
|
"Content-Type": "application/json",
|
|
972
|
-
...
|
|
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
|
-
...
|
|
1126
|
+
...baseHeaders
|
|
980
1127
|
}
|
|
981
1128
|
});
|
|
982
1129
|
} catch (error) {
|
|
983
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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 });
|