Skip to content
Open
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-serialization.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<ObjectId, { hex: any }>({
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
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 71 additions & 15 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)};`,
Expand All @@ -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'
: ''
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions src/server-functions/serialization.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { root: string; server: ViteDevServer }> = {};

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');
});
});
Loading