@jasonshimmy/vite-plugin-cer-app 0.20.3 → 0.20.4

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/commits.txt +1 -1
  3. package/dist/cli/commands/dev.d.ts.map +1 -1
  4. package/dist/cli/commands/dev.js +5 -0
  5. package/dist/cli/commands/dev.js.map +1 -1
  6. package/dist/plugin/build-ssg.d.ts.map +1 -1
  7. package/dist/plugin/build-ssg.js +0 -11
  8. package/dist/plugin/build-ssg.js.map +1 -1
  9. package/dist/plugin/content/index.d.ts +5 -4
  10. package/dist/plugin/content/index.d.ts.map +1 -1
  11. package/dist/plugin/content/index.js +9 -11
  12. package/dist/plugin/content/index.js.map +1 -1
  13. package/dist/plugin/dev-server.d.ts.map +1 -1
  14. package/dist/plugin/dev-server.js +40 -2
  15. package/dist/plugin/dev-server.js.map +1 -1
  16. package/dist/plugin/dts-generator.d.ts.map +1 -1
  17. package/dist/plugin/dts-generator.js +9 -1
  18. package/dist/plugin/dts-generator.js.map +1 -1
  19. package/dist/plugin/index.d.ts.map +1 -1
  20. package/dist/plugin/index.js +7 -0
  21. package/dist/plugin/index.js.map +1 -1
  22. package/dist/runtime/entry-server-template.d.ts +1 -1
  23. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  24. package/dist/runtime/entry-server-template.js +8 -2
  25. package/dist/runtime/entry-server-template.js.map +1 -1
  26. package/e2e/cypress/e2e/preview-hardening.cy.ts +42 -33
  27. package/e2e/cypress/e2e/use-page-data.cy.ts +122 -0
  28. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +4 -0
  29. package/e2e/kitchen-sink/app/pages/blog/index.ts +5 -0
  30. package/package.json +5 -2
  31. package/src/__tests__/plugin/build-ssg.test.ts +2 -2
  32. package/src/__tests__/plugin/content/loader.test.ts +19 -27
  33. package/src/__tests__/plugin/entry-server-template.test.ts +4 -1
  34. package/src/cli/commands/dev.ts +5 -0
  35. package/src/plugin/build-ssg.ts +0 -12
  36. package/src/plugin/content/index.ts +8 -11
  37. package/src/plugin/dev-server.ts +37 -2
  38. package/src/plugin/dts-generator.ts +7 -1
  39. package/src/plugin/index.ts +7 -0
  40. package/src/runtime/entry-server-template.ts +8 -2
@@ -11,5 +11,5 @@
11
11
  * - useHead() support via beginHeadCollection / endHeadCollection
12
12
  * - DSD polyfill injected at end of <body> after client-template merge
13
13
  */
