Skip to content

Commit 5828a6e

Browse files
feat: turnkey SSR via the object form of the ssr option
`ssr: {}` layers zero-config serving on top of the SSR transforms (Vite 6+): a dev middleware streams the rendered app for HTML-accepting GET requests (Vite client + dev style patch injected, errors to the overlay), a plain `vite build` produces client + server bundles through the environments/builder API, and the server bundle's entry is the new `virtual:solid-ssr-handler` whose handleRequest maps a web Request to a streamed Response with provideRequestEvent scoping and manifest-driven asset injection. Entries resolve conventionally with escape hatches: explicit options, src/entry-server.* / src/entry-client.* pairs (prod rewrites authored /src/entry-client.tsx references to the hashed asset), else both entries are generated from a root component (ssr.app, default src/App.*) wrapped in a document shell (ssr.document, default src/Document.*, else a built-in one). Composes with serverFunctions: the dev middleware runs ahead of SSR and the prod handler serves the endpoint before rendering. Node<->web request bridging is shared with the server-function middleware via src/http.ts, and SSR builds now merge the persisted server-function manifest at manifest load time so builder-mode single-invocation builds keep client-only registrations. Adds examples/ssr-turnkey with an e2e node suite covering dev/prod/document/entries modes (verified on Vite 6, 7, and 8); `ssr: true` behavior is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f74f536 commit 5828a6e

18 files changed

Lines changed: 1653 additions & 84 deletions

File tree

.changeset/turnkey-ssr.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'vite-plugin-solid': patch
3+
---
4+
5+
Turnkey SSR: the object form of the `ssr` option (even empty: `ssr: {}`)
6+
adds a serving layer on top of the SSR transforms so a plain Vite app gets
7+
streaming server-side rendering with zero wiring — no entry files, no
8+
index.html, no dev server script (requires Vite 6+; `ssr: true` keeps the
9+
transform-only behavior unchanged).
10+
11+
- Dev: a middleware on the Vite dev server streams the rendered app for
12+
HTML-accepting GET requests through the SSR environment, scoping each
13+
request with `provideRequestEvent` and injecting the Vite client and the
14+
dev style patch into `<head>`; SSR errors flow (stack-fixed) to Vite's
15+
error page with the overlay. `vite` is the whole dev story.
16+
- Build: a plain `vite build` produces both bundles via the
17+
environments/builder API — client assets and manifest to `dist/client`,
18+
the server bundle to `dist/server/server.js` (`vite build --app` and the
19+
classic two-step `vite build` + `vite build --ssr` also work).
20+
- Prod: the server bundle's entry is the new `virtual:solid-ssr-handler`,
21+
whose `handleRequest(request)` export maps a web-standard `Request` to a
22+
streamed `Response` — adapter-agnostic, one line to mount on any server.
23+
Hashed client assets are resolved through `virtual:solid-manifest`.
24+
- Entries are conventional with escape hatches, resolved in order: explicit
25+
`ssr.entryServer` / `ssr.entryClient`; conventional `src/entry-server.*` /
26+
`src/entry-client.*` (the server entry exports
27+
`render(request?, context?)`; authored `/src/entry-client.tsx` script
28+
references are rewritten to the hashed asset in prod); else both entries
29+
are generated from a root component (`ssr.app`, default `src/App.*`)
30+
wrapped in a document shell (`ssr.document`, default `src/Document.*`,
31+
else a built-in one).
32+
- With `serverFunctions` enabled the two compose: the dev server-function
33+
middleware runs ahead of SSR, and the production `handleRequest` serves
34+
the endpoint before rendering.
35+
- Server-function registration robustness: the SSR build now merges the
36+
client build's persisted manifest at manifest load time as well, so
37+
builder-mode (single-invocation) builds keep registrations for functions
38+
only client code references.

README.md

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,108 @@ If set to false, it won't inject the runtime in dev.
125125

126126
#### options.ssr
127127

128-
- Type: Boolean
128+
- Type: Boolean | Object
129129
- Default: false
130130

