@expressots/studio-agent 4.0.0-preview.1 → 4.0.0-preview.3

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 (40) hide show
  1. package/README.md +143 -143
  2. package/dist/agent.d.ts +75 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +443 -14
  5. package/dist/agent.js.map +1 -1
  6. package/dist/discovery/route-scanner.d.ts +62 -1
  7. package/dist/discovery/route-scanner.d.ts.map +1 -1
  8. package/dist/discovery/route-scanner.js +923 -101
  9. package/dist/discovery/route-scanner.js.map +1 -1
  10. package/dist/identity/index.d.ts +2 -0
  11. package/dist/identity/index.d.ts.map +1 -0
  12. package/dist/identity/index.js +2 -0
  13. package/dist/identity/index.js.map +1 -0
  14. package/dist/identity/install-id.d.ts +22 -0
  15. package/dist/identity/install-id.d.ts.map +1 -0
  16. package/dist/identity/install-id.js +73 -0
  17. package/dist/identity/install-id.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/instrumentation/tracer.d.ts.map +1 -1
  23. package/dist/instrumentation/tracer.js +40 -4
  24. package/dist/instrumentation/tracer.js.map +1 -1
  25. package/dist/introspection/database-introspector.d.ts +58 -0
  26. package/dist/introspection/database-introspector.d.ts.map +1 -0
  27. package/dist/introspection/database-introspector.js +351 -0
  28. package/dist/introspection/database-introspector.js.map +1 -0
  29. package/dist/logging/log-capture.d.ts.map +1 -1
  30. package/dist/logging/log-capture.js +23 -1
  31. package/dist/logging/log-capture.js.map +1 -1
  32. package/dist/recording/request-recorder.js +73 -73
  33. package/dist/security/posture-analyzer.d.ts.map +1 -1
  34. package/dist/security/posture-analyzer.js +1 -1
  35. package/dist/security/posture-analyzer.js.map +1 -1
  36. package/dist/types/index.d.ts +261 -2
  37. package/dist/types/index.d.ts.map +1 -1
  38. package/dist/types/index.js +2 -0
  39. package/dist/types/index.js.map +1 -1
  40. package/package.json +18 -15
package/dist/agent.js CHANGED
@@ -13,8 +13,10 @@ import { StudioTracer } from './instrumentation/tracer.js';
13
13
  import { RouteScanner } from './discovery/route-scanner.js';
14
14
  import { RequestRecorder } from './recording/request-recorder.js';
15
15
  import { ContainerIntrospector, } from './introspection/container-introspector.js';
16
+ import { DatabaseIntrospector } from './introspection/database-introspector.js';
16
17
  import { LogCapture } from './logging/log-capture.js';
17
18
  import { SecurityEngine } from './security/index.js';
19
+ import { resolveInstallId } from './identity/install-id.js';
18
20
  import * as fs from 'node:fs';
19
21
  import * as path from 'node:path';
20
22
  import { fileURLToPath } from 'node:url';
