diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index caf0c16cbc2..442e8df8b81 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -12,6 +12,7 @@ "build:vite": "vite build && tsc --noEmit", "build:rsbuild": "rsbuild build && tsc --noEmit", "preview": "vite preview", + "preview:rsbuild": "rsbuild preview", "start": "node server.js", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' & node -e 'import(\"./tests/setup/waitForDummyServer.ts\").then(m => m.default())'", "test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'", @@ -78,6 +79,10 @@ { "toolchain": "rsbuild", "mode": "prerender" + }, + { + "toolchain": "rsbuild", + "mode": "preview" } ] } diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index 5e46a2b1c7c..bd220240730 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -7,6 +7,7 @@ import { import packageJson from './package.json' with { type: 'json' } const mode = process.env.MODE ?? 'ssr' +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' const e2ePortKey = process.env.E2E_PORT_KEY ?? packageJson.name const distDir = process.env.E2E_DIST_DIR ?? 'dist' @@ -24,9 +25,13 @@ const PORT = await getTestServerPort(e2ePortKey) const START_PORT = await getTestServerPort(`${e2ePortKey}_start`) const EXTERNAL_PORT = await getDummyServerPort(e2ePortKey) const baseURL = `http://localhost:${PORT}` +const previewCommand = + toolchain === 'rsbuild' + ? `pnpm preview:rsbuild --port ${PORT}` + : `pnpm preview --outDir ${distDir} --port ${PORT}` const commandByMode = mode === 'preview' - ? `pnpm run test:e2e:startDummyServer && pnpm preview --outDir ${distDir} --port ${PORT}` + ? `pnpm run test:e2e:startDummyServer && ${previewCommand}` : `pnpm run test:e2e:startDummyServer && pnpm start` /** * See https://playwright.dev/docs/test-configuration. @@ -50,6 +55,7 @@ export default defineConfig({ stdout: 'pipe', env: { MODE: mode, + E2E_TOOLCHAIN: toolchain, VITE_NODE_ENV: 'test', VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), VITE_SERVER_PORT: String(PORT), diff --git a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts index e638f8319c8..2b7450b33cf 100644 --- a/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts +++ b/e2e/react-start/custom-server-rsbuild/rsbuild.config.ts @@ -5,6 +5,6 @@ import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' export default defineConfig({ plugins: [ pluginReact({ splitChunks: false }), - tanstackStart({ rsbuild: { installDevServerMiddleware: false } }), + tanstackStart({ rsbuild: { installServerMiddleware: false } }), ], }) diff --git a/packages/react-start/src/plugin/rsbuild.ts b/packages/react-start/src/plugin/rsbuild.ts index 7e0fcbb6c22..bd159690f13 100644 --- a/packages/react-start/src/plugin/rsbuild.ts +++ b/packages/react-start/src/plugin/rsbuild.ts @@ -10,6 +10,16 @@ import type { } from '@tanstack/start-plugin-core/rsbuild' import type { RsbuildPlugin } from '@rsbuild/core' +const reactStartRsbuildEnvironmentOverrides = { + all: { + resolve: { + dedupe: ['react', 'react-dom'], + }, + }, +} satisfies NonNullable< + TanStackStartRsbuildPluginCoreOptions['rsbuild'] +>['environments'] + export function tanstackStart( options?: TanStackStartRsbuildInputConfig & { rsc?: { enabled?: boolean } }, ): RsbuildPlugin { @@ -20,6 +30,9 @@ export function tanstackStart( defaultEntryPaths: reactStartDefaultEntryPaths, providerEnvironmentName: RSBUILD_ENVIRONMENT_NAMES.server, ssrIsProvider: true, + rsbuild: { + environments: reactStartRsbuildEnvironmentOverrides, + }, } if (rscEnabled) { diff --git a/packages/start-plugin-core/src/rsbuild/dev-server.ts b/packages/start-plugin-core/src/rsbuild/dev-server.ts deleted file mode 100644 index c83ec714480..00000000000 --- a/packages/start-plugin-core/src/rsbuild/dev-server.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { NodeRequest, sendNodeResponse } from 'srvx/node' -import { RSBUILD_ENVIRONMENT_NAMES } from './planning' -import type { IncomingMessage, ServerResponse } from 'node:http' -import type { RsbuildConfig } from '@rsbuild/core' - -type ServerSetupFn = Extract< - NonNullable['setup']>, - (...args: Array) => any -> -type SSRMiddleware = ( - req: IncomingMessage & { originalUrl?: string }, - res: ServerResponse, - next: () => void, -) => Promise - -/** - * Returns a `server.setup` function for rsbuild v2. - * - * Two middleware positions are used: - * - * 1. **Setup body** (BEFORE built-ins): Intercepts `/_serverFn/` URLs so - * they never reach rsbuild's htmlFallback/htmlCompletion middleware, - * which can swallow long base64 function IDs. - * - * 2. **Returned callback** (AFTER built-ins, BEFORE fallback): Handles - * all remaining SSR requests (page navigations). This position lets - * rsbuild's asset middleware serve compiled JS/CSS first. - * - * See rsbuild source: devMiddlewares.ts `applyDefaultMiddlewares()`. - */ -export function createServerSetup(opts: { - serverFnBasePath: string -}): ServerSetupFn { - return (context) => { - // Only install SSR middleware in dev mode - if (context.action !== 'dev') { - return () => {} - } - - const serverFnBase = opts.serverFnBasePath - - const handleSSR: SSRMiddleware = async (req, res, next) => { - const ssrEnv = - context.server.environments[RSBUILD_ENVIRONMENT_NAMES.server] - - if (!ssrEnv) { - console.error( - `[tanstack-start] SSR environment "${RSBUILD_ENVIRONMENT_NAMES.server}" not found`, - ) - return next() - } - - try { - const serverEntry = await ssrEnv.loadBundle<{ - default: { fetch: (req: Request) => Promise } - }>('index') - - // Restore the original URL (rsbuild may rewrite to /index.html) - if (req.originalUrl) { - req.url = req.originalUrl - } - - const webReq = new NodeRequest({ req, res }) - const webRes = await serverEntry.default.fetch(webReq) - return sendNodeResponse(res, webRes) - } catch (e) { - console.error('[tanstack-start] SSR error:', e) - - const webReq = new NodeRequest({ req, res }) - if (webReq.headers.get('content-type')?.includes('application/json')) { - return sendNodeResponse( - res, - new Response( - JSON.stringify( - { - status: 500, - error: 'Internal Server Error', - message: - 'An unexpected error occurred. Please try again later.', - timestamp: new Date().toISOString(), - }, - null, - 2, - ), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }, - ), - ) - } - - return sendNodeResponse( - res, - new Response( - ` - - Error - -

Internal Server Error

-
${e instanceof Error ? e.message : String(e)}
- -`, - { - status: 500, - headers: { 'Content-Type': 'text/html' }, - }, - ), - ) - } - } - - // Position 1: BEFORE built-ins — intercept server function calls - // early so they are not swallowed by htmlFallback or assetsMiddleware. - context.server.middlewares.use(async (req, res, next) => { - const url = req.url || '/' - if (url.startsWith(serverFnBase)) { - return handleSSR(req, res, next) - } - return next() - }) - - // Position 2: AFTER built-ins, before fallback — SSR catch-all for - // page navigations. Assets are already handled by rsbuild middleware. - return () => { - context.server.middlewares.use(handleSSR) - } - } -} diff --git a/packages/start-plugin-core/src/rsbuild/plugin.ts b/packages/start-plugin-core/src/rsbuild/plugin.ts index 337cdd55c42..12b6a32f93a 100644 --- a/packages/start-plugin-core/src/rsbuild/plugin.ts +++ b/packages/start-plugin-core/src/rsbuild/plugin.ts @@ -24,7 +24,7 @@ import { START_MANIFEST_PLACEHOLDER, registerVirtualModules, } from './virtual-modules' -import { createServerSetup } from './dev-server' +import { createServerSetup } from './server-middleware' import { registerClientBuildCapture } from './normalized-client-build' import { registerRouterPlugins } from './start-router-plugin' import { postBuildWithRsbuild } from './post-build' @@ -142,6 +142,8 @@ export function tanStackStartRsbuild( const resolvedEntryPlan = configContext.resolveEntries() const isDev = api.context.action === 'dev' + const isPreview = api.context.action === 'preview' + const shouldInstallServerMiddleware = isDev || isPreview const entryAliases = createRsbuildResolvedEntryAliases({ entryPaths: resolvedEntryPlan.entryPaths, @@ -199,6 +201,10 @@ export function tanStackStartRsbuild( }, }, server: { + ...(rsbuildConfig.server?.printUrls === undefined || + rsbuildConfig.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, @@ -207,11 +213,14 @@ export function tanStackStartRsbuild( htmlFallback: false, // server.setup returned callback runs after built-in middleware // but BEFORE fallback middleware — the ideal slot for SSR. - ...(isDev && - startPluginOpts.rsbuild?.installDevServerMiddleware !== false + ...(shouldInstallServerMiddleware && + startPluginOpts.rsbuild?.installServerMiddleware !== false ? { setup: createServerSetup({ serverFnBasePath: serverFnBase, + serverOutputDirectory: + resolvedStartConfig.outputDirectories.server, + publicBase: resolvedStartConfig.basePaths.publicBase, }), } : {}), diff --git a/packages/start-plugin-core/src/rsbuild/schema.ts b/packages/start-plugin-core/src/rsbuild/schema.ts index 9c0c0cbef7a..3f5911d70ce 100644 --- a/packages/start-plugin-core/src/rsbuild/schema.ts +++ b/packages/start-plugin-core/src/rsbuild/schema.ts @@ -9,7 +9,7 @@ export const tanstackStartRsbuildOptionsSchema = tanstackStartOptionsObjectSchema .extend({ rsbuild: z - .object({ installDevServerMiddleware: z.boolean().optional() }) + .object({ installServerMiddleware: z.boolean().optional() }) .optional(), }) .optional() diff --git a/packages/start-plugin-core/src/rsbuild/server-middleware.ts b/packages/start-plugin-core/src/rsbuild/server-middleware.ts new file mode 100644 index 00000000000..08011d9974c --- /dev/null +++ b/packages/start-plugin-core/src/rsbuild/server-middleware.ts @@ -0,0 +1,237 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { NodeRequest, sendNodeResponse } from 'srvx/node' +import { joinURL } from 'ufo' +import { RSBUILD_ENVIRONMENT_NAMES } from './planning' +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { RsbuildConfig } from '@rsbuild/core' + +type ServerSetupFn = Extract< + NonNullable['setup']>, + (...args: Array) => any +> +type ServerSetupContext = Parameters[0] +type SSRMiddleware = ( + req: IncomingMessage & { originalUrl?: string }, + res: ServerResponse, + next: (error?: unknown) => void, +) => Promise +type FetchHandler = (req: Request) => Promise | Response + +type ServerEntry = + | FetchHandler + | { + fetch?: FetchHandler + } + +function resolveFetchHandler(serverEntry: ServerEntry): FetchHandler { + if (typeof serverEntry === 'function') { + return serverEntry + } + + if (serverEntry && typeof serverEntry.fetch === 'function') { + return serverEntry.fetch.bind(serverEntry) + } + + throw new Error( + 'Unable to resolve a request handler from Rsbuild server bundle', + ) +} + +function getPublicBasePathname(publicBase: string): string { + try { + return new URL(publicBase, 'http://localhost').pathname + } catch { + return publicBase + } +} + +function restorePreviewUrl(opts: { + req: IncomingMessage & { originalUrl?: string } + publicBase: string +}) { + if (opts.req.originalUrl) { + opts.req.url = opts.req.originalUrl + } + + const publicBasePathname = getPublicBasePathname(opts.publicBase) + if (publicBasePathname === '/') { + return + } + + const url = opts.req.url ?? '/' + if (url.startsWith(publicBasePathname)) { + return + } + + opts.req.url = joinURL(publicBasePathname, url) +} + +async function loadDevFetchHandler( + context: ServerSetupContext, +): Promise { + if (context.action !== 'dev') { + throw new Error('Cannot load Rsbuild dev SSR bundle outside dev mode') + } + + const ssrEnv = context.server.environments[RSBUILD_ENVIRONMENT_NAMES.server] + + if (!ssrEnv) { + throw new Error( + `SSR environment "${RSBUILD_ENVIRONMENT_NAMES.server}" not found`, + ) + } + + const serverEntry = await ssrEnv.loadBundle<{ default: ServerEntry }>('index') + return resolveFetchHandler(serverEntry.default) +} + +/** + * Returns a `server.setup` function for rsbuild v2. + * + * Two middleware positions are used: + * + * 1. **Setup body** (BEFORE built-ins): Intercepts `/_serverFn/` URLs so + * they never reach rsbuild's htmlFallback/htmlCompletion middleware, + * which can swallow long base64 function IDs. + * + * 2. **Returned callback** (AFTER built-ins, BEFORE fallback): Handles + * all remaining SSR requests (page navigations). This position lets + * rsbuild's asset middleware serve compiled JS/CSS first. + * + * The middleware choreography is shared by dev and preview. The server entry + * loader differs: dev reads from Rsbuild's in-memory environment so rebuilds + * are reflected immediately, while preview lazy-imports the production server + * bundle from disk. + * + * See rsbuild source: devMiddlewares.ts `applyDefaultMiddlewares()` and + * previewServer.ts `startPreviewServer()`. + */ +export function createServerSetup(opts: { + serverFnBasePath: string + serverOutputDirectory: string + publicBase: string +}): ServerSetupFn { + let previewFetchHandlerPromise: Promise | undefined + + const getPreviewFetchHandler = () => { + if (!previewFetchHandlerPromise) { + previewFetchHandlerPromise = loadPreviewFetchHandler( + opts.serverOutputDirectory, + ) + } + + return previewFetchHandlerPromise + } + + return (context) => { + if (context.action !== 'dev' && context.action !== 'preview') { + return () => {} + } + + const serverFnBase = opts.serverFnBasePath + + const handleSSR: SSRMiddleware = async (req, res, next) => { + try { + const fetchHandler = + context.action === 'dev' + ? await loadDevFetchHandler(context) + : await getPreviewFetchHandler() + + if (context.action === 'preview') { + // Rsbuild preview's base middleware strips server.base before the + // returned setup callback runs. Put it back before creating the Web + // Request so Start's router sees the same URL shape as build/custom + // servers. The early server-fn middleware runs before that base + // middleware, so avoid prepending when the URL already has the base. + restorePreviewUrl({ req, publicBase: opts.publicBase }) + } else if (req.originalUrl) { + // Restore the original URL (rsbuild may rewrite to /index.html) + req.url = req.originalUrl + } + + const webReq = new NodeRequest({ req, res }) + const webRes = await fetchHandler(webReq) + return sendNodeResponse(res, webRes) + } catch (e) { + console.error('[tanstack-start] SSR error:', e) + + const webReq = new NodeRequest({ req, res }) + if (webReq.headers.get('content-type')?.includes('application/json')) { + return sendNodeResponse( + res, + new Response( + JSON.stringify( + { + status: 500, + error: 'Internal Server Error', + message: + 'An unexpected error occurred. Please try again later.', + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ) + } + + return sendNodeResponse( + res, + new Response( + ` + + Error + +

Internal Server Error

+
${e instanceof Error ? e.message : String(e)}
+ +`, + { + status: 500, + headers: { 'Content-Type': 'text/html' }, + }, + ), + ) + } + } + + // Position 1: BEFORE built-ins — intercept server function calls + // early so they are not swallowed by htmlFallback or assetsMiddleware. + context.server.middlewares.use(async (req, res, next) => { + const url = req.url || '/' + if (url.startsWith(serverFnBase)) { + return handleSSR(req, res, next) + } + return next() + }) + + // Position 2: AFTER built-ins, before fallback — SSR catch-all for + // page navigations. Assets are already handled by rsbuild middleware. + return () => { + context.server.middlewares.use(handleSSR) + } + } +} + +async function loadPreviewFetchHandler( + serverOutputDirectory: string, +): Promise { + const serverEntryPath = resolve(serverOutputDirectory, 'index.js') + const imported = (await import( + pathToFileURL(serverEntryPath).toString() + )) as { default: ServerEntry } + + try { + return resolveFetchHandler(imported.default) + } catch (error) { + throw new Error( + `Unable to resolve a request handler from Rsbuild server bundle at ${serverEntryPath}`, + { cause: error }, + ) + } +}