@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.d.ts +241 -23
- package/dist/index.js +373 -24
- 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 +90 -10
- 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 {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
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.
|
|
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
|
|
918
|
-
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
1115
|
+
return new Response(JSON.stringify({ error: sanitize(result2.error) }), {
|
|
960
1116
|
status: 500,
|
|
961
1117
|
headers: {
|
|
962
1118
|
"Content-Type": "application/json",
|
|
963
|
-
...
|
|
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
|
-
...
|
|
1126
|
+
...baseHeaders
|
|
971
1127
|
}
|
|
972
1128
|
});
|
|
973
1129
|
} catch (error) {
|
|
974
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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 });
|