/** * Route Types Writer * * Generates and writes TypeScript route type files (named-routes.gen.ts) * from discovered router manifests and static source parsing. */ import { dirname, join, resolve } from "node:path"; import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs"; import { generateRouteTypesSource, writeCombinedRouteTypes, findRouterFiles, buildCombinedRouteMapForRouterFile, genFileTsPath, resolveSearchSchemas, } from "../../build/generate-route-types.js"; import type { DiscoveryState } from "./state.js"; import { markSelfGenWrite } from "./self-gen-tracking.js"; import { isAutoGeneratedRouteName } from "../../route-name.js"; /** * Filter out auto-generated route names from a manifest. * Unnamed routes get "$path_"-prefixed names at runtime (see path-helper.ts). * These should not appear in the typed gen file. User-defined names * containing "$" (e.g. "$admin") are valid and preserved. */ function filterUserNamedRoutes( manifest: Record, ): Record { const filtered: Record = {}; for (const [name, pattern] of Object.entries(manifest)) { if (!isAutoGeneratedRouteName(name)) { filtered[name] = pattern; } } return filtered; } // Write a gen file only when content changed, marking the write as // self-generated BEFORE writeFileSync so the watcher distinguishes it from a // manual edit (the HMR self-gen-loop guard). function writeGenFileIfChanged( state: DiscoveryState, outPath: string, source: string, opts?: { log?: boolean }, ): void { const existing = existsSync(outPath) ? readFileSync(outPath, "utf-8") : null; if (existing === source) return; markSelfGenWrite(state, outPath, source); writeFileSync(outPath, source); if (opts?.log) console.log(`[rango] Generated route types -> ${outPath}`); } /** * Write combined route types for all router files. * Only writes when content has changed to avoid triggering HMR loops. */ export function writeCombinedRouteTypesWithTracking( state: DiscoveryState, opts?: { preserveIfLarger?: boolean }, ): void { const routerFiles = state.cachedRouterFiles ?? findRouterFiles(state.projectRoot, state.scanFilter); state.cachedRouterFiles = routerFiles; // Mark each gen file as self-generated BEFORE it is written, via the onWrite // callback fired at every writeFileSync site, so the watcher distinguishes // self-triggered change events from manual edits. The callback fires only // for files actually written, so unchanged files are never marked (stale // entries interfere with multi-server setups such as a shared webServer plus // an isolated HMR server). writeCombinedRouteTypes(state.projectRoot, routerFiles, { ...opts, onWrite: (outPath, content) => markSelfGenWrite(state, outPath, content), }); } /** * Write per-router route types files from runtime discovery data. */ export function writeRouteTypesFiles(state: DiscoveryState): void { if (state.perRouterManifests.length === 0) return; // Delete old combined named-routes.gen.ts if it exists try { const entryDir = dirname( resolve(state.projectRoot, state.resolvedEntryPath!), ); const oldCombinedPath = join(entryDir, "named-routes.gen.ts"); if (existsSync(oldCombinedPath)) { unlinkSync(oldCombinedPath); console.log( `[rango] Removed stale combined route types: ${oldCombinedPath}`, ); } } catch {} for (const { id, routeManifest, routeSearchSchemas, sourceFile, } of state.perRouterManifests) { if (!sourceFile) continue; // Validate sourceFile points to a real project file, not node_modules or // a Vite internal path. A bad sourceFile leads to route types written to // the wrong location, causing non-deterministic type resolution. if (sourceFile.includes("node_modules")) { throw new Error( `[rango] Router "${id}" has sourceFile inside node_modules: ${sourceFile}\n` + `This means createRouter() stack trace parsing matched a Vite internal frame.\n` + `Set an explicit \`id\` on createRouter() or check the call site.`, ); } const outPath = genFileTsPath(sourceFile); // Filter out auto-generated route names (e.g. "$path____debug_reverse-test") // to match the static parser's output and prevent HMR oscillation. const userRoutes = filterUserNamedRoutes(routeManifest); const effectiveSearchSchemas = resolveSearchSchemas( Object.keys(userRoutes), routeSearchSchemas, sourceFile, ); const source = generateRouteTypesSource( userRoutes, effectiveSearchSchemas && Object.keys(effectiveSearchSchemas).length > 0 ? effectiveSearchSchemas : undefined, ); writeGenFileIfChanged(state, outPath, source, { log: true }); } } /** * Supplement gen files with route groups from runtime manifests that the * static parser cannot resolve (factory calls like createDocsPatterns()). * Only adds groups whose dot-prefix (e.g. "docs.") is entirely absent * from the static output. Groups partially visible to the static parser * are left alone so renames/removals propagate immediately without * requiring a server restart. * * The runtime manifest (cachedManifest / perRouterManifestMap) is updated * automatically: the virtual:rsc-router/routes-manifest module imports the * gen file, so when we write new content here, Vite's HMR invalidates the * virtual module and re-evaluates it on the next request. */ export function supplementGenFilesWithRuntimeRoutes( state: DiscoveryState, ): void { // Cache static parsing results to avoid redundant I/O + parsing per router. const parseCache = new Map< string, ReturnType >(); const getParsed = (file: string) => { let cached = parseCache.get(file); if (!cached) { cached = buildCombinedRouteMapForRouterFile(file); parseCache.set(file, cached); } return cached; }; for (const { routeManifest, routeSearchSchemas, sourceFile, factoryOnlyPrefixes, } of state.perRouterManifests) { if (!sourceFile) continue; if (!factoryOnlyPrefixes || factoryOnlyPrefixes.size === 0) continue; const staticParsed = getParsed(sourceFile); // Merge: static routes (authoritative) + factory-only groups from runtime. const mergedRoutes: Record = { ...staticParsed.routes }; const mergedSearchSchemas: Record> = { ...staticParsed.searchSchemas, }; for (const [name, pattern] of Object.entries(routeManifest)) { // Skip internal runtime-only names from unnamed routes/includes. if (isAutoGeneratedRouteName(name)) continue; const dotIdx = name.indexOf("."); if (dotIdx <= 0) continue; const prefix = name.substring(0, dotIdx + 1); if (factoryOnlyPrefixes.has(prefix)) { mergedRoutes[name] = pattern; // Also merge search schemas from factory-generated routes if (routeSearchSchemas?.[name]) { mergedSearchSchemas[name] = routeSearchSchemas[name]; } } } const outPath = genFileTsPath(sourceFile); const source = generateRouteTypesSource( mergedRoutes, Object.keys(mergedSearchSchemas).length > 0 ? mergedSearchSchemas : undefined, ); writeGenFileIfChanged(state, outPath, source); } // No manual manifest update needed: the virtual module imports the gen // file, so Vite's HMR automatically re-evaluates it with fresh data. }