Skip to content

Commit c3c49e8

Browse files
add experimental server function compilation hoisted from solid-start
Hoists the "use server" directive compiler from SolidStart 2.0 alpha into src/server-functions, exposed via the serverFunctions option on the main plugin (which now returns Plugin[]) or the standalone serverFunctions() export for meta-frameworks that manage plugin ordering. The runtime ABI is bring-your-own through options.runtime module specifiers; a plain-Vite fixture (examples/server-functions) proves the compiler standalone with a minimal runtime built on @solidjs/web/serialization. Client-only referenced modules are fed to the server manifest across two-invocation builds via dist/client/.vite/solid-server-functions.json, and function IDs hash project-relative paths for reproducible builds. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 36428cb commit c3c49e8

29 files changed

Lines changed: 2495 additions & 773 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'vite-plugin-solid': patch
3+
---
4+
5+
`"use server"` server function compilation (experimental), hoisted from
6+
SolidStart 2.0 alpha's directive compiler. Enable it through the new
7+
`serverFunctions` option on the main plugin, or compose the standalone
8+
`serverFunctions(options)` export for full control over plugin ordering
9+
(e.g. relative to a file-system router). To support emitting the transform
10+
sub-plugins, `solid()` now returns `Plugin[]` instead of a single `Plugin`
11+
transparent at the Vite config level, where plugin arrays flatten.
12+
13+
- Both directive forms are supported: function-level (first statement of a
14+
function body) and module-level (every export becomes a server function).
15+
Server builds register the original function via `createServerReference`
16+
and reference it with `cloneServerReference`; client builds compile to
17+
ID-only references with the function bodies — and everything only they
18+
used, including module-level server-only code — removed.
19+
- The runtime is bring-your-own: compiled output imports the two reference
20+
functions from the module specifiers in `options.runtime.{server,client}`,
21+
so SolidStart's runtime, or a minimal custom one (see the
22+
`examples/server-functions` fixture built on `@solidjs/web/serialization`), can
23+
satisfy the ABI. Works identically under the Babel and native compiler
24+
backends since the transform runs as its own pre-pass (server functions
25+
live in plain `.ts`/`.js` files the JSX pass never sees).
26+
- A virtual manifest module (default `virtual:solid-server-function-manifest`)
27+
imports every module containing server functions; import it for side
28+
effects in the server entry so registrations exist before dispatch. Server
29+
functions referenced only from client-side code (e.g. event handlers,
30+
which the SSR JSX compile drops) are discovered by the client transform
31+
and fed into the server manifest — across the classic two-invocation build
32+
via `dist/client/.vite/solid-server-functions.json`.
33+
- Divergences from the SolidStart source: function IDs hash the
34+
project-relative path (reproducible across machines while still agreeing
35+
between the client and server builds) and modules without the directive
36+
substring skip the Babel parse entirely.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "example-server-functions",
3+
"private": "true",
4+
"type": "module",
5+
"scripts": {
6+
"dev": "node server.js",
7+
"build": "npm run build:client && npm run build:server",
8+
"build:client": "vite build --outDir dist/client",
9+
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
10+
"serve": "NODE_ENV=production node server.js",
11+
"test": "node test/run.mjs"
12+
},
13+
"devDependencies": {
14+
"vite": "^7.0.0",
15+
"vite-plugin-solid": "workspace:*"
16+
},
17+
"dependencies": {
18+
"solid-js": "catalog:",
19+
"@solidjs/web": "catalog:"
20+
}
21+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { createServer as createHttpServer } from 'node:http';
2+
import { readFileSync } from 'node:fs';
3+
import { fileURLToPath } from 'node:url';
4+
import path from 'node:path';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
const isProduction = process.env.NODE_ENV === 'production';
8+
const port = process.env.PORT || 3000;
9+
10+
function getClientEntry() {
11+
if (!isProduction) return '/src/entry-client.tsx';
12+
const manifest = JSON.parse(
13+
readFileSync(path.resolve(__dirname, 'dist/client/.vite/manifest.json'), 'utf-8'),
14+
);
15+
const entry = manifest['src/entry-client.tsx'];
16+
return '/' + entry.file;
17+
}
18+
19+
function readBody(req) {
20+
return new Promise((resolve, reject) => {
21+
const chunks = [];
22+
req.on('data', (chunk) => chunks.push(chunk));
23+
req.on('end', () => resolve(Buffer.concat(chunks)));
24+
req.on('error', reject);
25+
});
26+
}
27+
28+
async function start() {
29+
let vite;
30+
let devHeadInjection = '';
31+
32+
const loadEntryServer = () =>
33+
isProduction
34+
? import('./dist/server/entry-server.js')
35+
: vite.ssrLoadModule('/src/entry-server.tsx');
36+
37+
const server = createHttpServer(async (req, res) => {
38+
const url = req.url || '/';
39+
40+
try {
41+
// Server function endpoint: adapt the node request to a web Request and
42+
// hand it to the runtime's handler inside the SSR module graph (so it
43+
// shares the registry with the rendered app).
44+
if (url.startsWith('/_server')) {
45+
const { handleServerFunction } = await loadEntryServer();
46+
const body = await readBody(req);
47+
const request = new Request(`http://localhost:${port}${url}`, {
48+
method: req.method,
49+
headers: req.headers,
50+
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
51+
});
52+
const response = await handleServerFunction(request);
53+
res.statusCode = response.status;
54+
response.headers.forEach((value, key) => res.setHeader(key, value));
55+
res.end(await response.text());
56+
return;
57+
}
58+
59+
if (!isProduction) {
60+
const handled = await new Promise((resolve) => {
61+
vite.middlewares(req, res, () => resolve(false));
62+
});
63+
if (handled !== false) return;
64+
if (!req.headers.accept?.includes('text/html')) {
65+
if (!res.headersSent) {
66+
res.statusCode = 404;
67+
res.end();
68+
}
69+
return;
70+
}
71+
}
72+
73+
if (isProduction && url !== '/') {
74+
const filePath = path.resolve(__dirname, 'dist/client' + url);
75+
try {
76+
const content = readFileSync(filePath);
77+
const ext = path.extname(url);
78+
const types = {
79+
'.js': 'application/javascript',
80+
'.css': 'text/css',
81+
'.html': 'text/html',
82+
'.json': 'application/json',
83+
};
84+
res.setHeader('Content-Type', types[ext] || 'application/octet-stream');
85+
res.end(content);
86+
return;
87+
} catch {
88+
// Fall through to SSR
89+
}
90+
}
91+
92+
const { render } = await loadEntryServer();
93+
const stream = render();
94+
const clientEntry = getClientEntry();
95+
96+
res.setHeader('Content-Type', 'text/html');
97+
res.write('<!DOCTYPE html>');
98+
99+
stream.pipe({
100+
write(chunk) {
101+
let html = chunk;
102+
if (!isProduction && html.includes('</head>')) {
103+
html = html.replace('</head>', devHeadInjection + '</head>');
104+
}
105+
if (isProduction && html.includes('/src/entry-client.tsx')) {
106+
html = html.replace('/src/entry-client.tsx', clientEntry);
107+
}
108+
return res.write(html);
109+
},
110+
end() {
111+
res.end();
112+
},
113+
});
114+
} catch (e) {
115+
if (!isProduction) vite.ssrFixStacktrace(e);
116+
console.error(e);
117+
res.statusCode = 500;
118+
res.end(e.message);
119+
}
120+
});
121+
122+
if (!isProduction) {
123+
const { createServer } = await import('vite');
124+
vite = await createServer({
125+
server: { middlewareMode: true, hmr: { server } },
126+
appType: 'custom',
127+
});
128+
const { devStylePatch } = await import('vite-plugin-solid');
129+
devHeadInjection =
130+
`<script>${devStylePatch}</script>` +
131+
'<script type="module" src="/@vite/client"></script>';
132+
}
133+
134+
server.listen(port, () => {
135+
console.log(`Server running at http://localhost:${port}`);
136+
});
137+
}
138+
139+
start();
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createSignal } from 'solid-js';
2+
import { HydrationScript } from '@solidjs/web';
3+
import { getServerMessage, hasSecret, requestMethod } from './api';
4+
5+
export default function App() {
6+
const [message, setMessage] = createSignal('');
7+
const [doubled, setDoubled] = createSignal('');
8+
const [method, setMethod] = createSignal('');
9+
const [secret, setSecret] = createSignal('');
10+
11+
// Function-level directive inside a component: the compiler hoists the body
12+
// to a module-level registration on the server and swaps in a reference on
13+
// the client. (Bodies must not close over component scope.)
14+
const double = async (n: number) => {
15+
'use server';
16+
return n * 2;
17+
};
18+
19+
return (
20+
<html lang="en">
21+
<head>
22+
<meta charset="UTF-8" />
23+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
24+
<title>Server Functions</title>
25+
<HydrationScript />
26+
</head>
27+
<body>
28+
<h1>Server Functions</h1>
29+
<button id="call-message" onClick={async () => setMessage(await getServerMessage('client'))}>
30+
message
31+
</button>
32+
<button id="call-double" onClick={async () => setDoubled(String(await double(21)))}>
33+
double
34+
</button>
35+
<button id="call-method" onClick={async () => setMethod(await requestMethod())}>
36+
method
37+
</button>
38+
<button id="call-secret" onClick={async () => setSecret(String(await hasSecret()))}>
39+
secret
40+
</button>
41+
<p id="message">{message()}</p>
42+
<p id="doubled">{doubled()}</p>
43+
<p id="method">{method()}</p>
44+
<p id="secret">{secret()}</p>
45+
<script type="module" src="/src/entry-client.tsx" async />
46+
</body>
47+
</html>
48+
);
49+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use server';
2+
// Module-level directive: every export becomes a server function. The client
3+
// build of this module is replaced entirely by references — the secret below
4+
// (and the @solidjs/web import) must never appear in any client asset, which
5+
// test/run.mjs asserts.
6+
import { getRequestEvent } from '@solidjs/web';
7+
8+
const SERVER_ONLY_SECRET = 'SERVER-ONLY-SECRET-c81d';
9+
10+
export async function getServerMessage(name: string) {
11+
return `hello ${name} from the server`;
12+
}
13+
14+
export async function hasSecret() {
15+
return SERVER_ONLY_SECRET.length > 0;
16+
}
17+
18+
export async function requestMethod() {
19+
const event = getRequestEvent();
20+
return event ? event.request.method : 'no-event';
21+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { hydrate } from '@solidjs/web';
2+
import App from './App';
3+
4+
hydrate(() => <App />, document);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { renderToStream } from '@solidjs/web';
2+
import { provideRequestEvent } from '@solidjs/web/storage';
3+
import manifest from 'virtual:solid-manifest';
4+
// Side-effect import: pulls in every module containing server functions so
5+
// their registrations exist before any /_server request is dispatched (e.g.
6+
// functions in modules the SSR render itself never imports).
7+
import 'virtual:solid-server-function-manifest';
8+
import App from './App';
9+
import { getServerFunction } from './runtime/server';
10+
import { deserializeFromText, serializeToText } from './runtime/shared';
11+
12+
export function render() {
13+
return renderToStream(() => <App />, { manifest });
14+
}
15+
16+
/** HTTP endpoint for the prototype runtime: dispatches /_server requests. */
17+
export async function handleServerFunction(request: Request): Promise<Response> {
18+
const id = request.headers.get('x-server-function');
19+
if (!id) {
20+
return new Response('missing x-server-function header', { status: 400 });
21+
}
22+
23+
let fn: (...args: unknown[]) => unknown;
24+
try {
25+
fn = getServerFunction(id);
26+
} catch (error) {
27+
return new Response(String(error), { status: 404 });
28+
}
29+
30+
const body = await request.text();
31+
const args = body ? deserializeFromText<unknown[]>(body) : [];
32+
33+
const event = { request, locals: {} };
34+
try {
35+
const result = await provideRequestEvent(event as any, () => fn(...args));
36+
return new Response(await serializeToText(result), {
37+
status: 200,
38+
headers: { 'content-type': 'text/plain' },
39+
});
40+
} catch (error) {
41+
return new Response(await serializeToText(error), {
42+
status: 500,
43+
headers: { 'content-type': 'text/plain', 'x-error': '1' },
44+
});
45+
}
46+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Client half of the prototype runtime ABI. The compiled client build calls
2+
// `cloneServerReference(id)` where a server function was referenced; the
3+
// function body never reaches this bundle. Calls POST the serialized
4+
// arguments to the /_server endpoint handled in server.js.
5+
import { deserializeFromText, serializeToText } from './shared.js';
6+
7+
export function cloneServerReference(id: string) {
8+
return async (...args: unknown[]) => {
9+
const response = await fetch('/_server', {
10+
method: 'POST',
11+
headers: {
12+
'content-type': 'text/plain',
13+
'x-server-function': id,
14+
},
15+
body: await serializeToText(args),
16+
});
17+
const result = deserializeFromText<unknown>(await response.text());
18+
if (!response.ok) {
19+
throw result;
20+
}
21+
return result;
22+
};
23+
}
24+
25+
// Only ever referenced by server-mode output; present so a misconfigured
26+
// build fails loudly instead of with a missing-export error.
27+
export function createServerReference(): never {
28+
throw new Error('createServerReference must not be called in the client build');
29+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Server half of the prototype runtime ABI. The compiled server build calls
2+
// `createServerReference(id, fn)` for every server function (registering it
3+
// for HTTP dispatch) and `cloneServerReference(ref)` where the function was
4+
// referenced — during SSR the original function runs in-process.
5+
6+
export interface ServerFunctionReference {
7+
id: string;
8+
fn: (...args: any[]) => any;
9+
}
10+
11+
const registry = new Map<string, ServerFunctionReference['fn']>();
12+
13+
export function createServerReference(
14+
id: string,
15+
fn: ServerFunctionReference['fn'],
16+
): ServerFunctionReference {
17+
registry.set(id, fn);
18+
return { id, fn };
19+
}
20+
21+
export function cloneServerReference({ id, fn }: ServerFunctionReference) {
22+
if (typeof fn !== 'function') {
23+
throw new Error(`Export "${id}" from a 'use server' module must be a function`);
24+
}
25+
return fn;
26+
}
27+
28+
/** Used by the HTTP handler to dispatch incoming server function calls. */
29+
export function getServerFunction(id: string): ServerFunctionReference['fn'] {
30+
const fn = registry.get(id);
31+
if (!fn) {
32+
throw new Error(`Unknown server function: ${id}`);
33+
}
34+
return fn;
35+
}

0 commit comments

Comments
 (0)