opencons 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +382 -0
  3. package/opencons.d.ts +55 -0
  4. package/package.json +73 -0
  5. package/scripts/vendor-d3.js +22 -0
  6. package/src/core/context.js +44 -0
  7. package/src/core/index.js +198 -0
  8. package/src/core/tracer.js +252 -0
  9. package/src/drivers/db-language.js +207 -0
  10. package/src/drivers/detect.js +62 -0
  11. package/src/drivers/drizzle.js +87 -0
  12. package/src/drivers/index.js +43 -0
  13. package/src/drivers/mongoose.js +89 -0
  14. package/src/drivers/mysql2.js +116 -0
  15. package/src/drivers/pg.js +130 -0
  16. package/src/drivers/prisma.js +109 -0
  17. package/src/drivers/record.js +158 -0
  18. package/src/index.js +28 -0
  19. package/src/integrations/nest-lifecycle.js +357 -0
  20. package/src/integrations/nest.js +89 -0
  21. package/src/interceptors/express.js +270 -0
  22. package/src/interceptors/require-hook.js +109 -0
  23. package/src/lib/config.js +139 -0
  24. package/src/lib/errors.js +54 -0
  25. package/src/lib/http-response.js +37 -0
  26. package/src/lib/logger.js +69 -0
  27. package/src/lib/serialize.js +22 -0
  28. package/src/server/static.js +165 -0
  29. package/src/server/ws.js +62 -0
  30. package/src/store/source-cache.js +120 -0
  31. package/src/store/trace-store.js +117 -0
  32. package/src/transform/ast.js +255 -0
  33. package/src/transform/natural-language.js +146 -0
  34. package/src/transform/probe.js +161 -0
  35. package/src/transform/register.js +44 -0
  36. package/src/utils/label.js +26 -0
  37. package/src/utils/observable.js +103 -0
  38. package/widget/app.js +356 -0
  39. package/widget/db-language.js +90 -0
  40. package/widget/graph.js +1167 -0
  41. package/widget/index.html +132 -0
  42. package/widget/styles.css +773 -0
  43. package/widget/timeline.js +57 -0
  44. package/widget/vendor/d3.min.js +2 -0
