@stacknext/cli 0.0.0-stage → 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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -2
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +4 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/collector/server.d.ts +28 -0
  8. package/dist/collector/server.d.ts.map +1 -0
  9. package/dist/collector/server.js +1258 -0
  10. package/dist/collector/server.js.map +1 -0
  11. package/dist/collector/test-runs.d.ts +40 -0
  12. package/dist/collector/test-runs.d.ts.map +1 -0
  13. package/dist/collector/test-runs.js +153 -0
  14. package/dist/collector/test-runs.js.map +1 -0
  15. package/dist/commands/baseline.d.ts +25 -0
  16. package/dist/commands/baseline.d.ts.map +1 -0
  17. package/dist/commands/baseline.js +151 -0
  18. package/dist/commands/baseline.js.map +1 -0
  19. package/dist/commands/capture.d.ts +13 -0
  20. package/dist/commands/capture.d.ts.map +1 -0
  21. package/dist/commands/capture.js +277 -0
  22. package/dist/commands/capture.js.map +1 -0
  23. package/dist/commands/compare.d.ts +17 -0
  24. package/dist/commands/compare.d.ts.map +1 -0
  25. package/dist/commands/compare.js +95 -0
  26. package/dist/commands/compare.js.map +1 -0
  27. package/dist/commands/coverage.d.ts +9 -0
  28. package/dist/commands/coverage.d.ts.map +1 -0
  29. package/dist/commands/coverage.js +124 -0
  30. package/dist/commands/coverage.js.map +1 -0
  31. package/dist/commands/dev.d.ts +19 -0
  32. package/dist/commands/dev.d.ts.map +1 -0
  33. package/dist/commands/dev.js +296 -0
  34. package/dist/commands/dev.js.map +1 -0
  35. package/dist/commands/diagnose.d.ts +22 -0
  36. package/dist/commands/diagnose.d.ts.map +1 -0
  37. package/dist/commands/diagnose.js +235 -0
  38. package/dist/commands/diagnose.js.map +1 -0
  39. package/dist/commands/doctor.d.ts +7 -0
  40. package/dist/commands/doctor.d.ts.map +1 -0
  41. package/dist/commands/doctor.js +176 -0
  42. package/dist/commands/doctor.js.map +1 -0
  43. package/dist/commands/export.d.ts +9 -0
  44. package/dist/commands/export.d.ts.map +1 -0
  45. package/dist/commands/export.js +55 -0
  46. package/dist/commands/export.js.map +1 -0
  47. package/dist/commands/init.d.ts +11 -0
  48. package/dist/commands/init.d.ts.map +1 -0
  49. package/dist/commands/init.js +203 -0
  50. package/dist/commands/init.js.map +1 -0
  51. package/dist/commands/mcp.d.ts +12 -0
  52. package/dist/commands/mcp.d.ts.map +1 -0
  53. package/dist/commands/mcp.js +32 -0
  54. package/dist/commands/mcp.js.map +1 -0
  55. package/dist/commands/regression-shared.d.ts +12 -0
  56. package/dist/commands/regression-shared.d.ts.map +1 -0
  57. package/dist/commands/regression-shared.js +149 -0
  58. package/dist/commands/regression-shared.js.map +1 -0
  59. package/dist/commands/runs.d.ts +13 -0
  60. package/dist/commands/runs.d.ts.map +1 -0
  61. package/dist/commands/runs.js +85 -0
  62. package/dist/commands/runs.js.map +1 -0
  63. package/dist/commands/status.d.ts +7 -0
  64. package/dist/commands/status.d.ts.map +1 -0
  65. package/dist/commands/status.js +70 -0
  66. package/dist/commands/status.js.map +1 -0
  67. package/dist/commands/uninstall.d.ts +15 -0
  68. package/dist/commands/uninstall.d.ts.map +1 -0
  69. package/dist/commands/uninstall.js +209 -0
  70. package/dist/commands/uninstall.js.map +1 -0
  71. package/dist/http.d.ts +7 -0
  72. package/dist/http.d.ts.map +1 -0
  73. package/dist/http.js +54 -0
  74. package/dist/http.js.map +1 -0
  75. package/dist/index.d.ts +22 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +22 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/output.d.ts +16 -0
  80. package/dist/output.d.ts.map +1 -0
  81. package/dist/output.js +60 -0
  82. package/dist/output.js.map +1 -0
  83. package/dist/program.d.ts +4 -0
  84. package/dist/program.d.ts.map +1 -0
  85. package/dist/program.js +54 -0
  86. package/dist/program.js.map +1 -0
  87. package/dist/project.d.ts +35 -0
  88. package/dist/project.d.ts.map +1 -0
  89. package/dist/project.js +169 -0
  90. package/dist/project.js.map +1 -0
  91. package/dist/runtime.d.ts +26 -0
  92. package/dist/runtime.d.ts.map +1 -0
  93. package/dist/runtime.js +89 -0
  94. package/dist/runtime.js.map +1 -0
  95. package/dist/session.d.ts +10 -0
  96. package/dist/session.d.ts.map +1 -0
  97. package/dist/session.js +23 -0
  98. package/dist/session.js.map +1 -0
  99. package/dist/version.d.ts +2 -0
  100. package/dist/version.d.ts.map +1 -0
  101. package/dist/version.js +2 -0
  102. package/dist/version.js.map +1 -0
  103. package/package.json +58 -3