14
- export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport serverMiddleware from 'virtual:cer-server-middleware'\nimport { runtimeConfig, _runtimePrivateDefaults, _authSessionKey, _hooks } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig, useSession } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\n\n// Resolve private config from environment variables at server startup.\n// Each key declared in runtimeConfig.private is looked up in process.env,\n// first as-is, then as ALL_CAPS. The declared default is used as fallback.\ninitRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Async-local storage for request-scoped req/res access.\n// Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers\n// without prop-drilling the request context through the component tree.\nconst _cerReqStore = new AsyncLocalStorage()\n;(globalThis).__CER_REQ_STORE__ = _cerReqStore\n\n// Async-local storage for the authenticated user resolved before each render.\n// useAuth() reads this store server-side to return the current user synchronously.\nconst _cerAuthStore = new AsyncLocalStorage()\n;(globalThis).__CER_AUTH_STORE__ = _cerAuthStore\n\n// Async-local storage for per-request useFetch() data.\n// useFetch() writes fetched results here; the handler serialises the map into\n// window.__CER_FETCH_DATA__ for client-side hydration.\nconst _cerFetchStore = new AsyncLocalStorage()\n;(globalThis).__CER_FETCH_STORE__ = _cerFetchStore\n\n// Async-local storage for the current route info (path, params, query, meta).\n// useRoute() reads this on the server so layouts and components can access\n// route metadata without prop-drilling.\nconst _cerRouteStore = new AsyncLocalStorage()\n;(globalThis).__CER_ROUTE_STORE__ = _cerRouteStore\n\n// Async-local storage for per-request useState() reactive state.\n// Each request gets a fresh Map so concurrent SSR/SSG renders never share\n// reactive state set by different pages or loaders.\nconst _cerStateStore = new AsyncLocalStorage()\n;(globalThis).__CER_STATE_STORE__ = _cerStateStore\n\n// Runs fn inside the per-request AsyncLocalStorage context so that isomorphic\n// composables (useCookie, useSession, etc.) can access req/res without prop-drilling.\n// Call this for every API handler invocation \u2014 not just SSR renders.\nexport function runWithRequestContext(req, res, fn) {\n return _cerReqStore.run({ req, res }, fn)\n}\n\n// Runs the server/middleware/ chain for a request.\n// Returns false if a middleware short-circuited the response; true to continue.\n// Exported so Netlify / Vercel / Cloudflare bridges can also call it for API routes.\n// P1-2: Middleware may throw { status: 401 } (or any numeric .status) to produce\n// a non-500 response \u2014 the status is extracted and forwarded to res.statusCode.\nexport async function runServerMiddleware(req, res) {\n for (const { handler: mw } of (serverMiddleware ?? [])) {\n if (typeof mw !== 'function') continue\n let calledNext = false\n try {\n await new Promise((resolve, reject) => {\n Promise.resolve(mw(req, res, (err) => {\n if (err) reject(err)\n else { calledNext = true; resolve() }\n })).catch(reject)\n })\n } catch (err) {\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'middleware', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n if (!res.writableEnded) {\n const statusCode = (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number')\n ? (isNaN(err.status) ? 500 : err.status)\n : 500\n res.statusCode = statusCode\n res.end('Internal Server Error')\n }\n return false\n }\n if (res.writableEnded || !calledNext) return false\n }\n return true\n}\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\n//\n// Cloudflare Workers (and other runtimes without node:fs) can inject the\n// template before this module loads by setting globalThis.__CER_CLIENT_TEMPLATE__.\n// The Cloudflare adapter inlines dist/client/index.html as a string constant in\n// _worker.js and sets the global before dynamically importing the server bundle.\nlet _clientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? null\nif (!_clientTemplate) {\n try {\n const _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\n _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n } catch {\n // node:fs not available in this runtime \u2014 Cloudflare adapter must set\n // globalThis.__CER_CLIENT_TEMPLATE__ before importing this bundle.\n }\n}\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Store the current route info so useRoute() can read it synchronously\n // from any layout or component during this render pass.\n _cerRouteStore.enterWith({\n path: current.path,\n params,\n query: current.query ?? {},\n meta: route?.meta ?? null,\n })\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n // P2-2: Route-level error tag (from co-located .error.ts or _error.ts).\n // Preferred over the global errorTag when rendering loader errors for this route.\n const routeErrorTag = mod.errorTag ?? null\n\n // P1-1: Synthetic 404 catch-all \u2014 no page component registered.\n if (!pageTag) {\n const notFoundErrorTag = routeErrorTag ?? errorTag\n const notFoundVnode = notFoundErrorTag\n ? { tag: notFoundErrorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: notFoundVnode, router, head: undefined, status: 404 }\n }\n\n // Run the loader before creating the page vnode so we can pass its\n // primitive return values as HTML attributes. useProps() in the page\n // component reads element attributes, so merging loader data here makes\n // both useProps() and usePageData() work in SSR / SSG.\n let loaderAttrs = {}\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n // Expose primitive loader values as element attributes so useProps()\n // can read them. Complex objects are only accessible via usePageData().\n loaderAttrs = Object.fromEntries(\n Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')\n )\n }\n }\n\n pageVnode = { tag: pageTag, props: { attrs: { ...params, ...loaderAttrs } }, children: [] }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'loader', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n // P2-2: Prefer the route-level errorTag over the global one.\n // routeErrorTag is not in scope here; use route?.meta?.errorTag from the matched route.\n const effectiveErrorTag = route?.meta?.errorTag ?? errorTag\n if (!effectiveErrorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = effectiveErrorTag\n ? { tag: effectiveErrorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n // If the request matched a catch-all route (user-defined 404.ts or [...all].ts),\n // return HTTP 404 so browsers and crawlers treat it as a not-found response.\n const isCatchAll = route?.path === '/:all*'\n return { vnode, router, head, status: isCatchAll ? 404 : null }\n}\n\nexport const handler = async (req, res) => {\n const _requestPath = new URL(req.url ?? '/', 'http://x').pathname\n const _requestStart = Date.now()\n if (_hooks?.onRequest) {\n try { await _hooks.onRequest({ path: _requestPath, method: req.method ?? 'GET', req }) } catch { /* hooks must not crash the handler */ }\n }\n await _cerStateStore.run(new Map(), async () => {\n await _cerReqStore.run({ req, res }, async () => {\n await _cerDataStore.run(null, async () => {\n // Fresh per-request fetch map \u2014 populated by useFetch() calls inside loaders.\n const _fetchMap = new Map()\n await _cerFetchStore.run(_fetchMap, async () => {\n // Pre-resolve the authenticated user so useAuth() works synchronously during rendering.\n let _authUser = null\n if (_authSessionKey) {\n try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }\n }\n await _cerAuthStore.run(_authUser, async () => {\n const { vnode, router, head, status } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n let _headCollectionOpen = false\n try {\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n _headCollectionOpen = true\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n _headCollectionOpen = false\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk \u2014 rejects if the sync render failed.\n const { value: firstChunk = '' } = await reader.read()\n\n // Serialise useFetch() results collected during loader execution.\n const _fetchObj = Object.fromEntries(_fetchMap)\n const _fetchScript = Object.keys(_fetchObj).length > 0\n ? `<script>window.__CER_FETCH_DATA__ = ${JSON.stringify(_fetchObj)}</script>`\n : ''\n\n // Serialise the auth user for client-side hydration via useAuth().\n const _authScript = _authUser\n ? `<script>window.__CER_AUTH_USER__ = ${JSON.stringify(_authUser)}</script>`\n : ''\n\n // Serialise useState() values for client-side hydration.\n // The state Map is populated by loader calls (in _prepareRequest) and by component\n // render functions (during renderToStreamWithJITCSSDSD above). Both run before this\n // point. Injected as window.__CER_STATE_INIT__ so the client useState() can\n // pre-populate its singleton Map on first use \u2014 no flash to default values.\n const _stateMap = _cerStateStore.getStore()\n let _stateScript = ''\n if (_stateMap && _stateMap.size > 0) {\n const _stateObj = {}\n for (const [k, v] of _stateMap) { _stateObj[k] = v.value }\n _stateScript = `<script>window.__CER_STATE_INIT__ = ${JSON.stringify(_stateObj)}</script>`\n }\n\n // Merge loader data script + useHead() tags + fetch/auth/state hydration scripts.\n const headContent = [head, headTags, _fetchScript, _authScript, _stateScript].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n const merged = _clientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n } catch (_renderErr) {\n if (_hooks?.onError) {\n try { await _hooks.onError(_renderErr, { type: 'render', path: _requestPath, req }) } catch { /* hooks must not crash the handler */ }\n }\n // Ensure the head collector is never left open on error.\n if (_headCollectionOpen) { try { endHeadCollection() } catch { /* ignore */ } }\n // If headers have not been flushed yet we can still send a proper 500 page.\n // If writing has already started we can only close the connection cleanly.\n if (!res.headersSent) {\n res.statusCode = 500\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end('<!DOCTYPE html><html><head></head><body><h1>500 Internal Server Error</h1><p>An unexpected error occurred while rendering this page.</p></body></html>')\n } else {\n res.end()\n }\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n }\n }) // _cerAuthStore.run\n }) // _cerFetchStore.run\n }) // _cerDataStore.run\n }) // _cerReqStore.run\n }) // _cerStateStore.run\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes, serverMiddleware }\nexport default handler\n";
14
+ export declare const ENTRY_SERVER_TEMPLATE = "// Server-side entry \u2014 AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nimport { readFileSync, existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport routes from 'virtual:cer-routes'\nimport layouts from 'virtual:cer-layouts'\nimport plugins from 'virtual:cer-plugins'\nimport apiRoutes from 'virtual:cer-server-api'\nimport serverMiddleware from 'virtual:cer-server-middleware'\nimport { runtimeConfig, _runtimePrivateDefaults, _authSessionKey, _hooks } from 'virtual:cer-app-config'\nimport { registerBuiltinComponents } from '@jasonshimmy/custom-elements-runtime'\nimport { registerEntityMap, renderToStreamWithJITCSSDSD, DSD_POLYFILL_SCRIPT } from '@jasonshimmy/custom-elements-runtime/ssr'\nimport entitiesJson from '@jasonshimmy/custom-elements-runtime/entities.json'\nimport { initRouter } from '@jasonshimmy/custom-elements-runtime/router'\nimport { beginHeadCollection, endHeadCollection, serializeHeadTags, initRuntimeConfig, resolvePrivateConfig, useSession } from '@jasonshimmy/vite-plugin-cer-app/composables'\nimport { errorTag } from 'virtual:cer-error'\nimport { createIsrHandler } from '@jasonshimmy/vite-plugin-cer-app/isr'\n\nregisterBuiltinComponents()\n\n// Resolve private config from environment variables at server startup.\n// Each key declared in runtimeConfig.private is looked up in process.env,\n// first as-is, then as ALL_CAPS. The declared default is used as fallback.\ninitRuntimeConfig({ ...runtimeConfig, private: resolvePrivateConfig(_runtimePrivateDefaults ?? {}) })\n\n// Pre-load the full HTML entity map so named entities like &mdash; decode\n// correctly during SSR. Without this the bundled runtime falls back to a\n// minimal set (&lt;, &gt;, &amp; \u2026) and re-escapes everything else.\nregisterEntityMap(entitiesJson)\n\n// Run plugins once at server startup so their provide() values are available\n// to useInject() during every SSR/SSG render pass. Stored on globalThis so all\n// dynamically-imported page chunks share the same reference.\nconst _pluginProvides = new Map()\n;(globalThis).__cerPluginProvides = _pluginProvides\nconst _pluginsReady = (async () => {\n const _bootstrapRouter = initRouter({ routes })\n for (const plugin of plugins) {\n if (plugin && typeof plugin.setup === 'function') {\n await plugin.setup({\n router: _bootstrapRouter,\n provide: (key, value) => _pluginProvides.set(key, value),\n config: {},\n })\n }\n }\n})()\n\n// Async-local storage for request-scoped SSR loader data.\n// Using AsyncLocalStorage ensures concurrent SSR renders (e.g. SSG with\n// concurrency > 1) never see each other's data \u2014 each request's async chain\n// carries its own store value, so usePageData() is always race-condition-free.\nconst _cerDataStore = new AsyncLocalStorage()\n// Expose the store so the usePageData() composable can read it server-side.\n;(globalThis).__CER_DATA_STORE__ = _cerDataStore\n\n// Async-local storage for request-scoped req/res access.\n// Allows isomorphic composables (e.g. useCookie) to read/write HTTP headers\n// without prop-drilling the request context through the component tree.\nconst _cerReqStore = new AsyncLocalStorage()\n;(globalThis).__CER_REQ_STORE__ = _cerReqStore\n\n// Async-local storage for the authenticated user resolved before each render.\n// useAuth() reads this store server-side to return the current user synchronously.\nconst _cerAuthStore = new AsyncLocalStorage()\n;(globalThis).__CER_AUTH_STORE__ = _cerAuthStore\n\n// Async-local storage for per-request useFetch() data.\n// useFetch() writes fetched results here; the handler serialises the map into\n// window.__CER_FETCH_DATA__ for client-side hydration.\nconst _cerFetchStore = new AsyncLocalStorage()\n;(globalThis).__CER_FETCH_STORE__ = _cerFetchStore\n\n// Async-local storage for the current route info (path, params, query, meta).\n// useRoute() reads this on the server so layouts and components can access\n// route metadata without prop-drilling.\nconst _cerRouteStore = new AsyncLocalStorage()\n;(globalThis).__CER_ROUTE_STORE__ = _cerRouteStore\n\n// Async-local storage for per-request useState() reactive state.\n// Each request gets a fresh Map so concurrent SSR/SSG renders never share\n// reactive state set by different pages or loaders.\nconst _cerStateStore = new AsyncLocalStorage()\n;(globalThis).__CER_STATE_STORE__ = _cerStateStore\n\n// Runs fn inside the per-request AsyncLocalStorage context so that isomorphic\n// composables (useCookie, useSession, etc.) can access req/res without prop-drilling.\n// Call this for every API handler invocation \u2014 not just SSR renders.\nexport function runWithRequestContext(req, res, fn) {\n return _cerReqStore.run({ req, res }, fn)\n}\n\n// Runs the server/middleware/ chain for a request.\n// Returns false if a middleware short-circuited the response; true to continue.\n// Exported so Netlify / Vercel / Cloudflare bridges can also call it for API routes.\n// P1-2: Middleware may throw { status: 401 } (or any numeric .status) to produce\n// a non-500 response \u2014 the status is extracted and forwarded to res.statusCode.\nexport async function runServerMiddleware(req, res) {\n for (const { handler: mw } of (serverMiddleware ?? [])) {\n if (typeof mw !== 'function') continue\n let calledNext = false\n try {\n await new Promise((resolve, reject) => {\n Promise.resolve(mw(req, res, (err) => {\n if (err) reject(err)\n else { calledNext = true; resolve() }\n })).catch(reject)\n })\n } catch (err) {\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'middleware', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n if (!res.writableEnded) {\n const statusCode = (typeof err === 'object' && err !== null && 'status' in err && typeof err.status === 'number')\n ? (isNaN(err.status) ? 500 : err.status)\n : 500\n res.statusCode = statusCode\n res.end('Internal Server Error')\n }\n return false\n }\n if (res.writableEnded || !calledNext) return false\n }\n return true\n}\n\n// Load the Vite-built client index.html (dist/client/index.html) so every SSR\n// response includes the client-side scripts needed for hydration and routing.\n// The server bundle lives at dist/server/server.js, so ../client resolves correctly.\n//\n// Cloudflare Workers (and other runtimes without node:fs) can inject the\n// template before this module loads by setting globalThis.__CER_CLIENT_TEMPLATE__.\n// The Cloudflare adapter inlines dist/client/index.html as a string constant in\n// _worker.js and sets the global before dynamically importing the server bundle.\nlet _clientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? null\nif (!_clientTemplate) {\n try {\n const _clientTemplatePath = join(dirname(fileURLToPath(import.meta.url)), '../client/index.html')\n _clientTemplate = existsSync(_clientTemplatePath)\n ? readFileSync(_clientTemplatePath, 'utf-8')\n : null\n } catch {\n // node:fs not available in this runtime \u2014 Cloudflare adapter must set\n // globalThis.__CER_CLIENT_TEMPLATE__ before importing this bundle.\n }\n}\n\n// Merge the SSR rendered body with the Vite client shell so the final page\n// contains both pre-rendered DSD content and the client bundle scripts.\nfunction _mergeWithClientTemplate(ssrHtml, clientTemplate) {\n const headTag = '<head>', headCloseTag = '</head>'\n const bodyTag = '<body>', bodyCloseTag = '</body>'\n const headStart = ssrHtml.indexOf(headTag)\n const headEnd = ssrHtml.indexOf(headCloseTag)\n const bodyStart = ssrHtml.indexOf(bodyTag)\n const bodyEnd = ssrHtml.lastIndexOf(bodyCloseTag)\n const ssrHead = headStart >= 0 && headEnd > headStart\n ? ssrHtml.slice(headStart + headTag.length, headEnd).trim() : ''\n const ssrBody = bodyStart >= 0 && bodyEnd > bodyStart\n ? ssrHtml.slice(bodyStart + bodyTag.length, bodyEnd).trim() : ssrHtml\n // Hoist only top-level <style id=...> elements (cer-ssr-jit, cer-ssr-global)\n // from the SSR body into the document <head>. Plain <style> blocks without\n // an id attribute belong to shadow DOM templates and must stay in place \u2014\n // hoisting them to <head> breaks shadow DOM style encapsulation (document\n // styles do not pierce shadow roots), which is the root cause of FOUC.\n const headParts = ssrHead ? [ssrHead] : []\n let ssrBodyContent = ssrBody\n let pos = 0\n while (pos < ssrBodyContent.length) {\n const styleOpen = ssrBodyContent.indexOf('<style id=', pos)\n if (styleOpen < 0) break\n const styleClose = ssrBodyContent.indexOf('</style>', styleOpen)\n if (styleClose < 0) break\n headParts.push(ssrBodyContent.slice(styleOpen, styleClose + 8))\n ssrBodyContent = ssrBodyContent.slice(0, styleOpen) + ssrBodyContent.slice(styleClose + 8)\n pos = styleOpen\n }\n ssrBodyContent = ssrBodyContent.trim()\n // Inject the pre-rendered layout+page as light DOM of the app mount element\n // so it is visible before JS boots, then the client router takes over.\n let merged = clientTemplate\n if (merged.includes('<cer-layout-view></cer-layout-view>')) {\n merged = merged.replace('<cer-layout-view></cer-layout-view>',\n '<cer-layout-view>' + ssrBodyContent + '</cer-layout-view>')\n } else if (merged.includes('<div id=\"app\"></div>')) {\n merged = merged.replace('<div id=\"app\"></div>',\n '<div id=\"app\">' + ssrBodyContent + '</div>')\n }\n const headAdditions = headParts.filter(Boolean).join('\\n')\n if (headAdditions) {\n // If SSR provides a <title>, replace the client template's <title> so the\n // SSR title wins (client template title is the fallback default).\n if (headAdditions.includes('<title>')) {\n merged = merged.replace(/<title>[^<]*<\\/title>/, '')\n }\n merged = merged.replace('</head>', headAdditions + '\\n</head>')\n }\n return merged\n}\n\n// Per-request async setup: initialize a fresh router, resolve the matched\n// route and layout, pre-load the page module, and call the data loader.\n// Loader data is scoped to the current AsyncLocalStorage context via enterWith()\n// so concurrent renders never share state.\nconst _prepareRequest = async (req) => {\n await _pluginsReady\n const router = initRouter({ routes, initialUrl: req.url ?? '/' })\n const current = router.getCurrent()\n const { route, params } = router.matchRoute(current.path)\n\n // Store the current route info so useRoute() can read it synchronously\n // from any layout or component during this render pass.\n _cerRouteStore.enterWith({\n path: current.path,\n params,\n query: current.query ?? {},\n meta: route?.meta ?? null,\n })\n\n // Pre-load the page module so we can embed the component tag directly.\n // This avoids the async router-view (which injects content via script tags\n // and breaks Declarative Shadow DOM on initial parse).\n let pageVnode = { tag: 'div', props: {}, children: [] }\n let head\n if (route?.load) {\n try {\n const mod = await route.load()\n const pageTag = mod.default\n // P2-2: Route-level error tag (from co-located .error.ts or _error.ts).\n // Preferred over the global errorTag when rendering loader errors for this route.\n const routeErrorTag = mod.errorTag ?? null\n\n // P1-1: Synthetic 404 catch-all \u2014 no page component registered.\n if (!pageTag) {\n const notFoundErrorTag = routeErrorTag ?? errorTag\n const notFoundVnode = notFoundErrorTag\n ? { tag: notFoundErrorTag, props: { attrs: { error: 'Not Found', status: '404' } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: notFoundVnode, router, head: undefined, status: 404 }\n }\n\n // Run the loader before creating the page vnode so we can pass its\n // primitive return values as HTML attributes. useProps() in the page\n // component reads element attributes, so merging loader data here makes\n // both useProps() and usePageData() work in SSR / SSG.\n let loaderAttrs = {}\n if (typeof mod.loader === 'function') {\n const query = current.query ?? {}\n const data = await mod.loader({ params, query, req })\n if (data !== undefined && data !== null) {\n // enterWith() scopes the value to the current async context so\n // concurrent renders (SSG concurrency > 1) never share data.\n _cerDataStore.enterWith(data)\n head = `<script>window.__CER_DATA__ = ${JSON.stringify(data)}</script>`\n // Expose primitive loader values as element attributes so useProps()\n // can read them. Complex objects are only accessible via usePageData().\n loaderAttrs = Object.fromEntries(\n Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')\n )\n }\n }\n\n pageVnode = { tag: pageTag, props: { attrs: { ...params, ...loaderAttrs } }, children: [] }\n } catch (err) {\n // Loader threw \u2014 render the error page server-side if app/error.ts exists.\n const status = (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number')\n ? err.status : 500\n const message = (err instanceof Error) ? err.message : String(err)\n if (_hooks?.onError) {\n try { await _hooks.onError(err, { type: 'loader', path: new URL(req.url ?? '/', 'http://x').pathname, req }) } catch { /* hooks must not crash the handler */ }\n }\n // P2-2: Prefer the route-level errorTag over the global one.\n // routeErrorTag is not in scope here; use route?.meta?.errorTag from the matched route.\n const effectiveErrorTag = route?.meta?.errorTag ?? errorTag\n if (!effectiveErrorTag) {\n console.error('[cer-app] Loader error (no app/error.ts defined):', err)\n }\n const errVnode = effectiveErrorTag\n ? { tag: effectiveErrorTag, props: { attrs: { error: message, status: String(status) } }, children: [] }\n : { tag: 'div', props: {}, children: [] }\n return { vnode: errVnode, router, head: undefined, status }\n }\n }\n\n // Resolve layout chain: nested layouts (meta.layoutChain) or single layout.\n const chain = route?.meta?.layoutChain\n ? route.meta.layoutChain\n : [route?.meta?.layout ?? 'default']\n\n // Wrap pageVnode in the layout chain from innermost to outermost.\n let vnode = pageVnode\n for (let i = chain.length - 1; i >= 0; i--) {\n const tag = layouts[chain[i]]\n if (tag) vnode = { tag, props: {}, children: [vnode] }\n }\n\n // If the request matched a catch-all route (user-defined 404.ts or [...all].ts),\n // return HTTP 404 so browsers and crawlers treat it as a not-found response.\n const isCatchAll = route?.path === '/:all*'\n return { vnode, router, head, status: isCatchAll ? 404 : null }\n}\n\nexport const handler = async (req, res) => {\n const _requestPath = new URL(req.url ?? '/', 'http://x').pathname\n const _requestStart = Date.now()\n if (_hooks?.onRequest) {\n try { await _hooks.onRequest({ path: _requestPath, method: req.method ?? 'GET', req }) } catch { /* hooks must not crash the handler */ }\n }\n await _cerStateStore.run(new Map(), async () => {\n await _cerReqStore.run({ req, res }, async () => {\n await _cerDataStore.run(null, async () => {\n // Fresh per-request fetch map \u2014 populated by useFetch() calls inside loaders.\n const _fetchMap = new Map()\n await _cerFetchStore.run(_fetchMap, async () => {\n // Pre-resolve the authenticated user so useAuth() works synchronously during rendering.\n let _authUser = null\n if (_authSessionKey) {\n try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }\n }\n await _cerAuthStore.run(_authUser, async () => {\n const { vnode, router, head, status } = await _prepareRequest(req)\n if (status != null) res.statusCode = status\n\n let _headCollectionOpen = false\n try {\n // Begin collecting useHead() calls made during the synchronous render pass.\n // IMPORTANT: the stream's start() function runs synchronously on construction,\n // so ALL useHead() calls happen before the stream object is returned. We must\n // call endHeadCollection() immediately \u2014 before any await \u2014 to avoid a race\n // window where a concurrent request (e.g. SSG concurrency > 1) resets the\n // shared globalThis collector while this handler is suspended at an await.\n _headCollectionOpen = true\n beginHeadCollection()\n\n // dsdPolyfill: false \u2014 we inject the polyfill manually after merging so it\n // lands at the end of <body>, not inside <cer-layout-view> light DOM where\n // scripts may not execute.\n // The first chunk from the stream is the full synchronous render. Subsequent\n // chunks are async component swap scripts streamed as they resolve.\n const stream = renderToStreamWithJITCSSDSD(vnode, { dsdPolyfill: false, router })\n\n // Collect head tags synchronously \u2014 all useHead() calls have already fired\n // inside the stream constructor's start() before it returned.\n const headTags = serializeHeadTags(endHeadCollection())\n _headCollectionOpen = false\n\n const reader = stream.getReader()\n\n // Read the first (synchronous) chunk \u2014 rejects if the sync render failed.\n const { value: firstChunk = '' } = await reader.read()\n\n // Serialise useFetch() results collected during loader execution.\n const _fetchObj = Object.fromEntries(_fetchMap)\n const _fetchScript = Object.keys(_fetchObj).length > 0\n ? `<script>window.__CER_FETCH_DATA__ = ${JSON.stringify(_fetchObj)}</script>`\n : ''\n\n // Serialise the auth user for client-side hydration via useAuth().\n const _authScript = _authUser\n ? `<script>window.__CER_AUTH_USER__ = ${JSON.stringify(_authUser)}</script>`\n : ''\n\n // Serialise useState() values for client-side hydration.\n // The state Map is populated by loader calls (in _prepareRequest) and by component\n // render functions (during renderToStreamWithJITCSSDSD above). Both run before this\n // point. Injected as window.__CER_STATE_INIT__ so the client useState() can\n // pre-populate its singleton Map on first use \u2014 no flash to default values.\n const _stateMap = _cerStateStore.getStore()\n let _stateScript = ''\n if (_stateMap && _stateMap.size > 0) {\n const _stateObj = {}\n for (const [k, v] of _stateMap) { _stateObj[k] = v.value }\n _stateScript = `<script>window.__CER_STATE_INIT__ = ${JSON.stringify(_stateObj)}</script>`\n }\n\n // Merge loader data script + useHead() tags + fetch/auth/state hydration scripts.\n const headContent = [head, headTags, _fetchScript, _authScript, _stateScript].filter(Boolean).join('\\n')\n\n // Wrap the rendered body in a full HTML document and inject the head additions\n // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.\n const ssrHtml = `<!DOCTYPE html><html><head>${headContent}</head><body>${firstChunk}</body></html>`\n\n // In dev mode the module-level _clientTemplate is null (only the\n // production dist/client/index.html path is searched at init time).\n // The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request\n // after running server.transformIndexHtml so the Vite client scripts\n // (/@vite/client, HMR) are included in every SSR response.\n const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate\n const merged = _resolvedClientTemplate\n ? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)\n : ssrHtml\n\n // Split at </body> so async swap scripts and the DSD polyfill can be streamed\n // in before the document is closed.\n const bodyCloseIdx = merged.lastIndexOf('</body>')\n const beforeBodyClose = bodyCloseIdx >= 0 ? merged.slice(0, bodyCloseIdx) : merged\n const fromBodyClose = bodyCloseIdx >= 0 ? merged.slice(bodyCloseIdx) : ''\n\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.setHeader('Transfer-Encoding', 'chunked')\n res.write(beforeBodyClose)\n\n // Stream async component swap scripts through as-is.\n while (true) {\n const { value, done } = await reader.read()\n if (done) break\n res.write(value)\n }\n\n // Inject DSD polyfill immediately before </body>, then close the document.\n res.end(DSD_POLYFILL_SCRIPT + fromBodyClose)\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n } catch (_renderErr) {\n if (_hooks?.onError) {\n try { await _hooks.onError(_renderErr, { type: 'render', path: _requestPath, req }) } catch { /* hooks must not crash the handler */ }\n }\n // Ensure the head collector is never left open on error.\n if (_headCollectionOpen) { try { endHeadCollection() } catch { /* ignore */ } }\n // If headers have not been flushed yet we can still send a proper 500 page.\n // If writing has already started we can only close the connection cleanly.\n if (!res.headersSent) {\n res.statusCode = 500\n res.setHeader('Content-Type', 'text/html; charset=utf-8')\n res.end('<!DOCTYPE html><html><head></head><body><h1>500 Internal Server Error</h1><p>An unexpected error occurred while rendering this page.</p></body></html>')\n } else {\n res.end()\n }\n if (_hooks?.onResponse) {\n try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }\n }\n }\n }) // _cerAuthStore.run\n }) // _cerFetchStore.run\n }) // _cerDataStore.run\n }) // _cerReqStore.run\n }) // _cerStateStore.run\n}\n\n// ISR-wrapped handler for production integrations (Express, Hono, Fastify).\n// Routes with meta.ssg.revalidate are served stale-while-revalidate.\nexport const isrHandler = createIsrHandler(routes, handler)\n\nexport { apiRoutes, plugins, layouts, routes, serverMiddleware }\nexport default handler\n";
15
15
  //# sourceMappingURL=entry-server-template.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,kzrBAybjC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,qxsBA+bjC,CAAA"}