131-
This will force SSR code in the produced files.
131+
`ssr: true` enables the SSR transforms (hydratable client code, SSR server
132+
code); you provide the entries and the server yourself, as before.
133+
134+
The object form — even empty, `ssr: {}` — additionally turns on **turnkey
135+
SSR** (requires Vite 6+): a plain Vite app gets streaming server-side
136+
rendering with zero wiring. No entry files, no `index.html`, no dev server
137+
script.
138+
139+
```ts
140+
// vite.config.ts
141+
import { defineConfig } from 'vite';
142+
import solidPlugin from 'vite-plugin-solid';
143+
144+
export default defineConfig({
145+
plugins: [solidPlugin({ ssr: {} })],
146+
});
147+
```
148+
149+
```tsx
150+
// src/App.tsx — the entire app: a plain content component
151+
export default function App() {
152+
return <h1>Hello SSR</h1>;
153+
}
154+
```
155+
156+
- **Dev**: `vite` just works — a middleware on the dev server streams the
157+
rendered app for HTML-accepting GET requests through the SSR environment,
158+
injecting the Vite client (HMR, error overlay) and the dev style patch
159+
into `<head>`. SSR errors render Vite's error page with the overlay.
160+
- **Build**: a plain `vite build` produces both bundles via the
161+
environments/builder API — client assets (+ manifest) to `dist/client` and
162+
the server bundle to `dist/server/server.js`. (`vite build --app`, or the
163+
classic `vite build` + `vite build --ssr` two-step, work too.)
164+
- **Prod**: the server bundle's entry is `virtual:solid-ssr-handler`, whose
165+
`handleRequest(request)` export maps a web-standard `Request` to a
166+
streamed `Response` — adapter-agnostic, so any node server / worker /
167+
runtime mounts SSR in one line:
168+
169+
```js
170+
import { handleRequest } from './dist/server/server.js';
171+
// serve dist/client statically, everything else:
172+
const response = await handleRequest(request);
173+
```
174+
175+
Each request is scoped with `provideRequestEvent`, so `getRequestEvent()`
176+
works during the render; hashed client assets (entry script, CSS) are
177+
resolved through the build manifest and injected into `<head>`.
178+
179+
**Entry resolution** (all paths relative to the Vite root):
180+
181+
1. Explicit `ssr.entryServer` / `ssr.entryClient` options.
182+
2. Conventional files: `src/entry-server.{tsx,jsx,ts,js,mjs}` and
183+
`src/entry-client.{tsx,jsx,ts,js,mjs}`. Entry files come in pairs —
184+
providing only one is an error. The server entry must export
185+
`render(request?, context?)` returning a `renderToStream` result, an HTML
186+
string, or a `Response`; `context.clientEntry` carries the resolved
187+
client entry URL, and in production any literal
188+
`"/src/entry-client.tsx"` reference in the rendered HTML is rewritten to
189+
the hashed asset (the classic harness convention keeps working).
190+
3. Generated entries (the zero-config path): when no entry files exist, both
191+
are generated from a root component — `ssr.app`, defaulting to
192+
`src/App.{tsx,jsx,ts,js}` (or lowercase `src/app.*`) — wrapped in a
193+
document shell: `ssr.document`, defaulting to `src/Document.{tsx,jsx}`,
194+
else a built-in minimal shell. A custom document receives the app as
195+
`props.children` and must render the full `<html>` document including
196+
`<HydrationScript />`; the client entry script is injected into `<head>`
197+
automatically.
198+
199+
With [`serverFunctions`](#optionsserverfunctions) also enabled the two
200+
compose: in dev the server-function middleware handles the endpoint before
201+
SSR; in production the same `handleRequest` serves the endpoint too.
202+
203+
Turnkey serving is opt-in via the object form, so existing `ssr: true` setups
204+
are unaffected. On Vite versions below 6 the object form falls back to the
205+
transforms with a warning. See `examples/ssr-turnkey` for a complete app
206+
(including a one-file production server) and `examples/ssr` for the manual
207+
`ssr: true` wiring.
208+
209+
#### options.serverFunctions
210+
211+
- Type: Boolean | Object
212+
- Default: undefined
213+
214+
Enables `"use server"` server function compilation (experimental). Pass
215+
`true` for the defaults (runtime from `@solidjs/web/server-functions`,
216+
endpoint `/_server`) or an options object (`runtime`, `endpoint`, `filter`,
217+
`directive`, `manifest`) to customize.
218+
219+
The setup is turnkey: in dev a middleware on the Vite server handles the
220+
endpoint end to end — no server-function code needed in your server entry.
221+
For production SSR builds, either use turnkey SSR (the object form of
222+
[`ssr`](#optionsssr), whose handler serves the endpoint automatically) or
223+
import `virtual:solid-server-function-handler` in your server entry and
224+
mount its `handleServerFunctionRequest(request)` export on the endpoint.
225+
226+
Meta-frameworks that need to control plugin ordering and dispatch requests
227+
through their own server should use the standalone `serverFunctions()`
228+
export instead, which never installs the dev middleware. See
229+
`examples/server-functions` for a complete app.
132230

133231
#### options.compiler
134232

examples/ssr-turnkey/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "example-ssr-turnkey",
3+
"private": "true",
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "vite build",
8+
"serve": "NODE_ENV=production node server.js",
9+
"test": "node test/run.mjs"
10+
},
11+
"devDependencies": {
12+
"vite": "^7.0.0",
13+
"vite-plugin-solid": "workspace:*"
14+
},
15+
"dependencies": {
16+
"solid-js": "catalog:",
17+
"@solidjs/web": "catalog:"
18+
}
19+
}

examples/ssr-turnkey/server.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// The entire production server for a turnkey SSR app: static client assets
2+
// plus one import — the built server bundle's `handleRequest`, an
3+
// adapter-agnostic web `Request -> Response` handler that streams the SSR
4+
// render, resolves hashed client assets through the build manifest, and
5+
// (with serverFunctions enabled) serves the `/_server` endpoint too. The
6+
// node <-> web plumbing below is the only glue; on a web-native platform
7+
// (workers, Deno, Bun.serve) `handleRequest` is used directly.
8+
import { createServer } from 'node:http';
9+
import { readFileSync } from 'node:fs';
10+
import { Readable } from 'node:stream';
11+
import { fileURLToPath } from 'node:url';
12+
import path from 'node:path';
13+
import { handleRequest } from './dist/server/server.js';
14+
15+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
16+
const port = process.env.PORT || 3000;
17+
18+
const MIME = {
19+
'.js': 'application/javascript',
20+
'.css': 'text/css',
21+
'.html': 'text/html',
22+
'.json': 'application/json',
23+
'.ico': 'image/x-icon',
24+
'.svg': 'image/svg+xml',
25+
};
26+
27+
function webRequest(req) {
28+
const url = new URL(req.url || '/', `http://${req.headers.host || `localhost:${port}`}`);
29+
const method = req.method || 'GET';
30+
const body = method === 'GET' || method === 'HEAD' ? undefined : Readable.toWeb(req);
31+
return new Request(url, {
32+
method,
33+
headers: req.headers,
34+
body,
35+
...(body ? { duplex: 'half' } : {}),
36+
});
37+
}
38+
39+
const server = createServer(async (req, res) => {
40+
const url = req.url || '/';
41+
42+
// Static client assets first.
43+
if (url !== '/' && !url.includes('..')) {
44+
try {
45+
const content = readFileSync(path.resolve(__dirname, 'dist/client' + url.split('?')[0]));
46+
res.setHeader('Content-Type', MIME[path.extname(url)] || 'application/octet-stream');
47+
res.end(content);
48+
return;
49+
} catch {
50+
// Fall through to the handler (SSR routes, /_server, ...).
51+
}
52+
}
53+
54+
try {
55+
const response = await handleRequest(webRequest(req));
56+
res.statusCode = response.status;
57+
const cookies = response.headers.getSetCookie?.();
58+
response.headers.forEach((value, key) => {
59+
if (key !== 'set-cookie') res.setHeader(key, value);
60+
});
61+
if (cookies?.length) res.setHeader('set-cookie', cookies);
62+
if (response.body) {
63+
for await (const chunk of response.body) res.write(chunk);
64+
}
65+
res.end();
66+
} catch (e) {
67+
console.error(e);
68+
res.statusCode = 500;
69+
res.end(e.message);
70+
}
71+
});
72+
73+
server.listen(port, () => {
74+
console.log(`Server running at http://localhost:${port}`);
75+
});

examples/ssr-turnkey/src/App.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#title {
2+
color: rgb(20, 40, 60);
3+
}

