@st-h/vite-ember-ssr 0.2.0-alpha.1 → 0.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","names":[],"sources":["../src/dev.ts","../src/server.ts"],"sourcesContent":["/**\n * Dev-mode SSR renderer for vite-ember-ssr.\n *\n * Renders in-process using Vite's `ssrLoadModule` pipeline instead of a\n * tinypool worker pool. The SSR entry module is re-loaded on every render\n * so HMR changes are picked up immediately.\n *\n * A fresh HappyDOM Window is created and torn down for each render — there\n * is no long-lived state between requests.\n *\n * Usage:\n * ```js\n * import { createServer } from 'vite';\n * import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';\n *\n * const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });\n * const app = await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n *\n * // In your catch-all handler:\n * const rendered = await app.renderRoute(req.url);\n * const html = assembleHTML(template, rendered);\n * ```\n */\n\nimport { Window } from 'happy-dom';\nimport type {\n EmberApplication,\n EmberApplicationInstance,\n BootOptions,\n RenderRouteOptions,\n RenderResult,\n ShoeboxEntry,\n EmberApp,\n EmberAppDevOptions,\n} from './server.js';\n\n// ─── Constants ────────────────────────────────────────────────────────\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\nconst SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';\nconst SSR_BODY_START =\n '<script type=\"x/boundary\" id=\"ssr-body-start\"></script>';\nconst SSR_BODY_END = '<script type=\"x/boundary\" id=\"ssr-body-end\"></script>';\n\n// ─── Helpers ──────────────────────────────────────────────────────────\n\nfunction installGlobals(win: Window): Record<string, unknown> {\n const saved: Record<string, unknown> = {};\n for (const name of BROWSER_GLOBALS) {\n saved[name] = (globalThis as Record<string, unknown>)[name];\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 return saved;\n}\n\nfunction restoreGlobals(saved: Record<string, unknown>): void {\n for (const name of BROWSER_GLOBALS) {\n try {\n Object.defineProperty(globalThis, name, {\n value: saved[name],\n writable: true,\n configurable: true,\n enumerable: true,\n });\n } catch {\n /* skip */\n }\n }\n}\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\nfunction buildRouteCssLinks(\n manifest: NonNullable<RenderRouteOptions['cssManifest']>,\n instance: EmberApplicationInstance,\n): string {\n if (!instance.lookup) return '';\n let routeName: string | undefined;\n try {\n const router = instance.lookup('service:router') as\n | { currentRouteName?: string }\n | undefined;\n routeName = router?.currentRouteName ?? undefined;\n } catch {\n return '';\n }\n if (!routeName) return '';\n\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\n// ─── Dev EmberApp factory ─────────────────────────────────────────────\n\n/**\n * Creates a dev-mode EmberApp that renders in-process via Vite's\n * `ssrLoadModule`. Implements the same `EmberApp` interface as the\n * production `createEmberApp` so it can be used as a drop-in.\n */\nexport function createDevEmberApp(\n entryPath: string,\n devOptions: EmberAppDevOptions,\n): EmberApp {\n const { ssrLoadModule } = devOptions;\n\n return {\n async renderRoute(\n url: string,\n renderOptions: RenderRouteOptions = {},\n ): Promise<RenderResult> {\n const {\n shoebox = false,\n rehydrate = false,\n cssManifest,\n headers: forwardHeaders,\n } = renderOptions;\n\n // Fresh Window per request — no state bleeds between renders in dev.\n const 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 const savedGlobals = installGlobals(win);\n\n // Shoebox: intercept fetch for this render only.\n const realFetch = globalThis.fetch;\n const shoeboxEntries: Map<string, ShoeboxEntry> | null = shoebox\n ? new Map()\n : null;\n\n if (shoebox || forwardHeaders) {\n globalThis.fetch = async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ) => {\n // Inject forwarded request headers into outgoing fetches\n let effectiveInit = init;\n if (forwardHeaders) {\n effectiveInit = { ...init };\n const existingHeaders = new Headers(effectiveInit.headers);\n for (const [key, value] of Object.entries(forwardHeaders)) {\n if (!existingHeaders.has(key)) {\n existingHeaders.set(key, value);\n }\n }\n effectiveInit.headers = existingHeaders;\n }\n\n const request = new Request(input, effectiveInit);\n if (request.method.toUpperCase() !== 'GET')\n return realFetch(request);\n const response = await realFetch(request);\n if (shoeboxEntries) {\n try {\n const clone = response.clone();\n const body = await clone.text();\n const headers: Record<string, string> = {};\n clone.headers.forEach((v: string, k: string) => {\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 return response;\n };\n }\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 document = win.document;\n\n // Re-load the module on every request so HMR changes are reflected.\n const mod = (await ssrLoadModule(entryPath)) as {\n createSsrApp?: () => EmberApplication;\n };\n if (typeof mod.createSsrApp !== 'function') {\n throw new Error(\n `SSR entry '${entryPath}' does not export a 'createSsrApp' function. ` +\n `Found exports: ${Object.keys(mod).join(', ')}`,\n );\n }\n const app = mod.createSsrApp();\n\n const bootOptions: BootOptions = {\n isBrowser: false,\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) {\n cssLinks = buildRouteCssLinks(cssManifest, instance);\n }\n\n head = document.head?.innerHTML ?? '';\n body = document.body?.innerHTML ?? '';\n\n // Extract attributes set on <body> during rendering\n if (document.body) {\n for (const attr of Array.from(document.body.attributes)) {\n bodyAttrs[attr.name] = attr.value;\n }\n }\n\n instance.destroy();\n } catch (e) {\n error = e instanceof Error ? e : new Error(String(e));\n } finally {\n if (shoebox || forwardHeaders) globalThis.fetch = realFetch;\n restoreGlobals(savedGlobals);\n await win.happyDOM?.close?.();\n }\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 : `${SSR_BODY_START}${body}${SSR_BODY_END}`;\n\n return {\n head: fullHead,\n body: wrappedBody,\n bodyAttrs,\n statusCode: error ? 500 : 200,\n ...(error ? { error } : {}),\n };\n },\n\n async destroy(): Promise<void> {\n // Nothing to tear down — no worker pool in dev mode.\n },\n };\n}\n","import { pathToFileURL, fileURLToPath } from 'node:url';\nimport { cpus } from 'node:os';\nimport type { CssManifest } from './vite-plugin.js';\nimport { createDevEmberApp } from './dev.js';\n\n// ─── Worker script path ───────────────────────────────────────────────\n\n// Resolve the worker script relative to this compiled file.\n// In the dist/ output both server.js and worker.js sit side-by-side.\nconst WORKER_PATH = fileURLToPath(new URL('./worker.js', import.meta.url));\n\n// ─── Types ───────────────────────────────────────────────────────────\n\n/**\n * Minimal interface for an Ember Application that supports SSR.\n *\n * The app must be created with `autoboot: false` so the server can\n * control boot timing via `app.visit(url, options)`.\n */\nexport interface EmberApplication {\n visit(url: string, options?: BootOptions): Promise<EmberApplicationInstance>;\n destroy(): void;\n}\n\nexport interface EmberApplicationInstance {\n destroy(): void;\n getURL?(): string;\n _booted?: boolean;\n lookup?(fullName: string): unknown;\n}\n\nexport interface BootOptions {\n isBrowser: boolean;\n isInteractive?: boolean;\n document: Document;\n rootElement: Element;\n shouldRender: boolean;\n location?: string;\n _renderMode?: 'serialize' | 'rehydrate' | undefined;\n}\n\nexport interface RenderRouteOptions {\n /**\n * When true, intercepts all fetch() calls during SSR rendering and\n * serializes the responses into a <script> tag in the HTML output.\n */\n shoebox?: boolean;\n\n /**\n * Enable Glimmer VM rehydration mode.\n *\n * When true, the server renders with `_renderMode: 'serialize'`,\n * annotating the DOM with markers Glimmer can reuse on the client.\n *\n * @default false\n */\n rehydrate?: boolean;\n\n /**\n * CSS manifest mapping route names to their associated CSS asset paths.\n *\n * Generated automatically by the `emberSsr()` Vite plugin during the\n * client build (written as `css-manifest.json`).\n */\n cssManifest?: CssManifest;\n\n /**\n * HTTP headers from the incoming request to forward to fetch() calls\n * made during SSR rendering.\n *\n * Use this to forward authentication cookies, authorization tokens,\n * or other request-scoped headers so the SSR render can make\n * authenticated API calls on behalf of the user.\n *\n * Only the specified headers are forwarded. Common usage:\n * ```js\n * const rendered = await app.renderRoute(req.url, {\n * headers: { cookie: req.headers.cookie },\n * });\n * ```\n */\n headers?: Record<string, string>;\n}\n\nexport interface RenderResult {\n /** Rendered HTML from the document's <head> */\n head: string;\n /** Rendered HTML from the document's <body> */\n body: string;\n /** Attributes set on the <body> element during rendering (e.g., data-theme, class) */\n bodyAttrs: Record<string, string>;\n /** HTTP status code (200 by default) */\n statusCode: number;\n /** Any error that occurred during rendering */\n error?: Error;\n}\n\n// ─── Shoebox Types ───────────────────────────────────────────────────\n\n/**\n * A captured fetch response for transfer from server to client.\n */\nexport interface ShoeboxEntry {\n url: string;\n status: number;\n statusText: string;\n headers: Record<string, string>;\n body: string;\n}\n\n// ─── EmberApp ────────────────────────────────────────────────────────\n\nexport interface EmberAppDevOptions {\n /**\n * Vite's `ssrLoadModule` function from the dev server.\n *\n * When provided, `createEmberApp` skips tinypool entirely and renders\n * in-process using Vite's module resolution pipeline. The SSR entry is\n * re-loaded on every render so HMR changes are reflected immediately.\n *\n * Obtain this from your Vite dev server instance:\n * ```js\n * const vite = await createServer({ ... });\n * await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n * ```\n */\n ssrLoadModule: (path: string) => Promise<Record<string, unknown>>;\n}\n\nexport interface EmberAppOptions {\n /**\n * Number of long-lived worker threads in the pool.\n *\n * Each worker imports the SSR bundle once and handles all subsequent\n * render requests without re-importing — making per-render cost ~4ms\n * instead of ~200ms for a fresh-worker approach.\n *\n * Ignored when `dev` is provided.\n *\n * @default os.cpus().length\n */\n workers?: number;\n\n /**\n * How often (in milliseconds) to recycle all workers in the pool.\n *\n * When set, `pool.recycleWorkers()` is called on this interval —\n * tinypool waits for all in-flight tasks to complete, then replaces\n * every worker with a fresh one. This bounds memory growth in\n * long-running processes where workers accumulate state over time.\n *\n * Set to `0` or omit to disable periodic recycling.\n *\n * Ignored when `dev` is provided.\n *\n * @example\n * // Recycle workers every hour\n * await createEmberApp(bundlePath, { recycleWorkerInterval: 60 * 60 * 1000 });\n */\n recycleWorkerInterval?: number;\n\n /**\n * When `true`, each render task is handled by a freshly-started worker.\n *\n * This maps directly to tinypool's `isolateWorkers` option. The worker is\n * replaced after every task, so module-level state (caches, singletons,\n * open handles) never bleeds between requests. The trade-off is that every\n * render pays the full worker-startup and bundle-import cost instead of\n * reusing a warm worker.\n *\n * For most apps the default (long-lived, warm workers) is preferred.\n * Enable isolation when you need strict request-level process boundaries,\n * e.g. when the SSR bundle keeps global state that cannot be reset between\n * renders.\n *\n * Ignored when `dev` is provided.\n *\n * @default false\n */\n isolateWorkers?: boolean;\n\n /**\n * Dev mode options. When provided, skips tinypool and renders in-process\n * via Vite's `ssrLoadModule` so HMR changes are picked up on every render.\n */\n dev?: EmberAppDevOptions;\n}\n\nexport interface EmberApp {\n /**\n * Renders a route and returns the raw head/body HTML fragments.\n *\n * @param url The URL path to render, e.g. `'/'` or `'/about'`\n */\n renderRoute(url: string, options?: RenderRouteOptions): Promise<RenderResult>;\n\n /**\n * Shuts down the worker pool. Call this when the app server is\n * stopping or after SSG prerendering is complete.\n */\n destroy(): Promise<void>;\n}\n\n// ─── EmberApp factory ────────────────────────────────────────────────\n\n/**\n * Creates a long-lived worker thread pool for SSR/SSG rendering.\n *\n * Each worker imports the SSR bundle once at startup and reuses it for all\n * subsequent renders — no bundle re-import, no Worker respawn.\n *\n * Pass `dev: { ssrLoadModule }` to run in dev mode instead: renders happen\n * in-process via Vite's module resolution pipeline with no tinypool workers.\n * The SSR entry is re-loaded on every render so HMR changes are reflected\n * immediately.\n *\n * @example Production\n * ```js\n * import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';\n * import { resolve } from 'node:path';\n *\n * const app = await createEmberApp(resolve('dist/server/app-ssr.mjs'));\n *\n * // In a request handler:\n * const result = await app.renderRoute(req.url);\n * const html = assembleHTML(template, result);\n *\n * // On server shutdown:\n * await app.destroy();\n * ```\n *\n * @example Development\n * ```js\n * import { createServer } from 'vite';\n * import { createEmberApp, assembleHTML } from '@st-h/vite-ember-ssr/server';\n *\n * const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });\n * const app = await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n * ```\n */\nexport async function createEmberApp(\n ssrBundlePath: string,\n options: EmberAppOptions = {},\n): Promise<EmberApp> {\n if (options.dev) {\n return createDevEmberApp(ssrBundlePath, options.dev);\n }\n\n const bundleURL = ssrBundlePath.startsWith('file://')\n ? ssrBundlePath\n : pathToFileURL(ssrBundlePath).href;\n\n const workerCount = options.workers ?? cpus().length;\n\n const { default: Tinypool } = await import('tinypool');\n const pool = new Tinypool({\n filename: WORKER_PATH,\n minThreads: workerCount,\n maxThreads: workerCount,\n isolateWorkers: options.isolateWorkers ?? false,\n // Pass the bundle URL so the worker can import it eagerly at startup,\n // paying the cold-start cost once (at server init) rather than on the\n // first render request.\n workerData: { ssrBundlePath: bundleURL },\n });\n\n // Schedule periodic worker recycling when requested. pool.recycleWorkers()\n // waits for all in-flight renders to finish before replacing every worker\n // with a fresh one, bounding memory growth in long-running processes.\n let recycleTimer: ReturnType<typeof setInterval> | undefined;\n const recycleInterval = options.recycleWorkerInterval ?? 0;\n if (recycleInterval > 0) {\n recycleTimer = setInterval(() => {\n pool.recycleWorkers().catch(() => {\n // recycleWorkers rejects only if the pool is already being destroyed;\n // swallow the error to avoid an unhandled rejection on shutdown.\n });\n }, recycleInterval);\n // Allow the process to exit naturally without waiting for the next tick.\n recycleTimer.unref();\n }\n\n return {\n async renderRoute(\n url: string,\n renderOptions: RenderRouteOptions = {},\n ): Promise<RenderResult> {\n const result = (await pool.run({\n ssrBundlePath: bundleURL,\n url,\n shoebox: renderOptions.shoebox ?? false,\n rehydrate: renderOptions.rehydrate ?? false,\n cssManifest: renderOptions.cssManifest ?? null,\n headers: renderOptions.headers ?? null,\n })) as {\n head: string;\n body: string;\n bodyAttrs: Record<string, string>;\n statusCode: number;\n error?: string;\n };\n\n return {\n head: result.head,\n body: result.body,\n bodyAttrs: result.bodyAttrs ?? {},\n statusCode: result.statusCode,\n error: result.error ? new Error(result.error) : undefined,\n };\n },\n\n async destroy(): Promise<void> {\n clearInterval(recycleTimer);\n await pool.destroy();\n },\n };\n}\n\n// ─── HTML Assembly ───────────────────────────────────────────────────\n\nconst SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';\nconst SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';\nconst SSR_MARKER_REGEX = /<!-- VITE_EMBER_SSR_(HEAD|BODY) -->/g;\n\n/**\n * Assembles the final HTML response by inserting rendered content\n * into the index.html template.\n *\n * When `rendered.bodyAttrs` is provided, attributes set on the `<body>`\n * element during SSR (e.g., `data-theme`, `class`) are applied to the\n * `<body>` tag in the template HTML.\n */\nexport function assembleHTML(\n template: string,\n rendered: Pick<RenderResult, 'head' | 'body' | 'bodyAttrs'>,\n): string {\n let headReplaced = false;\n let bodyReplaced = false;\n\n let html = template.replace(SSR_MARKER_REGEX, (_match, tag: string) => {\n if (tag === 'HEAD' && !headReplaced) {\n headReplaced = true;\n return rendered.head;\n }\n if (tag === 'BODY' && !bodyReplaced) {\n bodyReplaced = true;\n return rendered.body;\n }\n return '';\n });\n\n // Apply body attributes from SSR rendering\n const attrs = rendered.bodyAttrs;\n if (attrs && Object.keys(attrs).length > 0) {\n const attrString = Object.entries(attrs)\n .map(([key, value]) => `${key}=\"${value.replace(/\"/g, '&quot;')}\"`)\n .join(' ');\n html = html.replace(/<body([^>]*)>/, `<body$1 ${attrString}>`);\n }\n\n return html;\n}\n\n/**\n * Checks whether an HTML template contains the required SSR markers.\n */\nexport function hasSSRMarkers(html: string): { head: boolean; body: boolean } {\n return {\n head: html.includes(SSR_HEAD_MARKER),\n body: html.includes(SSR_BODY_MARKER),\n };\n}\n\n// ─── CSS Manifest Loading ────────────────────────────────────────────\n\nexport type { CssManifest } from './vite-plugin.js';\nexport { CSS_MANIFEST_FILENAME } from './vite-plugin.js';\n\n/**\n * Loads the CSS manifest from the client build output directory.\n */\nexport async function loadCssManifest(\n clientDir: string,\n): Promise<CssManifest | undefined> {\n const { readFile } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { CSS_MANIFEST_FILENAME: filename } = await import('./vite-plugin.js');\n\n try {\n const raw = await readFile(join(clientDir, filename), 'utf-8');\n return JSON.parse(raw) as CssManifest;\n } catch {\n return undefined;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,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,MAAM,oBAAoB;AAC1B,MAAM,iBACJ;AACF,MAAM,eAAe;AAIrB,SAAS,eAAe,KAAsC;CAC5D,MAAM,QAAiC,EAAE;AACzC,MAAK,MAAM,QAAQ,iBAAiB;AAClC,QAAM,QAAS,WAAuC;AACtD,MAAI;AACF,UAAO,eAAe,YAAY,MAAM;IACtC,OAAQ,IAA2C;IACnD,UAAU;IACV,cAAc;IACd,YAAY;IACb,CAAC;UACI;;AAIV,QAAO;;AAGT,SAAS,eAAe,OAAsC;AAC5D,MAAK,MAAM,QAAQ,gBACjB,KAAI;AACF,SAAO,eAAe,YAAY,MAAM;GACtC,OAAO,MAAM;GACb,UAAU;GACV,cAAc;GACd,YAAY;GACb,CAAC;SACI;;AAMZ,SAAS,iBAAiB,SAAiC;AACzD,KAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAO,uCAAuC,kBAAkB,IAD/C,KAAK,UAAU,QAAQ,CAAC,QAAQ,iBAAiB,SAAS,CACE;;AAG/E,SAAS,mBACP,UACA,UACQ;AACR,KAAI,CAAC,SAAS,OAAQ,QAAO;CAC7B,IAAI;AACJ,KAAI;AAIF,cAHe,SAAS,OAAO,iBAAiB,EAG5B,oBAAoB,KAAA;SAClC;AACN,SAAO;;AAET,KAAI,CAAC,UAAW,QAAO;CAEvB,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;;;;;;;AAUvB,SAAgB,kBACd,WACA,YACU;CACV,MAAM,EAAE,kBAAkB;AAE1B,QAAO;EACL,MAAM,YACJ,KACA,gBAAoC,EAAE,EACf;GACvB,MAAM,EACJ,UAAU,OACV,YAAY,OACZ,aACA,SAAS,mBACP;GAGJ,MAAM,MAAM,IAAI,OAAO;IACrB,KAAK;IACL,OAAO;IACP,QAAQ;IACR,UAAU;KACR,8BAA8B;KAC9B,6BAA6B;KAC7B,uBAAuB;KACvB,WAAW,EAAE,WAAW,kBAAkB;KAC3C;IACF,CAAC;GAEF,MAAM,eAAe,eAAe,IAAI;GAGxC,MAAM,YAAY,WAAW;GAC7B,MAAM,iBAAmD,0BACrD,IAAI,KAAK,GACT;AAEJ,OAAI,WAAW,eACb,YAAW,QAAQ,OACjB,OACA,SACG;IAEH,IAAI,gBAAgB;AACpB,QAAI,gBAAgB;AAClB,qBAAgB,EAAE,GAAG,MAAM;KAC3B,MAAM,kBAAkB,IAAI,QAAQ,cAAc,QAAQ;AAC1D,UAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,CACvD,KAAI,CAAC,gBAAgB,IAAI,IAAI,CAC3B,iBAAgB,IAAI,KAAK,MAAM;AAGnC,mBAAc,UAAU;;IAG1B,MAAM,UAAU,IAAI,QAAQ,OAAO,cAAc;AACjD,QAAI,QAAQ,OAAO,aAAa,KAAK,MACnC,QAAO,UAAU,QAAQ;IAC3B,MAAM,WAAW,MAAM,UAAU,QAAQ;AACzC,QAAI,eACF,KAAI;KACF,MAAM,QAAQ,SAAS,OAAO;KAC9B,MAAM,OAAO,MAAM,MAAM,MAAM;KAC/B,MAAM,UAAkC,EAAE;AAC1C,WAAM,QAAQ,SAAS,GAAW,MAAc;AAC9C,cAAQ,KAAK;OACb;AACF,oBAAe,IAAI,QAAQ,KAAK;MAC9B,KAAK,QAAQ;MACb,QAAQ,MAAM;MACd,YAAY,MAAM;MAClB;MACA;MACD,CAAC;YACI;AAIV,WAAO;;GAIX,IAAI,OAAO;GACX,IAAI,OAAO;GACX,IAAI,YAAoC,EAAE;GAC1C,IAAI,WAAW;GACf,IAAI;AAEJ,OAAI;IACF,MAAM,WAAW,IAAI;IAGrB,MAAM,MAAO,MAAM,cAAc,UAAU;AAG3C,QAAI,OAAO,IAAI,iBAAiB,WAC9B,OAAM,IAAI,MACR,cAAc,UAAU,8DACJ,OAAO,KAAK,IAAI,CAAC,KAAK,KAAK,GAChD;IAEH,MAAM,MAAM,IAAI,cAAc;IAE9B,MAAM,cAA2B;KAC/B,WAAW;KACD;KACV,aAAa,SAAS;KACtB,cAAc;KACd,GAAI,YAAY,EAAE,aAAa,aAAsB,GAAG,EAAE;KAC3D;IAED,MAAM,WAAW,MAAM,IAAI,MAAM,KAAK,YAAY;AAGlD,UAAM,IAAI,SAAe,YAAY,WAAW,SAAS,EAAE,CAAC;AAE5D,QAAI,YACF,YAAW,mBAAmB,aAAa,SAAS;AAGtD,WAAO,SAAS,MAAM,aAAa;AACnC,WAAO,SAAS,MAAM,aAAa;AAGnC,QAAI,SAAS,KACX,MAAK,MAAM,QAAQ,MAAM,KAAK,SAAS,KAAK,WAAW,CACrD,WAAU,KAAK,QAAQ,KAAK;AAIhC,aAAS,SAAS;YACX,GAAG;AACV,YAAQ,aAAa,QAAQ,IAAI,IAAI,MAAM,OAAO,EAAE,CAAC;aAC7C;AACR,QAAI,WAAW,eAAgB,YAAW,QAAQ;AAClD,mBAAe,aAAa;AAC5B,UAAM,IAAI,UAAU,SAAS;;GAG/B,MAAM,cACJ,kBAAkB,eAAe,OAAO,IACpC,iBAAiB,MAAM,KAAK,eAAe,QAAQ,CAAC,CAAC,GACrD;AASN,UAAO;IACL,MANe,YAHK,YAClB,+DACA,MACwC,cAAc;IAOxD,MANkB,YAChB,OACA,GAAG,iBAAiB,OAAO;IAK7B;IACA,YAAY,QAAQ,MAAM;IAC1B,GAAI,QAAQ,EAAE,OAAO,GAAG,EAAE;IAC3B;;EAGH,MAAM,UAAyB;EAGhC;;;;ACnTH,MAAM,cAAc,cAAc,IAAI,IAAI,eAAe,OAAO,KAAK,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2O1E,eAAsB,eACpB,eACA,UAA2B,EAAE,EACV;AACnB,KAAI,QAAQ,IACV,QAAO,kBAAkB,eAAe,QAAQ,IAAI;CAGtD,MAAM,YAAY,cAAc,WAAW,UAAU,GACjD,gBACA,cAAc,cAAc,CAAC;CAEjC,MAAM,cAAc,QAAQ,WAAW,MAAM,CAAC;CAE9C,MAAM,EAAE,SAAS,aAAa,MAAM,OAAO;CAC3C,MAAM,OAAO,IAAI,SAAS;EACxB,UAAU;EACV,YAAY;EACZ,YAAY;EACZ,gBAAgB,QAAQ,kBAAkB;EAI1C,YAAY,EAAE,eAAe,WAAW;EACzC,CAAC;CAKF,IAAI;CACJ,MAAM,kBAAkB,QAAQ,yBAAyB;AACzD,KAAI,kBAAkB,GAAG;AACvB,iBAAe,kBAAkB;AAC/B,QAAK,gBAAgB,CAAC,YAAY,GAGhC;KACD,gBAAgB;AAEnB,eAAa,OAAO;;AAGtB,QAAO;EACL,MAAM,YACJ,KACA,gBAAoC,EAAE,EACf;GACvB,MAAM,SAAU,MAAM,KAAK,IAAI;IAC7B,eAAe;IACf;IACA,SAAS,cAAc,WAAW;IAClC,WAAW,cAAc,aAAa;IACtC,aAAa,cAAc,eAAe;IAC1C,SAAS,cAAc,WAAW;IACnC,CAAC;AAQF,UAAO;IACL,MAAM,OAAO;IACb,MAAM,OAAO;IACb,WAAW,OAAO,aAAa,EAAE;IACjC,YAAY,OAAO;IACnB,OAAO,OAAO,QAAQ,IAAI,MAAM,OAAO,MAAM,GAAG,KAAA;IACjD;;EAGH,MAAM,UAAyB;AAC7B,iBAAc,aAAa;AAC3B,SAAM,KAAK,SAAS;;EAEvB;;AAKH,MAAM,kBAAkB;AACxB,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;;;;;;;;;AAUzB,SAAgB,aACd,UACA,UACQ;CACR,IAAI,eAAe;CACnB,IAAI,eAAe;CAEnB,IAAI,OAAO,SAAS,QAAQ,mBAAmB,QAAQ,QAAgB;AACrE,MAAI,QAAQ,UAAU,CAAC,cAAc;AACnC,kBAAe;AACf,UAAO,SAAS;;AAElB,MAAI,QAAQ,UAAU,CAAC,cAAc;AACnC,kBAAe;AACf,UAAO,SAAS;;AAElB,SAAO;GACP;CAGF,MAAM,QAAQ,SAAS;AACvB,KAAI,SAAS,OAAO,KAAK,MAAM,CAAC,SAAS,GAAG;EAC1C,MAAM,aAAa,OAAO,QAAQ,MAAM,CACrC,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,IAAI,MAAM,QAAQ,MAAM,SAAS,CAAC,GAAG,CAClE,KAAK,IAAI;AACZ,SAAO,KAAK,QAAQ,iBAAiB,WAAW,WAAW,GAAG;;AAGhE,QAAO;;;;;AAMT,SAAgB,cAAc,MAAgD;AAC5E,QAAO;EACL,MAAM,KAAK,SAAS,gBAAgB;EACpC,MAAM,KAAK,SAAS,gBAAgB;EACrC;;;;;AAWH,eAAsB,gBACpB,WACkC;CAClC,MAAM,EAAE,aAAa,MAAM,OAAO;CAClC,MAAM,EAAE,SAAS,MAAM,OAAO;CAC9B,MAAM,EAAE,uBAAuB,aAAa,MAAM,OAAO;AAEzD,KAAI;EACF,MAAM,MAAM,MAAM,SAAS,KAAK,WAAW,SAAS,EAAE,QAAQ;AAC9D,SAAO,KAAK,MAAM,IAAI;SAChB;AACN"}
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/dev.ts","../src/server.ts"],"sourcesContent":["/**\n * Dev-mode SSR renderer for vite-ember-ssr.\n *\n * Renders in-process using Vite's `ssrLoadModule` pipeline instead of a\n * tinypool worker pool. The SSR entry module is re-loaded on every render\n * so HMR changes are picked up immediately.\n *\n * A fresh HappyDOM Window is created and torn down for each render — there\n * is no long-lived state between requests.\n *\n * Usage:\n * ```js\n * import { createServer } from 'vite';\n * import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';\n *\n * const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });\n * const app = await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n *\n * // In your catch-all handler:\n * const rendered = await app.renderRoute(req.url);\n * const html = assembleHTML(template, rendered);\n * ```\n */\n\nimport { Window } from 'happy-dom';\nimport type {\n EmberApplication,\n EmberApplicationInstance,\n BootOptions,\n RenderRouteOptions,\n RenderResult,\n ShoeboxEntry,\n ForwardedCookie,\n EmberApp,\n EmberAppDevOptions,\n} from './server.js';\nimport {\n compose,\n forwardCookieMiddleware,\n shoeboxMiddleware,\n} from './fetch-middleware.js';\n\n// ─── Constants ────────────────────────────────────────────────────────\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\nconst SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';\n\n// Warn only once per process — the SSR entry is re-loaded on every render\n// in dev mode, so a per-render warning would flood the console.\nlet warnedMissingSettled = false;\n\n// ─── Helpers ──────────────────────────────────────────────────────────\n\nfunction installGlobals(win: Window): Record<string, unknown> {\n const saved: Record<string, unknown> = {};\n for (const name of BROWSER_GLOBALS) {\n saved[name] = (globalThis as Record<string, unknown>)[name];\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 return saved;\n}\n\nfunction restoreGlobals(saved: Record<string, unknown>): void {\n for (const name of BROWSER_GLOBALS) {\n try {\n Object.defineProperty(globalThis, name, {\n value: saved[name],\n writable: true,\n configurable: true,\n enumerable: true,\n });\n } catch {\n /* skip */\n }\n }\n}\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\nfunction buildRouteCssLinks(\n manifest: NonNullable<RenderRouteOptions['cssManifest']>,\n instance: EmberApplicationInstance,\n): string {\n if (!instance.lookup) return '';\n let routeName: string | undefined;\n try {\n const router = instance.lookup('service:router') as\n | { currentRouteName?: string }\n | undefined;\n routeName = router?.currentRouteName ?? undefined;\n } catch {\n return '';\n }\n if (!routeName) return '';\n\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\n// ─── Dev EmberApp factory ─────────────────────────────────────────────\n\n/**\n * Creates a dev-mode EmberApp that renders in-process via Vite's\n * `ssrLoadModule`. Implements the same `EmberApp` interface as the\n * production `createEmberApp` so it can be used as a drop-in.\n */\nexport function createDevEmberApp(\n entryPath: string,\n devOptions: EmberAppDevOptions,\n): EmberApp {\n const { ssrLoadModule } = devOptions;\n\n // Install browser globals once at startup so module-evaluation-time access\n // (e.g. `@ember/test-helpers` reading `document` at the top level) succeeds\n // even before the first per-request window is created. The per-request\n // window below replaces these globals during the actual render.\n const startupWindow = 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 installGlobals(startupWindow);\n\n return {\n async renderRoute(\n url: string,\n renderOptions: RenderRouteOptions = {},\n ): Promise<RenderResult> {\n const {\n shoebox = false,\n cssManifest,\n settledTimeout = 10_000,\n forwardCookie,\n } = renderOptions;\n\n // Fresh Window per request — no state bleeds between renders in dev.\n const 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 const savedGlobals = installGlobals(win);\n\n // Build a per-render fetch middleware pipeline. State is captured by\n // closure so each render is fully isolated from the next.\n const realFetch = globalThis.fetch;\n const shoeboxEntries: Map<string, ShoeboxEntry> | null = shoebox\n ? new Map()\n : null;\n const cookie: ForwardedCookie | null = forwardCookie ?? null;\n const middlewareActive = shoeboxEntries !== null || cookie !== null;\n\n if (middlewareActive) {\n globalThis.fetch = compose(\n [\n forwardCookieMiddleware(() => cookie),\n shoeboxMiddleware(() => shoeboxEntries),\n ],\n (request) => realFetch(request),\n );\n }\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 document = win.document;\n\n // Re-load the module on every request so HMR changes are reflected.\n const mod = (await ssrLoadModule(entryPath)) as {\n createSsrApp?: () => EmberApplication;\n settled?: () => Promise<void>;\n };\n if (typeof mod.createSsrApp !== 'function') {\n throw new Error(\n `SSR entry '${entryPath}' does not export a 'createSsrApp' function. ` +\n `Found exports: ${Object.keys(mod).join(', ')}`,\n );\n }\n const app = mod.createSsrApp();\n const appSettled =\n typeof mod.settled === 'function' ? mod.settled : null;\n\n const bootOptions: BootOptions = {\n isBrowser: false,\n document: document as unknown as Document,\n rootElement: document.body as unknown as Element,\n shouldRender: true,\n _renderMode: 'serialize',\n };\n\n const instance = await app.visit(url, bootOptions);\n\n // Wait for the app to settle (test waiters, run loop, pending timers).\n // Fallback to a microtask drain when the SSR entry doesn't export it.\n if (appSettled) {\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n await Promise.race([\n appSettled(),\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () =>\n reject(\n new Error(\n `settled() timed out after ${settledTimeout}ms`,\n ),\n ),\n settledTimeout,\n );\n }),\n ]);\n } catch (e) {\n console.warn(\n `[vite-ember-ssr] settled() did not resolve within ${settledTimeout}ms, ` +\n `capturing DOM anyway:`,\n e instanceof Error ? e.message : e,\n );\n } finally {\n if (timer) clearTimeout(timer);\n }\n } else {\n if (settledTimeout > 0 && !warnedMissingSettled) {\n warnedMissingSettled = true;\n console.warn(\n '[vite-ember-ssr] settledTimeout is set but the SSR entry does ' +\n 'not export `settled` — renders will NOT wait for the app to ' +\n 'settle and may capture incomplete HTML. Add ' +\n \"`export { settled } from '@ember/test-helpers';` to your \" +\n 'SSR entry.',\n );\n }\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n }\n\n if (cssManifest) {\n cssLinks = buildRouteCssLinks(cssManifest, instance);\n }\n\n head = document.head?.innerHTML ?? '';\n body = document.body?.innerHTML ?? '';\n\n // Extract attributes set on <body> during rendering\n if (document.body) {\n for (const attr of Array.from(document.body.attributes)) {\n bodyAttrs[attr.name] = attr.value;\n }\n }\n\n instance.destroy();\n } catch (e) {\n error = e instanceof Error ? e : new Error(String(e));\n } finally {\n if (middlewareActive) globalThis.fetch = realFetch;\n restoreGlobals(savedGlobals);\n await win.happyDOM?.close?.();\n }\n\n const shoeboxHTML =\n shoeboxEntries && shoeboxEntries.size > 0\n ? serializeShoebox(Array.from(shoeboxEntries.values()))\n : '';\n const rehydrateHTML =\n '<script>window.__vite_ember_ssr_rehydrate__=true</script>';\n const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;\n\n return {\n head: fullHead,\n body,\n bodyAttrs,\n statusCode: error ? 500 : 200,\n ...(error ? { error } : {}),\n };\n },\n\n async destroy(): Promise<void> {\n // Nothing to tear down — no worker pool in dev mode.\n },\n };\n}\n","import { pathToFileURL, fileURLToPath } from 'node:url';\nimport { cpus } from 'node:os';\nimport type { CssManifest } from './vite-plugin.js';\nimport { createDevEmberApp } from './dev.js';\n\n// ─── Worker script path ───────────────────────────────────────────────\n\n// Resolve the worker script relative to this compiled file.\n// In the dist/ output both server.js and worker.js sit side-by-side.\nconst WORKER_PATH = fileURLToPath(new URL('./worker.js', import.meta.url));\n\n// ─── Types ───────────────────────────────────────────────────────────\n\n/**\n * Minimal interface for an Ember Application that supports SSR.\n *\n * The app must be created with `autoboot: false` so the server can\n * control boot timing via `app.visit(url, options)`.\n */\nexport interface EmberApplication {\n visit(url: string, options?: BootOptions): Promise<EmberApplicationInstance>;\n destroy(): void;\n}\n\nexport interface EmberApplicationInstance {\n destroy(): void;\n getURL?(): string;\n _booted?: boolean;\n lookup?(fullName: string): unknown;\n}\n\nexport interface BootOptions {\n isBrowser: boolean;\n isInteractive?: boolean;\n document: Document;\n rootElement: Element;\n shouldRender: boolean;\n location?: string;\n _renderMode?: 'serialize' | 'rehydrate' | undefined;\n}\n\n/**\n * Configures forwarding of the incoming request's `Cookie` header to\n * fetch() calls made during SSR rendering.\n *\n * `allowedHosts` is required: forwarding the session cookie to every\n * outbound fetch would leak credentials to third-party APIs the route\n * happens to call. Each entry is matched against the request URL's\n * `host` (hostname plus port) using exact equality — suffix wildcards\n * are not supported.\n */\nexport interface ForwardedCookie {\n /** Cookie header value from the incoming request. */\n value: string;\n /**\n * Hosts (`URL.host`) the cookie may be sent to. Exact match, no wildcards.\n *\n * @example ['api.example.com', 'auth.example.com:8080']\n */\n allowedHosts: string[];\n}\n\nexport interface RenderRouteOptions {\n /**\n * When true, intercepts all fetch() calls during SSR rendering and\n * serializes the responses into a <script> tag in the HTML output.\n */\n shoebox?: boolean;\n\n /**\n * CSS manifest mapping route names to their associated CSS asset paths.\n *\n * Generated automatically by the `emberSsr()` Vite plugin during the\n * client build (written as `css-manifest.json`).\n */\n cssManifest?: CssManifest;\n\n /**\n * Maximum time (in milliseconds) to wait for `settled()` to resolve after\n * `app.visit()`. Only applies when the SSR bundle exports a `settled`\n * function (typically re-exported from `@ember/test-helpers`).\n *\n * If the timeout is exceeded, a warning is logged and the DOM is captured\n * regardless. Use this to bound render time when a route registers a\n * waiter that never resolves.\n *\n * @default 10000\n */\n settledTimeout?: number;\n\n /**\n * Forward the incoming request's `Cookie` header to fetch() calls made\n * during SSR rendering. The cookie is only sent to hosts listed in\n * `allowedHosts`, so credentials never leak to third-party APIs the\n * route may also call.\n *\n * @example\n * ```js\n * await app.renderRoute(req.url, {\n * forwardCookie: {\n * value: req.headers.cookie ?? '',\n * allowedHosts: ['api.example.com'],\n * },\n * });\n * ```\n */\n forwardCookie?: ForwardedCookie;\n}\n\nexport interface RenderResult {\n /** Rendered HTML from the document's <head> */\n head: string;\n /** Rendered HTML from the document's <body> */\n body: string;\n /** Attributes set on the <body> element during rendering (e.g., data-theme, class) */\n bodyAttrs: Record<string, string>;\n /** HTTP status code (200 by default) */\n statusCode: number;\n /** Any error that occurred during rendering */\n error?: Error;\n}\n\n// ─── Shoebox Types ───────────────────────────────────────────────────\n\n/**\n * A captured fetch response for transfer from server to client.\n */\nexport interface ShoeboxEntry {\n url: string;\n status: number;\n statusText: string;\n headers: Record<string, string>;\n body: string;\n}\n\n// ─── EmberApp ────────────────────────────────────────────────────────\n\nexport interface EmberAppDevOptions {\n /**\n * Vite's `ssrLoadModule` function from the dev server.\n *\n * When provided, `createEmberApp` skips tinypool entirely and renders\n * in-process using Vite's module resolution pipeline. The SSR entry is\n * re-loaded on every render so HMR changes are reflected immediately.\n *\n * Obtain this from your Vite dev server instance:\n * ```js\n * const vite = await createServer({ ... });\n * await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n * ```\n */\n ssrLoadModule: (path: string) => Promise<Record<string, unknown>>;\n}\n\nexport interface EmberAppOptions {\n /**\n * Number of long-lived worker threads in the pool.\n *\n * Each worker imports the SSR bundle once and handles all subsequent\n * render requests without re-importing — making per-render cost ~4ms\n * instead of ~200ms for a fresh-worker approach.\n *\n * Ignored when `dev` is provided.\n *\n * @default os.cpus().length\n */\n workers?: number;\n\n /**\n * How often (in milliseconds) to recycle all workers in the pool.\n *\n * When set, `pool.recycleWorkers()` is called on this interval —\n * tinypool waits for all in-flight tasks to complete, then replaces\n * every worker with a fresh one. This bounds memory growth in\n * long-running processes where workers accumulate state over time.\n *\n * Set to `0` or omit to disable periodic recycling.\n *\n * Ignored when `dev` is provided.\n *\n * @example\n * // Recycle workers every hour\n * await createEmberApp(bundlePath, { recycleWorkerInterval: 60 * 60 * 1000 });\n */\n recycleWorkerInterval?: number;\n\n /**\n * When `true`, each render task is handled by a freshly-started worker.\n *\n * This maps directly to tinypool's `isolateWorkers` option. The worker is\n * replaced after every task, so module-level state (caches, singletons,\n * open handles) never bleeds between requests. The trade-off is that every\n * render pays the full worker-startup and bundle-import cost instead of\n * reusing a warm worker.\n *\n * For most apps the default (long-lived, warm workers) is preferred.\n * Enable isolation when you need strict request-level process boundaries,\n * e.g. when the SSR bundle keeps global state that cannot be reset between\n * renders.\n *\n * Ignored when `dev` is provided.\n *\n * @default false\n */\n isolateWorkers?: boolean;\n\n /**\n * Dev mode options. When provided, skips tinypool and renders in-process\n * via Vite's `ssrLoadModule` so HMR changes are picked up on every render.\n */\n dev?: EmberAppDevOptions;\n}\n\nexport interface EmberApp {\n /**\n * Renders a route and returns the raw head/body HTML fragments.\n *\n * @param url The URL path to render, e.g. `'/'` or `'/about'`\n */\n renderRoute(url: string, options?: RenderRouteOptions): Promise<RenderResult>;\n\n /**\n * Shuts down the worker pool. Call this when the app server is\n * stopping or after SSG prerendering is complete.\n */\n destroy(): Promise<void>;\n}\n\n// ─── EmberApp factory ────────────────────────────────────────────────\n\n/**\n * Creates a long-lived worker thread pool for SSR/SSG rendering.\n *\n * Each worker imports the SSR bundle once at startup and reuses it for all\n * subsequent renders — no bundle re-import, no Worker respawn.\n *\n * Pass `dev: { ssrLoadModule }` to run in dev mode instead: renders happen\n * in-process via Vite's module resolution pipeline with no tinypool workers.\n * The SSR entry is re-loaded on every render so HMR changes are reflected\n * immediately.\n *\n * @example Production\n * ```js\n * import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';\n * import { resolve } from 'node:path';\n *\n * const app = await createEmberApp(resolve('dist/server/app-ssr.mjs'));\n *\n * // In a request handler:\n * const result = await app.renderRoute(req.url);\n * const html = assembleHTML(template, result);\n *\n * // On server shutdown:\n * await app.destroy();\n * ```\n *\n * @example Development\n * ```js\n * import { createServer } from 'vite';\n * import { createEmberApp, assembleHTML } from 'vite-ember-ssr/server';\n *\n * const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' });\n * const app = await createEmberApp('app/app-ssr.ts', {\n * dev: { ssrLoadModule: vite.ssrLoadModule.bind(vite) },\n * });\n * ```\n */\nexport async function createEmberApp(\n ssrBundlePath: string,\n options: EmberAppOptions = {},\n): Promise<EmberApp> {\n if (options.dev) {\n return createDevEmberApp(ssrBundlePath, options.dev);\n }\n\n const bundleURL = ssrBundlePath.startsWith('file://')\n ? ssrBundlePath\n : pathToFileURL(ssrBundlePath).href;\n\n const workerCount = options.workers ?? cpus().length;\n\n const { default: Tinypool } = await import('tinypool');\n const pool = new Tinypool({\n filename: WORKER_PATH,\n minThreads: workerCount,\n maxThreads: workerCount,\n isolateWorkers: options.isolateWorkers ?? false,\n // Pass the bundle URL so the worker can import it eagerly at startup,\n // paying the cold-start cost once (at server init) rather than on the\n // first render request.\n workerData: { ssrBundlePath: bundleURL },\n });\n\n // Schedule periodic worker recycling when requested. pool.recycleWorkers()\n // waits for all in-flight renders to finish before replacing every worker\n // with a fresh one, bounding memory growth in long-running processes.\n let recycleTimer: ReturnType<typeof setInterval> | undefined;\n const recycleInterval = options.recycleWorkerInterval ?? 0;\n if (recycleInterval > 0) {\n recycleTimer = setInterval(() => {\n pool.recycleWorkers().catch(() => {\n // recycleWorkers rejects only if the pool is already being destroyed;\n // swallow the error to avoid an unhandled rejection on shutdown.\n });\n }, recycleInterval);\n // Allow the process to exit naturally without waiting for the next tick.\n recycleTimer.unref();\n }\n\n return {\n async renderRoute(\n url: string,\n renderOptions: RenderRouteOptions = {},\n ): Promise<RenderResult> {\n const result = (await pool.run({\n ssrBundlePath: bundleURL,\n url,\n shoebox: renderOptions.shoebox ?? false,\n cssManifest: renderOptions.cssManifest ?? null,\n settledTimeout: renderOptions.settledTimeout ?? 10_000,\n forwardCookie: renderOptions.forwardCookie ?? null,\n })) as {\n head: string;\n body: string;\n bodyAttrs: Record<string, string>;\n statusCode: number;\n error?: string;\n };\n\n return {\n head: result.head,\n body: result.body,\n bodyAttrs: result.bodyAttrs ?? {},\n statusCode: result.statusCode,\n error: result.error ? new Error(result.error) : undefined,\n };\n },\n\n async destroy(): Promise<void> {\n clearInterval(recycleTimer);\n await pool.destroy();\n },\n };\n}\n\n// ─── HTML Assembly ───────────────────────────────────────────────────\n\nconst SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';\nconst SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';\nconst SSR_MARKER_REGEX = /<!-- VITE_EMBER_SSR_(HEAD|BODY) -->/g;\n\n/**\n * Assembles the final HTML response by inserting rendered content\n * into the index.html template.\n *\n * When `rendered.bodyAttrs` is provided, attributes set on the `<body>`\n * element during SSR (e.g., `data-theme`, `class`) are applied to the\n * `<body>` tag in the template HTML.\n */\nexport function assembleHTML(\n template: string,\n rendered: Pick<RenderResult, 'head' | 'body' | 'bodyAttrs'>,\n): string {\n let headReplaced = false;\n let bodyReplaced = false;\n\n let html = template.replace(SSR_MARKER_REGEX, (_match, tag: string) => {\n if (tag === 'HEAD' && !headReplaced) {\n headReplaced = true;\n return rendered.head;\n }\n if (tag === 'BODY' && !bodyReplaced) {\n bodyReplaced = true;\n return rendered.body;\n }\n return '';\n });\n\n // Apply body attributes from SSR rendering\n const attrs = rendered.bodyAttrs;\n if (attrs && Object.keys(attrs).length > 0) {\n const attrString = Object.entries(attrs)\n .map(([key, value]) => `${key}=\"${value.replace(/\"/g, '&quot;')}\"`)\n .join(' ');\n html = html.replace(/<body([^>]*)>/, `<body$1 ${attrString}>`);\n }\n\n return html;\n}\n\n/**\n * Checks whether an HTML template contains the required SSR markers.\n */\nexport function hasSSRMarkers(html: string): { head: boolean; body: boolean } {\n return {\n head: html.includes(SSR_HEAD_MARKER),\n body: html.includes(SSR_BODY_MARKER),\n };\n}\n\n// ─── CSS Manifest Loading ────────────────────────────────────────────\n\nexport type { CssManifest } from './vite-plugin.js';\nexport { CSS_MANIFEST_FILENAME } from './vite-plugin.js';\n\n/**\n * Loads the CSS manifest from the client build output directory.\n */\nexport async function loadCssManifest(\n clientDir: string,\n): Promise<CssManifest | undefined> {\n const { readFile } = await import('node:fs/promises');\n const { join } = await import('node:path');\n const { CSS_MANIFEST_FILENAME: filename } = await import('./vite-plugin.js');\n\n try {\n const raw = await readFile(join(clientDir, filename), 'utf-8');\n return JSON.parse(raw) as CssManifest;\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
@@ -243,7 +243,7 @@ function emberSsr(options = {}) {
243
243
  * @example
244
244
  * ```js
245
245
  * // vite.config.mjs
246
- * import { emberSsg } from '@st-h/vite-ember-ssr/vite-plugin';
246
+ * import { emberSsg } from 'vite-ember-ssr/vite-plugin';
247
247
  *
248
248
  * export default defineConfig({
249
249
  * plugins: [
@@ -257,7 +257,7 @@ function emberSsr(options = {}) {
257
257
  * ```
258
258
  */
259
259
  function emberSsg(options) {
260
- const { routes, ssrEntry = "app/app-ssr.ts", shoebox = false, rehydrate = false } = options;
260
+ const { routes, ssrEntry = "app/app-ssr.ts", shoebox = false } = options;
261
261
  const explicitOutDir = options.outDir;
262
262
  let resolvedConfig;
263
263
  let isCombined = false;
@@ -359,7 +359,6 @@ function emberSsg(options) {
359
359
  try {
360
360
  const result = await app.renderRoute(url, {
361
361
  shoebox,
362
- rehydrate,
363
362
  cssManifest
364
363
  });
365
364
  if (result.error) {
@@ -395,4 +394,4 @@ function emberSsg(options) {
395
394
  //#endregion
396
395
  export { emberSsr as a, emberSsg as i, SSR_BODY_MARKER as n, SSR_HEAD_MARKER as r, CSS_MANIFEST_FILENAME as t };
397
396
 
398
- //# sourceMappingURL=vite-plugin-D-W5WQWe.js.map
397
+ //# sourceMappingURL=vite-plugin-9BSJgEL9.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite-plugin-9BSJgEL9.js","names":[],"sources":["../src/vite-plugin.ts"],"sourcesContent":["import type { Plugin, PluginOption, ResolvedConfig, UserConfig } from 'vite';\nimport { join, dirname } from 'node:path';\nimport {\n mkdir,\n writeFile,\n readFile,\n rm,\n copyFile,\n access,\n} from 'node:fs/promises';\nimport { pathToFileURL } from 'node:url';\nimport { cpus } from 'node:os';\n\nexport const SSR_HEAD_MARKER = '<!-- VITE_EMBER_SSR_HEAD -->';\nexport const SSR_BODY_MARKER = '<!-- VITE_EMBER_SSR_BODY -->';\n\n/**\n * Name of the CSS manifest file generated during the client build.\n * Maps dynamic entry source modules to their associated CSS asset paths.\n */\nexport const CSS_MANIFEST_FILENAME = 'css-manifest.json';\n\n/**\n * The CSS manifest maps Ember route names to the CSS files that Vite\n * extracted from their lazy-loaded template chunks during the client build.\n *\n * Route names use Ember's dot-separated convention for nested routes:\n * - `about` for `app/templates/about.gts`\n * - `blog.post` for `app/templates/blog/post.gts`\n *\n * Example:\n * ```json\n * {\n * \"about\": [\"/assets/about-VWk4xp3e.css\"]\n * }\n * ```\n *\n * During SSR, the renderer queries the active route name from Ember's\n * router service and looks up CSS files to inject as `<link>` tags.\n */\nexport type CssManifest = Record<string, string[]>;\n\n/**\n * Derives an Ember route name from a source module path following\n * Ember's conventional file layout.\n *\n * `app/templates/about.gts` → `about`\n * `app/templates/blog/post.gts` → `blog.post`\n * `app/templates/index.gts` → `index`\n *\n * Returns undefined if the path doesn't match the convention.\n */\nfunction sourcePathToRouteName(\n facadeModuleId: string,\n root: string,\n): string | undefined {\n // Make the path relative to the project root\n let relativePath = facadeModuleId;\n if (relativePath.startsWith(root)) {\n relativePath = relativePath.slice(root.length);\n }\n // Strip leading slash\n if (relativePath.startsWith('/')) {\n relativePath = relativePath.slice(1);\n }\n\n // Match app/templates/<route-path>.<ext>\n const match = relativePath.match(\n /^app\\/templates\\/(.+)\\.(gts|gjs|hbs|ts|js)$/,\n );\n if (!match) return undefined;\n\n // Convert path separators to dots for nested routes\n return match[1].replace(/\\//g, '.');\n}\n\n/**\n * Minimal type for a Rollup output chunk with Vite metadata.\n * We define this locally to avoid a direct dependency on the 'rollup' package.\n */\ninterface OutputChunkWithMeta {\n type: 'chunk';\n isDynamicEntry: boolean;\n isEntry: boolean;\n facadeModuleId: string | null;\n name: string;\n fileName: string;\n imports: string[];\n viteMetadata?: {\n importedCss?: Set<string>;\n };\n}\n\n/**\n * Walks the Rollup output bundle and collects CSS files associated\n * with dynamic entry chunks. These are CSS imports that Vite extracted\n * from code-split chunks (e.g., lazy-loaded route templates).\n *\n * The main entry's CSS is already linked in the HTML template by Vite,\n * so we only collect CSS from `isDynamicEntry` chunks.\n *\n * When a component with CSS is shared across multiple lazy routes,\n * Vite extracts the shared CSS into a separate chunk. We walk each\n * dynamic entry's static `imports` graph to collect CSS from those\n * shared chunks too, skipping the main entry chunk (whose CSS is\n * already in the HTML template).\n *\n * Keys are Ember route names derived from the source file path using\n * Ember's conventional `app/templates/` directory structure.\n */\nfunction buildCssManifest(\n bundle: Record<string, { type: string }>,\n base: string,\n root: string,\n): CssManifest {\n const manifest: CssManifest = {};\n\n // Build a lookup of fileName → chunk for walking the import graph.\n const chunksByFile = new Map<string, OutputChunkWithMeta>();\n const mainEntryFiles = new Set<string>();\n\n for (const [, output] of Object.entries(bundle)) {\n if (output.type !== 'chunk') continue;\n const chunk = output as unknown as OutputChunkWithMeta;\n chunksByFile.set(chunk.fileName, chunk);\n\n // Track main entry chunks so we can exclude their CSS.\n // Main entry CSS is already linked in the HTML template by Vite.\n if (chunk.isEntry && !chunk.isDynamicEntry) {\n mainEntryFiles.add(chunk.fileName);\n }\n }\n\n /**\n * Recursively collect all CSS from a chunk and its static imports,\n * excluding main entry chunks (whose CSS is already in the template).\n */\n function collectCss(\n fileName: string,\n seen: Set<string>,\n css: Set<string>,\n ): void {\n if (seen.has(fileName)) return;\n seen.add(fileName);\n\n // Don't collect CSS from the main entry — it's already in the HTML.\n if (mainEntryFiles.has(fileName)) return;\n\n const chunk = chunksByFile.get(fileName);\n if (!chunk) return;\n\n const importedCss = chunk.viteMetadata?.importedCss;\n if (importedCss) {\n for (const cssFile of importedCss) {\n css.add(cssFile);\n }\n }\n\n // Walk static imports (shared chunks extracted by Vite).\n for (const imp of chunk.imports) {\n collectCss(imp, seen, css);\n }\n }\n\n for (const [, output] of Object.entries(bundle)) {\n if (output.type !== 'chunk') continue;\n\n const chunk = output as unknown as OutputChunkWithMeta;\n\n // Only collect CSS from dynamic entries (code-split chunks).\n if (!chunk.isDynamicEntry) continue;\n\n // Collect CSS from this chunk and all its static imports.\n const css = new Set<string>();\n collectCss(chunk.fileName, new Set(), css);\n\n if (css.size === 0) continue;\n\n // Derive the Ember route name from the source module path.\n // If the path doesn't match Ember conventions, fall back to\n // the chunk name (e.g., 'about' from 'about-B5EiMzMx.js').\n const routeName = chunk.facadeModuleId\n ? (sourcePathToRouteName(chunk.facadeModuleId, root) ?? chunk.name)\n : chunk.name;\n\n if (!routeName) continue;\n\n // Prefix CSS paths with the base URL so they work as href values.\n const cssFiles = Array.from(css).map((c) => `${base}${c}`);\n\n if (cssFiles.length > 0) {\n manifest[routeName] = cssFiles;\n }\n }\n\n return manifest;\n}\n\n/**\n * Returns SSR config appropriate for the current Vite command.\n *\n * Ember's virtual packages (`@glimmer/tracking`, `@ember/*`, etc.) are\n * provided by `ember-source` and not published as real npm packages.\n * When Vite externalizes a dependency that transitively imports one of\n * these virtual packages, Node's runtime module resolution fails under\n * pnpm's strict `node_modules` layout.\n *\n * For both production builds and dev mode:\n * - Clears any user-specified `ssr.external` (explicit string entries\n * take precedence over `noExternal` patterns in Vite, so we must\n * remove them to ensure `noExternal: [/./]` applies).\n * - Sets `ssr: { noExternal: [/./] }` so all deps go through Vite's\n * transform pipeline. This lets `@embroider/vite`'s resolver handle\n * virtual Ember/Glimmer packages that don't exist outside `ember-source`\n * under pnpm's strict `node_modules` layout.\n *\n * In dev mode, `ssrLoadModule` uses `SSRCompatModuleRunner` +\n * `ESModulesEvaluator`. Without bundling, this evaluates all module code\n * inline. CJS/UMD packages (e.g. `@warp-drive/utilities/string`,\n * `json-to-ast`) reference `module`, `exports`, or `global` which are not\n * available in the evaluator's context.\n *\n * The `cjsSsrShimTransform` hook (applied by `emberSsr()` and `emberSsg()`)\n * intercepts those files before they reach `ssrTransform` and wraps them\n * with a lightweight CommonJS shim, providing the missing `module`,\n * `exports`, and `global` bindings.\n *\n * See: https://github.com/evoactivity/vite-ember-ssr/issues/4\n */\nfunction ssrDepsConfig(\n userConfig: UserConfig,\n _command: 'build' | 'serve',\n): { ssr?: UserConfig['ssr'] } {\n if (userConfig.ssr) {\n delete userConfig.ssr.external;\n }\n return { ssr: { noExternal: [/./] } };\n}\n\n/**\n * Returns a Vite `transform` hook that wraps CJS/UMD modules encountered\n * during SSR transforms.\n *\n * When `noExternal: [/./]` is set, every dependency goes through Vite's\n * `ssrTransform` → `ESModulesEvaluator` pipeline. CJS/UMD files that use\n * `module`, `exports`, or `global` fail because those globals are not\n * available inside `ESModulesEvaluator`'s `AsyncFunction` context.\n *\n * This transform detects CJS/UMD content (no top-level `import`/`export`\n * statements, but contains `exports.xxx` or `module.exports`) and wraps\n * the code so that:\n * 1. `module`, `exports`, and `global` are available as local variables.\n * 2. The module's exports are re-exported as the ES default export.\n *\n * The heuristic is intentionally simple and conservative — it only fires\n * on files that have no ESM syntax at all, which covers the CJS/UMD\n * packages that appear in the Ember + WarpDrive dependency tree without\n * misidentifying genuine ESM files.\n */\nfunction cjsSsrShimTransform(\n code: string,\n _id: string,\n options?: { ssr?: boolean },\n): { code: string; map: null } | null {\n // Only apply during SSR transforms\n if (!options?.ssr) return null;\n\n // Skip if the file contains any top-level import/export → it's ESM\n if (/^(?:import\\s|export\\s|export\\{|export default)/m.test(code)) return null;\n\n // Only wrap files that use CommonJS exports or module.exports\n if (!/\\bexports\\s*[.[=]|\\bmodule\\s*\\.\\s*exports\\b/.test(code)) return null;\n\n const wrapped = `\\\nconst __cjs_module__ = { exports: {} };\nconst __cjs_exports__ = __cjs_module__.exports;\nconst __cjs_global__ = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' ? global : {};\n(function(module, exports, global) {\n${code}\n})(__cjs_module__, __cjs_exports__, __cjs_global__);\nexport default __cjs_module__.exports;\n`;\n return { code: wrapped, map: null };\n}\n\n/**\n * Flatten and filter a Vite plugins array, which may contain nested arrays,\n * falsy values, and Promise-wrapped entries.\n */\nfunction flatPlugins(plugins: PluginOption[] | undefined): Plugin[] {\n if (!plugins) return [];\n return (plugins as unknown[])\n .flat(Infinity)\n .filter(\n (p): p is Plugin => p != null && typeof p === 'object' && 'name' in p,\n );\n}\n\nexport interface EmberSsrPluginOptions {\n /**\n * Output directory for the client build.\n * @default 'dist/client'\n */\n clientOutDir?: string;\n\n /**\n * Output directory for the SSR build.\n * @default 'dist/server'\n */\n serverOutDir?: string;\n}\n\n/**\n * Vite plugin that configures SSR support for Ember applications.\n *\n * Handles all SSR-related Vite configuration automatically:\n *\n * - Bundles all dependencies into SSR builds (`ssr.noExternal: [/./]`)\n * to avoid runtime resolution failures under pnpm's strict\n * node_modules layout (see issue #4)\n * - Sets build defaults: `dist/client` for client builds,\n * `dist/server` with `target: 'node22'` for SSR builds\n * - Writes a `package.json` with `\"type\": \"module\"` to the SSR\n * build output directory (needed for Node ESM compatibility)\n */\nexport function emberSsr(options: EmberSsrPluginOptions = {}): Plugin {\n let resolvedConfig: ResolvedConfig;\n\n return {\n name: 'vite-ember-ssr',\n\n config(userConfig, env): UserConfig {\n // Bundle all dependencies for SSR builds and dev mode to avoid runtime\n // failures under pnpm's strict node_modules layout when external packages\n // transitively import virtual Ember/Glimmer packages (e.g.\n // @glimmer/tracking) that only exist inside ember-source.\n // In dev mode, the `transform: cjsSsrShimTransform` hook wraps\n // CJS/UMD packages so they work with ESModulesEvaluator.\n // See: https://github.com/evoactivity/vite-ember-ssr/issues/4\n const ssrConfig = ssrDepsConfig(userConfig, env.command);\n\n // During the SSG child build, only set ssr config — don't\n // override build.outDir (the SSG plugin sets it explicitly\n // via inline config to a temp directory).\n if (process.env.__VITE_EMBER_SSG_CHILD__) {\n return ssrConfig;\n }\n\n if (env.isSsrBuild) {\n return {\n ...ssrConfig,\n build: {\n outDir: options.serverOutDir ?? 'dist/server',\n target: 'node22',\n sourcemap: true,\n minify: false,\n },\n };\n }\n\n return {\n ...ssrConfig,\n build: {\n outDir: options.clientOutDir ?? 'dist/client',\n },\n };\n },\n\n configResolved(config) {\n resolvedConfig = config;\n },\n\n transform: cjsSsrShimTransform,\n\n generateBundle(_outputOptions, bundle) {\n // Only generate the CSS manifest for client builds.\n // SSR builds strip CSS imports, so they have nothing to map.\n if (resolvedConfig.build.ssr) return;\n\n // Don't generate during the SSG child build (it's an SSR build)\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const base = resolvedConfig.base ?? '/';\n const root = resolvedConfig.root;\n const manifest = buildCssManifest(bundle, base, root);\n\n // Only emit the manifest if there are dynamic entries with CSS.\n // Apps without lazy-loaded CSS don't need this file.\n if (Object.keys(manifest).length === 0) return;\n\n this.emitFile({\n type: 'asset',\n fileName: CSS_MANIFEST_FILENAME,\n source: JSON.stringify(manifest, null, 2),\n });\n },\n\n async closeBundle() {\n // Only write package.json for SSR builds\n if (!resolvedConfig.build.ssr) return;\n\n // Don't interfere with the SSG child build's temp directory\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const outDir = join(resolvedConfig.root, resolvedConfig.build.outDir);\n const targetPath = join(outDir, 'package.json');\n await mkdir(outDir, { recursive: true });\n await writeFile(\n targetPath,\n JSON.stringify({ type: 'module' }, null, 2),\n 'utf-8',\n );\n },\n };\n}\n\n// ─── SSG Plugin ──────────────────────────────────────────────────────\n\nexport interface EmberSsgPluginOptions {\n /**\n * Routes to prerender as static HTML files.\n *\n * Each entry is a route path (without leading slash).\n * 'index' produces `index.html` at the root, other routes produce\n * `<route>/index.html` (e.g., 'about' → `about/index.html`).\n *\n * @example\n * ```js\n * emberSsg({\n * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],\n * })\n * ```\n */\n routes: string[];\n\n /**\n * The SSR entry module path, relative to the project root.\n * This file must export a `createSsrApp` function.\n * @default 'app/app-ssr.ts'\n */\n ssrEntry?: string;\n\n /**\n * Enable shoebox (fetch replay) for prerendered pages.\n *\n * When true, fetch responses from route model hooks are captured during\n * prerendering and serialized into the HTML. The client calls\n * `installShoebox()` before boot to replay those responses and avoid\n * duplicate API requests.\n *\n * @default false\n */\n shoebox?: boolean;\n\n /**\n * Output directory for the client build.\n * @default 'dist'\n */\n outDir?: string;\n}\n\n/**\n * Vite plugin for Static Site Generation (SSG) of Ember applications.\n *\n * Prerenders the specified routes to static HTML files at build time.\n * Fully self-contained — only a single `vite build` is needed.\n *\n * After the client build completes, the plugin runs a second SSR build\n * via `vite.build()` to produce a bundled SSR entry module, imports it,\n * renders each route using HappyDOM, and writes the resulting HTML files\n * into the client output directory. The temporary SSR bundle is cleaned\n * up automatically.\n *\n * All dependencies are bundled into the SSR output (no externals) to\n * avoid runtime resolution failures under pnpm's strict node_modules\n * layout. See issue #4.\n *\n * @example\n * ```js\n * // vite.config.mjs\n * import { emberSsg } from 'vite-ember-ssr/vite-plugin';\n *\n * export default defineConfig({\n * plugins: [\n * ember(),\n * babel({ babelHelpers: 'runtime', extensions }),\n * emberSsg({\n * routes: ['index', 'about', 'pokemon', 'pokemon/charmander'],\n * }),\n * ],\n * });\n * ```\n */\nexport function emberSsg(options: EmberSsgPluginOptions): Plugin {\n const { routes, ssrEntry = 'app/app-ssr.ts', shoebox = false } = options;\n\n // Track whether the user explicitly provided outDir\n const explicitOutDir = options.outDir;\n\n let resolvedConfig: ResolvedConfig;\n\n // Whether emberSsr is also registered — detected in config() hook\n let isCombined = false;\n\n return {\n name: 'vite-ember-ssg',\n\n config(userConfig, env): UserConfig {\n // Bundle all dependencies for SSR builds — see ssrDepsConfig().\n const ssrConfig = ssrDepsConfig(userConfig, env.command);\n\n // During the SSG child build, only set ssr config — don't touch\n // build.outDir or detect isCombined (irrelevant for child build).\n if (process.env.__VITE_EMBER_SSG_CHILD__) {\n return ssrConfig;\n }\n\n // Detect if emberSsr is also registered in this config.\n // When combined, defer build.outDir to emberSsr so that\n // prerendered files land in the SSR client directory.\n isCombined = flatPlugins(userConfig.plugins).some(\n (p) => p.name === 'vite-ember-ssr',\n );\n\n // Only set outDir when:\n // - the user explicitly passed outDir to emberSsg, OR\n // - emberSsr is NOT present (standalone SSG mode, default 'dist')\n const outDir = explicitOutDir ?? (isCombined ? undefined : 'dist');\n\n return {\n ...ssrConfig,\n ...(outDir != null ? { build: { outDir } } : {}),\n };\n },\n\n configResolved(config) {\n resolvedConfig = config;\n },\n\n transform: cjsSsrShimTransform,\n\n generateBundle(_outputOptions, bundle) {\n // When combined with emberSsr, the SSR plugin already emits\n // the CSS manifest — skip to avoid duplicate emission.\n if (isCombined) return;\n\n // Only generate the CSS manifest for client builds.\n if (resolvedConfig.build.ssr) return;\n\n // Don't generate during the SSG child build (it's an SSR build)\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const base = resolvedConfig.base ?? '/';\n const root = resolvedConfig.root;\n const manifest = buildCssManifest(bundle, base, root);\n\n if (Object.keys(manifest).length === 0) return;\n\n this.emitFile({\n type: 'asset',\n fileName: CSS_MANIFEST_FILENAME,\n source: JSON.stringify(manifest, null, 2),\n });\n },\n\n async closeBundle() {\n // Don't prerender during SSR builds (if the user also has emberSsr)\n if (resolvedConfig.build.ssr) return;\n\n // Prevent recursive prerendering when the child build\n // loads the same config file and re-registers this plugin.\n if (process.env.__VITE_EMBER_SSG_CHILD__) return;\n\n const { build: viteBuild } = await import('vite');\n const { assembleHTML, createEmberApp } = await import('./server.js');\n\n const root = resolvedConfig.root;\n const clientDir = join(root, resolvedConfig.build.outDir);\n const ssrOutDir = join(root, '.ssg-tmp');\n\n console.log('\\n[vite-ember-ssg] Prerendering routes...');\n\n // Read the built client index.html as template\n const templatePath = join(clientDir, 'index.html');\n let template: string;\n try {\n template = await readFile(templatePath, 'utf-8');\n } catch (e) {\n console.error(\n `[vite-ember-ssg] Failed to read template at ${templatePath}.`,\n );\n throw e;\n }\n\n // Read the CSS manifest (if it exists) so we can inject\n // lazy-loaded CSS into prerendered pages.\n let cssManifest: CssManifest | undefined;\n const cssManifestPath = join(clientDir, CSS_MANIFEST_FILENAME);\n try {\n const raw = await readFile(cssManifestPath, 'utf-8');\n cssManifest = JSON.parse(raw) as CssManifest;\n } catch {\n // No CSS manifest — app has no lazy-loaded CSS\n }\n\n // When combined with emberSsr, preserve the original index.html\n // as _template.html before prerendering overwrites it. The\n // production server reads _template.html for dynamic SSR rendering.\n if (isCombined) {\n const savedTemplatePath = join(clientDir, '_template.html');\n await copyFile(templatePath, savedTemplatePath);\n console.log(\n ` [vite-ember-ssg] Saved SSR template → ${savedTemplatePath.replace(root + '/', '')}`,\n );\n }\n\n // ── Step 1: Build the SSR bundle ────────────────────────────\n // Run vite.build() with ssr entry to produce a fully bundled\n // ESM module. This handles all CJS→ESM transforms, Babel,\n // Glimmer template compilation, etc. at build time.\n process.env.__VITE_EMBER_SSG_CHILD__ = '1';\n\n try {\n await viteBuild({\n root,\n configFile: resolvedConfig.configFile || undefined,\n logLevel: 'warn',\n build: {\n ssr: ssrEntry,\n outDir: ssrOutDir,\n target: 'node22',\n minify: false,\n sourcemap: false,\n },\n ssr: {\n // Belt-and-suspenders: the config hooks already call\n // ssrDepsConfig() for the child build, but setting it here\n // in inline config guarantees it even if the user's config\n // file doesn't register the SSR/SSG plugins for some reason.\n noExternal: [/./],\n },\n });\n } catch (e) {\n console.error('[vite-ember-ssg] SSR build failed:', e);\n throw e;\n } finally {\n delete process.env.__VITE_EMBER_SSG_CHILD__;\n }\n\n // Write package.json so Node loads the bundle as ESM\n await writeFile(\n join(ssrOutDir, 'package.json'),\n JSON.stringify({ type: 'module' }, null, 2),\n 'utf-8',\n );\n\n // ── Step 2: Import the SSR bundle and prerender ─────────────\n let successCount = 0;\n let errorCount = 0;\n\n try {\n // Determine the output filename — Vite names SSR output\n // after the entry: 'app/app-ssr.ts' → 'app-ssr.mjs'.\n // Some Vite versions using Rolldown output '.js' instead of '.mjs',\n // so we try both extensions.\n const entryBasename = ssrEntry\n .split('/')\n .pop()!\n .replace(/\\.[^.]+$/, '');\n\n let ssrBundlePath = join(ssrOutDir, `${entryBasename}.mjs`);\n try {\n await access(ssrBundlePath);\n } catch {\n ssrBundlePath = join(ssrOutDir, `${entryBasename}.js`);\n }\n const ssrBundleURL = pathToFileURL(ssrBundlePath).href;\n\n // Prerender all routes in parallel using a long-lived worker pool.\n // Workers import the SSR bundle once and reuse it across renders,\n // making per-render cost ~4ms vs ~200ms for a fresh-worker approach.\n const app = await createEmberApp(ssrBundleURL, {\n workers: cpus().length,\n });\n\n try {\n await Promise.all(\n routes.map(async (route) => {\n const url = route === 'index' ? '/' : `/${route}`;\n\n try {\n const result = await app.renderRoute(url, {\n shoebox,\n cssManifest,\n });\n\n if (result.error) {\n console.error(\n ` [vite-ember-ssg] Error rendering ${url}:\\n` +\n (result.error.stack ?? result.error.message),\n );\n errorCount++;\n return;\n }\n\n const html = assembleHTML(template, result);\n\n // 'index' → index.html (overwrite the shell)\n // 'about' → about/index.html\n // 'pokemon/charmander' → pokemon/charmander/index.html\n const outputPath =\n route === 'index'\n ? join(clientDir, 'index.html')\n : join(clientDir, route, 'index.html');\n\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, html, 'utf-8');\n\n console.log(\n ` [vite-ember-ssg] ${url} → ${outputPath.replace(root + '/', '')}`,\n );\n successCount++;\n } catch (e) {\n console.error(\n ` [vite-ember-ssg] Failed to prerender ${url}:\\n` +\n (e instanceof Error ? (e.stack ?? e.message) : String(e)),\n );\n errorCount++;\n }\n }),\n );\n } finally {\n await app.destroy();\n }\n } finally {\n // ── Step 3: Clean up the temporary SSR bundle ─────────────\n await rm(ssrOutDir, { recursive: true, force: true });\n }\n\n console.log(\n `[vite-ember-ssg] Done. ${successCount} pages generated` +\n (errorCount > 0 ? `, ${errorCount} errors` : '') +\n '.',\n );\n\n if (errorCount > 0 && successCount === 0) {\n throw new Error('[vite-ember-ssg] All routes failed to prerender.');\n }\n },\n };\n}\n\nexport default emberSsr;\n"],"mappings":"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"}
@@ -91,21 +91,6 @@ interface EmberSsgPluginOptions {
91
91
  * @default 'dist'
92
92
  */
93
93
  outDir?: string;
94
- /**
95
- * Enable Glimmer rehydration for prerendered pages.
96
- *
97
- * When `true`, the server renders with `_renderMode: 'serialize'`,
98
- * annotating the DOM with Glimmer markers. The client boots with
99
- * `app.visit(url, { _renderMode: 'rehydrate' })` to reuse the
100
- * static DOM instead of replacing it.
101
- *
102
- * When `false` (default), boundary markers are emitted and the
103
- * client uses `cleanupSSRContent()` in the application template
104
- * to remove the SSR content before Ember renders fresh.
105
- *
106
- * @default false
107
- */
108
- rehydrate?: boolean;
109
94
  }
110
95
  /**
111
96
  * Vite plugin for Static Site Generation (SSG) of Ember applications.
@@ -126,7 +111,7 @@ interface EmberSsgPluginOptions {
126
111
  * @example
127
112
  * ```js
128
113
  * // vite.config.mjs
129
- * import { emberSsg } from '@st-h/vite-ember-ssr/vite-plugin';
114
+ * import { emberSsg } from 'vite-ember-ssr/vite-plugin';
130
115
  *
131
116
  * export default defineConfig({
132
117
  * plugins: [
@@ -142,4 +127,4 @@ interface EmberSsgPluginOptions {
142
127
  declare function emberSsg(options: EmberSsgPluginOptions): Plugin;
143
128
  //#endregion
144
129
  export { SSR_BODY_MARKER as a, emberSsr as c, EmberSsrPluginOptions as i, CssManifest as n, SSR_HEAD_MARKER as o, EmberSsgPluginOptions as r, emberSsg as s, CSS_MANIFEST_FILENAME as t };
145
- //# sourceMappingURL=vite-plugin-CQou_tr5.d.ts.map
130
+ //# sourceMappingURL=vite-plugin-Dl5DbheW.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite-plugin-Dl5DbheW.d.ts","names":[],"sources":["../src/vite-plugin.ts"],"mappings":";;;cAaa,eAAA;AAAA,cACA,eAAA;AADb;;;;AAAA,cAOa,qBAAA;AANb;;;;;AAMA;;;;;AAoBA;;;;;AAkQA;;;AA5RA,KA0BY,WAAA,GAAc,MAAA;AAAA,UAkQT,qBAAA;EA2BD;;;;EAtBd,YAAA;EAsBuB;;;;EAhBvB,YAAA;AAAA;;;;;;;;;AAwLF;;;;;iBAxKgB,QAAA,CAAS,OAAA,GAAS,qBAAA,GAA6B,MAAA;AAAA,UA6F9C,qBAAA;EA2E+C;;;;;;;;;;;;;;EA5D9D,MAAA;;;;;;EAOA,QAAA;;;;;;;;;;;EAYA,OAAA;;;;;EAMA,MAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmCc,QAAA,CAAS,OAAA,EAAS,qBAAA,GAAwB,MAAA"}
@@ -1,2 +1,2 @@
1
- import { a as SSR_BODY_MARKER, c as emberSsr, i as EmberSsrPluginOptions, n as CssManifest, o as SSR_HEAD_MARKER, r as EmberSsgPluginOptions, s as emberSsg, t as CSS_MANIFEST_FILENAME } from "./vite-plugin-CQou_tr5.js";
1
+ import { a as SSR_BODY_MARKER, c as emberSsr, i as EmberSsrPluginOptions, n as CssManifest, o as SSR_HEAD_MARKER, r as EmberSsgPluginOptions, s as emberSsg, t as CSS_MANIFEST_FILENAME } from "./vite-plugin-Dl5DbheW.js";
2
2
  export { CSS_MANIFEST_FILENAME, CssManifest, EmberSsgPluginOptions, EmberSsrPluginOptions, SSR_BODY_MARKER, SSR_HEAD_MARKER, emberSsr as default, emberSsr, emberSsg };
@@ -1,2 +1,2 @@
1
- import { a as emberSsr, i as emberSsg, n as SSR_BODY_MARKER, r as SSR_HEAD_MARKER, t as CSS_MANIFEST_FILENAME } from "./vite-plugin-D-W5WQWe.js";
1
+ import { a as emberSsr, i as emberSsg, n as SSR_BODY_MARKER, r as SSR_HEAD_MARKER, t as CSS_MANIFEST_FILENAME } from "./vite-plugin-9BSJgEL9.js";
2
2
  export { CSS_MANIFEST_FILENAME, SSR_BODY_MARKER, SSR_HEAD_MARKER, emberSsr as default, emberSsr, emberSsg };
package/dist/worker.d.ts CHANGED
@@ -1,13 +1,14 @@
1
- import { n as CssManifest } from "./vite-plugin-CQou_tr5.js";
1
+ import { n as CssManifest } from "./vite-plugin-Dl5DbheW.js";
2
+ import { s as ForwardedCookie } from "./server-DJRlVUcm.js";
2
3
 
3
4
  //#region src/worker.d.ts
4
5
  interface WorkerRenderOptions {
5
6
  ssrBundlePath: string;
6
7
  url: string;
7
8
  shoebox: boolean;
8
- rehydrate: boolean;
9
9
  cssManifest: CssManifest | null;
10
- headers: Record<string, string> | null;
10
+ settledTimeout: number;
11
+ forwardCookie: ForwardedCookie | null;
11
12
  }
12
13
  interface WorkerRenderResult {
13
14
  head: string;
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","names":[],"sources":["../src/worker.ts"],"mappings":";;;UA6BiB,mBAAA;EACf,aAAA;EACA,GAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA,EAAa,WAAA;EACb,OAAA,EAAS,MAAA;AAAA;AAAA,UAGM,kBAAA;EACf,IAAA;EACA,IAAA;EACA,SAAA,EAAW,MAAA;EACX,UAAA;EACA,KAAA;AAAA;AAAA,iBAoM4B,MAAA,CAC5B,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,kBAAA"}
1
+ {"version":3,"file":"worker.d.ts","names":[],"sources":["../src/worker.ts"],"mappings":";;;;UAuCiB,mBAAA;EACf,aAAA;EACA,GAAA;EACA,OAAA;EACA,WAAA,EAAa,WAAA;EACb,cAAA;EACA,aAAA,EAAe,eAAA;AAAA;AAAA,UAGA,kBAAA;EACf,IAAA;EACA,IAAA;EACA,SAAA,EAAW,MAAA;EACX,UAAA;EACA,KAAA;AAAA;AAAA,iBAuM4B,MAAA,CAC5B,OAAA,EAAS,mBAAA,GACR,OAAA,CAAQ,kBAAA"}
package/dist/worker.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as shoeboxMiddleware, n as compose, r as forwardCookieMiddleware, t as abortSignalMiddleware } from "./fetch-middleware-DPLxOLL6.js";
1
2
  import { Window } from "happy-dom";
2
3
  //#region src/worker.ts
3
4
  /**
@@ -8,9 +9,12 @@ import { Window } from "happy-dom";
8
9
  * every render. Renders are serialised by tinypool (concurrentTasksPerWorker:1),
9
10
  * so there is no concurrency concern within a single worker.
10
11
  *
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.
12
+ * A fresh ApplicationInstance is created per visit and destroyed after the
13
+ * DOM is read, keeping container singletons clean. Because the Window is
14
+ * long-lived, every render resets the mutable per-request window state in a
15
+ * finally block: document.body (content + attributes), document.head/title,
16
+ * and local/session storage. Without this, one request's DOM or storage
17
+ * writes would bleed into the next request served by this worker.
14
18
  *
15
19
  * The shoebox fetch interceptor is installed once at startup. Each render
16
20
  * assigns a fresh entries Map before visiting, so entries never bleed between
@@ -69,46 +73,18 @@ const { ssrBundlePath: startupBundlePath } = process.__tinypool_state__.workerDa
69
73
  const startupMod = await import(startupBundlePath);
70
74
  if (typeof startupMod.createSsrApp !== "function") throw new Error(`SSR bundle '${startupBundlePath}' does not export a 'createSsrApp' function. Found exports: ${Object.keys(startupMod).join(", ")}`);
71
75
  const app = startupMod.createSsrApp();
76
+ const appSettled = typeof startupMod.settled === "function" ? startupMod.settled : null;
72
77
  const SHOEBOX_SCRIPT_ID = "vite-ember-ssr-shoebox";
73
78
  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;
79
+ let activeShoebox = null;
80
+ let activeCookie = null;
81
+ let activeAbort = null;
82
+ const fetchWithMiddleware = compose([
83
+ forwardCookieMiddleware(() => activeCookie),
84
+ shoeboxMiddleware(() => activeShoebox),
85
+ abortSignalMiddleware(() => activeAbort?.signal ?? null)
86
+ ], (request) => realFetch(request));
87
+ globalThis.fetch = fetchWithMiddleware;
112
88
  function serializeShoebox(entries) {
113
89
  if (entries.length === 0) return "";
114
90
  return `<script type="application/json" id="${SHOEBOX_SCRIPT_ID}">${JSON.stringify(entries).replace(/<\/(script)/gi, "<\\/$1")}<\/script>`;
@@ -139,16 +115,41 @@ function buildRouteCssLinks(manifest, instance) {
139
115
  }
140
116
  return links.join("");
141
117
  }
118
+ let warnedMissingSettled = false;
119
+ async function awaitSettled(timeoutMs) {
120
+ if (!appSettled) {
121
+ if (timeoutMs > 0 && !warnedMissingSettled) {
122
+ warnedMissingSettled = true;
123
+ console.warn("[vite-ember-ssr] settledTimeout is set but the SSR bundle does not export `settled` — renders will NOT wait for the app to settle and may capture incomplete HTML. Add `export { settled } from '@ember/test-helpers';` to your SSR entry.");
124
+ }
125
+ await new Promise((resolve) => setTimeout(resolve, 0));
126
+ return;
127
+ }
128
+ let timer;
129
+ try {
130
+ await Promise.race([appSettled(), new Promise((_, reject) => {
131
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`settled() timed out after ${timeoutMs}ms`)), timeoutMs);
132
+ })]);
133
+ } catch (e) {
134
+ console.warn(`[vite-ember-ssr] settled() did not resolve within ${timeoutMs}ms, capturing DOM anyway:`, e instanceof Error ? e.message : e);
135
+ } finally {
136
+ if (timer) clearTimeout(timer);
137
+ }
138
+ }
142
139
  async function render(options) {
143
- const { url, shoebox, rehydrate, cssManifest, headers } = options;
140
+ const { url, shoebox, cssManifest, settledTimeout, forwardCookie } = options;
144
141
  const document = win.document;
145
- shoeboxEntries = shoebox ? /* @__PURE__ */ new Map() : null;
146
- requestHeaders = headers;
142
+ activeShoebox = shoebox ? /* @__PURE__ */ new Map() : null;
143
+ activeCookie = forwardCookie;
144
+ activeAbort = new AbortController();
145
+ const preRenderTitle = document.title;
146
+ const preRenderHead = document.head?.innerHTML ?? "";
147
147
  let head = "";
148
148
  let body = "";
149
149
  let bodyAttrs = {};
150
150
  let cssLinks = "";
151
151
  let error;
152
+ let instance;
152
153
  try {
153
154
  const bootOptions = {
154
155
  isBrowser: true,
@@ -156,25 +157,39 @@ async function render(options) {
156
157
  document,
157
158
  rootElement: document.body,
158
159
  shouldRender: true,
159
- ...rehydrate ? { _renderMode: "serialize" } : {}
160
+ _renderMode: "serialize"
160
161
  };
161
- const instance = await app.visit(url, bootOptions);
162
- await new Promise((resolve) => setTimeout(resolve, 0));
162
+ instance = await app.visit(url, bootOptions);
163
+ await awaitSettled(settledTimeout);
163
164
  if (cssManifest) cssLinks = buildRouteCssLinks(cssManifest, instance);
164
165
  head = document.head?.innerHTML ?? "";
165
166
  body = document.body?.innerHTML ?? "";
166
167
  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
168
  } catch (e) {
171
169
  error = e instanceof Error ? e : new Error(String(e));
170
+ } finally {
171
+ try {
172
+ instance?.destroy();
173
+ } catch {}
174
+ if (document.body) {
175
+ document.body.innerHTML = "";
176
+ for (const attr of Array.from(document.body.attributes)) document.body.removeAttribute(attr.name);
177
+ }
178
+ if (document.head) document.head.innerHTML = preRenderHead;
179
+ if (document.title !== preRenderTitle) document.title = preRenderTitle;
180
+ try {
181
+ win.localStorage.clear();
182
+ win.sessionStorage.clear();
183
+ } catch {}
184
+ activeAbort?.abort();
185
+ activeAbort = null;
172
186
  }
173
- requestHeaders = null;
174
- const shoeboxHTML = shoeboxEntries && shoeboxEntries.size > 0 ? serializeShoebox(Array.from(shoeboxEntries.values())) : "";
187
+ const shoeboxHTML = activeShoebox && activeShoebox.size > 0 ? serializeShoebox(Array.from(activeShoebox.values())) : "";
188
+ activeShoebox = null;
189
+ activeCookie = null;
175
190
  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>`,
191
+ head: cssLinks + "<script>window.__vite_ember_ssr_rehydrate__=true<\/script>" + shoeboxHTML + head,
192
+ body,
178
193
  bodyAttrs,
179
194
  statusCode: error ? 500 : 200,
180
195
  ...error ? { error: error.message + (error.stack ? "\n" + error.stack : "") } : {}
@@ -1 +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"}
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 * A fresh ApplicationInstance is created per visit and destroyed after the\n * DOM is read, keeping container singletons clean. Because the Window is\n * long-lived, every render resets the mutable per-request window state in a\n * finally block: document.body (content + attributes), document.head/title,\n * and local/session storage. Without this, one request's DOM or storage\n * writes would bleed into the next request served by this worker.\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 ForwardedCookie,\n} from './server.js';\nimport {\n abortSignalMiddleware,\n compose,\n forwardCookieMiddleware,\n shoeboxMiddleware,\n} from './fetch-middleware.js';\n\n// ─── Types ────────────────────────────────────────────────────────────\n\nexport interface WorkerRenderOptions {\n ssrBundlePath: string;\n url: string;\n shoebox: boolean;\n cssManifest: CssManifest | null;\n settledTimeout: number;\n forwardCookie: ForwardedCookie | 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 settled?: () => Promise<void>;\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// Optional: the SSR bundle may re-export `settled` from `@ember/test-helpers`.\n// When present, the renderer awaits it after `app.visit()` so any registered\n// `@ember/test-waiters` (used by WarpDrive, ember-concurrency, etc.) drain\n// before the DOM is captured. When absent, we fall back to a single\n// Backburner autorun drain via `setTimeout(0)`.\nconst appSettled: (() => Promise<void>) | null =\n typeof startupMod.settled === 'function' ? startupMod.settled : null;\n\n// ─── Fetch middleware pipeline ────────────────────────────────────────\n\nconst SHOEBOX_SCRIPT_ID = 'vite-ember-ssr-shoebox';\n\n// The fetch pipeline is installed once at startup. globalThis.fetch never\n// changes. Per-render state (shoebox entries, forwarded cookie) lives in\n// module-level variables that the middlewares read via getters.\nconst realFetch = globalThis.fetch;\nlet activeShoebox: Map<string, ShoeboxEntry> | null = null;\nlet activeCookie: ForwardedCookie | null = null;\nlet activeAbort: AbortController | null = null;\n\nconst fetchWithMiddleware = compose(\n [\n forwardCookieMiddleware(() => activeCookie),\n shoeboxMiddleware(() => activeShoebox),\n abortSignalMiddleware(() => activeAbort?.signal ?? null),\n ],\n (request) => realFetch(request),\n);\n\n// Install once — never needs to be restored.\nglobalThis.fetch = fetchWithMiddleware;\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\nlet warnedMissingSettled = false;\n\nasync function awaitSettled(timeoutMs: number): Promise<void> {\n if (!appSettled) {\n if (timeoutMs > 0 && !warnedMissingSettled) {\n warnedMissingSettled = true;\n console.warn(\n '[vite-ember-ssr] settledTimeout is set but the SSR bundle does not ' +\n 'export `settled` — renders will NOT wait for the app to settle ' +\n 'and may capture incomplete HTML. Add ' +\n \"`export { settled } from '@ember/test-helpers';` to your SSR entry.\",\n );\n }\n // Fallback: drain Backburner's autorun microtask before reading the DOM.\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n return;\n }\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n await Promise.race([\n appSettled(),\n new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new Error(`settled() timed out after ${timeoutMs}ms`)),\n timeoutMs,\n );\n }),\n ]);\n } catch (e) {\n console.warn(\n `[vite-ember-ssr] settled() did not resolve within ${timeoutMs}ms, ` +\n `capturing DOM anyway:`,\n e instanceof Error ? e.message : e,\n );\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\nexport default async function render(\n options: WorkerRenderOptions,\n): Promise<WorkerRenderResult> {\n const { url, shoebox, cssManifest, settledTimeout, forwardCookie } = options;\n\n // Use the long-lived document directly — no new Window, no globalThis swap.\n const document = win.document;\n\n // Set per-render state. The middlewares read these via getters, so a\n // single shared pipeline can serve every render without re-installation.\n activeShoebox = shoebox ? new Map() : null;\n activeCookie = forwardCookie;\n activeAbort = new AbortController();\n\n // Snapshot the pre-render <head> state so the finally below can restore\n // it. ember-page-title and similar addons write into <head> during the\n // render; without a reset, one request's document title (which may contain\n // private data) bleeds into every later render served by this worker.\n const preRenderTitle = document.title;\n const preRenderHead = document.head?.innerHTML ?? '';\n\n let head = '';\n let body = '';\n let bodyAttrs: Record<string, string> = {};\n let cssLinks = '';\n let error: Error | undefined;\n let instance: EmberApplicationInstance | 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 _renderMode: 'serialize',\n };\n\n instance = await app.visit(url, bootOptions);\n\n // Wait for the app to settle (test waiters, run loop, pending timers, etc.)\n // before reading the DOM. Falls back to a microtask drain when the SSR\n // bundle doesn't export `settled`.\n await awaitSettled(settledTimeout);\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 } catch (e) {\n error = e instanceof Error ? e : new Error(String(e));\n } finally {\n // Destroy the instance so its container is torn down cleanly. app.visit()\n // creates a fresh ApplicationInstance per call; without destroying it the\n // container's singletons (including location:none) remain live and can\n // corrupt the next visit. This MUST run even when the render above throws\n // (settle timeout, CSS build, DOM read) — otherwise the leaked instance\n // accumulates in the long-lived worker. `instance` is undefined when\n // app.visit() itself threw before assigning it. Guard the call: a destroy\n // that throws inside this finally would skip the DOM reset below and mask\n // the render's own error.\n try {\n instance?.destroy();\n } catch {\n /* instance teardown failed — the DOM reset below must still run */\n }\n\n // Serialize mode leaves rehydration markers in the DOM; reset the body so\n // the next render starts from a clean slate regardless of success/failure.\n if (document.body) {\n document.body.innerHTML = '';\n\n // Clear body attributes so they don't bleed into the next render.\n for (const attr of Array.from(document.body.attributes)) {\n document.body.removeAttribute(attr.name);\n }\n }\n\n // Restore <head> (and the title) to its pre-render state. The rendered\n // head HTML was already captured above, so anything the render added —\n // <title> via ember-page-title, meta tags, etc. — must not survive into\n // the next request's document.\n if (document.head) document.head.innerHTML = preRenderHead;\n if (document.title !== preRenderTitle) document.title = preRenderTitle;\n\n // Clear web storage so values written during the render (user\n // preferences, cached tokens) don't bleed into the next request.\n try {\n win.localStorage.clear();\n win.sessionStorage.clear();\n } catch {\n /* storage unavailable in this happy-dom configuration */\n }\n\n // Abort any fetches this render left in flight (e.g. after a settled()\n // timeout) so they stop consuming the connection instead of lingering\n // into later renders. Their shoebox entries — if a response still\n // arrives — go to this render's already-dead map (see shoeboxMiddleware).\n activeAbort?.abort();\n activeAbort = null;\n }\n\n const shoeboxHTML =\n activeShoebox && activeShoebox.size > 0\n ? serializeShoebox(Array.from(activeShoebox.values()))\n : '';\n\n // Clear per-render state so a stray late fetch can't see stale config.\n activeShoebox = null;\n activeCookie = null;\n const rehydrateHTML =\n '<script>window.__vite_ember_ssr_rehydrate__=true</script>';\n const fullHead = cssLinks + rehydrateHTML + shoeboxHTML + head;\n\n return {\n head: fullHead,\n body,\n bodyAttrs,\n statusCode: error ? 500 : 200,\n ...(error\n ? { error: error.message + (error.stack ? '\\n' + error.stack : '') }\n : {}),\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@st-h/vite-ember-ssr",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.3.1",
4
4
  "description": "Vite plugin and SSR runtime for Ember.js applications using HappyDOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,7 @@
59
59
  "tinypool": "^2.1.0"
60
60
  },
61
61
  "peerDependencies": {
62
- "vite": "^6.0.0 || ^7.0.0"
62
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@playwright/test": "^1.58.2",