@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.
- package/README.md +143 -0
- package/dist/agent.d.ts +127 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +1031 -0
- package/dist/agent.js.map +1 -0
- package/dist/discovery/index.d.ts +2 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +2 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/route-scanner.d.ts +35 -0
- package/dist/discovery/route-scanner.d.ts.map +1 -0
- package/dist/discovery/route-scanner.js +385 -0
- package/dist/discovery/route-scanner.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation/index.d.ts +2 -0
- package/dist/instrumentation/index.d.ts.map +1 -0
- package/dist/instrumentation/index.js +2 -0
- package/dist/instrumentation/index.js.map +1 -0
- package/dist/instrumentation/tracer.d.ts +40 -0
- package/dist/instrumentation/tracer.d.ts.map +1 -0
- package/dist/instrumentation/tracer.js +190 -0
- package/dist/instrumentation/tracer.js.map +1 -0
- package/dist/introspection/container-introspector.d.ts +81 -0
- package/dist/introspection/container-introspector.d.ts.map +1 -0
- package/dist/introspection/container-introspector.js +251 -0
- package/dist/introspection/container-introspector.js.map +1 -0
- package/dist/logging/log-capture.d.ts +58 -0
- package/dist/logging/log-capture.d.ts.map +1 -0
- package/dist/logging/log-capture.js +184 -0
- package/dist/logging/log-capture.js.map +1 -0
- package/dist/recording/index.d.ts +2 -0
- package/dist/recording/index.d.ts.map +1 -0
- package/dist/recording/index.js +2 -0
- package/dist/recording/index.js.map +1 -0
- package/dist/recording/request-recorder.d.ts +43 -0
- package/dist/recording/request-recorder.d.ts.map +1 -0
- package/dist/recording/request-recorder.js +373 -0
- package/dist/recording/request-recorder.js.map +1 -0
- package/dist/security/fix-resolver.d.ts +40 -0
- package/dist/security/fix-resolver.d.ts.map +1 -0
- package/dist/security/fix-resolver.js +283 -0
- package/dist/security/fix-resolver.js.map +1 -0
- package/dist/security/fix-runner.d.ts +60 -0
- package/dist/security/fix-runner.d.ts.map +1 -0
- package/dist/security/fix-runner.js +188 -0
- package/dist/security/fix-runner.js.map +1 -0
- package/dist/security/index.d.ts +140 -0
- package/dist/security/index.d.ts.map +1 -0
- package/dist/security/index.js +460 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/lockfile-graph.d.ts +69 -0
- package/dist/security/lockfile-graph.d.ts.map +1 -0
- package/dist/security/lockfile-graph.js +245 -0
- package/dist/security/lockfile-graph.js.map +1 -0
- package/dist/security/npm-audit.d.ts +67 -0
- package/dist/security/npm-audit.d.ts.map +1 -0
- package/dist/security/npm-audit.js +320 -0
- package/dist/security/npm-audit.js.map +1 -0
- package/dist/security/osv-cache.d.ts +51 -0
- package/dist/security/osv-cache.d.ts.map +1 -0
- package/dist/security/osv-cache.js +99 -0
- package/dist/security/osv-cache.js.map +1 -0
- package/dist/security/osv-client.d.ts +47 -0
- package/dist/security/osv-client.d.ts.map +1 -0
- package/dist/security/osv-client.js +247 -0
- package/dist/security/osv-client.js.map +1 -0
- package/dist/security/posture-analyzer.d.ts +44 -0
- package/dist/security/posture-analyzer.d.ts.map +1 -0
- package/dist/security/posture-analyzer.js +397 -0
- package/dist/security/posture-analyzer.js.map +1 -0
- package/dist/security/reachability.d.ts +59 -0
- package/dist/security/reachability.d.ts.map +1 -0
- package/dist/security/reachability.js +302 -0
- package/dist/security/reachability.js.map +1 -0
- package/dist/security/score.d.ts +36 -0
- package/dist/security/score.d.ts.map +1 -0
- package/dist/security/score.js +94 -0
- package/dist/security/score.js.map +1 -0
- package/dist/types/index.d.ts +587 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +14 -0
- package/dist/types/index.js.map +1 -0
- 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
|