@cnstra/devtools-server 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,995 @@
1
+ import { createServer } from 'http';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { createRequire } from 'module';
5
+ import { WebSocketServer, WebSocket } from 'ws';
6
+
7
+ // src/start.ts
8
+ var _require = createRequire(join(process.cwd(), "package.json"));
9
+ function findPanelUIDir() {
10
+ try {
11
+ const pkg = _require.resolve("@cnstra/devtools-panel-ui/package.json");
12
+ return join(dirname(pkg), "dist");
13
+ } catch {
14
+ }
15
+ try {
16
+ const here = dirname(_require.resolve("@cnstra/devtools-server/package.json"));
17
+ const monorepoPath = join(here, "..", "..", "devtools-panel-ui", "dist");
18
+ if (existsSync(join(monorepoPath, "index.html"))) return monorepoPath;
19
+ } catch {
20
+ }
21
+ return null;
22
+ }
23
+ function makeCNSAdapter(cns, registry) {
24
+ return {
25
+ addResponseListener: (l) => cns.addResponseListener(l),
26
+ stimulate: (signal, opts) => cns.stimulate(signal, opts),
27
+ getNeurons: () => [...registry.getNamedNeurons().entries()].map(([name, n]) => ({
28
+ name,
29
+ axon: n.axon,
30
+ dendrites: n.dendrites
31
+ })),
32
+ getCollaterals: () => {
33
+ const out = [];
34
+ for (const [, n] of registry.getNamedNeurons()) {
35
+ for (const [key, col] of Object.entries(n.axon)) {
36
+ out.push({ name: key, createSignal: (p) => col.createSignal(p) });
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ };
42
+ }
43
+ var MIME = {
44
+ html: "text/html",
45
+ js: "application/javascript",
46
+ css: "text/css",
47
+ svg: "image/svg+xml",
48
+ png: "image/png",
49
+ woff2: "font/woff2"
50
+ };
51
+ function serveStatic(panelUIDir) {
52
+ return (_req, res) => {
53
+ const url = _req.url ?? "/";
54
+ const safe = url.split("?")[0].replace(/\.\./g, "");
55
+ const file = safe === "/" ? "/index.html" : safe;
56
+ const full = join(panelUIDir, file);
57
+ if (existsSync(full) && full.startsWith(panelUIDir)) {
58
+ const ext = full.split(".").pop() ?? "";
59
+ res.writeHead(200, { "Content-Type": MIME[ext] ?? "text/plain" });
60
+ res.end(readFileSync(full));
61
+ } else {
62
+ const idx = join(panelUIDir, "index.html");
63
+ if (existsSync(idx)) {
64
+ res.writeHead(200, { "Content-Type": "text/html" });
65
+ res.end(readFileSync(idx));
66
+ } else {
67
+ res.writeHead(503);
68
+ res.end("DevTools UI not built. Run: npm run build --workspace=@cnstra/devtools-panel-ui");
69
+ }
70
+ }
71
+ };
72
+ }
73
+ async function startDevTools(cns, registry, options = {}) {
74
+ const port = options.port ?? 3141;
75
+ const appId = options.appId ?? "app";
76
+ const logEnabled = options.consoleLogEnabled ?? false;
77
+ const panelUIDir = findPanelUIDir();
78
+ if (!panelUIDir) {
79
+ console.warn(
80
+ "\n\u26A0\uFE0F CNStra DevTools UI not found.\n WebSocket server will start but the UI will not be served.\n Install @cnstra/devtools-panel-ui or build it from the monorepo.\n"
81
+ );
82
+ }
83
+ const httpServer = createServer(
84
+ panelUIDir ? serveStatic(panelUIDir) : (_, res) => {
85
+ res.writeHead(503);
86
+ res.end("DevTools UI not available. See console for details.");
87
+ }
88
+ );
89
+ const wss = new WebSocketServer({ server: httpServer });
90
+ const { CNSDevToolsServerRepositoryInMemory } = await import('@cnstra/devtools-server-repository-in-memory');
91
+ const dtServer = new CNSDevToolsServer(
92
+ new CNSDevToolsServerRepositoryInMemory(),
93
+ { consoleLogEnabled: logEnabled }
94
+ );
95
+ const clients = /* @__PURE__ */ new Set();
96
+ wss.on("connection", (ws) => {
97
+ clients.add(ws);
98
+ ws.on("message", async (data) => {
99
+ try {
100
+ const msg = JSON.parse(data.toString());
101
+ const result = await dtServer.handleMessage(ws, msg);
102
+ if (result) {
103
+ const payload = JSON.stringify(result);
104
+ clients.forEach((c) => {
105
+ if (c.readyState === WebSocket.OPEN) c.send(payload);
106
+ });
107
+ }
108
+ } catch {
109
+ }
110
+ });
111
+ ws.on("close", () => {
112
+ clients.delete(ws);
113
+ dtServer.handleDisconnect(ws);
114
+ });
115
+ });
116
+ await new Promise((resolve) => httpServer.listen(port, resolve));
117
+ const url = `http://localhost:${port}`;
118
+ const { CNSDevTools } = await import('@cnstra/devtools');
119
+ const { CNSDevToolsTransportWs } = await import('@cnstra/devtools-transport-ws');
120
+ const transport = new CNSDevToolsTransportWs({
121
+ url: `ws://localhost:${port}`
122
+ });
123
+ const devtools = new CNSDevTools(appId, transport, {
124
+ devToolsInstanceName: appId,
125
+ consoleLogEnabled: logEnabled
126
+ });
127
+ devtools.registerCNS(makeCNSAdapter(cns, registry), "main");
128
+ if (logEnabled || process.stdout.isTTY) {
129
+ console.log(`
130
+ \u{1F9E0} CNStra DevTools: ${url}
131
+ `);
132
+ }
133
+ const shouldOpen = options.open ?? process.stdout.isTTY;
134
+ if (shouldOpen) {
135
+ const { exec } = await import('child_process');
136
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
137
+ exec(`${cmd} ${url}`);
138
+ }
139
+ return {
140
+ url,
141
+ stop: () => {
142
+ wss.close();
143
+ httpServer.close();
144
+ }
145
+ };
146
+ }
147
+
148
+ // src/index.ts
149
+ var CNSDevToolsServer = class _CNSDevToolsServer {
150
+ constructor(repository, options = {}) {
151
+ this.repository = repository;
152
+ this.options = options;
153
+ this.stopped = false;
154
+ this.appSockets = /* @__PURE__ */ new Map();
155
+ this.clientSockets = /* @__PURE__ */ new Set();
156
+ this.lastInitByApp = /* @__PURE__ */ new Map();
157
+ this.stimulationBuffer = [];
158
+ // Track app connection times
159
+ this.appFirstSeenAt = /* @__PURE__ */ new Map();
160
+ this.appLastSeenAt = /* @__PURE__ */ new Map();
161
+ this.replaysByApp = /* @__PURE__ */ new Map();
162
+ // server metrics sampling
163
+ this.lastCpuUsage = process.cpuUsage();
164
+ this.lastCpuTime = Date.now();
165
+ }
166
+ static sanitizeValue(value, depth = 0, limits = {
167
+ maxDepth: 5,
168
+ maxString: 2e3,
169
+ maxArray: 200,
170
+ maxObjectKeys: 200
171
+ }) {
172
+ if (value === null || value === void 0) return value;
173
+ if (depth >= limits.maxDepth) return "[MaxDepth]";
174
+ const t = typeof value;
175
+ if (t === "string") {
176
+ const s = value;
177
+ return s.length > limits.maxString ? s.slice(0, limits.maxString) + `\u2026[+${s.length - limits.maxString}]` : s;
178
+ }
179
+ if (t === "number" || t === "boolean" || t === "bigint" || t === "symbol")
180
+ return value;
181
+ if (t === "function") return "[Function]";
182
+ if (value instanceof Error) {
183
+ return {
184
+ name: value.name,
185
+ message: value.message,
186
+ stack: value.stack
187
+ };
188
+ }
189
+ if (Array.isArray(value)) {
190
+ const arr = value;
191
+ const slice = arr.slice(0, limits.maxArray);
192
+ const mapped = slice.map(
193
+ (v) => this.sanitizeValue(v, depth + 1, limits)
194
+ );
195
+ if (arr.length > slice.length)
196
+ mapped.push(`[\u2026+${arr.length - slice.length} items]`);
197
+ return mapped;
198
+ }
199
+ if (t === "object") {
200
+ const out = {};
201
+ const entries = Object.entries(value);
202
+ const slice = entries.slice(0, limits.maxObjectKeys);
203
+ for (const [k, v] of slice) {
204
+ out[k] = this.sanitizeValue(v, depth + 1, limits);
205
+ }
206
+ if (entries.length > slice.length) {
207
+ out["\u2026"] = `+${entries.length - slice.length} keys`;
208
+ }
209
+ return out;
210
+ }
211
+ return value;
212
+ }
213
+ log(...args) {
214
+ if (this.options.consoleLogEnabled) {
215
+ console.log(...args);
216
+ }
217
+ }
218
+ isNeuronResponseMessage(message) {
219
+ return message !== null && typeof message === "object" && "stimulationId" in message && "neuronId" in message && "appId" in message && "collateralName" in message && "timestamp" in message && typeof message.stimulationId === "string" && typeof message.neuronId === "string" && typeof message.appId === "string" && typeof message.collateralName === "string" && typeof message.timestamp === "number" && !("type" in message);
220
+ }
221
+ isMessageWithType(message) {
222
+ return message !== null && typeof message === "object" && "type" in message && typeof message.type === "string";
223
+ }
224
+ // Expose sockets map size for tests
225
+ /** @internal */
226
+ get connectedAppCount() {
227
+ return this.appSockets.size;
228
+ }
229
+ static applyWindowAndPaginate(items, params) {
230
+ const { fromTimestamp, toTimestamp } = params;
231
+ const offset = Math.max(0, params.offset ?? 0);
232
+ const limit = params.limit !== void 0 && params.limit > 0 ? params.limit : void 0;
233
+ const filtered = items.filter(
234
+ (item) => (fromTimestamp === void 0 || item.timestamp >= fromTimestamp) && (toTimestamp === void 0 || item.timestamp <= toTimestamp)
235
+ ).slice().sort((a, b) => a.timestamp - b.timestamp);
236
+ if (offset > 0 || limit !== void 0) {
237
+ return filtered.slice(
238
+ offset,
239
+ limit !== void 0 ? offset + limit : void 0
240
+ );
241
+ }
242
+ return filtered;
243
+ }
244
+ async handleMessage(ws, message) {
245
+ if (!this.isMessageWithType(message)) {
246
+ if (this.options.consoleLogEnabled) {
247
+ console.warn("Unknown message format:", message);
248
+ }
249
+ return null;
250
+ }
251
+ switch (message.type) {
252
+ case "devtools-client-connect":
253
+ this.log("DevTools client connecting...");
254
+ this.addClient(ws);
255
+ if (!this.metricsTimer) {
256
+ this.startMetricsLoop();
257
+ }
258
+ ws.send(JSON.stringify({ type: "devtools-client-connected" }));
259
+ this.log("DevTools client connected, total clients:", this.clientSockets.size);
260
+ return { type: "devtools-client-connected" };
261
+ case "init":
262
+ return this.handleInit(ws, message);
263
+ case "batch":
264
+ return this.handleBatch(
265
+ ws,
266
+ message
267
+ );
268
+ case "neuron-response-batch":
269
+ case "response-batch":
270
+ return this.handleResponseBatch(
271
+ ws,
272
+ message
273
+ );
274
+ case "stimulate":
275
+ return this.handleStimulate(ws, message);
276
+ case "stimulate-accepted":
277
+ try {
278
+ const msg = message;
279
+ const cmdId = msg.stimulationCommandId;
280
+ const appId = msg.appId;
281
+ if (cmdId && appId) {
282
+ const arr = this.replaysByApp.get(appId) || [];
283
+ for (let i = arr.length - 1; i >= 0; i--) {
284
+ if (arr[i].stimulationCommandId === cmdId) {
285
+ arr[i].result = "accepted";
286
+ break;
287
+ }
288
+ }
289
+ this.replaysByApp.set(appId, arr);
290
+ }
291
+ } catch {
292
+ }
293
+ return message;
294
+ case "stimulate-rejected":
295
+ try {
296
+ const msg = message;
297
+ const cmdId = msg.stimulationCommandId;
298
+ const appId = msg.appId;
299
+ if (cmdId && appId) {
300
+ const arr = this.replaysByApp.get(appId) || [];
301
+ for (let i = arr.length - 1; i >= 0; i--) {
302
+ if (arr[i].stimulationCommandId === cmdId) {
303
+ arr[i].result = "rejected";
304
+ break;
305
+ }
306
+ }
307
+ this.replaysByApp.set(appId, arr);
308
+ }
309
+ } catch {
310
+ }
311
+ return message;
312
+ case "stimulation":
313
+ return this.handleStimulation(
314
+ ws,
315
+ message
316
+ );
317
+ case "apps:get-topology": {
318
+ const inits = Array.from(this.lastInitByApp.values());
319
+ return { type: "apps:topology", inits };
320
+ }
321
+ case "apps:get-stimulations": {
322
+ const msg = message;
323
+ const {
324
+ appId,
325
+ limit,
326
+ fromTimestamp,
327
+ toTimestamp,
328
+ offset,
329
+ hasError,
330
+ errorContains,
331
+ collateralName,
332
+ neuronId
333
+ } = msg;
334
+ let stimulations = [];
335
+ if (appId) {
336
+ stimulations = await this.repository.getStimulationsByApp(
337
+ appId,
338
+ {
339
+ fromTimestamp,
340
+ toTimestamp,
341
+ limit,
342
+ offset,
343
+ hasError,
344
+ errorContains,
345
+ collateralName,
346
+ neuronId
347
+ }
348
+ );
349
+ } else {
350
+ const apps = await this.repository.listApps();
351
+ for (const app of apps) {
352
+ const appStimulations = await this.repository.getStimulationsByApp(
353
+ app.appId,
354
+ {
355
+ fromTimestamp,
356
+ toTimestamp,
357
+ limit,
358
+ offset,
359
+ hasError,
360
+ errorContains,
361
+ collateralName,
362
+ neuronId
363
+ }
364
+ );
365
+ stimulations = stimulations.concat(appStimulations);
366
+ }
367
+ return {
368
+ type: "stimulation-batch",
369
+ stimulations
370
+ };
371
+ }
372
+ return {
373
+ type: "stimulation-batch",
374
+ stimulations
375
+ };
376
+ }
377
+ case "apps:get-replays": {
378
+ const msg = message;
379
+ const { appId, limit, fromTimestamp, toTimestamp, offset } = msg;
380
+ if (!appId) {
381
+ return { type: "apps:replays", replays: [] };
382
+ }
383
+ let list = this.replaysByApp.get(appId) || [];
384
+ if (fromTimestamp !== void 0) {
385
+ list = list.filter((r) => r.timestamp >= fromTimestamp);
386
+ }
387
+ if (toTimestamp !== void 0) {
388
+ list = list.filter((r) => r.timestamp <= toTimestamp);
389
+ }
390
+ list = list.sort((a, b) => b.timestamp - a.timestamp);
391
+ if (offset !== void 0 && offset > 0) {
392
+ list = list.slice(offset);
393
+ }
394
+ if (limit !== void 0 && limit > 0) {
395
+ list = list.slice(0, limit);
396
+ }
397
+ return { type: "apps:replays", appId, replays: list };
398
+ }
399
+ // Export endpoints (JSON export over WS)
400
+ case "apps:export-topology": {
401
+ const { appId } = message;
402
+ const inits = Array.from(this.lastInitByApp.values());
403
+ if (appId) {
404
+ const filtered = inits.filter((init) => {
405
+ const baseAppId = init.appId || init.devToolsInstanceId;
406
+ return baseAppId === appId;
407
+ });
408
+ return { type: "apps:topology", inits: filtered };
409
+ }
410
+ return { type: "apps:topology", inits };
411
+ }
412
+ case "apps:export-stimulations": {
413
+ const {
414
+ appId,
415
+ limit,
416
+ fromTimestamp,
417
+ toTimestamp,
418
+ offset,
419
+ hasError,
420
+ errorContains,
421
+ collateralName,
422
+ neuronId
423
+ } = message;
424
+ let stimulations = [];
425
+ if (appId) {
426
+ stimulations = await this.repository.getStimulationsByApp(
427
+ appId,
428
+ {
429
+ fromTimestamp,
430
+ toTimestamp,
431
+ limit,
432
+ offset,
433
+ hasError,
434
+ errorContains,
435
+ collateralName,
436
+ neuronId
437
+ }
438
+ );
439
+ } else {
440
+ const apps = await this.repository.listApps();
441
+ for (const app of apps) {
442
+ const appStimulations = await this.repository.getStimulationsByApp(
443
+ app.appId,
444
+ {
445
+ fromTimestamp,
446
+ toTimestamp,
447
+ limit,
448
+ offset,
449
+ hasError,
450
+ errorContains,
451
+ collateralName,
452
+ neuronId
453
+ }
454
+ );
455
+ stimulations = stimulations.concat(appStimulations);
456
+ }
457
+ }
458
+ return {
459
+ type: "apps:export-stimulations",
460
+ stimulations
461
+ };
462
+ }
463
+ case "apps:export-snapshot": {
464
+ const { appId, limitResponses, limitStimulations } = message;
465
+ const inits = Array.from(this.lastInitByApp.values());
466
+ const topology = appId ? inits.filter(
467
+ (init) => (init.appId || init.devToolsInstanceId) === appId
468
+ ) : inits;
469
+ let stimulations = [];
470
+ if (appId) {
471
+ stimulations = await this.repository.getStimulationsByApp(
472
+ appId
473
+ );
474
+ } else {
475
+ const apps = await this.repository.listApps();
476
+ for (const app of apps) {
477
+ const appStimulations = await this.repository.getStimulationsByApp(
478
+ app.appId
479
+ );
480
+ stimulations = stimulations.concat(appStimulations);
481
+ }
482
+ }
483
+ const responses = [];
484
+ if (appId) {
485
+ const cnsIds = await this.repository.getCnsByApp(appId);
486
+ for (const cnsId of cnsIds) {
487
+ const cnsResponses = await this.repository.getResponsesByCns(cnsId);
488
+ responses.push(...cnsResponses);
489
+ }
490
+ } else {
491
+ const apps = await this.repository.listApps();
492
+ for (const app of apps) {
493
+ const cnsIds = await this.repository.getCnsByApp(
494
+ app.appId
495
+ );
496
+ for (const cnsId of cnsIds) {
497
+ const cnsResponses = await this.repository.getResponsesByCns(cnsId);
498
+ responses.push(...cnsResponses);
499
+ }
500
+ }
501
+ }
502
+ const tail = (arr, n) => typeof n === "number" && n > 0 ? arr.slice(Math.max(0, arr.length - n)) : arr;
503
+ const sanitizedStim = tail(stimulations, limitStimulations).map(
504
+ (s) => ({
505
+ ...s,
506
+ payload: _CNSDevToolsServer.sanitizeValue(
507
+ s.payload
508
+ )
509
+ })
510
+ );
511
+ const sanitizedResp = tail(responses, limitResponses).map(
512
+ (r) => ({
513
+ ...r,
514
+ payload: _CNSDevToolsServer.sanitizeValue(
515
+ r.payload
516
+ ),
517
+ contexts: _CNSDevToolsServer.sanitizeValue(
518
+ r.contexts
519
+ )
520
+ })
521
+ );
522
+ const snapshot = {
523
+ type: "apps:snapshot",
524
+ appId: appId || null,
525
+ topology,
526
+ stimulations: sanitizedStim,
527
+ responses: sanitizedResp,
528
+ createdAt: Date.now()
529
+ };
530
+ try {
531
+ const sizeBytes = Buffer.byteLength(
532
+ JSON.stringify(snapshot)
533
+ );
534
+ snapshot.sizeBytes = sizeBytes;
535
+ const maxBytes = 5 * 1024 * 1024;
536
+ if (sizeBytes > maxBytes) {
537
+ snapshot.warning = `Snapshot size ${sizeBytes} bytes exceeds ${maxBytes} bytes; consider narrowing filters.`;
538
+ }
539
+ } catch {
540
+ }
541
+ return snapshot;
542
+ }
543
+ case "cns:export-responses": {
544
+ const {
545
+ cnsId,
546
+ limit,
547
+ fromTimestamp,
548
+ toTimestamp,
549
+ offset,
550
+ hasError,
551
+ errorContains,
552
+ neuronId,
553
+ collateralName
554
+ } = message;
555
+ const responsesAll = await this.repository.getResponsesByCns(
556
+ cnsId,
557
+ {
558
+ fromTimestamp,
559
+ toTimestamp,
560
+ limit,
561
+ offset,
562
+ hasError,
563
+ errorContains,
564
+ neuronId,
565
+ collateralName
566
+ }
567
+ );
568
+ return {
569
+ type: "cns:export-responses",
570
+ cnsId,
571
+ responses: responsesAll
572
+ };
573
+ }
574
+ // REST-like over WS
575
+ case "apps:list": {
576
+ const apps = await this.repository.listApps();
577
+ return { type: "apps:list", apps };
578
+ }
579
+ case "apps:get-cns": {
580
+ const { appId } = message;
581
+ const cnsIds = await this.repository.getCnsByApp(appId);
582
+ const cns = cnsIds.map((cnsId) => ({ cnsId, appId }));
583
+ return { type: "apps:cns", appId, cns };
584
+ }
585
+ case "apps:get-responses": {
586
+ const {
587
+ appId,
588
+ limit,
589
+ fromTimestamp,
590
+ toTimestamp,
591
+ offset,
592
+ hasError,
593
+ errorContains,
594
+ neuronId,
595
+ collateralName
596
+ } = message;
597
+ let allResponses = [];
598
+ if (appId) {
599
+ const cnsIds = await this.repository.getCnsByApp(appId);
600
+ for (const cnsId of cnsIds) {
601
+ const responses = await this.repository.getResponsesByCns(cnsId, {
602
+ fromTimestamp,
603
+ toTimestamp,
604
+ hasError,
605
+ errorContains,
606
+ neuronId,
607
+ collateralName
608
+ });
609
+ allResponses = allResponses.concat(responses);
610
+ }
611
+ } else {
612
+ const apps = await this.repository.listApps();
613
+ for (const app of apps) {
614
+ const cnsIds = await this.repository.getCnsByApp(
615
+ app.appId
616
+ );
617
+ for (const cnsId of cnsIds) {
618
+ const responses = await this.repository.getResponsesByCns(cnsId, {
619
+ fromTimestamp,
620
+ toTimestamp,
621
+ hasError,
622
+ errorContains,
623
+ neuronId,
624
+ collateralName
625
+ });
626
+ allResponses = allResponses.concat(responses);
627
+ }
628
+ }
629
+ }
630
+ allResponses.sort((a, b) => b.timestamp - a.timestamp);
631
+ const response = {
632
+ type: "apps:responses",
633
+ appId: appId || "all",
634
+ responses: allResponses,
635
+ total: allResponses.length,
636
+ offset: offset || 0,
637
+ limit: limit || allResponses.length
638
+ };
639
+ ws.send(JSON.stringify(response));
640
+ return response;
641
+ }
642
+ case "cns:get-responses": {
643
+ const msg = message;
644
+ const {
645
+ cnsId,
646
+ limit,
647
+ fromTimestamp,
648
+ toTimestamp,
649
+ offset,
650
+ hasError,
651
+ errorContains,
652
+ neuronId,
653
+ collateralName
654
+ } = msg;
655
+ if (!cnsId) {
656
+ return { type: "cns:responses", cnsId: "", responses: [] };
657
+ }
658
+ const responses = await this.repository.getResponsesByCns(
659
+ cnsId,
660
+ {
661
+ fromTimestamp,
662
+ toTimestamp,
663
+ limit,
664
+ offset,
665
+ hasError,
666
+ errorContains,
667
+ neuronId,
668
+ collateralName
669
+ }
670
+ );
671
+ return { type: "cns:responses", cnsId, responses };
672
+ }
673
+ default:
674
+ this.log("Unknown message type:", message.type);
675
+ return null;
676
+ }
677
+ }
678
+ async handleInit(ws, message) {
679
+ const baseAppId = message.appId || message.devToolsInstanceId;
680
+ const cnsId = message.cnsId || message.devToolsInstanceId;
681
+ if (!baseAppId || !cnsId) {
682
+ throw new Error("Invalid InitMessage: missing appId or cnsId");
683
+ }
684
+ this.lastInitByApp.set(cnsId, message);
685
+ const now = Date.now();
686
+ if (!this.appFirstSeenAt.has(baseAppId)) {
687
+ this.appFirstSeenAt.set(baseAppId, now);
688
+ }
689
+ this.appLastSeenAt.set(baseAppId, now);
690
+ const app = {
691
+ appId: baseAppId,
692
+ appName: message.appName,
693
+ version: message.version,
694
+ firstSeenAt: this.appFirstSeenAt.get(baseAppId),
695
+ lastSeenAt: now
696
+ };
697
+ await this.repository.upsertApp(app);
698
+ this.appSockets.set(cnsId, ws);
699
+ const existingNeurons = await this.repository.getNeuronsByCns(cnsId);
700
+ const existingCollaterals = await this.repository.getCollateralsByCns(
701
+ cnsId
702
+ );
703
+ const existingDendrites = await this.repository.getDendritesByCns(
704
+ cnsId
705
+ );
706
+ for (const neuron of existingNeurons) {
707
+ if (this.repository.removeNeuron) {
708
+ await this.repository.removeNeuron(neuron.id);
709
+ }
710
+ }
711
+ for (const collateral of existingCollaterals) {
712
+ if (this.repository.removeCollateral) {
713
+ await this.repository.removeCollateral(collateral.id);
714
+ }
715
+ }
716
+ for (const dendrite of existingDendrites) {
717
+ if (this.repository.removeDendrite) {
718
+ await this.repository.removeDendrite(dendrite.id);
719
+ }
720
+ }
721
+ for (const neuron of message.neurons || []) {
722
+ await this.repository.upsertNeuron(neuron);
723
+ }
724
+ for (const collateral of message.collaterals || []) {
725
+ await this.repository.upsertCollateral(collateral);
726
+ }
727
+ for (const dendrite of message.dendrites || []) {
728
+ await this.repository.upsertDendrite(dendrite);
729
+ }
730
+ await this.repository.addCnsToApp(app.appId, cnsId);
731
+ this.log(`App connected: ${message.appName} (${message.devToolsInstanceId})`);
732
+ this.log(`Topology: ${message.neurons.length} neurons, ${message.collaterals.length} collaterals, ${message.dendrites.length} dendrites`);
733
+ const apps = await this.repository.listApps();
734
+ this.broadcastToClients({
735
+ type: "app:added",
736
+ app
737
+ });
738
+ this.log("Broadcasting apps:active to", this.clientSockets.size, "clients:", {
739
+ appsCount: apps.length,
740
+ appIds: apps.map((a) => a.appId)
741
+ });
742
+ this.broadcastToClients({
743
+ type: "apps:active",
744
+ apps
745
+ });
746
+ return {
747
+ type: "apps:active",
748
+ apps
749
+ };
750
+ }
751
+ async handleBatch(ws, message) {
752
+ this.log("Server handling batch with", message.items?.length || 0, "items");
753
+ if (!Array.isArray(message.items)) {
754
+ if (this.options.consoleLogEnabled) {
755
+ console.warn("Invalid batch message - no items array");
756
+ }
757
+ return;
758
+ }
759
+ const responses = [];
760
+ for (const item of message.items || []) {
761
+ const actualMessage = item.payload || item;
762
+ if (item.type === "response") {
763
+ responses.push(actualMessage);
764
+ } else {
765
+ this.log("Batch item (non-response):", {
766
+ type: actualMessage?.type
767
+ });
768
+ await this.handleMessage(ws, actualMessage);
769
+ }
770
+ }
771
+ if (responses.length > 0) {
772
+ this.log("Creating response-batch with", responses.length, "responses");
773
+ const responseBatch = {
774
+ type: "response-batch",
775
+ devToolsInstanceId: responses[0].cnsId || responses[0].appId || "unknown",
776
+ responses
777
+ };
778
+ await this.handleResponseBatch(
779
+ ws,
780
+ responseBatch
781
+ );
782
+ }
783
+ }
784
+ async handleResponseBatch(ws, message) {
785
+ const replayResponses = (message.responses || []).filter((r) => {
786
+ const stimId = r.stimulationId || "";
787
+ return typeof stimId === "string" && stimId.includes("-replay-");
788
+ });
789
+ if (replayResponses.length > 0) {
790
+ this.log("[Server] Received REPLAY response-batch:", {
791
+ totalResponses: message.responses?.length || 0,
792
+ replayResponsesCount: replayResponses.length,
793
+ replayStimIds: replayResponses.slice(0, 3).map((r) => r.stimulationId),
794
+ replayAppIds: replayResponses.slice(0, 3).map((r) => r.appId)
795
+ });
796
+ }
797
+ if (message.responses && message.responses.length > 0) {
798
+ const appId = message.responses[0].appId;
799
+ if (appId) {
800
+ this.appLastSeenAt.set(appId, Date.now());
801
+ }
802
+ }
803
+ for (const response of message.responses) {
804
+ await this.repository.saveResponse(response);
805
+ }
806
+ this.broadcastToClients(message);
807
+ return message;
808
+ }
809
+ async handleStimulate(ws, message) {
810
+ const cnsIdFromCmd = message.cnsId;
811
+ const appIdFromCmd = message.appId;
812
+ try {
813
+ try {
814
+ const entry = {
815
+ stimulationCommandId: message.stimulationCommandId,
816
+ appId: message.appId,
817
+ cnsId: message.cnsId,
818
+ collateralName: message.collateralName,
819
+ payload: message.payload,
820
+ contexts: message.contexts,
821
+ options: message.options,
822
+ timestamp: Date.now()
823
+ };
824
+ const arr = this.replaysByApp.get(entry.appId) || [];
825
+ arr.push(entry);
826
+ if (arr.length > 1e3) arr.splice(0, arr.length - 1e3);
827
+ this.replaysByApp.set(entry.appId, arr);
828
+ } catch {
829
+ }
830
+ if (cnsIdFromCmd) {
831
+ const appWs = this.appSockets.get(cnsIdFromCmd);
832
+ if (appWs && appWs.readyState === 1) {
833
+ appWs.send(JSON.stringify(message));
834
+ this.log(`Stimulate forwarded to cns ${cnsIdFromCmd}`);
835
+ } else {
836
+ if (this.options.consoleLogEnabled) {
837
+ console.warn(`CNS socket not available for ${cnsIdFromCmd}`);
838
+ }
839
+ }
840
+ } else if (appIdFromCmd) {
841
+ const cnsIds = await this.repository.getCnsByApp(appIdFromCmd);
842
+ let count = 0;
843
+ for (const cnsId of cnsIds) {
844
+ const appWs = this.appSockets.get(cnsId);
845
+ if (appWs && appWs.readyState === 1) {
846
+ appWs.send(JSON.stringify(message));
847
+ count++;
848
+ }
849
+ }
850
+ this.log(`Stimulate forwarded to ${count} CNS of app ${appIdFromCmd}`);
851
+ } else {
852
+ let count = 0;
853
+ for (const [, appWs] of Array.from(this.appSockets.entries())) {
854
+ if (appWs.readyState === 1) {
855
+ appWs.send(JSON.stringify(message));
856
+ count++;
857
+ }
858
+ }
859
+ }
860
+ } catch (e) {
861
+ if (this.options.consoleLogEnabled) {
862
+ console.error("Failed to forward stimulate:", e);
863
+ }
864
+ }
865
+ return null;
866
+ }
867
+ async handleDisconnect(ws) {
868
+ for (const [cnsId, socket] of Array.from(this.appSockets.entries())) {
869
+ if (socket === ws) {
870
+ this.appSockets.delete(cnsId);
871
+ this.log(`CNS disconnected: ${cnsId}`);
872
+ const sepIdx = cnsId.indexOf(":");
873
+ const baseAppId = sepIdx > 0 ? cnsId.slice(0, sepIdx) : cnsId;
874
+ this.lastInitByApp.delete(cnsId);
875
+ await this.repository.removeCnsFromApp(baseAppId, cnsId);
876
+ break;
877
+ }
878
+ }
879
+ this.clientSockets.delete(ws);
880
+ }
881
+ addClient(ws) {
882
+ this.clientSockets.add(ws);
883
+ if (!this.metricsTimer) {
884
+ this.startMetricsLoop();
885
+ }
886
+ const eventedWs = ws;
887
+ const remove = () => this.removeClient(ws);
888
+ if (eventedWs.on) {
889
+ eventedWs.on("close", remove);
890
+ eventedWs.on("error", remove);
891
+ } else if (eventedWs.addEventListener) {
892
+ eventedWs.addEventListener("close", remove);
893
+ eventedWs.addEventListener("error", remove);
894
+ }
895
+ }
896
+ removeClient(ws) {
897
+ this.clientSockets.delete(ws);
898
+ }
899
+ async handleStimulation(ws, message) {
900
+ await this.repository.saveStimulation(message);
901
+ this.stimulationBuffer.push(message);
902
+ this.broadcastToClients({
903
+ type: "stimulation-batch",
904
+ stimulations: [message]
905
+ });
906
+ }
907
+ broadcastToClients(message) {
908
+ const messageStr = JSON.stringify(message);
909
+ let sentCount = 0;
910
+ this.clientSockets.forEach((clientWs) => {
911
+ if (clientWs.readyState === 1) {
912
+ try {
913
+ clientWs.send(messageStr);
914
+ sentCount++;
915
+ } catch (error) {
916
+ if (this.options.consoleLogEnabled) {
917
+ console.error("Failed to send message to client:", error);
918
+ }
919
+ }
920
+ }
921
+ });
922
+ if (sentCount !== this.clientSockets.size && this.options.consoleLogEnabled) {
923
+ console.warn(
924
+ `Sent message to ${sentCount}/${this.clientSockets.size} clients`
925
+ );
926
+ }
927
+ }
928
+ startMetricsLoop() {
929
+ if (this.metricsTimer) {
930
+ return;
931
+ }
932
+ this.lastCpuUsage = process.cpuUsage();
933
+ this.lastCpuTime = Date.now();
934
+ this.metricsTimer = setInterval(() => {
935
+ if (this.stopped) {
936
+ clearInterval(this.metricsTimer);
937
+ this.metricsTimer = void 0;
938
+ return;
939
+ }
940
+ try {
941
+ const mem = process.memoryUsage();
942
+ const now = Date.now();
943
+ const cpu = process.cpuUsage(this.lastCpuUsage);
944
+ const elapsedMs = Math.max(1, now - this.lastCpuTime);
945
+ const userMs = cpu.user / 1e3;
946
+ const systemMs = cpu.system / 1e3;
947
+ const cpuPercent = Math.min(
948
+ 100,
949
+ Math.round((userMs + systemMs) / elapsedMs * 100)
950
+ );
951
+ this.lastCpuUsage = process.cpuUsage();
952
+ this.lastCpuTime = now;
953
+ const payload = {
954
+ type: "server:metrics",
955
+ timestamp: now,
956
+ rssMB: Math.round(mem.rss / 1024 / 1024 * 100) / 100,
957
+ heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 100) / 100,
958
+ heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 100) / 100,
959
+ externalMB: Math.round(mem.external / 1024 / 1024 * 100) / 100,
960
+ cpuPercent
961
+ };
962
+ this.broadcastToClients(payload);
963
+ } catch {
964
+ }
965
+ }, 1e3);
966
+ }
967
+ async getActiveApps() {
968
+ return await this.repository.listApps();
969
+ }
970
+ stop() {
971
+ this.log("Stopping server.");
972
+ this.stopped = true;
973
+ if (this.metricsTimer) {
974
+ clearInterval(this.metricsTimer);
975
+ this.metricsTimer = void 0;
976
+ }
977
+ for (const ws of Array.from(this.appSockets.values())) {
978
+ ws.close();
979
+ }
980
+ this.appSockets.clear();
981
+ for (const ws of Array.from(this.clientSockets)) {
982
+ ws.close();
983
+ }
984
+ this.clientSockets.clear();
985
+ this.lastInitByApp.clear();
986
+ this.stimulationBuffer = [];
987
+ this.appFirstSeenAt.clear();
988
+ this.appLastSeenAt.clear();
989
+ this.replaysByApp.clear();
990
+ }
991
+ };
992
+
993
+ export { CNSDevToolsServer, startDevTools };
994
+ //# sourceMappingURL=index.js.map
995
+ //# sourceMappingURL=index.js.map