Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/private-build-metadata.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions examples/start-ssr/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
16 changes: 16 additions & 0 deletions src/build-files.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
18 changes: 15 additions & 3 deletions src/deployment-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -59,14 +59,19 @@ function provider(output: string): Plugin {
};
}

async function buildApp(output: string, options: Parameters<typeof solidPlugin>[0]) {
async function buildApp(
output: string,
options: Parameters<typeof solidPlugin>[0],
config: InlineConfig = {},
) {
const builder = await createBuilder({
root,
configFile: false,
// Own optimizer cache: node_modules is a symlink to the repo's.
cacheDir: path.join(root, '.vite'),
logLevel: 'silent',
plugins: [solidPlugin(options), provider(output)],
...config,
});
await builder.buildApp();
}
Expand All @@ -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);

Expand Down Expand Up @@ -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);
});
60 changes: 51 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1604,12 +1605,18 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
const testRuntimeConsumers = new Set<string>();
const tsrxCss = new Map<string, string>();

// 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<string>();
let ownsClientManifest = false;
function clientManifestPath(): string | null {
for (const dir of [clientOutDir, 'dist/client']) {
if (!dir) continue;
Expand Down Expand Up @@ -2551,9 +2558,12 @@ export default function solidPlugin(options: Partial<Options> = {}): 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 —
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -3240,6 +3279,9 @@ export default manifest;`;
onClientEntryResolved(entryId) {
startClientEntryId = entryId;
},
onClientManifestOwned(owned) {
ownsClientManifest = owned;
},
}),
);
}
Expand Down
7 changes: 7 additions & 0 deletions src/server-functions/build-id.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 35 additions & 11 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string>,
functions: Map<string, FunctionRecord>,
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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string>();
// Endpoint with Vite `base` applied; final after configResolved, which
// runs before every transform/load/middleware that reads it.
let resolvedEndpoint = endpoint;
Expand Down Expand Up @@ -1009,21 +1022,30 @@ 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) {
// The directory this build actually wrote, which a host may have
// 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);
}
},
},
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/ssr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading