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