@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/src/server.ts ADDED
@@ -0,0 +1,399 @@
1
+ import { pathToFileURL, fileURLToPath } from 'node:url';
2
+ import { cpus } from 'node:os';
3
+ import type { CssManifest } from './vite-plugin.js';
4
+ import { createDevEmberApp } from './dev.js';
5
+
6
+ // ─── Worker script path ───────────────────────────────────────────────
7
+
8
+ // Resolve the worker script relative to this compiled file.
9
+ // In the dist/ output both server.js and worker.js sit side-by-side.
10
+ const WORKER_PATH = fileURLToPath(new URL('./worker.js', import.meta.url));
11
+
12
+ // ─── Types ───────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Minimal interface for an Ember Application that supports SSR.
16
+ *
17
+ * The app must be created with `autoboot: false` so the server can
18
+ * control boot timing via `app.visit(url, options)`.
19
+ */
20
+ export interface EmberApplication {
21
+ visit(url: string, options?: BootOptions): Promise<EmberApplicationInstance>;
22
+ destroy(): void;
23
+ }
24
+
25
+ export interface EmberApplicationInstance {
26
+ destroy(): void;
27
+ getURL?(): string;
28
+ _booted?: boolean;
29
+ lookup?(fullName: string): unknown;
30
+ }
31
+
32
+ export interface BootOptions {
33
+ isBrowser: boolean;
34
+ isInteractive?: boolean;
35
+ document: Document;
36
+ rootElement: Element;
37
+ shouldRender: boolean;
38
+ location?: string;
39
+ _renderMode?: 'serialize' | 'rehydrate' | undefined;
40
+ }
41
+
42
+ export interface RenderRouteOptions {
43
+ /**
44
+ * When true, intercepts all fetch() calls during SSR rendering and
45
+ * serializes the responses into a <script> tag in the HTML output.
46
+ */
47
+ shoebox?: boolean;
48
+
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
+ /**
60
+ * CSS manifest mapping route names to their associated CSS asset paths.
61
+ *
62
+ * Generated automatically by the `emberSsr()` Vite plugin during the
63
+ * client build (written as `css-manifest.json`).
64
+ */
65
+ cssManifest?: CssManifest;
66
+
67
+ /**
68
+ * HTTP headers from the incoming request to forward to fetch() calls
69
+ * made during SSR rendering.
70
+ *
71
+ * Use this to forward authentication cookies, authorization tokens,
72
+ * or other request-scoped headers so the SSR render can make
73
+ * authenticated API calls on behalf of the user.
74
+ *
75
+ * Only the specified headers are forwarded. Common usage:
76
+ * ```js
77
+ * const rendered = await app.renderRoute(req.url, {
78
+ * headers: { cookie: req.headers.cookie },
79
+ * });
80
+ * ```
81
+ */
82
+ headers?: Record<string, string>;
83
+ }
84
+
85
+ export interface RenderResult {
86
+ /** Rendered HTML from the document's <head> */
87
+ head: string;
88
+ /** Rendered HTML from the document's <body> */
89
+ body: string;
90
+ /** Attributes set on the <body> element during rendering (e.g., data-theme, class) */
91
+ bodyAttrs: Record<string, string>;
92
+ /** HTTP status code (200 by default) */
93
+ statusCode: number;
94
+ /** Any error that occurred during rendering */
95
+ error?: Error;
96
+ }
97
+
98
+ // ─── Shoebox Types ───────────────────────────────────────────────────
99
+
100
+ /**
101
+ * A captured fetch response for transfer from server to client.
102
+ */
103
+ export interface ShoeboxEntry {
104
+ url: string;
105
+ status: number;
106
+ statusText: string;
107
+ headers: Record<string, string>;
108
+ body: string;
109
+ }
110
+
111
+ // ─── EmberApp ────────────────────────────────────────────────────────
112
+
113
+ export interface EmberAppDevOptions {
114
+ /**
115
+ * Vite's `ssrLoadModule` function from the dev server.
116
+ *
117
+ * When provided, `createEmberApp` skips tinypool entirely and renders
118
+ * in-process using Vite's module resolution pipeline. The SSR entry is
119
+ * re-loaded on every render so HMR changes are reflected immediately.
120
+ *
121
+ * Obtain this from your Vite dev server instance:
122
+ * ```js
123
+ * const vite = await createServer({ ... });
124
+ * await createEmberApp('app/app-ssr.ts', {
125
+ * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },
126
+ * });
127
+ * ```
128
+ */
129
+ ssrLoadModule: (path: string) => Promise<Record<string, unknown>>;
130
+ }
131
+
132
+ export interface EmberAppOptions {
133
+ /**
134
+ * Number of long-lived worker threads in the pool.
135
+ *
136
+ * Each worker imports the SSR bundle once and handles all subsequent
137
+ * render requests without re-importing — making per-render cost ~4ms
138
+ * instead of ~200ms for a fresh-worker approach.
139
+ *
140
+ * Ignored when `dev` is provided.
141
+ *
142
+ * @default os.cpus().length
143
+ */
144
+ workers?: number;
145
+
146
+ /**
147
+ * How often (in milliseconds) to recycle all workers in the pool.
148
+ *
149
+ * When set, `pool.recycleWorkers()` is called on this interval —
150
+ * tinypool waits for all in-flight tasks to complete, then replaces
151
+ * every worker with a fresh one. This bounds memory growth in
152
+ * long-running processes where workers accumulate state over time.
153
+ *
154
+ * Set to `0` or omit to disable periodic recycling.
155
+ *
156
+ * Ignored when `dev` is provided.
157
+ *
158
+ * @example
159
+ * // Recycle workers every hour
160
+ * await createEmberApp(bundlePath, { recycleWorkerInterval: 60 * 60 * 1000 });
161
+ */
162
+ recycleWorkerInterval?: number;
163
+
164
+ /**
165
+ * When `true`, each render task is handled by a freshly-started worker.
166
+ *
167
+ * This maps directly to tinypool's `isolateWorkers` option. The worker is
168
+ * replaced after every task, so module-level state (caches, singletons,
169
+ * open handles) never bleeds between requests. The trade-off is that every
170
+ * render pays the full worker-startup and bundle-import cost instead of
171
+ * reusing a warm worker.
172
+ *
173
+ * For most apps the default (long-lived, warm workers) is preferred.
174
+ * Enable isolation when you need strict request-level process boundaries,
175
+ * e.g. when the SSR bundle keeps global state that cannot be reset between
176
+ * renders.
177
+ *
178
+ * Ignored when `dev` is provided.
179
+ *
180
+ * @default false
181
+ */
182
+ isolateWorkers?: boolean;
183
+
184
+ /**
185
+ * Dev mode options. When provided, skips tinypool and renders in-process
186
+ * via Vite's `ssrLoadModule` so HMR changes are picked up on every render.
187
+ */
188
+ dev?: EmberAppDevOptions;
189
+ }
190
+
191
+ export interface EmberApp {
192
+ /**
193
+ * Renders a route and returns the raw head/body HTML fragments.
194
+ *
195
+ * @param url The URL path to render, e.g. `'/'` or `'/about'`
196
+ */
197
+ renderRoute(url: string, options?: RenderRouteOptions): Promise<RenderResult>;
198
+
199
+ /**
200
+ * Shuts down the worker pool. Call this when the app server is
201
+ * stopping or after SSG prerendering is complete.
202
+ */
203
+ destroy(): Promise<void>;
204
+ }
205
+
206
+ // ─── EmberApp factory ────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Creates a long-lived worker thread pool for SSR/SSG rendering.
210
+ *
211
+ * Each worker imports the SSR bundle once at startup and reuses it for all
212
+ * subsequent renders — no bundle re-import, no Worker respawn.
213
+ *
214
+ * Pass `dev: { ssrLoadModule }` to run in dev mode instead: renders happen
215
+ * in-process via Vite's module resolution pipeline with no tinypool workers.
216
+ * The SSR entry is re-loaded on every render so HMR changes are reflected
217
+ * immediately.
218
+ *
219
+ * @example Production
220
+ * ```js
221
+ * import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';
222
+ * import { resolve } from 'node:path';
223
+ *
224
+ * const app = await createEmberApp(resolve('dist/server/app-ssr.mjs'));
225
+ *
226
+ * // In a request handler:
227
+ * const result = await app.renderRoute(req.url);
228
+ * const html = assembleHTML(template, result);
229
+ *
230
+ * // On server shutdown:
231
+ * await app.destroy();
232
+ * ```
233
+ *
234
+ * @example Development
235
+ * ```js
236
+ * import { createServer } from 'vite';
237
+ * import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';
238
+ *
239
+ * const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
240
+ * const app = await createEmberApp('app/app-ssr.ts', {
241
+ * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },
242
+ * });
243
+ * ```
244
+ */
245
+ export async function createEmberApp(
246
+ ssrBundlePath: string,
247
+ options: EmberAppOptions = {},
248
+ ): Promise<EmberApp> {
249
+ if (options.dev) {
250
+ return createDevEmberApp(ssrBundlePath, options.dev);
251
+ }
252
+
253
+ const bundleURL = ssrBundlePath.startsWith('file://')
254
+ ? ssrBundlePath
255
+ : pathToFileURL(ssrBundlePath).href;
256
+
257
+ const workerCount = options.workers ?? cpus().length;
258
+
259
+ const { default: Tinypool } = await import('tinypool');
260
+ const pool = new Tinypool({
261
+ filename: WORKER_PATH,
262
+ minThreads: workerCount,
263
+ maxThreads: workerCount,
264
+ isolateWorkers: options.isolateWorkers ?? false,
265
+ // Pass the bundle URL so the worker can import it eagerly at startup,
266
+ // paying the cold-start cost once (at server init) rather than on the
267
+ // first render request.
268
+ workerData: { ssrBundlePath: bundleURL },
269
+ });
270
+
271
+ // Schedule periodic worker recycling when requested. pool.recycleWorkers()
272
+ // waits for all in-flight renders to finish before replacing every worker
273
+ // with a fresh one, bounding memory growth in long-running processes.
274
+ let recycleTimer: ReturnType<typeof setInterval> | undefined;
275
+ const recycleInterval = options.recycleWorkerInterval ?? 0;
276
+ if (recycleInterval > 0) {
277
+ recycleTimer = setInterval(() => {
278
+ pool.recycleWorkers().catch(() => {
279
+ // recycleWorkers rejects only if the pool is already being destroyed;
280
+ // swallow the error to avoid an unhandled rejection on shutdown.
281
+ });
282
+ }, recycleInterval);
283
+ // Allow the process to exit naturally without waiting for the next tick.
284
+ recycleTimer.unref();
285
+ }
286
+
287
+ return {
288
+ async renderRoute(
289
+ url: string,
290
+ renderOptions: RenderRouteOptions = {},
291
+ ): Promise<RenderResult> {
292
+ const result = (await pool.run({
293
+ ssrBundlePath: bundleURL,
294
+ url,
295
+ shoebox: renderOptions.shoebox ?? false,
296
+ rehydrate: renderOptions.rehydrate ?? false,
297
+ cssManifest: renderOptions.cssManifest ?? null,
298
+ headers: renderOptions.headers ?? null,
299
+ })) as {
300
+ head: string;
301
+ body: string;
302
+ bodyAttrs: Record<string, string>;
303
+ statusCode: number;
304
+ error?: string;
305
+ };
306
+
307
+ return {
308
+ head: result.head,
309
+ body: result.body,
310
+ bodyAttrs: result.bodyAttrs ?? {},
311
+ statusCode: result.statusCode,
312
+ error: result.error ? new Error(result.error) : undefined,
313
+ };
314
+ },
315
+
316
+ async destroy(): Promise<void> {
317
+ clearInterval(recycleTimer);
318
+ await pool.destroy();
319
+ },
320
+ };
321
+ }
322
+
323
+ // ─── HTML Assembly ───────────────────────────────────────────────────
324
+
325
+ const SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';
326
+ const SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';
327
+ const SSR_MARKER_REGEX = /<!-- VITE_EMBER_SSR_(HEAD|BODY) -->/g;
328
+
329
+ /**
330
+ * Assembles the final HTML response by inserting rendered content
331
+ * into the index.html template.
332
+ *
333
+ * When `rendered.bodyAttrs` is provided, attributes set on the `<body>`
334
+ * element during SSR (e.g., `data-theme`, `class`) are applied to the
335
+ * `<body>` tag in the template HTML.
336
+ */
337
+ export function assembleHTML(
338
+ template: string,
339
+ rendered: Pick<RenderResult, 'head' | 'body' | 'bodyAttrs'>,
340
+ ): string {
341
+ let headReplaced = false;
342
+ let bodyReplaced = false;
343
+
344
+ let html = template.replace(SSR_MARKER_REGEX, (_match, tag: string) => {
345
+ if (tag === 'HEAD' && !headReplaced) {
346
+ headReplaced = true;
347
+ return rendered.head;
348
+ }
349
+ if (tag === 'BODY' && !bodyReplaced) {
350
+ bodyReplaced = true;
351
+ return rendered.body;
352
+ }
353
+ return '';
354
+ });
355
+
356
+ // Apply body attributes from SSR rendering
357
+ const attrs = rendered.bodyAttrs;
358
+ if (attrs && Object.keys(attrs).length > 0) {
359
+ const attrString = Object.entries(attrs)
360
+ .map(([key, value]) => `${key}="${value.replace(/"/g, '&quot;')}"`)
361
+ .join(' ');
362
+ html = html.replace(/<body([^>]*)>/, `<body$1 ${attrString}>`);
363
+ }
364
+
365
+ return html;
366
+ }
367
+
368
+ /**
369
+ * Checks whether an HTML template contains the required SSR markers.
370
+ */
371
+ export function hasSSRMarkers(html: string): { head: boolean; body: boolean } {
372
+ return {
373
+ head: html.includes(SSR_HEAD_MARKER),
374
+ body: html.includes(SSR_BODY_MARKER),
375
+ };
376
+ }
377
+
378
+ // ─── CSS Manifest Loading ────────────────────────────────────────────
379
+
380
+ export type { CssManifest } from './vite-plugin.js';
381
+ export { CSS_MANIFEST_FILENAME } from './vite-plugin.js';
382
+
383
+ /**
384
+ * Loads the CSS manifest from the client build output directory.
385
+ */
386
+ export async function loadCssManifest(
387
+ clientDir: string,
388
+ ): Promise<CssManifest | undefined> {
389
+ const { readFile } = await import('node:fs/promises');
390
+ const { join } = await import('node:path');
391
+ const { CSS_MANIFEST_FILENAME: filename } = await import('./vite-plugin.js');
392
+
393
+ try {
394
+ const raw = await readFile(join(clientDir, filename), 'utf-8');
395
+ return JSON.parse(raw) as CssManifest;
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ }