@st-h/vite-ember-ssr 0.2.0-alpha.1 → 0.3.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/README.md CHANGED
@@ -1,67 +1,66 @@
1
1
  # vite-ember-ssr
2
2
 
3
+ Vite plugin and runtime for server side rendering (SSR) and static site generation (SSG) of Ember.js applications. Renders Ember in Node.js using [HappyDOM](https://github.com/capricorn86/happy-dom), with no FastBoot and no VM sandbox.
4
+
3
5
  > [!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.
6
+ > **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
7
 
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.
8
+ ## Requirements
7
9
 
8
- Two modes are supported:
10
+ - An Ember app built with Embroider in compatless mode (no `@embroider/compat`, no `ember-cli-build.js`, no `classicEmberSupport()`).
11
+ - Vite 6+
12
+ - Node 22+
9
13
 
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.
14
+ ## Choose a mode
12
15
 
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.
16
+ This library exposes two Vite plugins. They can be used independently or together.
14
17
 
15
- ## Architecture
18
+ - **`emberSsg`** prerenders a known list of routes to static HTML at build time. A single `vite build` produces deploy ready files. No server is required.
19
+ - **`emberSsr`** renders pages on every request from a Node.js server.
20
+ - **Combined** uses both plugins. Known routes are prerendered, everything else falls back to dynamic SSR.
16
21
 
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).
22
+ | | SSG (`emberSsg`) | SSR (`emberSsr`) |
23
+ | -------------- | ---------------------------- | ------------------------------------------------- |
24
+ | Rendering | Build time | Request time |
25
+ | Server | Not required | Node.js server required |
26
+ | Build command | `vite build` | `vite build` + `vite build --ssr` |
27
+ | Deploy | Any static host | Node.js hosting |
28
+ | Dynamic routes | Must enumerate at build time | Any URL handled at runtime |
29
+ | Data freshness | Stale until next build | Fresh on every request |
30
+ | Best for | Marketing sites, docs, blogs | Apps with frequently changing or per request data |
21
31
 
22
- ## Requirements
32
+ If you are unsure, start with SSG. It has the fewest moving parts and the Quick Start below uses it.
23
33
 
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
34
+ ## Install
30
35
 
31
36
  ```sh
32
37
  pnpm add -D vite-ember-ssr
33
38
  ```
34
39
 
35
- ## Setup
36
-
37
- ### SSR (Server-Side Rendering)
40
+ ## Quick start (SSG)
38
41
 
39
- Use `emberSsr` when you have a Node.js server that renders pages on each request.
42
+ This is the smallest end to end setup. It prerenders `index` and `about` to static HTML and ships them with the client bundle.
40
43
 
41
- #### 1. Vite config
44
+ ### `vite.config.mjs`
42
45
 
43
46
  ```js
44
- // vite.config.mjs
45
47
  import { defineConfig } from 'vite';
46
48
  import { extensions, ember } from '@embroider/vite';
47
49
  import { babel } from '@rollup/plugin-babel';
48
- import { emberSsr } from 'vite-ember-ssr/vite-plugin';
50
+ import { emberSsg } from 'vite-ember-ssr/vite-plugin';
49
51
 
50
52
  export default defineConfig({
51
53
  plugins: [
52
54
  ember(),
53
- babel({
54
- babelHelpers: 'runtime',
55
- extensions,
55
+ babel({ babelHelpers: 'runtime', extensions }),
56
+ emberSsg({
57
+ routes: ['index', 'about'],
56
58
  }),
57
- emberSsr(),
58
59
  ],
59
60
  });
60
61
  ```
61
62
 
62
- #### 2. HTML template
63
-
64
- Add SSR markers to `index.html`:
63
+ ### `index.html`
65
64
 
66
65
  ```html
67
66
  <!DOCTYPE html>
@@ -77,15 +76,18 @@ Add SSR markers to `index.html`:
77
76
  </html>
78
77
  ```
79
78
 
80
- #### 3. SSR entry (`app/app-ssr.ts`)
81
-
82
- Export a factory that creates the Ember app with `autoboot: false`:
79
+ ### `app/app-ssr.ts`
83
80
 
84
81
  ```ts
85
82
  import EmberApp from 'ember-strict-application-resolver';
86
83
  import config from './config/environment.ts';
87
84
  import Router from './router.ts';
88
85
 
86
+ // Re-exporting `settled` lets the renderer await Ember's run loop, pending
87
+ // timers, and any registered test-waiters before capturing the DOM. See
88
+ // the Concepts section below for details.
89
+ export { settled } from '@ember/test-helpers';
90
+
89
91
  class App extends EmberApp {
90
92
  modules = {
91
93
  './router': Router,
@@ -99,30 +101,22 @@ export function createSsrApp() {
99
101
  }
100
102
  ```
101
103
 
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`)
104
+ ### `app/entry.ts`
105
105
 
106
106
  ```ts
107
107
  import Application from './app.ts';
108
108
  import config from './config/environment.ts';
109
+ import { bootRehydrated } from 'vite-ember-ssr/client';
109
110
 
110
- Application.create(config.APP);
111
+ bootRehydrated(Application, config);
111
112
  ```
112
113
 
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:
114
+ ### `app/templates/application.gts`
116
115
 
117
116
  ```gts
118
- import { pageTitle } from 'ember-page-title';
119
117
  import { LinkTo } from '@ember/routing';
120
- import { cleanupSSRContent } from 'vite-ember-ssr/client';
121
118
 
122
119
  <template>
123
- {{pageTitle "MyApp"}}
124
- {{cleanupSSRContent}}
125
-
126
120
  <nav>
127
121
  <LinkTo @route="index">Home</LinkTo>
128
122
  <LinkTo @route="about">About</LinkTo>
@@ -132,201 +126,185 @@ import { cleanupSSRContent } from 'vite-ember-ssr/client';
132
126
  </template>
133
127
  ```
134
128
 
135
- #### 6. Build
129
+ ### Build
136
130
 
137
131
  ```sh
138
- vite build # client → dist/client
139
- vite build --ssr app/app-ssr.ts # server → dist/server
132
+ vite build
133
+ ```
134
+
135
+ Output:
136
+
137
+ ```
138
+ dist/
139
+ index.html prerendered index route
140
+ about/index.html prerendered about route
141
+ assets/
142
+ main-abc123.js
143
+ main-abc123.css
140
144
  ```
141
145
 
142
- #### 7. Server
146
+ Serve `dist/` from any static host. That is the whole pipeline.
143
147
 
144
- Create the worker pool once at startup with `createEmberApp`, then call `renderRoute` in your catch-all handler:
148
+ ## Concepts
145
149
 
146
- ```js
147
- import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
150
+ The Quick Start uses four building blocks. Every mode in this library uses the same four. SSR and Combined sections below only show what differs from the Quick Start.
148
151
 
149
- // Production: creates a tinypool worker pool, imports the SSR bundle once per worker
150
- const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
152
+ ### How rendering works
151
153
 
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
- // });
154
+ - A per request HappyDOM `Window` provides a browser like environment in Node. Ember globals are swapped in for the duration of one render.
155
+ - `Application.visit(url, { _renderMode: 'serialize' })` drives the render cycle on the server, the same way FastBoot does. `_renderMode: 'serialize'` annotates the DOM with Glimmer rehydration markers.
156
+ - After Ember settles, the resulting `<head>` and `<body>` content are extracted and inserted into your HTML template at the SSR markers.
157
+ - On the client, `bootRehydrated` calls `app.visit(url, { _renderMode: 'rehydrate' })` and Glimmer attaches to the existing DOM in place. See [Client boot](#client-boot).
157
158
 
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
159
+ ### SSR markers in `index.html`
162
160
 
163
- // On server shutdown:
164
- await emberApp.destroy();
161
+ Two HTML comments tell the renderer where to inject content:
162
+
163
+ ```html
164
+ <!-- VITE_EMBER_SSR_HEAD -->
165
+ <!-- VITE_EMBER_SSR_BODY -->
166
+ ```
167
+
168
+ The first is replaced with anything Ember rendered into `<head>` (page title, meta tags, etc.). The second is replaced with the rendered application body.
169
+
170
+ ### SSR entry (`app/app-ssr.ts`)
171
+
172
+ This module exports a factory the renderer calls once per worker. The factory must:
173
+
174
+ - Create an `EmberApp` subclass that registers your routes, templates, and services via `import.meta.glob({ eager: true })`.
175
+ - Pass `autoboot: false` so the app does not try to boot itself when the module loads.
176
+
177
+ The exported function (named `createSsrApp` by convention) is wrapped in an async function inside the renderer that imports the SSR bundle on demand.
178
+
179
+ #### Settling
180
+
181
+ If your `app-ssr.ts` re-exports `settled` from `@ember/test-helpers`, the renderer awaits it after every `app.visit()`:
182
+
183
+ ```ts
184
+ export { settled } from '@ember/test-helpers';
165
185
  ```
166
186
 
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.
187
+ `settled()` waits for Ember's run loop, pending Backburner timers, in-flight AJAX, route transitions, and any registered `@ember/test-waiters` (used by WarpDrive, ember-concurrency, etc.) to drain before the DOM is captured.
188
+
189
+ Without this export, the renderer falls back to a single microtask drain (`await new Promise(r => setTimeout(r, 0))`), which catches synchronous reactivity but misses async work scheduled outside the microtask queue.
190
+
191
+ The renderer races `settled()` against a configurable timeout (default 10s). If exceeded, a warning is logged and the DOM is captured anyway. See [`app.renderRoute`](#vite-ember-ssrserver) for `settledTimeout`.
192
+
193
+ ### Client entry
194
+
195
+ The client entry calls `bootRehydrated(Application, config)`. This helper looks at `window.__vite_ember_ssr_rehydrate__` (set by the server) and either:
196
+
197
+ - Boots with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })` so Glimmer attaches to the server rendered DOM, or
198
+ - Falls back to `Application.create(config.APP)` for pages that were not server rendered.
168
199
 
169
- ### SSG (Static Site Generation)
200
+ See [Client boot](#client-boot) for the full helper behaviour.
170
201
 
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.).
202
+ ## SSR mode
172
203
 
173
- A single `vite build` command:
204
+ Use SSR when you need fresh data on every request, or when routes cannot be enumerated at build time.
174
205
 
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
206
+ ### Vite config
180
207
 
181
- #### 1. Vite config
208
+ Replace `emberSsg` with `emberSsr`:
182
209
 
183
210
  ```js
184
- // vite.config.mjs
185
211
  import { defineConfig } from 'vite';
186
212
  import { extensions, ember } from '@embroider/vite';
187
213
  import { babel } from '@rollup/plugin-babel';
188
- import { emberSsg } from 'vite-ember-ssr/vite-plugin';
214
+ import { emberSsr } from 'vite-ember-ssr/vite-plugin';
189
215
 
190
216
  export default defineConfig({
191
217
  plugins: [
192
218
  ember(),
193
219
  babel({ babelHelpers: 'runtime', extensions }),
194
- emberSsg({
195
- routes: ['index', 'about', 'contact', 'pokemon/charmander'],
196
- }),
220
+ emberSsr(),
197
221
  ],
198
222
  });
199
223
  ```
200
224
 
201
- #### 2. HTML template
225
+ ### Build
202
226
 
203
- Same as SSR add markers to `index.html`:
227
+ SSR needs both a client build and a server build:
204
228
 
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>
229
+ ```sh
230
+ vite build # client → dist/client
231
+ vite build --ssr app/app-ssr.ts # server → dist/server
217
232
  ```
218
233
 
219
- #### 3. SSR entry (`app/app-ssr.ts`)
234
+ ### Server integration
220
235
 
221
- Same as SSR export a `createSsrApp` factory function. See the SSR section above.
236
+ Create the worker pool once at startup. Call `renderRoute` from your catch all handler.
222
237
 
223
- #### 4. Client entry and application template
224
-
225
- Same as SSR — see [Client Boot Modes](#client-boot-modes) below for the two options:
238
+ ```js
239
+ import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
226
240
 
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.
241
+ // Production: tinypool worker pool, SSR bundle loaded once per worker
242
+ const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
229
243
 
230
- If your routes make `fetch` calls during SSG that the client would repeat, see [Shoebox](#shoebox) to avoid duplicate requests.
244
+ // Development: in process render via Vite's ssrLoadModule
245
+ // const vite = await createServer({ ... });
246
+ // const emberApp = await createEmberApp('app/app-ssr.ts', {
247
+ // dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },
248
+ // });
231
249
 
232
- #### 5. Build
250
+ const rendered = await emberApp.renderRoute(request.url);
251
+ const html = assembleHTML(template, rendered);
252
+ // rendered.statusCode and rendered.error are also available
233
253
 
234
- ```sh
235
- vite build
254
+ // Shutdown:
255
+ await emberApp.destroy();
236
256
  ```
237
257
 
238
- That's it. The output directory (default: `dist/`) contains deploy-ready static files:
258
+ See [examples/fastify.md](https://github.com/evoactivity/vite-ember-ssr/blob/main/examples/fastify.md) for a complete Fastify server with both dev and prod modes.
239
259
 
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
- ```
260
+ ## SSG mode
251
261
 
252
- #### 6. Deploy
262
+ Quick Start covers the basic case. This section documents everything else.
253
263
 
254
- Serve the `dist/` directory with any static file server. No Node.js runtime needed.
264
+ ### Route format
255
265
 
256
- ```sh
257
- # Local preview
258
- npx http-server dist
266
+ `routes` entries are URL paths without a leading slash. The renderer visits each URL and writes the result to disk.
259
267
 
260
- # Or deploy to any static host
261
- ```
268
+ - `'index'` is special cased and produces `dist/index.html`.
269
+ - Anything else produces `<path>/index.html`, so `'about'` becomes `dist/about/index.html` and `'pokemon/charmander'` becomes `dist/pokemon/charmander/index.html`.
262
270
 
263
- #### Route format
271
+ ### What `vite build` does
264
272
 
265
- Routes are specified as Ember route names (without leading slashes):
273
+ A single `vite build`:
266
274
 
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`
275
+ 1. Builds the client assets (JS, CSS, HTML shell).
276
+ 2. Runs a second SSR build internally to produce a temporary server bundle.
277
+ 3. Renders each route in `routes` using HappyDOM.
278
+ 4. Writes the resulting HTML files into the output directory.
279
+ 5. Cleans up the temporary bundle.
271
280
 
272
- #### Options
281
+ ### Options
273
282
 
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
- ```
283
+ See the [API reference](#emberssgoptions) for the full options table.
299
284
 
300
- #### SSG vs SSR: when to use which
285
+ ### Deploy
301
286
 
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 |
287
+ Serve `dist/` from any static host. No Node.js runtime required.
311
288
 
312
- ### Combined SSR + SSG
289
+ ```sh
290
+ npx http-server dist
291
+ ```
313
292
 
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.
293
+ ## Combined SSR + SSG
315
294
 
316
- #### How it works
295
+ Use both plugins together to prerender known static routes while keeping dynamic SSR for everything else. Prerendered routes are served as static files. Other routes render on demand.
317
296
 
318
- During `vite build`, `emberSsg` detects that `emberSsr` is present and:
297
+ When `emberSsg` detects that `emberSsr` is also installed, it changes its output strategy:
319
298
 
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
299
+ 1. Copies `dist/client/index.html` to `dist/client/_template.html` (preserving the SSR markers).
300
+ 2. Prerenders each route into `dist/client/`.
301
+ 3. If `'index'` is in your routes list, `index.html` is overwritten with the prerendered index route.
323
302
 
324
- Your production server reads `_template.html` as the SSR template for dynamic rendering, while prerendered routes are served directly as static files.
303
+ The server reads `_template.html` as the SSR template for dynamic rendering.
325
304
 
326
- #### 1. Vite config
305
+ ### Vite config
327
306
 
328
307
  ```js
329
- // vite.config.mjs
330
308
  import { defineConfig } from 'vite';
331
309
  import { extensions, ember } from '@embroider/vite';
332
310
  import { babel } from '@rollup/plugin-babel';
@@ -344,7 +322,7 @@ export default defineConfig({
344
322
  });
345
323
  ```
346
324
 
347
- #### 2. Build
325
+ ### Build
348
326
 
349
327
  ```sh
350
328
  vite build # client + SSG prerender → dist/client
@@ -356,161 +334,94 @@ Output structure:
356
334
  ```
357
335
  dist/
358
336
  client/
359
- _template.htmloriginal index.html with SSR markers (for dynamic SSR)
360
- index.htmlprerendered
361
- about/index.htmlprerendered
362
- contact/index.htmlprerendered
337
+ _template.html original index.html with SSR markers (used for dynamic SSR)
338
+ index.html prerendered
339
+ about/index.html prerendered
340
+ contact/index.html prerendered
363
341
  assets/
364
342
  main-abc123.js
365
343
  main-abc123.css
366
344
  server/
367
- app-ssr.mjsSSR server bundle
345
+ app-ssr.mjs SSR server bundle
368
346
  package.json
369
347
  ```
370
348
 
371
- #### 3. Server
349
+ ### Server integration
372
350
 
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.
351
+ The server checks for a prerendered file first, then falls back to dynamic SSR using `_template.html`.
374
352
 
375
353
  ```js
376
354
  import { readFile, access } from 'node:fs/promises';
377
355
  import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
378
356
 
379
- // Load _template.html once at startup — it contains the SSR markers
380
357
  const ssrTemplate = await readFile('dist/client/_template.html', 'utf-8');
381
-
382
- // Create the worker pool once at startup
383
358
  const emberApp = await createEmberApp('./dist/server/app-ssr.mjs');
384
359
 
385
- // In your catch-all route handler:
386
360
  app.get('*', async (request, reply) => {
387
361
  const url = request.url;
388
362
 
389
- // 1. Try serving a prerendered file
363
+ // 1. Try a prerendered file
390
364
  const staticPath = resolveStaticFile(clientDir, url);
391
365
  try {
392
366
  await access(staticPath);
393
367
  const html = await readFile(staticPath, 'utf-8');
394
368
  return reply.code(200).type('text/html').send(html);
395
369
  } catch {
396
- // No prerendered file fall through
370
+ // No prerendered file, fall through
397
371
  }
398
372
 
399
373
  // 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
- });
374
+ const rendered = await emberApp.renderRoute(url, { shoebox: true });
403
375
  const html = assembleHTML(ssrTemplate, rendered);
404
376
 
405
377
  return reply.code(rendered.statusCode).type('text/html').send(html);
406
378
  });
407
379
  ```
408
380
 
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.
381
+ See [examples/fastify-combined.md](https://github.com/evoactivity/vite-ember-ssr/blob/main/examples/fastify-combined.md) for a complete example.
414
382
 
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
- ```
383
+ ## Client boot
425
384
 
426
- **Client entry (`app/entry.ts`):**
385
+ The library always renders pages with Glimmer rehydration markers. On the client, `bootRehydrated` calls `app.visit(url, { _renderMode: 'rehydrate' })` and Glimmer attaches to the server rendered DOM in place. There is no flash, no DOM tear down, and no `cleanupSSRContent` step.
427
386
 
428
387
  ```ts
429
388
  import Application from './app.ts';
430
389
  import config from './config/environment.ts';
390
+ import { bootRehydrated } from 'vite-ember-ssr/client';
431
391
 
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);
392
+ bootRehydrated(Application, config);
457
393
  ```
458
394
 
459
- **Client entry (`app/entry.ts`):**
395
+ The server injects a `window.__vite_ember_ssr_rehydrate__` flag on every server rendered page. `bootRehydrated` checks for it and:
460
396
 
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:
397
+ - If present, creates the application with `autoboot: false` and calls `app.visit(url, { _renderMode: 'rehydrate' })`. The visit URL is derived from `window.location.pathname + search` with `config.rootURL` stripped.
398
+ - If absent, calls `Application.create(config.APP)` for a normal boot. This matters for SSG apps where the user navigates to a route that was never prerendered, or for dev pages hit without an SSR middleware.
462
399
 
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.
400
+ If you need to branch on rehydrate vs. plain boot yourself, `shouldRehydrate()` is exported and returns the same boolean.
486
401
 
487
402
  > **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
403
 
489
- ### Shoebox
404
+ ## Advanced topics
490
405
 
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.
406
+ ### Shoebox
492
407
 
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.
408
+ The shoebox captures `fetch` responses made during SSR or 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.
494
409
 
495
- #### Enabling shoebox
410
+ Shoebox is opt in. Enable it only when your routes make `fetch` calls during server rendering that the client would otherwise repeat.
496
411
 
497
- **Server side** — pass `shoebox: true` to `renderRoute()`:
412
+ Server (SSR):
498
413
 
499
414
  ```js
500
415
  const rendered = await emberApp.renderRoute(url, { shoebox: true });
501
- const html = assembleHTML(template, rendered);
502
416
  ```
503
417
 
504
- For SSG, pass `shoebox: true` to `emberSsg()`:
418
+ Server (SSG):
505
419
 
506
420
  ```js
507
- emberSsg({
508
- routes: ['index', 'about', 'pokemon'],
509
- shoebox: true,
510
- });
421
+ emberSsg({ routes: ['index', 'about'], shoebox: true });
511
422
  ```
512
423
 
513
- **Client side** — call `installShoebox()` in `entry.ts` **before** Ember boots:
424
+ Client, before `Application.create`:
514
425
 
515
426
  ```ts
516
427
  import Application from './app.ts';
@@ -521,84 +432,43 @@ installShoebox();
521
432
  Application.create(config.APP);
522
433
  ```
523
434
 
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
435
  #### How it works
551
436
 
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.
437
+ 1. During SSR or SSG, the server intercepts `fetch()` calls and records the responses.
438
+ 2. Responses are serialized as `<script type="application/json" class="shoebox">` tags.
439
+ 3. On the client, `installShoebox()` reads those tags, wraps `window.fetch`, and serves cached responses for matching URLs.
440
+ 4. Once all cached entries are consumed, the original `fetch` is restored automatically.
556
441
 
557
442
  #### When to use it
558
443
 
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.
444
+ - Routes that fetch data in `model()` hooks.
445
+ - Any case where the client would re fetch the same data immediately on boot.
561
446
 
562
447
  #### When to skip it
563
448
 
564
- - Static pages with no server-side data fetching.
565
- - Apps where the client intentionally re-fetches for freshness.
449
+ - Static pages with no server side data fetching.
450
+ - Apps that intentionally re fetch for freshness.
566
451
 
567
452
  #### Caveats
568
453
 
569
454
  - 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.
455
+ - Never serialize sensitive or user specific data into the shoebox. The HTML is cached and served to all users.
571
456
 
572
- ### Lazy Routes (`@embroider/router`)
457
+ ### Lazy routes (`@embroider/router`)
573
458
 
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.
459
+ Both SSR and SSG support `@embroider/router`'s lazy loaded route bundles (`window._embroiderRouteBundles_`). No additional configuration is required.
575
460
 
576
- #### Requirements
461
+ ### SSR bundling (`ssr.noExternal`)
577
462
 
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:
463
+ Both plugins set `ssr.noExternal: [/./]`, which tells Vite to bundle every dependency into the SSR build instead of leaving them as runtime `require`/`import` calls.
580
464
 
581
- ```js
582
- // babel.config.cjs
583
- module.exports = {
584
- plugins: [
585
- ['@embroider/core/babel-plugin', { active: true }],
586
- // ...
587
- ],
588
- };
589
- ```
465
+ This is necessary because Ember's virtual packages (`@glimmer/tracking`, `@ember/*`, etc.) are provided by `ember-source` and are not real packages on disk. If Vite externalises a dependency that imports one of them, Node's runtime resolution fails under pnpm's strict `node_modules` layout. Bundling everything also keeps CJS/UMD packages going through Vite's transform pipeline, where the plugin's CJS shim can wrap them.
590
466
 
591
- 3. Pass the absolute path to the built SSR bundle when calling `createEmberApp()`:
467
+ There is no real downside to bundling on the server. SSR builds are not shipped to browsers, so bundle size is not a constraint, and a single self contained SSR bundle simplifies deployment.
592
468
 
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.
469
+ You should not need to touch this. If you do need to add to it, Vite deep merges arrays so your entries are concatenated with the built in pattern.
600
470
 
601
- ## API
471
+ ## API reference
602
472
 
603
473
  ### `vite-ember-ssr/vite-plugin`
604
474
 
@@ -608,118 +478,89 @@ import { emberSsr, emberSsg } from 'vite-ember-ssr/vite-plugin';
608
478
 
609
479
  #### `emberSsr(options?)`
610
480
 
611
- Vite plugin for runtime SSR. Handles all SSR-related configuration:
481
+ Vite plugin for runtime SSR. Configures `ssr.noExternal`, build output directories (`dist/client`, `dist/server`), SSR build defaults (`target: 'node22'`, `sourcemap: true`, `minify: false`), and writes `{"type": "module"}` to the SSR output directory.
612
482
 
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
- ```
483
+ | Option | Type | Default | Description |
484
+ | -------------- | -------- | --------------- | ----------------------- |
485
+ | `clientOutDir` | `string` | `'dist/client'` | Client build output dir |
486
+ | `serverOutDir` | `string` | `'dist/server'` | SSR bundle output dir |
626
487
 
627
488
  #### `emberSsg(options)`
628
489
 
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.
490
+ Vite plugin for static site generation.
630
491
 
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.
492
+ | Option | Type | Default | Description |
493
+ | ---------- | ---------- | ------------------ | --------------------------------------------------------------------------------------- |
494
+ | `routes` | `string[]` | (required) | URL paths to prerender. `'index'` is special cased, see [Route format](#route-format) |
495
+ | `ssrEntry` | `string` | `'app/app-ssr.ts'` | Path to the SSR entry module |
496
+ | `shoebox` | `boolean` | `false` | Serialize captured fetch responses into the HTML, see [Shoebox](#shoebox) |
497
+ | `outDir` | `string` | `'dist'` | Output directory. Ignored when combined with `emberSsr` (output goes to `clientOutDir`) |
664
498
 
665
499
  ### `vite-ember-ssr/server`
666
500
 
667
501
  ```js
668
- import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
502
+ import {
503
+ createEmberApp,
504
+ assembleHTML,
505
+ loadCssManifest,
506
+ hasSSRMarkers,
507
+ } from 'vite-ember-ssr/server';
669
508
  ```
670
509
 
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 }`.
510
+ - **`createEmberApp(ssrBundlePath, options?)`** creates a long lived tinypool worker pool. Each worker imports the SSR bundle once at startup. Returns an `EmberApp`. Options: `{ workers?: number, recycleWorkerInterval?: number, isolateWorkers?: boolean, dev?: { ssrLoadModule } }`.
511
+ - **`app.renderRoute(url, options?)`** renders a URL path. Returns `{ head, body, statusCode, error }`. Options: `{ shoebox?, cssManifest?, settledTimeout? }`. `settledTimeout` (default `10000`) bounds how long the renderer waits for the SSR bundle's exported `settled()` to resolve, see [Settling](#settling).
512
+ - **`app.destroy()`** shuts down the worker pool.
513
+ - **`assembleHTML(template, renderResult)`** inserts rendered fragments into the template at the `<!-- VITE_EMBER_SSR_HEAD -->` and `<!-- VITE_EMBER_SSR_BODY -->` markers.
514
+ - **`loadCssManifest(clientDir)`** loads the CSS manifest from the client build output. Returns `undefined` if not present. Used with lazy routes.
515
+ - **`hasSSRMarkers(html)`** returns `{ head: boolean, body: boolean }` indicating which markers are present.
682
516
 
683
517
  ### `vite-ember-ssr/client`
684
518
 
685
519
  ```js
686
520
  import {
521
+ bootRehydrated,
522
+ shouldRehydrate,
687
523
  installShoebox,
688
- cleanupSSRContent,
689
524
  cleanupShoebox,
690
- isSSRRendered,
691
- shouldRehydrate,
692
525
  } from 'vite-ember-ssr/client';
693
526
  ```
694
527
 
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.
528
+ - **`bootRehydrated(Application, config)`** boots the client Ember app, rehydrating the server rendered DOM when present. Falls back to a normal `Application.create(config.APP)` when the page was not server rendered. See [Client boot](#client-boot).
529
+ - **`shouldRehydrate()`** returns `true` if the current page was rendered with rehydration markers (the server injected `window.__vite_ember_ssr_rehydrate__`). Useful when you need to branch on rehydrate vs. plain boot yourself.
530
+ - **`installShoebox()`** replays server captured fetch responses. Auto restores `fetch` once all entries are consumed. Call in `entry.ts` before booting.
531
+ - **`cleanupShoebox()`** manually restores the original `fetch`.
700
532
 
701
533
  ## Monorepo development
702
534
 
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 |
535
+ This repo contains the library and a set of test apps that exercise it.
536
+
537
+ | Path | Description |
538
+ | ------------------------------------------ | ---------------------------------------- |
539
+ | `vite-ember-ssr/` | Core library and test suites |
540
+ | `test-apps/test-app/` | Ember test app (SSR) |
541
+ | `test-apps/test-app-ssg/` | Ember test app (SSG) |
542
+ | `test-apps/test-app-combined/` | Ember test app (SSR + SSG) |
543
+ | `test-apps/test-app-lazy-ssr/` | Ember test app (SSR + lazy routes) |
544
+ | `test-apps/test-app-lazy-ssg/` | Ember test app (SSG + lazy routes) |
545
+ | `test-apps/test-app-monorepo-ssr/` | Ember test app consuming a monorepo lib |
546
+ | `test-apps/test-app-monorepo-ssg/` | Same, for SSG |
547
+ | `test-apps/test-app-ssr-loading-substate/` | Loading substate behaviour (SSR) |
548
+ | `test-apps/test-app-ssg-loading-substate/` | Loading substate behaviour (SSG) |
549
+ | `test-apps/monorepo-lib/` | Shared library used by the monorepo apps |
550
+ | `test-apps/test-server/` | Fastify SSR server |
551
+
552
+ Top level scripts:
714
553
 
715
554
  ```sh
716
555
  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
556
+ pnpm dev # dev server (Fastify + Vite middleware)
557
+ pnpm build # build library + test app
558
+ pnpm demo # build everything, start production server
559
+ pnpm test # vitest SSR tests
560
+ pnpm test:browser # playwright browser tests
561
+ pnpm test:all # both
562
+ pnpm clean # remove dist directories
563
+ pnpm format # prettier --write .
723
564
  ```
724
565
 
725
566
  ## Performance