@@ -90,6 +92,7 @@ export class StudioAgent {
90
92
  recorder;
91
93
  introspector = null;
92
94
  containerSnapshot = null;
95
+ databaseIntrospector = null;
93
96
  logCapture;
94
97
  securityEngine = null;
95
98
  io = null;
@@ -101,8 +104,18 @@ export class StudioAgent {
101
104
  responseTimes = [];
102
105
  startTime = Date.now();
103
106
  isRunning = false;
107
+ srcWatcher = null;
108
+ rescanTimer = null;
109
+ /**
110
+ * Periodic metrics-broadcast timer. Captured so `stop()` can clear it
111
+ * cleanly; `.unref()`'d so it never keeps the Node event loop alive in
112
+ * tests / serverless environments.
113
+ */
114
+ metricsTimer = null;
104
115
  constructor(config = {}) {
105
116
  this.config = {
117
+ mode: config.mode ?? 'development',
118
+ installId: resolveInstallId(config.installId || undefined),
106
119
  port: config.port ?? 3334,
107
120
  dbPath: config.dbPath ?? '.studio/studio.db',
108
121
  enableRecording: config.enableRecording ?? true,
@@ -119,6 +132,7 @@ export class StudioAgent {
119
132
  };
120
133
  if (this.config.appContainer) {
121
134
  this.introspector = new ContainerIntrospector(this.config.appContainer);
135
+ this.databaseIntrospector = new DatabaseIntrospector(this.config.appContainer);
122
136
  }
123
137
  this.logCapture = new LogCapture(1000);
124
138
  this.tracer = new StudioTracer(this.config.serviceName);
@@ -178,8 +192,64 @@ export class StudioAgent {
178
192
  this.securityEngine?.scheduleRefresh();
179
193
  });
180
194
  this.startSecurityEngine();
195
+ // Watch the project's `src/` for controller / module / DTO changes so
196
+ // the Routes, Architecture and API client tabs stay live without the
197
+ // user reloading Studio. Disabled in production and silently no-ops
198
+ // when the directory is missing or `fs.watch(recursive)` isn't
199
+ // supported on this platform/Node version.
200
+ this.startSrcWatcher();
181
201
  this.isRunning = true;
182
202
  }
203
+ /**
204
+ * Start a debounced filesystem watcher over `./src` (relative to the
205
+ * host's CWD) that triggers a route + structure rescan whenever a
206
+ * `*.ts` / `*.js` file changes. Uses `fs.watch({ recursive: true })`
207
+ * to avoid taking a chokidar dependency. Failures are non-fatal.
208
+ */
209
+ startSrcWatcher() {
210
+ if (process.env.NODE_ENV === 'production')
211
+ return;
212
+ if (process.env.EXPRESSOTS_STUDIO_FS_WATCH === 'false')
213
+ return;
214
+ const srcDir = path.resolve(process.cwd(), 'src');
215
+ if (!fs.existsSync(srcDir))
216
+ return;
217
+ const debounceMs = 300;
218
+ const onChange = (_event, filename) => {
219
+ if (!filename)
220
+ return;
221
+ const name = filename.toString();
222
+ // Only react to source file changes; skip editor swap files and the
223
+ // compiled output to avoid feedback loops.
224
+ if (!/\.(ts|js)$/i.test(name))
225
+ return;
226
+ if (name.includes('node_modules'))
227
+ return;
228
+ if (name.includes('dist' + path.sep) || name.startsWith('dist'))
229
+ return;
230
+ if (this.rescanTimer)
231
+ clearTimeout(this.rescanTimer);
232
+ this.rescanTimer = setTimeout(() => {
233
+ this.rescanTimer = null;
234
+ void this.scanRoutes();
235
+ }, debounceMs);
236
+ };
237
+ try {
238
+ this.srcWatcher = fs.watch(srcDir, { recursive: true }, onChange);
239
+ this.srcWatcher.on('error', () => {
240
+ // Best-effort: a closed handle / inotify exhaustion shouldn't take
241
+ // down the agent. Disable further reactions until next start().
242
+ this.srcWatcher?.close();
243
+ this.srcWatcher = null;
244
+ });
245
+ }
246
+ catch {
247
+ // Recursive watch is unsupported on some Linux setups (Node < 20).
248
+ // Live updates simply degrade to "rescan on next request"; users
249
+ // running tsx/nodemon will still get a fresh agent on each restart.
250
+ this.srcWatcher = null;
251
+ }
252
+ }
183
253
  /**
184
254
  * Stand up the SecurityEngine and kick off the first scan. The
185
255
  * engine reuses the existing Socket.IO server — every transition in
@@ -214,6 +284,23 @@ export class StudioAgent {
214
284
  getContainerSnapshot() {
215
285
  return this.containerSnapshot;
216
286
  }
287
+ /**
288
+ * Capture a fresh in-memory database snapshot. Returns an "unavailable"
289
+ * snapshot when no `InMemoryDBProvider` is registered or no container was
290
+ * provided to the agent.
291
+ */
292
+ captureDatabaseSnapshot() {
293
+ if (this.databaseIntrospector) {
294
+ return this.databaseIntrospector.capture();
295
+ }
296
+ return {
297
+ available: false,
298
+ tableCount: 0,
299
+ totalRecords: 0,
300
+ entities: [],
301
+ timestamp: new Date().toISOString(),
302
+ };
303
+ }
217
304
  /** Stop the Studio Agent */
218
305
  async stop() {
219
306
  if (!this.isRunning)
@@ -240,6 +327,23 @@ export class StudioAgent {
240
327
  this.securityEngine.stop();
241
328
  this.securityEngine = null;
242
329
  }
330
+ if (this.metricsTimer) {
331
+ clearInterval(this.metricsTimer);
332
+ this.metricsTimer = null;
333
+ }
334
+ if (this.rescanTimer) {
335
+ clearTimeout(this.rescanTimer);
336
+ this.rescanTimer = null;
337
+ }
338
+ if (this.srcWatcher) {
339
+ try {
340
+ this.srcWatcher.close();
341
+ }
342
+ catch {
343
+ // best-effort
344
+ }
345
+ this.srcWatcher = null;
346
+ }
243
347
  }
244
348
  /**
245
349
  * Tear down the WebSocket / HTTP server with a hard timeout so the
@@ -294,30 +398,118 @@ export class StudioAgent {
294
398
  try {
295
399
  this.appStructure = await this.scanner.scan();
296
400
  this.routes = this.scanner.getRoutes();
297
- // Route counts available via getRoutes()
298
- // If Express app is provided, also scan runtime routes
401
+ // Snapshot the un-prefixed path for every freshly scanned route.
402
+ // The static scanner only sees `@controller(...)` + `@Get(...)`
403
+ // metadata — the host mounts the whole router under
404
+ // `setGlobalRoutePrefix("/api")` later, so the source-of-truth
405
+ // path is kept on `originalPath` and the user-visible `path` is
406
+ // always recomputed from it. This lets `updateRuntimeInfo` swap
407
+ // the prefix later without compounding `/api/api/health`-style
408
+ // duplication on each call.
409
+ for (const r of this.routes) {
410
+ r.originalPath = r.path;
411
+ }
412
+ // Re-fold any previously reported runtime middleware data into
413
+ // the fresh static structure. Without this, a hot-reload rescan
414
+ // would drop the global pipeline nodes and Reflect-derived edges
415
+ // until the next `updateRuntimeInfo()` callback.
416
+ this.mergeRuntimeMiddlewareIntoStructure();
417
+ // Apply the host-supplied global URL prefix. Earlier versions
418
+ // tried to recover the prefix from `app.router.stack[i].regexp`
419
+ // at runtime, but Express 5 dropped that field in favour of
420
+ // opaque `matchers` closures, so the prefix is no longer
421
+ // recoverable from the layer alone. The host already passes it
422
+ // in via `agentOptions.globalPrefix` — use that as the source of
423
+ // truth.
424
+ this.applyGlobalPrefixToRoutes();
425
+ // If Express app is provided, also scan runtime routes and merge
426
+ // anything the static scan missed (handlers registered ad-hoc via
427
+ // `app.get(...)` outside a `@Controller()` class, e.g. health-check
428
+ // probes wired directly in `configureServices`). The static
429
+ // scanner is the source of truth for decorated routes; runtime
430
+ // routes only ever *add* — never replace.
299
431
  if (this.config.expressApp) {
300
432
  const runtimeRoutes = RouteScanner.scanExpressApp(this.config.expressApp);
301
- // Merge runtime routes with discovered routes
302
- // Merge with discovered routes
433
+ const prefix = this.normaliseGlobalPrefix(this.config.globalPrefix);
303
434
  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
- }
435
+ // Apply the same global prefix to runtime routes Express 5
436
+ // strips it from `app.router.stack` (the prefix lives inside
437
+ // sub-router matcher closures), so `scanExpressApp` returns
438
+ // un-prefixed paths just like the static scanner.
439
+ const fullPath = prefix
440
+ ? this.joinPrefixWithRoute(prefix, runtimeRoute.path)
441
+ : runtimeRoute.path;
442
+ const exact = this.routes.find((r) => r.path === fullPath && r.method === runtimeRoute.method);
443
+ if (exact)
444
+ continue;
445
+ this.routes.push({
446
+ ...runtimeRoute,
447
+ path: fullPath,
448
+ // Keep the un-prefixed form so a later prefix change rewrites
449
+ // this route the same way it does decorator-discovered ones.
450
+ ...({ originalPath: runtimeRoute.path }),
451
+ });
308
452
  }
309
453
  }
310
- // Broadcast to connected clients
454
+ // Broadcast to connected clients. The routes payload is small,
455
+ // but the architecture map needs the full `appStructure` (controllers,
456
+ // services, providers, middleware, dependencies) to redraw nodes
457
+ // and edges for newly added classes — without this second emit the
458
+ // map keeps showing the boot-time graph even after a rescan.
311
459
  this.broadcast('routes', this.routes);
460
+ if (this.appStructure) {
461
+ this.broadcast('structure', this.appStructure);
462
+ }
312
463
  }
313
464
  catch (error) {
314
- console.error('Failed to scan routes:', error);
465
+ // `console.error('Failed to scan routes:', error)` prints `{}` for any
466
+ // thrown value that isn't a `Error` instance (because plain objects
467
+ // serialise as their enumerable keys, of which there are none on most
468
+ // exception shapes). Normalise to a useful one-liner so users can
469
+ // actually diagnose what the static scan tripped over.
470
+ const err = error;
471
+ const message = (err && (err.message || err.code)) ||
472
+ (typeof error === 'string' ? error : JSON.stringify(error)) ||
473
+ 'unknown error';
474
+ console.error(`[StudioAgent] Failed to scan routes: ${message}`);
475
+ if (err?.stack && process.env.EXPRESSOTS_STUDIO_DEBUG === 'true') {
476
+ console.error(err.stack);
477
+ }
315
478
  }
316
479
  }
317
480
  /** Get discovered routes */
318
481
  getRoutes() {
319
482
  return this.routes;
320
483
  }
484
+ /**
485
+ * Normalise the host-supplied global URL prefix into the form we
486
+ * actually want to splice into route paths.
487
+ *
488
+ * - Returns `''` for "no prefix" (so callers can fall through with a
489
+ * simple truthy check).
490
+ * - Strips trailing slashes (`/api/` → `/api`) so the join helper
491
+ * never produces `/api//foo`.
492
+ * - Defends against the host passing through a legitimate but
493
+ * no-op `'/'` prefix.
494
+ */
495
+ normaliseGlobalPrefix(value) {
496
+ if (!value || typeof value !== 'string')
497
+ return '';
498
+ if (value === '/' || value === '')
499
+ return '';
500
+ return value.endsWith('/') ? value.slice(0, -1) : value;
501
+ }
502
+ /**
503
+ * Splice a normalised global prefix onto a controller-relative route
504
+ * path while preserving the leading slash and avoiding doubled
505
+ * separators. `/api` + `/` → `/api/`, `/api` + `users` → `/api/users`,
506
+ * `/api` + `/users` → `/api/users`.
507
+ */
508
+ joinPrefixWithRoute(prefix, path) {
509
+ if (!path || path === '/')
510
+ return prefix + '/';
511
+ return prefix + (path.startsWith('/') ? path : `/${path}`);
512
+ }
321
513
  /** Get application structure */
322
514
  getAppStructure() {
323
515
  return this.appStructure;
@@ -346,8 +538,18 @@ export class StudioAgent {
346
538
  updateRuntimeInfo(patch) {
347
539
  if (patch.appPort !== undefined)
348
540
  this.config.appPort = patch.appPort;
349
- if (patch.globalPrefix !== undefined)
541
+ // Track whether the prefix actually changed before assigning, so we
542
+ // know whether to re-prefix the cached `routes`. Without this, a
543
+ // late `updateRuntimeInfo({ globalPrefix: "/api" })` (e.g. fired
544
+ // from `app.listen()`'s callback after `setGlobalRoutePrefix("/api")`
545
+ // ran during configureServices) would update the config but leave
546
+ // the route list still showing un-prefixed paths until the next
547
+ // file-watcher rescan.
548
+ let prefixChanged = false;
549
+ if (patch.globalPrefix !== undefined && patch.globalPrefix !== this.config.globalPrefix) {
350
550
  this.config.globalPrefix = patch.globalPrefix;
551
+ prefixChanged = true;
552
+ }
351
553
  if (patch.startupMs !== undefined)
352
554
  this.config.startupMs = patch.startupMs;
353
555
  if (patch.interceptorCount !== undefined) {
@@ -360,17 +562,197 @@ export class StudioAgent {
360
562
  this.config.middlewareCount = patch.middlewareCount;
361
563
  }
362
564
  if (patch.runtimeItems !== undefined) {
363
- // Merge so partial updates (e.g. providers only) don't wipe the
364
- // other categories.
365
565
  this.config.runtimeItems = {
366
566
  ...this.config.runtimeItems,
367
567
  ...patch.runtimeItems,
368
568
  };
369
569
  }
570
+ if (patch.middlewarePreset !== undefined) {
571
+ this.config.middlewarePreset = patch.middlewarePreset;
572
+ }
573
+ // Fold the latest runtime data into the cached `appStructure` so the
574
+ // architecture map sees:
575
+ // 1. Global pipeline middleware as nodes (even when nothing in
576
+ // source extends ExpressoMiddleware — e.g. plain functions
577
+ // added via `Middleware.add`).
578
+ // 2. Scoped middleware → controller / route edges from the
579
+ // `middlewareBindings` payload.
580
+ //
581
+ // The merge is idempotent — calling `updateRuntimeInfo` repeatedly
582
+ // with the same payload yields the same structure.
583
+ const merged = this.mergeRuntimeMiddlewareIntoStructure();
584
+ if (merged && this.io) {
585
+ this.broadcast('structure', this.appStructure);
586
+ }
587
+ // If the global URL prefix changed, splice it onto every cached
588
+ // route so the Routes / API client tabs immediately reflect the
589
+ // mounted paths instead of the bare per-controller paths.
590
+ if (prefixChanged) {
591
+ this.applyGlobalPrefixToRoutes();
592
+ if (this.io)
593
+ this.broadcast('routes', this.routes);
594
+ }
370
595
  if (this.io) {
371
596
  this.broadcast('runtime', this.getRuntimeInfo());
372
597
  }
373
598
  }
599
+ /**
600
+ * Recompute every route's `path` from its captured `originalPath` plus
601
+ * the current `config.globalPrefix`. Idempotent and prefix-change-safe
602
+ * — calling it twice with different prefixes never produces the
603
+ * `/api/api/health` doubling we'd see if we appended in place.
604
+ *
605
+ * Routes pushed by older code paths that don't carry an `originalPath`
606
+ * (defensive — hot-reload scans always set it now) are left alone, so
607
+ * we never silently strip a prefix the source of truth set
608
+ * intentionally.
609
+ */
610
+ applyGlobalPrefixToRoutes() {
611
+ const prefix = this.normaliseGlobalPrefix(this.config.globalPrefix);
612
+ for (const route of this.routes) {
613
+ const original = route.originalPath;
614
+ if (typeof original !== 'string')
615
+ continue;
616
+ route.path = prefix
617
+ ? this.joinPrefixWithRoute(prefix, original)
618
+ : original;
619
+ }
620
+ }
621
+ /**
622
+ * Merge global pipeline middleware and runtime middleware bindings
623
+ * into the static `appStructure` so the architecture map sees a
624
+ * single source of truth. Returns `true` when the structure changed.
625
+ *
626
+ * Rules:
627
+ * - Each `runtimeItems.middleware` entry whose `type === 'custom'`
628
+ * gets a `MiddlewareInfo` node with `scope: 'global'` (built-in
629
+ * pipeline entries like `helmet` / `jsonParser` aren't worth
630
+ * plotting — they would clutter every architecture map without
631
+ * adding signal). Names are deduplicated against the existing
632
+ * middleware list.
633
+ * - Each `runtimeItems.middlewareBindings` entry contributes a
634
+ * `middleware → controller` edge (deduplicated with the static
635
+ * bindings produced by `RouteScanner`). The middleware node's
636
+ * scope is upgraded to `controller` or `route`.
637
+ * - Global middleware also gets synthetic edges to every
638
+ * controller in the structure so the map shows the pipeline
639
+ * fanning out across the app.
640
+ */
641
+ mergeRuntimeMiddlewareIntoStructure() {
642
+ if (!this.appStructure)
643
+ return false;
644
+ const runtime = this.config.runtimeItems;
645
+ if (!runtime)
646
+ return false;
647
+ let changed = false;
648
+ const byName = new Map();
649
+ for (const mw of this.appStructure.middleware) {
650
+ byName.set(mw.name, mw);
651
+ }
652
+ // Global pipeline middleware → upgrade or create a node.
653
+ for (const item of runtime.middleware ?? []) {
654
+ if (item.type === 'built-in')
655
+ continue;
656
+ const existing = byName.get(item.name);
657
+ if (existing) {
658
+ if (existing.scope !== 'global') {
659
+ existing.scope = 'global';
660
+ changed = true;
661
+ }
662
+ }
663
+ else {
664
+ const node = {
665
+ name: item.name,
666
+ filePath: '',
667
+ dependencies: [],
668
+ methods: [],
669
+ scope: 'global',
670
+ };
671
+ this.appStructure.middleware.push(node);
672
+ byName.set(item.name, node);
673
+ changed = true;
674
+ }
675
+ }
676
+ const knownNodes = new Set();
677
+ for (const c of this.appStructure.controllers)
678
+ knownNodes.add(c.name);
679
+ for (const s of this.appStructure.services)
680
+ knownNodes.add(s.name);
681
+ for (const p of this.appStructure.providers)
682
+ knownNodes.add(p.name);
683
+ for (const m of this.appStructure.middleware)
684
+ knownNodes.add(m.name);
685
+ const seenEdge = new Set();
686
+ for (const dep of this.appStructure.dependencies) {
687
+ seenEdge.add(`${dep.source}->${dep.target}@${dep.type}`);
688
+ }
689
+ // Scoped bindings from Reflect metadata. If a binding references a
690
+ // middleware name we haven't seen before (common for plain-function
691
+ // middleware like `newMiddleware()` that doesn't extend
692
+ // ExpressoMiddleware), create a lightweight node on-the-fly so the
693
+ // architecture map can still render it.
694
+ for (const binding of runtime.middlewareBindings ?? []) {
695
+ if (!knownNodes.has(binding.controllerName))
696
+ continue;
697
+ if (!knownNodes.has(binding.middlewareName)) {
698
+ const node = {
699
+ name: binding.middlewareName,
700
+ filePath: '',
701
+ dependencies: [],
702
+ methods: [],
703
+ scope: binding.scope,
704
+ };
705
+ this.appStructure.middleware.push(node);
706
+ byName.set(binding.middlewareName, node);
707
+ knownNodes.add(binding.middlewareName);
708
+ changed = true;
709
+ }
710
+ const key = `${binding.middlewareName}->${binding.controllerName}@middleware`;
711
+ if (!seenEdge.has(key)) {
712
+ this.appStructure.dependencies.push({
713
+ source: binding.middlewareName,
714
+ target: binding.controllerName,
715
+ type: 'middleware',
716
+ });
717
+ seenEdge.add(key);
718
+ changed = true;
719
+ }
720
+ const mw = byName.get(binding.middlewareName);
721
+ if (mw) {
722
+ if (binding.scope === 'controller' && mw.scope !== 'controller') {
723
+ mw.scope = 'controller';
724
+ changed = true;
725
+ }
726
+ else if (binding.scope === 'route' &&
727
+ mw.scope !== 'controller' &&
728
+ mw.scope !== 'route') {
729
+ mw.scope = 'route';
730
+ changed = true;
731
+ }
732
+ }
733
+ }
734
+ // Global middleware fans out to every controller. We only emit
735
+ // edges for nodes that already exist; the list is short (typically
736
+ // a handful of custom middleware × a handful of controllers) so
737
+ // duplication is cheap and produces a readable layered layout.
738
+ for (const mw of this.appStructure.middleware) {
739
+ if (mw.scope !== 'global')
740
+ continue;
741
+ for (const ctrl of this.appStructure.controllers) {
742
+ const key = `${mw.name}->${ctrl.name}@middleware`;
743
+ if (seenEdge.has(key))
744
+ continue;
745
+ this.appStructure.dependencies.push({
746
+ source: mw.name,
747
+ target: ctrl.name,
748
+ type: 'middleware',
749
+ });
750
+ seenEdge.add(key);
751
+ changed = true;
752
+ }
753
+ }
754
+ return changed;
755
+ }
374
756
  /**
375
757
  * Build a snapshot of runtime information for the Status dashboard.
376
758
  *
@@ -437,6 +819,7 @@ export class StudioAgent {
437
819
  },
438
820
  runtimeItems: this.config.runtimeItems,
439
821
  recordingEnabled: this.config.enableRecording,
822
+ middlewarePreset: this.config.middlewarePreset,
440
823
  };
441
824
  }
442
825
  /** Start WebSocket server */
@@ -491,6 +874,14 @@ export class StudioAgent {
491
874
  data: this.containerSnapshot,
492
875
  });
493
876
  }
877
+ // Send the in-memory database schema snapshot (when a provider is
878
+ // registered). Always emitted so the UI can render the "not detected"
879
+ // empty state when `available` is false.
880
+ socket.emit('message', {
881
+ type: 'database',
882
+ timestamp: Date.now(),
883
+ data: this.captureDatabaseSnapshot(),
884
+ });
494
885
  socket.emit('message', {
495
886
  type: 'recording_state',
496
887
  timestamp: Date.now(),
@@ -624,6 +1015,35 @@ export class StudioAgent {
624
1015
  data: this.containerSnapshot,
625
1016
  });
626
1017
  });
1018
+ // Re-send the in-memory database schema snapshot on demand. Captured
1019
+ // fresh each call so newly created tables / records are reflected.
1020
+ socket.on('get_database_schema', () => {
1021
+ socket.emit('message', {
1022
+ type: 'database',
1023
+ timestamp: Date.now(),
1024
+ data: this.captureDatabaseSnapshot(),
1025
+ });
1026
+ });
1027
+ // Return a page of rows for a single table.
1028
+ socket.on('get_database_table', async (params) => {
1029
+ const table = typeof params?.table === 'string' ? params.table : '';
1030
+ if (!table)
1031
+ return;
1032
+ const offset = typeof params?.offset === 'number' && params.offset >= 0
1033
+ ? params.offset
1034
+ : 0;
1035
+ const limit = typeof params?.limit === 'number' && params.limit > 0
1036
+ ? Math.min(params.limit, 200)
1037
+ : 50;
1038
+ const data = this.databaseIntrospector
1039
+ ? await this.databaseIntrospector.getTableData(table, offset, limit)
1040
+ : { table, rows: [], total: 0, offset, limit };
1041
+ socket.emit('message', {
1042
+ type: 'database_table',
1043
+ timestamp: Date.now(),
1044
+ data,
1045
+ });
1046
+ });
627
1047
  // Push the latest cached report on demand. Useful when the UI
628
1048
  // explicitly navigates to the Security view and wants a fresh
629
1049
  // copy even if nothing has changed.
@@ -893,7 +1313,13 @@ export class StudioAgent {
893
1313
  }
894
1314
  /** Start metrics collection interval */
895
1315
  startMetricsCollection() {
896
- setInterval(() => {
1316
+ // Reuse an existing timer rather than stacking duplicates if the host
1317
+ // re-invokes start(); also makes idempotent restarts safe.
1318
+ if (this.metricsTimer) {
1319
+ clearInterval(this.metricsTimer);
1320
+ this.metricsTimer = null;
1321
+ }
1322
+ this.metricsTimer = setInterval(() => {
897
1323
  // Calculate percentiles
898
1324
  if (this.responseTimes.length > 0) {
899
1325
  const sorted = [...this.responseTimes].sort((a, b) => a - b);
@@ -912,6 +1338,9 @@ export class StudioAgent {
912
1338
  // negligible.
913
1339
  this.broadcast('runtime', this.getRuntimeInfo());
914
1340
  }, 5000);
1341
+ // Don't keep the event loop alive solely for metrics broadcasting;
1342
+ // the agent is observability infrastructure, not application logic.
1343
+ this.metricsTimer.unref?.();
915
1344
  }
916
1345
  /** Create Express middleware for request/response recording */
917
1346
  createMiddleware() {