@warlock.js/web 5.4.0 → 5.6.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 +33 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +127 -205
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +5 -3
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/public-files.mjs +29 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +23 -103
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +10 -3
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +35 -2
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +6 -6
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +87 -23
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +10 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +2 -2
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -2
- package/esm/server/index.mjs +2 -2
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +43 -40
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +22 -2
- package/esm/server/install-page-routes.mjs +108 -42
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +25 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +11 -48
- package/esm/server/render-page.mjs +13 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/route-validation-error.mjs +32 -0
- package/esm/server/route-validation-error.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +24 -321
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +31 -70
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +5 -7
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +564 -485
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/add-web-to-an-app/SKILL.md +3 -3
- package/skills/create-a-page/SKILL.md +348 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -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\";\nimport { LocaleProvider } from \"../localization\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\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 \"../runtime/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 — `renderPage` calls `executePageRequest` and\r\n * everything here consumes its bundle as-is.\r\n *\r\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\r\n * the production orchestrator AND the test helper. Because a loader IS a\r\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\r\n * returns `{ html, status, headers, data }` in one call — asserting a page's\r\n * data and its response headers is a unit test, no browser, no server boot.\r\n */\r\n\r\n// ---------------------------------------------------------------------------\r\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\r\n// ---------------------------------------------------------------------------\r\n\r\nexport type PageRoutesRegistry = {\r\n routes: readonly PageRouteEntry[];\r\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n};\r\n\r\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\r\n\r\n/**\r\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\r\n * without each call site carrying the manifest. Returns the previous registry\r\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\r\n */\r\nexport function connectPageRoutes(\r\n registry: PageRoutesRegistry | undefined,\r\n): PageRoutesRegistry | undefined {\r\n const previous = pageRoutesRegistry;\r\n pageRoutesRegistry = registry;\r\n return previous;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// renderPage surface\r\n// ---------------------------------------------------------------------------\r\n\r\nexport type RenderPageOptions = {\r\n params?: Record<string, string>;\r\n query?: Record<string, string>;\r\n /** Per-call overrides of the connected registry (tests, mostly). */\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\n/**\r\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\r\n * name-based sugar buildUrl consumes) have no meaning here — everything else\r\n * is the same seam.\r\n */\r\nexport type RenderPageRequestOptions = Omit<RenderPageOptions, \"params\" | \"query\">;\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 * `renderPage` always carries one (its no-match throws instead).\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(\r\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\r\n): PageRoutesRegistry {\r\n const routes = options.routes ?? pageRoutesRegistry?.routes;\r\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\r\n\r\n if (!routes || !createHttp) {\r\n throw new Error(\r\n \"renderPage()/renderPageRequest() has no route registry connected \" +\r\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\r\n \"manifest, which the server bootstrap owns. Fix: \" +\r\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\r\n \"beforeAll), or pass { routes, createHttp } to this call.\",\r\n );\r\n }\r\n\r\n return { routes, createHttp };\r\n}\r\n\r\nfunction buildUrl(\r\n entry: PageRouteEntry,\r\n params: Record<string, string>,\r\n query: Record<string, string>,\r\n): string {\r\n const path = entry.path\r\n .split(\"/\")\r\n .map((segment) => {\r\n if (!segment.startsWith(\":\")) return segment;\r\n\r\n const name = segment.slice(1);\r\n const value = params[name];\r\n\r\n if (value === undefined) {\r\n throw new Error(\r\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\r\n `param \"${name}\" and the call did not provide it ` +\r\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\r\n `\\`params: { ${name}: … }\\`.`,\r\n );\r\n }\r\n\r\n return encodeURIComponent(value);\r\n })\r\n .join(\"/\");\r\n\r\n const queryString = new URLSearchParams(query).toString();\r\n\r\n return queryString ? `${path}?${queryString}` : path;\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: PageRoutesRegistry): {\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 = {\n nonce?: string;\n locale: string;\n};\n\n/** Reads document slots directly from core's Request. */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n if (captured === undefined) {\n throw new Error(\"The page pipeline reached rendering without its request context.\");\n }\n\n return { nonce: captured.request.nonce, locale: captured.request.locale };\n}\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),\n nonce: documentSlots.nonce,\n lang: documentSlots.locale,\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, {\n locale: documentValue.payload.locale,\n children: element,\n }),\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),\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 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, 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),\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. The boundary's LEVEL only decides which component\r\n // renders, never the status; 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 ? 500 : ((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));\n let value: DocumentContextValue = {\r\n metadata: undefined,\r\n payload: frameworkPayload,\r\n nonce: slots.nonce,\r\n lang: slots.locale,\n };\r\n const renderWithContext = (element: ReactNode): string =>\n renderToString(\n createElement(DocumentContext.Provider, {\n value,\n children: createElement(LocaleProvider, {\n locale: value.payload.locale,\n children: element,\n }),\n }),\n );\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)),\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 orchestrators\r\n// ---------------------------------------------------------------------------\r\n\r\nexport async function renderPage(\r\n routeName: string,\r\n options: RenderPageOptions = {},\r\n): Promise<RenderedPage | Response> {\r\n const registry = requireRegistry(options);\r\n const entry = registry.routes.find((candidate) => candidate.name === routeName);\r\n\r\n if (!entry) {\r\n const known = registry.routes.map((candidate) => `\"${candidate.name}\"`).join(\", \");\r\n\r\n throw new Error(\r\n `renderPage(\"${routeName}\"): no route with that name ` +\r\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\r\n \"Fix: use a name from the manifest, or connect the manifest that \" +\r\n \"declares this one.\",\r\n );\r\n }\r\n\r\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\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 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 throw new Error(\r\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\r\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\r\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\r\n );\r\n }\r\n\r\n return rendered;\r\n}\r\n\r\n/**\r\n * The URL-based sibling of `renderPage` — the production render surface: a\r\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\r\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\r\n * tail renders and emits.\r\n *\r\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\r\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\r\n * `{ html: \"\", status: 404 }` 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 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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-page-metadata.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-page-metadata.ts"],"sourcesContent":["/**\r\n * Stage 8 of the page pipeline — METADATA.\r\n *\r\n * Its own file because the behaviour that matters here is about ERRORS, and\r\n * reaching an error path through the full ten-stage pipeline means standing up\r\n * a real Request/Response pair to observe a `try`/`catch`. See\r\n * `resolve-page-metadata.spec.ts`.\r\n *\r\n * ## Why a page's `metadata` never runs after a failed loader\r\n *\r\n * It used to. `design/request-lifecycle.md` said \"exactly one of data/error\r\n * set\", and stage 8 duly called `metadata({ data: undefined, error })` when a\r\n * loader rejected — while `PageMetadata` declared `data` as always present.\r\n * **The type lied**, so the natural way to write a metadata function was also\r\n * the broken way:\r\n *\r\n * ```ts\r\n * export const metadata: PageMetadata<typeof loader> = ({ data }) => ({\r\n * title: \"Home\",\r\n * description: `${data.products.length} products in stock`,\r\n * });\r\n * ```\r\n *\r\n * All three function-form metadata exports in the reference app were written\r\n * exactly like that, and all three turned a loader's real error — a\r\n * `MissingDataSourceError`, say — into `TypeError: Cannot read properties of\r\n * undefined`, reported against a different file in a different subsystem. The\r\n * cost was never the crash; it was that every future debugging session on a\r\n * failed loader would start by investigating the wrong thing.\r\n *\r\n * The alternative was to widen `data` to `| undefined` and make every author\r\n * handle a path almost none of them care about — and without a discriminant,\r\n * TypeScript cannot narrow `data` from `if (error)` anyway, so authors would\r\n * have reached for `data!` and re-created the lie with extra syntax.\r\n *\r\n * So: a page describes a page it actually has. When there is no data there is\r\n * no page-authored description, and the framework supplies\r\n * {@link ERROR_PAGE_METADATA} instead.\r\n *\r\n * ## Partial failure\r\n *\r\n * Stage 7 runs app, layout and page loaders root-to-leaf, so an ancestor\n * rejection prevents the page loader from starting. `failed` is still the\n * relevant guard here: any recorded error means a boundary renders instead of\n * the page, and metadata describes what is on screen. Describing a page the\n * visitor never received is the same defect in a quieter form.\n *\r\n * If per-page error metadata is ever wanted, it is a separate `errorMetadata`\r\n * export — a distinct signature for a distinct situation, not an arm of this\r\n * one. Nothing has asked for it.\r\n */\r\n\r\nimport type { SharedContext } from \"../index\";\r\nimport type { MetadataOutput, PageMetadata } from \"../metadata\";\r\nimport type { PipelineLoader } from \"./execute-page-request\";\r\n\r\n/**\r\n * What `<head>` gets when the page did not render.\r\n *\r\n * `robots` is the load-bearing member, not `title`. An error render is a\r\n * transient server state that happens to be reachable at a real URL; letting a\r\n * crawler index it puts \"Something went wrong\" in a search result for a page\r\n * that works. `title` is a fallback a boundary is free to improve on.\r\n */\r\nexport const ERROR_PAGE_METADATA: MetadataOutput = Object.freeze({\n title: \"Something went wrong\",\r\n robots: \"noindex\",\r\n});\r\n\r\nexport type ResolvePageMetadataInput = {\r\n /** The page module's `metadata` export — absent, object, or function. */\r\n metadata: PageMetadata<PipelineLoader> | undefined;\r\n /** `bundle.pageData`. Read only when `failed` is false. */\r\n data: unknown;\r\n /** The value an earlier stage already recorded. Diagnostics only. */\r\n error: unknown;\r\n /** Whether an earlier stage recorded an error at all. */\r\n failed: boolean;\r\n shared: Readonly<SharedContext>;\r\n};\r\n\r\nexport type ResolvedPageMetadata = {\r\n metadata: MetadataOutput | undefined;\r\n /**\r\n * Set only when the page's own metadata function threw on the SUCCESS path.\r\n * Returned rather than rethrown so the caller can record it the same way a\r\n * loader throw is recorded — the boundary renders and the framework keeps\r\n * ownership of the status, instead of an exception escaping stage 8.\r\n *\r\n * Never set on the error path: nothing runs there that could throw, and an\r\n * error arriving at stage 8 has already won.\r\n */\r\n thrown?: unknown;\r\n};\r\n\r\n/** The function arm of `PageMetadata`, for the one call this module makes. */\r\ntype PageMetadataFunction = Extract<PageMetadata<PipelineLoader>, (...args: never) => unknown>;\r\n\r\nexport function resolvePageMetadata(input: ResolvePageMetadataInput): ResolvedPageMetadata {\r\n if (input.failed) {\r\n return { metadata: ERROR_PAGE_METADATA };\r\n }\r\n\r\n const { metadata } = input;\r\n\r\n if (typeof metadata !== \"function\") {\r\n return { metadata };\r\n }\r\n\r\n try {\r\n return {\r\n metadata: (metadata as PageMetadataFunction)({\r\n data: input.data as Parameters<PageMetadataFunction>[0][\"data\"],\r\n shared: input.shared,\r\n }),\r\n };\r\n } catch (thrown) {\r\n return { metadata: undefined, thrown };\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;AAgEA,MAAa,sBAAsC,OAAO,OAAO;CAC/D,OAAO;CACP,QAAQ;AACV,CAAC;AA+BD,SAAgB,oBAAoB,OAAuD;CACzF,IAAI,MAAM,QACR,OAAO,EAAE,UAAU,oBAAoB;CAGzC,MAAM,EAAE,aAAa;CAErB,IAAI,OAAO,aAAa,YACtB,OAAO,EAAE,SAAS;CAGpB,IAAI;EACF,OAAO,EACL,UAAW,SAAkC;GAC3C,MAAM,MAAM;GACZ,QAAQ,MAAM;EAChB,CAAC,EACH;CACF,SAAS,QAAQ;EACf,OAAO;GAAE,UAAU;GAAW;EAAO;CACvC;AACF"}
|
|
1
|
+
{"version":3,"file":"resolve-page-metadata.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-page-metadata.ts"],"sourcesContent":["/**\r\n * Stage 8 of the page pipeline — METADATA.\r\n *\r\n * Its own file because the behaviour that matters here is about ERRORS, and\r\n * reaching an error path through the full ten-stage pipeline means standing up\r\n * a real Request/Response pair to observe a `try`/`catch`. See\r\n * `resolve-page-metadata.spec.ts`.\r\n *\r\n * ## Why a page's `metadata` never runs after a failed loader\r\n *\r\n * It used to. `design/request-lifecycle.md` said \"exactly one of data/error\r\n * set\", and stage 8 duly called `metadata({ data: undefined, error })` when a\r\n * loader rejected — while `PageMetadata` declared `data` as always present.\r\n * **The type lied**, so the natural way to write a metadata function was also\r\n * the broken way:\r\n *\r\n * ```ts\r\n * export const metadata: PageMetadata<typeof loader> = ({ data }) => ({\r\n * title: \"Home\",\r\n * description: `${data.products.length} products in stock`,\r\n * });\r\n * ```\r\n *\r\n * All three function-form metadata exports in the reference app were written\r\n * exactly like that, and all three turned a loader's real error — a\r\n * `MissingDataSourceError`, say — into `TypeError: Cannot read properties of\r\n * undefined`, reported against a different file in a different subsystem. The\r\n * cost was never the crash; it was that every future debugging session on a\r\n * failed loader would start by investigating the wrong thing.\r\n *\r\n * The alternative was to widen `data` to `| undefined` and make every author\r\n * handle a path almost none of them care about — and without a discriminant,\r\n * TypeScript cannot narrow `data` from `if (error)` anyway, so authors would\r\n * have reached for `data!` and re-created the lie with extra syntax.\r\n *\r\n * So: a page describes a page it actually has. When there is no data there is\r\n * no page-authored description, and the framework supplies\r\n * {@link ERROR_PAGE_METADATA} instead.\r\n *\r\n * ## Partial failure\r\n *\r\n * Stage 7 runs app, layout and page loaders root-to-leaf, so an ancestor\r\n * rejection prevents the page loader from starting. `failed` is still the\r\n * relevant guard here: any recorded error means a boundary renders instead of\r\n * the page, and metadata describes what is on screen. Describing a page the\r\n * visitor never received is the same defect in a quieter form.\r\n *\r\n * If per-page error metadata is ever wanted, it is a separate `errorMetadata`\r\n * export — a distinct signature for a distinct situation, not an arm of this\r\n * one. Nothing has asked for it.\r\n */\r\n\r\nimport type { SharedContext } from \"../index\";\r\nimport type { MetadataOutput, PageMetadata } from \"../metadata\";\r\nimport type { PipelineLoader } from \"./execute-page-request\";\r\n\r\n/**\r\n * What `<head>` gets when the page did not render.\r\n *\r\n * `robots` is the load-bearing member, not `title`. An error render is a\r\n * transient server state that happens to be reachable at a real URL; letting a\r\n * crawler index it puts \"Something went wrong\" in a search result for a page\r\n * that works. `title` is a fallback a boundary is free to improve on.\r\n */\r\nexport const ERROR_PAGE_METADATA: MetadataOutput = Object.freeze({\r\n title: \"Something went wrong\",\r\n robots: \"noindex\",\r\n});\r\n\r\nexport type ResolvePageMetadataInput = {\r\n /** The page module's `metadata` export — absent, object, or function. */\r\n metadata: PageMetadata<PipelineLoader> | undefined;\r\n /** `bundle.pageData`. Read only when `failed` is false. */\r\n data: unknown;\r\n /** The value an earlier stage already recorded. Diagnostics only. */\r\n error: unknown;\r\n /** Whether an earlier stage recorded an error at all. */\r\n failed: boolean;\r\n shared: Readonly<SharedContext>;\r\n};\r\n\r\nexport type ResolvedPageMetadata = {\r\n metadata: MetadataOutput | undefined;\r\n /**\r\n * Set only when the page's own metadata function threw on the SUCCESS path.\r\n * Returned rather than rethrown so the caller can record it the same way a\r\n * loader throw is recorded — the boundary renders and the framework keeps\r\n * ownership of the status, instead of an exception escaping stage 8.\r\n *\r\n * Never set on the error path: nothing runs there that could throw, and an\r\n * error arriving at stage 8 has already won.\r\n */\r\n thrown?: unknown;\r\n};\r\n\r\n/** The function arm of `PageMetadata`, for the one call this module makes. */\r\ntype PageMetadataFunction = Extract<PageMetadata<PipelineLoader>, (...args: never) => unknown>;\r\n\r\nexport function resolvePageMetadata(input: ResolvePageMetadataInput): ResolvedPageMetadata {\r\n if (input.failed) {\r\n return { metadata: ERROR_PAGE_METADATA };\r\n }\r\n\r\n const { metadata } = input;\r\n\r\n if (typeof metadata !== \"function\") {\r\n return { metadata };\r\n }\r\n\r\n try {\r\n return {\r\n metadata: (metadata as PageMetadataFunction)({\r\n data: input.data as Parameters<PageMetadataFunction>[0][\"data\"],\r\n shared: input.shared,\r\n }),\r\n };\r\n } catch (thrown) {\r\n return { metadata: undefined, thrown };\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;AAgEA,MAAa,sBAAsC,OAAO,OAAO;CAC/D,OAAO;CACP,QAAQ;AACV,CAAC;AA+BD,SAAgB,oBAAoB,OAAuD;CACzF,IAAI,MAAM,QACR,OAAO,EAAE,UAAU,oBAAoB;CAGzC,MAAM,EAAE,aAAa;CAErB,IAAI,OAAO,aAAa,YACtB,OAAO,EAAE,SAAS;CAGpB,IAAI;EACF,OAAO,EACL,UAAW,SAAkC;GAC3C,MAAM,MAAM;GACZ,QAAQ,MAAM;EAChB,CAAC,EACH;CACF,SAAS,QAAQ;EACf,OAAO;GAAE,UAAU;GAAW;EAAO;CACvC;AACF"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region ../web/src/server/resolve-route-validation-input.ts
|
|
2
|
+
function resolveRouteValidationInput(request) {
|
|
3
|
+
return {
|
|
4
|
+
params: request.params ?? {},
|
|
5
|
+
query: request.query ?? {}
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { resolveRouteValidationInput };
|
|
11
|
+
//# sourceMappingURL=resolve-route-validation-input.mjs.map
|
|
@@ -0,0 +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 RouteValidationRequest = {\n params?: Record<string, unknown>;\n query?: Record<string, unknown>;\n};\n\nexport type RouteValidationInput = {\n params: Record<string, unknown>;\n query: Record<string, unknown>;\n};\n\nexport function resolveRouteValidationInput(request: RouteValidationRequest): RouteValidationInput {\n return { params: request.params ?? {}, query: request.query ?? {} };\n}\n"],"mappings":";AAoBA,SAAgB,4BAA4B,SAAuD;CACjG,OAAO;EAAE,QAAQ,QAAQ,UAAU,CAAC;EAAG,OAAO,QAAQ,SAAS,CAAC;CAAE;AACpE"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region ../web/src/server/route-validation-error.ts
|
|
2
|
+
/**
|
|
3
|
+
* Raised when a page's `route.validate` schema rejects `{ params, query }`.
|
|
4
|
+
*
|
|
5
|
+
* A page is a document, not an API endpoint (canon `b79c4f55`, point 2): this
|
|
6
|
+
* is thrown into the ordinary error-boundary pipeline rather than answered as
|
|
7
|
+
* a raw JSON body, so the request renders the application's error page (or the
|
|
8
|
+
* nearest authored `ErrorBoundary`) with status 400 instead of a blob of JSON
|
|
9
|
+
* where a page was expected.
|
|
10
|
+
*
|
|
11
|
+
* `errors` stays a real, structured property — never flattened away — so an
|
|
12
|
+
* `error.page.tsx` reading `(error as RouteValidationError).errors` during SSR
|
|
13
|
+
* can say exactly which field was wrong. `message` ALSO summarizes every
|
|
14
|
+
* failing field: SSR (`ServerErrorPageProps.error`) receives this instance
|
|
15
|
+
* directly, but the wire (`serializePageError`, `server/error-page.ts`) keeps
|
|
16
|
+
* only `name`/`message`/`stack` — the same 400 must still say what was wrong
|
|
17
|
+
* after that trip, not just that something was.
|
|
18
|
+
*/
|
|
19
|
+
var RouteValidationError = class extends Error {
|
|
20
|
+
errors;
|
|
21
|
+
/** Always 400 — a validation failure is the visitor's malformed input, never the server's fault. */
|
|
22
|
+
statusCode = 400;
|
|
23
|
+
constructor(errors) {
|
|
24
|
+
super(`Route validation failed: ${errors.map((issue) => `${issue.input}: ${issue.error}`).join("; ")}`);
|
|
25
|
+
this.errors = errors;
|
|
26
|
+
this.name = "RouteValidationError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { RouteValidationError };
|
|
32
|
+
//# sourceMappingURL=route-validation-error.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-validation-error.mjs","names":[],"sources":["../../../../../../../web/src/server/route-validation-error.ts"],"sourcesContent":["import type { ValidationResult } from \"@warlock.js/seal\";\n\n/**\n * Raised when a page's `route.validate` schema rejects `{ params, query }`.\n *\n * A page is a document, not an API endpoint (canon `b79c4f55`, point 2): this\n * is thrown into the ordinary error-boundary pipeline rather than answered as\n * a raw JSON body, so the request renders the application's error page (or the\n * nearest authored `ErrorBoundary`) with status 400 instead of a blob of JSON\n * where a page was expected.\n *\n * `errors` stays a real, structured property — never flattened away — so an\n * `error.page.tsx` reading `(error as RouteValidationError).errors` during SSR\n * can say exactly which field was wrong. `message` ALSO summarizes every\n * failing field: SSR (`ServerErrorPageProps.error`) receives this instance\n * directly, but the wire (`serializePageError`, `server/error-page.ts`) keeps\n * only `name`/`message`/`stack` — the same 400 must still say what was wrong\n * after that trip, not just that something was.\n */\nexport class RouteValidationError extends Error {\n /** Always 400 — a validation failure is the visitor's malformed input, never the server's fault. */\n public readonly statusCode = 400;\n\n public constructor(public readonly errors: ValidationResult[\"errors\"]) {\n super(\n `Route validation failed: ${errors.map((issue) => `${issue.input}: ${issue.error}`).join(\"; \")}`,\n );\n this.name = \"RouteValidationError\";\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,IAAa,uBAAb,cAA0C,MAAM;CAIX;;CAFnC,AAAgB,aAAa;CAE7B,AAAO,YAAY,AAAgB,QAAoC;EACrE,MACE,4BAA4B,OAAO,KAAK,UAAU,GAAG,MAAM,MAAM,IAAI,MAAM,OAAO,CAAC,CAAC,KAAK,IAAI,GAC/F;EAHiC;EAIjC,KAAK,OAAO;CACd;AACF"}
|
|
@@ -20,7 +20,7 @@ function designateBoundary(throwingLevel, triple) {
|
|
|
20
20
|
boundaryLevel: "app"
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
function buildErrorRecord(thrown, boundary, requestPath) {
|
|
23
|
+
function buildErrorRecord(thrown, boundary, requestPath, statusCode) {
|
|
24
24
|
const digest = randomUUID();
|
|
25
25
|
console.error("[warlock] page error", digest, ...requestPath ? [requestPath] : [], thrown);
|
|
26
26
|
if (process.env.NODE_ENV === "production") {
|
|
@@ -31,7 +31,8 @@ function buildErrorRecord(thrown, boundary, requestPath) {
|
|
|
31
31
|
error: surrogate,
|
|
32
32
|
boundary,
|
|
33
33
|
digest,
|
|
34
|
-
scrubbed: true
|
|
34
|
+
scrubbed: true,
|
|
35
|
+
statusCode
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
return {
|
|
@@ -39,7 +40,8 @@ function buildErrorRecord(thrown, boundary, requestPath) {
|
|
|
39
40
|
error: thrown,
|
|
40
41
|
boundary,
|
|
41
42
|
digest,
|
|
42
|
-
scrubbed: false
|
|
43
|
+
scrubbed: false,
|
|
44
|
+
statusCode
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
const LOADER_SHORT_CIRCUIT = Symbol("warlock.page.loaderShortCircuit");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settle-page-response.mjs","names":[],"sources":["../../../../../../../web/src/server/settle-page-response.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { Response } from \"@warlock.js/core\";\nimport type {\n PageBoundaryDesignation,\n PageErrorRecord,\n PageLevelName,\n PageRouteEntry,\n} from \"./execute-page-request.types\";\n\nexport const LEVEL_ORDER: readonly PageLevelName[] = [\"app\", \"layout\", \"page\"];\n\nexport function designateBoundary(\n throwingLevel: PageLevelName,\n triple: PageRouteEntry[\"triple\"],\n): PageBoundaryDesignation {\n const throwingIndex = LEVEL_ORDER.indexOf(throwingLevel);\n\n for (let index = throwingIndex; index >= 0; index--) {\n const level = LEVEL_ORDER[index];\n\n if (triple[level].ErrorBoundary) {\n return { throwingLevel, boundaryLevel: level };\n }\n }\n\n return { throwingLevel, boundaryLevel: \"app\" };\n}\n\nexport function buildErrorRecord(\n thrown: unknown,\n boundary: PageBoundaryDesignation,\n requestPath?: string,\n): PageErrorRecord {\n const digest = randomUUID();\n\n console.error(\"[warlock] page error\", digest, ...(requestPath ? [requestPath] : []), thrown);\n\n if (process.env.NODE_ENV === \"production\") {\n const surrogate = new Error(\"An unexpected error occurred.\");\n\n (surrogate as Error & { digest: string }).digest = digest;\n\n return { originalError: thrown, error: surrogate, boundary, digest, scrubbed: true };\n }\n\n // `error` already IS the real thrown value here — `originalError` only ever\n // needs to diverge from it on the scrubbed (production) path above. Leaving\n // it `undefined` rather than a redundant second reference to the same object\n // keeps the record's `toEqual` shape honest (undefined properties compare as\n // absent) and readers still get the real error via\n // `record.originalError ?? record.error`.\n return { originalError: undefined, error: thrown, boundary, digest, scrubbed: false };\n}\n\n// ---------------------------------------------------------------------------\n// Stage 6/7 — buffered per-level responses, and the root→leaf commit\n// ---------------------------------------------------------------------------\n\n/** A single committed response header, in application order. */\nexport type BufferedHeader = { key: string; value: string };\n\n/** A single committed response cookie — the shape `applyBufferedCookie` replays. */\nexport type BufferedCookie = {\n name: string;\n value: unknown;\n options?: Record<string, unknown>;\n};\n\n/** The two loader short-circuit kinds a buffered response can signal. */\nexport type LoaderShortCircuitKind = \"redirect\" | \"notFound\";\n\nconst LOADER_SHORT_CIRCUIT = Symbol(\"warlock.page.loaderShortCircuit\");\n\n/**\n * What `response.redirect()` / `response.permanentRedirect()` / `response.notFound()`\n * return from inside a loader — a branded value the stage 7 settle scan\n * recognises by symbol, never by shape (so an app returning an\n * accidentally-similar plain object can't be mistaken for one).\n */\nexport type LoaderShortCircuitSignal = {\n readonly [LOADER_SHORT_CIRCUIT]: true;\n kind: LoaderShortCircuitKind;\n statusCode: number;\n url?: string;\n body?: unknown;\n};\n\nexport function isLoaderShortCircuit(value: unknown): value is LoaderShortCircuitSignal {\n return Boolean(value) && typeof value === \"object\" && LOADER_SHORT_CIRCUIT in (value as object);\n}\n\n/** One level's scratch buffer — what `response.header()`/`.cookie()` write into. */\nexport type LevelBuffer = {\n headers: BufferedHeader[];\n cookies: BufferedCookie[];\n statusCode?: number;\n};\n\nexport function createLevelBuffer(): LevelBuffer {\n return { headers: [], cookies: [] };\n}\n\n/**\n * The response surface a LOADER sees — never the live core `Response`.\n * `header()`/`cookie()` queue into the level's own buffer; nothing here\n * touches the real reply. `redirect()`/`permanentRedirect()`/`notFound()`\n * queue the buffer's own status (+ `Location`, for the two redirects) AND\n * return the branded signal stage 7 detects — the loader is expected to\n * `return response.redirect(...)`.\n */\nexport type BufferedResponse = {\n header(key: string, value: unknown): BufferedResponse;\n headers(bag: Record<string, unknown>): BufferedResponse;\n cookie(name: string, value: unknown, options?: Record<string, unknown>): BufferedResponse;\n setStatusCode(statusCode: number): BufferedResponse;\n redirect(url: string, statusCode?: number): LoaderShortCircuitSignal;\n permanentRedirect(url: string): LoaderShortCircuitSignal;\n notFound(body?: unknown): LoaderShortCircuitSignal;\n};\n\nexport function createBufferedResponse(buffer: LevelBuffer): BufferedResponse {\n const bufferedResponse: BufferedResponse = {\n header(key, value) {\n buffer.headers.push({ key, value: String(value) });\n return bufferedResponse;\n },\n headers(bag) {\n for (const [key, value] of Object.entries(bag)) bufferedResponse.header(key, value);\n return bufferedResponse;\n },\n cookie(name, value, options) {\n buffer.cookies.push({ name, value, options });\n return bufferedResponse;\n },\n setStatusCode(statusCode) {\n buffer.statusCode = statusCode;\n return bufferedResponse;\n },\n redirect(url, statusCode = 302) {\n buffer.statusCode = statusCode;\n buffer.headers.push({ key: \"Location\", value: url });\n return { [LOADER_SHORT_CIRCUIT]: true, kind: \"redirect\", statusCode, url, body: undefined };\n },\n permanentRedirect(url) {\n return bufferedResponse.redirect(url, 301);\n },\n notFound(body) {\n buffer.statusCode = 404;\n return { [LOADER_SHORT_CIRCUIT]: true, kind: \"notFound\", statusCode: 404, url: undefined, body };\n },\n };\n\n return bufferedResponse;\n}\n\n/** Stage 7's folded, applied result — what `bundle.commit` carries. */\nexport type PageResponseCommit = {\n committedLevels: PageLevelName[];\n headers: BufferedHeader[];\n cookies: BufferedCookie[];\n statusCode?: number;\n};\n\n/**\n * Fold every surviving buffer root→leaf into ONE map per key (header key\n * case-insensitively, cookie by name) — leafward wins, insertion position\n * stays where the key FIRST appeared. Applies the folded headers and status\n * to the REAL response (`header()`/`setStatusCode()` are idempotent keyed\n * sets, so this is safe even though `commitBuffers` can run before render\n * changes its mind about the status later). Cookies are NOT applied to the\n * real response here — `cookie()` APPENDS, so mirroring it here and again at\n * the wire emit would duplicate every `Set-Cookie`. The single application\n * site is the emit (`create-page-route-handler.ts`, via `applyBufferedCookie`\n * over `bundle.commit.cookies`).\n */\nexport function commitBuffers(\n response: Response,\n buffers: Record<PageLevelName, LevelBuffer>,\n committedLevels: PageLevelName[],\n): PageResponseCommit {\n const headerOrder: string[] = [];\n const headerMap = new Map<string, BufferedHeader>();\n const cookieOrder: string[] = [];\n const cookieMap = new Map<string, BufferedCookie>();\n let statusCode: number | undefined;\n\n for (const level of committedLevels) {\n const buffer = buffers[level];\n\n for (const header of buffer.headers) {\n const key = header.key.toLowerCase();\n if (!headerMap.has(key)) headerOrder.push(key);\n headerMap.set(key, header);\n }\n\n for (const cookie of buffer.cookies) {\n if (!cookieMap.has(cookie.name)) cookieOrder.push(cookie.name);\n cookieMap.set(cookie.name, cookie);\n }\n\n if (buffer.statusCode !== undefined) statusCode = buffer.statusCode;\n }\n\n const headers = headerOrder.map(key => headerMap.get(key)!);\n const cookies = cookieOrder.map(name => cookieMap.get(name)!);\n\n for (const header of headers) response.header(header.key, header.value);\n if (statusCode !== undefined) response.setStatusCode(statusCode);\n\n return { committedLevels, headers, cookies, statusCode };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"settle-page-response.mjs","names":[],"sources":["../../../../../../../web/src/server/settle-page-response.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport type { Response } from \"@warlock.js/core\";\nimport type {\n PageBoundaryDesignation,\n PageErrorRecord,\n PageLevelName,\n PageRouteEntry,\n} from \"./execute-page-request.types\";\n\nexport const LEVEL_ORDER: readonly PageLevelName[] = [\"app\", \"layout\", \"page\"];\n\nexport function designateBoundary(\n throwingLevel: PageLevelName,\n triple: PageRouteEntry[\"triple\"],\n): PageBoundaryDesignation {\n const throwingIndex = LEVEL_ORDER.indexOf(throwingLevel);\n\n for (let index = throwingIndex; index >= 0; index--) {\n const level = LEVEL_ORDER[index];\n\n if (triple[level].ErrorBoundary) {\n return { throwingLevel, boundaryLevel: level };\n }\n }\n\n return { throwingLevel, boundaryLevel: \"app\" };\n}\n\nexport function buildErrorRecord(\n thrown: unknown,\n boundary: PageBoundaryDesignation,\n requestPath?: string,\n statusCode?: number,\n): PageErrorRecord {\n const digest = randomUUID();\n\n console.error(\"[warlock] page error\", digest, ...(requestPath ? [requestPath] : []), thrown);\n\n if (process.env.NODE_ENV === \"production\") {\n const surrogate = new Error(\"An unexpected error occurred.\");\n\n (surrogate as Error & { digest: string }).digest = digest;\n\n return {\n originalError: thrown,\n error: surrogate,\n boundary,\n digest,\n scrubbed: true,\n statusCode,\n };\n }\n\n // `error` already IS the real thrown value here — `originalError` only ever\n // needs to diverge from it on the scrubbed (production) path above. Leaving\n // it `undefined` rather than a redundant second reference to the same object\n // keeps the record's `toEqual` shape honest (undefined properties compare as\n // absent) and readers still get the real error via\n // `record.originalError ?? record.error`.\n return { originalError: undefined, error: thrown, boundary, digest, scrubbed: false, statusCode };\n}\n\n// ---------------------------------------------------------------------------\n// Stage 6/7 — buffered per-level responses, and the root→leaf commit\n// ---------------------------------------------------------------------------\n\n/** A single committed response header, in application order. */\nexport type BufferedHeader = { key: string; value: string };\n\n/** A single committed response cookie — the shape `applyBufferedCookie` replays. */\nexport type BufferedCookie = {\n name: string;\n value: unknown;\n options?: Record<string, unknown>;\n};\n\n/** The two loader short-circuit kinds a buffered response can signal. */\nexport type LoaderShortCircuitKind = \"redirect\" | \"notFound\";\n\nconst LOADER_SHORT_CIRCUIT = Symbol(\"warlock.page.loaderShortCircuit\");\n\n/**\n * What `response.redirect()` / `response.permanentRedirect()` / `response.notFound()`\n * return from inside a loader — a branded value the stage 7 settle scan\n * recognises by symbol, never by shape (so an app returning an\n * accidentally-similar plain object can't be mistaken for one).\n */\nexport type LoaderShortCircuitSignal = {\n readonly [LOADER_SHORT_CIRCUIT]: true;\n kind: LoaderShortCircuitKind;\n statusCode: number;\n url?: string;\n body?: unknown;\n};\n\nexport function isLoaderShortCircuit(value: unknown): value is LoaderShortCircuitSignal {\n return Boolean(value) && typeof value === \"object\" && LOADER_SHORT_CIRCUIT in (value as object);\n}\n\n/** One level's scratch buffer — what `response.header()`/`.cookie()` write into. */\nexport type LevelBuffer = {\n headers: BufferedHeader[];\n cookies: BufferedCookie[];\n statusCode?: number;\n};\n\nexport function createLevelBuffer(): LevelBuffer {\n return { headers: [], cookies: [] };\n}\n\n/**\n * The response surface a LOADER sees — never the live core `Response`.\n * `header()`/`cookie()` queue into the level's own buffer; nothing here\n * touches the real reply. `redirect()`/`permanentRedirect()`/`notFound()`\n * queue the buffer's own status (+ `Location`, for the two redirects) AND\n * return the branded signal stage 7 detects — the loader is expected to\n * `return response.redirect(...)`.\n */\nexport type BufferedResponse = {\n header(key: string, value: unknown): BufferedResponse;\n headers(bag: Record<string, unknown>): BufferedResponse;\n cookie(name: string, value: unknown, options?: Record<string, unknown>): BufferedResponse;\n setStatusCode(statusCode: number): BufferedResponse;\n redirect(url: string, statusCode?: number): LoaderShortCircuitSignal;\n permanentRedirect(url: string): LoaderShortCircuitSignal;\n notFound(body?: unknown): LoaderShortCircuitSignal;\n};\n\nexport function createBufferedResponse(buffer: LevelBuffer): BufferedResponse {\n const bufferedResponse: BufferedResponse = {\n header(key, value) {\n buffer.headers.push({ key, value: String(value) });\n return bufferedResponse;\n },\n headers(bag) {\n for (const [key, value] of Object.entries(bag)) bufferedResponse.header(key, value);\n return bufferedResponse;\n },\n cookie(name, value, options) {\n buffer.cookies.push({ name, value, options });\n return bufferedResponse;\n },\n setStatusCode(statusCode) {\n buffer.statusCode = statusCode;\n return bufferedResponse;\n },\n redirect(url, statusCode = 302) {\n buffer.statusCode = statusCode;\n buffer.headers.push({ key: \"Location\", value: url });\n return { [LOADER_SHORT_CIRCUIT]: true, kind: \"redirect\", statusCode, url, body: undefined };\n },\n permanentRedirect(url) {\n return bufferedResponse.redirect(url, 301);\n },\n notFound(body) {\n buffer.statusCode = 404;\n return {\n [LOADER_SHORT_CIRCUIT]: true,\n kind: \"notFound\",\n statusCode: 404,\n url: undefined,\n body,\n };\n },\n };\n\n return bufferedResponse;\n}\n\n/** Stage 7's folded, applied result — what `bundle.commit` carries. */\nexport type PageResponseCommit = {\n committedLevels: PageLevelName[];\n headers: BufferedHeader[];\n cookies: BufferedCookie[];\n statusCode?: number;\n};\n\n/**\n * Fold every surviving buffer root→leaf into ONE map per key (header key\n * case-insensitively, cookie by name) — leafward wins, insertion position\n * stays where the key FIRST appeared. Applies the folded headers and status\n * to the REAL response (`header()`/`setStatusCode()` are idempotent keyed\n * sets, so this is safe even though `commitBuffers` can run before render\n * changes its mind about the status later). Cookies are NOT applied to the\n * real response here — `cookie()` APPENDS, so mirroring it here and again at\n * the wire emit would duplicate every `Set-Cookie`. The single application\n * site is the emit (`create-page-route-handler.ts`, via `applyBufferedCookie`\n * over `bundle.commit.cookies`).\n */\nexport function commitBuffers(\n response: Response,\n buffers: Record<PageLevelName, LevelBuffer>,\n committedLevels: PageLevelName[],\n): PageResponseCommit {\n const headerOrder: string[] = [];\n const headerMap = new Map<string, BufferedHeader>();\n const cookieOrder: string[] = [];\n const cookieMap = new Map<string, BufferedCookie>();\n let statusCode: number | undefined;\n\n for (const level of committedLevels) {\n const buffer = buffers[level];\n\n for (const header of buffer.headers) {\n const key = header.key.toLowerCase();\n if (!headerMap.has(key)) headerOrder.push(key);\n headerMap.set(key, header);\n }\n\n for (const cookie of buffer.cookies) {\n if (!cookieMap.has(cookie.name)) cookieOrder.push(cookie.name);\n cookieMap.set(cookie.name, cookie);\n }\n\n if (buffer.statusCode !== undefined) statusCode = buffer.statusCode;\n }\n\n const headers = headerOrder.map((key) => headerMap.get(key)!);\n const cookies = cookieOrder.map((name) => cookieMap.get(name)!);\n\n for (const header of headers) response.header(header.key, header.value);\n if (statusCode !== undefined) response.setStatusCode(statusCode);\n\n return { committedLevels, headers, cookies, statusCode };\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { matchPath } from "./match-page-route.mjs";
|
|
2
2
|
import { isNotFoundPageFile } from "./not-found-page.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { toPosix } from "../shared/to-posix.mjs";
|
|
4
|
+
import { discoverPages, isDiscoveredRoutablePage } from "../build/discover-pages.mjs";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
//#region ../web/src/server/unregistered-pages.ts
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unregistered-pages.mjs","names":[],"sources":["../../../../../../../web/src/server/unregistered-pages.ts"],"sourcesContent":["import path from \"node:path\";\nimport {\n discoverPages,\n isDiscoveredRoutablePage,\n
|
|
1
|
+
{"version":3,"file":"unregistered-pages.mjs","names":[],"sources":["../../../../../../../web/src/server/unregistered-pages.ts"],"sourcesContent":["import path from \"node:path\";\nimport {\n discoverPages,\n isDiscoveredRoutablePage,\n type DiscoverPagesOptions,\n type DiscoveredPage,\n} from \"../build/discover-pages\";\nimport { matchPath } from \"./match-page-route\";\nimport { isNotFoundPageFile } from \"./not-found-page\";\nimport { toPosix } from \"../shared/to-posix\";\n\ntype DiscoveredGlobalPage = Extract<DiscoveredPage, { type: \"page\" }>;\ntype DiscoverPages = (options: DiscoverPagesOptions) => readonly DiscoveredPage[];\n\nexport type UnregisteredPagesOptions = {\n appRoot: string;\n appSrcRoot: string;\n registeredPageFiles: () => readonly string[];\n discover?: DiscoverPages;\n warn?: (message: string) => void;\n};\n\nfunction fileKey(file: string): string {\n const posix = toPosix(file);\n\n return process.platform === \"win32\" ? posix.toLowerCase() : posix;\n}\n\nfunction findUnregisteredPages(options: UnregisteredPagesOptions): DiscoveredGlobalPage[] {\n const { appRoot, appSrcRoot, registeredPageFiles, discover = discoverPages } = options;\n const registered = new Set(registeredPageFiles().map(fileKey));\n const webRoot = path.resolve(appSrcRoot, \"web\");\n\n return discover({ appRoot, srcDir: path.relative(appRoot, appSrcRoot) })\n .filter(isDiscoveredRoutablePage)\n .filter(\n (page) =>\n path.resolve(page.webRoot) === webRoot &&\n !isNotFoundPageFile(page.pageFile) &&\n !registered.has(fileKey(page.pageFile)),\n );\n}\n\nexport function findUnregisteredPageFiles(options: UnregisteredPagesOptions): string[] {\n return findUnregisteredPages(options).map((page) => page.pageFile);\n}\n\nexport function describeUnregisteredPages(\n pageFiles: readonly string[],\n appRoot: string,\n request: { method: string; url: string },\n): string {\n const named = pageFiles\n .map((pageFile) => ` - ${toPosix(path.relative(appRoot, pageFile))}`)\n .join(\"\\n\");\n\n return (\n `[warlock:web] ${request.method} ${request.url} answered 404. ` +\n \"These src/web page files exist on disk but are absent from the active route table:\\n\" +\n named\n );\n}\n\nexport function createUnregisteredPageReporter(\n options: UnregisteredPagesOptions,\n): (request: { method: string; url: string; pathname: string }) => void {\n const { appRoot, warn = console.warn } = options;\n const reported = new Set<string>();\n\n return (request) => {\n // `discoverPages()` deliberately refuses malformed pages. Its install/build\n // diagnostic is authoritative, so a response hook must leave this 404 alone.\n try {\n const unregistered = findUnregisteredPages(options).find(\n (page) =>\n !reported.has(fileKey(page.pageFile)) &&\n matchPath(page.routePath, request.pathname) !== undefined,\n );\n\n if (unregistered === undefined) return;\n\n reported.add(fileKey(unregistered.pageFile));\n warn(describeUnregisteredPages([unregistered.pageFile], appRoot, request));\n } catch {\n return;\n }\n };\n}\n"],"mappings":";;;;;;;AAsBA,SAAS,QAAQ,MAAsB;CACrC,MAAM,QAAQ,QAAQ,IAAI;CAE1B,OAAO,QAAQ,aAAa,UAAU,MAAM,YAAY,IAAI;AAC9D;AAEA,SAAS,sBAAsB,SAA2D;CACxF,MAAM,EAAE,SAAS,YAAY,qBAAqB,WAAW,kBAAkB;CAC/E,MAAM,aAAa,IAAI,IAAI,oBAAoB,CAAC,CAAC,IAAI,OAAO,CAAC;CAC7D,MAAM,UAAU,KAAK,QAAQ,YAAY,KAAK;CAE9C,OAAO,SAAS;EAAE;EAAS,QAAQ,KAAK,SAAS,SAAS,UAAU;CAAE,CAAC,CAAC,CACrE,OAAO,wBAAwB,CAAC,CAChC,QACE,SACC,KAAK,QAAQ,KAAK,OAAO,MAAM,WAC/B,CAAC,mBAAmB,KAAK,QAAQ,KACjC,CAAC,WAAW,IAAI,QAAQ,KAAK,QAAQ,CAAC,CAC1C;AACJ;AAMA,SAAgB,0BACd,WACA,SACA,SACQ;CACR,MAAM,QAAQ,UACX,KAAK,aAAa,OAAO,QAAQ,KAAK,SAAS,SAAS,QAAQ,CAAC,GAAG,CAAC,CACrE,KAAK,IAAI;CAEZ,OACE,iBAAiB,QAAQ,OAAO,GAAG,QAAQ,IAAI;IAE/C;AAEJ;AAEA,SAAgB,+BACd,SACsE;CACtE,MAAM,EAAE,SAAS,OAAO,QAAQ,SAAS;CACzC,MAAM,2BAAW,IAAI,IAAY;CAEjC,QAAQ,YAAY;EAGlB,IAAI;GACF,MAAM,eAAe,sBAAsB,OAAO,CAAC,CAAC,MACjD,SACC,CAAC,SAAS,IAAI,QAAQ,KAAK,QAAQ,CAAC,KACpC,UAAU,KAAK,WAAW,QAAQ,QAAQ,MAAM,MACpD;GAEA,IAAI,iBAAiB,QAAW;GAEhC,SAAS,IAAI,QAAQ,aAAa,QAAQ,CAAC;GAC3C,KAAK,0BAA0B,CAAC,aAAa,QAAQ,GAAG,SAAS,OAAO,CAAC;EAC3E,QAAQ;GACN;EACF;CACF;AACF"}
|
|
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
* WHY THIS MODULE EXISTS AT ALL, rather than the barrel exporting
|
|
13
13
|
* `WebConnector` directly: `./web-connector.ts` imports `../vite`
|
|
14
14
|
* (`web-connector.ts:52` → `@babel/parser` + `magic-string`),
|
|
15
|
-
* `../../../core/src/router/router` (`:51`) and `./dev-
|
|
15
|
+
* `../../../core/src/router/router` (`:51`) and `./dev-error-transport` (`:53`, which
|
|
16
16
|
* itself pulls core's http stack and `../vite`) at VALUE level. Re-exporting
|
|
17
17
|
* that class from `web/src/connector/index.ts` would drag every one of those
|
|
18
18
|
* into the static graph of every consuming app's config file — the exact
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-connector-factory.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector-factory.ts"],"sourcesContent":["/**\n * `webConnector()` — the ONE thing `warlock.config.ts` imports from\n * `@warlock.js/web/connector`, and the only value the `./connector` barrel\n * adds beyond the build/runtime seams.\n *\n * WHY THIS MODULE EXISTS AT ALL, rather than the barrel exporting\n * `WebConnector` directly: `./web-connector.ts` imports `../vite`\n * (`web-connector.ts:52` → `@babel/parser` + `magic-string`),\n * `../../../core/src/router/router` (`:51`) and `./dev-
|
|
1
|
+
{"version":3,"file":"web-connector-factory.mjs","names":[],"sources":["../../../../../../../web/src/server/web-connector-factory.ts"],"sourcesContent":["/**\n * `webConnector()` — the ONE thing `warlock.config.ts` imports from\n * `@warlock.js/web/connector`, and the only value the `./connector` barrel\n * adds beyond the build/runtime seams.\n *\n * WHY THIS MODULE EXISTS AT ALL, rather than the barrel exporting\n * `WebConnector` directly: `./web-connector.ts` imports `../vite`\n * (`web-connector.ts:52` → `@babel/parser` + `magic-string`),\n * `../../../core/src/router/router` (`:51`) and `./dev-error-transport` (`:53`, which\n * itself pulls core's http stack and `../vite`) at VALUE level. Re-exporting\n * that class from `web/src/connector/index.ts` would drag every one of those\n * into the static graph of every consuming app's config file — the exact\n * config-load weight the `./connector` subpath was created to prevent.\n *\n * So the factory returns a LAZY DELEGATE: a `Connector` whose identity fields\n * (`name`, `priority`, `lifecyclePhase`, `build`) are plain data available\n * synchronously, and whose lifecycle methods `await import(\"./web-connector\")`\n * on first use. `warlock build` reads `build` off this object and never boots\n * anything, so a build never loads Vite or React through\n * here either. This is the third instance of a pattern the codebase already\n * uses twice — `core/src/connectors/access-connector.ts:39` and\n * `web/src/server/dev-cli.ts:61` — not a new one.\n *\n * KEEP THIS MODULE LIGHT. Its whole value-level static graph is `node:path`,\n * `node:url`, `../../../core/src/connectors/types` (whose own two imports are\n * both `import type` and therefore erased — `core/src/connectors/types.ts:1-2`)\n * and `../build/contribution` (`node:fs` + `node:path` + type-only core).\n * Everything else here is `import type`, which `verbatimModuleSyntax` erases.\n */\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport {\n type Connector,\n type ConnectorBuildContribution,\n ConnectorLifecyclePhase,\n type ConnectorName,\n} from \"@warlock.js/core\";\nimport { createWebBuildContribution, type WebBuildOptions } from \"../build/contribution\";\nimport type { WebConnector, WebConnectorOptions } from \"./web-connector\";\n\n/**\n * Boot/shutdown position relative to core's own connectors.\n *\n * `ConnectorPriority.HTTP` is `5` and `ConnectorPriority.STORAGE` is `6`\n * (`core/src/connectors/types.ts:187-188`), and the manager sorts on a plain\n * numeric compare (`core/src/connectors/connectors-manager.ts:46`) — so `5.5`\n * is \"immediately after http, before everything else\".\n *\n * Defined HERE and re-exported by `./web-connector` rather than the other way\n * round: the delegate must publish `priority` synchronously, and reading it\n * from the heavy module would defeat the whole point of the delegate.\n */\nexport const WEB_CONNECTOR_PRIORITY = 5.5;\n\nexport type WebConnectorFactoryOptions = WebConnectorOptions & {\n /**\n * What web contributes to `warlock build`. Passed straight to\n * {@link createWebBuildContribution}; JSON-serializable values only.\n */\n build?: WebBuildOptions;\n};\n\n/**\n * The `@warlock.js/web` package root, derived once from THIS module's location\n * and then passed EXPLICITLY to both halves.\n *\n * `web/src/server/web-connector-factory.ts` → `web/src/server` → `web/src` →\n * `web`; published as `web/esm/server/web-connector-factory.js`, two levels up\n * is the package root under both layouts — the same arithmetic\n * `contribution.ts:136-140` documents.\n *\n * Deriving it here and handing it down means the build contribution never falls\n * back to its own `import.meta.url` guess (`contribution.ts:129-141`): one\n * derivation, one place to be wrong, and `assertWebPackageRoot` verifies it\n * against `<root>/package.json`'s `name` either way.\n */\nfunction deriveWebRoot(): string {\n return path.resolve(path.dirname(fileURLToPath(import.meta.url)), \"..\", \"..\");\n}\n\n/**\n * Construct web's connector for `warlock.config.ts > connectors`.\n *\n * @example\n * ```ts\n * export default defineConfig({ connectors: [webConnector()] });\n * ```\n */\nexport function webConnector(options: WebConnectorFactoryOptions = {}): Connector {\n const { build: buildOptions, ...connectorOptions } = options;\n const webRoot = connectorOptions.webRoot ?? deriveWebRoot();\n // One snapshot feeds both halves. Mutating the caller's array after config\n // construction cannot make dev and `warlock build` observe different lists.\n const connectorPlugins = [...(connectorOptions.plugins ?? [])];\n\n const build: ConnectorBuildContribution = createWebBuildContribution(\n {\n ...buildOptions,\n webRoot: buildOptions?.webRoot ?? webRoot,\n },\n connectorPlugins,\n );\n\n let instance: WebConnector | undefined;\n\n /**\n * Load the heavy half on first lifecycle call. `boot()` is always the first\n * of these to run (`core/src/connectors/connectors-manager.ts:87-93`), so the\n * import lands in a process that has already committed to serving pages.\n */\n const load = async (): Promise<WebConnector> => {\n if (!instance) {\n const { WebConnector: WebConnectorClass } = await import(\"./web-connector\");\n\n instance = new WebConnectorClass({\n ...connectorOptions,\n plugins: connectorPlugins,\n webRoot,\n });\n }\n\n return instance;\n };\n\n return {\n name: \"web\" satisfies ConnectorName,\n priority: WEB_CONNECTOR_PRIORITY,\n lifecyclePhase: ConnectorLifecyclePhase.Late,\n build,\n\n // Never loads the heavy half: a connector that was never booted is not\n // active, and answering that must not cost a Vite import.\n isActive: () => instance?.isActive() ?? false,\n\n boot: async () => {\n await (await load()).boot();\n },\n start: async () => {\n await (await load()).start();\n },\n restart: async () => {\n await (await load()).restart();\n },\n\n // Both of these are asked of every registered connector, including ones\n // that never booted — so neither may force the import.\n shutdown: async () => {\n await instance?.shutdown();\n },\n\n // Asked on EVERY watcher batch, so the `instance?.` is load-bearing twice:\n // it keeps the answer free for a connector that never booted, and it is the\n // only reason this delegate can forward the question at all — by the time a\n // watcher batch exists, `boot()` has already loaded the heavy half, so\n // reading the real answer off it costs nothing beyond the call.\n //\n // It used to be a hard `false`, which meant a `*.page.tsx` created while\n // `warlock dev` was running was never noticed: page routes are installed\n // once, in `WebConnector.boot()`, so the new page's URL 404'd in silence.\n // The connector itself now decides: add/remove and route-identity edits are\n // live route-table work; component-body-only edits remain Vite HMR work.\n shouldRestart: (changedFiles: string[]) => instance?.shouldRestart(changedFiles) ?? false,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoDA,MAAa,yBAAyB;;;;;;;;;;;;;;;AAwBtC,SAAS,gBAAwB;CAC/B,OAAO,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI;AAC9E;;;;;;;;;AAUA,SAAgB,aAAa,UAAsC,CAAC,GAAc;CAChF,MAAM,EAAE,OAAO,cAAc,GAAG,qBAAqB;CACrD,MAAM,UAAU,iBAAiB,WAAW,cAAc;CAG1D,MAAM,mBAAmB,CAAC,GAAI,iBAAiB,WAAW,CAAC,CAAE;CAE7D,MAAM,QAAoC,2BACxC;EACE,GAAG;EACH,SAAS,cAAc,WAAW;CACpC,GACA,gBACF;CAEA,IAAI;;;;;;CAOJ,MAAM,OAAO,YAAmC;EAC9C,IAAI,CAAC,UAAU;GACb,MAAM,EAAE,cAAc,sBAAsB,MAAM,OAAO;GAEzD,WAAW,IAAI,kBAAkB;IAC/B,GAAG;IACH,SAAS;IACT;GACF,CAAC;EACH;EAEA,OAAO;CACT;CAEA,OAAO;EACL,MAAM;EACN,UAAU;EACV,gBAAgB,wBAAwB;EACxC;EAIA,gBAAgB,UAAU,SAAS,KAAK;EAExC,MAAM,YAAY;GAChB,OAAO,MAAM,KAAK,EAAC,CAAE,KAAK;EAC5B;EACA,OAAO,YAAY;GACjB,OAAO,MAAM,KAAK,EAAC,CAAE,MAAM;EAC7B;EACA,SAAS,YAAY;GACnB,OAAO,MAAM,KAAK,EAAC,CAAE,QAAQ;EAC/B;EAIA,UAAU,YAAY;GACpB,MAAM,UAAU,SAAS;EAC3B;EAaA,gBAAgB,iBAA2B,UAAU,cAAc,YAAY,KAAK;CACtF;AACF"}
|