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/server-function-build-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

Production builds now stamp server function calls with a build id (solidjs/solid#4215). The client build adds an `X-Server-Function-Build` header to every non-GET call, and the server build configures the runtime with the same id, read from the client build's `.vite/solid-server-functions.json`. A tab still running the previous deploy now gets a 409 (`unknownFunction: true`) for a function whose id survived the deploy, instead of running it with arguments of another shape. The app's own `prepareRequest` hook keeps working: the plugin wraps `configureServerFunctionsClient` so the build's stamp runs first and the app's hook runs after it. Dev is unchanged, a server built without a client build checks nothing, and a custom `runtime` is left alone.
16 changes: 11 additions & 5 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5783,12 +5783,18 @@ async function runNodeMode() {
env: { ...env, START_NODE: '1' },
});
record(mode, 'build', 'dist/server/node.js emitted beside server.js', existsSync(nodeJs));
// The one thing that legitimately differs between two builds is the
// per-build random deployment secret the server-function handler bakes
// in (`globalThis.__SOLID_SECRET__ ??= "<hex>"`); everything else must
// be byte-identical — the entry is an emitted asset, not a build input.
// The things that legitimately differ between two builds are the
// per-build random values the server-function handler bakes in: the
// deployment secret (`globalThis.__SOLID_SECRET__ ??= "<hex>"`) and the
// build id (`buildId: "<hex>"`), which the client build also stamps on
// its calls, so the baked client manifest names client chunks with other
// content hashes. Everything else must be byte-identical — the entry is
// an emitted asset, not a build input.
const withoutSecret = (source) =>
source.replace(/__SOLID_SECRET__ \?\?= "[0-9a-f]+"/, '__SOLID_SECRET__ ??= "<per-build>"');
source
.replace(/__SOLID_SECRET__ \?\?= "[0-9a-f]+"/, '__SOLID_SECRET__ ??= "<per-build>"')
.replace(/buildId: "[0-9a-f]+"/, 'buildId: "<per-build>"')
.replace(/(assets\/[\w.-]+)-[\w-]{8}\.(js|css)/g, '$1-<hash>.$2');
const serverWith = readFileSync(serverJs, 'utf-8');
record(
mode,
Expand Down
103 changes: 103 additions & 0 deletions src/server-functions/build-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, type Rollup } from 'vite';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { serverFunctions } from './index.js';

const HANDLER_ID = 'virtual:solid-server-function-handler';

function chunkCode(output: Rollup.RollupOutput | Rollup.RollupOutput[] | Rollup.RollupWatcher) {
const outputs = Array.isArray(output) ? output : [output as Rollup.RollupOutput];
return outputs
.flatMap((o) => o.output)
.map((chunk) => (chunk.type === 'chunk' ? chunk.code : ''))
.join('\n');
}

