From 77031d3af5d696eb77c857b57cf9bad737fb0d62 Mon Sep 17 00:00:00 2001 From: addielaruee Date: Wed, 26 Aug 2026 19:08:08 +1000 Subject: [PATCH] fix(start): resolve the emitted server entry for prerendering The prerender preview server located the built server module by reconstructing its filename from the server input name and pinning a `.js` extension. Any build whose server output differs (a configured `output.entryFileNames`, or a Cloudflare/Nitro build emitting `index.mjs`) was never found: the dynamic import threw `ERR_MODULE_NOT_FOUND`, every prerender fetch returned 500, and the build aborted with the real cause swallowed. Resolve the entry the build actually emitted instead. Prefer the configured `output.entryFileNames` (resolving the `[name]` placeholder), then fall back to the input basename with the common output extensions. When no candidate exists, throw an error that names the filenames looked for and the files present in the output directory, so the failure is diagnosable instead of an opaque 500. Fixes #8118 --- .changeset/prerender-resolve-server-entry.md | 5 ++ .../src/vite/preview-server-plugin/plugin.ts | 20 ++--- .../resolve-server-entry.ts | 67 ++++++++++++++++ .../tests/vite/resolve-server-entry.test.ts | 76 +++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 .changeset/prerender-resolve-server-entry.md create mode 100644 packages/start-plugin-core/src/vite/preview-server-plugin/resolve-server-entry.ts create mode 100644 packages/start-plugin-core/tests/vite/resolve-server-entry.test.ts diff --git a/.changeset/prerender-resolve-server-entry.md b/.changeset/prerender-resolve-server-entry.md new file mode 100644 index 00000000000..a6fbc3802e4 --- /dev/null +++ b/.changeset/prerender-resolve-server-entry.md @@ -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 `.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. diff --git a/packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts b/packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts index 1541d9edfbe..fe76ec4ba17 100644 --- a/packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts @@ -1,10 +1,9 @@ import { pathToFileURL } from 'node:url' -import { basename, extname, join } from 'pathe' import { NodeRequest, sendNodeResponse } from 'srvx/node' import { joinURL } from 'ufo' import { VITE_ENVIRONMENT_NAMES } from '../../constants' import { getServerOutputDirectory } from '../output-directory' -import { getBundlerOptions } from '../../utils' +import { resolveServerEntry } from './resolve-server-entry' import type { Plugin } from 'vite' export function previewServerPlugin(): Plugin { @@ -24,20 +23,15 @@ export function previewServerPlugin(): Plugin { try { // Lazy load server build on first request if (!serverBuild) { - // Derive output filename from input + // Resolve the entry the build actually emitted, rather than + // reconstructing its name from the input and pinning `.js`. const serverEnv = server.config.environments[VITE_ENVIRONMENT_NAMES.server] - const serverInput = - getBundlerOptions(serverEnv?.build)?.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 serverEntryPath = resolveServerEntry( + serverEnv?.build, + serverOutputDir, + ) const imported = await import( pathToFileURL(serverEntryPath).toString() ) diff --git a/packages/start-plugin-core/src/vite/preview-server-plugin/resolve-server-entry.ts b/packages/start-plugin-core/src/vite/preview-server-plugin/resolve-server-entry.ts new file mode 100644 index 00000000000..c80a87835d6 --- /dev/null +++ b/packages/start-plugin-core/src/vite/preview-server-plugin/resolve-server-entry.ts @@ -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 `.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() + + // 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) + } + } + + // 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)'}.`, + ) +} diff --git a/packages/start-plugin-core/tests/vite/resolve-server-entry.test.ts b/packages/start-plugin-core/tests/vite/resolve-server-entry.test.ts new file mode 100644 index 00000000000..5c58cda5e6a --- /dev/null +++ b/packages/start-plugin-core/tests/vite/resolve-server-entry.test.ts @@ -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 = [] + +function makeServerDir(files: Array): 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 `.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/, + ) + }) +})