Skip to content

Commit 47e581d

Browse files
authored
Merge branch 'main' into fix-issue-1976
2 parents c08dab8 + 75debc3 commit 47e581d

18 files changed

Lines changed: 515 additions & 7 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@solidjs/start": minor
3+
---
4+
5+
Add `serialization.plugins` to configure custom Seroval plugins for server functions.
6+
7+
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:
8+
9+
```ts
10+
// vite.config.ts
11+
solidStart({
12+
serialization: {
13+
plugins: "src/seroval-plugins.ts",
14+
},
15+
});
16+
```
17+
18+
```ts
19+
// src/seroval-plugins.ts
20+
import { createPlugin } from "@solidjs/start/serialization";
21+
```
22+
23+
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`.
24+
25+
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.

‎.changeset/tidy-paths-resolve.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/start": patch
3+
---
4+
5+
Scope the built-in `~` alias to the app package, so files in other workspace packages can map `~` to their own root through an importer-aware plugin such as `vite-tsconfig-paths`. In stylesheets and asset URLs (CSS `@import`, `url()`, `new URL(..., import.meta.url)`) `~` still always means the app root, since Vite resolves those without running plugins.

‎apps/tests/src/e2e/server-function.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ test.describe("server-function", () => {
7373
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
7474
});
7575

76+
/**
77+
* A custom Seroval plugin registered through `serialization.plugins` must
78+
* apply to both the request and the response payload, so a class Seroval has
79+
* no built-in support for survives the round trip with its prototype intact.
80+
* https://github.com/solidjs/solid-start/issues/1474
81+
*/
82+
test("should round-trip a custom class through a custom seroval plugin", async ({ page }) => {
83+
await page.goto("http://localhost:3000/server-function-custom-class");
84+
await expect(page.locator("#server-fn-test")).toContainText(
85+
'{"receivedInstance":true,"receivedHex":"507f191e810c19729de860ea","returnedInstance":true,"returnedHex":"507f1f77bcf86cd799439011"}',
86+
);
87+
});
88+
7689
test("should build with a server function w/ form data", async ({ page }) => {
7790
await page.goto("http://localhost:3000/server-function-form-data");
7891
await expect(page.locator("#server-fn-test")).toContainText('{"result":true}');
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createEffect, createSignal } from "solid-js";
2+
import { ObjectId } from "../utils/object-id.ts";
3+
4+
/**
5+
* Exercises `serialization.plugins`: a class Seroval has no built-in support
6+
* for has to survive both directions of a server function call.
7+
*
8+
* @see https://github.com/solidjs/solid-start/issues/1474
9+
*/
10+
async function echoObjectId(id: ObjectId) {
11+
"use server";
12+
13+
return {
14+
receivedInstance: id instanceof ObjectId,
15+
receivedHex: id.toHexString(),
16+
returned: new ObjectId("507f1f77bcf86cd799439011"),
17+
};
18+
}
19+
20+
export default function App() {
21+
const [output, setOutput] = createSignal<Record<string, unknown>>({});
22+
23+
createEffect(async () => {
24+
const result = await echoObjectId(new ObjectId("507f191e810c19729de860ea"));
25+
26+
setOutput({
27+
receivedInstance: result.receivedInstance,
28+
receivedHex: result.receivedHex,
29+
returnedInstance: result.returned instanceof ObjectId,
30+
returnedHex: result.returned.toHexString(),
31+
});
32+
});
33+
34+
return (
35+
<main>
36+
<span id="server-fn-test">{JSON.stringify(output())}</span>
37+
</main>
38+
);
39+
}

‎apps/tests/src/seroval-plugins.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createPlugin } from "@solidjs/start/serialization";
2+
import { ObjectId } from "./utils/object-id.ts";
3+
4+
/**
5+
* Bundled into both the client and the server via `serialization.plugins`, so
6+
* this module must not import server-only code.
7+
*
8+
* `deserialize` is what rebuilds the value under the default `"json"`
9+
* serialization mode. `serialize` is only used by `mode: "js"`, where the
10+
* payload is evaluated on the client and can therefore reference globals only,
11+
* which is why it reaches for `globalThis` rather than the imported class.
12+
*/
13+
const ObjectIdPlugin = createPlugin<ObjectId, { hex: any }>({
14+
tag: "tests/ObjectId",
15+
test: value => value instanceof ObjectId,
16+
parse: {
17+
sync: (value, ctx) => ({ hex: ctx.parse(value.hex) }),
18+
async: async (value, ctx) => ({ hex: await ctx.parse(value.hex) }),
19+
stream: (value, ctx) => ({ hex: ctx.parse(value.hex) }),
20+
},
21+
serialize: (node, ctx) => `globalThis.ObjectId(${ctx.serialize(node.hex)})`,
22+
deserialize: (node, ctx) => new ObjectId(ctx.deserialize(node.hex) as string),
23+
});
24+
25+
(globalThis as any).ObjectId = (hex: string) => new ObjectId(hex);
26+
27+
export default [ObjectIdPlugin];

‎apps/tests/src/utils/object-id.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Stands in for a custom class that Seroval has no built-in support for, such
3+
* as Mongo's `ObjectId` or Prisma's `Decimal`. Serializing one of these across
4+
* a server function boundary requires a custom Seroval plugin.
5+
*
6+
* @see https://github.com/solidjs/solid-start/issues/1474
7+
*/
8+
export class ObjectId {
9+
constructor(readonly hex: string) {}
10+
11+
toHexString() {
12+
return this.hex;
13+
}
14+
}

‎apps/tests/vite.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export default defineConfig({
88
},
99
plugins: [
1010
solidStart({
11+
serialization: {
12+
plugins: "src/seroval-plugins.ts",
13+
},
1114
env: {
1215
server: {
1316
load() {

‎packages/start/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"./http": "./src/http/index.ts",
2525
"./env": "./env.d.ts",
2626
"./fns/server": "./src/fns/server.ts",
27-
"./fns/client": "./src/fns/client.ts"
27+
"./fns/client": "./src/fns/client.ts",
28+
"./serialization": "./src/fns/plugins.ts"
2829
},
2930
"publishConfig": {
3031
"exports": {
@@ -39,7 +40,8 @@
3940
"./http": "./dist/http/index.js",
4041
"./env": "./env.d.ts",
4142
"./fns/server": "./dist/fns/server.js",
42-
"./fns/client": "./dist/fns/client.js"
43+
"./fns/client": "./dist/fns/client.js",
44+
"./serialization": "./dist/fns/plugins.js"
4345
},
4446
"access": "public"
4547
},
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { build, createServer, type Plugin, type ViteDevServer } from "vite";
5+
import { afterEach, expect, it } from "vitest";
6+
7+
import { appRootAlias } from "./app-root-alias.ts";
8+
9+
const roots: string[] = [];
10+
const servers: ViteDevServer[] = [];
11+
12+
/** An app package with a `src` app root, plus a nested `lib` workspace package. */
13+
function createProject() {
14+
const root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-app-root-alias-")));
15+
roots.push(root);
16+
17+
mkdirSync(join(root, "src"));
18+
mkdirSync(join(root, "lib/src"), { recursive: true });
19+
writeFileSync(join(root, "package.json"), "{}");
20+
writeFileSync(join(root, "lib/package.json"), "{}");
21+
writeFileSync(join(root, "src/demo.ts"), 'export default "app";');
22+
writeFileSync(join(root, "lib/src/demo.ts"), 'export default "package";');
23+
24+
return root;
25+
}
26+
27+
afterEach(async () => {
28+
await Promise.all(servers.splice(0).map(server => server.close()));
29+
for (const root of roots.splice(0)) rmSync(root, { recursive: true });
30+
});
31+
32+
it("resolves ~ for the app package, leaving other packages to importer-aware plugins", async () => {
33+
const root = createProject();
34+
const packageRoot = join(root, "lib");
35+
// Stands in for vite-tsconfig-paths: maps `~` to the importing package's root.
36+
const packagePaths: Plugin = {
37+
name: "test:package-paths",
38+
enforce: "pre",
39+
resolveId(id, importer, options) {
40+
if (id !== "~/demo" || !importer?.startsWith(packageRoot)) return null;
41+
return this.resolve(join(packageRoot, "src/demo"), importer, { ...options, skipSelf: true });
42+
},
43+
};
44+
const server = await createServer({
45+
configFile: false,
46+
logLevel: "silent",
47+
root,
48+
server: { middlewareMode: true },
49+
plugins: [appRootAlias(root, "./src"), packagePaths],
50+
});
51+
servers.push(server);
52+
53+
await expect(
54+
server.pluginContainer.resolveId("~/demo", join(root, "src/app.tsx")),
55+
).resolves.toMatchObject({ id: join(root, "src/demo.ts") });
56+
await expect(
57+
server.pluginContainer.resolveId("~/demo", join(root, "lib/src/index.ts")),
58+
).resolves.toMatchObject({ id: join(root, "lib/src/demo.ts") });
59+
});
60+
61+
it("resolves ~ in stylesheets, which are resolved without user plugins", async () => {
62+
const root = createProject();
63+
writeFileSync(join(root, "src/theme.css"), "body { color: red; }");
64+
writeFileSync(join(root, "src/app.css"), '@import "~/theme.css";');
65+
writeFileSync(join(root, "src/main.js"), 'import "./app.css";');
66+
67+
const result = (await build({
68+
configFile: false,
69+
logLevel: "silent",
70+
root,
71+
plugins: [appRootAlias(root, "./src")],
72+
build: { write: false, rollupOptions: { input: join(root, "src/main.js") } },
73+
})) as { output: { fileName: string; source?: unknown }[] };
74+
75+
const css = result.output.find(chunk => chunk.fileName.endsWith(".css"));
76+
expect(String(css?.source)).toContain("color:red");
77+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { existsSync } from "node:fs";
2+
import { dirname, isAbsolute, join, resolve } from "node:path";
3+
import { normalizePath, type Plugin } from "vite";
4+
5+
/**
6+
* `~/app.css`, `~/logo.svg`, ... but not `~/lib/api.ts` or `~/components/Counter`.
7+
*
8+
* CSS `@import`, `url()`, preprocessor imports and `new URL(..., import.meta.url)`
9+
* are resolved by internal Vite resolvers that only run the alias plugin, never
10+
* user plugins, so those ids can never reach `resolveId` below. Keeping non-module
11+
* ids on a plain alias preserves `~` in stylesheets and asset URLs, where it always
12+
* means the app root.
13+
*/
14+
const NON_MODULE_ID = /^~\/([^?#]*\.(?![cm]?[jt]sx?(?:[?#]|$))[^./?#]+(?:[?#].*)?)$/;
15+
16+
/**
17+
* Provides SolidStart's `~` app-root alias without claiming module imports made
18+
* by other workspace packages. Those packages may map `~` to their own root
19+
* through an importer-aware resolver such as vite-tsconfig-paths.
20+
*/
21+
export function appRootAlias(projectRoot: string, appRoot: string): Plugin {
22+
const appDir = normalizePath(resolve(projectRoot, appRoot));
23+
const packageRoots = new Map<string, string | undefined>();
24+
25+
/** Nearest directory at or above `directory` that holds a `package.json`. */
26+
function packageRoot(directory: string): string | undefined {
27+
if (!packageRoots.has(directory)) {
28+
const parent = dirname(directory);
29+
packageRoots.set(
30+
directory,
31+
existsSync(join(directory, "package.json"))
32+
? directory
33+
: parent === directory
34+
? undefined
35+
: packageRoot(parent),
36+
);
37+
}
38+
return packageRoots.get(directory);
39+
}
40+
41+
const appPackage = packageRoot(appDir);
42+
43+
/** True only when the importer demonstrably belongs to another package. */
44+
function isForeignImporter(importer: string) {
45+
const file = normalizePath(importer.replace(/[?#].*$/s, ""));
46+
if (!isAbsolute(file)) return false;
47+
const owner = packageRoot(dirname(file));
48+
return owner !== undefined && owner !== appPackage;
49+
}
50+
51+
return {
52+
name: "solid-start:app-root-alias",
53+
enforce: "pre",
54+
config() {
55+
return { resolve: { alias: [{ find: NON_MODULE_ID, replacement: `${appDir}/$1` }] } };
56+
},
57+
async resolveId(id, importer, options) {
58+
if (id !== "~" && !id.startsWith("~/")) return null;
59+
if (importer && isForeignImporter(importer)) return null;
60+
61+
const target = join(appDir, id.slice(1));
62+
return (await this.resolve(target, importer, { ...options, skipSelf: true })) ?? target;
63+
},
64+
};
65+
}

0 commit comments

Comments
 (0)