diff --git a/.changeset/skip-unused-start-routing.md b/.changeset/skip-unused-start-routing.md new file mode 100644 index 00000000000..97f08ca4e21 --- /dev/null +++ b/.changeset/skip-unused-start-routing.md @@ -0,0 +1,6 @@ +--- +'@tanstack/start-server-core': patch +'@tanstack/start-plugin-core': patch +--- + +Use build-time route information to skip server-route handling for apps without a `server` option on any route. Skip the request middleware chain when none are configured, and keep early route matching when needed for early hints. diff --git a/packages/start-plugin-core/src/global.d.ts b/packages/start-plugin-core/src/global.d.ts index c974e5019e8..fe724497d55 100644 --- a/packages/start-plugin-core/src/global.d.ts +++ b/packages/start-plugin-core/src/global.d.ts @@ -1,12 +1,17 @@ /* eslint-disable no-var */ declare global { - var TSS_ROUTES_MANIFEST: Record< - string, - { - filePath: string - children?: Array - } - > + var TSS_ROUTES_MANIFEST: + | { + routes: Record< + string, + { + filePath: string + children?: Array + } + > + hasServerRoutes?: boolean + } + | undefined var TSS_PRERENDABLE_PATHS: Array<{ path: string }> | undefined } export {} diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts index 40dfb9e42a2..94c0c1c8db0 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -32,6 +32,8 @@ export function registerRouterPlugins( api.modifyRspackConfig((config, utils) => { const envName = utils.environment.name + const isBuild = api.context.action === 'build' && !config.watch + const routesPlugin = routesManifestPlugin(() => isBuild) const { startConfig } = opts.getConfig() const routerConfig = startConfig.router @@ -51,7 +53,7 @@ export function registerRouterPlugins( }) }, plugins: [ - routesManifestPlugin(), + routesPlugin, ...(opts.startPluginOpts.prerender?.enabled === true ? [prerenderRoutesPlugin()] : []), @@ -67,15 +69,23 @@ export function registerRouterPlugins( envName === RSBUILD_ENVIRONMENT_NAMES.server ) { const isClient = envName === RSBUILD_ENVIRONMENT_NAMES.client + const codeSplittingOptions = { + ...routerConfig.codeSplittingOptions, + deleteNodes: isClient ? ['ssr', 'server', 'headers'] : undefined, + addHmr: isClient, + compilerPlugins: + isBuild && + isClient && + (typeof config.cache !== 'object' || + config.cache.type !== 'persistent') + ? [routesPlugin] + : [], + } const splitterPlugin = TanStackRouterCodeSplitterRspack( { ...routerConfig, target: opts.corePluginOpts.framework, - codeSplittingOptions: { - ...routerConfig.codeSplittingOptions, - deleteNodes: isClient ? ['ssr', 'server', 'headers'] : undefined, - addHmr: isClient, - }, + codeSplittingOptions, }, routerPluginContext, ) diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 36a34278305..4a3c8e769bb 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -86,7 +86,8 @@ function generateManifestModuleDev( scriptFormat: ScriptFormat, ): string { const scriptFormatProperty = getScriptFormatProperty(scriptFormat) - return `const fallbackManifest = { + return `export const hasServerRoutes = true +const fallbackManifest = { ${scriptFormatProperty} routes: { __root__: { preloads: ['${devClientEntryUrl}'], @@ -103,10 +104,12 @@ function buildStartManifestData( inlineCss: InlineCssOptions, scriptFormat: ScriptFormat, ) { - const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST + const { routes: routeTreeRoutes, hasServerRoutes } = + globalThis.TSS_ROUTES_MANIFEST! return buildStartManifest({ clientBuild, routeTreeRoutes, + hasServerRoutes, basePath: publicBase, inlineCss, scriptFormat, @@ -133,10 +136,18 @@ function generateManifestModuleBuild( ): string { if (!clientBuild) { return `const tsrStartManifestData = ${JSON.stringify(START_MANIFEST_PLACEHOLDER)} -export const tsrStartManifest = () => tsrStartManifestData` +export const tsrStartManifest = () => tsrStartManifestData +export const hasServerRoutes = true` } - return `export const tsrStartManifest = () => (${serializeStartManifestData(clientBuild, publicBase, inlineCss, scriptFormat)})` + const manifest = buildStartManifestData( + clientBuild, + publicBase, + inlineCss, + scriptFormat, + ) + return `export const hasServerRoutes = ${manifest.hasServerRoutes !== false} +export const tsrStartManifest = () => (${JSON.stringify(manifest)})` } /** @@ -389,7 +400,8 @@ export function registerVirtualModules( opts.scriptFormat, ) } else { - content[paths.manifest] = 'export default {}' + content[paths.manifest] = + 'export const hasServerRoutes = true\nexport default {}' } // Server fn resolver — SSR and provider environments @@ -529,13 +541,14 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is const devClientEntryUrl = opts.getDevClientEntryUrl( resolvedStartConfig.basePaths.publicBase, ) + if (isDev) { + return generateManifestModuleDev(devClientEntryUrl, opts.scriptFormat) + } return generateManifestModuleBuild( newClientBuild, resolvedStartConfig.basePaths.publicBase, devClientEntryUrl, - !isDev - ? startConfig.server.build.inlineCss - : { enabled: false, transformAssets: false }, + startConfig.server.build.inlineCss, opts.scriptFormat, ) }, diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index 02a85865928..59ac3a3f829 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -51,6 +51,7 @@ type DedupeRoute = { } export interface StartManifest { + hasServerRoutes?: boolean scriptFormat?: ScriptFormat routes: Record inlineCss?: { @@ -257,6 +258,7 @@ function appendAdditionalRouteEntries( export function buildStartManifest(options: { clientBuild: NormalizedClientBuild routeTreeRoutes: RouteTreeRoutes + hasServerRoutes?: boolean basePath: string inlineCss?: InlineCssOptions scriptFormat?: ScriptFormat @@ -298,6 +300,7 @@ export function buildStartManifest(options: { const result: StartManifest = { routes, + hasServerRoutes: options.hasServerRoutes, } if (options.scriptFormat === 'iife') { diff --git a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts index 34cb7aded1e..6264ee0743c 100644 --- a/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts +++ b/packages/start-plugin-core/src/start-router-plugin/generator-plugins/routes-manifest-plugin.ts @@ -1,16 +1,24 @@ import { rootRouteId } from '@tanstack/router-core' - +import * as t from '@babel/types' +import { hasServerOptions } from '../pruneServerOnlySubtrees' +import { SERVER_PROP } from '../constants' +import type { CodeSplitCompilerPlugin } from '@tanstack/router-plugin' import type { GeneratorPlugin } from '@tanstack/router-generator' /** * this plugin builds the routes manifest and stores it on globalThis * so that it can be accessed later (e.g. from a vite plugin) */ -export function routesManifestPlugin(): GeneratorPlugin { +export function routesManifestPlugin( + isBuild: () => boolean, +): GeneratorPlugin & CodeSplitCompilerPlugin { + let manifest: typeof globalThis.TSS_ROUTES_MANIFEST + return { name: 'routes-manifest-plugin', onRouteTreeChanged: ({ routeTree, rootRouteNode, routeNodes }) => { const allChildren = routeTree.map((d) => d.routePath) + let hasServerRoutes: boolean | undefined = isBuild() ? undefined : true const routes: Record< string, { @@ -25,6 +33,9 @@ export function routesManifestPlugin(): GeneratorPlugin { ...Object.fromEntries( routeNodes.map((d) => { const filePathId = d.routePath + if (hasServerRoutes !== true && hasServerOptions(d) !== false) { + hasServerRoutes = true + } return [ filePathId, @@ -37,7 +48,31 @@ export function routesManifestPlugin(): GeneratorPlugin { ), } - globalThis.TSS_ROUTES_MANIFEST = routes + manifest = { routes, hasServerRoutes } + globalThis.TSS_ROUTES_MANIFEST = manifest + }, + onRouteOptions({ routeOptions, createRouteFn, opts }) { + if (!manifest || manifest.hasServerRoutes === true) { + return + } + if ( + routeOptions.properties.some( + (prop) => + t.isSpreadElement(prop) || + prop.computed || + t.isIdentifier(prop.key, { name: SERVER_PROP }) || + t.isStringLiteral(prop.key, { value: SERVER_PROP }), + ) + ) { + manifest.hasServerRoutes = true + } else if ( + opts.id === + manifest.routes[rootRouteId]?.filePath.replaceAll('\\', '/') && + (createRouteFn === 'createRootRoute' || + createRouteFn === 'createRootRouteWithContext') + ) { + manifest.hasServerRoutes ??= false + } }, } } diff --git a/packages/start-plugin-core/src/start-router-plugin/pruneServerOnlySubtrees.ts b/packages/start-plugin-core/src/start-router-plugin/pruneServerOnlySubtrees.ts index e5b995ede58..d9e797363ce 100644 --- a/packages/start-plugin-core/src/start-router-plugin/pruneServerOnlySubtrees.ts +++ b/packages/start-plugin-core/src/start-router-plugin/pruneServerOnlySubtrees.ts @@ -4,6 +4,10 @@ import type { RouteNode, } from '@tanstack/router-generator' +export function hasServerOptions(node: RouteNode) { + return node.createFileRouteProps?.has(SERVER_PROP) +} + export function pruneServerOnlySubtrees({ rootRouteNode, acc, @@ -39,8 +43,8 @@ function prune( } const allServerOnly = - node.createFileRouteProps?.has(SERVER_PROP) && - node.createFileRouteProps.size === 1 && + hasServerOptions(node) && + node.createFileRouteProps?.size === 1 && allChildrenServerOnly // prune this subtree if (allServerOnly) { diff --git a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts index 064c7623d4b..9489e3f7640 100644 --- a/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/dev-server-plugin/plugin.ts @@ -70,9 +70,7 @@ export function devServerPlugin({ // Look up route file paths from manifest // Only routes registered in the manifest are used - this prevents path injection - const routesManifest = (globalThis as any).TSS_ROUTES_MANIFEST as - | Record }> - | undefined + const routesManifest = globalThis.TSS_ROUTES_MANIFEST?.routes if (routesManifest && ids.length > 0) { for (const routeId of ids) { diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts index 4ff16d83ff2..d169131eb5c 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts @@ -68,16 +68,18 @@ export function startManifestPlugin(opts: { return getEmptyStartManifestModule(clientEntry) } - const routeTreeRoutes = globalThis.TSS_ROUTES_MANIFEST // TODO this needs further discussion with vite-rsc, this is a temporary workaround // If the client bundle isn't available yet (e.g., during RSC scan builds), // return a dummy manifest. The real manifest will be generated in the actual build. if (!clientBuild) { return getEmptyStartManifestModule(clientEntry) } + const { routes: routeTreeRoutes, hasServerRoutes } = + globalThis.TSS_ROUTES_MANIFEST! const startManifest = buildStartManifest({ clientBuild, routeTreeRoutes, + hasServerRoutes, basePath: resolvedStartConfig.basePaths.publicBase, inlineCss: startConfig.server.build.inlineCss, additionalRouteAssets: getViteAdditionalRouteAssets({ @@ -87,7 +89,8 @@ export function startManifestPlugin(opts: { }), }) - return `export const tsrStartManifest = () => (${serializeStartManifest(startManifest)})` + return `export const hasServerRoutes = ${startManifest.hasServerRoutes !== false} +export const tsrStartManifest = () => (${serializeStartManifest(startManifest)})` }, }), ] @@ -139,7 +142,8 @@ function getAssetFileNameByName( } function getEmptyStartManifestModule(clientEntry: string) { - return `export const tsrStartManifest = () => ({ + return `export const hasServerRoutes = true +export const tsrStartManifest = () => ({ routes: { __root__: { preloads: ['${clientEntry}'], diff --git a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts index e4f06893a7c..4bd90e7090d 100644 --- a/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-router-plugin/plugin.ts @@ -9,8 +9,10 @@ import { VITE_ENVIRONMENT_NAMES } from '../../constants' import { routesManifestPlugin } from '../../start-router-plugin/generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from '../../start-router-plugin/generator-plugins/prerender-routes-plugin' import { buildRouteTreeFileFooterFromConfig } from '../../start-router-plugin/route-tree-footer' -import { pruneServerOnlySubtrees } from '../../start-router-plugin/pruneServerOnlySubtrees' -import { SERVER_PROP } from '../../start-router-plugin/constants' +import { + hasServerOptions, + pruneServerOnlySubtrees, +} from '../../start-router-plugin/pruneServerOnlySubtrees' import type { GetConfigFn } from '../../types' import type { TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -25,10 +27,7 @@ function isServerOnlyNode(node: RouteNode | undefined) { if (!node?.createFileRouteProps) { return false } - return ( - node.createFileRouteProps.has(SERVER_PROP) && - node.createFileRouteProps.size === 1 - ) + return hasServerOptions(node) === true && node.createFileRouteProps.size === 1 } export function tanStackStartRouter( @@ -59,9 +58,10 @@ export function tanStackStartRouter( } let generatorInstance: Generator | null = null + let isBuild = false - const clientTreeGeneratorPlugin: GeneratorPlugin = { - name: 'start-client-tree-plugin', + const routesPlugin = { + ...routesManifestPlugin(() => isBuild), init({ generator }) { generatorInstance = generator }, @@ -70,7 +70,7 @@ export function tanStackStartRouter( invalidate() } }, - } + } satisfies GeneratorPlugin let routeTreeFileFooter: Array | null = null @@ -96,6 +96,9 @@ export function tanStackStartRouter( configureServer(server) { clientEnvironment = server.environments[VITE_ENVIRONMENT_NAMES.client] }, + configResolved(config) { + isBuild = config.command === 'build' && !config.build.watch + }, config() { type LoadObjectHook = Extract< typeof clientTreePlugin.load, @@ -146,7 +149,7 @@ export function tanStackStartRouter( clientTreePlugin, tanstackRouterGenerator(() => { const routerConfig = getConfig().startConfig.router - const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] + const plugins: Array = [routesPlugin] if (startPluginOpts.prerender?.enabled === true) { plugins.push(prerenderRoutesPlugin()) } @@ -165,6 +168,7 @@ export function tanStackStartRouter( ...routerConfig.codeSplittingOptions, deleteNodes: ['ssr', 'server', 'headers'], addHmr: true, + compilerPlugins: isBuild ? [routesPlugin] : [], }, plugin: { vite: { environmentName: VITE_ENVIRONMENT_NAMES.client }, diff --git a/packages/start-plugin-core/tests/routes-manifest-plugin.test.ts b/packages/start-plugin-core/tests/routes-manifest-plugin.test.ts new file mode 100644 index 00000000000..176338f0da6 --- /dev/null +++ b/packages/start-plugin-core/tests/routes-manifest-plugin.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { parseAst } from '@tanstack/router-utils' +import { routesManifestPlugin } from '../src/start-router-plugin/generator-plugins/routes-manifest-plugin' +import type * as t from '@babel/types' +import type { RouteNode } from '@tanstack/router-generator' + +beforeEach(() => vi.stubGlobal('TSS_ROUTES_MANIFEST', undefined)) +afterEach(() => vi.unstubAllGlobals()) + +function generateManifest( + plugin: ReturnType, + props: ReadonlyArray | undefined, +) { + const rootRouteNode: RouteNode = { + filePath: '__root.tsx', + fullPath: '/routes/__root.tsx', + variableName: 'root', + _fsRouteType: '__root', + } + const child: RouteNode = { + filePath: 'child.tsx', + fullPath: '/routes/child.tsx', + routePath: '/child', + variableName: 'child', + _fsRouteType: 'static', + createFileRouteProps: props && new Set(props), + } + plugin.onRouteTreeChanged!({ + rootRouteNode, + routeTree: [child], + routeNodes: [child], + acc: { + routeTree: [child], + routeNodes: [child], + routePiecesByPath: {}, + routeNodesByPath: new Map([['/child', child]]), + }, + }) + return plugin +} + +test.each([ + [false, ['component'], true], + [true, ['server'], true], + [true, ['component', 'server'], true], + [true, ['component'], undefined], + [true, undefined, true], +] as const)( + 'collects the server flag during route manifest generation (build: %s, props: %s)', + (isBuild, props, hasServerRoutes) => { + generateManifest( + routesManifestPlugin(() => isBuild), + props, + ) + + expect(globalThis.TSS_ROUTES_MANIFEST).toEqual({ + routes: { + __root__: { + filePath: '/routes/__root.tsx', + children: ['/child'], + }, + '/child': { + filePath: '/routes/child.tsx', + children: undefined, + }, + }, + hasServerRoutes, + }) + }, +) + +function observeOptions( + plugin: ReturnType, + createRouteFn: string, + properties: string, + id = '/routes/__root.tsx', +) { + const ast = parseAst({ code: `({ ${properties} })` }) + const statement = ast.program.body[0] as t.ExpressionStatement + plugin.onRouteOptions!({ + routeOptions: statement.expression as t.ObjectExpression, + createRouteFn, + opts: { id }, + } as Parameters>[0]) +} + +test.each(['createRootRoute', 'createRootRouteWithContext'])( + '%s without server options completes build-time detection', + (createRouteFn) => { + const plugin = generateManifest( + routesManifestPlugin(() => true), + [], + ) + const manifest = globalThis.TSS_ROUTES_MANIFEST + observeOptions( + plugin, + 'createFileRoute', + 'component: Page', + '/routes/child.tsx', + ) + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBeUndefined() + observeOptions(plugin, createRouteFn, 'component: Page') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(false) + expect(globalThis.TSS_ROUTES_MANIFEST).toBe(manifest) + }, +) + +test.each([ + 'server: {}', + '"server": {}', + 'get server() { return {} }', + '...options', + '[key]: options', +])('keeps server handling for route options with %s', (properties) => { + const plugin = generateManifest( + routesManifestPlugin(() => true), + [], + ) + observeOptions(plugin, 'createRootRoute', 'component: Page') + observeOptions(plugin, 'createFileRoute', properties, '/routes/child.tsx') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(true) + observeOptions(plugin, 'createRootRoute', 'component: Page') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(true) +}) + +test('detects server options on the root route', () => { + const plugin = generateManifest( + routesManifestPlugin(() => true), + [], + ) + observeOptions(plugin, 'createRootRoute', 'server: { middleware: [] }') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(true) +}) + +test('a root constructor in another route does not complete detection', () => { + const plugin = generateManifest( + routesManifestPlugin(() => true), + [], + ) + observeOptions( + plugin, + 'createRootRoute', + 'component: Page', + '/routes/child.tsx', + ) + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBeUndefined() +}) + +test('reads the build mode when generating the manifest', () => { + let isBuild = false + const plugin = routesManifestPlugin(() => isBuild) + isBuild = true + generateManifest(plugin, []) + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBeUndefined() + observeOptions(plugin, 'createRootRoute', 'component: Page') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(false) + + isBuild = false + generateManifest(plugin, []) + observeOptions(plugin, 'createRootRoute', 'component: Page') + expect(globalThis.TSS_ROUTES_MANIFEST?.hasServerRoutes).toBe(true) +}) + +test('each plugin updates its own generated manifest', () => { + const firstPlugin = generateManifest( + routesManifestPlugin(() => true), + [], + ) + const firstManifest = globalThis.TSS_ROUTES_MANIFEST + generateManifest( + routesManifestPlugin(() => true), + [], + ) + const secondManifest = globalThis.TSS_ROUTES_MANIFEST + + observeOptions(firstPlugin, 'createRootRoute', 'server: {}') + expect(firstManifest?.hasServerRoutes).toBe(true) + expect(secondManifest?.hasServerRoutes).toBeUndefined() + expect(globalThis.TSS_ROUTES_MANIFEST).toBe(secondManifest) +}) diff --git a/packages/start-plugin-core/tests/rsbuild/virtual-modules.test.ts b/packages/start-plugin-core/tests/rsbuild/virtual-modules.test.ts new file mode 100644 index 00000000000..d4680bc9eb5 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/virtual-modules.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { + START_MANIFEST_PLACEHOLDER, + registerVirtualModules, +} from '../../src/rsbuild/virtual-modules' +import { RSBUILD_ENVIRONMENT_NAMES } from '../../src/rsbuild/planning' +import type { NormalizedClientBuild } from '../../src/types' + +afterEach(() => vi.unstubAllGlobals()) + +function createRegistry( + options: { + isDev?: boolean + } = {}, +) { + vi.stubGlobal('TSS_ROUTES_MANIFEST', { + routes: { __root__: {} }, + hasServerRoutes: false, + }) + let configure: (config: any, utils: any) => void + const state = registerVirtualModules( + { + context: { action: options.isDev ? 'dev' : 'build' }, + modifyRspackConfig(callback: typeof configure) { + configure = callback + }, + } as any, + { + root: '/app', + getConfig: () => + ({ + resolvedStartConfig: { + basePaths: { publicBase: '/' }, + }, + startConfig: { + server: { + build: { inlineCss: { enabled: false, transformAssets: false } }, + }, + }, + }) as any, + serverFnsById: {}, + providerEnvName: RSBUILD_ENVIRONMENT_NAMES.server, + ssrIsProvider: true, + serializationAdapters: undefined, + getDevClientEntryUrl: () => '/assets/index.js', + scriptFormat: 'module', + }, + ) + + return { + state, + initialManifest( + environmentName: string = RSBUILD_ENVIRONMENT_NAMES.server, + ) { + let content: Record = {} + configure( + { plugins: [], resolve: {} }, + { + environment: { name: environmentName }, + rspack: { + experiments: { + VirtualModulesPlugin: class { + constructor(modules: Record) { + content = modules + } + }, + }, + NormalModuleReplacementPlugin: class {}, + }, + }, + ) + return content[state.manifestPath]! + }, + } +} + +function clientBuild(): NormalizedClientBuild { + return { + entryChunkFileName: 'entry.js', + chunksByFileName: new Map([ + [ + 'entry.js', + { + fileName: 'entry.js', + isEntry: true, + imports: [], + dynamicImports: [], + routeFilePaths: [], + hydrationIds: [], + css: [], + }, + ], + ]), + cssContentByFileName: new Map(), + } +} + +function readHasServerRoutes(code: string) { + return new Function( + `${code.replaceAll('export const ', 'const ')}\nreturn hasServerRoutes`, + )() as boolean +} + +describe('Rsbuild manifest server routes', () => { + test('keeps server-route handling enabled when a parallel build replaces its placeholder', () => { + const registry = createRegistry() + const initial = registry.initialManifest() + expect(initial).toContain(START_MANIFEST_PLACEHOLDER) + + const final = initial.replace( + JSON.stringify(START_MANIFEST_PLACEHOLDER), + registry.state.generateManifestValueLiteral(clientBuild()), + ) + expect(readHasServerRoutes(final)).toBe(true) + expect( + readHasServerRoutes( + registry.state.generateManifestContent(clientBuild()), + ), + ).toBe(false) + }) + + test('does not optimize when route metadata is missing', () => { + const registry = createRegistry() + vi.stubGlobal('TSS_ROUTES_MANIFEST', { routes: { __root__: {} } }) + const manifest = registry.state.generateManifestContent(clientBuild()) + + expect(readHasServerRoutes(manifest)).toBe(true) + }) + + test('exports the flag in the client manifest', () => { + const registry = createRegistry() + const manifest = registry.initialManifest(RSBUILD_ENVIRONMENT_NAMES.client) + + expect(manifest).toContain('export const hasServerRoutes = true') + }) + + test('keeps server-route handling enabled in development after client builds', () => { + const registry = createRegistry({ isDev: true }) + const initial = registry.initialManifest() + expect(initial).toContain('export const hasServerRoutes = true') + + registry.state.updateManifest(clientBuild()) + const updated = registry.state.generateManifestContent(clientBuild()) + expect(readHasServerRoutes(updated)).toBe(true) + }) +}) diff --git a/packages/start-plugin-core/tests/start-manifest-plugin.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin.test.ts index 07f7f6ed08a..55e7561885c 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin.test.ts @@ -12,15 +12,59 @@ vi.mock('@tanstack/start-server-core/virtual-modules', () => ({ describe('startManifestPlugin', () => { afterEach(() => vi.unstubAllGlobals()) + test.each([false, true])( + 'exports server-route presence determined from generated routes (%s)', + (serverRoute) => { + vi.stubGlobal('TSS_ROUTES_MANIFEST', { + routes: { __root__: {} }, + hasServerRoutes: serverRoute, + }) + const manifest = loadBuildManifest() + + expect(manifest).toContain( + `export const hasServerRoutes = ${serverRoute}`, + ) + }, + ) + + test('keeps server routing when route metadata is missing', () => { + vi.stubGlobal('TSS_ROUTES_MANIFEST', { routes: { __root__: {} } }) + const manifest = loadBuildManifest() + + expect(manifest).toContain('export const hasServerRoutes = true') + }) + + test.each(['serve', 'client', 'scan'])( + 'keeps server-route handling enabled for %s manifests', + (mode) => { + const manifest = loadBuildManifest({ + command: mode === 'serve' ? 'serve' : 'build', + environment: + mode === 'client' + ? START_ENVIRONMENT_NAMES.client + : START_ENVIRONMENT_NAMES.server, + captureClientBuild: mode !== 'scan', + }) + + expect(manifest).toContain('export const hasServerRoutes = true') + }, + ) + test.each([false, true])( 'captures inline CSS according to the resolved config (%s)', (enabled) => { - vi.stubGlobal('TSS_ROUTES_MANIFEST', { __root__: {} }) + vi.stubGlobal('TSS_ROUTES_MANIFEST', { + routes: { __root__: {} }, + hasServerRoutes: false, + }) const plugins = startManifestPlugin({ getConfig: () => ({ resolvedStartConfig: { basePaths: { publicBase: '/assets' } }, - startConfig: { server: { build: { inlineCss: { enabled } } } }, + startConfig: { + router: {}, + server: { build: { inlineCss: { enabled } } }, + }, }) as any, }) as Array const capture = plugins.find( @@ -80,6 +124,56 @@ describe('startManifestPlugin', () => { }) }) +function loadBuildManifest( + opts: { + command?: string + environment?: string + captureClientBuild?: boolean + } = {}, +) { + const plugins = startManifestPlugin({ + getConfig: () => + ({ + resolvedStartConfig: { + basePaths: { publicBase: '/' }, + }, + startConfig: { + server: { build: { inlineCss: { enabled: false } } }, + }, + }) as any, + }) as Array + if (opts.captureClientBuild !== false) { + plugins + .find((plugin) => plugin.generateBundle) + .generateBundle.call( + { environment: { name: START_ENVIRONMENT_NAMES.client } }, + {}, + { + 'entry.js': { + type: 'chunk', + fileName: 'entry.js', + isEntry: true, + imports: [], + dynamicImports: [], + moduleIds: [], + }, + }, + ) + } + const plugin = plugins.find( + (item) => item.name === 'tanstack-start:start-manifest-plugin', + )! + return plugin.load.handler.call( + { + environment: { + name: opts.environment ?? START_ENVIRONMENT_NAMES.server, + config: { command: opts.command ?? 'build', build: {} }, + }, + }, + plugin.resolveId.handler(VIRTUAL_MODULES.startManifest), + ) as string +} + function loadDevManifest(opts: { bundledDev: boolean }) { const plugins = startManifestPlugin({ getConfig: () => diff --git a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts index ad34b92e282..e0568071391 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts @@ -301,6 +301,31 @@ describe('createChunkCssAssetCollector', () => { }) describe('buildStartManifest', () => { + test.each([false, true, undefined])( + 'preserves the server-route flag (%s) through both manifest serializers', + (hasServerRoutes) => { + const manifest = buildStartManifest({ + clientBuild: normalizeTestBuild({ + 'entry.js': makeChunk({ fileName: 'entry.js', isEntry: true }), + }), + routeTreeRoutes: { + __root__: {}, + }, + hasServerRoutes, + basePath: '/', + }) + + expect(manifest.hasServerRoutes).toBe(hasServerRoutes) + expect( + deserializeSerializedManifest(serializeStartManifest(manifest)) + .hasServerRoutes, + ).toBe(hasServerRoutes) + expect(JSON.parse(JSON.stringify(manifest)).hasServerRoutes).toBe( + hasServerRoutes, + ) + }, + ) + test('skips inline CSS transforms when no relative URLs need rebasing', () => { expect(shouldRebaseInlineCssUrls('.root {\n color: red;\n}')).toBe(false) expect(shouldRebaseInlineCssUrls('.root{background:url(/dot.svg)}')).toBe( diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 2547318a34c..1ba72ecad3a 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -44,16 +44,13 @@ import type { AnyRequestMiddleware, AnyStartInstanceOptions, RouteMethod, - RouterEntry, - StartEntry, } from '@tanstack/start-client-core' import type { RequestHandler } from './request-handler' -import type { - AnyRoute, - AnyRouter, - AnySerializationAdapter, - Register, -} from '@tanstack/router-core' +import type { AnyRoute, AnyRouter, Register } from '@tanstack/router-core' +import type * as RouterEntry from '#tanstack-router-entry' +import type * as StartEntry from '#tanstack-start-entry' +import type * as PluginAdaptersEntry from '#tanstack-start-plugin-adapters' +import type * as ManifestEntry from 'tanstack-start-manifest:v' import type { HandlerCallback, HandlerCallbackResult, @@ -83,20 +80,16 @@ function getStartResponseHeaders(opts: { router: AnyRouter }) { return headers } -interface PluginAdaptersEntry { - hasPluginAdapters: boolean - pluginSerializationAdapters: Array -} - -interface Entries { - startEntry: StartEntry - routerEntry: RouterEntry - pluginAdapters: PluginAdaptersEntry -} - // Cached entries - promises stored immediately to prevent concurrent imports // that can cause race conditions during module initialization -let entriesPromise: Promise | undefined +let entriesPromise: + | Promise<{ + routerEntry: typeof RouterEntry + startEntry: typeof StartEntry + pluginAdapters: typeof PluginAdaptersEntry + manifest: typeof ManifestEntry + }> + | undefined let hasWarnedMissingCsrfMiddleware = false const defaultCsrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn', @@ -113,27 +106,18 @@ const createEarlyHintsForRequest: typeof createEarlyHintsCollector = ? () => undefined : createEarlyHintsCollector -async function loadEntries(): Promise { - const [routerEntry, startEntry, pluginAdapters] = await Promise.all([ - // @ts-ignore When building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core +function getEntries() { + return (entriesPromise ??= Promise.all([ import('#tanstack-router-entry'), - // @ts-ignore When building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core import('#tanstack-start-entry'), - // @ts-ignore When building, we currently don't respect tsconfig.ts' `include` so we are not picking up the .d.ts from start-client-core import('#tanstack-start-plugin-adapters'), - ]) - return { - routerEntry: routerEntry as unknown as RouterEntry, - startEntry: startEntry as unknown as StartEntry, - pluginAdapters: pluginAdapters as unknown as PluginAdaptersEntry, - } -} - -function getEntries() { - if (!entriesPromise) { - entriesPromise = loadEntries() - } - return entriesPromise + import('tanstack-start-manifest:v'), + ]).then(([routerEntry, startEntry, pluginAdapters, manifest]) => ({ + routerEntry, + startEntry, + pluginAdapters, + manifest, + }))) } function hasCsrfMiddleware( @@ -653,7 +637,9 @@ export function createStartHandler( executedRequestMiddlewares, handlerType, } - let terminal: (ctx: PipelineContext) => unknown + let terminal: ( + ctx: PipelineContext, + ) => HandlerCallbackResult | Promise if (isServerFnRequest) { if ( @@ -700,6 +686,19 @@ export function createStartHandler( ) } + const earlyHints = createEarlyHintsForRequest({ + onEarlyHints: requestOpts?.onEarlyHints, + responseLinkHeader: requestOpts?.responseLinkHeader, + }) + + let routerInstance: AnyRouter | undefined + if (!matchedRoutes && earlyHints) { + routerInstance = await getRouter() + matchedRoutes = routerInstance.getMatchedRoutes( + routerInstance.latestLocation.pathname, + )[0] + } + const manifest = await waitForRequest( resolveManifestForRequest({ request, @@ -709,14 +708,9 @@ export function createStartHandler( signal, ) - const earlyHints = createEarlyHintsForRequest({ - onEarlyHints: requestOpts?.onEarlyHints, - responseLinkHeader: requestOpts?.responseLinkHeader, - }) - earlyHints?.collectStatic({ manifest, matchedRoutes }) - const routerInstance = await getRouter() + routerInstance ??= await getRouter() attachRouterServerSsrUtils({ router: routerInstance, @@ -772,28 +766,51 @@ export function createStartHandler( terminal = ({ context }) => runWithStartContext( { ...startContext, contextAfterGlobalMiddlewares: context }, - () => - handleServerRoutes({ + () => { + if (entries.manifest.hasServerRoutes === false) { + return executeRouter(context) + } + return handleServerRoutes({ getRouter, request, executeRouter, context, executedRequestMiddlewares, - }), + }) + }, ) } - const middlewareResponse = await executeMiddleware( - flattenedRequestMiddlewares.map((d) => d.options.server), - terminal, - { - request, - pathname: url.pathname, - handlerType, - context: createNullProtoObject(requestOpts?.context), - }, - signal, - ) + const ctx = { + request, + pathname: url.pathname, + handlerType, + context: createNullProtoObject(requestOpts?.context), + } + let middlewareResponse: HandlerCallbackResult + if (flattenedRequestMiddlewares.length || isServerFnRequest) { + middlewareResponse = await executeMiddleware( + flattenedRequestMiddlewares.map((d) => d.options.server), + terminal, + ctx, + signal, + ) + } else { + const disposeLate = createLateResponseDisposer(signal) + try { + middlewareResponse = await waitForRequest( + terminal(ctx), + signal, + disposeLate, + disposeLate, + ) + } catch (error) { + if (signal.aborted || !(error instanceof Response)) { + throw error + } + middlewareResponse = error + } + } let result: SsrResponse try { diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts index 651e1712520..11e7079dd9f 100644 --- a/packages/start-server-core/src/tanstack-start.d.ts +++ b/packages/start-server-core/src/tanstack-start.d.ts @@ -1,7 +1,27 @@ +declare module '#tanstack-router-entry' { + import type { RouterEntry } from '@tanstack/start-client-core' + + export const getRouter: RouterEntry['getRouter'] +} + +declare module '#tanstack-start-entry' { + import type { StartEntry } from '@tanstack/start-client-core' + + export const startInstance: StartEntry['startInstance'] +} + +declare module '#tanstack-start-plugin-adapters' { + import type { AnySerializationAdapter } from '@tanstack/router-core' + + export const hasPluginAdapters: boolean + export const pluginSerializationAdapters: Array +} + declare module 'tanstack-start-manifest:v' { import type { ServerManifest } from '@tanstack/router-core' export const tsrStartManifest: () => ServerManifest + export const hasServerRoutes: boolean } declare module 'tanstack-start-route-tree:v' { diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index 2276b140394..680bff7ccf1 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -45,15 +45,21 @@ const startMocks = vi.hoisted(() => { serverFnHandler: undefined as undefined | (() => unknown), router: undefined as undefined | AnyRouter, routerFactory: undefined as undefined | (() => AnyRouter), + hasServerRoutes: true as boolean | undefined, + hasStartInstance: true, } }) vi.mock('#tanstack-start-entry', () => ({ - startInstance: { - getOptions: () => ({ - requestMiddleware: startMocks.requestMiddleware, - serializationAdapters: [], - }), + get startInstance() { + return startMocks.hasStartInstance + ? { + getOptions: () => ({ + requestMiddleware: startMocks.requestMiddleware, + serializationAdapters: [], + }), + } + : undefined }, })) @@ -61,6 +67,13 @@ vi.mock('#tanstack-router-entry', () => ({ getRouter: () => startMocks.routerFactory?.() ?? startMocks.router, })) +vi.mock('tanstack-start-manifest:v', async (importOriginal) => ({ + ...(await importOriginal()), + get hasServerRoutes() { + return startMocks.hasServerRoutes + }, +})) + vi.mock('../src/server-functions-handler', () => ({ handleServerAction: () => startMocks.serverFnHandler @@ -164,6 +177,8 @@ afterEach(() => { startMocks.serverFnHandler = undefined startMocks.router = undefined startMocks.routerFactory = undefined + startMocks.hasServerRoutes = true + startMocks.hasStartInstance = true vi.unstubAllEnvs() }) @@ -582,6 +597,141 @@ describe('createStartHandler redirect safety', () => { ) }) +describe('createStartHandler server-route handling', () => { + it('keeps default CSRF protection on server function requests', async () => { + startMocks.hasStartInstance = false + startMocks.hasServerRoutes = false + startMocks.router = makeRouter() + startMocks.serverFnResult = new Response('server function') + const handler = createStartHandler(() => new Response('app response')) + const headers = { 'sec-fetch-site': 'cross-site' } + + const page = await handler( + new Request('http://localhost/', { headers }), + {}, + ) + const serverFn = await handler( + new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers, + }), + {}, + ) + + expect(page.status).toBe(200) + expect(serverFn.status).toBe(403) + }) + + it.each([true, undefined])( + 'runs server middleware when hasServerRoutes is %s', + async (flag) => { + const middleware = vi.fn(({ next }) => next()) + const rootRoute = new BaseRootRoute({ + server: { middleware: [createMiddleware().server(middleware)] }, + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: rootRoute.addChildren([ + new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }), + ]), + }, + getStoreConfig, + ) + router.isServer = true + startMocks.router = router + startMocks.hasServerRoutes = flag + + const handler = createStartHandler(() => new Response('app response')) + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(200) + expect(middleware).toHaveBeenCalledOnce() + }, + ) + + it.each([false, true])( + 'skips server-route matching (request middleware=%s)', + async (hasMiddleware) => { + const middleware = vi.fn() + startMocks.requestMiddleware = hasMiddleware + ? [ + createMiddleware().server(({ next }) => { + middleware() + return next({ context: { message: 'global middleware' } }) + }), + ] + : [] + const router = makeRouter() + startMocks.router = router + startMocks.hasServerRoutes = false + const getMatchedRoutes = vi.spyOn(router, 'getMatchedRoutes') + const load = router.load + vi.spyOn(router, 'load').mockImplementation((options) => { + expect(getMatchedRoutes).not.toHaveBeenCalled() + return load(options) + }) + const handler = createStartHandler<{ + server: { requestContext: { message: string } } + }>(({ router: loadedRouter }) => { + expect(loadedRouter.state.matches.at(-1)?.routeId).toBe('/') + return Response.json( + loadedRouter.options.additionalContext?.serverContext, + ) + }) + + const response = await handler(new Request('http://localhost/'), { + context: { message: 'request context' }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + message: hasMiddleware ? 'global middleware' : 'request context', + }) + expect(middleware).toHaveBeenCalledTimes(hasMiddleware ? 1 : 0) + expect(getMatchedRoutes).toHaveBeenCalled() + }, + ) + + it.each([false, true])( + 'preserves static Link headers and early hints (callback=%s)', + async (callback) => { + const phases: Array = [] + const router = makeRouterWithRouteWork({ + loader: () => { + phases.push('loader') + }, + }) + startMocks.router = router + startMocks.hasServerRoutes = false + const handler = createStartHandler( + ({ responseHeaders }) => + new Response('app response', { headers: responseHeaders }), + ) + + const response = await handler(new Request('http://localhost/work'), { + responseLinkHeader: true, + onEarlyHints: callback + ? (event) => { + phases.push(event.phase) + } + : undefined, + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Link')).toContain('') + expect(response.headers.get('Link')).toContain('') + expect(phases).toEqual( + callback ? ['static', 'loader', 'dynamic'] : ['loader'], + ) + }, + ) +}) + describe('createStartHandler request location reuse', () => { it.each( ['plain', 'café'].flatMap((path) => @@ -2061,6 +2211,7 @@ describe('createStartHandler request cancellation', () => { } const router = makeRouterWithRouteWork({ [hook]: routeWork }) startMocks.router = router + startMocks.hasServerRoutes = false const requestController = new AbortController() const render = vi.fn(() => new Response('must not render')) const handler = createStartHandler(render) @@ -2085,6 +2236,7 @@ describe('createStartHandler request cancellation', () => { it('settles and cleans up while the render callback is still pending', async () => { const router = makeRouter() startMocks.router = router + startMocks.hasServerRoutes = false const requestController = new AbortController() let notifyRenderStarted!: () => void const renderStarted = new Promise((resolve) => { @@ -2269,6 +2421,7 @@ describe('createStartHandler request cancellation', () => { it('disposes a side-cloned stream when the request aborts after handoff', async () => { const router = makeRouter() startMocks.router = router + startMocks.hasServerRoutes = false const requestController = new AbortController() let cancelCalls = 0 let siblingResponse!: Response diff --git a/packages/start-server-core/tests/fixtures/start-manifest.ts b/packages/start-server-core/tests/fixtures/start-manifest.ts index 27ee4ced1fe..7b1092f4c2a 100644 --- a/packages/start-server-core/tests/fixtures/start-manifest.ts +++ b/packages/start-server-core/tests/fixtures/start-manifest.ts @@ -1,6 +1,12 @@ +export const hasServerRoutes = true + export const tsrStartManifest = () => ({ clientEntry: '/assets/client.js', routes: { __root__: {}, + '/work': { + preloads: ['/assets/work.js'], + css: ['/assets/work.css'], + }, }, }) diff --git a/packages/start-server-core/tsconfig.json b/packages/start-server-core/tsconfig.json index ac28a482bf5..3fb0aa9522e 100644 --- a/packages/start-server-core/tsconfig.json +++ b/packages/start-server-core/tsconfig.json @@ -5,10 +5,5 @@ "module": "esnext", "types": ["node"] }, - "include": [ - "src", - "tests", - "vite.config.ts", - "../start-client-core/src/start-entry.d.ts" - ] + "include": ["src", "tests", "vite.config.ts"] }