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
25 changes: 25 additions & 0 deletions .changeset/lemon-plugins-serialize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@solidjs/start": minor
---

Add `serialization.plugins` to configure custom Seroval plugins for server functions.

Values Seroval has no built-in support for (Mongo's `ObjectId`, Prisma's `Decimal`, `Temporal`, and other custom classes) previously threw when returned from or passed to a server function. Point the new option at a module whose default export is an array of plugins:

```ts
// vite.config.ts
solidStart({
serialization: {
plugins: "src/seroval-plugins.ts",
},
});
```

```ts
// src/seroval-plugins.ts
import { createPlugin } from "@solidjs/start/serialization";
```

The module is bundled into both the client and the server so both ends of a server function agree on the format, so it must not import server-only code. SolidStart's built-in plugins keep precedence. Only server-function and action payloads are affected; the SSR hydration payload is serialized by `solid-js/web`.

Also adds a `@solidjs/start/serialization` entrypoint re-exporting Seroval's `createPlugin`, `OpaqueReference`, and plugin types, so plugin authors stay on the same Seroval version SolidStart serializes with.
13 changes: 13 additions & 0 deletions apps/tests/src/e2e/server-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ test.describe("server-function", () => {
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
});

/**
* A custom Seroval plugin registered through `serialization.plugins` must
* apply to both the request and the response payload, so a class Seroval has
* no built-in support for survives the round trip with its prototype intact.
* https://github.com/solidjs/solid-start/issues/1474
*/
test("should round-trip a custom class through a custom seroval plugin", async ({ page }) => {
await page.goto("http://localhost:3000/server-function-custom-class");
await expect(page.locator("#server-fn-test")).toContainText(
'{"receivedInstance":true,"receivedHex":"507f191e810c19729de860ea","returnedInstance":true,"returnedHex":"507f1f77bcf86cd799439011"}',
);
});

test("should build with a server function w/ form data", async ({ page }) => {
await page.goto("http://localhost:3000/server-function-form-data");
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
Expand Down
39 changes: 39 additions & 0 deletions apps/tests/src/routes/server-function-custom-class.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createEffect, createSignal } from "solid-js";
import { ObjectId } from "../utils/object-id.ts";

/**
* Exercises `serialization.plugins`: a class Seroval has no built-in support
* for has to survive both directions of a server function call.
*
* @see https://github.com/solidjs/solid-start/issues/1474
*/
async function echoObjectId(id: ObjectId) {
"use server";

return {
receivedInstance: id instanceof ObjectId,
receivedHex: id.toHexString(),
returned: new ObjectId("507f1f77bcf86cd799439011"),
};
}

export default function App() {
const [output, setOutput] = createSignal<Record<string, unknown>>({});

createEffect(async () => {
const result = await echoObjectId(new ObjectId("507f191e810c19729de860ea"));

setOutput({
receivedInstance: result.receivedInstance,
receivedHex: result.receivedHex,
returnedInstance: result.returned instanceof ObjectId,
returnedHex: result.returned.toHexString(),
});
});

return (
<main>
<span id="server-fn-test">{JSON.stringify(output())}</span>
</main>
);
}
27 changes: 27 additions & 0 deletions apps/tests/src/seroval-plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createPlugin } from "@solidjs/start/serialization";
import { ObjectId } from "./utils/object-id.ts";

/**
* Bundled into both the client and the server via `serialization.plugins`, so
* this module must not import server-only code.
*
* `deserialize` is what rebuilds the value under the default `"json"`
* serialization mode. `serialize` is only used by `mode: "js"`, where the
* payload is evaluated on the client and can therefore reference globals only,
* which is why it reaches for `globalThis` rather than the imported class.
*/
const ObjectIdPlugin = createPlugin<ObjectId, { hex: any }>({
tag: "tests/ObjectId",
test: value => value instanceof ObjectId,
parse: {
sync: (value, ctx) => ({ hex: ctx.parse(value.hex) }),
async: async (value, ctx) => ({ hex: await ctx.parse(value.hex) }),
stream: (value, ctx) => ({ hex: ctx.parse(value.hex) }),
},
serialize: (node, ctx) => `globalThis.ObjectId(${ctx.serialize(node.hex)})`,
deserialize: (node, ctx) => new ObjectId(ctx.deserialize(node.hex) as string),
});

(globalThis as any).ObjectId = (hex: string) => new ObjectId(hex);