@@ -0,0 +1,1258 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { access, readFile, stat } from 'node:fs/promises';
3
+ import { createServer } from 'node:http';
4
+ import path from 'node:path';
5
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createRequire } from 'node:module';
8
+ import { DIAGNOSTIC_CATEGORIES, DIAGNOSTIC_RULE_IDS, DIAGNOSTIC_SEVERITIES, DIAGNOSTIC_STATUSES, TelemetryStore, computeCoverage, diagnosticReference, ingestEnvelopeSchema, nextOtelVerboseStatus, } from '@stacknext/core';
9
+ import { diagnosticOverview as buildDiagnosticOverview, generateDiagnosticReport, getDiagnosticDetail, listDiagnosticFindings, } from '@stacknext/diagnostics';
10
+ import { completeRunRequestSchema, createRunRequestSchema, listAllBaselines, listAllComparisons, listAllRuns, loadBaseline, loadComparison, loadRun, RegressionStorageError, submitSampleRequestSchema, } from '@stacknext/regression';
11
+ import { TestRunRegistry } from './test-runs.js';
12
+ import { CLI_VERSION } from '../version.js';
13
+ const VERSION = CLI_VERSION;
14
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
15
+ const SESSION_COOKIE = 'stacknext_session';
16
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1_000;
17
+ const CONNECTION_STALE_MS = 20_000;
18
+ const CONNECTION_RETENTION_MS = 10 * 60 * 1_000;
19
+ const MAX_SSE_CLIENTS = 50;
20
+ const SSE_HEARTBEAT_MS = 15_000;
21
+ const MAX_SESSIONS = 100;
22
+ function securityHeaders() {
23
+ return {
24
+ 'x-content-type-options': 'nosniff',
25
+ 'referrer-policy': 'no-referrer',
26
+ 'x-frame-options': 'DENY',
27
+ 'cross-origin-resource-policy': 'same-origin',
28
+ };
29
+ }
30
+ function dashboardCsp() {
31
+ return [
32
+ "default-src 'self'",
33
+ "script-src 'self'",
34
+ "style-src 'self' 'unsafe-inline'",
35
+ "img-src 'self' data:",
36
+ "font-src 'self'",
37
+ "connect-src 'self'",
38
+ "object-src 'none'",
39
+ "base-uri 'none'",
40
+ "form-action 'none'",
41
+ "frame-ancestors 'none'",
42
+ ].join('; ');
43
+ }
44
+ function sendJson(response, status, payload, extraHeaders = {}) {
45
+ if (response.headersSent)
46
+ return;
47
+ const body = JSON.stringify(payload);
48
+ response.writeHead(status, {
49
+ ...securityHeaders(),
50
+ 'content-type': 'application/json; charset=utf-8',
51
+ 'cache-control': 'no-store',
52
+ 'content-length': Buffer.byteLength(body),
53
+ ...extraHeaders,
54
+ });
55
+ response.end(body);
56
+ }
57
+ function sendError(response, status, message, issues) {
58
+ const payload = issues ? { error: message, issues } : { error: message };
59
+ sendJson(response, status, payload);
60
+ }
61
+ function parseCookies(header) {
62
+ if (!header)
63
+ return {};
64
+ const cookies = {};
65
+ for (const part of header.split(';')) {
66
+ const separator = part.indexOf('=');
67
+ if (separator < 0)
68
+ continue;
69
+ const key = part.slice(0, separator).trim();
70
+ const value = part.slice(separator + 1).trim();
71
+ if (key)
72
+ cookies[key] = decodeURIComponent(value);
73
+ }
74
+ return cookies;
75
+ }
76
+ function hashToken(value) {
77
+ return createHash('sha256').update(value).digest();
78
+ }
79
+ function tokensEqual(candidate, expected) {
80
+ const left = hashToken(candidate);
81
+ const right = hashToken(expected);
82
+ return timingSafeEqual(left, right);
83
+ }
84
+ function hostAllowed(hostHeader) {
85
+ if (!hostHeader)
86
+ return false;
87
+ let hostname;
88
+ try {
89
+ hostname = new URL(`http://${hostHeader}`).hostname;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ return (hostname === '127.0.0.1' ||
95
+ hostname === 'localhost' ||
96
+ hostname === '[::1]' ||
97
+ hostname === '::1');
98
+ }
99
+ function originAllowed(origin, port) {
100
+ try {
101
+ const url = new URL(origin);
102
+ return ((url.protocol === 'http:' || url.protocol === 'https:') &&
103
+ (url.hostname === '127.0.0.1' ||
104
+ url.hostname === 'localhost' ||
105
+ url.hostname === '[::1]' ||
106
+ url.hostname === '::1') &&
107
+ (url.port === '' || url.port === String(port)));
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ }
113
+ function queryNumber(value) {
114
+ if (value === null || value.trim() === '')
115
+ return undefined;
116
+ const parsed = Number(value);
117
+ return Number.isFinite(parsed) ? parsed : undefined;
118
+ }
119
+ function queryBoolean(value) {
120
+ if (value === null)
121
+ return undefined;
122
+ return value === '1' || value === 'true';
123
+ }
124
+ function filtersFromSearch(searchParams) {
125
+ const sort = searchParams.get('sort');
126
+ return {
127
+ ...(queryNumber(searchParams.get('limit')) !== undefined
128
+ ? { limit: queryNumber(searchParams.get('limit')) }
129
+ : {}),
130
+ ...(queryNumber(searchParams.get('offset')) !== undefined
131
+ ? { offset: queryNumber(searchParams.get('offset')) }
132
+ : {}),
133
+ ...(searchParams.get('q') ? { q: searchParams.get('q') ?? undefined } : {}),
134
+ ...(searchParams.get('method') ? { method: searchParams.get('method') ?? undefined } : {}),
135
+ ...(searchParams.get('status') ? { status: searchParams.get('status') ?? undefined } : {}),
136
+ ...(queryBoolean(searchParams.get('slow')) !== undefined
137
+ ? { slow: queryBoolean(searchParams.get('slow')) }
138
+ : {}),
139
+ ...(queryNumber(searchParams.get('minDuration')) !== undefined
140
+ ? { minDuration: queryNumber(searchParams.get('minDuration')) }
141
+ : {}),
142
+ ...(sort === 'recent' || sort === 'oldest' || sort === 'slowest' ? { sort } : {}),
143
+ };
144
+ }
145
+ function databaseFiltersFromSearch(searchParams) {
146
+ return {
147
+ ...(queryNumber(searchParams.get('limit')) !== undefined
148
+ ? { limit: queryNumber(searchParams.get('limit')) }
149
+ : {}),
150
+ ...(queryNumber(searchParams.get('offset')) !== undefined
151
+ ? { offset: queryNumber(searchParams.get('offset')) }
152
+ : {}),
153
+ ...(searchParams.get('q') ? { q: searchParams.get('q') ?? undefined } : {}),
154
+ ...(queryNumber(searchParams.get('minDuration')) !== undefined
155
+ ? { minDuration: queryNumber(searchParams.get('minDuration')) }
156
+ : {}),
157
+ ...(searchParams.get('traceId') ? { traceId: searchParams.get('traceId') ?? undefined } : {}),
158
+ ...(searchParams.get('databaseSystem')
159
+ ? { databaseSystem: searchParams.get('databaseSystem') ?? undefined }
160
+ : {}),
161
+ };
162
+ }
163
+ function errorFiltersFromSearch(searchParams) {
164
+ return {
165
+ ...(queryNumber(searchParams.get('limit')) !== undefined
166
+ ? { limit: queryNumber(searchParams.get('limit')) }
167
+ : {}),
168
+ ...(queryNumber(searchParams.get('offset')) !== undefined
169
+ ? { offset: queryNumber(searchParams.get('offset')) }
170
+ : {}),
171
+ ...(searchParams.get('q') ? { q: searchParams.get('q') ?? undefined } : {}),
172
+ ...(searchParams.get('route') ? { route: searchParams.get('route') ?? undefined } : {}),
173
+ ...(searchParams.get('name') ? { name: searchParams.get('name') ?? undefined } : {}),
174
+ };
175
+ }
176
+ function oneOf(value, allowed) {
177
+ if (!value)
178
+ return undefined;
179
+ return allowed.includes(value) ? value : undefined;
180
+ }
181
+ function diagnosticFiltersFromSearch(searchParams) {
182
+ const categoryValue = searchParams.get('category');
183
+ const severityValue = searchParams.get('severity');
184
+ const statusValue = searchParams.get('status');
185
+ const ruleValue = searchParams.get('ruleId');
186
+ const category = oneOf(categoryValue, DIAGNOSTIC_CATEGORIES);
187
+ const severity = oneOf(severityValue, DIAGNOSTIC_SEVERITIES);
188
+ const status = oneOf(statusValue, DIAGNOSTIC_STATUSES);
189
+ const ruleId = oneOf(ruleValue, DIAGNOSTIC_RULE_IDS);
190
+ if (categoryValue && !category)
191
+ return { filters: {}, error: 'Invalid diagnostic category' };
192
+ if (severityValue && !severity)
193
+ return { filters: {}, error: 'Invalid diagnostic severity' };
194
+ if (statusValue && !status)
195
+ return { filters: {}, error: 'Invalid diagnostic status' };
196
+ if (ruleValue && !ruleId)
197
+ return { filters: {}, error: 'Invalid diagnostic rule id' };
198
+ return {
199
+ filters: {
200
+ ...(queryNumber(searchParams.get('limit')) !== undefined
201
+ ? { limit: queryNumber(searchParams.get('limit')) }
202
+ : {}),
203
+ ...(queryNumber(searchParams.get('offset')) !== undefined
204
+ ? { offset: queryNumber(searchParams.get('offset')) }
205
+ : {}),
206
+ ...(category ? { category } : {}),
207
+ ...(severity ? { severity } : {}),
208
+ ...(status ? { status } : {}),
209
+ ...(ruleId ? { ruleId } : {}),
210
+ ...(searchParams.get('route') ? { route: searchParams.get('route') ?? undefined } : {}),
211
+ ...(searchParams.get('traceId')
212
+ ? { traceId: searchParams.get('traceId') ?? undefined }
213
+ : {}),
214
+ ...(searchParams.get('q') ? { q: searchParams.get('q') ?? undefined } : {}),
215
+ ...(queryNumber(searchParams.get('since')) !== undefined
216
+ ? { since: queryNumber(searchParams.get('since')) }
217
+ : {}),
218
+ ...(queryNumber(searchParams.get('until')) !== undefined
219
+ ? { until: queryNumber(searchParams.get('until')) }
220
+ : {}),
221
+ },
222
+ };
223
+ }
224
+ async function readBody(request, limit) {
225
+ const contentLength = Number(request.headers['content-length'] ?? '0');
226
+ if (Number.isFinite(contentLength) && contentLength > limit) {
227
+ request.resume();
228
+ return { ok: false, tooLarge: true };
229
+ }
230
+ const chunks = [];
231
+ let total = 0;
232
+ let tooLarge = false;
233
+ for await (const chunk of request) {
234
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
235
+ total += buffer.length;
236
+ if (total > limit) {
237
+ tooLarge = true;
238
+ continue;
239
+ }
240
+ chunks.push(buffer);
241
+ }
242
+ if (tooLarge)
243
+ return { ok: false, tooLarge: true };
244
+ return { ok: true, body: Buffer.concat(chunks).toString('utf8') };
245
+ }
246
+ function contentTypeFor(file) {
247
+ switch (path.extname(file).toLowerCase()) {
248
+ case '.html':
249
+ return 'text/html; charset=utf-8';
250
+ case '.js':
251
+ case '.mjs':
252
+ return 'text/javascript; charset=utf-8';
253
+ case '.css':
254
+ return 'text/css; charset=utf-8';
255
+ case '.json':
256
+ return 'application/json; charset=utf-8';
257
+ case '.svg':
258
+ return 'image/svg+xml';
259
+ case '.png':
260
+ return 'image/png';
261
+ case '.ico':
262
+ return 'image/x-icon';
263
+ case '.webp':
264
+ return 'image/webp';
265
+ case '.woff2':
266
+ return 'font/woff2';
267
+ case '.txt':
268
+ return 'text/plain; charset=utf-8';
269
+ default:
270
+ return 'application/octet-stream';
271
+ }
272
+ }
273
+ export async function resolveDashboardDir() {
274
+ const candidates = [];
275
+ if (process.env.STACKNEXT_DASHBOARD_DIR) {
276
+ candidates.push(path.resolve(process.env.STACKNEXT_DASHBOARD_DIR));
277
+ }
278
+ try {
279
+ const require = createRequire(import.meta.url);
280
+ const packageJsonPath = require.resolve('@stacknext/dashboard/package.json');
281
+ candidates.push(path.join(path.dirname(packageJsonPath), 'dist'));
282
+ }
283
+ catch {
284
+ // Workspace fallback below.
285
+ }
286
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
287
+ candidates.push(path.resolve(moduleDir, '../../../dashboard/dist'));
288
+ candidates.push(path.resolve(moduleDir, '../../dashboard/dist'));
289
+ candidates.push(path.resolve(process.cwd(), 'packages/dashboard/dist'));
290
+ for (const candidate of candidates) {
291
+ try {
292
+ await access(path.join(candidate, 'index.html'));
293
+ return candidate;
294
+ }
295
+ catch {
296
+ // Keep searching.
297
+ }
298
+ }
299
+ throw new Error('Dashboard build not found. Run "pnpm build" (or "pnpm --filter @stacknext/dashboard build") before starting StackNext.');
300
+ }
301
+ function listen(server, port, host) {
302
+ return new Promise((resolve, reject) => {
303
+ const onError = (error) => {
304
+ server.off('listening', onListening);
305
+ reject(error);
306
+ };
307
+ const onListening = () => {
308
+ server.off('error', onError);
309
+ const address = server.address();
310
+ if (typeof address === 'object' && address !== null)
311
+ resolve(address.port);
312
+ else
313
+ reject(new Error('Collector did not bind a TCP port'));
314
+ };
315
+ server.once('error', onError);
316
+ server.once('listening', onListening);
317
+ server.listen(port, host);
318
+ });
319
+ }
320
+ export async function startCollector(options) {
321
+ const host = options.host ?? '127.0.0.1';
322
+ if (host !== '127.0.0.1' && host !== 'localhost') {
323
+ throw new Error('StackNext collector may only bind to a loopback address');
324
+ }
325
+ if (options.token.length < 16) {
326
+ throw new Error('Collector token must be at least 16 characters');
327
+ }
328
+ const log = options.log ?? (() => undefined);
329
+ const store = new TelemetryStore({
330
+ maxSpans: options.config.collector.maxSpans,
331
+ retentionMs: options.config.collector.retentionMs,
332
+ });
333
+ const verboseStatus = options.nextOtelVerbose ??
334
+ nextOtelVerboseStatus(options.config.instrumentation.verbose, process.env.NEXT_OTEL_VERBOSE);
335
+ const startedAt = Date.now();
336
+ const sessions = new Map();
337
+ const connections = new Map();
338
+ const sseClients = new Set();
339
+ const serverStats = {
340
+ rejectedRequests: 0,
341
+ droppedPayload: 0,
342
+ droppedMalformed: 0,
343
+ };
344
+ let sequence = 0;
345
+ let port = options.port;
346
+ let dashboardUrl = `http://${host}:${port}`;
347
+ let diagnosticCache = null;
348
+ const getConnections = (now) => {
349
+ const output = [];
350
+ for (const [instanceId, record] of connections) {
351
+ if (now - record.lastSeen > CONNECTION_RETENTION_MS) {
352
+ connections.delete(instanceId);
353
+ continue;
354
+ }
355
+ output.push({
356
+ ...record.info,
357
+ connected: now - record.lastSeen <= CONNECTION_STALE_MS,
358
+ });
359
+ }
360
+ return output.sort((a, b) => b.lastSeen - a.lastSeen);
361
+ };
362
+ const getHealth = () => {
363
+ const now = Date.now();
364
+ const summary = store.summary(options.config.slowRequestMs);
365
+ const storeStats = store.getStats();
366
+ const connectionList = getConnections(now);
367
+ const diagnostics = connectionList
368
+ .map((connection) => connection.diagnostics)
369
+ .filter((entry) => entry !== undefined);
370
+ const lastError = diagnostics
371
+ .map((entry) => entry.lastError)
372
+ .filter((entry) => Boolean(entry))
373
+ .sort()
374
+ .at(-1) ?? null;
375
+ return {
376
+ status: 'ok',
377
+ name: 'stacknext',
378
+ version: options.version ?? VERSION,
379
+ projectName: options.projectName,
380
+ dashboardUrl,
381
+ startedAt,
382
+ uptimeMs: now - startedAt,
383
+ time: now,
384
+ connected: connectionList.some((connection) => connection.connected),
385
+ connections: connectionList,
386
+ instrumentation: {
387
+ nextOtelVerbose: verboseStatus,
388
+ },
389
+ counts: {
390
+ requests: summary.totalRequests,
391
+ failedRequests: summary.failedRequests,
392
+ slowRequests: summary.slowRequests,
393
+ spans: summary.spanCount,
394
+ errors: summary.errorCount,
395
+ databaseQueries: summary.databaseQueryCount,
396
+ },
397
+ store: storeStats,
398
+ server: {
399
+ ...serverStats,
400
+ sseClients: sseClients.size,
401
+ },
402
+ exporter: {
403
+ droppedSpans: diagnostics.reduce((sum, entry) => sum + entry.droppedSpans, 0),
404
+ droppedEvents: diagnostics.reduce((sum, entry) => sum + entry.droppedEvents, 0),
405
+ lastError,
406
+ },
407
+ config: options.config,
408
+ };
409
+ };
410
+ const getCoverage = () => {
411
+ const now = Date.now();
412
+ return computeCoverage({
413
+ projectName: options.projectName,
414
+ collectorVersion: options.version ?? VERSION,
415
+ now,
416
+ config: options.config,
417
+ connections: getConnections(now),
418
+ verbose: verboseStatus,
419
+ evidence: store.getCoverageEvidence(),
420
+ });
421
+ };
422
+ const getDiagnosticState = () => {
423
+ const now = Date.now();
424
+ const snapshot = store.getDiagnosticSnapshot(options.config.slowRequestMs);
425
+ const connectionList = getConnections(now);
426
+ if (!diagnosticCache || diagnosticCache.revision !== snapshot.revision) {
427
+ diagnosticCache = {
428
+ revision: snapshot.revision,
429
+ report: generateDiagnosticReport(snapshot, {
430
+ projectName: options.projectName,
431
+ collectorVersion: options.version ?? VERSION,
432
+ config: options.config.diagnostics,
433
+ generatedAt: now,
434
+ connected: connectionList.some((connection) => connection.connected),
435
+ connectedInstances: connectionList.filter((connection) => connection.connected).length,
436
+ }),
437
+ };
438
+ }
439
+ const cached = diagnosticCache.report;
440
+ return {
441
+ snapshot,
442
+ report: {
443
+ ...cached,
444
+ generatedAt: now,
445
+ sample: {
446
+ ...cached.sample,
447
+ connected: connectionList.some((connection) => connection.connected),
448
+ connectedInstances: connectionList.filter((connection) => connection.connected).length,
449
+ },
450
+ },
451
+ };
452
+ };
453
+ const getDiagnosticReport = () => getDiagnosticState().report;
454
+ const testRuns = new TestRunRegistry({
455
+ slowRequestMs: options.config.slowRequestMs,
456
+ isConnected: () => getConnections(Date.now()).some((connection) => connection.connected),
457
+ getDiagnosticReport,
458
+ lookupTrace: (traceId) => store.getTrace(traceId, options.config.slowRequestMs) ?? null,
459
+ });
460
+ const traceWithDiagnostics = (detail) => {
461
+ const references = listDiagnosticFindings(getDiagnosticReport(), {
462
+ traceId: detail.traceId,
463
+ limit: 200,
464
+ }).items.map(diagnosticReference);
465
+ return { ...detail, diagnostics: references };
466
+ };
467
+ const recordConnection = (instance, diagnostics, now) => {
468
+ const existing = connections.get(instance.instanceId);
469
+ connections.set(instance.instanceId, {
470
+ info: {
471
+ instanceId: instance.instanceId,
472
+ serviceName: instance.serviceName,
473
+ ...(instance.projectName ?? options.projectName
474
+ ? { projectName: instance.projectName ?? options.projectName }
475
+ : {}),
476
+ ...(instance.nextVersion ? { nextVersion: instance.nextVersion } : {}),
477
+ ...(instance.runtimeVersion ? { runtimeVersion: instance.runtimeVersion } : {}),
478
+ sdkVersion: instance.sdkVersion,
479
+ lastSeen: now,
480
+ ...(diagnostics ? { diagnostics } : existing?.info.diagnostics
481
+ ? { diagnostics: existing.info.diagnostics }
482
+ : {}),
483
+ },
484
+ lastSeen: now,
485
+ });
486
+ if (connections.size > 20) {
487
+ const oldest = [...connections.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)[0];
488
+ if (oldest)
489
+ connections.delete(oldest[0]);
490
+ }
491
+ };
492
+ const broadcast = (event, payload) => {
493
+ const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
494
+ for (const client of sseClients) {
495
+ try {
496
+ client.res.write(message);
497
+ }
498
+ catch {
499
+ sseClients.delete(client);
500
+ }
501
+ }
502
+ };
503
+ const heartbeatInterval = setInterval(() => {
504
+ for (const client of sseClients) {
505
+ try {
506
+ client.res.write(': heartbeat\n\n');
507
+ }
508
+ catch {
509
+ sseClients.delete(client);
510
+ }
511
+ }
512
+ }, options.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
513
+ heartbeatInterval.unref();
514
+ const sessionForRequest = (request, response) => {
515
+ const cookies = parseCookies(request.headers.cookie);
516
+ const existing = cookies[SESSION_COOKIE];
517
+ const now = Date.now();
518
+ if (existing && sessions.has(existing)) {
519
+ const createdAt = sessions.get(existing) ?? 0;
520
+ if (now - createdAt <= SESSION_TTL_MS)
521
+ return existing;
522
+ sessions.delete(existing);
523
+ }
524
+ const session = randomBytes(32).toString('base64url');
525
+ sessions.set(session, now);
526
+ if (sessions.size > MAX_SESSIONS) {
527
+ const oldest = [...sessions.entries()].sort((a, b) => a[1] - b[1])[0];
528
+ if (oldest)
529
+ sessions.delete(oldest[0]);
530
+ }
531
+ response.setHeader('set-cookie', `${SESSION_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`);
532
+ return session;
533
+ };
534
+ const hasSession = (request) => {
535
+ const cookies = parseCookies(request.headers.cookie);
536
+ const session = cookies[SESSION_COOKIE];
537
+ if (!session)
538
+ return false;
539
+ const createdAt = sessions.get(session);
540
+ return createdAt !== undefined && Date.now() - createdAt <= SESSION_TTL_MS;
541
+ };
542
+ const isReadAuthorized = (request) => {
543
+ const authorization = request.headers.authorization;
544
+ if (authorization?.startsWith('Bearer ')) {
545
+ return tokensEqual(authorization.slice('Bearer '.length), options.token);
546
+ }
547
+ return hasSession(request);
548
+ };
549
+ const isIngestAuthorized = (request) => {
550
+ const authorization = request.headers.authorization;
551
+ if (!authorization?.startsWith('Bearer '))
552
+ return false;
553
+ return tokensEqual(authorization.slice('Bearer '.length), options.token);
554
+ };
555
+ const serveIndex = async (response, cookies) => {
556
+ if (!options.dashboardDir) {
557
+ const html = '<!doctype html><html><head><meta charset="utf-8"><title>StackNext</title></head><body style="font-family:system-ui;background:#0b0d10;color:#e6e8eb;padding:3rem"><h1>Dashboard build not found</h1><p>Run <code>pnpm build</code>, then restart <code>stacknext dev</code>.</p></body></html>';
558
+ response.writeHead(503, {
559
+ ...securityHeaders(),
560
+ 'content-type': 'text/html; charset=utf-8',
561
+ 'cache-control': 'no-store',
562
+ 'content-security-policy': dashboardCsp(),
563
+ });
564
+ response.end(html);
565
+ return;
566
+ }
567
+ const file = path.join(options.dashboardDir, 'index.html');
568
+ const html = await readFile(file);
569
+ if (cookies) {
570
+ // Set-Cookie is attached by sessionForRequest before this function runs.
571
+ response.setHeader('set-cookie', response.getHeader('set-cookie') ?? []);
572
+ }
573
+ response.writeHead(200, {
574
+ ...securityHeaders(),
575
+ 'content-type': 'text/html; charset=utf-8',
576
+ 'cache-control': 'no-store',
577
+ 'content-security-policy': dashboardCsp(),
578
+ 'content-length': html.length,
579
+ });
580
+ response.end(html);
581
+ };
582
+ const serveStatic = async (pathname, request, response, setSession) => {
583
+ if (!options.dashboardDir)
584
+ return false;
585
+ if (pathname === '/' || !path.extname(pathname)) {
586
+ if (setSession)
587
+ sessionForRequest(request, response);
588
+ await serveIndex(response, true);
589
+ return true;
590
+ }
591
+ const decoded = decodeURIComponent(pathname);
592
+ const relative = decoded.replace(/^\/+/, '');
593
+ const resolved = path.resolve(options.dashboardDir, relative);
594
+ const root = path.resolve(options.dashboardDir);
595
+ if (!resolved.startsWith(`${root}${path.sep}`)) {
596
+ sendError(response, 403, 'Forbidden');
597
+ return true;
598
+ }
599
+ try {
600
+ const info = await stat(resolved);
601
+ if (!info.isFile())
602
+ return false;
603
+ const headers = {
604
+ ...securityHeaders(),
605
+ 'content-type': contentTypeFor(resolved),
606
+ 'content-length': info.size,
607
+ 'cache-control': relative.startsWith('assets/')
608
+ ? 'public, max-age=31536000, immutable'
609
+ : 'no-store',
610
+ };
611
+ response.writeHead(200, headers);
612
+ if (request.method === 'HEAD') {
613
+ response.end();
614
+ return true;
615
+ }
616
+ const stream = createReadStream(resolved);
617
+ stream.on('error', () => {
618
+ if (!response.headersSent)
619
+ sendError(response, 500, 'Could not read asset');
620
+ else
621
+ response.destroy();
622
+ });
623
+ stream.pipe(response);
624
+ return true;
625
+ }
626
+ catch {
627
+ return false;
628
+ }
629
+ };
630
+ const handleIngest = async (request, response) => {
631
+ if (!isIngestAuthorized(request)) {
632
+ serverStats.rejectedRequests += 1;
633
+ sendError(response, 401, 'Unauthorized');
634
+ return;
635
+ }
636
+ const contentType = String(request.headers['content-type'] ?? '');
637
+ if (!contentType.toLowerCase().includes('application/json')) {
638
+ serverStats.rejectedRequests += 1;
639
+ sendError(response, 415, 'Content-Type must be application/json');
640
+ return;
641
+ }
642
+ const body = await readBody(request, MAX_BODY_BYTES);
643
+ if (!body.ok) {
644
+ serverStats.rejectedRequests += 1;
645
+ serverStats.droppedPayload += 1;
646
+ sendError(response, 413, `Payload exceeds ${MAX_BODY_BYTES} bytes`);
647
+ return;
648
+ }
649
+ let parsed;
650
+ try {
651
+ parsed = JSON.parse(body.body);
652
+ }
653
+ catch {
654
+ serverStats.rejectedRequests += 1;
655
+ serverStats.droppedMalformed += 1;
656
+ sendError(response, 400, 'Malformed JSON payload');
657
+ return;
658
+ }
659
+ const result = ingestEnvelopeSchema.safeParse(parsed);
660
+ if (!result.success) {
661
+ serverStats.rejectedRequests += 1;
662
+ serverStats.droppedMalformed += 1;
663
+ sendError(response, 422, 'Telemetry payload failed validation', result.error.issues.map((issue) => issue.path.join('.') || '(root)'));
664
+ return;
665
+ }
666
+ const envelope = result.data;
667
+ const now = Date.now();
668
+ store.ingest(envelope, now);
669
+ recordConnection(envelope.instance, envelope.diagnostics, now);
670
+ const accepted = {
671
+ spans: envelope.spans.length,
672
+ errors: envelope.errors.length,
673
+ databaseQueries: envelope.databaseQueries.length,
674
+ };
675
+ if (accepted.spans + accepted.errors + accepted.databaseQueries > 0) {
676
+ sequence += 1;
677
+ broadcast('telemetry', {
678
+ type: 'telemetry',
679
+ sequence,
680
+ accepted,
681
+ time: now,
682
+ });
683
+ }
684
+ sendJson(response, 202, { ok: true, accepted, serverTime: now }, { 'x-stacknext-sequence': String(sequence) });
685
+ };
686
+ const handleTestRunWrite = async (request, response, url) => {
687
+ if (!isIngestAuthorized(request)) {
688
+ serverStats.rejectedRequests += 1;
689
+ sendError(response, 401, 'Unauthorized');
690
+ return;
691
+ }
692
+ const contentType = String(request.headers['content-type'] ?? '');
693
+ if (!contentType.toLowerCase().includes('application/json')) {
694
+ serverStats.rejectedRequests += 1;
695
+ sendError(response, 415, 'Content-Type must be application/json');
696
+ return;
697
+ }
698
+ const body = await readBody(request, MAX_BODY_BYTES);
699
+ if (!body.ok) {
700
+ serverStats.rejectedRequests += 1;
701
+ serverStats.droppedPayload += 1;
702
+ sendError(response, 413, `Payload exceeds ${MAX_BODY_BYTES} bytes`);
703
+ return;
704
+ }
705
+ let parsed;
706
+ try {
707
+ parsed = JSON.parse(body.body);
708
+ }
709
+ catch {
710
+ serverStats.rejectedRequests += 1;
711
+ serverStats.droppedMalformed += 1;
712
+ sendError(response, 400, 'Malformed JSON payload');
713
+ return;
714
+ }
715
+ if (url.pathname === '/api/test-runs') {
716
+ const result = createRunRequestSchema.safeParse(parsed);
717
+ if (!result.success) {
718
+ sendError(response, 422, 'Test run payload failed validation', result.error.issues.map((issue) => issue.path.join('.') || '(root)'));
719
+ return;
720
+ }
721
+ const run = testRuns.create(result.data);
722
+ sendJson(response, 201, { run });
723
+ return;
724
+ }
725
+ const sampleMatch = /^\/api\/test-runs\/(run-[0-9a-f]{16})\/samples$/.exec(url.pathname);
726
+ if (sampleMatch?.[1]) {
727
+ const result = submitSampleRequestSchema.safeParse(parsed);
728
+ if (!result.success) {
729
+ sendError(response, 422, 'Captured sample payload failed validation', result.error.issues.map((issue) => issue.path.join('.') || '(root)'));
730
+ return;
731
+ }
732
+ const submitted = testRuns.submit(sampleMatch[1], result.data);
733
+ if (!submitted.ok) {
734
+ if (submitted.code === 'not-found') {
735
+ sendError(response, 404, 'Test run not found');
736
+ }
737
+ else if (submitted.code === 'duplicate-sample') {
738
+ sendError(response, 409, 'Sample id already exists for this scenario');
739
+ }
740
+ else {
741
+ sendError(response, 409, 'Test run is no longer accepting samples');
742
+ }
743
+ return;
744
+ }
745
+ sendJson(response, 200, {
746
+ sample: submitted.sample,
747
+ run: submitted.run,
748
+ });
749
+ return;
750
+ }
751
+ const completeMatch = /^\/api\/test-runs\/(run-[0-9a-f]{16})\/complete$/.exec(url.pathname);
752
+ if (completeMatch?.[1]) {
753
+ const result = completeRunRequestSchema.safeParse(parsed);
754
+ if (!result.success) {
755
+ sendError(response, 422, 'Test run completion payload failed validation', result.error.issues.map((issue) => issue.path.join('.') || '(root)'));
756
+ return;
757
+ }
758
+ const run = testRuns.complete(completeMatch[1], result.data);
759
+ if (!run) {
760
+ sendError(response, 404, 'Test run not found');
761
+ return;
762
+ }
763
+ sendJson(response, 200, { run });
764
+ return;
765
+ }
766
+ sendError(response, 404, 'Not found');
767
+ };
768
+ const handleSse = (request, response) => {
769
+ if (sseClients.size >= MAX_SSE_CLIENTS) {
770
+ sendError(response, 429, 'Too many live dashboard connections');
771
+ return;
772
+ }
773
+ response.writeHead(200, {
774
+ ...securityHeaders(),
775
+ 'content-type': 'text/event-stream; charset=utf-8',
776
+ 'cache-control': 'no-cache, no-transform',
777
+ connection: 'keep-alive',
778
+ 'x-accel-buffering': 'no',
779
+ });
780
+ response.write(`event: ready\ndata: ${JSON.stringify({ type: 'ready', sequence, time: Date.now() })}\n\n`);
781
+ const client = { res: response };
782
+ sseClients.add(client);
783
+ request.on('close', () => {
784
+ sseClients.delete(client);
785
+ });
786
+ };
787
+ const pageItems = (items, searchParams, invalid = 0) => {
788
+ const limit = Math.min(Math.max(queryNumber(searchParams.get('limit')) ?? 50, 1), 200);
789
+ const offset = Math.max(queryNumber(searchParams.get('offset')) ?? 0, 0);
790
+ return {
791
+ items: items.slice(offset, offset + limit),
792
+ total: items.length,
793
+ limit,
794
+ offset,
795
+ invalid,
796
+ };
797
+ };
798
+ const savedRuns = async () => {
799
+ if (!options.projectDir)
800
+ return { items: [], invalid: 0 };
801
+ try {
802
+ return await listAllRuns(options.projectDir);
803
+ }
804
+ catch (error) {
805
+ log(`could not list saved test runs: ${error instanceof Error ? error.message : String(error)}`);
806
+ return { items: [], invalid: 0 };
807
+ }
808
+ };
809
+ const savedBaselines = async () => {
810
+ if (!options.projectDir)
811
+ return { items: [], invalid: 0 };
812
+ return listAllBaselines(options.projectDir);
813
+ };
814
+ const savedComparisons = async () => {
815
+ if (!options.projectDir)
816
+ return { items: [], invalid: 0 };
817
+ return listAllComparisons(options.projectDir);
818
+ };
819
+ const sendStorageError = (response, error, label) => {
820
+ if (error instanceof RegressionStorageError) {
821
+ if (error.code === 'not-found') {
822
+ sendError(response, 404, `${label} not found`);
823
+ return;
824
+ }
825
+ sendError(response, 422, error.message);
826
+ return;
827
+ }
828
+ log(`could not read ${label.toLowerCase()}: ${error instanceof Error ? error.message : String(error)}`);
829
+ sendError(response, 500, `Could not read ${label.toLowerCase()}`);
830
+ };
831
+ const handleApi = async (request, response, url) => {
832
+ const pathname = url.pathname;
833
+ if (pathname === '/api/ping') {
834
+ sendJson(response, 200, {
835
+ ok: true,
836
+ name: 'stacknext',
837
+ version: options.version ?? VERSION,
838
+ });
839
+ return;
840
+ }
841
+ if (pathname === '/api/stream') {
842
+ if (!isReadAuthorized(request)) {
843
+ sendError(response, 401, 'Unauthorized');
844
+ return;
845
+ }
846
+ handleSse(request, response);
847
+ return;
848
+ }
849
+ if (!isReadAuthorized(request)) {
850
+ serverStats.rejectedRequests += 1;
851
+ sendError(response, 401, 'Unauthorized');
852
+ return;
853
+ }
854
+ if (pathname === '/api/health') {
855
+ sendJson(response, 200, getHealth());
856
+ return;
857
+ }
858
+ if (pathname === '/api/summary') {
859
+ const payload = {
860
+ health: getHealth(),
861
+ summary: store.summary(options.config.slowRequestMs),
862
+ coverage: getCoverage(),
863
+ diagnosticOverview: buildDiagnosticOverview(getDiagnosticReport()),
864
+ };
865
+ sendJson(response, 200, payload);
866
+ return;
867
+ }
868
+ if (pathname === '/api/coverage') {
869
+ sendJson(response, 200, getCoverage());
870
+ return;
871
+ }
872
+ if (pathname === '/api/test-runs') {
873
+ try {
874
+ const live = testRuns.list();
875
+ const seen = new Set(live.map((run) => run.id));
876
+ const disk = await savedRuns();
877
+ const all = [...live, ...disk.items.filter((run) => !seen.has(run.id))].sort((left, right) => right.createdAt - left.createdAt ||
878
+ (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
879
+ sendJson(response, 200, pageItems(all, url.searchParams, disk.invalid));
880
+ }
881
+ catch (error) {
882
+ sendStorageError(response, error, 'Test runs');
883
+ }
884
+ return;
885
+ }
886
+ if (pathname.startsWith('/api/test-runs/')) {
887
+ const id = decodeURIComponent(pathname.slice('/api/test-runs/'.length));
888
+ if (!/^run-[0-9a-f]{16}$/.test(id)) {
889
+ sendError(response, 400, 'Invalid test run id');
890
+ return;
891
+ }
892
+ const live = testRuns.get(id);
893
+ if (live) {
894
+ sendJson(response, 200, live);
895
+ return;
896
+ }
897
+ if (!options.projectDir) {
898
+ sendError(response, 404, 'Test run not found');
899
+ return;
900
+ }
901
+ try {
902
+ sendJson(response, 200, await loadRun(options.projectDir, id));
903
+ }
904
+ catch (error) {
905
+ sendStorageError(response, error, 'Test run');
906
+ }
907
+ return;
908
+ }
909
+ if (pathname === '/api/baselines') {
910
+ if (!options.projectDir) {
911
+ sendJson(response, 200, pageItems([], url.searchParams));
912
+ return;
913
+ }
914
+ try {
915
+ const records = await savedBaselines();
916
+ sendJson(response, 200, pageItems(records.items, url.searchParams, records.invalid));
917
+ }
918
+ catch (error) {
919
+ sendStorageError(response, error, 'Baselines');
920
+ }
921
+ return;
922
+ }
923
+ if (pathname.startsWith('/api/baselines/')) {
924
+ const id = decodeURIComponent(pathname.slice('/api/baselines/'.length));
925
+ if (!/^base-[0-9a-f]{16}$/.test(id)) {
926
+ sendError(response, 400, 'Invalid baseline id');
927
+ return;
928
+ }
929
+ if (!options.projectDir) {
930
+ sendError(response, 404, 'Baseline not found');
931
+ return;
932
+ }
933
+ try {
934
+ sendJson(response, 200, await loadBaseline(options.projectDir, id));
935
+ }
936
+ catch (error) {
937
+ sendStorageError(response, error, 'Baseline');
938
+ }
939
+ return;
940
+ }
941
+ if (pathname === '/api/comparisons') {
942
+ if (!options.projectDir) {
943
+ sendJson(response, 200, pageItems([], url.searchParams));
944
+ return;
945
+ }
946
+ try {
947
+ const records = await savedComparisons();
948
+ sendJson(response, 200, pageItems(records.items, url.searchParams, records.invalid));
949
+ }
950
+ catch (error) {
951
+ sendStorageError(response, error, 'Comparisons');
952
+ }
953
+ return;
954
+ }
955
+ if (pathname.startsWith('/api/comparisons/')) {
956
+ const id = decodeURIComponent(pathname.slice('/api/comparisons/'.length));
957
+ if (!/^cmp-[0-9a-f]{16}$/.test(id)) {
958
+ sendError(response, 400, 'Invalid comparison id');
959
+ return;
960
+ }
961
+ if (!options.projectDir) {
962
+ sendError(response, 404, 'Comparison not found');
963
+ return;
964
+ }
965
+ try {
966
+ sendJson(response, 200, await loadComparison(options.projectDir, id));
967
+ }
968
+ catch (error) {
969
+ sendStorageError(response, error, 'Comparison');
970
+ }
971
+ return;
972
+ }
973
+ if (pathname === '/api/diagnostics') {
974
+ const parsed = diagnosticFiltersFromSearch(url.searchParams);
975
+ if (parsed.error) {
976
+ sendError(response, 400, parsed.error);
977
+ return;
978
+ }
979
+ const state = getDiagnosticState();
980
+ sendJson(response, 200, listDiagnosticFindings(state.report, parsed.filters));
981
+ return;
982
+ }
983
+ if (pathname.startsWith('/api/diagnostics/')) {
984
+ const id = decodeURIComponent(pathname.slice('/api/diagnostics/'.length));
985
+ if (!/^diag-[0-9a-f]{8}-[0-9a-f]{8}$/.test(id)) {
986
+ sendError(response, 400, 'Invalid diagnostic id');
987
+ return;
988
+ }
989
+ const state = getDiagnosticState();
990
+ const detail = getDiagnosticDetail(state.report, id, state.snapshot);
991
+ if (!detail) {
992
+ sendError(response, 404, 'Diagnostic not found');
993
+ return;
994
+ }
995
+ sendJson(response, 200, detail);
996
+ return;
997
+ }
998
+ if (pathname === '/api/config') {
999
+ sendJson(response, 200, {
1000
+ config: options.config,
1001
+ projectName: options.projectName,
1002
+ version: options.version ?? VERSION,
1003
+ instrumentation: {
1004
+ nextOtelVerbose: verboseStatus,
1005
+ },
1006
+ limitations: [
1007
+ 'Next.js built-in spans are limited to the operations Next.js exposes through OpenTelemetry.',
1008
+ 'Server Components are not individually traced; route render spans are captured instead.',
1009
+ 'Server Actions are not automatically identified by name. Use withStackNextAction() for successful actions.',
1010
+ 'Drizzle Logger does not expose query duration; durations are null for Drizzle queries.',
1011
+ 'Edge Runtime and production deployments are not supported.',
1012
+ ],
1013
+ });
1014
+ return;
1015
+ }
1016
+ if (pathname === '/api/requests') {
1017
+ sendJson(response, 200, store.listRequests(filtersFromSearch(url.searchParams), options.config.slowRequestMs));
1018
+ return;
1019
+ }
1020
+ if (pathname.startsWith('/api/requests/')) {
1021
+ const traceId = pathname.slice('/api/requests/'.length);
1022
+ if (!/^[0-9a-f]{32}$/i.test(traceId)) {
1023
+ sendError(response, 400, 'Invalid trace id');
1024
+ return;
1025
+ }
1026
+ const detail = store.getRequest(traceId, options.config.slowRequestMs);
1027
+ if (!detail) {
1028
+ sendError(response, 404, 'Request not found');
1029
+ return;
1030
+ }
1031
+ sendJson(response, 200, traceWithDiagnostics(detail));
1032
+ return;
1033
+ }
1034
+ if (pathname === '/api/traces') {
1035
+ const result = store.listTraces(filtersFromSearch(url.searchParams), options.config.slowRequestMs);
1036
+ sendJson(response, 200, result);
1037
+ return;
1038
+ }
1039
+ if (pathname.startsWith('/api/traces/')) {
1040
+ const traceId = pathname.slice('/api/traces/'.length);
1041
+ if (!/^[0-9a-f]{32}$/i.test(traceId)) {
1042
+ sendError(response, 400, 'Invalid trace id');
1043
+ return;
1044
+ }
1045
+ const detail = store.getTrace(traceId, options.config.slowRequestMs);
1046
+ if (!detail) {
1047
+ sendError(response, 404, 'Trace not found');
1048
+ return;
1049
+ }
1050
+ sendJson(response, 200, traceWithDiagnostics(detail));
1051
+ return;
1052
+ }
1053
+ if (pathname === '/api/errors') {
1054
+ const result = store.listErrors(errorFiltersFromSearch(url.searchParams));
1055
+ const payload = {
1056
+ items: result.items,
1057
+ total: result.total,
1058
+ limit: result.limit,
1059
+ offset: result.offset,
1060
+ groups: result.groups,
1061
+ };
1062
+ sendJson(response, 200, payload);
1063
+ return;
1064
+ }
1065
+ if (pathname.startsWith('/api/errors/')) {
1066
+ const id = decodeURIComponent(pathname.slice('/api/errors/'.length));
1067
+ if (!id || id.length > 128) {
1068
+ sendError(response, 400, 'Invalid error id');
1069
+ return;
1070
+ }
1071
+ const detail = store.getError(id, options.config.slowRequestMs);
1072
+ if (!detail) {
1073
+ sendError(response, 404, 'Error not found');
1074
+ return;
1075
+ }
1076
+ const payload = detail;
1077
+ sendJson(response, 200, payload);
1078
+ return;
1079
+ }
1080
+ if (pathname === '/api/database') {
1081
+ const result = store.listDatabaseQueries(databaseFiltersFromSearch(url.searchParams));
1082
+ sendJson(response, 200, result);
1083
+ return;
1084
+ }
1085
+ if (pathname === '/api/export') {
1086
+ const data = store.exportAll();
1087
+ const payload = {
1088
+ format: 'stacknext-export',
1089
+ version: 1,
1090
+ exportedAt: Date.now(),
1091
+ projectName: options.projectName,
1092
+ collectorVersion: options.version ?? VERSION,
1093
+ config: options.config,
1094
+ spans: data.spans,
1095
+ errors: data.errors,
1096
+ databaseQueries: data.databaseQueries,
1097
+ stats: {
1098
+ requests: store.summary(options.config.slowRequestMs).totalRequests,
1099
+ errors: data.errors.length,
1100
+ databaseQueries: data.databaseQueries.length,
1101
+ spans: data.spans.length,
1102
+ },
1103
+ diagnostics: getDiagnosticReport(),
1104
+ limitations: [
1105
+ 'Bodies, headers, cookies and SQL bind parameters are not stored.',
1106
+ 'Drizzle Logger does not expose query duration.',
1107
+ 'Edge Runtime is not supported.',
1108
+ ],
1109
+ };
1110
+ sendJson(response, 200, payload, {
1111
+ 'content-disposition': `attachment; filename="stacknext-${options.projectName.replace(/[^a-z0-9_-]+/gi, '-')}-${Date.now()}.json"`,
1112
+ });
1113
+ return;
1114
+ }
1115
+ sendError(response, 404, 'Not found');
1116
+ };
1117
+ const requestHandler = async (request, response) => {
1118
+ response.setHeader('x-stacknext', options.version ?? VERSION);
1119
+ if (!hostAllowed(request.headers.host)) {
1120
+ serverStats.rejectedRequests += 1;
1121
+ sendError(response, 403, 'Invalid Host header');
1122
+ return;
1123
+ }
1124
+ const origin = request.headers.origin;
1125
+ if (origin && !originAllowed(origin, port)) {
1126
+ serverStats.rejectedRequests += 1;
1127
+ sendError(response, 403, 'Cross-origin requests are not allowed');
1128
+ return;
1129
+ }
1130
+ if (request.headers['sec-fetch-site'] === 'cross-site') {
1131
+ serverStats.rejectedRequests += 1;
1132
+ sendError(response, 403, 'Cross-site requests are not allowed');
1133
+ return;
1134
+ }
1135
+ const url = new URL(request.url ?? '/', `http://${host}:${port}`);
1136
+ try {
1137
+ if (url.pathname === '/__stacknext/v1/ingest') {
1138
+ if (request.method !== 'POST') {
1139
+ response.setHeader('allow', 'POST');
1140
+ sendError(response, 405, 'Method not allowed');
1141
+ return;
1142
+ }
1143
+ await handleIngest(request, response);
1144
+ return;
1145
+ }
1146
+ if (url.pathname.startsWith('/api/')) {
1147
+ const testRunWrite = url.pathname === '/api/test-runs' ||
1148
+ url.pathname.startsWith('/api/test-runs/');
1149
+ if (request.method === 'POST' && testRunWrite) {
1150
+ await handleTestRunWrite(request, response, url);
1151
+ return;
1152
+ }
1153
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1154
+ response.setHeader('allow', testRunWrite ? 'GET, HEAD, POST' : 'GET, HEAD');
1155
+ sendError(response, 405, 'Method not allowed');
1156
+ return;
1157
+ }
1158
+ await handleApi(request, response, url);
1159
+ if (request.method === 'HEAD')
1160
+ request.resume();
1161
+ return;
1162
+ }
1163
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1164
+ response.setHeader('allow', 'GET, HEAD');
1165
+ sendError(response, 405, 'Method not allowed');
1166
+ return;
1167
+ }
1168
+ const served = await serveStatic(url.pathname, request, response, true);
1169
+ if (!served) {
1170
+ if (url.pathname.includes('.')) {
1171
+ sendError(response, 404, 'Asset not found');
1172
+ }
1173
+ else {
1174
+ sessionForRequest(request, response);
1175
+ await serveIndex(response, true);
1176
+ }
1177
+ }
1178
+ }
1179
+ catch (error) {
1180
+ log(`collector request failed: ${error instanceof Error ? error.message : String(error)}`);
1181
+ if (!response.headersSent) {
1182
+ sendError(response, 500, 'Internal collector error');
1183
+ }
1184
+ else {
1185
+ response.destroy();
1186
+ }
1187
+ }
1188
+ };
1189
+ const server = createServer((request, response) => {
1190
+ void requestHandler(request, response);
1191
+ });
1192
+ server.requestTimeout = 15_000;
1193
+ server.headersTimeout = 10_000;
1194
+ server.keepAliveTimeout = 5_000;
1195
+ const maxPortAttempts = options.autoPort === false ? 1 : 25;
1196
+ let lastError = null;
1197
+ let actualPort = null;
1198
+ for (let attempt = 0; attempt < maxPortAttempts; attempt += 1) {
1199
+ try {
1200
+ actualPort = await listen(server, port, host);
1201
+ break;
1202
+ }
1203
+ catch (error) {
1204
+ lastError = error;
1205
+ if (error.code !== 'EADDRINUSE')
1206
+ throw error;
1207
+ if (port === 0)
1208
+ throw error;
1209
+ port += 1;
1210
+ }
1211
+ }
1212
+ if (actualPort === null) {
1213
+ throw new Error(`Could not find an available dashboard port near ${options.port}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
1214
+ }
1215
+ port = actualPort;
1216
+ dashboardUrl = `http://${host === '127.0.0.1' ? '127.0.0.1' : host}:${port}`;
1217
+ let closing = false;
1218
+ const close = async () => {
1219
+ if (closing)
1220
+ return;
1221
+ closing = true;
1222
+ clearInterval(heartbeatInterval);
1223
+ for (const client of sseClients) {
1224
+ try {
1225
+ client.res.write(`event: shutdown\ndata: ${JSON.stringify({ type: 'shutdown', time: Date.now() })}\n\n`);
1226
+ client.res.end();
1227
+ }
1228
+ catch {
1229
+ // Ignore clients that already disconnected.
1230
+ }
1231
+ }
1232
+ sseClients.clear();
1233
+ await new Promise((resolve) => {
1234
+ const timeout = setTimeout(() => {
1235
+ server.closeAllConnections();
1236
+ resolve();
1237
+ }, 2_000);
1238
+ timeout.unref();
1239
+ server.close(() => {
1240
+ clearTimeout(timeout);
1241
+ resolve();
1242
+ });
1243
+ server.closeIdleConnections();
1244
+ });
1245
+ store.clear();
1246
+ sessions.clear();
1247
+ connections.clear();
1248
+ };
1249
+ return {
1250
+ port,
1251
+ url: dashboardUrl,
1252
+ token: options.token,
1253
+ store,
1254
+ health: getHealth,
1255
+ close,
1256
+ };
1257
+ }
1258
+ //# sourceMappingURL=server.js.map