diff --git a/.changeset/server-function-serialization.md b/.changeset/server-function-serialization.md new file mode 100644 index 0000000..b9c87b6 --- /dev/null +++ b/.changeset/server-function-serialization.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +New `serverFunctions.serialization` option: a module whose default export sets the codec for server-function payloads (`plugins`, `disabledFeatures`, `depthLimit`, `serializeErrorStacks`). The plugin bundles it into both the client and the server, so custom types such as ORM ids round-trip without registering the codec twice. A bare array of plugins also works, so a SolidStart `serialization.plugins` module can be reused unchanged. diff --git a/README.md b/README.md index 66f402e..442691c 100644 --- a/README.md +++ b/README.md @@ -891,7 +891,8 @@ between the modes), and `examples/ssr` for the manual `ssr: true` wiring. Enables `"use server"` server function compilation (experimental). Pass `true` for the defaults (runtime from `@solidjs/web/server-functions`, endpoint `/_server`) or an options object (`runtime`, `endpoint`, `filter`, -`directive`, `manifest`, `devMiddleware`, `configure`) to customize. +`directive`, `manifest`, `devMiddleware`, `configure`, `serialization`) to +customize. The setup is zero-config: in dev a middleware on the Vite server handles the endpoint end to end — no server-function code needed in your server entry. @@ -949,6 +950,51 @@ configureServerFunctionsServer({ Set `csrf: false` only when another trusted layer protects the endpoint. +**`serialization: './src/serialization.ts'`** sets the codec for +server-function payloads. Use it to send values the default codec does not +handle, such as ORM id types, decimals, or your own classes. The module's +default export is the `JSONCodecOptions` object from +`@solidjs/web/serialization`: + +```ts +// src/serialization.ts +import { createPlugin } from '@solidjs/web/serialization'; +import { Feature } from 'seroval'; +import { ObjectId } from 'mongodb'; + +const ObjectIdPlugin = createPlugin({ + tag: 'app/ObjectId', + test: (value) => value instanceof ObjectId, + parse: { + sync: (value, ctx) => ({ hex: ctx.parse(value.toHexString()) }), + async: async (value, ctx) => ({ hex: await ctx.parse(value.toHexString()) }), + stream: (value, ctx) => ({ hex: ctx.parse(value.toHexString()) }), + }, + serialize: () => { + throw new Error('unused by the JSON codec'); + }, + deserialize: (node, ctx) => new ObjectId(ctx.deserialize(node.hex) as string), +}); + +export default { + plugins: [ObjectIdPlugin], + // Optional. The default disables `RegExp`; an override replaces it. + disabledFeatures: Feature.RegExp, +}; +``` + +- Both ends of the wire must use the same codec. The plugin bundles the module + into the client and the server and configures the runtime on both sides, so + they cannot drift apart. +- The module ships to the browser, so it must not import server-only code. +- A bare array of plugins (`export default [ObjectIdPlugin]`) is shorthand for + `{ plugins: [...] }`. This is the shape of SolidStart's + `serialization.plugins` module, so that file works unchanged. +- The option owns the runtime's `codec` setting. Do not also pass `codec` to + `configureServerFunctionsServer` or `configureServerFunctionsClient`. +- Only server-function payloads use it. The SSR hydration payload is written + by `renderToStream`, which takes its own `plugins` option. + Meta-frameworks that need to control plugin ordering and dispatch requests through their own server should use the standalone `serverFunctions()` export instead, which never installs the dev middleware. See diff --git a/src/index.ts b/src/index.ts index 3b62e56..d69205f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -539,7 +539,9 @@ export interface Options { * `serverFunctions: { devMiddleware: false }` — see * {@link ServerFunctionsOptions.devMiddleware}. A server-only module can * be pinned into the handler graph for pre-dispatch runtime registration - * via {@link ServerFunctionsOptions.configure}. + * via {@link ServerFunctionsOptions.configure}. Custom serialization + * plugins for server-function payloads are set with + * {@link ServerFunctionsOptions.serialization}. * * Meta-frameworks that need to control plugin ordering themselves (e.g. * relative to a file-system router) and dispatch requests through their diff --git a/src/server-functions/index.ts b/src/server-functions/index.ts index ad43011..84e3716 100644 --- a/src/server-functions/index.ts +++ b/src/server-functions/index.ts @@ -128,6 +128,33 @@ export interface ServerFunctionsOptions { * @default undefined */ configure?: string; + /** + * Path to a module (resolved relative to the Vite root, like `configure`) + * whose default export sets the codec server-function payloads are + * serialized with: the `JSONCodecOptions` from `@solidjs/web/serialization` + * (`plugins`, `disabledFeatures`, `depthLimit`, `serializeErrorStacks`). A + * bare array of plugins is accepted too, as shorthand for `{ plugins }` + * (SolidStart's `serialization.plugins` shape). + * + * ```ts + * // src/serialization.ts + * import { createPlugin } from '@solidjs/web/serialization'; + * export default { plugins: [ObjectIdPlugin] }; + * ``` + * + * The codec must match on both ends of the wire, so the plugin bundles + * the module into both the client and the server and passes it as + * `codec` to `configureServerFunctions{Client,Server}` from every compiled + * module and from the generated `virtual:solid-server-function-handler`. + * It must not import server-only code. The option owns the runtime's + * `codec` config: a `codec` set elsewhere is replaced, not merged. + * + * Only server-function payloads use it. The SSR hydration payload is + * serialized by `renderToStream`, which takes its own `plugins` option. + * + * @default undefined + */ + serialization?: string; /** * Enable server components (experimental): `"use server"` functions that * return a component. Responses for them are served over the @@ -171,6 +198,10 @@ const DEFAULT_RUNTIME = '@solidjs/web/server-functions'; // equals it, no configure calls need to be emitted at all. const DEFAULT_ENDPOINT = '/_server'; const STORAGE_SOURCE = '@solidjs/web/storage'; +// The `serialization` module's default export, as imported by generated code, +// and the codec it becomes (a bare plugin array is shorthand for `{ plugins }`). +const SERIALIZATION_BINDING = '$$serialization'; +const CODEC_EXPRESSION = `Array.isArray(${SERIALIZATION_BINDING}) ? { plugins: ${SERIALIZATION_BINDING} } : ${SERIALIZATION_BINDING}`; /** * The endpoint mount as the runtime is configured with it — the option (or @@ -183,6 +214,21 @@ export function normalizeServerFunctionsEndpoint(endpoint?: string): string { const option = endpoint || DEFAULT_ENDPOINT; return option.startsWith('/') ? option : '/' + option; } +// Resolves a module-path option against the Vite root and checks it exists, +// so a typo fails at config time rather than as an unresolved import. +function resolveModuleOption( + name: 'configure' | 'serialization', + value: string | undefined, + root: string, +): string | null { + if (!value) return null; + const absolute = path.isAbsolute(value) ? value : path.resolve(root, value); + if (!existsSync(absolute)) { + throw new Error(`[@solidjs/vite-plugin] serverFunctions.${name} does not exist: ${value}`); + } + return absolute; +} + // 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'; @@ -409,6 +455,8 @@ export function serverFunctions( // Absolute path of the user's `configure` module; resolved (and existence- // checked) in configResolved, before any handler load can read it. let configureModulePath: string | null = null; + // Absolute path of the user's `serialization` module, resolved the same way. + let serializationModulePath: string | null = null; const manifest = createManifest(); @@ -473,17 +521,31 @@ export function serverFunctions( // configure call to each guarantees it runs before any reference is used. // The default endpoint appends nothing, keeping compiled output byte- // identical for setups that wire the runtime themselves. - function endpointConfigureSnippet(mode: CompileOptions['mode']): string { - if (resolvedEndpoint === DEFAULT_ENDPOINT) return ''; + // The `serialization` codec rides the same calls: it has to be in place + // before a reference encodes arguments or decodes a result, on both sides. + function runtimeConfigureSnippet(mode: CompileOptions['mode']): string { + const config: string[] = []; + if (resolvedEndpoint !== DEFAULT_ENDPOINT) { + config.push(`endpoint: ${JSON.stringify(resolvedEndpoint)}`); + } + if (serializationModulePath) config.push(`codec: ${CODEC_EXPRESSION}`); + if (config.length === 0) return ''; const name = mode === 'server' ? 'configureServerFunctionsServer' : 'configureServerFunctionsClient'; const source = mode === 'server' ? runtime.server : runtime.client; return ( + serializationImport() + `\nimport { ${name} as $$configureServerFunctions } from ${JSON.stringify(source)};` + - `\n$$configureServerFunctions({ endpoint: ${JSON.stringify(resolvedEndpoint)} });\n` + `\n$$configureServerFunctions({ ${config.join(', ')} });\n` ); } + function serializationImport(): string { + return serializationModulePath + ? `\nimport ${SERIALIZATION_BINDING} from ${JSON.stringify(serializationModulePath)};` + : ''; + } + // Dev omits the manifest import: the middleware loads the referenced // module on demand instead (importing the debounced manifest would stall // the first request and eagerly SSR-load every server-function module). @@ -513,6 +575,7 @@ export function serverFunctions( // below doesn't actually matter — runtime config merges per key — // import-first is just the cleaner shape. ...(configureModulePath ? [`import ${JSON.stringify(configureModulePath)};`] : []), + ...(serializationModulePath ? [serializationImport().trim()] : []), ...(includeManifest ? [`import ${JSON.stringify(manifestId)};`] : []), `import { handleServerFunctionRequest as handle, configureServerFunctionsServer } from ${JSON.stringify(runtime.server)};`, `import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`, @@ -537,6 +600,8 @@ export function serverFunctions( // 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)}${ + serializationModulePath ? `, codec: ${CODEC_EXPRESSION}` : '' + }${ components ? ', transformResult: frameTransformResult, transformFlightResult: frameTransformFlightResult, transformDirectResult: frameTransformDirectResult' : '' @@ -770,7 +835,7 @@ export function serverFunctions( return { // Appended (not prepended) so the source map for the compiled module // stays valid; imports hoist and the endpoint is only read at call time. - code: (result.code || '') + endpointConfigureSnippet(mode), + code: (result.code || '') + runtimeConfigureSnippet(mode), map: result.map, }; } @@ -804,17 +869,8 @@ export function serverFunctions( isSsrBuild = !!config.build.ssr; outDir = config.build.outDir; resolvedEndpoint = joinBase(config.base, endpoint); - if (options.configure) { - const absolute = path.isAbsolute(options.configure) - ? options.configure - : path.resolve(root, options.configure); - if (!existsSync(absolute)) { - throw new Error( - `[@solidjs/vite-plugin] serverFunctions.configure does not exist: ${options.configure}`, - ); - } - configureModulePath = absolute; - } + configureModulePath = resolveModuleOption('configure', options.configure, root); + serializationModulePath = resolveModuleOption('serialization', options.serialization, root); if (isBuild && isSsrBuild) { // Classic two-invocation build: pick up the modules the client // build discovered so the server manifest registers them even when diff --git a/src/server-functions/serialization.test.ts b/src/server-functions/serialization.test.ts new file mode 100644 index 0000000..e18e16e --- /dev/null +++ b/src/server-functions/serialization.test.ts @@ -0,0 +1,161 @@ +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { decodeResponse } from '@solidjs/web/server-functions/client'; +import { createServer, type ViteDevServer } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { serverFunctions } from './index.js'; + +const HANDLER_ID = 'virtual:solid-server-function-handler'; + +const MONEY_SOURCE = `export class Money { + constructor(cents) { + this.cents = cents; + } +} +`; + +const PLUGIN_SOURCE = `import { createPlugin } from '@solidjs/web/serialization'; +import { Money } from './money.js'; + +export const MoneyPlugin = createPlugin({ + tag: 'test/Money', + test: (value) => value instanceof Money, + parse: { + sync: (value, ctx) => ({ cents: ctx.parse(value.cents) }), + async: async (value, ctx) => ({ cents: await ctx.parse(value.cents) }), + stream: (value, ctx) => ({ cents: ctx.parse(value.cents) }), + }, + serialize: () => { + throw new Error('unused by the JSON codec'); + }, + deserialize: (node, ctx) => new Money(ctx.deserialize(node.cents)), +}); +`; + +const API_SOURCE = `"use server"; +import { Money } from './money.js'; + +export async function price() { + return new Money(500); +} +`; + +// Each case is a project whose `serialization` module default-exports the +// codec in one of the accepted shapes (or no option at all). +const cases = { + object: `export { MoneyPlugin } from './money-plugin.js'; +import { MoneyPlugin } from './money-plugin.js'; +export default { plugins: [MoneyPlugin] }; +`, + array: `import { MoneyPlugin } from './money-plugin.js'; +export default [MoneyPlugin]; +`, + none: null, +} as const; + +async function createProject(serialization: string | null) { + const root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-vite-plugin-codec-'))); + 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/money.js'), MONEY_SOURCE); + await writeFile(path.join(root, 'src/money-plugin.js'), PLUGIN_SOURCE); + await writeFile(path.join(root, 'src/api.js'), API_SOURCE); + if (serialization) await writeFile(path.join(root, 'src/serialization.js'), serialization); + const server = await createServer({ + root, + configFile: false, + appType: 'custom', + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + plugins: [serverFunctions(serialization ? { serialization: './src/serialization.js' } : {})], + }); + return { root, server }; +} + +// Calls `price` over the generated handler, the way the dev middleware and +// the production handler dispatch, and returns the raw response. +async function callPrice(server: ViteDevServer) { + const client = await server.environments.client.transformRequest('/src/api.js'); + const id = /createServerReference\w*\("(price-[^"]*)"/.exec(client?.code ?? '')?.[1]; + expect(id).toBeTruthy(); + const runner = server.environments.ssr.runner; + await runner.import('/src/api.js'); + const handler = await runner.import(HANDLER_ID); + const request = new Request( + `http://localhost/_server/${encodeURIComponent(id!)}?args=${encodeURIComponent('[]')}`, + // Same-origin, so the runtime's CSRF gate lets the call through. + { method: 'POST', headers: { origin: 'http://localhost' } }, + ); + return { + client: client!.code, + response: (await handler.handleServerFunctionRequest(request)) as Response, + }; +} + +describe('serverFunctions.serialization', () => { + const projects: Record = {}; + + beforeAll(async () => { + for (const [name, source] of Object.entries(cases)) { + projects[name] = await createProject(source); + } + }); + + afterAll(async () => { + for (const { root, server } of Object.values(projects)) { + await server.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + it.each(['object', 'array'])('configures the codec on both sides (%s export)', async (name) => { + const { server } = projects[name]!; + + // Vite rewrites the absolute import to the module's dev URL. + const { client, response } = await callPrice(server); + expect(client).toContain('import $$serialization from "/src/serialization.js";'); + expect(client).toMatch(/configureServerFunctionsClient as \$\$configureServerFunctions/); + expect(client).toContain('codec: Array.isArray($$serialization)'); + + const ssr = await server.environments.ssr.transformRequest('/src/api.js'); + expect(ssr?.code).toContain('"/src/serialization.js"'); + expect(ssr?.code).toContain('configureServerFunctionsServer'); + + // The server encoded the result through the plugin: its tag is on the + // wire, and a client using the same plugin decodes a real Money. + expect(response.status).toBe(200); + const body = await response.clone().text(); + expect(body).toContain('test/Money'); + const { MoneyPlugin } = await server.environments.ssr.runner.import('/src/money-plugin.js'); + const value = await decodeResponse<{ cents: number }>(response, { plugins: [MoneyPlugin] }); + expect(value.constructor.name).toBe('Money'); + expect(value.cents).toBe(500); + }); + + it('leaves compiled output and the codec alone without the option', async () => { + const { server } = projects.none!; + const { client, response } = await callPrice(server); + expect(client).not.toContain('$$serialization'); + expect(client).not.toContain('$$configureServerFunctions'); + expect(response.status).toBe(200); + expect(await response.text()).not.toContain('test/Money'); + }); + + it('rejects a module path that does not exist', async () => { + await expect( + createServer({ + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + plugins: [serverFunctions({ serialization: './src/missing.ts' })], + }), + ).rejects.toThrow('serverFunctions.serialization does not exist: ./src/missing.ts'); + }); +});