@st-h/vite-ember-ssr 0.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/README.md +733 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/server.d.ts +236 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +349 -0
- package/dist/server.js.map +1 -0
- package/dist/vite-plugin-CQou_tr5.d.ts +145 -0
- package/dist/vite-plugin-CQou_tr5.d.ts.map +1 -0
- package/dist/vite-plugin-D-W5WQWe.js +398 -0
- package/dist/vite-plugin-D-W5WQWe.js.map +1 -0
- package/dist/vite-plugin.d.ts +2 -0
- package/dist/vite-plugin.js +2 -0
- package/dist/worker.d.ts +22 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +186 -0
- package/dist/worker.js.map +1 -0
- package/package.json +73 -0
- package/src/client.ts +242 -0
- package/src/dev.ts +318 -0
- package/src/server.ts +399 -0
- package/src/vite-plugin.ts +775 -0
- package/src/worker.ts +334 -0
package/README.md
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
# vite-ember-ssr
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **EXPERIMENTAL** — This project is in early development and targets **compatless** Ember apps only (no `@embroider/compat`, no `ember-cli-build.js`, no `classicEmberSupport()`). APIs will change. Do not use in production.
|
|
5
|
+
|
|
6
|
+
Vite plugin and SSR/SSG runtime for Ember.js applications. Uses [HappyDOM](https://github.com/capricorn86/happy-dom) for server-side rendering — no FastBoot, no VM sandbox.
|
|
7
|
+
|
|
8
|
+
Two modes are supported:
|
|
9
|
+
|
|
10
|
+
- **SSR** (`emberSsr`) — server renders pages on each request at runtime. Requires a Node.js server.
|
|
11
|
+
- **SSG** (`emberSsg`) — prerenders specified routes to static HTML files at build time. A single `vite build` produces deploy-ready files. No server required.
|
|
12
|
+
|
|
13
|
+
Both plugins can be used together in the same Vite config for a **combined SSR + SSG** setup — prerender known static routes at build time while falling back to dynamic SSR for everything else.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
- **HappyDOM Window** provides a full per-request browser-like environment. Ember runs directly in the Node.js process with globals swapped per request.
|
|
18
|
+
- **`Application.visit(url)`** drives the entire render cycle server-side.
|
|
19
|
+
- **Two client boot modes** — cleanup mode (default) removes SSR content when Ember boots; rehydrate mode lets Glimmer reuse the server-rendered DOM. See [Client Boot Modes](#client-boot-modes) below.
|
|
20
|
+
- **Shoebox** (opt-in) — fetch responses captured during SSR/SSG can be serialized into the HTML and replayed on the client to avoid duplicate API requests. See [Shoebox](#shoebox).
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Ember app built with Embroider in "compatless" mode (no `@embroider/compat`, no `ember-cli-build.js`, and no `classicEmberSupport()`).
|
|
25
|
+
- Your app's `config/environment` must be a direct ES module import (i.e. `import config from './config/environment.ts'`). Do not rely on `<meta>` config injection or `@embroider/config-meta-loader`.
|
|
26
|
+
- Vite 6+
|
|
27
|
+
- Node 22+
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pnpm add -D vite-ember-ssr
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
### SSR (Server-Side Rendering)
|
|
38
|
+
|
|
39
|
+
Use `emberSsr` when you have a Node.js server that renders pages on each request.
|
|
40
|
+
|
|
41
|
+
#### 1. Vite config
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
// vite.config.mjs
|
|
45
|
+
import { defineConfig } from 'vite';
|
|
46
|
+
import { extensions, ember } from '@embroider/vite';
|
|
47
|
+
import { babel } from '@rollup/plugin-babel';
|
|
48
|
+
import { emberSsr } from 'vite-ember-ssr/vite-plugin';
|
|
49
|
+
|
|
50
|
+
export default defineConfig({
|
|
51
|
+
plugins: [
|
|
52
|
+
ember(),
|
|
53
|
+
babel({
|
|
54
|
+
babelHelpers: 'runtime',
|
|
55
|
+
extensions,
|
|
56
|
+
}),
|
|
57
|
+
emberSsr(),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### 2. HTML template
|
|
63
|
+
|
|
64
|
+
Add SSR markers to `index.html`:
|
|
65
|
+
|
|
66
|
+
```html
|
|
67
|
+
<!DOCTYPE html>
|
|
68
|
+
<html>
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8" />
|
|
71
|
+
<!-- VITE_EMBER_SSR_HEAD -->
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<!-- VITE_EMBER_SSR_BODY -->
|
|
75
|
+
<script type="module" src="/app/entry.ts"></script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### 3. SSR entry (`app/app-ssr.ts`)
|
|
81
|
+
|
|
82
|
+
Export a factory that creates the Ember app with `autoboot: false`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import EmberApp from 'ember-strict-application-resolver';
|
|
86
|
+
import config from './config/environment.ts';
|
|
87
|
+
import Router from './router.ts';
|
|
88
|
+
|
|
89
|
+
class App extends EmberApp {
|
|
90
|
+
modules = {
|
|
91
|
+
'./router': Router,
|
|
92
|
+
...import.meta.glob('./{routes,templates}/**/*.{ts,gts}', { eager: true }),
|
|
93
|
+
...import.meta.glob('./services/*.ts', { eager: true }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createSsrApp() {
|
|
98
|
+
return App.create({ ...config.APP, autoboot: false });
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> **Note:** The `createSsrApp` function here is a simple factory. The `createApp` option passed to `render()` must be an **async function** that imports this module and calls the factory — see the server examples below.
|
|
103
|
+
|
|
104
|
+
#### 4. Client entry (`app/entry.ts`)
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import Application from './app.ts';
|
|
108
|
+
import config from './config/environment.ts';
|
|
109
|
+
|
|
110
|
+
Application.create(config.APP);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### 5. Application template (`app/templates/application.gts`)
|
|
114
|
+
|
|
115
|
+
Call `cleanupSSRContent` from the application template so the SSR-rendered DOM is removed at the moment Ember renders, avoiding a flash of no content:
|
|
116
|
+
|
|
117
|
+
```gts
|
|
118
|
+
import { pageTitle } from 'ember-page-title';
|
|
119
|
+
import { LinkTo } from '@ember/routing';
|
|
120
|
+
import { cleanupSSRContent } from 'vite-ember-ssr/client';
|
|
121
|
+
|
|
122
|
+
<template>
|
|
123
|
+
{{pageTitle "MyApp"}}
|
|
124
|
+
{{cleanupSSRContent}}
|
|
125
|
+
|
|
126
|
+
<nav>
|
|
127
|
+
<LinkTo @route="index">Home</LinkTo>
|
|
128
|
+
<LinkTo @route="about">About</LinkTo>
|
|
129
|
+
</nav>
|
|
130
|
+
|
|
131
|
+
{{outlet}}
|
|
132
|
+
</template>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### 6. Build
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
vite build # client → dist/client
|
|
139
|
+
vite build --ssr app/app-ssr.ts # server → dist/server
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### 7. Server
|
|
143
|
+
|
|
144
|
+
Create the worker pool once at startup with `createEmberApp`, then call `renderRoute` in your catch-all handler:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
148
|
+
|
|
149
|
+
// Production: creates a tinypool worker pool, imports the SSR bundle once per worker
|
|
150
|
+
const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
|
|
151
|
+
|
|
152
|
+
// Development: renders in-process via Vite's ssrLoadModule, re-loads on every render
|
|
153
|
+
// const vite = await createServer({ ... });
|
|
154
|
+
// const emberApp = await createEmberApp('app/app-ssr.ts', {
|
|
155
|
+
// dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },
|
|
156
|
+
// });
|
|
157
|
+
|
|
158
|
+
// In your catch-all route handler:
|
|
159
|
+
const rendered = await emberApp.renderRoute(request.url);
|
|
160
|
+
const html = assembleHTML(template, rendered);
|
|
161
|
+
// rendered.statusCode, rendered.error also available
|
|
162
|
+
|
|
163
|
+
// On server shutdown:
|
|
164
|
+
await emberApp.destroy();
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
See [examples/fastify.md](https://github.com/evoactivity/vite-ember-ssr/blob/main/examples/fastify.md) for a complete Fastify example with dev and production modes.
|
|
168
|
+
|
|
169
|
+
### SSG (Static Site Generation)
|
|
170
|
+
|
|
171
|
+
Use `emberSsg` when you want to prerender routes to static HTML files at build time. No server required — the output can be deployed to any static hosting (Netlify, Vercel, GitHub Pages, S3, etc.).
|
|
172
|
+
|
|
173
|
+
A single `vite build` command:
|
|
174
|
+
|
|
175
|
+
1. Builds the client assets (JS, CSS, HTML shell)
|
|
176
|
+
2. Runs a second SSR build internally to produce a temporary server bundle
|
|
177
|
+
3. Renders each specified route using HappyDOM
|
|
178
|
+
4. Writes the resulting HTML files into the output directory
|
|
179
|
+
5. Cleans up the temporary bundle
|
|
180
|
+
|
|
181
|
+
#### 1. Vite config
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// vite.config.mjs
|
|
185
|
+
import { defineConfig } from 'vite';
|
|
186
|
+
import { extensions, ember } from '@embroider/vite';
|
|
187
|
+
import { babel } from '@rollup/plugin-babel';
|
|
188
|
+
import { emberSsg } from 'vite-ember-ssr/vite-plugin';
|
|
189
|
+
|
|
190
|
+
export default defineConfig({
|
|
191
|
+
plugins: [
|
|
192
|
+
ember(),
|
|
193
|
+
babel({ babelHelpers: 'runtime', extensions }),
|
|
194
|
+
emberSsg({
|
|
195
|
+
routes: ['index', 'about', 'contact', 'pokemon/charmander'],
|
|
196
|
+
}),
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### 2. HTML template
|
|
202
|
+
|
|
203
|
+
Same as SSR — add markers to `index.html`:
|
|
204
|
+
|
|
205
|
+
```html
|
|
206
|
+
<!DOCTYPE html>
|
|
207
|
+
<html>
|
|
208
|
+
<head>
|
|
209
|
+
<meta charset="utf-8" />
|
|
210
|
+
<!-- VITE_EMBER_SSR_HEAD -->
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<!-- VITE_EMBER_SSR_BODY -->
|
|
214
|
+
<script type="module" src="/app/entry.ts"></script>
|
|
215
|
+
</body>
|
|
216
|
+
</html>
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### 3. SSR entry (`app/app-ssr.ts`)
|
|
220
|
+
|
|
221
|
+
Same as SSR — export a `createSsrApp` factory function. See the SSR section above.
|
|
222
|
+
|
|
223
|
+
#### 4. Client entry and application template
|
|
224
|
+
|
|
225
|
+
Same as SSR — see [Client Boot Modes](#client-boot-modes) below for the two options:
|
|
226
|
+
|
|
227
|
+
- **Cleanup mode** (default): call `{{cleanupSSRContent}}` in the application template. Ember replaces the server-rendered DOM on boot.
|
|
228
|
+
- **Rehydrate mode** (`rehydrate: true` in `emberSsg()`): use `shouldRehydrate()` to detect prerendered pages and boot with `_renderMode: 'rehydrate'`. Non-SSG routes boot normally. No `cleanupSSRContent` needed.
|
|
229
|
+
|
|
230
|
+
If your routes make `fetch` calls during SSG that the client would repeat, see [Shoebox](#shoebox) to avoid duplicate requests.
|
|
231
|
+
|
|
232
|
+
#### 5. Build
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
vite build
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
That's it. The output directory (default: `dist/`) contains deploy-ready static files:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
dist/
|
|
242
|
+
index.html ← prerendered index route
|
|
243
|
+
about/index.html ← prerendered about route
|
|
244
|
+
contact/index.html ← prerendered contact route
|
|
245
|
+
pokemon/
|
|
246
|
+
charmander/index.html ← prerendered nested route
|
|
247
|
+
assets/
|
|
248
|
+
main-abc123.js ← client JS bundle
|
|
249
|
+
main-abc123.css ← client CSS bundle
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### 6. Deploy
|
|
253
|
+
|
|
254
|
+
Serve the `dist/` directory with any static file server. No Node.js runtime needed.
|
|
255
|
+
|
|
256
|
+
```sh
|
|
257
|
+
# Local preview
|
|
258
|
+
npx http-server dist
|
|
259
|
+
|
|
260
|
+
# Or deploy to any static host
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### Route format
|
|
264
|
+
|
|
265
|
+
Routes are specified as Ember route names (without leading slashes):
|
|
266
|
+
|
|
267
|
+
- `'index'` → `dist/index.html`
|
|
268
|
+
- `'about'` → `dist/about/index.html`
|
|
269
|
+
- `'pokemon'` → `dist/pokemon/index.html`
|
|
270
|
+
- `'pokemon/charmander'` → `dist/pokemon/charmander/index.html`
|
|
271
|
+
|
|
272
|
+
#### Options
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
emberSsg({
|
|
276
|
+
// Required: routes to prerender
|
|
277
|
+
routes: ['index', 'about', 'contact'],
|
|
278
|
+
|
|
279
|
+
// SSR entry module (default: 'app/app-ssr.ts')
|
|
280
|
+
ssrEntry: 'app/app-ssr.ts',
|
|
281
|
+
|
|
282
|
+
// Enable shoebox fetch replay (default: false)
|
|
283
|
+
// When true, fetch responses from model hooks are serialized into the HTML
|
|
284
|
+
// so the client doesn't re-fetch on boot. See the Shoebox section below.
|
|
285
|
+
shoebox: false,
|
|
286
|
+
|
|
287
|
+
// Enable Glimmer rehydration (default: false)
|
|
288
|
+
// When true, prerendered HTML includes Glimmer serialization markers
|
|
289
|
+
// and a `window.__vite_ember_ssr_rehydrate__` flag. The client uses
|
|
290
|
+
// `shouldRehydrate()` to detect this and boot with
|
|
291
|
+
// `app.visit(url, { _renderMode: 'rehydrate' })` to reuse the
|
|
292
|
+
// static DOM instead of replacing it.
|
|
293
|
+
rehydrate: false,
|
|
294
|
+
|
|
295
|
+
// Output directory (default: 'dist')
|
|
296
|
+
outDir: 'dist',
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### SSG vs SSR: when to use which
|
|
301
|
+
|
|
302
|
+
| | SSG (`emberSsg`) | SSR (`emberSsr`) |
|
|
303
|
+
| ------------------ | ---------------------------- | ------------------------------------------------------------------------------- |
|
|
304
|
+
| **Rendering** | Build time | Request time |
|
|
305
|
+
| **Server** | Not required | Node.js server required |
|
|
306
|
+
| **Build command** | `vite build` | `vite build` + `vite build --ssr` |
|
|
307
|
+
| **Deploy** | Any static host | Node.js hosting |
|
|
308
|
+
| **Dynamic routes** | Must enumerate at build time | Any URL handled at runtime |
|
|
309
|
+
| **Data freshness** | Stale until next build | Fresh on every request |
|
|
310
|
+
| **Best for** | Marketing sites, docs, blogs | Apps with frequently changing content, dynamic per-request data, real-time data |
|
|
311
|
+
|
|
312
|
+
### Combined SSR + SSG
|
|
313
|
+
|
|
314
|
+
Use both plugins together to prerender known static routes at build time while keeping dynamic SSR for everything else. The SSG plugin automatically detects `emberSsr` and defers to its output directory — prerendered files land in `dist/client/` alongside client assets.
|
|
315
|
+
|
|
316
|
+
#### How it works
|
|
317
|
+
|
|
318
|
+
During `vite build`, `emberSsg` detects that `emberSsr` is present and:
|
|
319
|
+
|
|
320
|
+
1. Copies `dist/client/index.html` to `dist/client/_template.html` (preserving the SSR markers)
|
|
321
|
+
2. Prerenders each route and writes the resulting HTML files into `dist/client/`
|
|
322
|
+
3. If `'index'` is in your routes list, `index.html` is overwritten with the prerendered index route
|
|
323
|
+
|
|
324
|
+
Your production server reads `_template.html` as the SSR template for dynamic rendering, while prerendered routes are served directly as static files.
|
|
325
|
+
|
|
326
|
+
#### 1. Vite config
|
|
327
|
+
|
|
328
|
+
```js
|
|
329
|
+
// vite.config.mjs
|
|
330
|
+
import { defineConfig } from 'vite';
|
|
331
|
+
import { extensions, ember } from '@embroider/vite';
|
|
332
|
+
import { babel } from '@rollup/plugin-babel';
|
|
333
|
+
import { emberSsr, emberSsg } from 'vite-ember-ssr/vite-plugin';
|
|
334
|
+
|
|
335
|
+
export default defineConfig({
|
|
336
|
+
plugins: [
|
|
337
|
+
ember(),
|
|
338
|
+
babel({ babelHelpers: 'runtime', extensions }),
|
|
339
|
+
emberSsr(),
|
|
340
|
+
emberSsg({
|
|
341
|
+
routes: ['index', 'about', 'contact'],
|
|
342
|
+
}),
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
#### 2. Build
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
vite build # client + SSG prerender → dist/client
|
|
351
|
+
vite build --ssr app/app-ssr.ts # server bundle → dist/server
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Output structure:
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
dist/
|
|
358
|
+
client/
|
|
359
|
+
_template.html ← original index.html with SSR markers (for dynamic SSR)
|
|
360
|
+
index.html ← prerendered
|
|
361
|
+
about/index.html ← prerendered
|
|
362
|
+
contact/index.html ← prerendered
|
|
363
|
+
assets/
|
|
364
|
+
main-abc123.js
|
|
365
|
+
main-abc123.css
|
|
366
|
+
server/
|
|
367
|
+
app-ssr.mjs ← SSR server bundle
|
|
368
|
+
package.json
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
#### 3. Server
|
|
372
|
+
|
|
373
|
+
Your server checks for a prerendered static file first, then falls back to dynamic SSR using `_template.html`. See [examples/fastify-combined.md](https://github.com/evoactivity/vite-ember-ssr/blob/main/examples/fastify-combined.md) for a complete Fastify example.
|
|
374
|
+
|
|
375
|
+
```js
|
|
376
|
+
import { readFile, access } from 'node:fs/promises';
|
|
377
|
+
import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
378
|
+
|
|
379
|
+
// Load _template.html once at startup — it contains the SSR markers
|
|
380
|
+
const ssrTemplate = await readFile('dist/client/_template.html', 'utf-8');
|
|
381
|
+
|
|
382
|
+
// Create the worker pool once at startup
|
|
383
|
+
const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
|
|
384
|
+
|
|
385
|
+
// In your catch-all route handler:
|
|
386
|
+
app.get('*', async (request, reply) => {
|
|
387
|
+
const url = request.url;
|
|
388
|
+
|
|
389
|
+
// 1. Try serving a prerendered file
|
|
390
|
+
const staticPath = resolveStaticFile(clientDir, url);
|
|
391
|
+
try {
|
|
392
|
+
await access(staticPath);
|
|
393
|
+
const html = await readFile(staticPath, 'utf-8');
|
|
394
|
+
return reply.code(200).type('text/html').send(html);
|
|
395
|
+
} catch {
|
|
396
|
+
// No prerendered file — fall through
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 2. Fall back to dynamic SSR
|
|
400
|
+
const rendered = await emberApp.renderRoute(url, {
|
|
401
|
+
shoebox: true, // opt-in: replay fetch responses on the client (see Shoebox section)
|
|
402
|
+
});
|
|
403
|
+
const html = assembleHTML(ssrTemplate, rendered);
|
|
404
|
+
|
|
405
|
+
return reply.code(rendered.statusCode).type('text/html').send(html);
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Prerendered routes are served instantly as static files (no Node.js rendering cost). All other routes are rendered on-demand by the SSR server.
|
|
410
|
+
|
|
411
|
+
### Client Boot Modes
|
|
412
|
+
|
|
413
|
+
There are two ways the client Ember app can take over from the server-rendered HTML. The server and client must agree on the mode.
|
|
414
|
+
|
|
415
|
+
#### Cleanup mode (default)
|
|
416
|
+
|
|
417
|
+
The server wraps rendered content in boundary markers. On boot, `cleanupSSRContent()` removes those markers and the SSR content, then Ember renders fresh into the empty `<body>`. This is the simplest approach — SSR provides a visual shell while JS loads, then Ember replaces it.
|
|
418
|
+
|
|
419
|
+
**Server:** use default options (no `rehydrate` flag)
|
|
420
|
+
|
|
421
|
+
```js
|
|
422
|
+
const rendered = await emberApp.renderRoute(url);
|
|
423
|
+
const html = assembleHTML(template, rendered);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
**Client entry (`app/entry.ts`):**
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
import Application from './app.ts';
|
|
430
|
+
import config from './config/environment.ts';
|
|
431
|
+
|
|
432
|
+
Application.create(config.APP);
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Application template (`app/templates/application.gts`):**
|
|
436
|
+
|
|
437
|
+
```gts
|
|
438
|
+
import { cleanupSSRContent } from 'vite-ember-ssr/client';
|
|
439
|
+
|
|
440
|
+
<template>
|
|
441
|
+
{{cleanupSSRContent}}
|
|
442
|
+
{{outlet}}
|
|
443
|
+
</template>
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Calling `cleanupSSRContent` from the template (rather than from `entry.ts` before boot) ensures the SSR content is removed at the moment Ember renders, avoiding a flash of no content.
|
|
447
|
+
|
|
448
|
+
#### Rehydrate mode
|
|
449
|
+
|
|
450
|
+
The server renders with `_renderMode: 'serialize'`, which annotates the DOM with Glimmer-specific markers. On boot, the client calls `app.visit()` with `_renderMode: 'rehydrate'`, and Glimmer walks the existing DOM and attaches its tracking/update machinery without tearing it down. This avoids the visual flash entirely — the server-rendered DOM becomes the live Ember app.
|
|
451
|
+
|
|
452
|
+
**Server:** pass `rehydrate: true`
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
const rendered = await emberApp.renderRoute(url, { rehydrate: true });
|
|
456
|
+
const html = assembleHTML(template, rendered);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**Client entry (`app/entry.ts`):**
|
|
460
|
+
|
|
461
|
+
The server injects a `window.__vite_ember_ssr_rehydrate__` flag when rehydrate mode is active. Use `shouldRehydrate()` to detect it and choose the correct boot mode — this is especially important for SSG apps where only prerendered routes carry the flag:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import Application from './app.ts';
|
|
465
|
+
import config from './config/environment.ts';
|
|
466
|
+
import { shouldRehydrate } from 'vite-ember-ssr/client';
|
|
467
|
+
|
|
468
|
+
if (shouldRehydrate()) {
|
|
469
|
+
const app = Application.create({ ...config.APP, autoboot: false });
|
|
470
|
+
|
|
471
|
+
const url = (window.location.pathname + window.location.search).replace(
|
|
472
|
+
config.rootURL,
|
|
473
|
+
'/',
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
void app.visit(url, {
|
|
477
|
+
_renderMode: 'rehydrate',
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
Application.create(config.APP);
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
No `cleanupSSRContent` is needed in rehydrate mode — Glimmer reuses the DOM as-is. No boundary markers are emitted by the server.
|
|
486
|
+
|
|
487
|
+
> **Note:** `_renderMode` is a private Ember API (underscore prefix) that has existed since Ember 2.x for FastBoot rehydration. It is stable in practice but not part of the public API.
|
|
488
|
+
|
|
489
|
+
### Shoebox
|
|
490
|
+
|
|
491
|
+
The shoebox captures `fetch` responses made during SSR/SSG and serializes them into `<script>` tags in the rendered HTML. On the client, `installShoebox()` intercepts `fetch` and replays the cached responses — avoiding duplicate API requests on first load.
|
|
492
|
+
|
|
493
|
+
**Shoebox is opt-in** (disabled by default). You only need it when your Ember routes make `fetch` calls during server rendering that the client would otherwise repeat.
|
|
494
|
+
|
|
495
|
+
#### Enabling shoebox
|
|
496
|
+
|
|
497
|
+
**Server side** — pass `shoebox: true` to `renderRoute()`:
|
|
498
|
+
|
|
499
|
+
```js
|
|
500
|
+
const rendered = await emberApp.renderRoute(url, { shoebox: true });
|
|
501
|
+
const html = assembleHTML(template, rendered);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
For SSG, pass `shoebox: true` to `emberSsg()`:
|
|
505
|
+
|
|
506
|
+
```js
|
|
507
|
+
emberSsg({
|
|
508
|
+
routes: ['index', 'about', 'pokemon'],
|
|
509
|
+
shoebox: true,
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Client side** — call `installShoebox()` in `entry.ts` **before** Ember boots:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
import Application from './app.ts';
|
|
517
|
+
import config from './config/environment.ts';
|
|
518
|
+
import { installShoebox } from 'vite-ember-ssr/client';
|
|
519
|
+
|
|
520
|
+
installShoebox();
|
|
521
|
+
Application.create(config.APP);
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
For rehydrate mode:
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
import Application from './app.ts';
|
|
528
|
+
import config from './config/environment.ts';
|
|
529
|
+
import { installShoebox, shouldRehydrate } from 'vite-ember-ssr/client';
|
|
530
|
+
|
|
531
|
+
installShoebox();
|
|
532
|
+
|
|
533
|
+
if (shouldRehydrate()) {
|
|
534
|
+
const app = Application.create({ ...config.APP, autoboot: false });
|
|
535
|
+
|
|
536
|
+
const url = (window.location.pathname + window.location.search).replace(
|
|
537
|
+
config.rootURL,
|
|
538
|
+
'/',
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
void app.visit(url, {
|
|
542
|
+
_renderMode: 'rehydrate',
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
Application.create(config.APP);
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
#### How it works
|
|
551
|
+
|
|
552
|
+
1. During SSR/SSG, the server intercepts all `fetch()` calls and records the responses.
|
|
553
|
+
2. The responses are serialized as `<script type="application/json" class="shoebox">` tags in the HTML.
|
|
554
|
+
3. On the client, `installShoebox()` reads those `<script>` tags, wraps `window.fetch`, and serves cached responses for matching URLs.
|
|
555
|
+
4. Once all cached entries have been consumed, the original `fetch` is automatically restored.
|
|
556
|
+
|
|
557
|
+
#### When to use it
|
|
558
|
+
|
|
559
|
+
- Routes that fetch data in `model()` hooks (e.g., API calls to load a page).
|
|
560
|
+
- Any SSR/SSG scenario where the client would re-fetch the same data immediately on boot.
|
|
561
|
+
|
|
562
|
+
#### When to skip it
|
|
563
|
+
|
|
564
|
+
- Static pages with no server-side data fetching.
|
|
565
|
+
- Apps where the client intentionally re-fetches for freshness.
|
|
566
|
+
|
|
567
|
+
#### Caveats
|
|
568
|
+
|
|
569
|
+
- Embedding large API responses increases HTML payload size.
|
|
570
|
+
- Never serialize sensitive or user-specific data into the shoebox — the HTML is cached/served to all users.
|
|
571
|
+
|
|
572
|
+
### Lazy Routes (`@embroider/router`)
|
|
573
|
+
|
|
574
|
+
Both SSR and SSG modes support `@embroider/router`'s lazy-loaded route bundles (`window._embroiderRouteBundles_`). No additional configuration is needed — the library detects and handles lazy bundles automatically.
|
|
575
|
+
|
|
576
|
+
#### Requirements
|
|
577
|
+
|
|
578
|
+
1. Your app uses `@embroider/router` with route splitting enabled.
|
|
579
|
+
2. The `@embroider/core` babel plugin must have `active: true` in your babel config:
|
|
580
|
+
|
|
581
|
+
```js
|
|
582
|
+
// babel.config.cjs
|
|
583
|
+
module.exports = {
|
|
584
|
+
plugins: [
|
|
585
|
+
['@embroider/core/babel-plugin', { active: true }],
|
|
586
|
+
// ...
|
|
587
|
+
],
|
|
588
|
+
};
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
3. Pass the absolute path to the built SSR bundle when calling `createEmberApp()`:
|
|
592
|
+
|
|
593
|
+
```js
|
|
594
|
+
const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
#### How it works
|
|
598
|
+
|
|
599
|
+
When `@embroider/router` is active, it registers route bundles on `window._embroiderRouteBundles_` at module load time. The library captures these bundles after the first render and re-applies them to subsequent HappyDOM windows, ensuring lazy routes resolve correctly across multiple SSR/SSG renders.
|
|
600
|
+
|
|
601
|
+
## API
|
|
602
|
+
|
|
603
|
+
### `vite-ember-ssr/vite-plugin`
|
|
604
|
+
|
|
605
|
+
```js
|
|
606
|
+
import { emberSsr, emberSsg } from 'vite-ember-ssr/vite-plugin';
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
#### `emberSsr(options?)`
|
|
610
|
+
|
|
611
|
+
Vite plugin for runtime SSR. Handles all SSR-related configuration:
|
|
612
|
+
|
|
613
|
+
- `ssr.noExternal` for Ember ecosystem packages
|
|
614
|
+
- Build output directories (`dist/client` and `dist/server`)
|
|
615
|
+
- SSR build defaults (`target: 'node22'`, `sourcemap: true`, `minify: false`)
|
|
616
|
+
- Writes `{"type": "module"}` to SSR output directory
|
|
617
|
+
|
|
618
|
+
Options:
|
|
619
|
+
|
|
620
|
+
```js
|
|
621
|
+
emberSsr({
|
|
622
|
+
clientOutDir: 'dist/client', // default
|
|
623
|
+
serverOutDir: 'dist/server', // default
|
|
624
|
+
});
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
#### `emberSsg(options)`
|
|
628
|
+
|
|
629
|
+
Vite plugin for static site generation. Prerenders specified routes to HTML files at build time with a single `vite build`. See the [SSG setup section](#ssg-static-site-generation) for usage.
|
|
630
|
+
|
|
631
|
+
Options:
|
|
632
|
+
|
|
633
|
+
```js
|
|
634
|
+
emberSsg({
|
|
635
|
+
routes: ['index', 'about'], // required: routes to prerender
|
|
636
|
+
ssrEntry: 'app/app-ssr.ts', // default: SSR entry module path
|
|
637
|
+
shoebox: false, // default: serialize fetch responses into HTML
|
|
638
|
+
rehydrate: false, // default: use Glimmer rehydration instead of cleanup mode
|
|
639
|
+
outDir: 'dist', // default: output directory (ignored when combined with emberSsr)
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
#### Third-party packages with CSS imports (`ssr.noExternal`)
|
|
644
|
+
|
|
645
|
+
Both plugins automatically configure `ssr.noExternal` for Ember ecosystem packages (`@ember/*`, `@glimmer/*`, `@embroider/*`, `@warp-drive/*`, `ember-*`, `decorator-transforms`). This tells Vite to bundle these packages into the SSR output, where their CSS imports are safely no-opped.
|
|
646
|
+
|
|
647
|
+
If your app uses third-party packages that contain bare CSS imports (e.g. `import './styles.css'`), you need to add them to `ssr.noExternal` in your Vite config. Without this, Node.js will try to load the `.css` files directly at runtime and fail with `ERR_UNKNOWN_FILE_EXTENSION`.
|
|
648
|
+
|
|
649
|
+
```js
|
|
650
|
+
// vite.config.mjs
|
|
651
|
+
export default defineConfig({
|
|
652
|
+
plugins: [
|
|
653
|
+
emberSsr(), // or emberSsg({ routes: [...] })
|
|
654
|
+
],
|
|
655
|
+
ssr: {
|
|
656
|
+
// Vite deep-merges this with the Ember ecosystem patterns
|
|
657
|
+
// that the plugin provides automatically.
|
|
658
|
+
noExternal: ['nvp.ui', 'some-other-package-with-css'],
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
This applies to both SSR and SSG modes. Vite's `config` hook deep-merges arrays, so your entries are concatenated with the built-in patterns — you don't need to repeat them.
|
|
664
|
+
|
|
665
|
+
### `vite-ember-ssr/server`
|
|
666
|
+
|
|
667
|
+
```js
|
|
668
|
+
import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
- **`createEmberApp(ssrBundlePath, options?)`** — creates a long-lived tinypool worker pool. Each worker imports the SSR bundle once at startup and handles all subsequent render requests without re-importing. Returns an `EmberApp` object. Options: `{ workers?: number }` (default: `os.cpus().length`).
|
|
672
|
+
|
|
673
|
+
- **`app.renderRoute(url, options?)`** — renders a URL path and returns `{ head, body, statusCode, error }`. Options: `{ shoebox?, rehydrate?, cssManifest? }`.
|
|
674
|
+
|
|
675
|
+
- **`app.destroy()`** — shuts down the worker pool. Call when the server is stopping.
|
|
676
|
+
|
|
677
|
+
- **`assembleHTML(template, renderResult)`** — inserts rendered `head` and `body` fragments into the HTML template by replacing the `<!-- VITE_EMBER_SSR_HEAD -->` and `<!-- VITE_EMBER_SSR_BODY -->` markers.
|
|
678
|
+
|
|
679
|
+
- **`loadCssManifest(clientDir)`** — loads the CSS manifest from the client build output directory. Returns `undefined` if not present. Used with lazy routes.
|
|
680
|
+
|
|
681
|
+
- **`hasSSRMarkers(html)`** — checks whether an HTML string contains the SSR markers. Returns `{ head: boolean, body: boolean }`.
|
|
682
|
+
|
|
683
|
+
### `vite-ember-ssr/client`
|
|
684
|
+
|
|
685
|
+
```js
|
|
686
|
+
import {
|
|
687
|
+
installShoebox,
|
|
688
|
+
cleanupSSRContent,
|
|
689
|
+
cleanupShoebox,
|
|
690
|
+
isSSRRendered,
|
|
691
|
+
shouldRehydrate,
|
|
692
|
+
} from 'vite-ember-ssr/client';
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
- **`installShoebox()`** — replay server-captured fetch responses, auto-restores `fetch` when all entries consumed. Call in `entry.ts` before Ember boots.
|
|
696
|
+
- **`cleanupSSRContent()`** — remove SSR-rendered DOM nodes. Call from the application template as `{{cleanupSSRContent}}` so removal happens at render time, avoiding a flash of no content. Only used in cleanup mode (not rehydrate mode).
|
|
697
|
+
- **`cleanupShoebox()`** — manually restore original `fetch`.
|
|
698
|
+
- **`isSSRRendered()`** — returns `true` if SSR boundary markers are present in the DOM. Useful for conditionally running client-side setup that should only happen on SSR-rendered pages.
|
|
699
|
+
- **`shouldRehydrate()`** — returns `true` if the current page was rendered with rehydrate mode (the server injected `window.__vite_ember_ssr_rehydrate__`). Use this in `entry.ts` to decide whether to boot Ember in rehydrate mode or with a normal boot. Essential for SSG apps where only prerendered routes should rehydrate.
|
|
700
|
+
|
|
701
|
+
## Monorepo development
|
|
702
|
+
|
|
703
|
+
This repo contains seven packages:
|
|
704
|
+
|
|
705
|
+
| Package | Description |
|
|
706
|
+
| ---------------------------- | ---------------------------------- |
|
|
707
|
+
| `packages/vite-ember-ssr` | Core library + test suites |
|
|
708
|
+
| `packages/test-app` | Ember test app (SSR) |
|
|
709
|
+
| `packages/test-app-ssg` | Ember test app (SSG) |
|
|
710
|
+
| `packages/test-app-combined` | Ember test app (SSR + SSG) |
|
|
711
|
+
| `packages/test-app-lazy-ssr` | Ember test app (SSR + lazy routes) |
|
|
712
|
+
| `packages/test-app-lazy-ssg` | Ember test app (SSG + lazy routes) |
|
|
713
|
+
| `packages/test-server` | Fastify SSR server |
|
|
714
|
+
|
|
715
|
+
```sh
|
|
716
|
+
pnpm install
|
|
717
|
+
pnpm dev # dev server (Fastify + Vite middleware)
|
|
718
|
+
pnpm build # build library + test app
|
|
719
|
+
pnpm demo # build everything, start production server
|
|
720
|
+
pnpm test # vitest SSR tests
|
|
721
|
+
pnpm test:browser # playwright browser tests
|
|
722
|
+
pnpm test:all # both
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
## Performance
|
|
726
|
+
|
|
727
|
+
- Server startup: ~1s (no ember-cli build step)
|
|
728
|
+
- First SSR render: ~3s (cold module loading)
|
|
729
|
+
- Warm SSR render: ~24ms
|
|
730
|
+
|
|
731
|
+
## License
|
|
732
|
+
|
|
733
|
+
MIT
|