@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 +257 -416
- package/dist/client.d.ts +42 -51
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -62
- package/dist/client.js.map +1 -1
- package/dist/fetch-middleware-DPLxOLL6.js +98 -0
- package/dist/fetch-middleware-DPLxOLL6.js.map +1 -0
- package/dist/server-DJRlVUcm.d.ts +260 -0
- package/dist/server-DJRlVUcm.d.ts.map +1 -0
- package/dist/server.d.ts +3 -236
- package/dist/server.js +46 -42
- package/dist/server.js.map +1 -1
- package/dist/{vite-plugin-D-W5WQWe.js → vite-plugin-9BSJgEL9.js} +3 -4
- package/dist/vite-plugin-9BSJgEL9.js.map +1 -0
- package/dist/{vite-plugin-CQou_tr5.d.ts → vite-plugin-Dl5DbheW.d.ts} +2 -17
- package/dist/vite-plugin-Dl5DbheW.d.ts.map +1 -0
- package/dist/vite-plugin.d.ts +1 -1
- package/dist/vite-plugin.js +1 -1
- package/dist/worker.d.ts +4 -3
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +69 -54
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +64 -73
- package/src/dev.ts +91 -61
- package/src/fetch-middleware.ts +166 -0
- package/src/server.ts +48 -23
- package/src/vite-plugin.ts +2 -24
- package/src/worker.ts +153 -105
- package/dist/server.d.ts.map +0 -1
- package/dist/vite-plugin-CQou_tr5.d.ts.map +0 -1
- package/dist/vite-plugin-D-W5WQWe.js.map +0 -1
package/src/server.ts
CHANGED
|
@@ -39,6 +39,27 @@ export interface BootOptions {
|
|
|
39
39
|
_renderMode?: 'serialize' | 'rehydrate' | undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Configures forwarding of the incoming request's `Cookie` header to
|
|
44
|
+
* fetch() calls made during SSR rendering.
|
|
45
|
+
*
|
|
46
|
+
* `allowedHosts` is required: forwarding the session cookie to every
|
|
47
|
+
* outbound fetch would leak credentials to third-party APIs the route
|
|
48
|
+
* happens to call. Each entry is matched against the request URL's
|
|
49
|
+
* `host` (hostname plus port) using exact equality — suffix wildcards
|
|
50
|
+
* are not supported.
|
|
51
|
+
*/
|
|
52
|
+
export interface ForwardedCookie {
|
|
53
|
+
/** Cookie header value from the incoming request. */
|
|
54
|
+
value: string;
|
|
55
|
+
/**
|
|
56
|
+
* Hosts (`URL.host`) the cookie may be sent to. Exact match, no wildcards.
|
|
57
|
+
*
|
|
58
|
+
* @example ['api.example.com', 'auth.example.com:8080']
|
|
59
|
+
*/
|
|
60
|
+
allowedHosts: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
export interface RenderRouteOptions {
|
|
43
64
|
/**
|
|
44
65
|
* When true, intercepts all fetch() calls during SSR rendering and
|
|
@@ -46,16 +67,6 @@ export interface RenderRouteOptions {
|
|
|
46
67
|
*/
|
|
47
68
|
shoebox?: boolean;
|
|
48
69
|
|
|
49
|
-
/**
|
|
50
|
-
* Enable Glimmer VM rehydration mode.
|
|
51
|
-
*
|
|
52
|
-
* When true, the server renders with `_renderMode: 'serialize'`,
|
|
53
|
-
* annotating the DOM with markers Glimmer can reuse on the client.
|
|
54
|
-
*
|
|
55
|
-
* @default false
|
|
56
|
-
*/
|
|
57
|
-
rehydrate?: boolean;
|
|
58
|
-
|
|
59
70
|
/**
|
|
60
71
|
* CSS manifest mapping route names to their associated CSS asset paths.
|
|
61
72
|
*
|
|
@@ -65,21 +76,35 @@ export interface RenderRouteOptions {
|
|
|
65
76
|
cssManifest?: CssManifest;
|
|
66
77
|
|
|
67
78
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
79
|
+
* Maximum time (in milliseconds) to wait for `settled()` to resolve after
|
|
80
|
+
* `app.visit()`. Only applies when the SSR bundle exports a `settled`
|
|
81
|
+
* function (typically re-exported from `@ember/test-helpers`).
|
|
70
82
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
83
|
+
* If the timeout is exceeded, a warning is logged and the DOM is captured
|
|
84
|
+
* regardless. Use this to bound render time when a route registers a
|
|
85
|
+
* waiter that never resolves.
|
|
74
86
|
*
|
|
75
|
-
*
|
|
87
|
+
* @default 10000
|
|
88
|
+
*/
|
|
89
|
+
settledTimeout?: number;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Forward the incoming request's `Cookie` header to fetch() calls made
|
|
93
|
+
* during SSR rendering. The cookie is only sent to hosts listed in
|
|
94
|
+
* `allowedHosts`, so credentials never leak to third-party APIs the
|
|
95
|
+
* route may also call.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
76
98
|
* ```js
|
|
77
|
-
*
|
|
78
|
-
*
|
|
99
|
+
* await app.renderRoute(req.url, {
|
|
100
|
+
* forwardCookie: {
|
|
101
|
+
* value: req.headers.cookie ?? '',
|
|
102
|
+
* allowedHosts: ['api.example.com'],
|
|
103
|
+
* },
|
|
79
104
|
* });
|
|
80
105
|
* ```
|
|
81
106
|
*/
|
|
82
|
-
|
|
107
|
+
forwardCookie?: ForwardedCookie;
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
export interface RenderResult {
|
|
@@ -218,7 +243,7 @@ export interface EmberApp {
|
|
|
218
243
|
*
|
|
219
244
|
* @example Production
|
|
220
245
|
* ```js
|
|
221
|
-
* import { createEmberApp, assembleHTML } from '
|
|
246
|
+
* import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
222
247
|
* import { resolve } from 'node:path';
|
|
223
248
|
*
|
|
224
249
|
* const app = await createEmberApp(resolve('dist/server/app-ssr.mjs'));
|
|
@@ -234,7 +259,7 @@ export interface EmberApp {
|
|
|
234
259
|
* @example Development
|
|
235
260
|
* ```js
|
|
236
261
|
* import { createServer } from 'vite';
|
|
237
|
-
* import { createEmberApp, assembleHTML } from '
|
|
262
|
+
* import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';
|
|
238
263
|
*
|
|
239
264
|
* const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
|
|
240
265
|
* const app = await createEmberApp('app/app-ssr.ts', {
|
|
@@ -293,9 +318,9 @@ export async function createEmberApp(
|
|
|
293
318
|
ssrBundlePath: bundleURL,
|
|
294
319
|
url,
|
|
295
320
|
shoebox: renderOptions.shoebox ?? false,
|
|
296
|
-
rehydrate: renderOptions.rehydrate ?? false,
|
|
297
321
|
cssManifest: renderOptions.cssManifest ?? null,
|
|
298
|
-
|
|
322
|
+
settledTimeout: renderOptions.settledTimeout ?? 10_000,
|
|
323
|
+
forwardCookie: renderOptions.forwardCookie ?? null,
|
|
299
324
|
})) as {
|
|
300
325
|
head: string;
|
|
301
326
|
body: string;
|
package/src/vite-plugin.ts
CHANGED
|
@@ -457,22 +457,6 @@ export interface EmberSsgPluginOptions {
|
|
|
457
457
|
* @default 'dist'
|
|
458
458
|
*/
|
|
459
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
460
|
}
|
|
477
461
|
|
|
478
462
|
/**
|
|
@@ -494,7 +478,7 @@ export interface EmberSsgPluginOptions {
|
|
|
494
478
|
* @example
|
|
495
479
|
* ```js
|
|
496
480
|
* // vite.config.mjs
|
|
497
|
-
* import { emberSsg } from '
|
|
481
|
+
* import { emberSsg } from 'vite-ember-ssr/vite-plugin';
|
|
498
482
|
*
|
|
499
483
|
* export default defineConfig({
|
|
500
484
|
* plugins: [
|
|
@@ -508,12 +492,7 @@ export interface EmberSsgPluginOptions {
|
|
|
508
492
|
* ```
|
|
509
493
|
*/
|
|
510
494
|
export function emberSsg(options: EmberSsgPluginOptions): Plugin {
|
|
511
|
-
const {
|
|
512
|
-
routes,
|
|
513
|
-
ssrEntry = 'app/app-ssr.ts',
|
|
514
|
-
shoebox = false,
|
|
515
|
-
rehydrate = false,
|
|
516
|
-
} = options;
|
|
495
|
+
const { routes, ssrEntry = 'app/app-ssr.ts', shoebox = false } = options;
|
|
517
496
|
|
|
518
497
|
// Track whether the user explicitly provided outDir
|
|
519
498
|
const explicitOutDir = options.outDir;
|
|
@@ -712,7 +691,6 @@ export function emberSsg(options: EmberSsgPluginOptions): Plugin {
|
|
|
712
691
|
try {
|
|
713
692
|
const result = await app.renderRoute(url, {
|
|
714
693
|
shoebox,
|
|
715
|
-
rehydrate,
|
|
716
694
|
cssManifest,
|
|
717
695
|
});
|
|
718
696
|
|
package/src/worker.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* every render. Renders are serialised by tinypool (concurrentTasksPerWorker:1),
|
|
7
7
|
* so there is no concurrency concern within a single worker.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* A fresh ApplicationInstance is created per visit and destroyed after the
|
|
10
|
+
* DOM is read, keeping container singletons clean. Because the Window is
|
|
11
|
+
* long-lived, every render resets the mutable per-request window state in a
|
|
12
|
+
* finally block: document.body (content + attributes), document.head/title,
|
|
13
|
+
* and local/session storage. Without this, one request's DOM or storage
|
|
14
|
+
* writes would bleed into the next request served by this worker.
|
|
12
15
|
*
|
|
13
16
|
* The shoebox fetch interceptor is installed once at startup. Each render
|
|
14
17
|
* assigns a fresh entries Map before visiting, so entries never bleed between
|
|
@@ -23,7 +26,14 @@ import type {
|
|
|
23
26
|
EmberApplicationInstance,
|
|
24
27
|
BootOptions,
|
|
25
28
|
ShoeboxEntry,
|
|
29
|
+
ForwardedCookie,
|
|
26
30
|
} from './server.js';
|
|
31
|
+
import {
|
|
32
|
+
abortSignalMiddleware,
|
|
33
|
+
compose,
|
|
34
|
+
forwardCookieMiddleware,
|
|
35
|
+
shoeboxMiddleware,
|
|
36
|
+
} from './fetch-middleware.js';
|
|
27
37
|
|
|
28
38
|
// ─── Types ────────────────────────────────────────────────────────────
|
|
29
39
|
|
|
@@ -31,9 +41,9 @@ export interface WorkerRenderOptions {
|
|
|
31
41
|
ssrBundlePath: string;
|
|
32
42
|
url: string;
|
|
33
43
|
shoebox: boolean;
|
|
34
|
-
rehydrate: boolean;
|
|
35
44
|
cssManifest: CssManifest | null;
|
|
36
|
-
|
|
45
|
+
settledTimeout: number;
|
|
46
|
+
forwardCookie: ForwardedCookie | null;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export interface WorkerRenderResult {
|
|
@@ -113,6 +123,7 @@ const { ssrBundlePath: startupBundlePath } = (
|
|
|
113
123
|
|
|
114
124
|
const startupMod = (await import(startupBundlePath)) as {
|
|
115
125
|
createSsrApp?: () => EmberApplication;
|
|
126
|
+
settled?: () => Promise<void>;
|
|
116
127
|
};
|
|
117
128
|
if (typeof startupMod.createSsrApp !== 'function') {
|
|
118
129
|
throw new Error(
|
|
@@ -123,75 +134,37 @@ if (typeof startupMod.createSsrApp !== 'function') {
|
|
|
123
134
|
|
|
124
135
|
const app: EmberApplication = startupMod.createSsrApp();
|
|
125
136
|
|
|
126
|
-
//
|
|
137
|
+
// Optional: the SSR bundle may re-export `settled` from `@ember/test-helpers`.
|
|
138
|
+
// When present, the renderer awaits it after `app.visit()` so any registered
|
|
139
|
+
// `@ember/test-waiters` (used by WarpDrive, ember-concurrency, etc.) drain
|
|
140
|
+
// before the DOM is captured. When absent, we fall back to a single
|
|
141
|
+
// Backburner autorun drain via `setTimeout(0)`.
|
|
142
|
+
const appSettled: (() => Promise<void>) | null =
|
|
143
|
+
typeof startupMod.settled === 'function' ? startupMod.settled : null;
|
|
144
|
+
|
|
145
|
+
// ─── Fetch middleware pipeline ────────────────────────────────────────
|
|
127
146
|
|
|
128
147
|
const SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';
|
|
129
148
|
|
|
130
|
-
// The fetch
|
|
131
|
-
//
|
|
132
|
-
//
|
|
149
|
+
// The fetch pipeline is installed once at startup. globalThis.fetch never
|
|
150
|
+
// changes. Per-render state (shoebox entries, forwarded cookie) lives in
|
|
151
|
+
// module-level variables that the middlewares read via getters.
|
|
133
152
|
const realFetch = globalThis.fetch;
|
|
134
|
-
let
|
|
135
|
-
let
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
for (const [key, value] of Object.entries(requestHeaders)) {
|
|
147
|
-
if (!existingHeaders.has(key)) {
|
|
148
|
-
existingHeaders.set(key, value);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
mergedInit.headers = existingHeaders;
|
|
152
|
-
const mergedRequest = new Request(input, mergedInit);
|
|
153
|
-
|
|
154
|
-
if (mergedRequest.method.toUpperCase() !== 'GET') return realFetch(mergedRequest);
|
|
155
|
-
const response = await realFetch(mergedRequest);
|
|
156
|
-
if (shoeboxEntries) {
|
|
157
|
-
captureShoeboxEntry(mergedRequest, response);
|
|
158
|
-
}
|
|
159
|
-
return response;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (request.method.toUpperCase() !== 'GET') return realFetch(input, init);
|
|
163
|
-
const response = await realFetch(input, init);
|
|
164
|
-
if (shoeboxEntries) {
|
|
165
|
-
captureShoeboxEntry(request, response);
|
|
166
|
-
}
|
|
167
|
-
return response;
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
async function captureShoeboxEntry(
|
|
171
|
-
request: Request,
|
|
172
|
-
response: Response,
|
|
173
|
-
): Promise<void> {
|
|
174
|
-
try {
|
|
175
|
-
const clone = response.clone();
|
|
176
|
-
const body = await clone.text();
|
|
177
|
-
const headers: Record<string, string> = {};
|
|
178
|
-
clone.headers.forEach((v, k) => {
|
|
179
|
-
headers[k] = v;
|
|
180
|
-
});
|
|
181
|
-
shoeboxEntries?.set(request.url, {
|
|
182
|
-
url: request.url,
|
|
183
|
-
status: clone.status,
|
|
184
|
-
statusText: clone.statusText,
|
|
185
|
-
headers,
|
|
186
|
-
body,
|
|
187
|
-
});
|
|
188
|
-
} catch {
|
|
189
|
-
/* skip */
|
|
190
|
-
}
|
|
191
|
-
}
|
|
153
|
+
let activeShoebox: Map<string, ShoeboxEntry> | null = null;
|
|
154
|
+
let activeCookie: ForwardedCookie | null = null;
|
|
155
|
+
let activeAbort: AbortController | null = null;
|
|
156
|
+
|
|
157
|
+
const fetchWithMiddleware = compose(
|
|
158
|
+
[
|
|
159
|
+
forwardCookieMiddleware(() => activeCookie),
|
|
160
|
+
shoeboxMiddleware(() => activeShoebox),
|
|
161
|
+
abortSignalMiddleware(() => activeAbort?.signal ?? null),
|
|
162
|
+
],
|
|
163
|
+
(request) => realFetch(request),
|
|
164
|
+
);
|
|
192
165
|
|
|
193
166
|
// Install once — never needs to be restored.
|
|
194
|
-
globalThis.fetch =
|
|
167
|
+
globalThis.fetch = fetchWithMiddleware;
|
|
195
168
|
|
|
196
169
|
function serializeShoebox(entries: ShoeboxEntry[]): string {
|
|
197
170
|
if (entries.length === 0) return '';
|
|
@@ -237,27 +210,73 @@ function buildRouteCssLinks(
|
|
|
237
210
|
return links.join('');
|
|
238
211
|
}
|
|
239
212
|
|
|
213
|
+
let warnedMissingSettled = false;
|
|
214
|
+
|
|
215
|
+
async function awaitSettled(timeoutMs: number): Promise<void> {
|
|
216
|
+
if (!appSettled) {
|
|
217
|
+
if (timeoutMs > 0 && !warnedMissingSettled) {
|
|
218
|
+
warnedMissingSettled = true;
|
|
219
|
+
console.warn(
|
|
220
|
+
'[vite-ember-ssr] settledTimeout is set but the SSR bundle does not ' +
|
|
221
|
+
'export `settled` — renders will NOT wait for the app to settle ' +
|
|
222
|
+
'and may capture incomplete HTML. Add ' +
|
|
223
|
+
"`export { settled } from '@ember/test-helpers';` to your SSR entry.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
// Fallback: drain Backburner's autorun microtask before reading the DOM.
|
|
227
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
+
try {
|
|
233
|
+
await Promise.race([
|
|
234
|
+
appSettled(),
|
|
235
|
+
new Promise<never>((_, reject) => {
|
|
236
|
+
timer = setTimeout(
|
|
237
|
+
() => reject(new Error(`settled() timed out after ${timeoutMs}ms`)),
|
|
238
|
+
timeoutMs,
|
|
239
|
+
);
|
|
240
|
+
}),
|
|
241
|
+
]);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
console.warn(
|
|
244
|
+
`[vite-ember-ssr] settled() did not resolve within ${timeoutMs}ms, ` +
|
|
245
|
+
`capturing DOM anyway:`,
|
|
246
|
+
e instanceof Error ? e.message : e,
|
|
247
|
+
);
|
|
248
|
+
} finally {
|
|
249
|
+
if (timer) clearTimeout(timer);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
240
253
|
export default async function render(
|
|
241
254
|
options: WorkerRenderOptions,
|
|
242
255
|
): Promise<WorkerRenderResult> {
|
|
243
|
-
const { url, shoebox,
|
|
256
|
+
const { url, shoebox, cssManifest, settledTimeout, forwardCookie } = options;
|
|
244
257
|
|
|
245
258
|
// Use the long-lived document directly — no new Window, no globalThis swap.
|
|
246
259
|
const document = win.document;
|
|
247
260
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
261
|
+
// Set per-render state. The middlewares read these via getters, so a
|
|
262
|
+
// single shared pipeline can serve every render without re-installation.
|
|
263
|
+
activeShoebox = shoebox ? new Map() : null;
|
|
264
|
+
activeCookie = forwardCookie;
|
|
265
|
+
activeAbort = new AbortController();
|
|
251
266
|
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
267
|
+
// Snapshot the pre-render <head> state so the finally below can restore
|
|
268
|
+
// it. ember-page-title and similar addons write into <head> during the
|
|
269
|
+
// render; without a reset, one request's document title (which may contain
|
|
270
|
+
// private data) bleeds into every later render served by this worker.
|
|
271
|
+
const preRenderTitle = document.title;
|
|
272
|
+
const preRenderHead = document.head?.innerHTML ?? '';
|
|
255
273
|
|
|
256
274
|
let head = '';
|
|
257
275
|
let body = '';
|
|
258
276
|
let bodyAttrs: Record<string, string> = {};
|
|
259
277
|
let cssLinks = '';
|
|
260
278
|
let error: Error | undefined;
|
|
279
|
+
let instance: EmberApplicationInstance | undefined;
|
|
261
280
|
|
|
262
281
|
try {
|
|
263
282
|
const bootOptions: BootOptions = {
|
|
@@ -266,13 +285,15 @@ export default async function render(
|
|
|
266
285
|
document: document as unknown as Document,
|
|
267
286
|
rootElement: document.body as unknown as Element,
|
|
268
287
|
shouldRender: true,
|
|
269
|
-
|
|
288
|
+
_renderMode: 'serialize',
|
|
270
289
|
};
|
|
271
290
|
|
|
272
|
-
|
|
291
|
+
instance = await app.visit(url, bootOptions);
|
|
273
292
|
|
|
274
|
-
//
|
|
275
|
-
|
|
293
|
+
// Wait for the app to settle (test waiters, run loop, pending timers, etc.)
|
|
294
|
+
// before reading the DOM. Falls back to a microtask drain when the SSR
|
|
295
|
+
// bundle doesn't export `settled`.
|
|
296
|
+
await awaitSettled(settledTimeout);
|
|
276
297
|
|
|
277
298
|
if (cssManifest) cssLinks = buildRouteCssLinks(cssManifest, instance);
|
|
278
299
|
head = document.head?.innerHTML ?? '';
|
|
@@ -284,47 +305,74 @@ export default async function render(
|
|
|
284
305
|
bodyAttrs[attr.name] = attr.value;
|
|
285
306
|
}
|
|
286
307
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
308
|
+
} catch (e) {
|
|
309
|
+
error = e instanceof Error ? e : new Error(String(e));
|
|
310
|
+
} finally {
|
|
311
|
+
// Destroy the instance so its container is torn down cleanly. app.visit()
|
|
312
|
+
// creates a fresh ApplicationInstance per call; without destroying it the
|
|
313
|
+
// container's singletons (including location:none) remain live and can
|
|
314
|
+
// corrupt the next visit. This MUST run even when the render above throws
|
|
315
|
+
// (settle timeout, CSS build, DOM read) — otherwise the leaked instance
|
|
316
|
+
// accumulates in the long-lived worker. `instance` is undefined when
|
|
317
|
+
// app.visit() itself threw before assigning it. Guard the call: a destroy
|
|
318
|
+
// that throws inside this finally would skip the DOM reset below and mask
|
|
319
|
+
// the render's own error.
|
|
320
|
+
try {
|
|
321
|
+
instance?.destroy();
|
|
322
|
+
} catch {
|
|
323
|
+
/* instance teardown failed — the DOM reset below must still run */
|
|
298
324
|
}
|
|
299
325
|
|
|
300
|
-
//
|
|
326
|
+
// Serialize mode leaves rehydration markers in the DOM; reset the body so
|
|
327
|
+
// the next render starts from a clean slate regardless of success/failure.
|
|
301
328
|
if (document.body) {
|
|
329
|
+
document.body.innerHTML = '';
|
|
330
|
+
|
|
331
|
+
// Clear body attributes so they don't bleed into the next render.
|
|
302
332
|
for (const attr of Array.from(document.body.attributes)) {
|
|
303
333
|
document.body.removeAttribute(attr.name);
|
|
304
334
|
}
|
|
305
335
|
}
|
|
306
|
-
} catch (e) {
|
|
307
|
-
error = e instanceof Error ? e : new Error(String(e));
|
|
308
|
-
}
|
|
309
336
|
|
|
310
|
-
|
|
311
|
-
|
|
337
|
+
// Restore <head> (and the title) to its pre-render state. The rendered
|
|
338
|
+
// head HTML was already captured above, so anything the render added —
|
|
339
|
+
// <title> via ember-page-title, meta tags, etc. — must not survive into
|
|
340
|
+
// the next request's document.
|
|
341
|
+
if (document.head) document.head.innerHTML = preRenderHead;
|
|
342
|
+
if (document.title !== preRenderTitle) document.title = preRenderTitle;
|
|
343
|
+
|
|
344
|
+
// Clear web storage so values written during the render (user
|
|
345
|
+
// preferences, cached tokens) don't bleed into the next request.
|
|
346
|
+
try {
|
|
347
|
+
win.localStorage.clear();
|
|
348
|
+
win.sessionStorage.clear();
|
|
349
|
+
} catch {
|
|
350
|
+
/* storage unavailable in this happy-dom configuration */
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Abort any fetches this render left in flight (e.g. after a settled()
|
|
354
|
+
// timeout) so they stop consuming the connection instead of lingering
|
|
355
|
+
// into later renders. Their shoebox entries — if a response still
|
|
356
|
+
// arrives — go to this render's already-dead map (see shoeboxMiddleware).
|
|
357
|
+
activeAbort?.abort();
|
|
358
|
+
activeAbort = null;
|
|
359
|
+
}
|
|
312
360
|
|
|
313
361
|
const shoeboxHTML =
|
|
314
|
-
|
|
315
|
-
? serializeShoebox(Array.from(
|
|
362
|
+
activeShoebox && activeShoebox.size > 0
|
|
363
|
+
? serializeShoebox(Array.from(activeShoebox.values()))
|
|
316
364
|
: '';
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
365
|
+
|
|
366
|
+
// Clear per-render state so a stray late fetch can't see stale config.
|
|
367
|
+
activeShoebox = null;
|
|
368
|
+
activeCookie = null;
|
|
369
|
+
const rehydrateHTML =
|
|
370
|
+
'<script>window.__vite_ember_ssr_rehydrate__=true</script>';
|
|
320
371
|
const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;
|
|
321
|
-
const wrappedBody = rehydrate
|
|
322
|
-
? body
|
|
323
|
-
: `<script type="x/boundary" id="ssr-body-start"></script>${body}<script type="x/boundary" id="ssr-body-end"></script>`;
|
|
324
372
|
|
|
325
373
|
return {
|
|
326
374
|
head: fullHead,
|
|
327
|
-
body
|
|
375
|
+
body,
|
|
328
376
|
bodyAttrs,
|
|
329
377
|
statusCode: error ? 500 : 200,
|
|
330
378
|
...(error
|
package/dist/server.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;;;AAmBA;;;;UAAiB,gBAAA;EACf,KAAA,CAAM,GAAA,UAAa,OAAA,GAAU,WAAA,GAAc,OAAA,CAAQ,wBAAA;EACnD,OAAA;AAAA;AAAA,UAGe,wBAAA;EACf,OAAA;EACA,MAAA;EACA,OAAA;EACA,MAAA,EAAQ,QAAA;AAAA;AAAA,UAGO,WAAA;EACf,SAAA;EACA,aAAA;EACA,QAAA,EAAU,QAAA;EACV,WAAA,EAAa,OAAA;EACb,YAAA;EACA,QAAA;EACA,WAAA;AAAA;AAAA,UAGe,kBAAA;EAbf;;;;EAkBA,OAAA;EAf0B;;;;;;;;EAyB1B,SAAA;EApBA;;;;;AAKF;EAuBE,WAAA,GAAc,WAAA;;;;;;;;;;;AAoBhB;;;;;EAHE,OAAA,GAAU,MAAA;AAAA;AAAA,UAGK,YAAA;EAQf;EANA,IAAA;EAQQ;EANR,IAAA;EAMa;EAJb,SAAA,EAAW,MAAA;EAYgB;EAV3B,UAAA;EAce;EAZf,KAAA,GAAQ,KAAA;AAAA;;;;UAQO,YAAA;EACf,GAAA;EACA,MAAA;EACA,UAAA;EACA,OAAA,EAAS,MAAA;EACT,IAAA;AAAA;AAAA,UAKe,kBAAA;EAgBC;;;;;AAGlB;;;;;;;;;;EAHE,aAAA,GAAgB,IAAA,aAAiB,OAAA,CAAQ,MAAA;AAAA;AAAA,UAG1B,eAAA;;;;;;;;;;;;EAYf,OAAA;EAqDgE;;;;;AAgDlE;;;;;;;;;;;EAnFE,qBAAA;EAsFS;;;AAyFX;;;;;;;;;;AAkCA;;;;;EA7LE,cAAA;EA6L4D;;;AAe9D;EAtME,GAAA,GAAM,kBAAA;AAAA;AAAA,UAGS,QAAA;EAoMf;;;;;EA9LA,WAAA,CAAY,GAAA,UAAa,OAAA,GAAU,kBAAA,GAAqB,OAAA,CAAQ,YAAA;;;;;EAMhE,OAAA,IAAW,OAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA0CS,cAAA,CACpB,aAAA,UACA,OAAA,GAAS,eAAA,GACR,OAAA,CAAQ,QAAA;;;;;;;;;iBAyFK,YAAA,CACd,QAAA,UACA,QAAA,EAAU,IAAA,CAAK,YAAA;;;;iBAgCD,aAAA,CAAc,IAAA;EAAiB,IAAA;EAAe,IAAA;AAAA;;;;iBAexC,eAAA,CACpB,SAAA,WACC,OAAA,CAAQ,WAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin-CQou_tr5.d.ts","names":[],"sources":["../src/vite-plugin.ts"],"mappings":";;;cAaa,eAAA;AAAA,cACA,eAAA;AADb;;;;AAAA,cAOa,qBAAA;AANb;;;;;AAMA;;;;;AAoBA;;;;;AAkQA;;;AA5RA,KA0BY,WAAA,GAAc,MAAA;AAAA,UAkQT,qBAAA;EA2BD;;;;EAtBd,YAAA;EAsBuB;;;;EAhBvB,YAAA;AAAA;;;;;;;;;;AAwMF;;;;iBAxLgB,QAAA,CAAS,OAAA,GAAS,qBAAA,GAA6B,MAAA;AAAA,UA6F9C,qBAAA;EA2FyC;;;;;;;;;;;;;;EA5ExD,MAAA;;;;;;EAOA,QAAA;;;;;;;;;;;EAYA,OAAA;;;;;EAMA,MAAA;;;;;;;;;;;;;;;EAgBA,SAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmCc,QAAA,CAAS,OAAA,EAAS,qBAAA,GAAwB,MAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"vite-plugin-D-W5WQWe.js","names":[],"sources":["../src/vite-plugin.ts"],"sourcesContent":["import type { Plugin, PluginOption, ResolvedConfig, UserConfig } from 'vite';\nimport { join, dirname } from 'node:path';\nimport {\n mkdir,\n writeFile,\n readFile,\n rm,\n copyFile,\n access,\n} from 'node:fs/promises';\nimport { pathToFileURL } from 'node:url';\nimport { cpus } from 'node:os';\n\nexport const SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';\nexport const SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';\n\n/**\n * Name of the CSS manifest file generated during the client build.\n * Maps dynamic entry source modules to their associated CSS asset paths.\n */\nexport const CSS_MANIFEST_FILENAME = 'css-manifest.json';\n\n/**\n * The CSS manifest maps Ember route names to the CSS files that Vite\n * extracted from their lazy-loaded template chunks during the client build.\n *\n * Route names use Ember's dot-separated convention for nested routes:\n * - `about` for `app/templates/about.gts`\n * - `blog.post` for `app/templates/blog/post.gts`\n *\n * Example:\n * ```json\n * {\n * \"about\": [\"/assets/about-VWk4xp3e.css\"]\n * }\n * ```\n *\n * During SSR, the renderer queries the active route name from Ember's\n * router service and looks up CSS files to inject as `<link>` tags.\n */\nexport type CssManifest = Record<string, string[]>;\n\n/**\n * Derives an Ember route name from a source module path following\n * Ember's conventional file layout.\n *\n * `app/templates/about.gts` → `about`\n * `app/templates/blog/post.gts` → `blog.post`\n * `app/templates/index.gts` → `index`\n *\n * Returns undefined if the path doesn't match the convention.\n */\nfunction sourcePathToRouteName(\n facadeModuleId: string,\n root: string,\n): string | undefined {\n // Make the path relative to the project root\n let relativePath = facadeModuleId;\n if (relativePath.startsWith(root)) {\n relativePath = relativePath.slice(root.length);\n }\n // Strip leading slash\n if (relativePath.startsWith('/')) {\n relativePath = relativePath.slice(1);\n }\n\n // Match app/templates/<route-path>.<ext>\n const match = relativePath.match(\n /^app\\/templates\\/(.+)\\.(gts|gjs|hbs|ts|js)$/,\n );\n if (!match) return undefined;\n\n // Convert path separators to dots for nested routes\n return match[1].replace(/\\//g, '.');\n}\n\n/**\n * Minimal type for a Rollup output chunk with Vite metadata.\n * We define this locally to avoid a direct dependency on the 'rollup' package.\n */\ninterface OutputChunkWithMeta {\n type: 'chunk';\n isDynamicEntry: boolean;\n isEntry: boolean;\n facadeModuleId: string | null;\n name: string;\n fileName: string;\n imports: string[];\n viteMetadata?: {\n importedCss?: Set<string>;\n };\n}\n\n/**\n * Walks the Rollup output bundle and collects CSS files associated\n * with dynamic entry chunks. These are CSS imports that Vite extracted\n * from code-split chunks (e.g., lazy-loaded route templates).\n *\n * The main entry's CSS is already linked in the HTML template by Vite,\n * so we only collect CSS from `isDynamicEntry` chunks.\n *\n * When a component with CSS is shared across multiple lazy routes,\n * Vite extracts the shared CSS into a separate chunk. We walk each\n * dynamic entry's static `imports` graph to collect CSS from those\n * shared chunks too, skipping the main entry chunk (whose CSS is\n * already in the HTML template).\n *\n * Keys are Ember route names derived from the source file path using\n * Ember's conventional `app/templates/` directory structure.\n */\nfunction buildCssManifest(\n bundle: Record<string, { type: string }>,\n base: string,\n root: string,\n): CssManifest {\n const manifest: CssManifest = {};\n\n // Build a lookup of fileName → chunk for walking the import graph.\n const chunksByFile = new Map<string, OutputChunkWithMeta>();\n const mainEntryFiles = new Set<string>();\n\n for (const [, output] of Object.entries(bundle)) {\n if (output.type !== 'chunk') continue;\n const chunk = output as unknown as OutputChunkWithMeta;\n chunksByFile.set(chunk.fileName, chunk);\n\n // Track main entry chunks so we can exclude their CSS.\n // Main entry CSS is already linked in the HTML template by Vite.\n if (chunk.isEntry && !chunk.isDynamicEntry) {\n mainEntryFiles.add(chunk.fileName);\n }\n }\n\n /**\n * Recursively collect all CSS from a chunk and its static imports,\n * excluding main entry chunks (whose CSS is already in the template).\n */\n function collectCss(\n fileName: string,\n seen: Set<string>,\n css: Set<string>,\n ): void {\n if (seen.has(fileName)) return;\n seen.add(fileName);\n\n // Don't collect CSS from the main entry — it's already in the HTML.\n if (mainEntryFiles.has(fileName)) return;\n\n const chunk = chunksByFile.get(fileName);\n if (!chunk) return;\n\n const importedCss = chunk.viteMetadata?.importedCss;\n if (importedCss) {\n for (const cssFile of importedCss) {\n css.add(cssFile);\n }\n }\n\n // Walk static imports (shared chunks extracted by Vite).\n for (const imp of chunk.imports) {\n collectCss(imp, seen, css);\n }\n }\n\n for (const [, output] of Object.entries(bundle)) {\n if (output.type !== 'chunk') continue;\n\n const chunk = output as unknown as OutputChunkWithMeta;\n\n // Only collect CSS from dynamic entries (code-split chunks).\n if (!chunk.isDynamicEntry) continue;\n\n // Collect CSS from this chunk and all its static imports.\n const css = new Set<string>();\n collectCss(chunk.fileName, new Set(), css);\n\n if (css.size === 0) continue;\n\n // Derive the Ember route name from the source module path.\n // If the path doesn't match Ember conventions, fall back to\n // the chunk name (e.g., 'about' from 'about-B5EiMzMx.js').\n const routeName = chunk.facadeModuleId\n ? (sourcePathToRouteName(chunk.facadeModuleId, root) ?? chunk.name)\n : chunk.name;\n\n if (!routeName) continue;\n\n // Prefix CSS paths with the base URL so they work as href values.\n const cssFiles = Array.from(css).map((c) => `${base}${c}`);\n\n if (cssFiles.length > 0) {\n manifest[routeName] = cssFiles;\n }\n }\n\n return manifest;\n}\n\n/**\n * Returns SSR config appropriate for the current Vite command.\n *\n * Ember's virtual packages (`@glimmer/tracking`, `@ember/*`, etc.) are\n * provided by `ember-source` and not published as real npm packages.\n * When Vite externalizes a dependency that transitively imports one of\n * these virtual packages, Node's runtime module resolution fails under\n * pnpm's strict `node_modules` layout.\n *\n * For both production builds and dev mode:\n * - Clears any user-specified `ssr.external` (explicit string entries\n * take precedence over `noExternal` patterns in Vite, so we must\n * remove them to ensure `noExternal: [/./]` applies).\n * - Sets `ssr: { noExternal: [/./] }` so all deps go through Vite's\n * transform pipeline. This lets `@embroider/vite`'s resolver handle\n * virtual Ember/Glimmer packages that don't exist outside `ember-source`\n * under pnpm's strict `node_modules` layout.\n *\n * In dev mode, `ssrLoadModule` uses `SSRCompatModuleRunner` +\n * `ESModulesEvaluator`. Without bundling, this evaluates all module code\n * inline. CJS/UMD packages (e.g. `@warp-drive/utilities/string`,\n * `json-to-ast`) reference `module`, `exports`, or `global` which are not\n * available in the evaluator's context.\n *\n * The `cjsSsrShimTransform` hook (applied by `emberSsr()` and `emberSsg()`)\n * intercepts those files before they reach `ssrTransform` and wraps them\n * with a lightweight CommonJS shim, providing the missing `module`,\n * `exports`, and `global` bindings.\n *\n * See: https://github.com/evoactivity/vite-ember-ssr/issues/4\n */\nfunction ssrDepsConfig(\n userConfig: UserConfig,\n _command: 'build' | 'serve',\n): { ssr?: UserConfig['ssr'] } {\n if (userConfig.ssr) {\n delete userConfig.ssr.external;\n }\n return { ssr: { noExternal: [/./] } };\n}\n\n/**\n * Returns a Vite `transform` hook that wraps CJS/UMD modules encountered\n * during SSR transforms.\n *\n * When `noExternal: [/./]` is set, every dependency goes through Vite's\n * `ssrTransform` → `ESModulesEvaluator` pipeline. CJS/UMD files that use\n * `module`, `exports`, or `global` fail because those globals are not\n * available inside `ESModulesEvaluator`'s `AsyncFunction` context.\n *\n * This transform detects CJS/UMD content (no top-level `import`/`export`\n * statements, but contains `exports.xxx` or `module.exports`) and wraps\n * the code so that:\n * 1. `module`, `exports`, and `global` are available as local variables.\n * 2. The module's exports are re-exported as the ES default export.\n *\n * The heuristic is intentionally simple and conservative — it only fires\n * on files that have no ESM syntax at all, which covers the CJS/UMD\n * packages that appear in the Ember + WarpDrive dependency tree without\n * misidentifying genuine ESM files.\n */\nfunction cjsSsrShimTransform(\n code: string,\n _id: string,\n options?: { ssr?: boolean },\n): { code: string; map: null } | null {\n // Only apply during SSR transforms\n if (!options?.ssr) return null;\n\n // Skip if the file contains any top-level import/export → it's ESM\n if (/^(?:import\\s|export\\s|export\\{|export default)/m.test(code)) return null;\n\n // Only wrap files that use CommonJS exports or module.exports\n if (!/\\bexports\\s*[.[=]|\\bmodule\\s*\\.\\s*exports\\b/.test(code)) return null;\n\n const wrapped = `\\\nconst __cjs_module__ = { exports: {} };\nconst __cjs_exports__ = __cjs_module__.exports;\nconst __cjs_global__ = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : {};\n(function(module, exports, global) {\n${code}\n})(__cjs_module__, __cjs_exports__, __cjs_global__);\nexport default __cjs_module__.exports;\n`;\n return { code: wrapped, map: null };\n}\n\n/**\n * Flatten and filter a Vite plugins array, which may contain nested arrays,\n * falsy values, and Promise-wrapped entries.\n */\nfunction flatPlugins(plugins: PluginOption[] | undefined): Plugin[] {\n if (!plugins) return [];\n return (plugins as unknown[])\n .flat(Infinity)\n .filter(\n (p): p is Plugin => p != null && typeof p === 'object' && 'name' in p,\n );\n}\n\nexport interface EmberSsrPluginOptions {\n /**\n * Output directory for the client build.\n * @default 'dist/client'\n */\n clientOutDir?: string;\n\n /**\n * Output directory for the SSR build.\n * @default 'dist/server'\n */\n serverOutDir?: string;\n}\n\n/**\n * Vite plugin that configures SSR support for Ember applications.\n *\n * Handles all SSR-related Vite configuration automatically:\n *\n * - Bundles all dependencies into SSR builds (`ssr.noExternal: [/./]`)\n * to avoid runtime resolution failures under pnpm's strict\n * node_modules layout (see issue #4)\n * - Sets build defaults: `dist/client` for client builds,\n * `dist/server` with `target: 'node22'` for SSR builds\n * - Writes a `package.json` with `\"type\": \"module\"` to the SSR\n * build output directory (needed for Node ESM compatibility)\n */\nexport function emberSsr(options: EmberSsrPluginOptions = {}): Plugin {\n let resolvedConfig: ResolvedConfig;\n\n return {\n name: 'vite-ember-ssr',\n\n config(userConfig, env): UserConfig {\n // Bundle all dependencies for SSR builds and dev mode to avoid runtime\n // failures under pnpm's strict node_modules layout when external packages\n // transitively import virtual Ember/Glimmer packages (e.g.\n // @glimmer/tracking) that only exist inside ember-source.\n // In dev mode, the `transform: cjsSsrShimTransform` hook wraps\n // CJS/UMD packages so they work with ESModulesEvaluator.\n // See: https://github.com/evoactivity/vite-ember-ssr/issues/4\n const ssrConfig = ssrDepsConfig(userConfig, env.command);\n\n // During the SSG child build, only set ssr config — don't\n // override build.outDir (the SSG plugin sets it explicitly\n // via inline config to a temp directory).\n if (process.env.__VITE_EMBER_SSG_CHILD__) {\n return ssrConfig;\n }\n\n if (env.isSsrBuild) {\n return {\n ...ssrConfig,\n build: {\n outDir: options.serverOutDir ?? 'dist/server',\n target: 'node22',\n sourcemap: true,\n minify: false,\n },\n };\n }\n\n return {\n ...ssrConfig,\n build: {\n outDir: options.clientOutDir ?? 'dist/client',\n },\n };\n },\n\n configResolved(config) {\n resolvedConfig = config;\n },\n\n transform: cjsSsrShimTransform,\n\n generateBundle(_outputOptions, bundle) {\n // Only generate the CSS manifest for client builds.\n // SSR builds strip CSS imports, so they have nothing to map.\n if (resolvedConfig.build.ssr) return;\n\n // Don't generate during the SSG child build (it's an SSR build)\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const base = resolvedConfig.base ?? '/';\n const root = resolvedConfig.root;\n const manifest = buildCssManifest(bundle, base, root);\n\n // Only emit the manifest if there are dynamic entries with CSS.\n // Apps without lazy-loaded CSS don't need this file.\n if (Object.keys(manifest).length === 0) return;\n\n this.emitFile({\n type: 'asset',\n fileName: CSS_MANIFEST_FILENAME,\n source: JSON.stringify(manifest, null, 2),\n });\n },\n\n async closeBundle() {\n // Only write package.json for SSR builds\n if (!resolvedConfig.build.ssr) return;\n\n // Don't interfere with the SSG child build's temp directory\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const outDir = join(resolvedConfig.root, resolvedConfig.build.outDir);\n const targetPath = join(outDir, 'package.json');\n await mkdir(outDir, { recursive: true });\n await writeFile(\n targetPath,\n JSON.stringify({ type: 'module' }, null, 2),\n 'utf-8',\n );\n },\n };\n}\n\n// ─── SSG Plugin ──────────────────────────────────────────────────────\n\nexport interface EmberSsgPluginOptions {\n /**\n * Routes to prerender as static HTML files.\n *\n * Each entry is a route path (without leading slash).\n * 'index' produces `index.html` at the root, other routes produce\n * `<route>/index.html` (e.g., 'about' → `about/index.html`).\n *\n * @example\n * ```js\n * emberSsg({\n * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],\n * })\n * ```\n */\n routes: string[];\n\n /**\n * The SSR entry module path, relative to the project root.\n * This file must export a `createSsrApp` function.\n * @default 'app/app-ssr.ts'\n */\n ssrEntry?: string;\n\n /**\n * Enable shoebox (fetch replay) for prerendered pages.\n *\n * When true, fetch responses from route model hooks are captured during\n * prerendering and serialized into the HTML. The client calls\n * `installShoebox()` before boot to replay those responses and avoid\n * duplicate API requests.\n *\n * @default false\n */\n shoebox?: boolean;\n\n /**\n * Output directory for the client build.\n * @default 'dist'\n */\n outDir?: string;\n\n /**\n * Enable Glimmer rehydration for prerendered pages.\n *\n * When `true`, the server renders with `_renderMode: 'serialize'`,\n * annotating the DOM with Glimmer markers. The client boots with\n * `app.visit(url, { _renderMode: 'rehydrate' })` to reuse the\n * static DOM instead of replacing it.\n *\n * When `false` (default), boundary markers are emitted and the\n * client uses `cleanupSSRContent()` in the application template\n * to remove the SSR content before Ember renders fresh.\n *\n * @default false\n */\n rehydrate?: boolean;\n}\n\n/**\n * Vite plugin for Static Site Generation (SSG) of Ember applications.\n *\n * Prerenders the specified routes to static HTML files at build time.\n * Fully self-contained — only a single `vite build` is needed.\n *\n * After the client build completes, the plugin runs a second SSR build\n * via `vite.build()` to produce a bundled SSR entry module, imports it,\n * renders each route using HappyDOM, and writes the resulting HTML files\n * into the client output directory. The temporary SSR bundle is cleaned\n * up automatically.\n *\n * All dependencies are bundled into the SSR output (no externals) to\n * avoid runtime resolution failures under pnpm's strict node_modules\n * layout. See issue #4.\n *\n * @example\n * ```js\n * // vite.config.mjs\n * import { emberSsg } from '@st-h/vite-ember-ssr/vite-plugin';\n *\n * export default defineConfig({\n * plugins: [\n * ember(),\n * babel({ babelHelpers: 'runtime', extensions }),\n * emberSsg({\n * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],\n * }),\n * ],\n * });\n * ```\n */\nexport function emberSsg(options: EmberSsgPluginOptions): Plugin {\n const {\n routes,\n ssrEntry = 'app/app-ssr.ts',\n shoebox = false,\n rehydrate = false,\n } = options;\n\n // Track whether the user explicitly provided outDir\n const explicitOutDir = options.outDir;\n\n let resolvedConfig: ResolvedConfig;\n\n // Whether emberSsr is also registered — detected in config() hook\n let isCombined = false;\n\n return {\n name: 'vite-ember-ssg',\n\n config(userConfig, env): UserConfig {\n // Bundle all dependencies for SSR builds — see ssrDepsConfig().\n const ssrConfig = ssrDepsConfig(userConfig, env.command);\n\n // During the SSG child build, only set ssr config — don't touch\n // build.outDir or detect isCombined (irrelevant for child build).\n if (process.env.__VITE_EMBER_SSG_CHILD__) {\n return ssrConfig;\n }\n\n // Detect if emberSsr is also registered in this config.\n // When combined, defer build.outDir to emberSsr so that\n // prerendered files land in the SSR client directory.\n isCombined = flatPlugins(userConfig.plugins).some(\n (p) => p.name === 'vite-ember-ssr',\n );\n\n // Only set outDir when:\n // - the user explicitly passed outDir to emberSsg, OR\n // - emberSsr is NOT present (standalone SSG mode, default 'dist')\n const outDir = explicitOutDir ?? (isCombined ? undefined : 'dist');\n\n return {\n ...ssrConfig,\n ...(outDir != null ? { build: { outDir } } : {}),\n };\n },\n\n configResolved(config) {\n resolvedConfig = config;\n },\n\n transform: cjsSsrShimTransform,\n\n generateBundle(_outputOptions, bundle) {\n // When combined with emberSsr, the SSR plugin already emits\n // the CSS manifest — skip to avoid duplicate emission.\n if (isCombined) return;\n\n // Only generate the CSS manifest for client builds.\n if (resolvedConfig.build.ssr) return;\n\n // Don't generate during the SSG child build (it's an SSR build)\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const base = resolvedConfig.base ?? '/';\n const root = resolvedConfig.root;\n const manifest = buildCssManifest(bundle, base, root);\n\n if (Object.keys(manifest).length === 0) return;\n\n this.emitFile({\n type: 'asset',\n fileName: CSS_MANIFEST_FILENAME,\n source: JSON.stringify(manifest, null, 2),\n });\n },\n\n async closeBundle() {\n // Don't prerender during SSR builds (if the user also has emberSsr)\n if (resolvedConfig.build.ssr) return;\n\n // Prevent recursive prerendering when the child build\n // loads the same config file and re-registers this plugin.\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const { build: viteBuild } = await import('vite');\n const { assembleHTML, createEmberApp } = await import('./server.js');\n\n const root = resolvedConfig.root;\n const clientDir = join(root, resolvedConfig.build.outDir);\n const ssrOutDir = join(root, '.ssg-tmp');\n\n console.log('\\n[vite-ember-ssg] Prerendering routes...');\n\n // Read the built client index.html as template\n const templatePath = join(clientDir, 'index.html');\n let template: string;\n try {\n template = await readFile(templatePath, 'utf-8');\n } catch (e) {\n console.error(\n `[vite-ember-ssg] Failed to read template at ${templatePath}.`,\n );\n throw e;\n }\n\n // Read the CSS manifest (if it exists) so we can inject\n // lazy-loaded CSS into prerendered pages.\n let cssManifest: CssManifest | undefined;\n const cssManifestPath = join(clientDir, CSS_MANIFEST_FILENAME);\n try {\n const raw = await readFile(cssManifestPath, 'utf-8');\n cssManifest = JSON.parse(raw) as CssManifest;\n } catch {\n // No CSS manifest — app has no lazy-loaded CSS\n }\n\n // When combined with emberSsr, preserve the original index.html\n // as _template.html before prerendering overwrites it. The\n // production server reads _template.html for dynamic SSR rendering.\n if (isCombined) {\n const savedTemplatePath = join(clientDir, '_template.html');\n await copyFile(templatePath, savedTemplatePath);\n console.log(\n ` [vite-ember-ssg] Saved SSR template → ${savedTemplatePath.replace(root + '/', '')}`,\n );\n }\n\n // ── Step 1: Build the SSR bundle ────────────────────────────\n // Run vite.build() with ssr entry to produce a fully bundled\n // ESM module. This handles all CJS→ESM transforms, Babel,\n // Glimmer template compilation, etc. at build time.\n process.env.__VITE_EMBER_SSG_CHILD__ = '1';\n\n try {\n await viteBuild({\n root,\n configFile: resolvedConfig.configFile || undefined,\n logLevel: 'warn',\n build: {\n ssr: ssrEntry,\n outDir: ssrOutDir,\n target: 'node22',\n minify: false,\n sourcemap: false,\n },\n ssr: {\n // Belt-and-suspenders: the config hooks already call\n // ssrDepsConfig() for the child build, but setting it here\n // in inline config guarantees it even if the user's config\n // file doesn't register the SSR/SSG plugins for some reason.\n noExternal: [/./],\n },\n });\n } catch (e) {\n console.error('[vite-ember-ssg] SSR build failed:', e);\n throw e;\n } finally {\n delete process.env.__VITE_EMBER_SSG_CHILD__;\n }\n\n // Write package.json so Node loads the bundle as ESM\n await writeFile(\n join(ssrOutDir, 'package.json'),\n JSON.stringify({ type: 'module' }, null, 2),\n 'utf-8',\n );\n\n // ── Step 2: Import the SSR bundle and prerender ─────────────\n let successCount = 0;\n let errorCount = 0;\n\n try {\n // Determine the output filename — Vite names SSR output\n // after the entry: 'app/app-ssr.ts' → 'app-ssr.mjs'.\n // Some Vite versions using Rolldown output '.js' instead of '.mjs',\n // so we try both extensions.\n const entryBasename = ssrEntry\n .split('/')\n .pop()!\n .replace(/\\.[^.]+$/, '');\n\n let ssrBundlePath = join(ssrOutDir, `${entryBasename}.mjs`);\n try {\n await access(ssrBundlePath);\n } catch {\n ssrBundlePath = join(ssrOutDir, `${entryBasename}.js`);\n }\n const ssrBundleURL = pathToFileURL(ssrBundlePath).href;\n\n // Prerender all routes in parallel using a long-lived worker pool.\n // Workers import the SSR bundle once and reuse it across renders,\n // making per-render cost ~4ms vs ~200ms for a fresh-worker approach.\n const app = await createEmberApp(ssrBundleURL, {\n workers: cpus().length,\n });\n\n try {\n await Promise.all(\n routes.map(async (route) => {\n const url = route === 'index' ? '/' : `/${route}`;\n\n try {\n const result = await app.renderRoute(url, {\n shoebox,\n rehydrate,\n cssManifest,\n });\n\n if (result.error) {\n console.error(\n ` [vite-ember-ssg] Error rendering ${url}:\\n` +\n (result.error.stack ?? result.error.message),\n );\n errorCount++;\n return;\n }\n\n const html = assembleHTML(template, result);\n\n // 'index' → index.html (overwrite the shell)\n // 'about' → about/index.html\n // 'pokemon/charmander' → pokemon/charmander/index.html\n const outputPath =\n route === 'index'\n ? join(clientDir, 'index.html')\n : join(clientDir, route, 'index.html');\n\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, html, 'utf-8');\n\n console.log(\n ` [vite-ember-ssg] ${url} → ${outputPath.replace(root + '/', '')}`,\n );\n successCount++;\n } catch (e) {\n console.error(\n ` [vite-ember-ssg] Failed to prerender ${url}:\\n` +\n (e instanceof Error ? (e.stack ?? e.message) : String(e)),\n );\n errorCount++;\n }\n }),\n );\n } finally {\n await app.destroy();\n }\n } finally {\n // ── Step 3: Clean up the temporary SSR bundle ─────────────\n await rm(ssrOutDir, { recursive: true, force: true });\n }\n\n console.log(\n `[vite-ember-ssg] Done. ${successCount} pages generated` +\n (errorCount > 0 ? `, ${errorCount} errors` : '') +\n '.',\n );\n\n if (errorCount > 0 && successCount === 0) {\n throw new Error('[vite-ember-ssg] All routes failed to prerender.');\n }\n },\n };\n}\n\nexport default emberSsr;\n"],"mappings":"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"}
|