diff --git a/.changeset/server-function-build-id.md b/.changeset/server-function-build-id.md new file mode 100644 index 0000000..c445dbf --- /dev/null +++ b/.changeset/server-function-build-id.md @@ -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. diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index c42fea1..887f329 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -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__ ??= ""`); 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__ ??= ""`) and the + // build id (`buildId: ""`), 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__ ??= ""'); + source + .replace(/__SOLID_SECRET__ \?\?= "[0-9a-f]+"/, '__SOLID_SECRET__ ??= ""') + .replace(/buildId: "[0-9a-f]+"/, 'buildId: ""') + .replace(/(assets\/[\w.-]+)-[\w-]{8}\.(js|css)/g, '$1-.$2'); const serverWith = readFileSync(serverJs, 'utf-8'); record( mode, diff --git a/src/server-functions/build-id.test.ts b/src/server-functions/build-id.test.ts new file mode 100644 index 0000000..b83a374 --- /dev/null +++ b/src/server-functions/build-id.test.ts @@ -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)}`); + }); +}); diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index 0ddd2ce..ce1fb40 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -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 @@ -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 { +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 { + 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, functions: Map, + buildId: string, ): void { const file = path.resolve(root, outDir, PERSISTED_MANIFEST_PATH); mkdirSync(path.dirname(file), { recursive: true }); @@ -245,6 +261,7 @@ function writePersistedManifest( name: record.name, module: relative(record.module), })), + buildId, }; writeFileSync(file, JSON.stringify(manifest, null, 2)); } @@ -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 = { directive, definitions: { @@ -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 @@ -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' : '' @@ -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', @@ -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, + ); } }, }, @@ -915,6 +997,7 @@ export function createServerFunctionsPlugins( return null; }, }, + clientRuntimePlugin, compilerPlugin, ...(internal.tsrxAfterSolid ? [tsrxCompilerPlugin] : []), ...startPlugins,