scanwarp 0.2.0 → 0.3.1

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.
@@ -0,0 +1,1054 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.devCommand = devCommand;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const http_1 = require("http");
13
+ const chokidar_1 = require("chokidar");
14
+ const detector_js_1 = require("../detector.js");
15
+ const analysis_engine_js_1 = require("../dev/analysis-engine.js");
16
+ const schema_drift_js_1 = require("../dev/analyzers/schema-drift.js");
17
+ // ─── Main dev command ───
18
+ // ─── Production setup detection ───
19
+ function checkProductionSetup(cwd) {
20
+ // Check if they've deployed or are using hosting platforms
21
+ const hasVercelConfig = fs_1.default.existsSync(path_1.default.join(cwd, '.vercel'));
22
+ const hasRailwayConfig = fs_1.default.existsSync(path_1.default.join(cwd, 'railway.json')) ||
23
+ fs_1.default.existsSync(path_1.default.join(cwd, 'railway.toml'));
24
+ const hasRenderConfig = fs_1.default.existsSync(path_1.default.join(cwd, 'render.yaml'));
25
+ const isUsingHosting = hasVercelConfig || hasRailwayConfig || hasRenderConfig;
26
+ if (!isUsingHosting) {
27
+ return true; // No hosting detected, so no need to warn
28
+ }
29
+ // Check if instrumentation is set up
30
+ const hasInstrumentationFile = fs_1.default.existsSync(path_1.default.join(cwd, 'instrumentation.ts')) ||
31
+ fs_1.default.existsSync(path_1.default.join(cwd, 'instrumentation.js'));
32
+ // Check if @scanwarp/instrument is in package.json
33
+ let hasInstrumentPackage = false;
34
+ const packageJsonPath = path_1.default.join(cwd, 'package.json');
35
+ if (fs_1.default.existsSync(packageJsonPath)) {
36
+ try {
37
+ const packageJson = JSON.parse(fs_1.default.readFileSync(packageJsonPath, 'utf-8'));
38
+ hasInstrumentPackage = !!(packageJson.dependencies?.['@scanwarp/instrument'] ||
39
+ packageJson.devDependencies?.['@scanwarp/instrument']);
40
+ }
41
+ catch {
42
+ // Ignore parse errors
43
+ }
44
+ }
45
+ return hasInstrumentationFile || hasInstrumentPackage;
46
+ }
47
+ async function devCommand(options = {}) {
48
+ const cwd = process.cwd();
49
+ console.log(chalk_1.default.bold.cyan('\n⚡ ScanWarp Dev Mode\n'));
50
+ // Step 1: Detect project
51
+ const spinner = (0, ora_1.default)('Detecting project...').start();
52
+ const detected = (0, detector_js_1.detectProject)(cwd);
53
+ spinner.succeed(`Detected: ${detected.framework || 'Node.js'}${detected.services.length > 0 ? ` + ${detected.services.join(', ')}` : ''}`);
54
+ // Check for production setup
55
+ const hasProductionSetup = checkProductionSetup(cwd);
56
+ if (!hasProductionSetup) {
57
+ console.log(chalk_1.default.yellow('\n⚠️ Production hosting detected but monitoring not configured!'));
58
+ console.log(chalk_1.default.gray(' Run `npx scanwarp init` to enable production monitoring.'));
59
+ console.log(chalk_1.default.gray(' This ensures you get alerts and AI diagnosis in production.\n'));
60
+ }
61
+ // Step 2: Determine the dev command
62
+ const devCmd = options.command || detectDevCommand(detected, cwd);
63
+ console.log(chalk_1.default.gray(` Dev command: ${devCmd}\n`));
64
+ // Step 3: Discover routes
65
+ const routeFileMap = {
66
+ fileToRoute: new Map(),
67
+ fileToType: new Map(),
68
+ };
69
+ const routes = discoverRoutes(detected, cwd, routeFileMap);
70
+ if (routes.pages.length > 0 || routes.apiRoutes.length > 0) {
71
+ console.log(chalk_1.default.green(` Found ${routes.pages.length} pages, ${routes.apiRoutes.length} API routes`));
72
+ }
73
+ // Step 4: Start in-memory ScanWarp server
74
+ const scanwarpPort = options.port || (await findAvailablePort(3456));
75
+ const { server: scanwarpServer, store } = await startLocalServer(scanwarpPort);
76
+ // Share route data with the store (for MCP API access)
77
+ store.routes = routes;
78
+ console.log(chalk_1.default.green(` ScanWarp local server: http://localhost:${scanwarpPort}\n`));
79
+ // Print MCP configuration instructions
80
+ console.log(chalk_1.default.bold(' MCP for AI coding tools:\n'));
81
+ console.log(chalk_1.default.gray(` Add to your MCP config (.cursor/mcp.json or claude_desktop_config.json):`));
82
+ console.log(chalk_1.default.gray(` {`));
83
+ console.log(chalk_1.default.gray(` "mcpServers": {`));
84
+ console.log(chalk_1.default.white(` "scanwarp-dev": {`));
85
+ console.log(chalk_1.default.white(` "command": "npx",`));
86
+ console.log(chalk_1.default.white(` "args": ["scanwarp", "dev-mcp", "--port", "${scanwarpPort}"]`));
87
+ console.log(chalk_1.default.white(` }`));
88
+ console.log(chalk_1.default.gray(` }`));
89
+ console.log(chalk_1.default.gray(` }\n`));
90
+ // Step 5: Start the user's dev server
91
+ const isNextJs = detected.framework === 'Next.js';
92
+ const devServerPort = detectDevServerPort(devCmd);
93
+ console.log(chalk_1.default.bold(` Starting: ${devCmd}\n`));
94
+ console.log(chalk_1.default.gray('─'.repeat(60)));
95
+ const child = startDevServer(devCmd, cwd, scanwarpPort, isNextJs);
96
+ // Use store's maps for tracking (shared with MCP API)
97
+ const previousResults = store.previousResults;
98
+ const baselines = store.baselines;
99
+ // eslint-disable-next-line prefer-const
100
+ let watcher;
101
+ // Handle cleanup
102
+ const cleanup = () => {
103
+ console.log(chalk_1.default.gray('\n\n Shutting down...'));
104
+ if (watcher) {
105
+ watcher.close();
106
+ }
107
+ if (child && !child.killed) {
108
+ child.kill('SIGTERM');
109
+ }
110
+ scanwarpServer.close();
111
+ // Print session summary
112
+ printSessionSummary(store);
113
+ process.exit(0);
114
+ };
115
+ process.on('SIGINT', cleanup);
116
+ process.on('SIGTERM', cleanup);
117
+ child.on('exit', (code) => {
118
+ console.log(chalk_1.default.gray(`\n Dev server exited with code ${code}`));
119
+ if (watcher)
120
+ watcher.close();
121
+ scanwarpServer.close();
122
+ printSessionSummary(store);
123
+ process.exit(code || 0);
124
+ });
125
+ // Step 6: Wait for dev server to be ready, then crawl routes
126
+ if (devServerPort && (routes.pages.length > 0 || routes.apiRoutes.length > 0)) {
127
+ await waitForServer(devServerPort, 30_000).then(async (ready) => {
128
+ if (ready) {
129
+ console.log(chalk_1.default.gray('\n─'.repeat(60)));
130
+ console.log(chalk_1.default.bold.cyan('\n Initial route check\n'));
131
+ const initialResults = await crawlRoutes(routes, devServerPort, store.schemaTracker);
132
+ // Store initial results as both previous and baseline
133
+ for (const r of initialResults) {
134
+ previousResults.set(r.route, r);
135
+ if (r.status > 0 && r.status < 400) {
136
+ baselines.set(r.route, r.timeMs);
137
+ }
138
+ }
139
+ console.log(chalk_1.default.gray('\n─'.repeat(60)));
140
+ console.log('');
141
+ }
142
+ }).catch(() => {
143
+ // Server didn't start in time — that's fine, skip crawl
144
+ });
145
+ }
146
+ // Step 7: Start file watcher
147
+ watcher = startFileWatcher(cwd, routes, routeFileMap, previousResults, baselines, store.schemaTracker, devServerPort);
148
+ // Enable live request log
149
+ store.liveLogEnabled = true;
150
+ console.log(chalk_1.default.bold.cyan(' Live request log\n'));
151
+ }
152
+ // ─── Dev command detection ───
153
+ function detectDevCommand(detected, cwd) {
154
+ // Check package.json scripts
155
+ const pkgPath = path_1.default.join(cwd, 'package.json');
156
+ let scripts = {};
157
+ if (fs_1.default.existsSync(pkgPath)) {
158
+ try {
159
+ const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
160
+ scripts = pkg.scripts || {};
161
+ }
162
+ catch {
163
+ // ignore
164
+ }
165
+ }
166
+ // Framework-specific defaults
167
+ if (detected.framework === 'Next.js') {
168
+ return scripts['dev'] ? 'npm run dev' : 'npx next dev';
169
+ }
170
+ if (detected.framework === 'Remix') {
171
+ return scripts['dev'] ? 'npm run dev' : 'npx remix dev';
172
+ }
173
+ // Vite-based frameworks
174
+ const viteFws = ['React', 'Vue', 'SvelteKit', 'Astro'];
175
+ if (detected.framework && viteFws.includes(detected.framework)) {
176
+ return scripts['dev'] ? 'npm run dev' : 'npx vite dev';
177
+ }
178
+ // Generic — use "dev" script if available
179
+ if (scripts['dev']) {
180
+ return 'npm run dev';
181
+ }
182
+ // Fallback — look for common entry points
183
+ if (scripts['start']) {
184
+ return 'npm start';
185
+ }
186
+ return 'npm run dev';
187
+ }
188
+ function detectDevServerPort(devCmd) {
189
+ // Try to extract port from the command
190
+ const portMatch = devCmd.match(/(?:-p|--port)\s+(\d+)/);
191
+ if (portMatch) {
192
+ return parseInt(portMatch[1]);
193
+ }
194
+ // Default ports by framework
195
+ if (devCmd.includes('next'))
196
+ return 3000;
197
+ if (devCmd.includes('vite'))
198
+ return 5173;
199
+ if (devCmd.includes('remix'))
200
+ return 5173;
201
+ if (devCmd.includes('astro'))
202
+ return 4321;
203
+ return 3000;
204
+ }
205
+ function discoverRoutes(detected, cwd, routeFileMap) {
206
+ const pages = [];
207
+ const apiRoutes = [];
208
+ if (detected.framework === 'Next.js') {
209
+ // Next.js App Router
210
+ const appDir = fs_1.default.existsSync(path_1.default.join(cwd, 'src', 'app'))
211
+ ? path_1.default.join(cwd, 'src', 'app')
212
+ : path_1.default.join(cwd, 'app');
213
+ if (fs_1.default.existsSync(appDir)) {
214
+ walkNextAppDir(appDir, appDir, pages, apiRoutes, routeFileMap);
215
+ }
216
+ // Next.js Pages Router
217
+ const pagesDir = fs_1.default.existsSync(path_1.default.join(cwd, 'src', 'pages'))
218
+ ? path_1.default.join(cwd, 'src', 'pages')
219
+ : path_1.default.join(cwd, 'pages');
220
+ if (fs_1.default.existsSync(pagesDir)) {
221
+ walkNextPagesDir(pagesDir, pagesDir, pages, apiRoutes, routeFileMap);
222
+ }
223
+ }
224
+ return { pages, apiRoutes };
225
+ }
226
+ function walkNextAppDir(dir, baseDir, pages, apiRoutes, routeFileMap) {
227
+ let entries;
228
+ try {
229
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
230
+ }
231
+ catch {
232
+ return;
233
+ }
234
+ for (const entry of entries) {
235
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
236
+ continue;
237
+ const fullPath = path_1.default.join(dir, entry.name);
238
+ if (entry.isDirectory()) {
239
+ walkNextAppDir(fullPath, baseDir, pages, apiRoutes, routeFileMap);
240
+ }
241
+ else if (entry.isFile()) {
242
+ const relativePath = path_1.default.relative(baseDir, dir);
243
+ const route = '/' + relativePath.replace(/\\/g, '/');
244
+ const normalizedRoute = route === '/.' ? '/' : route;
245
+ if (entry.name.match(/^route\.(ts|tsx|js|jsx)$/)) {
246
+ apiRoutes.push(normalizedRoute);
247
+ if (routeFileMap) {
248
+ routeFileMap.fileToRoute.set(fullPath, normalizedRoute);
249
+ routeFileMap.fileToType.set(fullPath, 'api');
250
+ }
251
+ }
252
+ else if (entry.name.match(/^page\.(ts|tsx|js|jsx)$/)) {
253
+ pages.push(normalizedRoute);
254
+ if (routeFileMap) {
255
+ routeFileMap.fileToRoute.set(fullPath, normalizedRoute);
256
+ routeFileMap.fileToType.set(fullPath, 'page');
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ function walkNextPagesDir(dir, baseDir, pages, apiRoutes, routeFileMap) {
263
+ let entries;
264
+ try {
265
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
266
+ }
267
+ catch {
268
+ return;
269
+ }
270
+ for (const entry of entries) {
271
+ if (entry.name.startsWith('.') || entry.name.startsWith('_') || entry.name === 'node_modules')
272
+ continue;
273
+ const fullPath = path_1.default.join(dir, entry.name);
274
+ if (entry.isDirectory()) {
275
+ walkNextPagesDir(fullPath, baseDir, pages, apiRoutes, routeFileMap);
276
+ }
277
+ else if (entry.isFile() && entry.name.match(/\.(ts|tsx|js|jsx)$/)) {
278
+ const relativePath = path_1.default.relative(baseDir, fullPath);
279
+ const routePath = '/' + relativePath
280
+ .replace(/\\/g, '/')
281
+ .replace(/\.(ts|tsx|js|jsx)$/, '')
282
+ .replace(/\/index$/, '')
283
+ || '/';
284
+ const isApi = relativePath.startsWith('api/') || relativePath.startsWith('api\\');
285
+ if (isApi) {
286
+ apiRoutes.push(routePath);
287
+ }
288
+ else {
289
+ pages.push(routePath);
290
+ }
291
+ if (routeFileMap) {
292
+ routeFileMap.fileToRoute.set(fullPath, routePath);
293
+ routeFileMap.fileToType.set(fullPath, isApi ? 'api' : 'page');
294
+ }
295
+ }
296
+ }
297
+ }
298
+ // ─── In-memory ScanWarp local server ───
299
+ const SPAN_KIND_MAP = {
300
+ 0: 'UNSPECIFIED',
301
+ 1: 'INTERNAL',
302
+ 2: 'SERVER',
303
+ 3: 'CLIENT',
304
+ 4: 'PRODUCER',
305
+ 5: 'CONSUMER',
306
+ };
307
+ const STATUS_CODE_MAP = {
308
+ 0: 'UNSET',
309
+ 1: 'OK',
310
+ 2: 'ERROR',
311
+ };
312
+ async function startLocalServer(port) {
313
+ const store = {
314
+ spans: [],
315
+ events: [],
316
+ liveLogEnabled: false,
317
+ analysisEngine: new analysis_engine_js_1.AnalysisEngine(),
318
+ routes: { pages: [], apiRoutes: [] },
319
+ previousResults: new Map(),
320
+ baselines: new Map(),
321
+ schemaTracker: new schema_drift_js_1.SchemaTracker(),
322
+ startedAt: Date.now(),
323
+ };
324
+ const server = (0, http_1.createServer)((req, res) => {
325
+ let body = '';
326
+ req.on('data', (chunk) => {
327
+ body += chunk.toString();
328
+ });
329
+ req.on('end', () => {
330
+ // CORS headers
331
+ res.setHeader('Access-Control-Allow-Origin', '*');
332
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
333
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-scanwarp-project-id');
334
+ if (req.method === 'OPTIONS') {
335
+ res.writeHead(204);
336
+ res.end();
337
+ return;
338
+ }
339
+ res.setHeader('Content-Type', 'application/json');
340
+ try {
341
+ // POST /v1/traces
342
+ if (req.method === 'POST' && req.url === '/v1/traces') {
343
+ handleTraceIngest(body, store);
344
+ res.writeHead(200);
345
+ res.end(JSON.stringify({ partialSuccess: {} }));
346
+ return;
347
+ }
348
+ // POST /v1/metrics
349
+ if (req.method === 'POST' && req.url === '/v1/metrics') {
350
+ res.writeHead(200);
351
+ res.end(JSON.stringify({ partialSuccess: {} }));
352
+ return;
353
+ }
354
+ // GET /health
355
+ if (req.method === 'GET' && req.url === '/health') {
356
+ res.writeHead(200);
357
+ res.end(JSON.stringify({ status: 'ok' }));
358
+ return;
359
+ }
360
+ // ─── Data API (for MCP server) ───
361
+ // GET /api/status
362
+ if (req.method === 'GET' && req.url === '/api/status') {
363
+ const traceIds = new Set(store.spans.map((s) => s.trace_id));
364
+ const uptimeMs = Date.now() - store.startedAt;
365
+ const totalRoutes = store.routes.pages.length + store.routes.apiRoutes.length;
366
+ res.writeHead(200);
367
+ res.end(JSON.stringify({
368
+ uptime_ms: uptimeMs,
369
+ total_routes: totalRoutes,
370
+ pages: store.routes.pages.length,
371
+ api_routes: store.routes.apiRoutes.length,
372
+ total_spans: store.spans.length,
373
+ total_traces: traceIds.size,
374
+ active_issues: store.analysisEngine.activeIssueCount,
375
+ error_count: store.events.filter((e) => e.type === 'trace_error').length,
376
+ schema_baselines: store.schemaTracker.getBaselineCount(),
377
+ }));
378
+ return;
379
+ }
380
+ // GET /api/issues
381
+ if (req.method === 'GET' && req.url === '/api/issues') {
382
+ const issues = store.analysisEngine.getActiveIssues();
383
+ res.writeHead(200);
384
+ res.end(JSON.stringify({ issues }));
385
+ return;
386
+ }
387
+ // GET /api/routes
388
+ if (req.method === 'GET' && req.url === '/api/routes') {
389
+ const allRoutes = [
390
+ ...store.routes.pages.map((r) => ({ path: r, type: 'page' })),
391
+ ...store.routes.apiRoutes.map((r) => ({ path: r, type: 'api' })),
392
+ ];
393
+ const routeData = allRoutes.map((route) => {
394
+ const lastCheck = store.previousResults.get(route.path);
395
+ const baseline = store.baselines.get(route.path);
396
+ let status = 'unknown';
397
+ if (lastCheck) {
398
+ if (lastCheck.status > 0 && lastCheck.status < 400) {
399
+ status = 'healthy';
400
+ if (baseline && lastCheck.timeMs > 500 && lastCheck.timeMs > baseline * 3) {
401
+ status = 'slow';
402
+ }
403
+ }
404
+ else {
405
+ status = 'error';
406
+ }
407
+ }
408
+ return {
409
+ path: route.path,
410
+ type: route.type,
411
+ status,
412
+ last_status_code: lastCheck?.status ?? null,
413
+ last_time_ms: lastCheck?.timeMs ?? null,
414
+ baseline_ms: baseline ?? null,
415
+ error_text: lastCheck?.errorText ?? null,
416
+ };
417
+ });
418
+ res.writeHead(200);
419
+ res.end(JSON.stringify({ routes: routeData }));
420
+ return;
421
+ }
422
+ // GET /api/slow-routes
423
+ if (req.method === 'GET' && req.url === '/api/slow-routes') {
424
+ const slowRoutes = [];
425
+ for (const [route, baseline] of store.baselines) {
426
+ const lastCheck = store.previousResults.get(route);
427
+ if (!lastCheck || lastCheck.status === 0 || lastCheck.status >= 400)
428
+ continue;
429
+ if (lastCheck.timeMs > 500 && lastCheck.timeMs > baseline * 3) {
430
+ // Find bottleneck span from recent traces for this route
431
+ const routeSpans = store.spans.filter((s) => {
432
+ const spanRoute = s.attributes['http.route'] || s.attributes['http.target'] || s.attributes['url.path'];
433
+ return spanRoute === route && s.kind === 'SERVER';
434
+ });
435
+ let bottleneck = null;
436
+ if (routeSpans.length > 0) {
437
+ const latestTrace = routeSpans[routeSpans.length - 1];
438
+ const traceSpans = store.spans
439
+ .filter((s) => s.trace_id === latestTrace.trace_id && s.span_id !== latestTrace.span_id)
440
+ .sort((a, b) => b.duration_ms - a.duration_ms);
441
+ if (traceSpans.length > 0) {
442
+ bottleneck = { name: traceSpans[0].operation_name, duration_ms: traceSpans[0].duration_ms };
443
+ }
444
+ }
445
+ slowRoutes.push({
446
+ path: route,
447
+ current_ms: lastCheck.timeMs,
448
+ baseline_ms: baseline,
449
+ ratio: Math.round((lastCheck.timeMs / baseline) * 10) / 10,
450
+ bottleneck,
451
+ });
452
+ }
453
+ }
454
+ res.writeHead(200);
455
+ res.end(JSON.stringify({ slow_routes: slowRoutes }));
456
+ return;
457
+ }
458
+ // GET /api/route-traces?path=/api/foo&limit=5
459
+ if (req.method === 'GET' && req.url?.startsWith('/api/route-traces')) {
460
+ const urlObj = new URL(req.url, 'http://localhost');
461
+ const routePath = urlObj.searchParams.get('path') || '/';
462
+ const limit = parseInt(urlObj.searchParams.get('limit') || '5');
463
+ // Find SERVER spans for this route
464
+ const serverSpans = store.spans
465
+ .filter((s) => {
466
+ const spanRoute = s.attributes['http.route'] || s.attributes['http.target'] || s.attributes['url.path'];
467
+ return s.kind === 'SERVER' && spanRoute === routePath;
468
+ })
469
+ .sort((a, b) => b.start_time - a.start_time)
470
+ .slice(0, limit);
471
+ const traces = serverSpans.map((rootSpan) => {
472
+ const traceSpans = store.spans
473
+ .filter((s) => s.trace_id === rootSpan.trace_id)
474
+ .sort((a, b) => a.start_time - b.start_time);
475
+ return {
476
+ trace_id: rootSpan.trace_id,
477
+ method: rootSpan.attributes['http.method'] || rootSpan.attributes['http.request.method'] || '???',
478
+ route: routePath,
479
+ status_code: rootSpan.attributes['http.status_code'] || rootSpan.attributes['http.response.status_code'],
480
+ duration_ms: rootSpan.duration_ms,
481
+ timestamp: rootSpan.start_time,
482
+ spans: traceSpans.map((s) => ({
483
+ span_id: s.span_id,
484
+ parent_span_id: s.parent_span_id,
485
+ operation: s.operation_name,
486
+ kind: s.kind,
487
+ duration_ms: s.duration_ms,
488
+ status: s.status_code,
489
+ service: s.service_name,
490
+ attributes: s.attributes,
491
+ })),
492
+ };
493
+ });
494
+ res.writeHead(200);
495
+ res.end(JSON.stringify({ path: routePath, traces }));
496
+ return;
497
+ }
498
+ // Catch-all for unknown routes
499
+ res.writeHead(404);
500
+ res.end(JSON.stringify({ error: 'Not found' }));
501
+ }
502
+ catch (err) {
503
+ res.writeHead(500);
504
+ res.end(JSON.stringify({ error: 'Internal server error' }));
505
+ }
506
+ });
507
+ });
508
+ return new Promise((resolve, reject) => {
509
+ server.listen(port, '127.0.0.1', () => {
510
+ resolve({ server, store });
511
+ });
512
+ server.on('error', reject);
513
+ });
514
+ }
515
+ function handleTraceIngest(body, store) {
516
+ let payload;
517
+ try {
518
+ payload = JSON.parse(body);
519
+ }
520
+ catch {
521
+ return;
522
+ }
523
+ if (!payload.resourceSpans)
524
+ return;
525
+ for (const resourceSpan of payload.resourceSpans) {
526
+ const serviceName = extractServiceName(resourceSpan.resource) || 'unknown';
527
+ for (const scopeSpan of resourceSpan.scopeSpans || []) {
528
+ for (const otlpSpan of scopeSpan.spans || []) {
529
+ const startTimeNano = BigInt(otlpSpan.startTimeUnixNano);
530
+ const endTimeNano = BigInt(otlpSpan.endTimeUnixNano);
531
+ const startTimeMs = Number(startTimeNano / BigInt(1_000_000));
532
+ const durationMs = Number((endTimeNano - startTimeNano) / BigInt(1_000_000));
533
+ const statusCode = STATUS_CODE_MAP[otlpSpan.status?.code ?? 0] || 'UNSET';
534
+ const attributes = flattenAttributes(otlpSpan.attributes);
535
+ const spanEvents = (otlpSpan.events || []).map((e) => ({
536
+ name: e.name,
537
+ attributes: flattenAttributes(e.attributes),
538
+ }));
539
+ const span = {
540
+ trace_id: otlpSpan.traceId,
541
+ span_id: otlpSpan.spanId,
542
+ parent_span_id: otlpSpan.parentSpanId || null,
543
+ service_name: serviceName,
544
+ operation_name: otlpSpan.name,
545
+ kind: SPAN_KIND_MAP[otlpSpan.kind] || 'UNSPECIFIED',
546
+ start_time: startTimeMs,
547
+ duration_ms: durationMs,
548
+ status_code: statusCode,
549
+ status_message: otlpSpan.status?.message || null,
550
+ attributes,
551
+ events: spanEvents,
552
+ };
553
+ store.spans.push(span);
554
+ // Live request log — print a line for each SERVER span (incoming HTTP request)
555
+ if (store.liveLogEnabled && span.kind === 'SERVER') {
556
+ printRequestLogLine(span);
557
+ }
558
+ // Track errors (non-SERVER spans that are errors, or SERVER errors already printed above)
559
+ if (statusCode === 'ERROR') {
560
+ const event = {
561
+ id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
562
+ type: 'trace_error',
563
+ source: 'otel',
564
+ message: `Error in ${serviceName}: ${otlpSpan.name}${otlpSpan.status?.message ? ` — ${otlpSpan.status.message}` : ''}`,
565
+ severity: 'high',
566
+ created_at: new Date(),
567
+ };
568
+ store.events.push(event);
569
+ // Print non-SERVER errors separately (SERVER errors show in the request log)
570
+ if (!store.liveLogEnabled || span.kind !== 'SERVER') {
571
+ console.log(chalk_1.default.red(` ✗ Error: ${span.operation_name} (${durationMs}ms) — ${serviceName}`));
572
+ if (otlpSpan.status?.message) {
573
+ console.log(chalk_1.default.gray(` ${otlpSpan.status.message}`));
574
+ }
575
+ }
576
+ }
577
+ // Track slow queries
578
+ if (attributes['db.system'] && durationMs > 1000) {
579
+ const event = {
580
+ id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
581
+ type: 'slow_query',
582
+ source: 'otel',
583
+ message: `Slow ${attributes['db.system']} query: ${otlpSpan.name} (${durationMs}ms)`,
584
+ severity: 'medium',
585
+ created_at: new Date(),
586
+ };
587
+ store.events.push(event);
588
+ if (store.liveLogEnabled) {
589
+ console.log(chalk_1.default.yellow(` ⚠ slow query: ${attributes['db.system']}: ${otlpSpan.name} (${durationMs}ms)`));
590
+ }
591
+ else {
592
+ console.log(chalk_1.default.yellow(` ⚠ Slow query: ${attributes['db.system']}: ${otlpSpan.name} (${durationMs}ms)`));
593
+ }
594
+ }
595
+ }
596
+ }
597
+ }
598
+ // Keep memory bounded — retain last 5000 spans
599
+ if (store.spans.length > 5000) {
600
+ store.spans = store.spans.slice(-5000);
601
+ }
602
+ if (store.events.length > 1000) {
603
+ store.events = store.events.slice(-1000);
604
+ }
605
+ // Run analysis on complete traces
606
+ if (store.liveLogEnabled) {
607
+ // Collect trace IDs from the spans we just ingested
608
+ const newTraceIds = new Set();
609
+ for (const resourceSpan of payload.resourceSpans) {
610
+ for (const scopeSpan of resourceSpan.scopeSpans || []) {
611
+ for (const otlpSpan of scopeSpan.spans || []) {
612
+ newTraceIds.add(otlpSpan.traceId);
613
+ }
614
+ }
615
+ }
616
+ // For each trace, gather all known spans and run analysis
617
+ for (const traceId of newTraceIds) {
618
+ const traceSpans = store.spans.filter((s) => s.trace_id === traceId);
619
+ store.analysisEngine.analyzeTrace(traceSpans);
620
+ }
621
+ }
622
+ }
623
+ /**
624
+ * Print a single request log line in the format:
625
+ * 14:02:01 ✓ GET /api/products 34ms
626
+ * 14:02:05 ✗ POST /api/checkout 0ms TypeError: Cannot read...
627
+ */
628
+ function printRequestLogLine(span) {
629
+ const now = new Date(span.start_time);
630
+ const time = chalk_1.default.gray(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`);
631
+ const attrs = span.attributes;
632
+ const method = String(attrs['http.method'] || attrs['http.request.method'] || '???');
633
+ const route = String(attrs['http.route'] || attrs['http.target'] || attrs['url.path'] || span.operation_name);
634
+ const httpStatus = attrs['http.status_code'] || attrs['http.response.status_code'];
635
+ const isError = span.status_code === 'ERROR' ||
636
+ (typeof httpStatus === 'number' && httpStatus >= 400);
637
+ const icon = isError ? chalk_1.default.red('✗') : chalk_1.default.green('✓');
638
+ const label = `${method} ${route}`;
639
+ const durationStr = `${span.duration_ms}ms`.padStart(6);
640
+ const durationColor = span.duration_ms > 1000 ? chalk_1.default.yellow(durationStr) : chalk_1.default.white(durationStr);
641
+ // Build error suffix
642
+ let errorSuffix = '';
643
+ if (isError) {
644
+ // Try to get an error message from span events
645
+ const exceptionEvent = span.events.find((e) => e.name === 'exception');
646
+ const exceptionMsg = exceptionEvent?.attributes?.['exception.message'];
647
+ if (typeof exceptionMsg === 'string') {
648
+ const truncated = exceptionMsg.length > 50 ? exceptionMsg.substring(0, 50) + '...' : exceptionMsg;
649
+ errorSuffix = ` ${chalk_1.default.red(truncated)}`;
650
+ }
651
+ else if (span.status_message) {
652
+ const truncated = span.status_message.length > 50 ? span.status_message.substring(0, 50) + '...' : span.status_message;
653
+ errorSuffix = ` ${chalk_1.default.red(truncated)}`;
654
+ }
655
+ else if (typeof httpStatus === 'number') {
656
+ errorSuffix = ` ${chalk_1.default.red(String(httpStatus))}`;
657
+ }
658
+ }
659
+ console.log(` ${time} ${icon} ${label.padEnd(28)} ${durationColor}${errorSuffix}`);
660
+ }
661
+ function extractServiceName(resource) {
662
+ if (!resource?.attributes)
663
+ return undefined;
664
+ for (const attr of resource.attributes) {
665
+ if (attr.key === 'service.name') {
666
+ return attr.value.stringValue;
667
+ }
668
+ }
669
+ return undefined;
670
+ }
671
+ function flattenAttributes(attrs) {
672
+ if (!attrs)
673
+ return {};
674
+ const result = {};
675
+ for (const attr of attrs) {
676
+ const val = attr.value;
677
+ if (val.stringValue !== undefined)
678
+ result[attr.key] = val.stringValue;
679
+ else if (val.intValue !== undefined)
680
+ result[attr.key] = Number(val.intValue);
681
+ else if (val.doubleValue !== undefined)
682
+ result[attr.key] = val.doubleValue;
683
+ else if (val.boolValue !== undefined)
684
+ result[attr.key] = val.boolValue;
685
+ }
686
+ return result;
687
+ }
688
+ // ─── Child process management ───
689
+ function startDevServer(devCmd, cwd, scanwarpPort, isNextJs) {
690
+ const [cmd, ...args] = parseCommand(devCmd);
691
+ // Build env vars
692
+ const env = {
693
+ ...process.env,
694
+ SCANWARP_SERVER: `http://localhost:${scanwarpPort}`,
695
+ SCANWARP_PROJECT_ID: 'local-dev',
696
+ SCANWARP_SERVICE_NAME: 'dev',
697
+ };
698
+ // For non-Next.js, inject NODE_OPTIONS to auto-load instrumentation
699
+ if (!isNextJs) {
700
+ const existingNodeOpts = process.env.NODE_OPTIONS || '';
701
+ env.NODE_OPTIONS = `--require @scanwarp/instrument ${existingNodeOpts}`.trim();
702
+ }
703
+ const child = (0, child_process_1.spawn)(cmd, args, {
704
+ cwd,
705
+ env,
706
+ stdio: ['inherit', 'inherit', 'inherit'],
707
+ shell: true,
708
+ });
709
+ return child;
710
+ }
711
+ function parseCommand(cmd) {
712
+ // Simple command parsing — split on spaces but respect quotes
713
+ const parts = [];
714
+ let current = '';
715
+ let inQuote = null;
716
+ for (const ch of cmd) {
717
+ if (inQuote) {
718
+ if (ch === inQuote) {
719
+ inQuote = null;
720
+ }
721
+ else {
722
+ current += ch;
723
+ }
724
+ }
725
+ else if (ch === '"' || ch === "'") {
726
+ inQuote = ch;
727
+ }
728
+ else if (ch === ' ') {
729
+ if (current) {
730
+ parts.push(current);
731
+ current = '';
732
+ }
733
+ }
734
+ else {
735
+ current += ch;
736
+ }
737
+ }
738
+ if (current)
739
+ parts.push(current);
740
+ return parts;
741
+ }
742
+ // ─── Server readiness check ───
743
+ async function waitForServer(port, timeoutMs) {
744
+ const start = Date.now();
745
+ const spinner = (0, ora_1.default)('Waiting for dev server to be ready...').start();
746
+ while (Date.now() - start < timeoutMs) {
747
+ try {
748
+ const controller = new AbortController();
749
+ const timeout = setTimeout(() => controller.abort(), 2000);
750
+ const response = await fetch(`http://localhost:${port}`, {
751
+ signal: controller.signal,
752
+ });
753
+ clearTimeout(timeout);
754
+ if (response.ok || response.status < 500) {
755
+ spinner.succeed(`Dev server ready on port ${port}`);
756
+ return true;
757
+ }
758
+ }
759
+ catch {
760
+ // Not ready yet
761
+ }
762
+ await sleep(500);
763
+ }
764
+ spinner.warn('Dev server did not respond in time — skipping route check');
765
+ return false;
766
+ }
767
+ // ─── Route crawling ───
768
+ async function crawlRoutes(routes, port, schemaTracker) {
769
+ const allGetRoutes = [
770
+ ...routes.pages,
771
+ ...routes.apiRoutes.filter((r) => !r.includes('[') || !r.includes(']')),
772
+ ];
773
+ // Filter out dynamic routes (contain [...] or [param]) since we can't crawl them
774
+ const staticRoutes = allGetRoutes.filter((r) => !r.includes('['));
775
+ if (staticRoutes.length === 0) {
776
+ console.log(chalk_1.default.gray(' No static routes to check (all routes are dynamic)\n'));
777
+ return [];
778
+ }
779
+ console.log(chalk_1.default.bold(' Initial scan:\n'));
780
+ const checkResults = await checkRoutes(staticRoutes, port);
781
+ printRouteResults(checkResults, { schemaTracker });
782
+ return checkResults;
783
+ }
784
+ /** Hit each route with GET and return results */
785
+ async function checkRoutes(routes, port) {
786
+ const results = [];
787
+ for (const route of routes) {
788
+ try {
789
+ const start = Date.now();
790
+ const controller = new AbortController();
791
+ const timeout = setTimeout(() => controller.abort(), 5000);
792
+ const response = await fetch(`http://localhost:${port}${route}`, {
793
+ signal: controller.signal,
794
+ redirect: 'follow',
795
+ });
796
+ clearTimeout(timeout);
797
+ const timeMs = Date.now() - start;
798
+ // Try to extract error text from non-ok responses
799
+ let errorText;
800
+ let responseBody;
801
+ if (response.status >= 400) {
802
+ try {
803
+ const text = await response.text();
804
+ try {
805
+ const json = JSON.parse(text);
806
+ errorText = json.error || json.message || undefined;
807
+ }
808
+ catch {
809
+ const firstLine = text.split('\n')[0];
810
+ if (firstLine && firstLine.length > 0) {
811
+ errorText = firstLine.length > 60 ? firstLine.substring(0, 60) + '...' : firstLine;
812
+ }
813
+ }
814
+ }
815
+ catch {
816
+ // ignore
817
+ }
818
+ }
819
+ else if (response.status >= 200 && response.status < 300 && route.startsWith('/api/')) {
820
+ // Read JSON body for 2xx API routes (schema drift detection)
821
+ try {
822
+ const contentType = response.headers.get('content-type') || '';
823
+ if (contentType.includes('application/json')) {
824
+ responseBody = await response.json();
825
+ }
826
+ }
827
+ catch {
828
+ // Not valid JSON — skip
829
+ }
830
+ }
831
+ results.push({ route, method: 'GET', status: response.status, timeMs, errorText, responseBody });
832
+ }
833
+ catch (err) {
834
+ results.push({
835
+ route,
836
+ method: 'GET',
837
+ status: 0,
838
+ timeMs: 0,
839
+ errorText: err instanceof Error ? err.message : String(err),
840
+ });
841
+ }
842
+ }
843
+ return results;
844
+ }
845
+ /** Print route check results with aligned columns */
846
+ function printRouteResults(results, opts = {}) {
847
+ if (results.length === 0)
848
+ return;
849
+ const { previousResults, baselines, quiet } = opts;
850
+ const maxRouteLen = Math.max(...results.map((r) => `${r.method} ${r.route}`.length));
851
+ let printedCount = 0;
852
+ let suppressedOkCount = 0;
853
+ for (const r of results) {
854
+ const isOk = r.status > 0 && r.status < 400;
855
+ const label = `${r.method} ${r.route}`;
856
+ const padded = label.padEnd(maxRouteLen + 2);
857
+ const timeStr = `${r.timeMs}ms`.padStart(6);
858
+ const timeColor = r.timeMs > 1000 ? chalk_1.default.yellow(timeStr) : chalk_1.default.gray(timeStr);
859
+ const statusStr = r.status === 0 ? '' : (isOk ? '' : ` ${chalk_1.default.red(String(r.status))}`);
860
+ const errStr = (!isOk && r.errorText) ? ` ${chalk_1.default.gray(r.errorText)}` : '';
861
+ // Change indicator compared to previous results
862
+ let changeIndicator = '';
863
+ let hasChange = false;
864
+ if (previousResults) {
865
+ const prev = previousResults.get(r.route);
866
+ if (prev) {
867
+ const prevOk = prev.status > 0 && prev.status < 400;
868
+ if (!prevOk && isOk) {
869
+ changeIndicator = ` ${chalk_1.default.green('FIXED')}`;
870
+ hasChange = true;
871
+ }
872
+ else if (prevOk && !isOk) {
873
+ changeIndicator = ` ${chalk_1.default.red('BROKE')}`;
874
+ hasChange = true;
875
+ }
876
+ else if (prev.status !== r.status) {
877
+ changeIndicator = ` ${chalk_1.default.yellow(`${prev.status}→${r.status}`)}`;
878
+ hasChange = true;
879
+ }
880
+ }
881
+ else {
882
+ changeIndicator = ` ${chalk_1.default.cyan('NEW')}`;
883
+ hasChange = true;
884
+ }
885
+ }
886
+ // Slow regression detection: 3x baseline AND over 500ms
887
+ let slowIndicator = '';
888
+ if (isOk && baselines) {
889
+ const baseline = baselines.get(r.route);
890
+ if (baseline !== undefined && r.timeMs > 500 && r.timeMs > baseline * 3) {
891
+ slowIndicator = ` ${chalk_1.default.yellow(`SLOW (baseline: ${baseline}ms)`)}`;
892
+ hasChange = true;
893
+ }
894
+ }
895
+ // In quiet mode, skip routes that are OK with no changes and no slow regression
896
+ if (quiet && isOk && !hasChange) {
897
+ suppressedOkCount++;
898
+ continue;
899
+ }
900
+ const icon = isOk ? chalk_1.default.green('✓') : chalk_1.default.red('✗');
901
+ console.log(` ${icon} ${padded} ${timeColor}${statusStr}${errStr}${changeIndicator}${slowIndicator}`);
902
+ printedCount++;
903
+ }
904
+ // Summary
905
+ const okCount = results.filter((r) => r.status > 0 && r.status < 400).length;
906
+ const errCount = results.filter((r) => r.status === 0 || r.status >= 400).length;
907
+ const validResults = results.filter((r) => r.status > 0);
908
+ const avgTime = validResults.length > 0
909
+ ? Math.round(validResults.reduce((sum, r) => sum + r.timeMs, 0) / validResults.length)
910
+ : 0;
911
+ if (quiet && suppressedOkCount > 0 && printedCount > 0) {
912
+ console.log(chalk_1.default.gray(` ... (${suppressedOkCount} more OK)`));
913
+ }
914
+ // In quiet mode with nothing interesting, print a single line
915
+ if (quiet && printedCount === 0) {
916
+ console.log(chalk_1.default.gray(` ✓ All ${okCount} routes OK (avg ${avgTime}ms)`));
917
+ return;
918
+ }
919
+ console.log('');
920
+ console.log(chalk_1.default.gray(` ${okCount} ok, ${errCount} errors, avg ${avgTime}ms`));
921
+ // Schema drift detection for API routes
922
+ if (opts.schemaTracker) {
923
+ for (const r of results) {
924
+ if (r.responseBody !== undefined && r.status >= 200 && r.status < 300) {
925
+ const diffs = opts.schemaTracker.processResponse(r.route, r.method, r.responseBody);
926
+ schema_drift_js_1.SchemaTracker.printDrift(r.route, r.method, diffs);
927
+ }
928
+ }
929
+ }
930
+ }
931
+ // ─── File watcher ───
932
+ function startFileWatcher(cwd, routes, routeFileMap, previousResults, baselines, schemaTracker, devServerPort) {
933
+ let debounceTimer = null;
934
+ let pendingFiles = new Set();
935
+ const watcher = (0, chokidar_1.watch)(['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs'], {
936
+ cwd,
937
+ ignored: ['**/node_modules/**', '**/.next/**', '**/.git/**', '**/dist/**', '**/build/**'],
938
+ ignoreInitial: true,
939
+ });
940
+ watcher.on('change', (relativePath) => {
941
+ const fullPath = path_1.default.resolve(cwd, relativePath);
942
+ pendingFiles.add(fullPath);
943
+ // Debounce — wait 1 second after the last change
944
+ if (debounceTimer)
945
+ clearTimeout(debounceTimer);
946
+ debounceTimer = setTimeout(async () => {
947
+ const changedFiles = new Set(pendingFiles);
948
+ pendingFiles = new Set();
949
+ const affectedRoutes = resolveAffectedRoutes(changedFiles, routes, routeFileMap);
950
+ if (affectedRoutes.length === 0)
951
+ return;
952
+ // Reset schema baselines for routes whose handler files changed
953
+ schemaTracker.resetForRoutes(affectedRoutes);
954
+ const fileNames = [...changedFiles].map((f) => path_1.default.relative(cwd, f)).join(', ');
955
+ console.log('');
956
+ console.log(chalk_1.default.gray('─'.repeat(60)));
957
+ console.log(chalk_1.default.bold.cyan(`\n File changed: ${chalk_1.default.white(fileNames)}\n`));
958
+ console.log(chalk_1.default.bold(` Re-checking ${affectedRoutes.length} route${affectedRoutes.length > 1 ? 's' : ''}...\n`));
959
+ const newResults = await checkRoutes(affectedRoutes, devServerPort);
960
+ printRouteResults(newResults, { previousResults, baselines, quiet: true, schemaTracker });
961
+ // Update previous results and baselines
962
+ for (const r of newResults) {
963
+ previousResults.set(r.route, r);
964
+ // Set baseline on first success (don't overwrite existing baselines)
965
+ if (r.status > 0 && r.status < 400 && !baselines.has(r.route)) {
966
+ baselines.set(r.route, r.timeMs);
967
+ }
968
+ }
969
+ console.log(chalk_1.default.gray('\n─'.repeat(60)));
970
+ console.log('');
971
+ }, 1000);
972
+ });
973
+ return watcher;
974
+ }
975
+ /** Map changed files to affected routes */
976
+ function resolveAffectedRoutes(changedFiles, routes, routeFileMap) {
977
+ const affected = new Set();
978
+ let hasNonRouteFile = false;
979
+ for (const file of changedFiles) {
980
+ const route = routeFileMap.fileToRoute.get(file);
981
+ if (route) {
982
+ // Direct match — this file IS a route file
983
+ affected.add(route);
984
+ }
985
+ else {
986
+ // Not a known route file — could be a utility, component, etc.
987
+ hasNonRouteFile = true;
988
+ }
989
+ }
990
+ // For non-route files, re-check all API routes (they're more likely to be affected by shared code)
991
+ if (hasNonRouteFile) {
992
+ const staticApiRoutes = routes.apiRoutes.filter((r) => !r.includes('['));
993
+ for (const route of staticApiRoutes) {
994
+ affected.add(route);
995
+ }
996
+ }
997
+ return [...affected];
998
+ }
999
+ // ─── Session summary ───
1000
+ function printSessionSummary(store) {
1001
+ const totalSpans = store.spans.length;
1002
+ const errorEvents = store.events.filter((e) => e.type === 'trace_error');
1003
+ const slowQueries = store.events.filter((e) => e.type === 'slow_query');
1004
+ const traceIds = new Set(store.spans.map((s) => s.trace_id));
1005
+ console.log(chalk_1.default.bold.cyan('\n Session Summary\n'));
1006
+ console.log(chalk_1.default.gray(` Traces: ${traceIds.size}`));
1007
+ console.log(chalk_1.default.gray(` Spans: ${totalSpans}`));
1008
+ if (errorEvents.length > 0) {
1009
+ console.log(chalk_1.default.red(` Errors: ${errorEvents.length}`));
1010
+ }
1011
+ else {
1012
+ console.log(chalk_1.default.green(` Errors: 0`));
1013
+ }
1014
+ if (slowQueries.length > 0) {
1015
+ console.log(chalk_1.default.yellow(` Slow queries: ${slowQueries.length}`));
1016
+ }
1017
+ // Analysis summary
1018
+ const analysis = store.analysisEngine.getSummary();
1019
+ if (analysis.total > 0) {
1020
+ console.log('');
1021
+ console.log(chalk_1.default.bold(' Analysis:'));
1022
+ if (analysis.active > 0) {
1023
+ console.log(chalk_1.default.yellow(` Active issues: ${analysis.active}`));
1024
+ }
1025
+ if (analysis.resolved > 0) {
1026
+ console.log(chalk_1.default.green(` Resolved: ${analysis.resolved}`));
1027
+ }
1028
+ for (const [rule, count] of analysis.byRule) {
1029
+ console.log(chalk_1.default.gray(` ${rule}: ${count}`));
1030
+ }
1031
+ }
1032
+ console.log('');
1033
+ }
1034
+ // ─── Utilities ───
1035
+ function sleep(ms) {
1036
+ return new Promise((resolve) => setTimeout(resolve, ms));
1037
+ }
1038
+ async function findAvailablePort(preferred) {
1039
+ return new Promise((resolve) => {
1040
+ const server = (0, http_1.createServer)();
1041
+ server.listen(preferred, '127.0.0.1', () => {
1042
+ server.close(() => resolve(preferred));
1043
+ });
1044
+ server.on('error', () => {
1045
+ // Port in use — try next
1046
+ const server2 = (0, http_1.createServer)();
1047
+ server2.listen(0, '127.0.0.1', () => {
1048
+ const addr = server2.address();
1049
+ const port = typeof addr === 'object' && addr ? addr.port : preferred + 1;
1050
+ server2.close(() => resolve(port));
1051
+ });
1052
+ });
1053
+ });
1054
+ }