@@ -394,8 +394,14 @@ export const handler = async (req, res) => {
394
394
  // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
395
395
  const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
396
396
 
397
- const merged = _clientTemplate
398
- ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
397
+ // In dev mode the module-level _clientTemplate is null (only the
398
+ // production dist/client/index.html path is searched at init time).
399
+ // The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request
400
+ // after running server.transformIndexHtml so the Vite client scripts
401
+ // (/@vite/client, HMR) are included in every SSR response.
402
+ const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate
403
+ const merged = _resolvedClientTemplate
404
+ ? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)
399
405
  : ssrHtml
400
406
 
401
407
  // Split at </body> so async swap scripts and the DSD polyfill can be streamed
@@ -1 +1 @@
1
- {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAybpC,CAAA"}
1
+ {"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+bpC,CAAA"}
@@ -5,33 +5,40 @@
5
5
  * Security headers and Cache-Control are server-level concerns, so these
6
6
  * tests only run in SSR and SSG modes (both use `cer-app preview`).
7
7
  * SPA mode also runs through the preview server, so the tests apply there too.
8
+ * Dev mode uses the Vite dev server which does not set these headers.
8
9
  */
9
10
 
10
- describe('Preview server security headers', () => {
11
- it('responds with X-Content-Type-Options: nosniff', () => {
12
- cy.request('/').then((response) => {
13
- expect(response.headers['x-content-type-options']).to.eq('nosniff')
11
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg' | 'dev'
12
+
13
+ // Security headers and Cache-Control are only set by the production preview server.
14
+ // Skip these tests in dev mode (Vite dev server does not set them).
15
+ if (mode !== 'dev') {
16
+ describe('Preview server — security headers', () => {
17
+ it('responds with X-Content-Type-Options: nosniff', () => {
18
+ cy.request('/').then((response) => {
19
+ expect(response.headers['x-content-type-options']).to.eq('nosniff')
20
+ })
14
21
  })
15
- })
16
22
 
17
- it('responds with X-Frame-Options: DENY', () => {
18
- cy.request('/').then((response) => {
19
- expect(response.headers['x-frame-options']).to.eq('DENY')
23
+ it('responds with X-Frame-Options: DENY', () => {
24
+ cy.request('/').then((response) => {
25
+ expect(response.headers['x-frame-options']).to.eq('DENY')
26
+ })
20
27
  })
21
- })
22
28
 
23
- it('responds with Referrer-Policy: strict-origin-when-cross-origin', () => {
24
- cy.request('/').then((response) => {
25
- expect(response.headers['referrer-policy']).to.eq('strict-origin-when-cross-origin')
29
+ it('responds with Referrer-Policy: strict-origin-when-cross-origin', () => {
30
+ cy.request('/').then((response) => {
31
+ expect(response.headers['referrer-policy']).to.eq('strict-origin-when-cross-origin')
32
+ })
26
33
  })
27
- })
28
34
 
29
- it('includes security headers on 404 responses', () => {
30
- cy.request({ url: '/definitely-not-a-real-page-xyz', failOnStatusCode: false }).then((response) => {
31
- expect(response.headers['x-content-type-options']).to.eq('nosniff')
35
+ it('includes security headers on 404 responses', () => {
36
+ cy.request({ url: '/definitely-not-a-real-page-xyz', failOnStatusCode: false }).then((response) => {
37
+ expect(response.headers['x-content-type-options']).to.eq('nosniff')
38
+ })
32
39
  })
33
40
  })
34
- })
41
+ }
35
42
 
36
43
  describe('Preview server — path traversal protection', () => {
37
44
  // HTTP clients (including Cypress/got) normalize `..` segments before sending,
@@ -55,25 +62,27 @@ describe('Preview server — path traversal protection', () => {
55
62
  // src/__tests__/cli/preview-isr.test.ts. No additional e2e assertion is needed.
56
63
  })
57
64
 
58
- describe('Preview server — Cache-Control', () => {
59
- it('serves HTML with Cache-Control: no-cache', () => {
60
- cy.request('/').then((response) => {
61
- expect(response.headers['cache-control']).to.include('no-cache')
65
+ if (mode !== 'dev') {
66
+ describe('Preview server Cache-Control', () => {
67
+ it('serves HTML with Cache-Control: no-cache', () => {
68
+ cy.request('/').then((response) => {
69
+ expect(response.headers['cache-control']).to.include('no-cache')
70
+ })
62
71
  })
63
- })
64
72
 
65
- it('serves content-hashed assets with immutable Cache-Control', () => {
66
- // Get the page to discover an actual asset URL (Vite hashes asset filenames)
67
- cy.request('/').then((htmlResponse) => {
68
- const assetMatch = htmlResponse.body.match(/\/assets\/[^"'\s]+\.js/)
69
- if (!assetMatch) return // no JS asset found in this page, skip
73
+ it('serves content-hashed assets with immutable Cache-Control', () => {
74
+ // Get the page to discover an actual asset URL (Vite hashes asset filenames)
75
+ cy.request('/').then((htmlResponse) => {
76
+ const assetMatch = htmlResponse.body.match(/\/assets\/[^"'\s]+\.js/)
77
+ if (!assetMatch) return // no JS asset found in this page, skip
70
78
 
71
- const assetUrl = assetMatch[0]
72
- cy.request(assetUrl).then((assetResponse) => {
73
- const cc = assetResponse.headers['cache-control'] as string
74
- expect(cc).to.include('max-age=31536000')
75
- expect(cc).to.include('immutable')
79
+ const assetUrl = assetMatch[0]
80
+ cy.request(assetUrl).then((assetResponse) => {
81
+ const cc = assetResponse.headers['cache-control'] as string
82
+ expect(cc).to.include('max-age=31536000')
83
+ expect(cc).to.include('immutable')
84
+ })
76
85
  })
77
86
  })
78
87
  })
79
- })
88
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * usePageData() timing regression tests — proves the queueMicrotask fix.
3
+ *
4
+ * Background:
5
+ * _doHydrate() runs _loadPageForPath (sets __CER_DATA__) then _replace(),
6
+ * which causes cer-layout-view to create a NEW page element via a queued
7
+ * microtask. That new element's setup() calls usePageData() to read __CER_DATA__.
8
+ *
9
+ * Pre-fix: delete __CER_DATA__ was synchronous → ran before the queued render
10
+ * → usePageData() saw undefined → returned null.
11
+ * Post-fix: queueMicrotask(() => delete __CER_DATA__) → delete is queued AFTER
12
+ * the render microtask → usePageData() reads the data correctly.
13
+ *
14
+ * Proof mechanism:
15
+ * Each blog page component captures `ssrData ? 'ssr' : 'client'` once, at the
16
+ * moment the element is created during hydration. In SSR/SSG/dev modes this
17
+ * value must always be 'ssr'. If it is 'client' the timing bug has regressed.
18
+ */
19
+
20
+ export {}
21
+
22
+ const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg' | 'dev'
23
+
24
+ // ─── Blog list — hard refresh ─────────────────────────────────────────────
25
+
26
+ describe('usePageData() — blog list hard refresh', () => {
27
+ if (mode !== 'spa') {
28
+ it('server response embeds __CER_DATA__ with both post titles', () => {
29
+ cy.request('/blog').then((res) => {
30
+ expect(res.body).to.include('__CER_DATA__')
31
+ expect(res.body).to.include('First Post')
32
+ expect(res.body).to.include('Second Post')
33
+ })
34
+ })
35
+
36
+ it('usePageData() is non-null during hydration re-render (data-source = "ssr")', () => {
37
+ cy.visit('/blog')
38
+ // Scope to the live shadow DOM (not the DSD pre-render copy) so the assertion
39
+ // targets the element created during hydration where usePageData() was called.
40
+ // 'ssr' proves queueMicrotask deferred the delete until after the render ran.
41
+ cy.get('cer-layout-view').shadow().find('page-blog').shadow()
42
+ .find('[data-cy=blog-data-source]').should('have.text', 'ssr')
43
+ })
44
+ }
45
+
46
+ // In SSR and dev modes the /api/posts route exists on the server.
47
+ // If usePageData() is non-null, the blog page's useOnConnected guard
48
+ // (`if (ssrData) return`) skips the fetch entirely.
49
+ if (mode === 'ssr' || mode === 'dev') {
50
+ it('no /api/posts network request is made — usePageData() skips client fetch', () => {
51
+ cy.intercept('GET', '/api/posts').as('apiFetch')
52
+ cy.visit('/blog')
53
+ // Wait for the page component to fully hydrate before asserting no requests.
54
+ cy.get('cer-layout-view').shadow().find('page-blog').shadow()
55
+ .find('[data-cy=blog-list]').should('exist')
56
+ cy.get('@apiFetch.all').should('have.length', 0)
57
+ })
58
+ }
59
+
60
+ it('blog posts are visible after hydration (all modes)', () => {
61
+ cy.visit('/blog')
62
+ cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
63
+ })
64
+ })
65
+
66
+ // ─── Blog detail — hard refresh ───────────────────────────────────────────
67
+
68
+ describe('usePageData() — blog detail hard refresh', () => {
69
+ if (mode !== 'spa') {
70
+ it('server response embeds __CER_DATA__ with post detail', () => {
71
+ cy.request('/blog/first-post').then((res) => {
72
+ expect(res.body).to.include('__CER_DATA__')
73
+ expect(res.body).to.include('First Post')
74
+ })
75
+ })
76
+
77
+ it('usePageData() is non-null during hydration re-render on detail page', () => {
78
+ cy.visit('/blog/first-post')
79
+ cy.get('cer-layout-view').shadow().find('page-blog-slug').shadow()
80
+ .find('[data-cy=blog-detail-data-source]').should('have.text', 'ssr')
81
+ })
82
+ }
83
+
84
+ if (mode === 'ssr' || mode === 'dev') {
85
+ it('no /api/posts/:slug network request is made — usePageData() skips client fetch', () => {
86
+ cy.intercept('GET', '/api/posts/*').as('apiPostDetail')
87
+ cy.visit('/blog/first-post')
88
+ cy.get('cer-layout-view').shadow().find('page-blog-slug').shadow()
89
+ .find('[data-cy=post-title]').should('exist')
90
+ cy.get('@apiPostDetail.all').should('have.length', 0)
91
+ })
92
+ }
93
+
94
+ it('"First Post" title renders on hard refresh (all modes)', () => {
95
+ cy.visit('/blog/first-post')
96
+ cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'First Post')
97
+ })
98
+
99
+ it('"Second Post" title renders on hard refresh (all modes)', () => {
100
+ cy.visit('/blog/second-post')
101
+ cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'Second Post')
102
+ })
103
+ })
104
+
105
+ // ─── Client-side navigation (post-hydration data loading) ─────────────────
106
+
107
+ describe('usePageData() — client-side navigation', () => {
108
+ it('navigating to blog from home shows posts', () => {
109
+ cy.visit('/')
110
+ cy.get('[data-cy=page-nav]').find('a[href="/blog"]').first().click({ force: true })
111
+ cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
112
+ })
113
+
114
+ it('navigating between blog posts loads correct data each time', () => {
115
+ cy.visit('/blog/first-post')
116
+ cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'First Post')
117
+ cy.get('[data-cy=post-back]').first().click({ force: true })
118
+ cy.url().should('include', '/blog')
119
+ cy.get('[data-cy=blog-link-second-post]', { timeout: 8000 }).first().click({ force: true })
120
+ cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'Second Post')
121
+ })
122
+ })
@@ -7,6 +7,9 @@ interface Post {
7
7
  component('page-blog-slug', () => {
8
8
  const props = useProps<{ slug: string }>({ slug: '' })
9
9
  const ssrData = usePageData<Post>()
10
+ // Captured once at element-creation time (during the hydration re-render).
11
+ // 'ssr' proves usePageData() was non-null — the queueMicrotask timing fix works.
12
+ const dataSource = ssrData ? 'ssr' : 'client'
10
13
 
11
14
  const title = ref(ssrData?.title ?? '')
12
15
  const body = ref(ssrData?.body ?? '')
@@ -29,6 +32,7 @@ component('page-blog-slug', () => {
29
32
 
30
33
  return html`
31
34
  <div>
35
+ <span data-cy="blog-detail-data-source" hidden>${dataSource}</span>
32
36
  <h1 data-cy="post-title">${title.value || props.slug}</h1>
33
37
  <p data-cy="post-slug"><em>slug: <code>${props.slug}</code></em></p>
34
38
  <div data-cy="post-body">${body.value}</div>
@@ -9,6 +9,10 @@ component('page-blog', () => {
9
9
 
10
10
  const ssrData = usePageData<{ posts: Post[] }>()
11
11
  const posts = ref<Post[]>(ssrData?.posts ?? [])
12
+ // Captured once at element-creation time (during the hydration re-render).
13
+ // 'ssr' proves usePageData() was non-null — the queueMicrotask timing fix works.
14
+ // 'client' means __CER_DATA__ was deleted before setup ran (regression).
15
+ const dataSource = ssrData ? 'ssr' : 'client'
12
16
 
13
17
  useOnConnected(async () => {
14
18
  if (ssrData) return // already hydrated — skip client fetch
@@ -28,6 +32,7 @@ component('page-blog', () => {
28
32
  <div>
29
33
  <h1 data-cy="blog-heading">Blog</h1>
30
34
  <p>Posts are loaded via a page <strong>loader</strong> (SSR/SSG) or client-side fetch (SPA).</p>
35
+ <span data-cy="blog-data-source" hidden>${dataSource}</span>
31
36
  <ul data-cy="blog-list">
32
37
  ${posts.value.map(post => html`
33
38
  <li data-cy="blog-item">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.20.3",
3
+ "version": "0.20.4",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -72,13 +72,16 @@
72
72
  "e2e:serve:spa": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4174",
73
73
  "e2e:serve:ssr": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4175 --ssr",
74
74
  "e2e:serve:ssg": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4176",
75
+ "e2e:serve:dev": "node dist/cli/index.js dev --root e2e/kitchen-sink --port 4177 --mode ssr",
75
76
  "e2e:run:spa": "cypress run --config baseUrl=http://localhost:4174 --env mode=spa",
76
77
  "e2e:run:ssr": "cypress run --config baseUrl=http://localhost:4175 --env mode=ssr",
77
78
  "e2e:run:ssg": "cypress run --config baseUrl=http://localhost:4176 --env mode=ssg",
79
+ "e2e:run:dev": "cypress run --config baseUrl=http://localhost:4177 --env mode=dev",
78
80
  "e2e:spa": "npm run e2e:build:spa && start-server-and-test e2e:serve:spa http://localhost:4174 e2e:run:spa",
79
81
  "e2e:ssr": "npm run e2e:build:ssr && start-server-and-test e2e:serve:ssr http://localhost:4175 e2e:run:ssr",
80
82
  "e2e:ssg": "npm run e2e:build:ssg && start-server-and-test e2e:serve:ssg http://localhost:4176 e2e:run:ssg",
81
- "e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa",
83
+ "e2e:dev": "npm run e2e:clean && start-server-and-test e2e:serve:dev http://localhost:4177 e2e:run:dev",
84
+ "e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa && npm run e2e:dev",
82
85
  "cypress:open": "cypress open"
83
86
  },
84
87
  "peerDependencies": {
@@ -137,8 +137,8 @@ describe('buildSSG — path collection', () => {
137
137
  it('calls fg when pagesDir exists and no explicit routes', async () => {
138
138
  vi.mocked(existsSync).mockReturnValue(true)
139
139
  await buildSSG(makeConfig())
140
- // fg is called once for page discovery and once by loadContentStore (content scan)
141
- expect(fg).toHaveBeenCalledTimes(2)
140
+ // fg is called once for page discovery
141
+ expect(fg).toHaveBeenCalledTimes(1)
142
142
  })
143
143
 
144
144
  it('skips Vite dev server when all discovered pages are static', async () => {
@@ -79,38 +79,37 @@ describe('resolveContentDir', () => {
79
79
 
80
80
  describe('loadContentStore — nonexistent dir', () => {
81
81
  it('returns empty array when contentDir does not exist', async () => {
82
- const items = await loadContentStore('/path/does/not/exist', false, false)
82
+ const items = await loadContentStore('/path/does/not/exist', false)
83
83
  expect(items).toEqual([])
84
84
  })
85
85
  })
86
86
 
87
- describe('loadContentStore — dev mode (isProduction=false)', () => {
88
- it('loads all files including drafts', async () => {
89
- const items = await loadContentStore(contentDir, false, false)
87
+ describe('loadContentStore — drafts excluded by default (includeDrafts=false)', () => {
88
+ it('loads all non-draft files', async () => {
89
+ const items = await loadContentStore(contentDir, false)
90
90
  const paths = items.map((i) => i._path).sort()
91
- // Root, about, blog/hello, blog/secret, data/products
91
+ // Root, about, blog/hello (not secret — it is a draft), data/products
92
92
  expect(paths).toContain('/')
93
93
  expect(paths).toContain('/about')
94
94
  expect(paths).toContain('/blog/hello')
95
- expect(paths).toContain('/blog/secret')
95
+ expect(paths).not.toContain('/blog/secret')
96
96
  expect(paths).toContain('/data/products')
97
97
  })
98
98
 
99
- it('includes draft items', async () => {
100
- const items = await loadContentStore(contentDir, false, false)
99
+ it('excludes draft items', async () => {
100
+ const items = await loadContentStore(contentDir, false)
101
101
  const secret = items.find((i) => i._path === '/blog/secret')
102
- expect(secret).toBeDefined()
103
- expect(secret?.draft).toBe(true)
102
+ expect(secret).toBeUndefined()
104
103
  })
105
104
 
106
105
  it('strips date prefix from slug', async () => {
107
- const items = await loadContentStore(contentDir, false, false)
106
+ const items = await loadContentStore(contentDir, false)
108
107
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
109
108
  expect(items.find((i) => i._path === '/blog/2026-04-03-hello')).toBeUndefined()
110
109
  })
111
110
 
112
111
  it('each item has required ContentItem fields', async () => {
113
- const items = await loadContentStore(contentDir, false, false)
112
+ const items = await loadContentStore(contentDir, false)
114
113
  for (const item of items) {
115
114
  expect(typeof item._path).toBe('string')
116
115
  expect(typeof item._file).toBe('string')
@@ -121,7 +120,7 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
121
120
  })
122
121
 
123
122
  it('normalises date fields to strings (not Date objects)', async () => {
124
- const items = await loadContentStore(contentDir, false, false)
123
+ const items = await loadContentStore(contentDir, false)
125
124
  const about = items.find((i) => i._path === '/about')
126
125
  expect(about).toBeDefined()
127
126
  expect(typeof about?.date).toBe('string')
@@ -129,31 +128,24 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
129
128
  })
130
129
  })
131
130
 
132
- describe('loadContentStore — production mode (isProduction=true, isDraft=false)', () => {
133
- it('excludes draft items', async () => {
134
- const items = await loadContentStore(contentDir, false, true)
131
+ describe('loadContentStore — drafts included (includeDrafts=true)', () => {
132
+ it('includes draft items when includeDrafts=true', async () => {
133
+ const items = await loadContentStore(contentDir, true)
135
134
  const secret = items.find((i) => i._path === '/blog/secret')
136
- expect(secret).toBeUndefined()
135
+ expect(secret).toBeDefined()
136
+ expect(secret?.draft).toBe(true)
137
137
  })
138
138
 
139
139
  it('includes non-draft items', async () => {
140
- const items = await loadContentStore(contentDir, false, true)
140
+ const items = await loadContentStore(contentDir, true)
141
141
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
142
142
  expect(items.find((i) => i._path === '/about')).toBeDefined()
143
143
  })
144
144
  })
145
145
 
146
- describe('loadContentStore — production mode with drafts enabled (isDraft=true)', () => {
147
- it('includes draft items when isDraft=true in production', async () => {
148
- const items = await loadContentStore(contentDir, true, true)
149
- const secret = items.find((i) => i._path === '/blog/secret')
150
- expect(secret).toBeDefined()
151
- })
152
- })
153
-
154
146
  describe('loadContentStore — JSON files', () => {
155
147
  it('includes JSON files with _type json', async () => {
156
- const items = await loadContentStore(contentDir, false, false)
148
+ const items = await loadContentStore(contentDir, false)
157
149
  const products = items.find((i) => i._path === '/data/products')
158
150
  expect(products).toBeDefined()
159
151
  expect(products?._type).toBe('json')
@@ -117,7 +117,10 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
117
117
  })
118
118
 
119
119
  it('merges SSR html with client template when available', () => {
120
- expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _clientTemplate)')
120
+ // Dev mode: per-request global takes precedence over module-level _clientTemplate
121
+ expect(src).toContain('_resolvedClientTemplate')
122
+ expect(src).toContain('(globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate')
123
+ expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)')
121
124
  })
122
125
 
123
126
  it('exports handler as both named and default export', () => {
@@ -77,9 +77,14 @@ export function devCommand(): Command {
77
77
  .option('-p, --port <port>', 'Port to listen on', '3000')
78
78
  .option('--host <host>', 'Host to bind to', 'localhost')
79
79
  .option('--root <root>', 'Project root directory', process.cwd())
80
+ .option('--mode <mode>', 'Dev mode: spa, ssr, or ssg (overrides cer.config.ts)')
80
81
  .action(async (options) => {
81
82
  const root = resolve(options.root)
82
83
  const userConfig = await loadCerConfig(root)
84
+ // CLI --mode flag overrides config file (mirrors build command behaviour)
85
+ if (options.mode) {
86
+ userConfig.mode = options.mode as 'spa' | 'ssr' | 'ssg'
87
+ }
83
88
  const port = options.port ? parseInt(options.port, 10) : (userConfig.port ?? 3000)
84
89
 
85
90
  console.log('[cer-app] Starting dev server...')
@@ -5,7 +5,6 @@ import { createServer, type UserConfig } from 'vite'
5
5
  import type { ResolvedCerConfig } from './dev-server.js'
6
6
  import { buildSSR } from './build-ssr.js'
7
7
  import { buildRouteEntry } from './path-utils.js'
8
- import { CONTENT_STORE_KEY, loadContentStore, resolveContentDir } from './content/index.js'
9
8
  import fg from 'fast-glob'
10
9
 
11
10
  interface SsgManifest {
@@ -250,17 +249,6 @@ export async function buildSSG(
250
249
  const paths = await collectSsgPaths(config, viteUserConfig)
251
250
  console.log(`[cer-app] Found ${paths.length} path(s) to generate:`, paths)
252
251
 
253
- // Restore the in-memory content store to the production (no-draft) content.
254
- // collectSsgPaths may spin up a Vite dev server (watchMode=true) whose
255
- // buildStart hook overwrites globalThis.__CER_CONTENT_STORE__ with drafts
256
- // included. Re-running loadContentStore with isProduction=true corrects this
257
- // so that every renderPath call sees only published content.
258
- {
259
- const contentDir = resolveContentDir(config.root)
260
- const productionItems = await loadContentStore(contentDir, false, true)
261
- ;(globalThis as Record<string, unknown>)[CONTENT_STORE_KEY] = productionItems
262
- }
263
-
264
252
  // Step 3+4: Render and write paths with bounded concurrency.
265
253
  // The server bundle uses per-request router instances (initRouter returns the
266
254
  // router; the factory passes it to createStreamingSSRHandler as { vnode, router })