From 764b37d1e3abe6ba0391f951c1da987d7a64d902 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Sat, 31 Oct 2026 01:47:44 +0100 Subject: [PATCH] fix: keep build metadata out of the client output in app builds When one process builds both environments, the server build reads Vite's manifest and the server-function data from memory. The plugin then removes the manifest it turned on (an app's own build.manifest stays) and moves .vite/solid-server-functions.json beside the server bundle, so hosts that publish the client output, like Nitro, don't serve them. --- .changeset/private-build-metadata.md | 5 +++ README.md | 24 +++++++---- examples/start-ssr/vite.config.ts | 20 +++++---- src/build-files.ts | 16 +++++++ src/deployment-contract.test.ts | 18 ++++++-- src/index.ts | 60 +++++++++++++++++++++++---- src/server-functions/build-id.test.ts | 7 ++++ src/server-functions/index.ts | 46 +++++++++++++++----- src/ssr/index.ts | 14 +++++++ 9 files changed, 171 insertions(+), 39 deletions(-) create mode 100644 .changeset/private-build-metadata.md create mode 100644 src/build-files.ts diff --git a/.changeset/private-build-metadata.md b/.changeset/private-build-metadata.md new file mode 100644 index 0000000..a92fea1 --- /dev/null +++ b/.changeset/private-build-metadata.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Keep build metadata out of the client output when one process builds both environments. The server build reads Vite's manifest and the server-function data from memory, then the plugin removes the manifest it turned on (set `build.manifest` to keep it) and moves `.vite/solid-server-functions.json` to the server output, so hosts like Nitro that publish the client output no longer serve them. diff --git a/README.md b/README.md index 8f941d2..79082bb 100644 --- a/README.md +++ b/README.md @@ -305,9 +305,9 @@ With `ssr: true` — **SSR start mode**: client entry enables the Chrome Performance panel tracks before it hydrates (see [`performanceTracks`](#optionsperformancetracks)). - **Build**: a plain `vite build` produces both bundles via the - environments/builder API — client assets (+ manifest) to `dist/client` and - the server bundle to `dist/server/server.js`. (`vite build --app`, or the - classic `vite build` + `vite build --ssr` two-step, work too.) + environments/builder API: client assets to `dist/client` and the server + bundle to `dist/server/server.js`. (`vite build --app`, or the classic + `vite build` + `vite build --ssr` two-step, work too.) - **Build ordering**: server builds read the client manifest, so with `ssr` enabled the plugin also orders builder-mode (environments API) app builds client-first via a `buildApp` hook. That covers composed @@ -931,8 +931,11 @@ The client build also writes `.vite/solid-server-functions.json` into its output directory (`dist/client` in start mode), typed `PersistedServerFunctionManifest`: the modules containing server functions, every function the client references (by wire id), and the build id, with -paths relative to the Vite root. The server build reads it back, and build -tooling such as a prerenderer can read it once the client build finishes. +paths relative to the Vite root. A separate `vite build --ssr` reads it back. +When one process builds both, the server build takes the same data from +memory and moves the file into its own output directory (`dist/server` in +start mode), so a host that publishes the client output doesn't serve it. +Build tooling such as a prerenderer can read it there. **`devMiddleware: false`** hands endpoint dispatch in dev to a host instead of the plugin's middleware. The middleware executes functions in Vite's @@ -1270,10 +1273,13 @@ in this README are internal. Nitro does) may take the environments in any order. 4. **Output.** Client files go to the `client` environment's `outDir` (default `dist/client`): content-hashed files under `build.assetsDir`, - safe to cache forever, and Vite's manifest under `.vite/`, which is not - for serving. The server bundle goes to the `ssr` environment's `outDir`. - A provider may move either directory; the plugin follows the configured - `outDir`. + safe to cache forever. Build metadata goes under `.vite/`, which is not + for serving. When one process builds both environments, the server reads + it from memory, and the plugin then removes Vite's manifest (unless + `build.manifest` is set) and moves `solid-server-functions.json` to the + server output. The server bundle goes to the `ssr` environment's + `outDir`. A provider may move either directory; the plugin follows the + configured `outDir`. 5. **Modes.** With `ssr: true`, every request that isn't a static file goes to the handler. In client mode, `dist/client` is a static site with a prerendered `index.html`, the fallback for page navigations. Its server diff --git a/examples/start-ssr/vite.config.ts b/examples/start-ssr/vite.config.ts index 9a0f8e1..051f572 100644 --- a/examples/start-ssr/vite.config.ts +++ b/examples/start-ssr/vite.config.ts @@ -143,13 +143,19 @@ export default defineConfig({ }, } : { test: { environment: 'jsdom' } }), - ...(process.env.EXTRA_CLIENT_INPUT - ? { - environments: { - client: { build: { rollupOptions: { input: ['src/ExtraInput.tsx'] } } }, - }, - } - : {}), + environments: { + client: { + build: { + // test/run.mjs reads the client manifest after builds. Asking for it + // keeps it in dist/client: otherwise the plugin removes the one it + // turned on once the server build has read it. + manifest: true, + ...(process.env.EXTRA_CLIENT_INPUT + ? { rollupOptions: { input: ['src/ExtraInput.tsx'] } } + : {}), + }, + }, + }, ...(process.env.SANITIZE_FILE_NAME === 'custom' || process.env.SANITIZE_FILE_NAME === 'off' ? { build: { diff --git a/src/build-files.ts b/src/build-files.ts new file mode 100644 index 0000000..276cfc5 --- /dev/null +++ b/src/build-files.ts @@ -0,0 +1,16 @@ +import { rmdirSync, rmSync } from 'fs'; +import path from 'path'; + +/** + * Removes a build-only file from the client output once a server build in + * the same process has read it, and its `.vite/` directory when nothing else + * is left there. Hosts may publish the whole client output (#511). + */ +export function removeClientBuildFile(file: string): void { + rmSync(file, { force: true }); + try { + rmdirSync(path.dirname(file)); + } catch { + // Not empty, or already gone. + } +} diff --git a/src/deployment-contract.test.ts b/src/deployment-contract.test.ts index e05d172..9cb0a6b 100644 --- a/src/deployment-contract.test.ts +++ b/src/deployment-contract.test.ts @@ -9,7 +9,7 @@ import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { createBuilder, type Plugin } from 'vite'; +import { createBuilder, type InlineConfig, type Plugin } from 'vite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import solidPlugin from './index.js'; @@ -59,7 +59,11 @@ function provider(output: string): Plugin { }; } -async function buildApp(output: string, options: Parameters[0]) { +async function buildApp( + output: string, + options: Parameters[0], + config: InlineConfig = {}, +) { const builder = await createBuilder({ root, configFile: false, @@ -67,6 +71,7 @@ async function buildApp(output: string, options: Parameters[ cacheDir: path.join(root, '.vite'), logLevel: 'silent', plugins: [solidPlugin(options), provider(output)], + ...config, }); await builder.buildApp(); } @@ -81,7 +86,8 @@ describe('deployment contract', () => { it('serves an SSR app through the index entry a provider builds', async () => { await buildApp('.ssr-output', { start: true, ssr: true }); const publicDir = path.join(root, '.ssr-output/public'); - expect(existsSync(path.join(publicDir, '.vite/manifest.json'))).toBe(true); + // The server read the manifest from memory: nothing under .vite/ is left to publish. + expect(existsSync(path.join(publicDir, '.vite'))).toBe(false); const serverEntry = path.join(root, '.ssr-output/server/server.js'); expect(existsSync(serverEntry)).toBe(true); @@ -119,5 +125,11 @@ describe('deployment contract', () => { const html = readFileSync(path.join(publicDir, 'index.html'), 'utf-8'); expect(clientEntry(html, publicDir)).toEqual({ src: expect.any(String), exists: true }); expect(existsSync(path.join(root, '.client-output/server/server.js'))).toBe(true); + expect(existsSync(path.join(publicDir, '.vite'))).toBe(false); + }, 120_000); + + it("keeps Vite's manifest in the client output when the app asks for it", async () => { + await buildApp('.manifest-output', { start: true, ssr: true }, { build: { manifest: true } }); + expect(existsSync(path.join(root, '.manifest-output/public/.vite/manifest.json'))).toBe(true); }, 120_000); }); diff --git a/src/index.ts b/src/index.ts index 7e4c17c..39334e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ import { withFilter, } from 'vite'; import { type AssetUrlOptions, transformAssetUrls } from './asset-urls.js'; +import { removeClientBuildFile } from './build-files.js'; import { createCssModuleClasses } from './css-module-classes.js'; import { getEnvironmentConsumer, isRunnableEnvironment } from './environment.js'; import { exactIds, extensionIds } from './hook-filters.js'; @@ -1604,12 +1605,18 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const testRuntimeConsumers = new Set(); const tsrxCss = new Map(); - // The client build's manifest, read back by SSR builds. In builder-mode - // (single process, e.g. SolidStart's nitro plugin) the client build runs - // first and generateBundle records its actual outDir — authoritative, since - // such setups relocate it. Two-invocation builds (`vite build --outDir - // dist/client` then `vite build --ssr`) run in separate processes, so the - // SSR process falls back to the `dist/client` convention. + // The client build's manifest, read back by SSR builds. In builder mode + // (one process, e.g. nitro) the client build runs first and its + // writeBundle keeps the manifest in memory, along with the file it wrote. + // Two-invocation builds (`vite build --outDir dist/client` then + // `vite build --ssr`) run in separate processes, so the SSR process reads + // the file from the recorded outDir or the `dist/client` convention. + let clientManifest: { source: string; file: string } | null = null; + // Server environments that read `clientManifest`. Once one has written its + // bundle, the file leaves the client output when start mode turned the + // manifest on (see onClientManifestOwned): hosts may publish that output. + const clientManifestReaders = new Set(); + let ownsClientManifest = false; function clientManifestPath(): string | null { for (const dir of [clientOutDir, 'dist/client']) { if (!dir) continue; @@ -2551,9 +2558,12 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { const deferBridge = !bridgeUrl && !!devServer?.httpServer; return devManifestCode(projectRoot, base, bridgeUrl, deferBridge); } - const manifestPath = clientManifestPath(); - if (manifestPath) { - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (clientManifest) clientManifestReaders.add(this.environment.name); + const manifestPath = clientManifest ? null : clientManifestPath(); + const manifestSource = + clientManifest?.source ?? (manifestPath && readFileSync(manifestPath, 'utf-8')); + if (manifestSource) { + const manifest = JSON.parse(manifestSource); // Manifest records are keyed the way Vite keys entry chunks (the // root-relative facade path, also carried as `src`), so the // configured client inputs identify the genuine entries here too — @@ -2681,6 +2691,35 @@ export default manifest;`; } }, + writeBundle(outputOptions, bundle) { + if (!isBuild) return; + if (isClientBuild(this)) { + const setting = this.environment?.config?.build?.manifest; + const fileName = typeof setting === 'string' ? setting : '.vite/manifest.json'; + const asset = setting ? bundle[fileName] : undefined; + clientManifest = + asset?.type === 'asset' + ? { + source: + typeof asset.source === 'string' + ? asset.source + : new TextDecoder().decode(asset.source), + file: path.resolve( + projectRoot, + outputOptions.dir ?? this.environment.config.build.outDir, + fileName, + ), + } + : null; + } else if ( + ownsClientManifest && + clientManifest && + clientManifestReaders.has(this.environment.name) + ) { + removeClientBuildFile(clientManifest.file); + } + }, + async transform(source, id, transformOptions) { if (isTsrxCssModule(id)) return null; const isSsr = getEnvironmentConsumer(this.environment, transformOptions) === 'server'; @@ -3240,6 +3279,9 @@ export default manifest;`; onClientEntryResolved(entryId) { startClientEntryId = entryId; }, + onClientManifestOwned(owned) { + ownsClientManifest = owned; + }, }), ); } diff --git a/src/server-functions/build-id.test.ts b/src/server-functions/build-id.test.ts index 248e30f..121f7e9 100644 --- a/src/server-functions/build-id.test.ts +++ b/src/server-functions/build-id.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -267,6 +268,12 @@ describe('server function build id in a shared-config app build', () => { const stamped = client.match(/"X-Server-Function-Build"\]\s*=\s*"([0-9a-f]{16})"/)?.[1]; expect(stamped).toMatch(/^[0-9a-f]{16}$/); expect(server).toContain(`buildId: ${JSON.stringify(stamped)}`); + // The file moves beside the server bundle, out of the client output. + expect(existsSync(path.join(root, 'out/public/.vite'))).toBe(false); + const moved = JSON.parse( + await readFile(path.join(root, 'out/server/.vite/solid-server-functions.json'), 'utf-8'), + ); + expect(moved.buildId).toBe(stamped); }, 60_000); // The README configures the client through the `/client` subpath. diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index 9c8c2c6..0f3e53f 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -19,6 +19,7 @@ import { type ViteDevServer, withFilter, } from 'vite'; +import { removeClientBuildFile } from '../build-files.js'; import { getEnvironmentConsumer, isRunnableEnvironment } from '../environment.js'; import { exactIds } from '../hook-filters.js'; import { joinBase, parseRequestTarget, sendWebResponse, webRequestFromNode } from '../http.js'; @@ -207,7 +208,8 @@ const PERSISTED_MANIFEST_PATH = '.vite/solid-server-functions.json'; /** * `.vite/solid-server-functions.json` in the client build's output * directory (`dist/client` in start mode), written when the client build - * finishes. Paths are relative to the Vite root, with `/` separators. + * finishes. A server build in the same process then moves it into its own + * output directory. Paths are relative to the Vite root, with `/` separators. */ export interface PersistedServerFunctionManifest { /** Modules containing server functions. */ @@ -255,15 +257,12 @@ function readPersistedBuildId(root: string, clientDirs: (string | null)[]): stri return parsed && !Array.isArray(parsed) ? parsed.buildId : undefined; } -function writePersistedManifest( +function persistedManifestJson( root: string, - outDir: string, entries: Set, functions: Map, buildId: string, -): void { - const file = path.resolve(root, outDir, PERSISTED_MANIFEST_PATH); - mkdirSync(path.dirname(file), { recursive: true }); +): string { const relative = (entry: string) => path.relative(root, entry).split(path.sep).join('/'); const manifest: PersistedServerFunctionManifest = { modules: [...entries].map(relative), @@ -274,7 +273,15 @@ function writePersistedManifest( })), buildId, }; - writeFileSync(file, JSON.stringify(manifest, null, 2)); + return JSON.stringify(manifest, null, 2); +} + +/** Writes the file into `outDir` and returns its path. */ +function writePersistedManifest(root: string, outDir: string, json: string): string { + const file = path.resolve(root, outDir, PERSISTED_MANIFEST_PATH); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, json); + return file; } interface FunctionRecord { @@ -479,6 +486,12 @@ export function createServerFunctionsPlugins( // and discoveries from memory: a persisted file can be stale, or live in a // directory the host moved (#511). let builtClient = false; + // The file that client build wrote, and the server environments that + // then loaded the manifest from memory. When one of them writes its + // bundle, the file moves beside it, out of the client output that hosts + // may publish (#511). + let clientPersisted: { json: string; file: string } | null = null; + const clientPersistedReaders = new Set(); // Endpoint with Vite `base` applied; final after configResolved, which // runs before every transform/load/middleware that reads it. let resolvedEndpoint = endpoint; @@ -1009,7 +1022,9 @@ export function createServerFunctionsPlugins( // Same client-build detection as the main plugin: builder-mode builds // run both environments in one process, so prefer the per-environment // consumer over the process-wide --ssr flag. - const ctx = this as { environment?: { config?: { consumer?: string } } }; + const ctx = this as { + environment?: { name?: string; config?: { consumer?: string } }; + }; const consumer = ctx.environment?.config?.consumer; const isClient = consumer ? consumer === 'client' : !isSsrBuild; if (isBuild && isClient) { @@ -1017,13 +1032,20 @@ export function createServerFunctionsPlugins( // moved away from the top-level `build.outDir`. writtenClientOutDir = outputOptions.dir ?? outDir; builtClient = true; - writePersistedManifest( + const json = persistedManifestJson( root, - writtenClientOutDir, manifest.modules.server, manifest.clientFunctions, buildId, ); + clientPersisted = { json, file: writePersistedManifest(root, writtenClientOutDir, json) }; + } else if ( + isBuild && + clientPersisted && + clientPersistedReaders.has(ctx.environment?.name ?? '') + ) { + writePersistedManifest(root, outputOptions.dir ?? outDir, clientPersisted.json); + removeClientBuildFile(clientPersisted.file); } }, }, @@ -1041,7 +1063,9 @@ export function createServerFunctionsPlugins( if (id === MANIFEST_ID) { // After a client build in this process, its discoveries are // already in memory (see `builtClient`). - if (isBuild && mode === 'server' && !builtClient) { + if (isBuild && mode === 'server' && builtClient) { + clientPersistedReaders.add(this.environment.name); + } else if (isBuild && mode === 'server') { // Merge the client build's persisted discoveries at load time, // not just configResolved: in builder mode (single process, // `vite build` with the environments API) all environment diff --git a/src/ssr/index.ts b/src/ssr/index.ts index 4655be7..8a1fd98 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -651,6 +651,13 @@ export function startServe( * have to guess the entry among other configured inputs (#353). */ onClientEntryResolved?: (entryId: string) => void; + /** + * Reports whether the client build's manifest is on only because this + * plugin turned it on (a build without `start.externalServer` and without + * a `build.manifest` of the app's), so the main plugin may remove it once + * a server build in the same process has read it. + */ + onClientManifestOwned?: (owned: boolean) => void; } = {}, ): Plugin[] { // Renamed before the stable release, without aliases: name the @@ -1753,6 +1760,13 @@ export function startServe( // a typo should not hide behind the `ssr` boolean. renderMode = resolveRenderMode(options.renderMode); if (clientMode) renderMode = 'stream'; + internal.onClientManifestOwned?.( + env.command === 'build' && + !env.isPreview && + !externalServer && + !userConfig.build?.manifest && + !userConfig.environments?.client?.build?.manifest, + ); if (env.isPreview) { // Defaults only. A host that relocates an environment in // `configEnvironment` still wins — that hook runs after this