@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/LICENSE.md +7 -0
- package/README.md +733 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/server.d.ts +236 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +349 -0
- package/dist/server.js.map +1 -0
- package/dist/vite-plugin-CQou_tr5.d.ts +145 -0
- package/dist/vite-plugin-CQou_tr5.d.ts.map +1 -0
- package/dist/vite-plugin-D-W5WQWe.js +398 -0
- package/dist/vite-plugin-D-W5WQWe.js.map +1 -0
- package/dist/vite-plugin.d.ts +2 -0
- package/dist/vite-plugin.js +2 -0
- package/dist/worker.d.ts +22 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +186 -0
- package/dist/worker.js.map +1 -0
- package/package.json +73 -0
- package/src/client.ts +242 -0
- package/src/dev.ts +318 -0
- package/src/server.ts +399 -0
- package/src/vite-plugin.ts +775 -0
- package/src/worker.ts +334 -0
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, '"')}"`)
|
|
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
|
+
}
|