From cab475a81bdfb19e5b9ece228eb7566947950dec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 04:30:49 +0000 Subject: [PATCH 01/16] Add rsbuild Start plugin implementation Co-authored-by: Zack Jackson --- packages/react-start/package.json | 12 + packages/react-start/src/plugin/rsbuild.ts | 35 ++ packages/react-start/vite.config.ts | 1 + packages/router-plugin/src/rspack.ts | 13 + packages/solid-start/package.json | 12 + packages/solid-start/src/plugin/rsbuild.ts | 35 ++ packages/solid-start/vite.config.ts | 1 + packages/start-plugin-core/package.json | 13 + packages/start-plugin-core/src/index.ts | 1 + .../start-plugin-core/src/rsbuild/index.ts | 3 + .../rsbuild/injected-head-scripts-plugin.ts | 18 + .../start-plugin-core/src/rsbuild/plugin.ts | 563 ++++++++++++++++++ .../src/rsbuild/post-server-build.ts | 68 +++ .../src/rsbuild/prerender.ts | 277 +++++++++ .../src/rsbuild/route-tree-loader.ts | 8 + .../src/rsbuild/route-tree-state.ts | 34 ++ .../src/rsbuild/start-compiler-loader.ts | 214 +++++++ .../src/rsbuild/start-compiler-plugin.ts | 90 +++ .../src/rsbuild/start-manifest-plugin.ts | 226 +++++++ .../src/rsbuild/start-router-plugin.ts | 179 ++++++ packages/start-plugin-core/vite.config.ts | 7 +- packages/vue-start/package.json | 12 + packages/vue-start/src/plugin/rsbuild.ts | 35 ++ packages/vue-start/vite.config.ts | 1 + pnpm-lock.yaml | 292 +++++++-- 25 files changed, 2083 insertions(+), 67 deletions(-) create mode 100644 packages/react-start/src/plugin/rsbuild.ts create mode 100644 packages/solid-start/src/plugin/rsbuild.ts create mode 100644 packages/start-plugin-core/src/rsbuild/index.ts create mode 100644 packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts create mode 100644 packages/start-plugin-core/src/rsbuild/plugin.ts create mode 100644 packages/start-plugin-core/src/rsbuild/post-server-build.ts create mode 100644 packages/start-plugin-core/src/rsbuild/prerender.ts create mode 100644 packages/start-plugin-core/src/rsbuild/route-tree-loader.ts create mode 100644 packages/start-plugin-core/src/rsbuild/route-tree-state.ts create mode 100644 packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts create mode 100644 packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts create mode 100644 packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts create mode 100644 packages/start-plugin-core/src/rsbuild/start-router-plugin.ts create mode 100644 packages/vue-start/src/plugin/rsbuild.ts diff --git a/packages/react-start/package.json b/packages/react-start/package.json index 24bc307f3fa..0b658714f81 100644 --- a/packages/react-start/package.json +++ b/packages/react-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -101,8 +107,14 @@ "pathe": "^2.0.3" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/react-start/src/plugin/rsbuild.ts b/packages/react-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..f02e273765a --- /dev/null +++ b/packages/react-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'react', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/react-start/vite.config.ts b/packages/react-start/vite.config.ts index d7ab699a07b..129669ced14 100644 --- a/packages/react-start/vite.config.ts +++ b/packages/react-start/vite.config.ts @@ -31,6 +31,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/ssr-rpc.ts', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: [ '@tanstack/react-start-client', diff --git a/packages/router-plugin/src/rspack.ts b/packages/router-plugin/src/rspack.ts index 9eef9f2221c..ab31067d68c 100644 --- a/packages/router-plugin/src/rspack.ts +++ b/packages/router-plugin/src/rspack.ts @@ -4,6 +4,7 @@ import { configSchema } from './core/config' import { unpluginRouterCodeSplitterFactory } from './core/router-code-splitter-plugin' import { unpluginRouterGeneratorFactory } from './core/router-generator-plugin' import { unpluginRouterComposedFactory } from './core/router-composed-plugin' +import { unpluginRouteAutoImportFactory } from './core/route-autoimport-plugin' import type { CodeSplittingOptions, Config } from './core/config' /** @@ -40,6 +41,13 @@ const TanStackRouterCodeSplitterRspack = /* #__PURE__ */ createRspackPlugin( unpluginRouterCodeSplitterFactory, ) +const tanstackRouterGenerator = TanStackRouterGeneratorRspack +const tanstackRouterCodeSplitter = TanStackRouterCodeSplitterRspack + +const TanStackRouterAutoImportRspack = /* #__PURE__ */ createRspackPlugin( + unpluginRouteAutoImportFactory, +) + /** * @example * ```ts @@ -57,12 +65,17 @@ const TanStackRouterRspack = /* #__PURE__ */ createRspackPlugin( unpluginRouterComposedFactory, ) const tanstackRouter = TanStackRouterRspack +const tanstackRouterAutoImport = TanStackRouterAutoImportRspack export default TanStackRouterRspack export { configSchema, TanStackRouterRspack, TanStackRouterGeneratorRspack, TanStackRouterCodeSplitterRspack, + TanStackRouterAutoImportRspack, + tanstackRouterGenerator, + tanstackRouterCodeSplitter, + tanstackRouterAutoImport, tanstackRouter, } export type { Config, CodeSplittingOptions } diff --git a/packages/solid-start/package.json b/packages/solid-start/package.json index 375fa862804..8712b09c40f 100644 --- a/packages/solid-start/package.json +++ b/packages/solid-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -104,7 +110,13 @@ "vite": "^7.3.1" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "solid-js": ">=1.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/solid-start/src/plugin/rsbuild.ts b/packages/solid-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..34bbc6542d5 --- /dev/null +++ b/packages/solid-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'solid', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/solid-start/vite.config.ts b/packages/solid-start/vite.config.ts index 4004316d919..519844c729d 100644 --- a/packages/solid-start/vite.config.ts +++ b/packages/solid-start/vite.config.ts @@ -31,6 +31,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/server.tsx', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: [ '@tanstack/solid-start-client', diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index fa51a8b842e..89761e9567e 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -50,6 +50,12 @@ "default": "./dist/esm/index.js" } }, + "./rsbuild": { + "import": { + "types": "./dist/esm/rsbuild/index.d.ts", + "default": "./dist/esm/rsbuild/index.js" + } + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -77,6 +83,7 @@ "srvx": "^0.11.2", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", + "unplugin": "^2.3.11", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" @@ -87,6 +94,12 @@ "vite": "^7.3.1" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/start-plugin-core/src/index.ts b/packages/start-plugin-core/src/index.ts index df946192ae1..777c7efeb11 100644 --- a/packages/start-plugin-core/src/index.ts +++ b/packages/start-plugin-core/src/index.ts @@ -1,6 +1,7 @@ export type { TanStackStartInputConfig } from './schema' export { TanStackStartVitePluginCore } from './plugin' +export { TanStackStartRsbuildPluginCore } from './rsbuild/plugin' export { resolveViteId } from './utils' export { VITE_ENVIRONMENT_NAMES } from './constants' diff --git a/packages/start-plugin-core/src/rsbuild/index.ts b/packages/start-plugin-core/src/rsbuild/index.ts new file mode 100644 index 00000000000..0bf18566976 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/index.ts @@ -0,0 +1,3 @@ +export { TanStackStartRsbuildPluginCore } from './plugin' +export { VITE_ENVIRONMENT_NAMES } from '../constants' +export { resolveViteId } from '../utils' diff --git a/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts new file mode 100644 index 00000000000..576882600ce --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts @@ -0,0 +1,18 @@ +import { createRspackPlugin } from 'unplugin' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' + +export function createInjectedHeadScriptsPlugin() { + return createRspackPlugin(() => ({ + name: 'tanstack-start:injected-head-scripts', + resolveId(id) { + if (id === VIRTUAL_MODULES.injectedHeadScripts) { + return id + } + return null + }, + load(id) { + if (id !== VIRTUAL_MODULES.injectedHeadScripts) return null + return `export const injectedHeadScripts = undefined` + }, + })) +} diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts new file mode 100644 index 00000000000..ef151dae44f --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -0,0 +1,563 @@ +import fs from 'node:fs' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { joinPaths } from '@tanstack/router-core' +import { NodeRequest, sendNodeResponse } from 'srvx/node' +import path from 'pathe' +import { joinURL } from 'ufo' +import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../constants' +import { resolveEntry } from '../resolve-entries' +import { parseStartConfig } from '../schema' +import { createInjectedHeadScriptsPlugin } from './injected-head-scripts-plugin' +import { createServerFnResolverPlugin } from './start-compiler-plugin' +import { + createStartManifestRspackPlugin, + createStartManifestVirtualModulePlugin, +} from './start-manifest-plugin' +import { postServerBuildRsbuild } from './post-server-build' +import { tanStackStartRouterRsbuild } from './start-router-plugin' +import type { ViteEnvironmentNames } from '../constants' +import type { TanStackStartInputConfig } from '../schema' +import type { + GetConfigFn, + ResolvedStartConfig, + TanStackStartVitePluginCoreOptions, +} from '../types' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +function isFullUrl(str: string): boolean { + try { + new URL(str) + return true + } catch { + return false + } +} + +function defineReplaceEnv( + key: TKey, + value: TValue, +): { [P in `process.env.${TKey}` | `import.meta.env.${TKey}`]: TValue } { + return { + [`process.env.${key}`]: JSON.stringify(value), + [`import.meta.env.${key}`]: JSON.stringify(value), + } as { [P in `process.env.${TKey}` | `import.meta.env.${TKey}`]: TValue } +} + +function mergeRspackConfig(base: any, next: any) { + return { + ...base, + ...next, + plugins: [...(base?.plugins ?? []), ...(next?.plugins ?? [])], + module: { + ...base?.module, + ...next?.module, + rules: [...(base?.module?.rules ?? []), ...(next?.module?.rules ?? [])], + }, + resolve: { + ...base?.resolve, + ...next?.resolve, + alias: { + ...(base?.resolve?.alias ?? {}), + ...(next?.resolve?.alias ?? {}), + }, + }, + } +} + +function mergeEnvConfig(base: any, next: any) { + return { + ...base, + ...next, + source: { + ...base?.source, + ...next?.source, + alias: { + ...(base?.source?.alias ?? {}), + ...(next?.source?.alias ?? {}), + }, + define: { + ...(base?.source?.define ?? {}), + ...(next?.source?.define ?? {}), + }, + }, + output: { + ...base?.output, + ...next?.output, + distPath: { + ...(base?.output?.distPath ?? {}), + ...(next?.output?.distPath ?? {}), + }, + }, + tools: { + ...base?.tools, + ...next?.tools, + rspack: mergeRspackConfig(base?.tools?.rspack, next?.tools?.rspack), + }, + } +} + +function getOutputDirectory( + root: string, + config: any, + environmentName: ViteEnvironmentNames, + directoryName: string, +) { + const envDistPath = + config.environments?.[environmentName]?.output?.distPath?.root + if (envDistPath) { + return path.resolve(root, envDistPath) + } + const rootDistPath = config.output?.distPath?.root ?? 'dist' + return path.resolve(root, rootDistPath, directoryName) +} + +function toPluginArray(plugin: any) { + if (!plugin) return [] + return Array.isArray(plugin) ? plugin : [plugin] +} + +function resolveLoaderPath(relativePath: string) { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const basePath = path.resolve(currentDir, relativePath) + const jsPath = `${basePath}.js` + const tsPath = `${basePath}.ts` + if (fs.existsSync(jsPath)) return jsPath + if (fs.existsSync(tsPath)) return tsPath + return jsPath +} + +export function TanStackStartRsbuildPluginCore( + corePluginOpts: TanStackStartVitePluginCoreOptions, + startPluginOpts: TanStackStartInputConfig, +): Array { + const serverFnProviderEnv = + corePluginOpts.serverFn?.providerEnv || VITE_ENVIRONMENT_NAMES.server + const ssrIsProvider = serverFnProviderEnv === VITE_ENVIRONMENT_NAMES.server + + const resolvedStartConfig: ResolvedStartConfig = { + root: '', + startFilePath: undefined, + routerFilePath: '', + srcDirectory: '', + viteAppBase: '', + serverFnProviderEnv, + } + + let startConfig: ReturnType | null = null + const getConfig: GetConfigFn = () => { + if (!resolvedStartConfig.root) { + throw new Error(`Cannot get config before root is resolved`) + } + if (!startConfig) { + startConfig = parseStartConfig( + startPluginOpts, + corePluginOpts, + resolvedStartConfig.root, + ) + } + return { startConfig, resolvedStartConfig, corePluginOpts } + } + + let resolvedServerEntryPath: string | undefined + let resolvedServerOutputDir: string | undefined + let resolvedClientOutputDir: string | undefined + + return [ + { + name: 'tanstack-start-core:rsbuild-config', + setup(api) { + api.modifyRsbuildConfig((config: any) => { + const root = config.root || process.cwd() + resolvedStartConfig.root = root + + const { startConfig } = getConfig() + const assetPrefix = config.output?.assetPrefix ?? '/' + resolvedStartConfig.viteAppBase = assetPrefix + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + resolvedStartConfig.viteAppBase = joinPaths([ + '/', + resolvedStartConfig.viteAppBase, + '/', + ]) + } + + if (startConfig.router.basepath === undefined) { + if (!isFullUrl(resolvedStartConfig.viteAppBase)) { + startConfig.router.basepath = + resolvedStartConfig.viteAppBase.replace(/^\/|\/$/g, '') + } else { + startConfig.router.basepath = '/' + } + } + + const TSS_SERVER_FN_BASE = joinPaths([ + '/', + startConfig.router.basepath, + startConfig.serverFns.base, + '/', + ]) + + const resolvedSrcDirectory = path.join(root, startConfig.srcDirectory) + resolvedStartConfig.srcDirectory = resolvedSrcDirectory + + const startFilePath = resolveEntry({ + type: 'start entry', + configuredEntry: startConfig.start.entry, + defaultEntry: 'start', + resolvedSrcDirectory, + required: false, + }) + resolvedStartConfig.startFilePath = startFilePath + + const routerFilePath = resolveEntry({ + type: 'router entry', + configuredEntry: startConfig.router.entry, + defaultEntry: 'router', + resolvedSrcDirectory, + required: true, + }) + resolvedStartConfig.routerFilePath = routerFilePath + + const clientEntryPath = resolveEntry({ + type: 'client entry', + configuredEntry: startConfig.client.entry, + defaultEntry: 'client', + resolvedSrcDirectory, + required: false, + }) + + const serverEntryPath = resolveEntry({ + type: 'server entry', + configuredEntry: startConfig.server.entry, + defaultEntry: 'server', + resolvedSrcDirectory, + required: false, + }) + resolvedServerEntryPath = + serverEntryPath ?? corePluginOpts.defaultEntryPaths.server + + const entryAliasConfiguration: Record< + (typeof ENTRY_POINTS)[keyof typeof ENTRY_POINTS], + string + > = { + [ENTRY_POINTS.client]: + clientEntryPath ?? corePluginOpts.defaultEntryPaths.client, + [ENTRY_POINTS.server]: + serverEntryPath ?? corePluginOpts.defaultEntryPaths.server, + [ENTRY_POINTS.start]: + startFilePath ?? corePluginOpts.defaultEntryPaths.start, + [ENTRY_POINTS.router]: routerFilePath, + } + + const clientOutputDir = getOutputDirectory( + root, + config, + VITE_ENVIRONMENT_NAMES.client, + 'client', + ) + resolvedClientOutputDir = clientOutputDir + const serverOutputDir = getOutputDirectory( + root, + config, + VITE_ENVIRONMENT_NAMES.server, + 'server', + ) + resolvedServerOutputDir = serverOutputDir + + const isDev = api.context?.command === 'serve' + const defineValues = { + ...defineReplaceEnv('TSS_SERVER_FN_BASE', TSS_SERVER_FN_BASE), + ...defineReplaceEnv('TSS_CLIENT_OUTPUT_DIR', clientOutputDir), + ...defineReplaceEnv( + 'TSS_ROUTER_BASEPATH', + startConfig.router.basepath, + ), + ...defineReplaceEnv('TSS_DEV_SERVER', isDev ? 'true' : 'false'), + ...(isDev + ? defineReplaceEnv( + 'TSS_SHELL', + startConfig.spa?.enabled ? 'true' : 'false', + ) + : {}), + } + + const routerPlugins = tanStackStartRouterRsbuild( + startPluginOpts, + getConfig, + corePluginOpts, + ) + + const startCompilerLoaderPath = resolveLoaderPath( + './start-compiler-loader', + ) + + const loaderRule = (env: 'client' | 'server', envName: string) => ({ + test: /\.[cm]?[jt]sx?$/, + exclude: /node_modules/, + enforce: 'pre', + use: [ + { + loader: startCompilerLoaderPath, + options: { + env, + envName, + root, + framework: corePluginOpts.framework, + providerEnvName: serverFnProviderEnv, + generateFunctionId: startPluginOpts?.serverFns?.generateFunctionId, + }, + }, + ], + }) + + const autoImportPlugins = toPluginArray(routerPlugins.autoImport) + + const clientEnvConfig = { + source: { + entry: { index: ENTRY_POINTS.client }, + alias: entryAliasConfiguration, + define: defineValues, + }, + output: { + target: 'web', + distPath: { + root: path.relative(root, clientOutputDir), + }, + }, + tools: { + rspack: { + plugins: [ + routerPlugins.generatorPlugin, + routerPlugins.clientCodeSplitter, + ...autoImportPlugins, + createStartManifestRspackPlugin({ + basePath: resolvedStartConfig.viteAppBase, + clientOutputDir, + }), + ], + module: { + rules: [ + loaderRule('client', VITE_ENVIRONMENT_NAMES.client), + { + include: [routerPlugins.getGeneratedRouteTreePath()], + use: [{ loader: routerPlugins.routeTreeLoaderPath }], + }, + ], + }, + resolve: { + alias: entryAliasConfiguration, + }, + }, + }, + } + + const serverEnvConfig = { + source: { + entry: { server: ENTRY_POINTS.server }, + alias: entryAliasConfiguration, + define: defineValues, + }, + output: { + target: 'node', + distPath: { + root: path.relative(root, serverOutputDir), + }, + }, + tools: { + rspack: { + plugins: [ + routerPlugins.generatorPlugin, + routerPlugins.serverCodeSplitter, + ...autoImportPlugins, + createServerFnResolverPlugin({ + environmentName: VITE_ENVIRONMENT_NAMES.server, + providerEnvName: serverFnProviderEnv, + }), + createInjectedHeadScriptsPlugin(), + createStartManifestVirtualModulePlugin({ + clientOutputDir, + }), + ], + module: { + rules: [loaderRule('server', VITE_ENVIRONMENT_NAMES.server)], + }, + resolve: { + alias: entryAliasConfiguration, + }, + }, + }, + } + + const setupMiddlewares = ( + middlewares: Array, + context: { environments?: Record }, + ) => { + if (startConfig.vite?.installDevServerMiddleware === false) { + return + } + const serverEnv = context.environments?.[ + VITE_ENVIRONMENT_NAMES.server + ] + middlewares.push(async (req: any, res: any, next: any) => { + if (res.headersSent || res.writableEnded) { + return next() + } + if (!serverEnv?.loadBundle) { + return next() + } + try { + const serverBundle = await serverEnv.loadBundle() + const serverBuild = serverBundle?.default ?? serverBundle + if (!serverBuild?.fetch) { + return next() + } + req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') + const webReq = new NodeRequest({ req, res }) + const webRes = await serverBuild.fetch(webReq) + return sendNodeResponse(res, webRes) + } catch (error) { + return next(error) + } + }) + } + + const existingSetupMiddlewares = config.dev?.setupMiddlewares + const mergedSetupMiddlewares = ( + middlewares: Array, + context: { environments?: Record }, + ) => { + if (typeof existingSetupMiddlewares === 'function') { + existingSetupMiddlewares(middlewares, context) + } else if (Array.isArray(existingSetupMiddlewares)) { + existingSetupMiddlewares.forEach((fn: any) => + fn(middlewares, context), + ) + } + setupMiddlewares(middlewares, context) + } + + const nextConfig = { + ...config, + environments: { + ...config.environments, + [VITE_ENVIRONMENT_NAMES.client]: mergeEnvConfig( + config.environments?.[VITE_ENVIRONMENT_NAMES.client], + clientEnvConfig, + ), + [VITE_ENVIRONMENT_NAMES.server]: mergeEnvConfig( + config.environments?.[VITE_ENVIRONMENT_NAMES.server], + serverEnvConfig, + ), + }, + dev: { + ...config.dev, + setupMiddlewares: mergedSetupMiddlewares, + }, + } + + if (!ssrIsProvider) { + nextConfig.environments = { + ...nextConfig.environments, + [serverFnProviderEnv]: mergeEnvConfig( + config.environments?.[serverFnProviderEnv], + { + source: { + entry: { provider: ENTRY_POINTS.server }, + alias: entryAliasConfiguration, + define: defineValues, + }, + output: { + target: 'node', + }, + tools: { + rspack: { + plugins: [ + createServerFnResolverPlugin({ + environmentName: serverFnProviderEnv, + providerEnvName: serverFnProviderEnv, + }), + createInjectedHeadScriptsPlugin(), + ], + module: { + rules: [ + loaderRule('server', serverFnProviderEnv), + ], + }, + resolve: { + alias: entryAliasConfiguration, + }, + }, + }, + }, + ), + } + } + + return nextConfig + }) + + api.onAfterStartProdServer?.(({ server }: { server: any }) => { + const serverOutputDir = resolvedServerOutputDir + if (!server?.middlewares?.use || !serverOutputDir) { + return + } + + let serverBuild: any = null + server.middlewares.use(async (req: any, res: any, next: any) => { + try { + if (res.headersSent || res.writableEnded) { + return next() + } + if (!resolvedServerEntryPath) { + return next() + } + + if (!serverBuild) { + const outputFilename = 'server.js' + const serverEntryPath = path.join( + serverOutputDir, + outputFilename, + ) + const imported = await import( + pathToFileURL(serverEntryPath).toString() + ) + serverBuild = imported.default ?? imported + } + + if (!serverBuild?.fetch) { + return next() + } + + req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') + + const webReq = new NodeRequest({ req, res }) + const webRes: Response = await serverBuild.fetch(webReq) + return sendNodeResponse(res, webRes) + } catch (error) { + next(error) + } + }) + }) + + api.onAfterBuild?.(async () => { + const { startConfig } = getConfig() + const clientOutputDir = resolvedClientOutputDir + const serverOutputDir = resolvedServerOutputDir + if (!clientOutputDir || !serverOutputDir) { + return + } + await postServerBuildRsbuild({ + startConfig, + clientOutputDir, + serverOutputDir, + }) + }) + }, + }, + ] +} diff --git a/packages/start-plugin-core/src/rsbuild/post-server-build.ts b/packages/start-plugin-core/src/rsbuild/post-server-build.ts new file mode 100644 index 00000000000..5aa1844a296 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/post-server-build.ts @@ -0,0 +1,68 @@ +import { HEADERS } from '@tanstack/start-server-core' +import path from 'pathe' +import { buildSitemap } from '../build-sitemap' +import { prerender } from './prerender' +import type { TanStackStartOutputConfig } from '../schema' + +export async function postServerBuildRsbuild({ + startConfig, + clientOutputDir, + serverOutputDir, +}: { + startConfig: TanStackStartOutputConfig + clientOutputDir: string + serverOutputDir: string +}) { + if (startConfig.prerender?.enabled !== false) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: + startConfig.prerender?.enabled ?? + startConfig.pages.some((d) => + typeof d === 'string' ? false : !!d.prerender?.enabled, + ), + } + } + + if (startConfig.spa?.enabled) { + startConfig.prerender = { + ...startConfig.prerender, + enabled: true, + } + + const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost') + if (maskUrl.origin !== 'http://localhost') { + throw new Error('spa.maskPath must be a path (no protocol/host)') + } + + startConfig.pages.push({ + path: maskUrl.toString().replace('http://localhost', ''), + prerender: { + ...startConfig.spa.prerender, + headers: { + ...startConfig.spa.prerender.headers, + [HEADERS.TSS_SHELL]: 'true', + }, + }, + sitemap: { + exclude: true, + }, + }) + } + + if (startConfig.prerender.enabled) { + const serverEntryPath = path.join(serverOutputDir, 'server.js') + await prerender({ + startConfig, + clientOutputDir, + serverEntryPath, + }) + } + + if (startConfig.sitemap?.enabled) { + buildSitemap({ + startConfig, + publicDir: clientOutputDir, + }) + } +} diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts new file mode 100644 index 00000000000..0669c90e9db --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -0,0 +1,277 @@ +import { pathToFileURL } from 'node:url' +import { promises as fsp } from 'node:fs' +import os from 'node:os' + +import path from 'pathe' +import { joinURL, withBase, withTrailingSlash, withoutBase } from 'ufo' +import { Queue } from '../queue' +import { createLogger } from '../utils' +import type { Page, TanStackStartOutputConfig } from '../schema' + +export async function prerender({ + startConfig, + clientOutputDir, + serverEntryPath, +}: { + startConfig: TanStackStartOutputConfig + clientOutputDir: string + serverEntryPath: string +}) { + const logger = createLogger('prerender') + logger.info('Prerendering pages...') + + if (startConfig.prerender?.enabled) { + let pages = startConfig.pages.length ? startConfig.pages : [{ path: '/' }] + + if (startConfig.prerender.autoStaticPathsDiscovery ?? true) { + const pagesMap = new Map(pages.map((item) => [item.path, item])) + const discoveredPages = globalThis.TSS_PRERENDABLE_PATHS || [] + + for (const page of discoveredPages) { + if (!pagesMap.has(page.path)) { + pagesMap.set(page.path, page) + } + } + + pages = Array.from(pagesMap.values()) + } + + startConfig.pages = pages + } + + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') + const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + + startConfig.pages = validateAndNormalizePrerenderPages( + startConfig.pages, + routerBaseUrl, + ) + + process.env.TSS_PRERENDERING = 'true' + + const serverBuild = await import(pathToFileURL(serverEntryPath).toString()) + const fetchHandler = serverBuild.default?.fetch ?? serverBuild.fetch + if (!fetchHandler) { + throw new Error('Server build does not export a fetch handler') + } + + const baseUrl = new URL('http://localhost') + + const isRedirectResponse = (res: Response) => { + return res.status >= 300 && res.status < 400 && res.headers.get('location') + } + + async function localFetch( + path: string, + options?: RequestInit, + maxRedirects: number = 5, + ): Promise { + const url = new URL(path, baseUrl) + const request = new Request(url, options) + const response = await fetchHandler(request) + + if (isRedirectResponse(response) && maxRedirects > 0) { + const location = response.headers.get('location')! + if (location.startsWith('http://localhost') || location.startsWith('/')) { + const newUrl = location.replace('http://localhost', '') + return localFetch(newUrl, options, maxRedirects - 1) + } else { + logger.warn(`Skipping redirect to external location: ${location}`) + } + } + + return response + } + + try { + const pages = await prerenderPages({ outputDir: clientOutputDir }) + + logger.info(`Prerendered ${pages.length} pages:`) + pages.forEach((page) => { + logger.info(`- ${page}`) + }) + } catch (error) { + logger.error(error) + } + + function extractLinks(html: string): Array { + const linkRegex = /]+href=["']([^"']+)["'][^>]*>/g + const links: Array = [] + let match + + while ((match = linkRegex.exec(html)) !== null) { + const href = match[1] + if (href && (href.startsWith('/') || href.startsWith('./'))) { + links.push(href) + } + } + + return links + } + + async function prerenderPages({ outputDir }: { outputDir: string }) { + const seen = new Set() + const prerendered = new Set() + const retriesByPath = new Map() + const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length + logger.info(`Concurrency: ${concurrency}`) + const queue = new Queue({ concurrency }) + const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') + + const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + startConfig.pages = validateAndNormalizePrerenderPages( + startConfig.pages, + routerBaseUrl, + ) + + startConfig.pages.forEach((page) => addCrawlPageTask(page)) + + await queue.start() + + return Array.from(prerendered) + + function addCrawlPageTask(page: Page) { + if (seen.has(page.path)) return + + seen.add(page.path) + + if (page.fromCrawl) { + startConfig.pages.push(page) + } + + if (!(page.prerender?.enabled ?? true)) return + + if (startConfig.prerender?.filter && !startConfig.prerender.filter(page)) + return + + const prerenderOptions = { + ...startConfig.prerender, + ...page.prerender, + } + + queue.add(async () => { + logger.info(`Crawling: ${page.path}`) + const retries = retriesByPath.get(page.path) || 0 + try { + const res = await localFetch( + withTrailingSlash(withBase(page.path, routerBasePath)), + { + headers: { + ...(prerenderOptions.headers ?? {}), + }, + }, + prerenderOptions.maxRedirects, + ) + + if (!res.ok) { + if (isRedirectResponse(res)) { + logger.warn(`Max redirects reached for ${page.path}`) + } + throw new Error(`Failed to fetch ${page.path}: ${res.statusText}`, { + cause: res, + }) + } + + const cleanPagePath = ( + prerenderOptions.outputPath || page.path + ).split(/[?#]/)[0]! + + const contentType = res.headers.get('content-type') || '' + const isImplicitHTML = + !cleanPagePath.endsWith('.html') && contentType.includes('html') + + const routeWithIndex = cleanPagePath.endsWith('/') + ? cleanPagePath + 'index' + : cleanPagePath + + const isSpaShell = + startConfig.spa?.prerender.outputPath === cleanPagePath + + let htmlPath: string + if (isSpaShell) { + htmlPath = cleanPagePath + '.html' + } else { + if ( + cleanPagePath.endsWith('/') || + (prerenderOptions.autoSubfolderIndex ?? true) + ) { + htmlPath = joinURL(cleanPagePath, 'index.html') + } else { + htmlPath = cleanPagePath + '.html' + } + } + + const filename = withoutBase( + isImplicitHTML ? htmlPath : routeWithIndex, + routerBasePath, + ) + + const html = await res.text() + + const filepath = path.join(outputDir, filename) + + await fsp.mkdir(path.dirname(filepath), { + recursive: true, + }) + + await fsp.writeFile(filepath, html) + + prerendered.add(page.path) + + const newPage = await prerenderOptions.onSuccess?.({ page, html }) + + if (newPage) { + Object.assign(page, newPage) + } + + if (prerenderOptions.crawlLinks ?? true) { + const links = extractLinks(html) + for (const link of links) { + addCrawlPageTask({ path: link, fromCrawl: true }) + } + } + } catch (error) { + if (retries < (prerenderOptions.retryCount ?? 0)) { + logger.warn(`Encountered error, retrying: ${page.path} in 500ms`) + await new Promise((resolve) => + setTimeout(resolve, prerenderOptions.retryDelay), + ) + retriesByPath.set(page.path, retries + 1) + addCrawlPageTask(page) + } else { + if (prerenderOptions.failOnError ?? true) { + throw error + } + } + } + }) + } + } +} + +function validateAndNormalizePrerenderPages( + pages: Array, + routerBaseUrl: URL, +): Array { + return pages.map((page) => { + let url: URL + try { + url = new URL(page.path, routerBaseUrl) + } catch (err) { + throw new Error(`prerender page path must be relative: ${page.path}`, { + cause: err, + }) + } + + if (url.origin !== 'http://localhost') { + throw new Error(`prerender page path must be relative: ${page.path}`) + } + + const decodedPathname = decodeURIComponent(url.pathname) + + return { + ...page, + path: decodedPathname + url.search + url.hash, + } + }) +} diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts new file mode 100644 index 00000000000..d37cae68ded --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts @@ -0,0 +1,8 @@ +import { getClientRouteTreeContent } from './route-tree-state' + +export default function routeTreeLoader(this: any) { + const callback = this.async() + getClientRouteTreeContent() + .then((code) => callback(null, code)) + .catch((error) => callback(error)) +} diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts new file mode 100644 index 00000000000..5809b8d765c --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts @@ -0,0 +1,34 @@ +import { pruneServerOnlySubtrees } from '../start-router-plugin/pruneServerOnlySubtrees' +import type { Generator } from '@tanstack/router-generator' + +let generatorInstance: Generator | null = null + +export function setGeneratorInstance(generator: Generator) { + generatorInstance = generator +} + +export async function getClientRouteTreeContent() { + if (!generatorInstance) { + throw new Error('Generator instance not initialized for route tree loader') + } + const crawlingResult = await generatorInstance.getCrawlingResult() + if (!crawlingResult) { + throw new Error('Crawling result not available') + } + const prunedAcc = pruneServerOnlySubtrees(crawlingResult) + const acc = { + ...crawlingResult.acc, + ...prunedAcc, + } + const buildResult = generatorInstance.buildRouteTree({ + ...crawlingResult, + acc, + config: { + disableTypes: true, + enableRouteTreeFormatting: false, + routeTreeFileHeader: [], + routeTreeFileFooter: [], + }, + }) + return buildResult.routeTreeContent +} diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts new file mode 100644 index 00000000000..76dc6e81d1f --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -0,0 +1,214 @@ +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { + KindDetectionPatterns, + LookupKindsPerEnv, + StartCompiler, + detectKindsInCode, +} from '../start-compiler-plugin/compiler' +import { cleanId } from '../start-compiler-plugin/utils' +import type { LookupConfig } from '../start-compiler-plugin/compiler' +import type { CompileStartFrameworkOptions } from '../types' +import type { + GenerateFunctionIdFnOptional, + ServerFn, +} from '../start-compiler-plugin/types' + +type LoaderOptions = { + env: 'client' | 'server' + envName: string + root: string + framework: CompileStartFrameworkOptions + providerEnvName: string + generateFunctionId?: GenerateFunctionIdFnOptional +} + +const compilers = new Map() +const serverFnsById: Record = {} + +export const getServerFnsById = () => serverFnsById + +const onServerFnsById = (d: Record) => { + Object.assign(serverFnsById, d) +} + +// Derive transform code filter from KindDetectionPatterns (single source of truth) +function getTransformCodeFilterForEnv(env: 'client' | 'server'): Array { + const validKinds = LookupKindsPerEnv[env] + const patterns: Array = [] + for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array< + [keyof typeof KindDetectionPatterns, RegExp] + >) { + if (validKinds.has(kind)) { + patterns.push(pattern) + } + } + return patterns +} + +const getLookupConfigurationsForEnv = ( + env: 'client' | 'server', + framework: CompileStartFrameworkOptions, +): Array => { + const commonConfigs: Array = [ + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createServerFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createIsomorphicFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createServerOnlyFn', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createClientOnlyFn', + kind: 'Root', + }, + ] + + if (env === 'client') { + return [ + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createMiddleware', + kind: 'Root', + }, + { + libName: `@tanstack/${framework}-start`, + rootExport: 'createStart', + kind: 'Root', + }, + ...commonConfigs, + ] + } + + return [ + ...commonConfigs, + { + libName: `@tanstack/${framework}-router`, + rootExport: 'ClientOnly', + kind: 'ClientOnlyJSX', + }, + ] +} + +function shouldTransformCode(code: string, env: 'client' | 'server') { + const patterns = getTransformCodeFilterForEnv(env) + return patterns.some((pattern) => pattern.test(code)) +} + +async function resolveId( + loaderContext: any, + source: string, + importer?: string, +): Promise { + const baseContext = importer + ? path.dirname(cleanId(importer)) + : loaderContext.context + + return new Promise((resolve) => { + loaderContext.resolve( + baseContext, + source, + (err: Error | null, result?: string) => { + if (err || !result) { + resolve(null) + return + } + resolve(cleanId(result)) + }, + ) + }) +} + +async function loadModule( + compiler: StartCompiler, + loaderContext: any, + id: string, +) { + const cleaned = cleanId(id) + const resolvedPath = + cleaned.startsWith('.') || cleaned.startsWith('/') + ? cleaned + : (await resolveId(loaderContext, cleaned)) ?? cleaned + + if (resolvedPath.includes('\0')) return + + try { + const code = await fsp.readFile(resolvedPath, 'utf-8') + compiler.ingestModule({ code, id: resolvedPath }) + } catch { + // ignore missing files + } +} + +export default function startCompilerLoader( + this: any, + code: string, + map: any, +) { + const callback = this.async() + const options = this.getOptions() as LoaderOptions + + const env = options.env + const envName = options.envName + const root = options.root || process.cwd() + const framework = options.framework + const providerEnvName = options.providerEnvName + + if (!shouldTransformCode(code, env)) { + callback(null, code, map) + return + } + + let compiler = compilers.get(envName) + if (!compiler) { + const mode = + this.mode === 'production' || this._compiler?.options?.mode === 'production' + ? 'build' + : 'dev' + compiler = new StartCompiler({ + env, + envName, + root, + lookupKinds: LookupKindsPerEnv[env], + lookupConfigurations: getLookupConfigurationsForEnv(env, framework), + mode, + framework, + providerEnvName, + generateFunctionId: options.generateFunctionId, + onServerFnsById, + getKnownServerFns: () => serverFnsById, + loadModule: async (id: string) => loadModule(compiler!, this, id), + resolveId: async (source: string, importer?: string) => + resolveId(this, source, importer), + }) + compilers.set(envName, compiler) + } + + const detectedKinds = detectKindsInCode(code, env) + + compiler + .compile({ + id: cleanId(this.resourcePath), + code, + detectedKinds, + }) + .then((result) => { + if (!result) { + callback(null, code, map) + return + } + callback(null, result.code, result.map ?? map) + }) + .catch((error) => { + callback(error) + }) +} diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts new file mode 100644 index 00000000000..a4b5aa43907 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -0,0 +1,90 @@ +import { createRspackPlugin } from 'unplugin' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' +import { VITE_ENVIRONMENT_NAMES } from '../constants' +import { getServerFnsById } from './start-compiler-loader' +import type { ServerFn } from '../start-compiler-plugin/types' + +function generateManifestModule( + serverFnsById: Record, + includeClientReferencedCheck: boolean, +): string { + const manifestEntries = Object.entries(serverFnsById) + .map(([id, fn]) => { + const baseEntry = `'${id}': { + functionName: '${fn.functionName}', + importer: () => import(${JSON.stringify(fn.extractedFilename)})${ + includeClientReferencedCheck + ? `, + isClientReferenced: ${fn.isClientReferenced ?? true}` + : '' + } + }` + return baseEntry + }) + .join(',') + + const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id' + const clientReferencedCheck = includeClientReferencedCheck + ? ` + if (opts?.fromClient && !serverFnInfo.isClientReferenced) { + throw new Error('Server function not accessible from client: ' + id) + } +` + : '' + + return ` + const manifest = {${manifestEntries}} + + export async function getServerFnById(${getServerFnByIdParams}) { + const serverFnInfo = manifest[id] + if (!serverFnInfo) { + throw new Error('Server function info not found for ' + id) + } +${clientReferencedCheck} + const fnModule = await serverFnInfo.importer() + + if (!fnModule) { + console.info('serverFnInfo', serverFnInfo) + throw new Error('Server function module not resolved for ' + id) + } + + const action = fnModule[serverFnInfo.functionName] + + if (!action) { + console.info('serverFnInfo', serverFnInfo) + console.info('fnModule', fnModule) + + throw new Error( + \`Server function module export not resolved for serverFn ID: \${id}\`, + ) + } + return action + } + ` +} + +export function createServerFnResolverPlugin(opts: { + environmentName: string + providerEnvName: string +}) { + const ssrIsProvider = opts.providerEnvName === VITE_ENVIRONMENT_NAMES.server + const includeClientReferencedCheck = !ssrIsProvider + + return createRspackPlugin(() => ({ + name: `tanstack-start-core:server-fn-resolver:${opts.environmentName}`, + resolveId(id) { + if (id === VIRTUAL_MODULES.serverFnResolver) { + return id + } + return null + }, + load(id) { + if (id !== VIRTUAL_MODULES.serverFnResolver) return null + if (opts.environmentName !== opts.providerEnvName) { + return `export { getServerFnById } from '@tanstack/start-server-core/server-fn-ssr-caller'` + } + const serverFnsById = getServerFnsById() + return generateManifestModule(serverFnsById, includeClientReferencedCheck) + }, + })) +} diff --git a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts new file mode 100644 index 00000000000..15bf0246de0 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts @@ -0,0 +1,226 @@ +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { joinURL } from 'ufo' +import { rootRouteId } from '@tanstack/router-core' +import { VIRTUAL_MODULES } from '@tanstack/start-server-core' +import { tsrSplit } from '@tanstack/router-plugin' +import { createRspackPlugin } from 'unplugin' +import type { Manifest, RouterManagedTag } from '@tanstack/router-core' + +const START_MANIFEST_FILE = 'tanstack-start-manifest.json' + +type StatsAsset = string | { name?: string } + +type StatsChunk = { + files?: Array + auxiliaryFiles?: Array + modules?: Array<{ name?: string; identifier?: string }> +} + +type StatsJson = { + entrypoints?: Record< + string, + { + assets?: Array + } + > + chunks?: Array +} + +function getAssetName(asset: StatsAsset): string | undefined { + if (!asset) return undefined + if (typeof asset === 'string') return asset + return asset.name +} + +function isJsAsset(asset: string) { + return asset.endsWith('.js') || asset.endsWith('.mjs') +} + +function isCssAsset(asset: string) { + return asset.endsWith('.css') +} + +function createCssTags( + basePath: string, + assets: Array, +): Array { + return assets.map((asset) => ({ + tag: 'link', + attrs: { + rel: 'stylesheet', + href: joinURL(basePath, asset), + type: 'text/css', + }, + })) +} + +function unique(items: Array) { + return Array.from(new Set(items)) +} + +function getStatsEntryAssets(statsJson: StatsJson): Array { + const entrypoints = statsJson.entrypoints ?? {} + const entrypoint = + entrypoints['index'] ?? + entrypoints['main'] ?? + entrypoints[Object.keys(entrypoints)[0] ?? ''] + + if (!entrypoint?.assets) return [] + + return unique( + entrypoint.assets + .map(getAssetName) + .filter((asset): asset is string => Boolean(asset)), + ) +} + +function buildStartManifest({ + statsJson, + basePath, +}: { + statsJson: StatsJson + basePath: string +}): Manifest & { clientEntry: string } { + const entryAssets = getStatsEntryAssets(statsJson) + const entryJsAssets = unique(entryAssets.filter(isJsAsset)) + const entryCssAssets = unique(entryAssets.filter(isCssAsset)) + + const entryFile = entryJsAssets[0] + if (!entryFile) { + throw new Error('No client entry file found in rsbuild stats') + } + + const routeTreeRoutes: Record = + globalThis.TSS_ROUTES_MANIFEST + + const routeChunks: Record> = {} + for (const chunk of statsJson.chunks ?? []) { + const modules = chunk.modules ?? [] + for (const mod of modules) { + const id = mod.identifier ?? mod.name ?? '' + if (!id.includes(tsrSplit)) continue + const [fileId, query] = id.split('?') + if (!fileId || !query) continue + const searchParams = new URLSearchParams(query) + if (!searchParams.has(tsrSplit)) continue + const existingChunks = routeChunks[fileId] + if (existingChunks) { + existingChunks.push(chunk) + } else { + routeChunks[fileId] = [chunk] + } + } + } + + const manifest: Manifest = { routes: {} } + + Object.entries(routeTreeRoutes).forEach(([routeId, route]) => { + const chunks = routeChunks[route.filePath] + if (!chunks?.length) { + manifest.routes[routeId] = {} + return + } + + const preloadAssets = unique( + chunks.flatMap((chunk) => chunk.files ?? []).filter(isJsAsset), + ) + const cssAssets = unique( + chunks + .flatMap((chunk) => [ + ...(chunk.files ?? []), + ...(chunk.auxiliaryFiles ?? []), + ]) + .filter(isCssAsset), + ) + + manifest.routes[routeId] = { + preloads: preloadAssets.map((asset) => joinURL(basePath, asset)), + assets: createCssTags(basePath, cssAssets), + } + }) + + manifest.routes[rootRouteId] = { + ...(manifest.routes[rootRouteId] ?? {}), + preloads: entryJsAssets.map((asset) => joinURL(basePath, asset)), + assets: [ + ...createCssTags(basePath, entryCssAssets), + ...(manifest.routes[rootRouteId]?.assets ?? []), + ], + } + + return { + routes: manifest.routes, + clientEntry: joinURL(basePath, entryFile), + } +} + +export function createStartManifestRspackPlugin(opts: { + basePath: string + clientOutputDir: string +}) { + return { + apply(compiler: any) { + compiler.hooks.done.tapPromise( + 'tanstack-start:manifest', + async (stats: any) => { + const statsJson: StatsJson = stats.toJson({ + all: false, + entrypoints: true, + chunks: true, + modules: true, + }) + + const manifest = buildStartManifest({ + statsJson, + basePath: opts.basePath, + }) + + const manifestPath = path.join( + opts.clientOutputDir, + START_MANIFEST_FILE, + ) + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }) + await fsp.writeFile( + manifestPath, + JSON.stringify(manifest), + 'utf-8', + ) + }, + ) + }, + } +} + +export function createStartManifestVirtualModulePlugin(opts: { + clientOutputDir: string +}) { + const manifestPath = path.join(opts.clientOutputDir, START_MANIFEST_FILE) + return createRspackPlugin(() => ({ + name: 'tanstack-start:manifest:virtual', + resolveId(id) { + if (id === VIRTUAL_MODULES.startManifest) { + return id + } + return null + }, + load(id) { + if (id !== VIRTUAL_MODULES.startManifest) return null + return ` +import fs from 'node:fs' + +let cached +export const tsrStartManifest = () => { + if (cached) return cached + try { + const raw = fs.readFileSync(${JSON.stringify(manifestPath)}, 'utf-8') + cached = JSON.parse(raw) + return cached + } catch (error) { + return { routes: {}, clientEntry: '' } + } +} +` + }, + })) +} diff --git a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts new file mode 100644 index 00000000000..6aae1d1415a --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -0,0 +1,179 @@ +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' +import path from 'pathe' +import { + tanstackRouterAutoImport, + tanstackRouterCodeSplitter, + tanstackRouterGenerator, +} from '@tanstack/router-plugin/rspack' +import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' +import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' +import { VITE_ENVIRONMENT_NAMES } from '../constants' +import { setGeneratorInstance } from './route-tree-state' +import type { GetConfigFn, TanStackStartVitePluginCoreOptions } from '../types' +import type { GeneratorPlugin } from '@tanstack/router-generator' +import type { TanStackStartInputConfig } from '../schema' + +function moduleDeclaration({ + startFilePath, + routerFilePath, + corePluginOpts, + generatedRouteTreePath, +}: { + startFilePath: string | undefined + routerFilePath: string + corePluginOpts: TanStackStartVitePluginCoreOptions + generatedRouteTreePath: string +}): string { + function getImportPath(absolutePath: string) { + let relativePath = path.relative( + path.dirname(generatedRouteTreePath), + absolutePath, + ) + + if (!relativePath.startsWith('.')) { + relativePath = './' + relativePath + } + + relativePath = relativePath.split(path.sep).join('/') + return relativePath + } + + const result: Array = [ + `import type { getRouter } from '${getImportPath(routerFilePath)}'`, + ] + if (startFilePath) { + result.push( + `import type { startInstance } from '${getImportPath(startFilePath)}'`, + ) + } else { + result.push( + `import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`, + ) + } + result.push( + `declare module '@tanstack/${corePluginOpts.framework}-start' { + interface Register { + ssr: true + router: Awaited>`, + ) + if (startFilePath) { + result.push( + ` config: Awaited>`, + ) + } + result.push(` } +}`) + + return result.join('\n') +} + +function resolveLoaderPath(relativePath: string) { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const basePath = path.resolve(currentDir, relativePath) + const jsPath = `${basePath}.js` + const tsPath = `${basePath}.ts` + if (fs.existsSync(jsPath)) return jsPath + if (fs.existsSync(tsPath)) return tsPath + return jsPath +} + +export function tanStackStartRouterRsbuild( + startPluginOpts: TanStackStartInputConfig, + getConfig: GetConfigFn, + corePluginOpts: TanStackStartVitePluginCoreOptions, +) { + const getGeneratedRouteTreePath = () => { + const { startConfig } = getConfig() + return path.resolve(startConfig.router.generatedRouteTree) + } + + const clientTreeGeneratorPlugin: GeneratorPlugin = { + name: 'start-client-tree-plugin', + init({ generator }) { + setGeneratorInstance(generator) + }, + } + + let routeTreeFileFooter: Array | null = null + function getRouteTreeFileFooter() { + if (routeTreeFileFooter) { + return routeTreeFileFooter + } + const { startConfig, resolvedStartConfig } = getConfig() + const ogRouteTreeFileFooter = startConfig.router.routeTreeFileFooter + if (ogRouteTreeFileFooter) { + if (Array.isArray(ogRouteTreeFileFooter)) { + routeTreeFileFooter = ogRouteTreeFileFooter + } else { + routeTreeFileFooter = ogRouteTreeFileFooter() + } + } + routeTreeFileFooter = [ + moduleDeclaration({ + generatedRouteTreePath: getGeneratedRouteTreePath(), + corePluginOpts, + startFilePath: resolvedStartConfig.startFilePath, + routerFilePath: resolvedStartConfig.routerFilePath, + }), + ...(routeTreeFileFooter ?? []), + ] + return routeTreeFileFooter + } + + const routeTreeLoaderPath = resolveLoaderPath('./route-tree-loader') + + const generatorPlugin = tanstackRouterGenerator(() => { + const routerConfig = getConfig().startConfig.router + const plugins = [clientTreeGeneratorPlugin, routesManifestPlugin()] + if (startPluginOpts?.prerender?.enabled === true) { + plugins.push(prerenderRoutesPlugin()) + } + return { + ...routerConfig, + target: corePluginOpts.framework, + routeTreeFileFooter: getRouteTreeFileFooter, + plugins, + } + }) + + const clientCodeSplitter = tanstackRouterCodeSplitter(() => { + const routerConfig = getConfig().startConfig.router + return { + ...routerConfig, + codeSplittingOptions: { + ...routerConfig.codeSplittingOptions, + deleteNodes: ['ssr', 'server', 'headers'], + addHmr: true, + }, + plugin: { + vite: { environmentName: VITE_ENVIRONMENT_NAMES.client }, + }, + } + }) + + const serverCodeSplitter = tanstackRouterCodeSplitter(() => { + const routerConfig = getConfig().startConfig.router + return { + ...routerConfig, + codeSplittingOptions: { + ...routerConfig.codeSplittingOptions, + addHmr: false, + }, + plugin: { + vite: { environmentName: VITE_ENVIRONMENT_NAMES.server }, + }, + } + }) + + const autoImport = tanstackRouterAutoImport(startPluginOpts?.router) + + return { + generatorPlugin, + clientCodeSplitter, + serverCodeSplitter, + autoImport, + routeTreeLoaderPath, + getGeneratedRouteTreePath, + } +} diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index d6650eebf65..6f84b1c040a 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -14,7 +14,12 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: './src/index.ts', + entry: [ + './src/index.ts', + './src/rsbuild/index.ts', + './src/rsbuild/start-compiler-loader.ts', + './src/rsbuild/route-tree-loader.ts', + ], srcDir: './src', outDir: './dist', cjs: false, diff --git a/packages/vue-start/package.json b/packages/vue-start/package.json index d515d54c85f..d732b138200 100644 --- a/packages/vue-start/package.json +++ b/packages/vue-start/package.json @@ -74,6 +74,12 @@ "default": "./dist/esm/plugin/vite.js" } }, + "./plugin/rsbuild": { + "import": { + "types": "./dist/esm/plugin/rsbuild.d.ts", + "default": "./dist/esm/plugin/rsbuild.js" + } + }, "./server-entry": { "import": { "types": "./dist/default-entry/esm/server.d.ts", @@ -106,7 +112,13 @@ "vue": "^3.5.25" }, "peerDependencies": { + "@rsbuild/core": ">=1.0.0", "vue": "^3.3.0", "vite": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + } } } diff --git a/packages/vue-start/src/plugin/rsbuild.ts b/packages/vue-start/src/plugin/rsbuild.ts new file mode 100644 index 00000000000..55b4b13323d --- /dev/null +++ b/packages/vue-start/src/plugin/rsbuild.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { TanStackStartRsbuildPluginCore } from '@tanstack/start-plugin-core/rsbuild' +import type { TanStackStartInputConfig } from '@tanstack/start-plugin-core' + +type RsbuildPlugin = { + name: string + setup: (api: any) => void +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const defaultEntryDir = path.resolve( + currentDir, + '..', + '..', + 'plugin', + 'default-entry', +) +const defaultEntryPaths = { + client: path.resolve(defaultEntryDir, 'client.tsx'), + server: path.resolve(defaultEntryDir, 'server.ts'), + start: path.resolve(defaultEntryDir, 'start.ts'), +} + +export function tanstackStart( + options?: TanStackStartInputConfig, +): Array { + return TanStackStartRsbuildPluginCore( + { + framework: 'vue', + defaultEntryPaths, + }, + options, + ) +} diff --git a/packages/vue-start/vite.config.ts b/packages/vue-start/vite.config.ts index 3e1088b3ef3..b9c0ec1e262 100644 --- a/packages/vue-start/vite.config.ts +++ b/packages/vue-start/vite.config.ts @@ -33,6 +33,7 @@ export default mergeConfig( './src/server-rpc.ts', './src/server.tsx', './src/plugin/vite.ts', + './src/plugin/rsbuild.ts', ], externalDeps: ['@tanstack/vue-start-client', '@tanstack/vue-start-server'], cjs: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9453614209..28e773e224c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1594,7 +1594,7 @@ importers: version: 4.7.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) sass: specifier: ^1.97.2 version: 1.97.2 @@ -1716,7 +1716,7 @@ importers: version: 9.2.1 nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260123-195236-c6b834cd(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: nitro-nightly@3.0.1-20260206-171553-bc737c0c(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) typescript: specifier: ^5.7.2 version: 5.9.3 @@ -7955,7 +7955,7 @@ importers: version: 4.6.0(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -10494,7 +10494,7 @@ importers: version: 25.0.9 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -10675,7 +10675,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -10721,7 +10721,7 @@ importers: version: 25.0.9 nitro: specifier: 3.0.1-alpha.2 - version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -11538,7 +11538,7 @@ importers: dependencies: nitropack: specifier: ^2.13.1 - version: 2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3) + version: 2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3)(rolldown@1.0.0-rc.3) pathe: specifier: ^2.0.3 version: 2.0.3 @@ -11644,6 +11644,9 @@ importers: packages/react-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/react-router': specifier: workspace:* version: link:../react-router @@ -12097,6 +12100,9 @@ importers: packages/solid-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/solid-router': specifier: workspace:* version: link:../solid-router @@ -12228,6 +12234,9 @@ importers: '@rolldown/pluginutils': specifier: 1.0.0-beta.40 version: 1.0.0-beta.40 + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/router-core': specifier: workspace:* version: link:../router-core @@ -12264,6 +12273,9 @@ importers: ufo: specifier: ^1.5.4 version: 1.6.1 + unplugin: + specifier: ^2.3.11 + version: 2.3.11 vitefu: specifier: ^1.1.1 version: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -12300,7 +12312,7 @@ importers: version: link:../start-storage-context h3-v2: specifier: npm:h3@2.0.1-rc.14 - version: h3@2.0.1-rc.14(crossws@0.4.3(srvx@0.11.2)) + version: h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)) seroval: specifier: ^1.4.2 version: 1.4.2 @@ -12460,6 +12472,9 @@ importers: packages/vue-start: dependencies: + '@rsbuild/core': + specifier: '>=1.0.0' + version: 1.2.4 '@tanstack/start-client-core': specifier: workspace:* version: link:../start-client-core @@ -15350,6 +15365,9 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/types@0.112.0': + resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + '@oxc-transform/binding-android-arm-eabi@0.110.0': resolution: {integrity: sha512-sE9dxvqqAax1YYJ3t7j+h5ZSI9jl6dYuDfngl6ieZUrIy5P89/8JKVgAzgp8o3wQSo7ndpJvYsi1K4ZqrmbP7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -16388,6 +16406,83 @@ packages: '@remix-run/node-fetch-server@0.8.1': resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==} + '@rolldown/binding-android-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.19': resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==} @@ -16409,6 +16504,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.54': resolution: {integrity: sha512-AHgcZ+w7RIRZ65ihSQL8YuoKcpD9Scew4sEeP1BBUT9QdTo6KjwHrZZXjID6nL10fhKessCH6OPany2QKwAwTQ==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/plugin-alias@6.0.0': resolution: {integrity: sha512-tPCzJOtS7uuVZd+xPhoy5W4vThe6KWXNmsFCNktaAh5RTqcLiSfT4huPQIXkgJ6YCOjJHvecOAzQxLFhPxKr+g==} engines: {node: '>=20.19.0'} @@ -17399,6 +17497,7 @@ packages: '@tanstack/config@0.22.0': resolution: {integrity: sha512-7Wwfw6wBv2Kc+OBNIJQzBSJ6q7GABtwVT+VOQ/7/Gl7z8z1rtEYUZrxUrNvbbrHY+J5/WNZNZjJjTWDf8nTUBw==} engines: {node: '>=18'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@tanstack/devtools-client@0.0.4': resolution: {integrity: sha512-LefnH9KE9uRDEWifc3QDcooskA8ikfs41bybDTgpYQpyTUspZnaEdUdya9Hry0KYxZ8nos0S3nNbsP79KHqr6Q==} @@ -19364,6 +19463,14 @@ packages: srvx: optional: true + crossws@0.4.4: + resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} + peerDependencies: + srvx: '>=0.7.1' + peerDependenciesMeta: + srvx: + optional: true + css-loader@7.1.2: resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} engines: {node: '>= 18.12.0'} @@ -20517,6 +20624,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -21885,24 +21993,21 @@ packages: netlify-redirector@0.5.0: resolution: {integrity: sha512-4zdzIP+6muqPCuE8avnrgDJ6KW/2+UpHTRcTbMXCIRxiRmyrX+IZ4WSJGZdHPWF3WmQpXpy603XxecZ9iygN7w==} + nf3@0.3.10: + resolution: {integrity: sha512-UlqmHkZiHGgSkRj17yrOXEsSu5ECvtlJ3Xm1W5WsWrTKgu9m7OjrMZh9H/ME2LcWrTlMD0/vmmNVpyBG4yRdGg==} + nf3@0.3.5: resolution: {integrity: sha512-1VozaVz0lVfGL3c2wZ4c6bmQCm340gDiIYUU3lcg8vVGL/WeuTdrd6OhJiUHZWofc7fFdquhS8Gm+13c3Tumcw==} - nf3@0.3.6: - resolution: {integrity: sha512-/XRUUILTAyuy1XunyVQuqGp8aEmZ2TfRTn8Rji+FA4xqv20qzL4jV7Reqbuey2XucKgPeRVcEYGScmJM0UnB6Q==} - - nitro-nightly@3.0.1-20260123-195236-c6b834cd: - resolution: {integrity: sha512-TjFlflqrAwl+jJcUwgXAq9qVSBRan3o6O4jR4SIt9J/8ipuoud8H+ERhvzUEZhunOJwjdbkp8B9X2Ik6cC1Yww==} + nitro-nightly@3.0.1-20260206-171553-bc737c0c: + resolution: {integrity: sha512-fqne2eTFStLkCODKJ2PWuN6mWv0HNL8mb0xYH/W14cNqbFPiwWQQPWPG9BWARfXm8q/QjN93kTyIYMwRgE5tag==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - rolldown: '>=1.0.0-beta.0' - rollup: ^4 + rollup: ^4.57.0 vite: ^7.3.1 xml2js: ^0.6.2 peerDependenciesMeta: - rolldown: - optional: true rollup: optional: true vite: @@ -22929,6 +23034,11 @@ packages: engines: {node: 20 || >=22} hasBin: true + rolldown@1.0.0-rc.3: + resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup-plugin-preserve-directives@0.4.0: resolution: {integrity: sha512-gx4nBxYm5BysmEQS+e2tAMrtFxrGvk+Pe5ppafRibQi0zlW7VYAbEGk6IKDw9sJGPdFWgVTE0o4BU4cdG0Fylg==} peerDependencies: @@ -23627,6 +23737,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.11: resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} @@ -24069,10 +24180,6 @@ packages: unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} - unplugin@2.3.10: - resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==} - engines: {node: '>=18.12.0'} - unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -24681,6 +24788,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -26815,7 +26923,7 @@ snapshots: commander: 11.1.0 consola: 3.4.0 json5: 2.2.3 - unplugin: 2.3.10 + unplugin: 2.3.11 urlpattern-polyfill: 10.1.0 transitivePeerDependencies: - babel-plugin-macros @@ -27357,13 +27465,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.55.3) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.55.3) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2) '@netlify/redirects': 3.1.0 @@ -27431,12 +27539,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.55.3)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.55.3)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.55.3) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.55.3) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -27526,9 +27634,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -27556,9 +27664,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -27586,13 +27694,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.55.3)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.55.3)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.55.3) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.55.3) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -27763,6 +27871,8 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.110.0': optional: true + '@oxc-project/types@0.112.0': {} + '@oxc-transform/binding-android-arm-eabi@0.110.0': optional: true @@ -28880,6 +28990,47 @@ snapshots: '@remix-run/node-fetch-server@0.8.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + optional: true + '@rolldown/pluginutils@1.0.0-beta.19': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -28894,6 +29045,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.54': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/plugin-alias@6.0.0(rollup@4.55.3)': optionalDependencies: rollup: 4.55.3 @@ -29220,7 +29373,7 @@ snapshots: '@module-federation/runtime-tools': 0.8.4 '@rspack/binding': 1.2.2 '@rspack/lite-tapable': 1.0.1 - caniuse-lite: 1.0.30001696 + caniuse-lite: 1.0.30001760 optionalDependencies: '@swc/helpers': 0.5.15 @@ -30797,7 +30950,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.55.3)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.55.3)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.55.3) @@ -31522,10 +31675,6 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-jsx@5.3.2(acorn@8.14.1): - dependencies: - acorn: 8.14.1 - acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -32472,10 +32621,9 @@ snapshots: optionalDependencies: srvx: 0.10.1 - crossws@0.4.3(srvx@0.11.2): + crossws@0.4.4(srvx@0.11.2): optionalDependencies: srvx: 0.11.2 - optional: true css-loader@7.1.2(@rspack/core@1.2.2(@swc/helpers@0.5.15))(webpack@5.97.1): dependencies: @@ -33439,8 +33587,8 @@ snapshots: espree@10.3.0: dependencies: - acorn: 8.14.1 - acorn-jsx: 5.3.2(acorn@8.14.1) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 espree@10.4.0: @@ -34016,12 +34164,12 @@ snapshots: optionalDependencies: crossws: 0.4.3(srvx@0.10.1) - h3@2.0.1-rc.14(crossws@0.4.3(srvx@0.11.2)): + h3@2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)): dependencies: rou3: 0.7.12 srvx: 0.11.2 optionalDependencies: - crossws: 0.4.3(srvx@0.11.2) + crossws: 0.4.4(srvx@0.11.2) handle-thing@2.0.1: {} @@ -35369,24 +35517,22 @@ snapshots: netlify-redirector@0.5.0: {} - nf3@0.3.5: {} + nf3@0.3.10: {} - nf3@0.3.6: {} + nf3@0.3.5: {} - nitro-nightly@3.0.1-20260123-195236-c6b834cd(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): + nitro-nightly@3.0.1-20260206-171553-bc737c0c(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: consola: 3.4.2 - crossws: 0.4.3(srvx@0.10.1) + crossws: 0.4.4(srvx@0.11.2) db0: 0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3) - h3: 2.0.1-rc.11(crossws@0.4.3(srvx@0.10.1)) + h3: 2.0.1-rc.14(crossws@0.4.4(srvx@0.11.2)) jiti: 2.6.1 - nf3: 0.3.6 + nf3: 0.3.10 ofetch: 2.0.0-alpha.3 ohash: 2.0.11 - oxc-minify: 0.110.0 - oxc-transform: 0.110.0 - srvx: 0.10.1 - undici: 7.18.2 + rolldown: 1.0.0-rc.3 + srvx: 0.11.2 unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.2.2)(ofetch@2.0.0-alpha.3) optionalDependencies: @@ -35421,7 +35567,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): + nitro@3.0.1-alpha.2(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(chokidar@5.0.0)(ioredis@5.9.2)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: consola: 3.4.2 crossws: 0.4.3(srvx@0.10.1) @@ -35438,6 +35584,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.5(@netlify/blobs@10.1.0)(chokidar@5.0.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(lru-cache@11.2.2)(ofetch@2.0.0-alpha.3) optionalDependencies: + rolldown: 1.0.0-rc.3 rollup: 4.55.3 vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: @@ -35469,7 +35616,7 @@ snapshots: - sqlite3 - uploadthing - nitropack@2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3): + nitropack@2.13.1(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(@netlify/blobs@10.1.0)(encoding@0.1.13)(mysql2@3.15.3)(rolldown@1.0.0-rc.3): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.55.3) @@ -35522,7 +35669,7 @@ snapshots: pretty-bytes: 7.1.0 radix3: 1.1.2 rollup: 4.55.3 - rollup-plugin-visualizer: 6.0.5(rollup@4.55.3) + rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.3)(rollup@4.55.3) scule: 1.3.0 semver: 7.7.3 serve-placeholder: 2.0.2 @@ -36686,19 +36833,39 @@ snapshots: glob: 13.0.0 package-json-from-dist: 1.0.1 + rolldown@1.0.0-rc.3: + dependencies: + '@oxc-project/types': 0.112.0 + '@rolldown/pluginutils': 1.0.0-rc.3 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-x64': 1.0.0-rc.3 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + rollup-plugin-preserve-directives@0.4.0(rollup@4.55.3): dependencies: '@rollup/pluginutils': 5.1.4(rollup@4.55.3) magic-string: 0.30.21 rollup: 4.55.3 - rollup-plugin-visualizer@6.0.5(rollup@4.55.3): + rollup-plugin-visualizer@6.0.5(rolldown@1.0.0-rc.3)(rollup@4.55.3): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: + rolldown: 1.0.0-rc.3 rollup: 4.55.3 rollup@4.52.5: @@ -37909,18 +38076,11 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.14.1 + acorn: 8.15.0 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 - unplugin@2.3.10: - dependencies: - '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 - picomatch: 4.0.3 - webpack-virtual-modules: 0.6.2 - unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 From 0f73fe3eb9b68648d248b1ae295b2e30f461c16b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 17:04:14 +0000 Subject: [PATCH 02/16] Implement rsbuild Start plugin parity Co-authored-by: Zack Jackson --- e2e/react-start/basic/package.json | 14 +- e2e/react-start/basic/rsbuild.config.ts | 60 ++ e2e/react-start/basic/scripts/run-bundler.mjs | 38 ++ e2e/react-start/basic/src/routeTree.gen.ts | 7 +- packages/router-core/src/router.ts | 6 +- .../src/client-rpc/serverFnFetcher.ts | 56 +- .../src/dev-server-plugin/plugin.ts | 2 +- .../rsbuild/injected-head-scripts-plugin.ts | 3 +- .../start-plugin-core/src/rsbuild/plugin.ts | 221 ++++++- .../src/rsbuild/route-tree-loader.ts | 9 +- .../src/rsbuild/route-tree-state.ts | 24 +- .../src/rsbuild/start-compiler-loader.ts | 73 ++- .../src/rsbuild/start-compiler-plugin.ts | 599 +++++++++++++++++- .../src/rsbuild/start-manifest-plugin.ts | 150 ++++- .../src/rsbuild/start-router-plugin.ts | 2 +- .../src/rsbuild/start-storage-context-stub.ts | 12 + .../src/start-compiler-plugin/compiler.ts | 2 +- .../handleCreateServerFn.ts | 76 ++- .../src/start-compiler-plugin/plugin.ts | 3 - .../src/start-compiler-plugin/types.ts | 6 + .../createServerFn/createServerFn.test.ts | 6 + .../createServerFnDestructured.tsx | 3 + .../createServerFnDestructuredRename.tsx | 3 + .../createServerFnStarImport.tsx | 3 + .../server-caller/createServerFnValidator.tsx | 3 + .../snapshots/server-caller/factory.tsx | 3 + .../server-caller/isomorphic-fns.tsx | 3 + packages/start-plugin-core/vite.config.ts | 1 + .../start-server-core/src/router-manifest.ts | 4 +- .../src/server-functions-handler.ts | 79 ++- .../start-server-core/src/tanstack-start.d.ts | 4 +- .../start-server-core/src/virtual-modules.ts | 4 +- pnpm-lock.yaml | 30 +- 33 files changed, 1388 insertions(+), 121 deletions(-) create mode 100644 e2e/react-start/basic/rsbuild.config.ts create mode 100644 e2e/react-start/basic/scripts/run-bundler.mjs create mode 100644 packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index 883627bb4f7..2e4d63558e2 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -4,12 +4,12 @@ "sideEffects": false, "type": "module", "scripts": { - "dev": "vite dev --port 3000", - "dev:e2e": "vite dev", - "build": "vite build && tsc --noEmit", - "build:spa": "MODE=spa vite build && tsc --noEmit", - "build:prerender": "MODE=prerender vite build && tsc --noEmit", - "preview": "vite preview", + "dev": "node scripts/run-bundler.mjs dev --port 3000", + "dev:e2e": "node scripts/run-bundler.mjs dev", + "build": "node scripts/run-bundler.mjs build", + "build:spa": "MODE=spa node scripts/run-bundler.mjs build", + "build:prerender": "MODE=prerender node scripts/run-bundler.mjs build", + "preview": "node scripts/run-bundler.mjs preview", "start": "node server.js", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", @@ -33,6 +33,8 @@ }, "devDependencies": { "@playwright/test": "^1.50.1", + "@rsbuild/core": "^1.2.4", + "@rsbuild/plugin-react": "^1.1.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/router-e2e-utils": "workspace:^", "@types/js-cookie": "^3.0.6", diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts new file mode 100644 index 00000000000..dd173dfd7dd --- /dev/null +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -0,0 +1,60 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' +import { isSpaMode } from './tests/utils/isSpaMode' +import { isPrerender } from './tests/utils/isPrerender' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) + +const spaModeConfiguration = { + enabled: true, + prerender: { + outputPath: 'index.html', + }, +} + +const prerenderConfiguration = { + enabled: true, + filter: (page: { path: string }) => + ![ + '/this-route-does-not-exist', + '/redirect', + '/i-do-not-exist', + '/not-found/via-beforeLoad', + '/not-found/via-loader', + '/specialChars/search', + '/specialChars/hash', + '/specialChars/malformed', + '/users', + ].some((p) => page.path.includes(p)), + maxRedirects: 100, +} + +export default defineConfig({ + plugins: [ + pluginReact(), + ...tanstackStart({ + spa: isSpaMode ? spaModeConfiguration : undefined, + prerender: isPrerender ? prerenderConfiguration : undefined, + }), + ], + tools: { + rspack: { + module: { + rules: [ + { + resourceQuery: /url/, + type: 'asset/resource', + }, + ], + }, + }, + }, + source: { + alias: { + '~': path.resolve(currentDir, 'src'), + }, + }, +}) diff --git a/e2e/react-start/basic/scripts/run-bundler.mjs b/e2e/react-start/basic/scripts/run-bundler.mjs new file mode 100644 index 00000000000..a7971a27f69 --- /dev/null +++ b/e2e/react-start/basic/scripts/run-bundler.mjs @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process' + +const command = process.argv[2] +const args = process.argv.slice(3) + +if (!command) { + console.error('Missing bundler command') + process.exit(1) +} + +const bundler = process.env.BUNDLER === 'rsbuild' ? 'rsbuild' : 'vite' + +const run = (cmd, cmdArgs) => + new Promise((resolve, reject) => { + const child = spawn(cmd, cmdArgs, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }) + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`${cmd} exited with code ${code}`)) + } + }) + }) + +try { + await run(bundler, [command, ...args]) + + if (command === 'build') { + await run('tsc', ['--noEmit']) + } +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index b5ecc4a50ff..48eaf8d7ca5 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -1,9 +1,3 @@ -/* eslint-disable */ - -// @ts-nocheck - -// noinspection JSUnusedGlobalSymbols - // 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. @@ -1365,6 +1359,7 @@ export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + import type { getRouter } from './router.tsx' import type { createStart } from '@tanstack/react-start' declare module '@tanstack/react-start' { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index d677f5530ea..6e6a8297d88 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2048,7 +2048,7 @@ export class RouterCore< * Commit a previously built location to history (push/replace), optionally * using view transitions and scroll restoration options. */ - commitLocation: CommitLocationFn = async ({ + commitLocation: CommitLocationFn = ({ viewTransition, ignoreBlocker, ...next @@ -2379,7 +2379,7 @@ export class RouterCore< onReady: async () => { // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { - this.startViewTransition(async () => { + this.startViewTransition(() => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was @@ -2442,6 +2442,8 @@ export class RouterCore< ) }) }) + + return Promise.resolve() }) }) }, diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 7d543b1df07..fc7133b3bcf 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -3,6 +3,7 @@ import { encode, isNotFound, parseRedirect, + redirect, } from '@tanstack/router-core' import { fromCrossJSON, toJSONAsync } from 'seroval' import invariant from 'tiny-invariant' @@ -33,6 +34,44 @@ function hasOwnProperties(obj: object): boolean { } return false } + +function isResponseLike(value: unknown): value is Response { + if (value instanceof Response) { + return true + } + if (value === null || typeof value !== 'object') { + return false + } + if (!('status' in value) || !('headers' in value)) { + return false + } + const headers = (value as { headers?: { get?: unknown } }).headers + return typeof headers?.get === 'function' +} + +function parseRedirectFallback(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return undefined + } + const candidate = payload as { + statusCode?: unknown + code?: unknown + to?: unknown + href?: unknown + } + const statusCode = + typeof candidate.statusCode === 'number' + ? candidate.statusCode + : typeof candidate.code === 'number' + ? candidate.code + : undefined + const hasLocation = + typeof candidate.to === 'string' || typeof candidate.href === 'string' + if (statusCode === undefined || !hasLocation) { + return undefined + } + return redirect(candidate as any) +} // caller => // serverFnFetcher => // client => @@ -172,7 +211,7 @@ async function getResponse(fn: () => Promise) { try { response = await fn() // client => server => fn => server => client } catch (error) { - if (error instanceof Response) { + if (isResponseLike(error)) { response = error } else { console.log(error) @@ -240,6 +279,14 @@ async function getResponse(fn: () => Promise) { } invariant(result, 'expected result to be resolved') + const serializedRedirect = + parseRedirect(result) ?? parseRedirectFallback(result) + if (serializedRedirect) { + throw serializedRedirect + } + if (isNotFound(result)) { + throw result + } if (result instanceof Error) { throw result } @@ -251,9 +298,10 @@ async function getResponse(fn: () => Promise) { // if it's JSON if (contentType.includes('application/json')) { const jsonPayload = await response.json() - const redirect = parseRedirect(jsonPayload) - if (redirect) { - throw redirect + const redirectResult = + parseRedirect(jsonPayload) ?? parseRedirectFallback(jsonPayload) + if (redirectResult) { + throw redirectResult } if (isNotFound(jsonPayload)) { throw jsonPayload diff --git a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts index 533e4f634a2..9a72472da79 100644 --- a/packages/start-plugin-core/src/dev-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/dev-server-plugin/plugin.ts @@ -181,7 +181,7 @@ export function devServerPlugin({ console.error(e) try { viteDevServer.ssrFixStacktrace(e as Error) - } catch (_e) {} + } catch {} if ( webReq.headers.get('content-type')?.includes('application/json') diff --git a/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts index 576882600ce..777f171edb1 100644 --- a/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/injected-head-scripts-plugin.ts @@ -2,7 +2,7 @@ import { createRspackPlugin } from 'unplugin' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' export function createInjectedHeadScriptsPlugin() { - return createRspackPlugin(() => ({ + const pluginFactory = createRspackPlugin(() => ({ name: 'tanstack-start:injected-head-scripts', resolveId(id) { if (id === VIRTUAL_MODULES.injectedHeadScripts) { @@ -15,4 +15,5 @@ export function createInjectedHeadScriptsPlugin() { return `export const injectedHeadScripts = undefined` }, })) + return pluginFactory() } diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index ef151dae44f..79ab11ed90a 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -9,14 +9,17 @@ import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../constants' import { resolveEntry } from '../resolve-entries' import { parseStartConfig } from '../schema' import { createInjectedHeadScriptsPlugin } from './injected-head-scripts-plugin' -import { createServerFnResolverPlugin } from './start-compiler-plugin' +import { + SERVER_FN_MANIFEST_TEMP_FILE, + createServerFnManifestRspackPlugin, + createServerFnResolverPlugin, +} from './start-compiler-plugin' import { createStartManifestRspackPlugin, createStartManifestVirtualModulePlugin, } from './start-manifest-plugin' import { postServerBuildRsbuild } from './post-server-build' import { tanStackStartRouterRsbuild } from './start-router-plugin' -import type { ViteEnvironmentNames } from '../constants' import type { TanStackStartInputConfig } from '../schema' import type { GetConfigFn, @@ -38,6 +41,52 @@ function isFullUrl(str: string): boolean { } } +function buildRouteTreeModuleDeclaration(opts: { + generatedRouteTreePath: string + routerFilePath: string + startFilePath?: string + framework: string +}) { + const getImportPath = (absolutePath: string) => { + let relativePath = path.relative( + path.dirname(opts.generatedRouteTreePath), + absolutePath, + ) + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + return relativePath.split(path.sep).join('/') + } + + const result: Array = [ + `import type { getRouter } from '${getImportPath(opts.routerFilePath)}'`, + ] + if (opts.startFilePath) { + result.push( + `import type { startInstance } from '${getImportPath(opts.startFilePath)}'`, + ) + } else { + result.push( + `import type { createStart } from '@tanstack/${opts.framework}-start'`, + ) + } + result.push( + `declare module '@tanstack/${opts.framework}-start' { + interface Register { + ssr: true + router: Awaited>`, + ) + if (opts.startFilePath) { + result.push( + ` config: Awaited>`, + ) + } + result.push(` } +}`) + + return result.join('\n') +} + function defineReplaceEnv( key: TKey, value: TValue, @@ -104,7 +153,7 @@ function mergeEnvConfig(base: any, next: any) { function getOutputDirectory( root: string, config: any, - environmentName: ViteEnvironmentNames, + environmentName: string, directoryName: string, ) { const envDistPath = @@ -166,6 +215,8 @@ export function TanStackStartRsbuildPluginCore( let resolvedServerEntryPath: string | undefined let resolvedServerOutputDir: string | undefined let resolvedClientOutputDir: string | undefined + let routeTreeModuleDeclaration: string | null = null + let routeTreeGeneratedPath: string | null = null return [ { @@ -253,6 +304,8 @@ export function TanStackStartRsbuildPluginCore( startFilePath ?? corePluginOpts.defaultEntryPaths.start, [ENTRY_POINTS.router]: routerFilePath, } + const resolvedClientEntry = entryAliasConfiguration[ENTRY_POINTS.client] + const resolvedServerEntry = entryAliasConfiguration[ENTRY_POINTS.server] const clientOutputDir = getOutputDirectory( root, @@ -268,8 +321,16 @@ export function TanStackStartRsbuildPluginCore( 'server', ) resolvedServerOutputDir = serverOutputDir + const serverFnManifestTempPath = path.join( + serverOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) const isDev = api.context?.command === 'serve' + const defineViteEnv = (key: string, fallback = '') => { + const value = process.env[key] ?? fallback + return defineReplaceEnv(key, value) + } const defineValues = { ...defineReplaceEnv('TSS_SERVER_FN_BASE', TSS_SERVER_FN_BASE), ...defineReplaceEnv('TSS_CLIENT_OUTPUT_DIR', clientOutputDir), @@ -277,6 +338,7 @@ export function TanStackStartRsbuildPluginCore( 'TSS_ROUTER_BASEPATH', startConfig.router.basepath, ), + ...defineReplaceEnv('TSS_BUNDLER', 'rsbuild'), ...defineReplaceEnv('TSS_DEV_SERVER', isDev ? 'true' : 'false'), ...(isDev ? defineReplaceEnv( @@ -284,6 +346,8 @@ export function TanStackStartRsbuildPluginCore( startConfig.spa?.enabled ? 'true' : 'false', ) : {}), + ...defineViteEnv('VITE_NODE_ENV', 'production'), + ...defineViteEnv('VITE_EXTERNAL_PORT', ''), } const routerPlugins = tanStackStartRouterRsbuild( @@ -291,14 +355,63 @@ export function TanStackStartRsbuildPluginCore( getConfig, corePluginOpts, ) + const clientRouterConfig = { + ...startConfig.router, + routeTreeFileHeader: [], + routeTreeFileFooter: [], + plugins: [], + } + const generatedRouteTreePath = routerPlugins.getGeneratedRouteTreePath() + const routeTreeModuleDeclarationValue = buildRouteTreeModuleDeclaration({ + generatedRouteTreePath, + routerFilePath: resolvedStartConfig.routerFilePath, + startFilePath: resolvedStartConfig.startFilePath, + framework: corePluginOpts.framework, + }) + routeTreeModuleDeclaration = routeTreeModuleDeclarationValue + routeTreeGeneratedPath = generatedRouteTreePath + const registerDeclaration = `declare module '@tanstack/${corePluginOpts.framework}-start'` + if (fs.existsSync(generatedRouteTreePath)) { + const existingTree = fs.readFileSync( + generatedRouteTreePath, + 'utf-8', + ) + if (!existingTree.includes(registerDeclaration)) { + fs.rmSync(generatedRouteTreePath) + } + } const startCompilerLoaderPath = resolveLoaderPath( './start-compiler-loader', ) + const startStorageContextStubPath = resolveLoaderPath( + './start-storage-context-stub', + ) + const clientAliasOverrides = { + '@tanstack/start-storage-context': startStorageContextStubPath, + } + + const startClientCoreDistPath = path.resolve( + root, + 'packages/start-client-core/dist/esm', + ) + const startClientCoreDistPattern = + /[\\/]start-client-core[\\/]dist[\\/]esm[\\/]/ + const loaderIncludePaths: Array = [ + resolvedStartConfig.srcDirectory, + ] + if (fs.existsSync(startClientCoreDistPath)) { + loaderIncludePaths.push(startClientCoreDistPath) + } + loaderIncludePaths.push(startClientCoreDistPattern) - const loaderRule = (env: 'client' | 'server', envName: string) => ({ + const loaderRule = ( + env: 'client' | 'server', + envName: string, + manifestPath?: string, + ) => ({ test: /\.[cm]?[jt]sx?$/, - exclude: /node_modules/, + include: loaderIncludePaths, enforce: 'pre', use: [ { @@ -310,6 +423,7 @@ export function TanStackStartRsbuildPluginCore( framework: corePluginOpts.framework, providerEnvName: serverFnProviderEnv, generateFunctionId: startPluginOpts?.serverFns?.generateFunctionId, + manifestPath, }, }, ], @@ -319,8 +433,11 @@ export function TanStackStartRsbuildPluginCore( const clientEnvConfig = { source: { - entry: { index: ENTRY_POINTS.client }, - alias: entryAliasConfiguration, + entry: { index: resolvedClientEntry }, + alias: { + ...entryAliasConfiguration, + ...clientAliasOverrides, + }, define: defineValues, }, output: { @@ -345,12 +462,23 @@ export function TanStackStartRsbuildPluginCore( loaderRule('client', VITE_ENVIRONMENT_NAMES.client), { include: [routerPlugins.getGeneratedRouteTreePath()], - use: [{ loader: routerPlugins.routeTreeLoaderPath }], + use: [ + { + loader: routerPlugins.routeTreeLoaderPath, + options: { + routerConfig: clientRouterConfig, + root, + }, + }, + ], }, ], }, resolve: { - alias: entryAliasConfiguration, + alias: { + ...entryAliasConfiguration, + ...clientAliasOverrides, + }, }, }, }, @@ -358,7 +486,7 @@ export function TanStackStartRsbuildPluginCore( const serverEnvConfig = { source: { - entry: { server: ENTRY_POINTS.server }, + entry: { server: resolvedServerEntry }, alias: entryAliasConfiguration, define: defineValues, }, @@ -370,6 +498,17 @@ export function TanStackStartRsbuildPluginCore( }, tools: { rspack: { + experiments: { + outputModule: true, + }, + output: { + module: true, + chunkFormat: 'module', + chunkLoading: 'import', + library: { + type: 'module', + }, + }, plugins: [ routerPlugins.generatorPlugin, routerPlugins.serverCodeSplitter, @@ -377,6 +516,10 @@ export function TanStackStartRsbuildPluginCore( createServerFnResolverPlugin({ environmentName: VITE_ENVIRONMENT_NAMES.server, providerEnvName: serverFnProviderEnv, + serverOutputDir, + }), + createServerFnManifestRspackPlugin({ + serverOutputDir, }), createInjectedHeadScriptsPlugin(), createStartManifestVirtualModulePlugin({ @@ -384,7 +527,13 @@ export function TanStackStartRsbuildPluginCore( }), ], module: { - rules: [loaderRule('server', VITE_ENVIRONMENT_NAMES.server)], + rules: [ + loaderRule( + 'server', + VITE_ENVIRONMENT_NAMES.server, + serverFnManifestTempPath, + ), + ], }, resolve: { alias: entryAliasConfiguration, @@ -461,31 +610,63 @@ export function TanStackStartRsbuildPluginCore( } if (!ssrIsProvider) { + const providerOutputDir = getOutputDirectory( + root, + config, + serverFnProviderEnv, + serverFnProviderEnv, + ) + const providerManifestTempPath = path.join( + providerOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) nextConfig.environments = { ...nextConfig.environments, [serverFnProviderEnv]: mergeEnvConfig( config.environments?.[serverFnProviderEnv], { source: { - entry: { provider: ENTRY_POINTS.server }, + entry: { provider: resolvedServerEntry }, alias: entryAliasConfiguration, define: defineValues, }, output: { target: 'node', + distPath: { + root: path.relative(root, providerOutputDir), + }, }, tools: { rspack: { + experiments: { + outputModule: true, + }, + output: { + module: true, + chunkFormat: 'module', + chunkLoading: 'import', + library: { + type: 'module', + }, + }, plugins: [ createServerFnResolverPlugin({ environmentName: serverFnProviderEnv, providerEnvName: serverFnProviderEnv, + serverOutputDir: providerOutputDir, + }), + createServerFnManifestRspackPlugin({ + serverOutputDir: providerOutputDir, }), createInjectedHeadScriptsPlugin(), ], module: { rules: [ - loaderRule('server', serverFnProviderEnv), + loaderRule( + 'server', + serverFnProviderEnv, + providerManifestTempPath, + ), ], }, resolve: { @@ -556,6 +737,20 @@ export function TanStackStartRsbuildPluginCore( clientOutputDir, serverOutputDir, }) + if (routeTreeGeneratedPath && routeTreeModuleDeclaration) { + if (fs.existsSync(routeTreeGeneratedPath)) { + const existingTree = fs.readFileSync( + routeTreeGeneratedPath, + 'utf-8', + ) + if (!existingTree.includes(routeTreeModuleDeclaration)) { + fs.appendFileSync( + routeTreeGeneratedPath, + `\n\n${routeTreeModuleDeclaration}\n`, + ) + } + } + } }) }, }, diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts index d37cae68ded..36ad41d2682 100644 --- a/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/route-tree-loader.ts @@ -2,7 +2,14 @@ import { getClientRouteTreeContent } from './route-tree-state' export default function routeTreeLoader(this: any) { const callback = this.async() - getClientRouteTreeContent() + const options = this.getOptions() as { + routerConfig?: any + root?: string + } + getClientRouteTreeContent({ + routerConfig: options.routerConfig, + root: options.root, + }) .then((code) => callback(null, code)) .catch((error) => callback(error)) } diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts index 5809b8d765c..18a09e5042e 100644 --- a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts +++ b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts @@ -1,5 +1,6 @@ +import { Generator } from '@tanstack/router-generator' import { pruneServerOnlySubtrees } from '../start-router-plugin/pruneServerOnlySubtrees' -import type { Generator } from '@tanstack/router-generator' +import type { Config } from '@tanstack/router-generator' let generatorInstance: Generator | null = null @@ -7,11 +8,22 @@ export function setGeneratorInstance(generator: Generator) { generatorInstance = generator } -export async function getClientRouteTreeContent() { - if (!generatorInstance) { - throw new Error('Generator instance not initialized for route tree loader') +export async function getClientRouteTreeContent(options?: { + routerConfig?: Config + root?: string +}) { + let generator = generatorInstance + if (!generator) { + if (!options?.routerConfig || !options.root) { + throw new Error('Generator instance not initialized for route tree loader') + } + generator = new Generator({ + config: options.routerConfig, + root: options.root, + }) + await generator.run() } - const crawlingResult = await generatorInstance.getCrawlingResult() + const crawlingResult = await generator.getCrawlingResult() if (!crawlingResult) { throw new Error('Crawling result not available') } @@ -20,7 +32,7 @@ export async function getClientRouteTreeContent() { ...crawlingResult.acc, ...prunedAcc, } - const buildResult = generatorInstance.buildRouteTree({ + const buildResult = generator.buildRouteTree({ ...crawlingResult, acc, config: { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts index 76dc6e81d1f..f2c442755ec 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -1,4 +1,5 @@ -import { promises as fsp } from 'node:fs' +import fs, { promises as fsp } from 'node:fs' +import { createRequire } from 'node:module' import path from 'node:path' import { KindDetectionPatterns, @@ -21,17 +22,23 @@ type LoaderOptions = { framework: CompileStartFrameworkOptions providerEnvName: string generateFunctionId?: GenerateFunctionIdFnOptional + manifestPath?: string } const compilers = new Map() const serverFnsById: Record = {} +const require = createRequire(import.meta.url) +const appendServerFnsToManifest = ( + manifestPath: string, + data: Record, +) => { + if (!manifestPath || Object.keys(data).length === 0) return + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }) + fs.appendFileSync(manifestPath, `${JSON.stringify(data)}\n`) +} export const getServerFnsById = () => serverFnsById -const onServerFnsById = (d: Record) => { - Object.assign(serverFnsById, d) -} - // Derive transform code filter from KindDetectionPatterns (single source of truth) function getTransformCodeFilterForEnv(env: 'client' | 'server'): Array { const validKinds = LookupKindsPerEnv[env] @@ -112,17 +119,36 @@ async function resolveId( const baseContext = importer ? path.dirname(cleanId(importer)) : loaderContext.context + const resolveContext = + source.startsWith('.') || source.startsWith('/') + ? baseContext + : loaderContext.rootContext || baseContext return new Promise((resolve) => { - loaderContext.resolve( - baseContext, + const resolver = + loaderContext.getResolve?.({ + conditionNames: ['import', 'module', 'default'], + }) ?? loaderContext.resolve + + resolver( + resolveContext, source, (err: Error | null, result?: string) => { - if (err || !result) { - resolve(null) - return - } - resolve(cleanId(result)) + if (!err && result) { + resolve(cleanId(result)) + return + } + try { + const resolved = require.resolve(source, { + paths: [ + baseContext, + loaderContext.rootContext || loaderContext.context, + ].filter(Boolean), + }) + resolve(cleanId(resolved)) + } catch { + resolve(null) + } }, ) }) @@ -162,8 +188,10 @@ export default function startCompilerLoader( const root = options.root || process.cwd() const framework = options.framework const providerEnvName = options.providerEnvName + const manifestPath = options.manifestPath - if (!shouldTransformCode(code, env)) { + const shouldTransform = shouldTransformCode(code, env) + if (!shouldTransform) { callback(null, code, map) return } @@ -174,6 +202,14 @@ export default function startCompilerLoader( this.mode === 'production' || this._compiler?.options?.mode === 'production' ? 'build' : 'dev' + const shouldPersistManifest = Boolean(manifestPath) && mode === 'build' + const onServerFnsById = (d: Record) => { + Object.assign(serverFnsById, d) + if (shouldPersistManifest && manifestPath) { + appendServerFnsToManifest(manifestPath, d) + } + } + compiler = new StartCompiler({ env, envName, @@ -194,10 +230,17 @@ export default function startCompilerLoader( } const detectedKinds = detectKindsInCode(code, env) - + const resourceQuery = + typeof this.resourceQuery === 'string' ? this.resourceQuery : '' + const baseResource = + typeof this.resource === 'string' ? this.resource : this.resourcePath + const resourceId = + resourceQuery && !baseResource.includes(resourceQuery) + ? `${baseResource}${resourceQuery}` + : baseResource compiler .compile({ - id: cleanId(this.resourcePath), + id: resourceId, code, detectedKinds, }) diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts index a4b5aa43907..20d773acb06 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -1,9 +1,76 @@ +import { promises as fsp } from 'node:fs' +import path from 'node:path' import { createRspackPlugin } from 'unplugin' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { getServerFnsById } from './start-compiler-loader' import type { ServerFn } from '../start-compiler-plugin/types' +const SERVER_FN_MANIFEST_FILE = 'tanstack-start-server-fn-manifest.json' +export const SERVER_FN_MANIFEST_TEMP_FILE = + 'tanstack-start-server-fn-manifest.temp.jsonl' + +const readTempManifest = async (manifestPath: string) => { + try { + const raw = await fsp.readFile(manifestPath, 'utf-8') + return raw + .split('\n') + .filter(Boolean) + .reduce>((acc, line) => { + try { + const parsed = JSON.parse(line) as Record + Object.assign(acc, parsed) + } catch { + return acc + } + return acc + }, {}) + } catch { + return {} + } +} + +const normalizeIdentifier = (value: unknown) => + `${value ?? ''}`.replace(/\\/g, '/') + +const isJsFile = (file: unknown): file is string => + typeof file === 'string' && file.endsWith('.js') + +type CompilationModule = { + id?: string | number + [key: string]: any +} + +const getChunkFiles = (chunk: any) => { + const files = [ + ...Array.from(chunk?.files ?? []), + ...Array.from(chunk?.auxiliaryFiles ?? []), + ] + return files.filter((file) => typeof file === 'string') +} + +const getModuleIdentifiers = (module: any) => + [ + module.identifier, + module.name, + module.nameForCondition, + module.resource, + module.moduleIdentifier, + ] + .filter(Boolean) + .map((value) => normalizeIdentifier(value)) + +const getCompilationModuleIdentifiers = (module: any) => + [ + module.resource, + module.userRequest, + module.request, + typeof module.identifier === 'function' ? module.identifier() : module.identifier, + module.debugId, + ] + .filter(Boolean) + .map((value) => normalizeIdentifier(value)) + function generateManifestModule( serverFnsById: Record, includeClientReferencedCheck: boolean, @@ -48,11 +115,128 @@ ${clientReferencedCheck} throw new Error('Server function module not resolved for ' + id) } - const action = fnModule[serverFnInfo.functionName] + let action = fnModule[serverFnInfo.functionName] + if (action?.serverFnMeta?.id && action.serverFnMeta.id !== id) { + action = undefined + } + if (!action) { + const fallbackAction = Object.values(fnModule).find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (fallbackAction) { + action = fallbackAction + } + } + if (Array.isArray(globalThis.__tssServerFnHandlers)) { + const globalMatch = globalThis.__tssServerFnHandlers.find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (globalMatch && (!action || action.__executeServer)) { + action = globalMatch + } + } + + if (!action) { + console.info('serverFnInfo', serverFnInfo) + console.info('fnModule', fnModule) + + throw new Error( + \`Server function module export not resolved for serverFn ID: \${id}\`, + ) + } + return action + } + ` +} + +function generateManifestModuleFromFile( + manifestPath: string, + includeClientReferencedCheck: boolean, +): string { + const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id' + const clientReferencedCheck = includeClientReferencedCheck + ? ` + if (opts?.fromClient && !serverFnInfo.isClientReferenced) { + throw new Error('Server function not accessible from client: ' + id) + } +` + : '' + + return ` + import fs from 'node:fs' + import { pathToFileURL } from 'node:url' + + let cached + const getManifest = () => { + if (cached) return cached + try { + const raw = fs.readFileSync(${JSON.stringify(manifestPath)}, 'utf-8') + cached = JSON.parse(raw) + return cached + } catch (error) { + cached = {} + return cached + } + } + + export async function getServerFnById(${getServerFnByIdParams}) { + const manifest = getManifest() + const serverFnInfo = manifest[id] + if (!serverFnInfo) { + throw new Error('Server function info not found for ' + id) + } +${clientReferencedCheck} + let fnModule + if (typeof __webpack_require__ === 'function' && serverFnInfo.importerModuleId != null) { + const chunkIds = Array.isArray(serverFnInfo.importerChunkIds) + ? serverFnInfo.importerChunkIds + : [] + if (chunkIds.length > 0 && typeof __webpack_require__.e === 'function') { + await Promise.all(chunkIds.map((chunkId) => __webpack_require__.e(chunkId))) + } + fnModule = __webpack_require__(serverFnInfo.importerModuleId) + } else { + const importerPath = serverFnInfo.importerPath ?? serverFnInfo.extractedFilename + fnModule = await import(/* webpackIgnore: true */ pathToFileURL(importerPath).href) + } + + if (!fnModule) { + console.info('serverFnInfo', serverFnInfo) + throw new Error('Server function module not resolved for ' + id) + } + + let action = fnModule[serverFnInfo.functionName] + if (action?.serverFnMeta?.id && action.serverFnMeta.id !== id) { + action = undefined + } + if (!action) { + const fallbackAction = Object.values(fnModule).find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (fallbackAction) { + action = fallbackAction + } + } + if (Array.isArray(globalThis.__tssServerFnHandlers)) { + const globalMatch = globalThis.__tssServerFnHandlers.find( + (candidate) => + candidate?.serverFnMeta?.id && + candidate.serverFnMeta.id === id, + ) + if (globalMatch && (!action || action.__executeServer)) { + action = globalMatch + } + } if (!action) { - console.info('serverFnInfo', serverFnInfo) - console.info('fnModule', fnModule) + console.info('serverFnInfo', serverFnInfo) + console.info('fnModule', fnModule) throw new Error( \`Server function module export not resolved for serverFn ID: \${id}\`, @@ -63,14 +247,399 @@ ${clientReferencedCheck} ` } +export function createServerFnManifestRspackPlugin(opts: { + serverOutputDir: string +}) { + const tempManifestPath = path.join( + opts.serverOutputDir, + SERVER_FN_MANIFEST_TEMP_FILE, + ) + + return { + apply(compiler: any) { + compiler.hooks.beforeRun.tapPromise( + 'tanstack-start:server-fn-manifest', + async () => { + await fsp.rm(tempManifestPath, { force: true }) + }, + ) + compiler.hooks.afterEmit.tapPromise( + 'tanstack-start:server-fn-manifest', + async (compilation: any) => { + const serverFnsById = getServerFnsById() + const fileServerFnsById = await readTempManifest(tempManifestPath) + const mergedServerFnsById = { + ...serverFnsById, + ...fileServerFnsById, + } + const stats = compilation?.getStats?.() + const statsJson = stats?.toJson?.({ + all: false, + assets: true, + chunks: true, + chunkModules: true, + moduleAssets: true, + modules: true, + }) + const chunks = statsJson?.chunks ?? [] + const chunksById = new Map( + chunks.map((chunk: any) => [chunk.id, getChunkFiles(chunk)]), + ) + const chunkModuleEntries = chunks.flatMap((chunk: any) => { + const chunkFiles = getChunkFiles(chunk) + const chunkModules = chunk.modules ?? [] + return chunkModules.flatMap((module: any) => + getModuleIdentifiers(module).map((identifier) => ({ + identifier, + files: chunkFiles, + })), + ) + }) + const modules = statsJson?.modules ?? [] + const compilationModules: Array = Array.from( + compilation?.modules ?? [], + ) + const chunkGraph = compilation?.chunkGraph + const moduleGraph = compilation?.moduleGraph + const compilationEntries = compilationModules.flatMap((module: any) => { + const identifiers = getCompilationModuleIdentifiers(module) + if (identifiers.length === 0) return [] + const chunkFiles = chunkGraph + ? Array.from(chunkGraph.getModuleChunksIterable(module) ?? []).flatMap( + (chunk: any) => getChunkFiles(chunk), + ) + : [] + return identifiers.map((identifier) => ({ + identifier, + files: chunkFiles, + })) + }) + const assetFiles = (statsJson?.assets ?? []) + .map((asset: any) => asset.name ?? asset) + .filter((name: string) => typeof name === 'string') + .filter((name: string) => name.endsWith('.js')) + const getAssetContent = async (assetName: string) => { + const assetFromCompilation = + (typeof compilation?.getAsset === 'function' + ? compilation.getAsset(assetName)?.source + : undefined) ?? + compilation?.assets?.[assetName] ?? + (typeof compilation?.assets?.get === 'function' + ? compilation.assets.get(assetName) + : undefined) + const sourceValue = + assetFromCompilation && typeof assetFromCompilation.source === 'function' + ? assetFromCompilation.source() + : assetFromCompilation + if (typeof sourceValue === 'string') return sourceValue + if (Buffer.isBuffer(sourceValue)) { + return sourceValue.toString('utf-8') + } + if (sourceValue && typeof sourceValue.toString === 'function') { + return sourceValue.toString() + } + try { + const assetPath = path.join(opts.serverOutputDir, assetName) + return await fsp.readFile(assetPath, 'utf-8') + } catch { + return undefined + } + } + const findAssetMatch = async (searchTokens: Array) => { + for (const assetName of assetFiles) { + const content = await getAssetContent(assetName) + if (!content) continue + if (searchTokens.some((token) => content.includes(token))) { + return assetName + } + } + return undefined + } + const manifestWithImporters: Record = {} + for (const [id, info] of Object.entries(mergedServerFnsById)) { + const normalizedExtracted = info.extractedFilename.replace(/\\/g, '/') + const normalizedFilename = info.filename.replace(/\\/g, '/') + const searchTokens = [ + normalizedExtracted, + normalizedFilename, + path.basename(normalizedExtracted), + path.basename(normalizedFilename), + id, + info.functionName, + ].filter(Boolean) + const matchedModuleByExtracted = modules.find((module: any) => + getModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedExtracted), + ), + ) + const matchedModuleByFilename = modules.find((module: any) => + getModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedFilename), + ), + ) + const matchedModule = matchedModuleByExtracted ?? matchedModuleByFilename + const chunkIds = matchedModule?.chunks ?? matchedModule?.chunkIds ?? [] + const statsModuleId = matchedModule?.id ?? matchedModule?.moduleId + const chunkFiles = chunkIds.flatMap((chunkId: any) => { + return chunksById.get(chunkId) ?? [] + }) + const moduleAssets = Array.isArray(matchedModule?.assets) + ? matchedModule.assets + : matchedModule?.assets + ? Object.keys(matchedModule.assets) + : [] + const matchedCompilationModuleByExtracted = compilationModules.find( + (module) => + getCompilationModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedExtracted), + ), + ) + const matchedCompilationModuleByFilename = compilationModules.find( + (module) => + getCompilationModuleIdentifiers(module).some((identifier) => + identifier.includes(normalizedFilename), + ), + ) + const matchedCompilationModule = + matchedCompilationModuleByExtracted ?? matchedCompilationModuleByFilename + const exportsInfo = matchedCompilationModule + ? moduleGraph?.getExportsInfo?.(matchedCompilationModule) + : null + const providedExports = + exportsInfo?.getProvidedExports?.() ?? + (Array.isArray(exportsInfo?.exports) + ? exportsInfo.exports + .map((exportInfo: any) => exportInfo.name) + .filter(Boolean) + : []) + const resolvedFunctionName = + Array.isArray(providedExports) && providedExports.length === 1 + ? providedExports[0] + : undefined + const compilationChunkIds = + matchedCompilationModule && chunkGraph + ? Array.from(chunkGraph.getModuleChunksIterable(matchedCompilationModule)) + .map((chunk: any) => chunk.id) + .filter((chunkId: any) => chunkId !== undefined && chunkId !== null) + : [] + const compilationModuleId = + matchedCompilationModule?.id ?? + (matchedCompilationModule && chunkGraph?.getModuleId + ? chunkGraph.getModuleId(matchedCompilationModule) + : undefined) + const compilationFiles = + compilationEntries.find((entry) => { + return entry.identifier.includes(normalizedExtracted) + })?.files ?? + compilationEntries.find((entry) => { + return entry.identifier.includes(normalizedFilename) + })?.files ?? + [] + const chunkModuleFiles = + chunkFiles.length > 0 + ? chunkFiles + : (chunkModuleEntries.find((entry: any) => { + return ( + entry.identifier.includes(normalizedExtracted) || + entry.identifier.includes(normalizedFilename) + ) + })?.files ?? []) + const jsFile = chunkFiles.find(isJsFile) + const fallbackJsFile = + jsFile ?? + chunkModuleFiles.find(isJsFile) ?? + compilationFiles.find(isJsFile) ?? + moduleAssets.find(isJsFile) + let importerPath = jsFile + ? path.join(opts.serverOutputDir, jsFile) + : fallbackJsFile + ? path.join(opts.serverOutputDir, fallbackJsFile) + : undefined + if (!importerPath) { + const assetMatch = await findAssetMatch(searchTokens) + importerPath = assetMatch + ? path.join(opts.serverOutputDir, assetMatch) + : undefined + } + + manifestWithImporters[id] = { + ...info, + functionName: resolvedFunctionName ?? info.functionName, + importerPath, + importerChunkIds: + chunkIds.length > 0 + ? chunkIds + : compilationChunkIds.length > 0 + ? compilationChunkIds + : undefined, + importerModuleId: statsModuleId ?? (compilationModuleId ?? undefined), + } + } + const extractExportName = ( + content: string, + moduleId: string | number, + functionId: string, + ) => { + const marker = `${moduleId}:function` + const startIndex = content.indexOf(marker) + const scope = + startIndex === -1 + ? content + : content.slice(startIndex, startIndex + 4000) + const idIndex = scope.indexOf(functionId) + if (idIndex === -1) return undefined + const beforeId = scope.slice(Math.max(0, idIndex - 300), idIndex) + const assignmentMatches = Array.from( + beforeId.matchAll(/([A-Za-z_$][\w$]*)=(?!>)/g), + ) + const handlerVar = + assignmentMatches[assignmentMatches.length - 1]?.[1] + if (!handlerVar) return undefined + const exportMatch = scope.match( + new RegExp(`([A-Za-z_$][\\\\w$]*):\\\\(\\\\)=>${handlerVar}`), + ) + return exportMatch?.[1] + } + const findExportName = async ( + moduleId: string | number, + functionId: string, + preferredAssetName?: string, + ) => { + if (preferredAssetName) { + const content = await getAssetContent(preferredAssetName) + const resolved = content + ? extractExportName(content, moduleId, functionId) + : undefined + if (resolved) return resolved + } + for (const assetName of assetFiles) { + if (assetName === preferredAssetName) continue + const content = await getAssetContent(assetName) + if (!content) continue + const resolved = extractExportName(content, moduleId, functionId) + if (resolved) return resolved + } + return undefined + } + const extractModuleIdFromContent = ( + content: string, + functionId: string, + ) => { + const idIndex = content.indexOf(functionId) + if (idIndex === -1) return undefined + const beforeId = content.slice(Math.max(0, idIndex - 1500), idIndex) + const matches = Array.from( + beforeId.matchAll( + /(?:^|[,{])\s*([0-9]+)\s*:\s*(?:function|\()/g, + ), + ) + const moduleId = matches[matches.length - 1]?.[1] + if (!moduleId) return undefined + return Number.isNaN(Number(moduleId)) ? moduleId : Number(moduleId) + } + const parseChunkIdFromAssetName = (assetName: string) => { + const base = path.basename(assetName, path.extname(assetName)) + if (!base) return undefined + return /^\d+$/.test(base) ? Number(base) : undefined + } + const findModuleIdByFunctionId = async ( + functionId: string, + preferredAssetName?: string, + ) => { + if (preferredAssetName) { + const content = await getAssetContent(preferredAssetName) + if (content) { + const moduleId = extractModuleIdFromContent(content, functionId) + if (moduleId !== undefined) { + return { + moduleId, + assetName: preferredAssetName, + } + } + } + } + for (const assetName of assetFiles) { + if (assetName === preferredAssetName) continue + const content = await getAssetContent(assetName) + if (!content) continue + const moduleId = extractModuleIdFromContent(content, functionId) + if (moduleId !== undefined) { + return { moduleId, assetName } + } + } + return undefined + } + for (const info of Object.values(manifestWithImporters)) { + const importerAssetName = info.importerPath + ? path + .relative(opts.serverOutputDir, info.importerPath) + .replace(/\\/g, '/') + : undefined + if (info.importerModuleId == null) { + const moduleMatch = await findModuleIdByFunctionId( + info.functionId, + importerAssetName, + ) + if (moduleMatch) { + info.importerModuleId = moduleMatch.moduleId + if (!info.importerPath) { + info.importerPath = path.join( + opts.serverOutputDir, + moduleMatch.assetName, + ) + } + if (!info.importerChunkIds) { + const chunkId = parseChunkIdFromAssetName( + moduleMatch.assetName, + ) + if (chunkId !== undefined) { + info.importerChunkIds = [chunkId] + } + } + } + } + if (info.importerModuleId == null) continue + const resolvedName = await findExportName( + info.importerModuleId, + info.functionId, + importerAssetName, + ) + if (resolvedName) { + info.functionName = resolvedName + } + } + const manifestPath = path.join( + opts.serverOutputDir, + SERVER_FN_MANIFEST_FILE, + ) + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }) + await fsp.writeFile( + manifestPath, + JSON.stringify(manifestWithImporters), + 'utf-8', + ) + await fsp.rm(tempManifestPath, { force: true }) + }, + ) + }, + } +} + export function createServerFnResolverPlugin(opts: { environmentName: string providerEnvName: string + serverOutputDir?: string }) { const ssrIsProvider = opts.providerEnvName === VITE_ENVIRONMENT_NAMES.server const includeClientReferencedCheck = !ssrIsProvider + const manifestPath = opts.serverOutputDir + ? path.join(opts.serverOutputDir, SERVER_FN_MANIFEST_FILE) + : null + const tempManifestPath = opts.serverOutputDir + ? path.join(opts.serverOutputDir, SERVER_FN_MANIFEST_TEMP_FILE) + : null - return createRspackPlugin(() => ({ + const pluginFactory = createRspackPlugin(() => ({ name: `tanstack-start-core:server-fn-resolver:${opts.environmentName}`, resolveId(id) { if (id === VIRTUAL_MODULES.serverFnResolver) { @@ -78,13 +647,33 @@ export function createServerFnResolverPlugin(opts: { } return null }, - load(id) { + async load(id) { if (id !== VIRTUAL_MODULES.serverFnResolver) return null if (opts.environmentName !== opts.providerEnvName) { return `export { getServerFnById } from '@tanstack/start-server-core/server-fn-ssr-caller'` } const serverFnsById = getServerFnsById() + const fileServerFnsById = tempManifestPath + ? await readTempManifest(tempManifestPath) + : {} + const mergedServerFnsById = { + ...serverFnsById, + ...fileServerFnsById, + } + if (Object.keys(mergedServerFnsById).length > 0) { + return generateManifestModule( + mergedServerFnsById, + includeClientReferencedCheck, + ) + } + if (manifestPath) { + return generateManifestModuleFromFile( + manifestPath, + includeClientReferencedCheck, + ) + } return generateManifestModule(serverFnsById, includeClientReferencedCheck) }, })) + return pluginFactory() } diff --git a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts index 15bf0246de0..74eff928c99 100644 --- a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts @@ -14,16 +14,20 @@ type StatsAsset = string | { name?: string } type StatsChunk = { files?: Array auxiliaryFiles?: Array - modules?: Array<{ name?: string; identifier?: string }> + modules?: Array + names?: Array + entry?: boolean + initial?: boolean +} + +type StatsModule = { + name?: string + identifier?: string + nameForCondition?: string } type StatsJson = { - entrypoints?: Record< - string, - { - assets?: Array - } - > + entrypoints?: Record }> chunks?: Array } @@ -55,26 +59,105 @@ function createCssTags( })) } +function createEntryScriptTags( + basePath: string, + assets: Array, +): Array { + return assets.map((asset) => ({ + tag: 'script', + attrs: { + type: 'module', + async: true, + src: joinURL(basePath, asset), + }, + })) +} + function unique(items: Array) { return Array.from(new Set(items)) } -function getStatsEntryAssets(statsJson: StatsJson): Array { +function getRouteModuleFilePath(module: StatsModule): string | undefined { + const moduleId = module.identifier ?? module.name ?? '' + if (!moduleId.includes(tsrSplit)) return undefined + + if (module.nameForCondition) { + return module.nameForCondition + } + + const resource = moduleId.split('!').pop() ?? moduleId + const cleanedResource = resource.startsWith('module|') + ? resource.slice('module|'.length) + : resource + const [resourcePath, queryString] = cleanedResource.split('?') + if (!queryString?.includes(tsrSplit)) return undefined + + return resourcePath +} + +function getStatsEntryPointName(statsJson: StatsJson): string | undefined { const entrypoints = statsJson.entrypoints ?? {} - const entrypoint = - entrypoints['index'] ?? - entrypoints['main'] ?? - entrypoints[Object.keys(entrypoints)[0] ?? ''] + if (entrypoints['index']) return 'index' + if (entrypoints['main']) return 'main' + return Object.keys(entrypoints)[0] +} - if (!entrypoint?.assets) return [] +function getStatsEntryAssets(statsJson: StatsJson): { + entrypointName?: string + assets: Array +} { + const entrypoints = statsJson.entrypoints ?? {} + const entrypointName = getStatsEntryPointName(statsJson) + const entrypoint = entrypointName ? entrypoints[entrypointName] : undefined + if (!entrypoint?.assets) { + return { entrypointName, assets: [] } + } + + return { + entrypointName, + assets: unique( + entrypoint.assets + .map(getAssetName) + .filter((asset): asset is string => Boolean(asset)), + ), + } +} + +function getEntryChunkAssets( + statsJson: StatsJson, + entrypointName?: string, +): Array { + if (!entrypointName) return [] + const chunks = statsJson.chunks ?? [] + const entryChunks = chunks.filter((chunk) => { + if (chunk.entry) return true + const names = chunk.names ?? [] + return names.includes(entrypointName) + }) return unique( - entrypoint.assets - .map(getAssetName) - .filter((asset): asset is string => Boolean(asset)), + entryChunks.flatMap((chunk) => chunk.files ?? []).filter(isJsAsset), ) } +function pickEntryAsset( + assets: Array, + entrypointName?: string, +): string | undefined { + if (assets.length === 0) return undefined + if (entrypointName) { + const match = assets.find((asset) => { + const baseName = path.posix.basename(asset) + return ( + baseName === `${entrypointName}.js` || + baseName.startsWith(`${entrypointName}.`) + ) + }) + if (match) return match + } + return assets[assets.length - 1] +} + function buildStartManifest({ statsJson, basePath, @@ -82,11 +165,17 @@ function buildStartManifest({ statsJson: StatsJson basePath: string }): Manifest & { clientEntry: string } { - const entryAssets = getStatsEntryAssets(statsJson) + const { entrypointName, assets: entryAssets } = + getStatsEntryAssets(statsJson) const entryJsAssets = unique(entryAssets.filter(isJsAsset)) const entryCssAssets = unique(entryAssets.filter(isCssAsset)) - const entryFile = entryJsAssets[0] + const entryFile = + pickEntryAsset(entryJsAssets, entrypointName) ?? + pickEntryAsset( + getEntryChunkAssets(statsJson, entrypointName), + entrypointName, + ) if (!entryFile) { throw new Error('No client entry file found in rsbuild stats') } @@ -98,17 +187,14 @@ function buildStartManifest({ for (const chunk of statsJson.chunks ?? []) { const modules = chunk.modules ?? [] for (const mod of modules) { - const id = mod.identifier ?? mod.name ?? '' - if (!id.includes(tsrSplit)) continue - const [fileId, query] = id.split('?') - if (!fileId || !query) continue - const searchParams = new URLSearchParams(query) - if (!searchParams.has(tsrSplit)) continue - const existingChunks = routeChunks[fileId] + const filePath = getRouteModuleFilePath(mod) + if (!filePath) continue + const normalizedPath = path.normalize(filePath) + const existingChunks = routeChunks[normalizedPath] if (existingChunks) { existingChunks.push(chunk) } else { - routeChunks[fileId] = [chunk] + routeChunks[normalizedPath] = [chunk] } } } @@ -116,7 +202,7 @@ function buildStartManifest({ const manifest: Manifest = { routes: {} } Object.entries(routeTreeRoutes).forEach(([routeId, route]) => { - const chunks = routeChunks[route.filePath] + const chunks = routeChunks[path.normalize(route.filePath)] if (!chunks?.length) { manifest.routes[routeId] = {} return @@ -140,11 +226,16 @@ function buildStartManifest({ } }) + const entryScriptAssets = entryJsAssets.filter( + (asset) => asset !== entryFile, + ) + manifest.routes[rootRouteId] = { ...(manifest.routes[rootRouteId] ?? {}), preloads: entryJsAssets.map((asset) => joinURL(basePath, asset)), assets: [ ...createCssTags(basePath, entryCssAssets), + ...createEntryScriptTags(basePath, entryScriptAssets), ...(manifest.routes[rootRouteId]?.assets ?? []), ], } @@ -168,9 +259,9 @@ export function createStartManifestRspackPlugin(opts: { all: false, entrypoints: true, chunks: true, + chunkModules: true, modules: true, }) - const manifest = buildStartManifest({ statsJson, basePath: opts.basePath, @@ -196,7 +287,7 @@ export function createStartManifestVirtualModulePlugin(opts: { clientOutputDir: string }) { const manifestPath = path.join(opts.clientOutputDir, START_MANIFEST_FILE) - return createRspackPlugin(() => ({ + const pluginFactory = createRspackPlugin(() => ({ name: 'tanstack-start:manifest:virtual', resolveId(id) { if (id === VIRTUAL_MODULES.startManifest) { @@ -223,4 +314,5 @@ export const tsrStartManifest = () => { ` }, })) + return pluginFactory() } 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 6aae1d1415a..fff31f162e7 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -132,7 +132,7 @@ export function tanStackStartRouterRsbuild( return { ...routerConfig, target: corePluginOpts.framework, - routeTreeFileFooter: getRouteTreeFileFooter, + routeTreeFileFooter: getRouteTreeFileFooter(), plugins, } }) diff --git a/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts b/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts new file mode 100644 index 00000000000..a1662a4364e --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/start-storage-context-stub.ts @@ -0,0 +1,12 @@ +export function getStartContext() { + return { + startOptions: undefined, + } +} + +export async function runWithStartContext( + _context: unknown, + fn: () => T | Promise, +) { + return fn() +} diff --git a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts index 36b9f429fba..cb1a257eaa1 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/compiler.ts @@ -7,7 +7,7 @@ import { generateFromAst, parseAst, } from '@tanstack/router-utils' -import babel from '@babel/core' +import * as babel from '@babel/core' import { handleCreateServerFn } from './handleCreateServerFn' import { handleCreateMiddleware } from './handleCreateMiddleware' import { handleCreateIsomorphicFn } from './handleCreateIsomorphicFn' diff --git a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts index ddad1cf0611..284a3d32f32 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/handleCreateServerFn.ts @@ -1,5 +1,5 @@ import * as t from '@babel/types' -import babel from '@babel/core' +import * as babel from '@babel/core' import path from 'pathe' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { cleanId, codeFrameError, stripMethodCall } from './utils' @@ -218,8 +218,11 @@ export function handleCreateServerFn( const exportNames = new Set() const serverFnsById: Record = {} + let providerImportPath: string | null = null + const providerImportNames = new Set() const [baseFilename] = context.id.split('?') as [string] + const baseDir = path.dirname(baseFilename) const extractedFilename = `${baseFilename}?${TSS_SERVERFN_SPLIT_PARAM}` const relativeFilename = path.relative(context.root, baseFilename) const knownFns = context.getKnownServerFns() @@ -309,6 +312,20 @@ export function handleCreateServerFn( // to avoid duplicates - provider files process the same functions if (!isProviderFile) { + if (!envConfig.isClientEnvironment && envConfig.ssrIsProvider) { + const [canonicalBase] = canonicalExtractedFilename.split('?') as [ + string, + ] + let relativeImportPath = path.relative(baseDir, canonicalBase) + if (!relativeImportPath.startsWith('.')) { + relativeImportPath = `./${relativeImportPath}` + } + relativeImportPath = relativeImportPath.split(path.sep).join('/') + providerImportPath = `${relativeImportPath}?${TSS_SERVERFN_SPLIT_PARAM}` + } + if (providerImportPath) { + providerImportNames.add(functionName) + } serverFnsById[functionId] = { functionName, functionId, @@ -452,12 +469,65 @@ export function handleCreateServerFn( context.onServerFnsById(serverFnsById) } - // Add runtime import using cached AST node const runtimeCode = getCachedRuntimeCode( context.framework, envConfig.runtimeCodeType, ) - context.ast.program.body.unshift(t.cloneNode(runtimeCode)) + + const importStatements: Array = [t.cloneNode(runtimeCode)] + if (providerImportPath && providerImportNames.size > 0) { + importStatements.push( + t.importDeclaration( + Array.from(providerImportNames).map((name) => + t.importSpecifier(t.identifier(name), t.identifier(name)), + ), + t.stringLiteral(providerImportPath), + ), + ) + } + + context.ast.program.body.unshift(...importStatements) + + if (providerImportPath && providerImportNames.size > 0) { + const globalHandlers = t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ) + const initHandlers = t.expressionStatement( + t.assignmentExpression( + '=', + t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ), + t.logicalExpression( + '||', + t.memberExpression( + t.identifier('globalThis'), + t.identifier('__tssServerFnHandlers'), + ), + t.arrayExpression([]), + ), + ), + ) + const pushHandlers = t.expressionStatement( + t.callExpression( + t.memberExpression(globalHandlers, t.identifier('push')), + Array.from(providerImportNames).map((name) => t.identifier(name)), + ), + ) + const lastImportIndex = context.ast.program.body.reduce( + (lastIndex, node, index) => + t.isImportDeclaration(node) ? index : lastIndex, + -1, + ) + context.ast.program.body.splice( + lastImportIndex + 1, + 0, + initHandlers, + pushHandlers, + ) + } } /** diff --git a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts index 973b2b192b6..dc86f545dd2 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/plugin.ts @@ -193,7 +193,6 @@ export function startCompilerPlugin( } let root = process.cwd() - let command: 'build' | 'serve' = 'build' const resolvedResolverVirtualImportId = resolveViteId( VIRTUAL_MODULES.serverFnResolver, @@ -227,7 +226,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, transform: { filter: { @@ -373,7 +371,6 @@ export function startCompilerPlugin( }, configResolved(config) { root = config.root - command = config.command }, resolveId: { filter: { id: new RegExp(VIRTUAL_MODULES.serverFnResolver) }, diff --git a/packages/start-plugin-core/src/start-compiler-plugin/types.ts b/packages/start-plugin-core/src/start-compiler-plugin/types.ts index 141cb4e28fb..19d4517ab8a 100644 --- a/packages/start-plugin-core/src/start-compiler-plugin/types.ts +++ b/packages/start-plugin-core/src/start-compiler-plugin/types.ts @@ -85,6 +85,12 @@ export interface ServerFn { extractedFilename: string /** The original source filename */ filename: string + /** The emitted chunk IDs for this function (rspack build) */ + importerChunkIds?: Array + /** The emitted module ID for this function (rspack build) */ + importerModuleId?: string | number + /** The emitted importer path for this function (rspack build fallback) */ + importerPath?: string /** * True when this function was discovered by the client build. * Used to restrict HTTP access to only client-referenced functions. diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index a99eed371ff..4ab0dbabd6d 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -128,7 +128,10 @@ describe('createServerFn compiles correctly', async () => { // Server caller: no second argument (implementation from extracted chunk) expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(` "import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; + import { myServerFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; + globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; + globalThis.__tssServerFnHandlers.push(myServerFn_createServerFn_handler); const myServerFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJteVNlcnZlckZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["myServerFn_createServerFn_handler"])));" `) @@ -184,7 +187,10 @@ describe('createServerFn compiles correctly', async () => { expect(compiledResultServerCaller!.code).toMatchInlineSnapshot(` "import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; + import { exportedFn_createServerFn_handler, nonExportedFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; + globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; + globalThis.__tssServerFnHandlers.push(exportedFn_createServerFn_handler, nonExportedFn_createServerFn_handler); export const exportedFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJleHBvcnRlZEZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["exportedFn_createServerFn_handler"]))); const nonExportedFn = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJub25FeHBvcnRlZEZuX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["nonExportedFn_createServerFn_handler"])));" `) diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx index b8dbdcaae58..626d0f3ae7a 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructured.tsx @@ -1,6 +1,9 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withArrowFunction_createServerFn_handler, withArrowFunctionAndFunction_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler, withValidatorFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withArrowFunction_createServerFn_handler, withArrowFunctionAndFunction_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler, withValidatorFn_createServerFn_handler); export const withUseServer = createServerFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx index 815702d5d8f..cadb811cf37 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnDestructuredRename.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn as serverFn } from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler); export const withUseServer = serverFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx index 2f68c8c276d..6452aa604d2 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnStarImport.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import * as TanStackStart from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler, withoutUseServer_createServerFn_handler, withVariable_createServerFn_handler, withZodValidator_createServerFn_handler); export const withUseServer = TanStackStart.createServerFn({ method: 'GET' }).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx index ba0b4b577e2..b6a5abb5eb4 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/createServerFnValidator.tsx @@ -1,6 +1,9 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { withUseServer_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(withUseServer_createServerFn_handler); export const withUseServer = createServerFn({ method: 'GET' }).inputValidator(z.number()).handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJ3aXRoVXNlU2VydmVyX2NyZWF0ZVNlcnZlckZuX2hhbmRsZXIifQ", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["withUseServer_createServerFn_handler"]))); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx index dbc4e764d9a..874d4c8fd90 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/factory.tsx @@ -1,5 +1,8 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { myAuthedFn_createServerFn_handler, deleteUserFn_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createServerFn, createMiddleware } from '@tanstack/react-start'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(myAuthedFn_createServerFn_handler, deleteUserFn_createServerFn_handler); const authMiddleware = createMiddleware({ type: 'function' }).server(({ diff --git a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx index 02675e9e956..aa574d6ff56 100644 --- a/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx +++ b/packages/start-plugin-core/tests/createServerFn/snapshots/server-caller/isomorphic-fns.tsx @@ -1,7 +1,10 @@ import { createSsrRpc } from '@tanstack/react-start/ssr-rpc'; +import { getServerEnv_createServerFn_handler, getServerEcho_createServerFn_handler } from "./test.ts?tss-serverfn-split"; import { createFileRoute } from '@tanstack/react-router'; import { createIsomorphicFn, createServerFn } from '@tanstack/react-start'; import { useState } from 'react'; +globalThis.__tssServerFnHandlers = globalThis.__tssServerFnHandlers || []; +globalThis.__tssServerFnHandlers.push(getServerEnv_createServerFn_handler, getServerEcho_createServerFn_handler); const getEnv = createIsomorphicFn().server(() => 'server').client(() => 'client'); const getServerEnv = createServerFn().handler(createSsrRpc("eyJmaWxlIjoiL0BpZC9zcmMvdGVzdC50cz90c3Mtc2VydmVyZm4tc3BsaXQiLCJleHBvcnQiOiJnZXRTZXJ2ZXJFbnZfY3JlYXRlU2VydmVyRm5faGFuZGxlciJ9", () => import("/test/src/test.ts?tss-serverfn-split").then(m => m["getServerEnv_createServerFn_handler"]))); const getEcho = createIsomorphicFn().server((input: string) => 'server received ' + input).client(input => 'client received ' + input); diff --git a/packages/start-plugin-core/vite.config.ts b/packages/start-plugin-core/vite.config.ts index 6f84b1c040a..dad7d4279ff 100644 --- a/packages/start-plugin-core/vite.config.ts +++ b/packages/start-plugin-core/vite.config.ts @@ -19,6 +19,7 @@ export default mergeConfig( './src/rsbuild/index.ts', './src/rsbuild/start-compiler-loader.ts', './src/rsbuild/route-tree-loader.ts', + './src/rsbuild/start-storage-context-stub.ts', ], srcDir: './src', outDir: './dist', diff --git a/packages/start-server-core/src/router-manifest.ts b/packages/start-server-core/src/router-manifest.ts index ceec7eaf4e8..925ae7893bf 100644 --- a/packages/start-server-core/src/router-manifest.ts +++ b/packages/start-server-core/src/router-manifest.ts @@ -20,7 +20,7 @@ const ROUTER_BASEPATH = process.env.TSS_ROUTER_BASEPATH || '/' export async function getStartManifest( matchedRoutes?: ReadonlyArray, ): Promise { - const { tsrStartManifest } = await import('tanstack-start-manifest:v') + const { tsrStartManifest } = await import('tanstack-start-manifest') const startManifest = tsrStartManifest() const rootRoute = (startManifest.routes[rootRouteId] = @@ -45,7 +45,7 @@ export async function getStartManifest( // build the client entry script tag after URL transforms are applied) let injectedHeadScripts: string | undefined if (process.env.TSS_DEV_SERVER === 'true') { - const mod = await import('tanstack-start-injected-head-scripts:v') + const mod = await import('tanstack-start-injected-head-scripts') if (mod.injectedHeadScripts) { injectedHeadScripts = mod.injectedHeadScripts } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index fdc6fb06723..4f2a15f278b 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -50,6 +50,10 @@ export const handleServerAction = async ({ const url = new URL(request.url) const action = await getServerFnById(serverFnId, { fromClient: true }) + const executableAction = + typeof (action as any)?.__executeServer === 'function' + ? (action as any).__executeServer.bind(action) + : action const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' @@ -111,7 +115,8 @@ export const handleServerAction = async ({ } } - return await action(params) + const result = await executableAction(params) + return result } // Get requests use the query string @@ -129,7 +134,8 @@ export const handleServerAction = async ({ payload.context = safeObjectMerge(context, payload.context) payload.method = methodUpper // Send it through! - return await action(payload) + const result = await executableAction(payload) + return result } if (methodLower !== 'post') { @@ -144,7 +150,8 @@ export const handleServerAction = async ({ const payload = jsonPayload ? parsePayload(jsonPayload) : {} payload.context = safeObjectMerge(payload.context, context) payload.method = methodUpper - return await action(payload) + const result = await executableAction(payload) + return result })() const unwrapped = res.result || res.error @@ -157,8 +164,18 @@ export const handleServerAction = async ({ return unwrapped } - if (unwrapped instanceof Response) { - if (isRedirect(unwrapped)) { + const redirectOptions = getRedirectOptions(unwrapped) + if (redirectOptions) { + return Response.json( + { ...redirectOptions, isSerializedRedirect: true }, + { headers: getResponseHeaders(unwrapped) }, + ) + } + + if (isResponseLike(unwrapped)) { + const isRedirectResponse = + isRedirect(unwrapped) || Boolean(getRedirectOptions(unwrapped)) + if (isRedirectResponse) { return unwrapped } unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') @@ -305,7 +322,20 @@ export const handleServerAction = async ({ }) } } catch (error: any) { - if (error instanceof Response) { + if (isResponseLike(error)) { + const redirectOptions = getRedirectOptions(error) + if (redirectOptions && isServerFn) { + return Response.json( + { ...redirectOptions, isSerializedRedirect: true }, + { headers: getResponseHeaders(error) }, + ) + } + const isRedirectResponse = + isRedirect(error) || Boolean(getRedirectOptions(error)) + if (isRedirectResponse) { + return error + } + error.headers.set(X_TSS_RAW_RESPONSE, 'true') return error } // else if ( @@ -365,3 +395,40 @@ function isNotFoundResponse(error: any) { }, }) } + +function isResponseLike(value: unknown): value is Response { + if (value instanceof Response) { + return true + } + if (value === null || typeof value !== 'object') { + return false + } + if (!('status' in value) || !('headers' in value)) { + return false + } + const headers = (value as { headers?: { get?: unknown } }).headers + return typeof headers?.get === 'function' +} + +function getRedirectOptions( + value: unknown, +): Record | undefined { + if (value === null || typeof value !== 'object') { + return undefined + } + if (!('options' in value)) { + return undefined + } + return (value as { options?: Record }).options +} + +function getResponseHeaders(value: unknown): Headers | undefined { + if (value === null || typeof value !== 'object') { + return undefined + } + if (!('headers' in value)) { + return undefined + } + const headers = (value as { headers?: Headers }).headers + return headers +} diff --git a/packages/start-server-core/src/tanstack-start.d.ts b/packages/start-server-core/src/tanstack-start.d.ts index 106375b4da9..49fb61c849a 100644 --- a/packages/start-server-core/src/tanstack-start.d.ts +++ b/packages/start-server-core/src/tanstack-start.d.ts @@ -1,4 +1,4 @@ -declare module 'tanstack-start-manifest:v' { +declare module 'tanstack-start-manifest' { import type { Manifest } from '@tanstack/router-core' export const tsrStartManifest: () => Manifest & { clientEntry: string } @@ -18,6 +18,6 @@ declare module '#tanstack-start-server-fn-resolver' { ): Promise } -declare module 'tanstack-start-injected-head-scripts:v' { +declare module 'tanstack-start-injected-head-scripts' { export const injectedHeadScripts: string | undefined } diff --git a/packages/start-server-core/src/virtual-modules.ts b/packages/start-server-core/src/virtual-modules.ts index 7280feacf26..3fd43ba4e9d 100644 --- a/packages/start-server-core/src/virtual-modules.ts +++ b/packages/start-server-core/src/virtual-modules.ts @@ -1,5 +1,5 @@ export const VIRTUAL_MODULES = { - startManifest: 'tanstack-start-manifest:v', - injectedHeadScripts: 'tanstack-start-injected-head-scripts:v', + startManifest: 'tanstack-start-manifest', + injectedHeadScripts: 'tanstack-start-injected-head-scripts', serverFnResolver: '#tanstack-start-server-fn-resolver', } as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e773e224c..b13062c3c4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1119,6 +1119,12 @@ importers: '@playwright/test': specifier: ^1.57.0 version: 1.57.0 + '@rsbuild/core': + specifier: ^1.2.4 + version: 1.2.4 + '@rsbuild/plugin-react': + specifier: ^1.1.0 + version: 1.1.0(@rsbuild/core@1.2.4) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -10675,7 +10681,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) @@ -27465,13 +27471,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.55.3) + '@netlify/functions-dev': 1.0.0(rollup@4.55.3) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2) '@netlify/redirects': 3.1.0 @@ -27539,12 +27545,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.55.3)': + '@netlify/functions-dev@1.0.0(rollup@4.55.3)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.55.3) + '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.55.3) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -27634,9 +27640,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -27664,9 +27670,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.9.2)(rollup@4.55.3) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.9.2)(rollup@4.55.3) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -27694,13 +27700,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.55.3)': + '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.55.3)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.55.3) + '@vercel/nft': 0.29.4(rollup@4.55.3) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -30950,7 +30956,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.55.3)': + '@vercel/nft@0.29.4(rollup@4.55.3)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.55.3) From bbd2c2d487064dccdc67af47217c8410d524cf34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 17:28:43 +0000 Subject: [PATCH 03/16] Fix SSR server static index handling Co-authored-by: Zack Jackson --- e2e/react-start/basic/server.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/react-start/basic/server.js b/e2e/react-start/basic/server.js index 83f5ff0079c..3c0a6668935 100644 --- a/e2e/react-start/basic/server.js +++ b/e2e/react-start/basic/server.js @@ -18,7 +18,12 @@ export async function createStartServer() { // to keep testing uniform stop express from redirecting /posts to /posts/ // when serving pre-rendered pages - app.use(express.static('./dist/client', { redirect: !isPrerender })) + app.use( + express.static('./dist/client', { + redirect: !isPrerender, + ...(isPrerender ? {} : { index: false }), + }), + ) app.use(async (req, res, next) => { try { From d0ade15776f1c2743181123dbc3eab00450bcb3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 8 Feb 2026 18:47:03 +0000 Subject: [PATCH 04/16] Fix rsbuild preview and css handling Co-authored-by: Zack Jackson --- e2e/react-start/basic/rsbuild.config.ts | 13 +-- e2e/react-start/basic/scripts/run-bundler.mjs | 22 ++++- e2e/react-start/basic/src/routes/__root.tsx | 3 +- e2e/react-start/basic/src/styles/app.css | 80 ++++++++++++------- 4 files changed, 72 insertions(+), 46 deletions(-) diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts index dd173dfd7dd..bd9be5efbd0 100644 --- a/e2e/react-start/basic/rsbuild.config.ts +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -40,18 +40,7 @@ export default defineConfig({ prerender: isPrerender ? prerenderConfiguration : undefined, }), ], - tools: { - rspack: { - module: { - rules: [ - { - resourceQuery: /url/, - type: 'asset/resource', - }, - ], - }, - }, - }, + tools: {}, source: { alias: { '~': path.resolve(currentDir, 'src'), diff --git a/e2e/react-start/basic/scripts/run-bundler.mjs b/e2e/react-start/basic/scripts/run-bundler.mjs index a7971a27f69..eae38814959 100644 --- a/e2e/react-start/basic/scripts/run-bundler.mjs +++ b/e2e/react-start/basic/scripts/run-bundler.mjs @@ -10,6 +10,14 @@ if (!command) { const bundler = process.env.BUNDLER === 'rsbuild' ? 'rsbuild' : 'vite' +const extractPort = (args) => { + const portIndex = args.indexOf('--port') + if (portIndex >= 0 && args[portIndex + 1]) { + return args[portIndex + 1] + } + return null +} + const run = (cmd, cmdArgs) => new Promise((resolve, reject) => { const child = spawn(cmd, cmdArgs, { @@ -27,10 +35,18 @@ const run = (cmd, cmdArgs) => }) try { - await run(bundler, [command, ...args]) + if (bundler === 'rsbuild' && command === 'preview') { + const port = extractPort(args) + if (port) { + process.env.PORT = port + } + await run('node', ['server.js']) + } else { + await run(bundler, [command, ...args]) - if (command === 'build') { - await run('tsc', ['--noEmit']) + if (command === 'build') { + await run('tsc', ['--noEmit']) + } } } catch (error) { console.error(error) diff --git a/e2e/react-start/basic/src/routes/__root.tsx b/e2e/react-start/basic/src/routes/__root.tsx index e1862b499c6..7bf17f27c93 100644 --- a/e2e/react-start/basic/src/routes/__root.tsx +++ b/e2e/react-start/basic/src/routes/__root.tsx @@ -10,7 +10,7 @@ import { import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' import { NotFound } from '~/components/NotFound' -import appCss from '~/styles/app.css?url' +import '~/styles/app.css' import { seo } from '~/utils/seo' export const Route = createRootRoute({ @@ -30,7 +30,6 @@ export const Route = createRootRoute({ }), ], links: [ - { rel: 'stylesheet', href: appCss }, { rel: 'apple-touch-icon', sizes: '180x180', diff --git a/e2e/react-start/basic/src/styles/app.css b/e2e/react-start/basic/src/styles/app.css index 37c1f5a6e2d..126fd9a937c 100644 --- a/e2e/react-start/basic/src/styles/app.css +++ b/e2e/react-start/basic/src/styles/app.css @@ -1,30 +1,52 @@ -@import 'tailwindcss' source('../'); - -@layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } -} - -@layer base { - html { - color-scheme: light dark; - } - - * { - @apply border-gray-200 dark:border-gray-800; - } - - html, - body { - @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; - } - - .using-mouse * { - outline: none !important; - } +*, +*::before, +*::after, +::backdrop, +::file-selector-button { + box-sizing: border-box; + border-color: #e5e7eb; +} + +html { + color-scheme: light dark; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: #f9fafb; + color: #111827; +} + +.using-mouse * { + outline: none !important; +} + +.p-2 { + padding: 0.5rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.flex { + display: flex; +} + +.gap-2 { + gap: 0.5rem; +} + +.text-lg { + font-size: 1.125rem; +} + +.font-bold { + font-weight: 700; +} + +.italic { + font-style: italic; } From 20fe2f24c2148bce3a761e6f81b497a207614a1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 03:41:53 +0000 Subject: [PATCH 05/16] Fix redirect serialization heuristics Co-authored-by: Zack Jackson --- .../src/client-rpc/serverFnFetcher.ts | 22 ++++++------------- .../src/server-functions-handler.ts | 7 ++---- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index fc7133b3bcf..304427cc3d0 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -53,24 +53,16 @@ function parseRedirectFallback(payload: unknown) { if (!payload || typeof payload !== 'object') { return undefined } - const candidate = payload as { - statusCode?: unknown - code?: unknown - to?: unknown - href?: unknown + if (!('isSerializedRedirect' in payload)) { + return undefined } - const statusCode = - typeof candidate.statusCode === 'number' - ? candidate.statusCode - : typeof candidate.code === 'number' - ? candidate.code - : undefined - const hasLocation = - typeof candidate.to === 'string' || typeof candidate.href === 'string' - if (statusCode === undefined || !hasLocation) { + if ( + (payload as { isSerializedRedirect?: boolean }).isSerializedRedirect !== + true + ) { return undefined } - return redirect(candidate as any) + return redirect(payload as any) } // caller => // serverFnFetcher => diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index 4f2a15f278b..e5774627bfb 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -413,13 +413,10 @@ function isResponseLike(value: unknown): value is Response { function getRedirectOptions( value: unknown, ): Record | undefined { - if (value === null || typeof value !== 'object') { - return undefined - } - if (!('options' in value)) { + if (!isRedirect(value)) { return undefined } - return (value as { options?: Record }).options + return value.options as Record } function getResponseHeaders(value: unknown): Headers | undefined { From 87f6ea4a2a0281f13e815b269746d503cb5b48ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Feb 2026 20:07:55 +0000 Subject: [PATCH 06/16] Harden PR CI and refine rsbuild build env handling Co-authored-by: Zack Jackson --- .github/workflows/autofix.yml | 3 +- .github/workflows/pr.yml | 6 +++- packages/react-router/eslint.config.ts | 6 ++++ .../start-plugin-core/src/rsbuild/plugin.ts | 34 ++++++++++++------- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 4ab8d059936..f4925c53145 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -10,7 +10,8 @@ concurrency: cancel-in-progress: true permissions: - contents: read + contents: write + pull-requests: write jobs: autofix: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27afd72710c..821940e8887 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,9 +27,12 @@ jobs: with: fetch-depth: 0 - name: Start Nx Agents + if: ${{ env.NX_CLOUD_ACCESS_TOKEN != '' }} run: npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" - name: Setup Tools uses: tanstack/config/.github/setup@main + - name: Install Playwright browsers + run: pnpm exec playwright install - name: Get base and head commits for `nx affected` uses: nrwl/nx-set-shas@v4.4.0 with: @@ -37,7 +40,7 @@ jobs: - name: Run Checks run: pnpm run test:pr --parallel=3 - name: Stop Nx Agents - if: ${{ always() }} + if: ${{ always() && env.NX_CLOUD_ACCESS_TOKEN != '' }} run: npx nx-cloud stop-all-agents preview: name: Preview @@ -52,4 +55,5 @@ jobs: - name: Build Packages run: pnpm run build:all - name: Publish Previews + if: ${{ github.repository_owner == 'TanStack' }} run: pnpx pkg-pr-new publish --pnpm './packages/*' --template './examples/*/*' diff --git a/packages/react-router/eslint.config.ts b/packages/react-router/eslint.config.ts index 5d879181f79..d5fcd3aec86 100644 --- a/packages/react-router/eslint.config.ts +++ b/packages/react-router/eslint.config.ts @@ -8,6 +8,12 @@ export default [ { files: ['src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'], }, + { + files: ['llms/rules/**/*.ts'], + rules: { + 'no-useless-escape': 'off', + }, + }, { plugins: { 'react-hooks': pluginReactHooks, diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 79ab11ed90a..e0d9214dadf 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -327,6 +327,7 @@ export function TanStackStartRsbuildPluginCore( ) const isDev = api.context?.command === 'serve' + const isBuild = api.context?.command === 'build' const defineViteEnv = (key: string, fallback = '') => { const value = process.env[key] ?? fallback return defineReplaceEnv(key, value) @@ -348,6 +349,13 @@ export function TanStackStartRsbuildPluginCore( : {}), ...defineViteEnv('VITE_NODE_ENV', 'production'), ...defineViteEnv('VITE_EXTERNAL_PORT', ''), + ...(isBuild && startConfig.server.build.staticNodeEnv + ? { + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV ?? 'production', + ), + } + : {}), } const routerPlugins = tanStackStartRouterRsbuild( @@ -737,20 +745,20 @@ export function TanStackStartRsbuildPluginCore( clientOutputDir, serverOutputDir, }) - if (routeTreeGeneratedPath && routeTreeModuleDeclaration) { - if (fs.existsSync(routeTreeGeneratedPath)) { - const existingTree = fs.readFileSync( - routeTreeGeneratedPath, - 'utf-8', - ) - if (!existingTree.includes(routeTreeModuleDeclaration)) { - fs.appendFileSync( - routeTreeGeneratedPath, - `\n\n${routeTreeModuleDeclaration}\n`, - ) - } - } + if (routeTreeGeneratedPath && routeTreeModuleDeclaration) { + if (fs.existsSync(routeTreeGeneratedPath)) { + const existingTree = fs.readFileSync( + routeTreeGeneratedPath, + 'utf-8', + ) + if (!existingTree.includes(routeTreeModuleDeclaration)) { + fs.appendFileSync( + routeTreeGeneratedPath, + `\n\n${routeTreeModuleDeclaration}\n`, + ) } + } + } }) }, }, From 8d0e57d02db5c109d4121ef3d62f490e69b2d580 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 12:42:42 -0800 Subject: [PATCH 07/16] Revert PR workflow audit changes Restore the PR GitHub Actions workflow to the main-branch behavior because the quality-audit PR changes were not needed for feat/rsbuild. Co-authored-by: Cursor --- .github/workflows/pr.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 821940e8887..27afd72710c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,12 +27,9 @@ jobs: with: fetch-depth: 0 - name: Start Nx Agents - if: ${{ env.NX_CLOUD_ACCESS_TOKEN != '' }} run: npx nx-cloud start-ci-run --distribute-on=".nx/workflows/dynamic-changesets.yaml" - name: Setup Tools uses: tanstack/config/.github/setup@main - - name: Install Playwright browsers - run: pnpm exec playwright install - name: Get base and head commits for `nx affected` uses: nrwl/nx-set-shas@v4.4.0 with: @@ -40,7 +37,7 @@ jobs: - name: Run Checks run: pnpm run test:pr --parallel=3 - name: Stop Nx Agents - if: ${{ always() && env.NX_CLOUD_ACCESS_TOKEN != '' }} + if: ${{ always() }} run: npx nx-cloud stop-all-agents preview: name: Preview @@ -55,5 +52,4 @@ jobs: - name: Build Packages run: pnpm run build:all - name: Publish Previews - if: ${{ github.repository_owner == 'TanStack' }} run: pnpx pkg-pr-new publish --pnpm './packages/*' --template './examples/*/*' From fbde76bec6b3425032011e98cb1bf45bdd5d0972 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:44:36 +0000 Subject: [PATCH 08/16] ci: apply automated fixes --- e2e/react-start/basic/src/styles/app.css | 7 +- .../start-plugin-core/src/rsbuild/plugin.ts | 35 ++++++---- .../src/rsbuild/route-tree-state.ts | 4 +- .../src/rsbuild/start-compiler-loader.ts | 49 ++++++-------- .../src/rsbuild/start-compiler-plugin.ts | 67 ++++++++++++------- .../src/rsbuild/start-manifest-plugin.ts | 13 +--- 6 files changed, 96 insertions(+), 79 deletions(-) diff --git a/e2e/react-start/basic/src/styles/app.css b/e2e/react-start/basic/src/styles/app.css index 126fd9a937c..0a5d2c761a8 100644 --- a/e2e/react-start/basic/src/styles/app.css +++ b/e2e/react-start/basic/src/styles/app.css @@ -13,7 +13,12 @@ html { body { margin: 0; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; background-color: #f9fafb; color: #111827; } diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index e0d9214dadf..33efc030beb 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -304,8 +304,10 @@ export function TanStackStartRsbuildPluginCore( startFilePath ?? corePluginOpts.defaultEntryPaths.start, [ENTRY_POINTS.router]: routerFilePath, } - const resolvedClientEntry = entryAliasConfiguration[ENTRY_POINTS.client] - const resolvedServerEntry = entryAliasConfiguration[ENTRY_POINTS.server] + const resolvedClientEntry = + entryAliasConfiguration[ENTRY_POINTS.client] + const resolvedServerEntry = + entryAliasConfiguration[ENTRY_POINTS.server] const clientOutputDir = getOutputDirectory( root, @@ -369,13 +371,15 @@ export function TanStackStartRsbuildPluginCore( routeTreeFileFooter: [], plugins: [], } - const generatedRouteTreePath = routerPlugins.getGeneratedRouteTreePath() - const routeTreeModuleDeclarationValue = buildRouteTreeModuleDeclaration({ - generatedRouteTreePath, - routerFilePath: resolvedStartConfig.routerFilePath, - startFilePath: resolvedStartConfig.startFilePath, - framework: corePluginOpts.framework, - }) + const generatedRouteTreePath = + routerPlugins.getGeneratedRouteTreePath() + const routeTreeModuleDeclarationValue = + buildRouteTreeModuleDeclaration({ + generatedRouteTreePath, + routerFilePath: resolvedStartConfig.routerFilePath, + startFilePath: resolvedStartConfig.startFilePath, + framework: corePluginOpts.framework, + }) routeTreeModuleDeclaration = routeTreeModuleDeclarationValue routeTreeGeneratedPath = generatedRouteTreePath const registerDeclaration = `declare module '@tanstack/${corePluginOpts.framework}-start'` @@ -430,7 +434,8 @@ export function TanStackStartRsbuildPluginCore( root, framework: corePluginOpts.framework, providerEnvName: serverFnProviderEnv, - generateFunctionId: startPluginOpts?.serverFns?.generateFunctionId, + generateFunctionId: + startPluginOpts?.serverFns?.generateFunctionId, manifestPath, }, }, @@ -557,9 +562,8 @@ export function TanStackStartRsbuildPluginCore( if (startConfig.vite?.installDevServerMiddleware === false) { return } - const serverEnv = context.environments?.[ - VITE_ENVIRONMENT_NAMES.server - ] + const serverEnv = + context.environments?.[VITE_ENVIRONMENT_NAMES.server] middlewares.push(async (req: any, res: any, next: any) => { if (res.headersSent || res.writableEnded) { return next() @@ -573,7 +577,10 @@ export function TanStackStartRsbuildPluginCore( if (!serverBuild?.fetch) { return next() } - req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') + req.url = joinURL( + resolvedStartConfig.viteAppBase, + req.url ?? '/', + ) const webReq = new NodeRequest({ req, res }) const webRes = await serverBuild.fetch(webReq) return sendNodeResponse(res, webRes) diff --git a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts index 18a09e5042e..35756a64bdd 100644 --- a/packages/start-plugin-core/src/rsbuild/route-tree-state.ts +++ b/packages/start-plugin-core/src/rsbuild/route-tree-state.ts @@ -15,7 +15,9 @@ export async function getClientRouteTreeContent(options?: { let generator = generatorInstance if (!generator) { if (!options?.routerConfig || !options.root) { - throw new Error('Generator instance not initialized for route tree loader') + throw new Error( + 'Generator instance not initialized for route tree loader', + ) } generator = new Generator({ config: options.routerConfig, diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts index f2c442755ec..57ea209eada 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -130,27 +130,23 @@ async function resolveId( conditionNames: ['import', 'module', 'default'], }) ?? loaderContext.resolve - resolver( - resolveContext, - source, - (err: Error | null, result?: string) => { - if (!err && result) { - resolve(cleanId(result)) - return - } - try { - const resolved = require.resolve(source, { - paths: [ - baseContext, - loaderContext.rootContext || loaderContext.context, - ].filter(Boolean), - }) - resolve(cleanId(resolved)) - } catch { - resolve(null) - } - }, - ) + resolver(resolveContext, source, (err: Error | null, result?: string) => { + if (!err && result) { + resolve(cleanId(result)) + return + } + try { + const resolved = require.resolve(source, { + paths: [ + baseContext, + loaderContext.rootContext || loaderContext.context, + ].filter(Boolean), + }) + resolve(cleanId(resolved)) + } catch { + resolve(null) + } + }) }) } @@ -163,7 +159,7 @@ async function loadModule( const resolvedPath = cleaned.startsWith('.') || cleaned.startsWith('/') ? cleaned - : (await resolveId(loaderContext, cleaned)) ?? cleaned + : ((await resolveId(loaderContext, cleaned)) ?? cleaned) if (resolvedPath.includes('\0')) return @@ -175,11 +171,7 @@ async function loadModule( } } -export default function startCompilerLoader( - this: any, - code: string, - map: any, -) { +export default function startCompilerLoader(this: any, code: string, map: any) { const callback = this.async() const options = this.getOptions() as LoaderOptions @@ -199,7 +191,8 @@ export default function startCompilerLoader( let compiler = compilers.get(envName) if (!compiler) { const mode = - this.mode === 'production' || this._compiler?.options?.mode === 'production' + this.mode === 'production' || + this._compiler?.options?.mode === 'production' ? 'build' : 'dev' const shouldPersistManifest = Boolean(manifestPath) && mode === 'build' diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts index 20d773acb06..d1b8b800f74 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -65,7 +65,9 @@ const getCompilationModuleIdentifiers = (module: any) => module.resource, module.userRequest, module.request, - typeof module.identifier === 'function' ? module.identifier() : module.identifier, + typeof module.identifier === 'function' + ? module.identifier() + : module.identifier, module.debugId, ] .filter(Boolean) @@ -301,19 +303,21 @@ export function createServerFnManifestRspackPlugin(opts: { ) const chunkGraph = compilation?.chunkGraph const moduleGraph = compilation?.moduleGraph - const compilationEntries = compilationModules.flatMap((module: any) => { - const identifiers = getCompilationModuleIdentifiers(module) - if (identifiers.length === 0) return [] - const chunkFiles = chunkGraph - ? Array.from(chunkGraph.getModuleChunksIterable(module) ?? []).flatMap( - (chunk: any) => getChunkFiles(chunk), - ) - : [] - return identifiers.map((identifier) => ({ - identifier, - files: chunkFiles, - })) - }) + const compilationEntries = compilationModules.flatMap( + (module: any) => { + const identifiers = getCompilationModuleIdentifiers(module) + if (identifiers.length === 0) return [] + const chunkFiles = chunkGraph + ? Array.from( + chunkGraph.getModuleChunksIterable(module) ?? [], + ).flatMap((chunk: any) => getChunkFiles(chunk)) + : [] + return identifiers.map((identifier) => ({ + identifier, + files: chunkFiles, + })) + }, + ) const assetFiles = (statsJson?.assets ?? []) .map((asset: any) => asset.name ?? asset) .filter((name: string) => typeof name === 'string') @@ -328,7 +332,8 @@ export function createServerFnManifestRspackPlugin(opts: { ? compilation.assets.get(assetName) : undefined) const sourceValue = - assetFromCompilation && typeof assetFromCompilation.source === 'function' + assetFromCompilation && + typeof assetFromCompilation.source === 'function' ? assetFromCompilation.source() : assetFromCompilation if (typeof sourceValue === 'string') return sourceValue @@ -357,7 +362,10 @@ export function createServerFnManifestRspackPlugin(opts: { } const manifestWithImporters: Record = {} for (const [id, info] of Object.entries(mergedServerFnsById)) { - const normalizedExtracted = info.extractedFilename.replace(/\\/g, '/') + const normalizedExtracted = info.extractedFilename.replace( + /\\/g, + '/', + ) const normalizedFilename = info.filename.replace(/\\/g, '/') const searchTokens = [ normalizedExtracted, @@ -377,8 +385,10 @@ export function createServerFnManifestRspackPlugin(opts: { identifier.includes(normalizedFilename), ), ) - const matchedModule = matchedModuleByExtracted ?? matchedModuleByFilename - const chunkIds = matchedModule?.chunks ?? matchedModule?.chunkIds ?? [] + const matchedModule = + matchedModuleByExtracted ?? matchedModuleByFilename + const chunkIds = + matchedModule?.chunks ?? matchedModule?.chunkIds ?? [] const statsModuleId = matchedModule?.id ?? matchedModule?.moduleId const chunkFiles = chunkIds.flatMap((chunkId: any) => { return chunksById.get(chunkId) ?? [] @@ -401,7 +411,8 @@ export function createServerFnManifestRspackPlugin(opts: { ), ) const matchedCompilationModule = - matchedCompilationModuleByExtracted ?? matchedCompilationModuleByFilename + matchedCompilationModuleByExtracted ?? + matchedCompilationModuleByFilename const exportsInfo = matchedCompilationModule ? moduleGraph?.getExportsInfo?.(matchedCompilationModule) : null @@ -418,9 +429,16 @@ export function createServerFnManifestRspackPlugin(opts: { : undefined const compilationChunkIds = matchedCompilationModule && chunkGraph - ? Array.from(chunkGraph.getModuleChunksIterable(matchedCompilationModule)) + ? Array.from( + chunkGraph.getModuleChunksIterable( + matchedCompilationModule, + ), + ) .map((chunk: any) => chunk.id) - .filter((chunkId: any) => chunkId !== undefined && chunkId !== null) + .filter( + (chunkId: any) => + chunkId !== undefined && chunkId !== null, + ) : [] const compilationModuleId = matchedCompilationModule?.id ?? @@ -472,7 +490,8 @@ export function createServerFnManifestRspackPlugin(opts: { : compilationChunkIds.length > 0 ? compilationChunkIds : undefined, - importerModuleId: statsModuleId ?? (compilationModuleId ?? undefined), + importerModuleId: + statsModuleId ?? compilationModuleId ?? undefined, } } const extractExportName = ( @@ -529,9 +548,7 @@ export function createServerFnManifestRspackPlugin(opts: { if (idIndex === -1) return undefined const beforeId = content.slice(Math.max(0, idIndex - 1500), idIndex) const matches = Array.from( - beforeId.matchAll( - /(?:^|[,{])\s*([0-9]+)\s*:\s*(?:function|\()/g, - ), + beforeId.matchAll(/(?:^|[,{])\s*([0-9]+)\s*:\s*(?:function|\()/g), ) const moduleId = matches[matches.length - 1]?.[1] if (!moduleId) return undefined diff --git a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts index 74eff928c99..14b8e2e1106 100644 --- a/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-manifest-plugin.ts @@ -165,8 +165,7 @@ function buildStartManifest({ statsJson: StatsJson basePath: string }): Manifest & { clientEntry: string } { - const { entrypointName, assets: entryAssets } = - getStatsEntryAssets(statsJson) + const { entrypointName, assets: entryAssets } = getStatsEntryAssets(statsJson) const entryJsAssets = unique(entryAssets.filter(isJsAsset)) const entryCssAssets = unique(entryAssets.filter(isCssAsset)) @@ -226,9 +225,7 @@ function buildStartManifest({ } }) - const entryScriptAssets = entryJsAssets.filter( - (asset) => asset !== entryFile, - ) + const entryScriptAssets = entryJsAssets.filter((asset) => asset !== entryFile) manifest.routes[rootRouteId] = { ...(manifest.routes[rootRouteId] ?? {}), @@ -272,11 +269,7 @@ export function createStartManifestRspackPlugin(opts: { START_MANIFEST_FILE, ) await fsp.mkdir(path.dirname(manifestPath), { recursive: true }) - await fsp.writeFile( - manifestPath, - JSON.stringify(manifest), - 'utf-8', - ) + await fsp.writeFile(manifestPath, JSON.stringify(manifest), 'utf-8') }, ) }, From ffc2b97ca63c0dd86b4df4799289954d263c2d8a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 12:57:57 -0800 Subject: [PATCH 09/16] ci: retrigger PR checks after preview service failure Re-run CI after an external pkg.pr.new Cloudflare worker error in the Preview publish step. Co-authored-by: Cursor From 1d4155bd627834f7695ac50b8f6bb45d996ab94c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 13:27:33 -0800 Subject: [PATCH 10/16] fix: address PR review issues in rsbuild integration Resolve branch-introduced review findings by hardening response/redirect handling, fixing prerender retry behavior, tightening workflow and peer dependency metadata, and improving rsbuild/webpack/vite plugin consistency. Co-authored-by: Cursor --- .github/workflows/autofix.yml | 3 +- e2e/react-start/basic/rsbuild.config.ts | 4 +- e2e/react-start/basic/scripts/run-bundler.mjs | 3 ++ packages/react-start/package.json | 3 ++ packages/router-plugin/src/vite.ts | 2 + packages/router-plugin/src/webpack.ts | 12 +++++ .../src/client-rpc/serverFnFetcher.ts | 27 ++++++++++-- packages/start-plugin-core/package.json | 3 ++ .../src/post-server-build.ts | 2 +- .../start-plugin-core/src/rsbuild/plugin.ts | 17 +++---- .../src/rsbuild/post-server-build.ts | 2 +- .../src/rsbuild/prerender.ts | 8 +++- .../src/rsbuild/start-compiler-loader.ts | 44 ++++++++++++------- .../src/rsbuild/start-compiler-plugin.ts | 21 ++++++--- .../src/server-functions-handler.ts | 11 +++-- 15 files changed, 115 insertions(+), 47 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index f4925c53145..4ab8d059936 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -10,8 +10,7 @@ concurrency: cancel-in-progress: true permissions: - contents: write - pull-requests: write + contents: read jobs: autofix: diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts index bd9be5efbd0..e82c1c30367 100644 --- a/e2e/react-start/basic/rsbuild.config.ts +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from '@rsbuild/core' -import { pluginReact } from '@rsbuild/plugin-react' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' import { isSpaMode } from './tests/utils/isSpaMode' import { isPrerender } from './tests/utils/isPrerender' diff --git a/e2e/react-start/basic/scripts/run-bundler.mjs b/e2e/react-start/basic/scripts/run-bundler.mjs index eae38814959..edfc8fcabbf 100644 --- a/e2e/react-start/basic/scripts/run-bundler.mjs +++ b/e2e/react-start/basic/scripts/run-bundler.mjs @@ -25,6 +25,9 @@ const run = (cmd, cmdArgs) => env: process.env, shell: process.platform === 'win32', }) + child.on('error', (error) => { + reject(error) + }) child.on('close', (code) => { if (code === 0) { resolve() diff --git a/packages/react-start/package.json b/packages/react-start/package.json index 0b658714f81..727792d4139 100644 --- a/packages/react-start/package.json +++ b/packages/react-start/package.json @@ -115,6 +115,9 @@ "peerDependenciesMeta": { "@rsbuild/core": { "optional": true + }, + "vite": { + "optional": true } } } diff --git a/packages/router-plugin/src/vite.ts b/packages/router-plugin/src/vite.ts index 75b66f31aa2..c7bd6a5dcea 100644 --- a/packages/router-plugin/src/vite.ts +++ b/packages/router-plugin/src/vite.ts @@ -34,6 +34,7 @@ const tanstackRouterGenerator = createVitePlugin(unpluginRouterGeneratorFactory) const tanStackRouterCodeSplitter = createVitePlugin( unpluginRouterCodeSplitterFactory, ) +const tanstackRouterCodeSplitter = tanStackRouterCodeSplitter /** * @example @@ -57,6 +58,7 @@ export { getConfig, tanstackRouterAutoImport, tanStackRouterCodeSplitter, + tanstackRouterCodeSplitter, tanstackRouterGenerator, TanStackRouterVite, tanstackRouter, diff --git a/packages/router-plugin/src/webpack.ts b/packages/router-plugin/src/webpack.ts index a718e6056af..d3b19d730eb 100644 --- a/packages/router-plugin/src/webpack.ts +++ b/packages/router-plugin/src/webpack.ts @@ -4,6 +4,7 @@ import { configSchema } from './core/config' import { unpluginRouterCodeSplitterFactory } from './core/router-code-splitter-plugin' import { unpluginRouterGeneratorFactory } from './core/router-generator-plugin' import { unpluginRouterComposedFactory } from './core/router-composed-plugin' +import { unpluginRouteAutoImportFactory } from './core/route-autoimport-plugin' import type { CodeSplittingOptions, Config } from './core/config' /** @@ -32,6 +33,10 @@ const TanStackRouterCodeSplitterWebpack = /* #__PURE__ */ createWebpackPlugin( unpluginRouterCodeSplitterFactory, ) +const TanStackRouterAutoImportWebpack = /* #__PURE__ */ createWebpackPlugin( + unpluginRouteAutoImportFactory, +) + /** * @example * ```ts @@ -45,6 +50,9 @@ const TanStackRouterWebpack = /* #__PURE__ */ createWebpackPlugin( unpluginRouterComposedFactory, ) +const tanstackRouterGenerator = TanStackRouterGeneratorWebpack +const tanstackRouterCodeSplitter = TanStackRouterCodeSplitterWebpack +const tanstackRouterAutoImport = TanStackRouterAutoImportWebpack const tanstackRouter = TanStackRouterWebpack export default TanStackRouterWebpack export { @@ -52,6 +60,10 @@ export { TanStackRouterWebpack, TanStackRouterGeneratorWebpack, TanStackRouterCodeSplitterWebpack, + TanStackRouterAutoImportWebpack, + tanstackRouterGenerator, + tanstackRouterCodeSplitter, + tanstackRouterAutoImport, tanstackRouter, } export type { Config, CodeSplittingOptions } diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 304427cc3d0..7d74a487301 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -21,6 +21,11 @@ import type { Plugin as SerovalPlugin } from 'seroval' let serovalPlugins: Array> | null = null +type ResponseLike = Pick< + Response, + 'status' | 'ok' | 'headers' | 'body' | 'json' | 'text' +> + /** * Checks if an object has at least one own enumerable property. * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. @@ -35,7 +40,7 @@ function hasOwnProperties(obj: object): boolean { return false } -function isResponseLike(value: unknown): value is Response { +function isResponseLike(value: unknown): value is ResponseLike { if (value instanceof Response) { return true } @@ -45,8 +50,22 @@ function isResponseLike(value: unknown): value is Response { if (!('status' in value) || !('headers' in value)) { return false } - const headers = (value as { headers?: { get?: unknown } }).headers - return typeof headers?.get === 'function' + const candidate = value as { + headers?: { get?: unknown; set?: unknown } + json?: unknown + text?: unknown + body?: unknown + ok?: unknown + } + const headers = candidate.headers + return ( + typeof headers?.get === 'function' && + typeof headers?.set === 'function' && + typeof candidate.json === 'function' && + typeof candidate.text === 'function' && + 'body' in candidate && + typeof candidate.ok === 'boolean' + ) } function parseRedirectFallback(payload: unknown) { @@ -199,7 +218,7 @@ async function getFetchBody( * @throws If the response is invalid or an error occurs during processing. */ async function getResponse(fn: () => Promise) { - let response: Response + let response: ResponseLike try { response = await fn() // client => server => fn => server => client } catch (error) { diff --git a/packages/start-plugin-core/package.json b/packages/start-plugin-core/package.json index 89761e9567e..5207a058c19 100644 --- a/packages/start-plugin-core/package.json +++ b/packages/start-plugin-core/package.json @@ -100,6 +100,9 @@ "peerDependenciesMeta": { "@rsbuild/core": { "optional": true + }, + "vite": { + "optional": true } } } diff --git a/packages/start-plugin-core/src/post-server-build.ts b/packages/start-plugin-core/src/post-server-build.ts index 478b2ef2104..036465b50af 100644 --- a/packages/start-plugin-core/src/post-server-build.ts +++ b/packages/start-plugin-core/src/post-server-build.ts @@ -43,7 +43,7 @@ export async function postServerBuild({ prerender: { ...startConfig.spa.prerender, headers: { - ...startConfig.spa.prerender.headers, + ...(startConfig.spa.prerender.headers ?? {}), [HEADERS.TSS_SHELL]: 'true', }, }, diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 33efc030beb..716b8bbe0cb 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -598,9 +598,9 @@ export function TanStackStartRsbuildPluginCore( if (typeof existingSetupMiddlewares === 'function') { existingSetupMiddlewares(middlewares, context) } else if (Array.isArray(existingSetupMiddlewares)) { - existingSetupMiddlewares.forEach((fn: any) => - fn(middlewares, context), - ) + existingSetupMiddlewares.forEach((fn: any) => { + fn(middlewares, context) + }) } setupMiddlewares(middlewares, context) } @@ -714,11 +714,12 @@ export function TanStackStartRsbuildPluginCore( } if (!serverBuild) { - const outputFilename = 'server.js' - const serverEntryPath = path.join( - serverOutputDir, - outputFilename, - ) + const outputCandidates = ['server.js', 'server.mjs', 'index.js'] + const outputFilename = + outputCandidates.find((candidate) => + fs.existsSync(path.join(serverOutputDir, candidate)), + ) ?? 'server.js' + const serverEntryPath = path.join(serverOutputDir, outputFilename) const imported = await import( pathToFileURL(serverEntryPath).toString() ) diff --git a/packages/start-plugin-core/src/rsbuild/post-server-build.ts b/packages/start-plugin-core/src/rsbuild/post-server-build.ts index 5aa1844a296..c8ccbbd0ac2 100644 --- a/packages/start-plugin-core/src/rsbuild/post-server-build.ts +++ b/packages/start-plugin-core/src/rsbuild/post-server-build.ts @@ -40,7 +40,7 @@ export async function postServerBuildRsbuild({ prerender: { ...startConfig.spa.prerender, headers: { - ...startConfig.spa.prerender.headers, + ...(startConfig.spa.prerender.headers ?? {}), [HEADERS.TSS_SHELL]: 'true', }, }, diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts index 0669c90e9db..a86278dfeb9 100644 --- a/packages/start-plugin-core/src/rsbuild/prerender.ts +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -232,11 +232,15 @@ export async function prerender({ } } catch (error) { if (retries < (prerenderOptions.retryCount ?? 0)) { - logger.warn(`Encountered error, retrying: ${page.path} in 500ms`) + const resolvedDelay = prerenderOptions.retryDelay ?? 500 + logger.warn( + `Encountered error, retrying: ${page.path} in ${resolvedDelay}ms`, + ) await new Promise((resolve) => - setTimeout(resolve, prerenderOptions.retryDelay), + setTimeout(resolve, resolvedDelay), ) retriesByPath.set(page.path, retries + 1) + seen.delete(page.path) addCrawlPageTask(page) } else { if (prerenderOptions.failOnError ?? true) { diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts index 57ea209eada..b2da2da9736 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -38,6 +38,12 @@ const appendServerFnsToManifest = ( } export const getServerFnsById = () => serverFnsById +export const resetServerFnCompilerState = () => { + compilers.clear() + for (const key of Object.keys(serverFnsById)) { + delete serverFnsById[key] + } +} // Derive transform code filter from KindDetectionPatterns (single source of truth) function getTransformCodeFilterForEnv(env: 'client' | 'server'): Array { @@ -130,23 +136,27 @@ async function resolveId( conditionNames: ['import', 'module', 'default'], }) ?? loaderContext.resolve - resolver(resolveContext, source, (err: Error | null, result?: string) => { - if (!err && result) { - resolve(cleanId(result)) - return - } - try { - const resolved = require.resolve(source, { - paths: [ - baseContext, - loaderContext.rootContext || loaderContext.context, - ].filter(Boolean), - }) - resolve(cleanId(resolved)) - } catch { - resolve(null) - } - }) + try { + resolver(resolveContext, source, (err: Error | null, result?: string) => { + if (!err && result) { + resolve(cleanId(result)) + return + } + try { + const resolved = require.resolve(source, { + paths: [ + baseContext, + loaderContext.rootContext || loaderContext.context, + ].filter(Boolean), + }) + resolve(cleanId(resolved)) + } catch { + resolve(null) + } + }) + } catch { + resolve(null) + } }) } diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts index d1b8b800f74..ba868f44116 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -3,7 +3,10 @@ import path from 'node:path' import { createRspackPlugin } from 'unplugin' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { VITE_ENVIRONMENT_NAMES } from '../constants' -import { getServerFnsById } from './start-compiler-loader' +import { + getServerFnsById, + resetServerFnCompilerState, +} from './start-compiler-loader' import type { ServerFn } from '../start-compiler-plugin/types' const SERVER_FN_MANIFEST_FILE = 'tanstack-start-server-fn-manifest.json' @@ -79,8 +82,8 @@ function generateManifestModule( ): string { const manifestEntries = Object.entries(serverFnsById) .map(([id, fn]) => { - const baseEntry = `'${id}': { - functionName: '${fn.functionName}', + const baseEntry = `${JSON.stringify(id)}: { + functionName: ${JSON.stringify(fn.functionName)}, importer: () => import(${JSON.stringify(fn.extractedFilename)})${ includeClientReferencedCheck ? `, @@ -180,8 +183,7 @@ function generateManifestModuleFromFile( cached = JSON.parse(raw) return cached } catch (error) { - cached = {} - return cached + return {} } } @@ -262,6 +264,7 @@ export function createServerFnManifestRspackPlugin(opts: { compiler.hooks.beforeRun.tapPromise( 'tanstack-start:server-fn-manifest', async () => { + resetServerFnCompilerState() await fsp.rm(tempManifestPath, { force: true }) }, ) @@ -514,8 +517,14 @@ export function createServerFnManifestRspackPlugin(opts: { const handlerVar = assignmentMatches[assignmentMatches.length - 1]?.[1] if (!handlerVar) return undefined + const escapedHandlerVar = handlerVar.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&', + ) const exportMatch = scope.match( - new RegExp(`([A-Za-z_$][\\\\w$]*):\\\\(\\\\)=>${handlerVar}`), + new RegExp( + `([A-Za-z_$][\\\\w$]*):\\\\(\\\\)=>${escapedHandlerVar}`, + ), ) return exportMatch?.[1] } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index e5774627bfb..eee12022d00 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -174,7 +174,7 @@ export const handleServerAction = async ({ if (isResponseLike(unwrapped)) { const isRedirectResponse = - isRedirect(unwrapped) || Boolean(getRedirectOptions(unwrapped)) + isRedirect(unwrapped) || Boolean(redirectOptions) if (isRedirectResponse) { return unwrapped } @@ -331,7 +331,7 @@ export const handleServerAction = async ({ ) } const isRedirectResponse = - isRedirect(error) || Boolean(getRedirectOptions(error)) + isRedirect(error) || Boolean(redirectOptions) if (isRedirectResponse) { return error } @@ -406,8 +406,11 @@ function isResponseLike(value: unknown): value is Response { if (!('status' in value) || !('headers' in value)) { return false } - const headers = (value as { headers?: { get?: unknown } }).headers - return typeof headers?.get === 'function' + const headers = (value as { headers?: { get?: unknown; set?: unknown } }) + .headers + return ( + typeof headers?.get === 'function' && typeof headers?.set === 'function' + ) } function getRedirectOptions( From 13a75306b9a4c32358d6fe211e851abb7f47b797 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:29:06 +0000 Subject: [PATCH 11/16] ci: apply automated fixes --- packages/start-plugin-core/src/rsbuild/plugin.ts | 5 ++++- packages/start-plugin-core/src/rsbuild/prerender.ts | 4 +--- packages/start-server-core/src/server-functions-handler.ts | 3 +-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 716b8bbe0cb..07773ee23ef 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -719,7 +719,10 @@ export function TanStackStartRsbuildPluginCore( outputCandidates.find((candidate) => fs.existsSync(path.join(serverOutputDir, candidate)), ) ?? 'server.js' - const serverEntryPath = path.join(serverOutputDir, outputFilename) + const serverEntryPath = path.join( + serverOutputDir, + outputFilename, + ) const imported = await import( pathToFileURL(serverEntryPath).toString() ) diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts index a86278dfeb9..f636a0edaae 100644 --- a/packages/start-plugin-core/src/rsbuild/prerender.ts +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -236,9 +236,7 @@ export async function prerender({ logger.warn( `Encountered error, retrying: ${page.path} in ${resolvedDelay}ms`, ) - await new Promise((resolve) => - setTimeout(resolve, resolvedDelay), - ) + await new Promise((resolve) => setTimeout(resolve, resolvedDelay)) retriesByPath.set(page.path, retries + 1) seen.delete(page.path) addCrawlPageTask(page) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index eee12022d00..fee97734fea 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -330,8 +330,7 @@ export const handleServerAction = async ({ { headers: getResponseHeaders(error) }, ) } - const isRedirectResponse = - isRedirect(error) || Boolean(redirectOptions) + const isRedirectResponse = isRedirect(error) || Boolean(redirectOptions) if (isRedirectResponse) { return error } From 448340dfefa2c837c8009d5b501c2970a9bf4bb5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 13:44:09 -0800 Subject: [PATCH 12/16] fix: restore response typing in server fn fetcher Use a Response type guard-compatible response variable while keeping stricter runtime checks so start-client-core type and eslint checks pass in CI. Co-authored-by: Cursor --- .../src/client-rpc/serverFnFetcher.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 7d74a487301..872df139c66 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -21,11 +21,6 @@ import type { Plugin as SerovalPlugin } from 'seroval' let serovalPlugins: Array> | null = null -type ResponseLike = Pick< - Response, - 'status' | 'ok' | 'headers' | 'body' | 'json' | 'text' -> - /** * Checks if an object has at least one own enumerable property. * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. @@ -40,7 +35,7 @@ function hasOwnProperties(obj: object): boolean { return false } -function isResponseLike(value: unknown): value is ResponseLike { +function isResponseLike(value: unknown): value is Response { if (value instanceof Response) { return true } @@ -59,8 +54,9 @@ function isResponseLike(value: unknown): value is ResponseLike { } const headers = candidate.headers return ( - typeof headers?.get === 'function' && - typeof headers?.set === 'function' && + !!headers && + typeof headers.get === 'function' && + typeof headers.set === 'function' && typeof candidate.json === 'function' && typeof candidate.text === 'function' && 'body' in candidate && @@ -218,7 +214,7 @@ async function getFetchBody( * @throws If the response is invalid or an error occurs during processing. */ async function getResponse(fn: () => Promise) { - let response: ResponseLike + let response: Response try { response = await fn() // client => server => fn => server => client } catch (error) { From 4ccdde42b37b1af6df0f000e78ab63200d419b77 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 16:27:23 -0800 Subject: [PATCH 13/16] fix: satisfy response-like eslint guard in server handler Remove unnecessary optional chaining in the response-like header guard so start-server-core eslint passes in CI. Co-authored-by: Cursor --- packages/start-server-core/src/server-functions-handler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index fee97734fea..54d5d4c6e96 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -408,7 +408,9 @@ function isResponseLike(value: unknown): value is Response { const headers = (value as { headers?: { get?: unknown; set?: unknown } }) .headers return ( - typeof headers?.get === 'function' && typeof headers?.set === 'function' + !!headers && + typeof headers.get === 'function' && + typeof headers.set === 'function' ) } From 21d9b1177cfa09a38583240450df27ab9e12d0f9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 19:23:55 -0800 Subject: [PATCH 14/16] refactor: resolve remaining PR #6623 review suggestions Extract shared route-tree and loader helpers, harden rsbuild middleware and route tree cleanup behavior, and address prerender/loader edge cases surfaced by review feedback. Co-authored-by: Cursor --- .../src/client-rpc/serverFnFetcher.ts | 3 +- .../start-plugin-core/src/rsbuild/plugin.ts | 87 +++++-------------- .../src/rsbuild/prerender.ts | 11 ++- .../src/rsbuild/resolve-loader-path.ts | 18 ++++ .../src/rsbuild/start-compiler-loader.ts | 7 +- .../src/rsbuild/start-compiler-plugin.ts | 13 ++- .../src/rsbuild/start-router-plugin.ts | 72 +-------------- .../src/start-router-plugin/plugin.ts | 62 +------------ .../route-tree-module-declaration.ts | 64 ++++++++++++++ 9 files changed, 136 insertions(+), 201 deletions(-) create mode 100644 packages/start-plugin-core/src/rsbuild/resolve-loader-path.ts create mode 100644 packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 872df139c66..8d005a7d92e 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -18,6 +18,7 @@ import { import { createFrameDecoder } from './frame-decoder' import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware' import type { Plugin as SerovalPlugin } from 'seroval' +import type { RedirectOptions } from '@tanstack/router-core' let serovalPlugins: Array> | null = null @@ -77,7 +78,7 @@ function parseRedirectFallback(payload: unknown) { ) { return undefined } - return redirect(payload as any) + return redirect(payload as unknown as RedirectOptions) } // caller => // serverFnFetcher => diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 07773ee23ef..85280514045 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { pathToFileURL } from 'node:url' import { joinPaths } from '@tanstack/router-core' import { NodeRequest, sendNodeResponse } from 'srvx/node' @@ -8,7 +8,9 @@ import { joinURL } from 'ufo' import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from '../constants' import { resolveEntry } from '../resolve-entries' import { parseStartConfig } from '../schema' +import { createRouteTreeModuleDeclaration } from '../start-router-plugin/route-tree-module-declaration' import { createInjectedHeadScriptsPlugin } from './injected-head-scripts-plugin' +import { resolveLoaderPath } from './resolve-loader-path' import { SERVER_FN_MANIFEST_TEMP_FILE, createServerFnManifestRspackPlugin, @@ -41,52 +43,6 @@ function isFullUrl(str: string): boolean { } } -function buildRouteTreeModuleDeclaration(opts: { - generatedRouteTreePath: string - routerFilePath: string - startFilePath?: string - framework: string -}) { - const getImportPath = (absolutePath: string) => { - let relativePath = path.relative( - path.dirname(opts.generatedRouteTreePath), - absolutePath, - ) - if (!relativePath.startsWith('.')) { - relativePath = `./${relativePath}` - } - return relativePath.split(path.sep).join('/') - } - - const result: Array = [ - `import type { getRouter } from '${getImportPath(opts.routerFilePath)}'`, - ] - if (opts.startFilePath) { - result.push( - `import type { startInstance } from '${getImportPath(opts.startFilePath)}'`, - ) - } else { - result.push( - `import type { createStart } from '@tanstack/${opts.framework}-start'`, - ) - } - result.push( - `declare module '@tanstack/${opts.framework}-start' { - interface Register { - ssr: true - router: Awaited>`, - ) - if (opts.startFilePath) { - result.push( - ` config: Awaited>`, - ) - } - result.push(` } -}`) - - return result.join('\n') -} - function defineReplaceEnv( key: TKey, value: TValue, @@ -170,16 +126,6 @@ function toPluginArray(plugin: any) { return Array.isArray(plugin) ? plugin : [plugin] } -function resolveLoaderPath(relativePath: string) { - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const basePath = path.resolve(currentDir, relativePath) - const jsPath = `${basePath}.js` - const tsPath = `${basePath}.ts` - if (fs.existsSync(jsPath)) return jsPath - if (fs.existsSync(tsPath)) return tsPath - return jsPath -} - export function TanStackStartRsbuildPluginCore( corePluginOpts: TanStackStartVitePluginCoreOptions, startPluginOpts: TanStackStartInputConfig, @@ -374,7 +320,7 @@ export function TanStackStartRsbuildPluginCore( const generatedRouteTreePath = routerPlugins.getGeneratedRouteTreePath() const routeTreeModuleDeclarationValue = - buildRouteTreeModuleDeclaration({ + createRouteTreeModuleDeclaration({ generatedRouteTreePath, routerFilePath: resolvedStartConfig.routerFilePath, startFilePath: resolvedStartConfig.startFilePath, @@ -389,7 +335,16 @@ export function TanStackStartRsbuildPluginCore( 'utf-8', ) if (!existingTree.includes(registerDeclaration)) { - fs.rmSync(generatedRouteTreePath) + const staleRouteTreePath = `${generatedRouteTreePath}.stale` + try { + fs.renameSync(generatedRouteTreePath, staleRouteTreePath) + fs.rmSync(staleRouteTreePath) + } catch (error: any) { + // Ignore transient concurrent-generation races and continue. + if (!['ENOENT', 'EBUSY'].includes(error?.code)) { + throw error + } + } } } @@ -577,11 +532,12 @@ export function TanStackStartRsbuildPluginCore( if (!serverBuild?.fetch) { return next() } - req.url = joinURL( + const requestWithBaseUrl = Object.create(req) + requestWithBaseUrl.url = joinURL( resolvedStartConfig.viteAppBase, req.url ?? '/', ) - const webReq = new NodeRequest({ req, res }) + const webReq = new NodeRequest({ req: requestWithBaseUrl, res }) const webRes = await serverBuild.fetch(webReq) return sendNodeResponse(res, webRes) } catch (error) { @@ -732,10 +688,13 @@ export function TanStackStartRsbuildPluginCore( if (!serverBuild?.fetch) { return next() } + const requestWithBaseUrl = Object.create(req) + requestWithBaseUrl.url = joinURL( + resolvedStartConfig.viteAppBase, + req.url ?? '/', + ) - req.url = joinURL(resolvedStartConfig.viteAppBase, req.url ?? '/') - - const webReq = new NodeRequest({ req, res }) + const webReq = new NodeRequest({ req: requestWithBaseUrl, res }) const webRes: Response = await serverBuild.fetch(webReq) return sendNodeResponse(res, webRes) } catch (error) { diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts index f636a0edaae..d94bf1837e2 100644 --- a/packages/start-plugin-core/src/rsbuild/prerender.ts +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -47,6 +47,7 @@ export async function prerender({ routerBaseUrl, ) + const previousPrerenderingEnv = process.env.TSS_PRERENDERING process.env.TSS_PRERENDERING = 'true' const serverBuild = await import(pathToFileURL(serverEntryPath).toString()) @@ -92,6 +93,13 @@ export async function prerender({ }) } catch (error) { logger.error(error) + throw error + } finally { + if (previousPrerenderingEnv === undefined) { + delete process.env.TSS_PRERENDERING + } else { + process.env.TSS_PRERENDERING = previousPrerenderingEnv + } } function extractLinks(html: string): Array { @@ -184,8 +192,9 @@ export async function prerender({ ? cleanPagePath + 'index' : cleanPagePath + const spaPrerender = startConfig.spa?.prerender const isSpaShell = - startConfig.spa?.prerender.outputPath === cleanPagePath + !!spaPrerender && spaPrerender.outputPath === cleanPagePath let htmlPath: string if (isSpaShell) { diff --git a/packages/start-plugin-core/src/rsbuild/resolve-loader-path.ts b/packages/start-plugin-core/src/rsbuild/resolve-loader-path.ts new file mode 100644 index 00000000000..097150c687a --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/resolve-loader-path.ts @@ -0,0 +1,18 @@ +import { fileURLToPath } from 'node:url' +import fs from 'node:fs' +import path from 'pathe' + +/** + * Resolve a local loader path to emitted JS when present, otherwise TS source. + */ +export function resolveLoaderPath(relativePath: string): string { + const currentDir = path.dirname(fileURLToPath(import.meta.url)) + const basePath = path.resolve(currentDir, relativePath) + const jsPath = `${basePath}.js` + const tsPath = `${basePath}.ts` + + if (fs.existsSync(jsPath)) return jsPath + if (fs.existsSync(tsPath)) return tsPath + + return jsPath +} diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts index b2da2da9736..a77b99ae852 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -167,11 +167,10 @@ async function loadModule( ) { const cleaned = cleanId(id) const resolvedPath = - cleaned.startsWith('.') || cleaned.startsWith('/') - ? cleaned - : ((await resolveId(loaderContext, cleaned)) ?? cleaned) + (await resolveId(loaderContext, cleaned)) ?? + (path.isAbsolute(cleaned) ? cleaned : null) - if (resolvedPath.includes('\0')) return + if (!resolvedPath || resolvedPath.includes('\0')) return try { const code = await fsp.readFile(resolvedPath, 'utf-8') diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts index ba868f44116..bf7261a7536 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-plugin.ts @@ -261,12 +261,17 @@ export function createServerFnManifestRspackPlugin(opts: { return { apply(compiler: any) { + const resetManifestState = async () => { + resetServerFnCompilerState() + await fsp.rm(tempManifestPath, { force: true }) + } compiler.hooks.beforeRun.tapPromise( 'tanstack-start:server-fn-manifest', - async () => { - resetServerFnCompilerState() - await fsp.rm(tempManifestPath, { force: true }) - }, + resetManifestState, + ) + compiler.hooks.watchRun.tapPromise( + 'tanstack-start:server-fn-manifest', + resetManifestState, ) compiler.hooks.afterEmit.tapPromise( 'tanstack-start:server-fn-manifest', 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 fff31f162e7..7cf530d7146 100644 --- a/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/start-router-plugin.ts @@ -1,5 +1,3 @@ -import { fileURLToPath } from 'node:url' -import fs from 'node:fs' import path from 'pathe' import { tanstackRouterAutoImport, @@ -8,76 +6,14 @@ import { } from '@tanstack/router-plugin/rspack' import { routesManifestPlugin } from '../start-router-plugin/generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from '../start-router-plugin/generator-plugins/prerender-routes-plugin' +import { createRouteTreeModuleDeclaration } from '../start-router-plugin/route-tree-module-declaration' import { VITE_ENVIRONMENT_NAMES } from '../constants' import { setGeneratorInstance } from './route-tree-state' +import { resolveLoaderPath } from './resolve-loader-path' import type { GetConfigFn, TanStackStartVitePluginCoreOptions } from '../types' import type { GeneratorPlugin } from '@tanstack/router-generator' import type { TanStackStartInputConfig } from '../schema' -function moduleDeclaration({ - startFilePath, - routerFilePath, - corePluginOpts, - generatedRouteTreePath, -}: { - startFilePath: string | undefined - routerFilePath: string - corePluginOpts: TanStackStartVitePluginCoreOptions - generatedRouteTreePath: string -}): string { - function getImportPath(absolutePath: string) { - let relativePath = path.relative( - path.dirname(generatedRouteTreePath), - absolutePath, - ) - - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath - } - - relativePath = relativePath.split(path.sep).join('/') - return relativePath - } - - const result: Array = [ - `import type { getRouter } from '${getImportPath(routerFilePath)}'`, - ] - if (startFilePath) { - result.push( - `import type { startInstance } from '${getImportPath(startFilePath)}'`, - ) - } else { - result.push( - `import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`, - ) - } - result.push( - `declare module '@tanstack/${corePluginOpts.framework}-start' { - interface Register { - ssr: true - router: Awaited>`, - ) - if (startFilePath) { - result.push( - ` config: Awaited>`, - ) - } - result.push(` } -}`) - - return result.join('\n') -} - -function resolveLoaderPath(relativePath: string) { - const currentDir = path.dirname(fileURLToPath(import.meta.url)) - const basePath = path.resolve(currentDir, relativePath) - const jsPath = `${basePath}.js` - const tsPath = `${basePath}.ts` - if (fs.existsSync(jsPath)) return jsPath - if (fs.existsSync(tsPath)) return tsPath - return jsPath -} - export function tanStackStartRouterRsbuild( startPluginOpts: TanStackStartInputConfig, getConfig: GetConfigFn, @@ -110,9 +46,9 @@ export function tanStackStartRouterRsbuild( } } routeTreeFileFooter = [ - moduleDeclaration({ + createRouteTreeModuleDeclaration({ generatedRouteTreePath: getGeneratedRouteTreePath(), - corePluginOpts, + framework: corePluginOpts.framework, startFilePath: resolvedStartConfig.startFilePath, routerFilePath: resolvedStartConfig.routerFilePath, }), diff --git a/packages/start-plugin-core/src/start-router-plugin/plugin.ts b/packages/start-plugin-core/src/start-router-plugin/plugin.ts index 1536d19c91e..a0e0ba6130f 100644 --- a/packages/start-plugin-core/src/start-router-plugin/plugin.ts +++ b/packages/start-plugin-core/src/start-router-plugin/plugin.ts @@ -9,6 +9,7 @@ import { VITE_ENVIRONMENT_NAMES } from '../constants' import { routesManifestPlugin } from './generator-plugins/routes-manifest-plugin' import { prerenderRoutesPlugin } from './generator-plugins/prerender-routes-plugin' import { pruneServerOnlySubtrees } from './pruneServerOnlySubtrees' +import { createRouteTreeModuleDeclaration } from './route-tree-module-declaration' import { SERVER_PROP } from './constants' import type { GetConfigFn, TanStackStartVitePluginCoreOptions } from '../types' import type { @@ -29,63 +30,6 @@ function isServerOnlyNode(node: RouteNode | undefined) { ) } -function moduleDeclaration({ - startFilePath, - routerFilePath, - corePluginOpts, - generatedRouteTreePath, -}: { - startFilePath: string | undefined - routerFilePath: string - corePluginOpts: TanStackStartVitePluginCoreOptions - generatedRouteTreePath: string -}): string { - function getImportPath(absolutePath: string) { - let relativePath = path.relative( - path.dirname(generatedRouteTreePath), - absolutePath, - ) - - if (!relativePath.startsWith('.')) { - relativePath = './' + relativePath - } - - // convert to POSIX-style for ESM imports (important on Windows) - relativePath = relativePath.split(path.sep).join('/') - return relativePath - } - - const result: Array = [ - `import type { getRouter } from '${getImportPath(routerFilePath)}'`, - ] - if (startFilePath) { - result.push( - `import type { startInstance } from '${getImportPath(startFilePath)}'`, - ) - } - // make sure we import something from start to get the server route declaration merge - else { - result.push( - `import type { createStart } from '@tanstack/${corePluginOpts.framework}-start'`, - ) - } - result.push( - `declare module '@tanstack/${corePluginOpts.framework}-start' { - interface Register { - ssr: true - router: Awaited>`, - ) - if (startFilePath) { - result.push( - ` config: Awaited>`, - ) - } - result.push(` } -}`) - - return result.join('\n') -} - export function tanStackStartRouter( startPluginOpts: TanStackStartInputConfig, getConfig: GetConfigFn, @@ -141,9 +85,9 @@ export function tanStackStartRouter( } } routeTreeFileFooter = [ - moduleDeclaration({ + createRouteTreeModuleDeclaration({ generatedRouteTreePath: getGeneratedRouteTreePath(), - corePluginOpts, + framework: corePluginOpts.framework, startFilePath: resolvedStartConfig.startFilePath, routerFilePath: resolvedStartConfig.routerFilePath, }), diff --git a/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts b/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts new file mode 100644 index 00000000000..13a922ec92c --- /dev/null +++ b/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts @@ -0,0 +1,64 @@ +import path from 'pathe' + +type RouteTreeModuleDeclarationOptions = { + generatedRouteTreePath: string + routerFilePath: string + framework: string + startFilePath?: string +} + +/** + * Resolve an import path from the generated route tree file to an absolute file path. + */ +function getImportPath( + generatedRouteTreePath: string, + absolutePath: string, +): string { + let relativePath = path.relative(path.dirname(generatedRouteTreePath), absolutePath) + + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + + // Use POSIX import separators for generated module declarations. + return relativePath.split(path.sep).join('/') +} + +/** + * Build the framework-specific Register module augmentation appended to route trees. + */ +export function createRouteTreeModuleDeclaration( + options: RouteTreeModuleDeclarationOptions, +): string { + const result: Array = [ + `import type { getRouter } from '${getImportPath(options.generatedRouteTreePath, options.routerFilePath)}'`, + ] + + if (options.startFilePath) { + result.push( + `import type { startInstance } from '${getImportPath(options.generatedRouteTreePath, options.startFilePath)}'`, + ) + } else { + result.push( + `import type { createStart } from '@tanstack/${options.framework}-start'`, + ) + } + + result.push( + `declare module '@tanstack/${options.framework}-start' { + interface Register { + ssr: true + router: Awaited>`, + ) + + if (options.startFilePath) { + result.push( + ` config: Awaited>`, + ) + } + + result.push(` } +}`) + + return result.join('\n') +} From 56efe06bd963c29feda068cb34e00a7a93079f60 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:25:16 +0000 Subject: [PATCH 15/16] ci: apply automated fixes --- .../src/start-router-plugin/route-tree-module-declaration.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts b/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts index 13a922ec92c..22c665be7be 100644 --- a/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts +++ b/packages/start-plugin-core/src/start-router-plugin/route-tree-module-declaration.ts @@ -14,7 +14,10 @@ function getImportPath( generatedRouteTreePath: string, absolutePath: string, ): string { - let relativePath = path.relative(path.dirname(generatedRouteTreePath), absolutePath) + let relativePath = path.relative( + path.dirname(generatedRouteTreePath), + absolutePath, + ) if (!relativePath.startsWith('.')) { relativePath = `./${relativePath}` From ef21285f2aeed10c95a7a0a7c8d89be3025c593b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 9 Feb 2026 19:51:34 -0800 Subject: [PATCH 16/16] fix: address remaining unresolved PR review threads Remove redundant monorepo-only loader include path, avoid double prerender normalization, and ensure cached compilers always resolve modules with the current loader context. Co-authored-by: Cursor --- packages/start-plugin-core/src/rsbuild/plugin.ts | 7 ------- packages/start-plugin-core/src/rsbuild/prerender.ts | 12 +++--------- .../src/rsbuild/start-compiler-loader.ts | 9 +++++++-- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 85280514045..e61ecb5fad9 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -358,18 +358,11 @@ export function TanStackStartRsbuildPluginCore( '@tanstack/start-storage-context': startStorageContextStubPath, } - const startClientCoreDistPath = path.resolve( - root, - 'packages/start-client-core/dist/esm', - ) const startClientCoreDistPattern = /[\\/]start-client-core[\\/]dist[\\/]esm[\\/]/ const loaderIncludePaths: Array = [ resolvedStartConfig.srcDirectory, ] - if (fs.existsSync(startClientCoreDistPath)) { - loaderIncludePaths.push(startClientCoreDistPath) - } loaderIncludePaths.push(startClientCoreDistPattern) const loaderRule = ( diff --git a/packages/start-plugin-core/src/rsbuild/prerender.ts b/packages/start-plugin-core/src/rsbuild/prerender.ts index d94bf1837e2..88891c9b1a6 100644 --- a/packages/start-plugin-core/src/rsbuild/prerender.ts +++ b/packages/start-plugin-core/src/rsbuild/prerender.ts @@ -124,15 +124,9 @@ export async function prerender({ const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) const queue = new Queue({ concurrency }) - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') - startConfig.pages = validateAndNormalizePrerenderPages( - startConfig.pages, - routerBaseUrl, - ) - - startConfig.pages.forEach((page) => addCrawlPageTask(page)) + startConfig.pages.forEach((page) => { + addCrawlPageTask(page) + }) await queue.start() diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts index a77b99ae852..0479e1b0e15 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-loader.ts @@ -26,6 +26,7 @@ type LoaderOptions = { } const compilers = new Map() +const loaderContexts = new Map() const serverFnsById: Record = {} const require = createRequire(import.meta.url) const appendServerFnsToManifest = ( @@ -40,6 +41,7 @@ const appendServerFnsToManifest = ( export const getServerFnsById = () => serverFnsById export const resetServerFnCompilerState = () => { compilers.clear() + loaderContexts.clear() for (const key of Object.keys(serverFnsById)) { delete serverFnsById[key] } @@ -197,6 +199,8 @@ export default function startCompilerLoader(this: any, code: string, map: any) { return } + loaderContexts.set(envName, this) + let compiler = compilers.get(envName) if (!compiler) { const mode = @@ -224,9 +228,10 @@ export default function startCompilerLoader(this: any, code: string, map: any) { generateFunctionId: options.generateFunctionId, onServerFnsById, getKnownServerFns: () => serverFnsById, - loadModule: async (id: string) => loadModule(compiler!, this, id), + loadModule: async (id: string) => + loadModule(compiler!, loaderContexts.get(envName), id), resolveId: async (source: string, importer?: string) => - resolveId(this, source, importer), + resolveId(loaderContexts.get(envName), source, importer), }) compilers.set(envName, compiler) }