// solidjs/solid#4215: the client build stamps its calls with a build id, and
// the server build is configured with the same id (read back from the
// persisted manifest, as the classic two-invocation build has to).
describe('server function build id', () => {
let root: string;
let client: string;
let server: string;
let buildId: string | undefined;

beforeAll(async () => {
root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-vite-plugin-build-id-')));
await symlink(
fileURLToPath(new URL('../../node_modules', import.meta.url)),
path.join(root, 'node_modules'),
'dir',
);
await mkdir(path.join(root, 'src'));
await writeFile(
path.join(root, 'src/greet.ts'),
'"use server";\nexport async function greet(name: string) {\n return `hello ${name}`;\n}\n',
);
await writeFile(
path.join(root, 'src/client.ts'),
[
"import { configureServerFunctionsClient } from '@solidjs/web/server-functions';",
"import { greet } from './greet';",
'configureServerFunctionsClient({',
' prepareRequest(init) {',
" init.headers['X-App'] = 'app-hook';",
' return init;',
' },',
'});',
"greet('a');",
].join('\n'),
);
await writeFile(
path.join(root, 'src/server.ts'),
`export { handleServerFunctionRequest } from ${JSON.stringify(HANDLER_ID)};\n`,
);
const shared = {
root,
configFile: false as const,
logLevel: 'silent' as const,
plugins: [serverFunctions()],
};
client = chunkCode(
await build({
...shared,
build: {
outDir: 'dist/client',
minify: false,
rollupOptions: { input: path.join(root, 'src/client.ts') },
},
}),
);
const persisted = JSON.parse(
await readFile(path.join(root, 'dist/client/.vite/solid-server-functions.json'), 'utf-8'),
);
buildId = persisted.buildId;
server = chunkCode(
await build({
...shared,
build: { outDir: 'dist/server', minify: false, ssr: path.join(root, 'src/server.ts') },
}),
);
}, 60_000);

afterAll(async () => {
await rm(root, { recursive: true, force: true });
});

it('persists a build id from the client build', () => {
expect(buildId).toMatch(/^[0-9a-f]{16}$/);
});

it('stamps client calls with the id, ahead of the app hook', () => {
expect(client).toContain('X-Server-Function-Build');
expect(client).toContain(JSON.stringify(buildId));
expect(client).toContain('app-hook');
});

it('configures the server with the client build id', () => {
expect(server).toContain(`buildId: ${JSON.stringify(buildId)}`);
});
});
105 changes: 94 additions & 11 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export function normalizeServerFunctionsEndpoint(endpoint?: string): string {
// Server-only handler: importing it wires the endpoint in one line
// (registrations via the manifest, request-event scoping, endpoint config).
const HANDLER_ID = 'virtual:solid-server-function-handler';
// The client runtime as a production client build sees it: the default
// runtime with the build-id header installed (see BUILD_HEADER below).
const CLIENT_RUNTIME_ID = '\0solid:server-functions-client';
// solidjs/solid#4215. The client runtime never sends it; the build does.
const BUILD_HEADER = 'X-Server-Function-Build';

// Server functions referenced only from client-side code (e.g. event
// handlers, which the SSR JSX compile drops) never get imported — or even
Expand All @@ -211,29 +216,40 @@ export interface PersistedServerFunctionManifest {
modules: string[];
/** Every server function the client build emitted a reference to. */
functions: Array<{ id: string; name: string; module: string }>;
/** The id the client build stamps on its calls, for the server build to check. */
buildId?: string;
}

function readPersistedManifest(root: string): Set<string> {
function readPersistedFile(root: string): string[] | PersistedServerFunctionManifest | undefined {
const file = path.resolve(root, 'dist/client', PERSISTED_MANIFEST_PATH);
if (!existsSync(file)) return new Set();
if (!existsSync(file)) return undefined;
try {
const parsed: string[] | PersistedServerFunctionManifest = JSON.parse(
readFileSync(file, 'utf-8'),
);
const entries = Array.isArray(parsed) ? parsed : parsed.modules;
return new Set(
entries.map((entry) => path.resolve(root, entry)).filter((entry) => existsSync(entry)),
);
return JSON.parse(readFileSync(file, 'utf-8'));
} catch {
return new Set();
return undefined;
}
}

function readPersistedManifest(root: string): Set<string> {
const parsed = readPersistedFile(root);
if (!parsed) return new Set();
const entries = Array.isArray(parsed) ? parsed : parsed.modules;
return new Set(
entries.map((entry) => path.resolve(root, entry)).filter((entry) => existsSync(entry)),
);
}

function readPersistedBuildId(root: string): string | undefined {
const parsed = readPersistedFile(root);
return parsed && !Array.isArray(parsed) ? parsed.buildId : undefined;
}

function writePersistedManifest(
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 });
Expand All @@ -245,6 +261,7 @@ function writePersistedManifest(
name: record.name,
module: relative(record.module),
})),
buildId,
};
writeFileSync(file, JSON.stringify(manifest, null, 2));
}
Expand Down Expand Up @@ -459,6 +476,42 @@ export function createServerFunctionsPlugins(
const deploymentSecret = randomBytes(32).toString('hex');
const deploymentSecretSnippet = `globalThis.__SOLID_SECRET__ ??= ${JSON.stringify(deploymentSecret)};`;

// THE BUILD ID (solidjs/solid#4215): a tab still running the previous
// deploy's client can call a function whose id survived the deploy but
// whose arguments or result changed shape. A production client build
// stamps every non-GET call with this id, and the server build configures
// the runtime with the id the client build persisted, so the server answers
// a call from another build with 409 (`unknownFunction: true` on the
// client) instead of running it. The server reads the id from the
// persisted manifest, not this closure, so the classic two-invocation
// build agrees with itself; with no client build there is no id and the
// server checks nothing. GET calls stay bare: their identity is the url
// (caches, preload matching). Only the default runtime is wrapped: a
// custom runtime's configure contract is its own.
const buildId = randomBytes(8).toString('hex');
const stampsBuild = !options.runtime;

// The client runtime's `configureServerFunctionsClient` replaces
// `prepareRequest` rather than composing it, so the build's hook can't
// simply be installed: the app's own hook would drop it. Every client
// import of the runtime resolves here instead, where the configure export
// is wrapped to run the build's stamp before the app's hook.
function clientRuntimeCode(): string {
const source = JSON.stringify(runtime.client);
return [
`import { configureServerFunctionsClient as configure } from ${source};`,
`export * from ${source};`,
`const stamp = (hook) => (init, context) => {`,
` if (init.method != 'GET') init.headers[${JSON.stringify(BUILD_HEADER)}] = ${JSON.stringify(buildId)};`,
` return hook ? hook(init, context) : init;`,
`};`,
`configure({ prepareRequest: stamp() });`,
`export function configureServerFunctionsClient(config) {`,
` configure(config && config.prepareRequest ? { ...config, prepareRequest: stamp(config.prepareRequest) } : config);`,
`}`,
].join('\n');
}

const clientOptions: Pick<CompileOptions, 'directive' | 'definitions'> = {
directive,
definitions: {
Expand Down Expand Up @@ -514,6 +567,7 @@ export function createServerFunctionsPlugins(
// Builds import it so tree-shaking can't drop registrations for functions
// only client code references.
function handlerModuleCode(includeManifest: boolean): string {
const serverBuildId = isBuild && stampsBuild ? readPersistedBuildId(root) : undefined;
// Server components ride the frame-stream wire protocol: the transform
// serves a function's component result as streamed HTML instead of data.
// Installing it here (config-level, merged with the other keys) covers
Expand Down Expand Up @@ -561,6 +615,8 @@ export function createServerFunctionsPlugins(
// depend on that: in dev, a mutation from an already-open page can be
// the first request after a server restart.
`configureServerFunctionsServer({ provideEvent: provideRequestEvent, endpoint: ${JSON.stringify(resolvedEndpoint)}${
serverBuildId ? `, buildId: ${JSON.stringify(serverBuildId)}` : ''
}${
components
? ', transformResult: frameTransformResult, transformFlightResult: frameTransformFlightResult, transformDirectResult: frameTransformDirectResult'
: ''
Expand Down Expand Up @@ -621,6 +677,26 @@ export function createServerFunctionsPlugins(
return relative.startsWith('..') ? '/@fs/' + entry : '/' + relative;
}

const clientRuntimePlugin: Plugin = {
name: 'solid:server-functions/client-runtime',
enforce: 'pre',
apply: 'build',
resolveId(source, importer, opts) {
if (
stampsBuild &&
source === runtime.client &&
importer !== CLIENT_RUNTIME_ID &&
getEnvironmentConsumer(this.environment, opts) === 'client'
) {
return CLIENT_RUNTIME_ID;
}
return null;
},
load(id) {
return id === CLIENT_RUNTIME_ID ? clientRuntimeCode() : null;
},
};

const startPlugins: Plugin[] = [
{
name: 'solid:server-functions/handler',
Expand Down Expand Up @@ -872,7 +948,13 @@ export function createServerFunctionsPlugins(
const consumer = ctx.environment?.config?.consumer;
const isClient = consumer ? consumer === 'client' : !isSsrBuild;
if (isBuild && isClient) {
writePersistedManifest(root, outDir, manifest.modules.server, manifest.clientFunctions);
writePersistedManifest(
root,
outDir,
manifest.modules.server,
manifest.clientFunctions,
buildId,
);
}
},
},
Expand Down Expand Up @@ -915,6 +997,7 @@ export function createServerFunctionsPlugins(
return null;
},
},
clientRuntimePlugin,
compilerPlugin,
...(internal.tsrxAfterSolid ? [tsrxCompilerPlugin] : []),
...startPlugins,
Expand Down
Loading