@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/worker.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR worker — long-lived Window per thread.
|
|
3
|
+
*
|
|
4
|
+
* One Window is created at worker startup and lives for the worker's lifetime.
|
|
5
|
+
* One EmberApplication is created eagerly (top-level await) and reused for
|
|
6
|
+
* every render. Renders are serialised by tinypool (concurrentTasksPerWorker:1),
|
|
7
|
+
* so there is no concurrency concern within a single worker.
|
|
8
|
+
*
|
|
9
|
+
* app.visit() fully owns document.head/body between calls, so DOM state does
|
|
10
|
+
* not bleed across renders. A fresh ApplicationInstance is created per visit
|
|
11
|
+
* and destroyed after the DOM is read, keeping container singletons clean.
|
|
12
|
+
*
|
|
13
|
+
* The shoebox fetch interceptor is installed once at startup. Each render
|
|
14
|
+
* assigns a fresh entries Map before visiting, so entries never bleed between
|
|
15
|
+
* requests. When shoebox is disabled, the Map is set to null and the
|
|
16
|
+
* interceptor is a no-op passthrough.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Window } from 'happy-dom';
|
|
20
|
+
import type { CssManifest } from './vite-plugin.js';
|
|
21
|
+
import type {
|
|
22
|
+
EmberApplication,
|
|
23
|
+
EmberApplicationInstance,
|
|
24
|
+
BootOptions,
|
|
25
|
+
ShoeboxEntry,
|
|
26
|
+
} from './server.js';
|
|
27
|
+
|
|
28
|
+
// ─── Types ────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface WorkerRenderOptions {
|
|
31
|
+
ssrBundlePath: string;
|
|
32
|
+
url: string;
|
|
33
|
+
shoebox: boolean;
|
|
34
|
+
rehydrate: boolean;
|
|
35
|
+
cssManifest: CssManifest | null;
|
|
36
|
+
headers: Record<string, string> | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkerRenderResult {
|
|
40
|
+
head: string;
|
|
41
|
+
body: string;
|
|
42
|
+
bodyAttrs: Record<string, string>;
|
|
43
|
+
statusCode: number;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Browser globals ──────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const BROWSER_GLOBALS = [
|
|
50
|
+
'window',
|
|
51
|
+
'document',
|
|
52
|
+
'navigator',
|
|
53
|
+
'location',
|
|
54
|
+
'history',
|
|
55
|
+
'HTMLElement',
|
|
56
|
+
'Element',
|
|
57
|
+
'Node',
|
|
58
|
+
'Event',
|
|
59
|
+
'CustomEvent',
|
|
60
|
+
'MutationObserver',
|
|
61
|
+
'requestAnimationFrame',
|
|
62
|
+
'cancelAnimationFrame',
|
|
63
|
+
'self',
|
|
64
|
+
'localStorage',
|
|
65
|
+
'sessionStorage',
|
|
66
|
+
'InputEvent',
|
|
67
|
+
'KeyboardEvent',
|
|
68
|
+
'MouseEvent',
|
|
69
|
+
'FocusEvent',
|
|
70
|
+
'PointerEvent',
|
|
71
|
+
'IntersectionObserver',
|
|
72
|
+
'ResizeObserver',
|
|
73
|
+
'CSSStyleSheet',
|
|
74
|
+
] as const;
|
|
75
|
+
|
|
76
|
+
function installGlobals(win: Window): void {
|
|
77
|
+
for (const name of BROWSER_GLOBALS) {
|
|
78
|
+
try {
|
|
79
|
+
Object.defineProperty(globalThis, name, {
|
|
80
|
+
value: (win as unknown as Record<string, unknown>)[name],
|
|
81
|
+
writable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
enumerable: true,
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
/* skip non-overridable globals */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Eager startup: single long-lived Window + app ────────────────────
|
|
92
|
+
|
|
93
|
+
const win = new Window({
|
|
94
|
+
url: 'http://localhost/',
|
|
95
|
+
width: 1024,
|
|
96
|
+
height: 768,
|
|
97
|
+
settings: {
|
|
98
|
+
disableJavaScriptFileLoading: true,
|
|
99
|
+
disableJavaScriptEvaluation: true,
|
|
100
|
+
disableCSSFileLoading: true,
|
|
101
|
+
navigator: { userAgent: 'vite-ember-ssr' },
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Install browser globals once for this worker's lifetime.
|
|
106
|
+
installGlobals(win);
|
|
107
|
+
|
|
108
|
+
const { ssrBundlePath: startupBundlePath } = (
|
|
109
|
+
process as unknown as {
|
|
110
|
+
__tinypool_state__: { workerData: { ssrBundlePath: string } };
|
|
111
|
+
}
|
|
112
|
+
).__tinypool_state__.workerData;
|
|
113
|
+
|
|
114
|
+
const startupMod = (await import(startupBundlePath)) as {
|
|
115
|
+
createSsrApp?: () => EmberApplication;
|
|
116
|
+
};
|
|
117
|
+
if (typeof startupMod.createSsrApp !== 'function') {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`SSR bundle '${startupBundlePath}' does not export a 'createSsrApp' function. ` +
|
|
120
|
+
`Found exports: ${Object.keys(startupMod).join(', ')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const app: EmberApplication = startupMod.createSsrApp();
|
|
125
|
+
|
|
126
|
+
// ─── Shoebox ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';
|
|
129
|
+
|
|
130
|
+
// The fetch interceptor is installed once at startup. globalThis.fetch
|
|
131
|
+
// never changes. Each render passes fresh per-render state so there is
|
|
132
|
+
// no bleed between requests.
|
|
133
|
+
const realFetch = globalThis.fetch;
|
|
134
|
+
let shoeboxEntries: Map<string, ShoeboxEntry> | null = null;
|
|
135
|
+
let requestHeaders: Record<string, string> | null = null;
|
|
136
|
+
|
|
137
|
+
const interceptedFetch: typeof fetch = async (input, init) => {
|
|
138
|
+
const request = new Request(input, init);
|
|
139
|
+
|
|
140
|
+
// Inject forwarded request headers (e.g., cookies) into outgoing fetches.
|
|
141
|
+
// Only applies to requests without an existing cookie/authorization header,
|
|
142
|
+
// so explicit headers in app code are not overwritten.
|
|
143
|
+
if (requestHeaders) {
|
|
144
|
+
const mergedInit = { ...init };
|
|
145
|
+
const existingHeaders = new Headers(mergedInit.headers);
|
|
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
|
+
}
|
|
192
|
+
|
|
193
|
+
// Install once — never needs to be restored.
|
|
194
|
+
globalThis.fetch = interceptedFetch;
|
|
195
|
+
|
|
196
|
+
function serializeShoebox(entries: ShoeboxEntry[]): string {
|
|
197
|
+
if (entries.length === 0) return '';
|
|
198
|
+
const safeJson = JSON.stringify(entries).replace(/<\/(script)/gi, '<\\/$1');
|
|
199
|
+
return `<script type="application/json" id="${SHOEBOX_SCRIPT_ID}">${safeJson}</script>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── CSS manifest helpers ─────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function getActiveRouteName(
|
|
205
|
+
instance: EmberApplicationInstance,
|
|
206
|
+
): string | undefined {
|
|
207
|
+
if (!instance.lookup) return undefined;
|
|
208
|
+
try {
|
|
209
|
+
const router = instance.lookup('service:router') as
|
|
210
|
+
| { currentRouteName?: string }
|
|
211
|
+
| undefined;
|
|
212
|
+
return router?.currentRouteName ?? undefined;
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildRouteCssLinks(
|
|
219
|
+
manifest: CssManifest | null,
|
|
220
|
+
instance: EmberApplicationInstance,
|
|
221
|
+
): string {
|
|
222
|
+
if (!manifest) return '';
|
|
223
|
+
const routeName = getActiveRouteName(instance);
|
|
224
|
+
if (!routeName) return '';
|
|
225
|
+
const segments = routeName.split('.');
|
|
226
|
+
const seen = new Set<string>();
|
|
227
|
+
const links: string[] = [];
|
|
228
|
+
for (let i = 1; i <= segments.length; i++) {
|
|
229
|
+
const cssFiles = manifest[segments.slice(0, i).join('.')];
|
|
230
|
+
if (!cssFiles) continue;
|
|
231
|
+
for (const href of cssFiles) {
|
|
232
|
+
if (seen.has(href)) continue;
|
|
233
|
+
seen.add(href);
|
|
234
|
+
links.push(`<link rel="stylesheet" href="${href}">`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return links.join('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default async function render(
|
|
241
|
+
options: WorkerRenderOptions,
|
|
242
|
+
): Promise<WorkerRenderResult> {
|
|
243
|
+
const { url, shoebox, rehydrate, cssManifest, headers } = options;
|
|
244
|
+
|
|
245
|
+
// Use the long-lived document directly — no new Window, no globalThis swap.
|
|
246
|
+
const document = win.document;
|
|
247
|
+
|
|
248
|
+
// Give the interceptor a fresh Map for this render, or null if shoebox
|
|
249
|
+
// is disabled, so entries never bleed between requests.
|
|
250
|
+
shoeboxEntries = shoebox ? new Map() : null;
|
|
251
|
+
|
|
252
|
+
// Forward request headers (e.g., cookies) to outgoing fetch calls
|
|
253
|
+
// for this render only. Cleared after the render completes.
|
|
254
|
+
requestHeaders = headers;
|
|
255
|
+
|
|
256
|
+
let head = '';
|
|
257
|
+
let body = '';
|
|
258
|
+
let bodyAttrs: Record<string, string> = {};
|
|
259
|
+
let cssLinks = '';
|
|
260
|
+
let error: Error | undefined;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const bootOptions: BootOptions = {
|
|
264
|
+
isBrowser: true,
|
|
265
|
+
isInteractive: true,
|
|
266
|
+
document: document as unknown as Document,
|
|
267
|
+
rootElement: document.body as unknown as Element,
|
|
268
|
+
shouldRender: true,
|
|
269
|
+
...(rehydrate ? { _renderMode: 'serialize' as const } : {}),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const instance = await app.visit(url, bootOptions);
|
|
273
|
+
|
|
274
|
+
// Drain Backburner's autorun microtask before reading the DOM.
|
|
275
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
276
|
+
|
|
277
|
+
if (cssManifest) cssLinks = buildRouteCssLinks(cssManifest, instance);
|
|
278
|
+
head = document.head?.innerHTML ?? '';
|
|
279
|
+
body = document.body?.innerHTML ?? '';
|
|
280
|
+
|
|
281
|
+
// Extract attributes set on <body> during rendering (e.g., data-theme, class).
|
|
282
|
+
if (document.body) {
|
|
283
|
+
for (const attr of Array.from(document.body.attributes)) {
|
|
284
|
+
bodyAttrs[attr.name] = attr.value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Destroy the instance so its container is torn down cleanly.
|
|
289
|
+
// app.visit() creates a fresh ApplicationInstance per call; without
|
|
290
|
+
// destroying it the container's singletons (including location:none)
|
|
291
|
+
// remain live and can corrupt the next visit.
|
|
292
|
+
instance.destroy();
|
|
293
|
+
|
|
294
|
+
// rehydrate mode causes left over rehydration markers to remain in the DOM, so
|
|
295
|
+
// we clear the body to ensure a clean slate for the next render.
|
|
296
|
+
if (rehydrate) {
|
|
297
|
+
document.body.innerHTML = '';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Clear body attributes so they don't bleed into the next render.
|
|
301
|
+
if (document.body) {
|
|
302
|
+
for (const attr of Array.from(document.body.attributes)) {
|
|
303
|
+
document.body.removeAttribute(attr.name);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch (e) {
|
|
307
|
+
error = e instanceof Error ? e : new Error(String(e));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Clear per-render state to prevent bleed between requests.
|
|
311
|
+
requestHeaders = null;
|
|
312
|
+
|
|
313
|
+
const shoeboxHTML =
|
|
314
|
+
shoeboxEntries && shoeboxEntries.size > 0
|
|
315
|
+
? serializeShoebox(Array.from(shoeboxEntries.values()))
|
|
316
|
+
: '';
|
|
317
|
+
const rehydrateHTML = rehydrate
|
|
318
|
+
? '<script>window.__vite_ember_ssr_rehydrate__=true</script>'
|
|
319
|
+
: '';
|
|
320
|
+
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
|
+
|
|
325
|
+
return {
|
|
326
|
+
head: fullHead,
|
|
327
|
+
body: wrappedBody,
|
|
328
|
+
bodyAttrs,
|
|
329
|
+
statusCode: error ? 500 : 200,
|
|
330
|
+
...(error
|
|
331
|
+
? { error: error.message + (error.stack ? '\n' + error.stack : '') }
|
|
332
|
+
: {}),
|
|
333
|
+
};
|
|
334
|
+
}
|