examples/ssr-turnkey/src/App.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// The entire app the user writes for turnkey SSR: a plain content component
2+
// (no <html>, no HydrationScript, no entries — the plugin's generated
3+
// document shell provides all of that). Exercises, for test/run.mjs:
4+
// - hydration + client interactivity (the counter),
5+
// - streaming (the async section renders after the shell),
6+
// - server functions alongside SSR (the message button),
7+
// - HMR (HmrTarget is edited on disk by the test),
8+
// - CSS handling (App.css must reach the page in dev and prod).
9+
import { createMemo, createSignal, Loading } from 'solid-js';
10+
import { getServerMessage } from './api';
11+
import HmrTarget from './HmrTarget';
12+
import './App.css';
13+
14+
export default function App() {
15+
const [count, setCount] = createSignal(0);
16+
const [message, setMessage] = createSignal('');
17+
18+
// Async-generator memo inside a Loading boundary: the SSR shell streams
19+
// immediately with the fallback, the yielded content follows in a later
20+
// chunk once it resolves.
21+
const streamed = createMemo(async function* () {
22+
await new Promise((resolve) => setTimeout(resolve, 300));
23+
yield 'STREAMED-ASYNC-CONTENT';
24+
});
25+
26+
return (
27+
<main>
28+
<h1 id="title">Turnkey SSR</h1>
29+
<button id="increment" onClick={() => setCount(count() + 1)}>
30+
count
31+
</button>
32+
<p id="count">{count()}</p>
33+
<button id="call-message" onClick={async () => setMessage(await getServerMessage('client'))}>
34+
message
35+
</button>
36+
<p id="message">{message()}</p>
37+
<HmrTarget />
38+
<Loading fallback={<p id="stream-fallback">streaming…</p>}>
39+
<p id="streamed">{streamed()}</p>
40+
</Loading>
41+
</main>
42+
);
43+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Document-shell escape hatch, activated by SSR_DOCUMENT in vite.config.ts
2+
// (named CustomDocument so the src/Document.* convention doesn't pick it up
3+
// in the default zero-config run). A document component receives the app as
4+
// children and must render the full <html> including <HydrationScript />.
5+
import type { ParentProps } from 'solid-js';
6+
import { HydrationScript } from '@solidjs/web';
7+
8+
export default function CustomDocument(props: ParentProps) {
9+
return (
10+
<html lang="en">
11+
<head>
12+
<meta charset="utf-8" />
13+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
14+
<title>Custom Document</title>
15+
<HydrationScript />
16+
</head>
17+
<body>{props.children}</body>
18+
</html>
19+
);
20+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// HMR fixture: test/run.mjs edits the rendered text below on disk and
2+
// asserts the page picks it up through solid-refresh without a full reload
3+
// and without resetting sibling component state. Keep the marker text and
4+
// element id in sync with the test.
5+
export default function HmrTarget() {
6+
return <p id="hmr-text">HMR-ORIGINAL</p>;
7+
}

examples/ssr-turnkey/src/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use server';
2+
3+
export async function getServerMessage(name: string) {
4+
return `hello ${name} from the server`;
5+
}

0 commit comments

Comments
 (0)