@runtimescope/collector 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3807 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/server.ts
9
+ import { createServer as createHttpsServer } from "https";
10
+ import { WebSocketServer } from "ws";
11
+
12
+ // src/ring-buffer.ts
13
+ var RingBuffer = class {
14
+ constructor(capacity) {
15
+ this.capacity = capacity;
16
+ this.buffer = new Array(capacity);
17
+ }
18
+ buffer;
19
+ head = 0;
20
+ _count = 0;
21
+ get count() {
22
+ return this._count;
23
+ }
24
+ push(item) {
25
+ this.buffer[this.head] = item;
26
+ this.head = (this.head + 1) % this.capacity;
27
+ if (this._count < this.capacity) this._count++;
28
+ }
29
+ /** Returns all items from oldest to newest. */
30
+ toArray() {
31
+ if (this._count === 0) return [];
32
+ const result = [];
33
+ const start = this._count < this.capacity ? 0 : this.head;
34
+ for (let i = 0; i < this._count; i++) {
35
+ const idx = (start + i) % this.capacity;
36
+ result.push(this.buffer[idx]);
37
+ }
38
+ return result;
39
+ }
40
+ /** Returns matching items from newest to oldest (most recent first). */
41
+ query(predicate) {
42
+ if (this._count === 0) return [];
43
+ const result = [];
44
+ const start = this._count < this.capacity ? 0 : this.head;
45
+ for (let i = this._count - 1; i >= 0; i--) {
46
+ const idx = (start + i) % this.capacity;
47
+ const item = this.buffer[idx];
48
+ if (predicate(item)) result.push(item);
49
+ }
50
+ return result;
51
+ }
52
+ clear() {
53
+ this.buffer = new Array(this.capacity);
54
+ this.head = 0;
55
+ this._count = 0;
56
+ }
57
+ };
58
+
59
+ // src/store.ts
60
+ var EventStore = class {
61
+ buffer;
62
+ sessions = /* @__PURE__ */ new Map();
63
+ sqliteStore = null;
64
+ currentProject = null;
65
+ onEventCallbacks = [];
66
+ redactor = null;
67
+ constructor(capacity = 1e4) {
68
+ this.buffer = new RingBuffer(capacity);
69
+ }
70
+ setRedactor(redactor) {
71
+ this.redactor = redactor;
72
+ }
73
+ get eventCount() {
74
+ return this.buffer.count;
75
+ }
76
+ setSqliteStore(store, project) {
77
+ this.sqliteStore = store;
78
+ this.currentProject = project;
79
+ }
80
+ onEvent(callback) {
81
+ this.onEventCallbacks.push(callback);
82
+ }
83
+ removeEventListener(callback) {
84
+ const idx = this.onEventCallbacks.indexOf(callback);
85
+ if (idx !== -1) this.onEventCallbacks.splice(idx, 1);
86
+ }
87
+ addEvent(event) {
88
+ if (this.redactor?.isEnabled()) {
89
+ event = this.redactor.redactEvent(event);
90
+ }
91
+ this.buffer.push(event);
92
+ if (event.eventType === "session") {
93
+ const se = event;
94
+ this.sessions.set(se.sessionId, {
95
+ sessionId: se.sessionId,
96
+ appName: se.appName,
97
+ connectedAt: se.connectedAt,
98
+ sdkVersion: se.sdkVersion,
99
+ eventCount: 0,
100
+ isConnected: true
101
+ });
102
+ }
103
+ const session = this.sessions.get(event.sessionId);
104
+ if (session) session.eventCount++;
105
+ if (this.sqliteStore && this.currentProject) {
106
+ this.sqliteStore.addEvent(event, this.currentProject);
107
+ }
108
+ for (const cb of this.onEventCallbacks) {
109
+ try {
110
+ cb(event);
111
+ } catch {
112
+ }
113
+ }
114
+ }
115
+ getNetworkRequests(filter = {}) {
116
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
117
+ return this.buffer.query((e) => {
118
+ if (e.eventType !== "network") return false;
119
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
120
+ const ne = e;
121
+ if (ne.timestamp < since) return false;
122
+ if (filter.urlPattern && !ne.url.includes(filter.urlPattern)) return false;
123
+ if (filter.status !== void 0 && ne.status !== filter.status) return false;
124
+ if (filter.method && ne.method.toUpperCase() !== filter.method.toUpperCase())
125
+ return false;
126
+ return true;
127
+ });
128
+ }
129
+ getConsoleMessages(filter = {}) {
130
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
131
+ return this.buffer.query((e) => {
132
+ if (e.eventType !== "console") return false;
133
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
134
+ const ce = e;
135
+ if (ce.timestamp < since) return false;
136
+ if (filter.level && ce.level !== filter.level) return false;
137
+ if (filter.search && !ce.message.toLowerCase().includes(filter.search.toLowerCase()))
138
+ return false;
139
+ return true;
140
+ });
141
+ }
142
+ getSessionInfo() {
143
+ return Array.from(this.sessions.values());
144
+ }
145
+ markDisconnected(sessionId) {
146
+ const s = this.sessions.get(sessionId);
147
+ if (s) s.isConnected = false;
148
+ }
149
+ getEventTimeline(filter = {}) {
150
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
151
+ const typeSet = filter.eventTypes ? new Set(filter.eventTypes) : null;
152
+ return this.buffer.toArray().filter((e) => {
153
+ if (e.timestamp < since) return false;
154
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
155
+ if (typeSet && !typeSet.has(e.eventType)) return false;
156
+ return true;
157
+ });
158
+ }
159
+ getAllEvents(sinceSeconds, sessionId) {
160
+ const since = sinceSeconds ? Date.now() - sinceSeconds * 1e3 : 0;
161
+ return this.buffer.toArray().filter((e) => {
162
+ if (e.timestamp < since) return false;
163
+ if (sessionId && e.sessionId !== sessionId) return false;
164
+ return true;
165
+ });
166
+ }
167
+ getSessionIdsForProject(appName) {
168
+ return Array.from(this.sessions.values()).filter((s) => s.appName === appName).map((s) => s.sessionId);
169
+ }
170
+ getStateEvents(filter = {}) {
171
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
172
+ return this.buffer.query((e) => {
173
+ if (e.eventType !== "state") return false;
174
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
175
+ const se = e;
176
+ if (se.timestamp < since) return false;
177
+ if (filter.storeId && se.storeId !== filter.storeId) return false;
178
+ return true;
179
+ });
180
+ }
181
+ getRenderEvents(filter = {}) {
182
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
183
+ return this.buffer.query((e) => {
184
+ if (e.eventType !== "render") return false;
185
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
186
+ const re = e;
187
+ if (re.timestamp < since) return false;
188
+ if (filter.componentName) {
189
+ const hasMatch = re.profiles.some(
190
+ (p) => p.componentName.toLowerCase().includes(filter.componentName.toLowerCase())
191
+ );
192
+ if (!hasMatch) return false;
193
+ }
194
+ return true;
195
+ });
196
+ }
197
+ getPerformanceMetrics(filter = {}) {
198
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
199
+ return this.buffer.query((e) => {
200
+ if (e.eventType !== "performance") return false;
201
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
202
+ const pe = e;
203
+ if (pe.timestamp < since) return false;
204
+ if (filter.metricName && pe.metricName !== filter.metricName) return false;
205
+ return true;
206
+ });
207
+ }
208
+ getDatabaseEvents(filter = {}) {
209
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
210
+ return this.buffer.query((e) => {
211
+ if (e.eventType !== "database") return false;
212
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
213
+ const de = e;
214
+ if (de.timestamp < since) return false;
215
+ if (filter.table) {
216
+ const hasTable = de.tablesAccessed.some(
217
+ (t) => t.toLowerCase() === filter.table.toLowerCase()
218
+ );
219
+ if (!hasTable) return false;
220
+ }
221
+ if (filter.minDurationMs !== void 0 && de.duration < filter.minDurationMs) return false;
222
+ if (filter.search && !de.query.toLowerCase().includes(filter.search.toLowerCase()))
223
+ return false;
224
+ if (filter.operation && de.operation !== filter.operation) return false;
225
+ if (filter.source && de.source !== filter.source) return false;
226
+ return true;
227
+ });
228
+ }
229
+ // ============================================================
230
+ // Recon event queries — returns the most recent event of each type
231
+ // ============================================================
232
+ getLatestReconEvent(eventType, filter = {}) {
233
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
234
+ const results = this.buffer.query((e) => {
235
+ if (e.eventType !== eventType) return false;
236
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
237
+ if (e.timestamp < since) return false;
238
+ if (filter.url) {
239
+ const re = e;
240
+ if (re.url && !re.url.includes(filter.url)) return false;
241
+ }
242
+ return true;
243
+ });
244
+ return results[0] ?? null;
245
+ }
246
+ getReconEvents(eventType, filter = {}) {
247
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
248
+ return this.buffer.query((e) => {
249
+ if (e.eventType !== eventType) return false;
250
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
251
+ if (e.timestamp < since) return false;
252
+ if (filter.url) {
253
+ const re = e;
254
+ if (re.url && !re.url.includes(filter.url)) return false;
255
+ }
256
+ return true;
257
+ });
258
+ }
259
+ getReconMetadata(filter = {}) {
260
+ return this.getLatestReconEvent("recon_metadata", filter);
261
+ }
262
+ getReconDesignTokens(filter = {}) {
263
+ return this.getLatestReconEvent("recon_design_tokens", filter);
264
+ }
265
+ getReconFonts(filter = {}) {
266
+ return this.getLatestReconEvent("recon_fonts", filter);
267
+ }
268
+ getReconLayoutTree(filter = {}) {
269
+ return this.getLatestReconEvent("recon_layout_tree", filter);
270
+ }
271
+ getReconAccessibility(filter = {}) {
272
+ return this.getLatestReconEvent("recon_accessibility", filter);
273
+ }
274
+ getReconComputedStyles(filter = {}) {
275
+ return this.getReconEvents("recon_computed_styles", filter);
276
+ }
277
+ getReconElementSnapshots(filter = {}) {
278
+ return this.getReconEvents("recon_element_snapshot", filter);
279
+ }
280
+ getReconAssetInventory(filter = {}) {
281
+ return this.getLatestReconEvent("recon_asset_inventory", filter);
282
+ }
283
+ clear() {
284
+ const count = this.buffer.count;
285
+ this.buffer.clear();
286
+ this.sessions.clear();
287
+ return { clearedCount: count };
288
+ }
289
+ };
290
+
291
+ // src/sqlite-store.ts
292
+ import Database from "better-sqlite3";
293
+ var SqliteStore = class {
294
+ db;
295
+ writeBuffer = [];
296
+ flushTimer = null;
297
+ batchSize;
298
+ insertEventStmt;
299
+ insertSessionStmt;
300
+ updateSessionDisconnectedStmt;
301
+ constructor(options) {
302
+ this.db = new Database(options.dbPath);
303
+ this.batchSize = options.batchSize ?? 50;
304
+ if (options.walMode !== false) {
305
+ this.db.pragma("journal_mode = WAL");
306
+ }
307
+ this.db.pragma("synchronous = NORMAL");
308
+ this.createSchema();
309
+ this.insertEventStmt = this.db.prepare(`
310
+ INSERT INTO events (event_id, session_id, project, event_type, timestamp, data)
311
+ VALUES (?, ?, ?, ?, ?, ?)
312
+ `);
313
+ this.insertSessionStmt = this.db.prepare(`
314
+ INSERT OR REPLACE INTO sessions (
315
+ session_id, project, app_name, connected_at, sdk_version,
316
+ event_count, is_connected, build_meta
317
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
318
+ `);
319
+ this.updateSessionDisconnectedStmt = this.db.prepare(`
320
+ UPDATE sessions SET is_connected = 0, disconnected_at = ? WHERE session_id = ?
321
+ `);
322
+ const flushInterval = options.flushIntervalMs ?? 100;
323
+ this.flushTimer = setInterval(() => this.flush(), flushInterval);
324
+ }
325
+ createSchema() {
326
+ this.db.exec(`
327
+ CREATE TABLE IF NOT EXISTS events (
328
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
329
+ event_id TEXT NOT NULL UNIQUE,
330
+ session_id TEXT NOT NULL,
331
+ project TEXT NOT NULL,
332
+ event_type TEXT NOT NULL,
333
+ timestamp INTEGER NOT NULL,
334
+ data TEXT NOT NULL
335
+ );
336
+
337
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
338
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
339
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
340
+ CREATE INDEX IF NOT EXISTS idx_events_type_timestamp ON events(event_type, timestamp);
341
+ CREATE INDEX IF NOT EXISTS idx_events_project ON events(project);
342
+
343
+ CREATE TABLE IF NOT EXISTS sessions (
344
+ session_id TEXT PRIMARY KEY,
345
+ project TEXT NOT NULL,
346
+ app_name TEXT NOT NULL,
347
+ connected_at INTEGER NOT NULL,
348
+ disconnected_at INTEGER,
349
+ sdk_version TEXT NOT NULL,
350
+ event_count INTEGER DEFAULT 0,
351
+ is_connected INTEGER DEFAULT 1,
352
+ build_meta TEXT
353
+ );
354
+
355
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
356
+
357
+ CREATE TABLE IF NOT EXISTS session_metrics (
358
+ session_id TEXT PRIMARY KEY,
359
+ project TEXT NOT NULL,
360
+ metrics TEXT NOT NULL,
361
+ created_at INTEGER NOT NULL,
362
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
363
+ );
364
+ `);
365
+ }
366
+ // --- Write Operations ---
367
+ addEvent(event, project) {
368
+ this.writeBuffer.push({ event, project });
369
+ if (this.writeBuffer.length >= this.batchSize) {
370
+ this.flush();
371
+ }
372
+ }
373
+ flush() {
374
+ if (this.writeBuffer.length === 0) return;
375
+ const batch = this.writeBuffer.splice(0);
376
+ const insertMany = this.db.transaction(() => {
377
+ for (const { event, project } of batch) {
378
+ try {
379
+ this.insertEventStmt.run(
380
+ event.eventId,
381
+ event.sessionId,
382
+ project,
383
+ event.eventType,
384
+ event.timestamp,
385
+ JSON.stringify(event)
386
+ );
387
+ } catch {
388
+ }
389
+ }
390
+ });
391
+ try {
392
+ insertMany();
393
+ } catch (err) {
394
+ console.error("[RuntimeScope] SQLite flush error:", err.message);
395
+ }
396
+ }
397
+ saveSession(info) {
398
+ this.insertSessionStmt.run(
399
+ info.sessionId,
400
+ info.project,
401
+ info.appName,
402
+ info.connectedAt,
403
+ info.sdkVersion,
404
+ info.eventCount,
405
+ info.isConnected ? 1 : 0,
406
+ info.buildMeta ? JSON.stringify(info.buildMeta) : null
407
+ );
408
+ }
409
+ updateSessionDisconnected(sessionId, disconnectedAt) {
410
+ this.updateSessionDisconnectedStmt.run(disconnectedAt, sessionId);
411
+ }
412
+ saveSessionMetrics(sessionId, project, metrics) {
413
+ this.db.prepare(`
414
+ INSERT OR REPLACE INTO session_metrics (session_id, project, metrics, created_at)
415
+ VALUES (?, ?, ?, ?)
416
+ `).run(sessionId, project, JSON.stringify(metrics), Date.now());
417
+ }
418
+ // --- Read Operations ---
419
+ getEvents(filter) {
420
+ const conditions = [];
421
+ const params = [];
422
+ if (filter.project) {
423
+ conditions.push("project = ?");
424
+ params.push(filter.project);
425
+ }
426
+ if (filter.sessionId) {
427
+ conditions.push("session_id = ?");
428
+ params.push(filter.sessionId);
429
+ }
430
+ if (filter.eventTypes && filter.eventTypes.length > 0) {
431
+ const placeholders = filter.eventTypes.map(() => "?").join(", ");
432
+ conditions.push(`event_type IN (${placeholders})`);
433
+ params.push(...filter.eventTypes);
434
+ }
435
+ if (filter.since) {
436
+ conditions.push("timestamp >= ?");
437
+ params.push(filter.since);
438
+ }
439
+ if (filter.until) {
440
+ conditions.push("timestamp <= ?");
441
+ params.push(filter.until);
442
+ }
443
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
444
+ const limit = filter.limit ? `LIMIT ${filter.limit}` : "LIMIT 1000";
445
+ const offset = filter.offset ? `OFFSET ${filter.offset}` : "";
446
+ const rows = this.db.prepare(`SELECT data FROM events ${where} ORDER BY timestamp ASC ${limit} ${offset}`).all(...params);
447
+ return rows.map((row) => JSON.parse(row.data));
448
+ }
449
+ getEventCount(filter) {
450
+ const conditions = [];
451
+ const params = [];
452
+ if (filter.project) {
453
+ conditions.push("project = ?");
454
+ params.push(filter.project);
455
+ }
456
+ if (filter.sessionId) {
457
+ conditions.push("session_id = ?");
458
+ params.push(filter.sessionId);
459
+ }
460
+ if (filter.eventTypes && filter.eventTypes.length > 0) {
461
+ const placeholders = filter.eventTypes.map(() => "?").join(", ");
462
+ conditions.push(`event_type IN (${placeholders})`);
463
+ params.push(...filter.eventTypes);
464
+ }
465
+ if (filter.since) {
466
+ conditions.push("timestamp >= ?");
467
+ params.push(filter.since);
468
+ }
469
+ if (filter.until) {
470
+ conditions.push("timestamp <= ?");
471
+ params.push(filter.until);
472
+ }
473
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
474
+ const row = this.db.prepare(`SELECT COUNT(*) as count FROM events ${where}`).get(...params);
475
+ return row.count;
476
+ }
477
+ getSessions(project, limit = 50) {
478
+ const rows = this.db.prepare(`
479
+ SELECT session_id, project, app_name, connected_at, disconnected_at,
480
+ sdk_version, event_count, is_connected, build_meta
481
+ FROM sessions
482
+ WHERE project = ?
483
+ ORDER BY connected_at DESC
484
+ LIMIT ?
485
+ `).all(project, limit);
486
+ return rows.map((row) => ({
487
+ sessionId: row.session_id,
488
+ project: row.project,
489
+ appName: row.app_name,
490
+ connectedAt: row.connected_at,
491
+ disconnectedAt: row.disconnected_at ?? void 0,
492
+ sdkVersion: row.sdk_version,
493
+ eventCount: row.event_count,
494
+ isConnected: row.is_connected === 1,
495
+ buildMeta: row.build_meta ? JSON.parse(row.build_meta) : void 0
496
+ }));
497
+ }
498
+ getSessionMetrics(sessionId) {
499
+ const row = this.db.prepare("SELECT metrics FROM session_metrics WHERE session_id = ?").get(sessionId);
500
+ return row ? JSON.parse(row.metrics) : null;
501
+ }
502
+ getEventsByType(project, eventType, sinceMs) {
503
+ const conditions = ["project = ?", "event_type = ?"];
504
+ const params = [project, eventType];
505
+ if (sinceMs) {
506
+ conditions.push("timestamp >= ?");
507
+ params.push(sinceMs);
508
+ }
509
+ const where = conditions.join(" AND ");
510
+ const rows = this.db.prepare(`SELECT data FROM events WHERE ${where} ORDER BY timestamp ASC LIMIT 1000`).all(...params);
511
+ return rows.map((row) => JSON.parse(row.data));
512
+ }
513
+ // --- Maintenance ---
514
+ deleteOldEvents(beforeTimestamp) {
515
+ const result = this.db.prepare("DELETE FROM events WHERE timestamp < ?").run(beforeTimestamp);
516
+ return result.changes;
517
+ }
518
+ vacuum() {
519
+ this.db.exec("VACUUM");
520
+ }
521
+ close() {
522
+ if (this.flushTimer) {
523
+ clearInterval(this.flushTimer);
524
+ this.flushTimer = null;
525
+ }
526
+ this.flush();
527
+ this.db.close();
528
+ }
529
+ };
530
+
531
+ // src/rate-limiter.ts
532
+ var SessionRateLimiter = class {
533
+ windows = /* @__PURE__ */ new Map();
534
+ maxPerSecond;
535
+ maxPerMinute;
536
+ _droppedTotal = 0;
537
+ constructor(config = {}) {
538
+ this.maxPerSecond = config.maxEventsPerSecond ?? Infinity;
539
+ this.maxPerMinute = config.maxEventsPerMinute ?? Infinity;
540
+ }
541
+ get droppedTotal() {
542
+ return this._droppedTotal;
543
+ }
544
+ isEnabled() {
545
+ return this.maxPerSecond !== Infinity || this.maxPerMinute !== Infinity;
546
+ }
547
+ /** Returns true if the event should be accepted, false if rate-limited. */
548
+ allow(sessionId) {
549
+ if (!this.isEnabled()) return true;
550
+ const now = Date.now();
551
+ let w = this.windows.get(sessionId);
552
+ if (!w) {
553
+ w = {
554
+ secondCount: 0,
555
+ secondStart: now,
556
+ minuteCount: 0,
557
+ minuteStart: now,
558
+ lastWarning: 0
559
+ };
560
+ this.windows.set(sessionId, w);
561
+ }
562
+ if (now - w.secondStart >= 1e3) {
563
+ w.secondCount = 0;
564
+ w.secondStart = now;
565
+ }
566
+ if (now - w.minuteStart >= 6e4) {
567
+ w.minuteCount = 0;
568
+ w.minuteStart = now;
569
+ }
570
+ if (w.secondCount >= this.maxPerSecond) {
571
+ this._droppedTotal++;
572
+ this.maybeWarn(sessionId, w, now);
573
+ return false;
574
+ }
575
+ if (w.minuteCount >= this.maxPerMinute) {
576
+ this._droppedTotal++;
577
+ this.maybeWarn(sessionId, w, now);
578
+ return false;
579
+ }
580
+ w.secondCount++;
581
+ w.minuteCount++;
582
+ return true;
583
+ }
584
+ /** Allow a batch of N events. Returns the number accepted. */
585
+ allowBatch(sessionId, count) {
586
+ let accepted = 0;
587
+ for (let i = 0; i < count; i++) {
588
+ if (this.allow(sessionId)) accepted++;
589
+ else break;
590
+ }
591
+ return accepted;
592
+ }
593
+ /** Remove tracking for sessions that haven't been seen in maxAgeMs. */
594
+ prune(maxAgeMs = 3e5) {
595
+ const cutoff = Date.now() - maxAgeMs;
596
+ for (const [id, w] of this.windows) {
597
+ if (w.minuteStart < cutoff) {
598
+ this.windows.delete(id);
599
+ }
600
+ }
601
+ }
602
+ maybeWarn(sessionId, w, now) {
603
+ if (now - w.lastWarning >= 6e4) {
604
+ w.lastWarning = now;
605
+ console.error(
606
+ `[RuntimeScope] Rate limiting session ${sessionId.slice(0, 8)}... (dropped ${this._droppedTotal} total)`
607
+ );
608
+ }
609
+ }
610
+ };
611
+
612
+ // src/tls.ts
613
+ import { readFileSync } from "fs";
614
+ function loadTlsOptions(config) {
615
+ return {
616
+ cert: readFileSync(config.certPath, "utf-8"),
617
+ key: readFileSync(config.keyPath, "utf-8"),
618
+ ...config.caPath ? { ca: readFileSync(config.caPath, "utf-8") } : {}
619
+ };
620
+ }
621
+ function resolveTlsConfig() {
622
+ const certPath = process.env.RUNTIMESCOPE_TLS_CERT;
623
+ const keyPath = process.env.RUNTIMESCOPE_TLS_KEY;
624
+ if (!certPath || !keyPath) return null;
625
+ return {
626
+ certPath,
627
+ keyPath,
628
+ caPath: process.env.RUNTIMESCOPE_TLS_CA
629
+ };
630
+ }
631
+
632
+ // src/server.ts
633
+ var CollectorServer = class {
634
+ wss = null;
635
+ store;
636
+ projectManager;
637
+ authManager = null;
638
+ rateLimiter;
639
+ clients = /* @__PURE__ */ new Map();
640
+ pendingHandshakes = /* @__PURE__ */ new Set();
641
+ pendingCommands = /* @__PURE__ */ new Map();
642
+ sqliteStores = /* @__PURE__ */ new Map();
643
+ disconnectCallbacks = [];
644
+ pruneTimer = null;
645
+ tlsConfig = null;
646
+ constructor(options = {}) {
647
+ this.store = new EventStore(options.bufferSize ?? 1e4);
648
+ this.projectManager = options.projectManager ?? null;
649
+ this.authManager = options.authManager ?? null;
650
+ this.rateLimiter = new SessionRateLimiter(options.rateLimits ?? {});
651
+ this.tlsConfig = options.tls ?? null;
652
+ if (this.projectManager) {
653
+ this.projectManager.ensureGlobalDir();
654
+ }
655
+ if (this.rateLimiter.isEnabled()) {
656
+ this.pruneTimer = setInterval(() => this.rateLimiter.prune(), 6e4);
657
+ }
658
+ }
659
+ getStore() {
660
+ return this.store;
661
+ }
662
+ getPort() {
663
+ const addr = this.wss?.address();
664
+ return addr && typeof addr === "object" ? addr.port : null;
665
+ }
666
+ getClientCount() {
667
+ return this.clients.size;
668
+ }
669
+ getProjectManager() {
670
+ return this.projectManager;
671
+ }
672
+ getSqliteStore(projectName) {
673
+ return this.sqliteStores.get(projectName);
674
+ }
675
+ getSqliteStores() {
676
+ return this.sqliteStores;
677
+ }
678
+ getRateLimiter() {
679
+ return this.rateLimiter;
680
+ }
681
+ onDisconnect(cb) {
682
+ this.disconnectCallbacks.push(cb);
683
+ }
684
+ start(options = {}) {
685
+ const port = options.port ?? 9090;
686
+ const host = options.host ?? "127.0.0.1";
687
+ const maxRetries = options.maxRetries ?? 5;
688
+ const retryDelayMs = options.retryDelayMs ?? 1e3;
689
+ const tls = options.tls ?? this.tlsConfig;
690
+ return this.tryStart(port, host, maxRetries, retryDelayMs, tls);
691
+ }
692
+ tryStart(port, host, retriesLeft, retryDelayMs, tls) {
693
+ return new Promise((resolve2, reject) => {
694
+ let wss;
695
+ if (tls) {
696
+ const httpsServer = createHttpsServer(loadTlsOptions(tls));
697
+ wss = new WebSocketServer({ server: httpsServer });
698
+ httpsServer.on("listening", () => {
699
+ this.wss = wss;
700
+ this.setupConnectionHandler(wss);
701
+ console.error(`[RuntimeScope] Collector listening on wss://${host}:${port}`);
702
+ resolve2();
703
+ });
704
+ httpsServer.on("error", (err) => {
705
+ httpsServer.close();
706
+ this.handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject);
707
+ });
708
+ httpsServer.listen(port, host);
709
+ } else {
710
+ wss = new WebSocketServer({ port, host });
711
+ wss.on("listening", () => {
712
+ this.wss = wss;
713
+ this.setupConnectionHandler(wss);
714
+ console.error(`[RuntimeScope] Collector listening on ws://${host}:${port}`);
715
+ resolve2();
716
+ });
717
+ wss.on("error", (err) => {
718
+ wss.close();
719
+ this.handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject);
720
+ });
721
+ }
722
+ });
723
+ }
724
+ handleStartError(err, port, host, retriesLeft, retryDelayMs, tls, resolve2, reject) {
725
+ if (err.code === "EADDRINUSE" && retriesLeft > 0) {
726
+ console.error(
727
+ `[RuntimeScope] Port ${port} in use, retrying in ${retryDelayMs}ms (${retriesLeft} attempts left)...`
728
+ );
729
+ setTimeout(() => {
730
+ this.tryStart(port, host, retriesLeft - 1, retryDelayMs, tls).then(resolve2).catch(reject);
731
+ }, retryDelayMs);
732
+ } else {
733
+ console.error("[RuntimeScope] WebSocket server error:", err.message);
734
+ reject(err);
735
+ }
736
+ }
737
+ ensureSqliteStore(projectName) {
738
+ if (!this.projectManager) return null;
739
+ let sqliteStore = this.sqliteStores.get(projectName);
740
+ if (!sqliteStore) {
741
+ try {
742
+ this.projectManager.ensureProjectDir(projectName);
743
+ const dbPath = this.projectManager.getProjectDbPath(projectName);
744
+ sqliteStore = new SqliteStore({ dbPath });
745
+ this.sqliteStores.set(projectName, sqliteStore);
746
+ this.store.setSqliteStore(sqliteStore, projectName);
747
+ console.error(`[RuntimeScope] SQLite store opened for project "${projectName}"`);
748
+ } catch (err) {
749
+ console.error(
750
+ `[RuntimeScope] Failed to open SQLite for "${projectName}":`,
751
+ err.message
752
+ );
753
+ return null;
754
+ }
755
+ }
756
+ return sqliteStore;
757
+ }
758
+ setupConnectionHandler(wss) {
759
+ wss.on("connection", (ws) => {
760
+ if (this.authManager?.isEnabled()) {
761
+ this.pendingHandshakes.add(ws);
762
+ const authTimeout = setTimeout(() => {
763
+ if (this.pendingHandshakes.has(ws)) {
764
+ this.pendingHandshakes.delete(ws);
765
+ try {
766
+ ws.send(JSON.stringify({
767
+ type: "error",
768
+ payload: { code: "AUTH_TIMEOUT", message: "Handshake timeout" },
769
+ timestamp: Date.now()
770
+ }));
771
+ } catch {
772
+ }
773
+ ws.close(4001, "Authentication timeout");
774
+ }
775
+ }, 5e3);
776
+ ws.on("close", () => {
777
+ clearTimeout(authTimeout);
778
+ this.pendingHandshakes.delete(ws);
779
+ });
780
+ }
781
+ ws.on("message", (data) => {
782
+ try {
783
+ const msg = JSON.parse(data.toString());
784
+ this.handleMessage(ws, msg);
785
+ } catch {
786
+ console.error("[RuntimeScope] Malformed WebSocket message, ignoring");
787
+ }
788
+ });
789
+ ws.on("close", () => {
790
+ const clientInfo = this.clients.get(ws);
791
+ if (clientInfo) {
792
+ this.store.markDisconnected(clientInfo.sessionId);
793
+ const sqliteStore = this.sqliteStores.get(clientInfo.projectName);
794
+ if (sqliteStore) {
795
+ sqliteStore.updateSessionDisconnected(clientInfo.sessionId, Date.now());
796
+ }
797
+ console.error(`[RuntimeScope] Session ${clientInfo.sessionId} disconnected`);
798
+ for (const cb of this.disconnectCallbacks) {
799
+ try {
800
+ cb(clientInfo.sessionId, clientInfo.projectName);
801
+ } catch {
802
+ }
803
+ }
804
+ }
805
+ this.clients.delete(ws);
806
+ });
807
+ ws.on("error", (err) => {
808
+ console.error("[RuntimeScope] WebSocket client error:", err.message);
809
+ });
810
+ });
811
+ }
812
+ handleMessage(ws, msg) {
813
+ switch (msg.type) {
814
+ case "handshake": {
815
+ const payload = msg.payload;
816
+ if (this.authManager?.isEnabled()) {
817
+ if (!this.authManager.isAuthorized(payload.authToken)) {
818
+ try {
819
+ ws.send(JSON.stringify({
820
+ type: "error",
821
+ payload: { code: "AUTH_FAILED", message: "Invalid or missing API key" },
822
+ timestamp: Date.now()
823
+ }));
824
+ } catch {
825
+ }
826
+ ws.close(4001, "Authentication failed");
827
+ return;
828
+ }
829
+ this.pendingHandshakes.delete(ws);
830
+ }
831
+ const projectName = payload.appName;
832
+ this.clients.set(ws, {
833
+ sessionId: payload.sessionId,
834
+ projectName
835
+ });
836
+ const sqliteStore = this.ensureSqliteStore(projectName);
837
+ if (sqliteStore) {
838
+ const sessionInfo = {
839
+ sessionId: payload.sessionId,
840
+ project: projectName,
841
+ appName: payload.appName,
842
+ connectedAt: msg.timestamp,
843
+ sdkVersion: payload.sdkVersion,
844
+ eventCount: 0,
845
+ isConnected: true
846
+ };
847
+ sqliteStore.saveSession(sessionInfo);
848
+ }
849
+ console.error(
850
+ `[RuntimeScope] Session ${payload.sessionId} connected (${payload.appName} v${payload.sdkVersion})`
851
+ );
852
+ break;
853
+ }
854
+ case "event": {
855
+ if (this.pendingHandshakes.has(ws)) return;
856
+ const clientInfo = this.clients.get(ws);
857
+ const payload = msg.payload;
858
+ if (Array.isArray(payload.events)) {
859
+ for (const event of payload.events) {
860
+ if (clientInfo && !this.rateLimiter.allow(clientInfo.sessionId)) {
861
+ break;
862
+ }
863
+ this.store.addEvent(event);
864
+ }
865
+ }
866
+ break;
867
+ }
868
+ case "command_response": {
869
+ const resp = msg;
870
+ const pending = this.pendingCommands.get(resp.requestId);
871
+ if (pending) {
872
+ clearTimeout(pending.timer);
873
+ this.pendingCommands.delete(resp.requestId);
874
+ pending.resolve(resp.payload);
875
+ }
876
+ break;
877
+ }
878
+ case "heartbeat":
879
+ break;
880
+ }
881
+ }
882
+ /** Find the WebSocket for a given sessionId */
883
+ findWsBySessionId(sessionId) {
884
+ for (const [ws, info] of this.clients) {
885
+ if (info.sessionId === sessionId) return ws;
886
+ }
887
+ return void 0;
888
+ }
889
+ /** Get the first connected session ID (for single-app use) */
890
+ getFirstSessionId() {
891
+ for (const [, info] of this.clients) {
892
+ return info.sessionId;
893
+ }
894
+ return void 0;
895
+ }
896
+ /** Get the project name for a session */
897
+ getProjectForSession(sessionId) {
898
+ for (const [, info] of this.clients) {
899
+ if (info.sessionId === sessionId) return info.projectName;
900
+ }
901
+ return void 0;
902
+ }
903
+ /** Send a command to the SDK and await the response */
904
+ sendCommand(sessionId, command, timeoutMs = 1e4) {
905
+ return new Promise((resolve2, reject) => {
906
+ const ws = this.findWsBySessionId(sessionId);
907
+ if (!ws || ws.readyState !== 1) {
908
+ reject(new Error(`No active WebSocket for session ${sessionId}`));
909
+ return;
910
+ }
911
+ const timer = setTimeout(() => {
912
+ this.pendingCommands.delete(command.requestId);
913
+ reject(new Error(`Command ${command.command} timed out after ${timeoutMs}ms`));
914
+ }, timeoutMs);
915
+ this.pendingCommands.set(command.requestId, { resolve: resolve2, reject, timer });
916
+ try {
917
+ ws.send(JSON.stringify({
918
+ type: "command",
919
+ payload: command,
920
+ timestamp: Date.now(),
921
+ sessionId
922
+ }));
923
+ } catch (err) {
924
+ clearTimeout(timer);
925
+ this.pendingCommands.delete(command.requestId);
926
+ reject(err);
927
+ }
928
+ });
929
+ }
930
+ stop() {
931
+ if (this.pruneTimer) {
932
+ clearInterval(this.pruneTimer);
933
+ this.pruneTimer = null;
934
+ }
935
+ for (const [name, sqliteStore] of this.sqliteStores) {
936
+ try {
937
+ sqliteStore.close();
938
+ console.error(`[RuntimeScope] SQLite store closed for "${name}"`);
939
+ } catch {
940
+ }
941
+ }
942
+ this.sqliteStores.clear();
943
+ if (this.wss) {
944
+ this.wss.close();
945
+ this.wss = null;
946
+ console.error("[RuntimeScope] Collector stopped");
947
+ }
948
+ }
949
+ };
950
+
951
+ // src/issue-detector.ts
952
+ function detectIssues(events) {
953
+ const issues = [];
954
+ const networkEvents = events.filter((e) => e.eventType === "network");
955
+ const consoleEvents = events.filter((e) => e.eventType === "console");
956
+ const renderEvents = events.filter((e) => e.eventType === "render");
957
+ const stateEvents = events.filter((e) => e.eventType === "state");
958
+ const perfEvents = events.filter((e) => e.eventType === "performance");
959
+ const dbEvents = events.filter((e) => e.eventType === "database");
960
+ issues.push(...detectFailedRequests(networkEvents));
961
+ issues.push(...detectSlowRequests(networkEvents));
962
+ issues.push(...detectN1Requests(networkEvents));
963
+ issues.push(...detectConsoleErrorSpam(consoleEvents));
964
+ issues.push(...detectHighErrorRate(consoleEvents));
965
+ issues.push(...detectExcessiveRerenders(renderEvents));
966
+ issues.push(...detectLargeStateUpdates(stateEvents));
967
+ issues.push(...detectPoorWebVitals(perfEvents));
968
+ issues.push(...detectSlowDbQueries(dbEvents));
969
+ issues.push(...detectN1DbQueries(dbEvents));
970
+ const order = { high: 0, medium: 1, low: 2 };
971
+ issues.sort((a, b) => order[a.severity] - order[b.severity]);
972
+ return issues;
973
+ }
974
+ function detectFailedRequests(events) {
975
+ const failed = events.filter((e) => e.status >= 400);
976
+ if (failed.length === 0) return [];
977
+ const grouped = /* @__PURE__ */ new Map();
978
+ for (const e of failed) {
979
+ const key = `${e.status} ${e.method} ${e.url}`;
980
+ const arr = grouped.get(key) ?? [];
981
+ arr.push(e);
982
+ grouped.set(key, arr);
983
+ }
984
+ return Array.from(grouped.entries()).map(([key, evts]) => ({
985
+ id: `failed-request-${key}`,
986
+ pattern: "failed_requests",
987
+ severity: evts[0].status >= 500 ? "high" : "medium",
988
+ title: `Failed request: ${key}`,
989
+ description: `${evts.length} request(s) returned ${evts[0].status}`,
990
+ evidence: evts.slice(0, 3).map(
991
+ (e) => `${e.method} ${e.url} \u2192 ${e.status} (${e.duration.toFixed(0)}ms) at ${new Date(e.timestamp).toISOString()}`
992
+ ),
993
+ suggestion: evts[0].status >= 500 ? "Server error \u2014 check backend logs for this endpoint" : "Client error \u2014 verify the request URL, auth headers, and payload"
994
+ }));
995
+ }
996
+ function detectSlowRequests(events) {
997
+ const slow = events.filter((e) => e.duration > 3e3);
998
+ if (slow.length === 0) return [];
999
+ return [{
1000
+ id: "slow-requests",
1001
+ pattern: "slow_requests",
1002
+ severity: "medium",
1003
+ title: `${slow.length} slow network request(s) (>3s)`,
1004
+ description: `Slowest: ${slow.sort((a, b) => b.duration - a.duration)[0].url} at ${(slow[0].duration / 1e3).toFixed(1)}s`,
1005
+ evidence: slow.slice(0, 5).map(
1006
+ (e) => `${e.method} ${e.url} \u2192 ${(e.duration / 1e3).toFixed(1)}s (status ${e.status})`
1007
+ ),
1008
+ suggestion: "Consider adding loading states, pagination, or caching for these endpoints"
1009
+ }];
1010
+ }
1011
+ function detectN1Requests(events) {
1012
+ const issues = [];
1013
+ const grouped = /* @__PURE__ */ new Map();
1014
+ for (const e of events) {
1015
+ const key = `${e.method} ${e.url}`;
1016
+ const arr = grouped.get(key) ?? [];
1017
+ arr.push(e);
1018
+ grouped.set(key, arr);
1019
+ }
1020
+ for (const [key, evts] of grouped) {
1021
+ if (evts.length <= 5) continue;
1022
+ const sorted = evts.sort((a, b) => a.timestamp - b.timestamp);
1023
+ let windowStart = sorted[0].timestamp;
1024
+ let windowCount = 1;
1025
+ for (let i = 1; i < sorted.length; i++) {
1026
+ if (sorted[i].timestamp - windowStart <= 2e3) {
1027
+ windowCount++;
1028
+ } else {
1029
+ windowStart = sorted[i].timestamp;
1030
+ windowCount = 1;
1031
+ }
1032
+ if (windowCount > 5) {
1033
+ issues.push({
1034
+ id: `n1-${key}`,
1035
+ pattern: "n1_requests",
1036
+ severity: "medium",
1037
+ title: `Possible N+1: ${key}`,
1038
+ description: `Called ${evts.length} times total, with ${windowCount}+ in a 2s window. This often happens when each list item fetches its own data.`,
1039
+ evidence: [
1040
+ `Total calls: ${evts.length}`,
1041
+ `Time span: ${((sorted[sorted.length - 1].timestamp - sorted[0].timestamp) / 1e3).toFixed(1)}s`,
1042
+ `First call: ${new Date(sorted[0].timestamp).toISOString()}`
1043
+ ],
1044
+ suggestion: "Lift the data fetch to the parent component, use a batch endpoint, or add a shared cache (e.g. React Query with a shared cache key)"
1045
+ });
1046
+ break;
1047
+ }
1048
+ }
1049
+ }
1050
+ return issues;
1051
+ }
1052
+ function detectConsoleErrorSpam(events) {
1053
+ const issues = [];
1054
+ const errors = events.filter((e) => e.level === "error");
1055
+ const grouped = /* @__PURE__ */ new Map();
1056
+ for (const e of errors) {
1057
+ const key = e.message.slice(0, 200);
1058
+ const arr = grouped.get(key) ?? [];
1059
+ arr.push(e);
1060
+ grouped.set(key, arr);
1061
+ }
1062
+ for (const [msg, evts] of grouped) {
1063
+ if (evts.length <= 5) continue;
1064
+ const sorted = evts.sort((a, b) => a.timestamp - b.timestamp);
1065
+ const span = sorted[sorted.length - 1].timestamp - sorted[0].timestamp;
1066
+ if (span <= 1e4) {
1067
+ const truncated = msg.length > 80 ? msg.slice(0, 80) + "..." : msg;
1068
+ issues.push({
1069
+ id: `error-spam-${msg.slice(0, 50)}`,
1070
+ pattern: "console_error_spam",
1071
+ severity: "medium",
1072
+ title: `Error spam: "${truncated}"`,
1073
+ description: `Repeated ${evts.length} times in ${(span / 1e3).toFixed(1)}s. This usually indicates a re-render loop or a recurring failed operation.`,
1074
+ evidence: [
1075
+ `Count: ${evts.length}`,
1076
+ `Time span: ${(span / 1e3).toFixed(1)}s`,
1077
+ ...evts[0].stackTrace ? [`Stack: ${evts[0].stackTrace.split("\n")[0]}`] : []
1078
+ ],
1079
+ suggestion: "Check for re-render loops, retry loops without backoff, or error boundaries that keep re-mounting"
1080
+ });
1081
+ }
1082
+ }
1083
+ return issues;
1084
+ }
1085
+ function detectHighErrorRate(events) {
1086
+ if (events.length < 10) return [];
1087
+ const errors = events.filter((e) => e.level === "error");
1088
+ const errorRate = errors.length / events.length;
1089
+ if (errorRate > 0.3) {
1090
+ return [{
1091
+ id: "high-error-rate",
1092
+ pattern: "high_error_rate",
1093
+ severity: "high",
1094
+ title: `High console error rate: ${(errorRate * 100).toFixed(0)}%`,
1095
+ description: `${errors.length} of ${events.length} console messages are errors. This suggests a systemic issue.`,
1096
+ evidence: [
1097
+ `Error count: ${errors.length}`,
1098
+ `Total console messages: ${events.length}`,
1099
+ `Error rate: ${(errorRate * 100).toFixed(0)}%`
1100
+ ],
1101
+ suggestion: "Check for unhandled promise rejections, missing error boundaries, or misconfigured API endpoints"
1102
+ }];
1103
+ }
1104
+ return [];
1105
+ }
1106
+ function detectExcessiveRerenders(events) {
1107
+ const issues = [];
1108
+ const seen = /* @__PURE__ */ new Set();
1109
+ for (const event of events) {
1110
+ for (const profile of event.profiles) {
1111
+ if (profile.suspicious && !seen.has(profile.componentName)) {
1112
+ seen.add(profile.componentName);
1113
+ issues.push({
1114
+ id: `excessive-rerenders-${profile.componentName}`,
1115
+ pattern: "excessive_rerenders",
1116
+ severity: "medium",
1117
+ title: `Excessive re-renders: <${profile.componentName}>`,
1118
+ description: `Rendering at ${profile.renderVelocity.toFixed(1)}/sec (${profile.renderCount} renders in snapshot). Last cause: ${profile.lastRenderCause}.`,
1119
+ evidence: [
1120
+ `Render velocity: ${profile.renderVelocity.toFixed(1)}/sec`,
1121
+ `Render count: ${profile.renderCount}`,
1122
+ `Avg duration: ${profile.avgDuration.toFixed(1)}ms`,
1123
+ `Last cause: ${profile.lastRenderCause}`
1124
+ ],
1125
+ suggestion: profile.lastRenderCause === "parent" ? `Wrap <${profile.componentName}> with React.memo() to prevent unnecessary re-renders from parent` : profile.lastRenderCause === "props" ? `Check if props passed to <${profile.componentName}> are stable (useMemo/useCallback for object/function props)` : `Audit state updates in <${profile.componentName}> \u2014 consider batching or debouncing`
1126
+ });
1127
+ }
1128
+ }
1129
+ }
1130
+ return issues;
1131
+ }
1132
+ function detectLargeStateUpdates(events) {
1133
+ const issues = [];
1134
+ const seen = /* @__PURE__ */ new Set();
1135
+ for (const event of events) {
1136
+ if (event.phase !== "update") continue;
1137
+ const stateStr = JSON.stringify(event.state);
1138
+ const sizeKB = Math.round(stateStr.length / 1024);
1139
+ if (sizeKB > 100 && !seen.has(event.storeId)) {
1140
+ seen.add(event.storeId);
1141
+ issues.push({
1142
+ id: `large-state-${event.storeId}`,
1143
+ pattern: "large_state_update",
1144
+ severity: "medium",
1145
+ title: `Large state update: ${event.storeId} (${sizeKB}KB)`,
1146
+ description: `Store "${event.storeId}" (${event.library}) has a state snapshot of ${sizeKB}KB. Large state can cause slow serialization and re-renders.`,
1147
+ evidence: [
1148
+ `Store: ${event.storeId}`,
1149
+ `Library: ${event.library}`,
1150
+ `State size: ${sizeKB}KB`,
1151
+ ...event.diff ? [`Changed keys: ${Object.keys(event.diff).join(", ")}`] : []
1152
+ ],
1153
+ suggestion: "Consider normalizing or splitting the store, and use selectors to subscribe to specific slices"
1154
+ });
1155
+ }
1156
+ }
1157
+ return issues;
1158
+ }
1159
+ function detectSlowDbQueries(events) {
1160
+ const slow = events.filter((e) => e.duration > 500);
1161
+ if (slow.length === 0) return [];
1162
+ const sorted = slow.sort((a, b) => b.duration - a.duration);
1163
+ return [{
1164
+ id: "slow-db-queries",
1165
+ pattern: "slow_db_queries",
1166
+ severity: "medium",
1167
+ title: `${slow.length} slow database quer${slow.length === 1 ? "y" : "ies"} (>500ms)`,
1168
+ description: `Slowest: ${sorted[0].query.slice(0, 100)} at ${sorted[0].duration.toFixed(0)}ms`,
1169
+ evidence: sorted.slice(0, 5).map(
1170
+ (e) => `${e.operation} on ${e.tablesAccessed.join(", ") || "?"} \u2192 ${e.duration.toFixed(0)}ms (${e.source})`
1171
+ ),
1172
+ suggestion: "Add indexes on columns used in WHERE/ORDER BY clauses, or reduce result set size with LIMIT"
1173
+ }];
1174
+ }
1175
+ function detectN1DbQueries(events) {
1176
+ const issues = [];
1177
+ const grouped = /* @__PURE__ */ new Map();
1178
+ for (const e of events) {
1179
+ if (e.operation !== "SELECT") continue;
1180
+ const table = e.tablesAccessed[0];
1181
+ if (!table) continue;
1182
+ const arr = grouped.get(table) ?? [];
1183
+ arr.push(e);
1184
+ grouped.set(table, arr);
1185
+ }
1186
+ for (const [table, evts] of grouped) {
1187
+ if (evts.length <= 5) continue;
1188
+ const sorted = evts.sort((a, b) => a.timestamp - b.timestamp);
1189
+ let windowStart = sorted[0].timestamp;
1190
+ let windowCount = 1;
1191
+ for (let i = 1; i < sorted.length; i++) {
1192
+ if (sorted[i].timestamp - windowStart <= 2e3) {
1193
+ windowCount++;
1194
+ } else {
1195
+ windowStart = sorted[i].timestamp;
1196
+ windowCount = 1;
1197
+ }
1198
+ if (windowCount > 5) {
1199
+ issues.push({
1200
+ id: `n1-db-${table}`,
1201
+ pattern: "n1_db_queries",
1202
+ severity: "high",
1203
+ title: `Possible N+1 DB queries on "${table}"`,
1204
+ description: `${evts.length} SELECT queries on "${table}" total, with ${windowCount}+ in a 2s window. This is a classic N+1 pattern.`,
1205
+ evidence: [
1206
+ `Table: ${table}`,
1207
+ `Total SELECTs: ${evts.length}`,
1208
+ `Peak burst: ${windowCount}+ in 2s`,
1209
+ `Sources: ${[...new Set(evts.map((e) => e.source))].join(", ")}`
1210
+ ],
1211
+ suggestion: "Use a JOIN, subquery, or batch fetch (e.g. WHERE id IN (...)) instead of querying per item"
1212
+ });
1213
+ break;
1214
+ }
1215
+ }
1216
+ }
1217
+ return issues;
1218
+ }
1219
+ function detectPoorWebVitals(events) {
1220
+ const issues = [];
1221
+ const seen = /* @__PURE__ */ new Set();
1222
+ for (const event of events) {
1223
+ if (event.rating !== "poor" || seen.has(event.metricName)) continue;
1224
+ seen.add(event.metricName);
1225
+ const isHighSeverity = event.metricName === "LCP" || event.metricName === "CLS";
1226
+ const suggestions = {
1227
+ LCP: "Optimize largest image/text block \u2014 preload hero images, use next-gen formats, reduce server response time",
1228
+ FCP: "Reduce render-blocking resources \u2014 inline critical CSS, defer non-essential JS",
1229
+ CLS: "Set explicit dimensions on images/videos, avoid injecting content above the fold",
1230
+ TTFB: "Improve server response time \u2014 add CDN, optimize database queries, enable caching",
1231
+ FID: "Break up long tasks \u2014 use requestIdleCallback, code-split heavy modules",
1232
+ INP: "Optimize event handlers \u2014 avoid synchronous layouts, defer non-critical work"
1233
+ };
1234
+ issues.push({
1235
+ id: `poor-vital-${event.metricName}`,
1236
+ pattern: "poor_web_vital",
1237
+ severity: isHighSeverity ? "high" : "medium",
1238
+ title: `Poor ${event.metricName}: ${event.value}${event.metricName === "CLS" ? "" : "ms"}`,
1239
+ description: `${event.metricName} is rated "poor" (value: ${event.value}). This directly impacts user experience and SEO.`,
1240
+ evidence: [
1241
+ `Metric: ${event.metricName}`,
1242
+ `Value: ${event.value}${event.metricName === "CLS" ? "" : "ms"}`,
1243
+ `Rating: ${event.rating}`,
1244
+ ...event.element ? [`Element: ${event.element}`] : []
1245
+ ],
1246
+ suggestion: suggestions[event.metricName] ?? "Review performance best practices at web.dev"
1247
+ });
1248
+ }
1249
+ return issues;
1250
+ }
1251
+
1252
+ // src/project-manager.ts
1253
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync, readdirSync } from "fs";
1254
+ import { join } from "path";
1255
+ import { homedir } from "os";
1256
+ var DEFAULT_GLOBAL_CONFIG = {
1257
+ defaultPort: 9090,
1258
+ bufferSize: 1e4,
1259
+ httpPort: 9091
1260
+ };
1261
+ var ProjectManager = class {
1262
+ baseDir;
1263
+ constructor(baseDir) {
1264
+ this.baseDir = baseDir ?? join(homedir(), ".runtimescope");
1265
+ }
1266
+ get rootDir() {
1267
+ return this.baseDir;
1268
+ }
1269
+ // --- Directory helpers ---
1270
+ getProjectDir(projectName) {
1271
+ return join(this.baseDir, "projects", projectName);
1272
+ }
1273
+ getProjectDbPath(projectName) {
1274
+ return join(this.getProjectDir(projectName), "events.db");
1275
+ }
1276
+ getSessionsDir(projectName) {
1277
+ return join(this.getProjectDir(projectName), "sessions");
1278
+ }
1279
+ getSessionSnapshotPath(projectName, sessionId, timestamp) {
1280
+ const date = new Date(timestamp);
1281
+ const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
1282
+ const shortId = sessionId.slice(0, 8);
1283
+ return join(this.getSessionsDir(projectName), `${dateStr}_${shortId}.db`);
1284
+ }
1285
+ // --- Lifecycle (idempotent) ---
1286
+ ensureGlobalDir() {
1287
+ this.mkdirp(this.baseDir);
1288
+ this.mkdirp(join(this.baseDir, "projects"));
1289
+ const configPath = join(this.baseDir, "config.json");
1290
+ if (!existsSync(configPath)) {
1291
+ this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
1292
+ }
1293
+ }
1294
+ ensureProjectDir(projectName) {
1295
+ const projectDir = this.getProjectDir(projectName);
1296
+ this.mkdirp(projectDir);
1297
+ this.mkdirp(this.getSessionsDir(projectName));
1298
+ const configPath = join(projectDir, "config.json");
1299
+ if (!existsSync(configPath)) {
1300
+ const config = {
1301
+ name: projectName,
1302
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1303
+ settings: {
1304
+ retentionDays: 30
1305
+ }
1306
+ };
1307
+ this.writeJson(configPath, config);
1308
+ }
1309
+ }
1310
+ // --- Config ---
1311
+ getGlobalConfig() {
1312
+ const configPath = join(this.baseDir, "config.json");
1313
+ if (!existsSync(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1314
+ return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
1315
+ }
1316
+ saveGlobalConfig(config) {
1317
+ this.writeJson(join(this.baseDir, "config.json"), config);
1318
+ }
1319
+ getProjectConfig(projectName) {
1320
+ const configPath = join(this.getProjectDir(projectName), "config.json");
1321
+ if (!existsSync(configPath)) return null;
1322
+ return this.readJson(configPath);
1323
+ }
1324
+ saveProjectConfig(projectName, config) {
1325
+ this.writeJson(join(this.getProjectDir(projectName), "config.json"), config);
1326
+ }
1327
+ getInfrastructureConfig(projectName) {
1328
+ const jsonPath = join(this.getProjectDir(projectName), "infrastructure.json");
1329
+ if (existsSync(jsonPath)) {
1330
+ const config = this.readJson(jsonPath);
1331
+ return this.resolveConfigEnvVars(config);
1332
+ }
1333
+ const yamlPath = join(this.getProjectDir(projectName), "infrastructure.yaml");
1334
+ if (existsSync(yamlPath)) {
1335
+ try {
1336
+ const content = readFileSync2(yamlPath, "utf-8");
1337
+ return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
1338
+ } catch {
1339
+ return null;
1340
+ }
1341
+ }
1342
+ return null;
1343
+ }
1344
+ getClaudeInstructions(projectName) {
1345
+ const filePath = join(this.getProjectDir(projectName), "claude-instructions.md");
1346
+ if (!existsSync(filePath)) return null;
1347
+ return readFileSync2(filePath, "utf-8");
1348
+ }
1349
+ // --- Discovery ---
1350
+ listProjects() {
1351
+ const projectsDir = join(this.baseDir, "projects");
1352
+ if (!existsSync(projectsDir)) return [];
1353
+ return readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1354
+ }
1355
+ projectExists(projectName) {
1356
+ return existsSync(this.getProjectDir(projectName));
1357
+ }
1358
+ // --- Environment variable resolution ---
1359
+ resolveEnvVars(value) {
1360
+ return value.replace(/\$\{([^}]+)\}/g, (_match, varName) => {
1361
+ return process.env[varName] ?? "";
1362
+ });
1363
+ }
1364
+ // --- Private helpers ---
1365
+ mkdirp(dir) {
1366
+ if (!existsSync(dir)) {
1367
+ mkdirSync(dir, { recursive: true });
1368
+ }
1369
+ }
1370
+ readJson(path) {
1371
+ const content = readFileSync2(path, "utf-8");
1372
+ return JSON.parse(content);
1373
+ }
1374
+ writeJson(path, data) {
1375
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
1376
+ }
1377
+ resolveConfigEnvVars(config) {
1378
+ const resolve2 = (obj) => {
1379
+ if (typeof obj === "string") return this.resolveEnvVars(obj);
1380
+ if (Array.isArray(obj)) return obj.map(resolve2);
1381
+ if (obj && typeof obj === "object") {
1382
+ const result = {};
1383
+ for (const [key, value] of Object.entries(obj)) {
1384
+ result[key] = resolve2(value);
1385
+ }
1386
+ return result;
1387
+ }
1388
+ return obj;
1389
+ };
1390
+ return resolve2(config);
1391
+ }
1392
+ /**
1393
+ * Minimal YAML parser for simple infrastructure config files.
1394
+ * Handles flat key-value pairs and one level of nesting.
1395
+ * For full YAML support, install js-yaml.
1396
+ */
1397
+ parseSimpleYaml(content) {
1398
+ try {
1399
+ const yaml = __require("js-yaml");
1400
+ return yaml.load(content);
1401
+ } catch {
1402
+ try {
1403
+ return JSON.parse(content);
1404
+ } catch {
1405
+ return {};
1406
+ }
1407
+ }
1408
+ }
1409
+ };
1410
+
1411
+ // src/auth.ts
1412
+ import { randomBytes, timingSafeEqual } from "crypto";
1413
+ var AuthManager = class {
1414
+ keys = /* @__PURE__ */ new Map();
1415
+ enabled;
1416
+ constructor(config = {}) {
1417
+ this.enabled = config.enabled ?? false;
1418
+ for (const entry of config.apiKeys ?? []) {
1419
+ this.keys.set(entry.key, entry);
1420
+ }
1421
+ }
1422
+ isEnabled() {
1423
+ return this.enabled;
1424
+ }
1425
+ /** Validate an API key. Returns the entry if valid, null if invalid. */
1426
+ validate(key) {
1427
+ if (!this.enabled) return null;
1428
+ if (!key) return null;
1429
+ for (const [storedKey, entry] of this.keys) {
1430
+ if (this.safeCompare(key, storedKey)) {
1431
+ return entry;
1432
+ }
1433
+ }
1434
+ return null;
1435
+ }
1436
+ /** Check if request is authorized. Returns true if auth is disabled or key is valid. */
1437
+ isAuthorized(key) {
1438
+ if (!this.enabled) return true;
1439
+ return this.validate(key) !== null;
1440
+ }
1441
+ /** Extract bearer token from Authorization header value. */
1442
+ static extractBearer(header) {
1443
+ if (!header) return void 0;
1444
+ const match = header.match(/^Bearer\s+(\S+)$/i);
1445
+ return match?.[1];
1446
+ }
1447
+ /** Constant-time string comparison to prevent timing attacks. */
1448
+ safeCompare(a, b) {
1449
+ if (a.length !== b.length) return false;
1450
+ try {
1451
+ return timingSafeEqual(Buffer.from(a, "utf-8"), Buffer.from(b, "utf-8"));
1452
+ } catch {
1453
+ return false;
1454
+ }
1455
+ }
1456
+ };
1457
+ function generateApiKey(label, project) {
1458
+ return {
1459
+ key: randomBytes(32).toString("hex"),
1460
+ label,
1461
+ project,
1462
+ createdAt: Date.now()
1463
+ };
1464
+ }
1465
+
1466
+ // src/redactor.ts
1467
+ var BUILT_IN_RULES = [
1468
+ {
1469
+ name: "jwt",
1470
+ pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g,
1471
+ replacement: "[REDACTED:jwt]"
1472
+ },
1473
+ {
1474
+ name: "credit_card",
1475
+ pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
1476
+ replacement: "[REDACTED:cc]"
1477
+ },
1478
+ {
1479
+ name: "ssn",
1480
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
1481
+ replacement: "[REDACTED:ssn]"
1482
+ },
1483
+ {
1484
+ name: "email",
1485
+ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi,
1486
+ replacement: "[REDACTED:email]"
1487
+ },
1488
+ {
1489
+ name: "bearer_token",
1490
+ pattern: /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
1491
+ replacement: "Bearer [REDACTED]"
1492
+ },
1493
+ {
1494
+ name: "api_key_param",
1495
+ pattern: /(?:api[_-]?key|apikey|secret|token|password|passwd|authorization)=[^&\s"']+/gi,
1496
+ replacement: "[REDACTED:param]"
1497
+ }
1498
+ ];
1499
+ var DEFAULT_SENSITIVE_KEYS = [
1500
+ "password",
1501
+ "passwd",
1502
+ "secret",
1503
+ "token",
1504
+ "accessToken",
1505
+ "refreshToken",
1506
+ "apiKey",
1507
+ "api_key",
1508
+ "authorization",
1509
+ "credit_card",
1510
+ "creditCard",
1511
+ "ssn",
1512
+ "socialSecurity"
1513
+ ];
1514
+ var Redactor = class {
1515
+ rules;
1516
+ sensitiveKeyPattern;
1517
+ enabled;
1518
+ constructor(config = {}) {
1519
+ this.enabled = config.enabled ?? true;
1520
+ this.rules = [];
1521
+ if (config.useBuiltIn !== false) {
1522
+ this.rules.push(...BUILT_IN_RULES);
1523
+ }
1524
+ if (config.rules) {
1525
+ this.rules.push(...config.rules);
1526
+ }
1527
+ const keys = config.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
1528
+ this.sensitiveKeyPattern = keys.length > 0 ? new RegExp(`^(${keys.join("|")})$`, "i") : null;
1529
+ }
1530
+ isEnabled() {
1531
+ return this.enabled;
1532
+ }
1533
+ /** Apply all redaction rules to a string value. */
1534
+ redactString(value) {
1535
+ let result = value;
1536
+ for (const rule of this.rules) {
1537
+ rule.pattern.lastIndex = 0;
1538
+ result = result.replace(rule.pattern, rule.replacement);
1539
+ }
1540
+ return result;
1541
+ }
1542
+ /**
1543
+ * Deep-walk an event and redact all string fields.
1544
+ * Returns a new event object (does not mutate the original).
1545
+ */
1546
+ redactEvent(event) {
1547
+ if (!this.enabled) return event;
1548
+ return this.deepRedact(event);
1549
+ }
1550
+ deepRedact(value, key) {
1551
+ if (value === null || value === void 0) return value;
1552
+ if (key && this.sensitiveKeyPattern?.test(key)) {
1553
+ return "[REDACTED]";
1554
+ }
1555
+ if (typeof value === "string") {
1556
+ return this.redactString(value);
1557
+ }
1558
+ if (Array.isArray(value)) {
1559
+ return value.map((item) => this.deepRedact(item));
1560
+ }
1561
+ if (typeof value === "object") {
1562
+ const result = {};
1563
+ for (const [k, v] of Object.entries(value)) {
1564
+ result[k] = this.deepRedact(v, k);
1565
+ }
1566
+ return result;
1567
+ }
1568
+ return value;
1569
+ }
1570
+ };
1571
+
1572
+ // src/engines/api-discovery.ts
1573
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1574
+ var NUMERIC_RE = /^\d+$/;
1575
+ var MONGO_ID_RE = /^[0-9a-f]{24}$/i;
1576
+ var SHORT_HASH_RE = /^[0-9a-f]{8,}$/i;
1577
+ var LONG_ALPHANUM_RE = /^[a-zA-Z0-9_-]{20,}$/;
1578
+ var BASE64_RE = /^[a-zA-Z0-9+/=_-]{16,}$/;
1579
+ function normalizeSegment(segment) {
1580
+ if (UUID_RE.test(segment)) return ":id";
1581
+ if (NUMERIC_RE.test(segment)) return ":id";
1582
+ if (MONGO_ID_RE.test(segment)) return ":id";
1583
+ if (SHORT_HASH_RE.test(segment) && segment.length >= 8) return ":id";
1584
+ if (LONG_ALPHANUM_RE.test(segment)) return ":id";
1585
+ if (BASE64_RE.test(segment) && segment.length >= 16) return ":token";
1586
+ return segment;
1587
+ }
1588
+ function normalizeUrl(url) {
1589
+ try {
1590
+ const parsed = new URL(url);
1591
+ const baseUrl = parsed.origin;
1592
+ const segments = parsed.pathname.split("/").filter(Boolean);
1593
+ const normalized = segments.map(normalizeSegment);
1594
+ return { baseUrl, normalizedPath: "/" + normalized.join("/") };
1595
+ } catch {
1596
+ return { baseUrl: "unknown", normalizedPath: url };
1597
+ }
1598
+ }
1599
+ var SERVICE_PATTERNS = [
1600
+ [/\.supabase\.co$/, "Supabase"],
1601
+ [/\.workers\.dev$/, "Cloudflare Workers"],
1602
+ [/\.vercel\.app$/, "Vercel"],
1603
+ [/api\.stripe\.com$/, "Stripe"],
1604
+ [/\.railway\.app$/, "Railway"],
1605
+ [/\.netlify\.app$/, "Netlify"],
1606
+ [/\.fly\.dev$/, "Fly.io"],
1607
+ [/\.render\.com$/, "Render"],
1608
+ [/api\.github\.com$/, "GitHub API"],
1609
+ [/api\.openai\.com$/, "OpenAI"],
1610
+ [/api\.anthropic\.com$/, "Anthropic"],
1611
+ [/\.clerk\.dev$/, "Clerk"],
1612
+ [/\.auth0\.com$/, "Auth0"],
1613
+ [/\.firebase(io|app)\.com$/, "Firebase"],
1614
+ [/\.amazonaws\.com$/, "AWS"],
1615
+ [/\.googleapis\.com$/, "Google APIs"],
1616
+ [/^(localhost|127\.0\.0\.1)$/, "Your API"]
1617
+ ];
1618
+ function detectService(hostname) {
1619
+ for (const [pattern, name] of SERVICE_PATTERNS) {
1620
+ if (pattern.test(hostname)) return name;
1621
+ }
1622
+ const parts = hostname.split(".");
1623
+ if (parts.length >= 2) {
1624
+ return parts.slice(-2).join(".");
1625
+ }
1626
+ return hostname;
1627
+ }
1628
+ function detectAuth(headers) {
1629
+ const authHeader = headers["authorization"] || headers["Authorization"];
1630
+ if (authHeader) {
1631
+ if (authHeader.startsWith("Bearer ")) return { type: "bearer", headerName: "Authorization" };
1632
+ if (authHeader.startsWith("Basic ")) return { type: "basic", headerName: "Authorization" };
1633
+ return { type: "api_key", headerName: "Authorization" };
1634
+ }
1635
+ for (const key of Object.keys(headers)) {
1636
+ const lower = key.toLowerCase();
1637
+ if (lower.includes("api-key") || lower.includes("apikey") || lower === "x-api-key") {
1638
+ return { type: "api_key", headerName: key };
1639
+ }
1640
+ }
1641
+ if (headers["cookie"] || headers["Cookie"]) {
1642
+ return { type: "cookie" };
1643
+ }
1644
+ return { type: "none" };
1645
+ }
1646
+ function inferFieldType(value) {
1647
+ if (value === null) return "null";
1648
+ if (Array.isArray(value)) return "array";
1649
+ return typeof value;
1650
+ }
1651
+ function walkObject(obj, prefix = "") {
1652
+ const fields = [];
1653
+ if (obj === null || obj === void 0 || typeof obj !== "object") return fields;
1654
+ if (Array.isArray(obj)) {
1655
+ fields.push({ path: prefix || "[]", type: "array", nullable: false, example: `[${obj.length} items]` });
1656
+ if (obj.length > 0 && typeof obj[0] === "object" && obj[0] !== null) {
1657
+ const itemFields = walkObject(obj[0], prefix ? `${prefix}[]` : "[]");
1658
+ fields.push(...itemFields);
1659
+ }
1660
+ return fields;
1661
+ }
1662
+ for (const [key, value] of Object.entries(obj)) {
1663
+ const path = prefix ? `${prefix}.${key}` : key;
1664
+ const type = inferFieldType(value);
1665
+ fields.push({
1666
+ path,
1667
+ type,
1668
+ nullable: value === null,
1669
+ example: typeof value === "string" ? value.slice(0, 50) : value
1670
+ });
1671
+ if (type === "object" && value !== null) {
1672
+ fields.push(...walkObject(value, path));
1673
+ }
1674
+ }
1675
+ return fields;
1676
+ }
1677
+ function inferContract(events) {
1678
+ const samples = events.slice(-10);
1679
+ const responseBodies = samples.filter((e) => e.responseBody).map((e) => {
1680
+ try {
1681
+ return JSON.parse(e.responseBody);
1682
+ } catch {
1683
+ return null;
1684
+ }
1685
+ }).filter(Boolean);
1686
+ if (responseBodies.length === 0) return void 0;
1687
+ const allFields = /* @__PURE__ */ new Map();
1688
+ for (const body of responseBodies) {
1689
+ const fields = walkObject(body);
1690
+ for (const field of fields) {
1691
+ const existing = allFields.get(field.path);
1692
+ if (existing) {
1693
+ existing.types.add(field.type);
1694
+ if (field.nullable) existing.nullable = true;
1695
+ } else {
1696
+ allFields.set(field.path, {
1697
+ types: /* @__PURE__ */ new Set([field.type]),
1698
+ nullable: field.nullable,
1699
+ example: field.example
1700
+ });
1701
+ }
1702
+ }
1703
+ }
1704
+ const responseFields = [];
1705
+ for (const [path, info] of allFields) {
1706
+ responseFields.push({
1707
+ path,
1708
+ type: [...info.types].join(" | "),
1709
+ nullable: info.nullable,
1710
+ example: info.example
1711
+ });
1712
+ }
1713
+ return { responseFields, sampleCount: responseBodies.length };
1714
+ }
1715
+ function percentile(sorted, p) {
1716
+ if (sorted.length === 0) return 0;
1717
+ const idx = Math.ceil(sorted.length * (p / 100)) - 1;
1718
+ return sorted[Math.max(0, idx)];
1719
+ }
1720
+ function endpointKey(method, normalizedPath, baseUrl) {
1721
+ return `${method.toUpperCase()} ${baseUrl}${normalizedPath}`;
1722
+ }
1723
+ var ApiDiscoveryEngine = class {
1724
+ endpoints = /* @__PURE__ */ new Map();
1725
+ store;
1726
+ constructor(store) {
1727
+ this.store = store;
1728
+ }
1729
+ rebuild() {
1730
+ this.endpoints.clear();
1731
+ const events = this.store.getNetworkRequests();
1732
+ for (const event of events) {
1733
+ this.ingestEvent(event);
1734
+ }
1735
+ }
1736
+ ingestEvent(event) {
1737
+ const { baseUrl, normalizedPath } = normalizeUrl(event.url);
1738
+ let hostname;
1739
+ try {
1740
+ hostname = new URL(event.url).hostname;
1741
+ } catch {
1742
+ hostname = "unknown";
1743
+ }
1744
+ const key = endpointKey(event.method, normalizedPath, baseUrl);
1745
+ const existing = this.endpoints.get(key);
1746
+ if (existing) {
1747
+ existing.events.push(event);
1748
+ return;
1749
+ }
1750
+ this.endpoints.set(key, {
1751
+ normalizedPath,
1752
+ method: event.method.toUpperCase(),
1753
+ baseUrl,
1754
+ service: detectService(hostname),
1755
+ events: [event],
1756
+ auth: detectAuth(event.requestHeaders),
1757
+ graphqlOperation: event.graphqlOperation
1758
+ });
1759
+ }
1760
+ // --- Refinement pass: parameterize segments with high cardinality ---
1761
+ refineEndpoints() {
1762
+ const groups = /* @__PURE__ */ new Map();
1763
+ for (const ep of this.endpoints.values()) {
1764
+ const segments = ep.normalizedPath.split("/").filter(Boolean);
1765
+ const groupKey = `${ep.method}|${ep.baseUrl}|${segments.length}`;
1766
+ const group = groups.get(groupKey) ?? [];
1767
+ group.push(ep);
1768
+ groups.set(groupKey, group);
1769
+ }
1770
+ for (const group of groups.values()) {
1771
+ if (group.length <= 5) continue;
1772
+ const segmentSets = [];
1773
+ for (const ep of group) {
1774
+ const segments = ep.normalizedPath.split("/").filter(Boolean);
1775
+ for (let i = 0; i < segments.length; i++) {
1776
+ if (!segmentSets[i]) segmentSets[i] = /* @__PURE__ */ new Map();
1777
+ segmentSets[i].set(segments[i], (segmentSets[i].get(segments[i]) ?? 0) + 1);
1778
+ }
1779
+ }
1780
+ for (let i = 0; i < segmentSets.length; i++) {
1781
+ if (segmentSets[i] && segmentSets[i].size > 5) {
1782
+ for (const ep of group) {
1783
+ const segments = ep.normalizedPath.split("/").filter(Boolean);
1784
+ if (segments[i] && segments[i] !== ":id" && segments[i] !== ":token") {
1785
+ segments[i] = ":id";
1786
+ ep.normalizedPath = "/" + segments.join("/");
1787
+ }
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+ const merged = /* @__PURE__ */ new Map();
1793
+ for (const ep of this.endpoints.values()) {
1794
+ const key = endpointKey(ep.method, ep.normalizedPath, ep.baseUrl);
1795
+ const existing = merged.get(key);
1796
+ if (existing) {
1797
+ existing.events.push(...ep.events);
1798
+ } else {
1799
+ merged.set(key, { ...ep });
1800
+ }
1801
+ }
1802
+ this.endpoints = merged;
1803
+ }
1804
+ // --- Public API ---
1805
+ getCatalog(filter) {
1806
+ this.rebuild();
1807
+ this.refineEndpoints();
1808
+ const results = [];
1809
+ for (const ep of this.endpoints.values()) {
1810
+ if (filter?.service && ep.service !== filter.service) continue;
1811
+ if (filter?.minCalls && ep.events.length < filter.minCalls) continue;
1812
+ const timestamps = ep.events.map((e) => e.timestamp);
1813
+ results.push({
1814
+ normalizedPath: ep.normalizedPath,
1815
+ method: ep.method,
1816
+ baseUrl: ep.baseUrl,
1817
+ service: ep.service,
1818
+ callCount: ep.events.length,
1819
+ firstSeen: Math.min(...timestamps),
1820
+ lastSeen: Math.max(...timestamps),
1821
+ auth: ep.auth,
1822
+ contract: inferContract(ep.events),
1823
+ graphqlOperation: ep.graphqlOperation
1824
+ });
1825
+ }
1826
+ return results.sort((a, b) => b.callCount - a.callCount);
1827
+ }
1828
+ getHealth(filter) {
1829
+ this.rebuild();
1830
+ this.refineEndpoints();
1831
+ const since = filter?.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
1832
+ const results = [];
1833
+ for (const ep of this.endpoints.values()) {
1834
+ if (filter?.endpoint) {
1835
+ const key = `${ep.method} ${ep.normalizedPath}`;
1836
+ if (!key.includes(filter.endpoint)) continue;
1837
+ }
1838
+ const events = ep.events.filter((e) => e.timestamp >= since);
1839
+ if (events.length === 0) continue;
1840
+ const durations = events.map((e) => e.duration).sort((a, b) => a - b);
1841
+ const errorCount = events.filter((e) => e.status >= 400).length;
1842
+ const errorCodes = {};
1843
+ for (const e of events) {
1844
+ if (e.status >= 400) {
1845
+ errorCodes[e.status] = (errorCodes[e.status] ?? 0) + 1;
1846
+ }
1847
+ }
1848
+ results.push({
1849
+ normalizedPath: ep.normalizedPath,
1850
+ method: ep.method,
1851
+ service: ep.service,
1852
+ callCount: events.length,
1853
+ successRate: (events.length - errorCount) / events.length,
1854
+ avgLatency: durations.reduce((s, d) => s + d, 0) / durations.length,
1855
+ p50Latency: percentile(durations, 50),
1856
+ p95Latency: percentile(durations, 95),
1857
+ errorRate: errorCount / events.length,
1858
+ errorCodes
1859
+ });
1860
+ }
1861
+ return results.sort((a, b) => b.callCount - a.callCount);
1862
+ }
1863
+ getDocumentation(filter) {
1864
+ const catalog = this.getCatalog(filter ? { service: filter.service } : void 0);
1865
+ const health = this.getHealth();
1866
+ const healthMap = new Map(health.map((h) => [`${h.method} ${h.normalizedPath}`, h]));
1867
+ const lines = ["# API Documentation (Auto-Generated from Traffic)", ""];
1868
+ const byService = /* @__PURE__ */ new Map();
1869
+ for (const ep of catalog) {
1870
+ const list = byService.get(ep.service) ?? [];
1871
+ list.push(ep);
1872
+ byService.set(ep.service, list);
1873
+ }
1874
+ for (const [service, endpoints] of byService) {
1875
+ lines.push(`## ${service}`, "");
1876
+ for (const ep of endpoints) {
1877
+ const h = healthMap.get(`${ep.method} ${ep.normalizedPath}`);
1878
+ lines.push(`### ${ep.method} ${ep.normalizedPath}`);
1879
+ lines.push(`- Base URL: ${ep.baseUrl}`);
1880
+ lines.push(`- Calls: ${ep.callCount}`);
1881
+ lines.push(`- Auth: ${ep.auth.type}${ep.auth.headerName ? ` (${ep.auth.headerName})` : ""}`);
1882
+ if (h) {
1883
+ lines.push(`- Avg Latency: ${h.avgLatency.toFixed(0)}ms`);
1884
+ lines.push(`- P95 Latency: ${h.p95Latency.toFixed(0)}ms`);
1885
+ lines.push(`- Error Rate: ${(h.errorRate * 100).toFixed(1)}%`);
1886
+ }
1887
+ if (ep.graphqlOperation) {
1888
+ lines.push(`- GraphQL: ${ep.graphqlOperation.type} ${ep.graphqlOperation.name}`);
1889
+ }
1890
+ if (ep.contract) {
1891
+ lines.push("- Response Shape:");
1892
+ for (const field of ep.contract.responseFields.slice(0, 20)) {
1893
+ lines.push(` - \`${field.path}\`: ${field.type}${field.nullable ? " (nullable)" : ""}`);
1894
+ }
1895
+ if (ep.contract.responseFields.length > 20) {
1896
+ lines.push(` - ... and ${ep.contract.responseFields.length - 20} more fields`);
1897
+ }
1898
+ }
1899
+ lines.push("");
1900
+ }
1901
+ }
1902
+ return lines.join("\n");
1903
+ }
1904
+ getServiceMap() {
1905
+ this.rebuild();
1906
+ this.refineEndpoints();
1907
+ const services = /* @__PURE__ */ new Map();
1908
+ for (const ep of this.endpoints.values()) {
1909
+ const existing = services.get(ep.service);
1910
+ if (existing) {
1911
+ existing.endpoints.add(`${ep.method} ${ep.normalizedPath}`);
1912
+ existing.totalCalls += ep.events.length;
1913
+ existing.totalDuration += ep.events.reduce((s, e) => s + e.duration, 0);
1914
+ existing.errorCount += ep.events.filter((e) => e.status >= 400).length;
1915
+ } else {
1916
+ let detectedPlatform;
1917
+ try {
1918
+ const hostname = new URL(ep.baseUrl).hostname;
1919
+ for (const [pattern, name] of SERVICE_PATTERNS) {
1920
+ if (pattern.test(hostname)) {
1921
+ detectedPlatform = name;
1922
+ break;
1923
+ }
1924
+ }
1925
+ } catch {
1926
+ }
1927
+ services.set(ep.service, {
1928
+ baseUrl: ep.baseUrl,
1929
+ endpoints: /* @__PURE__ */ new Set([`${ep.method} ${ep.normalizedPath}`]),
1930
+ totalCalls: ep.events.length,
1931
+ totalDuration: ep.events.reduce((s, e) => s + e.duration, 0),
1932
+ errorCount: ep.events.filter((e) => e.status >= 400).length,
1933
+ auth: ep.auth,
1934
+ platform: detectedPlatform
1935
+ });
1936
+ }
1937
+ }
1938
+ const results = [];
1939
+ for (const [name, data] of services) {
1940
+ results.push({
1941
+ name,
1942
+ baseUrl: data.baseUrl,
1943
+ endpointCount: data.endpoints.size,
1944
+ totalCalls: data.totalCalls,
1945
+ avgLatency: data.totalCalls > 0 ? data.totalDuration / data.totalCalls : 0,
1946
+ errorRate: data.totalCalls > 0 ? data.errorCount / data.totalCalls : 0,
1947
+ auth: data.auth,
1948
+ detectedPlatform: data.platform
1949
+ });
1950
+ }
1951
+ return results.sort((a, b) => b.totalCalls - a.totalCalls);
1952
+ }
1953
+ getApiChanges(sessionA, sessionB) {
1954
+ const allEvents = this.store.getNetworkRequests();
1955
+ const eventsA = allEvents.filter((e) => e.sessionId === sessionA);
1956
+ const eventsB = allEvents.filter((e) => e.sessionId === sessionB);
1957
+ const catalogA = this.buildCatalogFromEvents(eventsA);
1958
+ const catalogB = this.buildCatalogFromEvents(eventsB);
1959
+ const changes = [];
1960
+ const allKeys = /* @__PURE__ */ new Set([...catalogA.keys(), ...catalogB.keys()]);
1961
+ for (const key of allKeys) {
1962
+ const a = catalogA.get(key);
1963
+ const b = catalogB.get(key);
1964
+ if (!a && b) {
1965
+ changes.push({
1966
+ normalizedPath: b.normalizedPath,
1967
+ method: b.method,
1968
+ changeType: "added"
1969
+ });
1970
+ } else if (a && !b) {
1971
+ changes.push({
1972
+ normalizedPath: a.normalizedPath,
1973
+ method: a.method,
1974
+ changeType: "removed"
1975
+ });
1976
+ } else if (a && b) {
1977
+ const contractA = inferContract(a.events);
1978
+ const contractB = inferContract(b.events);
1979
+ if (contractA && contractB) {
1980
+ const fieldsA = new Map(contractA.responseFields.map((f) => [f.path, f.type]));
1981
+ const fieldsB = new Map(contractB.responseFields.map((f) => [f.path, f.type]));
1982
+ const fieldChanges = [];
1983
+ for (const [path, type] of fieldsB) {
1984
+ if (!fieldsA.has(path)) {
1985
+ fieldChanges.push({ path, change: "added", newType: type });
1986
+ } else if (fieldsA.get(path) !== type) {
1987
+ fieldChanges.push({ path, change: "type_changed", oldType: fieldsA.get(path), newType: type });
1988
+ }
1989
+ }
1990
+ for (const [path] of fieldsA) {
1991
+ if (!fieldsB.has(path)) {
1992
+ fieldChanges.push({ path, change: "removed", oldType: fieldsA.get(path) });
1993
+ }
1994
+ }
1995
+ if (fieldChanges.length > 0) {
1996
+ changes.push({
1997
+ normalizedPath: a.normalizedPath,
1998
+ method: a.method,
1999
+ changeType: "modified",
2000
+ fieldChanges
2001
+ });
2002
+ }
2003
+ }
2004
+ }
2005
+ }
2006
+ return changes;
2007
+ }
2008
+ buildCatalogFromEvents(events) {
2009
+ const catalog = /* @__PURE__ */ new Map();
2010
+ for (const event of events) {
2011
+ const { baseUrl, normalizedPath } = normalizeUrl(event.url);
2012
+ let hostname;
2013
+ try {
2014
+ hostname = new URL(event.url).hostname;
2015
+ } catch {
2016
+ hostname = "unknown";
2017
+ }
2018
+ const key = endpointKey(event.method, normalizedPath, baseUrl);
2019
+ const existing = catalog.get(key);
2020
+ if (existing) {
2021
+ existing.events.push(event);
2022
+ } else {
2023
+ catalog.set(key, {
2024
+ normalizedPath,
2025
+ method: event.method.toUpperCase(),
2026
+ baseUrl,
2027
+ service: detectService(hostname),
2028
+ events: [event],
2029
+ auth: detectAuth(event.requestHeaders),
2030
+ graphqlOperation: event.graphqlOperation
2031
+ });
2032
+ }
2033
+ }
2034
+ return catalog;
2035
+ }
2036
+ detectIssues() {
2037
+ const issues = [];
2038
+ const health = this.getHealth();
2039
+ for (const ep of health) {
2040
+ if (ep.errorRate > 0.5 && ep.callCount >= 3) {
2041
+ issues.push({
2042
+ id: `api-degradation-${ep.method}-${ep.normalizedPath}`,
2043
+ pattern: "api_degradation",
2044
+ severity: "medium",
2045
+ title: `API Degradation: ${ep.method} ${ep.normalizedPath}`,
2046
+ description: `Error rate is ${(ep.errorRate * 100).toFixed(0)}% (${Object.entries(ep.errorCodes).map(([k, v]) => `${k}: ${v}`).join(", ")})`,
2047
+ evidence: [`${ep.callCount} calls, ${(ep.errorRate * 100).toFixed(0)}% errors`],
2048
+ suggestion: "Check server logs for this endpoint. Verify the backend is healthy."
2049
+ });
2050
+ }
2051
+ if (ep.p95Latency > 5e3 && ep.callCount >= 3) {
2052
+ issues.push({
2053
+ id: `high-latency-${ep.method}-${ep.normalizedPath}`,
2054
+ pattern: "high_latency_endpoint",
2055
+ severity: "medium",
2056
+ title: `High Latency: ${ep.method} ${ep.normalizedPath}`,
2057
+ description: `P95 latency is ${(ep.p95Latency / 1e3).toFixed(1)}s`,
2058
+ evidence: [`p50: ${ep.p50Latency.toFixed(0)}ms, p95: ${ep.p95Latency.toFixed(0)}ms, avg: ${ep.avgLatency.toFixed(0)}ms`],
2059
+ suggestion: "Consider adding caching, optimizing the query, or paginating the response."
2060
+ });
2061
+ }
2062
+ }
2063
+ const services = this.getServiceMap();
2064
+ const catalog = this.getCatalog();
2065
+ for (const service of services) {
2066
+ const serviceEndpoints = catalog.filter((ep) => ep.service === service.name);
2067
+ const authTypes = new Set(serviceEndpoints.map((ep) => ep.auth.type));
2068
+ if (authTypes.size > 1 && !authTypes.has("none")) {
2069
+ issues.push({
2070
+ id: `auth-inconsistency-${service.name}`,
2071
+ pattern: "auth_inconsistency",
2072
+ severity: "medium",
2073
+ title: `Auth Inconsistency: ${service.name}`,
2074
+ description: `Mixed auth patterns detected: ${[...authTypes].join(", ")}`,
2075
+ evidence: serviceEndpoints.map((ep) => `${ep.method} ${ep.normalizedPath}: ${ep.auth.type}`),
2076
+ suggestion: "Verify that all endpoints for this service use consistent authentication."
2077
+ });
2078
+ }
2079
+ }
2080
+ return issues;
2081
+ }
2082
+ };
2083
+
2084
+ // src/engines/query-monitor.ts
2085
+ function percentile2(sorted, p) {
2086
+ if (sorted.length === 0) return 0;
2087
+ const idx = Math.ceil(sorted.length * (p / 100)) - 1;
2088
+ return sorted[Math.max(0, idx)];
2089
+ }
2090
+ function aggregateQueryStats(events) {
2091
+ const groups = /* @__PURE__ */ new Map();
2092
+ for (const e of events) {
2093
+ const key = e.normalizedQuery;
2094
+ const group = groups.get(key) ?? [];
2095
+ group.push(e);
2096
+ groups.set(key, group);
2097
+ }
2098
+ const stats = [];
2099
+ for (const [normalizedQuery, groupEvents] of groups) {
2100
+ const durations = groupEvents.map((e) => e.duration).sort((a, b) => a - b);
2101
+ const totalDuration = durations.reduce((s, d) => s + d, 0);
2102
+ const tables = /* @__PURE__ */ new Set();
2103
+ for (const e of groupEvents) {
2104
+ for (const t of e.tablesAccessed) tables.add(t);
2105
+ }
2106
+ const rowCounts = groupEvents.filter((e) => e.rowsReturned !== void 0).map((e) => e.rowsReturned);
2107
+ stats.push({
2108
+ normalizedQuery,
2109
+ tables: [...tables],
2110
+ operation: groupEvents[0].operation,
2111
+ callCount: groupEvents.length,
2112
+ avgDuration: totalDuration / groupEvents.length,
2113
+ maxDuration: Math.max(...durations),
2114
+ p95Duration: percentile2(durations, 95),
2115
+ totalDuration,
2116
+ avgRowsReturned: rowCounts.length > 0 ? rowCounts.reduce((s, r) => s + r, 0) / rowCounts.length : 0
2117
+ });
2118
+ }
2119
+ return stats.sort((a, b) => b.totalDuration - a.totalDuration);
2120
+ }
2121
+ function detectN1Queries(events) {
2122
+ const issues = [];
2123
+ const tableWindows = /* @__PURE__ */ new Map();
2124
+ for (const e of events) {
2125
+ if (e.operation !== "SELECT") continue;
2126
+ for (const table of e.tablesAccessed) {
2127
+ const entry = tableWindows.get(table) ?? { timestamps: [], queries: [] };
2128
+ entry.timestamps.push(e.timestamp);
2129
+ entry.queries.push(e.query);
2130
+ tableWindows.set(table, entry);
2131
+ }
2132
+ }
2133
+ for (const [table, data] of tableWindows) {
2134
+ const sorted = data.timestamps.sort((a, b) => a - b);
2135
+ let windowStart = 0;
2136
+ for (let i = 0; i < sorted.length; i++) {
2137
+ while (sorted[i] - sorted[windowStart] > 2e3) windowStart++;
2138
+ const windowSize = i - windowStart + 1;
2139
+ if (windowSize > 5) {
2140
+ issues.push({
2141
+ id: `n1-${table}-${sorted[windowStart]}`,
2142
+ pattern: "n1_db_query",
2143
+ severity: "high",
2144
+ title: `N+1 Query Pattern: ${table}`,
2145
+ description: `${windowSize} SELECT queries on table "${table}" within 2 seconds. This is a classic N+1 query pattern.`,
2146
+ evidence: [
2147
+ `${windowSize} queries in ${((sorted[i] - sorted[windowStart]) / 1e3).toFixed(1)}s`,
2148
+ `Sample: ${data.queries[windowStart]?.slice(0, 100)}`
2149
+ ],
2150
+ suggestion: `Use a JOIN or batch query (e.g., WHERE id IN (...)) instead of querying "${table}" in a loop.`
2151
+ });
2152
+ windowStart = i + 1;
2153
+ }
2154
+ }
2155
+ }
2156
+ return issues;
2157
+ }
2158
+ function detectSlowQueries(events, thresholdMs = 500) {
2159
+ const issues = [];
2160
+ const seen = /* @__PURE__ */ new Set();
2161
+ for (const e of events) {
2162
+ if (e.duration >= thresholdMs && !seen.has(e.normalizedQuery)) {
2163
+ seen.add(e.normalizedQuery);
2164
+ issues.push({
2165
+ id: `slow-query-${e.eventId}`,
2166
+ pattern: "slow_db_query",
2167
+ severity: e.duration > 2e3 ? "high" : "medium",
2168
+ title: `Slow Query: ${e.duration.toFixed(0)}ms`,
2169
+ description: `Query took ${e.duration.toFixed(0)}ms (threshold: ${thresholdMs}ms). Tables: ${e.tablesAccessed.join(", ") || "unknown"}.`,
2170
+ evidence: [
2171
+ `Duration: ${e.duration.toFixed(0)}ms`,
2172
+ `Query: ${e.query.slice(0, 150)}`,
2173
+ e.rowsReturned !== void 0 ? `Rows returned: ${e.rowsReturned}` : ""
2174
+ ].filter(Boolean),
2175
+ suggestion: "Consider adding indexes, reducing the result set, or optimizing the query."
2176
+ });
2177
+ }
2178
+ }
2179
+ return issues;
2180
+ }
2181
+ function suggestIndexes(events) {
2182
+ const suggestions = [];
2183
+ const seen = /* @__PURE__ */ new Set();
2184
+ const WHERE_COL_RE = /WHERE\s+.*?["'`]?(\w+)["'`]?\s*(=|>|<|>=|<=|!=|LIKE|IN|IS)\s/gi;
2185
+ const ORDER_COL_RE = /ORDER\s+BY\s+["'`]?(\w+)["'`]?/gi;
2186
+ for (const e of events) {
2187
+ if (e.duration < 100) continue;
2188
+ for (const table of e.tablesAccessed) {
2189
+ const columns = [];
2190
+ let match;
2191
+ const whereRe = new RegExp(WHERE_COL_RE.source, WHERE_COL_RE.flags);
2192
+ while ((match = whereRe.exec(e.query)) !== null) {
2193
+ columns.push(match[1]);
2194
+ }
2195
+ const orderRe = new RegExp(ORDER_COL_RE.source, ORDER_COL_RE.flags);
2196
+ while ((match = orderRe.exec(e.query)) !== null) {
2197
+ columns.push(match[1]);
2198
+ }
2199
+ if (columns.length === 0) continue;
2200
+ const key = `${table}:${columns.sort().join(",")}`;
2201
+ if (seen.has(key)) continue;
2202
+ seen.add(key);
2203
+ suggestions.push({
2204
+ table,
2205
+ columns,
2206
+ reason: `Query taking ${e.duration.toFixed(0)}ms uses these columns in WHERE/ORDER BY`,
2207
+ estimatedImpact: e.duration > 1e3 ? "high" : e.duration > 300 ? "medium" : "low",
2208
+ queryPattern: e.normalizedQuery.slice(0, 150)
2209
+ });
2210
+ }
2211
+ }
2212
+ return suggestions;
2213
+ }
2214
+ function detectOverfetching(events) {
2215
+ const issues = [];
2216
+ const seen = /* @__PURE__ */ new Set();
2217
+ for (const e of events) {
2218
+ if (e.operation !== "SELECT") continue;
2219
+ if (e.query.match(/SELECT\s+\*/i) && e.rowsReturned !== void 0 && e.rowsReturned > 100) {
2220
+ const key = e.normalizedQuery;
2221
+ if (seen.has(key)) continue;
2222
+ seen.add(key);
2223
+ issues.push({
2224
+ id: `overfetch-${e.eventId}`,
2225
+ pattern: "overfetching",
2226
+ severity: e.rowsReturned > 1e3 ? "high" : "medium",
2227
+ title: `Overfetching: SELECT * returning ${e.rowsReturned} rows`,
2228
+ description: `Query uses SELECT * and returns ${e.rowsReturned} rows. Tables: ${e.tablesAccessed.join(", ")}.`,
2229
+ evidence: [
2230
+ `Query: ${e.query.slice(0, 150)}`,
2231
+ `Rows: ${e.rowsReturned}`
2232
+ ],
2233
+ suggestion: "Select only the columns you need and add LIMIT if appropriate."
2234
+ });
2235
+ }
2236
+ }
2237
+ return issues;
2238
+ }
2239
+
2240
+ // src/db/connections.ts
2241
+ var ConnectionManager = class {
2242
+ connections = /* @__PURE__ */ new Map();
2243
+ async addConnection(config) {
2244
+ const client = await this.createClient(config);
2245
+ this.connections.set(config.id, {
2246
+ config,
2247
+ client,
2248
+ isHealthy: true,
2249
+ lastChecked: Date.now()
2250
+ });
2251
+ }
2252
+ getConnection(id) {
2253
+ return this.connections.get(id);
2254
+ }
2255
+ listConnections() {
2256
+ return Array.from(this.connections.values()).map((c) => ({
2257
+ id: c.config.id,
2258
+ type: c.config.type,
2259
+ label: c.config.label,
2260
+ isHealthy: c.isHealthy
2261
+ }));
2262
+ }
2263
+ async healthCheck(id) {
2264
+ const conn = this.connections.get(id);
2265
+ if (!conn) return false;
2266
+ try {
2267
+ if (conn.config.type === "postgres") {
2268
+ const pg = conn.client;
2269
+ await pg.query("SELECT 1");
2270
+ } else if (conn.config.type === "mysql") {
2271
+ const mysql = conn.client;
2272
+ await mysql.query("SELECT 1");
2273
+ } else if (conn.config.type === "sqlite") {
2274
+ const db = conn.client;
2275
+ db.prepare("SELECT 1").get();
2276
+ }
2277
+ conn.isHealthy = true;
2278
+ } catch {
2279
+ conn.isHealthy = false;
2280
+ }
2281
+ conn.lastChecked = Date.now();
2282
+ return conn.isHealthy;
2283
+ }
2284
+ async closeConnection(id) {
2285
+ const conn = this.connections.get(id);
2286
+ if (!conn) return;
2287
+ try {
2288
+ if (conn.config.type === "postgres") {
2289
+ await conn.client.end();
2290
+ } else if (conn.config.type === "mysql") {
2291
+ await conn.client.end();
2292
+ } else if (conn.config.type === "sqlite") {
2293
+ conn.client.close();
2294
+ }
2295
+ } catch {
2296
+ }
2297
+ this.connections.delete(id);
2298
+ }
2299
+ async closeAll() {
2300
+ const ids = [...this.connections.keys()];
2301
+ for (const id of ids) {
2302
+ await this.closeConnection(id);
2303
+ }
2304
+ }
2305
+ // Dynamic require wrapper to avoid TypeScript module resolution at compile time
2306
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2307
+ dynamicRequire(mod) {
2308
+ try {
2309
+ return __require(mod);
2310
+ } catch {
2311
+ throw new Error(`Module '${mod}' not found. Install it: npm install ${mod}`);
2312
+ }
2313
+ }
2314
+ async createClient(config) {
2315
+ switch (config.type) {
2316
+ case "postgres": {
2317
+ try {
2318
+ const pg = this.dynamicRequire("pg");
2319
+ const pool = new pg.Pool({ connectionString: config.connectionString });
2320
+ await pool.query("SELECT 1");
2321
+ return pool;
2322
+ } catch (err) {
2323
+ throw new Error(`Failed to connect to PostgreSQL: ${err.message}. Ensure 'pg' is installed.`);
2324
+ }
2325
+ }
2326
+ case "mysql": {
2327
+ try {
2328
+ const mysql2 = this.dynamicRequire("mysql2/promise");
2329
+ const pool = mysql2.createPool(config.connectionString);
2330
+ await pool.query("SELECT 1");
2331
+ return pool;
2332
+ } catch (err) {
2333
+ throw new Error(`Failed to connect to MySQL: ${err.message}. Ensure 'mysql2' is installed.`);
2334
+ }
2335
+ }
2336
+ case "sqlite": {
2337
+ try {
2338
+ const BetterSqlite3 = this.dynamicRequire("better-sqlite3");
2339
+ return new BetterSqlite3(config.connectionString);
2340
+ } catch (err) {
2341
+ throw new Error(`Failed to open SQLite: ${err.message}. Ensure 'better-sqlite3' is installed.`);
2342
+ }
2343
+ }
2344
+ default:
2345
+ throw new Error(`Unsupported database type: ${config.type}`);
2346
+ }
2347
+ }
2348
+ };
2349
+
2350
+ // src/db/schema-introspector.ts
2351
+ var SchemaIntrospector = class {
2352
+ async introspect(connection, tableName) {
2353
+ switch (connection.config.type) {
2354
+ case "postgres":
2355
+ return this.introspectPostgres(connection, tableName);
2356
+ case "mysql":
2357
+ return this.introspectMysql(connection, tableName);
2358
+ case "sqlite":
2359
+ return this.introspectSqlite(connection, tableName);
2360
+ default:
2361
+ throw new Error(`Unsupported database type: ${connection.config.type}`);
2362
+ }
2363
+ }
2364
+ async introspectPostgres(conn, tableName) {
2365
+ const pg = conn.client;
2366
+ const tableFilter = tableName ? `AND table_name = $1` : "";
2367
+ const tableParams = tableName ? [tableName] : [];
2368
+ const tablesResult = await pg.query(
2369
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ${tableFilter} ORDER BY table_name`,
2370
+ tableParams
2371
+ );
2372
+ const tables = [];
2373
+ for (const row of tablesResult.rows) {
2374
+ const tName = row.table_name;
2375
+ const colsResult = await pg.query(
2376
+ `SELECT column_name, data_type, is_nullable, column_default
2377
+ FROM information_schema.columns
2378
+ WHERE table_schema = 'public' AND table_name = $1
2379
+ ORDER BY ordinal_position`,
2380
+ [tName]
2381
+ );
2382
+ const pkResult = await pg.query(
2383
+ `SELECT kcu.column_name
2384
+ FROM information_schema.table_constraints tc
2385
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
2386
+ WHERE tc.table_schema = 'public' AND tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'`,
2387
+ [tName]
2388
+ );
2389
+ const pkColumns = new Set(pkResult.rows.map((r) => r.column_name));
2390
+ const columns = colsResult.rows.map((r) => ({
2391
+ name: r.column_name,
2392
+ type: r.data_type,
2393
+ nullable: r.is_nullable === "YES",
2394
+ defaultValue: r.column_default,
2395
+ isPrimaryKey: pkColumns.has(r.column_name)
2396
+ }));
2397
+ const fkResult = await pg.query(
2398
+ `SELECT kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column
2399
+ FROM information_schema.table_constraints tc
2400
+ JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
2401
+ JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
2402
+ WHERE tc.table_schema = 'public' AND tc.table_name = $1 AND tc.constraint_type = 'FOREIGN KEY'`,
2403
+ [tName]
2404
+ );
2405
+ const foreignKeys = fkResult.rows.map((r) => ({
2406
+ column: r.column_name,
2407
+ referencedTable: r.referenced_table,
2408
+ referencedColumn: r.referenced_column
2409
+ }));
2410
+ const idxResult = await pg.query(
2411
+ `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = $1 AND schemaname = 'public'`,
2412
+ [tName]
2413
+ );
2414
+ const indexes = idxResult.rows.map((r) => {
2415
+ const indexDef = r.indexdef;
2416
+ const isUnique = indexDef.includes("UNIQUE");
2417
+ const colMatch = indexDef.match(/\(([^)]+)\)/);
2418
+ const idxCols = colMatch ? colMatch[1].split(",").map((c) => c.trim().replace(/"/g, "")) : [];
2419
+ return {
2420
+ name: r.indexname,
2421
+ columns: idxCols,
2422
+ unique: isUnique
2423
+ };
2424
+ });
2425
+ const countResult = await pg.query(
2426
+ `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`,
2427
+ [tName]
2428
+ );
2429
+ const rowCount = countResult.rows[0]?.estimate;
2430
+ tables.push({ name: tName, columns, foreignKeys, indexes, rowCount: rowCount ?? void 0 });
2431
+ }
2432
+ return { connectionId: conn.config.id, tables, fetchedAt: Date.now() };
2433
+ }
2434
+ async introspectMysql(conn, tableName) {
2435
+ const mysql = conn.client;
2436
+ const tableFilter = tableName ? `AND table_name = ?` : "";
2437
+ const tableParams = tableName ? [tableName] : [];
2438
+ const [tableRows] = await mysql.query(
2439
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() ${tableFilter} ORDER BY table_name`,
2440
+ tableParams
2441
+ );
2442
+ const tables = [];
2443
+ for (const row of tableRows) {
2444
+ const tName = row.table_name;
2445
+ const [colRows] = await mysql.query(
2446
+ `SELECT column_name, data_type, is_nullable, column_default, column_key
2447
+ FROM information_schema.columns
2448
+ WHERE table_schema = DATABASE() AND table_name = ?
2449
+ ORDER BY ordinal_position`,
2450
+ [tName]
2451
+ );
2452
+ const columns = colRows.map((r) => ({
2453
+ name: r.column_name,
2454
+ type: r.data_type,
2455
+ nullable: r.is_nullable === "YES",
2456
+ defaultValue: r.column_default,
2457
+ isPrimaryKey: r.column_key === "PRI"
2458
+ }));
2459
+ const [fkRows] = await mysql.query(
2460
+ `SELECT column_name, referenced_table_name, referenced_column_name
2461
+ FROM information_schema.key_column_usage
2462
+ WHERE table_schema = DATABASE() AND table_name = ? AND referenced_table_name IS NOT NULL`,
2463
+ [tName]
2464
+ );
2465
+ const foreignKeys = fkRows.map((r) => ({
2466
+ column: r.column_name,
2467
+ referencedTable: r.referenced_table_name,
2468
+ referencedColumn: r.referenced_column_name
2469
+ }));
2470
+ const [idxRows] = await mysql.query(`SHOW INDEX FROM \`${tName}\``);
2471
+ const indexMap = /* @__PURE__ */ new Map();
2472
+ for (const r of idxRows) {
2473
+ const name = r.Key_name;
2474
+ const existing = indexMap.get(name) ?? { columns: [], unique: r.Non_unique === 0 };
2475
+ existing.columns.push(r.Column_name);
2476
+ indexMap.set(name, existing);
2477
+ }
2478
+ const indexes = Array.from(indexMap.entries()).map(([name, data]) => ({
2479
+ name,
2480
+ columns: data.columns,
2481
+ unique: data.unique
2482
+ }));
2483
+ tables.push({ name: tName, columns, foreignKeys, indexes });
2484
+ }
2485
+ return { connectionId: conn.config.id, tables, fetchedAt: Date.now() };
2486
+ }
2487
+ async introspectSqlite(conn, tableName) {
2488
+ const db = conn.client;
2489
+ let tableNames;
2490
+ if (tableName) {
2491
+ tableNames = [tableName];
2492
+ } else {
2493
+ const rows = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`).all();
2494
+ tableNames = rows.map((r) => r.name);
2495
+ }
2496
+ const tables = [];
2497
+ for (const tName of tableNames) {
2498
+ const colRows = db.prepare(`PRAGMA table_info("${tName}")`).all();
2499
+ const columns = colRows.map((r) => ({
2500
+ name: r.name,
2501
+ type: r.type,
2502
+ nullable: r.notnull === 0,
2503
+ defaultValue: r.dflt_value,
2504
+ isPrimaryKey: r.pk > 0
2505
+ }));
2506
+ const fkRows = db.prepare(`PRAGMA foreign_key_list("${tName}")`).all();
2507
+ const foreignKeys = fkRows.map((r) => ({
2508
+ column: r.from,
2509
+ referencedTable: r.table,
2510
+ referencedColumn: r.to
2511
+ }));
2512
+ const idxRows = db.prepare(`PRAGMA index_list("${tName}")`).all();
2513
+ const indexes = idxRows.map((r) => {
2514
+ const idxInfoRows = db.prepare(`PRAGMA index_info("${r.name}")`).all();
2515
+ return {
2516
+ name: r.name,
2517
+ columns: idxInfoRows.map((ir) => ir.name),
2518
+ unique: r.unique === 1
2519
+ };
2520
+ });
2521
+ const countRow = db.prepare(`SELECT COUNT(*) as count FROM "${tName}"`).all()[0];
2522
+ const rowCount = countRow?.count;
2523
+ tables.push({ name: tName, columns, foreignKeys, indexes, rowCount });
2524
+ }
2525
+ return { connectionId: conn.config.id, tables, fetchedAt: Date.now() };
2526
+ }
2527
+ };
2528
+
2529
+ // src/db/data-browser.ts
2530
+ var MAX_AFFECTED_ROWS = 100;
2531
+ var DataBrowser = class {
2532
+ async read(connection, options) {
2533
+ const limit = Math.min(options.limit ?? 50, 1e3);
2534
+ const offset = options.offset ?? 0;
2535
+ switch (connection.config.type) {
2536
+ case "postgres":
2537
+ return this.readPostgres(connection, options, limit, offset);
2538
+ case "mysql":
2539
+ return this.readMysql(connection, options, limit, offset);
2540
+ case "sqlite":
2541
+ return this.readSqlite(connection, options, limit, offset);
2542
+ default:
2543
+ throw new Error(`Unsupported database type: ${connection.config.type}`);
2544
+ }
2545
+ }
2546
+ async write(connection, options) {
2547
+ this.assertLocalhost(connection);
2548
+ if ((options.operation === "update" || options.operation === "delete") && !options.where) {
2549
+ return { success: false, affectedRows: 0, error: "WHERE clause is required for UPDATE and DELETE operations" };
2550
+ }
2551
+ switch (connection.config.type) {
2552
+ case "postgres":
2553
+ return this.writePostgres(connection, options);
2554
+ case "mysql":
2555
+ return this.writeMysql(connection, options);
2556
+ case "sqlite":
2557
+ return this.writeSqlite(connection, options);
2558
+ default:
2559
+ throw new Error(`Unsupported database type: ${connection.config.type}`);
2560
+ }
2561
+ }
2562
+ assertLocalhost(connection) {
2563
+ const connStr = connection.config.connectionString ?? "";
2564
+ const isLocal = connStr.includes("localhost") || connStr.includes("127.0.0.1") || connStr.includes("0.0.0.0") || connection.config.type === "sqlite";
2565
+ if (!isLocal) {
2566
+ throw new Error("Write operations are only allowed on localhost/local database connections. This safety guard prevents accidental modification of production databases.");
2567
+ }
2568
+ }
2569
+ // --- PostgreSQL ---
2570
+ async readPostgres(conn, opts, limit, offset) {
2571
+ const pg = conn.client;
2572
+ const where = opts.where ? `WHERE ${opts.where}` : "";
2573
+ const orderBy = opts.orderBy ? `ORDER BY ${opts.orderBy}` : "";
2574
+ const countResult = await pg.query(`SELECT COUNT(*) as total FROM "${opts.table}" ${where}`);
2575
+ const total = parseInt(countResult.rows[0].total, 10);
2576
+ const result = await pg.query(`SELECT * FROM "${opts.table}" ${where} ${orderBy} LIMIT ${limit} OFFSET ${offset}`);
2577
+ return { rows: result.rows, total, limit, offset };
2578
+ }
2579
+ async writePostgres(conn, opts) {
2580
+ const pg = conn.client;
2581
+ try {
2582
+ if (opts.operation !== "insert" && opts.where) {
2583
+ const countResult = await pg.query(`SELECT COUNT(*) as cnt FROM "${opts.table}" WHERE ${opts.where}`);
2584
+ const count = parseInt(countResult.rows[0].cnt, 10);
2585
+ if (count > MAX_AFFECTED_ROWS) {
2586
+ return { success: false, affectedRows: 0, error: `Operation would affect ${count} rows (max ${MAX_AFFECTED_ROWS}). Narrow your WHERE clause.` };
2587
+ }
2588
+ }
2589
+ await pg.query("BEGIN");
2590
+ let result;
2591
+ switch (opts.operation) {
2592
+ case "insert": {
2593
+ const cols = Object.keys(opts.data);
2594
+ const vals = Object.values(opts.data).map((v) => typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v);
2595
+ result = await pg.query(`INSERT INTO "${opts.table}" (${cols.join(", ")}) VALUES (${vals.join(", ")})`);
2596
+ break;
2597
+ }
2598
+ case "update": {
2599
+ const sets = Object.entries(opts.data).map(
2600
+ ([k, v]) => `${k} = ${typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v}`
2601
+ );
2602
+ result = await pg.query(`UPDATE "${opts.table}" SET ${sets.join(", ")} WHERE ${opts.where}`);
2603
+ break;
2604
+ }
2605
+ case "delete":
2606
+ result = await pg.query(`DELETE FROM "${opts.table}" WHERE ${opts.where}`);
2607
+ break;
2608
+ default:
2609
+ throw new Error(`Unknown operation: ${opts.operation}`);
2610
+ }
2611
+ await pg.query("COMMIT");
2612
+ return { success: true, affectedRows: result.rowCount };
2613
+ } catch (err) {
2614
+ await pg.query("ROLLBACK").catch(() => {
2615
+ });
2616
+ return { success: false, affectedRows: 0, error: err.message };
2617
+ }
2618
+ }
2619
+ // --- MySQL ---
2620
+ async readMysql(conn, opts, limit, offset) {
2621
+ const mysql = conn.client;
2622
+ const where = opts.where ? `WHERE ${opts.where}` : "";
2623
+ const orderBy = opts.orderBy ? `ORDER BY ${opts.orderBy}` : "";
2624
+ const [countRows] = await mysql.query(`SELECT COUNT(*) as total FROM \`${opts.table}\` ${where}`);
2625
+ const total = countRows[0].total;
2626
+ const [rows] = await mysql.query(`SELECT * FROM \`${opts.table}\` ${where} ${orderBy} LIMIT ${limit} OFFSET ${offset}`);
2627
+ return { rows, total, limit, offset };
2628
+ }
2629
+ async writeMysql(conn, opts) {
2630
+ const mysql = conn.client;
2631
+ try {
2632
+ if (opts.operation !== "insert" && opts.where) {
2633
+ const [countRows] = await mysql.query(`SELECT COUNT(*) as cnt FROM \`${opts.table}\` WHERE ${opts.where}`);
2634
+ const count = countRows[0].cnt;
2635
+ if (count > MAX_AFFECTED_ROWS) {
2636
+ return { success: false, affectedRows: 0, error: `Operation would affect ${count} rows (max ${MAX_AFFECTED_ROWS}).` };
2637
+ }
2638
+ }
2639
+ await mysql.query("START TRANSACTION");
2640
+ let result;
2641
+ switch (opts.operation) {
2642
+ case "insert": {
2643
+ const cols = Object.keys(opts.data);
2644
+ const vals = Object.values(opts.data).map((v) => typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v);
2645
+ result = await mysql.query(`INSERT INTO \`${opts.table}\` (${cols.join(", ")}) VALUES (${vals.join(", ")})`);
2646
+ break;
2647
+ }
2648
+ case "update": {
2649
+ const sets = Object.entries(opts.data).map(
2650
+ ([k, v]) => `\`${k}\` = ${typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v}`
2651
+ );
2652
+ result = await mysql.query(`UPDATE \`${opts.table}\` SET ${sets.join(", ")} WHERE ${opts.where}`);
2653
+ break;
2654
+ }
2655
+ case "delete":
2656
+ result = await mysql.query(`DELETE FROM \`${opts.table}\` WHERE ${opts.where}`);
2657
+ break;
2658
+ default:
2659
+ throw new Error(`Unknown operation: ${opts.operation}`);
2660
+ }
2661
+ await mysql.query("COMMIT");
2662
+ return { success: true, affectedRows: result[0].affectedRows };
2663
+ } catch (err) {
2664
+ await mysql.query("ROLLBACK").catch(() => {
2665
+ });
2666
+ return { success: false, affectedRows: 0, error: err.message };
2667
+ }
2668
+ }
2669
+ // --- SQLite ---
2670
+ async readSqlite(conn, opts, limit, offset) {
2671
+ const db = conn.client;
2672
+ const where = opts.where ? `WHERE ${opts.where}` : "";
2673
+ const orderBy = opts.orderBy ? `ORDER BY ${opts.orderBy}` : "";
2674
+ const countRow = db.prepare(`SELECT COUNT(*) as total FROM "${opts.table}" ${where}`).get();
2675
+ const total = countRow.total;
2676
+ const rows = db.prepare(`SELECT * FROM "${opts.table}" ${where} ${orderBy} LIMIT ${limit} OFFSET ${offset}`).all();
2677
+ return { rows, total, limit, offset };
2678
+ }
2679
+ async writeSqlite(conn, opts) {
2680
+ const db = conn.client;
2681
+ try {
2682
+ if (opts.operation !== "insert" && opts.where) {
2683
+ const countRow = db.prepare(`SELECT COUNT(*) as cnt FROM "${opts.table}" WHERE ${opts.where}`).get();
2684
+ const count = countRow.cnt;
2685
+ if (count > MAX_AFFECTED_ROWS) {
2686
+ return { success: false, affectedRows: 0, error: `Operation would affect ${count} rows (max ${MAX_AFFECTED_ROWS}).` };
2687
+ }
2688
+ }
2689
+ db.exec("BEGIN");
2690
+ let changes;
2691
+ switch (opts.operation) {
2692
+ case "insert": {
2693
+ const cols = Object.keys(opts.data);
2694
+ const vals = Object.values(opts.data).map((v) => typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v);
2695
+ const result = db.prepare(`INSERT INTO "${opts.table}" (${cols.join(", ")}) VALUES (${vals.join(", ")})`).run();
2696
+ changes = result.changes;
2697
+ break;
2698
+ }
2699
+ case "update": {
2700
+ const sets = Object.entries(opts.data).map(
2701
+ ([k, v]) => `"${k}" = ${typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v}`
2702
+ );
2703
+ const result = db.prepare(`UPDATE "${opts.table}" SET ${sets.join(", ")} WHERE ${opts.where}`).run();
2704
+ changes = result.changes;
2705
+ break;
2706
+ }
2707
+ case "delete": {
2708
+ const result = db.prepare(`DELETE FROM "${opts.table}" WHERE ${opts.where}`).run();
2709
+ changes = result.changes;
2710
+ break;
2711
+ }
2712
+ default:
2713
+ throw new Error(`Unknown operation: ${opts.operation}`);
2714
+ }
2715
+ db.exec("COMMIT");
2716
+ return { success: true, affectedRows: changes };
2717
+ } catch (err) {
2718
+ try {
2719
+ db.exec("ROLLBACK");
2720
+ } catch {
2721
+ }
2722
+ return { success: false, affectedRows: 0, error: err.message };
2723
+ }
2724
+ }
2725
+ };
2726
+
2727
+ // src/engines/process-monitor.ts
2728
+ import { execSync } from "child_process";
2729
+ var PROCESS_PATTERNS = [
2730
+ [/next[\s-]dev|next-server/, "next"],
2731
+ [/vite/, "vite"],
2732
+ [/webpack[\s-]dev[\s-]server|webpack serve/, "webpack"],
2733
+ [/wrangler/, "wrangler"],
2734
+ [/prisma\s+studio|prisma\s+dev/, "prisma"],
2735
+ [/docker/, "docker"],
2736
+ [/postgres|pg_/, "postgres"],
2737
+ [/mysqld/, "mysql"],
2738
+ [/redis-server/, "redis"],
2739
+ [/\bbun\b/, "bun"],
2740
+ [/\bdeno\b/, "deno"],
2741
+ [/\bpython[23]?\b/, "python"],
2742
+ [/\bnode\b/, "node"]
2743
+ ];
2744
+ function detectProcessType(command) {
2745
+ for (const [pattern, type] of PROCESS_PATTERNS) {
2746
+ if (pattern.test(command)) return type;
2747
+ }
2748
+ return "unknown";
2749
+ }
2750
+ function parsePs() {
2751
+ try {
2752
+ const output = execSync("ps aux", { encoding: "utf-8", timeout: 5e3 });
2753
+ const lines = output.split("\n").slice(1);
2754
+ const results = [];
2755
+ for (const line of lines) {
2756
+ const parts = line.trim().split(/\s+/);
2757
+ if (parts.length < 11) continue;
2758
+ const pid = parseInt(parts[1], 10);
2759
+ const cpu = parseFloat(parts[2]);
2760
+ const mem = parseFloat(parts[3]);
2761
+ const command = parts.slice(10).join(" ");
2762
+ if (isNaN(pid)) continue;
2763
+ results.push({ pid, cpu, mem, command });
2764
+ }
2765
+ return results;
2766
+ } catch {
2767
+ return [];
2768
+ }
2769
+ }
2770
+ function getListenPorts(pid) {
2771
+ try {
2772
+ const output = execSync(`lsof -nP -p ${pid} 2>/dev/null | grep LISTEN`, {
2773
+ encoding: "utf-8",
2774
+ timeout: 3e3
2775
+ });
2776
+ const ports = [];
2777
+ for (const line of output.split("\n")) {
2778
+ const match = line.match(/:(\d+)\s+\(LISTEN\)/);
2779
+ if (match) {
2780
+ ports.push(parseInt(match[1], 10));
2781
+ }
2782
+ }
2783
+ return [...new Set(ports)];
2784
+ } catch {
2785
+ return [];
2786
+ }
2787
+ }
2788
+ function getCwd(pid) {
2789
+ try {
2790
+ const output = execSync(`lsof -p ${pid} 2>/dev/null | grep cwd`, {
2791
+ encoding: "utf-8",
2792
+ timeout: 3e3
2793
+ });
2794
+ const match = output.match(/cwd\s+\w+\s+\w+\s+\d+\w?\s+\d+\s+\d+\s+\d+\s+(.+)/);
2795
+ return match?.[1]?.trim();
2796
+ } catch {
2797
+ return void 0;
2798
+ }
2799
+ }
2800
+ function getMemoryMB(pid) {
2801
+ try {
2802
+ const output = execSync(`ps -o rss= -p ${pid}`, { encoding: "utf-8", timeout: 2e3 });
2803
+ const rss = parseInt(output.trim(), 10);
2804
+ return isNaN(rss) ? 0 : rss / 1024;
2805
+ } catch {
2806
+ return 0;
2807
+ }
2808
+ }
2809
+ var ProcessMonitor = class {
2810
+ store;
2811
+ scanInterval = null;
2812
+ processes = /* @__PURE__ */ new Map();
2813
+ lastActivity = /* @__PURE__ */ new Map();
2814
+ constructor(store) {
2815
+ this.store = store;
2816
+ }
2817
+ start(intervalMs = 1e4) {
2818
+ this.scan();
2819
+ this.scanInterval = setInterval(() => this.scan(), intervalMs);
2820
+ }
2821
+ stop() {
2822
+ if (this.scanInterval) {
2823
+ clearInterval(this.scanInterval);
2824
+ this.scanInterval = null;
2825
+ }
2826
+ }
2827
+ scan() {
2828
+ const allProcesses = parsePs();
2829
+ const relevantTypes = /* @__PURE__ */ new Set([
2830
+ "next",
2831
+ "vite",
2832
+ "webpack",
2833
+ "wrangler",
2834
+ "prisma",
2835
+ "docker",
2836
+ "postgres",
2837
+ "mysql",
2838
+ "redis",
2839
+ "bun",
2840
+ "deno"
2841
+ ]);
2842
+ const foundPids = /* @__PURE__ */ new Set();
2843
+ for (const proc of allProcesses) {
2844
+ const type = detectProcessType(proc.command);
2845
+ if (type === "unknown" || type === "python") continue;
2846
+ if (type === "node" && !relevantTypes.has(type)) {
2847
+ if (!proc.command.includes("server") && !proc.command.includes("dev") && !proc.command.includes("start")) {
2848
+ continue;
2849
+ }
2850
+ }
2851
+ foundPids.add(proc.pid);
2852
+ const existing = this.processes.get(proc.pid);
2853
+ const ports = existing?.ports ?? getListenPorts(proc.pid);
2854
+ const cwd = existing?.cwd ?? getCwd(proc.pid);
2855
+ const memoryMB = getMemoryMB(proc.pid);
2856
+ const lastActive = this.lastActivity.get(proc.pid) ?? Date.now();
2857
+ const isOrphaned = Date.now() - lastActive > 30 * 60 * 1e3;
2858
+ this.processes.set(proc.pid, {
2859
+ pid: proc.pid,
2860
+ command: proc.command.slice(0, 200),
2861
+ type,
2862
+ cpuPercent: proc.cpu,
2863
+ memoryMB,
2864
+ ports,
2865
+ cwd,
2866
+ isOrphaned
2867
+ });
2868
+ }
2869
+ for (const pid of this.processes.keys()) {
2870
+ if (!foundPids.has(pid)) {
2871
+ this.processes.delete(pid);
2872
+ this.lastActivity.delete(pid);
2873
+ }
2874
+ }
2875
+ }
2876
+ getProcesses(filter) {
2877
+ const results = Array.from(this.processes.values());
2878
+ return results.filter((p) => {
2879
+ if (filter?.type && p.type !== filter.type) return false;
2880
+ if (filter?.project && p.project !== filter.project) return false;
2881
+ return true;
2882
+ });
2883
+ }
2884
+ killProcess(pid, signal = "SIGTERM") {
2885
+ try {
2886
+ process.kill(pid, signal);
2887
+ this.processes.delete(pid);
2888
+ return { success: true };
2889
+ } catch (err) {
2890
+ return { success: false, error: err.message };
2891
+ }
2892
+ }
2893
+ getPortUsage(port) {
2894
+ const results = [];
2895
+ for (const proc of this.processes.values()) {
2896
+ for (const p of proc.ports) {
2897
+ if (port !== void 0 && p !== port) continue;
2898
+ results.push({
2899
+ port: p,
2900
+ pid: proc.pid,
2901
+ process: proc.command.slice(0, 100),
2902
+ type: proc.type,
2903
+ project: proc.project
2904
+ });
2905
+ }
2906
+ }
2907
+ return results.sort((a, b) => a.port - b.port);
2908
+ }
2909
+ detectIssues() {
2910
+ const issues = [];
2911
+ for (const proc of this.processes.values()) {
2912
+ if (proc.isOrphaned) {
2913
+ issues.push({
2914
+ id: `orphaned-${proc.pid}`,
2915
+ pattern: "orphaned_process",
2916
+ severity: "low",
2917
+ title: `Orphaned Process: ${proc.type} (PID ${proc.pid})`,
2918
+ description: `Dev server process has had no activity for 30+ minutes.`,
2919
+ evidence: [
2920
+ `PID: ${proc.pid}`,
2921
+ `Type: ${proc.type}`,
2922
+ `Memory: ${proc.memoryMB.toFixed(0)}MB`,
2923
+ `Command: ${proc.command.slice(0, 100)}`
2924
+ ],
2925
+ suggestion: `Kill with: kill ${proc.pid}`
2926
+ });
2927
+ }
2928
+ if (proc.memoryMB > 1024) {
2929
+ issues.push({
2930
+ id: `high-memory-${proc.pid}`,
2931
+ pattern: "high_memory_process",
2932
+ severity: "medium",
2933
+ title: `High Memory: ${proc.type} using ${proc.memoryMB.toFixed(0)}MB`,
2934
+ description: `Process is using ${proc.memoryMB.toFixed(0)}MB of memory (>1GB).`,
2935
+ evidence: [
2936
+ `PID: ${proc.pid}`,
2937
+ `Memory: ${proc.memoryMB.toFixed(0)}MB`,
2938
+ `CPU: ${proc.cpuPercent}%`
2939
+ ],
2940
+ suggestion: "Restart the process or investigate memory leaks."
2941
+ });
2942
+ }
2943
+ }
2944
+ return issues;
2945
+ }
2946
+ };
2947
+
2948
+ // src/engines/infra-connector.ts
2949
+ function createVercelClient(projectId, token) {
2950
+ const baseUrl = "https://api.vercel.com";
2951
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
2952
+ return {
2953
+ name: "Vercel",
2954
+ async getDeployments(opts) {
2955
+ const limit = opts?.limit ?? 10;
2956
+ const url = opts?.deployId ? `${baseUrl}/v13/deployments/${opts.deployId}` : `${baseUrl}/v6/deployments?projectId=${projectId}&limit=${limit}`;
2957
+ const res = await fetch(url, { headers });
2958
+ if (!res.ok) return [];
2959
+ const data = await res.json();
2960
+ if (opts?.deployId) {
2961
+ return [mapVercelDeployment(data, projectId)];
2962
+ }
2963
+ const deployments = data.deployments ?? [];
2964
+ return deployments.map((d) => mapVercelDeployment(d, projectId));
2965
+ },
2966
+ async getRuntimeLogs(opts) {
2967
+ void opts;
2968
+ return [];
2969
+ },
2970
+ async getBuildStatus() {
2971
+ const res = await fetch(`${baseUrl}/v6/deployments?projectId=${projectId}&limit=1`, { headers });
2972
+ if (!res.ok) return null;
2973
+ const data = await res.json();
2974
+ const deployments = data.deployments ?? [];
2975
+ if (deployments.length === 0) return null;
2976
+ const latest = deployments[0];
2977
+ return {
2978
+ platform: "Vercel",
2979
+ project: projectId,
2980
+ latestDeployId: latest.uid,
2981
+ status: mapVercelStatus(latest.state),
2982
+ url: latest.url ? `https://${latest.url}` : void 0,
2983
+ lastDeployed: latest.created
2984
+ };
2985
+ }
2986
+ };
2987
+ }
2988
+ function mapVercelDeployment(d, projectId) {
2989
+ return {
2990
+ id: d.uid,
2991
+ platform: "Vercel",
2992
+ project: projectId,
2993
+ status: mapVercelStatus(d.state ?? d.readyState),
2994
+ url: d.url ? `https://${d.url}` : void 0,
2995
+ branch: d.meta?.githubCommitRef,
2996
+ commit: d.meta?.githubCommitSha,
2997
+ createdAt: d.created ?? d.createdAt,
2998
+ readyAt: d.ready,
2999
+ errorMessage: void 0
3000
+ };
3001
+ }
3002
+ function mapVercelStatus(state) {
3003
+ if (state === "READY" || state === "ready") return "ready";
3004
+ if (state === "ERROR" || state === "error") return "error";
3005
+ if (state === "CANCELED" || state === "canceled") return "canceled";
3006
+ return "building";
3007
+ }
3008
+ function createCloudflareClient(accountId, workerName, token) {
3009
+ const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}`;
3010
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
3011
+ return {
3012
+ name: "Cloudflare Workers",
3013
+ async getDeployments(opts) {
3014
+ void opts;
3015
+ const res = await fetch(`${baseUrl}/workers/scripts/${workerName}`, { headers });
3016
+ if (!res.ok) return [];
3017
+ const data = await res.json();
3018
+ const result = data.result;
3019
+ if (!result) return [];
3020
+ return [{
3021
+ id: result.id ?? workerName,
3022
+ platform: "Cloudflare Workers",
3023
+ project: workerName,
3024
+ status: "ready",
3025
+ createdAt: new Date(result.modified_on).getTime()
3026
+ }];
3027
+ },
3028
+ async getRuntimeLogs(opts) {
3029
+ void opts;
3030
+ return [];
3031
+ },
3032
+ async getBuildStatus() {
3033
+ const res = await fetch(`${baseUrl}/workers/scripts/${workerName}`, { headers });
3034
+ if (!res.ok) return null;
3035
+ const data = await res.json();
3036
+ const result = data.result;
3037
+ if (!result) return null;
3038
+ return {
3039
+ platform: "Cloudflare Workers",
3040
+ project: workerName,
3041
+ latestDeployId: result.id ?? workerName,
3042
+ status: "ready",
3043
+ lastDeployed: new Date(result.modified_on).getTime()
3044
+ };
3045
+ }
3046
+ };
3047
+ }
3048
+ function createRailwayClient(projectId, token) {
3049
+ const graphqlUrl = "https://backboard.railway.app/graphql/v2";
3050
+ async function gql(query, variables) {
3051
+ const res = await fetch(graphqlUrl, {
3052
+ method: "POST",
3053
+ headers: {
3054
+ Authorization: `Bearer ${token}`,
3055
+ "Content-Type": "application/json"
3056
+ },
3057
+ body: JSON.stringify({ query, variables })
3058
+ });
3059
+ if (!res.ok) return {};
3060
+ return await res.json();
3061
+ }
3062
+ return {
3063
+ name: "Railway",
3064
+ async getDeployments(opts) {
3065
+ const limit = opts?.limit ?? 10;
3066
+ const data = await gql(`
3067
+ query($projectId: String!, $first: Int) {
3068
+ deployments(input: { projectId: $projectId }, first: $first) {
3069
+ edges {
3070
+ node {
3071
+ id
3072
+ status
3073
+ createdAt
3074
+ url
3075
+ meta { branch commitHash }
3076
+ }
3077
+ }
3078
+ }
3079
+ }
3080
+ `, { projectId, first: limit });
3081
+ const edges = data.data?.deployments?.edges ?? [];
3082
+ return edges.map((edge) => {
3083
+ const d = edge.node;
3084
+ const meta = d.meta;
3085
+ return {
3086
+ id: d.id,
3087
+ platform: "Railway",
3088
+ project: projectId,
3089
+ status: mapRailwayStatus(d.status),
3090
+ url: d.url,
3091
+ branch: meta?.branch,
3092
+ commit: meta?.commitHash,
3093
+ createdAt: new Date(d.createdAt).getTime()
3094
+ };
3095
+ });
3096
+ },
3097
+ async getRuntimeLogs(opts) {
3098
+ void opts;
3099
+ return [];
3100
+ },
3101
+ async getBuildStatus() {
3102
+ const data = await gql(`
3103
+ query($projectId: String!) {
3104
+ deployments(input: { projectId: $projectId }, first: 1) {
3105
+ edges {
3106
+ node { id status createdAt url }
3107
+ }
3108
+ }
3109
+ }
3110
+ `, { projectId });
3111
+ const edges = data.data?.deployments?.edges ?? [];
3112
+ if (edges.length === 0) return null;
3113
+ const d = edges[0].node;
3114
+ return {
3115
+ platform: "Railway",
3116
+ project: projectId,
3117
+ latestDeployId: d.id,
3118
+ status: mapRailwayStatus(d.status),
3119
+ url: d.url,
3120
+ lastDeployed: new Date(d.createdAt).getTime()
3121
+ };
3122
+ }
3123
+ };
3124
+ }
3125
+ function mapRailwayStatus(status) {
3126
+ if (status === "SUCCESS") return "ready";
3127
+ if (status === "FAILED") return "error";
3128
+ if (status === "REMOVED" || status === "CANCELLED") return "canceled";
3129
+ return "building";
3130
+ }
3131
+ var InfraConnector = class {
3132
+ store;
3133
+ clients = /* @__PURE__ */ new Map();
3134
+ constructor(store) {
3135
+ this.store = store;
3136
+ }
3137
+ loadFromConfig(projectManager, projectName) {
3138
+ const config = projectManager.getInfrastructureConfig(projectName);
3139
+ if (!config?.deployments) return;
3140
+ for (const [key, deployment] of Object.entries(config.deployments)) {
3141
+ switch (deployment.platform) {
3142
+ case "vercel": {
3143
+ const token = process.env.VERCEL_TOKEN ?? "";
3144
+ if (token && deployment.project_id) {
3145
+ this.clients.set(key, createVercelClient(deployment.project_id, token));
3146
+ }
3147
+ break;
3148
+ }
3149
+ case "cloudflare": {
3150
+ const token = process.env.CLOUDFLARE_API_TOKEN ?? "";
3151
+ if (token && deployment.account_id && deployment.worker_name) {
3152
+ this.clients.set(key, createCloudflareClient(deployment.account_id, deployment.worker_name, token));
3153
+ }
3154
+ break;
3155
+ }
3156
+ case "railway": {
3157
+ const token = process.env.RAILWAY_TOKEN ?? "";
3158
+ if (token && deployment.project_id) {
3159
+ this.clients.set(key, createRailwayClient(deployment.project_id, token));
3160
+ }
3161
+ break;
3162
+ }
3163
+ }
3164
+ }
3165
+ }
3166
+ async getDeployLogs(project, platform, deployId) {
3167
+ const results = [];
3168
+ for (const [key, client] of this.clients) {
3169
+ if (platform && client.name.toLowerCase() !== platform.toLowerCase()) continue;
3170
+ void key;
3171
+ try {
3172
+ const logs = await client.getDeployments({ deployId, limit: 10 });
3173
+ results.push(...logs);
3174
+ } catch {
3175
+ }
3176
+ }
3177
+ return results.sort((a, b) => b.createdAt - a.createdAt);
3178
+ }
3179
+ async getRuntimeLogs(project, opts) {
3180
+ const results = [];
3181
+ for (const [, client] of this.clients) {
3182
+ if (opts?.platform && client.name.toLowerCase() !== opts.platform.toLowerCase()) continue;
3183
+ try {
3184
+ const logs = await client.getRuntimeLogs(opts);
3185
+ results.push(...logs);
3186
+ } catch {
3187
+ }
3188
+ }
3189
+ return results.sort((a, b) => b.timestamp - a.timestamp);
3190
+ }
3191
+ async getBuildStatus(project) {
3192
+ const results = [];
3193
+ for (const [, client] of this.clients) {
3194
+ try {
3195
+ const status = await client.getBuildStatus();
3196
+ if (status) results.push(status);
3197
+ } catch {
3198
+ }
3199
+ }
3200
+ return results;
3201
+ }
3202
+ getInfraOverview(project) {
3203
+ const networkEvents = this.store.getNetworkRequests();
3204
+ const detectedPlatforms = /* @__PURE__ */ new Set();
3205
+ for (const event of networkEvents) {
3206
+ try {
3207
+ const hostname = new URL(event.url).hostname;
3208
+ if (hostname.includes("vercel")) detectedPlatforms.add("Vercel");
3209
+ if (hostname.includes("cloudflare") || hostname.includes("workers.dev")) detectedPlatforms.add("Cloudflare");
3210
+ if (hostname.includes("railway")) detectedPlatforms.add("Railway");
3211
+ if (hostname.includes("supabase")) detectedPlatforms.add("Supabase");
3212
+ if (hostname.includes("firebase")) detectedPlatforms.add("Firebase");
3213
+ if (hostname.includes("netlify")) detectedPlatforms.add("Netlify");
3214
+ } catch {
3215
+ }
3216
+ }
3217
+ const platforms = Array.from(this.clients.entries()).map(([, client]) => ({
3218
+ name: client.name,
3219
+ configured: true,
3220
+ deployCount: 0,
3221
+ status: "configured"
3222
+ }));
3223
+ return [{
3224
+ project: project ?? "default",
3225
+ platforms,
3226
+ detectedFromTraffic: [...detectedPlatforms]
3227
+ }];
3228
+ }
3229
+ };
3230
+
3231
+ // src/session-manager.ts
3232
+ var SessionManager = class {
3233
+ projectManager;
3234
+ sqliteStores;
3235
+ store;
3236
+ constructor(projectManager, sqliteStores, store) {
3237
+ this.projectManager = projectManager;
3238
+ this.sqliteStores = sqliteStores;
3239
+ this.store = store;
3240
+ }
3241
+ computeMetrics(sessionId, project, events) {
3242
+ const sessionEvents = events.filter((e) => e.sessionId === sessionId);
3243
+ const networkEvents = sessionEvents.filter((e) => e.eventType === "network");
3244
+ const renderEvents = sessionEvents.filter((e) => e.eventType === "render");
3245
+ const stateEvents = sessionEvents.filter((e) => e.eventType === "state");
3246
+ const performanceEvents = sessionEvents.filter((e) => e.eventType === "performance");
3247
+ const databaseEvents = sessionEvents.filter((e) => e.eventType === "database");
3248
+ const consoleEvents = sessionEvents.filter((e) => e.eventType === "console");
3249
+ const endpoints = {};
3250
+ const endpointGroups = /* @__PURE__ */ new Map();
3251
+ for (const e of networkEvents) {
3252
+ const key = `${e.method} ${e.url}`;
3253
+ const group = endpointGroups.get(key) ?? [];
3254
+ group.push(e);
3255
+ endpointGroups.set(key, group);
3256
+ }
3257
+ for (const [key, group] of endpointGroups) {
3258
+ const avgLatency = group.reduce((s, e) => s + e.duration, 0) / group.length;
3259
+ const errorCount2 = group.filter((e) => e.status >= 400).length;
3260
+ endpoints[key] = { avgLatency, errorRate: errorCount2 / group.length, callCount: group.length };
3261
+ }
3262
+ const components = {};
3263
+ for (const re of renderEvents) {
3264
+ for (const p of re.profiles) {
3265
+ const existing = components[p.componentName];
3266
+ if (existing) {
3267
+ existing.renderCount += p.renderCount;
3268
+ existing.avgDuration = (existing.avgDuration + p.avgDuration) / 2;
3269
+ } else {
3270
+ components[p.componentName] = { renderCount: p.renderCount, avgDuration: p.avgDuration };
3271
+ }
3272
+ }
3273
+ }
3274
+ const stores = {};
3275
+ for (const se of stateEvents) {
3276
+ const existing = stores[se.storeId];
3277
+ if (existing) {
3278
+ existing.updateCount++;
3279
+ } else {
3280
+ stores[se.storeId] = { updateCount: 1 };
3281
+ }
3282
+ }
3283
+ const webVitals = {};
3284
+ for (const pe of performanceEvents) {
3285
+ if (pe.rating) {
3286
+ webVitals[pe.metricName] = { value: pe.value, rating: pe.rating };
3287
+ }
3288
+ }
3289
+ const queries = {};
3290
+ const queryGroups = /* @__PURE__ */ new Map();
3291
+ for (const de of databaseEvents) {
3292
+ const group = queryGroups.get(de.normalizedQuery) ?? [];
3293
+ group.push(de);
3294
+ queryGroups.set(de.normalizedQuery, group);
3295
+ }
3296
+ for (const [key, group] of queryGroups) {
3297
+ const avgDuration = group.reduce((s, e) => s + e.duration, 0) / group.length;
3298
+ queries[key] = { avgDuration, callCount: group.length };
3299
+ }
3300
+ const timestamps = sessionEvents.map((e) => e.timestamp);
3301
+ const errorCount = consoleEvents.filter((e) => e.level === "error").length + networkEvents.filter((e) => e.status >= 400).length;
3302
+ return {
3303
+ sessionId,
3304
+ project,
3305
+ connectedAt: timestamps.length > 0 ? Math.min(...timestamps) : Date.now(),
3306
+ disconnectedAt: timestamps.length > 0 ? Math.max(...timestamps) : Date.now(),
3307
+ totalEvents: sessionEvents.length,
3308
+ errorCount,
3309
+ endpoints,
3310
+ components,
3311
+ stores,
3312
+ webVitals,
3313
+ queries
3314
+ };
3315
+ }
3316
+ createSnapshot(sessionId, project) {
3317
+ const events = this.store.getAllEvents();
3318
+ const metrics = this.computeMetrics(sessionId, project, events);
3319
+ const sqliteStore = this.sqliteStores.get(project);
3320
+ if (sqliteStore) {
3321
+ sqliteStore.saveSessionMetrics(sessionId, project, metrics);
3322
+ }
3323
+ return {
3324
+ sessionId,
3325
+ project,
3326
+ metrics,
3327
+ createdAt: Date.now()
3328
+ };
3329
+ }
3330
+ getSessionHistory(project, limit = 20) {
3331
+ const sqliteStore = this.sqliteStores.get(project);
3332
+ if (!sqliteStore) return [];
3333
+ const sessions = sqliteStore.getSessions(project, limit);
3334
+ const snapshots = [];
3335
+ for (const session of sessions) {
3336
+ const metricsData = sqliteStore.getSessionMetrics(session.sessionId);
3337
+ if (metricsData) {
3338
+ snapshots.push({
3339
+ sessionId: session.sessionId,
3340
+ project,
3341
+ metrics: metricsData,
3342
+ buildMeta: session.buildMeta,
3343
+ createdAt: session.disconnectedAt ?? session.connectedAt
3344
+ });
3345
+ }
3346
+ }
3347
+ return snapshots;
3348
+ }
3349
+ };
3350
+
3351
+ // src/session-differ.ts
3352
+ var CHANGE_THRESHOLD = 0.1;
3353
+ function classifyDelta(percentChange) {
3354
+ if (percentChange > CHANGE_THRESHOLD) return "regression";
3355
+ if (percentChange < -CHANGE_THRESHOLD) return "improvement";
3356
+ return "unchanged";
3357
+ }
3358
+ function computeDeltas(before, after, keyPrefix = "") {
3359
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
3360
+ const deltas = [];
3361
+ for (const key of allKeys) {
3362
+ const bVal = before[key] ?? 0;
3363
+ const aVal = after[key] ?? 0;
3364
+ const delta = aVal - bVal;
3365
+ const percentChange = bVal !== 0 ? delta / bVal : aVal !== 0 ? 1 : 0;
3366
+ deltas.push({
3367
+ key: keyPrefix ? `${keyPrefix}:${key}` : key,
3368
+ before: bVal,
3369
+ after: aVal,
3370
+ delta,
3371
+ percentChange,
3372
+ classification: classifyDelta(percentChange)
3373
+ });
3374
+ }
3375
+ return deltas.filter((d) => d.classification !== "unchanged");
3376
+ }
3377
+ function compareSessions(metricsA, metricsB) {
3378
+ const endpointLatencyA = {};
3379
+ const endpointLatencyB = {};
3380
+ for (const [key, data] of Object.entries(metricsA.endpoints)) {
3381
+ endpointLatencyA[key] = data.avgLatency;
3382
+ }
3383
+ for (const [key, data] of Object.entries(metricsB.endpoints)) {
3384
+ endpointLatencyB[key] = data.avgLatency;
3385
+ }
3386
+ const componentRendersA = {};
3387
+ const componentRendersB = {};
3388
+ for (const [key, data] of Object.entries(metricsA.components)) {
3389
+ componentRendersA[key] = data.renderCount;
3390
+ }
3391
+ for (const [key, data] of Object.entries(metricsB.components)) {
3392
+ componentRendersB[key] = data.renderCount;
3393
+ }
3394
+ const storeUpdatesA = {};
3395
+ const storeUpdatesB = {};
3396
+ for (const [key, data] of Object.entries(metricsA.stores)) {
3397
+ storeUpdatesA[key] = data.updateCount;
3398
+ }
3399
+ for (const [key, data] of Object.entries(metricsB.stores)) {
3400
+ storeUpdatesB[key] = data.updateCount;
3401
+ }
3402
+ const vitalsA = {};
3403
+ const vitalsB = {};
3404
+ for (const [key, data] of Object.entries(metricsA.webVitals)) {
3405
+ vitalsA[key] = data.value;
3406
+ }
3407
+ for (const [key, data] of Object.entries(metricsB.webVitals)) {
3408
+ vitalsB[key] = data.value;
3409
+ }
3410
+ const queryDurA = {};
3411
+ const queryDurB = {};
3412
+ for (const [key, data] of Object.entries(metricsA.queries)) {
3413
+ queryDurA[key] = data.avgDuration;
3414
+ }
3415
+ for (const [key, data] of Object.entries(metricsB.queries)) {
3416
+ queryDurB[key] = data.avgDuration;
3417
+ }
3418
+ return {
3419
+ sessionA: metricsA.sessionId,
3420
+ sessionB: metricsB.sessionId,
3421
+ endpointDeltas: computeDeltas(endpointLatencyA, endpointLatencyB, "endpoint"),
3422
+ componentDeltas: computeDeltas(componentRendersA, componentRendersB, "component"),
3423
+ storeDeltas: computeDeltas(storeUpdatesA, storeUpdatesB, "store"),
3424
+ webVitalDeltas: computeDeltas(vitalsA, vitalsB, "vital"),
3425
+ queryDeltas: computeDeltas(queryDurA, queryDurB, "query"),
3426
+ overallDelta: {
3427
+ errorCountDelta: metricsB.errorCount - metricsA.errorCount,
3428
+ totalEventsDelta: metricsB.totalEvents - metricsA.totalEvents
3429
+ }
3430
+ };
3431
+ }
3432
+
3433
+ // src/http-server.ts
3434
+ import { createServer } from "http";
3435
+ import { createServer as createHttpsServer2 } from "https";
3436
+ import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
3437
+ import { resolve, dirname } from "path";
3438
+ import { fileURLToPath } from "url";
3439
+ import { WebSocketServer as WebSocketServer2 } from "ws";
3440
+ var HttpServer = class {
3441
+ server = null;
3442
+ wss = null;
3443
+ store;
3444
+ processMonitor;
3445
+ authManager;
3446
+ allowedOrigins;
3447
+ dashboardClients = /* @__PURE__ */ new Set();
3448
+ eventListener = null;
3449
+ routes = /* @__PURE__ */ new Map();
3450
+ sdkBundlePath = null;
3451
+ activePort = 9091;
3452
+ startedAt = Date.now();
3453
+ constructor(store, processMonitor, options) {
3454
+ this.store = store;
3455
+ this.processMonitor = processMonitor ?? null;
3456
+ this.authManager = options?.authManager ?? null;
3457
+ this.allowedOrigins = options?.allowedOrigins ?? null;
3458
+ this.registerRoutes();
3459
+ }
3460
+ registerRoutes() {
3461
+ this.routes.set("GET /api/health", (_req, res) => {
3462
+ this.json(res, {
3463
+ status: "ok",
3464
+ timestamp: Date.now(),
3465
+ uptime: Math.floor((Date.now() - this.startedAt) / 1e3),
3466
+ sessions: this.store.getSessionInfo().filter((s) => s.isConnected).length,
3467
+ authEnabled: this.authManager?.isEnabled() ?? false
3468
+ });
3469
+ });
3470
+ this.routes.set("GET /api/sessions", (_req, res) => {
3471
+ const sessions = this.store.getSessionInfo();
3472
+ this.json(res, { data: sessions, count: sessions.length });
3473
+ });
3474
+ this.routes.set("GET /api/projects", (_req, res) => {
3475
+ const sessions = this.store.getSessionInfo();
3476
+ const projectMap = /* @__PURE__ */ new Map();
3477
+ for (const s of sessions) {
3478
+ const existing = projectMap.get(s.appName);
3479
+ if (existing) {
3480
+ existing.sessions.push(s.sessionId);
3481
+ existing.eventCount += s.eventCount;
3482
+ if (s.isConnected) existing.isConnected = true;
3483
+ } else {
3484
+ projectMap.set(s.appName, {
3485
+ appName: s.appName,
3486
+ sessions: [s.sessionId],
3487
+ isConnected: s.isConnected,
3488
+ eventCount: s.eventCount
3489
+ });
3490
+ }
3491
+ }
3492
+ const projects = Array.from(projectMap.values());
3493
+ this.json(res, { data: projects, count: projects.length });
3494
+ });
3495
+ this.routes.set("GET /api/processes", (_req, res, params) => {
3496
+ if (!this.processMonitor) {
3497
+ this.json(res, { data: [], count: 0 });
3498
+ return;
3499
+ }
3500
+ const type = params.get("type") ?? void 0;
3501
+ const project = params.get("project") ?? void 0;
3502
+ const processes = this.processMonitor.getProcesses({ type, project });
3503
+ this.json(res, { data: processes, count: processes.length });
3504
+ });
3505
+ this.routes.set("GET /api/ports", (_req, res, params) => {
3506
+ if (!this.processMonitor) {
3507
+ this.json(res, { data: [], count: 0 });
3508
+ return;
3509
+ }
3510
+ const port = numParam(params, "port");
3511
+ const ports = this.processMonitor.getPortUsage(port);
3512
+ this.json(res, { data: ports, count: ports.length });
3513
+ });
3514
+ this.routes.set("GET /api/events/network", (_req, res, params) => {
3515
+ const events = this.store.getNetworkRequests({
3516
+ sinceSeconds: numParam(params, "since_seconds"),
3517
+ urlPattern: params.get("url_pattern") ?? void 0,
3518
+ method: params.get("method") ?? void 0,
3519
+ sessionId: params.get("session_id") ?? void 0
3520
+ });
3521
+ this.json(res, { data: events, count: events.length });
3522
+ });
3523
+ this.routes.set("GET /api/events/console", (_req, res, params) => {
3524
+ const events = this.store.getConsoleMessages({
3525
+ sinceSeconds: numParam(params, "since_seconds"),
3526
+ level: params.get("level") ?? void 0,
3527
+ search: params.get("search") ?? void 0,
3528
+ sessionId: params.get("session_id") ?? void 0
3529
+ });
3530
+ this.json(res, { data: events, count: events.length });
3531
+ });
3532
+ this.routes.set("GET /api/events/state", (_req, res, params) => {
3533
+ const events = this.store.getStateEvents({
3534
+ sinceSeconds: numParam(params, "since_seconds"),
3535
+ storeId: params.get("store_id") ?? void 0,
3536
+ sessionId: params.get("session_id") ?? void 0
3537
+ });
3538
+ this.json(res, { data: events, count: events.length });
3539
+ });
3540
+ this.routes.set("GET /api/events/renders", (_req, res, params) => {
3541
+ const events = this.store.getRenderEvents({
3542
+ sinceSeconds: numParam(params, "since_seconds"),
3543
+ componentName: params.get("component") ?? void 0,
3544
+ sessionId: params.get("session_id") ?? void 0
3545
+ });
3546
+ this.json(res, { data: events, count: events.length });
3547
+ });
3548
+ this.routes.set("GET /api/events/performance", (_req, res, params) => {
3549
+ const events = this.store.getPerformanceMetrics({
3550
+ sinceSeconds: numParam(params, "since_seconds"),
3551
+ metricName: params.get("metric") ?? void 0,
3552
+ sessionId: params.get("session_id") ?? void 0
3553
+ });
3554
+ this.json(res, { data: events, count: events.length });
3555
+ });
3556
+ this.routes.set("GET /api/events/database", (_req, res, params) => {
3557
+ const events = this.store.getDatabaseEvents({
3558
+ sinceSeconds: numParam(params, "since_seconds"),
3559
+ table: params.get("table") ?? void 0,
3560
+ minDurationMs: numParam(params, "min_duration_ms"),
3561
+ search: params.get("search") ?? void 0,
3562
+ sessionId: params.get("session_id") ?? void 0
3563
+ });
3564
+ this.json(res, { data: events, count: events.length });
3565
+ });
3566
+ this.routes.set("GET /api/events/timeline", (_req, res, params) => {
3567
+ const eventTypes = params.get("event_types")?.split(",") ?? void 0;
3568
+ const events = this.store.getEventTimeline({
3569
+ sinceSeconds: numParam(params, "since_seconds"),
3570
+ eventTypes,
3571
+ sessionId: params.get("session_id") ?? void 0
3572
+ });
3573
+ this.json(res, { data: events, count: events.length });
3574
+ });
3575
+ this.routes.set("DELETE /api/events", (_req, res) => {
3576
+ const result = this.store.clear();
3577
+ this.json(res, result);
3578
+ });
3579
+ }
3580
+ /**
3581
+ * Resolve the SDK IIFE bundle path.
3582
+ * Tries multiple locations for monorepo and installed-package scenarios.
3583
+ */
3584
+ resolveSdkPath() {
3585
+ if (this.sdkBundlePath) return this.sdkBundlePath;
3586
+ const __dir = dirname(fileURLToPath(import.meta.url));
3587
+ const candidates = [
3588
+ resolve(__dir, "../../sdk/dist/index.global.js"),
3589
+ // monorepo: packages/collector/dist -> packages/sdk/dist
3590
+ resolve(__dir, "../../../node_modules/@runtimescope/sdk/dist/index.global.js")
3591
+ // npm installed
3592
+ ];
3593
+ for (const p of candidates) {
3594
+ if (existsSync2(p)) {
3595
+ this.sdkBundlePath = p;
3596
+ return p;
3597
+ }
3598
+ }
3599
+ return null;
3600
+ }
3601
+ async start(options = {}) {
3602
+ const basePort = options.port ?? parseInt(process.env.RUNTIMESCOPE_HTTP_PORT ?? "9091", 10);
3603
+ const host = options.host ?? "127.0.0.1";
3604
+ const tls = options.tls;
3605
+ const maxRetries = 5;
3606
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3607
+ const port = basePort + attempt;
3608
+ try {
3609
+ await this.tryStart(port, host, tls);
3610
+ return;
3611
+ } catch (err) {
3612
+ const isAddrInUse = err.code === "EADDRINUSE";
3613
+ if (isAddrInUse && attempt < maxRetries) {
3614
+ console.error(`[RuntimeScope] HTTP port ${port} in use, trying ${port + 1}...`);
3615
+ continue;
3616
+ }
3617
+ throw err;
3618
+ }
3619
+ }
3620
+ }
3621
+ tryStart(port, host, tls) {
3622
+ return new Promise((resolve2, reject) => {
3623
+ const handler = (req, res) => this.handleRequest(req, res);
3624
+ const server = tls ? createHttpsServer2(loadTlsOptions(tls), handler) : createServer(handler);
3625
+ this.wss = new WebSocketServer2({ server, path: "/api/ws/events" });
3626
+ this.wss.on("connection", (ws) => {
3627
+ this.dashboardClients.add(ws);
3628
+ ws.on("close", () => this.dashboardClients.delete(ws));
3629
+ ws.on("error", () => this.dashboardClients.delete(ws));
3630
+ });
3631
+ this.eventListener = (event) => this.broadcastEvent(event);
3632
+ this.store.onEvent(this.eventListener);
3633
+ server.on("listening", () => {
3634
+ this.server = server;
3635
+ this.activePort = port;
3636
+ this.startedAt = Date.now();
3637
+ const proto = tls ? "https" : "http";
3638
+ console.error(`[RuntimeScope] HTTP API listening on ${proto}://${host}:${port}`);
3639
+ resolve2();
3640
+ });
3641
+ server.on("error", (err) => {
3642
+ this.wss?.close();
3643
+ this.wss = null;
3644
+ if (this.eventListener) {
3645
+ this.store.removeEventListener(this.eventListener);
3646
+ this.eventListener = null;
3647
+ }
3648
+ reject(err);
3649
+ });
3650
+ server.listen(port, host);
3651
+ });
3652
+ }
3653
+ async stop() {
3654
+ if (this.eventListener) {
3655
+ this.store.removeEventListener(this.eventListener);
3656
+ this.eventListener = null;
3657
+ }
3658
+ for (const ws of this.dashboardClients) {
3659
+ ws.close();
3660
+ }
3661
+ this.dashboardClients.clear();
3662
+ if (this.wss) {
3663
+ this.wss.close();
3664
+ this.wss = null;
3665
+ }
3666
+ if (this.server) {
3667
+ return new Promise((resolve2) => {
3668
+ this.server.close(() => {
3669
+ this.server = null;
3670
+ console.error("[RuntimeScope] HTTP API stopped");
3671
+ resolve2();
3672
+ });
3673
+ });
3674
+ }
3675
+ }
3676
+ broadcastEvent(event) {
3677
+ if (this.dashboardClients.size === 0) return;
3678
+ const message = JSON.stringify({ type: "event", data: event });
3679
+ for (const ws of this.dashboardClients) {
3680
+ if (ws.readyState === 1) {
3681
+ try {
3682
+ ws.send(message);
3683
+ } catch {
3684
+ this.dashboardClients.delete(ws);
3685
+ }
3686
+ }
3687
+ }
3688
+ }
3689
+ handleRequest(req, res) {
3690
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
3691
+ this.setCorsHeaders(req, res);
3692
+ if (req.method === "OPTIONS") {
3693
+ res.writeHead(204);
3694
+ res.end();
3695
+ return;
3696
+ }
3697
+ const isPublic = url.pathname === "/api/health" || url.pathname === "/runtimescope.js" || url.pathname === "/snippet";
3698
+ if (!isPublic && this.authManager?.isEnabled()) {
3699
+ const token = AuthManager.extractBearer(req.headers.authorization);
3700
+ if (!this.authManager.isAuthorized(token)) {
3701
+ this.json(res, { error: "Unauthorized", code: "AUTH_FAILED" }, 401);
3702
+ return;
3703
+ }
3704
+ }
3705
+ if (req.method === "GET" && url.pathname === "/runtimescope.js") {
3706
+ const sdkPath = this.resolveSdkPath();
3707
+ if (sdkPath) {
3708
+ const bundle = readFileSync3(sdkPath, "utf-8");
3709
+ res.writeHead(200, {
3710
+ "Content-Type": "application/javascript",
3711
+ "Cache-Control": "no-cache"
3712
+ });
3713
+ res.end(bundle);
3714
+ } else {
3715
+ res.writeHead(404, { "Content-Type": "text/plain" });
3716
+ res.end("SDK bundle not found. Run: npm run build -w packages/sdk");
3717
+ }
3718
+ return;
3719
+ }
3720
+ if (req.method === "GET" && url.pathname === "/snippet") {
3721
+ const appName = url.searchParams.get("app") || "my-app";
3722
+ const wsPort = process.env.RUNTIMESCOPE_PORT ?? "9090";
3723
+ const snippet = `<!-- RuntimeScope SDK \u2014 paste before </body> -->
3724
+ <script src="http://localhost:${this.activePort}/runtimescope.js"></script>
3725
+ <script>
3726
+ RuntimeScope.init({
3727
+ appName: '${appName}',
3728
+ endpoint: 'ws://localhost:${wsPort}',
3729
+ });
3730
+ </script>`;
3731
+ res.writeHead(200, {
3732
+ "Content-Type": "text/plain"
3733
+ });
3734
+ res.end(snippet);
3735
+ return;
3736
+ }
3737
+ const routeKey = `${req.method} ${url.pathname}`;
3738
+ const handler = this.routes.get(routeKey);
3739
+ if (handler) {
3740
+ try {
3741
+ const result = handler(req, res, url.searchParams);
3742
+ if (result instanceof Promise) {
3743
+ result.catch((err) => {
3744
+ this.json(res, { error: err.message }, 500);
3745
+ });
3746
+ }
3747
+ } catch (err) {
3748
+ this.json(res, { error: err.message }, 500);
3749
+ }
3750
+ } else {
3751
+ this.json(res, { error: "Not found", path: url.pathname }, 404);
3752
+ }
3753
+ }
3754
+ setCorsHeaders(req, res) {
3755
+ const origin = req.headers.origin;
3756
+ if (this.allowedOrigins && this.allowedOrigins.length > 0) {
3757
+ if (origin && this.allowedOrigins.includes(origin)) {
3758
+ res.setHeader("Access-Control-Allow-Origin", origin);
3759
+ res.setHeader("Vary", "Origin");
3760
+ }
3761
+ } else {
3762
+ res.setHeader("Access-Control-Allow-Origin", "*");
3763
+ }
3764
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
3765
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
3766
+ }
3767
+ json(res, data, status = 200) {
3768
+ res.writeHead(status, { "Content-Type": "application/json" });
3769
+ res.end(JSON.stringify(data));
3770
+ }
3771
+ };
3772
+ function numParam(params, key) {
3773
+ const val = params.get(key);
3774
+ if (!val) return void 0;
3775
+ const num = parseInt(val, 10);
3776
+ return isNaN(num) ? void 0 : num;
3777
+ }
3778
+ export {
3779
+ ApiDiscoveryEngine,
3780
+ AuthManager,
3781
+ BUILT_IN_RULES,
3782
+ CollectorServer,
3783
+ ConnectionManager,
3784
+ DataBrowser,
3785
+ EventStore,
3786
+ HttpServer,
3787
+ InfraConnector,
3788
+ ProcessMonitor,
3789
+ ProjectManager,
3790
+ Redactor,
3791
+ RingBuffer,
3792
+ SchemaIntrospector,
3793
+ SessionManager,
3794
+ SessionRateLimiter,
3795
+ SqliteStore,
3796
+ aggregateQueryStats,
3797
+ compareSessions,
3798
+ detectIssues,
3799
+ detectN1Queries,
3800
+ detectOverfetching,
3801
+ detectSlowQueries,
3802
+ generateApiKey,
3803
+ loadTlsOptions,
3804
+ resolveTlsConfig,
3805
+ suggestIndexes
3806
+ };
3807
+ //# sourceMappingURL=index.js.map