@warlock.js/web 5.5.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +130 -208
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +7 -5
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/public-files.mjs +30 -2
- 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/fetch-page-data.mjs +3 -10
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +24 -104
- 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 +19 -10
- 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 +13 -1
- 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 +9 -0
- 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 +18 -12
- 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 +91 -35
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +21 -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 +3 -3
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +4 -3
- package/esm/server/index.mjs +6 -6
- package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
- package/esm/server/install-page-routes-from-manifest.mjs +61 -58
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +24 -26
- package/esm/server/install-page-routes.mjs +149 -60
- 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 +6 -10
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -0
- package/esm/server/not-found-page.mjs +8 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-module-shapes.d.mts +24 -0
- 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 +26 -2
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +13 -49
- package/esm/server/render-page.mjs +14 -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/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs +78 -19
- package/esm/server/stylesheet-urls.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 +25 -322
- 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/validation.d.mts +12 -1
- package/esm/vite/build-client.mjs +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 +32 -71
- 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 +6 -8
- 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 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +354 -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":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\r\n * The page handler, as a named seam.\r\n *\r\n * This is the request handler `installPageRoutes` used to inline into its\r\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\r\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\r\n * The behaviour is unchanged, byte for byte — what changes is that it is now\r\n * a named, exported, independently constructible function instead of a closure\r\n * over eight ambient bindings of `installPageRoutes`.\r\n *\r\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\r\n * only capability the handler ever needed, and the two runtimes answer it\r\n * differently — dev goes through Vite's SSR graph\r\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\r\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\r\n * module\" as an INPUT is what lets the same handler serve both, and what lets\r\n * a test construct it with a plain async function — no Vite, no dev server, no\r\n * `app/` directory on disk.\r\n *\r\n * Scope: this file creates a seam and nothing else. It does not implement\r\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\r\n */\r\nimport { container, Response, type FastifyInstance, type HttpContext } from \"@warlock.js/core\";\r\n\r\nimport {\r\n DATA_RESPONSE_CONTENT_TYPE,\r\n isDataRequest,\r\n WARLOCK_DATA_REQUEST_HEADER,\r\n} from \"../routing/data-request\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../runtime/register-modules\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport { applyResponseCacheFloor } from \"./response-cache-floor\";\r\nimport type { PageCacheOptIn } from \"../routing/route-identity\";\r\nimport { ensureSetCookieCacheFloorHook, markPageResponse } from \"./set-cookie-cache-floor-hook\";\r\nimport type { BufferedCookie, PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { isNonHydrating } from \"./page-render-bundle\";\r\nimport { renderPageFailure, renderPageRequest, type RenderedPage } from \"./render-page\";\r\n\r\ndeclare module \"@warlock.js/core\" {\r\n interface RequestLocals {\r\n /**\r\n * Set by this file's route handler, on every page-route response\r\n * (document and data representations alike) — never inferred from URL\r\n * shape or content-type. `set-cookie-cache-floor-hook.ts`'s `onSend` hook\r\n * reads this to scope its effect to page responses only.\r\n */\r\n isPageResponse?: boolean;\r\n }\r\n}\r\nimport type { ErrorPageModuleLoader } from \"./error-page\";\r\n\r\n/**\r\n * Raised when a page route handler is constructed WITHOUT an `httpServer`\r\n * option AND the framework container has no `\"http.server\"` binding either —\r\n * i.e. there is no way, deliberate or ambient, to register the `Set-Cookie`\r\n * cache-floor hook. `container.get(\"http.server\")` (`core/src/container/index.ts`)\r\n * is a bare `Map.get` that TypeScript types as always returning a\r\n * `FastifyInstance`, so a silently-missing binding used to read as \"no\r\n * server\" and skip the hook with no signal at all. This throws instead of\r\n * repeating that mistake. To fix: register `http.server` in the container\r\n * before this factory runs (the ordinary `HttpConnector.boot()` path), or —\r\n * if this handler genuinely has no server on purpose, such as a unit test —\r\n * pass `httpServer: undefined` explicitly to say so.\r\n */\r\nexport class MissingHttpServerForPageRouteError extends Error {\r\n public constructor() {\r\n super(\r\n 'createPageRouteHandler: no \"httpServer\" option was supplied and the container has no ' +\r\n '\"http.server\" binding, so the Set-Cookie cache-floor hook on page responses cannot be ' +\r\n \"registered. Register `http.server` in the container before this factory runs, or pass \" +\r\n \"`httpServer: undefined` explicitly if this handler is meant to have no server.\",\r\n );\r\n this.name = \"MissingHttpServerForPageRouteError\";\r\n }\r\n}\r\n\r\n/**\r\n * Replay ONE committed cookie through core's own `Response.cookie()` — the\r\n * same serializer every ordinary controller's cookie goes through, so there\r\n * is nothing here for a second implementation to drift from. The one-liner\r\n * `dev-server.ts` wires as the production default; passed in (`applyBufferedCookie`\r\n * option, below) rather than imported so this file stays free of anything\r\n * Vite-shaped.\r\n */\r\nfunction defaultApplyBufferedCookie(response: Response, cookie: BufferedCookie): void {\r\n response.cookie(cookie.name, cookie.value as never, cookie.options ?? {});\r\n}\r\n\r\n/**\r\n * Stage 10a — apply the stage 7 commit (headers, then cookies) to the LIVE\r\n * response, once, before either terminal write (10b: `html()` or `send()`).\r\n * Both the document and data representations of a page route go through this\r\n * so a client navigation never drops a `Set-Cookie` a full load would have\r\n * kept (`create-page-route-handler.spec.ts` — \"applies committed cookies and\r\n * headers exactly as the document path does\").\r\n */\r\nfunction applyCommit(\r\n response: Response,\r\n rendered: Pick<RenderedPage, \"headers\" | \"cookies\">,\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void,\r\n): void {\r\n response.headers(rendered.headers ?? {});\r\n\r\n for (const cookie of rendered.cookies ?? []) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n}\r\n\r\n/**\r\n * How the handler obtains a page/layout/app module, by the same id\r\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\r\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\r\n * owns the dev/prod split, so the handler never learns which one it got.\r\n */\r\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\r\n\r\nexport type PageRouteHandlerOptions = {\r\n /** The composed, registered route path — `composeRoutePath`'s output. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\r\n appFile: string;\r\n /** The page module's id. */\r\n pageFile: string;\r\n /** The page's own-directory `layout.tsx`, when it has one. */\r\n layoutFile?: string | undefined;\r\n loadModule: PageModuleLoader;\r\n /** Optional lazy application `error.page.tsx` loader. Never called on success. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n /**\r\n * Load the REAL layout module namespaces, outermost first, for universal\r\n * registration. This stays separate from `loadModule(layoutFile)` because\r\n * dev may answer that id with a synthetic wrapper whose middleware is the\r\n * composition of several layouts. That wrapper is a render-pipeline detail,\r\n * not a module identity, and must never enter `registerModules`' WeakSet.\r\n */\r\n loadRegistrationLayouts?: () => Promise<readonly RegisterableModuleNamespace[]>;\r\n /** Browser module appended after the server-rendered document. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\r\n * styled. Absent or empty means the application has no CSS — it never means\r\n * a stylesheet failed to resolve, which is the build's job to report.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n /**\r\n * The pattern stage 1 matches `request.path` against, when it differs from\r\n * the REGISTERED path. Defaults to `path`, which is right for every route\r\n * whose URL is its own.\r\n *\r\n * Exactly one route needs it: the not-found page, registered on the catch-all\r\n * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and\r\n * has no wildcard token, so a route registered as `*` matches NOTHING — the\r\n * pipeline reports no match and `renderPageRequest` answers `{ html: \"\",\r\n * status: 404 }`. Correct status, empty document: a 404 page that never\r\n * renders its own body. Handing it `requestPath => requestPath` makes the\r\n * requested URL the route's pattern for that one request, so the match is\r\n * trivially true and the page renders for the URL the visitor actually asked\r\n * for.\r\n */\r\n matchPath?: (requestPath: string) => string;\r\n /**\r\n * The status this route answers with when the pipeline settles on a plain\r\n * `200` — the not-found route's `404`, and nothing else uses it.\r\n *\r\n * Applied ONLY to `200`, never as a blanket override: a `200` from this\r\n * pipeline means \"the document rendered and nobody objected\", which for this\r\n * route is precisely the not-found case. Any other settled status is a real\r\n * outcome that the page or the boundary decided — a 500 from a failed render,\r\n * a redirect — and overwriting it would report a broken page as a missing one.\r\n */\r\n statusForRenderedOk?: number;\r\n /**\r\n * Exclude the page module's loader from the request triple while preserving\r\n * the real namespace for `register()` and rendering. Used only by the\r\n * catch-all 404 page: a missing URL must not run application data work or\r\n * turn a simple miss into a second failure path.\r\n */\r\n skipPageLoader?: boolean;\r\n /**\r\n * Replays one committed cookie through core's `Response.cookie()`. Defaults\r\n * to doing exactly that (`defaultApplyBufferedCookie`, above); injectable so\r\n * a caller with a different `Response` shape (or a test) can observe/replace\r\n * the call.\r\n */\r\n applyBufferedCookie?: (response: Response, cookie: BufferedCookie) => void;\r\n /**\r\n * The Fastify instance to register the `Set-Cookie` cache-floor `onSend`\r\n * hook on (`ensureSetCookieCacheFloorHook`, `set-cookie-cache-floor-hook.ts`).\r\n * Defaults to `container.get(\"http.server\")` — the same instance\r\n * `HttpConnector` publishes during its own `boot()`, which runs before\r\n * `WebConnector.boot()` calls this factory. Injectable so a test can hand\r\n * this factory a self-contained Fastify instance it built and booted\r\n * itself, with no framework connector graph involved.\r\n */\r\n httpServer?: FastifyInstance;\r\n /**\r\n * This route's resolved `cache` opt-in, already validated\r\n * ({@link resolvePageRouteCache}) by whichever installer (dev's\r\n * `install-page-routes.ts` or production's\r\n * `install-page-routes-from-manifest.ts`) built these options — `undefined`\r\n * means the route declared no `cache` at all. Read by\r\n * `applyResponseCacheFloor` (`response-cache-floor.ts`) at the same seam\r\n * that applies the `Set-Cookie`/auth-derived floor, so the document and the\r\n * data representation can never disagree on `Cache-Control`.\r\n */\r\n cache?: PageCacheOptIn;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void | Response>;\r\n\r\nfunction escapeHtmlAttribute(value: string): string {\r\n return value.replace(/[&<>\"']/g, (character) => {\r\n switch (character) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\nfunction installHydrationClientModule(\r\n html: string,\r\n moduleUrl: string | undefined,\r\n nonce: string | undefined,\r\n): string {\r\n if (moduleUrl === undefined || html === \"\") return html;\r\n\r\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\r\n if (closingBodyIndex === -1) {\r\n throw new Error(\r\n \"installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\r\n );\r\n }\r\n\r\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\r\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\r\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\r\n}\r\n\r\n/**\r\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\r\n *\r\n * Without this the document carries no CSS at all. The stylesheet reaches the\r\n * browser only because the CLIENT bundle imports it, which means it is applied\r\n * by JavaScript after the module graph loads — the page renders unstyled first\r\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\r\n * in the console to explain it.\r\n *\r\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\r\n * here: the browser holds the first paint until the CSS is in, so there is no\r\n * flash rather than a faster ugly one.\r\n *\r\n * Inserted before `</head>` rather than after `<head>` so an application's own\r\n * `<link>`/`<style>` in the root document still comes FIRST and can be\r\n * overridden by these — matching how the framework's tags are documented to\r\n * behave, and keeping cascade order predictable.\r\n */\r\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\r\n if (stylesheetUrls.length === 0 || html === \"\") return html;\r\n\r\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\r\n\r\n // No `<head>` is not an error the way a missing `</body>` is: a root that\r\n // renders no head is unusual but legal, and losing the stylesheet is a\r\n // cosmetic failure where losing hydration is a broken page. Silently\r\n // dropping it would be the wrong trade the other way, though — so the\r\n // document is left exactly as rendered and the caller's own missing-`</body>`\r\n // check remains the loud one.\r\n if (closingHeadIndex === -1) return html;\r\n\r\n const links = stylesheetUrls\r\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\r\n .join(\"\");\r\n\r\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\r\n}\r\n\r\n/**\r\n * Build the handler for ONE page route. Per request it loads the App + layout\r\n * + page triple (concurrently, in that order), renders the URL through\r\n * `renderPageRequest`, splices in the hydration module, and flushes the\r\n * document.\r\n *\r\n * No try/catch, deliberately: loader/render throws are already absorbed by the\r\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\r\n * escapes (a module-load or register failure, the missing-`</body>` throw\r\n * above) belongs to the router's error path — which is exactly where it went\r\n * before.\r\n */\r\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\r\n const {\r\n path,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n loadModule,\r\n loadErrorPage,\r\n loadRegistrationLayouts,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n matchPath,\r\n statusForRenderedOk,\r\n skipPageLoader = false,\r\n applyBufferedCookie = defaultApplyBufferedCookie,\r\n cache,\r\n } = options;\r\n\r\n // Distinguish \"not supplied\" (fall back to the container, and REQUIRE the\r\n // container to have it) from \"supplied as `undefined`\" (a deliberate \"this\r\n // handler has no server\" — the escape hatch unit tests use). Collapsing\r\n // both into one optional-with-a-default, as this used to, let a genuinely\r\n // missing `http.server` container binding masquerade as the deliberate\r\n // no-server case with no signal at all — see `MissingHttpServerForPageRouteError`.\r\n let httpServer: FastifyInstance | undefined;\r\n\r\n if (\"httpServer\" in options) {\r\n httpServer = options.httpServer;\r\n } else if (container.has(\"http.server\")) {\r\n httpServer = container.get(\"http.server\");\r\n } else {\r\n throw new MissingHttpServerForPageRouteError();\r\n }\r\n\r\n // Registration-time, not request-time: this runs once per page route, while\r\n // `WebConnector.boot()` installs routes — after `HttpConnector.boot()` has\r\n // already registered `@fastify/cookie` (`set-cookie-cache-floor-hook.ts`\r\n // explains why that ordering is what makes the hook able to see the\r\n // header). `httpServer` is `undefined` here only when it was supplied that\r\n // way explicitly (checked above) — nothing to register the hook on, and\r\n // nothing that will ever mark a request as a page response either, so\r\n // skipping is correct, not just safe.\r\n if (httpServer) {\r\n ensureSetCookieCacheFloorHook(httpServer);\r\n }\r\n\r\n return async ({ request, response }: HttpContext) => {\r\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n try {\r\n const [appModule, layoutModule, ownPageModule, registrationLayouts] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n loadRegistrationLayouts?.() ?? Promise.resolve([]),\r\n ]);\r\n\r\n // Registration is the first lifecycle action after all module namespaces\r\n // have loaded and before `renderPageRequest` can run middleware, loaders or\r\n // render. App/page are already their real namespaces. Layouts deliberately\r\n // come from the separate raw chain above, never from `layoutModule`, which\r\n // may be the synthetic composed middleware wrapper used by dev.\r\n registerModules([\r\n appModule as RegisterableModuleNamespace,\r\n ...registrationLayouts,\r\n ownPageModule as RegisterableModuleNamespace,\r\n ]);\r\n\r\n const pageModule = ownPageModule as PageTripleModule;\r\n const triple: PageRouteEntry[\"triple\"] = {\r\n app: appModule as PageTripleModule,\r\n layout: layoutModule as PageTripleModule,\r\n // Registration above deliberately receives the REAL namespace. Only the\r\n // pipeline view is projected: spreading preserves the component,\r\n // metadata, middleware and boundary exports while making a custom 404's\r\n // loader uncallable.\r\n page: skipPageLoader\r\n ? {\r\n ...pageModule,\r\n // Vite and native ESM loaders hand us module namespace objects,\r\n // whose export descriptors are not an object-spread contract.\r\n // Keep the rendering export explicitly while hiding only loader.\r\n default: pageModule.default,\r\n loader: undefined,\r\n }\r\n : pageModule,\r\n };\r\n\r\n const requestUrl = request.path;\r\n const [requestPathname] = requestUrl.split(\"?\");\r\n const routes: PageRouteEntry[] = [\r\n { path: matchPath === undefined ? path : matchPath(requestPathname), name, triple },\r\n ];\r\n\r\n // A DATA request runs everything above and below this line identically —\r\n // it is the same route, the same match and the same pipeline — and differs\r\n // only in what gets written at the end. Decided here, before the render, so\r\n // the branch is visibly about REPRESENTATION and not about behaviour.\r\n const rendered = await renderPageRequest(requestUrl, {\r\n routes,\r\n createHttp: () => ({ request, response }),\r\n loadErrorPage,\r\n });\r\n\r\n if (rendered instanceof Response) return rendered;\r\n\r\n // See `statusForRenderedOk`: a settled 200 is the only status this route is\r\n // allowed to restate, and both the document and the data branch below must\r\n // restate it the same way — a client navigation that received 200 with a\r\n // not-found payload would push the URL into history as a real page.\r\n const status =\r\n rendered.status === 200 && statusForRenderedOk !== undefined\r\n ? statusForRenderedOk\r\n : rendered.status;\r\n\r\n // Stage 10a: the stage 7 commit (headers, then cookies), applied ONCE,\r\n // identically for the document and the data representation — see\r\n // `applyCommit`.\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n // Marks this request for `set-cookie-cache-floor-hook.ts`'s `onSend`\r\n // hook, which runs LATER than this seam — after `@fastify/cookie` has\r\n // flushed a parked `setCookie()`/`clearCookie()` call onto the real\r\n // header. Must happen before either terminal write below, same as\r\n // `applyResponseCacheFloor` just below it.\r\n markPageResponse(request);\r\n\r\n // `request.locals.authDerived` (core `Request`) is set the moment `user`\r\n // or `decodedAccessToken` is assigned, and never cleared. Overriding\r\n // `Cache-Control` here — after `applyCommit`'s default `private` and\r\n // before EITHER terminal write below — closes two gaps `private` alone\r\n // leaves open: a browser (not a shared cache; `private` already stops\r\n // those) holding an authenticated page in its own disk/back-forward\r\n // cache with no freshness directive, AND a `Set-Cookie` response held in\r\n // a shared cache handing the same cookie to every later visitor\r\n // (session fixation — see `response-cache-floor.ts`). Read once,\r\n // applied identically to both representations, so neither can carry a\r\n // weaker header than the other.\r\n //\r\n // TRI-STATE, deliberately, not `=== true`: several existing unit tests\r\n // hand this handler a plain `{ path, header }` mock with no `locals` at\r\n // all, never a real core `Request` — that is the auth mark mechanism\r\n // being genuinely UNOBSERVABLE on this request, not the mechanism\r\n // having fired `false`. Collapsing both into one boolean via\r\n // `request.locals?.authDerived === true` used to read \"unobservable\" as\r\n // \"provably clean\", which let an opted-in route serve `public,\r\n // max-age=N` to a request nobody could actually vouch for. The ruling\r\n // for the per-route cache opt-in is fail-CLOSED — unproven means\r\n // revoked — so `undefined` is passed through as its own state here and\r\n // it is `applyResponseCacheFloor` (`response-cache-floor.ts`) that\r\n // decides what each of the three states does to the opt-in; this seam\r\n // only reports what it actually knows.\r\n applyResponseCacheFloor(response, {\r\n authDerived: request.locals === undefined ? undefined : request.locals.authDerived === true,\r\n cache,\r\n });\r\n\r\n if (wantsData) {\r\n // So a shared cache can never serve a document to a client that asked for\r\n // JSON, or the reverse. See `data-request.ts` on why this stays even\r\n // while page responses are `no-store`.\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n\r\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\r\n // ran and there is no payload to build. Fastify already matched this\r\n // route to get here, so reaching it means `request.path` did not satisfy\r\n // the entry's own pattern — answered as the 404 it is, rather than\r\n // synthesising an empty payload the client would try to render as a page.\r\n if (rendered.bundle === undefined) {\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify({ error: \"not_found\" }), status);\r\n\r\n return;\r\n }\r\n\r\n // SERIALIZED HERE, and handed over as a STRING on purpose.\r\n //\r\n // `response.send(object)` runs the body through core's `Response.parse`,\r\n // which recurses the object, calls `toJSON()` on anything that has one\r\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\r\n // the right behaviour for a controller returning Resources; it is the\r\n // wrong behaviour here, because the DOCUMENT path serializes this exact\r\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\r\n // one path through a transformer and not the other is precisely the\r\n // drift `build-hydration-payload.ts` exists to prevent — the browser\r\n // would build one tree on a page load and a different one on a\r\n // navigation to the same URL.\r\n //\r\n // A string body also bypasses `parseBody()` entirely, so the content type\r\n // has to be declared rather than inferred from an object body.\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\n JSON.stringify(buildHydrationPayload(rendered.bundle, request.locale)),\n status,\n );\n\r\n return;\r\n }\r\n\r\n // Stylesheets first: they go in `<head>`, the hydration module goes before\r\n // `</body>`, and doing the head work on the already-rendered string keeps\r\n // both splices in one place rather than threading CSS through the React\r\n // render just to reach the same bytes.\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n const html = installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n\r\n await response.html(html, status);\r\n } catch (thrown) {\r\n // This is outside the page pipeline: loading/registering a module can\r\n // fail before a triple exists for its authored boundaries to handle.\r\n // Reuse this request/response pair so headers, nonce and response\r\n // ownership remain exactly the same as the ordinary path.\r\n //\r\n // Nested try/catch, deliberately: this block's own job is to render a\r\n // NICER answer for `thrown` — it must never let a failure IN THAT\r\n // ATTEMPT (`renderPageFailure` itself throwing, or misbehaving) replace\r\n // `thrown` with a less useful error. If rendering the failure page\r\n // fails too, the original throw escapes exactly as it would have with\r\n // no try/catch at all (the file header's stated contract) — the\r\n // router's own error path is still the answer, just one throw later.\r\n try {\r\n const rendered = await renderPageFailure({\r\n name,\r\n path: request.path,\r\n request,\r\n response,\r\n thrown,\r\n loadErrorPage,\r\n });\r\n\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n if (wantsData) {\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\n JSON.stringify(buildHydrationPayload(rendered.bundle!, request.locale)),\n 500,\n );\n return;\r\n }\r\n\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n // `renderPageFailure` marks its bundle non-hydrating (page-render-bundle.ts):\r\n // there is no triple, so there is nothing on the client the hydration\r\n // module could attach to. Injecting it anyway would ship a script that\r\n // hydrates against a composition the server never trusted.\r\n const html = isNonHydrating(rendered.bundle)\r\n ? styled\r\n : installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n await response.html(html, 500);\r\n } catch {\r\n throw thrown;\r\n }\r\n }\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\r\n * The page handler, as a named seam.\r\n *\r\n * This is the request handler `installPageRoutes` used to inline into its\r\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\r\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\r\n * The behaviour is unchanged, byte for byte — what changes is that it is now\r\n * a named, exported, independently constructible function instead of a closure\r\n * over eight ambient bindings of `installPageRoutes`.\r\n *\r\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\r\n * only capability the handler ever needed, and the two runtimes answer it\r\n * differently — dev goes through Vite's SSR graph\r\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\r\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\r\n * module\" as an INPUT is what lets the same handler serve both, and what lets\r\n * a test construct it with a plain async function — no Vite, no dev server, no\r\n * `app/` directory on disk.\r\n *\r\n * Scope: this file creates a seam and nothing else. It does not implement\r\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\r\n */\r\nimport { container, Response, type FastifyInstance, type HttpContext } from \"@warlock.js/core\";\r\n\r\nimport {\r\n DATA_RESPONSE_CONTENT_TYPE,\r\n isDataRequest,\r\n WARLOCK_DATA_REQUEST_HEADER,\r\n} from \"../routing/data-request\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../register-modules\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport { applyResponseCacheFloor } from \"./response-cache-floor\";\r\nimport type { PageCacheOptIn } from \"../routing/route-identity\";\r\nimport { ensureSetCookieCacheFloorHook, markPageResponse } from \"./set-cookie-cache-floor-hook\";\r\nimport type { BufferedCookie, PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { isNonHydrating } from \"./page-render-bundle\";\r\nimport { renderPageFailure, renderPageRequest, type RenderedPage } from \"./render-page\";\r\n\r\ndeclare module \"@warlock.js/core\" {\r\n interface RequestLocals {\r\n /**\r\n * Set by this file's route handler, on every page-route response\r\n * (document and data representations alike) — never inferred from URL\r\n * shape or content-type. `set-cookie-cache-floor-hook.ts`'s `onSend` hook\r\n * reads this to scope its effect to page responses only.\r\n */\r\n isPageResponse?: boolean;\r\n }\r\n}\r\nimport type { ErrorPageModuleLoader } from \"./error-page\";\r\n\r\n/**\r\n * Raised when a page route handler is constructed WITHOUT an `httpServer`\r\n * option AND the framework container has no `\"http.server\"` binding either —\r\n * i.e. there is no way, deliberate or ambient, to register the `Set-Cookie`\r\n * cache-floor hook. `container.get(\"http.server\")` (`core/src/container/index.ts`)\r\n * is a bare `Map.get` that TypeScript types as always returning a\r\n * `FastifyInstance`, so a silently-missing binding used to read as \"no\r\n * server\" and skip the hook with no signal at all. This throws instead of\r\n * repeating that mistake. To fix: register `http.server` in the container\r\n * before this factory runs (the ordinary `HttpConnector.boot()` path), or —\r\n * if this handler genuinely has no server on purpose, such as a unit test —\r\n * pass `httpServer: undefined` explicitly to say so.\r\n */\r\nexport class MissingHttpServerForPageRouteError extends Error {\r\n public constructor() {\r\n super(\r\n 'createPageRouteHandler: no \"httpServer\" option was supplied and the container has no ' +\r\n '\"http.server\" binding, so the Set-Cookie cache-floor hook on page responses cannot be ' +\r\n \"registered. Register `http.server` in the container before this factory runs, or pass \" +\r\n \"`httpServer: undefined` explicitly if this handler is meant to have no server.\",\r\n );\r\n this.name = \"MissingHttpServerForPageRouteError\";\r\n }\r\n}\r\n\r\n/**\r\n * Replay ONE committed cookie through core's own `Response.cookie()` — the\r\n * same serializer every ordinary controller's cookie goes through, so there\r\n * is nothing here for a second implementation to drift from. Passed in\r\n * (`applyBufferedCookie` option, below) rather than imported at the call site\r\n * so this file stays free of anything Vite-shaped. Exported for tests: this\r\n * is the only production implementation of the cookie commit.\r\n */\r\nexport function defaultApplyBufferedCookie(response: Response, cookie: BufferedCookie): void {\r\n response.cookie(cookie.name, cookie.value as never, cookie.options ?? {});\r\n}\r\n\r\n/**\r\n * Stage 10a — apply the stage 7 commit (headers, then cookies) to the LIVE\r\n * response, once, before either terminal write (10b: `html()` or `send()`).\r\n * Both the document and data representations of a page route go through this\r\n * so a client navigation never drops a `Set-Cookie` a full load would have\r\n * kept (`create-page-route-handler.spec.ts` — \"applies committed cookies and\r\n * headers exactly as the document path does\").\r\n */\r\nfunction applyCommit(\r\n response: Response,\r\n rendered: Pick<RenderedPage, \"headers\" | \"cookies\">,\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void,\r\n): void {\r\n response.headers(rendered.headers ?? {});\r\n\r\n for (const cookie of rendered.cookies ?? []) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n}\r\n\r\n/**\r\n * How the handler obtains a page/layout/app module, by the same id\r\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\r\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\r\n * owns the dev/prod split, so the handler never learns which one it got.\r\n */\r\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\r\n\r\nexport type PageRouteHandlerOptions = {\r\n /** The composed, registered route path — `composeRoutePath`'s output. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\r\n appFile: string;\r\n /** The page module's id. */\r\n pageFile: string;\r\n /** The page's own-directory `layout.tsx`, when it has one. */\r\n layoutFile?: string | undefined;\r\n loadModule: PageModuleLoader;\r\n /** Optional lazy application `error.page.tsx` loader. Never called on success. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n /**\r\n * Load the REAL layout module namespaces, outermost first, for universal\r\n * registration. This stays separate from `loadModule(layoutFile)` because\r\n * dev may answer that id with a synthetic wrapper whose middleware is the\r\n * composition of several layouts. That wrapper is a render-pipeline detail,\r\n * not a module identity, and must never enter `registerModules`' WeakSet.\r\n */\r\n loadRegistrationLayouts?: () => Promise<readonly RegisterableModuleNamespace[]>;\r\n /** Browser module appended after the server-rendered document. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\r\n * styled. Absent or empty means the application has no CSS — it never means\r\n * a stylesheet failed to resolve, which is the build's job to report.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-error-transport.ts` exports — passed in, never imported. */\r\n /**\r\n * The pattern stage 1 matches `request.path` against, when it differs from\r\n * the REGISTERED path. Defaults to `path`, which is right for every route\r\n * whose URL is its own.\r\n *\r\n * Exactly one route needs it: the not-found page, registered on the catch-all\r\n * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and\r\n * has no wildcard token, so a route registered as `*` matches NOTHING — the\r\n * pipeline reports no match and `renderPageRequest` answers `{ html: \"\",\r\n * status: 404 }`. Correct status, empty document: a 404 page that never\r\n * renders its own body. Handing it `requestPath => requestPath` makes the\r\n * requested URL the route's pattern for that one request, so the match is\r\n * trivially true and the page renders for the URL the visitor actually asked\r\n * for.\r\n */\r\n matchPath?: (requestPath: string) => string;\r\n /**\r\n * The status this route answers with when the pipeline settles on a plain\r\n * `200` — the not-found route's `404`, and nothing else uses it.\r\n *\r\n * Applied ONLY to `200`, never as a blanket override: a `200` from this\r\n * pipeline means \"the document rendered and nobody objected\", which for this\r\n * route is precisely the not-found case. Any other settled status is a real\r\n * outcome that the page or the boundary decided — a 500 from a failed render,\r\n * a redirect — and overwriting it would report a broken page as a missing one.\r\n */\r\n statusForRenderedOk?: number;\r\n /**\r\n * Exclude the page module's loader from the request triple while preserving\r\n * the real namespace for `register()` and rendering. Used only by the\r\n * catch-all 404 page: a missing URL must not run application data work or\r\n * turn a simple miss into a second failure path.\r\n */\r\n skipPageLoader?: boolean;\r\n /**\r\n * Replays one committed cookie through core's `Response.cookie()`. Defaults\r\n * to doing exactly that (`defaultApplyBufferedCookie`, above); injectable so\r\n * a caller with a different `Response` shape (or a test) can observe/replace\r\n * the call.\r\n */\r\n applyBufferedCookie?: (response: Response, cookie: BufferedCookie) => void;\r\n /**\r\n * The Fastify instance to register the `Set-Cookie` cache-floor `onSend`\r\n * hook on (`ensureSetCookieCacheFloorHook`, `set-cookie-cache-floor-hook.ts`).\r\n * Defaults to `container.get(\"http.server\")` — the same instance\r\n * `HttpConnector` publishes during its own `boot()`, which runs before\r\n * `WebConnector.boot()` calls this factory. Injectable so a test can hand\r\n * this factory a self-contained Fastify instance it built and booted\r\n * itself, with no framework connector graph involved.\r\n */\r\n httpServer?: FastifyInstance;\r\n /**\r\n * This route's resolved `cache` opt-in, already validated\r\n * ({@link resolvePageRouteCache}) by whichever installer (dev's\r\n * `install-page-routes.ts` or production's\r\n * `install-page-routes-from-manifest.ts`) built these options — `undefined`\r\n * means the route declared no `cache` at all. Read by\r\n * `applyResponseCacheFloor` (`response-cache-floor.ts`) at the same seam\r\n * that applies the `Set-Cookie`/auth-derived floor, so the document and the\r\n * data representation can never disagree on `Cache-Control`.\r\n */\r\n cache?: PageCacheOptIn;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void | Response>;\r\n\r\nfunction escapeHtmlAttribute(value: string): string {\r\n return value.replace(/[&<>\"']/g, (character) => {\r\n switch (character) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\nfunction installHydrationClientModule(\r\n html: string,\r\n moduleUrl: string | undefined,\r\n nonce: string | undefined,\r\n): string {\r\n if (moduleUrl === undefined || html === \"\") return html;\r\n\r\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\r\n if (closingBodyIndex === -1) {\r\n throw new Error(\r\n \"createPageRouteHandler: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\r\n );\r\n }\r\n\r\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\r\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\r\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\r\n}\r\n\r\n/**\r\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\r\n *\r\n * Without this the document carries no CSS at all. The stylesheet reaches the\r\n * browser only because the CLIENT bundle imports it, which means it is applied\r\n * by JavaScript after the module graph loads — the page renders unstyled first\r\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\r\n * in the console to explain it.\r\n *\r\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\r\n * here: the browser holds the first paint until the CSS is in, so there is no\r\n * flash rather than a faster ugly one.\r\n *\r\n * Inserted before `</head>` rather than after `<head>` so an application's own\r\n * `<link>`/`<style>` in the root document still comes FIRST and can be\r\n * overridden by these — matching how the framework's tags are documented to\r\n * behave, and keeping cascade order predictable.\r\n */\r\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\r\n if (stylesheetUrls.length === 0 || html === \"\") return html;\r\n\r\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\r\n\r\n // No `<head>` is not an error the way a missing `</body>` is: a root that\r\n // renders no head is unusual but legal, and losing the stylesheet is a\r\n // cosmetic failure where losing hydration is a broken page. Silently\r\n // dropping it would be the wrong trade the other way, though — so the\r\n // document is left exactly as rendered and the caller's own missing-`</body>`\r\n // check remains the loud one.\r\n if (closingHeadIndex === -1) return html;\r\n\r\n const links = stylesheetUrls\r\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\r\n .join(\"\");\r\n\r\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\r\n}\r\n\r\n/**\r\n * Build the handler for ONE page route. Per request it loads the App + layout\r\n * + page triple (concurrently, in that order), renders the URL through\r\n * `renderPageRequest`, splices in the hydration module, and flushes the\r\n * document.\r\n *\r\n * No try/catch, deliberately: loader/render throws are already absorbed by the\r\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\r\n * escapes (a module-load or register failure, the missing-`</body>` throw\r\n * above) belongs to the router's error path — which is exactly where it went\r\n * before.\r\n */\r\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\r\n const {\r\n path,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n loadModule,\r\n loadErrorPage,\r\n loadRegistrationLayouts,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n matchPath,\r\n statusForRenderedOk,\r\n skipPageLoader = false,\r\n applyBufferedCookie = defaultApplyBufferedCookie,\r\n cache,\r\n } = options;\r\n\r\n // Distinguish \"not supplied\" (fall back to the container, and REQUIRE the\r\n // container to have it) from \"supplied as `undefined`\" (a deliberate \"this\r\n // handler has no server\" — the escape hatch unit tests use). Collapsing\r\n // both into one optional-with-a-default, as this used to, let a genuinely\r\n // missing `http.server` container binding masquerade as the deliberate\r\n // no-server case with no signal at all — see `MissingHttpServerForPageRouteError`.\r\n let httpServer: FastifyInstance | undefined;\r\n\r\n if (\"httpServer\" in options) {\r\n httpServer = options.httpServer;\r\n } else if (container.has(\"http.server\")) {\r\n httpServer = container.get(\"http.server\");\r\n } else {\r\n throw new MissingHttpServerForPageRouteError();\r\n }\r\n\r\n // Registration-time, not request-time: this runs once per page route, while\r\n // `WebConnector.boot()` installs routes — after `HttpConnector.boot()` has\r\n // already registered `@fastify/cookie` (`set-cookie-cache-floor-hook.ts`\r\n // explains why that ordering is what makes the hook able to see the\r\n // header). `httpServer` is `undefined` here only when it was supplied that\r\n // way explicitly (checked above) — nothing to register the hook on, and\r\n // nothing that will ever mark a request as a page response either, so\r\n // skipping is correct, not just safe.\r\n if (httpServer) {\r\n ensureSetCookieCacheFloorHook(httpServer);\r\n }\r\n\r\n return async ({ request, response }: HttpContext) => {\r\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n try {\r\n const [appModule, layoutModule, ownPageModule, registrationLayouts] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n loadRegistrationLayouts?.() ?? Promise.resolve([]),\r\n ]);\r\n\r\n // Registration is the first lifecycle action after all module namespaces\r\n // have loaded and before `renderPageRequest` can run middleware, loaders or\r\n // render. App/page are already their real namespaces. Layouts deliberately\r\n // come from the separate raw chain above, never from `layoutModule`, which\r\n // may be the synthetic composed middleware wrapper used by dev.\r\n registerModules([\r\n appModule as RegisterableModuleNamespace,\r\n ...registrationLayouts,\r\n ownPageModule as RegisterableModuleNamespace,\r\n ]);\r\n\r\n const pageModule = ownPageModule as PageTripleModule;\r\n const triple: PageRouteEntry[\"triple\"] = {\r\n app: appModule as PageTripleModule,\r\n layout: layoutModule as PageTripleModule,\r\n // Registration above deliberately receives the REAL namespace. Only the\r\n // pipeline view is projected: spreading preserves the component,\r\n // metadata, middleware and boundary exports while making a custom 404's\r\n // loader uncallable.\r\n page: skipPageLoader\r\n ? {\r\n ...pageModule,\r\n // Vite and native ESM loaders hand us module namespace objects,\r\n // whose export descriptors are not an object-spread contract.\r\n // Keep the rendering export explicitly while hiding only loader.\r\n default: pageModule.default,\r\n loader: undefined,\r\n }\r\n : pageModule,\r\n };\r\n\r\n const requestUrl = request.path;\r\n const [requestPathname] = requestUrl.split(\"?\");\r\n const entry: PageRouteEntry = {\r\n path: matchPath === undefined ? path : matchPath(requestPathname),\r\n name,\r\n triple,\r\n };\r\n\r\n // Core selected this handler before it constructed the HTTP context and\r\n // decoded dynamic segments into `request.params`. Passing that result\r\n // through makes core the sole matcher on the live path. The catch-all\r\n // page deliberately has no wildcard param: its virtual path is the\r\n // missed URL itself, so it remains the named `not-found` route with `{}`.\r\n const params = matchPath === undefined ? (request.params as Record<string, string>) : {};\r\n\r\n // A DATA request runs everything above and below this line identically —\r\n // it is the same route, the same match and the same pipeline — and differs\r\n // only in what gets written at the end. Decided here, before the render, so\r\n // the branch is visibly about REPRESENTATION and not about behaviour.\r\n const rendered = await renderPageRequest(requestUrl, {\r\n routes: [entry],\r\n matched: { entry, params },\r\n createHttp: () => ({ request, response }),\r\n loadErrorPage,\r\n });\r\n\r\n if (rendered instanceof Response) return rendered;\r\n\r\n // See `statusForRenderedOk`: a settled 200 is the only status this route is\r\n // allowed to restate, and both the document and the data branch below must\r\n // restate it the same way — a client navigation that received 200 with a\r\n // not-found payload would push the URL into history as a real page.\r\n const status =\r\n rendered.status === 200 && statusForRenderedOk !== undefined\r\n ? statusForRenderedOk\r\n : rendered.status;\r\n\r\n // Stage 10a: the stage 7 commit (headers, then cookies), applied ONCE,\r\n // identically for the document and the data representation — see\r\n // `applyCommit`.\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n // Marks this request for `set-cookie-cache-floor-hook.ts`'s `onSend`\r\n // hook, which runs LATER than this seam — after `@fastify/cookie` has\r\n // flushed a parked `setCookie()`/`clearCookie()` call onto the real\r\n // header. Must happen before either terminal write below, same as\r\n // `applyResponseCacheFloor` just below it.\r\n markPageResponse(request);\r\n\r\n // `request.locals.authDerived` (core `Request`) is set the moment `user`\r\n // or `decodedAccessToken` is assigned, and never cleared. Overriding\r\n // `Cache-Control` here — after `applyCommit`'s default `private` and\r\n // before EITHER terminal write below — closes two gaps `private` alone\r\n // leaves open: a browser (not a shared cache; `private` already stops\r\n // those) holding an authenticated page in its own disk/back-forward\r\n // cache with no freshness directive, AND a `Set-Cookie` response held in\r\n // a shared cache handing the same cookie to every later visitor\r\n // (session fixation — see `response-cache-floor.ts`). Read once,\r\n // applied identically to both representations, so neither can carry a\r\n // weaker header than the other.\r\n //\r\n // TRI-STATE, deliberately, not `=== true`: several existing unit tests\r\n // hand this handler a plain `{ path, header }` mock with no `locals` at\r\n // all, never a real core `Request` — that is the auth mark mechanism\r\n // being genuinely UNOBSERVABLE on this request, not the mechanism\r\n // having fired `false`. Collapsing both into one boolean via\r\n // `request.locals?.authDerived === true` used to read \"unobservable\" as\r\n // \"provably clean\", which let an opted-in route serve `public,\r\n // max-age=N` to a request nobody could actually vouch for. The ruling\r\n // for the per-route cache opt-in is fail-CLOSED — unproven means\r\n // revoked — so `undefined` is passed through as its own state here and\r\n // it is `applyResponseCacheFloor` (`response-cache-floor.ts`) that\r\n // decides what each of the three states does to the opt-in; this seam\r\n // only reports what it actually knows.\r\n applyResponseCacheFloor(response, {\r\n authDerived: request.locals === undefined ? undefined : request.locals.authDerived === true,\r\n cache,\r\n });\r\n\r\n if (wantsData) {\r\n // So a shared cache can never serve a document to a client that asked for\r\n // JSON, or the reverse. See `data-request.ts` on why this stays even\r\n // while page responses are `no-store`.\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n\r\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\r\n // ran and there is no payload to build. Fastify already matched this\r\n // route to get here, so reaching it means `request.path` did not satisfy\r\n // the entry's own pattern — answered as the 404 it is, rather than\r\n // synthesising an empty payload the client would try to render as a page.\r\n if (rendered.bundle === undefined) {\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify({ error: \"not_found\" }), status);\r\n\r\n return;\r\n }\r\n\r\n // SERIALIZED HERE, and handed over as a STRING on purpose.\r\n //\r\n // `response.send(object)` runs the body through core's `Response.parse`,\r\n // which recurses the object, calls `toJSON()` on anything that has one\r\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\r\n // the right behaviour for a controller returning Resources; it is the\r\n // wrong behaviour here, because the DOCUMENT path serializes this exact\r\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\r\n // one path through a transformer and not the other is precisely the\r\n // drift `build-hydration-payload.ts` exists to prevent — the browser\r\n // would build one tree on a page load and a different one on a\r\n // navigation to the same URL.\r\n //\r\n // A string body also bypasses `parseBody()` entirely, so the content type\r\n // has to be declared rather than inferred from an object body.\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\r\n JSON.stringify(buildHydrationPayload(rendered.bundle, request.locale)),\r\n status,\r\n );\r\n\r\n return;\r\n }\r\n\r\n // Stylesheets first: they go in `<head>`, the hydration module goes before\r\n // `</body>`, and doing the head work on the already-rendered string keeps\r\n // both splices in one place rather than threading CSS through the React\r\n // render just to reach the same bytes.\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n const html = installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n\r\n await response.html(html, status);\r\n } catch (thrown) {\r\n // This is outside the page pipeline: loading/registering a module can\r\n // fail before a triple exists for its authored boundaries to handle.\r\n // Reuse this request/response pair so headers, nonce and response\r\n // ownership remain exactly the same as the ordinary path.\r\n //\r\n // Nested try/catch, deliberately: this block's own job is to render a\r\n // NICER answer for `thrown` — it must never let a failure IN THAT\r\n // ATTEMPT (`renderPageFailure` itself throwing, or misbehaving) replace\r\n // `thrown` with a less useful error. If rendering the failure page\r\n // fails too, the original throw escapes exactly as it would have with\r\n // no try/catch at all (the file header's stated contract) — the\r\n // router's own error path is still the answer, just one throw later.\r\n try {\r\n const rendered = await renderPageFailure({\r\n name,\r\n path: request.path,\r\n request,\r\n response,\r\n thrown,\r\n loadErrorPage,\r\n });\r\n\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n if (wantsData) {\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\r\n JSON.stringify(buildHydrationPayload(rendered.bundle!, request.locale)),\r\n 500,\r\n );\r\n return;\r\n }\r\n\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n // `renderPageFailure` marks its bundle non-hydrating (page-render-bundle.ts):\r\n // there is no triple, so there is nothing on the client the hydration\r\n // module could attach to. Injecting it anyway would ship a script that\r\n // hydrates against a composition the server never trusted.\r\n const html = isNonHydrating(rendered.bundle)\r\n ? styled\r\n : installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n await response.html(html, 500);\r\n } catch {\r\n throw thrown;\r\n }\r\n }\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from "node:util";
|
|
2
2
|
|
|
3
|
-
//#region ../web/src/server/dev-
|
|
3
|
+
//#region ../web/src/server/dev-error-transport.ts
|
|
4
4
|
/**
|
|
5
5
|
* Where a captured transform/resolve failure rides from Vite's connect stack to
|
|
6
6
|
* the Fastify hook that mounted it.
|
|
@@ -153,4 +153,4 @@ function sendCapturedDevError(request, response) {
|
|
|
153
153
|
|
|
154
154
|
//#endregion
|
|
155
155
|
export { devErrorTransportPlugin, sendCapturedDevError };
|
|
156
|
-
//# sourceMappingURL=dev-
|
|
156
|
+
//# sourceMappingURL=dev-error-transport.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-error-transport.mjs","names":[],"sources":["../../../../../../../web/src/server/dev-error-transport.ts"],"sourcesContent":["/** Development-only transport for Vite transform failures. */\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { stripVTControlCharacters } from \"node:util\";\nimport type { Connect, Plugin } from \"vite\";\n\n/**\n * Where a captured transform/resolve failure rides from Vite's connect stack to\n * the Fastify hook that mounted it.\n *\n * A `Symbol.for` key on the raw `IncomingMessage` rather than a `WeakMap`\n * because the two halves live in different modules and are wired at different\n * times; the request object is the only thing they provably share, and the\n * symbol cannot collide with a Vite/Fastify/user property.\n *\n * Exported so a test can stage a captured failure without booting a vite\n * server, and so the two halves cannot drift onto two different keys.\n */\nexport const DEV_TRANSFORM_ERROR_BODY = Symbol.for(\"warlock.web.devTransformErrorBody\");\n\ntype DevTransformErrorCarrier = { [DEV_TRANSFORM_ERROR_BODY]?: string };\n\n/**\n * The status a refused module now answers with.\n *\n * NOT the 404 this replaces. That 404 was never a decision about the module —\n * it is what an unmatched URL gets once vite has declined it, and which of the\n * two framework answers you see depends only on whether the app declares a\n * catch-all page: with one (v5/app does — `path: \"*\"`) the request lands in the\n * page pipeline, matches no route, and `./render-page.ts:604` returns\n * `{ html: \"\", status: 404 }` for `./create-page-route-handler.ts:147` to write\n * as an empty `text/html` body; without one it is `core/src/router/router.ts:879`.\n * Either way \"the module does not exist\" is precisely the wrong thing to tell a\n * developer whose module exists and was refused. 500 is the status VITE ITSELF writes for\n * this exact condition when it is not in middleware mode\n * (`node_modules/vite/dist/node/chunks/config.js:9528`), so this adopts that\n * convention rather than inventing a third one.\n */\nexport const DEV_TRANSFORM_ERROR_STATUS = 500;\n\n/**\n * `buildErrorMessage` as vite exports it. Declared structurally so this module\n * needs no value import of vite — vite is an optional, dev-only peer and a\n * production install does not carry it.\n */\nexport type BuildErrorMessage = (error: Error, args?: string[], includeStack?: boolean) => string;\n\n/**\n * The dev error transport was constructed while the process is hosting a\n * PRODUCTION build. Refused by name at construction rather than degraded,\n * because everything this transport does — file paths, source frames, plugin\n * names — is exactly what a production response must never carry.\n */\nexport class DevErrorTransportInProductionError extends Error {\n public constructor() {\n super(\n \"The dev error transport was constructed with `Application.runtimeStrategy === \" +\n '\"production\"`. It exists only to put a Vite transform failure in front of a ' +\n \"developer and its response body carries absolute file paths and source frames, \" +\n \"so it must never be mounted on a production-hosted server.\",\n );\n this.name = \"DevErrorTransportInProductionError\";\n }\n}\n\n/**\n * Render a refused module's failure as the plain-text body the browser gets.\n *\n * Formatting is DELEGATED to vite's own exported `buildErrorMessage`, not\n * reimplemented: it is the same function vite uses to print the failure to the\n * terminal, so the text a developer reads in the network panel and the text\n * they read in the terminal cannot drift. Two things are added around it —\n * `error.name`, which vite's terminal path replaces with a fixed\n * \"Internal server error:\" prefix and which is the single most useful token for\n * a named gate refusal (`ProjectionAmbiguityError`), and the `cause` chain,\n * which vite does not walk.\n *\n * `stripVTControlCharacters` is not optional: `buildErrorMessage` colours its\n * output with picocolors, which is ON whenever the dev server owns a TTY, and\n * raw ANSI escapes in an HTTP body are noise. Vite strips them the same way for\n * the overlay payload (`config.js:9490-9497`).\n *\n * The stack is deliberately omitted (`includeStack: false`). A gate refusal's\n * stack points into the gate, not into the developer's code; the fields that\n * locate the problem — plugin, file, line, source frame — are what\n * `buildErrorMessage` puts there without it.\n */\nexport function formatDevTransformError(\n error: unknown,\n buildErrorMessage: BuildErrorMessage,\n): string {\n const failure =\n error instanceof Error ? error : new Error(typeof error === \"string\" ? error : String(error));\n\n const lines = [\n stripVTControlCharacters(\n buildErrorMessage(failure, [`${failure.name}: ${failure.message}`], false),\n ),\n ];\n\n // Walk the `cause` chain. A gate may wrap a parser failure, and the wrapped\n // message is usually the one naming the actual syntax that was refused.\n let cause = (failure as { cause?: unknown }).cause;\n\n while (cause instanceof Error) {\n lines.push(` Caused by: ${cause.name}: ${stripVTControlCharacters(cause.message)}`);\n cause = (cause as { cause?: unknown }).cause;\n }\n\n return `${lines.join(\"\\n\")}\\n`;\n}\n\n/**\n * DEV-ONLY. Capture the transform/resolve failure that vite is about to throw\n * away, so the request that caused it can answer with it.\n *\n * WHY THIS IS A PLUGIN AND NOT A `middlewares.use(...)` CALL — this is the\n * whole defect, and it is an ordering fact, not a style choice:\n *\n * - Vite mounts its own error handler LAST, built as\n * `errorMiddleware(server, !!middlewareMode)`\n * (`node_modules/vite/dist/node/chunks/config.js:25705`).\n * - In middleware mode that `allowNext` flag is `true`, and the handler then\n * logs the error to the TERMINAL and calls `next()` — with no error\n * (`config.js:9525-9527`).\n * - connect only routes an error to a 4-arity handler while an error is in\n * flight (`config.js:10611-10626`), so `next()` clears it: every layer after\n * that point, INCLUDING the `done` callback `./web-connector.ts` hands the\n * stack, is called as if the request had simply gone unhandled. The\n * framework then answers the only way it can for a URL it does not know — a\n * 404, empty (see {@link DEV_TRANSFORM_ERROR_STATUS} for which of the two\n * produces it).\n * - Anything registered with `vite.middlewares.use(...)` after `createServer()`\n * resolves lands AFTER that handler and is therefore unreachable. A\n * `configureServer` POST hook does not: vite runs post hooks at\n * `config.js:25700`, five lines BEFORE it mounts its error handler.\n *\n * So this sits between the failure and vite's logger. It captures, then calls\n * `next(error)` and lets vite's own handler run exactly as before — the\n * terminal message and the `hot.send({ type: \"error\" })` overlay push\n * (`config.js:9511-9521`) are unchanged. This transport ADDS a reader; it\n * replaces nothing.\n *\n * @param isProductionRuntime the connector's own hosting-mode signal\n * (`./web-connector.ts:122`) — passed in rather than re-derived so there\n * is one definition of \"this process is Vite-hosted\", not two.\n */\nexport function devErrorTransportPlugin(options: {\n isProductionRuntime: () => boolean;\n buildErrorMessage: BuildErrorMessage;\n}): Plugin {\n const { isProductionRuntime, buildErrorMessage } = options;\n\n if (isProductionRuntime()) {\n throw new DevErrorTransportInProductionError();\n }\n\n const capture: Connect.ErrorHandleFunction = (error, request, _response, next) => {\n // Re-asserted per request, not just at construction: `runtimeStrategy` is\n // process state and a transport that leaks source frames is not something\n // to hold open on a boot-time reading alone. In production this layer is a\n // pass-through and vite's handler behaves exactly as it does today.\n if (isProductionRuntime()) return next(error);\n\n (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY] = formatDevTransformError(\n error,\n buildErrorMessage,\n );\n\n next(error);\n };\n\n return {\n name: \"warlock:dev-error-transport\",\n // Belt to the `isProductionRuntime` braces: this plugin has no business in\n // a `vite build` graph either.\n apply: \"serve\",\n configureServer(server) {\n // RETURNING a function is what makes this a POST hook — the ordering the\n // note above depends on. Mounting inline here would land the layer BEFORE\n // vite's transform middleware, where no error has been thrown yet.\n return () => {\n server.middlewares.use(capture);\n };\n },\n };\n}\n\n/**\n * Answer the request with the failure {@link devErrorTransportPlugin} captured,\n * if there was one. Returns `false` when there was not, which is the normal\n * case and means \"carry on down the framework's own path\".\n *\n * Called from the Fastify `onRequest` hook that mounts vite\n * (`./web-connector.ts:321`), in the `done` callback — i.e. at the one moment\n * where connect has finished, vite has declined to answer, and the framework is\n * about to 404. Writing to the raw `ServerResponse` rather than through Fastify\n * is what the mount already does for every response vite serves, so this stays\n * on the same side of the seam.\n */\nexport function sendCapturedDevError(request: IncomingMessage, response: ServerResponse): boolean {\n const body = (request as DevTransformErrorCarrier)[DEV_TRANSFORM_ERROR_BODY];\n\n if (typeof body !== \"string\") return false;\n\n // A middleware further down may have answered already (vite serves plenty of\n // requests itself). Never write twice; the captured body is then just dropped.\n if (response.headersSent || response.writableEnded) return false;\n\n response.statusCode = DEV_TRANSFORM_ERROR_STATUS;\n response.setHeader(\"content-type\", \"text/plain; charset=utf-8\");\n // A refusal is a fact about the CURRENT source. Caching it would survive the\n // edit that fixes it.\n response.setHeader(\"cache-control\", \"no-store\");\n response.end(body);\n\n return true;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAiBA,MAAa,2BAA2B,OAAO,IAAI,mCAAmC;;;;;;;;;;;;;;;;;AAoBtF,MAAa,6BAA6B;;;;;;;AAe1C,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,cAAc;EACnB,MACE,uSAIF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,wBACd,OACA,mBACQ;CACR,MAAM,UACJ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK,CAAC;CAE9F,MAAM,QAAQ,CACZ,yBACE,kBAAkB,SAAS,CAAC,GAAG,QAAQ,KAAK,IAAI,QAAQ,SAAS,GAAG,KAAK,CAC3E,CACF;CAIA,IAAI,QAAS,QAAgC;CAE7C,OAAO,iBAAiB,OAAO;EAC7B,MAAM,KAAK,gBAAgB,MAAM,KAAK,IAAI,yBAAyB,MAAM,OAAO,GAAG;EACnF,QAAS,MAA8B;CACzC;CAEA,OAAO,GAAG,MAAM,KAAK,IAAI,EAAE;AAC7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,wBAAwB,SAG7B;CACT,MAAM,EAAE,qBAAqB,sBAAsB;CAEnD,IAAI,oBAAoB,GACtB,MAAM,IAAI,mCAAmC;CAG/C,MAAM,WAAwC,OAAO,SAAS,WAAW,SAAS;EAKhF,IAAI,oBAAoB,GAAG,OAAO,KAAK,KAAK;EAE5C,AAAC,QAAqC,4BAA4B,wBAChE,OACA,iBACF;EAEA,KAAK,KAAK;CACZ;CAEA,OAAO;EACL,MAAM;EAGN,OAAO;EACP,gBAAgB,QAAQ;GAItB,aAAa;IACX,OAAO,YAAY,IAAI,OAAO;GAChC;EACF;CACF;AACF;;;;;;;;;;;;;AAcA,SAAgB,qBAAqB,SAA0B,UAAmC;CAChG,MAAM,OAAQ,QAAqC;CAEnD,IAAI,OAAO,SAAS,UAAU,OAAO;CAIrC,IAAI,SAAS,eAAe,SAAS,eAAe,OAAO;CAE3D,SAAS;CACT,SAAS,UAAU,gBAAgB,2BAA2B;CAG9D,SAAS,UAAU,iBAAiB,UAAU;CAC9C,SAAS,IAAI,IAAI;CAEjB,OAAO;AACT"}
|
|
@@ -1,17 +1,41 @@
|
|
|
1
1
|
import { enterSharedScope, sealShared } from "../shared.mjs";
|
|
2
|
+
import { connectRequestSearch } from "../routing/query-string.mjs";
|
|
2
3
|
import { resolvePageMetadata } from "./resolve-page-metadata.mjs";
|
|
3
4
|
import { connectPageContext, enterAdditionalSharedScope, requireRunner } from "./page-context.mjs";
|
|
4
5
|
import { matchRoute } from "./match-page-route.mjs";
|
|
5
6
|
import { resolveValidationData } from "./resolve-validation-data.mjs";
|
|
7
|
+
import { resolvePageValidationInput } from "./resolve-route-validation-input.mjs";
|
|
6
8
|
import { LEVEL_ORDER, buildErrorRecord, commitBuffers, createBufferedResponse, createLevelBuffer, designateBoundary, isLoaderShortCircuit } from "./settle-page-response.mjs";
|
|
9
|
+
import { RouteMiddlewareRemovedError } from "./install-page-routes.mjs";
|
|
7
10
|
import { Response } from "@warlock.js/core";
|
|
8
11
|
import { v } from "@warlock.js/seal";
|
|
9
12
|
|
|
10
13
|
//#region ../web/src/server/execute-page-request.ts
|
|
14
|
+
/**
|
|
15
|
+
* Self-wires `useQueryString`'s SSR seam to the SAME per-request store
|
|
16
|
+
* `connectSharedStore`/`connectPageContext` already read (canon `1ca1e8ae`'s
|
|
17
|
+
* scoping) — done HERE, once, rather than asking every server bootstrap
|
|
18
|
+
* (dev's Vite-graph wiring in `web-connector.ts`, prod's
|
|
19
|
+
* `installProductionPageRoutes`) to remember a THIRD `connect*` call for the
|
|
20
|
+
* same store.
|
|
21
|
+
*
|
|
22
|
+
* The resolver calls `requireRunner()` on every read, not the `runner`
|
|
23
|
+
* closed over by its caller: `runner.getStore()` is only valid for as long as
|
|
24
|
+
* that particular runner is connected, and a test (or a later reconnect)
|
|
25
|
+
* that swaps in a new one via `connectPageContext` must not leave this
|
|
26
|
+
* resolver reading a stale runner's store.
|
|
27
|
+
*/
|
|
28
|
+
let requestSearchWired = false;
|
|
29
|
+
function wireRequestSearch() {
|
|
30
|
+
if (requestSearchWired) return;
|
|
31
|
+
requestSearchWired = true;
|
|
32
|
+
connectRequestSearch(() => requireRunner().getStore()?.request.url);
|
|
33
|
+
}
|
|
11
34
|
async function executePageRequest(options) {
|
|
12
35
|
const runner = requireRunner();
|
|
36
|
+
wireRequestSearch();
|
|
13
37
|
const [pathname, queryString] = options.url.split("?");
|
|
14
|
-
const matched = matchRoute(pathname, options.routes);
|
|
38
|
+
const matched = options.matched ?? matchRoute(pathname, options.routes);
|
|
15
39
|
if (!matched) return void 0;
|
|
16
40
|
const query = Object.fromEntries(new URLSearchParams(queryString ?? ""));
|
|
17
41
|
const match = {
|
|
@@ -38,40 +62,70 @@ async function executePageRequest(options) {
|
|
|
38
62
|
params: match.params,
|
|
39
63
|
query
|
|
40
64
|
} };
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
/**
|
|
66
|
+
* THE MIDDLEWARE SURFACES, TOGETHER — the one place both are documented,
|
|
67
|
+
* so they cannot again be found "separately and inconsistently" (canon
|
|
68
|
+
* `b79c4f55`, point 5):
|
|
69
|
+
*
|
|
70
|
+
* - A LAYOUT'S `middleware` export (`../routing/layout-policy.ts` — a
|
|
71
|
+
* middleware-only layout, one with no default export, is treated as a
|
|
72
|
+
* deliberate authorization boundary and composes freely). Every layout
|
|
73
|
+
* on a page's chain contributes, outermost first
|
|
74
|
+
* (`install-page-routes.ts`'s `composeLayoutLevel` folds the whole
|
|
75
|
+
* chain into `triple.layout.middleware` before this runs).
|
|
76
|
+
* - The PAGE's OWN top-level `middleware` export (`triple.page.middleware`)
|
|
77
|
+
* — a page's own answer to "what does this URL require", one level
|
|
78
|
+
* below the layout instead of borrowed from it (canon `f2e514c0`).
|
|
79
|
+
*
|
|
80
|
+
* ONE ordering rule covers both: `LEVEL_ORDER` (app, layout, page) runs
|
|
81
|
+
* outermost-first, so the page's own list always runs LAST — closest to
|
|
82
|
+
* the loader. A layout's auth gate can therefore never be bypassed by a
|
|
83
|
+
* page's own middleware.
|
|
84
|
+
*/
|
|
85
|
+
for (const level of LEVEL_ORDER) {
|
|
86
|
+
const middlewareForLevel = triple[level].middleware ?? [];
|
|
87
|
+
for (const middleware of middlewareForLevel) {
|
|
88
|
+
let output;
|
|
89
|
+
try {
|
|
90
|
+
output = await middleware({
|
|
91
|
+
request,
|
|
92
|
+
response
|
|
93
|
+
});
|
|
94
|
+
} catch (thrown) {
|
|
95
|
+
bundle.error = buildErrorRecord(thrown, designateBoundary(level, triple), pathname);
|
|
96
|
+
response.setStatusCode(500);
|
|
97
|
+
return finish(bundle);
|
|
98
|
+
}
|
|
99
|
+
if (output !== void 0) {
|
|
100
|
+
bundle.shortCircuit = {
|
|
101
|
+
stage: "middleware",
|
|
102
|
+
level,
|
|
103
|
+
value: output,
|
|
104
|
+
statusCode: response.statusCode
|
|
105
|
+
};
|
|
106
|
+
return finish(bundle);
|
|
107
|
+
}
|
|
61
108
|
}
|
|
62
109
|
}
|
|
63
110
|
const validation = triple.page.validation;
|
|
64
|
-
if (validation
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
if (validation) {
|
|
112
|
+
const legacyValidation = "schema" in validation || "validating" in validation;
|
|
113
|
+
const schema = legacyValidation ? validation.schema : v.object({
|
|
114
|
+
...validation.params === void 0 ? {} : { params: validation.params },
|
|
115
|
+
...validation.query === void 0 ? {} : { query: validation.query }
|
|
116
|
+
});
|
|
117
|
+
if (schema) {
|
|
118
|
+
const data = legacyValidation ? resolveValidationData(validation.validating, request) : resolvePageValidationInput(request);
|
|
119
|
+
const result = await v.validate(schema, data);
|
|
120
|
+
if (result.isValid && result.data) request.setValidatedData(result.data);
|
|
121
|
+
if (!result.isValid) {
|
|
122
|
+
bundle.shortCircuit = {
|
|
123
|
+
stage: "validation",
|
|
124
|
+
status: 400,
|
|
125
|
+
errors: result.errors
|
|
126
|
+
};
|
|
127
|
+
return finish(bundle);
|
|
128
|
+
}
|
|
75
129
|
}
|
|
76
130
|
}
|
|
77
131
|
const sealedShared = await sealShared(store);
|
|
@@ -123,10 +177,12 @@ async function executePageRequest(options) {
|
|
|
123
177
|
else if (signalKind === "throw") {
|
|
124
178
|
committedLevels = LEVEL_ORDER.slice(0, signalIndex);
|
|
125
179
|
const boundary = designateBoundary(LEVEL_ORDER[signalIndex], triple);
|
|
126
|
-
|
|
180
|
+
const ownStatusCode = signalThrown?.statusCode;
|
|
181
|
+
bundle.error = buildErrorRecord(signalThrown, boundary, pathname, ownStatusCode);
|
|
127
182
|
if (boundary.boundaryLevel === "app") {
|
|
128
|
-
|
|
129
|
-
|
|
183
|
+
const status = ownStatusCode ?? 500;
|
|
184
|
+
response.setStatusCode(status);
|
|
185
|
+
forcedStatusCode = status;
|
|
130
186
|
}
|
|
131
187
|
} else {
|
|
132
188
|
committedLevels = LEVEL_ORDER.slice(0, signalIndex + 1);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute-page-request.mjs","names":[],"sources":["../../../../../../../web/src/server/execute-page-request.ts"],"sourcesContent":["import { Response } from \"@warlock.js/core\";\nimport { v } from \"@warlock.js/seal\";\nimport { enterSharedScope, sealShared } from \"../shared\";\nimport { enterAdditionalSharedScope, requireRunner } from \"./page-context\";\nimport { matchRoute } from \"./match-page-route\";\nimport { resolvePageMetadata } from \"./resolve-page-metadata\";\nimport { resolveValidationData } from \"./resolve-validation-data\";\nimport {\n buildErrorRecord,\n commitBuffers,\n createBufferedResponse,\n createLevelBuffer,\n designateBoundary,\n isLoaderShortCircuit,\n LEVEL_ORDER,\n type LevelBuffer,\n type PageResponseCommit,\n} from \"./settle-page-response\";\nimport type {\n ExecutePageRequestOptions,\n PageDataBundle,\n PageLevelName,\n PageRouteMatch,\n PipelineStore,\n} from \"./execute-page-request.types\";\n\nexport * from \"./execute-page-request.types\";\nexport { connectPageContext } from \"./page-context\";\nexport {\n buildErrorRecord,\n designateBoundary,\n type BufferedCookie,\n type BufferedHeader,\n type LoaderShortCircuitKind,\n type PageResponseCommit,\n} from \"./settle-page-response\";\n\n/** Widens `PageDataBundle` with the two fields stage 6/7 populate. */\ntype Bundle = PageDataBundle & {\n commit?: PageResponseCommit;\n shortCircuit?: PageDataBundle[\"shortCircuit\"] & {\n kind?: \"redirect\" | \"notFound\";\n url?: string;\n body?: unknown;\n };\n};\n\nexport async function executePageRequest<TResult = PageDataBundle>(\n options: ExecutePageRequestOptions<TResult>,\n): Promise<TResult | Response | undefined> {\n const runner = requireRunner();\n const [pathname, queryString] = options.url.split(\"?\");\n const matched = matchRoute(pathname, options.routes);\n\n if (!matched) return undefined;\n\n const query = Object.fromEntries(new URLSearchParams(queryString ?? \"\"));\n const match: PageRouteMatch = { entry: matched.entry, params: matched.params, query };\n const { triple } = matched.entry;\n const { request, response } = options.createHttp(match);\n const store: PipelineStore = runner.buildStore\n ? runner.buildStore({ request, response })\n : { request, response };\n\n return runner.run(store, async () => {\n enterSharedScope(store);\n enterAdditionalSharedScope(store);\n\n const finish = async (bundle: PageDataBundle): Promise<TResult> =>\n options.finish ? await options.finish(bundle) : (bundle as TResult);\n\n const bundle: Bundle = {\n route: {\n name: matched.entry.name,\n path: matched.entry.path,\n params: match.params,\n query,\n },\n };\n\n for (const level of LEVEL_ORDER) {\n for (const middleware of triple[level].middleware ?? []) {\n let output: unknown;\n\n try {\n output = await middleware({ request, response });\n } catch (thrown) {\n bundle.error = buildErrorRecord(thrown, designateBoundary(level, triple), pathname);\n response.setStatusCode(500);\n return finish(bundle);\n }\n\n if (output !== undefined) {\n bundle.shortCircuit = {\n stage: \"middleware\",\n level,\n value: output,\n statusCode: response.statusCode,\n };\n return finish(bundle);\n }\n }\n }\n\n const validation = triple.page.validation;\n\n if (validation?.schema) {\n const data = resolveValidationData(validation.validating, request);\n const result = await v.validate(validation.schema, data);\n\n if (result.isValid && result.data) {\n request.setValidatedData(result.data);\n }\n\n if (!result.isValid) {\n bundle.shortCircuit = { stage: \"validation\", status: 422, errors: result.errors };\n return finish(bundle);\n }\n }\n\n const sealedShared = await sealShared(store);\n bundle.shared = sealedShared;\n\n const dataKeys: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n };\n\n // Stage 6 — LOADERS, root to leaf. Every level gets its OWN buffer (never\n // the live response), and a terminal result or throw prevents every lower\n // loader from starting.\n const buffers: Record<PageLevelName, LevelBuffer> = {\n app: createLevelBuffer(),\n layout: createLevelBuffer(),\n page: createLevelBuffer(),\n };\n\n let signalIndex = -1;\n let signalKind: \"throw\" | \"shortCircuit\" | undefined;\n let signalThrown: unknown;\n let signalCircuit:\n | { kind: \"redirect\" | \"notFound\"; statusCode: number; url?: string; body?: unknown }\n | undefined;\n\n for (let index = 0; index < LEVEL_ORDER.length; index++) {\n const level = LEVEL_ORDER[index];\n const loader = triple[level].loader;\n\n if (!loader) continue;\n\n let value: unknown;\n\n try {\n value = await loader({\n request,\n response: createBufferedResponse(buffers[level]),\n shared: sealedShared,\n });\n } catch (thrown) {\n signalIndex = index;\n signalKind = \"throw\";\n signalThrown = thrown;\n break;\n }\n\n if (value instanceof Response) return value;\n\n if (isLoaderShortCircuit(value)) {\n signalIndex = index;\n signalKind = \"shortCircuit\";\n signalCircuit = value;\n break;\n }\n\n bundle[dataKeys[level]] = value;\n }\n\n let committedLevels: PageLevelName[];\n /** Set only when a THROW escalated to the app boundary — forces 500. */\n let forcedStatusCode: number | undefined;\n\n if (signalIndex === -1) {\n committedLevels = [...LEVEL_ORDER];\n } else if (signalKind === \"throw\") {\n // The throwing level's buffer is discarded; lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex);\n\n const boundary = designateBoundary(LEVEL_ORDER[signalIndex], triple);\n bundle.error = buildErrorRecord(signalThrown, boundary, pathname);\n\n if (boundary.boundaryLevel === \"app\") {\n response.setStatusCode(500);\n forcedStatusCode = 500;\n }\n } else {\n // Short-circuit: the signalling level's OWN buffer commits too\n // (inclusive); lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex + 1);\n\n const circuit = signalCircuit!;\n\n bundle.shortCircuit = {\n stage: \"loaders\",\n level: LEVEL_ORDER[signalIndex],\n kind: circuit.kind,\n statusCode: circuit.statusCode,\n url: circuit.url,\n body: circuit.body,\n } as unknown as PageDataBundle[\"shortCircuit\"];\n }\n\n bundle.commit = commitBuffers(response, buffers, committedLevels);\n\n // Forced AFTER the fold: an app-boundary escalation forces 500\n // regardless of what the surviving (rootward) buffers happened to set —\n // it is the framework's answer, not a loader's.\n if (forcedStatusCode !== undefined) bundle.commit.statusCode = forcedStatusCode;\n\n // Stage 8 — METADATA. Skipped entirely for a short-circuit (there is no\n // page to describe); a throw still runs it, same as before this stage 6/7\n // rewrite (a boundary still needs a title/robots answer).\n if (bundle.shortCircuit) {\n return finish(bundle);\n }\n\n const resolved = resolvePageMetadata({\n metadata: triple.page.metadata,\n data: bundle.pageData,\n error: bundle.error?.error,\n failed: Boolean(bundle.error),\n shared: sealedShared,\n });\n\n bundle.metadata = resolved.metadata;\n\n if (resolved.thrown !== undefined) {\n const boundary = designateBoundary(\"page\", triple);\n bundle.error = buildErrorRecord(resolved.thrown, boundary, bundle.route.path);\n\n if (boundary.boundaryLevel === \"app\") response.setStatusCode(500);\n }\n\n return finish(bundle);\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"execute-page-request.mjs","names":[],"sources":["../../../../../../../web/src/server/execute-page-request.ts"],"sourcesContent":["import { Response } from \"@warlock.js/core\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { enterSharedScope, sealShared } from \"../shared\";\r\nimport { connectRequestSearch } from \"../routing/query-string\";\r\nimport { enterAdditionalSharedScope, requireRunner } from \"./page-context\";\r\nimport { matchRoute } from \"./match-page-route\";\r\nimport { resolvePageMetadata } from \"./resolve-page-metadata\";\r\nimport { resolveValidationData } from \"./resolve-validation-data\";\r\nimport { resolvePageValidationInput } from \"./resolve-route-validation-input\";\r\nimport {\r\n buildErrorRecord,\r\n commitBuffers,\r\n createBufferedResponse,\r\n createLevelBuffer,\r\n designateBoundary,\r\n isLoaderShortCircuit,\r\n LEVEL_ORDER,\r\n type LevelBuffer,\r\n type PageResponseCommit,\r\n} from \"./settle-page-response\";\r\nimport type {\r\n ExecutePageRequestOptions,\r\n PageDataBundle,\r\n PageLevelName,\r\n PageRouteMatch,\r\n PipelineStore,\r\n} from \"./execute-page-request.types\";\r\n\r\nexport * from \"./execute-page-request.types\";\r\nexport { connectPageContext } from \"./page-context\";\r\nexport {\r\n buildErrorRecord,\r\n designateBoundary,\r\n type BufferedCookie,\r\n type BufferedHeader,\r\n type LoaderShortCircuitKind,\r\n type PageResponseCommit,\r\n} from \"./settle-page-response\";\r\nexport { RouteMiddlewareRemovedError } from \"./install-page-routes\";\r\n\r\n/**\r\n * `route.middleware` shipped in 5.6.0 and was withdrawn (owner ruling,\r\n * 2026-09-08): a page declares middleware in exactly one place, the\r\n * top-level `middleware` export. Thrown the first time a matched route's\r\n * page module still carries a `middleware` key on `route` — loud, not a\r\n * silent no-op, so the guard the author thinks is running is never quietly\r\n * dropped.\r\n */\r\n/* RouteMiddlewareRemovedError moved to install-page-routes.ts, where pageFile is available.\r\nexport class RouteMiddlewareRemovedError extends Error {\r\n public constructor(\r\n public readonly routeName: string,\r\n public readonly routePath: string,\r\n ) {\r\n super(\r\n `Warlock route \"${routeName}\" (${routePath}) declares \\`route.middleware\\`, which no ` +\r\n \"longer runs — it was withdrawn after 5.6.0. Move it to the page's own top-level \" +\r\n \"`middleware` export instead: `export const middleware = [...]`.\",\r\n );\r\n this.name = \"RouteMiddlewareRemovedError\";\r\n }\r\n}\r\n*/\r\n\r\n/** Widens `PageDataBundle` with the two fields stage 6/7 populate. */\r\ntype Bundle = PageDataBundle & {\r\n commit?: PageResponseCommit;\r\n shortCircuit?: PageDataBundle[\"shortCircuit\"] & {\r\n kind?: \"redirect\" | \"notFound\";\r\n url?: string;\r\n body?: unknown;\r\n };\r\n};\r\n\r\n/**\r\n * Self-wires `useQueryString`'s SSR seam to the SAME per-request store\r\n * `connectSharedStore`/`connectPageContext` already read (canon `1ca1e8ae`'s\r\n * scoping) — done HERE, once, rather than asking every server bootstrap\r\n * (dev's Vite-graph wiring in `web-connector.ts`, prod's\r\n * `installProductionPageRoutes`) to remember a THIRD `connect*` call for the\r\n * same store.\r\n *\r\n * The resolver calls `requireRunner()` on every read, not the `runner`\r\n * closed over by its caller: `runner.getStore()` is only valid for as long as\r\n * that particular runner is connected, and a test (or a later reconnect)\r\n * that swaps in a new one via `connectPageContext` must not leave this\r\n * resolver reading a stale runner's store.\r\n */\r\nlet requestSearchWired = false;\r\n\r\nfunction wireRequestSearch(): void {\r\n if (requestSearchWired) return;\r\n\r\n requestSearchWired = true;\r\n connectRequestSearch(() => requireRunner().getStore()?.request.url);\r\n}\r\n\r\nexport async function executePageRequest<TResult = PageDataBundle>(\r\n options: ExecutePageRequestOptions<TResult>,\r\n): Promise<TResult | Response | undefined> {\r\n const runner = requireRunner();\r\n\r\n wireRequestSearch();\r\n const [pathname, queryString] = options.url.split(\"?\");\r\n // HTTP page handlers arrive here after core's router selected their route.\r\n // Keep its entry and decoded params authoritative; standalone rendering has\r\n // no such request, so it still resolves against the supplied route table.\r\n const matched = options.matched ?? matchRoute(pathname, options.routes);\r\n\r\n if (!matched) return undefined;\r\n\r\n const query = Object.fromEntries(new URLSearchParams(queryString ?? \"\"));\r\n const match: PageRouteMatch = { entry: matched.entry, params: matched.params, query };\r\n const { triple } = matched.entry;\r\n const { request, response } = options.createHttp(match);\r\n const store: PipelineStore = runner.buildStore\r\n ? runner.buildStore({ request, response })\r\n : { request, response };\r\n\r\n return runner.run(store, async () => {\r\n enterSharedScope(store);\r\n enterAdditionalSharedScope(store);\r\n\r\n const finish = async (bundle: PageDataBundle): Promise<TResult> =>\r\n options.finish ? await options.finish(bundle) : (bundle as TResult);\r\n\r\n const bundle: Bundle = {\r\n route: {\r\n name: matched.entry.name,\r\n path: matched.entry.path,\r\n params: match.params,\r\n query,\r\n },\r\n };\r\n\r\n // `route.middleware` shipped in 5.6.0 and was withdrawn (owner ruling,\r\n // 2026-09-08): a page declares middleware in exactly ONE place, the\r\n // top-level `middleware` export. A page module built before the ruling\r\n // that still exports `route.middleware` must fail loudly here rather than\r\n // have that guard silently stop running — the exact defect class this\r\n // workspace keeps paying to fix.\r\n /**\r\n * THE MIDDLEWARE SURFACES, TOGETHER — the one place both are documented,\r\n * so they cannot again be found \"separately and inconsistently\" (canon\r\n * `b79c4f55`, point 5):\r\n *\r\n * - A LAYOUT'S `middleware` export (`../routing/layout-policy.ts` — a\r\n * middleware-only layout, one with no default export, is treated as a\r\n * deliberate authorization boundary and composes freely). Every layout\r\n * on a page's chain contributes, outermost first\r\n * (`install-page-routes.ts`'s `composeLayoutLevel` folds the whole\r\n * chain into `triple.layout.middleware` before this runs).\r\n * - The PAGE's OWN top-level `middleware` export (`triple.page.middleware`)\r\n * — a page's own answer to \"what does this URL require\", one level\r\n * below the layout instead of borrowed from it (canon `f2e514c0`).\r\n *\r\n * ONE ordering rule covers both: `LEVEL_ORDER` (app, layout, page) runs\r\n * outermost-first, so the page's own list always runs LAST — closest to\r\n * the loader. A layout's auth gate can therefore never be bypassed by a\r\n * page's own middleware.\r\n */\r\n for (const level of LEVEL_ORDER) {\r\n const middlewareForLevel = triple[level].middleware ?? [];\r\n\r\n for (const middleware of middlewareForLevel) {\r\n let output: unknown;\r\n\r\n try {\r\n output = await middleware({ request, response });\r\n } catch (thrown) {\r\n bundle.error = buildErrorRecord(thrown, designateBoundary(level, triple), pathname);\r\n response.setStatusCode(500);\r\n return finish(bundle);\r\n }\r\n\r\n if (output !== undefined) {\r\n bundle.shortCircuit = {\r\n stage: \"middleware\",\r\n level,\r\n value: output,\r\n statusCode: response.statusCode,\r\n };\r\n return finish(bundle);\r\n }\r\n }\r\n }\r\n\r\n const validation = triple.page.validation;\r\n\r\n if (validation) {\r\n const legacyValidation = \"schema\" in validation || \"validating\" in validation;\r\n const schema = legacyValidation\r\n ? validation.schema\r\n : v.object({\r\n ...(validation.params === undefined ? {} : { params: validation.params }),\r\n ...(validation.query === undefined ? {} : { query: validation.query }),\r\n });\r\n\r\n if (schema) {\r\n const data = legacyValidation\r\n ? resolveValidationData(validation.validating, request)\r\n : resolvePageValidationInput(request);\r\n const result = await v.validate(schema, data);\r\n\r\n if (result.isValid && result.data) {\r\n request.setValidatedData(result.data);\r\n }\r\n\r\n if (!result.isValid) {\r\n bundle.shortCircuit = { stage: \"validation\", status: 400, errors: result.errors };\r\n return finish(bundle);\r\n }\r\n }\r\n }\r\n\r\n const sealedShared = await sealShared(store);\r\n bundle.shared = sealedShared;\r\n\r\n const dataKeys: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\r\n app: \"appData\",\r\n layout: \"layoutData\",\r\n page: \"pageData\",\r\n };\r\n\r\n // Stage 6 — LOADERS, root to leaf. Every level gets its OWN buffer (never\r\n // the live response), and a terminal result or throw prevents every lower\r\n // loader from starting.\r\n const buffers: Record<PageLevelName, LevelBuffer> = {\r\n app: createLevelBuffer(),\r\n layout: createLevelBuffer(),\r\n page: createLevelBuffer(),\r\n };\r\n\r\n let signalIndex = -1;\r\n let signalKind: \"throw\" | \"shortCircuit\" | undefined;\r\n let signalThrown: unknown;\r\n let signalCircuit:\r\n | { kind: \"redirect\" | \"notFound\"; statusCode: number; url?: string; body?: unknown }\r\n | undefined;\r\n\r\n for (let index = 0; index < LEVEL_ORDER.length; index++) {\r\n const level = LEVEL_ORDER[index];\r\n\r\n // `route.validate` — the PAGE's own declared schema, over `{ params,\r\n // query }` kept as two separate keys (canon `b79c4f55`, point 1). Runs\r\n // HERE, at the front of the page level's own turn: app and layout\r\n // loaders have already run (their data survives a rejection, exactly\r\n // as an ordinary page-level throw leaves them untouched) and the\r\n // page's OWN loader has not (mirrors the top-level `validation`\r\n // export's \"before the loader\" contract). A failure is folded into the\r\n // ordinary THROW signal below rather than given a fourth code path: it\r\n // designates a boundary and renders the application's error\r\n // page/boundary with status 400 (point 2) — a page is a document, not\r\n // an API endpoint, so this must never answer a raw JSON body.\r\n const loader = triple[level].loader;\r\n\r\n if (!loader) continue;\r\n\r\n let value: unknown;\r\n\r\n try {\r\n value = await loader({\r\n request,\r\n response: createBufferedResponse(buffers[level]),\r\n shared: sealedShared,\r\n });\r\n } catch (thrown) {\r\n signalIndex = index;\r\n signalKind = \"throw\";\r\n signalThrown = thrown;\r\n break;\r\n }\r\n\r\n if (value instanceof Response) return value;\r\n\r\n if (isLoaderShortCircuit(value)) {\r\n signalIndex = index;\r\n signalKind = \"shortCircuit\";\r\n signalCircuit = value;\r\n break;\r\n }\r\n\r\n bundle[dataKeys[level]] = value;\r\n }\r\n\r\n let committedLevels: PageLevelName[];\r\n /** Set only when a THROW escalated to the app boundary — forces 500. */\r\n let forcedStatusCode: number | undefined;\r\n\r\n if (signalIndex === -1) {\r\n committedLevels = [...LEVEL_ORDER];\r\n } else if (signalKind === \"throw\") {\r\n // The throwing level's buffer is discarded; lower levels never ran.\r\n committedLevels = LEVEL_ORDER.slice(0, signalIndex);\r\n\r\n const boundary = designateBoundary(LEVEL_ORDER[signalIndex], triple);\r\n // A failure that OWNS its own status (a `RouteValidationError`'s 400)\r\n // carries it through here; an ordinary throw carries none and keeps\r\n // the pipeline's ordinary answer, 500.\r\n const ownStatusCode = (signalThrown as { statusCode?: number } | null)?.statusCode;\r\n bundle.error = buildErrorRecord(signalThrown, boundary, pathname, ownStatusCode);\r\n\r\n if (boundary.boundaryLevel === \"app\") {\r\n const status = ownStatusCode ?? 500;\r\n response.setStatusCode(status);\r\n forcedStatusCode = status;\r\n }\r\n } else {\r\n // Short-circuit: the signalling level's OWN buffer commits too\r\n // (inclusive); lower levels never ran.\r\n committedLevels = LEVEL_ORDER.slice(0, signalIndex + 1);\r\n\r\n const circuit = signalCircuit!;\r\n\r\n bundle.shortCircuit = {\r\n stage: \"loaders\",\r\n level: LEVEL_ORDER[signalIndex],\r\n kind: circuit.kind,\r\n statusCode: circuit.statusCode,\r\n url: circuit.url,\r\n body: circuit.body,\r\n } as unknown as PageDataBundle[\"shortCircuit\"];\r\n }\r\n\r\n bundle.commit = commitBuffers(response, buffers, committedLevels);\r\n\r\n // Forced AFTER the fold: an app-boundary escalation forces 500\r\n // regardless of what the surviving (rootward) buffers happened to set —\r\n // it is the framework's answer, not a loader's.\r\n if (forcedStatusCode !== undefined) bundle.commit.statusCode = forcedStatusCode;\r\n\r\n // Stage 8 — METADATA. Skipped entirely for a short-circuit (there is no\r\n // page to describe); a throw still runs it, same as before this stage 6/7\r\n // rewrite (a boundary still needs a title/robots answer).\r\n if (bundle.shortCircuit) {\r\n return finish(bundle);\r\n }\r\n\r\n const resolved = resolvePageMetadata({\r\n metadata: triple.page.metadata,\r\n data: bundle.pageData,\r\n error: bundle.error?.error,\r\n failed: Boolean(bundle.error),\r\n shared: sealedShared,\r\n });\r\n\r\n bundle.metadata = resolved.metadata;\r\n\r\n if (resolved.thrown !== undefined) {\r\n const boundary = designateBoundary(\"page\", triple);\r\n bundle.error = buildErrorRecord(resolved.thrown, boundary, bundle.route.path);\r\n\r\n if (boundary.boundaryLevel === \"app\") response.setStatusCode(500);\r\n }\r\n\r\n return finish(bundle);\r\n });\r\n}\r\n"],"mappings":"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"}
|
|
@@ -35,11 +35,18 @@ type PageTripleModule = {
|
|
|
35
35
|
route?: string | {
|
|
36
36
|
readonly path: string;
|
|
37
37
|
readonly name?: string;
|
|
38
|
-
};
|
|
38
|
+
}; /** This page's own guards, run LAST — see `LEVEL_ORDER` below. */
|
|
39
39
|
middleware?: readonly PipelineMiddleware[];
|
|
40
40
|
validation?: {
|
|
41
41
|
schema?: BaseValidator;
|
|
42
42
|
validating?: readonly string[];
|
|
43
|
+
params?: never;
|
|
44
|
+
query?: never;
|
|
45
|
+
} | {
|
|
46
|
+
params?: BaseValidator;
|
|
47
|
+
query?: BaseValidator;
|
|
48
|
+
schema?: never;
|
|
49
|
+
validating?: never;
|
|
43
50
|
};
|
|
44
51
|
loader?: PipelineLoader;
|
|
45
52
|
metadata?: PageMetadata<PipelineLoader>;
|
|
@@ -63,6 +70,12 @@ type PageRouteMatch = {
|
|
|
63
70
|
type ExecutePageRequestOptions<TResult = PageDataBundle> = {
|
|
64
71
|
url: string;
|
|
65
72
|
routes: readonly PageRouteEntry[];
|
|
73
|
+
/**
|
|
74
|
+
* An HTTP router has already selected this entry and decoded its params.
|
|
75
|
+
* Supplying it prevents the page pipeline from matching the same URL again;
|
|
76
|
+
* callers without an HTTP request continue to resolve against `routes`.
|
|
77
|
+
*/
|
|
78
|
+
matched?: Pick<PageRouteMatch, "entry" | "params">;
|
|
66
79
|
createHttp(match: PageRouteMatch): HttpContext;
|
|
67
80
|
finish?(bundle: PageDataBundle): TResult | Promise<TResult>;
|
|
68
81
|
};
|
|
@@ -86,6 +99,13 @@ type PageErrorRecord = {
|
|
|
86
99
|
boundary: PageBoundaryDesignation;
|
|
87
100
|
digest: string;
|
|
88
101
|
scrubbed: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Undefined means the pipeline's ordinary answer to any escalated failure:
|
|
104
|
+
* 500. Set only by a failure that OWNS its own status — today, a
|
|
105
|
+
* `route.validate` rejection's 400 (canon `b79c4f55`, point 2: the visitor's
|
|
106
|
+
* malformed input, never the server's fault).
|
|
107
|
+
*/
|
|
108
|
+
statusCode?: number;
|
|
89
109
|
};
|
|
90
110
|
type PageDataBundle = {
|
|
91
111
|
route: {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isLoaderShortCircuit } from "./settle-page-response.mjs";
|
|
2
|
+
import { Response } from "@warlock.js/core";
|
|
3
|
+
|
|
4
|
+
//#region ../web/src/server/fold-layout-loaders.ts
|
|
5
|
+
/**
|
|
6
|
+
* Folds a page's whole layout chain's loaders into the render pipeline's ONE
|
|
7
|
+
* layout-slot loader — shared by both page installers
|
|
8
|
+
* (`install-page-routes.ts`'s dev `composeLayoutLevel`,
|
|
9
|
+
* `install-page-routes-from-manifest.ts`'s production one). Source-agnostic:
|
|
10
|
+
* this module never asks where a loader came from, only runs the ordered list
|
|
11
|
+
* each caller already resolved from its own loaded layout modules.
|
|
12
|
+
*
|
|
13
|
+
* Runs outermost first — the order stage 3 runs the composed loader in
|
|
14
|
+
* (`execute-page-request.ts:519-524`) and the order an outer `optionalAuth`
|
|
15
|
+
* needs in order to have resolved an identity before an inner `gate()` checks
|
|
16
|
+
* it — and stops the instant any loader short-circuits (a `Response` or a
|
|
17
|
+
* `LoaderShortCircuitSignal`), so an outer redirect or guard never lets an
|
|
18
|
+
* inner loader run at all. Only the HOST layout's own return value becomes
|
|
19
|
+
* the page's data; every other layout's loader runs for its side effects
|
|
20
|
+
* (or its short-circuit) alone.
|
|
21
|
+
*/
|
|
22
|
+
function foldLayoutLoaders(loaders, hostIndex) {
|
|
23
|
+
return async (context) => {
|
|
24
|
+
let hostData;
|
|
25
|
+
for (let index = 0; index < loaders.length; index++) {
|
|
26
|
+
const value = await loaders[index]?.(context);
|
|
27
|
+
if (value instanceof Response || isLoaderShortCircuit(value)) return value;
|
|
28
|
+
if (index === hostIndex) hostData = value;
|
|
29
|
+
}
|
|
30
|
+
return hostData;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
export { foldLayoutLoaders };
|
|
36
|
+
//# sourceMappingURL=fold-layout-loaders.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fold-layout-loaders.mjs","names":[],"sources":["../../../../../../../web/src/server/fold-layout-loaders.ts"],"sourcesContent":["/**\n * Folds a page's whole layout chain's loaders into the render pipeline's ONE\n * layout-slot loader — shared by both page installers\n * (`install-page-routes.ts`'s dev `composeLayoutLevel`,\n * `install-page-routes-from-manifest.ts`'s production one). Source-agnostic:\n * this module never asks where a loader came from, only runs the ordered list\n * each caller already resolved from its own loaded layout modules.\n *\n * Runs outermost first — the order stage 3 runs the composed loader in\n * (`execute-page-request.ts:519-524`) and the order an outer `optionalAuth`\n * needs in order to have resolved an identity before an inner `gate()` checks\n * it — and stops the instant any loader short-circuits (a `Response` or a\n * `LoaderShortCircuitSignal`), so an outer redirect or guard never lets an\n * inner loader run at all. Only the HOST layout's own return value becomes\n * the page's data; every other layout's loader runs for its side effects\n * (or its short-circuit) alone.\n */\nimport { Response } from \"@warlock.js/core\";\nimport type { PipelineLoader } from \"./execute-page-request\";\nimport { isLoaderShortCircuit } from \"./settle-page-response\";\n\nexport function foldLayoutLoaders(\n loaders: readonly (PipelineLoader | undefined)[],\n hostIndex: number,\n): PipelineLoader {\n return async (context) => {\n let hostData: unknown;\n\n for (let index = 0; index < loaders.length; index++) {\n const value = await loaders[index]?.(context);\n\n if (value instanceof Response || isLoaderShortCircuit(value)) return value;\n if (index === hostIndex) hostData = value;\n }\n\n return hostData;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,kBACd,SACA,WACgB;CAChB,OAAO,OAAO,YAAY;EACxB,IAAI;EAEJ,KAAK,IAAI,QAAQ,GAAG,QAAQ,QAAQ,QAAQ,SAAS;GACnD,MAAM,QAAQ,MAAM,QAAQ,MAAM,GAAG,OAAO;GAE5C,IAAI,iBAAiB,YAAY,qBAAqB,KAAK,GAAG,OAAO;GACrE,IAAI,UAAU,WAAW,WAAW;EACtC;EAEA,OAAO;CACT;AACF"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CLIENT_ASSET_URL_PREFIX } from "./client-asset-url-prefix.mjs";
|
|
2
2
|
import { HYDRATION_CLIENT_ENTRY_NAME } from "../vite/hydration-entries.mjs";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
3
|
import path from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
//#region ../web/src/server/hydration-client-url.ts
|
|
7
7
|
/**
|
|
@@ -60,8 +60,8 @@ var WebClientAssetPrefixViolationError = class extends Error {
|
|
|
60
60
|
* Find the hydration entry in a parsed Vite manifest.
|
|
61
61
|
*
|
|
62
62
|
* VITE KEYS BY SOURCE PATH, NOT BY ENTRY NAME. A rollup input of
|
|
63
|
-
* `{ hydration: "src/
|
|
64
|
-
* `"src/
|
|
63
|
+
* `{ hydration: "src/entry/index.ts" }` produces the record
|
|
64
|
+
* `"src/entry/index.ts": { name: "hydration", isEntry: true, file: "assets/hydration-<hash>.js" }`
|
|
65
65
|
* — the name the build and this module share lives in the `name` FIELD, and
|
|
66
66
|
* there is no `"hydration"` key at all. Indexing the manifest by
|
|
67
67
|
* {@link HYDRATION_CLIENT_ENTRY_NAME} therefore missed every real manifest and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hydration-client-url.mjs","names":[],"sources":["../../../../../../../web/src/server/hydration-client-url.ts"],"sourcesContent":["/**\n * The ONE runtime manifest read in production: one boot-time JSON\n * lookup of the `hydration` entry in `<clientDir>/.vite/manifest.json`,\n * producing the string `install-page-routes.ts:139` already accepts as\n * `hydrationClientModuleUrl`.\n *\n * NEVER FALLS BACK. Each failure is its own named error so the boot log says\n * which half of the build→runtime handoff broke: a missing\n * file means the client build never ran, malformed JSON means the artifact is\n * corrupt, and a missing entry means the build/runtime entry-name contract\n * drifted. \"Serve without hydration\" is not among the outcomes.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { HYDRATION_CLIENT_ENTRY_NAME } from \"../vite/hydration-entries\";\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\n\nexport interface ResolveHydrationClientUrlOptions {\n /** Absolute path to the client build output dir: `<outdir>/client`. */\n clientDir: string;\n}\n\n/** The manifest file is absent — the client build never ran, or ran elsewhere. */\nexport class WebClientManifestMissingError extends Error {\n public constructor(manifestPath: string, cause: unknown) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" is missing or unreadable. ` +\n \"This app was started in production without its `warlock build` client artifacts.\",\n { cause },\n );\n this.name = \"WebClientManifestMissingError\";\n }\n}\n\n/** The manifest file exists but is not parsable JSON, or is not a JSON object. */\nexport class WebClientManifestMalformedError extends Error {\n public constructor(manifestPath: string, cause: unknown) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" is not valid JSON object content.`,\n { cause },\n );\n this.name = \"WebClientManifestMalformedError\";\n }\n}\n\n/**\n * The manifest parses but carries no usable `hydration` entry.\n *\n * The entry name is a shared contract between the build (`build-client.ts`\n * rollup input) and this runtime read; drift fails loudly HERE. Nobody adds a\n * default.\n */\nexport class WebClientManifestEntryMissingError extends Error {\n public constructor(manifestPath: string, entryName: string) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" has no \"${entryName}\" entry with a \"file\" string. ` +\n \"The build and runtime hydration entry names have drifted.\",\n );\n this.name = \"WebClientManifestEntryMissingError\";\n }\n}\n\n/**\n * The manifest names an entry file that does NOT live under the asset\n * directory `CLIENT_ASSET_URL_PREFIX` is mounted from.\n *\n * Serving it would 404 — the static route only exposes that one directory —\n * so the artifact is rejected at boot instead of at first page view.\n */\nexport class WebClientAssetPrefixViolationError extends Error {\n public constructor(manifestPath: string, file: string) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" points its entry at \"${file}\", ` +\n `which is not under the \"${CLIENT_ASSET_URL_PREFIX}\" asset directory this framework serves. ` +\n \"This client artifact was not produced by this framework's client build configuration. \" +\n \"Re-run the client build instead of hand-editing the manifest or the build output.\",\n );\n this.name = \"WebClientAssetPrefixViolationError\";\n }\n}\n\ntype ManifestEntry = { file?: unknown; name?: unknown; isEntry?: unknown };\n\n/**\n * Find the hydration entry in a parsed Vite manifest.\n *\n * VITE KEYS BY SOURCE PATH, NOT BY ENTRY NAME. A rollup input of\n * `{ hydration: \"src/
|
|
1
|
+
{"version":3,"file":"hydration-client-url.mjs","names":[],"sources":["../../../../../../../web/src/server/hydration-client-url.ts"],"sourcesContent":["/**\n * The ONE runtime manifest read in production: one boot-time JSON\n * lookup of the `hydration` entry in `<clientDir>/.vite/manifest.json`,\n * producing the string `install-page-routes.ts:139` already accepts as\n * `hydrationClientModuleUrl`.\n *\n * NEVER FALLS BACK. Each failure is its own named error so the boot log says\n * which half of the build→runtime handoff broke: a missing\n * file means the client build never ran, malformed JSON means the artifact is\n * corrupt, and a missing entry means the build/runtime entry-name contract\n * drifted. \"Serve without hydration\" is not among the outcomes.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { HYDRATION_CLIENT_ENTRY_NAME } from \"../vite/hydration-entries\";\nimport { CLIENT_ASSET_URL_PREFIX } from \"./client-asset-url-prefix\";\n\nexport interface ResolveHydrationClientUrlOptions {\n /** Absolute path to the client build output dir: `<outdir>/client`. */\n clientDir: string;\n}\n\n/** The manifest file is absent — the client build never ran, or ran elsewhere. */\nexport class WebClientManifestMissingError extends Error {\n public constructor(manifestPath: string, cause: unknown) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" is missing or unreadable. ` +\n \"This app was started in production without its `warlock build` client artifacts.\",\n { cause },\n );\n this.name = \"WebClientManifestMissingError\";\n }\n}\n\n/** The manifest file exists but is not parsable JSON, or is not a JSON object. */\nexport class WebClientManifestMalformedError extends Error {\n public constructor(manifestPath: string, cause: unknown) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" is not valid JSON object content.`,\n { cause },\n );\n this.name = \"WebClientManifestMalformedError\";\n }\n}\n\n/**\n * The manifest parses but carries no usable `hydration` entry.\n *\n * The entry name is a shared contract between the build (`build-client.ts`\n * rollup input) and this runtime read; drift fails loudly HERE. Nobody adds a\n * default.\n */\nexport class WebClientManifestEntryMissingError extends Error {\n public constructor(manifestPath: string, entryName: string) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" has no \"${entryName}\" entry with a \"file\" string. ` +\n \"The build and runtime hydration entry names have drifted.\",\n );\n this.name = \"WebClientManifestEntryMissingError\";\n }\n}\n\n/**\n * The manifest names an entry file that does NOT live under the asset\n * directory `CLIENT_ASSET_URL_PREFIX` is mounted from.\n *\n * Serving it would 404 — the static route only exposes that one directory —\n * so the artifact is rejected at boot instead of at first page view.\n */\nexport class WebClientAssetPrefixViolationError extends Error {\n public constructor(manifestPath: string, file: string) {\n super(\n `Cannot resolve the hydration client URL: the Vite client manifest \"${manifestPath}\" points its entry at \"${file}\", ` +\n `which is not under the \"${CLIENT_ASSET_URL_PREFIX}\" asset directory this framework serves. ` +\n \"This client artifact was not produced by this framework's client build configuration. \" +\n \"Re-run the client build instead of hand-editing the manifest or the build output.\",\n );\n this.name = \"WebClientAssetPrefixViolationError\";\n }\n}\n\ntype ManifestEntry = { file?: unknown; name?: unknown; isEntry?: unknown };\n\n/**\n * Find the hydration entry in a parsed Vite manifest.\n *\n * VITE KEYS BY SOURCE PATH, NOT BY ENTRY NAME. A rollup input of\n * `{ hydration: \"src/entry/index.ts\" }` produces the record\n * `\"src/entry/index.ts\": { name: \"hydration\", isEntry: true, file: \"assets/hydration-<hash>.js\" }`\n * — the name the build and this module share lives in the `name` FIELD, and\n * there is no `\"hydration\"` key at all. Indexing the manifest by\n * {@link HYDRATION_CLIENT_ENTRY_NAME} therefore missed every real manifest and\n * reported an entry-name drift that had not happened.\n *\n * Matching on `isEntry` as well as `name` keeps this unambiguous: a shared\n * chunk can carry a `name` too, and only entries are addressable as a module\n * URL.\n */\nfunction findHydrationEntry(\n manifest: Record<string, ManifestEntry | undefined>,\n): ManifestEntry | undefined {\n for (const entry of Object.values(manifest)) {\n if (\n entry !== undefined &&\n typeof entry === \"object\" &&\n entry.isEntry === true &&\n entry.name === HYDRATION_CLIENT_ENTRY_NAME\n ) {\n return entry;\n }\n }\n\n return undefined;\n}\n\nexport function resolveHydrationClientUrl(options: ResolveHydrationClientUrlOptions): string {\n const manifestPath = path.join(options.clientDir, \".vite\", \"manifest.json\");\n\n let raw: string;\n\n try {\n raw = readFileSync(manifestPath, \"utf-8\");\n } catch (error) {\n throw new WebClientManifestMissingError(manifestPath, error);\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(raw);\n } catch (error) {\n throw new WebClientManifestMalformedError(manifestPath, error);\n }\n\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n throw new WebClientManifestMalformedError(\n manifestPath,\n new TypeError(\"The manifest root is not a JSON object.\"),\n );\n }\n\n const entry = findHydrationEntry(parsed as Record<string, ManifestEntry | undefined>);\n\n if (entry === undefined || typeof entry !== \"object\" || typeof entry.file !== \"string\") {\n throw new WebClientManifestEntryMissingError(manifestPath, HYDRATION_CLIENT_ENTRY_NAME);\n }\n\n const url = `/${entry.file}`;\n\n // The prefix is DERIVED from CLIENT_ASSET_URL_PREFIX, never restated: this\n // file must not carry a second copy of the literal the constant owns.\n if (!url.startsWith(`${CLIENT_ASSET_URL_PREFIX}/`)) {\n throw new WebClientAssetPrefixViolationError(manifestPath, entry.file);\n }\n\n // Past this point the returned URL starts with CLIENT_ASSET_URL_PREFIX by\n // construction — which is exactly what the static-file route mounting\n // `<clientDir>/assets` at that same imported symbol relies on.\n return url;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAuBA,IAAa,gCAAb,cAAmD,MAAM;CACvD,AAAO,YAAY,cAAsB,OAAgB;EACvD,MACE,sEAAsE,aAAa,iHAEnF,EAAE,MAAM,CACV;EACA,KAAK,OAAO;CACd;AACF;;AAGA,IAAa,kCAAb,cAAqD,MAAM;CACzD,AAAO,YAAY,cAAsB,OAAgB;EACvD,MACE,sEAAsE,aAAa,sCACnF,EAAE,MAAM,CACV;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,YAAY,cAAsB,WAAmB;EAC1D,MACE,sEAAsE,aAAa,YAAY,UAAU,wFAE3G;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;AASA,IAAa,qCAAb,cAAwD,MAAM;CAC5D,AAAO,YAAY,cAAsB,MAAc;EACrD,MACE,sEAAsE,aAAa,yBAAyB,KAAK,6BACpF,wBAAwB,iNAGvD;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;AAmBA,SAAS,mBACP,UAC2B;CAC3B,KAAK,MAAM,SAAS,OAAO,OAAO,QAAQ,GACxC,IACE,UAAU,UACV,OAAO,UAAU,YACjB,MAAM,YAAY,QAClB,MAAM,sBAEN,OAAO;AAKb;AAEA,SAAgB,0BAA0B,SAAmD;CAC3F,MAAM,eAAe,KAAK,KAAK,QAAQ,WAAW,SAAS,eAAe;CAE1E,IAAI;CAEJ,IAAI;EACF,MAAM,aAAa,cAAc,OAAO;CAC1C,SAAS,OAAO;EACd,MAAM,IAAI,8BAA8B,cAAc,KAAK;CAC7D;CAEA,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,GAAG;CACzB,SAAS,OAAO;EACd,MAAM,IAAI,gCAAgC,cAAc,KAAK;CAC/D;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,MAAM,IAAI,gCACR,8BACA,IAAI,UAAU,yCAAyC,CACzD;CAGF,MAAM,QAAQ,mBAAmB,MAAmD;CAEpF,IAAI,UAAU,UAAa,OAAO,UAAU,YAAY,OAAO,MAAM,SAAS,UAC5E,MAAM,IAAI,mCAAmC,cAAc,2BAA2B;CAGxF,MAAM,MAAM,IAAI,MAAM;CAItB,IAAI,CAAC,IAAI,WAAW,aAA2B,EAAE,GAC/C,MAAM,IAAI,mCAAmC,cAAc,MAAM,IAAI;CAMvE,OAAO;AACT"}
|