@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/dist/worker.js ADDED
@@ -0,0 +1,186 @@
1
+ import { Window } from "happy-dom";
2
+ //#region src/worker.ts
3
+ /**
4
+ * SSR worker — long-lived Window per thread.
5
+ *
6
+ * One Window is created at worker startup and lives for the worker's lifetime.
7
+ * One EmberApplication is created eagerly (top-level await) and reused for
8
+ * every render. Renders are serialised by tinypool (concurrentTasksPerWorker:1),
9
+ * so there is no concurrency concern within a single worker.
10
+ *
11
+ * app.visit() fully owns document.head/body between calls, so DOM state does
12
+ * not bleed across renders. A fresh ApplicationInstance is created per visit
13
+ * and destroyed after the DOM is read, keeping container singletons clean.
14
+ *
15
+ * The shoebox fetch interceptor is installed once at startup. Each render
16
+ * assigns a fresh entries Map before visiting, so entries never bleed between
17
+ * requests. When shoebox is disabled, the Map is set to null and the
18
+ * interceptor is a no-op passthrough.
19
+ */
20
+ const BROWSER_GLOBALS = [
21
+ "window",
22
+ "document",
23
+ "navigator",
24
+ "location",
25
+ "history",
26
+ "HTMLElement",
27
+ "Element",
28
+ "Node",
29
+ "Event",
30
+ "CustomEvent",
31
+ "MutationObserver",
32
+ "requestAnimationFrame",
33
+ "cancelAnimationFrame",
34
+ "self",
35
+ "localStorage",
36
+ "sessionStorage",
37
+ "InputEvent",
38
+ "KeyboardEvent",
39
+ "MouseEvent",
40
+ "FocusEvent",
41
+ "PointerEvent",
42
+ "IntersectionObserver",
43
+ "ResizeObserver",
44
+ "CSSStyleSheet"
45
+ ];
46
+ function installGlobals(win) {
47
+ for (const name of BROWSER_GLOBALS) try {
48
+ Object.defineProperty(globalThis, name, {
49
+ value: win[name],
50
+ writable: true,
51
+ configurable: true,
52
+ enumerable: true
53
+ });
54
+ } catch {}
55
+ }
56
+ const win = new Window({
57
+ url: "http://localhost/",
58
+ width: 1024,
59
+ height: 768,
60
+ settings: {
61
+ disableJavaScriptFileLoading: true,
62
+ disableJavaScriptEvaluation: true,
63
+ disableCSSFileLoading: true,
64
+ navigator: { userAgent: "vite-ember-ssr" }
65
+ }
66
+ });
67
+ installGlobals(win);
68
+ const { ssrBundlePath: startupBundlePath } = process.__tinypool_state__.workerData;
69
+ const startupMod = await import(startupBundlePath);
70
+ if (typeof startupMod.createSsrApp !== "function") throw new Error(`SSR bundle '${startupBundlePath}' does not export a 'createSsrApp' function. Found exports: ${Object.keys(startupMod).join(", ")}`);
71
+ const app = startupMod.createSsrApp();
72
+ const SHOEBOX_SCRIPT_ID = "vite-ember-ssr-shoebox";
73
+ const realFetch = globalThis.fetch;
74
+ let shoeboxEntries = null;
75
+ let requestHeaders = null;
76
+ const interceptedFetch = async (input, init) => {
77
+ const request = new Request(input, init);
78
+ if (requestHeaders) {
79
+ const mergedInit = { ...init };
80
+ const existingHeaders = new Headers(mergedInit.headers);
81
+ for (const [key, value] of Object.entries(requestHeaders)) if (!existingHeaders.has(key)) existingHeaders.set(key, value);
82
+ mergedInit.headers = existingHeaders;
83
+ const mergedRequest = new Request(input, mergedInit);
84
+ if (mergedRequest.method.toUpperCase() !== "GET") return realFetch(mergedRequest);
85
+ const response = await realFetch(mergedRequest);
86
+ if (shoeboxEntries) captureShoeboxEntry(mergedRequest, response);
87
+ return response;
88
+ }
89
+ if (request.method.toUpperCase() !== "GET") return realFetch(input, init);
90
+ const response = await realFetch(input, init);
91
+ if (shoeboxEntries) captureShoeboxEntry(request, response);
92
+ return response;
93
+ };
94
+ async function captureShoeboxEntry(request, response) {
95
+ try {
96
+ const clone = response.clone();
97
+ const body = await clone.text();
98
+ const headers = {};
99
+ clone.headers.forEach((v, k) => {
100
+ headers[k] = v;
101
+ });
102
+ shoeboxEntries?.set(request.url, {
103
+ url: request.url,
104
+ status: clone.status,
105
+ statusText: clone.statusText,
106
+ headers,
107
+ body
108
+ });
109
+ } catch {}
110
+ }
111
+ globalThis.fetch = interceptedFetch;
112
+ function serializeShoebox(entries) {
113
+ if (entries.length === 0) return "";
114
+ return `<script type="application/json" id="${SHOEBOX_SCRIPT_ID}">${JSON.stringify(entries).replace(/<\/(script)/gi, "<\\/$1")}<\/script>`;
115
+ }
116
+ function getActiveRouteName(instance) {
117
+ if (!instance.lookup) return void 0;
118
+ try {
119
+ return instance.lookup("service:router")?.currentRouteName ?? void 0;
120
+ } catch {
121
+ return;
122
+ }
123
+ }
124
+ function buildRouteCssLinks(manifest, instance) {
125
+ if (!manifest) return "";
126
+ const routeName = getActiveRouteName(instance);
127
+ if (!routeName) return "";
128
+ const segments = routeName.split(".");
129
+ const seen = /* @__PURE__ */ new Set();
130
+ const links = [];
131
+ for (let i = 1; i <= segments.length; i++) {
132
+ const cssFiles = manifest[segments.slice(0, i).join(".")];
133
+ if (!cssFiles) continue;
134
+ for (const href of cssFiles) {
135
+ if (seen.has(href)) continue;
136
+ seen.add(href);
137
+ links.push(`<link rel="stylesheet" href="${href}">`);
138
+ }
139
+ }
140
+ return links.join("");
141
+ }
142
+ async function render(options) {
143
+ const { url, shoebox, rehydrate, cssManifest, headers } = options;
144
+ const document = win.document;
145
+ shoeboxEntries = shoebox ? /* @__PURE__ */ new Map() : null;
146
+ requestHeaders = headers;
147
+ let head = "";
148
+ let body = "";
149
+ let bodyAttrs = {};
150
+ let cssLinks = "";
151
+ let error;
152
+ try {
153
+ const bootOptions = {
154
+ isBrowser: true,
155
+ isInteractive: true,
156
+ document,
157
+ rootElement: document.body,
158
+ shouldRender: true,
159
+ ...rehydrate ? { _renderMode: "serialize" } : {}
160
+ };
161
+ const instance = await app.visit(url, bootOptions);
162
+ await new Promise((resolve) => setTimeout(resolve, 0));
163
+ if (cssManifest) cssLinks = buildRouteCssLinks(cssManifest, instance);
164
+ head = document.head?.innerHTML ?? "";
165
+ body = document.body?.innerHTML ?? "";
166
+ if (document.body) for (const attr of Array.from(document.body.attributes)) bodyAttrs[attr.name] = attr.value;
167
+ instance.destroy();
168
+ if (rehydrate) document.body.innerHTML = "";
169
+ if (document.body) for (const attr of Array.from(document.body.attributes)) document.body.removeAttribute(attr.name);
170
+ } catch (e) {
171
+ error = e instanceof Error ? e : new Error(String(e));
172
+ }
173
+ requestHeaders = null;
174
+ const shoeboxHTML = shoeboxEntries && shoeboxEntries.size > 0 ? serializeShoebox(Array.from(shoeboxEntries.values())) : "";
175
+ return {
176
+ head: cssLinks + (rehydrate ? "<script>window.__vite_ember_ssr_rehydrate__=true<\/script>" : "") + shoeboxHTML + head,
177
+ body: rehydrate ? body : `<script type="x/boundary" id="ssr-body-start"><\/script>${body}<script type="x/boundary" id="ssr-body-end"><\/script>`,
178
+ bodyAttrs,
179
+ statusCode: error ? 500 : 200,
180
+ ...error ? { error: error.message + (error.stack ? "\n" + error.stack : "") } : {}
181
+ };
182
+ }
183
+ //#endregion
184
+ export { render as default };
185
+
186
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * SSR worker — long-lived Window per thread.\n *\n * One Window is created at worker startup and lives for the worker's lifetime.\n * One EmberApplication is created eagerly (top-level await) and reused for\n * every render. Renders are serialised by tinypool (concurrentTasksPerWorker:1),\n * so there is no concurrency concern within a single worker.\n *\n * app.visit() fully owns document.head/body between calls, so DOM state does\n * not bleed across renders. A fresh ApplicationInstance is created per visit\n * and destroyed after the DOM is read, keeping container singletons clean.\n *\n * The shoebox fetch interceptor is installed once at startup. Each render\n * assigns a fresh entries Map before visiting, so entries never bleed between\n * requests. When shoebox is disabled, the Map is set to null and the\n * interceptor is a no-op passthrough.\n */\n\nimport { Window } from 'happy-dom';\nimport type { CssManifest } from './vite-plugin.js';\nimport type {\n EmberApplication,\n EmberApplicationInstance,\n BootOptions,\n ShoeboxEntry,\n} from './server.js';\n\n// ─── Types ────────────────────────────────────────────────────────────\n\nexport interface WorkerRenderOptions {\n ssrBundlePath: string;\n url: string;\n shoebox: boolean;\n rehydrate: boolean;\n cssManifest: CssManifest | null;\n headers: Record<string, string> | null;\n}\n\nexport interface WorkerRenderResult {\n head: string;\n body: string;\n bodyAttrs: Record<string, string>;\n statusCode: number;\n error?: string;\n}\n\n// ─── Browser globals ──────────────────────────────────────────────────\n\nconst BROWSER_GLOBALS = [\n 'window',\n 'document',\n 'navigator',\n 'location',\n 'history',\n 'HTMLElement',\n 'Element',\n 'Node',\n 'Event',\n 'CustomEvent',\n 'MutationObserver',\n 'requestAnimationFrame',\n 'cancelAnimationFrame',\n 'self',\n 'localStorage',\n 'sessionStorage',\n 'InputEvent',\n 'KeyboardEvent',\n 'MouseEvent',\n 'FocusEvent',\n 'PointerEvent',\n 'IntersectionObserver',\n 'ResizeObserver',\n 'CSSStyleSheet',\n] as const;\n\nfunction installGlobals(win: Window): void {\n for (const name of BROWSER_GLOBALS) {\n try {\n Object.defineProperty(globalThis, name, {\n value: (win as unknown as Record<string, unknown>)[name],\n writable: true,\n configurable: true,\n enumerable: true,\n });\n } catch {\n /* skip non-overridable globals */\n }\n }\n}\n\n// ─── Eager startup: single long-lived Window + app ────────────────────\n\nconst win = new Window({\n url: 'http://localhost/',\n width: 1024,\n height: 768,\n settings: {\n disableJavaScriptFileLoading: true,\n disableJavaScriptEvaluation: true,\n disableCSSFileLoading: true,\n navigator: { userAgent: 'vite-ember-ssr' },\n },\n});\n\n// Install browser globals once for this worker's lifetime.\ninstallGlobals(win);\n\nconst { ssrBundlePath: startupBundlePath } = (\n process as unknown as {\n __tinypool_state__: { workerData: { ssrBundlePath: string } };\n }\n).__tinypool_state__.workerData;\n\nconst startupMod = (await import(startupBundlePath)) as {\n createSsrApp?: () => EmberApplication;\n};\nif (typeof startupMod.createSsrApp !== 'function') {\n throw new Error(\n `SSR bundle '${startupBundlePath}' does not export a 'createSsrApp' function. ` +\n `Found exports: ${Object.keys(startupMod).join(', ')}`,\n );\n}\n\nconst app: EmberApplication = startupMod.createSsrApp();\n\n// ─── Shoebox ──────────────────────────────────────────────────────────\n\nconst SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';\n\n// The fetch interceptor is installed once at startup. globalThis.fetch\n// never changes. Each render passes fresh per-render state so there is\n// no bleed between requests.\nconst realFetch = globalThis.fetch;\nlet shoeboxEntries: Map<string, ShoeboxEntry> | null = null;\nlet requestHeaders: Record<string, string> | null = null;\n\nconst interceptedFetch: typeof fetch = async (input, init) => {\n const request = new Request(input, init);\n\n // Inject forwarded request headers (e.g., cookies) into outgoing fetches.\n // Only applies to requests without an existing cookie/authorization header,\n // so explicit headers in app code are not overwritten.\n if (requestHeaders) {\n const mergedInit = { ...init };\n const existingHeaders = new Headers(mergedInit.headers);\n for (const [key, value] of Object.entries(requestHeaders)) {\n if (!existingHeaders.has(key)) {\n existingHeaders.set(key, value);\n }\n }\n mergedInit.headers = existingHeaders;\n const mergedRequest = new Request(input, mergedInit);\n\n if (mergedRequest.method.toUpperCase() !== 'GET') return realFetch(mergedRequest);\n const response = await realFetch(mergedRequest);\n if (shoeboxEntries) {\n captureShoeboxEntry(mergedRequest, response);\n }\n return response;\n }\n\n if (request.method.toUpperCase() !== 'GET') return realFetch(input, init);\n const response = await realFetch(input, init);\n if (shoeboxEntries) {\n captureShoeboxEntry(request, response);\n }\n return response;\n};\n\nasync function captureShoeboxEntry(\n request: Request,\n response: Response,\n): Promise<void> {\n try {\n const clone = response.clone();\n const body = await clone.text();\n const headers: Record<string, string> = {};\n clone.headers.forEach((v, k) => {\n headers[k] = v;\n });\n shoeboxEntries?.set(request.url, {\n url: request.url,\n status: clone.status,\n statusText: clone.statusText,\n headers,\n body,\n });\n } catch {\n /* skip */\n }\n}\n\n// Install once — never needs to be restored.\nglobalThis.fetch = interceptedFetch;\n\nfunction serializeShoebox(entries: ShoeboxEntry[]): string {\n if (entries.length === 0) return '';\n const safeJson = JSON.stringify(entries).replace(/<\\/(script)/gi, '<\\\\/$1');\n return `<script type=\"application/json\" id=\"${SHOEBOX_SCRIPT_ID}\">${safeJson}</script>`;\n}\n\n// ─── CSS manifest helpers ─────────────────────────────────────────────\n\nfunction getActiveRouteName(\n instance: EmberApplicationInstance,\n): string | undefined {\n if (!instance.lookup) return undefined;\n try {\n const router = instance.lookup('service:router') as\n | { currentRouteName?: string }\n | undefined;\n return router?.currentRouteName ?? undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction buildRouteCssLinks(\n manifest: CssManifest | null,\n instance: EmberApplicationInstance,\n): string {\n if (!manifest) return '';\n const routeName = getActiveRouteName(instance);\n if (!routeName) return '';\n const segments = routeName.split('.');\n const seen = new Set<string>();\n const links: string[] = [];\n for (let i = 1; i <= segments.length; i++) {\n const cssFiles = manifest[segments.slice(0, i).join('.')];\n if (!cssFiles) continue;\n for (const href of cssFiles) {\n if (seen.has(href)) continue;\n seen.add(href);\n links.push(`<link rel=\"stylesheet\" href=\"${href}\">`);\n }\n }\n return links.join('');\n}\n\nexport default async function render(\n options: WorkerRenderOptions,\n): Promise<WorkerRenderResult> {\n const { url, shoebox, rehydrate, cssManifest, headers } = options;\n\n // Use the long-lived document directly — no new Window, no globalThis swap.\n const document = win.document;\n\n // Give the interceptor a fresh Map for this render, or null if shoebox\n // is disabled, so entries never bleed between requests.\n shoeboxEntries = shoebox ? new Map() : null;\n\n // Forward request headers (e.g., cookies) to outgoing fetch calls\n // for this render only. Cleared after the render completes.\n requestHeaders = headers;\n\n let head = '';\n let body = '';\n let bodyAttrs: Record<string, string> = {};\n let cssLinks = '';\n let error: Error | undefined;\n\n try {\n const bootOptions: BootOptions = {\n isBrowser: true,\n isInteractive: true,\n document: document as unknown as Document,\n rootElement: document.body as unknown as Element,\n shouldRender: true,\n ...(rehydrate ? { _renderMode: 'serialize' as const } : {}),\n };\n\n const instance = await app.visit(url, bootOptions);\n\n // Drain Backburner's autorun microtask before reading the DOM.\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n\n if (cssManifest) cssLinks = buildRouteCssLinks(cssManifest, instance);\n head = document.head?.innerHTML ?? '';\n body = document.body?.innerHTML ?? '';\n\n // Extract attributes set on <body> during rendering (e.g., data-theme, class).\n if (document.body) {\n for (const attr of Array.from(document.body.attributes)) {\n bodyAttrs[attr.name] = attr.value;\n }\n }\n\n // Destroy the instance so its container is torn down cleanly.\n // app.visit() creates a fresh ApplicationInstance per call; without\n // destroying it the container's singletons (including location:none)\n // remain live and can corrupt the next visit.\n instance.destroy();\n\n // rehydrate mode causes left over rehydration markers to remain in the DOM, so\n // we clear the body to ensure a clean slate for the next render.\n if (rehydrate) {\n document.body.innerHTML = '';\n }\n\n // Clear body attributes so they don't bleed into the next render.\n if (document.body) {\n for (const attr of Array.from(document.body.attributes)) {\n document.body.removeAttribute(attr.name);\n }\n }\n } catch (e) {\n error = e instanceof Error ? e : new Error(String(e));\n }\n\n // Clear per-render state to prevent bleed between requests.\n requestHeaders = null;\n\n const shoeboxHTML =\n shoeboxEntries && shoeboxEntries.size > 0\n ? serializeShoebox(Array.from(shoeboxEntries.values()))\n : '';\n const rehydrateHTML = rehydrate\n ? '<script>window.__vite_ember_ssr_rehydrate__=true</script>'\n : '';\n const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;\n const wrappedBody = rehydrate\n ? body\n : `<script type=\"x/boundary\" id=\"ssr-body-start\"></script>${body}<script type=\"x/boundary\" id=\"ssr-body-end\"></script>`;\n\n return {\n head: fullHead,\n body: wrappedBody,\n bodyAttrs,\n statusCode: error ? 500 : 200,\n ...(error\n ? { error: error.message + (error.stack ? '\\n' + error.stack : '') }\n : {}),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgDA,MAAM,kBAAkB;CACtB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;AAED,SAAS,eAAe,KAAmB;AACzC,MAAK,MAAM,QAAQ,gBACjB,KAAI;AACF,SAAO,eAAe,YAAY,MAAM;GACtC,OAAQ,IAA2C;GACnD,UAAU;GACV,cAAc;GACd,YAAY;GACb,CAAC;SACI;;AAQZ,MAAM,MAAM,IAAI,OAAO;CACrB,KAAK;CACL,OAAO;CACP,QAAQ;CACR,UAAU;EACR,8BAA8B;EAC9B,6BAA6B;EAC7B,uBAAuB;EACvB,WAAW,EAAE,WAAW,kBAAkB;EAC3C;CACF,CAAC;AAGF,eAAe,IAAI;AAEnB,MAAM,EAAE,eAAe,sBACrB,QAGA,mBAAmB;AAErB,MAAM,aAAc,MAAM,OAAO;AAGjC,IAAI,OAAO,WAAW,iBAAiB,WACrC,OAAM,IAAI,MACR,eAAe,kBAAkB,8DACb,OAAO,KAAK,WAAW,CAAC,KAAK,KAAK,GACvD;AAGH,MAAM,MAAwB,WAAW,cAAc;AAIvD,MAAM,oBAAoB;AAK1B,MAAM,YAAY,WAAW;AAC7B,IAAI,iBAAmD;AACvD,IAAI,iBAAgD;AAEpD,MAAM,mBAAiC,OAAO,OAAO,SAAS;CAC5D,MAAM,UAAU,IAAI,QAAQ,OAAO,KAAK;AAKxC,KAAI,gBAAgB;EAClB,MAAM,aAAa,EAAE,GAAG,MAAM;EAC9B,MAAM,kBAAkB,IAAI,QAAQ,WAAW,QAAQ;AACvD,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,CACvD,KAAI,CAAC,gBAAgB,IAAI,IAAI,CAC3B,iBAAgB,IAAI,KAAK,MAAM;AAGnC,aAAW,UAAU;EACrB,MAAM,gBAAgB,IAAI,QAAQ,OAAO,WAAW;AAEpD,MAAI,cAAc,OAAO,aAAa,KAAK,MAAO,QAAO,UAAU,cAAc;EACjF,MAAM,WAAW,MAAM,UAAU,cAAc;AAC/C,MAAI,eACF,qBAAoB,eAAe,SAAS;AAE9C,SAAO;;AAGT,KAAI,QAAQ,OAAO,aAAa,KAAK,MAAO,QAAO,UAAU,OAAO,KAAK;CACzE,MAAM,WAAW,MAAM,UAAU,OAAO,KAAK;AAC7C,KAAI,eACF,qBAAoB,SAAS,SAAS;AAExC,QAAO;;AAGT,eAAe,oBACb,SACA,UACe;AACf,KAAI;EACF,MAAM,QAAQ,SAAS,OAAO;EAC9B,MAAM,OAAO,MAAM,MAAM,MAAM;EAC/B,MAAM,UAAkC,EAAE;AAC1C,QAAM,QAAQ,SAAS,GAAG,MAAM;AAC9B,WAAQ,KAAK;IACb;AACF,kBAAgB,IAAI,QAAQ,KAAK;GAC/B,KAAK,QAAQ;GACb,QAAQ,MAAM;GACd,YAAY,MAAM;GAClB;GACA;GACD,CAAC;SACI;;AAMV,WAAW,QAAQ;AAEnB,SAAS,iBAAiB,SAAiC;AACzD,KAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAO,uCAAuC,kBAAkB,IAD/C,KAAK,UAAU,QAAQ,CAAC,QAAQ,iBAAiB,SAAS,CACE;;AAK/E,SAAS,mBACP,UACoB;AACpB,KAAI,CAAC,SAAS,OAAQ,QAAO,KAAA;AAC7B,KAAI;AAIF,SAHe,SAAS,OAAO,iBAAiB,EAGjC,oBAAoB,KAAA;SAC7B;AACN;;;AAIJ,SAAS,mBACP,UACA,UACQ;AACR,KAAI,CAAC,SAAU,QAAO;CACtB,MAAM,YAAY,mBAAmB,SAAS;AAC9C,KAAI,CAAC,UAAW,QAAO;CACvB,MAAM,WAAW,UAAU,MAAM,IAAI;CACrC,MAAM,uBAAO,IAAI,KAAa;CAC9B,MAAM,QAAkB,EAAE;AAC1B,MAAK,IAAI,IAAI,GAAG,KAAK,SAAS,QAAQ,KAAK;EACzC,MAAM,WAAW,SAAS,SAAS,MAAM,GAAG,EAAE,CAAC,KAAK,IAAI;AACxD,MAAI,CAAC,SAAU;AACf,OAAK,MAAM,QAAQ,UAAU;AAC3B,OAAI,KAAK,IAAI,KAAK,CAAE;AACpB,QAAK,IAAI,KAAK;AACd,SAAM,KAAK,gCAAgC,KAAK,IAAI;;;AAGxD,QAAO,MAAM,KAAK,GAAG;;AAGvB,eAA8B,OAC5B,SAC6B;CAC7B,MAAM,EAAE,KAAK,SAAS,WAAW,aAAa,YAAY;CAG1D,MAAM,WAAW,IAAI;AAIrB,kBAAiB,0BAAU,IAAI,KAAK,GAAG;AAIvC,kBAAiB;CAEjB,IAAI,OAAO;CACX,IAAI,OAAO;CACX,IAAI,YAAoC,EAAE;CAC1C,IAAI,WAAW;CACf,IAAI;AAEJ,KAAI;EACF,MAAM,cAA2B;GAC/B,WAAW;GACX,eAAe;GACL;GACV,aAAa,SAAS;GACtB,cAAc;GACd,GAAI,YAAY,EAAE,aAAa,aAAsB,GAAG,EAAE;GAC3D;EAED,MAAM,WAAW,MAAM,IAAI,MAAM,KAAK,YAAY;AAGlD,QAAM,IAAI,SAAe,YAAY,WAAW,SAAS,EAAE,CAAC;AAE5D,MAAI,YAAa,YAAW,mBAAmB,aAAa,SAAS;AACrE,SAAO,SAAS,MAAM,aAAa;AACnC,SAAO,SAAS,MAAM,aAAa;AAGnC,MAAI,SAAS,KACX,MAAK,MAAM,QAAQ,MAAM,KAAK,SAAS,KAAK,WAAW,CACrD,WAAU,KAAK,QAAQ,KAAK;AAQhC,WAAS,SAAS;AAIlB,MAAI,UACF,UAAS,KAAK,YAAY;AAI5B,MAAI,SAAS,KACX,MAAK,MAAM,QAAQ,MAAM,KAAK,SAAS,KAAK,WAAW,CACrD,UAAS,KAAK,gBAAgB,KAAK,KAAK;UAGrC,GAAG;AACV,UAAQ,aAAa,QAAQ,IAAI,IAAI,MAAM,OAAO,EAAE,CAAC;;AAIvD,kBAAiB;CAEjB,MAAM,cACJ,kBAAkB,eAAe,OAAO,IACpC,iBAAiB,MAAM,KAAK,eAAe,QAAQ,CAAC,CAAC,GACrD;AASN,QAAO;EACL,MANe,YAHK,YAClB,+DACA,MACwC,cAAc;EAOxD,MANkB,YAChB,OACA,2DAA0D,KAAK;EAKjE;EACA,YAAY,QAAQ,MAAM;EAC1B,GAAI,QACA,EAAE,OAAO,MAAM,WAAW,MAAM,QAAQ,OAAO,MAAM,QAAQ,KAAK,GAClE,EAAE;EACP"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@st-h/vite-ember-ssr",
3
+ "version": "0.2.0-alpha.1",
4
+ "description": "Vite plugin and SSR runtime for Ember.js applications using HappyDOM",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Liam Potter",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/evoactivity/vite-ember-ssr.git",
11
+ "directory": "packages/vite-ember-ssr"
12
+ },
13
+ "homepage": "https://github.com/evoactivity/vite-ember-ssr/tree/main/packages/vite-ember-ssr#readme",
14
+ "bugs": "https://github.com/evoactivity/vite-ember-ssr/issues",
15
+ "keywords": [
16
+ "vite",
17
+ "vite-plugin",
18
+ "ember",
19
+ "emberjs",
20
+ "ssr",
21
+ "server-side-rendering",
22
+ "happydom",
23
+ "embroider"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "sideEffects": false,
29
+ "exports": {
30
+ "./vite-plugin": {
31
+ "types": "./dist/vite-plugin.d.ts",
32
+ "import": "./dist/vite-plugin.js"
33
+ },
34
+ "./server": {
35
+ "types": "./dist/server.d.ts",
36
+ "import": "./dist/server.js"
37
+ },
38
+ "./client": {
39
+ "types": "./dist/client.d.ts",
40
+ "import": "./dist/client.js"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist",
45
+ "src",
46
+ "LICENSE.md"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "dev": "tsdown --watch",
51
+ "prepublishOnly": "pnpm run build",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:browser": "playwright test",
55
+ "test:all": "vitest run && playwright test"
56
+ },
57
+ "dependencies": {
58
+ "happy-dom": "^20.8.9",
59
+ "tinypool": "^2.1.0"
60
+ },
61
+ "peerDependencies": {
62
+ "vite": "^6.0.0 || ^7.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@playwright/test": "^1.58.2",
66
+ "@types/node": "^25.5.0",
67
+ "playwright": "^1.58.2",
68
+ "tsdown": "^0.21.7",
69
+ "typescript": "^6.0.2",
70
+ "vite": "^7.3.1",
71
+ "vitest": "^4.1.2"
72
+ }
73
+ }
package/src/client.ts ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Client-side utilities for vite-ember-ssr.
3
+ *
4
+ * Currently the client Ember app boots normally and replaces the
5
+ * SSR-rendered content. True DOM hydration is planned for a future
6
+ * phase.
7
+ *
8
+ * For now, the SSR content provides the initial visual while client
9
+ * JavaScript loads, parses, and Ember boots.
10
+ */
11
+
12
+ // ─── Shoebox Types ───────────────────────────────────────────────────
13
+
14
+ /**
15
+ * A captured fetch response transferred from the server.
16
+ * Must match the ShoeboxEntry interface in server.ts.
17
+ */
18
+ interface ShoeboxEntry {
19
+ url: string;
20
+ status: number;
21
+ statusText: string;
22
+ headers: Record<string, string>;
23
+ body: string;
24
+ }
25
+
26
+ const SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';
27
+
28
+ // ─── Shoebox: Client-Side Fetch Replay ───────────────────────────────
29
+
30
+ /** Original fetch function, saved before monkey-patching */
31
+ let _originalFetch: typeof fetch | null = null;
32
+
33
+ /** Map of URL → { entry, refCount } for reference-counted consumption */
34
+ let _shoeboxMap: Map<string, { entry: ShoeboxEntry; refCount: number }> | null =
35
+ null;
36
+
37
+ /**
38
+ * Installs the shoebox fetch interceptor on the client.
39
+ *
40
+ * Reads the shoebox data from the server-injected <script> tag,
41
+ * removes the tag from the DOM, and monkey-patches globalThis.fetch
42
+ * to serve cached responses for URLs that match shoebox entries.
43
+ *
44
+ * Each entry is reference-counted: concurrent fetch calls to the same
45
+ * URL all receive the shoebox response. The entry is removed only when
46
+ * the last concurrent consumer has been served.
47
+ *
48
+ * Call this BEFORE creating the Ember application, typically as the
49
+ * first thing in your client entry point.
50
+ *
51
+ * @returns true if shoebox data was found and installed, false otherwise
52
+ */
53
+ export function installShoebox(): boolean {
54
+ const scriptEl = document.getElementById(SHOEBOX_SCRIPT_ID);
55
+ if (!scriptEl) {
56
+ return false;
57
+ }
58
+
59
+ // Parse the shoebox data
60
+ let entries: ShoeboxEntry[];
61
+ try {
62
+ entries = JSON.parse(scriptEl.textContent ?? '[]');
63
+ } catch {
64
+ // Malformed shoebox data — skip
65
+ scriptEl.remove();
66
+ return false;
67
+ }
68
+
69
+ // Remove the script tag from the DOM
70
+ scriptEl.remove();
71
+
72
+ if (entries.length === 0) {
73
+ return false;
74
+ }
75
+
76
+ // Build the lookup map with ref counts
77
+ _shoeboxMap = new Map();
78
+ for (const entry of entries) {
79
+ _shoeboxMap.set(entry.url, { entry, refCount: 1 });
80
+ }
81
+
82
+ // Save the original fetch and install our interceptor
83
+ _originalFetch = globalThis.fetch;
84
+
85
+ globalThis.fetch = function shoeboxFetch(
86
+ input: RequestInfo | URL,
87
+ init?: RequestInit,
88
+ ): Promise<Response> {
89
+ // Only intercept GET requests (or requests with no method, which default to GET)
90
+ const method = init?.method?.toUpperCase() ?? 'GET';
91
+ if (method !== 'GET' || !_shoeboxMap || _shoeboxMap.size === 0) {
92
+ return _originalFetch!(input, init);
93
+ }
94
+
95
+ // Resolve the URL string for matching
96
+ let url: string;
97
+ try {
98
+ if (typeof input === 'string') {
99
+ url = new URL(input, globalThis.location?.href).href;
100
+ } else if (input instanceof URL) {
101
+ url = input.href;
102
+ } else if (input instanceof Request) {
103
+ url = input.url;
104
+ } else {
105
+ return _originalFetch!(input, init);
106
+ }
107
+ } catch {
108
+ return _originalFetch!(input, init);
109
+ }
110
+
111
+ const cached = _shoeboxMap.get(url);
112
+ if (!cached) {
113
+ return _originalFetch!(input, init);
114
+ }
115
+
116
+ // Decrement ref count and remove if exhausted
117
+ cached.refCount--;
118
+ if (cached.refCount <= 0) {
119
+ _shoeboxMap.delete(url);
120
+ }
121
+
122
+ // Construct a Response from the cached data
123
+ const { entry } = cached;
124
+ const response = new Response(entry.body, {
125
+ status: entry.status,
126
+ statusText: entry.statusText,
127
+ headers: new Headers(entry.headers),
128
+ });
129
+
130
+ // Auto-cleanup when the map is empty
131
+ if (_shoeboxMap.size === 0) {
132
+ cleanupShoebox();
133
+ }
134
+
135
+ return Promise.resolve(response);
136
+ };
137
+
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * Restores the original fetch function and cleans up shoebox state.
143
+ *
144
+ * Called automatically when all shoebox entries have been consumed,
145
+ * or can be called manually to force cleanup.
146
+ */
147
+ export function cleanupShoebox(): void {
148
+ if (_originalFetch) {
149
+ globalThis.fetch = _originalFetch;
150
+ _originalFetch = null;
151
+ }
152
+ _shoeboxMap = null;
153
+ }
154
+
155
+ // ─── SSR Content Cleanup ─────────────────────────────────────────────
156
+
157
+ /**
158
+ * Removes the SSR-rendered content from the DOM so the client Ember
159
+ * app can render into a clean `<body>`. This prevents the "double
160
+ * render" where both server-rendered HTML and client-rendered HTML
161
+ * are visible simultaneously.
162
+ *
163
+ * Removes everything between (and including) the SSR boundary markers:
164
+ * <script type="x/boundary" id="ssr-body-start">
165
+ * ...server rendered content...
166
+ * <script type="x/boundary" id="ssr-body-end">
167
+ *
168
+ * **Call this from your application template** rather than from
169
+ * `entry.ts` — this ensures removal happens at the moment Ember
170
+ * renders, avoiding a flash of no content:
171
+ *
172
+ * ```gts
173
+ * import { cleanupSSRContent } from '@st-h/vite-ember-ssr/client';
174
+ *
175
+ * <template>
176
+ * {{cleanupSSRContent}}
177
+ * {{outlet}}
178
+ * </template>
179
+ * ```
180
+ *
181
+ * Only used in cleanup mode (default). Not needed when using
182
+ * `rehydrate: true` — in that mode Glimmer reuses the existing DOM.
183
+ */
184
+ export function cleanupSSRContent(): void {
185
+ const start = document.getElementById('ssr-body-start');
186
+ const end = document.getElementById('ssr-body-end');
187
+
188
+ if (!start || !end) {
189
+ return; // Not an SSR-rendered page
190
+ }
191
+
192
+ // Remove all nodes between start and end markers (inclusive)
193
+ const parent = start.parentNode;
194
+ if (!parent) return;
195
+
196
+ let node: ChildNode | null = start;
197
+ while (node) {
198
+ const next: ChildNode | null = node.nextSibling;
199
+ parent.removeChild(node);
200
+ if (node === end) break;
201
+ node = next;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Checks if the current page was server-side rendered by looking
207
+ * for SSR boundary markers in the DOM.
208
+ */
209
+ export function isSSRRendered(): boolean {
210
+ return document.getElementById('ssr-body-start') !== null;
211
+ }
212
+
213
+ /**
214
+ * Checks whether the current page was rendered with rehydration mode.
215
+ *
216
+ * Returns `true` when the server (or SSG build) injected the
217
+ * `window.__vite_ember_ssr_rehydrate__` flag. Use this in your client
218
+ * entry point to decide whether to boot Ember in rehydrate mode or
219
+ * with a normal boot:
220
+ *
221
+ * ```ts
222
+ * import { shouldRehydrate, installShoebox } from '@st-h/vite-ember-ssr/client';
223
+ *
224
+ * installShoebox();
225
+ *
226
+ * const app = Application.create({ ...config.APP, autoboot: false });
227
+ *
228
+ * app.visit(window.location.pathname + window.location.search, {
229
+ * ...(shouldRehydrate() ? { _renderMode: 'rehydrate' } : {}),
230
+ * });
231
+ * ```
232
+ *
233
+ * This is especially important for SSG apps where only prerendered
234
+ * routes carry the flag — non-SSG routes will boot normally without
235
+ * attempting rehydration (which would fail with no serialized DOM).
236
+ */
237
+ export function shouldRehydrate(): boolean {
238
+ return (
239
+ (window as unknown as Record<string, unknown>)
240
+ .__vite_ember_ssr_rehydrate__ === true
241
+ );
242
+ }