@gravito/spectrum 3.0.0 → 3.0.2

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.cjs CHANGED
@@ -36,49 +36,116 @@ __export(index_exports, {
36
36
  });
37
37
  module.exports = __toCommonJS(index_exports);
38
38
 
39
+ // src/SpectrumOrbit.ts
40
+ var import_core = require("@gravito/core");
41
+
39
42
  // src/storage/MemoryStorage.ts
40
43
  var MemoryStorage = class {
41
44
  requests = [];
42
45
  logs = [];
43
46
  queries = [];
44
47
  maxItems = 1e3;
48
+ /**
49
+ * Initializes the memory storage.
50
+ *
51
+ * As memory storage does not require external resources, this is a no-op.
52
+ *
53
+ * @returns Resolves immediately
54
+ */
45
55
  async init() {
46
56
  }
57
+ /**
58
+ * Temporarily stores a captured HTTP request in memory.
59
+ *
60
+ * @param req - The request snapshot to store
61
+ */
47
62
  async storeRequest(req) {
48
63
  this.requests.unshift(req);
49
64
  this.trim(this.requests);
50
65
  }
66
+ /**
67
+ * Temporarily stores a captured log entry in memory.
68
+ *
69
+ * @param log - The log entry to store
70
+ */
51
71
  async storeLog(log) {
52
72
  this.logs.unshift(log);
53
73
  this.trim(this.logs);
54
74
  }
75
+ /**
76
+ * Temporarily stores a captured database query in memory.
77
+ *
78
+ * @param query - The query information to store
79
+ */
55
80
  async storeQuery(query) {
56
81
  this.queries.unshift(query);
57
82
  this.trim(this.queries);
58
83
  }
84
+ /**
85
+ * Retrieves a list of recent HTTP requests from memory.
86
+ *
87
+ * @param limit - Maximum number of requests to return
88
+ * @param offset - Number of requests to skip for pagination
89
+ * @returns A promise resolving to an array of captured requests
90
+ */
59
91
  async getRequests(limit = 100, offset = 0) {
60
92
  return this.requests.slice(offset, offset + limit);
61
93
  }
94
+ /**
95
+ * Finds a specific HTTP request by its unique identifier.
96
+ *
97
+ * @param id - The unique ID of the request to find
98
+ * @returns The found request or null if not present
99
+ */
62
100
  async getRequest(id) {
63
101
  return this.requests.find((r) => r.id === id) || null;
64
102
  }
103
+ /**
104
+ * Retrieves a list of recent application logs from memory.
105
+ *
106
+ * @param limit - Maximum number of logs to return
107
+ * @param offset - Number of logs to skip for pagination
108
+ * @returns A promise resolving to an array of captured logs
109
+ */
65
110
  async getLogs(limit = 100, offset = 0) {
66
111
  return this.logs.slice(offset, offset + limit);
67
112
  }
113
+ /**
114
+ * Retrieves a list of recent database queries from memory.
115
+ *
116
+ * @param limit - Maximum number of queries to return
117
+ * @param offset - Number of queries to skip for pagination
118
+ * @returns A promise resolving to an array of captured queries
119
+ */
68
120
  async getQueries(limit = 100, offset = 0) {
69
121
  return this.queries.slice(offset, offset + limit);
70
122
  }
123
+ /**
124
+ * Wipes all captured telemetry data from memory.
125
+ *
126
+ * @returns Resolves when all collections are emptied
127
+ */
71
128
  async clear() {
72
129
  this.requests = [];
73
130
  this.logs = [];
74
131
  this.queries = [];
75
132
  }
133
+ /**
134
+ * Sets a new capacity limit and prunes existing data to fit.
135
+ *
136
+ * @param maxItems - The new maximum number of items to retain per category
137
+ */
76
138
  async prune(maxItems) {
77
139
  this.maxItems = maxItems;
78
140
  this.trim(this.requests);
79
141
  this.trim(this.logs);
80
142
  this.trim(this.queries);
81
143
  }
144
+ /**
145
+ * Truncates an array to ensure it does not exceed the maximum allowed capacity.
146
+ *
147
+ * @param arr - The target array to truncate
148
+ */
82
149
  trim(arr) {
83
150
  if (arr.length > this.maxItems) {
84
151
  arr.splice(this.maxItems);
@@ -88,13 +155,30 @@ var MemoryStorage = class {
88
155
 
89
156
  // src/SpectrumOrbit.ts
90
157
  var SpectrumOrbit = class _SpectrumOrbit {
158
+ /**
159
+ * Global instance of SpectrumOrbit.
160
+ *
161
+ * Used for internal access and singleton patterns within the framework.
162
+ */
91
163
  static instance;
92
164
  name = "spectrum";
93
165
  config;
94
166
  // Event listeners for SSE
95
167
  listeners = /* @__PURE__ */ new Set();
168
+ currentRequestId = null;
96
169
  warnedSecurity = false;
97
170
  deps;
171
+ /**
172
+ * Initializes a new instance of SpectrumOrbit.
173
+ *
174
+ * @param config - Configuration options for behavior and storage
175
+ * @param deps - Internal dependencies for integration with other orbits
176
+ *
177
+ * @example
178
+ * ```typescript
179
+ * const spectrum = new SpectrumOrbit({ path: '/debug' });
180
+ * ```
181
+ */
98
182
  constructor(config = {}, deps = {}) {
99
183
  this.config = {
100
184
  path: config.path || "/gravito/spectrum",
@@ -107,29 +191,60 @@ var SpectrumOrbit = class _SpectrumOrbit {
107
191
  this.deps = deps;
108
192
  _SpectrumOrbit.instance = this;
109
193
  }
194
+ /**
195
+ * Checks if the current operation should be captured based on sample rate.
196
+ *
197
+ * @returns True if capture is allowed
198
+ */
110
199
  shouldCapture() {
111
200
  if (this.config.sampleRate >= 1) {
112
201
  return true;
113
202
  }
114
203
  return Math.random() < this.config.sampleRate;
115
204
  }
205
+ /**
206
+ * Broadcasts telemetry data to all connected SSE clients.
207
+ *
208
+ * @param type - The category of the data
209
+ * @param data - The telemetry payload
210
+ */
116
211
  broadcast(type, data) {
117
212
  const payload = JSON.stringify({ type, data });
118
213
  for (const listener of this.listeners) {
119
214
  listener(payload);
120
215
  }
121
216
  }
217
+ /**
218
+ * Installs the Spectrum orbit into the Gravito core.
219
+ *
220
+ * Sets up collection listeners, initializes storage, and registers API/UI routes.
221
+ *
222
+ * @param core - The planet core instance
223
+ * @returns Resolves when installation is complete
224
+ * @throws {Error} If storage initialization fails
225
+ *
226
+ * @example
227
+ * ```typescript
228
+ * await spectrum.install(core);
229
+ * ```
230
+ */
122
231
  async install(core) {
123
232
  if (!this.config.enabled) {
124
233
  return;
125
234
  }
126
235
  await this.config.storage.init();
236
+ await this.config.storage.prune(this.config.maxItems);
127
237
  this.setupHttpCollection(core);
128
238
  this.setupLogCollection(core);
129
239
  this.setupDatabaseCollection(core);
130
240
  this.registerRoutes(core);
131
241
  core.logger.info(`[Spectrum] Debug Dashboard initialized at ${this.config.path}`);
132
242
  }
243
+ /**
244
+ * Configures collection of database queries from Atlas orbit.
245
+ *
246
+ * @param core - The planet core instance
247
+ */
133
248
  setupDatabaseCollection(core) {
134
249
  const attachListener = (atlas) => {
135
250
  if (!atlas?.Connection) {
@@ -141,7 +256,8 @@ var SpectrumOrbit = class _SpectrumOrbit {
141
256
  }
142
257
  const data = {
143
258
  id: crypto.randomUUID(),
144
- ...query
259
+ ...query,
260
+ requestId: this.currentRequestId || void 0
145
261
  };
146
262
  this.config.storage.storeQuery(data);
147
263
  this.broadcast("query", data);
@@ -169,21 +285,29 @@ var SpectrumOrbit = class _SpectrumOrbit {
169
285
  } catch (_e) {
170
286
  }
171
287
  }
288
+ /**
289
+ * Configures interception of HTTP requests and responses.
290
+ *
291
+ * @param core - The planet core instance
292
+ */
172
293
  setupHttpCollection(core) {
173
294
  const middleware = async (c, next) => {
174
295
  if (c.req.path.startsWith(this.config.path)) {
175
296
  return await next();
176
297
  }
298
+ const requestId = crypto.randomUUID();
299
+ this.currentRequestId = requestId;
177
300
  const startTime = performance.now();
178
301
  const startTimestamp = Date.now();
179
302
  const res = await next();
180
303
  const duration = performance.now() - startTime;
304
+ this.currentRequestId = null;
181
305
  if (!this.shouldCapture()) {
182
306
  return res;
183
307
  }
184
308
  const finalRes = res || c.res;
185
309
  const request = {
186
- id: crypto.randomUUID(),
310
+ id: requestId,
187
311
  method: c.req.method,
188
312
  path: c.req.path,
189
313
  url: c.req.url,
@@ -193,7 +317,8 @@ var SpectrumOrbit = class _SpectrumOrbit {
193
317
  ip: c.req.header("x-forwarded-for") || "127.0.0.1",
194
318
  userAgent: c.req.header("user-agent"),
195
319
  requestHeaders: Object.fromEntries(c.req.raw.headers.entries()),
196
- responseHeaders: finalRes ? Object.fromEntries(finalRes.headers.entries()) : {}
320
+ responseHeaders: finalRes ? Object.fromEntries(finalRes.headers.entries()) : {},
321
+ requestId
197
322
  };
198
323
  this.config.storage.storeRequest(request);
199
324
  this.broadcast("request", request);
@@ -201,6 +326,13 @@ var SpectrumOrbit = class _SpectrumOrbit {
201
326
  };
202
327
  core.adapter.use("*", middleware);
203
328
  }
329
+ /**
330
+ * Configures interception of application logs.
331
+ *
332
+ * Wraps the core logger to capture all log calls.
333
+ *
334
+ * @param core - The planet core instance
335
+ */
204
336
  setupLogCollection(core) {
205
337
  const originalLogger = core.logger;
206
338
  const spectrumLogger = {
@@ -223,6 +355,13 @@ var SpectrumOrbit = class _SpectrumOrbit {
223
355
  };
224
356
  core.logger = spectrumLogger;
225
357
  }
358
+ /**
359
+ * Processes and stores a captured log entry.
360
+ *
361
+ * @param level - Log severity level
362
+ * @param message - Primary message string
363
+ * @param args - Additional log arguments
364
+ */
226
365
  captureLog(level, message, args) {
227
366
  if (!this.shouldCapture()) {
228
367
  return;
@@ -232,11 +371,17 @@ var SpectrumOrbit = class _SpectrumOrbit {
232
371
  level,
233
372
  message,
234
373
  args,
235
- timestamp: Date.now()
374
+ timestamp: Date.now(),
375
+ requestId: this.currentRequestId || void 0
236
376
  };
237
377
  this.config.storage.storeLog(log);
238
378
  this.broadcast("log", log);
239
379
  }
380
+ /**
381
+ * Registers all API and UI routes for the Spectrum dashboard.
382
+ *
383
+ * @param core - The planet core instance
384
+ */
240
385
  registerRoutes(core) {
241
386
  const router = core.router;
242
387
  const apiPath = `${this.config.path}/api`;
@@ -280,6 +425,13 @@ var SpectrumOrbit = class _SpectrumOrbit {
280
425
  router.post(
281
426
  `${apiPath}/clear`,
282
427
  wrap(async (c) => {
428
+ if (c.req && typeof c.req.header === "function") {
429
+ const token = c.req.header("x-csrf-token") || (await c.req.parseBody())?._csrf;
430
+ const expectedToken = (0, import_core.getCsrfToken)(c);
431
+ if (expectedToken && (!token || token !== expectedToken)) {
432
+ return c.json({ error: "Invalid CSRF token" }, 419);
433
+ }
434
+ }
283
435
  await this.config.storage.clear();
284
436
  return c.json({ success: true });
285
437
  })
@@ -290,23 +442,37 @@ var SpectrumOrbit = class _SpectrumOrbit {
290
442
  const { readable, writable } = new TransformStream();
291
443
  const writer = writable.getWriter();
292
444
  const encoder = new TextEncoder();
445
+ let isClosed = false;
446
+ const cleanup = () => {
447
+ if (isClosed) {
448
+ return;
449
+ }
450
+ isClosed = true;
451
+ clearInterval(heartbeat);
452
+ this.listeners.delete(send);
453
+ if (typeof writer.close === "function") {
454
+ writer.close().catch(() => {
455
+ });
456
+ }
457
+ };
293
458
  const send = (payload) => {
294
- try {
295
- writer.write(encoder.encode(`data: ${payload}
296
-
297
- `));
298
- } catch (_e) {
299
- this.listeners.delete(send);
459
+ if (isClosed) {
460
+ return;
300
461
  }
462
+ Promise.resolve().then(() => writer.write(encoder.encode(`data: ${payload}
463
+
464
+ `))).catch(() => {
465
+ cleanup();
466
+ });
301
467
  };
302
468
  this.listeners.add(send);
303
469
  const heartbeat = setInterval(() => {
304
- try {
305
- writer.write(encoder.encode(": heartbeat\n\n"));
306
- } catch (_e) {
307
- clearInterval(heartbeat);
308
- this.listeners.delete(send);
470
+ if (isClosed) {
471
+ return;
309
472
  }
473
+ Promise.resolve().then(() => writer.write(encoder.encode(": heartbeat\n\n"))).catch(() => {
474
+ cleanup();
475
+ });
310
476
  }, 3e4);
311
477
  return new Response(readable, {
312
478
  headers: {
@@ -320,6 +486,13 @@ var SpectrumOrbit = class _SpectrumOrbit {
320
486
  router.post(
321
487
  `${apiPath}/replay/:id`,
322
488
  wrap(async (c) => {
489
+ if (c.req && typeof c.req.header === "function") {
490
+ const token = c.req.header("x-csrf-token") || (await c.req.parseBody())?._csrf;
491
+ const expectedToken = (0, import_core.getCsrfToken)(c);
492
+ if (expectedToken && (!token || token !== expectedToken)) {
493
+ return c.json({ error: "Invalid CSRF token" }, 419);
494
+ }
495
+ }
323
496
  const id = c.req.param("id");
324
497
  if (!id) {
325
498
  return c.json({ error: "ID required" }, 400);
@@ -332,7 +505,6 @@ var SpectrumOrbit = class _SpectrumOrbit {
332
505
  const replayReq = new Request(req.url, {
333
506
  method: req.method,
334
507
  headers: req.requestHeaders
335
- // Body logic would be needed here (if captured)
336
508
  });
337
509
  const res = await core.adapter.fetch(replayReq);
338
510
  return c.json({
@@ -483,7 +655,8 @@ var SpectrumOrbit = class _SpectrumOrbit {
483
655
  logs: [],
484
656
  queries: [],
485
657
  connected: false,
486
- eventSource: null
658
+ eventSource: null,
659
+ csrfToken: this.getCsrfTokenFromCookie()
487
660
  }
488
661
  },
489
662
  computed: {
@@ -505,6 +678,14 @@ var SpectrumOrbit = class _SpectrumOrbit {
505
678
  this.fetchData();
506
679
  this.initRealtime();
507
680
  },
681
+ unmounted() {
682
+ // Cleanup EventSource to prevent memory leaks
683
+ if (this.eventSource) {
684
+ this.eventSource.close();
685
+ this.eventSource = null;
686
+ this.connected = false;
687
+ }
688
+ },
508
689
  methods: {
509
690
  initRealtime() {
510
691
  this.eventSource = new EventSource('${apiPath}/events');
@@ -543,13 +724,35 @@ var SpectrumOrbit = class _SpectrumOrbit {
543
724
  },
544
725
  async clearData() {
545
726
  if (confirm('Are you sure you want to clear all debug data?')) {
546
- await fetch('${apiPath}/clear', { method: 'POST' });
547
- this.fetchData();
727
+ try {
728
+ const res = await fetch('${apiPath}/clear', {
729
+ method: 'POST',
730
+ headers: {
731
+ 'X-CSRF-Token': this.csrfToken
732
+ }
733
+ });
734
+ if (res.status === 419) {
735
+ alert('CSRF token invalid. Please refresh the page.');
736
+ return;
737
+ }
738
+ this.fetchData();
739
+ } catch (e) {
740
+ alert('Failed to clear data: ' + e.message);
741
+ }
548
742
  }
549
743
  },
550
744
  async replayRequest(id) {
551
745
  try {
552
- const res = await fetch('${apiPath}/replay/' + id, { method: 'POST' });
746
+ const res = await fetch('${apiPath}/replay/' + id, {
747
+ method: 'POST',
748
+ headers: {
749
+ 'X-CSRF-Token': this.csrfToken
750
+ }
751
+ });
752
+ if (res.status === 419) {
753
+ alert('CSRF token invalid. Please refresh the page.');
754
+ return;
755
+ }
553
756
  const data = await res.json();
554
757
  if (data.success) {
555
758
  alert('Replay successful! Status: ' + data.status);
@@ -588,6 +791,10 @@ var SpectrumOrbit = class _SpectrumOrbit {
588
791
  'debug': 'text-slate-500'
589
792
  };
590
793
  return map[l] || 'text-slate-400';
794
+ },
795
+ getCsrfTokenFromCookie() {
796
+ const match = document.cookie.match(/csrf_token=([^;]+)/);
797
+ return match ? match[1] : '';
591
798
  }
592
799
  }
593
800
  }).mount('#app')
@@ -604,6 +811,16 @@ var SpectrumOrbit = class _SpectrumOrbit {
604
811
  var import_node_fs = require("fs");
605
812
  var import_node_path = require("path");
606
813
  var FileStorage = class {
814
+ /**
815
+ * Initializes a new instance of FileStorage.
816
+ *
817
+ * @param config - Configuration including the target directory for JSONL files
818
+ *
819
+ * @example
820
+ * ```typescript
821
+ * const storage = new FileStorage({ directory: './storage' });
822
+ * ```
823
+ */
607
824
  constructor(config) {
608
825
  this.config = config;
609
826
  this.requestsPath = (0, import_node_path.join)(config.directory, "spectrum-requests.jsonl");
@@ -621,6 +838,12 @@ var FileStorage = class {
621
838
  logs: [],
622
839
  queries: []
623
840
  };
841
+ /**
842
+ * Initializes the storage by creating the directory and loading existing files into memory.
843
+ *
844
+ * @returns Resolves when initialization is complete
845
+ * @throws {Error} If directory creation fails
846
+ */
624
847
  async init() {
625
848
  if (!(0, import_node_fs.existsSync)(this.config.directory)) {
626
849
  (0, import_node_fs.mkdirSync)(this.config.directory, { recursive: true });
@@ -629,6 +852,12 @@ var FileStorage = class {
629
852
  this.loadCache(this.logsPath, this.cache.logs);
630
853
  this.loadCache(this.queriesPath, this.cache.queries);
631
854
  }
855
+ /**
856
+ * Loads newline-delimited JSON data from a file into a target array.
857
+ *
858
+ * @param path - The absolute path to the JSONL file
859
+ * @param target - The array to populate with parsed objects
860
+ */
632
861
  loadCache(path, target) {
633
862
  if (!(0, import_node_fs.existsSync)(path)) {
634
863
  return;
@@ -646,6 +875,13 @@ var FileStorage = class {
646
875
  console.error(`[Spectrum] Failed to load cache from ${path}`, e);
647
876
  }
648
877
  }
878
+ /**
879
+ * Internal helper to append data to both the in-memory cache and the JSONL file.
880
+ *
881
+ * @param path - File path to append to
882
+ * @param data - The telemetry object
883
+ * @param list - The cache array
884
+ */
649
885
  async append(path, data, list) {
650
886
  list.unshift(data);
651
887
  try {
@@ -655,27 +891,72 @@ var FileStorage = class {
655
891
  console.error(`[Spectrum] Failed to write to ${path}`, e);
656
892
  }
657
893
  }
894
+ /**
895
+ * Persists a captured HTTP request.
896
+ *
897
+ * @param req - The request snapshot
898
+ */
658
899
  async storeRequest(req) {
659
900
  await this.append(this.requestsPath, req, this.cache.requests);
660
901
  }
902
+ /**
903
+ * Persists a captured log entry.
904
+ *
905
+ * @param log - The log snapshot
906
+ */
661
907
  async storeLog(log) {
662
908
  await this.append(this.logsPath, log, this.cache.logs);
663
909
  }
910
+ /**
911
+ * Persists a captured database query.
912
+ *
913
+ * @param query - The query snapshot
914
+ */
664
915
  async storeQuery(query) {
665
916
  await this.append(this.queriesPath, query, this.cache.queries);
666
917
  }
918
+ /**
919
+ * Retrieves recent HTTP requests from storage.
920
+ *
921
+ * @param limit - Maximum number of items to return
922
+ * @param offset - Pagination offset
923
+ * @returns Array of requests
924
+ */
667
925
  async getRequests(limit = 100, offset = 0) {
668
926
  return this.cache.requests.slice(offset, offset + limit);
669
927
  }
928
+ /**
929
+ * Retrieves a specific HTTP request by its unique ID.
930
+ *
931
+ * @param id - The snapshot ID
932
+ * @returns The request or null if not found
933
+ */
670
934
  async getRequest(id) {
671
935
  return this.cache.requests.find((r) => r.id === id) || null;
672
936
  }
937
+ /**
938
+ * Retrieves recent logs from storage.
939
+ *
940
+ * @param limit - Maximum number of items to return
941
+ * @param offset - Pagination offset
942
+ * @returns Array of logs
943
+ */
673
944
  async getLogs(limit = 100, offset = 0) {
674
945
  return this.cache.logs.slice(offset, offset + limit);
675
946
  }
947
+ /**
948
+ * Retrieves recent database queries from storage.
949
+ *
950
+ * @param limit - Maximum number of items to return
951
+ * @param offset - Pagination offset
952
+ * @returns Array of queries
953
+ */
676
954
  async getQueries(limit = 100, offset = 0) {
677
955
  return this.cache.queries.slice(offset, offset + limit);
678
956
  }
957
+ /**
958
+ * Wipes all data from both cache and files.
959
+ */
679
960
  async clear() {
680
961
  this.cache.requests = [];
681
962
  this.cache.logs = [];
@@ -684,12 +965,31 @@ var FileStorage = class {
684
965
  (0, import_node_fs.writeFileSync)(this.logsPath, "");
685
966
  (0, import_node_fs.writeFileSync)(this.queriesPath, "");
686
967
  }
968
+ /**
969
+ * Truncates the storage to stay within the specified limit.
970
+ *
971
+ * @param maxItems - The maximum allowed records per category
972
+ */
687
973
  async prune(maxItems) {
688
974
  if (this.cache.requests.length > maxItems) {
689
975
  this.cache.requests = this.cache.requests.slice(0, maxItems);
690
976
  this.rewrite(this.requestsPath, this.cache.requests);
691
977
  }
978
+ if (this.cache.logs.length > maxItems) {
979
+ this.cache.logs = this.cache.logs.slice(0, maxItems);
980
+ this.rewrite(this.logsPath, this.cache.logs);
981
+ }
982
+ if (this.cache.queries.length > maxItems) {
983
+ this.cache.queries = this.cache.queries.slice(0, maxItems);
984
+ this.rewrite(this.queriesPath, this.cache.queries);
985
+ }
692
986
  }
987
+ /**
988
+ * Overwrites the file content with the current in-memory data.
989
+ *
990
+ * @param path - File path to overwrite
991
+ * @param data - The array of data objects
992
+ */
693
993
  rewrite(path, data) {
694
994
  const content = `${data.slice().reverse().map((d) => JSON.stringify(d)).join("\n")}
695
995
  `;