@expressots/studio-agent 4.0.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +143 -0
  2. package/dist/agent.d.ts +127 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +1031 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/discovery/index.d.ts +2 -0
  7. package/dist/discovery/index.d.ts.map +1 -0
  8. package/dist/discovery/index.js +2 -0
  9. package/dist/discovery/index.js.map +1 -0
  10. package/dist/discovery/route-scanner.d.ts +35 -0
  11. package/dist/discovery/route-scanner.d.ts.map +1 -0
  12. package/dist/discovery/route-scanner.js +385 -0
  13. package/dist/discovery/route-scanner.js.map +1 -0
  14. package/dist/index.d.ts +15 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +15 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/instrumentation/index.d.ts +2 -0
  19. package/dist/instrumentation/index.d.ts.map +1 -0
  20. package/dist/instrumentation/index.js +2 -0
  21. package/dist/instrumentation/index.js.map +1 -0
  22. package/dist/instrumentation/tracer.d.ts +40 -0
  23. package/dist/instrumentation/tracer.d.ts.map +1 -0
  24. package/dist/instrumentation/tracer.js +190 -0
  25. package/dist/instrumentation/tracer.js.map +1 -0
  26. package/dist/introspection/container-introspector.d.ts +81 -0
  27. package/dist/introspection/container-introspector.d.ts.map +1 -0
  28. package/dist/introspection/container-introspector.js +251 -0
  29. package/dist/introspection/container-introspector.js.map +1 -0
  30. package/dist/logging/log-capture.d.ts +58 -0
  31. package/dist/logging/log-capture.d.ts.map +1 -0
  32. package/dist/logging/log-capture.js +184 -0
  33. package/dist/logging/log-capture.js.map +1 -0
  34. package/dist/recording/index.d.ts +2 -0
  35. package/dist/recording/index.d.ts.map +1 -0
  36. package/dist/recording/index.js +2 -0
  37. package/dist/recording/index.js.map +1 -0
  38. package/dist/recording/request-recorder.d.ts +43 -0
  39. package/dist/recording/request-recorder.d.ts.map +1 -0
  40. package/dist/recording/request-recorder.js +373 -0
  41. package/dist/recording/request-recorder.js.map +1 -0
  42. package/dist/security/fix-resolver.d.ts +40 -0
  43. package/dist/security/fix-resolver.d.ts.map +1 -0
  44. package/dist/security/fix-resolver.js +283 -0
  45. package/dist/security/fix-resolver.js.map +1 -0
  46. package/dist/security/fix-runner.d.ts +60 -0
  47. package/dist/security/fix-runner.d.ts.map +1 -0
  48. package/dist/security/fix-runner.js +188 -0
  49. package/dist/security/fix-runner.js.map +1 -0
  50. package/dist/security/index.d.ts +140 -0
  51. package/dist/security/index.d.ts.map +1 -0
  52. package/dist/security/index.js +460 -0
  53. package/dist/security/index.js.map +1 -0
  54. package/dist/security/lockfile-graph.d.ts +69 -0
  55. package/dist/security/lockfile-graph.d.ts.map +1 -0
  56. package/dist/security/lockfile-graph.js +245 -0
  57. package/dist/security/lockfile-graph.js.map +1 -0
  58. package/dist/security/npm-audit.d.ts +67 -0
  59. package/dist/security/npm-audit.d.ts.map +1 -0
  60. package/dist/security/npm-audit.js +320 -0
  61. package/dist/security/npm-audit.js.map +1 -0
  62. package/dist/security/osv-cache.d.ts +51 -0
  63. package/dist/security/osv-cache.d.ts.map +1 -0
  64. package/dist/security/osv-cache.js +99 -0
  65. package/dist/security/osv-cache.js.map +1 -0
  66. package/dist/security/osv-client.d.ts +47 -0
  67. package/dist/security/osv-client.d.ts.map +1 -0
  68. package/dist/security/osv-client.js +247 -0
  69. package/dist/security/osv-client.js.map +1 -0
  70. package/dist/security/posture-analyzer.d.ts +44 -0
  71. package/dist/security/posture-analyzer.d.ts.map +1 -0
  72. package/dist/security/posture-analyzer.js +397 -0
  73. package/dist/security/posture-analyzer.js.map +1 -0
  74. package/dist/security/reachability.d.ts +59 -0
  75. package/dist/security/reachability.d.ts.map +1 -0
  76. package/dist/security/reachability.js +302 -0
  77. package/dist/security/reachability.js.map +1 -0
  78. package/dist/security/score.d.ts +36 -0
  79. package/dist/security/score.d.ts.map +1 -0
  80. package/dist/security/score.js +94 -0
  81. package/dist/security/score.js.map +1 -0
  82. package/dist/types/index.d.ts +587 -0
  83. package/dist/types/index.d.ts.map +1 -0
  84. package/dist/types/index.js +14 -0
  85. package/dist/types/index.js.map +1 -0
  86. package/package.json +75 -0
