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, /