@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.
- package/README.md +143 -143
- package/dist/agent.d.ts +75 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +443 -14
- package/dist/agent.js.map +1 -1
- package/dist/discovery/route-scanner.d.ts +62 -1
- package/dist/discovery/route-scanner.d.ts.map +1 -1
- package/dist/discovery/route-scanner.js +923 -101
- package/dist/discovery/route-scanner.js.map +1 -1
- package/dist/identity/index.d.ts +2 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +2 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/install-id.d.ts +22 -0
- package/dist/identity/install-id.d.ts.map +1 -0
- package/dist/identity/install-id.js +73 -0
- package/dist/identity/install-id.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/instrumentation/tracer.d.ts.map +1 -1
- package/dist/instrumentation/tracer.js +40 -4
- package/dist/instrumentation/tracer.js.map +1 -1
- package/dist/introspection/database-introspector.d.ts +58 -0
- package/dist/introspection/database-introspector.d.ts.map +1 -0
- package/dist/introspection/database-introspector.js +351 -0
- package/dist/introspection/database-introspector.js.map +1 -0
- package/dist/logging/log-capture.d.ts.map +1 -1
- package/dist/logging/log-capture.js +23 -1
- package/dist/logging/log-capture.js.map +1 -1
- package/dist/recording/request-recorder.js +73 -73
- package/dist/security/posture-analyzer.d.ts.map +1 -1
- package/dist/security/posture-analyzer.js +1 -1
- package/dist/security/posture-analyzer.js.map +1 -1
- package/dist/types/index.d.ts +261 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- 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
|
-
//
|
|
298
|
-
//
|
|
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
|
-
|
|
302
|
-
// Merge with discovered routes
|
|
433
|
+
const prefix = this.normaliseGlobalPrefix(this.config.globalPrefix);
|
|
303
434
|
for (const runtimeRoute of runtimeRoutes) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|