package/dist/agent.js ADDED
@@ -0,0 +1,1031 @@
1
+ /**
2
+ * StudioAgent - Main orchestrator for ExpressoTS Studio instrumentation
3
+ *
4
+ * Provides:
5
+ * - OpenTelemetry instrumentation
6
+ * - Route discovery
7
+ * - Request/response recording
8
+ * - WebSocket communication with Studio UI
9
+ */
10
+ import { Server as SocketIOServer } from 'socket.io';
11
+ import { createServer } from 'http';
12
+ import { StudioTracer } from './instrumentation/tracer.js';
13
+ import { RouteScanner } from './discovery/route-scanner.js';
14
+ import { RequestRecorder } from './recording/request-recorder.js';
15
+ import { ContainerIntrospector, } from './introspection/container-introspector.js';
16
+ import { LogCapture } from './logging/log-capture.js';
17
+ import { SecurityEngine } from './security/index.js';
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ /**
22
+ * Best-effort version lookup for a package installed in the host's
23
+ * `node_modules`. We read `package.json` straight from disk instead of
24
+ * going through `require()` — most modern packages don't expose
25
+ * `./package.json` in their `exports` map, which made the `require`
26
+ * approach silently return `undefined`.
27
+ */
28
+ function safePackageVersion(pkgName) {
29
+ const candidates = [
30
+ // Standard layout: <cwd>/node_modules/<pkg>/package.json
31
+ path.resolve(process.cwd(), 'node_modules', ...pkgName.split('/'), 'package.json'),
32
+ // Walk up from this module's location for nested / hoisted layouts.
33
+ ...walkParentNodeModules(pkgName),
34
+ ];
35
+ for (const file of candidates) {
36
+ try {
37
+ const raw = fs.readFileSync(file, 'utf-8');
38
+ const parsed = JSON.parse(raw);
39
+ if (parsed?.version)
40
+ return parsed.version;
41
+ }
42
+ catch {
43
+ // try the next candidate
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ /** Yield candidate `node_modules/<pkg>/package.json` paths walking up from this file. */
49
+ function walkParentNodeModules(pkgName) {
50
+ const out = [];
51
+ try {
52
+ let dir = path.dirname(fileURLToPath(import.meta.url));
53
+ for (let i = 0; i < 8; i++) {
54
+ out.push(path.resolve(dir, 'node_modules', ...pkgName.split('/'), 'package.json'));
55
+ const parent = path.dirname(dir);
56
+ if (parent === dir)
57
+ break;
58
+ dir = parent;
59
+ }
60
+ }
61
+ catch {
62
+ // import.meta.url may be unavailable in some bundles — fine.
63
+ }
64
+ return out;
65
+ }
66
+ /**
67
+ * Resolve our own package version from the agent's bundled `package.json`.
68
+ * Reads the manifest sitting two levels up from the compiled `agent.js`
69
+ * (i.e. `dist/agent.js` → `package.json`). Falls back to the host lookup,
70
+ * then to "unknown".
71
+ */
72
+ function resolveOwnVersion() {
73
+ try {
74
+ const here = path.dirname(fileURLToPath(import.meta.url));
75
+ const candidate = path.resolve(here, '..', 'package.json');
76
+ const raw = fs.readFileSync(candidate, 'utf-8');
77
+ const parsed = JSON.parse(raw);
78
+ if (parsed?.version)
79
+ return parsed.version;
80
+ }
81
+ catch {
82
+ // fall through
83
+ }
84
+ return safePackageVersion('@expressots/studio-agent') ?? 'unknown';
85
+ }
86
+ export class StudioAgent {
87
+ config;
88
+ tracer;
89
+ scanner;
90
+ recorder;
91
+ introspector = null;
92
+ containerSnapshot = null;
93
+ logCapture;
94
+ securityEngine = null;
95
+ io = null;
96
+ httpServer = null;
97
+ routes = [];
98
+ appStructure = null;
99
+ metrics;
100
+ endpointStats = new Map();
101
+ responseTimes = [];
102
+ startTime = Date.now();
103
+ isRunning = false;
104
+ constructor(config = {}) {
105
+ this.config = {
106
+ port: config.port ?? 3334,
107
+ dbPath: config.dbPath ?? '.studio/studio.db',
108
+ enableRecording: config.enableRecording ?? true,
109
+ maxRecordedExchanges: config.maxRecordedExchanges ?? 1000,
110
+ enableProfiling: config.enableProfiling ?? true,
111
+ traceSampleRate: config.traceSampleRate ?? 1.0,
112
+ serviceName: config.serviceName ?? 'expressots-app',
113
+ expressApp: config.expressApp,
114
+ appContainer: config.appContainer,
115
+ appPort: config.appPort,
116
+ globalPrefix: config.globalPrefix,
117
+ startupMs: config.startupMs,
118
+ interceptorCount: config.interceptorCount,
119
+ };
120
+ if (this.config.appContainer) {
121
+ this.introspector = new ContainerIntrospector(this.config.appContainer);
122
+ }
123
+ this.logCapture = new LogCapture(1000);
124
+ this.tracer = new StudioTracer(this.config.serviceName);
125
+ this.scanner = new RouteScanner();
126
+ this.recorder = new RequestRecorder(this.config.dbPath, this.config.maxRecordedExchanges);
127
+ this.metrics = {
128
+ uptime: 0,
129
+ requestCount: 0,
130
+ errorCount: 0,
131
+ avgResponseTime: 0,
132
+ p50ResponseTime: 0,
133
+ p95ResponseTime: 0,
134
+ p99ResponseTime: 0,
135
+ memoryUsage: process.memoryUsage(),
136
+ activeConnections: 0,
137
+ };
138
+ }
139
+ /** Start the Studio Agent */
140
+ async start() {
141
+ if (this.isRunning) {
142
+ console.warn('StudioAgent is already running');
143
+ return;
144
+ }
145
+ // Initialize recorder
146
+ if (this.config.enableRecording) {
147
+ await this.recorder.initialize();
148
+ }
149
+ // Start tracer
150
+ await this.tracer.start((trace) => this.handleTrace(trace));
151
+ // Scan for routes
152
+ await this.scanRoutes();
153
+ // Capture DI container snapshot (bindings + dependency graph). Best-effort:
154
+ // if the container is missing or not the expected Inversify shape we just
155
+ // get an empty snapshot back and the Container view in the UI stays empty.
156
+ if (this.introspector && this.introspector.isAvailable()) {
157
+ try {
158
+ this.containerSnapshot = this.introspector.capture();
159
+ this.introspector.installResolutionTracker();
160
+ }
161
+ catch {
162
+ this.containerSnapshot = null;
163
+ }
164
+ }
165
+ // Start WebSocket server
166
+ await this.startWebSocketServer();
167
+ // Start metrics collection
168
+ this.startMetricsCollection();
169
+ // Capture console.* output and stream it to the UI. We install this
170
+ // last so the agent's own startup logs ("Studio Agent listening on …")
171
+ // still go through unmodified.
172
+ this.logCapture.install();
173
+ this.logCapture.onLog((entry) => {
174
+ this.broadcast('log', entry);
175
+ // Each new log line is a potential signal for the posture
176
+ // analyzer (e.g. it may reveal a leaked secret). Cheap to
177
+ // debounce; the engine collapses bursts.
178
+ this.securityEngine?.scheduleRefresh();
179
+ });
180
+ this.startSecurityEngine();
181
+ this.isRunning = true;
182
+ }
183
+ /**
184
+ * Stand up the SecurityEngine and kick off the first scan. The
185
+ * engine reuses the existing Socket.IO server — every transition in
186
+ * its report goes out as a `WSMessage<'security'>` envelope, gated
187
+ * on at least one connected client.
188
+ */
189
+ startSecurityEngine() {
190
+ this.securityEngine = new SecurityEngine({
191
+ cwd: process.cwd(),
192
+ dbPath: this.config.dbPath,
193
+ getRoutes: () => this.routes,
194
+ getStructure: () => this.appStructure,
195
+ getExchanges: () => this.config.enableRecording
196
+ ? this.recorder.getRecentExchanges(this.config.maxRecordedExchanges, 0)
197
+ : [],
198
+ getLogs: () => this.logCapture.getBuffer(),
199
+ });
200
+ this.securityEngine.onReport((report) => {
201
+ // Gate on clientsCount > 0 — no point queueing 100 KB frames
202
+ // against a backgrounded tab. The next reconnecting client gets
203
+ // the latest report from the initial-data replay anyway.
204
+ if (!this.io || this.io.engine.clientsCount === 0)
205
+ return;
206
+ this.broadcast('security', report);
207
+ });
208
+ // Kick off the first full scan in the background — never blocks
209
+ // start(). Failures are absorbed by the engine and surface in
210
+ // `scanState.audit === 'error'`.
211
+ void this.securityEngine.runFullScan();
212
+ }
213
+ /** Get the captured container snapshot (or null if unavailable). */
214
+ getContainerSnapshot() {
215
+ return this.containerSnapshot;
216
+ }
217
+ /** Stop the Studio Agent */
218
+ async stop() {
219
+ if (!this.isRunning)
220
+ return;
221
+ // Mark stopped up-front so concurrent stop() calls bail and the
222
+ // host's shutdown hook isn't held waiting on a duplicate teardown.
223
+ this.isRunning = false;
224
+ await this.shutdownWebSocketServer();
225
+ try {
226
+ await this.tracer.stop();
227
+ }
228
+ catch {
229
+ // best-effort
230
+ }
231
+ try {
232
+ this.recorder.close();
233
+ }
234
+ catch {
235
+ // best-effort
236
+ }
237
+ // Restore original console.* so the host process logs untouched.
238
+ this.logCapture.uninstall();
239
+ if (this.securityEngine) {
240
+ this.securityEngine.stop();
241
+ this.securityEngine = null;
242
+ }
243
+ }
244
+ /**
245
+ * Tear down the WebSocket / HTTP server with a hard timeout so the
246
+ * host's graceful shutdown never hangs on a slow socket.io drain.
247
+ *
248
+ * If the close doesn't complete in time, we move on — the OS reclaims
249
+ * the port the moment the host process exits, so the next hot-reload
250
+ * start succeeds anyway. (`tsx --watch` / `nodemon` will SIGKILL us
251
+ * otherwise, which surfaces to the user as "Failed running ./src/main.ts".)
252
+ */
253
+ async shutdownWebSocketServer() {
254
+ const io = this.io;
255
+ const httpServer = this.httpServer;
256
+ this.io = null;
257
+ this.httpServer = null;
258
+ if (!io && !httpServer)
259
+ return;
260
+ // Force-close any lingering keep-alive / WebSocket sockets so the
261
+ // underlying server can release the port immediately rather than
262
+ // waiting for the OS-level read timeout. (Node 18.2+; older Node
263
+ // silently no-ops via the optional-call.)
264
+ if (httpServer) {
265
+ try {
266
+ httpServer
267
+ .closeAllConnections?.();
268
+ }
269
+ catch {
270
+ // best-effort
271
+ }
272
+ }
273
+ const drained = new Promise((resolve) => {
274
+ const finish = () => resolve();
275
+ if (io) {
276
+ // socket.io closes the underlying http server itself.
277
+ io.close(finish);
278
+ }
279
+ else if (httpServer) {
280
+ httpServer.close(() => finish());
281
+ }
282
+ else {
283
+ finish();
284
+ }
285
+ });
286
+ // Hard cap: 500ms is plenty for a clean drain after
287
+ // closeAllConnections; anything slower is a stuck client and we
288
+ // don't want that to hold up the host's shutdown.
289
+ const timeout = new Promise((resolve) => setTimeout(resolve, 500));
290
+ await Promise.race([drained, timeout]);
291
+ }
292
+ /** Scan application for routes */
293
+ async scanRoutes() {
294
+ try {
295
+ this.appStructure = await this.scanner.scan();
296
+ this.routes = this.scanner.getRoutes();
297
+ // Route counts available via getRoutes()
298
+ // If Express app is provided, also scan runtime routes
299
+ if (this.config.expressApp) {
300
+ const runtimeRoutes = RouteScanner.scanExpressApp(this.config.expressApp);
301
+ // Merge runtime routes with discovered routes
302
+ // Merge with discovered routes
303
+ for (const runtimeRoute of runtimeRoutes) {
304
+ const exists = this.routes.some((r) => r.path === runtimeRoute.path && r.method === runtimeRoute.method);
305
+ if (!exists) {
306
+ this.routes.push(runtimeRoute);
307
+ }
308
+ }
309
+ }
310
+ // Broadcast to connected clients
311
+ this.broadcast('routes', this.routes);
312
+ }
313
+ catch (error) {
314
+ console.error('Failed to scan routes:', error);
315
+ }
316
+ }
317
+ /** Get discovered routes */
318
+ getRoutes() {
319
+ return this.routes;
320
+ }
321
+ /** Get application structure */
322
+ getAppStructure() {
323
+ return this.appStructure;
324
+ }
325
+ /** Get current metrics */
326
+ getMetrics() {
327
+ return {
328
+ ...this.metrics,
329
+ uptime: Date.now() - this.startTime,
330
+ memoryUsage: process.memoryUsage(),
331
+ };
332
+ }
333
+ /** Get endpoint statistics (without internal durations array) */
334
+ getEndpointStats() {
335
+ return Array.from(this.endpointStats.values()).map(({ durations, ...stats }) => stats);
336
+ }
337
+ /**
338
+ * Apply runtime details that the host application only knows after
339
+ * boot (e.g. the actual port returned by `app.listen()`, total startup
340
+ * duration, count of registered interceptors).
341
+ *
342
+ * Called by the adapter integration once the HTTP server is listening.
343
+ * Re-broadcasts the updated runtime info so connected Studio clients
344
+ * see fresh values without waiting for the next metrics tick.
345
+ */
346
+ updateRuntimeInfo(patch) {
347
+ if (patch.appPort !== undefined)
348
+ this.config.appPort = patch.appPort;
349
+ if (patch.globalPrefix !== undefined)
350
+ this.config.globalPrefix = patch.globalPrefix;
351
+ if (patch.startupMs !== undefined)
352
+ this.config.startupMs = patch.startupMs;
353
+ if (patch.interceptorCount !== undefined) {
354
+ this.config.interceptorCount = patch.interceptorCount;
355
+ }
356
+ if (patch.providerCount !== undefined) {
357
+ this.config.providerCount = patch.providerCount;
358
+ }
359
+ if (patch.middlewareCount !== undefined) {
360
+ this.config.middlewareCount = patch.middlewareCount;
361
+ }
362
+ if (patch.runtimeItems !== undefined) {
363
+ // Merge so partial updates (e.g. providers only) don't wipe the
364
+ // other categories.
365
+ this.config.runtimeItems = {
366
+ ...this.config.runtimeItems,
367
+ ...patch.runtimeItems,
368
+ };
369
+ }
370
+ if (this.io) {
371
+ this.broadcast('runtime', this.getRuntimeInfo());
372
+ }
373
+ }
374
+ /**
375
+ * Build a snapshot of runtime information for the Status dashboard.
376
+ *
377
+ * Pulls together:
378
+ * - host process info (`pid`, `nodeVersion`, `platform`, etc.)
379
+ * - explicit values passed via `AgentConfig` (port, prefix, startupMs)
380
+ * - counts derived from the latest discovery scan
381
+ * - best-effort framework versions from the host's `node_modules`
382
+ *
383
+ * Designed to be cheap to call on every WebSocket connection.
384
+ */
385
+ getRuntimeInfo() {
386
+ // Resolve the host application's HTTP port. Order of preference:
387
+ // 1) Explicit value passed via AgentConfig (most accurate; the
388
+ // adapter-express integration forwards the listening port here).
389
+ // 2) `PORT` environment variable, which a lot of hosting platforms
390
+ // (and `expressots dev`) set.
391
+ // 3) ExpressoTS default port (3000).
392
+ const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
393
+ const appPort = this.config.appPort ?? (Number.isFinite(envPort) ? envPort : 3000);
394
+ const appUrl = appPort ? `http://localhost:${appPort}` : undefined;
395
+ // Prefer counts reported by the adapter (which uses the same
396
+ // `MetricsCollector` as the CLI banner — so Studio always agrees with
397
+ // the terminal) and fall back to whatever the static scan turned up.
398
+ //
399
+ // This matters for things our static scanner can't see:
400
+ // - framework-registered providers (lifecycle, logger, etc.)
401
+ // - interceptors registered via decorators on classes the agent
402
+ // hasn't reached during file traversal
403
+ const interceptorCount = this.config.interceptorCount ??
404
+ this.appStructure?.middleware.length;
405
+ const providerCount = this.config.providerCount ??
406
+ this.appStructure?.providers.length ??
407
+ 0;
408
+ const middlewareCount = this.config.middlewareCount ??
409
+ this.appStructure?.middleware.length ??
410
+ 0;
411
+ return {
412
+ serviceName: this.config.serviceName,
413
+ pid: process.pid,
414
+ nodeVersion: process.version,
415
+ platform: process.platform,
416
+ arch: process.arch,
417
+ env: process.env.NODE_ENV || 'development',
418
+ agentPort: this.config.port,
419
+ appPort,
420
+ appUrl,
421
+ globalPrefix: this.config.globalPrefix ?? '/',
422
+ startedAt: this.startTime,
423
+ uptimeMs: Date.now() - this.startTime,
424
+ startupMs: this.config.startupMs,
425
+ versions: {
426
+ agent: resolveOwnVersion(),
427
+ core: safePackageVersion('@expressots/core'),
428
+ adapterExpress: safePackageVersion('@expressots/adapter-express'),
429
+ },
430
+ counts: {
431
+ controllers: this.appStructure?.controllers.length ?? 0,
432
+ services: this.appStructure?.services.length ?? 0,
433
+ providers: providerCount,
434
+ routes: this.routes.length,
435
+ middleware: middlewareCount,
436
+ interceptors: interceptorCount,
437
+ },
438
+ runtimeItems: this.config.runtimeItems,
439
+ recordingEnabled: this.config.enableRecording,
440
+ };
441
+ }
442
+ /** Start WebSocket server */
443
+ async startWebSocketServer() {
444
+ this.httpServer = createServer((req, res) => {
445
+ // Health check endpoint
446
+ if (req.url === '/health') {
447
+ res.writeHead(200, { 'Content-Type': 'application/json' });
448
+ res.end(JSON.stringify({ status: 'ok', agent: 'studio-agent' }));
449
+ return;
450
+ }
451
+ res.writeHead(404);
452
+ res.end();
453
+ });
454
+ // Critical: handle server errors so EADDRINUSE doesn't crash the host
455
+ // process. Without this, an unhandled `'error'` event during `.listen()`
456
+ // emits an unhandled error and Node terminates the host app — which is
457
+ // exactly what users hit on hot-reload when the previous tsx-watched
458
+ // process hasn't yet released the port.
459
+ this.httpServer.on('error', (err) => {
460
+ const code = err.code;
461
+ if (code === 'EADDRINUSE') {
462
+ // Surfaced so `start()` can retry / degrade gracefully.
463
+ return;
464
+ }
465
+ console.warn(`[studio-agent] WebSocket server error (${code ?? 'unknown'}):`, err.message);
466
+ });
467
+ this.io = new SocketIOServer(this.httpServer, {
468
+ cors: {
469
+ origin: '*',
470
+ methods: ['GET', 'POST'],
471
+ },
472
+ });
473
+ this.io.on('connection', (socket) => {
474
+ // Client connected
475
+ this.metrics.activeConnections++;
476
+ // Send initial data
477
+ socket.emit('message', this.createMessage('routes', this.routes));
478
+ socket.emit('message', this.createMessage('metrics', this.getMetrics()));
479
+ socket.emit('message', this.createMessage('runtime', this.getRuntimeInfo()));
480
+ if (this.appStructure) {
481
+ socket.emit('message', {
482
+ type: 'structure',
483
+ timestamp: Date.now(),
484
+ data: this.appStructure,
485
+ });
486
+ }
487
+ if (this.containerSnapshot) {
488
+ socket.emit('message', {
489
+ type: 'container',
490
+ timestamp: Date.now(),
491
+ data: this.containerSnapshot,
492
+ });
493
+ }
494
+ socket.emit('message', {
495
+ type: 'recording_state',
496
+ timestamp: Date.now(),
497
+ data: { enabled: this.config.enableRecording },
498
+ });
499
+ // Replay buffered logs so reconnecting clients catch up to the stream.
500
+ const buffered = this.logCapture.getBuffer();
501
+ if (buffered.length > 0) {
502
+ socket.emit('message', {
503
+ type: 'logs',
504
+ timestamp: Date.now(),
505
+ data: buffered,
506
+ });
507
+ }
508
+ // Replay the latest security report so the Security view doesn't
509
+ // sit empty until the next analyzer tick. The engine always has
510
+ // a report (it initialises to an empty A-grade one).
511
+ if (this.securityEngine) {
512
+ socket.emit('message', {
513
+ type: 'security',
514
+ timestamp: Date.now(),
515
+ data: this.securityEngine.getReport(),
516
+ });
517
+ }
518
+ // Handle client requests
519
+ socket.on('get_routes', () => {
520
+ socket.emit('message', this.createMessage('routes', this.routes));
521
+ });
522
+ socket.on('get_metrics', () => {
523
+ socket.emit('message', this.createMessage('metrics', this.getMetrics()));
524
+ });
525
+ socket.on('get_structure', () => {
526
+ socket.emit('message', {
527
+ type: 'structure',
528
+ timestamp: Date.now(),
529
+ data: this.appStructure,
530
+ });
531
+ });
532
+ socket.on('get_runtime', () => {
533
+ socket.emit('message', this.createMessage('runtime', this.getRuntimeInfo()));
534
+ });
535
+ socket.on('get_exchanges', (params) => {
536
+ const exchanges = this.recorder.getRecentExchanges(params.limit || 100, params.offset || 0);
537
+ socket.emit('message', {
538
+ type: 'exchanges',
539
+ timestamp: Date.now(),
540
+ data: exchanges,
541
+ });
542
+ });
543
+ socket.on('get_exchange', (params) => {
544
+ const exchange = this.recorder.getExchange(params.id);
545
+ socket.emit('message', {
546
+ type: 'exchange',
547
+ timestamp: Date.now(),
548
+ data: exchange,
549
+ });
550
+ });
551
+ socket.on('search_exchanges', (params) => {
552
+ const exchanges = this.recorder.searchExchanges(params.query, params.method, params.limit || 100);
553
+ socket.emit('message', {
554
+ type: 'exchanges',
555
+ timestamp: Date.now(),
556
+ data: exchanges,
557
+ });
558
+ });
559
+ socket.on('replay', async (params) => {
560
+ await this.replayRequest(params.exchangeId, socket);
561
+ });
562
+ socket.on('rescan', async () => {
563
+ await this.scanRoutes();
564
+ });
565
+ socket.on('clear_recordings', () => {
566
+ this.recorder.clearAll();
567
+ // Reset in-memory aggregates so the Metrics / Endpoint tabs reflect
568
+ // the cleared timeline instead of showing pre-clear totals.
569
+ this.endpointStats.clear();
570
+ this.responseTimes = [];
571
+ this.metrics.requestCount = 0;
572
+ this.metrics.errorCount = 0;
573
+ this.broadcast('cleared', { success: true });
574
+ this.broadcast('metrics', this.getMetrics());
575
+ this.broadcast('endpoint_stats', this.getEndpointStats());
576
+ });
577
+ socket.on('set_recording', (params) => {
578
+ this.config.enableRecording = Boolean(params?.enabled);
579
+ this.broadcast('recording_state', {
580
+ enabled: this.config.enableRecording,
581
+ });
582
+ this.broadcast('runtime', this.getRuntimeInfo());
583
+ });
584
+ socket.on('get_stats', () => {
585
+ const stats = this.recorder.getStats();
586
+ socket.emit('message', {
587
+ type: 'stats',
588
+ timestamp: Date.now(),
589
+ data: stats,
590
+ });
591
+ });
592
+ socket.on('get_endpoint_stats', () => {
593
+ socket.emit('message', {
594
+ type: 'endpoint_stats',
595
+ timestamp: Date.now(),
596
+ data: this.getEndpointStats(),
597
+ });
598
+ });
599
+ // Lightweight round-trip used by the UI to compute agent latency.
600
+ // We echo the client's timestamp so the round-trip can be measured
601
+ // without depending on agent vs. client clock drift.
602
+ socket.on('ping_studio', (payload) => {
603
+ socket.emit('message', {
604
+ type: 'pong_studio',
605
+ timestamp: Date.now(),
606
+ data: { sentAt: payload?.sentAt ?? 0, agentNow: Date.now() },
607
+ });
608
+ });
609
+ socket.on('get_logs', () => {
610
+ socket.emit('message', {
611
+ type: 'logs',
612
+ timestamp: Date.now(),
613
+ data: this.logCapture.getBuffer(),
614
+ });
615
+ });
616
+ socket.on('clear_logs', () => {
617
+ this.logCapture.clear();
618
+ this.broadcast('logs_cleared', { success: true });
619
+ });
620
+ socket.on('get_container', () => {
621
+ socket.emit('message', {
622
+ type: 'container',
623
+ timestamp: Date.now(),
624
+ data: this.containerSnapshot,
625
+ });
626
+ });
627
+ // Push the latest cached report on demand. Useful when the UI
628
+ // explicitly navigates to the Security view and wants a fresh
629
+ // copy even if nothing has changed.
630
+ socket.on('get_security_report', () => {
631
+ if (!this.securityEngine)
632
+ return;
633
+ socket.emit('message', {
634
+ type: 'security',
635
+ timestamp: Date.now(),
636
+ data: this.securityEngine.getReport(),
637
+ });
638
+ });
639
+ // User-initiated rescan: re-run `npm audit` + OSV. The engine
640
+ // coalesces concurrent calls, so spamming this button is safe.
641
+ socket.on('request_security_scan', () => {
642
+ if (!this.securityEngine)
643
+ return;
644
+ void this.securityEngine.runFullScan();
645
+ });
646
+ // User clicked "Apply fix" on a finding or fix group. The engine
647
+ // spawns the npm command and streams each output line through
648
+ // `fix_progress` so the UI can render a live transcript. When the
649
+ // command exits the agent emits a single `fix_result`; the engine
650
+ // also kicks off a full rescan, so the next `security` frame
651
+ // reflects whatever actually changed.
652
+ socket.on('apply_security_fix', async (params) => {
653
+ if (!this.securityEngine)
654
+ return;
655
+ if (!params ||
656
+ (params.targetKind !== 'finding' && params.targetKind !== 'fix-group') ||
657
+ typeof params.targetId !== 'string' ||
658
+ params.targetId.length === 0) {
659
+ return;
660
+ }
661
+ const result = await this.securityEngine.applyFix({
662
+ targetKind: params.targetKind,
663
+ targetId: params.targetId,
664
+ allowMajor: Boolean(params.allowMajor),
665
+ }, (msg) => {
666
+ this.broadcast('fix_progress', msg);
667
+ });
668
+ this.broadcast('fix_result', result);
669
+ });
670
+ socket.on('disconnect', () => {
671
+ this.metrics.activeConnections--;
672
+ });
673
+ });
674
+ await this.listenWithRetry(this.httpServer, this.config.port);
675
+ }
676
+ /**
677
+ * `httpServer.listen()` that survives transient `EADDRINUSE` from
678
+ * hot-reload races — when `tsx --watch` (or nodemon) restarts the host
679
+ * process before the previous run has released the agent port. We
680
+ * retry a few times with exponential-ish backoff before giving up.
681
+ *
682
+ * On final failure throws an `Error` whose `.code` is preserved so
683
+ * the integration layer (`@expressots/adapter-express`) can decide
684
+ * whether to surface it; today it just logs a warning and the host
685
+ * app keeps running (Studio is opt-in dev tooling).
686
+ */
687
+ async listenWithRetry(server, port, attempts = 5, initialDelayMs = 250) {
688
+ let delay = initialDelayMs;
689
+ for (let i = 1; i <= attempts; i++) {
690
+ try {
691
+ await this.listenOnce(server, port);
692
+ return;
693
+ }
694
+ catch (err) {
695
+ const code = err.code;
696
+ if (code !== 'EADDRINUSE' || i === attempts) {
697
+ throw err;
698
+ }
699
+ // Hot-reload race — port hasn't been released yet. Wait and retry.
700
+ console.warn(`[studio-agent] Port ${port} busy (attempt ${i}/${attempts}); retrying in ${delay}ms…`);
701
+ await new Promise((r) => setTimeout(r, delay));
702
+ delay = Math.min(delay * 2, 2000);
703
+ }
704
+ }
705
+ }
706
+ /** Single attempt — resolves on `listening`, rejects on `error`. */
707
+ listenOnce(server, port) {
708
+ return new Promise((resolve, reject) => {
709
+ const onError = (err) => {
710
+ server.removeListener('listening', onListening);
711
+ reject(err);
712
+ };
713
+ const onListening = () => {
714
+ server.removeListener('error', onError);
715
+ resolve();
716
+ };
717
+ server.once('error', onError);
718
+ server.once('listening', onListening);
719
+ server.listen(port);
720
+ });
721
+ }
722
+ /** Handle incoming trace */
723
+ handleTrace(trace) {
724
+ // Update metrics
725
+ this.metrics.requestCount++;
726
+ this.responseTimes.push(trace.duration);
727
+ // Keep only last 1000 response times for percentile calculation
728
+ if (this.responseTimes.length > 1000) {
729
+ this.responseTimes = this.responseTimes.slice(-1000);
730
+ }
731
+ // Check if error
732
+ if (trace.rootSpan.status === 'ERROR') {
733
+ this.metrics.errorCount++;
734
+ }
735
+ // Update endpoint stats
736
+ const httpMethod = trace.rootSpan.attributes['http.method'];
737
+ const httpPath = trace.rootSpan.attributes['http.target'] ||
738
+ trace.rootSpan.attributes['http.route'];
739
+ if (httpMethod && httpPath) {
740
+ const isError = trace.rootSpan.status === 'ERROR';
741
+ this.updateEndpointStats(httpMethod, httpPath, trace.duration, isError);
742
+ }
743
+ // Store trace
744
+ if (this.config.enableRecording) {
745
+ this.recorder.recordTrace(trace.traceId, trace);
746
+ }
747
+ // Broadcast to UI
748
+ this.broadcast('trace', trace);
749
+ }
750
+ /** Update endpoint statistics */
751
+ updateEndpointStats(method, path, duration, isError = false) {
752
+ const key = `${method}:${path}`;
753
+ let stats = this.endpointStats.get(key);
754
+ if (!stats) {
755
+ stats = {
756
+ path,
757
+ method,
758
+ requestCount: 0,
759
+ errorCount: 0,
760
+ avgDuration: 0,
761
+ minDuration: Infinity,
762
+ maxDuration: 0,
763
+ p50Duration: 0,
764
+ p95Duration: 0,
765
+ p99Duration: 0,
766
+ lastRequestTime: 0,
767
+ durations: [], // Track durations for percentile calculation
768
+ };
769
+ this.endpointStats.set(key, stats);
770
+ }
771
+ stats.requestCount++;
772
+ stats.lastRequestTime = Date.now();
773
+ stats.minDuration = Math.min(stats.minDuration, duration);
774
+ stats.maxDuration = Math.max(stats.maxDuration, duration);
775
+ // Track errors
776
+ if (isError) {
777
+ stats.errorCount++;
778
+ }
779
+ // Rolling average
780
+ stats.avgDuration =
781
+ (stats.avgDuration * (stats.requestCount - 1) + duration) /
782
+ stats.requestCount;
783
+ // Track durations for percentile calculation (keep last 100 per endpoint)
784
+ if (!stats.durations) {
785
+ stats.durations = [];
786
+ }
787
+ stats.durations.push(duration);
788
+ if (stats.durations.length > 100) {
789
+ stats.durations = stats.durations.slice(-100);
790
+ }
791
+ // Calculate percentiles
792
+ if (stats.durations.length > 0) {
793
+ const sorted = [...stats.durations].sort((a, b) => a - b);
794
+ const len = sorted.length;
795
+ stats.p50Duration = sorted[Math.floor(len * 0.5)] || 0;
796
+ stats.p95Duration = sorted[Math.floor(len * 0.95)] || 0;
797
+ stats.p99Duration = sorted[Math.floor(len * 0.99)] || 0;
798
+ }
799
+ }
800
+ /** Replay a recorded request */
801
+ async replayRequest(exchangeId, socket) {
802
+ const exchange = this.recorder.getExchange(exchangeId);
803
+ if (!exchange) {
804
+ socket.emit('message', {
805
+ type: 'replay_result',
806
+ timestamp: Date.now(),
807
+ data: { success: false, error: 'Exchange not found' },
808
+ });
809
+ return;
810
+ }
811
+ try {
812
+ // The recorder stores only the request path (e.g. "/users/1"). To
813
+ // replay we need an absolute URL — reconstruct it from the original
814
+ // `host` header captured at record time.
815
+ const recordedHeaders = (exchange.request.headers || {});
816
+ const host = recordedHeaders['host'] || recordedHeaders['Host'] || 'localhost';
817
+ const recordedUrl = exchange.request.url || exchange.request.path || '/';
818
+ const targetUrl = /^https?:\/\//i.test(recordedUrl)
819
+ ? recordedUrl
820
+ : `http://${host}${recordedUrl.startsWith('/') ? '' : '/'}${recordedUrl}`;
821
+ // Strip hop-by-hop and content-length headers so fetch can compute its
822
+ // own. Also drop `host` (browsers/Node set it from the URL).
823
+ const replayHeaders = {};
824
+ for (const [k, v] of Object.entries(recordedHeaders)) {
825
+ const key = k.toLowerCase();
826
+ if (key === 'host' ||
827
+ key === 'content-length' ||
828
+ key === 'connection' ||
829
+ key.startsWith('sec-') ||
830
+ key === 'origin' ||
831
+ key === 'referer') {
832
+ continue;
833
+ }
834
+ replayHeaders[k] = String(v);
835
+ }
836
+ const replayStart = Date.now();
837
+ const response = await fetch(targetUrl, {
838
+ method: exchange.request.method,
839
+ headers: replayHeaders,
840
+ body: exchange.request.body
841
+ ? JSON.stringify(exchange.request.body)
842
+ : undefined,
843
+ });
844
+ const responseBody = await response.text();
845
+ const replayDuration = Date.now() - replayStart;
846
+ let parsedBody;
847
+ try {
848
+ parsedBody = JSON.parse(responseBody);
849
+ }
850
+ catch {
851
+ parsedBody = responseBody;
852
+ }
853
+ socket.emit('message', {
854
+ type: 'replay_result',
855
+ timestamp: Date.now(),
856
+ data: {
857
+ success: true,
858
+ original: exchange,
859
+ replay: {
860
+ statusCode: response.status,
861
+ statusMessage: response.statusText,
862
+ headers: Object.fromEntries(response.headers.entries()),
863
+ body: parsedBody,
864
+ duration: replayDuration,
865
+ },
866
+ },
867
+ });
868
+ }
869
+ catch (error) {
870
+ socket.emit('message', {
871
+ type: 'replay_result',
872
+ timestamp: Date.now(),
873
+ data: {
874
+ success: false,
875
+ error: error instanceof Error ? error.message : 'Unknown error',
876
+ },
877
+ });
878
+ }
879
+ }
880
+ /** Broadcast message to all connected clients */
881
+ broadcast(type, data) {
882
+ if (this.io) {
883
+ this.io.emit('message', this.createMessage(type, data));
884
+ }
885
+ }
886
+ /** Create WebSocket message */
887
+ createMessage(type, data) {
888
+ return {
889
+ type: type,
890
+ timestamp: Date.now(),
891
+ data,
892
+ };
893
+ }
894
+ /** Start metrics collection interval */
895
+ startMetricsCollection() {
896
+ setInterval(() => {
897
+ // Calculate percentiles
898
+ if (this.responseTimes.length > 0) {
899
+ const sorted = [...this.responseTimes].sort((a, b) => a - b);
900
+ const len = sorted.length;
901
+ this.metrics.avgResponseTime =
902
+ sorted.reduce((a, b) => a + b, 0) / len;
903
+ this.metrics.p50ResponseTime = sorted[Math.floor(len * 0.5)] || 0;
904
+ this.metrics.p95ResponseTime = sorted[Math.floor(len * 0.95)] || 0;
905
+ this.metrics.p99ResponseTime = sorted[Math.floor(len * 0.99)] || 0;
906
+ }
907
+ // Broadcast metrics
908
+ this.broadcast('metrics', this.getMetrics());
909
+ // Piggyback runtime info on the metrics tick so the Status page's
910
+ // uptime counter and memory chip stay in sync without a separate
911
+ // timer. The payload is small (~600 B JSON) so the extra traffic is
912
+ // negligible.
913
+ this.broadcast('runtime', this.getRuntimeInfo());
914
+ }, 5000);
915
+ }
916
+ /** Create Express middleware for request/response recording */
917
+ createMiddleware() {
918
+ return (req, res, next) => {
919
+ // CORS for Studio UI: allow any localhost origin in dev so the
920
+ // built-in API Client (served from a different localhost port)
921
+ // can read responses and send preflighted methods.
922
+ const origin = req.headers.origin;
923
+ if (origin &&
924
+ /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
925
+ res.setHeader('Access-Control-Allow-Origin', origin);
926
+ res.setHeader('Vary', 'Origin');
927
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
928
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS');
929
+ const reqHeaders = req.headers['access-control-request-headers'];
930
+ res.setHeader('Access-Control-Allow-Headers', reqHeaders || 'Content-Type, Authorization, X-Trace-Id');
931
+ res.setHeader('Access-Control-Max-Age', '600');
932
+ // Short-circuit preflights so they don't pollute the request timeline
933
+ if (req.method === 'OPTIONS') {
934
+ res.statusCode = 204;
935
+ return res.end();
936
+ }
937
+ }
938
+ if (!this.config.enableRecording) {
939
+ return next();
940
+ }
941
+ const startTime = Date.now();
942
+ const traceId = req.headers['x-trace-id'] || '';
943
+ // Record request
944
+ const recordedRequest = this.recorder.recordRequest(req.method, req.path, req.originalUrl || req.url, req.headers, req.query || {}, req.body, req.cookies, traceId);
945
+ // Capture response
946
+ const originalEnd = res.end;
947
+ let responseBody;
948
+ res.end = function (chunk, ...args) {
949
+ if (chunk) {
950
+ responseBody = chunk.toString();
951
+ }
952
+ return originalEnd.apply(res, [chunk, ...args]);
953
+ };
954
+ // Track DI resolutions for this request (if the introspector is wired).
955
+ // We capture the live Set reference and read it on `finish`, which fires
956
+ // after the handler chain has fully drained.
957
+ let resolvedRef;
958
+ res.on('finish', () => {
959
+ const duration = Date.now() - startTime;
960
+ const isError = res.statusCode >= 400;
961
+ try {
962
+ let parsedBody;
963
+ try {
964
+ parsedBody = responseBody ? JSON.parse(responseBody) : undefined;
965
+ }
966
+ catch {
967
+ parsedBody = responseBody;
968
+ }
969
+ this.recorder.recordResponse(recordedRequest.id, res.statusCode, res.statusMessage || '', res.getHeaders(), parsedBody, duration, traceId);
970
+ // Update metrics
971
+ this.metrics.requestCount++;
972
+ if (isError)
973
+ this.metrics.errorCount++;
974
+ this.responseTimes.push(duration);
975
+ if (this.responseTimes.length > 1000) {
976
+ this.responseTimes = this.responseTimes.slice(-1000);
977
+ }
978
+ this.updateEndpointStats(req.method, req.path, duration, isError);
979
+ // Emit request to UI
980
+ this.broadcast('request', {
981
+ request: recordedRequest,
982
+ response: {
983
+ statusCode: res.statusCode,
984
+ duration,
985
+ },
986
+ });
987
+ // Per-request DI resolutions (if tracked)
988
+ if (resolvedRef && resolvedRef.size > 0) {
989
+ this.broadcast('container_resolutions', {
990
+ exchangeId: recordedRequest.id,
991
+ traceId,
992
+ method: req.method,
993
+ path: req.path,
994
+ resolved: Array.from(resolvedRef),
995
+ timestamp: Date.now(),
996
+ });
997
+ }
998
+ // Broadcast updated metrics immediately for real-time updates
999
+ this.broadcast('metrics', this.getMetrics());
1000
+ this.broadcast('endpoint_stats', this.getEndpointStats());
1001
+ // New exchange = potential signal for the posture analyzer
1002
+ // (new route, new header pattern, error leakage, …). Cheap
1003
+ // to debounce; the engine collapses bursts.
1004
+ this.securityEngine?.scheduleRefresh();
1005
+ }
1006
+ catch (error) {
1007
+ console.error('[Studio] Error in middleware:', error);
1008
+ }
1009
+ });
1010
+ // Run the rest of the request chain inside two nested ALS scopes:
1011
+ // - LogCapture's, so any `console.*` calls get tagged with the traceId.
1012
+ // - ContainerIntrospector's, so any `container.get(...)` resolutions
1013
+ // are recorded for the per-request "Resolved bindings" panel.
1014
+ const scopedTraceId = String(traceId || recordedRequest.id);
1015
+ const runner = (cb) => this.logCapture.runWith(scopedTraceId, cb);
1016
+ if (this.introspector) {
1017
+ runner(() => {
1018
+ const { resolved } = this.introspector.runWithRequest(scopedTraceId, () => {
1019
+ next();
1020
+ return undefined;
1021
+ });
1022
+ resolvedRef = resolved;
1023
+ });
1024
+ }
1025
+ else {
1026
+ runner(() => next());
1027
+ }
1028
+ };
1029
+ }
1030
+ }
1031
+ //# sourceMappingURL=agent.js.map