Skip to content
2 changes: 1 addition & 1 deletion e2e/react-start/custom-server-rsbuild/express-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 6 additions & 8 deletions e2e/react-start/custom-server-rsbuild/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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
// - `client.output.module: false` — 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.
//
Expand All @@ -18,23 +18,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(),
tanstackStart({
rsbuild: {
installDevServerMiddleware: false,
client: {
output: 'iife',
},
},
}),
],
Expand All @@ -47,9 +43,11 @@ export default defineConfig({
environments: {
client: {
output: {
module: false,
distPath: {
root: path.resolve(__dirname, 'dist/client'),
js: '',
css: '',
},
},
tools: {
Expand Down
24 changes: 14 additions & 10 deletions packages/solid-start/src/plugin/rsbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,22 +23,20 @@ export function tanstackStart(
defaultEntryPaths: solidStartDefaultEntryPaths,
providerEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.server,
ssrIsProvider: true,
rsbuild: {
environments: {
all: {
resolve: {
conditionNames: ['solid', '...'],
},
},
},
},
}

const basePlugin = tanStackStartRsbuild(corePluginOpts, options)

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 }) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/start-plugin-core/src/config-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import type { TanStackStartOutputConfig } from './schema'
import type {
GetConfigFn,
NormalizedBasePaths,
ResolvedStartConfig,
TanStackStartCoreOptions,
} from './types'
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion packages/start-plugin-core/src/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
238 changes: 238 additions & 0 deletions packages/start-plugin-core/src/rsbuild/enforced-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
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

/**
* 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`,
* `output.assetPrefix`, and `output.distPath` 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,
},
},
server: {
...commonEnvironmentConfig,
output: {
target: true,
},
},
},
} satisfies StartRsbuildEnforcedConfig

function findOverriddenConfig(
config: unknown,
resolvedConfig: unknown,
enforced: EnforcedConfig,
path = '',
out: Array<string> = [],
): Array<string> {
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<string> {
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<string, unknown> {
return typeof value === 'object' && value !== null
}
Loading