export default [ObjectIdPlugin];
14 changes: 14 additions & 0 deletions apps/tests/src/utils/object-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Stands in for a custom class that Seroval has no built-in support for, such
* as Mongo's `ObjectId` or Prisma's `Decimal`. Serializing one of these across
* a server function boundary requires a custom Seroval plugin.
*
* @see https://github.com/solidjs/solid-start/issues/1474
*/
export class ObjectId {
constructor(readonly hex: string) {}

toHexString() {
return this.hex;
}
}
3 changes: 3 additions & 0 deletions apps/tests/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export default defineConfig({
},
plugins: [
solidStart({
serialization: {
plugins: "src/seroval-plugins.ts",
},
env: {
server: {
load() {
Expand Down
6 changes: 4 additions & 2 deletions packages/start/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"./http": "./src/http/index.ts",
"./env": "./env.d.ts",
"./fns/server": "./src/fns/server.ts",
"./fns/client": "./src/fns/client.ts"
"./fns/client": "./src/fns/client.ts",
"./serialization": "./src/fns/plugins.ts"
},
"publishConfig": {
"exports": {
Expand All @@ -39,7 +40,8 @@
"./http": "./dist/http/index.js",
"./env": "./env.d.ts",
"./fns/server": "./dist/fns/server.js",
"./fns/client": "./dist/fns/client.js"
"./fns/client": "./dist/fns/client.js",
"./serialization": "./dist/fns/plugins.js"
},
"access": "public"
},
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const VIRTUAL_MODULES = {
getClientManifest: "solid-start:get-client-manifest",
getManifest: "solid-start:get-manifest",
middleware: "solid-start:middleware",
serovalPlugins: "solid-start:seroval-plugins",
serverFnManifest: "solid-start:server-fn-manifest",
clientEntry: "solid-start:client-entry",
serverEntry: "solid-start:server-entry",
Expand Down
24 changes: 24 additions & 0 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ export interface SolidStartOptions {
* @default "json"
*/
mode?: "js" | "json";

/**
* Path to a module whose default export is an array of custom Seroval
* plugins, used to serialize values Seroval doesn't understand natively
* (ORM id types, decimals, `Temporal`, and other custom classes).
*
* Build plugins with `createPlugin` from `seroval`. The module is bundled
* into both the client and the server so that both ends of a server
* function agree on the format, so it must not import server-only code.
*
* SolidStart's built-in plugins take precedence: Seroval uses the first
* plugin whose `test()` passes, and these are appended after the built-ins.
*
* A plugin's `deserialize` rebuilds the value under {@link mode} `"json"`.
* `serialize` is only used by `mode: "js"`, where the payload is evaluated
* on the client and may therefore reference globals only, not the plugin
* module's own imports.
*
* Only applies to server-function and action payloads. The SSR hydration
* payload is serialized by `solid-js/web` and is unaffected.
*
* @example "src/seroval-plugins.ts"
*/
plugins?: string;
};

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/start/src/config/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export function manifest(start: SolidStartOptions): PluginOption {
if (start.middleware) return await this.resolve(start.middleware);
return `\0${VIRTUAL_MODULES.middleware}`;
}
if (id === VIRTUAL_MODULES.serovalPlugins) {
const plugins = start.serialization?.plugins;
if (plugins) return await this.resolve(plugins);
return `\0${VIRTUAL_MODULES.serovalPlugins}`;
}
},
async load(id) {
if (id === `\0${VIRTUAL_MODULES.clientViteManifest}`) {
Expand Down Expand Up @@ -88,6 +93,7 @@ export function manifest(start: SolidStartOptions): PluginOption {
}
return `export const clientViteManifest = ${JSON.stringify(clientViteManifest)};`;
} else if (id === `\0${VIRTUAL_MODULES.middleware}`) return "export default {};";
else if (id === `\0${VIRTUAL_MODULES.serovalPlugins}`) return "export default [];";
else if (id.startsWith("/@manifest")) {
if (this.environment.mode !== "dev")
throw new Error("@manifest queries are only allowed in dev");
Expand Down
44 changes: 44 additions & 0 deletions packages/start/src/fns/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Re-exports Seroval's plugin authoring API for use with the
* `serialization.plugins` option in `vite.config.ts`.
*
* Importing from here rather than depending on `seroval` directly keeps plugin
* authors on the exact Seroval version SolidStart serializes with. A mismatched
* version would not fail the build: it would produce plugin nodes the other end
* of the wire can't interpret.
*
* @see https://github.com/solidjs/solid-start/issues/1474
*
* @example
* ```ts
* // src/seroval-plugins.ts
* import { createPlugin } from "@solidjs/start/serialization";
* 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: (node, ctx) => `globalThis.ObjectId(${ctx.serialize(node.hex)})`,
* deserialize: (node, ctx) => new ObjectId(ctx.deserialize(node.hex) as string),
* });
*
* export default [ObjectIdPlugin];
* ```
*/
export { createPlugin, OpaqueReference } from "seroval";
export type {
AsyncParsePluginContext,
DeserializePluginContext,
Plugin,
PluginData,
PluginInfo,
SerializePluginContext,
SerovalNode,
StreamParsePluginContext,
SyncParsePluginContext,
} from "seroval";
Loading
Loading