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/, + ) + }) +})