diff --git a/e2e/react-start/basic/package.json b/e2e/react-start/basic/package.json index acb036c14f7..1b81972f0cf 100644 --- a/e2e/react-start/basic/package.json +++ b/e2e/react-start/basic/package.json @@ -9,6 +9,7 @@ "build": "vite build && tsc --noEmit", "build:spa": "MODE=spa vite build && tsc --noEmit", "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "preview": "vite preview", "start": "pnpx srvx --prod -s ../client dist/server/server.js", "start:spa": "node server.js", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", @@ -16,7 +17,8 @@ "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender" + "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:preview" }, "dependencies": { "@tanstack/react-router": "workspace:^", diff --git a/e2e/react-start/basic/playwright.config.ts b/e2e/react-start/basic/playwright.config.ts index 493f4e29edc..aa29067f463 100644 --- a/e2e/react-start/basic/playwright.config.ts +++ b/e2e/react-start/basic/playwright.config.ts @@ -5,10 +5,11 @@ import { } from '@tanstack/router-e2e-utils' import { isSpaMode } from './tests/utils/isSpaMode' import { isPrerender } from './tests/utils/isPrerender' +import { isPreview } from './tests/utils/isPreview' import packageJson from './package.json' with { type: 'json' } const PORT = await getTestServerPort( - `${packageJson.name}${isSpaMode ? '_spa' : ''}`, + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, ) const START_PORT = await getTestServerPort( `${packageJson.name}${isSpaMode ? '_spa_start' : ''}`, @@ -18,14 +19,17 @@ const baseURL = `http://localhost:${PORT}` const spaModeCommand = `pnpm build:spa && pnpm start:spa` const ssrModeCommand = `pnpm build && pnpm start` const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` const getCommand = () => { if (isSpaMode) return spaModeCommand if (isPrerender) return prerenderModeCommand + if (isPreview) return previewModeCommand return ssrModeCommand } console.log('running in spa mode: ', isSpaMode.toString()) console.log('running in prerender mode: ', isPrerender.toString()) +console.log('running in preview mode: ', isPreview.toString()) /** * See https://playwright.dev/docs/test-configuration. */ diff --git a/e2e/react-start/basic/tests/redirect.spec.ts b/e2e/react-start/basic/tests/redirect.spec.ts index e987c2abc89..7fcaed8d73a 100644 --- a/e2e/react-start/basic/tests/redirect.spec.ts +++ b/e2e/react-start/basic/tests/redirect.spec.ts @@ -7,13 +7,14 @@ import { test, } from '@tanstack/router-e2e-utils' import { isSpaMode } from '../tests/utils/isSpaMode' +import { isPreview } from '../tests/utils/isPreview' import packageJson from '../package.json' with { type: 'json' } // somehow playwright does not correctly import default exports const combinate = (combinateImport as any).default as typeof combinateImport const PORT = await getTestServerPort( - `${packageJson.name}${isSpaMode ? '_spa' : ''}`, + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, ) const EXTERNAL_HOST_PORT = await getDummyServerPort(packageJson.name) diff --git a/e2e/react-start/basic/tests/utils/isPreview.ts b/e2e/react-start/basic/tests/utils/isPreview.ts new file mode 100644 index 00000000000..7ea362a83ed --- /dev/null +++ b/e2e/react-start/basic/tests/utils/isPreview.ts @@ -0,0 +1 @@ +export const isPreview: boolean = process.env.MODE === 'preview' diff --git a/e2e/solid-start/basic/package.json b/e2e/solid-start/basic/package.json index f67908e811c..60c169339c2 100644 --- a/e2e/solid-start/basic/package.json +++ b/e2e/solid-start/basic/package.json @@ -9,6 +9,7 @@ "build": "vite build && tsc --noEmit", "build:spa": "MODE=spa vite build && tsc --noEmit", "build:prerender": "MODE=prerender vite build && tsc --noEmit", + "preview": "vite preview", "start": "pnpx srvx --prod -s ../client dist/server/server.js", "start:spa": "node server.js", "test:e2e:startDummyServer": "node -e 'import(\"./tests/setup/global.setup.ts\").then(m => m.default())' &", @@ -16,7 +17,8 @@ "test:e2e:spaMode": "rm -rf port*.txt; MODE=spa playwright test --project=chromium", "test:e2e:ssrMode": "rm -rf port*.txt; playwright test --project=chromium", "test:e2e:prerender": "rm -rf port*.txt; MODE=prerender playwright test --project=chromium", - "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender" + "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test --project=chromium", + "test:e2e": "pnpm run test:e2e:spaMode && pnpm run test:e2e:ssrMode && pnpm run test:e2e:prerender && pnpm run test:e2e:preview" }, "dependencies": { "@tanstack/solid-router": "workspace:^", diff --git a/e2e/solid-start/basic/playwright.config.ts b/e2e/solid-start/basic/playwright.config.ts index 493f4e29edc..aa29067f463 100644 --- a/e2e/solid-start/basic/playwright.config.ts +++ b/e2e/solid-start/basic/playwright.config.ts @@ -5,10 +5,11 @@ import { } from '@tanstack/router-e2e-utils' import { isSpaMode } from './tests/utils/isSpaMode' import { isPrerender } from './tests/utils/isPrerender' +import { isPreview } from './tests/utils/isPreview' import packageJson from './package.json' with { type: 'json' } const PORT = await getTestServerPort( - `${packageJson.name}${isSpaMode ? '_spa' : ''}`, + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, ) const START_PORT = await getTestServerPort( `${packageJson.name}${isSpaMode ? '_spa_start' : ''}`, @@ -18,14 +19,17 @@ const baseURL = `http://localhost:${PORT}` const spaModeCommand = `pnpm build:spa && pnpm start:spa` const ssrModeCommand = `pnpm build && pnpm start` const prerenderModeCommand = `pnpm run test:e2e:startDummyServer && pnpm build:prerender && pnpm run test:e2e:stopDummyServer && pnpm start` +const previewModeCommand = `pnpm build && pnpm preview --port ${PORT}` const getCommand = () => { if (isSpaMode) return spaModeCommand if (isPrerender) return prerenderModeCommand + if (isPreview) return previewModeCommand return ssrModeCommand } console.log('running in spa mode: ', isSpaMode.toString()) console.log('running in prerender mode: ', isPrerender.toString()) +console.log('running in preview mode: ', isPreview.toString()) /** * See https://playwright.dev/docs/test-configuration. */ diff --git a/e2e/solid-start/basic/tests/redirect.spec.ts b/e2e/solid-start/basic/tests/redirect.spec.ts index b355268ac7b..60fd3df329c 100644 --- a/e2e/solid-start/basic/tests/redirect.spec.ts +++ b/e2e/solid-start/basic/tests/redirect.spec.ts @@ -8,12 +8,13 @@ import { } from '@tanstack/router-e2e-utils' import packageJson from '../package.json' with { type: 'json' } import { isSpaMode } from '../tests/utils/isSpaMode' +import { isPreview } from '../tests/utils/isPreview' // somehow playwright does not correctly import default exports const combinate = (combinateImport as any).default as typeof combinateImport const PORT = await getTestServerPort( - `${packageJson.name}${isSpaMode ? '_spa' : ''}`, + `${packageJson.name}${isSpaMode ? '_spa' : ''}${isPreview ? '_preview' : ''}`, ) const EXTERNAL_HOST_PORT = await getDummyServerPort(packageJson.name) diff --git a/e2e/solid-start/basic/tests/utils/isPreview.ts b/e2e/solid-start/basic/tests/utils/isPreview.ts new file mode 100644 index 00000000000..7ea362a83ed --- /dev/null +++ b/e2e/solid-start/basic/tests/utils/isPreview.ts @@ -0,0 +1 @@ +export const isPreview: boolean = process.env.MODE === 'preview' diff --git a/packages/start-plugin-core/src/output-directory.ts b/packages/start-plugin-core/src/output-directory.ts index af33e57f3e4..aa1655433f1 100644 --- a/packages/start-plugin-core/src/output-directory.ts +++ b/packages/start-plugin-core/src/output-directory.ts @@ -3,16 +3,20 @@ import { VITE_ENVIRONMENT_NAMES } from './constants' import type { ViteEnvironmentNames } from './constants' import type * as vite from 'vite' -export function getClientOutputDirectory(userConfig: vite.UserConfig) { +export function getClientOutputDirectory( + userConfig: vite.UserConfig | vite.ResolvedConfig, +) { return getOutputDirectory(userConfig, VITE_ENVIRONMENT_NAMES.client, 'client') } -export function getServerOutputDirectory(userConfig: vite.UserConfig) { +export function getServerOutputDirectory( + userConfig: vite.UserConfig | vite.ResolvedConfig, +) { return getOutputDirectory(userConfig, VITE_ENVIRONMENT_NAMES.server, 'server') } function getOutputDirectory( - userConfig: vite.UserConfig, + userConfig: vite.UserConfig | vite.ResolvedConfig, environmentName: ViteEnvironmentNames, directoryName: string, ) { diff --git a/packages/start-plugin-core/src/plugin.ts b/packages/start-plugin-core/src/plugin.ts index 05d578f4d2e..25d3c67f8ae 100644 --- a/packages/start-plugin-core/src/plugin.ts +++ b/packages/start-plugin-core/src/plugin.ts @@ -11,6 +11,7 @@ import { ENTRY_POINTS, VITE_ENVIRONMENT_NAMES } from './constants' import { tanStackStartRouter } from './start-router-plugin/plugin' import { loadEnvPlugin } from './load-env-plugin/plugin' import { devServerPlugin } from './dev-server-plugin/plugin' +import { previewServerPlugin } from './preview-server-plugin/plugin' import { parseStartConfig } from './schema' import { resolveEntry } from './resolve-entries' import { @@ -399,6 +400,7 @@ export function TanStackStartVitePluginCore( getConfig, }), devServerPlugin({ getConfig }), + previewServerPlugin(), { name: 'tanstack-start:core:capture-bundle', applyToEnvironment(e) { diff --git a/packages/start-plugin-core/src/preview-server-plugin/plugin.ts b/packages/start-plugin-core/src/preview-server-plugin/plugin.ts new file mode 100644 index 00000000000..5322ef94438 --- /dev/null +++ b/packages/start-plugin-core/src/preview-server-plugin/plugin.ts @@ -0,0 +1,64 @@ +import { pathToFileURL } from 'node:url' +import { basename, extname, join } from 'pathe' +import { NodeRequest, sendNodeResponse } from 'srvx/node' +import { VITE_ENVIRONMENT_NAMES } from '../constants' +import { getServerOutputDirectory } from '../output-directory' +import type { Plugin } from 'vite' + +export function previewServerPlugin(): Plugin { + return { + name: 'tanstack-start-core:preview-server', + configurePreviewServer: { + // Run last so platform plugins (Cloudflare, Vercel, etc.) can register their handlers first + order: 'post', + handler(server) { + // Return a function so Vite's internal middlewares (static files, etc.) handle requests first. + // Our SSR handler only processes requests that nothing else handled. + return () => { + // Cache the server build to avoid re-importing on every request + let serverBuild: any = null + + server.middlewares.use(async (req, res, next) => { + try { + // Lazy load server build on first request + if (!serverBuild) { + // Derive output filename from input + const serverEnv = + server.config.environments[VITE_ENVIRONMENT_NAMES.server] + const serverInput = + serverEnv?.build.rollupOptions.input ?? 'server' + + if (typeof serverInput !== 'string') { + throw new Error('Invalid server input. Expected a string.') + } + + // Get basename without extension and add .js + const outputFilename = `${basename(serverInput, extname(serverInput))}.js` + const serverOutputDir = getServerOutputDirectory(server.config) + const serverEntryPath = join(serverOutputDir, outputFilename) + const imported = await import( + pathToFileURL(serverEntryPath).toString() + ) + + serverBuild = imported.default + } + + const webReq = new NodeRequest({ req, res }) + const webRes: Response = await serverBuild.fetch(webReq) + + // Temporary workaround + // Vite preview's compression middleware doesn't support flattened array headers that srvx sets + // Call writeHead() before srvx to avoid corruption + res.setHeaders(webRes.headers) + res.writeHead(webRes.status, webRes.statusText) + + return sendNodeResponse(res, webRes) + } catch (error) { + next(error) + } + }) + } + }, + }, + } +}