@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/dev.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode SSR renderer for vite-ember-ssr.
|
|
3
|
+
*
|
|
4
|
+
* Renders in-process using Vite's `ssrLoadModule` pipeline instead of a
|
|
5
|
+
* tinypool worker pool. The SSR entry module is re-loaded on every render
|
|
6
|
+
* so HMR changes are picked up immediately.
|
|
7
|
+
*
|
|
8
|
+
* A fresh HappyDOM Window is created and torn down for each render — there
|
|
9
|
+
* is no long-lived state between requests.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* ```js
|
|
13
|
+
* import { createServer } from 'vite';
|
|
14
|
+
* import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';
|
|
15
|
+
*
|
|
16
|
+
* const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
|
|
17
|
+
* const app = await createEmberApp('app/app-ssr.ts', {
|
|
18
|
+
* dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* // In your catch-all handler:
|
|
22
|
+
* const rendered = await app.renderRoute(req.url);
|
|
23
|
+
* const html = assembleHTML(template, rendered);
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Window } from 'happy-dom';
|
|
28
|
+
import type {
|
|
29
|
+
EmberApplication,
|
|
30
|
+
EmberApplicationInstance,
|
|
31
|
+
BootOptions,
|
|
32
|
+
RenderRouteOptions,
|
|
33
|
+
RenderResult,
|
|
34
|
+
ShoeboxEntry,
|
|
35
|
+
EmberApp,
|
|
36
|
+
EmberAppDevOptions,
|
|
37
|
+
} from './server.js';
|
|
38
|
+
|
|
39
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const BROWSER_GLOBALS = [
|
|
42
|
+
'window',
|
|
43
|
+
'document',
|
|
44
|
+
'navigator',
|
|
45
|
+
'location',
|
|
46
|
+
'history',
|
|
47
|
+
'HTMLElement',
|
|
48
|
+
'Element',
|
|
49
|
+
'Node',
|
|
50
|
+
'Event',
|
|
51
|
+
'CustomEvent',
|
|
52
|
+
'MutationObserver',
|
|
53
|
+
'requestAnimationFrame',
|
|
54
|
+
'cancelAnimationFrame',
|
|
55
|
+
'self',
|
|
56
|
+
'localStorage',
|
|
57
|
+
'sessionStorage',
|
|
58
|
+
'InputEvent',
|
|
59
|
+
'KeyboardEvent',
|
|
60
|
+
'MouseEvent',
|
|
61
|
+
'FocusEvent',
|
|
62
|
+
'PointerEvent',
|
|
63
|
+
'IntersectionObserver',
|
|
64
|
+
'ResizeObserver',
|
|
65
|
+
'CSSStyleSheet',
|
|
66
|
+
] as const;
|
|
67
|
+
|
|
68
|
+
const SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';
|
|
69
|
+
const SSR_BODY_START =
|
|
70
|
+
'<script type="x/boundary" id="ssr-body-start"></script>';
|
|
71
|
+
const SSR_BODY_END = '<script type="x/boundary" id="ssr-body-end"></script>';
|
|
72
|
+
|
|
73
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function installGlobals(win: Window): Record<string, unknown> {
|
|
76
|
+
const saved: Record<string, unknown> = {};
|
|
77
|
+
for (const name of BROWSER_GLOBALS) {
|
|
78
|
+
saved[name] = (globalThis as Record<string, unknown>)[name];
|
|
79
|
+
try {
|
|
80
|
+
Object.defineProperty(globalThis, name, {
|
|
81
|
+
value: (win as unknown as Record<string, unknown>)[name],
|
|
82
|
+
writable: true,
|
|
83
|
+
configurable: true,
|
|
84
|
+
enumerable: true,
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
/* skip non-overridable globals */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return saved;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function restoreGlobals(saved: Record<string, unknown>): void {
|
|
94
|
+
for (const name of BROWSER_GLOBALS) {
|
|
95
|
+
try {
|
|
96
|
+
Object.defineProperty(globalThis, name, {
|
|
97
|
+
value: saved[name],
|
|
98
|
+
writable: true,
|
|
99
|
+
configurable: true,
|
|
100
|
+
enumerable: true,
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
/* skip */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function serializeShoebox(entries: ShoeboxEntry[]): string {
|
|
109
|
+
if (entries.length === 0) return '';
|
|
110
|
+
const safeJson = JSON.stringify(entries).replace(/<\/(script)/gi, '<\\/$1');
|
|
111
|
+
return `<script type="application/json" id="${SHOEBOX_SCRIPT_ID}">${safeJson}</script>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildRouteCssLinks(
|
|
115
|
+
manifest: NonNullable<RenderRouteOptions['cssManifest']>,
|
|
116
|
+
instance: EmberApplicationInstance,
|
|
117
|
+
): string {
|
|
118
|
+
if (!instance.lookup) return '';
|
|
119
|
+
let routeName: string | undefined;
|
|
120
|
+
try {
|
|
121
|
+
const router = instance.lookup('service:router') as
|
|
122
|
+
| { currentRouteName?: string }
|
|
123
|
+
| undefined;
|
|
124
|
+
routeName = router?.currentRouteName ?? undefined;
|
|
125
|
+
} catch {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
if (!routeName) return '';
|
|
129
|
+
|
|
130
|
+
const segments = routeName.split('.');
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
const links: string[] = [];
|
|
133
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
134
|
+
const cssFiles = manifest[segments.slice(0, i).join('.')];
|
|
135
|
+
if (!cssFiles) continue;
|
|
136
|
+
for (const href of cssFiles) {
|
|
137
|
+
if (seen.has(href)) continue;
|
|
138
|
+
seen.add(href);
|
|
139
|
+
links.push(`<link rel="stylesheet" href="${href}">`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return links.join('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── Dev EmberApp factory ─────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Creates a dev-mode EmberApp that renders in-process via Vite's
|
|
149
|
+
* `ssrLoadModule`. Implements the same `EmberApp` interface as the
|
|
150
|
+
* production `createEmberApp` so it can be used as a drop-in.
|
|
151
|
+
*/
|
|
152
|
+
export function createDevEmberApp(
|
|
153
|
+
entryPath: string,
|
|
154
|
+
devOptions: EmberAppDevOptions,
|
|
155
|
+
): EmberApp {
|
|
156
|
+
const { ssrLoadModule } = devOptions;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
async renderRoute(
|
|
160
|
+
url: string,
|
|
161
|
+
renderOptions: RenderRouteOptions = {},
|
|
162
|
+
): Promise<RenderResult> {
|
|
163
|
+
const {
|
|
164
|
+
shoebox = false,
|
|
165
|
+
rehydrate = false,
|
|
166
|
+
cssManifest,
|
|
167
|
+
headers: forwardHeaders,
|
|
168
|
+
} = renderOptions;
|
|
169
|
+
|
|
170
|
+
// Fresh Window per request — no state bleeds between renders in dev.
|
|
171
|
+
const win = new Window({
|
|
172
|
+
url: 'http://localhost/',
|
|
173
|
+
width: 1024,
|
|
174
|
+
height: 768,
|
|
175
|
+
settings: {
|
|
176
|
+
disableJavaScriptFileLoading: true,
|
|
177
|
+
disableJavaScriptEvaluation: true,
|
|
178
|
+
disableCSSFileLoading: true,
|
|
179
|
+
navigator: { userAgent: 'vite-ember-ssr' },
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const savedGlobals = installGlobals(win);
|
|
184
|
+
|
|
185
|
+
// Shoebox: intercept fetch for this render only.
|
|
186
|
+
const realFetch = globalThis.fetch;
|
|
187
|
+
const shoeboxEntries: Map<string, ShoeboxEntry> | null = shoebox
|
|
188
|
+
? new Map()
|
|
189
|
+
: null;
|
|
190
|
+
|
|
191
|
+
if (shoebox || forwardHeaders) {
|
|
192
|
+
globalThis.fetch = async (
|
|
193
|
+
input: RequestInfo | URL,
|
|
194
|
+
init?: RequestInit,
|
|
195
|
+
) => {
|
|
196
|
+
// Inject forwarded request headers into outgoing fetches
|
|
197
|
+
let effectiveInit = init;
|
|
198
|
+
if (forwardHeaders) {
|
|
199
|
+
effectiveInit = { ...init };
|
|
200
|
+
const existingHeaders = new Headers(effectiveInit.headers);
|
|
201
|
+
for (const [key, value] of Object.entries(forwardHeaders)) {
|
|
202
|
+
if (!existingHeaders.has(key)) {
|
|
203
|
+
existingHeaders.set(key, value);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
effectiveInit.headers = existingHeaders;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const request = new Request(input, effectiveInit);
|
|
210
|
+
if (request.method.toUpperCase() !== 'GET')
|
|
211
|
+
return realFetch(request);
|
|
212
|
+
const response = await realFetch(request);
|
|
213
|
+
if (shoeboxEntries) {
|
|
214
|
+
try {
|
|
215
|
+
const clone = response.clone();
|
|
216
|
+
const body = await clone.text();
|
|
217
|
+
const headers: Record<string, string> = {};
|
|
218
|
+
clone.headers.forEach((v: string, k: string) => {
|
|
219
|
+
headers[k] = v;
|
|
220
|
+
});
|
|
221
|
+
shoeboxEntries.set(request.url, {
|
|
222
|
+
url: request.url,
|
|
223
|
+
status: clone.status,
|
|
224
|
+
statusText: clone.statusText,
|
|
225
|
+
headers,
|
|
226
|
+
body,
|
|
227
|
+
});
|
|
228
|
+
} catch {
|
|
229
|
+
/* skip */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return response;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let head = '';
|
|
237
|
+
let body = '';
|
|
238
|
+
let bodyAttrs: Record<string, string> = {};
|
|
239
|
+
let cssLinks = '';
|
|
240
|
+
let error: Error | undefined;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const document = win.document;
|
|
244
|
+
|
|
245
|
+
// Re-load the module on every request so HMR changes are reflected.
|
|
246
|
+
const mod = (await ssrLoadModule(entryPath)) as {
|
|
247
|
+
createSsrApp?: () => EmberApplication;
|
|
248
|
+
};
|
|
249
|
+
if (typeof mod.createSsrApp !== 'function') {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`SSR entry '${entryPath}' does not export a 'createSsrApp' function. ` +
|
|
252
|
+
`Found exports: ${Object.keys(mod).join(', ')}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const app = mod.createSsrApp();
|
|
256
|
+
|
|
257
|
+
const bootOptions: BootOptions = {
|
|
258
|
+
isBrowser: false,
|
|
259
|
+
document: document as unknown as Document,
|
|
260
|
+
rootElement: document.body as unknown as Element,
|
|
261
|
+
shouldRender: true,
|
|
262
|
+
...(rehydrate ? { _renderMode: 'serialize' as const } : {}),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const instance = await app.visit(url, bootOptions);
|
|
266
|
+
|
|
267
|
+
// Drain Backburner's autorun microtask before reading the DOM.
|
|
268
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
269
|
+
|
|
270
|
+
if (cssManifest) {
|
|
271
|
+
cssLinks = buildRouteCssLinks(cssManifest, instance);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
head = document.head?.innerHTML ?? '';
|
|
275
|
+
body = document.body?.innerHTML ?? '';
|
|
276
|
+
|
|
277
|
+
// Extract attributes set on <body> during rendering
|
|
278
|
+
if (document.body) {
|
|
279
|
+
for (const attr of Array.from(document.body.attributes)) {
|
|
280
|
+
bodyAttrs[attr.name] = attr.value;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
instance.destroy();
|
|
285
|
+
} catch (e) {
|
|
286
|
+
error = e instanceof Error ? e : new Error(String(e));
|
|
287
|
+
} finally {
|
|
288
|
+
if (shoebox || forwardHeaders) globalThis.fetch = realFetch;
|
|
289
|
+
restoreGlobals(savedGlobals);
|
|
290
|
+
await win.happyDOM?.close?.();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const shoeboxHTML =
|
|
294
|
+
shoeboxEntries && shoeboxEntries.size > 0
|
|
295
|
+
? serializeShoebox(Array.from(shoeboxEntries.values()))
|
|
296
|
+
: '';
|
|
297
|
+
const rehydrateHTML = rehydrate
|
|
298
|
+
? '<script>window.__vite_ember_ssr_rehydrate__=true</script>'
|
|
299
|
+
: '';
|
|
300
|
+
const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;
|
|
301
|
+
const wrappedBody = rehydrate
|
|
302
|
+
? body
|
|
303
|
+
: `${SSR_BODY_START}${body}${SSR_BODY_END}`;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
head: fullHead,
|
|
307
|
+
body: wrappedBody,
|
|
308
|
+
bodyAttrs,
|
|
309
|
+
statusCode: error ? 500 : 200,
|
|
310
|
+
...(error ? { error } : {}),
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async destroy(): Promise<void> {
|
|
315
|
+
// Nothing to tear down — no worker pool in dev mode.
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|