@runtimescope/collector 0.7.1 → 0.8.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.
@@ -233,6 +233,17 @@ var EventStore = class {
233
233
  return true;
234
234
  });
235
235
  }
236
+ getUIInteractions(filter = {}) {
237
+ const since = filter.sinceSeconds ? Date.now() - filter.sinceSeconds * 1e3 : 0;
238
+ return this.buffer.query((e) => {
239
+ if (e.eventType !== "ui") return false;
240
+ if (filter.sessionId && e.sessionId !== filter.sessionId) return false;
241
+ const ue = e;
242
+ if (ue.timestamp < since) return false;
243
+ if (filter.action && ue.action !== filter.action) return false;
244
+ return true;
245
+ });
246
+ }
236
247
  // ============================================================
237
248
  // Recon event queries — returns the most recent event of each type
238
249
  // ============================================================
@@ -296,41 +307,94 @@ var EventStore = class {
296
307
  };
297
308
 
298
309
  // src/sqlite-store.ts
299
- import Database from "better-sqlite3";
300
- var SqliteStore = class {
310
+ import { renameSync, existsSync } from "fs";
311
+ import { createRequire } from "module";
312
+ var DatabaseConstructor;
313
+ function getDatabase() {
314
+ if (!DatabaseConstructor) {
315
+ const require2 = createRequire(import.meta.url);
316
+ DatabaseConstructor = require2("better-sqlite3");
317
+ }
318
+ return DatabaseConstructor;
319
+ }
320
+ var SqliteStore = class _SqliteStore {
301
321
  db;
302
322
  writeBuffer = [];
303
323
  flushTimer = null;
304
324
  batchSize;
325
+ dbPath;
326
+ static MAX_SNAPSHOTS_PER_SESSION = 50;
305
327
  insertEventStmt;
306
328
  insertSessionStmt;
307
329
  updateSessionDisconnectedStmt;
308
330
  constructor(options) {
309
- this.db = new Database(options.dbPath);
331
+ this.dbPath = options.dbPath;
310
332
  this.batchSize = options.batchSize ?? 50;
311
- if (options.walMode !== false) {
312
- this.db.pragma("journal_mode = WAL");
333
+ this.db = this.openDatabase(options);
334
+ const flushInterval = options.flushIntervalMs ?? 100;
335
+ this.flushTimer = setInterval(() => this.flush(), flushInterval);
336
+ }
337
+ openDatabase(options) {
338
+ const Db = getDatabase();
339
+ try {
340
+ const db = new Db(options.dbPath);
341
+ if (options.walMode !== false) {
342
+ db.pragma("journal_mode = WAL");
343
+ }
344
+ db.pragma("synchronous = NORMAL");
345
+ const check = db.pragma("integrity_check");
346
+ if (check[0]?.integrity_check !== "ok") {
347
+ throw new Error("Integrity check failed");
348
+ }
349
+ this.createSchema(db);
350
+ this.prepareStatements(db);
351
+ return db;
352
+ } catch (err) {
353
+ console.error(
354
+ `[RuntimeScope] SQLite database corrupt or unreadable (${err.message}), recreating...`
355
+ );
356
+ try {
357
+ if (existsSync(options.dbPath)) {
358
+ const backupPath = `${options.dbPath}.corrupt.${Date.now()}`;
359
+ renameSync(options.dbPath, backupPath);
360
+ console.error(`[RuntimeScope] Renamed corrupt DB to ${backupPath}`);
361
+ }
362
+ for (const suffix of ["-wal", "-shm"]) {
363
+ const p = options.dbPath + suffix;
364
+ if (existsSync(p)) {
365
+ renameSync(p, `${p}.corrupt.${Date.now()}`);
366
+ }
367
+ }
368
+ } catch {
369
+ }
370
+ const db = new Db(options.dbPath);
371
+ if (options.walMode !== false) {
372
+ db.pragma("journal_mode = WAL");
373
+ }
374
+ db.pragma("synchronous = NORMAL");
375
+ this.createSchema(db);
376
+ this.prepareStatements(db);
377
+ return db;
313
378
  }
314
- this.db.pragma("synchronous = NORMAL");
315
- this.createSchema();
316
- this.insertEventStmt = this.db.prepare(`
379
+ }
380
+ prepareStatements(db) {
381
+ this.insertEventStmt = db.prepare(`
317
382
  INSERT INTO events (event_id, session_id, project, event_type, timestamp, data)
318
383
  VALUES (?, ?, ?, ?, ?, ?)
319
384
  `);
320
- this.insertSessionStmt = this.db.prepare(`
385
+ this.insertSessionStmt = db.prepare(`
321
386
  INSERT OR REPLACE INTO sessions (
322
387
  session_id, project, app_name, connected_at, sdk_version,
323
388
  event_count, is_connected, build_meta
324
389
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
325
390
  `);
326
- this.updateSessionDisconnectedStmt = this.db.prepare(`
391
+ this.updateSessionDisconnectedStmt = db.prepare(`
327
392
  UPDATE sessions SET is_connected = 0, disconnected_at = ? WHERE session_id = ?
328
393
  `);
329
- const flushInterval = options.flushIntervalMs ?? 100;
330
- this.flushTimer = setInterval(() => this.flush(), flushInterval);
331
394
  }
332
- createSchema() {
333
- this.db.exec(`
395
+ createSchema(db) {
396
+ const d = db ?? this.db;
397
+ d.exec(`
334
398
  CREATE TABLE IF NOT EXISTS events (
335
399
  id INTEGER PRIMARY KEY AUTOINCREMENT,
336
400
  event_id TEXT NOT NULL UNIQUE,
@@ -374,7 +438,7 @@ var SqliteStore = class {
374
438
  CREATE INDEX IF NOT EXISTS idx_snapshots_session ON session_snapshots(session_id);
375
439
  CREATE INDEX IF NOT EXISTS idx_snapshots_project ON session_snapshots(project, created_at);
376
440
  `);
377
- this.migrateSessionMetrics();
441
+ this.migrateSessionMetrics(d);
378
442
  }
379
443
  // --- Write Operations ---
380
444
  addEvent(event, project) {
@@ -427,6 +491,21 @@ var SqliteStore = class {
427
491
  INSERT INTO session_snapshots (session_id, project, label, metrics, created_at)
428
492
  VALUES (?, ?, ?, ?, ?)
429
493
  `).run(sessionId, project, label ?? null, JSON.stringify(metrics), Date.now());
494
+ this.pruneSnapshots(sessionId);
495
+ }
496
+ /** Remove oldest snapshots for a session beyond the retention limit */
497
+ pruneSnapshots(sessionId) {
498
+ const count = this.db.prepare("SELECT COUNT(*) as cnt FROM session_snapshots WHERE session_id = ?").get(sessionId).cnt;
499
+ if (count > _SqliteStore.MAX_SNAPSHOTS_PER_SESSION) {
500
+ this.db.prepare(`
501
+ DELETE FROM session_snapshots WHERE id IN (
502
+ SELECT id FROM session_snapshots
503
+ WHERE session_id = ?
504
+ ORDER BY created_at ASC
505
+ LIMIT ?
506
+ )
507
+ `).run(sessionId, count - _SqliteStore.MAX_SNAPSHOTS_PER_SESSION);
508
+ }
430
509
  }
431
510
  // --- Read Operations ---
432
511
  getEvents(filter) {
@@ -552,17 +631,17 @@ var SqliteStore = class {
552
631
  return rows.map((row) => JSON.parse(row.data));
553
632
  }
554
633
  // --- Migration ---
555
- migrateSessionMetrics() {
556
- const hasOldTable = this.db.prepare(
634
+ migrateSessionMetrics(db) {
635
+ const hasOldTable = db.prepare(
557
636
  "SELECT name FROM sqlite_master WHERE type='table' AND name='session_metrics'"
558
637
  ).get();
559
638
  if (hasOldTable) {
560
- this.db.exec(`
639
+ db.exec(`
561
640
  INSERT OR IGNORE INTO session_snapshots (session_id, project, label, metrics, created_at)
562
641
  SELECT session_id, project, 'auto-disconnect', metrics, created_at
563
642
  FROM session_metrics
564
643
  `);
565
- this.db.exec("DROP TABLE session_metrics");
644
+ db.exec("DROP TABLE session_metrics");
566
645
  }
567
646
  }
568
647
  // --- Maintenance ---
@@ -584,14 +663,14 @@ var SqliteStore = class {
584
663
  };
585
664
 
586
665
  // src/sqlite-check.ts
587
- import { createRequire } from "module";
666
+ import { createRequire as createRequire2 } from "module";
588
667
  var _checked = false;
589
668
  var _available = false;
590
669
  function isSqliteAvailable() {
591
670
  if (_checked) return _available;
592
671
  _checked = true;
593
672
  try {
594
- const require2 = createRequire(import.meta.url);
673
+ const require2 = createRequire2(import.meta.url);
595
674
  require2("better-sqlite3");
596
675
  _available = true;
597
676
  } catch {
@@ -727,6 +806,7 @@ var CollectorServer = class {
727
806
  connectCallbacks = [];
728
807
  disconnectCallbacks = [];
729
808
  pruneTimer = null;
809
+ heartbeatTimer = null;
730
810
  tlsConfig = null;
731
811
  constructor(options = {}) {
732
812
  this.store = new EventStore(options.bufferSize ?? 1e4);
@@ -786,6 +866,8 @@ var CollectorServer = class {
786
866
  httpsServer.on("listening", () => {
787
867
  this.wss = wss;
788
868
  this.setupConnectionHandler(wss);
869
+ this.setupPersistentErrorHandler(wss);
870
+ this.startHeartbeat(wss);
789
871
  console.error(`[RuntimeScope] Collector listening on wss://${host}:${port}`);
790
872
  resolve2();
791
873
  });
@@ -799,6 +881,8 @@ var CollectorServer = class {
799
881
  wss.on("listening", () => {
800
882
  this.wss = wss;
801
883
  this.setupConnectionHandler(wss);
884
+ this.setupPersistentErrorHandler(wss);
885
+ this.startHeartbeat(wss);
802
886
  console.error(`[RuntimeScope] Collector listening on ws://${host}:${port}`);
803
887
  resolve2();
804
888
  });
@@ -844,8 +928,33 @@ var CollectorServer = class {
844
928
  }
845
929
  return sqliteStore;
846
930
  }
931
+ /** Catch runtime errors on the WSS so an unhandled error doesn't crash the process */
932
+ setupPersistentErrorHandler(wss) {
933
+ wss.on("error", (err) => {
934
+ console.error("[RuntimeScope] WebSocket server runtime error:", err.message);
935
+ });
936
+ }
937
+ /** Ping all connected clients every 15s — terminate those that don't respond */
938
+ startHeartbeat(wss) {
939
+ this.heartbeatTimer = setInterval(() => {
940
+ for (const ws of wss.clients) {
941
+ const ext = ws;
942
+ if (ext._rsAlive === false) {
943
+ ws.terminate();
944
+ continue;
945
+ }
946
+ ext._rsAlive = false;
947
+ ws.ping();
948
+ }
949
+ }, 15e3);
950
+ }
847
951
  setupConnectionHandler(wss) {
848
952
  wss.on("connection", (ws) => {
953
+ const ext = ws;
954
+ ext._rsAlive = true;
955
+ ws.on("pong", () => {
956
+ ext._rsAlive = true;
957
+ });
849
958
  if (this.authManager?.isEnabled()) {
850
959
  this.pendingHandshakes.add(ws);
851
960
  const authTimeout = setTimeout(() => {
@@ -1040,10 +1149,27 @@ var CollectorServer = class {
1040
1149
  });
1041
1150
  }
1042
1151
  stop() {
1152
+ if (this.heartbeatTimer) {
1153
+ clearInterval(this.heartbeatTimer);
1154
+ this.heartbeatTimer = null;
1155
+ }
1043
1156
  if (this.pruneTimer) {
1044
1157
  clearInterval(this.pruneTimer);
1045
1158
  this.pruneTimer = null;
1046
1159
  }
1160
+ if (this.wss) {
1161
+ for (const client of this.wss.clients) {
1162
+ if (client.readyState === 1) {
1163
+ try {
1164
+ client.send(JSON.stringify({
1165
+ type: "__server_restart",
1166
+ timestamp: Date.now()
1167
+ }));
1168
+ } catch {
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1047
1173
  for (const [name, sqliteStore] of this.sqliteStores) {
1048
1174
  try {
1049
1175
  sqliteStore.close();
@@ -1061,7 +1187,7 @@ var CollectorServer = class {
1061
1187
  };
1062
1188
 
1063
1189
  // src/project-manager.ts
1064
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync, readdirSync } from "fs";
1190
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, readdirSync } from "fs";
1065
1191
  import { join } from "path";
1066
1192
  import { homedir } from "os";
1067
1193
  var DEFAULT_GLOBAL_CONFIG = {
@@ -1093,7 +1219,7 @@ var ProjectManager = class {
1093
1219
  this.mkdirp(this.baseDir);
1094
1220
  this.mkdirp(join(this.baseDir, "projects"));
1095
1221
  const configPath = join(this.baseDir, "config.json");
1096
- if (!existsSync(configPath)) {
1222
+ if (!existsSync2(configPath)) {
1097
1223
  this.writeJson(configPath, DEFAULT_GLOBAL_CONFIG);
1098
1224
  }
1099
1225
  }
@@ -1101,7 +1227,7 @@ var ProjectManager = class {
1101
1227
  const projectDir = this.getProjectDir(projectName);
1102
1228
  this.mkdirp(projectDir);
1103
1229
  const configPath = join(projectDir, "config.json");
1104
- if (!existsSync(configPath)) {
1230
+ if (!existsSync2(configPath)) {
1105
1231
  const config = {
1106
1232
  name: projectName,
1107
1233
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1115,7 +1241,7 @@ var ProjectManager = class {
1115
1241
  // --- Config ---
1116
1242
  getGlobalConfig() {
1117
1243
  const configPath = join(this.baseDir, "config.json");
1118
- if (!existsSync(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1244
+ if (!existsSync2(configPath)) return { ...DEFAULT_GLOBAL_CONFIG };
1119
1245
  return { ...DEFAULT_GLOBAL_CONFIG, ...this.readJson(configPath) };
1120
1246
  }
1121
1247
  saveGlobalConfig(config) {
@@ -1123,7 +1249,7 @@ var ProjectManager = class {
1123
1249
  }
1124
1250
  getProjectConfig(projectName) {
1125
1251
  const configPath = join(this.getProjectDir(projectName), "config.json");
1126
- if (!existsSync(configPath)) return null;
1252
+ if (!existsSync2(configPath)) return null;
1127
1253
  return this.readJson(configPath);
1128
1254
  }
1129
1255
  saveProjectConfig(projectName, config) {
@@ -1131,12 +1257,12 @@ var ProjectManager = class {
1131
1257
  }
1132
1258
  getInfrastructureConfig(projectName) {
1133
1259
  const jsonPath = join(this.getProjectDir(projectName), "infrastructure.json");
1134
- if (existsSync(jsonPath)) {
1260
+ if (existsSync2(jsonPath)) {
1135
1261
  const config = this.readJson(jsonPath);
1136
1262
  return this.resolveConfigEnvVars(config);
1137
1263
  }
1138
1264
  const yamlPath = join(this.getProjectDir(projectName), "infrastructure.yaml");
1139
- if (existsSync(yamlPath)) {
1265
+ if (existsSync2(yamlPath)) {
1140
1266
  try {
1141
1267
  const content = readFileSync2(yamlPath, "utf-8");
1142
1268
  return this.resolveConfigEnvVars(this.parseSimpleYaml(content));
@@ -1148,17 +1274,17 @@ var ProjectManager = class {
1148
1274
  }
1149
1275
  getClaudeInstructions(projectName) {
1150
1276
  const filePath = join(this.getProjectDir(projectName), "claude-instructions.md");
1151
- if (!existsSync(filePath)) return null;
1277
+ if (!existsSync2(filePath)) return null;
1152
1278
  return readFileSync2(filePath, "utf-8");
1153
1279
  }
1154
1280
  // --- Discovery ---
1155
1281
  listProjects() {
1156
1282
  const projectsDir = join(this.baseDir, "projects");
1157
- if (!existsSync(projectsDir)) return [];
1283
+ if (!existsSync2(projectsDir)) return [];
1158
1284
  return readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1159
1285
  }
1160
1286
  projectExists(projectName) {
1161
- return existsSync(this.getProjectDir(projectName));
1287
+ return existsSync2(this.getProjectDir(projectName));
1162
1288
  }
1163
1289
  // --- Environment variable resolution ---
1164
1290
  resolveEnvVars(value) {
@@ -1168,7 +1294,7 @@ var ProjectManager = class {
1168
1294
  }
1169
1295
  // --- Private helpers ---
1170
1296
  mkdirp(dir) {
1171
- if (!existsSync(dir)) {
1297
+ if (!existsSync2(dir)) {
1172
1298
  mkdirSync(dir, { recursive: true });
1173
1299
  }
1174
1300
  }
@@ -1508,14 +1634,14 @@ var SessionManager = class {
1508
1634
  // src/http-server.ts
1509
1635
  import { createServer } from "http";
1510
1636
  import { createServer as createHttpsServer2 } from "https";
1511
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1637
+ import { readFileSync as readFileSync3, existsSync as existsSync4 } from "fs";
1512
1638
  import { resolve, dirname } from "path";
1513
1639
  import { fileURLToPath } from "url";
1514
1640
  import { WebSocketServer as WebSocketServer2 } from "ws";
1515
1641
 
1516
1642
  // src/pm/pm-routes.ts
1517
1643
  import { readdir, readFile, writeFile, unlink, mkdir } from "fs/promises";
1518
- import { existsSync as existsSync2 } from "fs";
1644
+ import { existsSync as existsSync3 } from "fs";
1519
1645
  import { join as join2 } from "path";
1520
1646
  import { homedir as homedir2 } from "os";
1521
1647
  import { spawn, execSync, execFileSync } from "child_process";
@@ -2450,7 +2576,7 @@ function parseGitStatus(porcelain) {
2450
2576
  }
2451
2577
  async function readRuleFile(filePath) {
2452
2578
  try {
2453
- if (existsSync2(filePath)) {
2579
+ if (existsSync3(filePath)) {
2454
2580
  const content = await readFile(filePath, "utf-8");
2455
2581
  return { path: filePath, content, exists: true };
2456
2582
  }
@@ -2653,6 +2779,15 @@ var HttpServer = class {
2653
2779
  });
2654
2780
  this.json(res, { data: events, count: events.length });
2655
2781
  });
2782
+ this.routes.set("GET /api/events/ui", (_req, res, params) => {
2783
+ const action = params.get("action");
2784
+ const events = this.store.getUIInteractions({
2785
+ action: action ?? void 0,
2786
+ sinceSeconds: numParam(params, "since_seconds"),
2787
+ sessionId: params.get("session_id") ?? void 0
2788
+ });
2789
+ this.json(res, { data: events, count: events.length });
2790
+ });
2656
2791
  this.routes.set("DELETE /api/events", (_req, res) => {
2657
2792
  const result = this.store.clear();
2658
2793
  this.json(res, result);
@@ -2700,6 +2835,9 @@ var HttpServer = class {
2700
2835
  "dom_snapshot",
2701
2836
  "performance",
2702
2837
  "database",
2838
+ "custom",
2839
+ "navigation",
2840
+ "ui",
2703
2841
  "recon_metadata",
2704
2842
  "recon_design_tokens",
2705
2843
  "recon_fonts",
@@ -2745,7 +2883,7 @@ var HttpServer = class {
2745
2883
  // npm installed
2746
2884
  ];
2747
2885
  for (const p of candidates) {
2748
- if (existsSync3(p)) {
2886
+ if (existsSync4(p)) {
2749
2887
  this.sdkBundlePath = p;
2750
2888
  return p;
2751
2889
  }
@@ -3018,11 +3156,11 @@ function numParam(params, key) {
3018
3156
  }
3019
3157
 
3020
3158
  // src/pm/pm-store.ts
3021
- import Database2 from "better-sqlite3";
3159
+ import Database from "better-sqlite3";
3022
3160
  var PmStore = class {
3023
3161
  db;
3024
3162
  constructor(options) {
3025
- this.db = new Database2(options.dbPath);
3163
+ this.db = new Database(options.dbPath);
3026
3164
  if (options.walMode !== false) {
3027
3165
  this.db.pragma("journal_mode = WAL");
3028
3166
  }
@@ -4101,7 +4239,7 @@ async function parseSessionJsonl(jsonlPath, sessionId, projectId) {
4101
4239
  // src/pm/project-discovery.ts
4102
4240
  import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "fs/promises";
4103
4241
  import { join as join3, basename as basename2 } from "path";
4104
- import { existsSync as existsSync4 } from "fs";
4242
+ import { existsSync as existsSync5 } from "fs";
4105
4243
  import { homedir as homedir3 } from "os";
4106
4244
  var LOG_PREFIX = "[RuntimeScope PM]";
4107
4245
  async function detectSdkInstalled(projectPath) {
@@ -4168,7 +4306,7 @@ function slugifyPath(fsPath) {
4168
4306
  }
4169
4307
  function decodeClaudeKey(key) {
4170
4308
  const naive = "/" + key.slice(1).replace(/-/g, "/");
4171
- if (existsSync4(naive)) return naive;
4309
+ if (existsSync5(naive)) return naive;
4172
4310
  const parts = key.slice(1).split("-");
4173
4311
  return resolvePathSegments(parts);
4174
4312
  }
@@ -4176,16 +4314,16 @@ function resolvePathSegments(parts) {
4176
4314
  if (parts.length === 0) return null;
4177
4315
  function tryResolve(prefix, remaining) {
4178
4316
  if (remaining.length === 0) {
4179
- return existsSync4(prefix) ? prefix : null;
4317
+ return existsSync5(prefix) ? prefix : null;
4180
4318
  }
4181
4319
  for (let count = remaining.length; count >= 1; count--) {
4182
4320
  const segment = remaining.slice(0, count).join("-");
4183
4321
  const candidate = join3(prefix, segment);
4184
4322
  if (count === remaining.length) {
4185
- if (existsSync4(candidate)) return candidate;
4323
+ if (existsSync5(candidate)) return candidate;
4186
4324
  } else {
4187
4325
  try {
4188
- if (existsSync4(candidate)) {
4326
+ if (existsSync5(candidate)) {
4189
4327
  const result = tryResolve(candidate, remaining.slice(count));
4190
4328
  if (result) return result;
4191
4329
  }
@@ -4679,4 +4817,4 @@ export {
4679
4817
  parseSessionJsonl,
4680
4818
  ProjectDiscovery
4681
4819
  };
4682
- //# sourceMappingURL=chunk-VZSMLTUQ.js.map
4820
+ //# sourceMappingURL=chunk-BKRGXAJB.js.map