-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(start): resolve the emitted server entry for prerendering #8172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/start-plugin-core': patch | ||
| --- | ||
|
|
||
| Fix prerendering failing with `ERR_MODULE_NOT_FOUND` when the server build emits an entry named something other than `<serverInput>.js` (for example a configured `output.entryFileNames`, or a Cloudflare/Nitro build that emits `index.mjs`). The preview server now resolves the entry the build actually emitted, and when it cannot find one it throws a clear error listing the filenames it looked for and the files present in the server output directory instead of an opaque 500. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { existsSync, readdirSync } from 'node:fs' | ||
| import { basename, extname, join } from 'pathe' | ||
| import { getBundlerOptions } from '../../utils' | ||
| import type * as vite from 'vite' | ||
|
|
||
| const SERVER_ENTRY_EXTENSIONS = ['.js', '.mjs', '.cjs'] | ||
|
|
||
| /** | ||
| * Resolve the server entry file that the build actually emitted into | ||
| * `serverOutputDir`. | ||
| * | ||
| * The emitted filename is not always `<serverInputBasename>.js`: a configured | ||
| * `output.entryFileNames`, or a builder plugin producing the server bundle, can | ||
| * change both the name and the extension. Instead of reconstructing the name | ||
| * and pinning `.js`, resolve the file that is present on disk. If none of the | ||
| * candidates exist, throw an error that names what was looked for and what the | ||
| * output directory actually contains. | ||
| */ | ||
| export function resolveServerEntry( | ||
| serverBuild: vite.BuildEnvironmentOptions | undefined, | ||
| serverOutputDir: string, | ||
| ): string { | ||
| const bundlerOptions = getBundlerOptions(serverBuild) | ||
| const serverInput = bundlerOptions?.input ?? 'server' | ||
|
|
||
| if (typeof serverInput !== 'string') { | ||
| throw new Error('Invalid server input. Expected a string.') | ||
| } | ||
|
|
||
| const inputName = basename(serverInput, extname(serverInput)) | ||
|
|
||
| const output = Array.isArray(bundlerOptions?.output) | ||
| ? bundlerOptions.output[0] | ||
| : bundlerOptions?.output | ||
| const entryFileNames = output?.entryFileNames | ||
|
|
||
| const candidates = new Set<string>() | ||
|
|
||
| // Prefer the configured output name, resolving the `[name]` placeholder. | ||
| // Other placeholders (`[hash]` etc.) cannot be known here and are skipped. | ||
| if (typeof entryFileNames === 'string') { | ||
| const resolved = entryFileNames.replaceAll('[name]', inputName) | ||
| if (!resolved.includes('[')) { | ||
| candidates.add(resolved) | ||
| } | ||
|
Comment on lines
+41
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify the repository-declared Vite/Rolldown version and inspect configured
# entry filename patterns before implementing matcher behavior.
fd -a -t f 'package.json' 'pnpm-lock.yaml' 'yarn.lock' 'package-lock.json' . \
-E node_modules -E .git \
| xargs -r rg -n -C2 '"vite"|"rolldown"|entryFileNames'
rg -n -C5 'entryFileNames.*hash|\[hash' \
packages/start-plugin-coreRepository: TanStack/router Length of output: 50372 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- resolver ---'
cat -n packages/start-plugin-core/src/vite/preview-server-plugin/resolve-server-entry.ts
printf '%s\n' '--- direct references ---'
rg -n -C4 'resolveServerEntry|entryFileNames|serverEntry' packages/start-plugin-core/src packages/start-plugin-core/tests 2>/dev/null || true
printf '%s\n' '--- Vite catalog/version declarations ---'
rg -n -C3 '"vite"|rolldown' package.json packages/start-plugin-core/package.json pnpm-workspace.yaml pnpm-lock.yaml 2>/dev/null | head -120Repository: TanStack/router Length of output: 34798 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact bundler-options binding ---'
cat -n packages/start-plugin-core/src/utils.ts | sed -n '1,80p'
printf '%s\n' '--- preview import path ---'
cat -n packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts | sed -n '20,45p'
printf '%s\n' '--- locked Vite and Rolldown versions ---'
rg -n '^ vite@|^ rolldown@|version: 8\.0\.14|version: 1\.0\.2' pnpm-lock.yaml | head -40Repository: TanStack/router Length of output: 8204 🌐 Web query:
💡 Result: In Vite 8, which utilizes Rolldown as its underlying bundler [1], the output.entryFileNames configuration follows the same semantics established by Rollup [2][3]. This option defines the pattern for naming output chunks created from entry points [2][3]. The [name] and [hash] placeholders function as follows: [name]: This placeholder represents the file name of the entry point (without its extension) [2][3]. If you use the object form for your input configuration, [name] will resolve to the name of the object property instead of the original file name [2][3]. [hash]: This placeholder generates a hash based on the content of the final rendered entry chunk [2][3]. This includes any transformations applied during the build process, such as those from renderChunk hooks or referenced file hashes [2][3]. You can control the length of this hash by appending a colon and a number, such as [hash:10] [2][3]. Additional semantic rules and behaviors: - Sub-directories: You can use forward slashes (/) within your entryFileNames pattern to organize output into specific sub-directories (e.g., assets/[name].js) [2][3]. - Constraints: Patterns cannot be absolute or relative paths (e.g., starting with / or../) [4]. They must be relative to the output directory [4]. - Default Behavior: The default value for entryFileNames is typically "[name].js" [2][3]. - Functional API: Besides a string pattern, entryFileNames can also be defined as a function that accepts chunk information and returns a string pattern, allowing for dynamic filename generation [2][3]. Because Vite 8 integrates Rolldown for bundling, it maintains high compatibility with these existing Rollup-style configuration patterns to ensure predictable output paths [5]. Citations:
Resolve configured hashed entry names. When 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Fall back to the input basename with the common output extensions. | ||
| for (const extension of SERVER_ENTRY_EXTENSIONS) { | ||
| candidates.add(`${inputName}${extension}`) | ||
| } | ||
|
|
||
| for (const candidate of candidates) { | ||
| const candidatePath = join(serverOutputDir, candidate) | ||
| if (existsSync(candidatePath)) { | ||
| return candidatePath | ||
| } | ||
| } | ||
|
|
||
| const present = existsSync(serverOutputDir) ? readdirSync(serverOutputDir) : [] | ||
|
|
||
| throw new Error( | ||
| `Could not find the server entry for prerendering in "${serverOutputDir}". ` + | ||
| `Looked for: ${Array.from(candidates).join(', ')}. ` + | ||
| `Files present: ${present.join(', ') || '(none)'}.`, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import { afterEach, describe, expect, test } from 'vitest' | ||
| import { join } from 'pathe' | ||
| import { resolveServerEntry } from '../../src/vite/preview-server-plugin/resolve-server-entry' | ||
| import type { BuildEnvironmentOptions } from 'vite' | ||
|
|
||
| const tempDirs: Array<string> = [] | ||
|
|
||
| function makeServerDir(files: Array<string>): string { | ||
| const dir = mkdtempSync(join(tmpdir(), 'tss-server-entry-')) | ||
| tempDirs.push(dir) | ||
| for (const file of files) { | ||
| writeFileSync(join(dir, file), 'export default {}') | ||
| } | ||
| return dir | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| while (tempDirs.length) { | ||
| const dir = tempDirs.pop() | ||
| if (dir) { | ||
| rmSync(dir, { recursive: true, force: true }) | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| describe('resolveServerEntry', () => { | ||
| test('resolves the default `<input>.js` entry', () => { | ||
| const dir = makeServerDir(['server.js']) | ||
| expect(resolveServerEntry(undefined, dir)).toBe(join(dir, 'server.js')) | ||
| }) | ||
|
|
||
| test('resolves an entry renamed via output.entryFileNames', () => { | ||
| const dir = makeServerDir(['index.mjs']) | ||
| const build: BuildEnvironmentOptions = { | ||
| rollupOptions: { | ||
| input: 'server', | ||
| output: { entryFileNames: 'index.mjs' }, | ||
| }, | ||
| } | ||
| expect(resolveServerEntry(build, dir)).toBe(join(dir, 'index.mjs')) | ||
| }) | ||
|
|
||
| test('resolves the `[name]` placeholder in entryFileNames', () => { | ||
| const dir = makeServerDir(['server.mjs']) | ||
| const build: BuildEnvironmentOptions = { | ||
| rollupOptions: { output: { entryFileNames: '[name].mjs' } }, | ||
| } | ||
| expect(resolveServerEntry(build, dir)).toBe(join(dir, 'server.mjs')) | ||
| }) | ||
|
|
||
| test('falls back to alternate extensions when no output name is configured', () => { | ||
| const dir = makeServerDir(['server.mjs']) | ||
| expect(resolveServerEntry(undefined, dir)).toBe(join(dir, 'server.mjs')) | ||
| }) | ||
|
|
||
| test('throws a diagnostic error naming candidates and present files', () => { | ||
| const dir = makeServerDir(['index.mjs', 'wrangler.json']) | ||
| expect(() => resolveServerEntry(undefined, dir)).toThrow( | ||
| /Could not find the server entry/, | ||
| ) | ||
| // Names a filename it looked for and a file that is actually present. | ||
| expect(() => resolveServerEntry(undefined, dir)).toThrow(/server\.js/) | ||
| expect(() => resolveServerEntry(undefined, dir)).toThrow(/index\.mjs/) | ||
| }) | ||
|
|
||
| test('throws when the server input is not a string', () => { | ||
| const build: BuildEnvironmentOptions = { | ||
| rollupOptions: { input: { app: 'src/server.ts' } }, | ||
| } | ||
| expect(() => resolveServerEntry(build, tmpdir())).toThrow( | ||
| /Invalid server input/, | ||
| ) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Add preview-server workflow coverage.
Add an integration or end-to-end test that emits a renamed server entry, starts the preview middleware, and verifies a request loads that entry successfully. The resolver unit tests do not cover this import and request path.
As per coding guidelines, “Add appropriate unit tests for isolated behavior and end-to-end tests for browser or application workflows.”
🤖 Prompt for AI Agents
Source: Coding guidelines