@warlock.js/web 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/discover-pages.mjs +3 -3
- package/esm/build/generate-pages-barrel.mjs +2 -2
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/public-files.mjs +1 -1
- package/esm/client/navigation/fetch-page-data.mjs +3 -10
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +1 -1
- package/esm/hydration-payload.mjs +19 -10
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/loaders.d.mts +12 -7
- package/esm/route.d.mts +8 -32
- package/esm/server/create-page-route-handler.mjs +12 -6
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/execute-page-request.mjs +29 -37
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +15 -4
- package/esm/server/hydration-client-url.mjs +1 -1
- package/esm/server/index.d.mts +2 -1
- package/esm/server/index.mjs +4 -4
- package/esm/server/install-page-routes-from-manifest.d.mts +9 -0
- package/esm/server/install-page-routes-from-manifest.mjs +20 -20
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +2 -24
- package/esm/server/install-page-routes.mjs +48 -25
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +6 -10
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -0
- package/esm/server/not-found-page.mjs +8 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-module-shapes.d.mts +24 -0
- package/esm/server/page-route-reload.mjs +1 -1
- package/esm/server/register-production-public-files.mjs +1 -1
- package/esm/server/render-page.d.mts +3 -2
- package/esm/server/render-page.mjs +1 -0
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +2 -2
- package/esm/server/resolve-route-validation-input.mjs.map +1 -1
- package/esm/server/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs +78 -19
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/web-connector.mjs +2 -2
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/validation.d.mts +12 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/gate-a-resolve.mjs +1 -1
- package/esm/vite/hydration-entries.mjs +1 -1
- package/llms-full.txt +25 -19
- package/package.json +3 -3
- package/skills/create-a-page/SKILL.md +25 -19
- package/esm/server/route-validation-error.mjs +0 -32
- package/esm/server/route-validation-error.mjs.map +0 -1
|
@@ -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\";\r\nimport { Response, type Request } from \"@warlock.js/core\";\r\nimport DefaultApp from \"../components/default-app\";\r\nimport {\r\n DocumentContext,\r\n escapePayload,\r\n PAYLOAD_SCRIPT_ID,\r\n type DocumentContextValue,\r\n} from \"../components/document-context\";\r\nimport type { SharedContext } from \"../index\";\r\nimport { LocaleProvider } from \"../localization\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport {\r\n hydrationErrorPageProps,\r\n resolveErrorPageMetadata,\r\n type ErrorPageModule,\r\n type ErrorPageModuleLoader,\r\n} from \"./error-page\";\r\nimport { ERROR_PAGE_METADATA } from \"./resolve-page-metadata\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../register-modules\";\r\nimport { markNonHydrating } from \"./page-render-bundle\";\r\nimport type { ServerErrorPageProps } from \"../props\";\r\nimport {\r\n buildErrorRecord,\r\n designateBoundary,\r\n executePageRequest,\r\n type BufferedCookie,\r\n type ExecutePageRequestOptions,\r\n type PageDataBundle,\r\n type PageErrorRecord,\r\n type PageLevelName,\r\n type PageResponseCommit,\r\n type PageRouteEntry,\r\n type PageRouteMatch,\r\n type PageTripleModule,\r\n} from \"./execute-page-request\";\r\n\r\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\r\nexport type { BufferedCookie };\r\n\r\n/** Widens `PageDataBundle` with the stage 7 commit record — see `execute-page-request.ts`. */\r\ntype Bundle = PageDataBundle & { commit?: PageResponseCommit };\r\n\r\n/** Reads the stage 7 commit into the lowercased header map `RenderedPage` carries. */\r\nfunction committedHeaders(bundle: PageDataBundle): Record<string, string> {\r\n const headers: Record<string, string> = {};\r\n\r\n for (const header of (bundle as Bundle).commit?.headers ?? []) {\r\n headers[header.key.toLowerCase()] = header.value;\r\n }\r\n\r\n return headers;\r\n}\r\n\r\n/** Reads the stage 7 commit into the cookie list `RenderedPage` carries. */\r\nfunction committedCookies(bundle: PageDataBundle): BufferedCookie[] {\r\n return (bundle as Bundle).commit?.cookies ?? [];\r\n}\r\n\r\n/**\r\n * Pipeline stages 9–10: RENDER the page tree from the\r\n * data bundle stages 1–8 produced, then return finalized { html, status,\r\n * headers }. Stage 10 happens at the CALL SITE in two halves —\r\n * 10a the caller applies status + headers (the single live-response write,\r\n * after render, before anything flushes), 10b it flushes\r\n * the document. Nothing in this module writes the live response. It never\r\n * re-runs any earlier stage — `renderPageRequest` calls `executePageRequest`\r\n * and everything here consumes its bundle as-is.\r\n *\r\n * `renderPageRequest` is deliberately double-duty (dx-differentiators.md §3):\r\n * it is the production orchestrator AND the test helper. Because a loader IS\r\n * a controller, `renderPageRequest(\"/products/42\")` returns\r\n * `{ html, status, headers, data }` in one call — asserting a page's data and\r\n * its response headers is a unit test, no browser, no server boot.\r\n */\r\n\r\n// ---------------------------------------------------------------------------\r\n// renderPageRequest surface\r\n// ---------------------------------------------------------------------------\r\n\r\ntype RouteRegistry = {\r\n routes: readonly PageRouteEntry[];\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n};\r\n\r\nexport type RenderPageRequestOptions = {\r\n routes: readonly PageRouteEntry[];\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n /** Loaded only after the ordinary boundary chain has been exhausted. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\nexport type RenderedPage = {\r\n /** The full document (\"\" when the pipeline short-circuited before render). */\r\n html: string;\r\n status: number;\r\n /** Committed response headers, lowercased key → value. */\r\n headers: Record<string, string>;\r\n /** Committed response cookies, in commit order — stage 7's `bundle.commit.cookies`. */\r\n cookies: BufferedCookie[];\r\n /**\r\n * The PAGE loader's data — `data.product.name` reads as the dx story\r\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\r\n */\r\n data: unknown;\r\n /**\r\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\r\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\r\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\r\n */\r\n bundle: PageDataBundle | undefined;\r\n};\r\n\r\nexport type RenderPageFailureOptions = {\r\n name: string;\r\n path: string;\r\n request: Request;\r\n response: Response;\r\n thrown: unknown;\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\nfunction requireRegistry(options: RenderPageRequestOptions): RouteRegistry {\r\n const { routes, createHttp } = options;\r\n\r\n if (!routes || !createHttp) {\r\n throw new Error(\r\n \"renderPageRequest() has no route registry (web/src/server/render-page.ts). \" +\r\n \"It resolves against the page manifest, which the server bootstrap owns. \" +\r\n \"Fix: pass { routes, createHttp } to this call.\",\r\n );\r\n }\r\n\r\n return { routes, createHttp };\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Stage 9 — RENDER\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The framework-owned terminal boundary (P1 §4: designation falls back to\r\n * `app` even when no level exports one — \"the framework owns a root\r\n * boundary\"). Deliberately generic: the error itself is server knowledge and\r\n * never serialized into the document.\r\n */\r\nfunction FrameworkRootBoundary(): ReactNode {\r\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\r\n}\r\n\r\nfunction errorPageElement(module: ErrorPageModule, props: ServerErrorPageProps): ReactNode {\r\n const ErrorPage = module.default as ((input: ServerErrorPageProps) => ReactNode) | undefined;\r\n if (!ErrorPage) {\r\n throw new Error(\"The application error.page.tsx module has no default export.\");\r\n }\r\n return createElement(ErrorPage, props);\r\n}\r\n\r\ntype LevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n children?: ReactNode;\r\n};\r\n\r\n/** The ordinary page leaf alone receives the route match's params. */\r\ntype PageLevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n params: Readonly<Record<string, string>>;\r\n};\r\n\r\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\r\n app: \"appData\",\r\n layout: \"layoutData\",\r\n page: \"pageData\",\r\n};\r\n\r\n/**\r\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\r\n * level receiving ITS OWN loader data and the same sealed `shared` — the\r\n * exact props the M1 contract declares (web/src/props.ts) and never\r\n * request/response (the component also renders on a machine where neither\r\n * exists, props.ts:19-22).\r\n *\r\n * A level with no default export contributes no DOM and passes children\r\n * through — that is `layout.tsx` omitting its default export to be a guard\r\n * with no DOM.\r\n */\r\nfunction buildPageElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n): ReactNode {\r\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle));\r\n}\r\n\r\n/**\r\n * The error path renders the DESIGNATED boundary in place of the level it\r\n * covers, still wrapped by every level rootward of it — a page-level throw\r\n * keeps its App and Layout chrome, whose data survived the settle rules\r\n * (P1 §4: fulfilled sibling data stays in the bundle).\r\n *\r\n * `record` is explicit rather than read from `bundle.error` — a render-time\r\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\r\n */\r\nfunction buildBoundaryElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n record: PageErrorRecord,\r\n): ReactNode {\r\n const { boundary, error } = record;\r\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\r\n ((props: { error: unknown }) => ReactNode) | undefined;\r\n\r\n const element = Boundary\r\n ? createElement(Boundary, { error })\r\n : createElement(FrameworkRootBoundary, {});\r\n\r\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\r\n\r\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\r\n // unwrapped when the boundary covers the app level itself — but the\r\n // pipeline always emits a complete document, so the\r\n // framework default supplies the shell here even though the app's own\r\n // (broken) root is what's being bypassed.\r\n return boundary.boundaryLevel === \"app\"\r\n ? createElement(DefaultApp, { children: wrapped })\r\n : wrapped;\r\n}\r\n\r\nfunction buildLeaf(module: PageTripleModule, bundle: PageDataBundle): ReactNode {\r\n const Component = module.default as ((props: PageLevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) return null;\r\n\r\n return createElement(Component as ComponentType<PageLevelProps>, {\r\n data: bundle.pageData,\r\n shared: bundle.shared,\r\n params: bundle.route.params,\r\n });\r\n}\r\n\r\nfunction wrapRootward(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n from: PageLevelName,\r\n leaf: ReactNode,\r\n): ReactNode {\r\n const wrappers: PageLevelName[] =\r\n from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\r\n\r\n let element = leaf;\r\n\r\n for (const level of wrappers) {\r\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) {\r\n // \"App\" is the root: no App export means no custom document, but the\r\n // pipeline always emits a complete one — the\r\n // framework default App supplies it. Layout has no such fallback: an\r\n // omitted layout default export stays a no-DOM passthrough,\r\n // unchanged from before.\r\n if (level === \"app\") {\r\n element = createElement(DefaultApp, { children: element });\r\n }\r\n\r\n continue;\r\n }\r\n\r\n element = createElement(Component as ComponentType<LevelProps>, {\r\n data: bundle[DATA_KEYS[level]],\r\n shared: bundle.shared,\r\n children: element,\r\n });\r\n }\r\n\r\n return element;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The root (App or the framework default) now ALWAYS renders a complete\r\n * `<html>…</html>` document itself — `<Head/>`/\r\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\r\n * around the element in `finishRender`, below) and emit real elements.\r\n * There is nothing left for this stage to assemble by string surgery; it\r\n * only prepends the doctype `renderToString` never includes.\r\n */\r\nfunction emitDocument(body: string): string {\r\n return \"<!DOCTYPE html>\" + body;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The shared tail (stages 9–10) — both orchestrators end here\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The real request/response pair `capturingCreateHttp` captured for this\r\n * call. It is used at the two orchestrator call sites to read the document\r\n * slots (`documentSlotsFrom`, below).\r\n */\r\ntype CapturedHttp = {\r\n request: Request;\r\n response: Response;\r\n};\r\n\r\n/**\r\n * Wrap the caller's createHttp to capture the real pair (for the document\r\n * slots, `documentSlotsFrom` below) and the matched entry (the only place a\r\n * URL-based caller learns which triple to render).\r\n */\r\nfunction capturingCreateHttp(registry: RouteRegistry): {\r\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n} {\r\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\r\n\r\n return {\r\n state,\r\n createHttp(match) {\r\n state.match = match;\r\n state.captured = registry.createHttp(match);\r\n\r\n return state.captured;\r\n },\r\n };\r\n}\r\n\r\n/**\r\n * The two request-derived document slots (`nonce`/`lang` on\r\n * `DocumentContextValue`), extracted at the orchestrator call sites\r\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\r\n * here: core's Request has no dir-like field (checked\r\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\r\n * exist) — an app supplies `dir` via its own convention.\r\n */\r\ntype DocumentSlots = {\r\n nonce?: string;\r\n locale: string;\r\n};\r\n\r\n/** Reads document slots directly from core's Request. */\r\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\r\n if (captured === undefined) {\r\n throw new Error(\"The page pipeline reached rendering without its request context.\");\r\n }\r\n\r\n return { nonce: captured.request.nonce, locale: captured.request.locale };\r\n}\r\n\r\nasync function finishRender(\r\n triple: PageRouteEntry[\"triple\"],\r\n bundle: PageDataBundle,\r\n documentSlots: DocumentSlots,\r\n response: Response,\r\n loadErrorPage: ErrorPageModuleLoader | undefined,\r\n): Promise<RenderedPage> {\r\n // Read from the stage 7 commit, never live off `response` — this function\r\n // writes (and now reads) the live response zero times. A bundle with no\r\n // commit (no loader ran at all) simply has no headers/cookies to report.\r\n const headers = committedHeaders(bundle);\r\n const cookies = committedCookies(bundle);\r\n\r\n // Middleware and validation short-circuits emit no document. Loader-returned\r\n // Response instances never reach this function.\r\n if (bundle.shortCircuit) {\r\n const status =\r\n bundle.shortCircuit.stage === \"validation\"\r\n ? bundle.shortCircuit.status\r\n : (bundle.shortCircuit.statusCode ?? 200);\r\n return {\r\n html: \"\",\r\n status,\r\n headers,\r\n cookies,\r\n data: bundle.pageData,\r\n bundle,\r\n };\r\n }\r\n\r\n // `Cache-Control` is NOT decided here. The final value — the floor, an\r\n // opted-in route's `public, max-age`, or the closed-by-default `no-store` —\r\n // is decided once, at the `create-page-route-handler.ts` seam, by\r\n // `applyResponseCacheFloor` (`response-cache-floor.ts`), identically for the\r\n // document and the data representation. Anything this function's `headers`\r\n // map put under `cache-control` is overwritten there on purpose: two sites\r\n // deciding this key is exactly the drift that seam exists to prevent.\r\n\r\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\r\n // Lazy import: react-dom is a peer used only on this path, so merely\r\n // loading the server barrel never requires it.\r\n const { renderToString } = await import(\"react-dom/server\");\r\n\r\n // JSON.stringify omits object properties whose value is undefined. Loader\r\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\r\n // both already final by this point (stages 1-8 are done), so there is\r\n // nothing left for the root to await.\r\n //\r\n // The payload comes from `buildHydrationPayload` rather than being assembled\r\n // here, so that this document and the `_loader` route hand the browser the\r\n // SAME object. See that module for why the two must not drift.\r\n let documentValue: DocumentContextValue = {\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n nonce: documentSlots.nonce,\r\n lang: documentSlots.locale,\r\n };\r\n\r\n const renderWithContext = (element: ReactNode): string =>\r\n renderToString(\r\n createElement(DocumentContext.Provider, {\r\n value: documentValue,\r\n children: createElement(LocaleProvider, {\r\n locale: documentValue.payload.locale,\r\n children: element,\r\n }),\r\n }),\r\n );\r\n\r\n // A boundary that throws while rendering escalates to\r\n // the next enclosing boundary rootward; if none survives, the framework's\r\n // last-resort terminal renders. `currentError` starts as whatever stage\r\n // 1-8 already designated (`bundle.error`, undefined for a normal page\r\n // render) and is replaced by each escalation — `bundle.error` itself is\r\n // never mutated, staying a truthful stage 1-8 record.\r\n let currentError = bundle.error;\r\n let renderTimeThrow = false;\r\n let body: string;\r\n\r\n const renderFrameworkRoot = (): string =>\r\n renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n const renderFrameworkAfterErrorPageFailure = (): string => {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n };\r\n return renderFrameworkRoot();\r\n };\r\n\r\n const renderErrorPage = async (\r\n thrown: unknown,\r\n serializableError: unknown = thrown,\r\n ): Promise<string | undefined> => {\r\n if (!loadErrorPage) return undefined;\r\n\r\n // The status is the FAILURE's own — 500 for an ordinary escalated throw,\r\n // but a `route.validate` rejection carries its own 400\r\n // (`PageErrorRecord.statusCode`, canon `b79c4f55`, point 2) and the error\r\n // page must receive that, not a blanket 500.\r\n const props: ServerErrorPageProps = { error: thrown, status: currentError?.statusCode ?? 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props, serializableError);\r\n bundle.errorPage = errorPage;\r\n bundle.metadata = resolveErrorPageMetadata(module, props);\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n };\r\n return renderWithContext(wrapRootward(triple, bundle, \"page\", errorPageElement(module, props)));\r\n };\r\n\r\n for (;;) {\r\n try {\r\n // The application error page is the framework terminal, never a rival\r\n // to an authored boundary. It is reached only after no app boundary\r\n // exists (or after that boundary has itself thrown below).\r\n if (currentError?.boundary.boundaryLevel === \"app\" && !triple.app.ErrorBoundary) {\r\n try {\r\n body =\r\n (await renderErrorPage(\r\n currentError.originalError ?? currentError.error,\r\n currentError.error,\r\n )) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n renderTimeThrow = true;\r\n break;\r\n }\r\n\r\n const element = currentError\r\n ? buildBoundaryElement(triple, bundle, currentError)\r\n : buildPageElement(triple, bundle);\r\n\r\n body = renderWithContext(element);\r\n break;\r\n } catch (thrown) {\r\n renderTimeThrow = true;\r\n\r\n if (currentError?.boundary.boundaryLevel === \"app\") {\r\n // The floor: the app-level boundary's own render just threw, so\r\n // there is nothing rootward of `app` to escalate to (§2's \"none\r\n // survives\"). Render the framework's trivial boundary directly —\r\n // bypassing the app's ErrorBoundary/App component, since that is\r\n // what just failed — wrapped in DefaultApp so the response is still\r\n // a complete `<html>` document (default-app.tsx:22-46) rather than\r\n // a bare `<main>` fragment.\r\n try {\r\n body = (await renderErrorPage(thrown)) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n break;\r\n }\r\n\r\n // Escalate from the level rootward of whatever just threw — searching\r\n // from the SAME level would re-select the boundary that just failed.\r\n // A throw not yet attributable to a level (a normal page render, no\r\n // prior designation) starts the search at `page`.\r\n const throwingLevel: PageLevelName =\r\n currentError?.boundary.boundaryLevel === \"layout\"\r\n ? \"app\"\r\n : currentError\r\n ? \"layout\"\r\n : \"page\";\r\n\r\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\r\n }\r\n }\r\n\r\n // Status is chosen after render — the last thing that can change the\r\n // outcome — and RETURNED, never applied: `finishRender` writes the live\r\n // response zero times. The caller applies status + headers at one site and\r\n // flushes immediately after (stage 10a/10b). \"The framework owns the status\r\n // whenever a boundary renders\" (design/request-lifecycle.md stage 7): ANY\r\n // boundary — nested or app-level, discovered pre-render or escalated during\r\n // render — forces 500, UNLESS the failure carries its own status\r\n // (`PageErrorRecord.statusCode` — a `route.validate` rejection's 400, canon\r\n // `b79c4f55` point 2). The boundary's LEVEL only decides which component\r\n // renders; the committed status from stage 7\r\n // (`bundle.commit.statusCode`) stands only for a page with no error at all\r\n // — read off the commit, never off the live `response`, same as `headers`\r\n // above. No committed status (no loader called `setStatusCode`) is the\r\n // ordinary 200.\r\n const status = currentError\r\n ? (currentError.statusCode ?? 500)\r\n : ((bundle as Bundle).commit?.statusCode ?? 200);\r\n\r\n const html = emitDocument(body);\r\n\r\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\r\n}\r\n\r\n/**\r\n * Render failures that happen before the page pipeline has a triple (notably a\r\n * module-load or registration throw). This deliberately owns one terminal\r\n * attempt: an error-page failure falls straight to FrameworkRootBoundary.\r\n *\r\n * There is no triple yet, so there is no trustworthy server composition for\r\n * the browser to hydrate against — every response this function produces is\r\n * marked `markNonHydrating` (page-render-bundle.ts), on both the bundle and\r\n * the document payload, whether or not it managed to render the app's own\r\n * `error.page.tsx`. A normal app error page reached through `finishRender`\r\n * renders inside a real triple and stays hydratable; this path never does.\r\n */\r\nexport async function renderPageFailure(options: RenderPageFailureOptions): Promise<RenderedPage> {\r\n const { request, response, name, path, thrown, loadErrorPage } = options;\r\n const bundle: PageDataBundle = markNonHydrating({\r\n route: { name, path, params: {}, query: {} },\r\n });\r\n // No pipeline ran (there is no triple), so there is no commit to read —\r\n // never a live `response.getHeaders()` read either; see `finishRender`.\r\n const headers: Record<string, string> = { \"cache-control\": \"private\" };\r\n\r\n const { renderToString } = await import(\"react-dom/server\");\r\n const slots = documentSlotsFrom({ request, response });\r\n const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle, slots.locale));\r\n let value: DocumentContextValue = {\r\n metadata: undefined,\r\n payload: frameworkPayload,\r\n nonce: slots.nonce,\r\n lang: slots.locale,\r\n };\r\n const renderWithContext = (element: ReactNode): string =>\r\n renderToString(\r\n createElement(DocumentContext.Provider, {\r\n value,\r\n children: createElement(LocaleProvider, {\r\n locale: value.payload.locale,\r\n children: element,\r\n }),\r\n }),\r\n );\r\n let body: string;\r\n\r\n try {\r\n if (!loadErrorPage) throw new Error(\"No application error page is configured.\");\r\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props);\r\n bundle.errorPage = errorPage;\r\n value = {\r\n ...value,\r\n metadata: resolveErrorPageMetadata(module, props),\r\n payload: markNonHydrating({ ...frameworkPayload, errorPage }),\r\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, { children: errorPageElement(module, props) }),\r\n );\r\n } catch {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n value = {\r\n ...value,\r\n metadata: bundle.metadata,\r\n payload: markNonHydrating(buildHydrationPayload(bundle, slots.locale)),\r\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n }\r\n\r\n return {\r\n html: emitDocument(body),\r\n status: 500,\r\n headers,\r\n cookies: [],\r\n data: undefined,\r\n bundle,\r\n };\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The orchestrator\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The production render surface: a real HTTP server has a URL. The url goes\r\n * STRAIGHT to executePageRequest's stage-1 matcher, then the shared tail\r\n * (`finishRender`) renders and emits.\r\n *\r\n * No-match is not thrown on: an arbitrary URL matching no route is a\r\n * legitimate 404, and a server must ANSWER it — `{ html: \"\", status: 404 }`\r\n * with an undefined `bundle` (see RenderedPage).\r\n */\r\nexport async function renderPageRequest(\r\n url: string,\r\n options: RenderPageRequestOptions,\r\n): Promise<RenderedPage | Response> {\r\n const registry = requireRegistry(options);\r\n const { state, createHttp } = capturingCreateHttp(registry);\r\n\r\n const rendered = await executePageRequest({\r\n url,\r\n routes: registry.routes,\r\n createHttp,\r\n finish: (bundle) =>\r\n finishRender(\r\n state.match!.entry.triple,\r\n bundle,\r\n documentSlotsFrom(state.captured),\r\n state.captured!.response,\r\n options.loadErrorPage,\r\n ),\r\n });\r\n\r\n if (!rendered) {\r\n return {\r\n html: \"\",\r\n status: 404,\r\n headers: {},\r\n cookies: [],\r\n data: undefined,\r\n bundle: undefined,\r\n };\r\n }\r\n\r\n // executePageRequest only produces a bundle after createHttp ran for the\r\n // match, so the captured entry is present whenever the bundle is.\r\n return rendered;\r\n}\r\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\";\r\nimport { Response, type Request } from \"@warlock.js/core\";\r\nimport DefaultApp from \"../components/default-app\";\r\nimport {\r\n DocumentContext,\r\n escapePayload,\r\n PAYLOAD_SCRIPT_ID,\r\n type DocumentContextValue,\r\n} from \"../components/document-context\";\r\nimport type { SharedContext } from \"../index\";\r\nimport { LocaleProvider } from \"../localization\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport {\r\n hydrationErrorPageProps,\r\n resolveErrorPageMetadata,\r\n type ErrorPageModule,\r\n type ErrorPageModuleLoader,\r\n} from \"./error-page\";\r\nimport { ERROR_PAGE_METADATA } from \"./resolve-page-metadata\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../register-modules\";\r\nimport { markNonHydrating } from \"./page-render-bundle\";\r\nimport type { ServerErrorPageProps } from \"../props\";\r\nimport {\r\n buildErrorRecord,\r\n designateBoundary,\r\n executePageRequest,\r\n type BufferedCookie,\r\n type ExecutePageRequestOptions,\r\n type PageDataBundle,\r\n type PageErrorRecord,\r\n type PageLevelName,\r\n type PageResponseCommit,\r\n type PageRouteEntry,\r\n type PageRouteMatch,\r\n type PageTripleModule,\r\n} from \"./execute-page-request\";\r\n\r\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\r\nexport type { BufferedCookie };\r\n\r\n/** Widens `PageDataBundle` with the stage 7 commit record — see `execute-page-request.ts`. */\r\ntype Bundle = PageDataBundle & { commit?: PageResponseCommit };\r\n\r\n/** Reads the stage 7 commit into the lowercased header map `RenderedPage` carries. */\r\nfunction committedHeaders(bundle: PageDataBundle): Record<string, string> {\r\n const headers: Record<string, string> = {};\r\n\r\n for (const header of (bundle as Bundle).commit?.headers ?? []) {\r\n headers[header.key.toLowerCase()] = header.value;\r\n }\r\n\r\n return headers;\r\n}\r\n\r\n/** Reads the stage 7 commit into the cookie list `RenderedPage` carries. */\r\nfunction committedCookies(bundle: PageDataBundle): BufferedCookie[] {\r\n return (bundle as Bundle).commit?.cookies ?? [];\r\n}\r\n\r\n/**\r\n * Pipeline stages 9–10: RENDER the page tree from the\r\n * data bundle stages 1–8 produced, then return finalized { html, status,\r\n * headers }. Stage 10 happens at the CALL SITE in two halves —\r\n * 10a the caller applies status + headers (the single live-response write,\r\n * after render, before anything flushes), 10b it flushes\r\n * the document. Nothing in this module writes the live response. It never\r\n * re-runs any earlier stage — `renderPageRequest` calls `executePageRequest`\r\n * and everything here consumes its bundle as-is.\r\n *\r\n * `renderPageRequest` is deliberately double-duty (dx-differentiators.md §3):\r\n * it is the production orchestrator AND the test helper. Because a loader IS\r\n * a controller, `renderPageRequest(\"/products/42\")` returns\r\n * `{ html, status, headers, data }` in one call — asserting a page's data and\r\n * its response headers is a unit test, no browser, no server boot.\r\n */\r\n\r\n// ---------------------------------------------------------------------------\r\n// renderPageRequest surface\r\n// ---------------------------------------------------------------------------\r\n\r\ntype RouteRegistry = {\r\n routes: readonly PageRouteEntry[];\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n};\r\n\r\nexport type RenderPageRequestOptions = {\r\n routes: readonly PageRouteEntry[];\r\n /** Core's already-resolved HTTP route, when this is serving a live request. */\r\n matched?: Pick<PageRouteMatch, \"entry\" | \"params\">;\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n /** Loaded only after the ordinary boundary chain has been exhausted. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\nexport type RenderedPage = {\r\n /** The full document (\"\" when the pipeline short-circuited before render). */\r\n html: string;\r\n status: number;\r\n /** Committed response headers, lowercased key → value. */\r\n headers: Record<string, string>;\r\n /** Committed response cookies, in commit order — stage 7's `bundle.commit.cookies`. */\r\n cookies: BufferedCookie[];\r\n /**\r\n * The PAGE loader's data — `data.product.name` reads as the dx story\r\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\r\n */\r\n data: unknown;\r\n /**\r\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\r\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\r\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\r\n */\r\n bundle: PageDataBundle | undefined;\r\n};\r\n\r\nexport type RenderPageFailureOptions = {\r\n name: string;\r\n path: string;\r\n request: Request;\r\n response: Response;\r\n thrown: unknown;\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\nfunction requireRegistry(options: RenderPageRequestOptions): RouteRegistry {\r\n const { routes, createHttp } = options;\r\n\r\n if (!routes || !createHttp) {\r\n throw new Error(\r\n \"renderPageRequest() has no route registry (web/src/server/render-page.ts). \" +\r\n \"It resolves against the page manifest, which the server bootstrap owns. \" +\r\n \"Fix: pass { routes, createHttp } to this call.\",\r\n );\r\n }\r\n\r\n return { routes, createHttp };\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Stage 9 — RENDER\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The framework-owned terminal boundary (P1 §4: designation falls back to\r\n * `app` even when no level exports one — \"the framework owns a root\r\n * boundary\"). Deliberately generic: the error itself is server knowledge and\r\n * never serialized into the document.\r\n */\r\nfunction FrameworkRootBoundary(): ReactNode {\r\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\r\n}\r\n\r\nfunction errorPageElement(module: ErrorPageModule, props: ServerErrorPageProps): ReactNode {\r\n const ErrorPage = module.default as ((input: ServerErrorPageProps) => ReactNode) | undefined;\r\n if (!ErrorPage) {\r\n throw new Error(\"The application error.page.tsx module has no default export.\");\r\n }\r\n return createElement(ErrorPage, props);\r\n}\r\n\r\ntype LevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n children?: ReactNode;\r\n};\r\n\r\n/** The ordinary page leaf alone receives the route match's params. */\r\ntype PageLevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n params: Readonly<Record<string, string>>;\r\n};\r\n\r\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\r\n app: \"appData\",\r\n layout: \"layoutData\",\r\n page: \"pageData\",\r\n};\r\n\r\n/**\r\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\r\n * level receiving ITS OWN loader data and the same sealed `shared` — the\r\n * exact props the M1 contract declares (web/src/props.ts) and never\r\n * request/response (the component also renders on a machine where neither\r\n * exists, props.ts:19-22).\r\n *\r\n * A level with no default export contributes no DOM and passes children\r\n * through — that is `layout.tsx` omitting its default export to be a guard\r\n * with no DOM.\r\n */\r\nfunction buildPageElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n): ReactNode {\r\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle));\r\n}\r\n\r\n/**\r\n * The error path renders the DESIGNATED boundary in place of the level it\r\n * covers, still wrapped by every level rootward of it — a page-level throw\r\n * keeps its App and Layout chrome, whose data survived the settle rules\r\n * (P1 §4: fulfilled sibling data stays in the bundle).\r\n *\r\n * `record` is explicit rather than read from `bundle.error` — a render-time\r\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\r\n */\r\nfunction buildBoundaryElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n record: PageErrorRecord,\r\n): ReactNode {\r\n const { boundary, error } = record;\r\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\r\n ((props: { error: unknown }) => ReactNode) | undefined;\r\n\r\n const element = Boundary\r\n ? createElement(Boundary, { error })\r\n : createElement(FrameworkRootBoundary, {});\r\n\r\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\r\n\r\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\r\n // unwrapped when the boundary covers the app level itself — but the\r\n // pipeline always emits a complete document, so the\r\n // framework default supplies the shell here even though the app's own\r\n // (broken) root is what's being bypassed.\r\n return boundary.boundaryLevel === \"app\"\r\n ? createElement(DefaultApp, { children: wrapped })\r\n : wrapped;\r\n}\r\n\r\nfunction buildLeaf(module: PageTripleModule, bundle: PageDataBundle): ReactNode {\r\n const Component = module.default as ((props: PageLevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) return null;\r\n\r\n return createElement(Component as ComponentType<PageLevelProps>, {\r\n data: bundle.pageData,\r\n shared: bundle.shared,\r\n params: bundle.route.params,\r\n });\r\n}\r\n\r\nfunction wrapRootward(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n from: PageLevelName,\r\n leaf: ReactNode,\r\n): ReactNode {\r\n const wrappers: PageLevelName[] =\r\n from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\r\n\r\n let element = leaf;\r\n\r\n for (const level of wrappers) {\r\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) {\r\n // \"App\" is the root: no App export means no custom document, but the\r\n // pipeline always emits a complete one — the\r\n // framework default App supplies it. Layout has no such fallback: an\r\n // omitted layout default export stays a no-DOM passthrough,\r\n // unchanged from before.\r\n if (level === \"app\") {\r\n element = createElement(DefaultApp, { children: element });\r\n }\r\n\r\n continue;\r\n }\r\n\r\n element = createElement(Component as ComponentType<LevelProps>, {\r\n data: bundle[DATA_KEYS[level]],\r\n shared: bundle.shared,\r\n children: element,\r\n });\r\n }\r\n\r\n return element;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The root (App or the framework default) now ALWAYS renders a complete\r\n * `<html>…</html>` document itself — `<Head/>`/\r\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\r\n * around the element in `finishRender`, below) and emit real elements.\r\n * There is nothing left for this stage to assemble by string surgery; it\r\n * only prepends the doctype `renderToString` never includes.\r\n */\r\nfunction emitDocument(body: string): string {\r\n return \"<!DOCTYPE html>\" + body;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The shared tail (stages 9–10) — both orchestrators end here\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The real request/response pair `capturingCreateHttp` captured for this\r\n * call. It is used at the two orchestrator call sites to read the document\r\n * slots (`documentSlotsFrom`, below).\r\n */\r\ntype CapturedHttp = {\r\n request: Request;\r\n response: Response;\r\n};\r\n\r\n/**\r\n * Wrap the caller's createHttp to capture the real pair (for the document\r\n * slots, `documentSlotsFrom` below) and the matched entry (the only place a\r\n * URL-based caller learns which triple to render).\r\n */\r\nfunction capturingCreateHttp(registry: RouteRegistry): {\r\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n} {\r\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\r\n\r\n return {\r\n state,\r\n createHttp(match) {\r\n state.match = match;\r\n state.captured = registry.createHttp(match);\r\n\r\n return state.captured;\r\n },\r\n };\r\n}\r\n\r\n/**\r\n * The two request-derived document slots (`nonce`/`lang` on\r\n * `DocumentContextValue`), extracted at the orchestrator call sites\r\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\r\n * here: core's Request has no dir-like field (checked\r\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\r\n * exist) — an app supplies `dir` via its own convention.\r\n */\r\ntype DocumentSlots = {\r\n nonce?: string;\r\n locale: string;\r\n};\r\n\r\n/** Reads document slots directly from core's Request. */\r\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\r\n if (captured === undefined) {\r\n throw new Error(\"The page pipeline reached rendering without its request context.\");\r\n }\r\n\r\n return { nonce: captured.request.nonce, locale: captured.request.locale };\r\n}\r\n\r\nasync function finishRender(\r\n triple: PageRouteEntry[\"triple\"],\r\n bundle: PageDataBundle,\r\n documentSlots: DocumentSlots,\r\n response: Response,\r\n loadErrorPage: ErrorPageModuleLoader | undefined,\r\n): Promise<RenderedPage> {\r\n // Read from the stage 7 commit, never live off `response` — this function\r\n // writes (and now reads) the live response zero times. A bundle with no\r\n // commit (no loader ran at all) simply has no headers/cookies to report.\r\n const headers = committedHeaders(bundle);\r\n const cookies = committedCookies(bundle);\r\n\r\n // Middleware and validation short-circuits emit no document. Loader-returned\r\n // Response instances never reach this function.\r\n if (bundle.shortCircuit) {\r\n const status =\r\n bundle.shortCircuit.stage === \"validation\"\r\n ? bundle.shortCircuit.status\r\n : (bundle.shortCircuit.statusCode ?? 200);\r\n return {\r\n html: \"\",\r\n status,\r\n headers,\r\n cookies,\r\n data: bundle.pageData,\r\n bundle,\r\n };\r\n }\r\n\r\n // `Cache-Control` is NOT decided here. The final value — the floor, an\r\n // opted-in route's `public, max-age`, or the closed-by-default `no-store` —\r\n // is decided once, at the `create-page-route-handler.ts` seam, by\r\n // `applyResponseCacheFloor` (`response-cache-floor.ts`), identically for the\r\n // document and the data representation. Anything this function's `headers`\r\n // map put under `cache-control` is overwritten there on purpose: two sites\r\n // deciding this key is exactly the drift that seam exists to prevent.\r\n\r\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\r\n // Lazy import: react-dom is a peer used only on this path, so merely\r\n // loading the server barrel never requires it.\r\n const { renderToString } = await import(\"react-dom/server\");\r\n\r\n // JSON.stringify omits object properties whose value is undefined. Loader\r\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\r\n // both already final by this point (stages 1-8 are done), so there is\r\n // nothing left for the root to await.\r\n //\r\n // The payload comes from `buildHydrationPayload` rather than being assembled\r\n // here, so that this document and the `_loader` route hand the browser the\r\n // SAME object. See that module for why the two must not drift.\r\n let documentValue: DocumentContextValue = {\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n nonce: documentSlots.nonce,\r\n lang: documentSlots.locale,\r\n };\r\n\r\n const renderWithContext = (element: ReactNode): string =>\r\n renderToString(\r\n createElement(DocumentContext.Provider, {\r\n value: documentValue,\r\n children: createElement(LocaleProvider, {\r\n locale: documentValue.payload.locale,\r\n children: element,\r\n }),\r\n }),\r\n );\r\n\r\n // A boundary that throws while rendering escalates to\r\n // the next enclosing boundary rootward; if none survives, the framework's\r\n // last-resort terminal renders. `currentError` starts as whatever stage\r\n // 1-8 already designated (`bundle.error`, undefined for a normal page\r\n // render) and is replaced by each escalation — `bundle.error` itself is\r\n // never mutated, staying a truthful stage 1-8 record.\r\n let currentError = bundle.error;\r\n let renderTimeThrow = false;\r\n let body: string;\r\n\r\n const renderFrameworkRoot = (): string =>\r\n renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n const renderFrameworkAfterErrorPageFailure = (): string => {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n };\r\n return renderFrameworkRoot();\r\n };\r\n\r\n const renderErrorPage = async (\r\n thrown: unknown,\r\n serializableError: unknown = thrown,\r\n ): Promise<string | undefined> => {\r\n if (!loadErrorPage) return undefined;\r\n\r\n // The status is the FAILURE's own — 500 for an ordinary escalated throw,\r\n // but a `route.validate` rejection carries its own 400\r\n // (`PageErrorRecord.statusCode`, canon `b79c4f55`, point 2) and the error\r\n // page must receive that, not a blanket 500.\r\n const props: ServerErrorPageProps = { error: thrown, status: currentError?.statusCode ?? 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props, serializableError);\r\n bundle.errorPage = errorPage;\r\n bundle.metadata = resolveErrorPageMetadata(module, props);\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\r\n };\r\n return renderWithContext(wrapRootward(triple, bundle, \"page\", errorPageElement(module, props)));\r\n };\r\n\r\n for (;;) {\r\n try {\r\n // The application error page is the framework terminal, never a rival\r\n // to an authored boundary. It is reached only after no app boundary\r\n // exists (or after that boundary has itself thrown below).\r\n if (currentError?.boundary.boundaryLevel === \"app\" && !triple.app.ErrorBoundary) {\r\n try {\r\n body =\r\n (await renderErrorPage(\r\n currentError.originalError ?? currentError.error,\r\n currentError.error,\r\n )) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n renderTimeThrow = true;\r\n break;\r\n }\r\n\r\n const element = currentError\r\n ? buildBoundaryElement(triple, bundle, currentError)\r\n : buildPageElement(triple, bundle);\r\n\r\n body = renderWithContext(element);\r\n break;\r\n } catch (thrown) {\r\n renderTimeThrow = true;\r\n\r\n if (currentError?.boundary.boundaryLevel === \"app\") {\r\n // The floor: the app-level boundary's own render just threw, so\r\n // there is nothing rootward of `app` to escalate to (§2's \"none\r\n // survives\"). Render the framework's trivial boundary directly —\r\n // bypassing the app's ErrorBoundary/App component, since that is\r\n // what just failed — wrapped in DefaultApp so the response is still\r\n // a complete `<html>` document (default-app.tsx:22-46) rather than\r\n // a bare `<main>` fragment.\r\n try {\r\n body = (await renderErrorPage(thrown)) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n break;\r\n }\r\n\r\n // Escalate from the level rootward of whatever just threw — searching\r\n // from the SAME level would re-select the boundary that just failed.\r\n // A throw not yet attributable to a level (a normal page render, no\r\n // prior designation) starts the search at `page`.\r\n const throwingLevel: PageLevelName =\r\n currentError?.boundary.boundaryLevel === \"layout\"\r\n ? \"app\"\r\n : currentError\r\n ? \"layout\"\r\n : \"page\";\r\n\r\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\r\n }\r\n }\r\n\r\n // Status is chosen after render — the last thing that can change the\r\n // outcome — and RETURNED, never applied: `finishRender` writes the live\r\n // response zero times. The caller applies status + headers at one site and\r\n // flushes immediately after (stage 10a/10b). \"The framework owns the status\r\n // whenever a boundary renders\" (design/request-lifecycle.md stage 7): ANY\r\n // boundary — nested or app-level, discovered pre-render or escalated during\r\n // render — forces 500, UNLESS the failure carries its own status\r\n // (`PageErrorRecord.statusCode` — a `route.validate` rejection's 400, canon\r\n // `b79c4f55` point 2). The boundary's LEVEL only decides which component\r\n // renders; the committed status from stage 7\r\n // (`bundle.commit.statusCode`) stands only for a page with no error at all\r\n // — read off the commit, never off the live `response`, same as `headers`\r\n // above. No committed status (no loader called `setStatusCode`) is the\r\n // ordinary 200.\r\n const status = currentError\r\n ? (currentError.statusCode ?? 500)\r\n : ((bundle as Bundle).commit?.statusCode ?? 200);\r\n\r\n const html = emitDocument(body);\r\n\r\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\r\n}\r\n\r\n/**\r\n * Render failures that happen before the page pipeline has a triple (notably a\r\n * module-load or registration throw). This deliberately owns one terminal\r\n * attempt: an error-page failure falls straight to FrameworkRootBoundary.\r\n *\r\n * There is no triple yet, so there is no trustworthy server composition for\r\n * the browser to hydrate against — every response this function produces is\r\n * marked `markNonHydrating` (page-render-bundle.ts), on both the bundle and\r\n * the document payload, whether or not it managed to render the app's own\r\n * `error.page.tsx`. A normal app error page reached through `finishRender`\r\n * renders inside a real triple and stays hydratable; this path never does.\r\n */\r\nexport async function renderPageFailure(options: RenderPageFailureOptions): Promise<RenderedPage> {\r\n const { request, response, name, path, thrown, loadErrorPage } = options;\r\n const bundle: PageDataBundle = markNonHydrating({\r\n route: { name, path, params: {}, query: {} },\r\n });\r\n // No pipeline ran (there is no triple), so there is no commit to read —\r\n // never a live `response.getHeaders()` read either; see `finishRender`.\r\n const headers: Record<string, string> = { \"cache-control\": \"private\" };\r\n\r\n const { renderToString } = await import(\"react-dom/server\");\r\n const slots = documentSlotsFrom({ request, response });\r\n const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle, slots.locale));\r\n let value: DocumentContextValue = {\r\n metadata: undefined,\r\n payload: frameworkPayload,\r\n nonce: slots.nonce,\r\n lang: slots.locale,\r\n };\r\n const renderWithContext = (element: ReactNode): string =>\r\n renderToString(\r\n createElement(DocumentContext.Provider, {\r\n value,\r\n children: createElement(LocaleProvider, {\r\n locale: value.payload.locale,\r\n children: element,\r\n }),\r\n }),\r\n );\r\n let body: string;\r\n\r\n try {\r\n if (!loadErrorPage) throw new Error(\"No application error page is configured.\");\r\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props);\r\n bundle.errorPage = errorPage;\r\n value = {\r\n ...value,\r\n metadata: resolveErrorPageMetadata(module, props),\r\n payload: markNonHydrating({ ...frameworkPayload, errorPage }),\r\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, { children: errorPageElement(module, props) }),\r\n );\r\n } catch {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n value = {\r\n ...value,\r\n metadata: bundle.metadata,\r\n payload: markNonHydrating(buildHydrationPayload(bundle, slots.locale)),\r\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n }\r\n\r\n return {\r\n html: emitDocument(body),\r\n status: 500,\r\n headers,\r\n cookies: [],\r\n data: undefined,\r\n bundle,\r\n };\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The orchestrator\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The production render surface: a real HTTP server has a URL. The url goes\r\n * STRAIGHT to executePageRequest's stage-1 matcher, then the shared tail\r\n * (`finishRender`) renders and emits.\r\n *\r\n * No-match is not thrown on: an arbitrary URL matching no route is a\r\n * legitimate 404, and a server must ANSWER it — `{ html: \"\", status: 404 }`\r\n * with an undefined `bundle` (see RenderedPage).\r\n */\r\nexport async function renderPageRequest(\r\n url: string,\r\n options: RenderPageRequestOptions,\r\n): Promise<RenderedPage | Response> {\r\n const registry = requireRegistry(options);\r\n const { state, createHttp } = capturingCreateHttp(registry);\r\n\r\n const rendered = await executePageRequest({\r\n url,\r\n routes: registry.routes,\r\n matched: options.matched,\r\n createHttp,\r\n finish: (bundle) =>\r\n finishRender(\r\n state.match!.entry.triple,\r\n bundle,\r\n documentSlotsFrom(state.captured),\r\n state.captured!.response,\r\n options.loadErrorPage,\r\n ),\r\n });\r\n\r\n if (!rendered) {\r\n return {\r\n html: \"\",\r\n status: 404,\r\n headers: {},\r\n cookies: [],\r\n data: undefined,\r\n bundle: undefined,\r\n };\r\n }\r\n\r\n // executePageRequest only produces a bundle after createHttp ran for the\r\n // match, so the captured entry is present whenever the bundle is.\r\n return rendered;\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region ../web/src/server/resolve-route-validation-input.ts
|
|
2
|
-
function
|
|
2
|
+
function resolvePageValidationInput(request) {
|
|
3
3
|
return {
|
|
4
4
|
params: request.params ?? {},
|
|
5
5
|
query: request.query ?? {}
|
|
@@ -7,5 +7,5 @@ function resolveRouteValidationInput(request) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
//#endregion
|
|
10
|
-
export {
|
|
10
|
+
export { resolvePageValidationInput };
|
|
11
11
|
//# sourceMappingURL=resolve-route-validation-input.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-route-validation-input.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-route-validation-input.ts"],"sourcesContent":["/**\n * Stage 4b's input — what a page's `route.validate` schema is handed.\n *\n * Deliberately the request's OWN `params`/`query`, kept as two separate keys,\n * never merged into one bag: canon `b79c4f55`, point 1. Merging them would\n * invent a collision between a `:id` path segment and a `?id=` query key that\n * nobody asked for and that a schema author cannot see coming from reading\n * their own schema.\n */\n\nexport type
|
|
1
|
+
{"version":3,"file":"resolve-route-validation-input.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-route-validation-input.ts"],"sourcesContent":["/**\n * Stage 4b's input — what a page's `route.validate` schema is handed.\n *\n * Deliberately the request's OWN `params`/`query`, kept as two separate keys,\n * never merged into one bag: canon `b79c4f55`, point 1. Merging them would\n * invent a collision between a `:id` path segment and a `?id=` query key that\n * nobody asked for and that a schema author cannot see coming from reading\n * their own schema.\n */\n\nexport type PageValidationRequest = {\n params?: Record<string, unknown>;\n query?: Record<string, unknown>;\n};\n\nexport type PageValidationInput = {\n params: Record<string, unknown>;\n query: Record<string, unknown>;\n};\n\nexport function resolvePageValidationInput(request: PageValidationRequest): PageValidationInput {\n return { params: request.params ?? {}, query: request.query ?? {} };\n}\n"],"mappings":";AAoBA,SAAgB,2BAA2B,SAAqD;CAC9F,OAAO;EAAE,QAAQ,QAAQ,UAAU,CAAC;EAAG,OAAO,QAAQ,SAAS,CAAC;CAAE;AACpE"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region ../web/src/server/resolve-validation-data.ts
|
|
2
|
-
/** The four names a page may list in `validation.validating`. */
|
|
2
|
+
/** The four names a legacy page may list in `validation.validating`. */
|
|
3
3
|
const SOURCES = {
|
|
4
4
|
body: (request) => request.body,
|
|
5
5
|
query: (request) => request.query,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-validation-data.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-validation-data.ts"],"sourcesContent":["/**\n * Stage 4's input — what the page's `validation.schema` is handed.\n *\n * ## One request, one parse, one grammar\n *\n * This used to draw its four sources from **two** places. `body` and `headers`\n * came from the request; `query` and `params` came from a match object that\n * `execute-page-request.ts` built by re-parsing the URL itself with\n * `Object.fromEntries(new URLSearchParams(...))`.\n *\n * Core's parser is bracket-aware and preserves repeated keys. That one is\n * neither:\n *\n * ```\n * ?tags=a&tags=b&filter[status]=active\n * core → { tags: [\"a\",\"b\"], filter: { status: \"active\" } }\n * match → { tags: \"b\", \"filter[status]\": \"active\" }\n * ```\n *\n * **So a page's validation rules were checking different data than its loader\n * received.** A rule on `filter.status` could never fire — validation saw a key\n * literally named `filter[status]` — and where a key was sent twice, only the\n * last value was ever examined. A field the author believed was validated was\n * not, and nothing about that was visible from reading the validation rules.\n *\n * Every source is now the request. Core has already parsed the URL by the time\n * this runs; parsing it a second time can only ever produce a second answer.\n *\n * Mirrors core's `validateAll.ts:8-31`, with the page default: **params +\n * query, not body** — a page load is a GET.\n */\n\nexport type ValidationRequest = {\n body?: Record<string, unknown>;\n headers?: Record<string, unknown>;\n query?: Record<string, unknown>;\n params?: Record<string, unknown>;\n};\n\n/** The four names a page may list in `validation.validating`. */\nconst SOURCES = {\n body: (request: ValidationRequest) => request.body,\n query: (request: ValidationRequest) => request.query,\n params: (request: ValidationRequest) => request.params,\n headers: (request: ValidationRequest) => request.headers,\n} as const;\n
|
|
1
|
+
{"version":3,"file":"resolve-validation-data.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-validation-data.ts"],"sourcesContent":["/**\n * Stage 4's input — what the page's `validation.schema` is handed.\n *\n * ## One request, one parse, one grammar\n *\n * This used to draw its four sources from **two** places. `body` and `headers`\n * came from the request; `query` and `params` came from a match object that\n * `execute-page-request.ts` built by re-parsing the URL itself with\n * `Object.fromEntries(new URLSearchParams(...))`.\n *\n * Core's parser is bracket-aware and preserves repeated keys. That one is\n * neither:\n *\n * ```\n * ?tags=a&tags=b&filter[status]=active\n * core → { tags: [\"a\",\"b\"], filter: { status: \"active\" } }\n * match → { tags: \"b\", \"filter[status]\": \"active\" }\n * ```\n *\n * **So a page's validation rules were checking different data than its loader\n * received.** A rule on `filter.status` could never fire — validation saw a key\n * literally named `filter[status]` — and where a key was sent twice, only the\n * last value was ever examined. A field the author believed was validated was\n * not, and nothing about that was visible from reading the validation rules.\n *\n * Every source is now the request. Core has already parsed the URL by the time\n * this runs; parsing it a second time can only ever produce a second answer.\n *\n * Mirrors core's `validateAll.ts:8-31`, with the page default: **params +\n * query, not body** — a page load is a GET.\n */\n\nexport type ValidationRequest = {\n body?: Record<string, unknown>;\n headers?: Record<string, unknown>;\n query?: Record<string, unknown>;\n params?: Record<string, unknown>;\n};\n\n/** The four names a legacy page may list in `validation.validating`. */\nconst SOURCES = {\n body: (request: ValidationRequest) => request.body,\n query: (request: ValidationRequest) => request.query,\n params: (request: ValidationRequest) => request.params,\n headers: (request: ValidationRequest) => request.headers,\n} as const;\nexport function resolveValidationData(\n validating: readonly string[] | undefined,\n request: ValidationRequest,\n): Record<string, unknown>;\nexport function resolveValidationData(\n validating: readonly string[] | undefined,\n request: ValidationRequest,\n): Record<string, unknown> {\n // The page default. `params` last so a route parameter wins a query key of\n // the same name — the URL path is the more authoritative of the two.\n if (!validating || validating.length === 0) {\n return { ...request.query, ...request.params };\n }\n\n let data: Record<string, unknown> = {};\n\n for (const source of validating) {\n const read = SOURCES[source as keyof typeof SOURCES];\n if (read) data = { ...data, ...(read(request) ?? {}) };\n }\n\n return data;\n}\n"],"mappings":";;AAwCA,MAAM,UAAU;CACd,OAAO,YAA+B,QAAQ;CAC9C,QAAQ,YAA+B,QAAQ;CAC/C,SAAS,YAA+B,QAAQ;CAChD,UAAU,YAA+B,QAAQ;AACnD;AAKA,SAAgB,sBACd,YACA,SACyB;CAGzB,IAAI,CAAC,cAAc,WAAW,WAAW,GACvC,OAAO;EAAE,GAAG,QAAQ;EAAO,GAAG,QAAQ;CAAO;CAG/C,IAAI,OAAgC,CAAC;CAErC,KAAK,MAAM,UAAU,YAAY;EAC/B,MAAM,OAAO,QAAQ;EACrB,IAAI,MAAM,OAAO;GAAE,GAAG;GAAM,GAAI,KAAK,OAAO,KAAK,CAAC;EAAG;CACvD;CAEA,OAAO;AACT"}
|
|
@@ -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
|
/**
|
|
@@ -48,6 +48,18 @@ const STYLE_EXTENSIONS = [
|
|
|
48
48
|
* page. `?direct` is what makes Vite reply with real `text/css`.
|
|
49
49
|
*/
|
|
50
50
|
const VITE_DIRECT_CSS_QUERY = "?direct";
|
|
51
|
+
/** Walk one stylesheet dependency graph, preserving each root's load order. */
|
|
52
|
+
function collectStylesheetGraph(roots) {
|
|
53
|
+
const stylesheets = [];
|
|
54
|
+
const visit = (node, visited) => {
|
|
55
|
+
if (visited.has(node.id)) return;
|
|
56
|
+
visited.add(node.id);
|
|
57
|
+
for (const stylesheet of node.stylesheets) if (!stylesheets.includes(stylesheet)) stylesheets.push(stylesheet);
|
|
58
|
+
for (const imported of node.imports) visit(imported, visited);
|
|
59
|
+
};
|
|
60
|
+
for (const root of roots) visit(root, /* @__PURE__ */ new Set());
|
|
61
|
+
return stylesheets;
|
|
62
|
+
}
|
|
51
63
|
/**
|
|
52
64
|
* ONE source file's own directly imported stylesheets, as dev URLs.
|
|
53
65
|
*
|
|
@@ -78,7 +90,7 @@ function devStylesheetUrls(appRoot, sourceFile) {
|
|
|
78
90
|
} catch {
|
|
79
91
|
return [];
|
|
80
92
|
}
|
|
81
|
-
const
|
|
93
|
+
const imports = [];
|
|
82
94
|
const pattern = /\bimport\s*(?:\(\s*)?["']([^"']+)["']/g;
|
|
83
95
|
let match = pattern.exec(source);
|
|
84
96
|
while (match !== null) {
|
|
@@ -89,12 +101,20 @@ function devStylesheetUrls(appRoot, sourceFile) {
|
|
|
89
101
|
const relative = path.relative(appRoot, absolute).split(path.sep).join("/");
|
|
90
102
|
if (!relative.startsWith("..")) {
|
|
91
103
|
const url = `/${relative}${VITE_DIRECT_CSS_QUERY}`;
|
|
92
|
-
|
|
104
|
+
imports.push({
|
|
105
|
+
id: url,
|
|
106
|
+
stylesheets: [url],
|
|
107
|
+
imports: []
|
|
108
|
+
});
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
match = pattern.exec(source);
|
|
96
112
|
}
|
|
97
|
-
return
|
|
113
|
+
return collectStylesheetGraph([{
|
|
114
|
+
id: sourceFile,
|
|
115
|
+
stylesheets: [],
|
|
116
|
+
imports
|
|
117
|
+
}]);
|
|
98
118
|
}
|
|
99
119
|
/**
|
|
100
120
|
* ONE handler's whole dev CSS chain: every `sourceFiles` member's own direct
|
|
@@ -106,10 +126,43 @@ function devStylesheetUrls(appRoot, sourceFile) {
|
|
|
106
126
|
* `app.css` imported by both the root and a page must still produce one
|
|
107
127
|
* `<link>`, not two.
|
|
108
128
|
*/
|
|
109
|
-
function devHandlerStylesheetUrls(appRoot, sourceFiles) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
129
|
+
function devHandlerStylesheetUrls(appRoot, sourceFiles, moduleGraph) {
|
|
130
|
+
if (moduleGraph === void 0) return collectStylesheetGraph(sourceFiles.map((sourceFile) => ({
|
|
131
|
+
id: sourceFile,
|
|
132
|
+
stylesheets: devStylesheetUrls(appRoot, sourceFile),
|
|
133
|
+
imports: []
|
|
134
|
+
})));
|
|
135
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
136
|
+
const adapt = (module) => {
|
|
137
|
+
const existing = nodes.get(module);
|
|
138
|
+
if (existing !== void 0) return existing;
|
|
139
|
+
const node = {
|
|
140
|
+
id: module.id ?? module.url,
|
|
141
|
+
stylesheets: devModuleStylesheetUrls(appRoot, module),
|
|
142
|
+
imports: []
|
|
143
|
+
};
|
|
144
|
+
nodes.set(module, node);
|
|
145
|
+
node.imports = [...module.importedModules].map(adapt);
|
|
146
|
+
return node;
|
|
147
|
+
};
|
|
148
|
+
return collectStylesheetGraph(sourceFiles.flatMap((sourceFile) => findDevModules(moduleGraph, sourceFile).map(adapt)));
|
|
149
|
+
}
|
|
150
|
+
/** Finds Vite's module node despite its normalized-path cache keys. */
|
|
151
|
+
function findDevModules(moduleGraph, sourceFile) {
|
|
152
|
+
const direct = moduleGraph.getModulesByFile(sourceFile);
|
|
153
|
+
if (direct !== void 0) return [...direct];
|
|
154
|
+
const normalizedSource = path.resolve(sourceFile);
|
|
155
|
+
const nodes = [];
|
|
156
|
+
for (const [file, modules] of moduleGraph.fileToModulesMap) if (path.resolve(file) === normalizedSource) nodes.push(...modules);
|
|
157
|
+
return nodes;
|
|
158
|
+
}
|
|
159
|
+
/** Turns a Vite CSS module into a browser stylesheet URL when it is served by the app. */
|
|
160
|
+
function devModuleStylesheetUrls(appRoot, module) {
|
|
161
|
+
if (module.file === null || module.type !== "css" && !STYLE_EXTENSIONS.includes(path.extname(module.file).toLowerCase())) return [];
|
|
162
|
+
const relative = path.relative(appRoot, module.file);
|
|
163
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return [];
|
|
164
|
+
const url = module.url.split("?", 1)[0];
|
|
165
|
+
return url.startsWith("/") ? [`${url}${VITE_DIRECT_CSS_QUERY}`] : [];
|
|
113
166
|
}
|
|
114
167
|
/**
|
|
115
168
|
* Find the manifest key for an app-root-relative POSIX source id.
|
|
@@ -150,14 +203,20 @@ function findManifestKey(manifest, sourceFile) {
|
|
|
150
203
|
* and a fresh `visited` set per entry is simpler to reason about than one
|
|
151
204
|
* threaded across unrelated chains.
|
|
152
205
|
*/
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
206
|
+
function manifestStylesheetGraph(manifest, key, nodes) {
|
|
207
|
+
const existing = nodes.get(key);
|
|
208
|
+
if (existing !== void 0) return existing;
|
|
156
209
|
const entry = manifest[key];
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
210
|
+
const node = {
|
|
211
|
+
id: key,
|
|
212
|
+
stylesheets: [],
|
|
213
|
+
imports: []
|
|
214
|
+
};
|
|
215
|
+
nodes.set(key, node);
|
|
216
|
+
if (entry === void 0) return node;
|
|
217
|
+
node.stylesheets = Array.isArray(entry.css) ? entry.css.filter((file) => typeof file === "string" && file !== "").map((file) => `/${file}`) : [];
|
|
218
|
+
node.imports = Array.isArray(entry.imports) ? entry.imports.filter((id) => typeof id === "string").map((importedKey) => manifestStylesheetGraph(manifest, importedKey, nodes)) : [];
|
|
219
|
+
return node;
|
|
161
220
|
}
|
|
162
221
|
/**
|
|
163
222
|
* ONE handler's whole production CSS chain.
|
|
@@ -192,15 +251,15 @@ function productionStylesheetUrls(clientDir, sourceFiles) {
|
|
|
192
251
|
return [];
|
|
193
252
|
}
|
|
194
253
|
if (typeof manifest !== "object" || manifest === null) return [];
|
|
195
|
-
const
|
|
254
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
255
|
+
const roots = [];
|
|
196
256
|
for (const sourceFile of sourceFiles) {
|
|
197
257
|
const key = findManifestKey(manifest, sourceFile);
|
|
198
258
|
if (key === void 0) continue;
|
|
199
|
-
|
|
259
|
+
roots.push(manifestStylesheetGraph(manifest, key, nodes));
|
|
200
260
|
}
|
|
201
261
|
const urls = [];
|
|
202
|
-
for (const
|
|
203
|
-
const url = `/${file}`;
|
|
262
|
+
for (const url of collectStylesheetGraph(roots)) {
|
|
204
263
|
if (!url.startsWith(`${"/assets"}/`)) continue;
|
|
205
264
|
if (!urls.includes(url)) urls.push(url);
|
|
206
265
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stylesheet-urls.mjs","names":[],"sources":["../../../../../../../web/src/server/stylesheet-urls.ts"],"sourcesContent":["/**\n * Which stylesheets a REGISTERED HANDLER 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 SCOPE IS PER HANDLER, NOT PER APPLICATION. Every page renders inside\n * `[root, ...outer-to-inner matched layouts, page]` — that is the exact triple\n * (widened to a chain) `create-page-route-handler.ts` loads per request — so a\n * handler's CSS is the ordered, deduped union of what those specific source\n * files pull in, and nothing else. Collecting across the WHOLE application\n * (every page's manifest entry, every root import) is the bug this shape\n * exists to avoid: it ships page B's stylesheet on page A's response, and it\n * only gets worse as an application grows.\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, matching each source file's own\n * id and walking its recorded `css` and imported chunks.\n * - DEV has no manifest — Vite serves modules on demand — so the URLs are\n * derived from each source file's own import statements.\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 * 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\n/**\n * ONE source file's own directly imported stylesheets, as dev URLs.\n *\n * Dev has no manifest, so the source is the file itself: whatever it imports\n * with a stylesheet extension is what it needs. This is deliberately narrow —\n * it answers \"what CSS does THIS file set up\", not \"what CSS does the whole\n * module graph this file eventually reaches end up pulling in\" — because the\n * latter would require reproducing Vite's module graph, which is precisely\n * what dev has no manifest to shortcut.\n *\n * The narrowness is the honest part: production splits CSS per chunk and can\n * follow imported chunks (`productionStylesheetUrls` below); dev can only read\n * the ONE file handed to it. A page whose own module imports its own\n * stylesheet still gets it in dev — Vite's client graph injects it as before —\n * it simply is not render-blocking the way a chain member's own import is.\n *\n * Called once per chain member — root, then every matched layout outer to\n * inner, then the page — by the installers below, which is what turns \"one\n * file's own imports\" into a handler's whole CSS chain.\n *\n * Specifiers are resolved against `sourceFile` and expressed relative to\n * `appRoot`, because that is the shape Vite's dev server serves from.\n */\nexport function devStylesheetUrls(appRoot: string, sourceFile: string): string[] {\n let source: string;\n\n try {\n source = readFileSync(sourceFile, \"utf-8\");\n } catch {\n return [];\n }\n\n const urls: string[] = [];\n const pattern = /\\bimport\\s*(?:\\(\\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(sourceFile), 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 * ONE handler's whole dev CSS chain: every `sourceFiles` member's own direct\n * stylesheet imports (`devStylesheetUrls`), in the order given — the caller\n * passes `[root, ...outer-to-inner matched layouts, page]` — concatenated and\n * deduped across the WHOLE chain, not just within one file.\n *\n * Cross-file dedup matters as much as within-file dedup: an application-wide\n * `app.css` imported by both the root and a page must still produce one\n * `<link>`, not two.\n */\nexport function devHandlerStylesheetUrls(\n appRoot: string,\n sourceFiles: readonly string[],\n): string[] {\n const urls: string[] = [];\n\n for (const sourceFile of sourceFiles) {\n for (const url of devStylesheetUrls(appRoot, sourceFile)) {\n if (!urls.includes(url)) urls.push(url);\n }\n }\n\n return urls;\n}\n\ntype ManifestEntry = {\n css?: unknown;\n imports?: unknown;\n};\n\n/**\n * Find the manifest key for an app-root-relative POSIX source id.\n *\n * VITE KEYS BY SOURCE PATH RELATIVE TO ITS OWN `root`, not to the app's\n * `appRoot` — the client build's `root` is the framework's own package\n * (`build-client.ts`), so a key for an app source file carries a `../`-laden\n * prefix (`\"../my-app/src/web/root.tsx\"`) rather than matching `sourceFile`\n * (`\"src/web/root.tsx\"`) byte for byte. The two forms always share the same\n * TAIL, though — both are anchored at the same file — so an exact match is\n * tried first (the case where the client build's root IS the app root, which\n * every fixture and every test below uses) and a `/`-boundary suffix match\n * second, rather than trying to reconstruct the build's own root here, which\n * this runtime read has no way to independently confirm.\n */\nfunction findManifestKey(\n manifest: Record<string, ManifestEntry | undefined>,\n sourceFile: string,\n): string | undefined {\n if (manifest[sourceFile] !== undefined) return sourceFile;\n\n const suffix = `/${sourceFile}`;\n\n for (const key of Object.keys(manifest)) {\n if (key.endsWith(suffix)) return key;\n }\n\n return undefined;\n}\n\n/**\n * Every stylesheet reachable from ONE manifest entry: its own recorded `css`,\n * plus the same walk repeated over every chunk it STATICALLY `imports`.\n *\n * `imports` only, never `dynamicImports`. Vite's manifest records\n * `dynamicImports` on shared entry points (the hydration entry names every\n * page as one) precisely because the browser must NOT download them eagerly —\n * walking that array here would pull every other page's CSS onto this one,\n * which is the exact \"unrelated pages\" leak this module exists to end.\n * `imports`, by contrast, are chunks THIS module synchronously depends on:\n * code Vite split out of it but that loads whenever it does, so their CSS is\n * this handler's CSS too.\n *\n * `visited` guards against a chunk graph cycle; sharing one set across the\n * whole walk from a single entry is enough; a shared chunk revisited from a\n * SEPARATE top-level entry (root vs. a layout vs. the page) is deliberately\n * walked again — the final merge in `productionStylesheetUrls` dedupes by URL,\n * and a fresh `visited` set per entry is simpler to reason about than one\n * threaded across unrelated chains.\n */\nfunction collectManifestCss(\n manifest: Record<string, ManifestEntry | undefined>,\n key: string,\n visited: Set<string>,\n): string[] {\n if (visited.has(key)) return [];\n visited.add(key);\n\n const entry = manifest[key];\n if (entry === undefined) return [];\n\n const ownCss = Array.isArray(entry.css)\n ? entry.css.filter((file): file is string => typeof file === \"string\" && file !== \"\")\n : [];\n\n const imports = Array.isArray(entry.imports)\n ? entry.imports.filter((id): id is string => typeof id === \"string\")\n : [];\n\n return [\n ...ownCss,\n ...imports.flatMap((importedKey) => collectManifestCss(manifest, importedKey, visited)),\n ];\n}\n\n/**\n * ONE handler's whole production CSS chain.\n *\n * `sourceFiles` is `[root, ...outer-to-inner matched layouts, page]`, each an\n * app-root-relative POSIX source id — the SAME id\n * `install-page-routes-from-manifest.ts` already carries as `sourceFile` on\n * every manifest entry, because that identity is what lets this function match\n * EXPLICITLY rather than guess: every id is looked up on its own\n * (`findManifestKey`), its own chunk's CSS is collected\n * (`collectManifestCss`), and an id with no matching entry contributes\n * nothing — it is never treated as license to fall back to scanning the whole\n * manifest, which is what let an unrelated page's CSS leak onto this handler\n * before.\n *\n * Duplicates are collapsed and order is preserved across the WHOLE chain, in\n * the order `sourceFiles` was given — root's own CSS first, then each\n * layout's outer to inner, then the page's — so cascade order matches the\n * chain's own outer-to-inner rendering order.\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(\n clientDir: string,\n sourceFiles: readonly string[],\n): 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 files: string[] = [];\n\n for (const sourceFile of sourceFiles) {\n const key = findManifestKey(manifest, sourceFile);\n if (key === undefined) continue;\n\n files.push(...collectManifestCss(manifest, key, new Set()));\n }\n\n const urls: string[] = [];\n\n for (const file of files) {\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 return urls;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,mBAAmB;CAAC;CAAQ;CAAS;CAAS;CAAS;AAAO;;;;;;;;;;AAWpE,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;AAyBrC,SAAgB,kBAAkB,SAAiB,YAA8B;CAC/E,IAAI;CAEJ,IAAI;EACF,SAAS,aAAa,YAAY,OAAO;CAC3C,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,UAAU,GAAG,SAAS;GACjE,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;;;;;;;;;;;AAYA,SAAgB,yBACd,SACA,aACU;CACV,MAAM,OAAiB,CAAC;CAExB,KAAK,MAAM,cAAc,aACvB,KAAK,MAAM,OAAO,kBAAkB,SAAS,UAAU,GACrD,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;CAI1C,OAAO;AACT;;;;;;;;;;;;;;;AAqBA,SAAS,gBACP,UACA,YACoB;CACpB,IAAI,SAAS,gBAAgB,QAAW,OAAO;CAE/C,MAAM,SAAS,IAAI;CAEnB,KAAK,MAAM,OAAO,OAAO,KAAK,QAAQ,GACpC,IAAI,IAAI,SAAS,MAAM,GAAG,OAAO;AAIrC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAS,mBACP,UACA,KACA,SACU;CACV,IAAI,QAAQ,IAAI,GAAG,GAAG,OAAO,CAAC;CAC9B,QAAQ,IAAI,GAAG;CAEf,MAAM,QAAQ,SAAS;CACvB,IAAI,UAAU,QAAW,OAAO,CAAC;CAEjC,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,IAClC,MAAM,IAAI,QAAQ,SAAyB,OAAO,SAAS,YAAY,SAAS,EAAE,IAClF,CAAC;CAEL,MAAM,UAAU,MAAM,QAAQ,MAAM,OAAO,IACvC,MAAM,QAAQ,QAAQ,OAAqB,OAAO,OAAO,QAAQ,IACjE,CAAC;CAEL,OAAO,CACL,GAAG,QACH,GAAG,QAAQ,SAAS,gBAAgB,mBAAmB,UAAU,aAAa,OAAO,CAAC,CACxF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,yBACd,WACA,aACU;CACV,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,QAAkB,CAAC;CAEzB,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,MAAM,gBAAgB,UAAU,UAAU;EAChD,IAAI,QAAQ,QAAW;EAEvB,MAAM,KAAK,GAAG,mBAAmB,UAAU,qBAAK,IAAI,IAAI,CAAC,CAAC;CAC5D;CAEA,MAAM,OAAiB,CAAC;CAExB,KAAK,MAAM,QAAQ,OAAO;EAMxB,MAAM,MAAM,IAAI;EAKhB,IAAI,CAAC,IAAI,WAAW,aAA2B,EAAE,GAAG;EAEpD,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;CACxC;CAEA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"stylesheet-urls.mjs","names":[],"sources":["../../../../../../../web/src/server/stylesheet-urls.ts"],"sourcesContent":["/**\n * Which stylesheets a REGISTERED HANDLER 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 SCOPE IS PER HANDLER, NOT PER APPLICATION. Every page renders inside\n * `[root, ...outer-to-inner matched layouts, page]` — that is the exact triple\n * (widened to a chain) `create-page-route-handler.ts` loads per request — so a\n * handler's CSS is the ordered, deduped union of what those specific source\n * files pull in, and nothing else. Collecting across the WHOLE application\n * (every page's manifest entry, every root import) is the bug this shape\n * exists to avoid: it ships page B's stylesheet on page A's response, and it\n * only gets worse as an application grows.\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, matching each source file's own\n * id and walking its recorded `css` and imported chunks.\n * - DEV has no manifest — Vite serves modules on demand — so the URLs are\n * derived from each source file's own import statements.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { ModuleGraph, ModuleNode } from \"vite\";\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 * 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 StylesheetGraphNode = {\n id: string;\n stylesheets: string[];\n imports: StylesheetGraphNode[];\n};\n\n/** Walk one stylesheet dependency graph, preserving each root's load order. */\nfunction collectStylesheetGraph(roots: readonly StylesheetGraphNode[]): string[] {\n const stylesheets: string[] = [];\n\n const visit = (node: StylesheetGraphNode, visited: Set<string>) => {\n if (visited.has(node.id)) return;\n visited.add(node.id);\n\n for (const stylesheet of node.stylesheets) {\n if (!stylesheets.includes(stylesheet)) stylesheets.push(stylesheet);\n }\n\n for (const imported of node.imports) visit(imported, visited);\n };\n\n for (const root of roots) visit(root, new Set());\n\n return stylesheets;\n}\n\n/**\n * ONE source file's own directly imported stylesheets, as dev URLs.\n *\n * Dev has no manifest, so the source is the file itself: whatever it imports\n * with a stylesheet extension is what it needs. This is deliberately narrow —\n * it answers \"what CSS does THIS file set up\", not \"what CSS does the whole\n * module graph this file eventually reaches end up pulling in\" — because the\n * latter would require reproducing Vite's module graph, which is precisely\n * what dev has no manifest to shortcut.\n *\n * The narrowness is the honest part: production splits CSS per chunk and can\n * follow imported chunks (`productionStylesheetUrls` below); dev can only read\n * the ONE file handed to it. A page whose own module imports its own\n * stylesheet still gets it in dev — Vite's client graph injects it as before —\n * it simply is not render-blocking the way a chain member's own import is.\n *\n * Called once per chain member — root, then every matched layout outer to\n * inner, then the page — by the installers below, which is what turns \"one\n * file's own imports\" into a handler's whole CSS chain.\n *\n * Specifiers are resolved against `sourceFile` and expressed relative to\n * `appRoot`, because that is the shape Vite's dev server serves from.\n */\nexport function devStylesheetUrls(appRoot: string, sourceFile: string): string[] {\n let source: string;\n\n try {\n source = readFileSync(sourceFile, \"utf-8\");\n } catch {\n return [];\n }\n\n const imports: StylesheetGraphNode[] = [];\n const pattern = /\\bimport\\s*(?:\\(\\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(sourceFile), 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 imports.push({ id: url, stylesheets: [url], imports: [] });\n }\n }\n\n match = pattern.exec(source);\n }\n\n return collectStylesheetGraph([{ id: sourceFile, stylesheets: [], imports }]);\n}\n\n/**\n * ONE handler's whole dev CSS chain: every `sourceFiles` member's own direct\n * stylesheet imports (`devStylesheetUrls`), in the order given — the caller\n * passes `[root, ...outer-to-inner matched layouts, page]` — concatenated and\n * deduped across the WHOLE chain, not just within one file.\n *\n * Cross-file dedup matters as much as within-file dedup: an application-wide\n * `app.css` imported by both the root and a page must still produce one\n * `<link>`, not two.\n */\nexport function devHandlerStylesheetUrls(\n appRoot: string,\n sourceFiles: readonly string[],\n moduleGraph?: ModuleGraph,\n): string[] {\n if (moduleGraph === undefined) {\n return collectStylesheetGraph(\n sourceFiles.map((sourceFile) => ({\n id: sourceFile,\n stylesheets: devStylesheetUrls(appRoot, sourceFile),\n imports: [],\n })),\n );\n }\n\n const nodes = new Map<ModuleNode, StylesheetGraphNode>();\n\n const adapt = (module: ModuleNode): StylesheetGraphNode => {\n const existing = nodes.get(module);\n if (existing !== undefined) return existing;\n\n const node: StylesheetGraphNode = {\n id: module.id ?? module.url,\n stylesheets: devModuleStylesheetUrls(appRoot, module),\n imports: [],\n };\n nodes.set(module, node);\n node.imports = [...module.importedModules].map(adapt);\n return node;\n };\n\n return collectStylesheetGraph(\n sourceFiles.flatMap((sourceFile) => findDevModules(moduleGraph, sourceFile).map(adapt)),\n );\n}\n\n/** Finds Vite's module node despite its normalized-path cache keys. */\nfunction findDevModules(moduleGraph: ModuleGraph, sourceFile: string): ModuleNode[] {\n const direct = moduleGraph.getModulesByFile(sourceFile);\n if (direct !== undefined) return [...direct];\n\n const normalizedSource = path.resolve(sourceFile);\n const nodes: ModuleNode[] = [];\n\n for (const [file, modules] of moduleGraph.fileToModulesMap) {\n if (path.resolve(file) === normalizedSource) nodes.push(...modules);\n }\n\n return nodes;\n}\n\n/** Turns a Vite CSS module into a browser stylesheet URL when it is served by the app. */\nfunction devModuleStylesheetUrls(appRoot: string, module: ModuleNode): string[] {\n if (\n module.file === null ||\n (module.type !== \"css\" && !STYLE_EXTENSIONS.includes(path.extname(module.file).toLowerCase()))\n ) {\n return [];\n }\n\n const relative = path.relative(appRoot, module.file);\n if (relative.startsWith(\"..\") || path.isAbsolute(relative)) return [];\n\n const url = module.url.split(\"?\", 1)[0];\n return url.startsWith(\"/\") ? [`${url}${VITE_DIRECT_CSS_QUERY}`] : [];\n}\n\ntype ManifestEntry = {\n css?: unknown;\n imports?: unknown;\n};\n\n/**\n * Find the manifest key for an app-root-relative POSIX source id.\n *\n * VITE KEYS BY SOURCE PATH RELATIVE TO ITS OWN `root`, not to the app's\n * `appRoot` — the client build's `root` is the framework's own package\n * (`build-client.ts`), so a key for an app source file carries a `../`-laden\n * prefix (`\"../my-app/src/web/root.tsx\"`) rather than matching `sourceFile`\n * (`\"src/web/root.tsx\"`) byte for byte. The two forms always share the same\n * TAIL, though — both are anchored at the same file — so an exact match is\n * tried first (the case where the client build's root IS the app root, which\n * every fixture and every test below uses) and a `/`-boundary suffix match\n * second, rather than trying to reconstruct the build's own root here, which\n * this runtime read has no way to independently confirm.\n */\nfunction findManifestKey(\n manifest: Record<string, ManifestEntry | undefined>,\n sourceFile: string,\n): string | undefined {\n if (manifest[sourceFile] !== undefined) return sourceFile;\n\n const suffix = `/${sourceFile}`;\n\n for (const key of Object.keys(manifest)) {\n if (key.endsWith(suffix)) return key;\n }\n\n return undefined;\n}\n\n/**\n * Every stylesheet reachable from ONE manifest entry: its own recorded `css`,\n * plus the same walk repeated over every chunk it STATICALLY `imports`.\n *\n * `imports` only, never `dynamicImports`. Vite's manifest records\n * `dynamicImports` on shared entry points (the hydration entry names every\n * page as one) precisely because the browser must NOT download them eagerly —\n * walking that array here would pull every other page's CSS onto this one,\n * which is the exact \"unrelated pages\" leak this module exists to end.\n * `imports`, by contrast, are chunks THIS module synchronously depends on:\n * code Vite split out of it but that loads whenever it does, so their CSS is\n * this handler's CSS too.\n *\n * `visited` guards against a chunk graph cycle; sharing one set across the\n * whole walk from a single entry is enough; a shared chunk revisited from a\n * SEPARATE top-level entry (root vs. a layout vs. the page) is deliberately\n * walked again — the final merge in `productionStylesheetUrls` dedupes by URL,\n * and a fresh `visited` set per entry is simpler to reason about than one\n * threaded across unrelated chains.\n */\nfunction manifestStylesheetGraph(\n manifest: Record<string, ManifestEntry | undefined>,\n key: string,\n nodes: Map<string, StylesheetGraphNode>,\n): StylesheetGraphNode {\n const existing = nodes.get(key);\n if (existing !== undefined) return existing;\n\n const entry = manifest[key];\n const node: StylesheetGraphNode = { id: key, stylesheets: [], imports: [] };\n nodes.set(key, node);\n\n if (entry === undefined) return node;\n\n node.stylesheets = Array.isArray(entry.css)\n ? entry.css\n .filter((file): file is string => typeof file === \"string\" && file !== \"\")\n .map((file) => `/${file}`)\n : [];\n node.imports = Array.isArray(entry.imports)\n ? entry.imports\n .filter((id): id is string => typeof id === \"string\")\n .map((importedKey) => manifestStylesheetGraph(manifest, importedKey, nodes))\n : [];\n\n return node;\n}\n\n/**\n * ONE handler's whole production CSS chain.\n *\n * `sourceFiles` is `[root, ...outer-to-inner matched layouts, page]`, each an\n * app-root-relative POSIX source id — the SAME id\n * `install-page-routes-from-manifest.ts` already carries as `sourceFile` on\n * every manifest entry, because that identity is what lets this function match\n * EXPLICITLY rather than guess: every id is looked up on its own\n * (`findManifestKey`), its own chunk's CSS is collected\n * (`collectManifestCss`), and an id with no matching entry contributes\n * nothing — it is never treated as license to fall back to scanning the whole\n * manifest, which is what let an unrelated page's CSS leak onto this handler\n * before.\n *\n * Duplicates are collapsed and order is preserved across the WHOLE chain, in\n * the order `sourceFiles` was given — root's own CSS first, then each\n * layout's outer to inner, then the page's — so cascade order matches the\n * chain's own outer-to-inner rendering order.\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(\n clientDir: string,\n sourceFiles: readonly string[],\n): 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 nodes = new Map<string, StylesheetGraphNode>();\n const roots: StylesheetGraphNode[] = [];\n\n for (const sourceFile of sourceFiles) {\n const key = findManifestKey(manifest, sourceFile);\n if (key === undefined) continue;\n\n roots.push(manifestStylesheetGraph(manifest, key, nodes));\n }\n\n const urls: string[] = [];\n\n for (const url of collectStylesheetGraph(roots)) {\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 // 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 return urls;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAM,mBAAmB;CAAC;CAAQ;CAAS;CAAS;CAAS;AAAO;;;;;;;;;;AAWpE,MAAa,wBAAwB;;AASrC,SAAS,uBAAuB,OAAiD;CAC/E,MAAM,cAAwB,CAAC;CAE/B,MAAM,SAAS,MAA2B,YAAyB;EACjE,IAAI,QAAQ,IAAI,KAAK,EAAE,GAAG;EAC1B,QAAQ,IAAI,KAAK,EAAE;EAEnB,KAAK,MAAM,cAAc,KAAK,aAC5B,IAAI,CAAC,YAAY,SAAS,UAAU,GAAG,YAAY,KAAK,UAAU;EAGpE,KAAK,MAAM,YAAY,KAAK,SAAS,MAAM,UAAU,OAAO;CAC9D;CAEA,KAAK,MAAM,QAAQ,OAAO,MAAM,sBAAM,IAAI,IAAI,CAAC;CAE/C,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,kBAAkB,SAAiB,YAA8B;CAC/E,IAAI;CAEJ,IAAI;EACF,SAAS,aAAa,YAAY,OAAO;CAC3C,QAAQ;EACN,OAAO,CAAC;CACV;CAEA,MAAM,UAAiC,CAAC;CACxC,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,UAAU,GAAG,SAAS;GACjE,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,QAAQ,KAAK;KAAE,IAAI;KAAK,aAAa,CAAC,GAAG;KAAG,SAAS,CAAC;IAAE,CAAC;GAC3D;EACF;EAEA,QAAQ,QAAQ,KAAK,MAAM;CAC7B;CAEA,OAAO,uBAAuB,CAAC;EAAE,IAAI;EAAY,aAAa,CAAC;EAAG;CAAQ,CAAC,CAAC;AAC9E;;;;;;;;;;;AAYA,SAAgB,yBACd,SACA,aACA,aACU;CACV,IAAI,gBAAgB,QAClB,OAAO,uBACL,YAAY,KAAK,gBAAgB;EAC/B,IAAI;EACJ,aAAa,kBAAkB,SAAS,UAAU;EAClD,SAAS,CAAC;CACZ,EAAE,CACJ;CAGF,MAAM,wBAAQ,IAAI,IAAqC;CAEvD,MAAM,SAAS,WAA4C;EACzD,MAAM,WAAW,MAAM,IAAI,MAAM;EACjC,IAAI,aAAa,QAAW,OAAO;EAEnC,MAAM,OAA4B;GAChC,IAAI,OAAO,MAAM,OAAO;GACxB,aAAa,wBAAwB,SAAS,MAAM;GACpD,SAAS,CAAC;EACZ;EACA,MAAM,IAAI,QAAQ,IAAI;EACtB,KAAK,UAAU,CAAC,GAAG,OAAO,eAAe,CAAC,CAAC,IAAI,KAAK;EACpD,OAAO;CACT;CAEA,OAAO,uBACL,YAAY,SAAS,eAAe,eAAe,aAAa,UAAU,CAAC,CAAC,IAAI,KAAK,CAAC,CACxF;AACF;;AAGA,SAAS,eAAe,aAA0B,YAAkC;CAClF,MAAM,SAAS,YAAY,iBAAiB,UAAU;CACtD,IAAI,WAAW,QAAW,OAAO,CAAC,GAAG,MAAM;CAE3C,MAAM,mBAAmB,KAAK,QAAQ,UAAU;CAChD,MAAM,QAAsB,CAAC;CAE7B,KAAK,MAAM,CAAC,MAAM,YAAY,YAAY,kBACxC,IAAI,KAAK,QAAQ,IAAI,MAAM,kBAAkB,MAAM,KAAK,GAAG,OAAO;CAGpE,OAAO;AACT;;AAGA,SAAS,wBAAwB,SAAiB,QAA8B;CAC9E,IACE,OAAO,SAAS,QACf,OAAO,SAAS,SAAS,CAAC,iBAAiB,SAAS,KAAK,QAAQ,OAAO,IAAI,CAAC,CAAC,YAAY,CAAC,GAE5F,OAAO,CAAC;CAGV,MAAM,WAAW,KAAK,SAAS,SAAS,OAAO,IAAI;CACnD,IAAI,SAAS,WAAW,IAAI,KAAK,KAAK,WAAW,QAAQ,GAAG,OAAO,CAAC;CAEpE,MAAM,MAAM,OAAO,IAAI,MAAM,KAAK,CAAC,CAAC,CAAC;CACrC,OAAO,IAAI,WAAW,GAAG,IAAI,CAAC,GAAG,MAAM,uBAAuB,IAAI,CAAC;AACrE;;;;;;;;;;;;;;;AAqBA,SAAS,gBACP,UACA,YACoB;CACpB,IAAI,SAAS,gBAAgB,QAAW,OAAO;CAE/C,MAAM,SAAS,IAAI;CAEnB,KAAK,MAAM,OAAO,OAAO,KAAK,QAAQ,GACpC,IAAI,IAAI,SAAS,MAAM,GAAG,OAAO;AAIrC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAS,wBACP,UACA,KACA,OACqB;CACrB,MAAM,WAAW,MAAM,IAAI,GAAG;CAC9B,IAAI,aAAa,QAAW,OAAO;CAEnC,MAAM,QAAQ,SAAS;CACvB,MAAM,OAA4B;EAAE,IAAI;EAAK,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1E,MAAM,IAAI,KAAK,IAAI;CAEnB,IAAI,UAAU,QAAW,OAAO;CAEhC,KAAK,cAAc,MAAM,QAAQ,MAAM,GAAG,IACtC,MAAM,IACH,QAAQ,SAAyB,OAAO,SAAS,YAAY,SAAS,EAAE,CAAC,CACzE,KAAK,SAAS,IAAI,MAAM,IAC3B,CAAC;CACL,KAAK,UAAU,MAAM,QAAQ,MAAM,OAAO,IACtC,MAAM,QACH,QAAQ,OAAqB,OAAO,OAAO,QAAQ,CAAC,CACpD,KAAK,gBAAgB,wBAAwB,UAAU,aAAa,KAAK,CAAC,IAC7E,CAAC;CAEL,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,yBACd,WACA,aACU;CACV,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,wBAAQ,IAAI,IAAiC;CACnD,MAAM,QAA+B,CAAC;CAEtC,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,MAAM,gBAAgB,UAAU,UAAU;EAChD,IAAI,QAAQ,QAAW;EAEvB,MAAM,KAAK,wBAAwB,UAAU,KAAK,KAAK,CAAC;CAC1D;CAEA,MAAM,OAAiB,CAAC;CAExB,KAAK,MAAM,OAAO,uBAAuB,KAAK,GAAG;EAS/C,IAAI,CAAC,IAAI,WAAW,aAA2B,EAAE,GAAG;EAEpD,IAAI,CAAC,KAAK,SAAS,GAAG,GAAG,KAAK,KAAK,GAAG;CACxC;CAEA,OAAO;AACT"}
|
|
@@ -14,8 +14,8 @@ import { pageRouteSourceFiles, pageRoutesNeedReplacement, registeredPageFiles }
|
|
|
14
14
|
import { registerProductionPublicFiles } from "./register-production-public-files.mjs";
|
|
15
15
|
import { createUnregisteredPageReporter } from "./unregistered-pages.mjs";
|
|
16
16
|
import { Application, BaseConnector, ConnectorLifecyclePhase, container, requestContext, router } from "@warlock.js/core";
|
|
17
|
-
import fs from "node:fs";
|
|
18
17
|
import path from "node:path";
|
|
18
|
+
import fs from "node:fs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
21
|
//#region ../web/src/server/web-connector.ts
|
|
@@ -260,8 +260,8 @@ var WebConnector = class extends BaseConnector {
|
|
|
260
260
|
vite: this.vite,
|
|
261
261
|
appSrcRoot: paths.appSrcRoot,
|
|
262
262
|
appFile: paths.appFile,
|
|
263
|
+
appRoot: paths.appRoot,
|
|
263
264
|
hydrationClientModuleUrl: this.resolveHydrationClientModuleUrl(paths.webRoot),
|
|
264
|
-
stylesheetUrls: webServerSsr.devStylesheetUrls(paths.appRoot, paths.appFile),
|
|
265
265
|
httpServer: fastify
|
|
266
266
|
});
|
|
267
267
|
this.installedPages = await this.installDevPageRoutes();
|