diff --git a/.changeset/lemon-plugins-serialize.md b/.changeset/lemon-plugins-serialize.md new file mode 100644 index 000000000..1c4c75233 --- /dev/null +++ b/.changeset/lemon-plugins-serialize.md @@ -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. diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index b87aff9a7..672092e62 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -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}'); diff --git a/apps/tests/src/routes/server-function-custom-class.tsx b/apps/tests/src/routes/server-function-custom-class.tsx new file mode 100644 index 000000000..4d3f2476c --- /dev/null +++ b/apps/tests/src/routes/server-function-custom-class.tsx @@ -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>({}); + + 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 ( +
+ {JSON.stringify(output())} +
+ ); +} diff --git a/apps/tests/src/seroval-plugins.ts b/apps/tests/src/seroval-plugins.ts new file mode 100644 index 000000000..55d9e23fb --- /dev/null +++ b/apps/tests/src/seroval-plugins.ts @@ -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({ + 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]; diff --git a/apps/tests/src/utils/object-id.ts b/apps/tests/src/utils/object-id.ts new file mode 100644 index 000000000..5c0918f5d --- /dev/null +++ b/apps/tests/src/utils/object-id.ts @@ -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; + } +} diff --git a/apps/tests/vite.config.ts b/apps/tests/vite.config.ts index 7ac29edec..dfb3367fe 100644 --- a/apps/tests/vite.config.ts +++ b/apps/tests/vite.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ }, plugins: [ solidStart({ + serialization: { + plugins: "src/seroval-plugins.ts", + }, env: { server: { load() { diff --git a/packages/start/package.json b/packages/start/package.json index 2db0a46bf..f5d09f702 100644 --- a/packages/start/package.json +++ b/packages/start/package.json @@ -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": { @@ -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" }, diff --git a/packages/start/src/config/constants.ts b/packages/start/src/config/constants.ts index 93a170582..33bdd12a2 100644 --- a/packages/start/src/config/constants.ts +++ b/packages/start/src/config/constants.ts @@ -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", diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 4ec6ab107..de2b20503 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -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; }; /** diff --git a/packages/start/src/config/manifest.ts b/packages/start/src/config/manifest.ts index be532924e..5ffa2041b 100644 --- a/packages/start/src/config/manifest.ts +++ b/packages/start/src/config/manifest.ts @@ -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}`) { @@ -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"); diff --git a/packages/start/src/fns/plugins.ts b/packages/start/src/fns/plugins.ts new file mode 100644 index 000000000..04ca98254 --- /dev/null +++ b/packages/start/src/fns/plugins.ts @@ -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({ + * 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"; diff --git a/packages/start/src/fns/serialization.spec.ts b/packages/start/src/fns/serialization.spec.ts index a78fa5100..c2dcaa76e 100644 --- a/packages/start/src/fns/serialization.spec.ts +++ b/packages/start/src/fns/serialization.spec.ts @@ -1,3 +1,4 @@ +import { createPlugin } from "seroval"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; async function loadSerialization(prod: boolean) { @@ -7,6 +8,32 @@ async function loadSerialization(prod: boolean) { return await import("./serialization.ts"); } +/** + * Stands in for a value Seroval has no built-in support for, the way a Mongo + * `ObjectId` or a Prisma `Decimal` would. + * @see https://github.com/solidjs/solid-start/issues/1474 + */ +class Money { + constructor(readonly cents: number) {} +} + +const MoneyPlugin = createPlugin({ + tag: "solid-start/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: (node, ctx) => `new Money(${ctx.serialize(node.cents)})`, + deserialize: (node, ctx) => new Money(ctx.deserialize(node.cents) as number), +}); + +/** Mirrors what the `solid-start:seroval-plugins` virtual module returns. */ +function useUserPlugins(plugins: unknown[]) { + (globalThis as any).SEROVAL_PLUGINS_STUB = plugins; +} + async function readStream(stream: ReadableStream) { return await new Response(stream).text(); } @@ -30,6 +57,7 @@ describe("serialization", () => { afterEach(() => { vi.unstubAllEnvs(); + delete (globalThis as any).SEROVAL_PLUGINS_STUB; }); it("omits the error stack from the JSON stream in production", async () => { @@ -76,3 +104,96 @@ describe("serialization", () => { expect(parsed.stack).toContain("serialization.spec.ts"); }); }); + +describe("custom seroval plugins", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as any).SEROVAL_PLUGINS_STUB; + }); + + it("throws on an unsupported class when no plugins are configured", async () => { + useUserPlugins([]); + const { serializeToJSONString } = await loadSerialization(true); + + await expect(serializeToJSONString([new Money(1999)])).rejects.toThrow(); + }); + + it("round-trips a custom class through the JSON payload", async () => { + useUserPlugins([MoneyPlugin]); + const { serializeToJSONString, deserializeFromJSONString } = await loadSerialization(true); + + const payload = await serializeToJSONString([new Money(1999)]); + const [parsed] = (await deserializeFromJSONString(payload)) as [Money]; + + expect(parsed).toBeInstanceOf(Money); + expect(parsed.cents).toBe(1999); + }); + + it("round-trips a custom class nested inside supported containers", async () => { + useUserPlugins([MoneyPlugin]); + const { serializeToJSONString, deserializeFromJSONString } = await loadSerialization(true); + + const payload = await serializeToJSONString([ + { total: new Money(500), items: new Map([["a", new Money(250)]]) }, + ]); + const [parsed] = (await deserializeFromJSONString(payload)) as [ + { total: Money; items: Map }, + ]; + + expect(parsed.total).toBeInstanceOf(Money); + expect(parsed.total.cents).toBe(500); + expect(parsed.items.get("a")).toBeInstanceOf(Money); + expect(parsed.items.get("a")!.cents).toBe(250); + }); + + it("preserves referential identity across the payload", async () => { + useUserPlugins([MoneyPlugin]); + const { serializeToJSONString, deserializeFromJSONString } = await loadSerialization(true); + + const shared = new Money(42); + const payload = await serializeToJSONString([{ a: shared, b: shared }]); + const [parsed] = (await deserializeFromJSONString(payload)) as [{ a: Money; b: Money }]; + + expect(parsed.a).toBe(parsed.b); + }); + + it("keeps built-in plugins working alongside a user plugin", async () => { + useUserPlugins([MoneyPlugin]); + const { serializeToJSONString, deserializeFromJSONString } = await loadSerialization(true); + + const payload = await serializeToJSONString([ + { url: new URL("https://solidjs.com/docs"), price: new Money(1) }, + ]); + const [parsed] = (await deserializeFromJSONString(payload)) as [{ url: URL; price: Money }]; + + expect(parsed.url).toBeInstanceOf(URL); + expect(parsed.url.href).toBe("https://solidjs.com/docs"); + expect(parsed.price).toBeInstanceOf(Money); + }); + + it("lets built-in plugins win over a user plugin that claims the same value", async () => { + const hostile = createPlugin({ + tag: "solid-start/test/HostileURL", + test: value => value instanceof URL, + parse: { + sync: (value, ctx) => ({ href: ctx.parse("hijacked") }), + async: async (value, ctx) => ({ href: await ctx.parse("hijacked") }), + stream: (value, ctx) => ({ href: ctx.parse("hijacked") }), + }, + serialize: (node, ctx) => ctx.serialize(node.href), + deserialize: (node, ctx) => ctx.deserialize(node.href) as unknown as URL, + }); + useUserPlugins([hostile]); + const { serializeToJSONString, deserializeFromJSONString } = await loadSerialization(true); + + const payload = await serializeToJSONString([new URL("https://solidjs.com/")]); + const [parsed] = (await deserializeFromJSONString(payload)) as [URL]; + + expect(parsed).toBeInstanceOf(URL); + expect(parsed.href).toBe("https://solidjs.com/"); + }); +}); diff --git a/packages/start/src/fns/serialization.ts b/packages/start/src/fns/serialization.ts index 4335661b5..4aae62c97 100644 --- a/packages/start/src/fns/serialization.ts +++ b/packages/start/src/fns/serialization.ts @@ -20,8 +20,8 @@ import { URLPlugin, URLSearchParamsPlugin, } from "seroval-plugins/web"; +import userPlugins from "solid-start:seroval-plugins"; -// TODO(Alexis): if we can, allow providing an option to extend these. const DEFAULT_PLUGINS = [ AbortSignalPlugin, CustomEventPlugin, @@ -35,6 +35,17 @@ const DEFAULT_PLUGINS = [ URLSearchParamsPlugin, URLPlugin, ]; + +/** + * Plugins from `serialization.plugins` are appended rather than prepended: + * seroval picks the first plugin whose `test()` passes, so the built-ins win + * and a loose user `test()` can't take over `Request`/`FormData`/`URL`. + * + * The same list has to be used on both ends of a server function, which is why + * this comes from a virtual module bundled into the client and the server + * rather than a runtime option. + */ +const PLUGINS = [...DEFAULT_PLUGINS, ...userPlugins]; const MAX_SERIALIZATION_DEPTH_LIMIT = 64; const DISABLED_FEATURES = Feature.RegExp; @@ -93,7 +104,7 @@ export function serializeToJSStream(id: string, value: any) { crossSerializeStream(value, { scopeId: id, disabledFeatures: JS_SERIALIZE_DISABLED_FEATURES, - plugins: DEFAULT_PLUGINS, + plugins: PLUGINS, onSerialize(data: string, initial: boolean) { controller.enqueue( createChunk(initial ? `(${getCrossReferenceHeader(id)},${data})` : data), @@ -116,7 +127,7 @@ export function serializeToJSONStream(value: any) { toCrossJSONStream(value, { disabledFeatures: SERIALIZE_DISABLED_FEATURES, depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, - plugins: DEFAULT_PLUGINS, + plugins: PLUGINS, onParse(node) { controller.enqueue(createChunk(JSON.stringify(node))); }, @@ -236,7 +247,7 @@ export async function deserializeJSONStream(response: Response | Request) { refs, disabledFeatures: DISABLED_FEATURES, depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, - plugins: DEFAULT_PLUGINS, + plugins: PLUGINS, }); return value; } diff --git a/packages/start/src/virtual.d.ts b/packages/start/src/virtual.d.ts index 53083c11d..d62ced0cd 100644 --- a/packages/start/src/virtual.d.ts +++ b/packages/start/src/virtual.d.ts @@ -25,3 +25,7 @@ declare module "solid-start:middleware" { type MaybeArray = T | Array; export default Middleware as import("h3").Middleware[]; } + +declare module "solid-start:seroval-plugins" { + export default SerovalPlugins as import("seroval").Plugin[]; +} diff --git a/packages/start/vitest.config.ts b/packages/start/vitest.config.ts index 0cf5c2332..e2a27fadd 100644 --- a/packages/start/vitest.config.ts +++ b/packages/start/vitest.config.ts @@ -1,6 +1,32 @@ import { defineConfig } from "vitest/config"; +import { VIRTUAL_MODULES } from "./src/config/constants.ts"; + +/** + * Unit tests import modules like `fns/serialization.ts` directly, without the + * `solidStart()` plugin that normally serves SolidStart's virtual modules. + * Stand in for the ones those modules import so they resolve under vitest. + * + * `SEROVAL_PLUGINS_STUB` lets a spec swap in its own plugin list by writing to + * `globalThis`, which is how the custom-plugin round-trip test works without a + * full Vite build. + */ +function virtualModuleStubs() { + const stubs: Record = { + [VIRTUAL_MODULES.serovalPlugins]: "export default globalThis.SEROVAL_PLUGINS_STUB ?? [];", + }; + return { + name: "solid-start:test-virtual-module-stubs", + resolveId(id: string) { + if (id in stubs) return `\0${id}`; + }, + load(id: string) { + if (id.startsWith("\0")) return stubs[id.slice(1)]; + }, + }; +} export default defineConfig({ + plugins: [virtualModuleStubs()], test: { globals: true, environment: "node",