package/widget/app.js ADDED
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+
3
+ const WS_PORT = parseInt(window.location.port, 10) || 7331;
4
+ const WS_URL = `ws://${window.location.hostname}:${WS_PORT}`;
5
+
6
+ /** @type {Map<string, object>} */
7
+ const traces = new Map();
8
+
9
+ /** @type {string | null} */
10
+ let selectedTraceId = null;
11
+
12
+ /** @type {Set<string>} */
13
+ const highlightIds = new Set();
14
+
15
+ /** @type {WebSocket | null} */
16
+ let socket = null;
17
+
18
+ const els = {
19
+ statusDot: document.querySelector('.status-dot'),
20
+ statusText: document.querySelector('.status-text'),
21
+ requestItems: document.getElementById('request-items'),
22
+ requestCount: document.getElementById('request-count'),
23
+ emptyState: document.getElementById('empty-state'),
24
+ traceTitle: document.getElementById('trace-title'),
25
+ nodeDetail: document.getElementById('node-detail'),
26
+ nodeDetailContent: document.getElementById('node-detail-content'),
27
+ sourcePeek: document.getElementById('source-peek'),
28
+ sourcePeekPath: document.getElementById('source-peek-path'),
29
+ sourcePeekContent: document.getElementById('source-peek-content'),
30
+ };
31
+
32
+ function connect() {
33
+ socket = new WebSocket(WS_URL);
34
+
35
+ socket.addEventListener('open', () => {
36
+ setConnectionStatus(true);
37
+ els.emptyState.textContent = 'Listening for requests…';
38
+ socket.send(JSON.stringify({ type: 'get_history', limit: 50 }));
39
+ });
40
+
41
+ socket.addEventListener('close', () => {
42
+ setConnectionStatus(false);
43
+ els.emptyState.textContent = 'Reconnecting…';
44
+ setTimeout(connect, 2000);
45
+ });
46
+
47
+ socket.addEventListener('message', (event) => {
48
+ const message = JSON.parse(event.data);
49
+
50
+ switch (message.type) {
51
+ case 'trace_start':
52
+ upsertTrace(message.payload, { highlight: true, autoSelect: true });
53
+ break;
54
+ case 'trace_update':
55
+ upsertTrace(message.payload, { refreshDetail: true });
56
+ break;
57
+ case 'trace':
58
+ upsertTrace(message.payload, { highlight: true, refreshDetail: true });
59
+ break;
60
+ case 'history':
61
+ message.payload.forEach((trace) => upsertTrace(trace));
62
+ break;
63
+ default:
64
+ break;
65
+ }
66
+ });
67
+ }
68
+
69
+ /**
70
+ * @param {object} trace
71
+ * @param {object} [options]
72
+ */
73
+ function upsertTrace(trace, options = {}) {
74
+ const previous = traces.get(trace.id);
75
+ const isNew = !previous;
76
+ traces.set(trace.id, trace);
77
+ renderRequestList();
78
+
79
+ if (options.highlight || isNew) {
80
+ highlightRequest(trace.id);
81
+ }
82
+
83
+ if (options.autoSelect && (!selectedTraceId || trace.state === 'active')) {
84
+ selectTrace(trace.id, { preserveHighlight: true });
85
+ } else if (options.refreshDetail && trace.id === selectedTraceId) {
86
+ renderTraceDetail(trace);
87
+ }
88
+ }
89
+
90
+ function highlightRequest(id) {
91
+ highlightIds.add(id);
92
+ renderRequestList();
93
+
94
+ setTimeout(() => {
95
+ highlightIds.delete(id);
96
+ renderRequestList();
97
+ }, 2000);
98
+ }
99
+
100
+ function renderRequestList() {
101
+ const items = Array.from(traces.values()).sort((a, b) => b.timestamp - a.timestamp);
102
+
103
+ els.requestCount.textContent = String(items.length);
104
+ els.emptyState.style.display = items.length ? 'none' : 'block';
105
+ els.requestItems.innerHTML = '';
106
+
107
+ for (const trace of items) {
108
+ const li = document.createElement('li');
109
+ const classes = ['request-item'];
110
+
111
+ if (trace.id === selectedTraceId) classes.push('active');
112
+ if (trace.state === 'active') classes.push('in-flight');
113
+ if (highlightIds.has(trace.id)) classes.push('highlight');
114
+
115
+ li.className = classes.join(' ');
116
+ li.dataset.id = trace.id;
117
+
118
+ const statusClass = trace.state === 'active' ? 'status-pending' : statusBadgeClass(trace.status);
119
+ const statusLabel = trace.state === 'active' ? '…' : (trace.status ?? '—');
120
+ const durationLabel = trace.state === 'active' ? 'running' : `${trace.duration_ms}ms`;
121
+
122
+ li.innerHTML = `
123
+ <div>
124
+ <span class="method method-${trace.method}">${trace.method}</span>
125
+ <span class="url">${escapeHtml(trace.url)}</span>
126
+ ${trace.state === 'active' ? '<span class="live-dot" title="In progress"></span>' : ''}
127
+ </div>
128
+ <div class="request-meta">
129
+ <span class="status-badge ${statusClass}">${statusLabel}</span>
130
+ <span>${durationLabel}</span>
131
+ <span>${formatTime(trace.timestamp)}</span>
132
+ </div>
133
+ `;
134
+
135
+ li.addEventListener('click', () => selectTrace(trace.id));
136
+ els.requestItems.appendChild(li);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @param {string} id
142
+ * @param {object} [options]
143
+ */
144
+ function selectTrace(id, options = {}) {
145
+ selectedTraceId = id;
146
+ const trace = traces.get(id);
147
+
148
+ if (!trace) return;
149
+
150
+ if (!options.preserveHighlight) {
151
+ highlightIds.delete(id);
152
+ }
153
+
154
+ renderTraceDetail(trace);
155
+ renderRequestList();
156
+ }
157
+
158
+ /**
159
+ * @param {object} trace
160
+ */
161
+ function renderTraceDetail(trace) {
162
+ const titleSuffix = trace.state === 'active' ? ' (in progress)' : '';
163
+ els.traceTitle.textContent = `${trace.method} ${trace.url}${titleSuffix}`;
164
+
165
+ if (window.OpenconsTimeline) {
166
+ window.OpenconsTimeline.render(trace);
167
+ }
168
+
169
+ els.nodeDetail.classList.add('hidden');
170
+ els.sourcePeek.classList.add('hidden');
171
+
172
+ requestAnimationFrame(() => {
173
+ renderGraph(trace);
174
+ });
175
+ }
176
+
177
+ /**
178
+ * @param {object} trace
179
+ */
180
+ function renderGraph(trace) {
181
+ if (!window.OpenconsGraph) return;
182
+ window.OpenconsGraph.render(trace, onNodeSelect);
183
+ }
184
+
185
+ /**
186
+ * @param {object} node
187
+ */
188
+ function onNodeSelect(node) {
189
+ els.nodeDetail.classList.remove('hidden');
190
+ els.nodeDetailContent.innerHTML = '';
191
+
192
+ const fields = [
193
+ ['Step', node.label || node.type],
194
+ ['Type', node.type],
195
+ ['Duration', node.duration_ms != null ? `${node.duration_ms}ms` : '—'],
196
+ ];
197
+
198
+ if (node.summary) {
199
+ fields.splice(1, 0, ['What happened', node.summary]);
200
+ }
201
+
202
+ if (node.condition) {
203
+ fields.push(['Condition', node.condition]);
204
+ }
205
+
206
+ if (node.called_next !== undefined) {
207
+ fields.push(['Called next()', String(node.called_next)]);
208
+ }
209
+
210
+ if (node.exit_reason) {
211
+ fields.push(['Exit reason', node.exit_reason]);
212
+ }
213
+
214
+ if (node.outcomes?.length) {
215
+ fields.push([
216
+ 'Branches',
217
+ node.outcomes.map((o) => `${o.taken ? '✓' : '○'} ${o.label}`).join('\n'),
218
+ ]);
219
+ } else if ((node.type === 'branch' || node.type === 'loop') && node.value != null) {
220
+ fields.push(['Result', String(node.value)]);
221
+ }
222
+
223
+ if (node.type === 'db-hub') {
224
+ fields.splice(1, 0, ['Role', 'Central database — all queries route through here']);
225
+ if (node.drivers?.length) {
226
+ fields.push(['Stack', node.drivers.join(' · ')]);
227
+ }
228
+ if (node.dbQueries?.length) {
229
+ const lang = window.OpenconsDbLanguage;
230
+ const lines = node.dbQueries.map((query) => {
231
+ const title = lang ? lang.dbNodeTitle(query) : query.label;
232
+ const result = lang ? lang.dbNodeResult(query) : query.db_result;
233
+ return `${title} → ${result}`;
234
+ });
235
+ fields.push(['Queries in this request', lines.join('\n')]);
236
+ }
237
+ }
238
+
239
+ if (node.type === 'db' || node.isDbQuery) {
240
+ const lang = window.OpenconsDbLanguage;
241
+ const intent = lang ? lang.dbNodeIntent(node) : node.db_intent;
242
+ const result = lang ? lang.dbNodeResult(node) : node.db_result;
243
+
244
+ if (node.parentLabel) fields.push(['From handler', node.parentLabel]);
245
+ if (intent) fields.push(['Sent to database', intent]);
246
+ if (result) fields.push(['Came back with', result]);
247
+ if (node.query) fields.push(['SQL', node.query]);
248
+ if (node.params) fields.push(['Parameters', JSON.stringify(node.params)]);
249
+ if (node.rows != null) fields.push(['Rows', String(node.rows)]);
250
+ if (node.driver) fields.push(['Driver', node.driver]);
251
+ }
252
+
253
+ if (node.source?.file) {
254
+ const loc =
255
+ node.source.line != null ? `${node.source.file}:${node.source.line}` : node.source.file;
256
+ fields.push(['Source', loc]);
257
+ }
258
+
259
+ for (const [label, value] of fields) {
260
+ els.nodeDetailContent.innerHTML += `<dt>${label}</dt><dd>${escapeHtml(String(value))}</dd>`;
261
+ }
262
+
263
+ if (node.source?.file && node.source.line != null) {
264
+ loadSourcePeek(node.source.file, node.source.line);
265
+ } else {
266
+ els.sourcePeek.classList.add('hidden');
267
+ }
268
+ }
269
+
270
+ /**
271
+ * @param {string} file
272
+ * @param {number} line
273
+ */
274
+ async function loadSourcePeek(file, line) {
275
+ els.sourcePeek.classList.remove('hidden');
276
+ els.sourcePeekPath.textContent = `${file}:${line}`;
277
+ els.sourcePeekContent.textContent = 'Loading…';
278
+
279
+ try {
280
+ const response = await fetch(`/api/source?file=${encodeURIComponent(file)}&line=${line}`);
281
+ if (!response.ok) {
282
+ els.sourcePeekContent.textContent = 'Source not available (file not transformed yet).';
283
+ return;
284
+ }
285
+
286
+ const snippet = await response.json();
287
+ els.sourcePeekPath.textContent = snippet.file || `${file}:${line}`;
288
+ els.sourcePeekContent.innerHTML = snippet.lines
289
+ .map((row) => {
290
+ const cls = row.highlight ? 'source-line highlight' : 'source-line';
291
+ const num = String(row.number).padStart(4, ' ');
292
+ return `<span class="${cls}"><span class="line-num">${num}</span>${escapeHtml(row.text)}</span>`;
293
+ })
294
+ .join('\n');
295
+ } catch {
296
+ els.sourcePeekContent.textContent = 'Failed to load source.';
297
+ }
298
+ }
299
+
300
+ function setConnectionStatus(connected) {
301
+ els.statusDot.className = `status-dot${connected ? ' connected' : ''}`;
302
+ els.statusText.textContent = connected ? 'Connected' : 'Disconnected';
303
+ }
304
+
305
+ /**
306
+ * @param {number | null} status
307
+ */
308
+ function statusBadgeClass(status) {
309
+ if (!status) return '';
310
+ if (status < 300) return 'status-2xx';
311
+ if (status < 400) return 'status-3xx';
312
+ if (status < 500) return 'status-4xx';
313
+ return 'status-5xx';
314
+ }
315
+
316
+ /**
317
+ * @param {number} ts
318
+ */
319
+ function formatTime(ts) {
320
+ return new Date(ts).toLocaleTimeString();
321
+ }
322
+
323
+ /**
324
+ * @param {string} str
325
+ */
326
+ function escapeHtml(str) {
327
+ return str
328
+ .replace(/&/g, '&amp;')
329
+ .replace(/</g, '&lt;')
330
+ .replace(/>/g, '&gt;')
331
+ .replace(/"/g, '&quot;');
332
+ }
333
+
334
+ document.getElementById('graph-zoom-in')?.addEventListener('click', () => {
335
+ window.OpenconsGraph?.zoomIn();
336
+ });
337
+
338
+ document.getElementById('graph-zoom-out')?.addEventListener('click', () => {
339
+ window.OpenconsGraph?.zoomOut();
340
+ });
341
+
342
+ document.getElementById('graph-zoom-reset')?.addEventListener('click', () => {
343
+ window.OpenconsGraph?.resetView();
344
+ });
345
+
346
+ document.querySelectorAll('.tab').forEach((tab) => {
347
+ tab.addEventListener('click', () => {
348
+ document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
349
+ document.querySelectorAll('.view').forEach((v) => v.classList.remove('active'));
350
+
351
+ tab.classList.add('active');
352
+ document.getElementById(`${tab.dataset.view}-view`).classList.add('active');
353
+ });
354
+ });
355
+
356
+ connect();
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const DB_ACTION_ICONS = {
4
+ select: '↗',
5
+ insert: '↳',
6
+ update: '✎',
7
+ delete: '✕',
8
+ count: '#',
9
+ transaction: '⟳',
10
+ query: '◎',
11
+ };
12
+
13
+ /**
14
+ * @param {object} node
15
+ */
16
+ function dbActionIcon(node) {
17
+ return DB_ACTION_ICONS[node.db_action] || '◎';
18
+ }
19
+
20
+ /**
21
+ * @param {object} node
22
+ */
23
+ function dbNodeTitle(node) {
24
+ if (node.label && !node.label.includes('drizzle') && !node.label.includes('pg ')) {
25
+ return node.label;
26
+ }
27
+
28
+ const action = node.db_action || 'query';
29
+ const table = node.collection || 'records';
30
+ const name = String(table).replace(/_/g, ' ').toLowerCase();
31
+
32
+ switch (action) {
33
+ case 'select':
34
+ return `Fetch ${name}`;
35
+ case 'insert':
36
+ return `Save ${name}`;
37
+ case 'update':
38
+ return `Update ${name}`;
39
+ case 'delete':
40
+ return `Delete ${name}`;
41
+ case 'count':
42
+ return `Count ${name}`;
43
+ default:
44
+ return node.label || 'Database query';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {object} node
50
+ */
51
+ function dbNodeIntent(node) {
52
+ if (node.db_intent) return node.db_intent;
53
+
54
+ const action = node.db_action || 'query';
55
+ const table = node.collection || 'records';
56
+ const name = String(table).replace(/_/g, ' ').toLowerCase();
57
+
58
+ switch (action) {
59
+ case 'select':
60
+ return `Fetching ${name}`;
61
+ case 'insert':
62
+ return `Saving to ${name}`;
63
+ case 'update':
64
+ return `Updating ${name}`;
65
+ case 'delete':
66
+ return `Removing from ${name}`;
67
+ default:
68
+ return 'Running a database query';
69
+ }
70
+ }
71
+
72
+ /**
73
+ * @param {object} node
74
+ */
75
+ function dbNodeResult(node) {
76
+ if (node.db_result) return node.db_result;
77
+ if (node.summary) return node.summary.replace(/\s·\s[\d.]+ms$/, '');
78
+ if (node.exit_reason) return `Failed — ${node.exit_reason}`;
79
+ if (node.rows === 0) return 'No rows';
80
+ if (node.rows === 1) return '1 row';
81
+ if (node.rows != null) return `${node.rows} rows`;
82
+ return 'Completed';
83
+ }
84
+
85
+ window.OpenconsDbLanguage = {
86
+ dbActionIcon,
87
+ dbNodeTitle,
88
+ dbNodeIntent,
89
+ dbNodeResult,
90
+ };