1import fs from 'node:fs';
2import path from 'node:path';
3
4// ---------------------------------------------------------------------------
5// File walker
6// ---------------------------------------------------------------------------
7
8const SKIP_DIRS = new Set([
9 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
10 '.svelte-kit', '__pycache__', '.turbo', '.vercel',
11]);
12
13const SCANNABLE_EXTENSIONS = new Set([
14 '.html', '.htm', '.css', '.scss', '.less',
15 '.jsx', '.tsx', '.js', '.ts',
16 '.vue', '.svelte', '.astro',
17]);
18
19const HTML_EXTENSIONS = new Set(['.html', '.htm']);
20
21function walkDir(dir) {
22 const files = [];
23 let entries;
24 try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
25 for (const entry of entries) {
26 if (SKIP_DIRS.has(entry.name)) continue;
27 const full = path.join(dir, entry.name);
28 if (entry.isDirectory()) files.push(...walkDir(full));
29 else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
30 }
31 return files;
32}
33
34
35// ---------------------------------------------------------------------------
36// Import graph (multi-file awareness)
37// ---------------------------------------------------------------------------
38
39function resolveImport(specifier, fromDir, fileSet) {
40 if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
41 const base = path.resolve(fromDir, specifier);
42 if (fileSet.has(base)) return base;
43 for (const ext of SCANNABLE_EXTENSIONS) {
44 const withExt = base + ext;
45 if (fileSet.has(withExt)) return withExt;
46 }
47 // index file convention
48 for (const ext of SCANNABLE_EXTENSIONS) {
49 const indexFile = path.join(base, 'index' + ext);
50 if (fileSet.has(indexFile)) return indexFile;
51 }
52 return null;
53}
54
55function buildImportGraph(files) {
56 const fileSet = new Set(files);
57 const graph = new Map();
58
59 for (const file of files) {
60 const content = fs.readFileSync(file, 'utf-8');
61 const dir = path.dirname(file);
62 const imports = new Set();
63
64 // ES imports: import ... from '...' and import '...'
65 const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
66 let m;
67 while ((m = esRe.exec(content)) !== null) {
68 const resolved = resolveImport(m[1], dir, fileSet);
69 if (resolved) imports.add(resolved);
70 }
71
72 // CSS @import
73 const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
74 while ((m = cssRe.exec(content)) !== null) {
75 const resolved = resolveImport(m[1], dir, fileSet);
76 if (resolved) imports.add(resolved);
77 }
78
79 // SCSS @use / @forward
80 const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
81 while ((m = scssRe.exec(content)) !== null) {
82 const resolved = resolveImport(m[1], dir, fileSet);
83 if (resolved) imports.add(resolved);
84 }
85
86 graph.set(file, imports);
87 }
88 return graph;
89}
90
91// ---------------------------------------------------------------------------
92// Framework dev server detection
93// ---------------------------------------------------------------------------
94
95const FRAMEWORK_CONFIGS = [
96 { name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
97 portRe: /port\s*[:=]\s*(\d+)/,
98 fingerprint: { header: 'x-powered-by', value: /next/i } },
99 { name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
100 portRe: /port\s*[:=]\s*(\d+)/,
101 fingerprint: { header: 'x-sveltekit-page', value: null } },
102 { name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
103 portRe: /port\s*[:=]\s*(\d+)/,
104 fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
105 { name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
106 portRe: /port\s*[:=]\s*(\d+)/,
107 fingerprint: { body: /@vite\/client/ } },
108 { name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
109 portRe: /port\s*[:=]\s*(\d+)/,
110 fingerprint: { body: /astro/i } },
111 { name: 'Angular', files: ['angular.json'], defaultPort: 4200,
112 portRe: /"port"\s*:\s*(\d+)/,
113 fingerprint: { body: /ng-version/i } },
114 { name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
115 portRe: /port\s*[:=]\s*(\d+)/,
116 fingerprint: { header: 'x-powered-by', value: /remix/i } },
117];
118
119function detectFrameworkConfig(dir) {
120 let entries;
121 try { entries = fs.readdirSync(dir); } catch { return null; }
122 const entrySet = new Set(entries);
123
124 for (const cfg of FRAMEWORK_CONFIGS) {
125 const match = cfg.files.find(f => entrySet.has(f));
126 if (!match) continue;
127
128 const configPath = path.join(dir, match);
129 let port = cfg.defaultPort;
130 try {
131 const content = fs.readFileSync(configPath, 'utf-8');
132 const portMatch = content.match(cfg.portRe);
133 if (portMatch) port = parseInt(portMatch[1], 10);
134 } catch { /* use default */ }
135
136 return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
137 }
138 return null;
139}
140
141/**
142 * Check if a port is listening and optionally verify it matches the expected framework.
143 * Returns { listening: true, matched: true/false } or { listening: false }.
144 */
145async function isPortListening(port, fingerprint = null) {
146 if (!fingerprint) {
147 // Simple TCP probe fallback
148 const net = await import('node:net');
149 return new Promise((resolve) => {
150 const sock = net.default.createConnection({ port, host: '127.0.0.1' });
151 sock.setTimeout(500);
152 sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
153 sock.on('error', () => resolve({ listening: false }));
154 sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
155 });
156 }
157
158 // HTTP probe with fingerprint matching
159 try {
160 const controller = new AbortController();
161 const timeout = setTimeout(() => controller.abort(), 2000);
162 const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
163 clearTimeout(timeout);
164
165 // Check header fingerprint
166 if (fingerprint.header) {
167 const val = res.headers.get(fingerprint.header);
168 if (val && (!fingerprint.value || fingerprint.value.test(val))) {
169 return { listening: true, matched: true };
170 }
171 }
172
173 // Check body fingerprint
174 if (fingerprint.body) {
175 const body = await res.text();
176 if (fingerprint.body.test(body)) {
177 return { listening: true, matched: true };
178 }
179 }
180
181 // Port is listening but doesn't match the expected framework
182 return { listening: true, matched: false };
183 } catch {
184 return { listening: false };
185 }
186}
187
188export {
189 SKIP_DIRS,
190 SCANNABLE_EXTENSIONS,
191 HTML_EXTENSIONS,
192 walkDir,
193 resolveImport,
194 buildImportGraph,
195 FRAMEWORK_CONFIGS,
196 detectFrameworkConfig,
197 isPortListening,
198};