@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/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