diff --git a/.changeset/client-host-prerender.md b/.changeset/client-host-prerender.md new file mode 100644 index 0000000..00188c8 --- /dev/null +++ b/.changeset/client-host-prerender.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Use the resolved client and server build directories when prerendering client-mode shells and serving preview requests. Preserve the SSR service for host build orchestrators such as Nitro, including client mode without server functions, while standalone client builds still produce only static output. diff --git a/README.md b/README.md index 2ae8e11..78a0544 100644 --- a/README.md +++ b/README.md @@ -808,12 +808,13 @@ export default defineConfig({ graph's CSS inlined; deep links get the same shell (history-fallback semantics). The generated client entry `render()`s (not hydrates) the app into `document.body`. -- **Build**: `vite build` emits a purely static `dist/client` — the shell is - prerendered once through the built handler into `dist/client/index.html`, - with the hashed entry script and the entry graph's CSS links — deployable - to any static host. No server bundle remains unless `serverFunctions` is - enabled, in which case `dist/server` is kept and its `handleRequest` - serves the endpoint (pages stay static). +- **Build**: standalone `vite build` emits a static `dist/client`. The shell + is prerendered once through the built handler into `dist/client/index.html`, + with hashed scripts and CSS links, ready for a static host. The server bundle + is removed unless `serverFunctions` is enabled, in which case `dist/server` + is kept and its `handleRequest` serves the endpoint (pages stay static). + Hosts such as Nitro can relocate the client and server outputs; prerender + uses those directories and retains the server service for the host's build. - **Transforms**: client code compiles exactly like a plain SPA today (`generate: 'dom'`, non-hydratable); only the document shell goes through the SSR transforms. diff --git a/examples/start-client/package.json b/examples/start-client/package.json index a7a96f8..860a7d9 100644 --- a/examples/start-client/package.json +++ b/examples/start-client/package.json @@ -6,7 +6,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "node test/run.mjs" + "test": "node test/run.mjs && node test/host-build.mjs" }, "devDependencies": { "vite": "^8.2.1", diff --git a/examples/start-client/test/cleanup-target.mjs b/examples/start-client/test/cleanup-target.mjs new file mode 100644 index 0000000..d291cd4 --- /dev/null +++ b/examples/start-client/test/cleanup-target.mjs @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import path from 'node:path'; +import { realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { createBuilder } from 'vite'; +import solid from '@solidjs/vite-plugin'; + +const preservedWarning = (target) => + '[@solidjs/vite-plugin] Preserving SSR output at ' + + target + + ': automatic cleanup requires a directory inside the project root that does not contain client output.'; + +async function run(failure) { + const sandbox = realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'solid-cleanup-target-'))); + const originalRemove = fs.rmSync; + let primaryError; + try { + const root = path.join(sandbox, 'project'); + const clientDir = path.join(root, 'dist/client'); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.mkdirSync(clientDir, { recursive: true }); + fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}'); + fs.writeFileSync( + path.join(root, 'src/App.tsx'), + 'export default function App() { return null; }', + ); + const sentinel = path.join(clientDir, 'asset.js'); + fs.writeFileSync(sentinel, 'client asset'); + + if (failure === 'setup') throw new Error('Injected setup failure'); + + // Exercise the real prerender hook with completed environment builds. Its + // removal is observed without executing it, even for project-root targets. + const builder = await createBuilder({ + root, + configFile: false, + plugins: [solid({ start: true })], + }); + const serving = builder.config.plugins.find((plugin) => plugin.name === 'solid:ssr/setup'); + const prerender = builder.config.plugins.find( + (plugin) => plugin.name === 'solid:start/prerender', + ); + assert.ok(serving && prerender); + const warnings = []; + builder.config.logger.warn = (message) => warnings.push(message); + const removals = []; + fs.rmSync = (directory) => removals.push(directory); + syncBuiltinESMExports(); + for (const [name, target, removable] of [ + ['project root', root, false], + ['project ancestor', sandbox, false], + ['outside root', path.join(sandbox, 'outside'), false], + ['root prefix sibling', root + '-sibling', false], + ['client directory', clientDir, false], + ['client ancestor', path.dirname(clientDir), false], + ['standalone server', path.join(root, 'dist/server'), true], + ]) { + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync( + path.join(target, 'server.js'), + failure === 'build' + ? 'export function handleRequest() { throw new Error("Injected build failure"); }' + : 'export function handleRequest() { return new Response(""); }', + ); + serving.generateBundle.handler.call( + { environment: builder.environments.client }, + { dir: clientDir }, + {}, + ); + serving.generateBundle.handler.call( + { environment: builder.environments.ssr }, + { dir: target }, + {}, + ); + removals.length = 0; + warnings.length = 0; + await prerender.buildApp.handler({ + config: builder.config, + environments: { client: { isBuilt: true }, ssr: { isBuilt: true } }, + }); + if (failure === 'assertion') assert.fail('Injected assertion failure'); + assert.deepEqual(removals, removable ? [target] : [], `cleanup eligibility: ${name}`); + assert.deepEqual( + warnings, + removable ? [] : [preservedWarning(target)], + 'warning eligibility: ' + name, + ); + const host = { name: 'fixture:host', buildApp() {} }; + builder.config.plugins.push(host); + warnings.length = 0; + removals.length = 0; + await prerender.buildApp.handler({ + config: builder.config, + environments: { client: { isBuilt: true }, ssr: { isBuilt: true } }, + }); + assert.deepEqual(warnings, [], 'host never warns: ' + name); + assert.deepEqual(removals, [], 'host output retained: ' + name); + builder.config.plugins.pop(); + const functionsBuilder = await createBuilder({ + root, + configFile: false, + customLogger: { ...builder.config.logger, warn: (message) => warnings.push(message) }, + plugins: [solid({ start: true, serverFunctions: true })], + }); + const functionsServing = functionsBuilder.config.plugins.find( + (plugin) => plugin.name === 'solid:ssr/setup', + ); + functionsServing.generateBundle.handler.call( + { environment: functionsBuilder.environments.client }, + { dir: clientDir }, + {}, + ); + functionsServing.generateBundle.handler.call( + { environment: functionsBuilder.environments.ssr }, + { dir: target }, + {}, + ); + await functionsBuilder.config.plugins + .find((plugin) => plugin.name === 'solid:start/prerender') + .buildApp.handler({ + config: functionsBuilder.config, + environments: { client: { isBuilt: true }, ssr: { isBuilt: true } }, + }); + assert.deepEqual(warnings, [], 'function output never warns: ' + name); + assert.deepEqual(removals, [], 'function output retained: ' + name); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'client asset'); + console.log(`PASS cleanup target: ${name}`); + } + } catch (error) { + primaryError = error; + throw error; + } finally { + fs.rmSync = originalRemove; + syncBuiltinESMExports(); + try { + fs.rmSync(sandbox, { recursive: true, force: true }); + assert.equal(fs.existsSync(sandbox), false, 'cleanup-target sandbox cleaned'); + } catch (error) { + if (primaryError) { + throw new AggregateError([primaryError, error], 'Failed to clean cleanup-target sandbox'); + } + throw error; + } + } +} +await run(); +for (const failure of ['setup', 'build', 'assertion']) { + await assert.rejects(run(failure), new RegExp('Injected ' + failure + ' failure')); + console.log('PASS cleanup-target failure cleanup: ' + failure); +} diff --git a/examples/start-client/test/host-build.mjs b/examples/start-client/test/host-build.mjs new file mode 100644 index 0000000..8f6b5fa --- /dev/null +++ b/examples/start-client/test/host-build.mjs @@ -0,0 +1,268 @@ +// Production proof for relocated environments. An optional Nitro module runs +// the same assertions against nitro/vite without adding a fixture dependency: +// NITRO_VITE_MODULE=/absolute/path/to/nitro/dist/vite.mjs node test/host-build.mjs +import assert from 'node:assert/strict'; +import { + existsSync, + readFileSync, + readdirSync, + mkdtempSync, + cpSync, + symlinkSync, + realpathSync, + rmSync, +} from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { createBuilder, preview } from 'vite'; +import solid from '@solidjs/vite-plugin'; + +const fixture = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const mode = process.argv[2]; +if (!mode) { + const modes = process.env.NITRO_VITE_MODULE + ? ['functions', 'static', 'ssr'] + : ['functions', 'static', 'config-host', 'ssr', 'configured-functions', 'configured-ssr']; + for (const test of modes) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), test], { + stdio: 'inherit', + env: process.env, + }); + assert.equal(result.status, 0, `${test} production proof failed`); + } + if (!process.env.NITRO_VITE_MODULE) { + for (const failure of ['setup', 'build', 'assertion']) { + const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'static'], { + encoding: 'utf8', + env: { ...process.env, HOST_TEST_FAILURE: failure }, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, new RegExp('Injected ' + failure + ' failure')); + const sandbox = /PASS owned fixture cleaned: (.+)/.exec(result.stdout)?.[1]; + assert.ok(sandbox, failure + ' cleanup completed'); + assert.equal(existsSync(sandbox), false, failure + ' artifacts cleaned'); + if (failure === 'assertion') assert.match(result.stdout, /PASS preview closed/); + console.log('PASS failure cleanup: ' + failure); + } + await import('./cleanup-target.mjs'); + } +} else { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'solid-host-build-'))); + let server; + let primaryError; + try { + cpSync(path.join(fixture, 'src'), path.join(root, 'src'), { recursive: true }); + cpSync(path.join(fixture, 'package.json'), path.join(root, 'package.json')); + symlinkSync(path.join(fixture, 'node_modules'), path.join(root, 'node_modules'), 'dir'); + if (process.env.HOST_TEST_FAILURE === 'setup') throw new Error('Injected setup failure'); + const configured = mode.startsWith('configured-'); + const serverFunctions = mode === 'functions' || configured; + const ssr = mode === 'ssr' || mode === 'configured-ssr'; + let applicationModuleChecked = false; + const output = path.join(root, 'dist', 'host'); + const clientDir = path.join(output, 'public'); + // Configured modes relocate through a later configEnvironment, which is + // what actually overrides the plugin's dist/client and dist/server + // defaults. A hardcoded prerender or preview path fails these modes. + const serverDir = path.join(output, configured ? 'user-server' : 'service'); + let consumed = false; + const buildService = async (builder) => { + for (const environment of Object.values(builder.environments)) { + if (!environment.isBuilt) await builder.build(environment); + } + if (process.env.HOST_TEST_FAILURE === 'build') throw new Error('Injected build failure'); + assert.ok(existsSync(path.join(serverDir, 'server.js')), 'host service retained'); + consumed = true; + }; + const miniatureHost = { + name: 'fixture:host', + config() { + return { + ...(mode === 'config-host' ? { builder: { buildApp: buildService } } : {}), + }; + }, + configEnvironment(name, config) { + if (name === 'client') config.build.outDir = clientDir; + if (name === 'ssr') config.build.outDir = serverDir; + }, + buildApp: + mode === 'config-host' + ? undefined + : { + order: 'post', + async handler(builder) { + await buildService(builder); + if (!ssr) { + assert.ok(existsSync(path.join(clientDir, 'index.html')), 'shell ready for host'); + } + }, + }, + }; + // No buildApp here: relocation has to win as a configEnvironment override + // of the plugin defaults, including for preview's resolved directories. + const relocate = { + name: 'fixture:relocate', + configEnvironment(name, config) { + if (name === 'client') config.build.outDir = clientDir; + if (name === 'ssr') config.build.outDir = serverDir; + }, + }; + const makeHost = async () => { + if (!process.env.NITRO_VITE_MODULE) return miniatureHost; + const { nitro } = await import(pathToFileURL(process.env.NITRO_VITE_MODULE).href); + return nitro({ + preset: 'node-server', + output: { dir: output, publicDir: clientDir, serverDir: path.join(output, 'server') }, + buildDir: path.join(output, 'nitro'), + }); + }; + // Nitro treats port 0 as its default port. Reserve an available port before + // starting preview so the proof never shares a user's running service. + const reservation = createServer(); + await new Promise((resolve) => reservation.listen(0, '127.0.0.1', resolve)); + const port = reservation.address().port; + await new Promise((resolve) => reservation.close(resolve)); + const config = async () => ({ + root, + cacheDir: path.join(root, '.vite-cache'), + configFile: false, + plugins: [ + solid({ start: true, ssr, serverFunctions }), + ...(configured ? [relocate] : [await makeHost()]), + { + name: 'fixture:application-pipeline', + enforce: 'post', + transform(code, id) { + if ( + ssr && + this.environment.name === 'ssr' && + id.split('?')[0] === path.join(root, 'src/LazySection.tsx') + ) { + assert.match(code, /\$\$moduleUrl\s*=/, 'application SSR module metadata'); + applicationModuleChecked = true; + } + }, + }, + ], + preview: { host: '127.0.0.1', port, strictPort: true }, + }); + const builder = await createBuilder(await config()); + await builder.buildApp(); + if (ssr) { + assert.ok(applicationModuleChecked, 'application SSR transform exercised'); + console.log('PASS application SSR module metadata'); + } + const builtClientDir = clientDir; + const builtServerDir = path.resolve(root, builder.environments.ssr.config.build.outDir); + if (!process.env.NITRO_VITE_MODULE) { + assert.equal(builtServerDir, path.resolve(serverDir), 'SSR output follows configEnvironment'); + assert.equal( + path.resolve(root, builder.environments.client.config.build.outDir), + path.resolve(clientDir), + 'client output follows configEnvironment', + ); + assert.ok( + existsSync(path.join(serverDir, 'server.js')), + 'server.js emitted to relocated dir', + ); + assert.ok( + !existsSync(path.join(root, 'dist/server/server.js')), + 'hardcoded dist/server was not the emit directory', + ); + } + if (!configured && !process.env.NITRO_VITE_MODULE) { + assert.ok(consumed, 'host orchestrator ran'); + assert.ok(existsSync(path.join(serverDir, 'server.js')), 'host service survives prerender'); + } + if (!ssr) { + const shell = readFileSync(path.join(builtClientDir, 'index.html'), 'utf8'); + assert.ok(shell.startsWith(']+src="\/assets\//, 'hashed client entry'); + assert.match(shell, /]+href="\/assets\//, 'entry CSS in shell'); + assert.ok( + !existsSync(path.join(root, 'dist/client/index.html')), + 'shell was not written to hardcoded dist/client', + ); + } + server = await preview(await config()); + if (process.env.HOST_TEST_FAILURE === 'assertion') assert.fail('Injected assertion failure'); + if (!process.env.NITRO_VITE_MODULE) { + assert.equal( + path.resolve(root, server.config.environments.ssr.build.outDir), + path.resolve(serverDir), + 'preview SSR directory follows configEnvironment', + ); + assert.equal( + path.resolve(root, server.config.environments.client.build.outDir), + path.resolve(clientDir), + 'preview client directory follows configEnvironment', + ); + } + const origin = `http://127.0.0.1:${server.httpServer.address().port}`; + const response = await fetch(origin + '/', { headers: { accept: 'text/html' } }); + assert.equal(response.status, 200); + const html = await response.text(); + assert.match(html, /Start Client<\/title>/); + assert.equal(html.includes('CLIENT-RENDERED-APP'), ssr); + for (const [, url] of html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)) { + assert.equal((await fetch(origin + url)).status, 200, `HTTP asset: ${url}`); + } + if (serverFunctions) { + const assets = readdirSync(path.join(builtClientDir, 'assets')) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(path.join(builtClientDir, 'assets', file), 'utf8')) + .join('\n'); + const id = /ping-[a-zA-Z0-9_-]+/.exec(assets)?.[0]; + assert.ok(id, 'compiled server function id'); + const endpoint = await fetch( + `${origin}/_server/${id}?args=${encodeURIComponent('["host"]')}`, + { + method: 'POST', + headers: { 'Sec-Fetch-Site': 'same-origin' }, + }, + ); + assert.equal(endpoint.status, 200); + assert.equal(await endpoint.text(), 'pong:host'); + } + console.log( + `PASS ${process.env.NITRO_VITE_MODULE ? 'nitro' : 'miniature host'} ${mode}: production shell, assets and HTTP dispatch`, + ); + } catch (error) { + primaryError = error; + throw error; + } finally { + const cleanupErrors = []; + try { + if (server) { + await new Promise((resolve, reject) => + server.httpServer.close((error) => (error ? reject(error) : resolve())), + ); + assert.equal(server.httpServer.listening, false); + console.log('PASS preview closed'); + } + } catch (error) { + cleanupErrors.push(error); + } + try { + rmSync(root, { recursive: true, force: true }); + assert.equal(existsSync(root), false, 'owned fixture cleaned'); + console.log('PASS owned fixture cleaned: ' + root); + } catch (error) { + cleanupErrors.push(error); + } + if (cleanupErrors.length) { + throw new AggregateError( + [...(primaryError ? [primaryError] : []), ...cleanupErrors], + 'Failed to clean host build fixture', + ); + } + } +} diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 249b1d6..fb9c38c 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -45,10 +45,11 @@ // Client mode (`start` without `ssr: true`) rides the same machinery with // three deltas: the generated server entry renders the document shell // WITHOUT the app (dev serving doubles as history fallback, and a -// post-build hook prerenders it once into dist/client/index.html), the -// generated client entry render()s instead of hydrating, and dist/server is -// dropped from the output unless `serverFunctions` needs it for the -// endpoint. Client code compiles non-hydratable, exactly like a plain SPA. +// post-build hook prerenders it once into the client emit directory), the +// generated client entry render()s instead of hydrating, and the server +// bundle is dropped unless `serverFunctions` needs it or another plugin +// owns the app build. Client code compiles non-hydratable, exactly like a +// plain SPA. import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -486,6 +487,17 @@ function normalizeUserPath(root: string, spec: string, option: string): string { return relative; } +/** True when `directory` is nested inside `parent` and is not `parent` itself. */ +function isStrictSubdirectory(parent: string, directory: string): boolean { + const relative = path.relative(parent, directory); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + type RenderMode = 'stream' | 'async'; /** @@ -700,6 +712,10 @@ export function startServe( let root = process.cwd(); let base = '/'; let isBuild = false; + // Set from the config hook, before Vite installs the default no-op + // `builder.buildApp`. A user or host callback here means the SSR service + // must survive client-mode prerender even with server functions off. + let configBuildApp = false; let entries: ResolvedEntries | undefined; /** Absolute path of the user's middleware module, when configured. */ let middlewarePath: string | null = null; @@ -1681,22 +1697,36 @@ export function startServe( renderMode = resolveRenderMode(options.renderMode); if (clientMode) renderMode = 'stream'; if (env.isPreview) { + // Defaults only. A host that relocates an environment in + // `configEnvironment` still wins — that hook runs after this + // merge. `start.external` leaves both directories to the host. + const previewBuild = externalServer + ? {} + : { + build: { outDir: 'dist/client' }, + environments: { + ssr: { + build: { + outDir: 'dist/server', + }, + }, + }, + }; if (clientMode) { - // Client-mode builds emit a real dist/client/index.html (the - // prerendered shell), so preview is Vite's stock static + - // history-fallback story. When server functions are on, the - // endpoint dispatches through the kept dist/server handler - // (configurePreviewServer). - return { appType: 'spa', build: { outDir: 'dist/client' } }; + // Client-mode builds emit a real index.html (the prerendered + // shell), so preview is Vite's stock static + history-fallback + // story. When server functions are on, the endpoint dispatches + // through the kept server handler (configurePreviewServer). + return { appType: 'spa', ...previewBuild }; } - // `vite preview` serves `build.outDir` statically; point it at the - // client bundle so hashed assets resolve, while HTML (and - // everything else unhandled) falls through to the + // `vite preview` serves the client outDir statically; point the + // default at the client bundle so hashed assets resolve, while + // HTML (and everything else unhandled) falls through to the // configurePreviewServer dispatch below. No index.html exists, so // `custom` keeps preview from attempting an SPA fallback. return { appType: 'custom', - ...(externalServer ? {} : { build: { outDir: 'dist/client' } }), + ...previewBuild, }; } const build = env.command === 'build'; @@ -1820,13 +1850,19 @@ export function startServe( if (internal.performanceTracks && !isBuild && config.mode !== 'test') { performanceTracks = detectPerformanceTracksSubpath(root); } - if (isBuild && nodeEntry) { + { + // Resolved directories for prerender, preview, and the Node entry. + // `configEnvironment` has already run, so a host relocation wins + // over the dist/client and dist/server defaults. generateBundle + // below overrides these with the directories actually written. const environments = (config as any).environments ?? {}; const clientBuild = environments.client?.build ?? config.build; - const serverBuild = environments.ssr?.build ?? config.build; + const serverBuild = environments.ssr?.build; clientOutDir = path.resolve(root, clientBuild.outDir); - serverOutDir = path.resolve(root, serverBuild.outDir); + serverOutDir = path.resolve(root, serverBuild?.outDir ?? 'dist/server'); clientAssetsDir = clientBuild.assetsDir ?? 'assets'; + } + if (isBuild && nodeEntry) { if (externalServer) { config.logger.warn( '[@solidjs/vite-plugin] start.node is ignored with start.external: the host owns the ' + @@ -1852,7 +1888,7 @@ export function startServe( // is byte-identical with and without the option. order: 'post', handler(outputOptions, bundle) { - if (!isBuild || !nodeEntryApplies) return; + if (!isBuild) return; const consumer = getEnvironmentConsumer(this.environment); if (consumer === 'client') { // Where the client build actually landed (authoritative over @@ -1861,6 +1897,8 @@ export function startServe( return; } if (this.environment?.name !== 'ssr') return; + if (outputOptions.dir) serverOutDir = path.resolve(root, outputOptions.dir); + if (!nodeEntryApplies) return; // The entry imports ./server.js; without that chunk (a provider // rewrote the ssr environment's output) it could not run. const serverChunk = bundle[SERVER_ENTRY_FILE]; @@ -2051,7 +2089,7 @@ export function startServe( server.middlewares.use((req, res, next) => { (async () => { handlerPromise ??= import( - pathToFileURL(path.resolve(root, 'dist/server/server.js')).href + pathToFileURL(path.join(serverOutDir!, SERVER_ENTRY_FILE)).href ); const handler = await handlerPromise; // Preview's base middleware runs before this post hook and @@ -2179,6 +2217,14 @@ export function startServe( { name: 'solid:start/prerender', apply: 'build', + config: { + order: 'post', + handler(config) { + // The resolved builder always has a default no-op buildApp. + // Read the user/plugin callback before Vite adds that default. + configBuildApp = !!config.builder?.buildApp; + }, + }, buildApp: { // Post order: this hook owns the whole client-mode app build (the // client-build-first orchestration pair is SSR-only). It @@ -2186,13 +2232,13 @@ export function startServe( // hook suppresses Vite's build-all fallback, so the ordering // is guaranteed and the manifest is on disk before the shell // bundle bakes it in — then runs the built handler once to - // prerender the shell into dist/client/index.html and drops - // dist/server unless server functions still need its handler. - // The shell arrives complete from the handler: the runtime - // registers every manifest entry's CSS during the render - // (registerEntryAssets), so the entry graph's stylesheet - // links are already in its head — injecting them here again - // double-links every stylesheet. + // prerender the shell into the client emit directory and drops + // the server directory unless server functions or a host still + // need its handler. The shell arrives complete from the + // handler: the runtime registers every manifest entry's CSS + // during the render (registerEntryAssets), so the entry + // graph's stylesheet links are already in its head — injecting + // them here again double-links every stylesheet. order: 'post' as const, async handler(builder: any) { const client = builder.environments.client; @@ -2202,24 +2248,49 @@ export function startServe( await builder.build(ssrEnvironment); } - const serverDir = path.resolve(root, 'dist/server'); + const serverDir = serverOutDir!; const handler = await import( - pathToFileURL(path.join(serverDir, 'server.js')).href + pathToFileURL(path.join(serverDir, SERVER_ENTRY_FILE)).href ); const response: Response = await handler.handleRequest( new Request(new URL(base || '/', 'http://localhost')), ); // The built handler answers failures with a 500 instead of // rejecting; a shell that did not render must fail the - // build, not become index.html. + // build, not become index.html. Cleanup runs only after a + // shell was actually written. if (!response.ok) { throw new Error( `[@solidjs/vite-plugin] prerendering the client-mode shell failed: the handler answered ${response.status}`, ); } - writeFileSync(path.resolve(root, 'dist/client/index.html'), await response.text()); - if (!internal.serverFunctions) { - rmSync(serverDir, { recursive: true, force: true }); + writeFileSync(path.join(clientOutDir!, 'index.html'), await response.text()); + // A host may still need this service for its final server + // build, even when the application has no server functions. + // Pre-order buildApp hooks prepare (they clean output) and + // do not count; a function hook is normal order and does. + const hostBuild = + configBuildApp || + builder.config.plugins.some( + (plugin: Plugin) => + plugin.name !== 'solid:start/prerender' && + !!plugin.buildApp && + (typeof plugin.buildApp !== 'object' || plugin.buildApp.order !== 'pre'), + ); + const safeCleanup = + isStrictSubdirectory(root, serverDir) && + serverDir !== clientOutDir && + !isStrictSubdirectory(serverDir, clientOutDir!); + if (!internal.serverFunctions && !hostBuild) { + if (safeCleanup) { + rmSync(serverDir, { recursive: true, force: true }); + } else { + builder.config.logger.warn( + '[@solidjs/vite-plugin] Preserving SSR output at ' + + serverDir + + ': automatic cleanup requires a directory inside the project root that does not contain client output.', + ); + } } }, },