UNPKG

@tanstack/router-generator

Version:

Modern and scalable routing for React applications

1,374 lines (1,244 loc) 45.4 kB
import path from 'node:path' import * as fsp from 'node:fs/promises' import { mkdtempSync } from 'node:fs' import crypto from 'node:crypto' import { deepEqual, rootRouteId } from '@tanstack/router-core' import { logging } from './logger' import { isVirtualConfigFile, getRouteNodes as physicalGetRouteNodes, } from './filesystem/physical/getRouteNodes' import { getRouteNodes as virtualGetRouteNodes } from './filesystem/virtual/getRouteNodes' import { rootPathId } from './filesystem/physical/rootPathId' import { buildFileRoutesByPathInterface, buildImportString, buildRouteTreeConfig, checkFileExists, createRouteNodesByFullPath, createRouteNodesById, createRouteNodesByTo, determineNodePath, findParent, format, getResolvedRouteNodeVariableName, hasParentRoute, isRouteNodeValidForAugmentation, lowerCaseFirstChar, mergeImportDeclarations, multiSortBy, removeExt, removeGroups, removeLastSegmentFromPath, removeLayoutSegments, removeUnderscores, replaceBackslash, resetRegex, routePathToVariable, trimPathLeft, } from './utils' import { fillTemplate, getTargetTemplate } from './template' import { transform } from './transform/transform' import { defaultGeneratorPlugin } from './plugin/default-generator-plugin' import type { GeneratorPlugin, GeneratorPluginWithTransform, } from './plugin/types' import type { TargetTemplate } from './template' import type { FsRouteType, GetRouteNodesResult, GetRoutesByFileMapResult, HandleNodeAccumulator, ImportDeclaration, RouteNode, } from './types' import type { Config } from './config' import type { Logger } from './logger' import type { TransformPlugin } from './transform/types' interface fs { stat: (filePath: string) => Promise<{ mtimeMs: bigint }> mkdtempSync: (prefix: string) => string rename: (oldPath: string, newPath: string) => Promise<void> writeFile: (filePath: string, content: string) => Promise<void> readFile: ( filePath: string, ) => Promise< { stat: { mtimeMs: bigint }; fileContent: string } | 'file-not-existing' > } const DefaultFileSystem: fs = { stat: (filePath) => fsp.stat(filePath, { bigint: true }), mkdtempSync: mkdtempSync, rename: (oldPath, newPath) => fsp.rename(oldPath, newPath), writeFile: (filePath, content) => fsp.writeFile(filePath, content), readFile: async (filePath: string) => { try { const fileHandle = await fsp.open(filePath, 'r') const stat = await fileHandle.stat({ bigint: true }) const fileContent = (await fileHandle.readFile()).toString() await fileHandle.close() return { stat, fileContent } } catch (e: any) { if ('code' in e) { if (e.code === 'ENOENT') { return 'file-not-existing' } } throw e } }, } interface Rerun { rerun: true msg?: string event: GeneratorEvent } function rerun(opts: { msg?: string; event?: GeneratorEvent }): Rerun { const { event, ...rest } = opts return { rerun: true, event: event ?? { type: 'rerun' }, ...rest } } function isRerun(result: unknown): result is Rerun { return ( typeof result === 'object' && result !== null && 'rerun' in result && result.rerun === true ) } export type FileEventType = 'create' | 'update' | 'delete' export type FileEvent = { type: FileEventType path: string } export type GeneratorEvent = FileEvent | { type: 'rerun' } type FileCacheChange<TCacheEntry extends GeneratorCacheEntry> = | { result: false cacheEntry: TCacheEntry } | { result: true; mtimeMs: bigint; cacheEntry: TCacheEntry } | { result: 'file-not-in-cache' } | { result: 'cannot-stat-file' } interface GeneratorCacheEntry { mtimeMs: bigint fileContent: string } interface RouteNodeCacheEntry extends GeneratorCacheEntry { exports: Array<string> routeId: string } type GeneratorRouteNodeCache = Map</** filePath **/ string, RouteNodeCacheEntry> export class Generator { /** * why do we have two caches for the route files? * During processing, we READ from the cache and WRITE to the shadow cache. * * After a route file is processed, we write to the shadow cache. * If during processing we bail out and re-run, we don't lose this modification * but still can track whether the file contributed changes and thus the route tree file needs to be regenerated. * After all files are processed, we swap the shadow cache with the main cache and initialize a new shadow cache. * That way we also ensure deleted/renamed files don't stay in the cache forever. */ private routeNodeCache: GeneratorRouteNodeCache = new Map() private routeNodeShadowCache: GeneratorRouteNodeCache = new Map() private routeTreeFileCache: GeneratorCacheEntry | undefined public config: Config public targetTemplate: TargetTemplate private root: string private routesDirectoryPath: string private tmpDir: string private fs: fs private logger: Logger private generatedRouteTreePath: string private runPromise: Promise<void> | undefined private fileEventQueue: Array<GeneratorEvent> = [] private plugins: Array<GeneratorPlugin> = [defaultGeneratorPlugin()] private pluginsWithTransform: Array<GeneratorPluginWithTransform> = [] // this is just a cache for the transform plugins since we need them for each route file that is to be processed private transformPlugins: Array<TransformPlugin> = [] private routeGroupPatternRegex = /\(.+\)/g private physicalDirectories: Array<string> = [] constructor(opts: { config: Config; root: string; fs?: fs }) { this.config = opts.config this.logger = logging({ disabled: this.config.disableLogging }) this.root = opts.root this.fs = opts.fs || DefaultFileSystem this.tmpDir = this.fs.mkdtempSync( path.join(this.config.tmpDir, 'router-generator-'), ) this.generatedRouteTreePath = path.resolve(this.config.generatedRouteTree) this.targetTemplate = getTargetTemplate(this.config) this.routesDirectoryPath = this.getRoutesDirectoryPath() this.plugins.push(...(opts.config.plugins || [])) this.plugins.forEach((plugin) => { if ('transformPlugin' in plugin) { if (this.pluginsWithTransform.find((p) => p.name === plugin.name)) { throw new Error( `Plugin with name "${plugin.name}" is already registered for export ${plugin.transformPlugin.exportName}!`, ) } this.pluginsWithTransform.push(plugin) this.transformPlugins.push(plugin.transformPlugin) } }) } private getRoutesDirectoryPath() { return path.isAbsolute(this.config.routesDirectory) ? this.config.routesDirectory : path.resolve(this.root, this.config.routesDirectory) } public getRoutesByFileMap(): GetRoutesByFileMapResult { return new Map( [...this.routeNodeCache.entries()].map(([filePath, cacheEntry]) => [ filePath, { routePath: cacheEntry.routeId }, ]), ) } public async run(event?: GeneratorEvent): Promise<void> { if ( event && event.type !== 'rerun' && !this.isFileRelevantForRouteTreeGeneration(event.path) ) { return } this.fileEventQueue.push(event ?? { type: 'rerun' }) // only allow a single run at a time if (this.runPromise) { return this.runPromise } this.runPromise = (async () => { do { // synchronously copy and clear the queue since we are going to iterate asynchronously over it // and while we do so, a new event could be put into the queue const tempQueue = this.fileEventQueue this.fileEventQueue = [] // if we only have 'update' events in the queue // and we already have the affected files' latest state in our cache, we can exit early const remainingEvents = ( await Promise.all( tempQueue.map(async (e) => { if (e.type === 'update') { let cacheEntry: GeneratorCacheEntry | undefined if (e.path === this.generatedRouteTreePath) { cacheEntry = this.routeTreeFileCache } else { // we only check the routeNodeCache here // if the file's state is only up-to-date in the shadow cache we need to re-run cacheEntry = this.routeNodeCache.get(e.path) } const change = await this.didFileChangeComparedToCache( { path: e.path }, cacheEntry, ) if (change.result === false) { return null } } return e }), ) ).filter((e) => e !== null) if (remainingEvents.length === 0) { break } try { const start = performance.now() await this.generatorInternal() const end = performance.now() this.logger.info( `Generated route tree in ${Math.round(end - start)}ms`, ) } catch (err) { const errArray = !Array.isArray(err) ? [err] : err const recoverableErrors = errArray.filter((e) => isRerun(e)) if (recoverableErrors.length === errArray.length) { this.fileEventQueue.push(...recoverableErrors.map((e) => e.event)) recoverableErrors.forEach((e) => { if (e.msg) { this.logger.info(e.msg) } }) } else { const unrecoverableErrors = errArray.filter((e) => !isRerun(e)) this.runPromise = undefined throw new Error( unrecoverableErrors.map((e) => (e as Error).message).join(), ) } } } while (this.fileEventQueue.length) this.runPromise = undefined })() return this.runPromise } private async generatorInternal() { let writeRouteTreeFile: boolean | 'force' = false let getRouteNodesResult: GetRouteNodesResult if (this.config.virtualRouteConfig) { getRouteNodesResult = await virtualGetRouteNodes(this.config, this.root) } else { getRouteNodesResult = await physicalGetRouteNodes(this.config, this.root) } const { rootRouteNode, routeNodes: beforeRouteNodes, physicalDirectories, } = getRouteNodesResult if (rootRouteNode === undefined) { let errorMessage = `rootRouteNode must not be undefined. Make sure you've added your root route into the route-tree.` if (!this.config.virtualRouteConfig) { errorMessage += `\nMake sure that you add a "${rootPathId}.${this.config.disableTypes ? 'js' : 'tsx'}" file to your routes directory.\nAdd the file in: "${this.config.routesDirectory}/${rootPathId}.${this.config.disableTypes ? 'js' : 'tsx'}"` } throw new Error(errorMessage) } this.physicalDirectories = physicalDirectories writeRouteTreeFile = await this.handleRootNode(rootRouteNode) const preRouteNodes = multiSortBy(beforeRouteNodes, [ (d) => (d.routePath === '/' ? -1 : 1), (d) => d.routePath?.split('/').length, (d) => d.filePath.match(new RegExp(`[./]${this.config.indexToken}[.]`)) ? 1 : -1, (d) => d.filePath.match( /[./](component|errorComponent|pendingComponent|loader|lazy)[.]/, ) ? 1 : -1, (d) => d.filePath.match(new RegExp(`[./]${this.config.routeToken}[.]`)) ? -1 : 1, (d) => (d.routePath?.endsWith('/') ? -1 : 1), (d) => d.routePath, ]).filter((d) => ![`/${rootPathId}`].includes(d.routePath || '')) const routeFileAllResult = await Promise.allSettled( preRouteNodes // only process routes that are backed by an actual file .filter((n) => !n.isVirtualParentRoute && !n.isVirtual) .map((n) => this.processRouteNodeFile(n)), ) const rejections = routeFileAllResult.filter( (result) => result.status === 'rejected', ) if (rejections.length > 0) { throw rejections.map((e) => e.reason) } const routeFileResult = routeFileAllResult.flatMap((result) => { if (result.status === 'fulfilled' && result.value !== null) { return result.value } return [] }) routeFileResult.forEach((result) => { if (!result.node.exports?.length) { this.logger.warn( `Route file "${result.cacheEntry.fileContent}" does not export any route piece. This is likely a mistake.`, ) } }) if (routeFileResult.find((r) => r.shouldWriteTree)) { writeRouteTreeFile = true } // this is the first time the generator runs, so read in the route tree file if it exists yet if (!this.routeTreeFileCache) { const routeTreeFile = await this.fs.readFile(this.generatedRouteTreePath) if (routeTreeFile !== 'file-not-existing') { this.routeTreeFileCache = { fileContent: routeTreeFile.fileContent, mtimeMs: routeTreeFile.stat.mtimeMs, } } writeRouteTreeFile = true } else { const routeTreeFileChange = await this.didFileChangeComparedToCache( { path: this.generatedRouteTreePath }, this.routeTreeFileCache, ) if (routeTreeFileChange.result !== false) { writeRouteTreeFile = 'force' if (routeTreeFileChange.result === true) { const routeTreeFile = await this.fs.readFile( this.generatedRouteTreePath, ) if (routeTreeFile !== 'file-not-existing') { this.routeTreeFileCache = { fileContent: routeTreeFile.fileContent, mtimeMs: routeTreeFile.stat.mtimeMs, } } } } } if (!writeRouteTreeFile) { // only needs to be done if no other changes have been detected yet // compare shadowCache and cache to identify deleted routes for (const fullPath of this.routeNodeCache.keys()) { if (!this.routeNodeShadowCache.has(fullPath)) { writeRouteTreeFile = true break } } } if (!writeRouteTreeFile) { this.swapCaches() return } let routeTreeContent = this.buildRouteTreeFileContent( rootRouteNode, preRouteNodes, routeFileResult, ) routeTreeContent = this.config.enableRouteTreeFormatting ? await format(routeTreeContent, this.config) : routeTreeContent let newMtimeMs: bigint | undefined if (this.routeTreeFileCache) { if ( writeRouteTreeFile !== 'force' && this.routeTreeFileCache.fileContent === routeTreeContent ) { // existing route tree file is already up-to-date, don't write it // we should only get here in the initial run when the route cache is not filled yet } else { const newRouteTreeFileStat = await this.safeFileWrite({ filePath: this.generatedRouteTreePath, newContent: routeTreeContent, strategy: { type: 'mtime', expectedMtimeMs: this.routeTreeFileCache.mtimeMs, }, }) newMtimeMs = newRouteTreeFileStat.mtimeMs } } else { const newRouteTreeFileStat = await this.safeFileWrite({ filePath: this.generatedRouteTreePath, newContent: routeTreeContent, strategy: { type: 'new-file', }, }) newMtimeMs = newRouteTreeFileStat.mtimeMs } if (newMtimeMs !== undefined) { this.routeTreeFileCache = { fileContent: routeTreeContent, mtimeMs: newMtimeMs, } } this.swapCaches() } private swapCaches() { this.routeNodeCache = this.routeNodeShadowCache this.routeNodeShadowCache = new Map() } private buildRouteTreeFileContent( rootRouteNode: RouteNode, preRouteNodes: Array<RouteNode>, routeFileResult: Array<{ cacheEntry: RouteNodeCacheEntry node: RouteNode }>, ) { const getImportForRouteNode = (node: RouteNode, exportName: string) => { if (node.exports?.includes(exportName)) { return { source: `./${this.getImportPath(node)}`, specifiers: [ { imported: exportName, local: `${node.variableName}${exportName}Import`, }, ], } satisfies ImportDeclaration } return undefined } const buildRouteTreeForExport = (plugin: GeneratorPluginWithTransform) => { const exportName = plugin.transformPlugin.exportName const acc: HandleNodeAccumulator = { routeTree: [], routeNodes: [], routePiecesByPath: {}, } for (const node of preRouteNodes) { if (node.exports?.includes(plugin.transformPlugin.exportName)) { this.handleNode(node, acc) } } const sortedRouteNodes = multiSortBy(acc.routeNodes, [ (d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1), (d) => d.routePath?.split('/').length, (d) => (d.routePath?.endsWith(this.config.indexToken) ? -1 : 1), (d) => d, ]) const pluginConfig = plugin.config({ generator: this, rootRouteNode, sortedRouteNodes, }) const routeImports = sortedRouteNodes .filter((d) => !d.isVirtual) .flatMap((node) => getImportForRouteNode(node, exportName) ?? []) const hasMatchingRouteFiles = acc.routeNodes.length > 0 || rootRouteNode.exports?.includes(exportName) const virtualRouteNodes = sortedRouteNodes .filter((d) => d.isVirtual) .map((node) => { return `const ${ node.variableName }${exportName}Import = ${plugin.createVirtualRouteCode({ node })}` }) if ( !rootRouteNode.exports?.includes(exportName) && pluginConfig.virtualRootRoute ) { virtualRouteNodes.unshift( `const ${rootRouteNode.variableName}${exportName}Import = ${plugin.createRootRouteCode()}`, ) } const imports = plugin.imports({ sortedRouteNodes, acc, generator: this, rootRouteNode, }) const routeTreeConfig = buildRouteTreeConfig( acc.routeTree, exportName, this.config.disableTypes, ) const createUpdateRoutes = sortedRouteNodes.map((node) => { const loaderNode = acc.routePiecesByPath[node.routePath!]?.loader const componentNode = acc.routePiecesByPath[node.routePath!]?.component const errorComponentNode = acc.routePiecesByPath[node.routePath!]?.errorComponent const pendingComponentNode = acc.routePiecesByPath[node.routePath!]?.pendingComponent const lazyComponentNode = acc.routePiecesByPath[node.routePath!]?.lazy return [ [ `const ${node.variableName}${exportName} = ${node.variableName}${exportName}Import.update({ ${[ `id: '${node.path}'`, !node.isNonPath ? `path: '${node.cleanedPath}'` : undefined, `getParentRoute: () => ${findParent(node, exportName)}`, ] .filter(Boolean) .join(',')} }${this.config.disableTypes ? '' : 'as any'})`, loaderNode ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash( removeExt( path.relative( path.dirname(this.config.generatedRouteTree), path.resolve( this.config.routesDirectory, loaderNode.filePath, ), ), this.config.addExtensions, ), )}'), 'loader') })` : '', componentNode || errorComponentNode || pendingComponentNode ? `.update({ ${( [ ['component', componentNode], ['errorComponent', errorComponentNode], ['pendingComponent', pendingComponentNode], ] as const ) .filter((d) => d[1]) .map((d) => { return `${ d[0] }: lazyRouteComponent(() => import('./${replaceBackslash( removeExt( path.relative( path.dirname(this.config.generatedRouteTree), path.resolve( this.config.routesDirectory, d[1]!.filePath, ), ), this.config.addExtensions, ), )}'), '${d[0]}')` }) .join('\n,')} })` : '', lazyComponentNode ? `.lazy(() => import('./${replaceBackslash( removeExt( path.relative( path.dirname(this.config.generatedRouteTree), path.resolve( this.config.routesDirectory, lazyComponentNode.filePath, ), ), this.config.addExtensions, ), )}').then((d) => d.${exportName}))` : '', ].join(''), ].join('\n\n') }) let fileRoutesByPathInterfacePerPlugin = '' let fileRoutesByFullPathPerPlugin = '' if (!this.config.disableTypes && hasMatchingRouteFiles) { fileRoutesByFullPathPerPlugin = [ `export interface File${exportName}sByFullPath { ${[...createRouteNodesByFullPath(acc.routeNodes).entries()] .filter(([fullPath]) => fullPath) .map(([fullPath, routeNode]) => { return `'${fullPath}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}` })} }`, `export interface File${exportName}sByTo { ${[...createRouteNodesByTo(acc.routeNodes).entries()] .filter(([to]) => to) .map(([to, routeNode]) => { return `'${to}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}` })} }`, `export interface File${exportName}sById { '${rootRouteId}': typeof root${exportName}Import, ${[...createRouteNodesById(acc.routeNodes).entries()].map(([id, routeNode]) => { return `'${id}': typeof ${getResolvedRouteNodeVariableName(routeNode, exportName)}` })} }`, `export interface File${exportName}Types { file${exportName}sByFullPath: File${exportName}sByFullPath fullPaths: ${ acc.routeNodes.length > 0 ? [...createRouteNodesByFullPath(acc.routeNodes).keys()] .filter((fullPath) => fullPath) .map((fullPath) => `'${fullPath}'`) .join('|') : 'never' } file${exportName}sByTo: File${exportName}sByTo to: ${ acc.routeNodes.length > 0 ? [...createRouteNodesByTo(acc.routeNodes).keys()] .filter((to) => to) .map((to) => `'${to}'`) .join('|') : 'never' } id: ${[`'${rootRouteId}'`, ...[...createRouteNodesById(acc.routeNodes).keys()].map((id) => `'${id}'`)].join('|')} file${exportName}sById: File${exportName}sById }`, `export interface Root${exportName}Children { ${acc.routeTree.map((child) => `${child.variableName}${exportName}: typeof ${getResolvedRouteNodeVariableName(child, exportName)}`).join(',')} }`, ].join('\n') fileRoutesByPathInterfacePerPlugin = buildFileRoutesByPathInterface({ ...plugin.moduleAugmentation({ generator: this }), routeNodes: this.config.verboseFileRoutes !== false ? sortedRouteNodes : [ ...routeFileResult.map(({ node }) => node), ...sortedRouteNodes.filter((d) => d.isVirtual), ], exportName, }) } let routeTree = '' if (hasMatchingRouteFiles) { routeTree = [ `const root${exportName}Children${this.config.disableTypes ? '' : `: Root${exportName}Children`} = { ${acc.routeTree .map( (child) => `${child.variableName}${exportName}: ${getResolvedRouteNodeVariableName(child, exportName)}`, ) .join(',')} }`, `export const ${lowerCaseFirstChar(exportName)}Tree = root${exportName}Import._addFileChildren(root${exportName}Children)${this.config.disableTypes ? '' : `._addFileTypes<File${exportName}Types>()`}`, ].join('\n') } return { routeImports, sortedRouteNodes, acc, virtualRouteNodes, routeTreeConfig, routeTree, imports, createUpdateRoutes, fileRoutesByFullPathPerPlugin, fileRoutesByPathInterfacePerPlugin, } } const routeTrees = this.pluginsWithTransform.map((plugin) => ({ exportName: plugin.transformPlugin.exportName, ...buildRouteTreeForExport(plugin), })) this.plugins.map((plugin) => { return plugin.onRouteTreesChanged?.({ routeTrees, rootRouteNode, generator: this, }) }) let mergedImports = mergeImportDeclarations( routeTrees.flatMap((d) => d.imports), ) if (this.config.disableTypes) { mergedImports = mergedImports.filter((d) => d.importKind !== 'type') } const importStatements = mergedImports.map(buildImportString) let moduleAugmentation = '' if (this.config.verboseFileRoutes === false && !this.config.disableTypes) { moduleAugmentation = routeFileResult .map(({ node }) => { const getModuleDeclaration = (routeNode?: RouteNode) => { if (!isRouteNodeValidForAugmentation(routeNode)) { return '' } const moduleAugmentation = this.pluginsWithTransform .map((plugin) => { return plugin.routeModuleAugmentation({ routeNode, }) }) .filter(Boolean) .join('\n') return `declare module './${this.getImportPath(routeNode)}' { ${moduleAugmentation} }` } return getModuleDeclaration(node) }) .join('\n') } const routeImports = routeTrees.flatMap((t) => t.routeImports) const rootRouteImports = this.pluginsWithTransform.flatMap( (p) => getImportForRouteNode(rootRouteNode, p.transformPlugin.exportName) ?? [], ) if (rootRouteImports.length > 0) { routeImports.unshift(...rootRouteImports) } const routeTreeContent = [ ...this.config.routeTreeFileHeader, `// This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.`, [...importStatements].join('\n'), mergeImportDeclarations(routeImports).map(buildImportString).join('\n'), routeTrees.flatMap((t) => t.virtualRouteNodes).join('\n'), routeTrees.flatMap((t) => t.createUpdateRoutes).join('\n'), routeTrees.map((t) => t.fileRoutesByFullPathPerPlugin).join('\n'), routeTrees.map((t) => t.fileRoutesByPathInterfacePerPlugin).join('\n'), moduleAugmentation, routeTrees.flatMap((t) => t.routeTreeConfig).join('\n'), routeTrees.map((t) => t.routeTree).join('\n'), ...this.config.routeTreeFileFooter, ] .filter(Boolean) .join('\n\n') return routeTreeContent } private getImportPath(node: RouteNode) { return replaceBackslash( removeExt( path.relative( path.dirname(this.config.generatedRouteTree), path.resolve(this.config.routesDirectory, node.filePath), ), this.config.addExtensions, ), ) } private async processRouteNodeFile(node: RouteNode): Promise<{ shouldWriteTree: boolean cacheEntry: RouteNodeCacheEntry node: RouteNode } | null> { const result = await this.isRouteFileCacheFresh(node) if (result.status === 'fresh') { node.exports = result.cacheEntry.exports return { node, shouldWriteTree: result.exportsChanged, cacheEntry: result.cacheEntry, } } const existingRouteFile = await this.fs.readFile(node.fullPath) if (existingRouteFile === 'file-not-existing') { throw new Error(`⚠️ File ${node.fullPath} does not exist`) } const updatedCacheEntry: RouteNodeCacheEntry = { fileContent: existingRouteFile.fileContent, mtimeMs: existingRouteFile.stat.mtimeMs, exports: [], routeId: node.routePath ?? '$$TSR_NO_ROUTE_PATH_ASSIGNED$$', } const escapedRoutePath = node.routePath?.replaceAll('$', '$$') ?? '' let shouldWriteRouteFile = false // now we need to either scaffold the file or transform it if (!existingRouteFile.fileContent) { shouldWriteRouteFile = true // Creating a new lazy route file if (node._fsRouteType === 'lazy') { const tLazyRouteTemplate = this.targetTemplate.lazyRoute // Check by default check if the user has a specific lazy route template // If not, check if the user has a route template and use that instead updatedCacheEntry.fileContent = await fillTemplate( this.config, (this.config.customScaffolding?.lazyRouteTemplate || this.config.customScaffolding?.routeTemplate) ?? tLazyRouteTemplate.template(), { tsrImports: tLazyRouteTemplate.imports.tsrImports(), tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'), tsrExportStart: tLazyRouteTemplate.imports.tsrExportStart(escapedRoutePath), tsrExportEnd: tLazyRouteTemplate.imports.tsrExportEnd(), }, ) updatedCacheEntry.exports = ['Route'] } else if ( // Creating a new normal route file (['layout', 'static'] satisfies Array<FsRouteType>).some( (d) => d === node._fsRouteType, ) || ( [ 'component', 'pendingComponent', 'errorComponent', 'loader', ] satisfies Array<FsRouteType> ).every((d) => d !== node._fsRouteType) ) { const tRouteTemplate = this.targetTemplate.route updatedCacheEntry.fileContent = await fillTemplate( this.config, this.config.customScaffolding?.routeTemplate ?? tRouteTemplate.template(), { tsrImports: tRouteTemplate.imports.tsrImports(), tsrPath: escapedRoutePath.replaceAll(/\{(.+)\}/gm, '$1'), tsrExportStart: tRouteTemplate.imports.tsrExportStart(escapedRoutePath), tsrExportEnd: tRouteTemplate.imports.tsrExportEnd(), }, ) updatedCacheEntry.exports = ['Route'] } else { return null } } else { // transform the file const transformResult = await transform({ source: updatedCacheEntry.fileContent, ctx: { target: this.config.target, routeId: escapedRoutePath, lazy: node._fsRouteType === 'lazy', verboseFileRoutes: !(this.config.verboseFileRoutes === false), }, plugins: this.transformPlugins, }) if (transformResult.result === 'error') { throw new Error( `Error transforming route file ${node.fullPath}: ${transformResult.error}`, ) } updatedCacheEntry.exports = transformResult.exports if (transformResult.result === 'modified') { updatedCacheEntry.fileContent = transformResult.output shouldWriteRouteFile = true } } // file was changed if (shouldWriteRouteFile) { const stats = await this.safeFileWrite({ filePath: node.fullPath, newContent: updatedCacheEntry.fileContent, strategy: { type: 'mtime', expectedMtimeMs: updatedCacheEntry.mtimeMs, }, }) updatedCacheEntry.mtimeMs = stats.mtimeMs } this.routeNodeShadowCache.set(node.fullPath, updatedCacheEntry) node.exports = updatedCacheEntry.exports const shouldWriteTree = !deepEqual( result.cacheEntry?.exports, updatedCacheEntry.exports, ) return { node, shouldWriteTree, cacheEntry: updatedCacheEntry, } } private async didRouteFileChangeComparedToCache( file: { path: string mtimeMs?: bigint }, cache: 'routeNodeCache' | 'routeNodeShadowCache', ): Promise<FileCacheChange<RouteNodeCacheEntry>> { const cacheEntry = this[cache].get(file.path) return this.didFileChangeComparedToCache(file, cacheEntry) } private async didFileChangeComparedToCache< TCacheEntry extends GeneratorCacheEntry, >( file: { path: string mtimeMs?: bigint }, cacheEntry: TCacheEntry | undefined, ): Promise<FileCacheChange<TCacheEntry>> { // for now we rely on the modification time of the file // to determine if the file has changed // we could also compare the file content but this would be slower as we would have to read the file if (!cacheEntry) { return { result: 'file-not-in-cache' } } let mtimeMs = file.mtimeMs if (mtimeMs === undefined) { try { const currentStat = await this.fs.stat(file.path) mtimeMs = currentStat.mtimeMs } catch { return { result: 'cannot-stat-file' } } } return { result: mtimeMs !== cacheEntry.mtimeMs, mtimeMs, cacheEntry } } private async safeFileWrite(opts: { filePath: string newContent: string strategy: | { type: 'mtime' expectedMtimeMs: bigint } | { type: 'new-file' } }) { const tmpPath = this.getTempFileName(opts.filePath) await this.fs.writeFile(tmpPath, opts.newContent) if (opts.strategy.type === 'mtime') { const beforeStat = await this.fs.stat(opts.filePath) if (beforeStat.mtimeMs !== opts.strategy.expectedMtimeMs) { throw rerun({ msg: `File ${opts.filePath} was modified by another process during processing.`, event: { type: 'update', path: opts.filePath }, }) } } else { if (await checkFileExists(opts.filePath)) { throw rerun({ msg: `File ${opts.filePath} already exists. Cannot overwrite.`, event: { type: 'update', path: opts.filePath }, }) } } const stat = await this.fs.stat(tmpPath) await this.fs.rename(tmpPath, opts.filePath) return stat } private getTempFileName(filePath: string) { const absPath = path.resolve(filePath) const hash = crypto.createHash('md5').update(absPath).digest('hex') return path.join(this.tmpDir, hash) } private async isRouteFileCacheFresh(node: RouteNode): Promise< | { status: 'fresh' cacheEntry: RouteNodeCacheEntry exportsChanged: boolean } | { status: 'stale'; cacheEntry?: RouteNodeCacheEntry } > { const fileChangedCache = await this.didRouteFileChangeComparedToCache( { path: node.fullPath }, 'routeNodeCache', ) if (fileChangedCache.result === false) { this.routeNodeShadowCache.set(node.fullPath, fileChangedCache.cacheEntry) return { status: 'fresh', exportsChanged: false, cacheEntry: fileChangedCache.cacheEntry, } } if (fileChangedCache.result === 'cannot-stat-file') { throw new Error(`⚠️ expected route file to exist at ${node.fullPath}`) } const mtimeMs = fileChangedCache.result === true ? fileChangedCache.mtimeMs : undefined const shadowCacheFileChange = await this.didRouteFileChangeComparedToCache( { path: node.fullPath, mtimeMs }, 'routeNodeShadowCache', ) if (shadowCacheFileChange.result === 'cannot-stat-file') { throw new Error(`⚠️ expected route file to exist at ${node.fullPath}`) } if (shadowCacheFileChange.result === false) { // shadow cache has latest file state already // compare shadowCache against cache to determine whether exports changed // if they didn't, cache is fresh if (fileChangedCache.result === true) { if ( deepEqual( fileChangedCache.cacheEntry.exports, shadowCacheFileChange.cacheEntry.exports, ) ) { return { status: 'fresh', exportsChanged: false, cacheEntry: shadowCacheFileChange.cacheEntry, } } return { status: 'fresh', exportsChanged: true, cacheEntry: shadowCacheFileChange.cacheEntry, } } } if (fileChangedCache.result === 'file-not-in-cache') { return { status: 'stale', } } return { status: 'stale', cacheEntry: fileChangedCache.cacheEntry } } private async handleRootNode(node: RouteNode) { const result = await this.isRouteFileCacheFresh(node) if (result.status === 'fresh') { node.exports = result.cacheEntry.exports this.routeNodeShadowCache.set(node.fullPath, result.cacheEntry) return result.exportsChanged } const rootNodeFile = await this.fs.readFile(node.fullPath) if (rootNodeFile === 'file-not-existing') { throw new Error(`⚠️ expected root route to exist at ${node.fullPath}`) } const updatedCacheEntry: RouteNodeCacheEntry = { fileContent: rootNodeFile.fileContent, mtimeMs: rootNodeFile.stat.mtimeMs, exports: [], routeId: node.routePath ?? '$$TSR_NO_ROOT_ROUTE_PATH_ASSIGNED$$', } // scaffold the root route if (!rootNodeFile.fileContent) { const rootTemplate = this.targetTemplate.rootRoute const rootRouteContent = await fillTemplate( this.config, rootTemplate.template(), { tsrImports: rootTemplate.imports.tsrImports(), tsrPath: rootPathId, tsrExportStart: rootTemplate.imports.tsrExportStart(), tsrExportEnd: rootTemplate.imports.tsrExportEnd(), }, ) this.logger.log(`🟡 Creating ${node.fullPath}`) const stats = await this.safeFileWrite({ filePath: node.fullPath, newContent: rootRouteContent, strategy: { type: 'mtime', expectedMtimeMs: rootNodeFile.stat.mtimeMs, }, }) updatedCacheEntry.fileContent = rootRouteContent updatedCacheEntry.mtimeMs = stats.mtimeMs } const rootRouteExports: Array<string> = [] for (const plugin of this.pluginsWithTransform) { const exportName = plugin.transformPlugin.exportName // TODO we need to parse instead of just string match // otherwise a commented out export will still be detected if (rootNodeFile.fileContent.includes(`export const ${exportName}`)) { rootRouteExports.push(exportName) } } updatedCacheEntry.exports = rootRouteExports node.exports = rootRouteExports this.routeNodeShadowCache.set(node.fullPath, updatedCacheEntry) const shouldWriteTree = !deepEqual( result.cacheEntry?.exports, rootRouteExports, ) return shouldWriteTree } private handleNode(node: RouteNode, acc: HandleNodeAccumulator) { // Do not remove this as we need to set the lastIndex to 0 as it // is necessary to reset the regex's index when using the global flag // otherwise it might not match the next time it's used resetRegex(this.routeGroupPatternRegex) let parentRoute = hasParentRoute(acc.routeNodes, node, node.routePath) // if the parent route is a virtual parent route, we need to find the real parent route if (parentRoute?.isVirtualParentRoute && parentRoute.children?.length) { // only if this sub-parent route returns a valid parent route, we use it, if not leave it as it const possibleParentRoute = hasParentRoute( parentRoute.children, node, node.routePath, ) if (possibleParentRoute) { parentRoute = possibleParentRoute } } if (parentRoute) node.parent = parentRoute node.path = determineNodePath(node) const trimmedPath = trimPathLeft(node.path ?? '') const split = trimmedPath.split('/') const lastRouteSegment = split[split.length - 1] ?? trimmedPath node.isNonPath = lastRouteSegment.startsWith('_') || this.routeGroupPatternRegex.test(lastRouteSegment) node.cleanedPath = removeGroups( removeUnderscores(removeLayoutSegments(node.path)) ?? '', ) if ( !node.isVirtual && ( [ 'lazy', 'loader', 'component', 'pendingComponent', 'errorComponent', ] satisfies Array<FsRouteType> ).some((d) => d === node._fsRouteType) ) { acc.routePiecesByPath[node.routePath!] = acc.routePiecesByPath[node.routePath!] || {} acc.routePiecesByPath[node.routePath!]![ node._fsRouteType === 'lazy' ? 'lazy' : node._fsRouteType === 'loader' ? 'loader' : node._fsRouteType === 'errorComponent' ? 'errorComponent' : node._fsRouteType === 'pendingComponent' ? 'pendingComponent' : 'component' ] = node const anchorRoute = acc.routeNodes.find( (d) => d.routePath === node.routePath, ) if (!anchorRoute) { this.handleNode( { ...node, isVirtual: true, _fsRouteType: 'static', }, acc, ) } return } const cleanedPathIsEmpty = (node.cleanedPath || '').length === 0 const nonPathRoute = node._fsRouteType === 'pathless_layout' && node.isNonPath node.isVirtualParentRequired = node._fsRouteType === 'pathless_layout' || nonPathRoute ? !cleanedPathIsEmpty : false if (!node.isVirtual && node.isVirtualParentRequired) { const parentRoutePath = removeLastSegmentFromPath(node.routePath) || '/' const parentVariableName = routePathToVariable(parentRoutePath) const anchorRoute = acc.routeNodes.find( (d) => d.routePath === parentRoutePath, ) if (!anchorRoute) { const parentNode: RouteNode = { ...node, path: removeLastSegmentFromPath(node.path) || '/', filePath: removeLastSegmentFromPath(node.filePath) || '/', fullPath: removeLastSegmentFromPath(node.fullPath) || '/', routePath: parentRoutePath, variableName: parentVariableName, isVirtual: true, _fsRouteType: 'layout', // layout since this route will wrap other routes isVirtualParentRoute: true, isVirtualParentRequired: false, } parentNode.children = parentNode.children ?? [] parentNode.children.push(node) node.parent = parentNode if (node._fsRouteType === 'pathless_layout') { // since `node.path` is used as the `id` on the route definition, we need to update it node.path = determineNodePath(node) } this.handleNode(parentNode, acc) } else { anchorRoute.children = anchorRoute.children ?? [] anchorRoute.children.push(node) node.parent = anchorRoute } } if (node.parent) { if (!node.isVirtualParentRequired) { node.parent.children = node.parent.children ?? [] node.parent.children.push(node) } } else { acc.routeTree.push(node) } acc.routeNodes.push(node) } // only process files that are relevant for the route tree generation private isFileRelevantForRouteTreeGeneration(filePath: string): boolean { // the generated route tree file if (filePath === this.generatedRouteTreePath) { return true } // files inside the routes folder if (filePath.startsWith(this.routesDirectoryPath)) { return true } // the virtual route config file passed into `virtualRouteConfig` if ( typeof this.config.virtualRouteConfig === 'string' && filePath === this.config.virtualRouteConfig ) { return true } // this covers all files that are mounted via `virtualRouteConfig` or any `__virtual.ts` files if (this.routeNodeCache.has(filePath)) { return true } // virtual config files such as`__virtual.ts` if (isVirtualConfigFile(path.basename(filePath))) { return true } // route files inside directories mounted via `physical()` inside a virtual route config if (this.physicalDirectories.some((dir) => filePath.startsWith(dir))) { return true } return false } }