diff --git a/.changeset/client-host-prerender.md b/.changeset/client-host-prerender.md new file mode 100644 index 00000000..00188c8f --- /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 0a8ff20d..c00adfde 100644 --- a/README.md +++ b/README.md @@ -778,12 +778,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 a7a96f87..860a7d9c 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 00000000..92ebfbce --- /dev/null +++ b/examples/start-client/test/cleanup-target.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createBuilder } from 'vite'; +import solid from '@solidjs/vite-plugin'; +import trash from 'trash'; + +const fixture = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +async function run(failure) { + const sandbox = fs.mkdtempSync(path.join(fixture, 'node_modules/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 + ? [] + : [ + 'Preserving SSR output at ' + + target + + ': automatic cleanup requires a directory inside the project root that does not contain client output.', + ], + '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 { + await trash([sandbox], { glob: false }); + 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 00000000..833fc26e --- /dev/null +++ b/examples/start-client/test/host-build.mjs @@ -0,0 +1,234 @@ +// 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, +} 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'; +import trash from 'trash'; + +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'); + const serverDir = path.join(output, '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'); + } + }, + }, + }; + 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 ? [] : [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; + } + }, + }, + ], + ...(configured + ? { environments: { ssr: { build: { outDir: path.join(output, 'user-server') } } } } + : {}), + 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 = configured ? path.join(root, 'dist/client') : clientDir; + const builtServerDir = path.resolve(root, builder.environments.ssr.config.build.outDir); + 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'); + } + server = await preview(await config()); + if (process.env.HOST_TEST_FAILURE === 'assertion') assert.fail('Injected assertion failure'); + if (configured) { + assert.equal( + path.resolve(root, server.config.environments.ssr.build.outDir), + builtServerDir, + 'explicit SSR outDir resolves consistently for build and preview', + ); + } + 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 { + await trash([root], { glob: false }); + 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/package.json b/package.json index f46a4c0d..08c8b22c 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "rollup": "^4.5.0", "rollup-plugin-cleaner": "^1.0.0", "solid-js": "^2.0.0-rc.13", + "trash": "^10.1.1", "typescript": "^5.2.2", "vite": "^8.2.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e54a8f20..917ed17b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: solid-js: specifier: ^2.0.0-rc.13 version: 2.0.0-rc.13 + trash: + specifier: ^10.1.1 + version: 10.1.1 typescript: specifier: ^5.2.2 version: 5.9.3 @@ -1358,6 +1361,18 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/df@1.0.1': + resolution: {integrity: sha512-1Hyp7NQnD/u4DSxR2DGW78TF9k7R0wZ8ev0BpMAIzA6yTQSHqNb5wTuvtcPYf4FWbVse2rW7RgDsyL8ua2vXHw==} + engines: {node: '>=0.10.0'} + + '@sindresorhus/df@3.1.1': + resolution: {integrity: sha512-SME/vtXaJcnQ/HpeV6P82Egy+jThn11IKfwW8+/XVoRD0rmPHVTeKMtww1oWdVnMykzVPjmrDN9S8NBndPEHCQ==} + engines: {node: '>=8'} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + '@skypack/package-check@0.2.2': resolution: {integrity: sha512-T4Wyi9lUuz0a1C2OHuzqZ0aFOCI0AmaGTb2LP9sHgWdoHXlB3JU02gfBpa0Y081G/gFsJYpQ/R0iCJRzF/nknw==} hasBin: true @@ -1439,6 +1454,10 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stroncium/procfs@1.2.1': + resolution: {integrity: sha512-X1Iui3FUNZP18EUvysTHxt+Avu2nlVzyf90YM8OYgP6SGzTzzX/0JgObfO1AQQDzuZtNNz29bVh8h5R97JrjxA==} + engines: {node: '>=8'} + '@sveltejs/acorn-typescript@1.0.13': resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} peerDependencies: @@ -1736,6 +1755,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chunkify@5.0.0: + resolution: {integrity: sha512-G8dj/3/Gm+1yL4oWSdwIxihZWFlgC4V2zYtIApacI0iPIRKBHlBGOGAiDUBZgrj4H8MBA8g8fPFwnJrWF3wl7Q==} + engines: {node: '>=18'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1975,6 +1998,10 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + execa@2.1.0: + resolution: {integrity: sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==} + engines: {node: ^8.12.0 || >=9.7.0} + execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} engines: {node: '>=10'} @@ -2111,6 +2138,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2180,6 +2211,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.11: + resolution: {integrity: sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==} + engines: {node: '>= 4'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -2199,6 +2234,11 @@ packages: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2211,6 +2251,11 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-installed-globally@0.4.0: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} @@ -2226,6 +2271,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2258,6 +2307,10 @@ packages: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -2482,6 +2535,14 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mount-point@3.0.0: + resolution: {integrity: sha512-jAhfD7ZCG+dbESZjcY1SdFVFqSJkh/yGbdsifHcPkvuLRO5ugK0Ssmd9jdATu29BTd4JiN+vkpMzVvsUgP3SZA==} + engines: {node: '>=0.10.0'} + + move-file@4.1.0: + resolution: {integrity: sha512-YE06K9XLIvMlqSfoZTl32qvbZLPgL70Za41wS8pEhsSOhy71xz2fn8J07nuz/LEEtPSuUzLUFGAJSx499eKDSw==} + engines: {node: '>=20'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2502,6 +2563,10 @@ packages: resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} engines: {node: '>=18'} + npm-run-path@3.1.0: + resolution: {integrity: sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==} + engines: {node: '>=8'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -2524,6 +2589,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + os-homedir@1.0.2: + resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==} + engines: {node: '>=0.10.0'} + ospath@1.2.2: resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} @@ -2534,6 +2603,10 @@ packages: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} + p-finally@2.0.1: + resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} + engines: {node: '>=8'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -2550,6 +2623,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-map@7.0.8: + resolution: {integrity: sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==} + engines: {node: '>=18'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -2579,6 +2656,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2607,6 +2688,14 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + pixelmatch@5.3.0: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true @@ -2633,6 +2722,14 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -2835,6 +2932,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -2955,6 +3056,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trash@10.1.1: + resolution: {integrity: sha512-L/mu8sfblMwaS+exj1MxpmihlIRwVQyB6ieKuTTmBJG0lXWBPfx3pMGQG8i3NT/S8vvNZrflDUOp+j0o7Cnxzw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3003,6 +3108,10 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -3021,6 +3130,10 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + user-home@2.0.0: + resolution: {integrity: sha512-KMWqdlOcjCYdtIJpicDSFBQ8nFwS2i9sslAd6f4+CBGcU4gist2REnr2fxj2YocvJFxSF3ZOHLYLVZnUxv4BZQ==} + engines: {node: '>=0.10.0'} + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -3187,6 +3300,18 @@ packages: utf-8-validate: optional: true + wsl-utils@0.4.0: + resolution: {integrity: sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==} + engines: {node: '>=20'} + + xdg-basedir@4.0.0: + resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} + engines: {node: '>=8'} + + xdg-trashdir@3.1.0: + resolution: {integrity: sha512-N1XQngeqMBoj9wM4ZFadVV2MymImeiFfYD+fJrNlcVcOHsJFFQe7n3b+aBoTPwARuq2HQxukfzVpQmAk1gN4sQ==} + engines: {node: '>=10'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -4442,6 +4567,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sindresorhus/df@1.0.1': {} + + '@sindresorhus/df@3.1.1': + dependencies: + execa: 2.1.0 + + '@sindresorhus/merge-streams@2.3.0': {} + '@skypack/package-check@0.2.2': dependencies: kleur: 4.1.5 @@ -4518,6 +4651,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stroncium/procfs@1.2.1': {} + '@sveltejs/acorn-typescript@1.0.13(acorn@8.18.0)': dependencies: acorn: 8.18.0 @@ -4854,6 +4989,8 @@ snapshots: dependencies: readdirp: 5.1.1 + chunkify@5.0.0: {} + ci-info@4.4.0: {} clean-stack@2.2.0: {} @@ -5117,6 +5254,18 @@ snapshots: eventemitter2@6.4.7: {} + execa@2.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 3.1.0 + onetime: 5.1.2 + p-finally: 2.0.1 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + execa@4.1.0: dependencies: cross-spawn: 7.0.6 @@ -5289,6 +5438,15 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.11 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -5352,6 +5510,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.11: {} + indent-string@4.0.0: {} inflight@1.0.6: @@ -5367,6 +5527,8 @@ snapshots: dependencies: hasown: 2.0.4 + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -5375,6 +5537,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-installed-globally@0.4.0: dependencies: global-dirs: 3.0.1 @@ -5386,6 +5552,8 @@ snapshots: is-path-inside@3.0.3: {} + is-path-inside@4.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-reference@1.2.1: @@ -5410,6 +5578,10 @@ snapshots: is-windows@1.0.2: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} isstream@0.1.2: {} @@ -5616,6 +5788,14 @@ snapshots: minimist@1.2.8: {} + mount-point@3.0.0: + dependencies: + '@sindresorhus/df': 1.0.1 + pify: 2.3.0 + pinkie-promise: 2.0.1 + + move-file@4.1.0: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -5626,6 +5806,10 @@ snapshots: node-releases@2.0.52: {} + npm-run-path@3.1.0: + dependencies: + path-key: 3.1.1 + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -5644,6 +5828,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + os-homedir@1.0.2: {} + ospath@1.2.2: {} outdent@0.5.0: {} @@ -5652,6 +5838,8 @@ snapshots: dependencies: p-map: 2.1.0 + p-finally@2.0.1: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -5666,6 +5854,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-map@7.0.8: {} + p-try@2.2.0: {} package-manager-detector@0.2.11: @@ -5686,6 +5876,8 @@ snapshots: path-type@4.0.0: {} + path-type@6.0.0: {} + pathe@2.0.3: {} pend@1.2.0: {} @@ -5702,6 +5894,12 @@ snapshots: pify@4.0.1: {} + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + pixelmatch@5.3.0: dependencies: pngjs: 6.0.0 @@ -5724,6 +5922,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + + powershell-utils@0.2.1: {} + prettier@2.8.8: {} prettier@3.9.6: {} @@ -5955,6 +6157,8 @@ snapshots: slash@3.0.0: {} + slash@5.1.0: {} + slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -6068,6 +6272,18 @@ snapshots: dependencies: punycode: 2.3.1 + trash@10.1.1: + dependencies: + '@stroncium/procfs': 1.2.1 + chunkify: 5.0.0 + globby: 14.1.0 + is-path-inside: 4.0.0 + move-file: 4.1.0 + p-map: 7.0.8 + powershell-utils: 0.2.1 + wsl-utils: 0.4.0 + xdg-trashdir: 3.1.0 + tree-kill@1.2.2: {} truncate-utf8-bytes@1.0.2: @@ -6101,6 +6317,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.3.0: {} + universalify@0.1.2: {} universalify@2.0.1: {} @@ -6113,6 +6331,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + user-home@2.0.0: + dependencies: + os-homedir: 1.0.2 + utf8-byte-length@1.0.5: {} uuid@8.3.2: {} @@ -6214,6 +6436,20 @@ snapshots: ws@8.21.3: {} + wsl-utils@0.4.0: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + xdg-basedir@4.0.0: {} + + xdg-trashdir@3.1.0: + dependencies: + '@sindresorhus/df': 3.1.1 + mount-point: 3.0.0 + user-home: 2.0.0 + xdg-basedir: 4.0.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 8fb6bb86..1ab5b1f3 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -480,6 +480,16 @@ function normalizeUserPath(root: string, spec: string, option: string): string { return relative; } +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'; /** @@ -715,6 +725,7 @@ export function startServe( let root = process.cwd(); let base = '/'; let isBuild = false; + let configBuildApp = false; let entries: ResolvedEntries | undefined; /** Absolute path of the user's middleware module, when configured. */ let middlewarePath: string | null = null; @@ -1508,13 +1519,25 @@ export function startServe( renderModePath = null; } if (env.isPreview) { + 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' } }; + return { appType: 'spa', ...previewBuild }; } // `vite preview` serves `build.outDir` statically; point it at the // client bundle so hashed assets resolve, while HTML (and @@ -1523,7 +1546,7 @@ export function startServe( // `custom` keeps preview from attempting an SPA fallback. return { appType: 'custom', - ...(externalServer ? {} : { build: { outDir: 'dist/client' } }), + ...previewBuild, }; } const build = env.command === 'build'; @@ -1647,13 +1670,15 @@ export function startServe( if (internal.performanceTracks && !isBuild && config.mode !== 'test') { performanceTracks = detectPerformanceTracksSubpath(root); } - if (isBuild && nodeEntry) { + { 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 ' + @@ -1679,7 +1704,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 @@ -1688,6 +1713,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]; @@ -1878,7 +1905,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 @@ -2006,6 +2033,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 @@ -2029,16 +2064,38 @@ 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')), ); - 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. + 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( + 'Preserving SSR output at ' + + serverDir + + ': automatic cleanup requires a directory inside the project root that does not contain client output.', + ); + } } }, },