/** * Filesystem scanning for the file-based router. * * Scanning happens once at startup. We build a table of routes from the files * that exist on disk; request paths are later matched against that table. * Crucially, request input is NEVER turned into a file path — this is what * makes the router immune to path-traversal. */ import { existsSync, readdirSync, statSync } from "node:fs"; import { join, relative, sep } from "node:path"; /** Extensions we are willing to load as route modules. */ const ALLOWED_EXTENSIONS = [".ts", ".tsx", ".wrn"] as const; export interface ScannedFile { /** Absolute path to the file on disk. */ file: string; /** Path relative to the scanned base directory, using forward slashes. */ rel: string; } function hasAllowedExtension(name: string): boolean { return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext)); } /** Hidden files/dirs (dotfiles) and underscore-prefixed files are ignored. */ function isIgnored(name: string): boolean { return name.startsWith(".") || name.startsWith("_"); } /** * Recursively collect allowed route files under `baseDir`. * Returns [] if the directory does not exist (a route kind may be unused). * * `extraExtensions` widens the allow-list for a caller that scans a non-route * directory and accepts plain `.js` modules (currently only `app/authz`); it * defaults to empty so every other caller — route scanning (`app/pages`, * `app/api`, `app/realtime`, ...) as well as `app/schemas`, which does not * pass it and so still only sees `.ts`/`.tsx`/`.wrn` — is unaffected. */ export function scanDir(baseDir: string, extraExtensions: readonly string[] = []): ScannedFile[] { if (!existsSync(baseDir)) return []; const out: ScannedFile[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir)) { if (isIgnored(entry)) continue; const abs = join(dir, entry); const stats = statSync(abs); if (stats.isDirectory()) { walk(abs); } else if ( stats.isFile() && (hasAllowedExtension(entry) || extraExtensions.some((ext) => entry.endsWith(ext))) ) { out.push({ file: abs, rel: relative(baseDir, abs).split(sep).join("/"), }); } } }; walk(baseDir); return out; }