Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/react-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
"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())' &",
"test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'",
"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:^",
Expand Down
6 changes: 5 additions & 1 deletion e2e/react-start/basic/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' : ''}`,
Expand All @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion e2e/react-start/basic/tests/redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions e2e/react-start/basic/tests/utils/isPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isPreview: boolean = process.env.MODE === 'preview'
4 changes: 3 additions & 1 deletion e2e/solid-start/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
"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())' &",
"test:e2e:stopDummyServer": "node -e 'import(\"./tests/setup/global.teardown.ts\").then(m => m.default())'",
"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:^",
Expand Down
6 changes: 5 additions & 1 deletion e2e/solid-start/basic/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' : ''}`,
Expand All @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion e2e/solid-start/basic/tests/redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions e2e/solid-start/basic/tests/utils/isPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isPreview: boolean = process.env.MODE === 'preview'
10 changes: 7 additions & 3 deletions packages/start-plugin-core/src/output-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
2 changes: 2 additions & 0 deletions packages/start-plugin-core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -399,6 +400,7 @@ export function TanStackStartVitePluginCore(
getConfig,
}),
devServerPlugin({ getConfig }),
previewServerPlugin(),
{
name: 'tanstack-start:core:capture-bundle',
applyToEnvironment(e) {
Expand Down
64 changes: 64 additions & 0 deletions packages/start-plugin-core/src/preview-server-plugin/plugin.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +49 to +53
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For context:

Vite preview server uses @polka/compression to handle compression. This middleware overwrites the res.writeHead method, which expects headers to be an object and uses a for...in loop to iterate over them.

However, srvx passes headers as a flattened array to writeHead(). When the middleware's for...in loop runs through an array, it iterates over array indices ('0', '1', '2', ...) instead of the actual header names.

This causes header corruption where a header like:

Content-Type: text/html; charset=utf-8

Becomes:

0: Content-Type
1: text/html; charset=utf-8

The workaround is to call res.writeHead() directly before sendNodeResponse(), which sets res.headersSent = true and causes sendNodeResponse() to skip header writing entirely.

I will try to submit a patch to either Vite or @polka/compression, but we will likely want to keep this workaround for compatibility with earlier versions of Vite.


return sendNodeResponse(res, webRes)
Comment on lines +52 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard res.setHeaders for older Node runtimes.

During vite preview, many projects still run on Node 18.14 or earlier. On those versions ServerResponse#setHeaders is undefined, so these lines throw and the preview server never responds. Please gate this by checking typeof res.setHeaders === 'function' and fall back to iterating webRes.headers with res.setHeader(...) when it isn’t available.

🤖 Prompt for AI Agents
In packages/start-plugin-core/src/preview-server-plugin/plugin.ts around lines
52 to 55, res.setHeaders may be undefined on older Node versions (e.g., Node
18.14) which causes a crash; guard that call by checking if typeof
res.setHeaders === 'function' and call it when available, otherwise iterate over
webRes.headers and call res.setHeader(headerName, headerValue) for each entry
(normalize headerValue to a string or join arrays with ',') before calling
res.writeHead and returning sendNodeResponse.

} catch (error) {
next(error)
}
})
}
},
},
}
}
Loading