@warlock.js/web 5.0.0 → 5.0.2

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 (39) hide show
  1. package/esm/build/contribution.mjs +1 -1
  2. package/esm/build/discover-pages.mjs +21 -2
  3. package/esm/build/discover-pages.mjs.map +1 -1
  4. package/esm/build/generate-pages-barrel.mjs +1 -1
  5. package/esm/components/document-context.d.mts +7 -1
  6. package/esm/connector/index.mjs +1 -1
  7. package/esm/metadata.d.mts +1 -1
  8. package/esm/routing/compose-route-path.d.mts +29 -0
  9. package/esm/server/buffered-response.d.mts +58 -0
  10. package/esm/server/create-page-module-loader.d.mts +36 -0
  11. package/esm/server/create-page-route-handler.d.mts +31 -0
  12. package/esm/server/execute-page-request.d.mts +7 -1
  13. package/esm/server/execute-page-request.mjs +1 -1
  14. package/esm/server/execute-page-request.types.d.mts +174 -1
  15. package/esm/server/hydration-client-url.mjs +1 -1
  16. package/esm/server/index.d.mts +13 -0
  17. package/esm/server/index.mjs +6 -6
  18. package/esm/server/install-page-routes-from-manifest.d.mts +44 -0
  19. package/esm/server/install-page-routes-from-manifest.mjs +1 -1
  20. package/esm/server/install-page-routes.d.mts +59 -1
  21. package/esm/server/install-page-routes.mjs +149 -3
  22. package/esm/server/install-page-routes.mjs.map +1 -0
  23. package/esm/server/page-context.d.mts +14 -1
  24. package/esm/server/page-context.mjs +11 -1
  25. package/esm/server/page-context.mjs.map +1 -1
  26. package/esm/server/render-page.d.mts +89 -0
  27. package/esm/server/render-page.mjs +40 -1
  28. package/esm/server/render-page.mjs.map +1 -1
  29. package/esm/server/stylesheet-urls.d.mts +52 -0
  30. package/esm/server/stylesheet-urls.mjs +64 -2
  31. package/esm/server/stylesheet-urls.mjs.map +1 -1
  32. package/esm/server/web-connector.d.mts +1 -1
  33. package/esm/server/web-connector.mjs +30 -7
  34. package/esm/server/web-connector.mjs.map +1 -1
  35. package/esm/shared.d.mts +25 -1
  36. package/esm/vite/build-client.mjs +1 -1
  37. package/esm/vite/gate-a-resolve.mjs +2 -2
  38. package/esm/vite/hydration-entries.mjs +1 -1
  39. package/package.json +10 -4
