@tanstack/router-generator
Version:
Modern and scalable routing for React applications
1,374 lines (1,244 loc) • 45.4 kB
text/typescript
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
}
}