@a-company/sentinel 0.2.0 → 3.5.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.
@@ -1,13 +1,16 @@
1
1
  import {
2
- SentinelStorage
3
- } from "../chunk-KPMG4XED.js";
2
+ SentinelStorage,
3
+ loadServerConfig
4
+ } from "../chunk-FOF7CPJ6.js";
4
5
 
5
6
  // src/server/index.ts
6
7
  import express from "express";
8
+ import * as http from "http";
7
9
  import * as path3 from "path";
8
10
  import * as fs2 from "fs";
9
11
  import { fileURLToPath } from "url";
10
12
  import chalk3 from "chalk";
13
+ import { WebSocketServer, WebSocket } from "ws";
11
14
 
12
15
  // src/server/routes/symbols.ts
13
16
  import { Router } from "express";
@@ -104,7 +107,7 @@ async function loadParadigmConfig(projectDir) {
104
107
  }
105
108
  async function loadWithPremiseCore(projectDir) {
106
109
  try {
107
- const { aggregateFromDirectory } = await import("../dist-2F7NO4H4.js");
110
+ const { aggregateFromDirectory } = await import("../dist-AG5JNIZU.js");
108
111
  log.flow("load-symbols").info("Using premise-core aggregator", { path: projectDir });
109
112
  const result = await aggregateFromDirectory(projectDir);
110
113
  const counts = {};
@@ -762,6 +765,458 @@ function createPatternsRouter(_projectDir) {
762
765
  return router;
763
766
  }
764
767
 
768
+ // src/server/routes/logs.ts
769
+ import { Router as Router6 } from "express";
770
+ import { v4 as uuidv4 } from "uuid";
771
+ function inferSymbolType(symbol) {
772
+ if (symbol.startsWith("#")) return "component";
773
+ if (symbol.startsWith("^")) return "gate";
774
+ if (symbol.startsWith("!")) return "signal";
775
+ if (symbol.startsWith("$")) return "flow";
776
+ if (symbol.startsWith("~")) return "aspect";
777
+ return "raw";
778
+ }
779
+ function validateSymbol(symbol, index) {
780
+ const entry = index.find((e) => e.symbol === symbol);
781
+ if (entry) return { known: true };
782
+ const symbolName = symbol.replace(/^[#^!$~]/, "");
783
+ let bestMatch;
784
+ let bestScore = 0;
785
+ for (const e of index) {
786
+ const eName = e.symbol.replace(/^[#^!$~]/, "");
787
+ let shared = 0;
788
+ for (let i = 0; i < Math.min(symbolName.length, eName.length); i++) {
789
+ if (symbolName[i] === eName[i]) shared++;
790
+ else break;
791
+ }
792
+ const score = shared / Math.max(symbolName.length, eName.length);
793
+ if (score > bestScore && score > 0.5) {
794
+ bestScore = score;
795
+ bestMatch = e.symbol;
796
+ }
797
+ }
798
+ return { known: false, suggestion: bestMatch };
799
+ }
800
+ function autoPromoteToIncident(entry, storage) {
801
+ try {
802
+ const symbolType = inferSymbolType(entry.symbol);
803
+ const symbols = {};
804
+ if (symbolType === "component") symbols.component = entry.symbol;
805
+ else if (symbolType === "gate") symbols.gate = entry.symbol;
806
+ else if (symbolType === "signal") symbols.signal = entry.symbol;
807
+ else if (symbolType === "flow") symbols.flow = entry.symbol;
808
+ else symbols.component = entry.symbol;
809
+ storage.recordIncident({
810
+ error: {
811
+ message: entry.message,
812
+ type: "LogError"
813
+ },
814
+ symbols,
815
+ environment: entry.environment || "unknown",
816
+ service: entry.service
817
+ });
818
+ } catch {
819
+ }
820
+ }
821
+ var insertsSincePrune = 0;
822
+ function createLogsRouter(options) {
823
+ const router = Router6();
824
+ const { storage, serverConfig, onLogReceived, symbolIndex } = options;
825
+ router.post("/", async (req, res) => {
826
+ try {
827
+ const body = req.body;
828
+ let entries;
829
+ if (Array.isArray(body.entries)) {
830
+ entries = body.entries;
831
+ } else if (body.level && body.symbol && body.message && body.service) {
832
+ entries = [body];
833
+ } else {
834
+ res.status(400).json({ error: "Expected {entries: [...]} or a single log entry with level, symbol, message, service" });
835
+ return;
836
+ }
837
+ if (entries.length > serverConfig.maxBatchSize) {
838
+ res.status(413).json({
839
+ error: `Batch too large: ${entries.length} entries, max ${serverConfig.maxBatchSize}`
840
+ });
841
+ return;
842
+ }
843
+ for (let i = 0; i < entries.length; i++) {
844
+ const e = entries[i];
845
+ if (!e.level || !e.symbol || !e.message || !e.service) {
846
+ res.status(400).json({
847
+ error: `Entry ${i}: missing required fields (level, symbol, message, service)`
848
+ });
849
+ return;
850
+ }
851
+ if (!["debug", "info", "warn", "error"].includes(e.level)) {
852
+ res.status(400).json({
853
+ error: `Entry ${i}: invalid level "${e.level}", must be debug|info|warn|error`
854
+ });
855
+ return;
856
+ }
857
+ }
858
+ const result = storage.insertLogBatch(entries);
859
+ for (const input of entries) {
860
+ const entry = {
861
+ id: input.id || uuidv4(),
862
+ timestamp: input.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
863
+ level: input.level,
864
+ symbol: input.symbol,
865
+ symbolType: input.symbolType || inferSymbolType(input.symbol),
866
+ message: input.message,
867
+ data: input.data,
868
+ service: input.service,
869
+ sessionId: input.sessionId,
870
+ correlationId: input.correlationId,
871
+ durationMs: input.durationMs,
872
+ environment: input.environment
873
+ };
874
+ let validation;
875
+ if (symbolIndex) {
876
+ validation = validateSymbol(entry.symbol, symbolIndex);
877
+ }
878
+ if (entry.level === "error") {
879
+ autoPromoteToIncident(entry, storage);
880
+ }
881
+ if (onLogReceived) {
882
+ onLogReceived(entry, validation);
883
+ }
884
+ }
885
+ insertsSincePrune += result.accepted;
886
+ if (serverConfig.maxLogs > 0 && insertsSincePrune >= serverConfig.pruneIntervalInserts) {
887
+ insertsSincePrune = 0;
888
+ storage.pruneLogs(serverConfig.maxLogs);
889
+ }
890
+ res.json({ accepted: result.accepted, errors: result.errors.length > 0 ? result.errors : void 0 });
891
+ } catch (error) {
892
+ res.status(500).json({ error: "Failed to insert logs" });
893
+ }
894
+ });
895
+ router.get("/", async (req, res) => {
896
+ try {
897
+ const options2 = {
898
+ level: req.query.level,
899
+ symbol: req.query.symbol,
900
+ service: req.query.service,
901
+ sessionId: req.query.sessionId,
902
+ correlationId: req.query.correlationId,
903
+ search: req.query.search,
904
+ since: req.query.since,
905
+ until: req.query.until,
906
+ limit: req.query.limit ? parseInt(req.query.limit) : 100,
907
+ offset: req.query.offset ? parseInt(req.query.offset) : 0
908
+ };
909
+ const logs = storage.queryLogs(options2);
910
+ const total = storage.getLogCount(options2);
911
+ res.json({ count: logs.length, total, logs });
912
+ } catch (error) {
913
+ res.status(500).json({ error: "Failed to query logs" });
914
+ }
915
+ });
916
+ return router;
917
+ }
918
+
919
+ // src/server/routes/services.ts
920
+ import { Router as Router7 } from "express";
921
+ function createServicesRouter(options) {
922
+ const router = Router7();
923
+ const { storage } = options;
924
+ router.post("/", async (req, res) => {
925
+ try {
926
+ const { name, version, pid, environment, metadata } = req.body;
927
+ if (!name) {
928
+ res.status(400).json({ error: "Missing required field: name" });
929
+ return;
930
+ }
931
+ storage.registerService({ name, version, pid, environment, metadata });
932
+ res.json({ success: true, service: name });
933
+ } catch (error) {
934
+ res.status(500).json({ error: "Failed to register service" });
935
+ }
936
+ });
937
+ router.get("/", async (_req, res) => {
938
+ try {
939
+ const services = storage.getServices();
940
+ res.json({ count: services.length, services });
941
+ } catch (error) {
942
+ res.status(500).json({ error: "Failed to list services" });
943
+ }
944
+ });
945
+ return router;
946
+ }
947
+ function createStateRouter(options) {
948
+ const router = Router7();
949
+ const { storage } = options;
950
+ router.post("/", async (req, res) => {
951
+ try {
952
+ const { service, sessionId, state, activeFlows, activeGates } = req.body;
953
+ if (!service || !sessionId || !state) {
954
+ res.status(400).json({ error: "Missing required fields: service, sessionId, state" });
955
+ return;
956
+ }
957
+ storage.upsertAppState({
958
+ service,
959
+ sessionId,
960
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
961
+ state,
962
+ activeFlows,
963
+ activeGates
964
+ });
965
+ storage.updateServiceLastSeen(service);
966
+ res.json({ success: true });
967
+ } catch (error) {
968
+ res.status(500).json({ error: "Failed to update state" });
969
+ }
970
+ });
971
+ router.get("/", async (_req, res) => {
972
+ try {
973
+ const states = storage.getAllAppStates();
974
+ res.json({ count: states.length, states });
975
+ } catch (error) {
976
+ res.status(500).json({ error: "Failed to get states" });
977
+ }
978
+ });
979
+ router.get("/:service", async (req, res) => {
980
+ try {
981
+ const states = storage.getAppState(req.params.service);
982
+ res.json({ count: states.length, states });
983
+ } catch (error) {
984
+ res.status(500).json({ error: "Failed to get service state" });
985
+ }
986
+ });
987
+ return router;
988
+ }
989
+
990
+ // src/server/routes/metrics.ts
991
+ import { Router as Router8 } from "express";
992
+ var VALID_METRIC_TYPES = ["counter", "gauge", "histogram"];
993
+ function createMetricsRouter(options) {
994
+ const router = Router8();
995
+ const { storage, serverConfig } = options;
996
+ router.post("/", (req, res) => {
997
+ try {
998
+ const body = req.body;
999
+ let entries;
1000
+ if (Array.isArray(body.entries)) {
1001
+ entries = body.entries;
1002
+ } else if (body.name && body.type && body.value !== void 0 && body.service) {
1003
+ entries = [body];
1004
+ } else {
1005
+ res.status(400).json({
1006
+ error: "Expected {entries: [...]} or a single metric with name, type, value, service"
1007
+ });
1008
+ return;
1009
+ }
1010
+ if (entries.length > serverConfig.maxBatchSize) {
1011
+ res.status(413).json({
1012
+ error: `Batch too large: ${entries.length} entries, max ${serverConfig.maxBatchSize}`
1013
+ });
1014
+ return;
1015
+ }
1016
+ for (let i = 0; i < entries.length; i++) {
1017
+ const e = entries[i];
1018
+ if (!e.name || !e.type || e.value === void 0 || !e.service) {
1019
+ res.status(400).json({
1020
+ error: `Entry ${i}: missing required fields (name, type, value, service)`
1021
+ });
1022
+ return;
1023
+ }
1024
+ if (!VALID_METRIC_TYPES.includes(e.type)) {
1025
+ res.status(400).json({
1026
+ error: `Entry ${i}: invalid type "${e.type}", must be counter|gauge|histogram`
1027
+ });
1028
+ return;
1029
+ }
1030
+ }
1031
+ const result = storage.insertMetricBatch(entries);
1032
+ res.json({
1033
+ accepted: result.accepted,
1034
+ errors: result.errors.length > 0 ? result.errors : void 0
1035
+ });
1036
+ } catch {
1037
+ res.status(500).json({ error: "Failed to insert metrics" });
1038
+ }
1039
+ });
1040
+ router.get("/", (req, res) => {
1041
+ try {
1042
+ const options2 = {
1043
+ name: req.query.name,
1044
+ type: req.query.type,
1045
+ service: req.query.service,
1046
+ tag: req.query.tag,
1047
+ since: req.query.since,
1048
+ until: req.query.until,
1049
+ limit: req.query.limit ? parseInt(req.query.limit) : 100,
1050
+ offset: req.query.offset ? parseInt(req.query.offset) : 0
1051
+ };
1052
+ const metrics = storage.queryMetrics(options2);
1053
+ const total = storage.getMetricCount(options2);
1054
+ res.json({ count: metrics.length, total, metrics });
1055
+ } catch {
1056
+ res.status(500).json({ error: "Failed to query metrics" });
1057
+ }
1058
+ });
1059
+ router.get("/aggregate/:name", (req, res) => {
1060
+ try {
1061
+ const aggregation = storage.aggregateMetric(req.params.name, {
1062
+ service: req.query.service,
1063
+ since: req.query.since,
1064
+ until: req.query.until
1065
+ });
1066
+ res.json(aggregation);
1067
+ } catch {
1068
+ res.status(500).json({ error: "Failed to aggregate metric" });
1069
+ }
1070
+ });
1071
+ return router;
1072
+ }
1073
+
1074
+ // src/server/routes/traces.ts
1075
+ import { Router as Router9 } from "express";
1076
+ function createTracesRouter(options) {
1077
+ const router = Router9();
1078
+ const { storage } = options;
1079
+ router.post("/", (req, res) => {
1080
+ try {
1081
+ const body = req.body;
1082
+ if (!body.traceId || !body.service || !body.symbol || !body.operation) {
1083
+ res.status(400).json({
1084
+ error: "Missing required fields: traceId, service, symbol, operation"
1085
+ });
1086
+ return;
1087
+ }
1088
+ const spanId = storage.insertSpan(body);
1089
+ res.json({ spanId, traceId: body.traceId });
1090
+ } catch {
1091
+ res.status(500).json({ error: "Failed to insert trace span" });
1092
+ }
1093
+ });
1094
+ router.get("/", (req, res) => {
1095
+ try {
1096
+ const traces = storage.queryTraces({
1097
+ service: req.query.service,
1098
+ symbol: req.query.symbol,
1099
+ since: req.query.since,
1100
+ limit: req.query.limit ? parseInt(req.query.limit) : 20
1101
+ });
1102
+ res.json({ count: traces.length, traces });
1103
+ } catch {
1104
+ res.status(500).json({ error: "Failed to query traces" });
1105
+ }
1106
+ });
1107
+ router.get("/:traceId", (req, res) => {
1108
+ try {
1109
+ const trace = storage.getTrace(req.params.traceId);
1110
+ if (!trace) {
1111
+ res.status(404).json({ error: "Trace not found" });
1112
+ return;
1113
+ }
1114
+ res.json(trace);
1115
+ } catch {
1116
+ res.status(500).json({ error: "Failed to get trace" });
1117
+ }
1118
+ });
1119
+ return router;
1120
+ }
1121
+
1122
+ // src/server/middleware/auth.ts
1123
+ function createAuthMiddleware(config) {
1124
+ return function authMiddleware(requiredPermission) {
1125
+ return (req, res, next) => {
1126
+ if (!config.enabled) {
1127
+ next();
1128
+ return;
1129
+ }
1130
+ const authHeader = req.headers.authorization;
1131
+ if (!authHeader) {
1132
+ res.status(401).json({ error: "Authentication required. Provide Authorization: Bearer <token>" });
1133
+ return;
1134
+ }
1135
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
1136
+ if (!match) {
1137
+ res.status(401).json({ error: "Invalid authorization format. Use: Bearer <token>" });
1138
+ return;
1139
+ }
1140
+ const tokenValue = match[1];
1141
+ const tokenEntry = config.tokens.find((t) => t.token === tokenValue);
1142
+ if (!tokenEntry) {
1143
+ res.status(401).json({ error: "Invalid token" });
1144
+ return;
1145
+ }
1146
+ if (tokenEntry.expiresAt && new Date(tokenEntry.expiresAt) < /* @__PURE__ */ new Date()) {
1147
+ res.status(401).json({ error: "Token expired" });
1148
+ return;
1149
+ }
1150
+ const permissionLevel = { read: 1, write: 2, admin: 3 };
1151
+ const hasPermission = tokenEntry.permissions.some(
1152
+ (p) => permissionLevel[p] >= permissionLevel[requiredPermission]
1153
+ );
1154
+ if (!hasPermission) {
1155
+ res.status(403).json({ error: `Insufficient permissions. Required: ${requiredPermission}` });
1156
+ return;
1157
+ }
1158
+ req.authToken = tokenEntry;
1159
+ next();
1160
+ };
1161
+ };
1162
+ }
1163
+
1164
+ // src/server/middleware/rate-limit.ts
1165
+ var serviceWindows = /* @__PURE__ */ new Map();
1166
+ var globalWindow = { count: 0, windowStart: Date.now() };
1167
+ var WINDOW_MS = 6e4;
1168
+ function getOrResetWindow(entry) {
1169
+ const now = Date.now();
1170
+ if (now - entry.windowStart > WINDOW_MS) {
1171
+ entry.count = 0;
1172
+ entry.windowStart = now;
1173
+ }
1174
+ return entry;
1175
+ }
1176
+ function createRateLimiter(config) {
1177
+ return (req, res, next) => {
1178
+ if (!config.enabled) {
1179
+ next();
1180
+ return;
1181
+ }
1182
+ const service = req.body?.service || req.body?.entries?.[0]?.service || req.query.service || "_unknown";
1183
+ const rule = config.perService[service] || config.global;
1184
+ if (rule.samplingRate < 1 && Math.random() > rule.samplingRate) {
1185
+ res.status(200).json({ accepted: 0, sampled: true, message: "Request dropped by sampling" });
1186
+ return;
1187
+ }
1188
+ const gw = getOrResetWindow(globalWindow);
1189
+ if (gw.count >= config.global.maxRequestsPerMinute) {
1190
+ res.status(429).json({
1191
+ error: "Global rate limit exceeded",
1192
+ retryAfterMs: WINDOW_MS - (Date.now() - gw.windowStart)
1193
+ });
1194
+ return;
1195
+ }
1196
+ if (!serviceWindows.has(service)) {
1197
+ serviceWindows.set(service, { count: 0, windowStart: Date.now() });
1198
+ }
1199
+ const sw = getOrResetWindow(serviceWindows.get(service));
1200
+ if (sw.count >= rule.maxRequestsPerMinute) {
1201
+ res.status(429).json({
1202
+ error: `Rate limit exceeded for service: ${service}`,
1203
+ retryAfterMs: WINDOW_MS - (Date.now() - sw.windowStart)
1204
+ });
1205
+ return;
1206
+ }
1207
+ const batchSize = req.body?.entries?.length || 1;
1208
+ if (batchSize > rule.maxEntriesPerBatch) {
1209
+ res.status(413).json({
1210
+ error: `Batch too large: ${batchSize} entries, max ${rule.maxEntriesPerBatch} for service ${service}`
1211
+ });
1212
+ return;
1213
+ }
1214
+ gw.count++;
1215
+ sw.count++;
1216
+ next();
1217
+ };
1218
+ }
1219
+
765
1220
  // src/server/index.ts
766
1221
  var __filename = fileURLToPath(import.meta.url);
767
1222
  var __dirname = path3.dirname(__filename);
@@ -790,11 +1245,20 @@ var log3 = {
790
1245
  };
791
1246
  function createApp(options) {
792
1247
  const app = express();
793
- app.use(express.json());
1248
+ app.use(express.json({ limit: "5mb" }));
794
1249
  app.use((_req, res, next) => {
795
- res.header("Access-Control-Allow-Origin", "*");
1250
+ const corsOrigin = options.serverConfig?.cors?.origin;
1251
+ const origin = Array.isArray(corsOrigin) ? corsOrigin.join(", ") : corsOrigin ?? "*";
1252
+ res.header("Access-Control-Allow-Origin", origin);
796
1253
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
797
- res.header("Access-Control-Allow-Headers", "Content-Type");
1254
+ res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
1255
+ if (options.serverConfig?.cors?.credentials) {
1256
+ res.header("Access-Control-Allow-Credentials", "true");
1257
+ }
1258
+ if (_req.method === "OPTIONS") {
1259
+ res.sendStatus(204);
1260
+ return;
1261
+ }
798
1262
  next();
799
1263
  });
800
1264
  app.use("/api/symbols", createSymbolsRouter(options.projectDir));
@@ -802,6 +1266,26 @@ function createApp(options) {
802
1266
  app.use("/api/commits", createCommitsRouter(options.projectDir));
803
1267
  app.use("/api/incidents", createIncidentsRouter(options.projectDir));
804
1268
  app.use("/api/patterns", createPatternsRouter(options.projectDir));
1269
+ if (options.storage && options.serverConfig) {
1270
+ const config = options.serverConfig;
1271
+ const auth = createAuthMiddleware(config.auth);
1272
+ const rateLimiter = createRateLimiter(config.rateLimit);
1273
+ app.use("/api/logs", rateLimiter, auth("write"), createLogsRouter({
1274
+ storage: options.storage,
1275
+ serverConfig: config,
1276
+ onLogReceived: options.onLogReceived,
1277
+ symbolIndex: options.symbolIndex
1278
+ }));
1279
+ app.use("/api/services", rateLimiter, auth("write"), createServicesRouter({ storage: options.storage }));
1280
+ app.use("/api/state", rateLimiter, auth("write"), createStateRouter({ storage: options.storage }));
1281
+ app.use("/api/metrics", rateLimiter, auth("write"), createMetricsRouter({
1282
+ storage: options.storage,
1283
+ serverConfig: config
1284
+ }));
1285
+ app.use("/api/traces", rateLimiter, auth("write"), createTracesRouter({
1286
+ storage: options.storage
1287
+ }));
1288
+ }
805
1289
  app.get("/api/health", (_req, res) => {
806
1290
  res.json({ status: "ok", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
807
1291
  });
@@ -817,12 +1301,100 @@ function createApp(options) {
817
1301
  return app;
818
1302
  }
819
1303
  async function startServer(options) {
820
- const app = createApp(options);
1304
+ const serverConfig = loadServerConfig(options.projectDir);
1305
+ if (options.logPruneLimit !== void 0) {
1306
+ serverConfig.maxLogs = options.logPruneLimit;
1307
+ }
1308
+ const storage = new SentinelStorage(options.dbPath);
1309
+ await storage.ensureReady();
1310
+ let symbolIndex = [];
1311
+ try {
1312
+ symbolIndex = await loadSymbolIndex(options.projectDir);
1313
+ } catch {
1314
+ log3.component("sentinel-server").warn("Could not load symbol index for validation");
1315
+ }
1316
+ const wsClients = /* @__PURE__ */ new Set();
1317
+ function broadcast(message) {
1318
+ const data = JSON.stringify(message);
1319
+ for (const client of wsClients) {
1320
+ if (client.readyState === WebSocket.OPEN) {
1321
+ client.send(data);
1322
+ }
1323
+ }
1324
+ }
1325
+ function onLogReceived(entry, validation) {
1326
+ const message = { type: "log", entry };
1327
+ if (validation && !validation.known) {
1328
+ message.validation = validation;
1329
+ }
1330
+ broadcast(message);
1331
+ if (entry.symbolType === "signal" || entry.symbolType === "gate" || entry.symbolType === "flow") {
1332
+ broadcast({
1333
+ type: "flow_event",
1334
+ flowId: entry.symbolType === "flow" ? entry.symbol : void 0,
1335
+ nodeSymbol: entry.symbol,
1336
+ event: entry.symbolType,
1337
+ timestamp: entry.timestamp,
1338
+ service: entry.service
1339
+ });
1340
+ }
1341
+ }
1342
+ const app = createApp({
1343
+ ...options,
1344
+ storage,
1345
+ serverConfig,
1346
+ symbolIndex,
1347
+ onLogReceived
1348
+ });
821
1349
  log3.component("sentinel-server").info("Starting server", { port: options.port });
822
1350
  log3.component("sentinel-server").info("Project directory", { path: options.projectDir });
823
1351
  return new Promise((resolve, reject) => {
824
- const server = app.listen(options.port, () => {
1352
+ const httpServer = http.createServer(app);
1353
+ const wss = new WebSocketServer({ server: httpServer });
1354
+ wss.on("connection", (ws) => {
1355
+ if (wsClients.size >= serverConfig.wsMaxSubscribers) {
1356
+ ws.close(1013, "Max subscribers reached");
1357
+ return;
1358
+ }
1359
+ wsClients.add(ws);
1360
+ log3.component("sentinel-ws").info("Client connected", { total: wsClients.size });
1361
+ ws.on("message", (raw) => {
1362
+ try {
1363
+ const msg = JSON.parse(raw.toString());
1364
+ if (msg.method === "ping") {
1365
+ ws.send(JSON.stringify({
1366
+ jsonrpc: "2.0",
1367
+ result: { pong: true, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
1368
+ id: msg.id
1369
+ }));
1370
+ } else if (msg.method === "subscribe") {
1371
+ ws.send(JSON.stringify({
1372
+ jsonrpc: "2.0",
1373
+ result: { subscribed: true },
1374
+ id: msg.id
1375
+ }));
1376
+ } else if (msg.method === "query_logs") {
1377
+ const logs = storage.queryLogs(msg.params || {});
1378
+ ws.send(JSON.stringify({
1379
+ jsonrpc: "2.0",
1380
+ result: { logs },
1381
+ id: msg.id
1382
+ }));
1383
+ }
1384
+ } catch {
1385
+ }
1386
+ });
1387
+ ws.on("close", () => {
1388
+ wsClients.delete(ws);
1389
+ log3.component("sentinel-ws").info("Client disconnected", { total: wsClients.size });
1390
+ });
1391
+ ws.on("error", () => {
1392
+ wsClients.delete(ws);
1393
+ });
1394
+ });
1395
+ httpServer.listen(options.port, () => {
825
1396
  log3.component("sentinel-server").success("Server running", { url: `http://localhost:${options.port}` });
1397
+ log3.component("sentinel-ws").success("WebSocket ready", { url: `ws://localhost:${options.port}` });
826
1398
  if (options.open) {
827
1399
  import("open").then((openModule) => {
828
1400
  openModule.default(`http://localhost:${options.port}`);
@@ -833,7 +1405,7 @@ async function startServer(options) {
833
1405
  }
834
1406
  resolve();
835
1407
  });
836
- server.on("error", (err) => {
1408
+ httpServer.on("error", (err) => {
837
1409
  if (err.code === "EADDRINUSE") {
838
1410
  log3.component("sentinel-server").error("Port already in use", { port: options.port });
839
1411
  } else {