@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.
@@ -0,0 +1,775 @@
1
+ import type { Plugin, PluginOption, ResolvedConfig, UserConfig } from 'vite';
2
+ import { join, dirname } from 'node:path';
3
+ import {
4
+ mkdir,
5
+ writeFile,
6
+ readFile,
7
+ rm,
8
+ copyFile,
9
+ access,
10
+ } from 'node:fs/promises';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { cpus } from 'node:os';
13
+
14
+ export const SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';
15
+ export const SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';
16
+
17
+ /**
18
+ * Name of the CSS manifest file generated during the client build.
19
+ * Maps dynamic entry source modules to their associated CSS asset paths.
20
+ */
21
+ export const CSS_MANIFEST_FILENAME = 'css-manifest.json';
22
+
23
+ /**
24
+ * The CSS manifest maps Ember route names to the CSS files that Vite
25
+ * extracted from their lazy-loaded template chunks during the client build.
26
+ *
27
+ * Route names use Ember's dot-separated convention for nested routes:
28
+ * - `about` for `app/templates/about.gts`
29
+ * - `blog.post` for `app/templates/blog/post.gts`
30
+ *
31
+ * Example:
32
+ * ```json
33
+ * {
34
+ * "about": ["/assets/about-VWk4xp3e.css"]
35
+ * }
36
+ * ```
37
+ *
38
+ * During SSR, the renderer queries the active route name from Ember's
39
+ * router service and looks up CSS files to inject as `<link>` tags.
40
+ */
41
+ export type CssManifest = Record<string, string[]>;
42
+
43
+ /**
44
+ * Derives an Ember route name from a source module path following
45
+ * Ember's conventional file layout.
46
+ *
47
+ * `app/templates/about.gts` → `about`
48
+ * `app/templates/blog/post.gts` → `blog.post`
49
+ * `app/templates/index.gts` → `index`
50
+ *
51
+ * Returns undefined if the path doesn't match the convention.
52
+ */
53
+ function sourcePathToRouteName(
54
+ facadeModuleId: string,
55
+ root: string,
56
+ ): string | undefined {
57
+ // Make the path relative to the project root
58
+ let relativePath = facadeModuleId;
59
+ if (relativePath.startsWith(root)) {
60
+ relativePath = relativePath.slice(root.length);
61
+ }
62
+ // Strip leading slash
63
+ if (relativePath.startsWith('/')) {
64
+ relativePath = relativePath.slice(1);
65
+ }
66
+
67
+ // Match app/templates/<route-path>.<ext>
68
+ const match = relativePath.match(
69
+ /^app\/templates\/(.+)\.(gts|gjs|hbs|ts|js)$/,
70
+ );
71
+ if (!match) return undefined;
72
+
73
+ // Convert path separators to dots for nested routes
74
+ return match[1].replace(/\//g, '.');
75
+ }
76
+
77
+ /**
78
+ * Minimal type for a Rollup output chunk with Vite metadata.
79
+ * We define this locally to avoid a direct dependency on the 'rollup' package.
80
+ */
81
+ interface OutputChunkWithMeta {
82
+ type: 'chunk';
83
+ isDynamicEntry: boolean;
84
+ isEntry: boolean;
85
+ facadeModuleId: string | null;
86
+ name: string;
87
+ fileName: string;
88
+ imports: string[];
89
+ viteMetadata?: {
90
+ importedCss?: Set<string>;
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Walks the Rollup output bundle and collects CSS files associated
96
+ * with dynamic entry chunks. These are CSS imports that Vite extracted
97
+ * from code-split chunks (e.g., lazy-loaded route templates).
98
+ *
99
+ * The main entry's CSS is already linked in the HTML template by Vite,
100
+ * so we only collect CSS from `isDynamicEntry` chunks.
101
+ *
102
+ * When a component with CSS is shared across multiple lazy routes,
103
+ * Vite extracts the shared CSS into a separate chunk. We walk each
104
+ * dynamic entry's static `imports` graph to collect CSS from those
105
+ * shared chunks too, skipping the main entry chunk (whose CSS is
106
+ * already in the HTML template).
107
+ *
108
+ * Keys are Ember route names derived from the source file path using
109
+ * Ember's conventional `app/templates/` directory structure.
110
+ */
111
+ function buildCssManifest(
112
+ bundle: Record<string, { type: string }>,
113
+ base: string,
114
+ root: string,
115
+ ): CssManifest {
116
+ const manifest: CssManifest = {};
117
+
118
+ // Build a lookup of fileName → chunk for walking the import graph.
119
+ const chunksByFile = new Map<string, OutputChunkWithMeta>();
120
+ const mainEntryFiles = new Set<string>();
121
+
122
+ for (const [, output] of Object.entries(bundle)) {
123
+ if (output.type !== 'chunk') continue;
124
+ const chunk = output as unknown as OutputChunkWithMeta;
125
+ chunksByFile.set(chunk.fileName, chunk);
126
+
127
+ // Track main entry chunks so we can exclude their CSS.
128
+ // Main entry CSS is already linked in the HTML template by Vite.
129
+ if (chunk.isEntry && !chunk.isDynamicEntry) {
130
+ mainEntryFiles.add(chunk.fileName);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Recursively collect all CSS from a chunk and its static imports,
136
+ * excluding main entry chunks (whose CSS is already in the template).
137
+ */
138
+ function collectCss(
139
+ fileName: string,
140
+ seen: Set<string>,
141
+ css: Set<string>,
142
+ ): void {
143
+ if (seen.has(fileName)) return;
144
+ seen.add(fileName);
145
+
146
+ // Don't collect CSS from the main entry — it's already in the HTML.
147
+ if (mainEntryFiles.has(fileName)) return;
148
+
149
+ const chunk = chunksByFile.get(fileName);
150
+ if (!chunk) return;
151
+
152
+ const importedCss = chunk.viteMetadata?.importedCss;
153
+ if (importedCss) {
154
+ for (const cssFile of importedCss) {
155
+ css.add(cssFile);
156
+ }
157
+ }
158
+
159
+ // Walk static imports (shared chunks extracted by Vite).
160
+ for (const imp of chunk.imports) {
161
+ collectCss(imp, seen, css);
162
+ }
163
+ }
164
+
165
+ for (const [, output] of Object.entries(bundle)) {
166
+ if (output.type !== 'chunk') continue;
167
+
168
+ const chunk = output as unknown as OutputChunkWithMeta;
169
+
170
+ // Only collect CSS from dynamic entries (code-split chunks).
171
+ if (!chunk.isDynamicEntry) continue;
172
+
173
+ // Collect CSS from this chunk and all its static imports.
174
+ const css = new Set<string>();
175
+ collectCss(chunk.fileName, new Set(), css);
176
+
177
+ if (css.size === 0) continue;
178
+
179
+ // Derive the Ember route name from the source module path.
180
+ // If the path doesn't match Ember conventions, fall back to
181
+ // the chunk name (e.g., 'about' from 'about-B5EiMzMx.js').
182
+ const routeName = chunk.facadeModuleId
183
+ ? (sourcePathToRouteName(chunk.facadeModuleId, root) ?? chunk.name)
184
+ : chunk.name;
185
+
186
+ if (!routeName) continue;
187
+
188
+ // Prefix CSS paths with the base URL so they work as href values.
189
+ const cssFiles = Array.from(css).map((c) => `${base}${c}`);
190
+
191
+ if (cssFiles.length > 0) {
192
+ manifest[routeName] = cssFiles;
193
+ }
194
+ }
195
+
196
+ return manifest;
197
+ }
198
+
199
+ /**
200
+ * Returns SSR config appropriate for the current Vite command.
201
+ *
202
+ * Ember's virtual packages (`@glimmer/tracking`, `@ember/*`, etc.) are
203
+ * provided by `ember-source` and not published as real npm packages.
204
+ * When Vite externalizes a dependency that transitively imports one of
205
+ * these virtual packages, Node's runtime module resolution fails under
206
+ * pnpm's strict `node_modules` layout.
207
+ *
208
+ * For both production builds and dev mode:
209
+ * - Clears any user-specified `ssr.external` (explicit string entries
210
+ * take precedence over `noExternal` patterns in Vite, so we must
211
+ * remove them to ensure `noExternal: [/./]` applies).
212
+ * - Sets `ssr: { noExternal: [/./] }` so all deps go through Vite's
213
+ * transform pipeline. This lets `@embroider/vite`'s resolver handle
214
+ * virtual Ember/Glimmer packages that don't exist outside `ember-source`
215
+ * under pnpm's strict `node_modules` layout.
216
+ *
217
+ * In dev mode, `ssrLoadModule` uses `SSRCompatModuleRunner` +
218
+ * `ESModulesEvaluator`. Without bundling, this evaluates all module code
219
+ * inline. CJS/UMD packages (e.g. `@warp-drive/utilities/string`,
220
+ * `json-to-ast`) reference `module`, `exports`, or `global` which are not
221
+ * available in the evaluator's context.
222
+ *
223
+ * The `cjsSsrShimTransform` hook (applied by `emberSsr()` and `emberSsg()`)
224
+ * intercepts those files before they reach `ssrTransform` and wraps them
225
+ * with a lightweight CommonJS shim, providing the missing `module`,
226
+ * `exports`, and `global` bindings.
227
+ *
228
+ * See: https://github.com/evoactivity/vite-ember-ssr/issues/4
229
+ */
230
+ function ssrDepsConfig(
231
+ userConfig: UserConfig,
232
+ _command: 'build' | 'serve',
233
+ ): { ssr?: UserConfig['ssr'] } {
234
+ if (userConfig.ssr) {
235
+ delete userConfig.ssr.external;
236
+ }
237
+ return { ssr: { noExternal: [/./] } };
238
+ }
239
+
240
+ /**
241
+ * Returns a Vite `transform` hook that wraps CJS/UMD modules encountered
242
+ * during SSR transforms.
243
+ *
244
+ * When `noExternal: [/./]` is set, every dependency goes through Vite's
245
+ * `ssrTransform` → `ESModulesEvaluator` pipeline. CJS/UMD files that use
246
+ * `module`, `exports`, or `global` fail because those globals are not
247
+ * available inside `ESModulesEvaluator`'s `AsyncFunction` context.
248
+ *
249
+ * This transform detects CJS/UMD content (no top-level `import`/`export`
250
+ * statements, but contains `exports.xxx` or `module.exports`) and wraps
251
+ * the code so that:
252
+ * 1. `module`, `exports`, and `global` are available as local variables.
253
+ * 2. The module's exports are re-exported as the ES default export.
254
+ *
255
+ * The heuristic is intentionally simple and conservative — it only fires
256
+ * on files that have no ESM syntax at all, which covers the CJS/UMD
257
+ * packages that appear in the Ember + WarpDrive dependency tree without
258
+ * misidentifying genuine ESM files.
259
+ */
260
+ function cjsSsrShimTransform(
261
+ code: string,
262
+ _id: string,
263
+ options?: { ssr?: boolean },
264
+ ): { code: string; map: null } | null {
265
+ // Only apply during SSR transforms
266
+ if (!options?.ssr) return null;
267
+
268
+ // Skip if the file contains any top-level import/export → it's ESM
269
+ if (/^(?:import\s|export\s|export\{|export default)/m.test(code)) return null;
270
+
271
+ // Only wrap files that use CommonJS exports or module.exports
272
+ if (!/\bexports\s*[.[=]|\bmodule\s*\.\s*exports\b/.test(code)) return null;
273
+
274
+ const wrapped = `\
275
+ const __cjs_module__ = { exports: {} };
276
+ const __cjs_exports__ = __cjs_module__.exports;
277
+ const __cjs_global__ = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : {};
278
+ (function(module, exports, global) {
279
+ ${code}
280
+ })(__cjs_module__, __cjs_exports__, __cjs_global__);
281
+ export default __cjs_module__.exports;
282
+ `;
283
+ return { code: wrapped, map: null };
284
+ }
285
+
286
+ /**
287
+ * Flatten and filter a Vite plugins array, which may contain nested arrays,
288
+ * falsy values, and Promise-wrapped entries.
289
+ */
290
+ function flatPlugins(plugins: PluginOption[] | undefined): Plugin[] {
291
+ if (!plugins) return [];
292
+ return (plugins as unknown[])
293
+ .flat(Infinity)
294
+ .filter(
295
+ (p): p is Plugin => p != null && typeof p === 'object' && 'name' in p,
296
+ );
297
+ }
298
+
299
+ export interface EmberSsrPluginOptions {
300
+ /**
301
+ * Output directory for the client build.
302
+ * @default 'dist/client'
303
+ */
304
+ clientOutDir?: string;
305
+
306
+ /**
307
+ * Output directory for the SSR build.
308
+ * @default 'dist/server'
309
+ */
310
+ serverOutDir?: string;
311
+ }
312
+
313
+ /**
314
+ * Vite plugin that configures SSR support for Ember applications.
315
+ *
316
+ * Handles all SSR-related Vite configuration automatically:
317
+ *
318
+ * - Bundles all dependencies into SSR builds (`ssr.noExternal: [/./]`)
319
+ * to avoid runtime resolution failures under pnpm's strict
320
+ * node_modules layout (see issue #4)
321
+ * - Sets build defaults: `dist/client` for client builds,
322
+ * `dist/server` with `target: 'node22'` for SSR builds
323
+ * - Writes a `package.json` with `"type": "module"` to the SSR
324
+ * build output directory (needed for Node ESM compatibility)
325
+ */
326
+ export function emberSsr(options: EmberSsrPluginOptions = {}): Plugin {
327
+ let resolvedConfig: ResolvedConfig;
328
+
329
+ return {
330
+ name: 'vite-ember-ssr',
331
+
332
+ config(userConfig, env): UserConfig {
333
+ // Bundle all dependencies for SSR builds and dev mode to avoid runtime
334
+ // failures under pnpm's strict node_modules layout when external packages
335
+ // transitively import virtual Ember/Glimmer packages (e.g.
336
+ // @glimmer/tracking) that only exist inside ember-source.
337
+ // In dev mode, the `transform: cjsSsrShimTransform` hook wraps
338
+ // CJS/UMD packages so they work with ESModulesEvaluator.
339
+ // See: https://github.com/evoactivity/vite-ember-ssr/issues/4
340
+ const ssrConfig = ssrDepsConfig(userConfig, env.command);
341
+
342
+ // During the SSG child build, only set ssr config — don't
343
+ // override build.outDir (the SSG plugin sets it explicitly
344
+ // via inline config to a temp directory).
345
+ if (process.env.__VITE_EMBER_SSG_CHILD__) {
346
+ return ssrConfig;
347
+ }
348
+
349
+ if (env.isSsrBuild) {
350
+ return {
351
+ ...ssrConfig,
352
+ build: {
353
+ outDir: options.serverOutDir ?? 'dist/server',
354
+ target: 'node22',
355
+ sourcemap: true,
356
+ minify: false,
357
+ },
358
+ };
359
+ }
360
+
361
+ return {
362
+ ...ssrConfig,
363
+ build: {
364
+ outDir: options.clientOutDir ?? 'dist/client',
365
+ },
366
+ };
367
+ },
368
+
369
+ configResolved(config) {
370
+ resolvedConfig = config;
371
+ },
372
+
373
+ transform: cjsSsrShimTransform,
374
+
375
+ generateBundle(_outputOptions, bundle) {
376
+ // Only generate the CSS manifest for client builds.
377
+ // SSR builds strip CSS imports, so they have nothing to map.
378
+ if (resolvedConfig.build.ssr) return;
379
+
380
+ // Don't generate during the SSG child build (it's an SSR build)
381
+ if (process.env.__VITE_EMBER_SSG_CHILD__) return;
382
+
383
+ const base = resolvedConfig.base ?? '/';
384
+ const root = resolvedConfig.root;
385
+ const manifest = buildCssManifest(bundle, base, root);
386
+
387
+ // Only emit the manifest if there are dynamic entries with CSS.
388
+ // Apps without lazy-loaded CSS don't need this file.
389
+ if (Object.keys(manifest).length === 0) return;
390
+
391
+ this.emitFile({
392
+ type: 'asset',
393
+ fileName: CSS_MANIFEST_FILENAME,
394
+ source: JSON.stringify(manifest, null, 2),
395
+ });
396
+ },
397
+
398
+ async closeBundle() {
399
+ // Only write package.json for SSR builds
400
+ if (!resolvedConfig.build.ssr) return;
401
+
402
+ // Don't interfere with the SSG child build's temp directory
403
+ if (process.env.__VITE_EMBER_SSG_CHILD__) return;
404
+
405
+ const outDir = join(resolvedConfig.root, resolvedConfig.build.outDir);
406
+ const targetPath = join(outDir, 'package.json');
407
+ await mkdir(outDir, { recursive: true });
408
+ await writeFile(
409
+ targetPath,
410
+ JSON.stringify({ type: 'module' }, null, 2),
411
+ 'utf-8',
412
+ );
413
+ },
414
+ };
415
+ }
416
+
417
+ // ─── SSG Plugin ──────────────────────────────────────────────────────
418
+
419
+ export interface EmberSsgPluginOptions {
420
+ /**
421
+ * Routes to prerender as static HTML files.
422
+ *
423
+ * Each entry is a route path (without leading slash).
424
+ * 'index' produces `index.html` at the root, other routes produce
425
+ * `<route>/index.html` (e.g., 'about' → `about/index.html`).
426
+ *
427
+ * @example
428
+ * ```js
429
+ * emberSsg({
430
+ * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],
431
+ * })
432
+ * ```
433
+ */
434
+ routes: string[];
435
+
436
+ /**
437
+ * The SSR entry module path, relative to the project root.
438
+ * This file must export a `createSsrApp` function.
439
+ * @default 'app/app-ssr.ts'
440
+ */
441
+ ssrEntry?: string;
442
+
443
+ /**
444
+ * Enable shoebox (fetch replay) for prerendered pages.
445
+ *
446
+ * When true, fetch responses from route model hooks are captured during
447
+ * prerendering and serialized into the HTML. The client calls
448
+ * `installShoebox()` before boot to replay those responses and avoid
449
+ * duplicate API requests.
450
+ *
451
+ * @default false
452
+ */
453
+ shoebox?: boolean;
454
+
455
+ /**
456
+ * Output directory for the client build.
457
+ * @default 'dist'
458
+ */
459
+ outDir?: string;
460
+
461
+ /**
462
+ * Enable Glimmer rehydration for prerendered pages.
463
+ *
464
+ * When `true`, the server renders with `_renderMode: 'serialize'`,
465
+ * annotating the DOM with Glimmer markers. The client boots with
466
+ * `app.visit(url, { _renderMode: 'rehydrate' })` to reuse the
467
+ * static DOM instead of replacing it.
468
+ *
469
+ * When `false` (default), boundary markers are emitted and the
470
+ * client uses `cleanupSSRContent()` in the application template
471
+ * to remove the SSR content before Ember renders fresh.
472
+ *
473
+ * @default false
474
+ */
475
+ rehydrate?: boolean;
476
+ }
477
+
478
+ /**
479
+ * Vite plugin for Static Site Generation (SSG) of Ember applications.
480
+ *
481
+ * Prerenders the specified routes to static HTML files at build time.
482
+ * Fully self-contained — only a single `vite build` is needed.
483
+ *
484
+ * After the client build completes, the plugin runs a second SSR build
485
+ * via `vite.build()` to produce a bundled SSR entry module, imports it,
486
+ * renders each route using HappyDOM, and writes the resulting HTML files
487
+ * into the client output directory. The temporary SSR bundle is cleaned
488
+ * up automatically.
489
+ *
490
+ * All dependencies are bundled into the SSR output (no externals) to
491
+ * avoid runtime resolution failures under pnpm's strict node_modules
492
+ * layout. See issue #4.
493
+ *
494
+ * @example
495
+ * ```js
496
+ * // vite.config.mjs
497
+ * import { emberSsg } from '@st-h/vite-ember-ssr/vite-plugin';
498
+ *
499
+ * export default defineConfig({
500
+ * plugins: [
501
+ * ember(),
502
+ * babel({ babelHelpers: 'runtime', extensions }),
503
+ * emberSsg({
504
+ * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],
505
+ * }),
506
+ * ],
507
+ * });
508
+ * ```
509
+ */
510
+ export function emberSsg(options: EmberSsgPluginOptions): Plugin {
511
+ const {
512
+ routes,
513
+ ssrEntry = 'app/app-ssr.ts',
514
+ shoebox = false,
515
+ rehydrate = false,
516
+ } = options;
517
+
518
+ // Track whether the user explicitly provided outDir
519
+ const explicitOutDir = options.outDir;
520
+
521
+ let resolvedConfig: ResolvedConfig;
522
+
523
+ // Whether emberSsr is also registered — detected in config() hook
524
+ let isCombined = false;
525
+
526
+ return {
527
+ name: 'vite-ember-ssg',
528
+
529
+ config(userConfig, env): UserConfig {
530
+ // Bundle all dependencies for SSR builds — see ssrDepsConfig().
531
+ const ssrConfig = ssrDepsConfig(userConfig, env.command);
532
+
533
+ // During the SSG child build, only set ssr config — don't touch
534
+ // build.outDir or detect isCombined (irrelevant for child build).
535
+ if (process.env.__VITE_EMBER_SSG_CHILD__) {
536
+ return ssrConfig;
537
+ }
538
+
539
+ // Detect if emberSsr is also registered in this config.
540
+ // When combined, defer build.outDir to emberSsr so that
541
+ // prerendered files land in the SSR client directory.
542
+ isCombined = flatPlugins(userConfig.plugins).some(
543
+ (p) => p.name === 'vite-ember-ssr',
544
+ );
545
+
546
+ // Only set outDir when:
547
+ // - the user explicitly passed outDir to emberSsg, OR
548
+ // - emberSsr is NOT present (standalone SSG mode, default 'dist')
549
+ const outDir = explicitOutDir ?? (isCombined ? undefined : 'dist');
550
+
551
+ return {
552
+ ...ssrConfig,
553
+ ...(outDir != null ? { build: { outDir } } : {}),
554
+ };
555
+ },
556
+
557
+ configResolved(config) {
558
+ resolvedConfig = config;
559
+ },
560
+
561
+ transform: cjsSsrShimTransform,
562
+
563
+ generateBundle(_outputOptions, bundle) {
564
+ // When combined with emberSsr, the SSR plugin already emits
565
+ // the CSS manifest — skip to avoid duplicate emission.
566
+ if (isCombined) return;
567
+
568
+ // Only generate the CSS manifest for client builds.
569
+ if (resolvedConfig.build.ssr) return;
570
+
571
+ // Don't generate during the SSG child build (it's an SSR build)
572
+ if (process.env.__VITE_EMBER_SSG_CHILD__) return;
573
+
574
+ const base = resolvedConfig.base ?? '/';
575
+ const root = resolvedConfig.root;
576
+ const manifest = buildCssManifest(bundle, base, root);
577
+
578
+ if (Object.keys(manifest).length === 0) return;
579
+
580
+ this.emitFile({
581
+ type: 'asset',
582
+ fileName: CSS_MANIFEST_FILENAME,
583
+ source: JSON.stringify(manifest, null, 2),
584
+ });
585
+ },
586
+
587
+ async closeBundle() {
588
+ // Don't prerender during SSR builds (if the user also has emberSsr)
589
+ if (resolvedConfig.build.ssr) return;
590
+
591
+ // Prevent recursive prerendering when the child build
592
+ // loads the same config file and re-registers this plugin.
593
+ if (process.env.__VITE_EMBER_SSG_CHILD__) return;
594
+
595
+ const { build: viteBuild } = await import('vite');
596
+ const { assembleHTML, createEmberApp } = await import('./server.js');
597
+
598
+ const root = resolvedConfig.root;
599
+ const clientDir = join(root, resolvedConfig.build.outDir);
600
+ const ssrOutDir = join(root, '.ssg-tmp');
601
+
602
+ console.log('\n[vite-ember-ssg] Prerendering routes...');
603
+
604
+ // Read the built client index.html as template
605
+ const templatePath = join(clientDir, 'index.html');
606
+ let template: string;
607
+ try {
608
+ template = await readFile(templatePath, 'utf-8');
609
+ } catch (e) {
610
+ console.error(
611
+ `[vite-ember-ssg] Failed to read template at ${templatePath}.`,
612
+ );
613
+ throw e;
614
+ }
615
+
616
+ // Read the CSS manifest (if it exists) so we can inject
617
+ // lazy-loaded CSS into prerendered pages.
618
+ let cssManifest: CssManifest | undefined;
619
+ const cssManifestPath = join(clientDir, CSS_MANIFEST_FILENAME);
620
+ try {
621
+ const raw = await readFile(cssManifestPath, 'utf-8');
622
+ cssManifest = JSON.parse(raw) as CssManifest;
623
+ } catch {
624
+ // No CSS manifest — app has no lazy-loaded CSS
625
+ }
626
+
627
+ // When combined with emberSsr, preserve the original index.html
628
+ // as _template.html before prerendering overwrites it. The
629
+ // production server reads _template.html for dynamic SSR rendering.
630
+ if (isCombined) {
631
+ const savedTemplatePath = join(clientDir, '_template.html');
632
+ await copyFile(templatePath, savedTemplatePath);
633
+ console.log(
634
+ ` [vite-ember-ssg] Saved SSR template → ${savedTemplatePath.replace(root + '/', '')}`,
635
+ );
636
+ }
637
+
638
+ // ── Step 1: Build the SSR bundle ────────────────────────────
639
+ // Run vite.build() with ssr entry to produce a fully bundled
640
+ // ESM module. This handles all CJS→ESM transforms, Babel,
641
+ // Glimmer template compilation, etc. at build time.
642
+ process.env.__VITE_EMBER_SSG_CHILD__ = '1';
643
+
644
+ try {
645
+ await viteBuild({
646
+ root,
647
+ configFile: resolvedConfig.configFile || undefined,
648
+ logLevel: 'warn',
649
+ build: {
650
+ ssr: ssrEntry,
651
+ outDir: ssrOutDir,
652
+ target: 'node22',
653
+ minify: false,
654
+ sourcemap: false,
655
+ },
656
+ ssr: {
657
+ // Belt-and-suspenders: the config hooks already call
658
+ // ssrDepsConfig() for the child build, but setting it here
659
+ // in inline config guarantees it even if the user's config
660
+ // file doesn't register the SSR/SSG plugins for some reason.
661
+ noExternal: [/./],
662
+ },
663
+ });
664
+ } catch (e) {
665
+ console.error('[vite-ember-ssg] SSR build failed:', e);
666
+ throw e;
667
+ } finally {
668
+ delete process.env.__VITE_EMBER_SSG_CHILD__;
669
+ }
670
+
671
+ // Write package.json so Node loads the bundle as ESM
672
+ await writeFile(
673
+ join(ssrOutDir, 'package.json'),
674
+ JSON.stringify({ type: 'module' }, null, 2),
675
+ 'utf-8',
676
+ );
677
+
678
+ // ── Step 2: Import the SSR bundle and prerender ─────────────
679
+ let successCount = 0;
680
+ let errorCount = 0;
681
+
682
+ try {
683
+ // Determine the output filename — Vite names SSR output
684
+ // after the entry: 'app/app-ssr.ts' → 'app-ssr.mjs'.
685
+ // Some Vite versions using Rolldown output '.js' instead of '.mjs',
686
+ // so we try both extensions.
687
+ const entryBasename = ssrEntry
688
+ .split('/')
689
+ .pop()!
690
+ .replace(/\.[^.]+$/, '');
691
+
692
+ let ssrBundlePath = join(ssrOutDir, `${entryBasename}.mjs`);
693
+ try {
694
+ await access(ssrBundlePath);
695
+ } catch {
696
+ ssrBundlePath = join(ssrOutDir, `${entryBasename}.js`);
697
+ }
698
+ const ssrBundleURL = pathToFileURL(ssrBundlePath).href;
699
+
700
+ // Prerender all routes in parallel using a long-lived worker pool.
701
+ // Workers import the SSR bundle once and reuse it across renders,
702
+ // making per-render cost ~4ms vs ~200ms for a fresh-worker approach.
703
+ const app = await createEmberApp(ssrBundleURL, {
704
+ workers: cpus().length,
705
+ });
706
+
707
+ try {
708
+ await Promise.all(
709
+ routes.map(async (route) => {
710
+ const url = route === 'index' ? '/' : `/${route}`;
711
+
712
+ try {
713
+ const result = await app.renderRoute(url, {
714
+ shoebox,
715
+ rehydrate,
716
+ cssManifest,
717
+ });
718
+
719
+ if (result.error) {
720
+ console.error(
721
+ ` [vite-ember-ssg] Error rendering ${url}:\n` +
722
+ (result.error.stack ?? result.error.message),
723
+ );
724
+ errorCount++;
725
+ return;
726
+ }
727
+
728
+ const html = assembleHTML(template, result);
729
+
730
+ // 'index' → index.html (overwrite the shell)
731
+ // 'about' → about/index.html
732
+ // 'pokemon/charmander' → pokemon/charmander/index.html
733
+ const outputPath =
734
+ route === 'index'
735
+ ? join(clientDir, 'index.html')
736
+ : join(clientDir, route, 'index.html');
737
+
738
+ await mkdir(dirname(outputPath), { recursive: true });
739
+ await writeFile(outputPath, html, 'utf-8');
740
+
741
+ console.log(
742
+ ` [vite-ember-ssg] ${url} → ${outputPath.replace(root + '/', '')}`,
743
+ );
744
+ successCount++;
745
+ } catch (e) {
746
+ console.error(
747
+ ` [vite-ember-ssg] Failed to prerender ${url}:\n` +
748
+ (e instanceof Error ? (e.stack ?? e.message) : String(e)),
749
+ );
750
+ errorCount++;
751
+ }
752
+ }),
753
+ );
754
+ } finally {
755
+ await app.destroy();
756
+ }
757
+ } finally {
758
+ // ── Step 3: Clean up the temporary SSR bundle ─────────────
759
+ await rm(ssrOutDir, { recursive: true, force: true });
760
+ }
761
+
762
+ console.log(
763
+ `[vite-ember-ssg] Done. ${successCount} pages generated` +
764
+ (errorCount > 0 ? `, ${errorCount} errors` : '') +
765
+ '.',
766
+ );
767
+
768
+ if (errorCount > 0 && successCount === 0) {
769
+ throw new Error('[vite-ember-ssg] All routes failed to prerender.');
770
+ }
771
+ },
772
+ };
773
+ }
774
+
775
+ export default emberSsr;