From 2c62230c4f92284aa344fc23e4c06b247fb3608f Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 19:14:01 +0800 Subject: [PATCH 1/6] refactor(start): streamline Rsbuild environment configuration --- .../start-plugin-core/src/config-context.ts | 3 + packages/start-plugin-core/src/planning.ts | 3 +- .../src/rsbuild/enforced-config.ts | 255 +++++++++++ .../start-plugin-core/src/rsbuild/planning.ts | 298 +++++++------ .../start-plugin-core/src/rsbuild/plugin.ts | 407 ++++++++++-------- .../start-plugin-core/src/rsbuild/schema.ts | 11 - .../start-plugin-core/src/rsbuild/types.ts | 13 - .../src/rsbuild/virtual-modules.ts | 44 +- .../tests/rsbuild/enforced-config.test.ts | 188 ++++++++ .../tests/rsbuild/output-directory.test.ts | 255 +++++++++-- 10 files changed, 1080 insertions(+), 397 deletions(-) create mode 100644 packages/start-plugin-core/src/rsbuild/enforced-config.ts create mode 100644 packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts diff --git a/packages/start-plugin-core/src/config-context.ts b/packages/start-plugin-core/src/config-context.ts index 825860a595c..f434bd0f9b6 100644 --- a/packages/start-plugin-core/src/config-context.ts +++ b/packages/start-plugin-core/src/config-context.ts @@ -7,6 +7,7 @@ import { import type { TanStackStartOutputConfig } from './schema' import type { GetConfigFn, + NormalizedBasePaths, ResolvedStartConfig, TanStackStartCoreOptions, } from './types' @@ -111,12 +112,14 @@ export function applyResolvedBaseAndOutput(opts: { resolvedStartConfig: ResolvedStartConfig root: string publicBase: string + assetBase?: NormalizedBasePaths['assetBase'] clientOutputDirectory: string serverOutputDirectory: string }): void { opts.resolvedStartConfig.root = opts.root opts.resolvedStartConfig.basePaths = createNormalizedBasePaths({ publicBase: opts.publicBase, + assetBase: opts.assetBase, }) opts.resolvedStartConfig.outputDirectories = createNormalizedOutputDirectories({ diff --git a/packages/start-plugin-core/src/planning.ts b/packages/start-plugin-core/src/planning.ts index 102a6df93c0..46178b2d50b 100644 --- a/packages/start-plugin-core/src/planning.ts +++ b/packages/start-plugin-core/src/planning.ts @@ -62,10 +62,11 @@ export function shouldRewriteDevBasepath(opts: { export function createNormalizedBasePaths(opts: { publicBase: string + assetBase?: NormalizedBasePaths['assetBase'] }): NormalizedBasePaths { return { publicBase: opts.publicBase, - assetBase: { + assetBase: opts.assetBase ?? { dev: opts.publicBase, build: opts.publicBase, }, diff --git a/packages/start-plugin-core/src/rsbuild/enforced-config.ts b/packages/start-plugin-core/src/rsbuild/enforced-config.ts new file mode 100644 index 00000000000..9152070c8e0 --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/enforced-config.ts @@ -0,0 +1,255 @@ +import { isDeepStrictEqual, styleText } from 'node:util' +import { mergeRsbuildConfig } from '@rsbuild/core' +import { ENTRY_POINTS } from '../constants' +import type { RsbuildConfig } from '@rsbuild/core' + +interface EnforcedConfig { + [key: string]: EnforcedConfig | true +} + +interface StartRsbuildEnforcedConfig { + global: EnforcedConfig + environments: { + client: EnforcedConfig + server: EnforcedConfig + } +} + +const enforcedDefineConfig = { + 'process.env.TSS_SERVER_FN_BASE': true, + 'import.meta.env.TSS_SERVER_FN_BASE': true, + 'process.env.TSS_ROUTER_BASEPATH': true, + 'import.meta.env.TSS_ROUTER_BASEPATH': true, + 'process.env.TSS_DEV_SERVER': true, + 'import.meta.env.TSS_DEV_SERVER': true, + 'process.env.TSS_DEV_SSR_STYLES_ENABLED': true, + 'import.meta.env.TSS_DEV_SSR_STYLES_ENABLED': true, + 'process.env.TSS_DEV_SSR_STYLES_BASEPATH': true, + 'import.meta.env.TSS_DEV_SSR_STYLES_BASEPATH': true, + 'process.env.TSS_INLINE_CSS_ENABLED': true, + 'import.meta.env.TSS_INLINE_CSS_ENABLED': true, + 'process.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': true, + 'import.meta.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': true, +} satisfies EnforcedConfig + +const commonEnvironmentConfig = { + source: { + define: enforcedDefineConfig, + entry: { + index: true, + }, + }, + resolve: { + alias: { + [ENTRY_POINTS.client]: true, + [ENTRY_POINTS.server]: true, + [ENTRY_POINTS.start]: true, + [ENTRY_POINTS.router]: true, + 'react-server-dom-rspack/server$': true, + }, + }, +} satisfies EnforcedConfig + +const publicAssetDistPathConfig = { + css: true, + cssAsync: true, + svg: true, + font: true, + wasm: true, + image: true, + media: true, + assets: true, +} satisfies EnforcedConfig + +/** + * Rsbuild config fields that TanStack Start owns. + * + * A `true` leaf means that Start writes the final value for that field. Keep + * user-owned fields such as `server.base`, `dev.assetPrefix`, and + * `output.assetPrefix` out of this object: Start consumes those values but + * must not claim ownership of them. + */ +const enforcedConfig = { + global: { + source: { + define: enforcedDefineConfig, + }, + server: { + compress: true, + htmlFallback: true, + }, + dev: { + lazyCompilation: true, + liveReload: true, + }, + }, + environments: { + client: { + ...commonEnvironmentConfig, + output: { + target: true, + distPath: { + ...publicAssetDistPathConfig, + js: true, + jsAsync: true, + }, + }, + }, + server: { + ...commonEnvironmentConfig, + output: { + target: true, + distPath: publicAssetDistPathConfig, + }, + }, + }, +} satisfies StartRsbuildEnforcedConfig + +function findOverriddenConfig( + config: unknown, + resolvedConfig: unknown, + enforced: EnforcedConfig, + path = '', + out: Array = [], +): Array { + if (!isObject(config) || !isObject(resolvedConfig)) { + return out + } + + for (const key in enforced) { + if (!(key in config) || !(key in resolvedConfig)) { + continue + } + + const rule = enforced[key]! + const configuredValue = config[key] + const resolvedValue = resolvedConfig[key] + + if (rule === true) { + if ( + !isDeepStrictEqual( + comparable(configuredValue), + comparable(resolvedValue), + ) + ) { + out.push(path + key) + } + } else { + findOverriddenConfig( + configuredValue, + resolvedValue, + rule, + `${path}${key}.`, + out, + ) + } + } + + return out +} + +function findRsbuildOverriddenConfig(opts: { + originalConfig: RsbuildConfig + resolvedConfig: RsbuildConfig + clientEnvironmentName: string + serverEnvironmentName: string + providerEnvironmentName: string +}): Array { + const overridden = findOverriddenConfig( + opts.originalConfig, + opts.resolvedConfig, + enforcedConfig.global, + ) + const originalBaseConfig = { ...opts.originalConfig } + delete originalBaseConfig.environments + const resolvedBaseConfig = { ...opts.resolvedConfig } + delete resolvedBaseConfig.environments + + const environmentNames = new Set([ + opts.clientEnvironmentName, + opts.serverEnvironmentName, + opts.providerEnvironmentName, + ]) + + for (const name of environmentNames) { + const explicitEnvironment = opts.originalConfig.environments?.[name] + const originalEnvironment = mergeRsbuildConfig( + originalBaseConfig, + explicitEnvironment, + ) + const resolvedEnvironment = mergeRsbuildConfig( + resolvedBaseConfig, + opts.resolvedConfig.environments?.[name], + ) + + // Root source.define conflicts are reported once by enforcedConfig.global. + // Only compare defines explicitly written in this environment here, or an + // inherited root define would be reported again for every environment. + if (originalEnvironment.source) { + originalEnvironment.source.define = explicitEnvironment?.source?.define + } + const roleConfig = + name === opts.clientEnvironmentName + ? enforcedConfig.environments.client + : name === opts.serverEnvironmentName || + name === opts.providerEnvironmentName + ? enforcedConfig.environments.server + : undefined + + if (roleConfig) { + findOverriddenConfig( + originalEnvironment, + resolvedEnvironment, + roleConfig, + `environments.${name}.`, + overridden, + ) + } + } + + return [...new Set(overridden)] +} + +export function warnOverriddenConfig(opts: { + originalConfig: RsbuildConfig + resolvedConfig: RsbuildConfig + clientEnvironmentName: string + serverEnvironmentName: string + providerEnvironmentName: string +}): void { + const overridden = findRsbuildOverriddenConfig(opts) + + if (overridden.length === 0) { + return + } + + console.error( + styleText( + ['bold', 'red'], + 'The following Rsbuild config options will be overridden by TanStack Start:', + ) + overridden.map((key) => `\n - ${key}`).join(''), + ) +} + +function comparable(value: unknown): unknown { + if (typeof value === 'string') { + const normalized = value.replaceAll('\\', '/') + return process.platform === 'win32' ? normalized.toLowerCase() : normalized + } + + if (Array.isArray(value)) { + return value.map(comparable) + } + + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, comparable(entry)]), + ) + } + + return value +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 65fbd94024a..7e81d910996 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -1,13 +1,12 @@ -import { createRequire } from 'node:module' import { join } from 'pathe' -import { mergeRsbuildConfig } from '@rsbuild/core' import { ENTRY_POINTS } from '../constants' -import type { EnvironmentConfig } from '@rsbuild/core' +import { normalizePublicBase } from '../planning' +import type { + EnvironmentConfig, + RsbuildConfig, + SourceConfig, +} from '@rsbuild/core' import type { ResolvedStartEntryPlan } from '../planning' -import type { RsbuildEnvironmentOverrides } from './types' -import type { ScriptFormat } from '@tanstack/router-core' - -const require = createRequire(import.meta.url) export const RSBUILD_ENVIRONMENT_NAMES = { client: 'client', @@ -87,156 +86,181 @@ export function createRsbuildResolvedEntryAliases(opts: { export interface RsbuildEnvironmentPlanResult { environments: Record - alias: Record } -export function createRsbuildEnvironmentPlan(opts: { - root: string - entryAliases: RsbuildResolvedEntryAliases - clientOutputDirectory: string - serverOutputDirectory: string - publicBase: string +export function createRsbuildEnvironmentDefaults(opts: { + environmentName: string + config: RsbuildConfig + isDev: boolean + rscEnabled: boolean serverFnProviderEnv: string - environmentOverrides?: RsbuildEnvironmentOverrides - scriptFormat?: ScriptFormat - rsc?: boolean | undefined - dev?: boolean | undefined -}): RsbuildEnvironmentPlanResult { - const alias = { - ...opts.entryAliases.alias, - ...(opts.rsc - ? { - 'react-server-dom-rspack/server$': resolveFromRoot( - 'react-server-dom-rspack/server.node', - opts.root, - ), - } - : {}), +}): EnvironmentConfig { + const environmentConfig = opts.config.environments?.[opts.environmentName] + const outputModuleConfigured = + environmentConfig?.output?.module !== undefined || + opts.config.output?.module !== undefined + + if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.client) { + return { + ...(!outputModuleConfigured + ? { + output: { + module: true, + }, + } + : {}), + ...(environmentConfig?.performance?.chunkSplit === undefined && + opts.config.performance?.chunkSplit === undefined + ? { + // Only split async chunks (route code-splitting). Keep all initial + // vendor/shared code inlined in the entry chunk so the SSR HTML + // only needs the single client entry bootstrap. + performance: { + chunkSplit: { + strategy: 'custom', + override: { + chunks: 'async', + }, + }, + }, + } + : {}), + } + } + + if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.server) { + return { + ...(opts.isDev && !outputModuleConfigured + ? { + // Rsbuild's dev `loadBundle()` path evaluates ESM via + // vm.SourceTextModule, which requires + // `--experimental-vm-modules`. Default the server environment to + // CJS so SSR works without extra Node flags. + output: { + module: false, + }, + } + : {}), + ...(opts.rscEnabled && + environmentConfig?.splitChunks === undefined && + opts.config.splitChunks === undefined + ? { + splitChunks: { + preset: 'single-vendor', + }, + } + : {}), + } } - const environmentOverrides = opts.environmentOverrides ?? {} - const scriptFormat = opts.scriptFormat ?? 'module' - const clientOutputModule = scriptFormat === 'module' - const userClientOutputModule = - environmentOverrides.client?.output?.module ?? - environmentOverrides.all?.output?.module if ( - typeof userClientOutputModule === 'boolean' && - userClientOutputModule !== clientOutputModule + opts.environmentName === opts.serverFnProviderEnv && + opts.isDev && + !opts.rscEnabled && + !outputModuleConfigured ) { - throw new Error( - 'TanStack Start rsbuild.client.output controls environments.client.output.module. Remove environments.client.output.module or set rsbuild.client.output to match it.', - ) + return { + output: { + module: false, + }, + } } + return {} +} + +export function createRsbuildEnvironmentPlan(opts: { + entryAliases: Pick + clientOutputDirectory: string + serverOutputDirectory: string + serverFnProviderEnv: string + enforcedDefines: NonNullable + enforcedAliases: Record + rsc?: boolean | undefined +}): RsbuildEnvironmentPlanResult { + const createEnvironment = (environment: { + entry: string + target: 'web' | 'node' + outputDirectory: string + includeJsAssets?: boolean + layer?: string + }): EnvironmentConfig => ({ + source: { + define: opts.enforcedDefines, + entry: { + index: { + import: environment.entry, + html: false, + ...(environment.layer ? { layer: environment.layer } : {}), + }, + }, + }, + output: { + target: environment.target, + distPath: environment.includeJsAssets + ? createClientAssetDistPath(environment.outputDirectory) + : createPublicAssetDistPath(environment.outputDirectory), + }, + resolve: { + alias: opts.enforcedAliases, + }, + }) + return { environments: { - [RSBUILD_ENVIRONMENT_NAMES.client]: mergeRsbuildConfig( - { - source: { - entry: { - index: { - import: opts.entryAliases.client, - html: false, - }, - }, - }, - output: { - target: 'web', - module: clientOutputModule, - distPath: createClientAssetDistPath(opts.clientOutputDirectory), - assetPrefix: opts.publicBase, - }, - resolve: { - alias, - }, - // Only split async chunks (route code-splitting). Keep all initial - // vendor/shared code inlined in the entry chunk so the SSR HTML only - // needs the single client entry bootstrap. - performance: { - chunkSplit: { - strategy: 'custom', - override: { - chunks: 'async', - }, - }, - }, - }, - environmentOverrides.all, - environmentOverrides.client, - ), - [RSBUILD_ENVIRONMENT_NAMES.server]: mergeRsbuildConfig( - { - source: { - entry: { - index: { - import: opts.entryAliases.server, - html: false, - ...(opts.rsc ? { layer: RSBUILD_RSC_LAYERS.ssr } : {}), - }, - }, - }, - output: { - target: 'node', - // Rsbuild's dev `loadBundle()` path evaluates ESM via vm.SourceTextModule, - // which requires `--experimental-vm-modules`. Emit CJS for the dev - // server bundle so SSR works without extra Node flags. - ...(opts.dev ? { module: false } : {}), - distPath: createPublicAssetDistPath(opts.serverOutputDirectory), - assetPrefix: opts.publicBase, - }, - resolve: { - alias, - }, - ...(opts.rsc - ? { - splitChunks: { - preset: 'single-vendor', - }, - } - : {}), - }, - environmentOverrides.all, - environmentOverrides.server, - ), + [RSBUILD_ENVIRONMENT_NAMES.client]: createEnvironment({ + entry: opts.entryAliases.client, + target: 'web', + outputDirectory: opts.clientOutputDirectory, + includeJsAssets: true, + }), + [RSBUILD_ENVIRONMENT_NAMES.server]: createEnvironment({ + entry: opts.entryAliases.server, + target: 'node', + outputDirectory: opts.serverOutputDirectory, + ...(opts.rsc ? { layer: RSBUILD_RSC_LAYERS.ssr } : {}), + }), // When provider is a separate environment (not layered RSC), // create a third environment. With the layered RSC setup this branch // is not taken because provider maps to the same `ssr` environment. ...(opts.serverFnProviderEnv !== RSBUILD_ENVIRONMENT_NAMES.server && !opts.rsc ? { - [opts.serverFnProviderEnv]: mergeRsbuildConfig( - { - source: { - entry: { - index: { - import: opts.entryAliases.server, - html: false, - }, - }, - }, - output: { - target: 'node', - ...(opts.dev ? { module: false } : {}), - distPath: createPublicAssetDistPath( - `${opts.serverOutputDirectory}/${opts.serverFnProviderEnv}`, - ), - assetPrefix: opts.publicBase, - }, - resolve: { - alias, - }, - }, - environmentOverrides.all, - environmentOverrides.provider, - ), + [opts.serverFnProviderEnv]: createEnvironment({ + entry: opts.entryAliases.server, + target: 'node', + outputDirectory: `${opts.serverOutputDirectory}/${opts.serverFnProviderEnv}`, + }), } : {}), }, - alias, } } +export function resolveRsbuildAssetBase(opts: { + config: Pick + environmentName?: string | undefined + action: 'dev' | 'build' | 'preview' | undefined +}): string { + const environment = opts.environmentName + ? opts.config.environments?.[opts.environmentName] + : undefined + const assetPrefix = + opts.action === 'dev' + ? (environment?.dev?.assetPrefix ?? opts.config.dev?.assetPrefix) + : (environment?.output?.assetPrefix ?? opts.config.output?.assetPrefix) + + if (assetPrefix === false) { + return '/' + } + + return normalizePublicBase( + typeof assetPrefix === 'string' && assetPrefix !== 'auto' + ? assetPrefix + : opts.config.server?.base, + ) +} + export function resolveRsbuildOutputDirectory(opts: { distPath: RsbuildDistPath | undefined rootDistPath: RsbuildDistPath | undefined @@ -265,9 +289,3 @@ export function resolveRsbuildOutputDirectory(opts: { function normalizeEntryPath(path: string) { return path.includes('\\') ? path.replaceAll('\\', '/') : path } - -function resolveFromRoot(specifier: string, root: string): string { - return require.resolve(specifier, { - paths: [root], - }) -} diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 3de4ed8c2f0..46ed20a673e 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -1,4 +1,5 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { joinURL } from 'ufo' @@ -9,13 +10,15 @@ import { } from '../config-context' import { escapeRegExp, normalizePath } from '../utils' import { createServerFnBasePath, normalizePublicBase } from '../planning' -import { parseStartConfig, rsbuildClientOutputSchema } from './schema' +import { parseStartConfig } from './schema' import { RSBUILD_CLIENT_ASSETS_DIR, RSBUILD_ENVIRONMENT_NAMES, RSBUILD_RSC_LAYERS, + createRsbuildEnvironmentDefaults, createRsbuildEnvironmentPlan, createRsbuildResolvedEntryAliases, + resolveRsbuildAssetBase, resolveRsbuildOutputDirectory, } from './planning' import { registerStartCompilerTransforms } from './start-compiler-host' @@ -29,10 +32,12 @@ import { registerClientBuildCapture } from './normalized-client-build' import { registerRouterPlugins } from './start-router-plugin' import { postBuildWithRsbuild } from './post-build' import { enableSwcReactServerComponents } from './swc-rsc' +import { warnOverriddenConfig } from './enforced-config' import type { ServerFn } from '../start-compiler/types' import type { TanStackStartRsbuildPluginCoreOptions } from './types' import type { ModifyRspackConfigFn, + RsbuildConfig, RsbuildDevServer, RsbuildPlugin, RsbuildPluginAPI, @@ -40,7 +45,8 @@ import type { rspack as rspackNamespaceType, } from '@rsbuild/core' import type { TanStackStartRsbuildInputConfig } from './schema' -import type { ScriptFormat } from '@tanstack/router-core' + +const require = createRequire(import.meta.url) // Detect whether this plugin source is running from inside the TanStack // Router monorepo (packages/start-plugin-core/src/rsbuild/plugin.ts). When @@ -80,9 +86,6 @@ export function tanStackStartRsbuild( const { getConfig, resolvedStartConfig } = configContext const serverFnProviderEnv = corePluginOpts.providerEnvironmentName const ssrIsProvider = corePluginOpts.ssrIsProvider - const scriptFormat = rsbuildClientOutputSchema.parse( - startPluginOpts.rsbuild?.client?.output ?? 'module', - ) satisfies ScriptFormat // RSC plugin instances — created lazily when rspack namespace is available let rscPlugins: RscPluginPair | undefined @@ -95,6 +98,9 @@ export function tanStackStartRsbuild( return { name: 'tanstack-start-rsbuild', setup(api: RsbuildPluginAPI) { + const isDev = api.context.action === 'dev' + const isPreview = api.context.action === 'preview' + const startCompilerEnvironments = [ { name: RSBUILD_ENVIRONMENT_NAMES.client, type: 'client' as const }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: 'server' as const }, @@ -108,183 +114,247 @@ export function tanStackStartRsbuild( .map((env) => env.name) // --------------------------------------------------------------- - // 1. modifyRsbuildConfig — resolve config, set up environments + // 1. Rsbuild config lifecycle + // + // TanStack Start treats Rsbuild config as three ordered layers: + // + // framework defaults -> user config -> framework enforced config + // + // - Defaults provide working conventions, but users can override them. + // - User config is the source of truth for every field Start does not + // own. It is also used to resolve Start's derived config below. + // - Enforced config contains invariants required by Start's runtime. + // It wins over conflicting user values, which are reported together + // by warnOverriddenConfig. + // + // Environment defaults are filled after Rsbuild has merged the root + // and environment configs. Framework invariants are applied through a + // post hook so they win over conflicting user values. // --------------------------------------------------------------- - api.modifyRsbuildConfig((rsbuildConfig, { mergeRsbuildConfig }) => { - const root = - typeof rsbuildConfig.root === 'string' - ? rsbuildConfig.root - : process.cwd() - - const serverBase = rsbuildConfig.server?.base - const assetPrefix = rsbuildConfig.output?.assetPrefix - const publicBase = normalizePublicBase( - typeof serverBase === 'string' - ? serverBase - : typeof assetPrefix === 'string' && assetPrefix !== 'auto' - ? assetPrefix - : undefined, - ) - const rootDistPath = rsbuildConfig.output?.distPath - const clientDistPath = - rsbuildConfig.environments?.[RSBUILD_ENVIRONMENT_NAMES.client]?.output - ?.distPath - const serverDistPath = - rsbuildConfig.environments?.[RSBUILD_ENVIRONMENT_NAMES.server]?.output - ?.distPath - - applyResolvedBaseAndOutput({ - resolvedStartConfig, - root, - publicBase, - clientOutputDirectory: resolveRsbuildOutputDirectory({ - distPath: clientDistPath, - rootDistPath, - fallback: 'dist/client', - subdirectory: 'client', - }), - serverOutputDirectory: resolveRsbuildOutputDirectory({ - distPath: serverDistPath, - rootDistPath, - fallback: 'dist/server', - subdirectory: 'server', - }), - }) + api.modifyEnvironmentConfig({ + order: 'pre', + handler(environmentConfig, { name, mergeEnvironmentConfig }) { + const defaults = createRsbuildEnvironmentDefaults({ + environmentName: name, + config: api.getRsbuildConfig(), + isDev, + rscEnabled, + serverFnProviderEnv, + }) - const { startConfig } = getConfig() - const routerBasepath = applyResolvedRouterBasepath({ - resolvedStartConfig, - startConfig, - }) + return mergeEnvironmentConfig(environmentConfig, defaults) + }, + }) - const resolvedEntryPlan = configContext.resolveEntries() - const isDev = api.context.action === 'dev' - const isPreview = api.context.action === 'preview' + // Framework invariants win over the config resolved above. + api.modifyRsbuildConfig({ + order: 'post', + handler(userConfigWithDefaults, { mergeRsbuildConfig }) { + const root = + typeof userConfigWithDefaults.root === 'string' + ? userConfigWithDefaults.root + : process.cwd() + + const publicBase = normalizePublicBase( + userConfigWithDefaults.server?.base, + ) + const assetBase = { + dev: resolveRsbuildAssetBase({ + config: userConfigWithDefaults, + environmentName: RSBUILD_ENVIRONMENT_NAMES.client, + action: 'dev', + }), + build: resolveRsbuildAssetBase({ + config: userConfigWithDefaults, + environmentName: RSBUILD_ENVIRONMENT_NAMES.client, + action: 'build', + }), + } + const rootDistPath = userConfigWithDefaults.output?.distPath + const clientDistPath = + userConfigWithDefaults.environments?.[ + RSBUILD_ENVIRONMENT_NAMES.client + ]?.output?.distPath + const serverDistPath = + userConfigWithDefaults.environments?.[ + RSBUILD_ENVIRONMENT_NAMES.server + ]?.output?.distPath + + applyResolvedBaseAndOutput({ + resolvedStartConfig, + root, + publicBase, + assetBase, + clientOutputDirectory: resolveRsbuildOutputDirectory({ + distPath: clientDistPath, + rootDistPath, + fallback: 'dist/client', + subdirectory: 'client', + }), + serverOutputDirectory: resolveRsbuildOutputDirectory({ + distPath: serverDistPath, + rootDistPath, + fallback: 'dist/server', + subdirectory: 'server', + }), + }) - const entryAliases = createRsbuildResolvedEntryAliases({ - entryPaths: resolvedEntryPlan.entryPaths, - }) + const { startConfig } = getConfig() + const routerBasepath = applyResolvedRouterBasepath({ + resolvedStartConfig, + startConfig, + }) - const environmentPlan = createRsbuildEnvironmentPlan({ - root, - entryAliases, - clientOutputDirectory: resolvedStartConfig.outputDirectories.client, - serverOutputDirectory: resolvedStartConfig.outputDirectories.server, - publicBase: resolvedStartConfig.basePaths.publicBase, - serverFnProviderEnv, - environmentOverrides: corePluginOpts.rsbuild?.environments, - scriptFormat, - rsc: rscOpts, - dev: isDev, - }) - const serverFnBase = createServerFnBasePath({ - routerBasepath, - serverFnBase: startConfig.serverFns.base, - }) - const inlineCssEnabled = - !isDev && startConfig.server.build.inlineCss.enabled + const resolvedEntryPlan = configContext.resolveEntries() - return mergeRsbuildConfig(rsbuildConfig, { - source: { - ...(rscEnabled - ? { - include: [ - // RSC needs SWC to inspect package code in node_modules so directives such as "use client" can be discovered. - // This follows Rsbuild's documented broad include form for compiling node_modules, with core-js excluded: - // https://rsbuild.rs/config/source/include#compile-node_modules - // - // TODO: Once the Rspack rule matching needed here is ready, narrow this to React-aware packages, for example via - // descriptionData: { "peerDependencies.react": /./ }, so unrelated dependencies are not sent through swc-loader. - { - not: /[\\/]core-js[\\/]/, - }, - ], - } - : {}), - define: { - 'process.env.TSS_SERVER_FN_BASE': JSON.stringify(serverFnBase), - 'import.meta.env.TSS_SERVER_FN_BASE': - JSON.stringify(serverFnBase), - 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify(routerBasepath), - 'import.meta.env.TSS_ROUTER_BASEPATH': - JSON.stringify(routerBasepath), - 'process.env.TSS_DEV_SERVER': JSON.stringify( - isDev ? 'true' : 'false', - ), - 'import.meta.env.TSS_DEV_SERVER': JSON.stringify( - isDev ? 'true' : 'false', - ), - // Rsbuild dev already injects emitted CSS asset hrefs, so keep - // Start's synthetic `/@tanstack-start/styles.css` path disabled. - 'process.env.TSS_DEV_SSR_STYLES_ENABLED': JSON.stringify('false'), - 'import.meta.env.TSS_DEV_SSR_STYLES_ENABLED': - JSON.stringify('false'), - 'process.env.TSS_DEV_SSR_STYLES_BASEPATH': JSON.stringify( - resolvedStartConfig.basePaths.publicBase, - ), - 'import.meta.env.TSS_DEV_SSR_STYLES_BASEPATH': JSON.stringify( - resolvedStartConfig.basePaths.publicBase, - ), - 'process.env.TSS_INLINE_CSS_ENABLED': JSON.stringify( - inlineCssEnabled ? 'true' : 'false', - ), - 'import.meta.env.TSS_INLINE_CSS_ENABLED': JSON.stringify( - inlineCssEnabled ? 'true' : 'false', - ), - 'process.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': JSON.stringify( + const entryAliases = createRsbuildResolvedEntryAliases({ + entryPaths: resolvedEntryPlan.entryPaths, + }) + + const serverFnBase = createServerFnBasePath({ + routerBasepath, + serverFnBase: startConfig.serverFns.base, + }) + const inlineCssEnabled = + !isDev && startConfig.server.build.inlineCss.enabled + const enforcedDefines = { + 'process.env.TSS_SERVER_FN_BASE': JSON.stringify(serverFnBase), + 'import.meta.env.TSS_SERVER_FN_BASE': JSON.stringify(serverFnBase), + 'process.env.TSS_ROUTER_BASEPATH': JSON.stringify(routerBasepath), + 'import.meta.env.TSS_ROUTER_BASEPATH': + JSON.stringify(routerBasepath), + 'process.env.TSS_DEV_SERVER': JSON.stringify( + isDev ? 'true' : 'false', + ), + 'import.meta.env.TSS_DEV_SERVER': JSON.stringify( + isDev ? 'true' : 'false', + ), + // Rsbuild dev already injects emitted CSS asset hrefs, so keep + // Start's synthetic `/@tanstack-start/styles.css` path disabled. + 'process.env.TSS_DEV_SSR_STYLES_ENABLED': JSON.stringify('false'), + 'import.meta.env.TSS_DEV_SSR_STYLES_ENABLED': + JSON.stringify('false'), + 'process.env.TSS_DEV_SSR_STYLES_BASEPATH': JSON.stringify( + resolvedStartConfig.basePaths.assetBase.dev, + ), + 'import.meta.env.TSS_DEV_SSR_STYLES_BASEPATH': JSON.stringify( + resolvedStartConfig.basePaths.assetBase.dev, + ), + 'process.env.TSS_INLINE_CSS_ENABLED': JSON.stringify( + inlineCssEnabled ? 'true' : 'false', + ), + 'import.meta.env.TSS_INLINE_CSS_ENABLED': JSON.stringify( + inlineCssEnabled ? 'true' : 'false', + ), + 'process.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': JSON.stringify( + startConfig.serverFns.disableCsrfMiddlewareWarning + ? 'true' + : 'false', + ), + 'import.meta.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': + JSON.stringify( startConfig.serverFns.disableCsrfMiddlewareWarning ? 'true' : 'false', ), - 'import.meta.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING': - JSON.stringify( - startConfig.serverFns.disableCsrfMiddlewareWarning - ? 'true' - : 'false', - ), - }, - }, - server: { - ...(rsbuildConfig.server?.printUrls === undefined || - rsbuildConfig.server.printUrls === true - ? { printUrls: ({ urls }: { urls: Array }) => urls } + } + const enforcedAliases = { + ...entryAliases.alias, + ...(rscEnabled + ? { + 'react-server-dom-rspack/server$': require.resolve( + 'react-server-dom-rspack/server.node', + { paths: [root] }, + ), + } : {}), - // Rsbuild compression currently treats Node's raw header array - // writeHead form as an object, which corrupts SSR response headers. - compress: false, - // SSR apps render every route on the server — disable HTML - // fallback so rsbuild doesn't intercept /_serverFn/ URLs. - htmlFallback: false, - // server.setup returned callback runs after built-in middleware - // but BEFORE fallback middleware — the ideal slot for SSR. - // Preview always installs the middleware since it is the only SSR - // handler; dev can opt out when a custom server hosts SSR. - ...(isPreview || - (isDev && - startPluginOpts.rsbuild?.installDevServerMiddleware !== false) + } + const environmentPlan = createRsbuildEnvironmentPlan({ + entryAliases, + clientOutputDirectory: resolvedStartConfig.outputDirectories.client, + serverOutputDirectory: resolvedStartConfig.outputDirectories.server, + serverFnProviderEnv, + enforcedDefines, + enforcedAliases, + rsc: rscOpts, + }) + + const frameworkEnforcedConfig: RsbuildConfig = { + source: { + ...(rscEnabled + ? { + include: [ + // RSC needs SWC to inspect package code in node_modules so directives such as "use client" can be discovered. + // This follows Rsbuild's documented broad include form for compiling node_modules, with core-js excluded: + // https://rsbuild.rs/config/source/include#compile-node_modules + // + // TODO: Once the Rspack rule matching needed here is ready, narrow this to React-aware packages, for example via + // descriptionData: { "peerDependencies.react": /./ }, so unrelated dependencies are not sent through swc-loader. + { + not: /[\\/]core-js[\\/]/, + }, + ], + } + : {}), + define: enforcedDefines, + }, + server: { + ...(userConfigWithDefaults.server?.printUrls === undefined || + userConfigWithDefaults.server.printUrls === true + ? { printUrls: ({ urls }: { urls: Array }) => urls } + : {}), + // Rsbuild compression currently treats Node's raw header array + // writeHead form as an object, which corrupts SSR response headers. + compress: false, + // SSR apps render every route on the server — disable HTML + // fallback so rsbuild doesn't intercept /_serverFn/ URLs. + htmlFallback: false, + // server.setup returned callback runs after built-in middleware + // but BEFORE fallback middleware — the ideal slot for SSR. + // Preview always installs the middleware since it is the only SSR + // handler; dev can opt out when a custom server hosts SSR. + ...(isPreview || + (isDev && + startPluginOpts.rsbuild?.installDevServerMiddleware !== false) + ? { + setup: createServerSetup({ + serverFnBasePath: serverFnBase, + serverOutputDirectory: + resolvedStartConfig.outputDirectories.server, + publicBase: resolvedStartConfig.basePaths.publicBase, + }), + } + : {}), + }, + ...(isDev ? { - setup: createServerSetup({ - serverFnBasePath: serverFnBase, - serverOutputDirectory: - resolvedStartConfig.outputDirectories.server, - publicBase: resolvedStartConfig.basePaths.publicBase, - }), + dev: { + lazyCompilation: false, + ...(rscEnabled ? { liveReload: false } : {}), + }, } : {}), - }, - ...(isDev - ? { - dev: { - lazyCompilation: false, - ...(rscEnabled ? { liveReload: false } : {}), - }, - } - : {}), - environments: environmentPlan.environments, - resolve: { - alias: environmentPlan.alias, - }, - }) + environments: environmentPlan.environments, + resolve: { + alias: enforcedAliases, + }, + } + + const resolvedConfig = mergeRsbuildConfig( + userConfigWithDefaults, + frameworkEnforcedConfig, + ) + + warnOverriddenConfig({ + originalConfig: api.getRsbuildConfig('original'), + resolvedConfig, + clientEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.client, + serverEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.server, + providerEnvironmentName: serverFnProviderEnv, + }) + + return resolvedConfig + }, }) // --------------------------------------------------------------- @@ -328,7 +398,6 @@ export function tanStackStartRsbuild( getDevClientEntryUrl: (publicBase: string) => joinURL(publicBase, RSBUILD_CLIENT_ASSETS_DIR, 'js/index.js'), rscEnabled, - scriptFormat, }) updateServerFnResolver = virtualModuleState.updateServerFnResolver diff --git a/packages/start-plugin-core/src/rsbuild/schema.ts b/packages/start-plugin-core/src/rsbuild/schema.ts index 86194e309c6..3b584924e4f 100644 --- a/packages/start-plugin-core/src/rsbuild/schema.ts +++ b/packages/start-plugin-core/src/rsbuild/schema.ts @@ -6,20 +6,12 @@ import { import type { CompileStartFrameworkOptions } from '../types' import type { InlineCssInputOptions } from '../schema' -export const rsbuildClientOutputSchema = z.enum(['module', 'iife']) - export const tanstackStartRsbuildOptionsSchema = tanstackStartOptionsObjectSchema .extend({ rsbuild: z .object({ installDevServerMiddleware: z.boolean().optional(), - client: z - .object({ - output: rsbuildClientOutputSchema.optional().default('module'), - }) - .optional() - .prefault({}), }) .optional(), }) @@ -42,9 +34,6 @@ export type TanStackStartRsbuildInputConfig = z.input< > & { rsbuild?: { installDevServerMiddleware?: boolean - client?: { - output?: z.input - } } server?: { build?: { diff --git a/packages/start-plugin-core/src/rsbuild/types.ts b/packages/start-plugin-core/src/rsbuild/types.ts index fa10d65f640..c7414f6cb71 100644 --- a/packages/start-plugin-core/src/rsbuild/types.ts +++ b/packages/start-plugin-core/src/rsbuild/types.ts @@ -1,20 +1,7 @@ -import type { EnvironmentConfig } from '@rsbuild/core' import type { TanStackStartCoreOptions } from '../types' -export interface RsbuildEnvironmentOverrides { - all?: EnvironmentConfig | undefined - client?: EnvironmentConfig | undefined - server?: EnvironmentConfig | undefined - provider?: EnvironmentConfig | undefined -} - -export interface RsbuildCoreOptions { - environments?: RsbuildEnvironmentOverrides | undefined -} - export type TanStackStartRsbuildPluginCoreOptions = TanStackStartCoreOptions & { providerEnvironmentName: string ssrIsProvider: boolean - rsbuild?: RsbuildCoreOptions | undefined rsc?: boolean | undefined } diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 591625ce1c0..b5124730784 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -235,7 +235,6 @@ export interface RegisterVirtualModulesOptions { getDevClientEntryUrl: (publicBase: string) => string /** Whether RSC virtual modules should be registered. */ rscEnabled?: boolean | undefined - scriptFormat: ScriptFormat } /** @@ -281,6 +280,14 @@ export function registerVirtualModules( opts.providerEnvName !== RSBUILD_ENVIRONMENT_NAMES.server const hasSerializationAdapters = Boolean(opts.serializationAdapters?.length) + function getScriptFormat(): ScriptFormat { + return api.getNormalizedConfig({ + environment: RSBUILD_ENVIRONMENT_NAMES.client, + }).output.module === false + ? 'iife' + : 'module' + } + function isProviderEnvironment(environmentName: string): boolean { return environmentName === opts.providerEnvName } @@ -371,21 +378,23 @@ export function registerVirtualModules( const { resolvedStartConfig, startConfig } = opts.getConfig() const isServerEnv = environmentName === RSBUILD_ENVIRONMENT_NAMES.server const isClientEnv = environmentName === RSBUILD_ENVIRONMENT_NAMES.client + const assetBase = isDev + ? resolvedStartConfig.basePaths.assetBase.dev + : resolvedStartConfig.basePaths.assetBase.build + const scriptFormat = getScriptFormat() const content: Record = {} // Manifest — only meaningful for server env if (isServerEnv) { - const devClientEntryUrl = opts.getDevClientEntryUrl( - resolvedStartConfig.basePaths.publicBase, - ) + const devClientEntryUrl = opts.getDevClientEntryUrl(assetBase) content[paths.manifest] = isDev - ? generateManifestModuleDev(devClientEntryUrl, opts.scriptFormat) + ? generateManifestModuleDev(devClientEntryUrl, scriptFormat) : generateManifestModuleBuild( clientBuild, - resolvedStartConfig.basePaths.publicBase, + assetBase, devClientEntryUrl, startConfig.server.build.inlineCss, - opts.scriptFormat, + scriptFormat, ) } else { content[paths.manifest] = 'export default {}' @@ -525,17 +534,18 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is generateManifestContent(newClientBuild: NormalizedClientBuild): string { const { resolvedStartConfig, startConfig } = opts.getConfig() - const devClientEntryUrl = opts.getDevClientEntryUrl( - resolvedStartConfig.basePaths.publicBase, - ) + const assetBase = isDev + ? resolvedStartConfig.basePaths.assetBase.dev + : resolvedStartConfig.basePaths.assetBase.build + const devClientEntryUrl = opts.getDevClientEntryUrl(assetBase) return generateManifestModuleBuild( newClientBuild, - resolvedStartConfig.basePaths.publicBase, + assetBase, devClientEntryUrl, !isDev ? startConfig.server.build.inlineCss : { enabled: false, transformAssets: false }, - opts.scriptFormat, + getScriptFormat(), ) }, @@ -545,11 +555,13 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is const { resolvedStartConfig, startConfig } = opts.getConfig() return serializeStartManifestData( newClientBuild, - resolvedStartConfig.basePaths.publicBase, + isDev + ? resolvedStartConfig.basePaths.assetBase.dev + : resolvedStartConfig.basePaths.assetBase.build, !isDev ? startConfig.server.build.inlineCss : { enabled: false, transformAssets: false }, - opts.scriptFormat, + getScriptFormat(), ) }, @@ -564,9 +576,9 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is } )[DEV_START_MANIFEST_GLOBAL] = buildStartManifestData( clientBuild, - resolvedStartConfig.basePaths.publicBase, + resolvedStartConfig.basePaths.assetBase.dev, { enabled: false, transformAssets: false }, - opts.scriptFormat, + getScriptFormat(), ) } }, diff --git a/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts b/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts new file mode 100644 index 00000000000..6545784d2f4 --- /dev/null +++ b/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts @@ -0,0 +1,188 @@ +import { stripVTControlCharacters } from 'node:util' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { warnOverriddenConfig } from '../../src/rsbuild/enforced-config' +import type { RsbuildConfig } from '@rsbuild/core' + +const frameworkDefine = { + 'process.env.TSS_SERVER_FN_BASE': '"/_serverFn/"', +} + +const resolvedConfig: RsbuildConfig = { + source: { + define: frameworkDefine, + }, + server: { + compress: false, + htmlFallback: false, + }, + environments: { + client: { + source: { + define: frameworkDefine, + entry: { + index: { + import: '/app/client.tsx', + html: false, + }, + }, + }, + output: { + target: 'web', + module: true, + }, + }, + ssr: { + source: { + define: frameworkDefine, + entry: { + index: { + import: '/app/server.ts', + html: false, + }, + }, + }, + output: { + target: 'node', + }, + }, + provider: { + source: { + define: frameworkDefine, + entry: { + index: { + import: 'C:/app/src/server.ts', + html: false, + }, + }, + }, + output: { + target: 'node', + }, + }, + }, +} + +const environmentNames = { + clientEnvironmentName: 'client', + serverEnvironmentName: 'ssr', + providerEnvironmentName: 'provider', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('warnOverriddenConfig', () => { + test('prints all overridden paths in one warning', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + warnOverriddenConfig({ + originalConfig: { + source: { + define: { + 'process.env.TSS_SERVER_FN_BASE': '"/custom/"', + }, + }, + server: { + base: '/app/', + compress: true, + htmlFallback: 'index', + }, + output: { + assetPrefix: 'https://cdn.example.com/', + }, + environments: { + client: { + source: { + define: { + 'process.env.TSS_SERVER_FN_BASE': '"/client-custom/"', + }, + entry: { + index: './src/custom-client.tsx', + }, + }, + output: { + target: 'node', + module: false, + }, + }, + provider: { + source: { + entry: { + index: './src/custom-provider.ts', + }, + }, + output: { + target: 'web', + }, + }, + }, + }, + resolvedConfig, + ...environmentNames, + }) + + expect(error).toHaveBeenCalledOnce() + expect( + stripVTControlCharacters(error.mock.calls[0]![0]), + ).toMatchInlineSnapshot(` + "The following Rsbuild config options will be overridden by TanStack Start: + - source.define.process.env.TSS_SERVER_FN_BASE + - server.compress + - server.htmlFallback + - environments.client.source.define.process.env.TSS_SERVER_FN_BASE + - environments.client.source.entry.index + - environments.client.output.target + - environments.provider.source.entry.index + - environments.provider.output.target" + `) + }) + + test('does not print compatible or user-owned config', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + warnOverriddenConfig({ + originalConfig: { + server: { + base: '/app/', + compress: false, + htmlFallback: false, + }, + dev: { + assetPrefix: '/dev-assets/', + }, + output: { + assetPrefix: 'https://cdn.example.com/', + }, + environments: { + client: { + source: resolvedConfig.environments!.client!.source, + output: { + target: 'web', + module: true, + assetPrefix: '/client-assets/', + }, + }, + provider: { + source: { + entry: { + index: { + import: 'C:\\app\\src\\server.ts', + html: false, + }, + }, + }, + output: { + target: 'node', + module: true, + }, + }, + }, + }, + resolvedConfig, + ...environmentNames, + }) + + expect(error).not.toHaveBeenCalled() + }) +}) diff --git a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts index c0185a74eb3..17576a6d727 100644 --- a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts @@ -1,9 +1,105 @@ import { describe, expect, test } from 'vitest' import { + createRsbuildEnvironmentDefaults, createRsbuildEnvironmentPlan, + resolveRsbuildAssetBase, resolveRsbuildOutputDirectory, } from '../../src/rsbuild/planning' +describe('createRsbuildEnvironmentDefaults', () => { + test('provides framework defaults when the user leaves them unset', () => { + const clientDefaults = createRsbuildEnvironmentDefaults({ + environmentName: 'client', + config: {}, + isDev: true, + rscEnabled: true, + serverFnProviderEnv: 'ssr', + }) + const serverDefaults = createRsbuildEnvironmentDefaults({ + environmentName: 'ssr', + config: {}, + isDev: true, + rscEnabled: true, + serverFnProviderEnv: 'ssr', + }) + + expect(clientDefaults.output?.module).toBe(true) + expect(clientDefaults.performance?.chunkSplit).toEqual({ + strategy: 'custom', + override: { + chunks: 'async', + }, + }) + expect(serverDefaults.output?.module).toBe(false) + expect(serverDefaults.splitChunks).toEqual({ + preset: 'single-vendor', + }) + }) + + test('does not shadow values in the shared client config', () => { + const defaults = createRsbuildEnvironmentDefaults({ + environmentName: 'client', + config: { + output: { + module: false, + }, + performance: { + chunkSplit: { + strategy: 'split-by-experience', + }, + }, + }, + isDev: true, + rscEnabled: true, + serverFnProviderEnv: 'ssr', + }) + + expect(defaults).toEqual({}) + }) + + test('does not shadow values in the shared server config', () => { + const defaults = createRsbuildEnvironmentDefaults({ + environmentName: 'ssr', + config: { + output: { + module: true, + }, + splitChunks: false, + }, + isDev: true, + rscEnabled: true, + serverFnProviderEnv: 'ssr', + }) + + expect(defaults).toEqual({}) + }) + + test('does not shadow values in an environment config', () => { + const defaults = createRsbuildEnvironmentDefaults({ + environmentName: 'client', + config: { + environments: { + client: { + output: { + module: false, + }, + performance: { + chunkSplit: { + strategy: 'all-in-one', + }, + }, + }, + }, + }, + isDev: true, + rscEnabled: false, + serverFnProviderEnv: 'ssr', + }) + + expect(defaults).toEqual({}) + }) +}) + describe('resolveRsbuildOutputDirectory', () => { test('uses explicit environment distPath string', () => { expect( @@ -61,9 +157,86 @@ describe('resolveRsbuildOutputDirectory', () => { }) }) +describe('resolveRsbuildAssetBase', () => { + test('uses the production asset prefix for build and preview', () => { + for (const action of ['build', 'preview'] as const) { + expect( + resolveRsbuildAssetBase({ + action, + config: { + server: { base: '/app/' }, + output: { assetPrefix: 'https://cdn.example.com/assets/' }, + }, + }), + ).toBe('https://cdn.example.com/assets/') + } + }) + + test('uses the development asset prefix in dev', () => { + expect( + resolveRsbuildAssetBase({ + action: 'dev', + config: { + dev: { assetPrefix: '/dev-assets/' }, + output: { assetPrefix: 'https://cdn.example.com/assets/' }, + server: { base: '/app/' }, + }, + }), + ).toBe('/dev-assets/') + }) + + test('prefers the client environment asset prefix over the root config', () => { + expect( + resolveRsbuildAssetBase({ + action: 'build', + environmentName: 'client', + config: { + output: { assetPrefix: '/root-assets/' }, + environments: { + client: { + output: { assetPrefix: 'https://cdn.example.com/client/' }, + }, + ssr: { + output: { assetPrefix: '/server-assets/' }, + }, + }, + }, + }), + ).toBe('https://cdn.example.com/client/') + }) + + test('prefers the client development asset prefix in dev', () => { + expect( + resolveRsbuildAssetBase({ + action: 'dev', + environmentName: 'client', + config: { + dev: { assetPrefix: '/root-dev-assets/' }, + environments: { + client: { + dev: { assetPrefix: '/client-dev-assets/' }, + }, + }, + }, + }), + ).toBe('/client-dev-assets/') + }) + + test('falls back to server.base when the active asset prefix is not concrete', () => { + expect( + resolveRsbuildAssetBase({ + action: 'build', + config: { + output: { assetPrefix: 'auto' }, + server: { base: '/app/' }, + }, + }), + ).toBe('/app/') + }) +}) + describe('createRsbuildEnvironmentPlan client output', () => { const baseOptions = { - root: '/app', entryAliases: { client: '/app/src/client.tsx', server: '/app/src/server.ts', @@ -78,59 +251,47 @@ describe('createRsbuildEnvironmentPlan client output', () => { }, clientOutputDirectory: 'dist/client', serverOutputDirectory: 'dist/server', - publicBase: '/_build/', serverFnProviderEnv: 'ssr', + enforcedDefines: {}, + enforcedAliases: { + 'virtual:tanstack-start-client-entry': '/app/src/client.tsx', + 'virtual:tanstack-start-server-entry': '/app/src/server.ts', + '#tanstack-start-entry': '/app/src/start.ts', + '#tanstack-router-entry': '/app/src/router.tsx', + }, } - test('sets client output.module from scriptFormat', () => { - expect( - createRsbuildEnvironmentPlan({ - ...baseOptions, - scriptFormat: 'iife', - }).environments.client!.output?.module, - ).toBe(false) + test('leaves assetPrefix unset so environments inherit the root config', () => { + const environments = createRsbuildEnvironmentPlan(baseOptions).environments + expect(environments.client!.output?.assetPrefix).toBeUndefined() + expect(environments.ssr!.output?.assetPrefix).toBeUndefined() + expect(environments.client!.performance).toBeUndefined() expect( - createRsbuildEnvironmentPlan({ - ...baseOptions, - scriptFormat: 'module', - }).environments.client!.output?.module, - ).toBe(true) - }) - - test('throws when client output.module conflicts with scriptFormat', () => { - expect(() => - createRsbuildEnvironmentPlan({ - ...baseOptions, - scriptFormat: 'iife', - environmentOverrides: { - client: { - output: { - module: true, - }, - }, - }, - }), - ).toThrow( - 'TanStack Start rsbuild.client.output controls environments.client.output.module', - ) + createRsbuildEnvironmentPlan({ ...baseOptions, rsc: true }).environments + .ssr!.splitChunks, + ).toBeUndefined() }) - test('throws when shared output.module conflicts with scriptFormat', () => { - expect(() => - createRsbuildEnvironmentPlan({ - ...baseOptions, - scriptFormat: 'iife', - environmentOverrides: { - all: { - output: { - module: true, - }, - }, - }, - }), - ).toThrow( - 'TanStack Start rsbuild.client.output controls environments.client.output.module', + test('applies enforced defines and aliases to every managed environment', () => { + const enforcedDefines = { + 'process.env.TSS_SERVER_FN_BASE': '"/_serverFn/"', + } + const environments = createRsbuildEnvironmentPlan({ + ...baseOptions, + serverFnProviderEnv: 'server-fn', + enforcedDefines, + }).environments + + expect(environments.client!.source?.define).toBe(enforcedDefines) + expect(environments.ssr!.source?.define).toBe(enforcedDefines) + expect(environments['server-fn']!.source?.define).toBe(enforcedDefines) + expect(environments.client!.resolve?.alias).toBe( + baseOptions.enforcedAliases, + ) + expect(environments.ssr!.resolve?.alias).toBe(baseOptions.enforcedAliases) + expect(environments['server-fn']!.resolve?.alias).toBe( + baseOptions.enforcedAliases, ) }) }) From c1350fad92604198434690b50fe6232ab86f243b Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 19:26:27 +0800 Subject: [PATCH 2/6] chore: refactor test case --- .../tests/rsbuild/output-directory.test.ts | 174 ------------------ 1 file changed, 174 deletions(-) diff --git a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts index 17576a6d727..c437c657e59 100644 --- a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts @@ -1,105 +1,9 @@ import { describe, expect, test } from 'vitest' import { - createRsbuildEnvironmentDefaults, createRsbuildEnvironmentPlan, - resolveRsbuildAssetBase, resolveRsbuildOutputDirectory, } from '../../src/rsbuild/planning' -describe('createRsbuildEnvironmentDefaults', () => { - test('provides framework defaults when the user leaves them unset', () => { - const clientDefaults = createRsbuildEnvironmentDefaults({ - environmentName: 'client', - config: {}, - isDev: true, - rscEnabled: true, - serverFnProviderEnv: 'ssr', - }) - const serverDefaults = createRsbuildEnvironmentDefaults({ - environmentName: 'ssr', - config: {}, - isDev: true, - rscEnabled: true, - serverFnProviderEnv: 'ssr', - }) - - expect(clientDefaults.output?.module).toBe(true) - expect(clientDefaults.performance?.chunkSplit).toEqual({ - strategy: 'custom', - override: { - chunks: 'async', - }, - }) - expect(serverDefaults.output?.module).toBe(false) - expect(serverDefaults.splitChunks).toEqual({ - preset: 'single-vendor', - }) - }) - - test('does not shadow values in the shared client config', () => { - const defaults = createRsbuildEnvironmentDefaults({ - environmentName: 'client', - config: { - output: { - module: false, - }, - performance: { - chunkSplit: { - strategy: 'split-by-experience', - }, - }, - }, - isDev: true, - rscEnabled: true, - serverFnProviderEnv: 'ssr', - }) - - expect(defaults).toEqual({}) - }) - - test('does not shadow values in the shared server config', () => { - const defaults = createRsbuildEnvironmentDefaults({ - environmentName: 'ssr', - config: { - output: { - module: true, - }, - splitChunks: false, - }, - isDev: true, - rscEnabled: true, - serverFnProviderEnv: 'ssr', - }) - - expect(defaults).toEqual({}) - }) - - test('does not shadow values in an environment config', () => { - const defaults = createRsbuildEnvironmentDefaults({ - environmentName: 'client', - config: { - environments: { - client: { - output: { - module: false, - }, - performance: { - chunkSplit: { - strategy: 'all-in-one', - }, - }, - }, - }, - }, - isDev: true, - rscEnabled: false, - serverFnProviderEnv: 'ssr', - }) - - expect(defaults).toEqual({}) - }) -}) - describe('resolveRsbuildOutputDirectory', () => { test('uses explicit environment distPath string', () => { expect( @@ -157,84 +61,6 @@ describe('resolveRsbuildOutputDirectory', () => { }) }) -describe('resolveRsbuildAssetBase', () => { - test('uses the production asset prefix for build and preview', () => { - for (const action of ['build', 'preview'] as const) { - expect( - resolveRsbuildAssetBase({ - action, - config: { - server: { base: '/app/' }, - output: { assetPrefix: 'https://cdn.example.com/assets/' }, - }, - }), - ).toBe('https://cdn.example.com/assets/') - } - }) - - test('uses the development asset prefix in dev', () => { - expect( - resolveRsbuildAssetBase({ - action: 'dev', - config: { - dev: { assetPrefix: '/dev-assets/' }, - output: { assetPrefix: 'https://cdn.example.com/assets/' }, - server: { base: '/app/' }, - }, - }), - ).toBe('/dev-assets/') - }) - - test('prefers the client environment asset prefix over the root config', () => { - expect( - resolveRsbuildAssetBase({ - action: 'build', - environmentName: 'client', - config: { - output: { assetPrefix: '/root-assets/' }, - environments: { - client: { - output: { assetPrefix: 'https://cdn.example.com/client/' }, - }, - ssr: { - output: { assetPrefix: '/server-assets/' }, - }, - }, - }, - }), - ).toBe('https://cdn.example.com/client/') - }) - - test('prefers the client development asset prefix in dev', () => { - expect( - resolveRsbuildAssetBase({ - action: 'dev', - environmentName: 'client', - config: { - dev: { assetPrefix: '/root-dev-assets/' }, - environments: { - client: { - dev: { assetPrefix: '/client-dev-assets/' }, - }, - }, - }, - }), - ).toBe('/client-dev-assets/') - }) - - test('falls back to server.base when the active asset prefix is not concrete', () => { - expect( - resolveRsbuildAssetBase({ - action: 'build', - config: { - output: { assetPrefix: 'auto' }, - server: { base: '/app/' }, - }, - }), - ).toBe('/app/') - }) -}) - describe('createRsbuildEnvironmentPlan client output', () => { const baseOptions = { entryAliases: { From 4470dcdacb6b6ccd3a0cffc4a18cbeab30de20bd Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 19:45:22 +0800 Subject: [PATCH 3/6] fix: rsbuild defaults config for distPath --- .../custom-server-rsbuild/rsbuild.config.ts | 15 +- .../src/rsbuild/enforced-config.ts | 23 +-- .../start-plugin-core/src/rsbuild/planning.ts | 145 +++++++++++++++--- .../start-plugin-core/src/rsbuild/plugin.ts | 6 - .../src/rsbuild/virtual-modules.ts | 19 ++- .../tests/rsbuild/enforced-config.test.ts | 11 +- .../tests/rsbuild/output-directory.test.ts | 4 +- 7 files changed, 148 insertions(+), 75 deletions(-) diff --git a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts index dec3a6d0fcf..ca1c55b3c6a 100644 --- a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts +++ b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts @@ -7,10 +7,6 @@ import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' // non-default client chunk layout. The combination that matters for the // repro is: // -// - `client.output: 'iife'` — emit the client entry as a self-executing -// script. The manifest uses plain script tags and classic script preloads; -// setting IIFE here exercises that non-module asset path. -// // - Client `runtimeChunk: 'single'` — extracts the webpack runtime into // its own chunk. With IIFE plain scripts, the entry can't bootstrap // until the runtime has executed, so `` has to emit a @@ -18,23 +14,19 @@ import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' // the regression this fixture covers. // // - `client.distPath.root` + `distPath.js: ''` — flat layout, JS at the -// dist root. Matches the path `express-server.ts` serves via -// `express.static('dist/client')`. +// dist root mounted by `express-server.ts`. // // - `performance.buildCache: true` — exercise the rspack persistent // cache, including warm-restart paths. // -// - `output.assetPrefix: '/static/'` — force manifest URLs through an -// explicit prefix. +// - `output.assetPrefix: '/static/'` — force manifest URLs through the +// explicit prefix mounted by `express-server.ts`. export default defineConfig({ plugins: [ pluginReact({ splitChunks: false }), tanstackStart({ rsbuild: { installDevServerMiddleware: false, - client: { - output: 'iife', - }, }, }), ], @@ -47,6 +39,7 @@ export default defineConfig({ environments: { client: { output: { + module: false, distPath: { root: path.resolve(__dirname, 'dist/client'), js: '', diff --git a/packages/start-plugin-core/src/rsbuild/enforced-config.ts b/packages/start-plugin-core/src/rsbuild/enforced-config.ts index 9152070c8e0..2ffc4332140 100644 --- a/packages/start-plugin-core/src/rsbuild/enforced-config.ts +++ b/packages/start-plugin-core/src/rsbuild/enforced-config.ts @@ -50,24 +50,13 @@ const commonEnvironmentConfig = { }, } satisfies EnforcedConfig -const publicAssetDistPathConfig = { - css: true, - cssAsync: true, - svg: true, - font: true, - wasm: true, - image: true, - media: true, - assets: true, -} satisfies EnforcedConfig - /** * Rsbuild config fields that TanStack Start owns. * * A `true` leaf means that Start writes the final value for that field. Keep - * user-owned fields such as `server.base`, `dev.assetPrefix`, and - * `output.assetPrefix` out of this object: Start consumes those values but - * must not claim ownership of them. + * user-owned fields such as `server.base`, `dev.assetPrefix`, + * `output.assetPrefix`, and `output.distPath` out of this object: Start + * consumes those values but must not claim ownership of them. */ const enforcedConfig = { global: { @@ -88,18 +77,12 @@ const enforcedConfig = { ...commonEnvironmentConfig, output: { target: true, - distPath: { - ...publicAssetDistPathConfig, - js: true, - jsAsync: true, - }, }, }, server: { ...commonEnvironmentConfig, output: { target: true, - distPath: publicAssetDistPathConfig, }, }, }, diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index 7e81d910996..c4811ce371e 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -29,7 +29,9 @@ export const RSBUILD_CLIENT_ASSETS_DIR = 'assets' export type RsbuildEnvironmentName = (typeof RSBUILD_ENVIRONMENT_NAMES)[keyof typeof RSBUILD_ENVIRONMENT_NAMES] -type RsbuildDistPath = NonNullable['distPath'] +type RsbuildDistPath = NonNullable< + NonNullable['distPath'] +> type RsbuildDistPathObject = Exclude function createPublicAssetDistPath(root: string): RsbuildDistPathObject { @@ -54,6 +56,93 @@ function createClientAssetDistPath(root: string): RsbuildDistPathObject { } } +function getDistPathProperty( + distPath: RsbuildDistPath | undefined, + key: keyof RsbuildDistPathObject, +): unknown { + if (typeof distPath === 'string') { + return key === 'root' ? distPath : undefined + } + + return distPath?.[key] +} + +function createEnvironmentDistPathDefaults(opts: { + outputDirectory: string + environmentDistPath: RsbuildDistPath | undefined + rootDistPath: RsbuildDistPath | undefined + includeJsAssets: boolean +}): RsbuildDistPathObject | undefined { + const defaultDistPath = opts.includeJsAssets + ? createClientAssetDistPath(opts.outputDirectory) + : createPublicAssetDistPath(opts.outputDirectory) + const entries = Object.entries(defaultDistPath).filter(([key]) => { + const distPathKey = key as keyof RsbuildDistPathObject + + if (distPathKey === 'root') { + // An explicit environment root wins. Otherwise provide the Start + // convention derived from the shared root output directory. + return ( + getDistPathProperty(opts.environmentDistPath, distPathKey) === undefined + ) + } + + return ( + getDistPathProperty(opts.environmentDistPath, distPathKey) === + undefined && + getDistPathProperty(opts.rootDistPath, distPathKey) === undefined + ) + }) + + return entries.length > 0 + ? (Object.fromEntries(entries) as RsbuildDistPathObject) + : undefined +} + +function resolveEnvironmentOutputDirectory(opts: { + environmentName: string + config: RsbuildConfig + serverFnProviderEnv: string +}): string | undefined { + const rootDistPath = opts.config.output?.distPath + const environmentDistPath = + opts.config.environments?.[opts.environmentName]?.output?.distPath + + if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.client) { + return resolveRsbuildOutputDirectory({ + distPath: environmentDistPath, + rootDistPath, + fallback: 'dist/client', + subdirectory: 'client', + }) + } + + const serverDistPath = + opts.config.environments?.[RSBUILD_ENVIRONMENT_NAMES.server]?.output + ?.distPath + const serverOutputDirectory = resolveRsbuildOutputDirectory({ + distPath: serverDistPath, + rootDistPath, + fallback: 'dist/server', + subdirectory: 'server', + }) + + if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.server) { + return serverOutputDirectory + } + + if (opts.environmentName === opts.serverFnProviderEnv) { + return resolveRsbuildOutputDirectory({ + distPath: environmentDistPath, + rootDistPath: undefined, + fallback: join(serverOutputDirectory, opts.serverFnProviderEnv), + subdirectory: opts.serverFnProviderEnv, + }) + } + + return undefined +} + export interface RsbuildResolvedEntryAliases { client: string server: string @@ -96,16 +185,28 @@ export function createRsbuildEnvironmentDefaults(opts: { serverFnProviderEnv: string }): EnvironmentConfig { const environmentConfig = opts.config.environments?.[opts.environmentName] + const outputDirectory = resolveEnvironmentOutputDirectory(opts) + const rootDistPath = opts.config.output?.distPath + const distPathDefaults = outputDirectory + ? createEnvironmentDistPathDefaults({ + outputDirectory, + environmentDistPath: environmentConfig?.output?.distPath, + rootDistPath, + includeJsAssets: + opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.client, + }) + : undefined const outputModuleConfigured = environmentConfig?.output?.module !== undefined || opts.config.output?.module !== undefined if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.client) { return { - ...(!outputModuleConfigured + ...(distPathDefaults || !outputModuleConfigured ? { output: { - module: true, + ...(distPathDefaults ? { distPath: distPathDefaults } : {}), + ...(!outputModuleConfigured ? { module: true } : {}), }, } : {}), @@ -130,14 +231,17 @@ export function createRsbuildEnvironmentDefaults(opts: { if (opts.environmentName === RSBUILD_ENVIRONMENT_NAMES.server) { return { - ...(opts.isDev && !outputModuleConfigured + ...(distPathDefaults || (opts.isDev && !outputModuleConfigured) ? { // Rsbuild's dev `loadBundle()` path evaluates ESM via // vm.SourceTextModule, which requires // `--experimental-vm-modules`. Default the server environment to // CJS so SSR works without extra Node flags. output: { - module: false, + ...(distPathDefaults ? { distPath: distPathDefaults } : {}), + ...(opts.isDev && !outputModuleConfigured + ? { module: false } + : {}), }, } : {}), @@ -153,16 +257,18 @@ export function createRsbuildEnvironmentDefaults(opts: { } } - if ( - opts.environmentName === opts.serverFnProviderEnv && - opts.isDev && - !opts.rscEnabled && - !outputModuleConfigured - ) { + if (opts.environmentName === opts.serverFnProviderEnv && !opts.rscEnabled) { return { - output: { - module: false, - }, + ...(distPathDefaults || (opts.isDev && !outputModuleConfigured) + ? { + output: { + ...(distPathDefaults ? { distPath: distPathDefaults } : {}), + ...(opts.isDev && !outputModuleConfigured + ? { module: false } + : {}), + }, + } + : {}), } } @@ -171,8 +277,6 @@ export function createRsbuildEnvironmentDefaults(opts: { export function createRsbuildEnvironmentPlan(opts: { entryAliases: Pick - clientOutputDirectory: string - serverOutputDirectory: string serverFnProviderEnv: string enforcedDefines: NonNullable enforcedAliases: Record @@ -181,8 +285,6 @@ export function createRsbuildEnvironmentPlan(opts: { const createEnvironment = (environment: { entry: string target: 'web' | 'node' - outputDirectory: string - includeJsAssets?: boolean layer?: string }): EnvironmentConfig => ({ source: { @@ -197,9 +299,6 @@ export function createRsbuildEnvironmentPlan(opts: { }, output: { target: environment.target, - distPath: environment.includeJsAssets - ? createClientAssetDistPath(environment.outputDirectory) - : createPublicAssetDistPath(environment.outputDirectory), }, resolve: { alias: opts.enforcedAliases, @@ -211,13 +310,10 @@ export function createRsbuildEnvironmentPlan(opts: { [RSBUILD_ENVIRONMENT_NAMES.client]: createEnvironment({ entry: opts.entryAliases.client, target: 'web', - outputDirectory: opts.clientOutputDirectory, - includeJsAssets: true, }), [RSBUILD_ENVIRONMENT_NAMES.server]: createEnvironment({ entry: opts.entryAliases.server, target: 'node', - outputDirectory: opts.serverOutputDirectory, ...(opts.rsc ? { layer: RSBUILD_RSC_LAYERS.ssr } : {}), }), // When provider is a separate environment (not layered RSC), @@ -229,7 +325,6 @@ export function createRsbuildEnvironmentPlan(opts: { [opts.serverFnProviderEnv]: createEnvironment({ entry: opts.entryAliases.server, target: 'node', - outputDirectory: `${opts.serverOutputDirectory}/${opts.serverFnProviderEnv}`, }), } : {}), diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 46ed20a673e..30fd42344a3 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -2,7 +2,6 @@ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { joinURL } from 'ufo' import { applyResolvedBaseAndOutput, applyResolvedRouterBasepath, @@ -12,7 +11,6 @@ import { escapeRegExp, normalizePath } from '../utils' import { createServerFnBasePath, normalizePublicBase } from '../planning' import { parseStartConfig } from './schema' import { - RSBUILD_CLIENT_ASSETS_DIR, RSBUILD_ENVIRONMENT_NAMES, RSBUILD_RSC_LAYERS, createRsbuildEnvironmentDefaults, @@ -271,8 +269,6 @@ export function tanStackStartRsbuild( } const environmentPlan = createRsbuildEnvironmentPlan({ entryAliases, - clientOutputDirectory: resolvedStartConfig.outputDirectories.client, - serverOutputDirectory: resolvedStartConfig.outputDirectories.server, serverFnProviderEnv, enforcedDefines, enforcedAliases, @@ -395,8 +391,6 @@ export function tanStackStartRsbuild( providerEnvName: serverFnProviderEnv, ssrIsProvider, serializationAdapters: corePluginOpts.serializationAdapters, - getDevClientEntryUrl: (publicBase: string) => - joinURL(publicBase, RSBUILD_CLIENT_ASSETS_DIR, 'js/index.js'), rscEnabled, }) updateServerFnResolver = virtualModuleState.updateServerFnResolver diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index b5124730784..f0767b018c7 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -1,4 +1,5 @@ import { VIRTUAL_MODULES } from '@tanstack/start-server-core/virtual-modules' +import { joinURL } from 'ufo' import { generateSerializationAdaptersModule } from '../serialization-adapters-module' import { generateServerFnResolverModule } from '../start-compiler/server-fn-resolver-module' import { buildStartManifest } from '../start-manifest-plugin/manifestBuilder' @@ -227,12 +228,6 @@ export interface RegisterVirtualModulesOptions { providerEnvName: string ssrIsProvider: boolean serializationAdapters: Array | undefined - /** - * Get the URL at which the rsbuild dev server serves the client entry JS. - * Called lazily inside modifyRspackConfig when getConfig() is available. - * Example return: '/assets/js/index.js' - */ - getDevClientEntryUrl: (publicBase: string) => string /** Whether RSC virtual modules should be registered. */ rscEnabled?: boolean | undefined } @@ -288,6 +283,14 @@ export function registerVirtualModules( : 'module' } + function getDevClientEntryUrl(assetBase: string): string { + const clientConfig = api.getNormalizedConfig({ + environment: RSBUILD_ENVIRONMENT_NAMES.client, + }) + + return joinURL(assetBase, clientConfig.output.distPath.js, 'index.js') + } + function isProviderEnvironment(environmentName: string): boolean { return environmentName === opts.providerEnvName } @@ -386,7 +389,7 @@ export function registerVirtualModules( // Manifest — only meaningful for server env if (isServerEnv) { - const devClientEntryUrl = opts.getDevClientEntryUrl(assetBase) + const devClientEntryUrl = getDevClientEntryUrl(assetBase) content[paths.manifest] = isDev ? generateManifestModuleDev(devClientEntryUrl, scriptFormat) : generateManifestModuleBuild( @@ -537,7 +540,7 @@ export function createFromReadableStream() { throw new Error('RSC SSR decode is const assetBase = isDev ? resolvedStartConfig.basePaths.assetBase.dev : resolvedStartConfig.basePaths.assetBase.build - const devClientEntryUrl = opts.getDevClientEntryUrl(assetBase) + const devClientEntryUrl = getDevClientEntryUrl(assetBase) return generateManifestModuleBuild( newClientBuild, assetBase, diff --git a/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts b/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts index 6545784d2f4..8b44b0c8444 100644 --- a/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/enforced-config.test.ts @@ -29,6 +29,9 @@ const resolvedConfig: RsbuildConfig = { output: { target: 'web', module: true, + distPath: { + js: 'assets/js', + }, }, }, ssr: { @@ -123,9 +126,8 @@ describe('warnOverriddenConfig', () => { }) expect(error).toHaveBeenCalledOnce() - expect( - stripVTControlCharacters(error.mock.calls[0]![0]), - ).toMatchInlineSnapshot(` + expect(stripVTControlCharacters(error.mock.calls[0]![0])) + .toMatchInlineSnapshot(` "The following Rsbuild config options will be overridden by TanStack Start: - source.define.process.env.TSS_SERVER_FN_BASE - server.compress @@ -161,6 +163,9 @@ describe('warnOverriddenConfig', () => { target: 'web', module: true, assetPrefix: '/client-assets/', + distPath: { + js: '', + }, }, }, provider: { diff --git a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts index c437c657e59..b62cf9bd11f 100644 --- a/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts +++ b/packages/start-plugin-core/tests/rsbuild/output-directory.test.ts @@ -75,8 +75,6 @@ describe('createRsbuildEnvironmentPlan client output', () => { '#tanstack-router-entry': '/app/src/router.tsx', }, }, - clientOutputDirectory: 'dist/client', - serverOutputDirectory: 'dist/server', serverFnProviderEnv: 'ssr', enforcedDefines: {}, enforcedAliases: { @@ -92,6 +90,8 @@ describe('createRsbuildEnvironmentPlan client output', () => { expect(environments.client!.output?.assetPrefix).toBeUndefined() expect(environments.ssr!.output?.assetPrefix).toBeUndefined() + expect(environments.client!.output?.distPath).toBeUndefined() + expect(environments.ssr!.output?.distPath).toBeUndefined() expect(environments.client!.performance).toBeUndefined() expect( createRsbuildEnvironmentPlan({ ...baseOptions, rsc: true }).environments From ba253f7d708431f8b8cdeba7870e68f617f5be85 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 19:54:04 +0800 Subject: [PATCH 4/6] fix: solid start config --- packages/solid-start/src/plugin/rsbuild.ts | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/solid-start/src/plugin/rsbuild.ts b/packages/solid-start/src/plugin/rsbuild.ts index b1f25322644..62d1d650f78 100644 --- a/packages/solid-start/src/plugin/rsbuild.ts +++ b/packages/solid-start/src/plugin/rsbuild.ts @@ -7,7 +7,13 @@ import type { TanStackStartRsbuildInputConfig, TanStackStartRsbuildPluginCoreOptions, } from '@tanstack/start-plugin-core/rsbuild' -import type { RsbuildPlugin } from '@rsbuild/core' +import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core' + +const frameworkDefaults = { + resolve: { + conditionNames: ['solid', '...'], + }, +} satisfies RsbuildConfig export function tanstackStart( options?: TanStackStartRsbuildInputConfig, @@ -17,15 +23,6 @@ export function tanstackStart( defaultEntryPaths: solidStartDefaultEntryPaths, providerEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.server, ssrIsProvider: true, - rsbuild: { - environments: { - all: { - resolve: { - conditionNames: ['solid', '...'], - }, - }, - }, - }, } const basePlugin = tanStackStartRsbuild(corePluginOpts, options) @@ -33,6 +30,13 @@ export function tanstackStart( return { name: 'tanstack-solid-start-rsbuild', setup(api) { + api.modifyRsbuildConfig({ + order: 'pre', + handler(userConfig, { mergeRsbuildConfig }) { + return mergeRsbuildConfig(frameworkDefaults, userConfig) + }, + }) + basePlugin.setup(api) api.modifyBundlerChain(async (chain, { CHAIN_ID, target }) => { From 2199ef22b88d090e3d6e04c00f4d19b508bc01e9 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 19:58:36 +0800 Subject: [PATCH 5/6] fix: test case --- e2e/react-start/custom-server-rsbuild/express-server.ts | 2 +- e2e/react-start/custom-server-rsbuild/rsbuild.config.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/react-start/custom-server-rsbuild/express-server.ts b/e2e/react-start/custom-server-rsbuild/express-server.ts index 23784f2efcd..1b07e5ff516 100644 --- a/e2e/react-start/custom-server-rsbuild/express-server.ts +++ b/e2e/react-start/custom-server-rsbuild/express-server.ts @@ -67,7 +67,7 @@ if (DEVELOPMENT) { const { default: handler } = (await import('./dist/server/index.js')) as FetchServerEntry const nodeHandler = toNodeHandler(handler.fetch) as NodeHttp1Handler - app.use(express.static('dist/client')) + app.use('/static', express.static('dist/client')) app.use(async (req, res, next) => { try { await nodeHandler(req, res) diff --git a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts index ca1c55b3c6a..59d21747473 100644 --- a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts +++ b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ distPath: { root: path.resolve(__dirname, 'dist/client'), js: '', + css: '' }, }, tools: { From 04c933721f7ec4408ab32064b8b20d13b969c791 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Fri, 21 Aug 2026 20:09:00 +0800 Subject: [PATCH 6/6] fix(start): preserve string Rsbuild distPath defaults --- packages/start-plugin-core/src/rsbuild/planning.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/start-plugin-core/src/rsbuild/planning.ts b/packages/start-plugin-core/src/rsbuild/planning.ts index c4811ce371e..1efc5892629 100644 --- a/packages/start-plugin-core/src/rsbuild/planning.ts +++ b/packages/start-plugin-core/src/rsbuild/planning.ts @@ -80,6 +80,13 @@ function createEnvironmentDistPathDefaults(opts: { const distPathKey = key as keyof RsbuildDistPathObject if (distPathKey === 'root') { + // Preserve the string shorthand by carrying its resolved value into the + // object defaults. Otherwise merging these defaults after the + // environment config would replace the string and lose its root. + if (typeof opts.environmentDistPath === 'string') { + return true + } + // An explicit environment root wins. Otherwise provide the Start // convention derived from the shared root output directory. return (