file-system.mjs

  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};