@@ -1 +1 @@
1
- {"version":3,"file":"render-page.mjs","names":[],"sources":["../../../../../../../web/src/server/render-page.ts"],"sourcesContent":["import { createElement, type ComponentType, type ReactNode } from \"react\";\nimport DefaultApp from \"../components/default-app\";\nimport {\n DocumentContext,\n escapePayload,\n PAYLOAD_SCRIPT_ID,\n type DocumentContextValue,\n} from \"../components/document-context\";\nimport type { SharedContext } from \"../index\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport type { BufferedCookie } from \"./buffered-response\";\nimport {\n buildErrorRecord,\n designateBoundary,\n executePageRequest,\n type ExecutePageRequestOptions,\n type PageDataBundle,\n type PageErrorRecord,\n type PageLevelName,\n type PageRouteEntry,\n type PageRouteMatch,\n type PageTripleModule,\n type PipelineRequest,\n type PipelineResponse,\n} from \"./execute-page-request\";\n\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\n\n/**\n * Pipeline stages 9–10: RENDER the page tree from the\n * data bundle stages 1–8 produced, then return finalized { html, status,\n * headers, cookies }. Stage 10 happens at the CALL SITE in two halves —\n * 10a the caller applies status + headers (the single live-response write,\n * after render, before anything flushes), 10b it flushes\n * the document. Nothing in this module writes the live response. It never\n * re-runs any earlier stage — `renderPage` calls `executePageRequest` and\n * everything here consumes its bundle as-is.\n *\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\n * the production orchestrator AND the test helper. Because a loader IS a\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\n * returns `{ html, status, headers, data }` in one call — asserting a page's\n * data and its response headers is a unit test, no browser, no server boot.\n */\n\n// ---------------------------------------------------------------------------\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\n// ---------------------------------------------------------------------------\n\nexport type PageRoutesRegistry = {\n routes: readonly PageRouteEntry[];\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n};\n\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\n\n/**\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\n * without each call site carrying the manifest. Returns the previous registry\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\n */\nexport function connectPageRoutes(\n registry: PageRoutesRegistry | undefined,\n): PageRoutesRegistry | undefined {\n const previous = pageRoutesRegistry;\n pageRoutesRegistry = registry;\n return previous;\n}\n\n// ---------------------------------------------------------------------------\n// renderPage surface\n// ---------------------------------------------------------------------------\n\nexport type RenderPageOptions = {\n params?: Record<string, string>;\n query?: Record<string, string>;\n /**\n * Impersonation for tests: assigned to `request.user` right after the\n * request pair is constructed — `user` is a plain public property on core's\n * Request (core/src/http/request.ts:92) and this is exactly the write auth\n * middleware would have performed.\n */\n as?: unknown;\n /** Per-call overrides of the connected registry (tests, mostly). */\n routes?: readonly PageRouteEntry[];\n createHttp?: ExecutePageRequestOptions[\"createHttp\"];\n};\n\n/**\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\n * name-based sugar buildUrl consumes) have no meaning here — everything else\n * is the same seam.\n */\nexport type RenderPageRequestOptions = Omit<RenderPageOptions, \"params\" | \"query\">;\n\nexport type RenderedPage = {\n /** The full document (\"\" when the pipeline short-circuited before render). */\n html: string;\n status: number;\n /** Committed response headers, lowercased key → value. */\n headers: Record<string, string>;\n /**\n * Committed cookies in commit order, attribute-faithful: each entry carries\n * the loader's raw value (pre-serialization) AND its options\n * (`httpOnly`/`secure`/`sameSite`/`path`/`expires`/…). Never flattened to a\n * name→value map — a map cannot express the attributes, and a Set-Cookie\n * built without them is a security defect, not a convenience.\n */\n cookies: BufferedCookie[];\n /**\n * The PAGE loader's data — `data.product.name` reads as the dx story\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\n */\n data: unknown;\n /**\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\n * `renderPage` always carries one (its no-match throws instead).\n */\n bundle: PageDataBundle | undefined;\n};\n\nfunction requireRegistry(\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\n): PageRoutesRegistry {\n const routes = options.routes ?? pageRoutesRegistry?.routes;\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\n\n if (!routes || !createHttp) {\n throw new Error(\n \"renderPage()/renderPageRequest() has no route registry connected \" +\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\n \"manifest, which the server bootstrap owns. Fix: \" +\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\n \"beforeAll), or pass { routes, createHttp } to this call.\",\n );\n }\n\n return { routes, createHttp };\n}\n\nfunction buildUrl(\n entry: PageRouteEntry,\n params: Record<string, string>,\n query: Record<string, string>,\n): string {\n const path = entry.path\n .split(\"/\")\n .map(segment => {\n if (!segment.startsWith(\":\")) return segment;\n\n const name = segment.slice(1);\n const value = params[name];\n\n if (value === undefined) {\n throw new Error(\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\n `param \"${name}\" and the call did not provide it ` +\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\n `\\`params: { ${name}: … }\\`.`,\n );\n }\n\n return encodeURIComponent(value);\n })\n .join(\"/\");\n\n const queryString = new URLSearchParams(query).toString();\n\n return queryString ? `${path}?${queryString}` : path;\n}\n\n// ---------------------------------------------------------------------------\n// Stage 9 — RENDER\n// ---------------------------------------------------------------------------\n\n/**\n * The framework-owned terminal boundary (P1 §4: designation falls back to\n * `app` even when no level exports one — \"the framework owns a root\n * boundary\"). Deliberately generic: the error itself is server knowledge and\n * never serialized into the document.\n */\nfunction FrameworkRootBoundary(): ReactNode {\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\n}\n\ntype LevelProps = {\n data: unknown;\n shared: Readonly<SharedContext> | undefined;\n children?: ReactNode;\n};\n\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n};\n\n/**\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\n * level receiving ITS OWN loader data and the same sealed `shared` — the\n * exact props the M1 contract declares (web/src/props.ts) and never\n * request/response (the component also renders on a machine where neither\n * exists, props.ts:19-22).\n *\n * A level with no default export contributes no DOM and passes children\n * through — that is `layout.tsx` omitting its default export to be a guard\n * with no DOM.\n */\nfunction buildPageElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n): ReactNode {\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle, \"page\"));\n}\n\n/**\n * The error path renders the DESIGNATED boundary in place of the level it\n * covers, still wrapped by every level rootward of it — a page-level throw\n * keeps its App and Layout chrome, whose data survived the settle rules\n * (P1 §4: fulfilled sibling data stays in the bundle).\n *\n * `record` is explicit rather than read from `bundle.error` — a render-time\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\n */\nfunction buildBoundaryElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n record: PageErrorRecord,\n): ReactNode {\n const { boundary, error } = record;\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\n | ((props: { error: unknown }) => ReactNode)\n | undefined;\n\n const element = Boundary\n ? createElement(Boundary, { error })\n : createElement(FrameworkRootBoundary, {});\n\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\n\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\n // unwrapped when the boundary covers the app level itself — but the\n // pipeline always emits a complete document, so the\n // framework default supplies the shell here even though the app's own\n // (broken) root is what's being bypassed.\n return boundary.boundaryLevel === \"app\"\n ? createElement(DefaultApp, { children: wrapped })\n : wrapped;\n}\n\nfunction buildLeaf(\n module: PageTripleModule,\n bundle: PageDataBundle,\n level: PageLevelName,\n): ReactNode {\n const Component = module.default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) return null;\n\n return createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n });\n}\n\nfunction wrapRootward(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n from: PageLevelName,\n leaf: ReactNode,\n): ReactNode {\n const wrappers: PageLevelName[] = from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\n\n let element = leaf;\n\n for (const level of wrappers) {\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) {\n // \"App\" is the root: no App export means no custom document, but the\n // pipeline always emits a complete one — the\n // framework default App supplies it. Layout has no such fallback: an\n // omitted layout default export stays a no-DOM passthrough,\n // unchanged from before.\n if (level === \"app\") {\n element = createElement(DefaultApp, { children: element });\n }\n\n continue;\n }\n\n element = createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n children: element,\n });\n }\n\n return element;\n}\n\n// ---------------------------------------------------------------------------\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\n// ---------------------------------------------------------------------------\n\n/**\n * The root (App or the framework default) now ALWAYS renders a complete\n * `<html>…</html>` document itself — `<Head/>`/\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\n * around the element in `finishRender`, below) and emit real elements.\n * There is nothing left for this stage to assemble by string surgery; it\n * only prepends the doctype `renderToString` never includes.\n */\nfunction emitDocument(body: string): string {\n return \"<!DOCTYPE html>\" + body;\n}\n\n// ---------------------------------------------------------------------------\n// The shared tail (stages 9–10) — both orchestrators end here\n// ---------------------------------------------------------------------------\n\n/**\n * The real request/response pair `capturingCreateHttp` captured for this\n * call. `finishRender` itself no longer takes this (D1: the short-circuit\n * status now lives on `bundle.shortCircuit`/`bundle.commit`) — it is used at\n * the two orchestrator call sites for the `as` impersonation write\n * (`state.captured.request.user = as`, below) and to read the document\n * slots (`documentSlotsFrom`, below).\n */\ntype CapturedHttp = {\n request: PipelineRequest;\n response: PipelineResponse;\n};\n\n/**\n * Wrap the caller's createHttp to capture the real pair (for the document\n * slots, `documentSlotsFrom` below), the matched entry (the only place a\n * URL-based caller learns which triple to render), and to apply `as` —\n * `user` is a plain public property on core's Request\n * (core/src/http/request.ts:92), exactly the write auth middleware performs.\n */\nfunction capturingCreateHttp(\n registry: PageRoutesRegistry,\n as: unknown,\n): {\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n} {\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\n\n return {\n state,\n createHttp(match) {\n state.match = match;\n state.captured = registry.createHttp(match);\n\n if (as !== undefined) state.captured.request.user = as;\n\n return state.captured;\n },\n };\n}\n\n/**\n * The two request-derived document slots (`nonce`/`lang` on\n * `DocumentContextValue`), extracted at the orchestrator call sites\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\n * here: core's Request has no dir-like field (checked\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\n * exist) — an app supplies `dir` via its own convention.\n */\ntype DocumentSlots = {\n nonce?: string;\n lang?: string;\n};\n\n/**\n * Structural read of the two core `Request` fields the slots need. Neither\n * `nonce` nor `locale` is declared on `PipelineRequest`/`WebRequest` (the\n * loader-facing facade only declares `validated`/`input`/`user`,\n * context.ts:30-68) — a narrow typed intersection at the one call site that\n * needs it, the same pattern `execute-page-request.ts` uses for its own\n * `(response as PipelineResponse & { statusCode?: number })` read\n * (execute-page-request.ts:517).\n */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n const request = captured?.request as (PipelineRequest & { nonce?: string; locale?: string }) | undefined;\n\n return { nonce: request?.nonce, lang: request?.locale };\n}\n\nasync function finishRender(\n triple: PageRouteEntry[\"triple\"],\n bundle: PageDataBundle,\n documentSlots: DocumentSlots,\n): Promise<RenderedPage> {\n const headers: Record<string, string> = {};\n\n for (const header of bundle.commit?.headers ?? []) {\n headers[header.key.toLowerCase()] = header.value;\n }\n\n // The commit already deduplicated per name and ordered root→leaf\n // (execute-page-request.ts settle/commit) — pass it through untransformed so\n // every attribute survives to the caller's Set-Cookie.\n const cookies: BufferedCookie[] = bundle.commit?.cookies ?? [];\n\n // Short-circuit paths emit no document: the status IS the answer\n // (redirect/notFound/guard/422 — P1 §4), nothing renders to describe.\n if (bundle.shortCircuit) {\n const status =\n bundle.shortCircuit.stage === \"validation\"\n ? bundle.shortCircuit.status\n : bundle.shortCircuit.stage === \"loaders\"\n ? bundle.shortCircuit.statusCode\n // D1: the middleware variant now carries its own statusCode,\n // captured at stage 3 where the pipeline legitimately touches the\n // live response (execute-page-request.ts:514-518) — no live read here.\n : (bundle.shortCircuit.statusCode ?? 200);\n\n return { html: \"\", status, headers, cookies, data: bundle.pageData, bundle };\n }\n\n // The framework's closed-by-default answer (README rule 8): every document\n // is `Cache-Control: private` unless a loader's committed headers already\n // answered for the key. Map-only — `finishRender` never writes the live\n // response; the caller applies the returned headers.\n if (headers[\"cache-control\"] === undefined) {\n headers[\"cache-control\"] = \"private\";\n }\n\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\n // Lazy import: react-dom is a peer used only on this path, so merely\n // loading the server barrel never requires it.\n const { renderToString } = await import(\"react-dom/server\");\n\n // JSON.stringify omits object properties whose value is undefined. Loader\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\n // both already final by this point (stages 1-8 are done), so there is\n // nothing left for the root to await.\n //\n // The payload comes from `buildHydrationPayload` rather than being assembled\n // here, so that this document and the `_loader` route hand the browser the\n // SAME object. See that module for why the two must not drift.\n const documentValue: DocumentContextValue = {\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n nonce: documentSlots.nonce,\n lang: documentSlots.lang,\n };\n\n const renderWithContext = (element: ReactNode): string =>\n renderToString(createElement(DocumentContext.Provider, { value: documentValue, children: element }));\n\n // A boundary that throws while rendering escalates to\n // the next enclosing boundary rootward; if none survives, the framework's\n // last-resort terminal renders. `currentError` starts as whatever stage\n // 1-8 already designated (`bundle.error`, undefined for a normal page\n // render) and is replaced by each escalation — `bundle.error` itself is\n // never mutated, staying a truthful stage 1-8 record.\n let currentError = bundle.error;\n let renderTimeThrow = false;\n let body: string;\n\n for (;;) {\n try {\n const element = currentError\n ? buildBoundaryElement(triple, bundle, currentError)\n : buildPageElement(triple, bundle);\n\n body = renderWithContext(element);\n break;\n } catch (thrown) {\n renderTimeThrow = true;\n\n if (currentError?.boundary.boundaryLevel === \"app\") {\n // The floor: the app-level boundary's own render just threw, so\n // there is nothing rootward of `app` to escalate to (§2's \"none\n // survives\"). Render the framework's trivial boundary directly —\n // bypassing the app's ErrorBoundary/App component, since that is\n // what just failed — wrapped in DefaultApp so the response is still\n // a complete `<html>` document (default-app.tsx:22-46) rather than\n // a bare `<main>` fragment.\n body = renderWithContext(createElement(DefaultApp, { children: createElement(FrameworkRootBoundary, {}) }));\n break;\n }\n\n // Escalate from the level rootward of whatever just threw — searching\n // from the SAME level would re-select the boundary that just failed.\n // A throw not yet attributable to a level (a normal page render, no\n // prior designation) starts the search at `page`.\n const throwingLevel: PageLevelName =\n currentError?.boundary.boundaryLevel === \"layout\" ? \"app\" : currentError ? \"layout\" : \"page\";\n\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\n }\n }\n\n // Status is chosen after render — the last thing that can change the\n // outcome — and RETURNED, never applied: `finishRender` writes the live\n // response zero times (and now zero live response READS either — D1 deleted the last one). The caller applies\n // status + headers at one site and flushes immediately after (stage\n // 10a/10b). A render-time throw follows the same status rule as a\n // pre-render one: a NESTED boundary catch\n // (page/layout) keeps the committed status; the app boundary or the\n // framework terminal forces 500. Without a render-time throw, the\n // pre-render designation already carries this rule — P1's commit forces\n // 500 only when it designated `app` (execute-page-request.ts:625-637).\n const status = renderTimeThrow\n ? currentError!.boundary.boundaryLevel === \"app\"\n ? 500\n : (bundle.commit?.statusCode ?? 200)\n : bundle.error\n ? (bundle.commit?.statusCode ?? 500)\n : (bundle.commit?.statusCode ?? 200);\n\n const html = emitDocument(body);\n\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\n}\n\n// ---------------------------------------------------------------------------\n// The orchestrators\n// ---------------------------------------------------------------------------\n\nexport async function renderPage(\n routeName: string,\n options: RenderPageOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const entry = registry.routes.find(candidate => candidate.name === routeName);\n\n if (!entry) {\n const known = registry.routes.map(candidate => `\"${candidate.name}\"`).join(\", \");\n\n throw new Error(\n `renderPage(\"${routeName}\"): no route with that name ` +\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\n \"Fix: use a name from the manifest, or connect the manifest that \" +\n \"declares this one.\",\n );\n }\n\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n throw new Error(\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\n );\n }\n\n return rendered;\n}\n\n/**\n * The URL-based sibling of `renderPage` — the production render surface: a\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\n * tail renders and emits.\n *\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\n * `{ html: \"\", status: 404 }` with an undefined `bundle` (see RenderedPage).\n */\nexport async function renderPageRequest(\n url: string,\n options: RenderPageRequestOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(state.match!.entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n return { html: \"\", status: 404, headers: {}, cookies: [], data: undefined, bundle: undefined };\n }\n\n // executePageRequest only produces a bundle after createHttp ran for the\n // match, so the captured entry is present whenever the bundle is.\n return rendered;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"render-page.mjs","names":[],"sources":["../../../../../../../web/src/server/render-page.ts"],"sourcesContent":["import { createElement, type ComponentType, type ReactNode } from \"react\";\nimport DefaultApp from \"../components/default-app\";\nimport {\n DocumentContext,\n escapePayload,\n PAYLOAD_SCRIPT_ID,\n type DocumentContextValue,\n} from \"../components/document-context\";\nimport type { SharedContext } from \"../index\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport type { BufferedCookie } from \"./buffered-response\";\nimport {\n buildErrorRecord,\n designateBoundary,\n executePageRequest,\n type ExecutePageRequestOptions,\n type PageDataBundle,\n type PageErrorRecord,\n type PageLevelName,\n type PageRouteEntry,\n type PageRouteMatch,\n type PageTripleModule,\n type PipelineRequest,\n type PipelineResponse,\n} from \"./execute-page-request\";\n\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\n\n/**\n * Pipeline stages 9–10: RENDER the page tree from the\n * data bundle stages 1–8 produced, then return finalized { html, status,\n * headers, cookies }. Stage 10 happens at the CALL SITE in two halves —\n * 10a the caller applies status + headers (the single live-response write,\n * after render, before anything flushes), 10b it flushes\n * the document. Nothing in this module writes the live response. It never\n * re-runs any earlier stage — `renderPage` calls `executePageRequest` and\n * everything here consumes its bundle as-is.\n *\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\n * the production orchestrator AND the test helper. Because a loader IS a\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\n * returns `{ html, status, headers, data }` in one call — asserting a page's\n * data and its response headers is a unit test, no browser, no server boot.\n */\n\n// ---------------------------------------------------------------------------\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\n// ---------------------------------------------------------------------------\n\nexport type PageRoutesRegistry = {\n routes: readonly PageRouteEntry[];\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n};\n\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\n\n/**\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\n * without each call site carrying the manifest. Returns the previous registry\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\n */\nexport function connectPageRoutes(\n registry: PageRoutesRegistry | undefined,\n): PageRoutesRegistry | undefined {\n const previous = pageRoutesRegistry;\n pageRoutesRegistry = registry;\n return previous;\n}\n\n// ---------------------------------------------------------------------------\n// renderPage surface\n// ---------------------------------------------------------------------------\n\nexport type RenderPageOptions = {\n params?: Record<string, string>;\n query?: Record<string, string>;\n /**\n * Impersonation for tests: assigned to `request.user` right after the\n * request pair is constructed — `user` is a plain public property on core's\n * Request (core/src/http/request.ts:92) and this is exactly the write auth\n * middleware would have performed.\n */\n as?: unknown;\n /** Per-call overrides of the connected registry (tests, mostly). */\n routes?: readonly PageRouteEntry[];\n createHttp?: ExecutePageRequestOptions[\"createHttp\"];\n};\n\n/**\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\n * name-based sugar buildUrl consumes) have no meaning here — everything else\n * is the same seam.\n */\nexport type RenderPageRequestOptions = Omit<RenderPageOptions, \"params\" | \"query\">;\n\nexport type RenderedPage = {\n /** The full document (\"\" when the pipeline short-circuited before render). */\n html: string;\n status: number;\n /** Committed response headers, lowercased key → value. */\n headers: Record<string, string>;\n /**\n * Committed cookies in commit order, attribute-faithful: each entry carries\n * the loader's raw value (pre-serialization) AND its options\n * (`httpOnly`/`secure`/`sameSite`/`path`/`expires`/…). Never flattened to a\n * name→value map — a map cannot express the attributes, and a Set-Cookie\n * built without them is a security defect, not a convenience.\n */\n cookies: BufferedCookie[];\n /**\n * The PAGE loader's data — `data.product.name` reads as the dx story\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\n */\n data: unknown;\n /**\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\n * `renderPage` always carries one (its no-match throws instead).\n */\n bundle: PageDataBundle | undefined;\n};\n\nfunction requireRegistry(\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\n): PageRoutesRegistry {\n const routes = options.routes ?? pageRoutesRegistry?.routes;\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\n\n if (!routes || !createHttp) {\n throw new Error(\n \"renderPage()/renderPageRequest() has no route registry connected \" +\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\n \"manifest, which the server bootstrap owns. Fix: \" +\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\n \"beforeAll), or pass { routes, createHttp } to this call.\",\n );\n }\n\n return { routes, createHttp };\n}\n\nfunction buildUrl(\n entry: PageRouteEntry,\n params: Record<string, string>,\n query: Record<string, string>,\n): string {\n const path = entry.path\n .split(\"/\")\n .map(segment => {\n if (!segment.startsWith(\":\")) return segment;\n\n const name = segment.slice(1);\n const value = params[name];\n\n if (value === undefined) {\n throw new Error(\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\n `param \"${name}\" and the call did not provide it ` +\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\n `\\`params: { ${name}: … }\\`.`,\n );\n }\n\n return encodeURIComponent(value);\n })\n .join(\"/\");\n\n const queryString = new URLSearchParams(query).toString();\n\n return queryString ? `${path}?${queryString}` : path;\n}\n\n// ---------------------------------------------------------------------------\n// Stage 9 — RENDER\n// ---------------------------------------------------------------------------\n\n/**\n * The framework-owned terminal boundary (P1 §4: designation falls back to\n * `app` even when no level exports one — \"the framework owns a root\n * boundary\"). Deliberately generic: the error itself is server knowledge and\n * never serialized into the document.\n */\nfunction FrameworkRootBoundary(): ReactNode {\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\n}\n\ntype LevelProps = {\n data: unknown;\n shared: Readonly<SharedContext> | undefined;\n children?: ReactNode;\n};\n\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n};\n\n/**\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\n * level receiving ITS OWN loader data and the same sealed `shared` — the\n * exact props the M1 contract declares (web/src/props.ts) and never\n * request/response (the component also renders on a machine where neither\n * exists, props.ts:19-22).\n *\n * A level with no default export contributes no DOM and passes children\n * through — that is `layout.tsx` omitting its default export to be a guard\n * with no DOM.\n */\nfunction buildPageElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n): ReactNode {\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle, \"page\"));\n}\n\n/**\n * The error path renders the DESIGNATED boundary in place of the level it\n * covers, still wrapped by every level rootward of it — a page-level throw\n * keeps its App and Layout chrome, whose data survived the settle rules\n * (P1 §4: fulfilled sibling data stays in the bundle).\n *\n * `record` is explicit rather than read from `bundle.error` — a render-time\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\n */\nfunction buildBoundaryElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n record: PageErrorRecord,\n): ReactNode {\n const { boundary, error } = record;\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\n | ((props: { error: unknown }) => ReactNode)\n | undefined;\n\n const element = Boundary\n ? createElement(Boundary, { error })\n : createElement(FrameworkRootBoundary, {});\n\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\n\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\n // unwrapped when the boundary covers the app level itself — but the\n // pipeline always emits a complete document, so the\n // framework default supplies the shell here even though the app's own\n // (broken) root is what's being bypassed.\n return boundary.boundaryLevel === \"app\"\n ? createElement(DefaultApp, { children: wrapped })\n : wrapped;\n}\n\nfunction buildLeaf(\n module: PageTripleModule,\n bundle: PageDataBundle,\n level: PageLevelName,\n): ReactNode {\n const Component = module.default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) return null;\n\n return createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n });\n}\n\nfunction wrapRootward(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n from: PageLevelName,\n leaf: ReactNode,\n): ReactNode {\n const wrappers: PageLevelName[] = from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\n\n let element = leaf;\n\n for (const level of wrappers) {\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) {\n // \"App\" is the root: no App export means no custom document, but the\n // pipeline always emits a complete one — the\n // framework default App supplies it. Layout has no such fallback: an\n // omitted layout default export stays a no-DOM passthrough,\n // unchanged from before.\n if (level === \"app\") {\n element = createElement(DefaultApp, { children: element });\n }\n\n continue;\n }\n\n element = createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n children: element,\n });\n }\n\n return element;\n}\n\n// ---------------------------------------------------------------------------\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\n// ---------------------------------------------------------------------------\n\n/**\n * The root (App or the framework default) now ALWAYS renders a complete\n * `<html>…</html>` document itself — `<Head/>`/\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\n * around the element in `finishRender`, below) and emit real elements.\n * There is nothing left for this stage to assemble by string surgery; it\n * only prepends the doctype `renderToString` never includes.\n */\nfunction emitDocument(body: string): string {\n return \"<!DOCTYPE html>\" + body;\n}\n\n// ---------------------------------------------------------------------------\n// The shared tail (stages 9–10) — both orchestrators end here\n// ---------------------------------------------------------------------------\n\n/**\n * The real request/response pair `capturingCreateHttp` captured for this\n * call. `finishRender` itself no longer takes this (D1: the short-circuit\n * status now lives on `bundle.shortCircuit`/`bundle.commit`) — it is used at\n * the two orchestrator call sites for the `as` impersonation write\n * (`state.captured.request.user = as`, below) and to read the document\n * slots (`documentSlotsFrom`, below).\n */\ntype CapturedHttp = {\n request: PipelineRequest;\n response: PipelineResponse;\n};\n\n/**\n * Wrap the caller's createHttp to capture the real pair (for the document\n * slots, `documentSlotsFrom` below), the matched entry (the only place a\n * URL-based caller learns which triple to render), and to apply `as` —\n * `user` is a plain public property on core's Request\n * (core/src/http/request.ts:92), exactly the write auth middleware performs.\n */\nfunction capturingCreateHttp(\n registry: PageRoutesRegistry,\n as: unknown,\n): {\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n} {\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\n\n return {\n state,\n createHttp(match) {\n state.match = match;\n state.captured = registry.createHttp(match);\n\n if (as !== undefined) state.captured.request.user = as;\n\n return state.captured;\n },\n };\n}\n\n/**\n * The two request-derived document slots (`nonce`/`lang` on\n * `DocumentContextValue`), extracted at the orchestrator call sites\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\n * here: core's Request has no dir-like field (checked\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\n * exist) — an app supplies `dir` via its own convention.\n */\ntype DocumentSlots = {\n nonce?: string;\n lang?: string;\n};\n\n/**\n * Structural read of the two core `Request` fields the slots need. Neither\n * `nonce` nor `locale` is declared on `PipelineRequest`/`WebRequest` (the\n * loader-facing facade only declares `validated`/`input`/`user`,\n * context.ts:30-68) — a narrow typed intersection at the one call site that\n * needs it, the same pattern `execute-page-request.ts` uses for its own\n * `(response as PipelineResponse & { statusCode?: number })` read\n * (execute-page-request.ts:517).\n */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n const request = captured?.request as (PipelineRequest & { nonce?: string; locale?: string }) | undefined;\n\n return { nonce: request?.nonce, lang: request?.locale };\n}\n\nasync function finishRender(\n triple: PageRouteEntry[\"triple\"],\n bundle: PageDataBundle,\n documentSlots: DocumentSlots,\n): Promise<RenderedPage> {\n const headers: Record<string, string> = {};\n\n for (const header of bundle.commit?.headers ?? []) {\n headers[header.key.toLowerCase()] = header.value;\n }\n\n // The commit already deduplicated per name and ordered root→leaf\n // (execute-page-request.ts settle/commit) — pass it through untransformed so\n // every attribute survives to the caller's Set-Cookie.\n const cookies: BufferedCookie[] = bundle.commit?.cookies ?? [];\n\n // Short-circuit paths emit no document: the status IS the answer\n // (redirect/notFound/guard/422 — P1 §4), nothing renders to describe.\n if (bundle.shortCircuit) {\n const status =\n bundle.shortCircuit.stage === \"validation\"\n ? bundle.shortCircuit.status\n : bundle.shortCircuit.stage === \"loaders\"\n ? bundle.shortCircuit.statusCode\n // D1: the middleware variant now carries its own statusCode,\n // captured at stage 3 where the pipeline legitimately touches the\n // live response (execute-page-request.ts:514-518) — no live read here.\n : (bundle.shortCircuit.statusCode ?? 200);\n\n return { html: \"\", status, headers, cookies, data: bundle.pageData, bundle };\n }\n\n // The framework's closed-by-default answer (README rule 8): every document\n // is `Cache-Control: private` unless a loader's committed headers already\n // answered for the key. Map-only — `finishRender` never writes the live\n // response; the caller applies the returned headers.\n if (headers[\"cache-control\"] === undefined) {\n headers[\"cache-control\"] = \"private\";\n }\n\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\n // Lazy import: react-dom is a peer used only on this path, so merely\n // loading the server barrel never requires it.\n const { renderToString } = await import(\"react-dom/server\");\n\n // JSON.stringify omits object properties whose value is undefined. Loader\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\n // both already final by this point (stages 1-8 are done), so there is\n // nothing left for the root to await.\n //\n // The payload comes from `buildHydrationPayload` rather than being assembled\n // here, so that this document and the `_loader` route hand the browser the\n // SAME object. See that module for why the two must not drift.\n const documentValue: DocumentContextValue = {\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n nonce: documentSlots.nonce,\n lang: documentSlots.lang,\n };\n\n const renderWithContext = (element: ReactNode): string =>\n renderToString(createElement(DocumentContext.Provider, { value: documentValue, children: element }));\n\n // A boundary that throws while rendering escalates to\n // the next enclosing boundary rootward; if none survives, the framework's\n // last-resort terminal renders. `currentError` starts as whatever stage\n // 1-8 already designated (`bundle.error`, undefined for a normal page\n // render) and is replaced by each escalation — `bundle.error` itself is\n // never mutated, staying a truthful stage 1-8 record.\n let currentError = bundle.error;\n let renderTimeThrow = false;\n let body: string;\n\n for (;;) {\n try {\n const element = currentError\n ? buildBoundaryElement(triple, bundle, currentError)\n : buildPageElement(triple, bundle);\n\n body = renderWithContext(element);\n break;\n } catch (thrown) {\n renderTimeThrow = true;\n\n if (currentError?.boundary.boundaryLevel === \"app\") {\n // The floor: the app-level boundary's own render just threw, so\n // there is nothing rootward of `app` to escalate to (§2's \"none\n // survives\"). Render the framework's trivial boundary directly —\n // bypassing the app's ErrorBoundary/App component, since that is\n // what just failed — wrapped in DefaultApp so the response is still\n // a complete `<html>` document (default-app.tsx:22-46) rather than\n // a bare `<main>` fragment.\n body = renderWithContext(createElement(DefaultApp, { children: createElement(FrameworkRootBoundary, {}) }));\n break;\n }\n\n // Escalate from the level rootward of whatever just threw — searching\n // from the SAME level would re-select the boundary that just failed.\n // A throw not yet attributable to a level (a normal page render, no\n // prior designation) starts the search at `page`.\n const throwingLevel: PageLevelName =\n currentError?.boundary.boundaryLevel === \"layout\" ? \"app\" : currentError ? \"layout\" : \"page\";\n\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\n }\n }\n\n // Status is chosen after render — the last thing that can change the\n // outcome — and RETURNED, never applied: `finishRender` writes the live\n // response zero times (and now zero live response READS either — D1 deleted the last one). The caller applies\n // status + headers at one site and flushes immediately after (stage\n // 10a/10b). A render-time throw follows the same status rule as a\n // pre-render one: a NESTED boundary catch\n // (page/layout) keeps the committed status; the app boundary or the\n // framework terminal forces 500. Without a render-time throw, the\n // pre-render designation already carries this rule — P1's commit forces\n // 500 only when it designated `app` (execute-page-request.ts:625-637).\n const status = renderTimeThrow\n ? currentError!.boundary.boundaryLevel === \"app\"\n ? 500\n : (bundle.commit?.statusCode ?? 200)\n : bundle.error\n ? (bundle.commit?.statusCode ?? 500)\n : (bundle.commit?.statusCode ?? 200);\n\n const html = emitDocument(body);\n\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\n}\n\n// ---------------------------------------------------------------------------\n// The orchestrators\n// ---------------------------------------------------------------------------\n\nexport async function renderPage(\n routeName: string,\n options: RenderPageOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const entry = registry.routes.find(candidate => candidate.name === routeName);\n\n if (!entry) {\n const known = registry.routes.map(candidate => `\"${candidate.name}\"`).join(\", \");\n\n throw new Error(\n `renderPage(\"${routeName}\"): no route with that name ` +\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\n \"Fix: use a name from the manifest, or connect the manifest that \" +\n \"declares this one.\",\n );\n }\n\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n throw new Error(\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\n );\n }\n\n return rendered;\n}\n\n/**\n * The URL-based sibling of `renderPage` — the production render surface: a\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\n * tail renders and emits.\n *\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\n * `{ html: \"\", status: 404 }` with an undefined `bundle` (see RenderedPage).\n */\nexport async function renderPageRequest(\n url: string,\n options: RenderPageRequestOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(state.match!.entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n return { html: \"\", status: 404, headers: {}, cookies: [], data: undefined, bundle: undefined };\n }\n\n // executePageRequest only produces a bundle after createHttp ran for the\n // match, so the captured entry is present whenever the bundle is.\n return rendered;\n}\n"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ //#region ../web/src/server/stylesheet-urls.d.ts
2
+ /**
3
+ * The stylesheets the ROOT document imports, as dev URLs.
4
+ *
5
+ * Dev has no manifest, so the source is the root file itself: whatever
6
+ * `root.tsx` imports with a stylesheet extension is what the document needs.
7
+ * That is deliberately narrow — it answers "what CSS does this application
8
+ * set up globally", which is where `app.css` lives and where Tailwind is
9
+ * wired, and it does NOT try to reproduce Vite's per-route CSS graph.
10
+ *
11
+ * The narrowness is the honest part: production splits CSS per chunk, dev
12
+ * links the root's stylesheets on every page. A page whose own module imports
13
+ * its own stylesheet still gets it in dev — Vite's client graph injects it as
14
+ * before — it simply is not render-blocking the way the root's is. That is a
15
+ * smaller gap than the flash this removes, and it is stated rather than
16
+ * hidden.
17
+ *
18
+ * Specifiers are resolved against the root file and expressed relative to the
19
+ * app root, because that is the shape Vite's dev server serves from.
20
+ */
21
+ declare function devStylesheetUrls(appRoot: string, appFile: string): string[];
22
+ /**
23
+ * A stylesheet Vite serves in DEV must be requested with `?direct`.
24
+ *
25
+ * Without it Vite answers the same URL with `text/javascript` — its CSS-as-JS
26
+ * module transform, meant for `import "./app.css"` — and a
27
+ * `<link rel="stylesheet">` pointing at a JavaScript response applies
28
+ * NOTHING, silently. No console error, no network failure, just an unstyled
29
+ * page. `?direct` is what makes Vite reply with real `text/css`.
30
+ */
31
+ declare const VITE_DIRECT_CSS_QUERY = "?direct";
32
+ /**
33
+ * Every stylesheet the client build emitted, as URLs the asset route serves.
34
+ *
35
+ * Vite records CSS against the CHUNK that imported it — an app whose
36
+ * `root.tsx` imports `app.css` produces a `root.tsx` entry carrying
37
+ * `css: ["assets/root-<hash>.css"]`, not a hydration entry carrying it. So
38
+ * this collects across every entry rather than looking under one name, which
39
+ * would silently find nothing the moment a stylesheet moved file.
40
+ *
41
+ * Duplicates are collapsed and order is preserved: two chunks importing the
42
+ * same stylesheet must not emit two `<link>` tags.
43
+ *
44
+ * A missing or malformed manifest returns NOTHING rather than throwing. The
45
+ * hydration resolver already fails loudly on exactly those conditions, from
46
+ * exactly the same file, and it runs first — a second, worse error for the
47
+ * same cause helps nobody.
48
+ */
49
+ declare function productionStylesheetUrls(clientDir: string): string[];
50
+ //#endregion
51
+ export { VITE_DIRECT_CSS_QUERY, devStylesheetUrls, productionStylesheetUrls };
52
+ //# sourceMappingURL=stylesheet-urls.d.mts.map
@@ -1,6 +1,6 @@
1
1
  import { CLIENT_ASSET_URL_PREFIX } from "./client-asset-url-prefix.mjs";
2
- import { readFileSync } from "node:fs";
3
2
  import path from "node:path";
3
+ import { readFileSync } from "node:fs";
4
4
 
5
5
  //#region ../web/src/server/stylesheet-urls.ts
6
6
  /**
@@ -21,6 +21,68 @@ import path from "node:path";
21
21
  * - DEV has no manifest — Vite serves modules on demand — so the URLs are
22
22
  * derived from the source files themselves.
23
23
  */
24
+ /** Stylesheet extensions Vite can serve directly. Mirrors the build's list. */
25
+ const STYLE_EXTENSIONS = [
26
+ ".css",
27
+ ".scss",
28
+ ".sass",
29
+ ".less",
30
+ ".styl"
31
+ ];
32
+ /**
33
+ * The stylesheets the ROOT document imports, as dev URLs.
34
+ *
35
+ * Dev has no manifest, so the source is the root file itself: whatever
36
+ * `root.tsx` imports with a stylesheet extension is what the document needs.
37
+ * That is deliberately narrow — it answers "what CSS does this application
38
+ * set up globally", which is where `app.css` lives and where Tailwind is
39
+ * wired, and it does NOT try to reproduce Vite's per-route CSS graph.
40
+ *
41
+ * The narrowness is the honest part: production splits CSS per chunk, dev
42
+ * links the root's stylesheets on every page. A page whose own module imports
43
+ * its own stylesheet still gets it in dev — Vite's client graph injects it as
44
+ * before — it simply is not render-blocking the way the root's is. That is a
45
+ * smaller gap than the flash this removes, and it is stated rather than
46
+ * hidden.
47
+ *
48
+ * Specifiers are resolved against the root file and expressed relative to the
49
+ * app root, because that is the shape Vite's dev server serves from.
50
+ */
51
+ function devStylesheetUrls(appRoot, appFile) {
52
+ let source;
53
+ try {
54
+ source = readFileSync(appFile, "utf-8");
55
+ } catch {
56
+ return [];
57
+ }
58
+ const urls = [];
59
+ const pattern = /\bimport\s*["']([^"']+)["']/g;
60
+ let match = pattern.exec(source);
61
+ while (match !== null) {
62
+ const specifier = match[1];
63
+ const lowered = specifier.toLowerCase();
64
+ if (STYLE_EXTENSIONS.some((extension) => lowered.endsWith(extension))) {
65
+ const absolute = path.resolve(path.dirname(appFile), specifier);
66
+ const relative = path.relative(appRoot, absolute).split(path.sep).join("/");
67
+ if (!relative.startsWith("..")) {
68
+ const url = `/${relative}${VITE_DIRECT_CSS_QUERY}`;
69
+ if (!urls.includes(url)) urls.push(url);
70
+ }
71
+ }
72
+ match = pattern.exec(source);
73
+ }
74
+ return urls;
75
+ }
76
+ /**
77
+ * A stylesheet Vite serves in DEV must be requested with `?direct`.
78
+ *
79
+ * Without it Vite answers the same URL with `text/javascript` — its CSS-as-JS
80
+ * module transform, meant for `import "./app.css"` — and a
81
+ * `<link rel="stylesheet">` pointing at a JavaScript response applies
82
+ * NOTHING, silently. No console error, no network failure, just an unstyled
83
+ * page. `?direct` is what makes Vite reply with real `text/css`.
84
+ */
85
+ const VITE_DIRECT_CSS_QUERY = "?direct";
24
86
  /**
25
87
  * Every stylesheet the client build emitted, as URLs the asset route serves.
26
88
  *
@@ -61,5 +123,5 @@ function productionStylesheetUrls(clientDir) {
61
123
  }
62
124
 
63
125
  //#endregion
64
- export { productionStylesheetUrls };
126
+ export { VITE_DIRECT_CSS_QUERY, devStylesheetUrls, productionStylesheetUrls };
65
127
  //# sourceMappingURL=stylesheet-urls.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"stylesheet-urls.mjs","names":[],"sources":["../../../../../../../web/src/server/stylesheet-urls.ts"],"sourcesContent":["/**\n * Which stylesheets a document must link, in each of the two modes.\n *\n * WHY THIS EXISTS AT ALL. Nothing used to put CSS into the server-rendered\n * document. A stylesheet reached the browser only because the CLIENT bundle\n * imported it, which means JavaScript applied it after the module graph\n * loaded — so every full page load painted unstyled first and restyled a\n * moment later. The markup was correct the whole time, which is precisely why\n * it was easy to miss.\n *\n * The two modes learn the answer from different places, and neither can use\n * the other's:\n *\n * - PRODUCTION reads Vite's client manifest, the same artifact the hydration\n * entry is already resolved from.\n * - DEV has no manifest — Vite serves modules on demand — so the URLs are\n * derived from the source files themselves.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\n\n/** Stylesheet extensions Vite can serve directly. Mirrors the build's list. */\nconst STYLE_EXTENSIONS = [\".css\", \".scss\", \".sass\", \".less\", \".styl\"];\n\n/**\n * The stylesheets the ROOT document imports, as dev URLs.\n *\n * Dev has no manifest, so the source is the root file itself: whatever\n * `root.tsx` imports with a stylesheet extension is what the document needs.\n * That is deliberately narrow — it answers \"what CSS does this application\n * set up globally\", which is where `app.css` lives and where Tailwind is\n * wired, and it does NOT try to reproduce Vite's per-route CSS graph.\n *\n * The narrowness is the honest part: production splits CSS per chunk, dev\n * links the root's stylesheets on every page. A page whose own module imports\n * its own stylesheet still gets it in dev — Vite's client graph injects it as\n * before — it simply is not render-blocking the way the root's is. That is a\n * smaller gap than the flash this removes, and it is stated rather than\n * hidden.\n *\n * Specifiers are resolved against the root file and expressed relative to the\n * app root, because that is the shape Vite's dev server serves from.\n */\nexport function devStylesheetUrls(appRoot: string, appFile: string): string[] {\n let source: string;\n\n try {\n source = readFileSync(appFile, \"utf-8\");\n } catch {\n return [];\n }\n\n const urls: string[] = [];\n const pattern = /\\bimport\\s*[\"']([^\"']+)[\"']/g;\n\n let match = pattern.exec(source);\n\n while (match !== null) {\n const specifier = match[1];\n const lowered = specifier.toLowerCase();\n\n if (STYLE_EXTENSIONS.some((extension) => lowered.endsWith(extension))) {\n const absolute = path.resolve(path.dirname(appFile), specifier);\n const relative = path.relative(appRoot, absolute).split(path.sep).join(\"/\");\n\n // Outside the app root Vite would need an `/@fs/` URL and a widened\n // `fs.allow`; a stylesheet living there is unusual enough that guessing\n // is worse than leaving it to the client import.\n if (!relative.startsWith(\"..\")) {\n const url = `/${relative}${VITE_DIRECT_CSS_QUERY}`;\n\n if (!urls.includes(url)) urls.push(url);\n }\n }\n\n match = pattern.exec(source);\n }\n\n return urls;\n}\n\n/**\n * A stylesheet Vite serves in DEV must be requested with `?direct`.\n *\n * Without it Vite answers the same URL with `text/javascript` — its CSS-as-JS\n * module transform, meant for `import \"./app.css\"` — and a\n * `<link rel=\"stylesheet\">` pointing at a JavaScript response applies\n * NOTHING, silently. No console error, no network failure, just an unstyled\n * page. `?direct` is what makes Vite reply with real `text/css`.\n */\nexport const VITE_DIRECT_CSS_QUERY = \"?direct\";\n\ntype ManifestEntry = {\n css?: unknown;\n file?: unknown;\n};\n\n/**\n * Every stylesheet the client build emitted, as URLs the asset route serves.\n *\n * Vite records CSS against the CHUNK that imported it — an app whose\n * `root.tsx` imports `app.css` produces a `root.tsx` entry carrying\n * `css: [\"assets/root-<hash>.css\"]`, not a hydration entry carrying it. So\n * this collects across every entry rather than looking under one name, which\n * would silently find nothing the moment a stylesheet moved file.\n *\n * Duplicates are collapsed and order is preserved: two chunks importing the\n * same stylesheet must not emit two `<link>` tags.\n *\n * A missing or malformed manifest returns NOTHING rather than throwing. The\n * hydration resolver already fails loudly on exactly those conditions, from\n * exactly the same file, and it runs first — a second, worse error for the\n * same cause helps nobody.\n */\nexport function productionStylesheetUrls(clientDir: string): string[] {\n const manifestPath = path.join(clientDir, \".vite\", \"manifest.json\");\n\n let manifest: Record<string, ManifestEntry | undefined>;\n\n try {\n manifest = JSON.parse(readFileSync(manifestPath, \"utf-8\")) as Record<\n string,\n ManifestEntry | undefined\n >;\n } catch {\n return [];\n }\n\n if (typeof manifest !== \"object\" || manifest === null) return [];\n\n const urls: string[] = [];\n\n for (const entry of Object.values(manifest)) {\n if (entry === undefined || !Array.isArray(entry.css)) continue;\n\n for (const file of entry.css) {\n if (typeof file !== \"string\" || file === \"\") continue;\n\n // Built EXACTLY as the hydration entry's URL is built — `/${file}`, then\n // checked against the prefix — rather than reassembled from a basename.\n // The manifest already records `assets/root-<hash>.css`, and rebuilding\n // that path here would be a second expression of a convention\n // `client-asset-url-prefix.ts` owns.\n const url = `/${file}`;\n\n // A stylesheet outside the directory the asset route mounts would 404.\n // Dropped rather than emitted, because a dead <link> in <head> is a\n // silent styling failure — the exact thing this module exists to end.\n if (!url.startsWith(`${CLIENT_ASSET_URL_PREFIX}/`)) continue;\n\n if (!urls.includes(url)) urls.push(url);\n }\n }\n\n return urls;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmHA,SAAgB,yBAAyB,WAA6B;CACpE,MAAM,eAAe,KAAK,KAAK,WAAW,SAAS,eAAe;CAElE,IAAI;CAEJ,IAAI;EACF,WAAW,KAAK,MAAM,aAAa,cAAc,OAAO,CAAC;CAI3D,QAAQ;EACN,OAAO,CAAC;CACV;CAEA,IAAI,OAAO,aAAa,YAAY,aAAa,MAAM,OAAO,CAAC;CAE/D,MAAM,OAAiB,CAAC;CAExB,KAAK,MAAM,SAAS,OAAO,OAAO,QAAQ,GAAG;EAC3C,IAAI,UAAU,UAAa,CAAC,MAAM,QAAQ,MAAM,GAAG,GAAG;EAEtD,KAAK,MAAM,QAAQ,MAAM,KAAK;GAC5B,IAAI,OAAO,SAAS,YAAY,SAAS,IAAI;GAO7C,MAAM,MAAM,IAAI;GAKhB,IAAI,CAAC,IAAI,WAAW,aAA2B,EAAE,GAAG;GAEpD,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;EACxC;CACF;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"stylesheet-urls.mjs","names":[],"sources":["../../../../../../../web/src/server/stylesheet-urls.ts"],"sourcesContent":["/**\n * Which stylesheets a document must link, in each of the two modes.\n *\n * WHY THIS EXISTS AT ALL. Nothing used to put CSS into the server-rendered\n * document. A stylesheet reached the browser only because the CLIENT bundle\n * imported it, which means JavaScript applied it after the module graph\n * loaded — so every full page load painted unstyled first and restyled a\n * moment later. The markup was correct the whole time, which is precisely why\n * it was easy to miss.\n *\n * The two modes learn the answer from different places, and neither can use\n * the other's:\n *\n * - PRODUCTION reads Vite's client manifest, the same artifact the hydration\n * entry is already resolved from.\n * - DEV has no manifest — Vite serves modules on demand — so the URLs are\n * derived from the source files themselves.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\n\n/** Stylesheet extensions Vite can serve directly. Mirrors the build's list. */\nconst STYLE_EXTENSIONS = [\".css\", \".scss\", \".sass\", \".less\", \".styl\"];\n\n/**\n * The stylesheets the ROOT document imports, as dev URLs.\n *\n * Dev has no manifest, so the source is the root file itself: whatever\n * `root.tsx` imports with a stylesheet extension is what the document needs.\n * That is deliberately narrow — it answers \"what CSS does this application\n * set up globally\", which is where `app.css` lives and where Tailwind is\n * wired, and it does NOT try to reproduce Vite's per-route CSS graph.\n *\n * The narrowness is the honest part: production splits CSS per chunk, dev\n * links the root's stylesheets on every page. A page whose own module imports\n * its own stylesheet still gets it in dev — Vite's client graph injects it as\n * before — it simply is not render-blocking the way the root's is. That is a\n * smaller gap than the flash this removes, and it is stated rather than\n * hidden.\n *\n * Specifiers are resolved against the root file and expressed relative to the\n * app root, because that is the shape Vite's dev server serves from.\n */\nexport function devStylesheetUrls(appRoot: string, appFile: string): string[] {\n let source: string;\n\n try {\n source = readFileSync(appFile, \"utf-8\");\n } catch {\n return [];\n }\n\n const urls: string[] = [];\n const pattern = /\\bimport\\s*[\"']([^\"']+)[\"']/g;\n\n let match = pattern.exec(source);\n\n while (match !== null) {\n const specifier = match[1];\n const lowered = specifier.toLowerCase();\n\n if (STYLE_EXTENSIONS.some((extension) => lowered.endsWith(extension))) {\n const absolute = path.resolve(path.dirname(appFile), specifier);\n const relative = path.relative(appRoot, absolute).split(path.sep).join(\"/\");\n\n // Outside the app root Vite would need an `/@fs/` URL and a widened\n // `fs.allow`; a stylesheet living there is unusual enough that guessing\n // is worse than leaving it to the client import.\n if (!relative.startsWith(\"..\")) {\n const url = `/${relative}${VITE_DIRECT_CSS_QUERY}`;\n\n if (!urls.includes(url)) urls.push(url);\n }\n }\n\n match = pattern.exec(source);\n }\n\n return urls;\n}\n\n/**\n * A stylesheet Vite serves in DEV must be requested with `?direct`.\n *\n * Without it Vite answers the same URL with `text/javascript` — its CSS-as-JS\n * module transform, meant for `import \"./app.css\"` — and a\n * `<link rel=\"stylesheet\">` pointing at a JavaScript response applies\n * NOTHING, silently. No console error, no network failure, just an unstyled\n * page. `?direct` is what makes Vite reply with real `text/css`.\n */\nexport const VITE_DIRECT_CSS_QUERY = \"?direct\";\n\ntype ManifestEntry = {\n css?: unknown;\n file?: unknown;\n};\n\n/**\n * Every stylesheet the client build emitted, as URLs the asset route serves.\n *\n * Vite records CSS against the CHUNK that imported it — an app whose\n * `root.tsx` imports `app.css` produces a `root.tsx` entry carrying\n * `css: [\"assets/root-<hash>.css\"]`, not a hydration entry carrying it. So\n * this collects across every entry rather than looking under one name, which\n * would silently find nothing the moment a stylesheet moved file.\n *\n * Duplicates are collapsed and order is preserved: two chunks importing the\n * same stylesheet must not emit two `<link>` tags.\n *\n * A missing or malformed manifest returns NOTHING rather than throwing. The\n * hydration resolver already fails loudly on exactly those conditions, from\n * exactly the same file, and it runs first — a second, worse error for the\n * same cause helps nobody.\n */\nexport function productionStylesheetUrls(clientDir: string): string[] {\n const manifestPath = path.join(clientDir, \".vite\", \"manifest.json\");\n\n let manifest: Record<string, ManifestEntry | undefined>;\n\n try {\n manifest = JSON.parse(readFileSync(manifestPath, \"utf-8\")) as Record<\n string,\n ManifestEntry | undefined\n >;\n } catch {\n return [];\n }\n\n if (typeof manifest !== \"object\" || manifest === null) return [];\n\n const urls: string[] = [];\n\n for (const entry of Object.values(manifest)) {\n if (entry === undefined || !Array.isArray(entry.css)) continue;\n\n for (const file of entry.css) {\n if (typeof file !== \"string\" || file === \"\") continue;\n\n // Built EXACTLY as the hydration entry's URL is built — `/${file}`, then\n // checked against the prefix — rather than reassembled from a basename.\n // The manifest already records `assets/root-<hash>.css`, and rebuilding\n // that path here would be a second expression of a convention\n // `client-asset-url-prefix.ts` owns.\n const url = `/${file}`;\n\n // A stylesheet outside the directory the asset route mounts would 404.\n // Dropped rather than emitted, because a dead <link> in <head> is a\n // silent styling failure — the exact thing this module exists to end.\n if (!url.startsWith(`${CLIENT_ASSET_URL_PREFIX}/`)) continue;\n\n if (!urls.includes(url)) urls.push(url);\n }\n }\n\n return urls;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAM,mBAAmB;CAAC;CAAQ;CAAS;CAAS;CAAS;AAAO;;;;;;;;;;;;;;;;;;;;AAqBpE,SAAgB,kBAAkB,SAAiB,SAA2B;CAC5E,IAAI;CAEJ,IAAI;EACF,SAAS,aAAa,SAAS,OAAO;CACxC,QAAQ;EACN,OAAO,CAAC;CACV;CAEA,MAAM,OAAiB,CAAC;CACxB,MAAM,UAAU;CAEhB,IAAI,QAAQ,QAAQ,KAAK,MAAM;CAE/B,OAAO,UAAU,MAAM;EACrB,MAAM,YAAY,MAAM;EACxB,MAAM,UAAU,UAAU,YAAY;EAEtC,IAAI,iBAAiB,MAAM,cAAc,QAAQ,SAAS,SAAS,CAAC,GAAG;GACrE,MAAM,WAAW,KAAK,QAAQ,KAAK,QAAQ,OAAO,GAAG,SAAS;GAC9D,MAAM,WAAW,KAAK,SAAS,SAAS,QAAQ,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG;GAK1E,IAAI,CAAC,SAAS,WAAW,IAAI,GAAG;IAC9B,MAAM,MAAM,IAAI,WAAW;IAE3B,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;GACxC;EACF;EAEA,QAAQ,QAAQ,KAAK,MAAM;CAC7B;CAEA,OAAO;AACT;;;;;;;;;;AAWA,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;AAwBrC,SAAgB,yBAAyB,WAA6B;CACpE,MAAM,eAAe,KAAK,KAAK,WAAW,SAAS,eAAe;CAElE,IAAI;CAEJ,IAAI;EACF,WAAW,KAAK,MAAM,aAAa,cAAc,OAAO,CAAC;CAI3D,QAAQ;EACN,OAAO,CAAC;CACV;CAEA,IAAI,OAAO,aAAa,YAAY,aAAa,MAAM,OAAO,CAAC;CAE/D,MAAM,OAAiB,CAAC;CAExB,KAAK,MAAM,SAAS,OAAO,OAAO,QAAQ,GAAG;EAC3C,IAAI,UAAU,UAAa,CAAC,MAAM,QAAQ,MAAM,GAAG,GAAG;EAEtD,KAAK,MAAM,QAAQ,MAAM,KAAK;GAC5B,IAAI,OAAO,SAAS,YAAY,SAAS,IAAI;GAO7C,MAAM,MAAM,IAAI;GAKhB,IAAI,CAAC,IAAI,WAAW,aAA2B,EAAE,GAAG;GAEpD,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;EACxC;CACF;CAEA,OAAO;AACT"}
@@ -1,5 +1,5 @@
1
1
  import { BaseConnector, ConnectorLifecyclePhase } from "@warlock.js/core";
2
- import { Alias, PluginOption } from "vite";
2
+ import { Alias, PluginOption, ViteDevServer } from "vite";
3
3
 
4
4
  //#region ../web/src/server/web-connector.d.ts
5
5
  type WebConnectorOptions = {
@@ -1,6 +1,6 @@
1
+ import { CLIENT_ASSET_URL_PREFIX } from "./client-asset-url-prefix.mjs";
1
2
  import { resolveWebPackageRoot } from "../build/contribution.mjs";
2
3
  import { consumePageManifest } from "./page-manifest.mjs";
3
- import { CLIENT_ASSET_URL_PREFIX } from "./client-asset-url-prefix.mjs";
4
4
  import { createHydrationClientEntry } from "../vite/hydration-entries.mjs";
5
5
  import { resolveHydrationClientUrl } from "./hydration-client-url.mjs";
6
6
  import { WEB_CONNECTOR_PRIORITY } from "./web-connector-factory.mjs";
@@ -8,8 +8,8 @@ import { warlockClientBoundary } from "../vite/index.mjs";
8
8
  import { appConventionAliases } from "../vite/app-convention-aliases.mjs";
9
9
  import { applyBufferedCookie, devErrorTransportPlugin, sendCapturedDevError } from "./dev-server.mjs";
10
10
  import { installProductionPageRoutes } from "./install-production-page-routes.mjs";
11
- import fs from "node:fs";
12
11
  import path from "node:path";
12
+ import fs from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { Application, BaseConnector, ConnectorLifecyclePhase, container, requestContext, router } from "@warlock.js/core";
15
15
 
@@ -498,11 +498,34 @@ var WebConnector = class extends BaseConnector {
498
498
  target: "es2022",
499
499
  jsx: "automatic"
500
500
  },
501
- ssr: { external: [
502
- ...CORE_OPTIONAL_PEERS,
503
- ...WEB_OPTIONAL_PEERS,
504
- ...this.options.ssrExternal ?? []
505
- ] },
501
+ ssr: {
502
+ external: [
503
+ ...CORE_OPTIONAL_PEERS,
504
+ ...WEB_OPTIONAL_PEERS,
505
+ ...this.options.ssrExternal ?? []
506
+ ],
507
+ /**
508
+ * ONE `@warlock.js/web`, for the same reason `resolve.dedupe` below
509
+ * insists on one React — and it is invisible from inside this repo.
510
+ *
511
+ * Vite externalises `node_modules` in SSR by default, so an installed
512
+ * app gets TWO instances: the app's own `root.tsx` imports
513
+ * `@warlock.js/web` and Vite hands that off to Node, while the pipeline
514
+ * is loaded deliberately through `vite.ssrLoadModule(...)` and stays
515
+ * inside Vite's graph. `renderPage` then sets the document context on
516
+ * Vite's copy of `components/document-context`, and the app's `<Head/>`
517
+ * reads Node's copy, which has nothing in it:
518
+ *
519
+ * <Head/> was rendered outside the page pipeline's document context
520
+ *
521
+ * Measured on a published 5.0.1 install: `GET /` 500 without this line,
522
+ * 200 with it (404 control still 404). In THIS checkout the package
523
+ * resolves to source under Vite's root, never through `node_modules`,
524
+ * so both paths land on one instance and the bug cannot reproduce.
525
+ * Canon `6b7ab838`.
526
+ */
527
+ noExternal: ["@warlock.js/web"]
528
+ },
506
529
  resolve: {
507
530
  dedupe: ["react", "react-dom"],
508
531
  alias: [...this.options.resolveAlias ?? [], ...appConventionAliases(paths.appSrcRoot)]