@warlock.js/web 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/esm/build/contribution.mjs +1 -1
  3. package/esm/build/discover-pages.mjs +3 -3
  4. package/esm/build/generate-pages-barrel.mjs +2 -2
  5. package/esm/build/page-routes-manifest.mjs +1 -1
  6. package/esm/build/public-files.mjs +1 -1
  7. package/esm/client/navigation/fetch-page-data.mjs +3 -10
  8. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  9. package/esm/client/navigation/navigation-root.mjs +1 -1
  10. package/esm/hydration-payload.mjs +19 -10
  11. package/esm/hydration-payload.mjs.map +1 -1
  12. package/esm/loaders.d.mts +12 -7
  13. package/esm/route.d.mts +8 -32
  14. package/esm/server/create-page-route-handler.mjs +12 -6
  15. package/esm/server/create-page-route-handler.mjs.map +1 -1
  16. package/esm/server/execute-page-request.mjs +29 -37
  17. package/esm/server/execute-page-request.mjs.map +1 -1
  18. package/esm/server/execute-page-request.types.d.mts +15 -4
  19. package/esm/server/hydration-client-url.mjs +1 -1
  20. package/esm/server/index.d.mts +2 -1
  21. package/esm/server/index.mjs +4 -4
  22. package/esm/server/install-page-routes-from-manifest.d.mts +9 -0
  23. package/esm/server/install-page-routes-from-manifest.mjs +20 -20
  24. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  25. package/esm/server/install-page-routes.d.mts +2 -24
  26. package/esm/server/install-page-routes.mjs +48 -25
  27. package/esm/server/install-page-routes.mjs.map +1 -1
  28. package/esm/server/match-page-route.mjs +6 -10
  29. package/esm/server/match-page-route.mjs.map +1 -1
  30. package/esm/server/not-found-page.d.mts +1 -0
  31. package/esm/server/not-found-page.mjs +8 -1
  32. package/esm/server/not-found-page.mjs.map +1 -1
  33. package/esm/server/page-file-change.mjs +1 -1
  34. package/esm/server/page-module-shapes.d.mts +24 -0
  35. package/esm/server/page-route-reload.mjs +1 -1
  36. package/esm/server/register-production-public-files.mjs +1 -1
  37. package/esm/server/render-page.d.mts +3 -2
  38. package/esm/server/render-page.mjs +1 -0
  39. package/esm/server/render-page.mjs.map +1 -1
  40. package/esm/server/resolve-route-validation-input.mjs +2 -2
  41. package/esm/server/resolve-route-validation-input.mjs.map +1 -1
  42. package/esm/server/resolve-validation-data.mjs +1 -1
  43. package/esm/server/resolve-validation-data.mjs.map +1 -1
  44. package/esm/server/stylesheet-urls.mjs +78 -19
  45. package/esm/server/stylesheet-urls.mjs.map +1 -1
  46. package/esm/server/web-connector.mjs +2 -2
  47. package/esm/server/web-connector.mjs.map +1 -1
  48. package/esm/validation.d.mts +12 -1
  49. package/esm/vite/build-client.mjs +1 -1
  50. package/esm/vite/gate-a-resolve.mjs +1 -1
  51. package/esm/vite/hydration-entries.mjs +1 -1
  52. package/llms-full.txt +25 -19
  53. package/package.json +3 -3
  54. package/skills/create-a-page/SKILL.md +25 -19
  55. package/esm/server/route-validation-error.mjs +0 -32
  56. package/esm/server/route-validation-error.mjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to `@warlock.js/web` are documented here.
4
4
 
5
+ ## 5.7.0 - 2026-09-11
6
+
7
+ ### Removed
8
+
9
+ - **`route.validate` and `route.middleware` are withdrawn, one release after 5.6.0 added them.** They were a second way to say what the top-level `validation` and `middleware` exports already said, on the same file — and the two validation surfaces disagreed about the status code. **Migration is a move, not a rewrite:** the schema shape is unchanged (`params` and `query` stay separate, never merged) and the failure is still 400.
10
+
11
+ ```diff
12
+ - export const route = { path: "/products/:id", validate: v.object({ … }), middleware: [guard] } as const;
13
+ + export const route = { path: "/products/:id" } as const;
14
+ + export const validation = { params: v.object({ … }), query: v.object({ … }) };
15
+ + export const middleware = [guard];
16
+ ```
17
+
18
+ **A page still declaring either one refuses to boot and names the file.** It is never silently ignored — which for `route.middleware` is the difference between a deploy that fails and a route that serves without its auth guard.
19
+
20
+ ### Fixed
21
+
22
+ - **Dev and production agree about stylesheets.** A stylesheet reached only through a component import was collected by production's bundler-graph walk and was structurally invisible to dev's scan of the page file — so a page rendered unstyled in development and correct in production. Both sides now end in one traversal, gated by a fixture built through **both** pipelines with the outputs diffed.
23
+ - **A client navigation whose data payload is incomplete now loads the page normally instead of rendering it blank.** Navigation carried its own copy of the payload rule and checked two of the six required keys, so a payload that could not render a page was accepted and handed to React anyway; the failure surfaced later, somewhere else, pointing at nothing. It now falls back to a full page load — slower for that one click, and the page arrives.
24
+ - **The dev server no longer says it is watching for changes while it is not yet serving.** On a slow boot that line arrived up to three minutes before the port was bound; every word of it was true and the impression it left was false.
25
+ - **The `create-a-page` skill and `llms-full.txt` taught `route.validate` and `route.middleware`** — with a complete worked example — after both were withdrawn. Following our own documentation produced an app that would not start.
26
+
27
+ ### Changed
28
+
29
+ - The dev and production page installers now agree on the layout **chain**, the layout **level**, and the hydration entry URL by construction rather than by inspection, each gated with a red control. The three places they still differ — live `public/` serving, its cache header, and hashed-asset caching — are deliberate and are now declared in the code that implements them.
30
+
5
31
  ## 5.6.0 - 2026-09-08
6
32
  ### Added
7
33
 
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import path from "node:path";
2
+ import { readFileSync } from "node:fs";
3
3
 
4
4
  //#region ../web/src/build/contribution.ts
5
5
  /**
@@ -1,14 +1,14 @@
1
1
  import { composeRoutePath } from "../routing/compose-route-path.mjs";
2
- import { deriveFilesystemRoutePath } from "../routing/filesystem-route.mjs";
2
+ import { NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, isNotFoundPageFile } from "../server/not-found-page.mjs";
3
3
  import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
4
+ import { deriveFilesystemRoutePath } from "../routing/filesystem-route.mjs";
4
5
  import { canonicalizeRouteExport, resolvePageRouteName } from "../routing/route-identity.mjs";
5
- import { NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, isNotFoundPageFile } from "../server/not-found-page.mjs";
6
6
  import { assertPageHasDefaultExport } from "./page-default-export.mjs";
7
7
  import { UnknownMetadataKeyError, readMetadataKeys } from "./read-metadata-keys.mjs";
8
8
  import { NonLiteralRouteExportError, readRouteExports } from "./read-route-exports.mjs";
9
9
  import { toPosix } from "../shared/to-posix.mjs";
10
- import fs from "node:fs";
11
10
  import path from "node:path";
11
+ import fs from "node:fs";
12
12
  import { parse } from "@babel/parser";
13
13
 
14
14
  //#region ../web/src/build/discover-pages.ts
@@ -1,10 +1,10 @@
1
- import { NestedLayoutsNotSupportedError } from "../routing/layout-policy.mjs";
2
1
  import { NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, isNotFoundPageFile } from "../server/not-found-page.mjs";
2
+ import { NestedLayoutsNotSupportedError } from "../routing/layout-policy.mjs";
3
3
  import { toPosix } from "../shared/to-posix.mjs";
4
4
  import { discoverPages, discoverWebRoots, isDiscoveredRoutablePage, isFile, layoutChainFor, walkFiles } from "./discover-pages.mjs";
5
5
  import { normalizeRoutePath } from "../core/src/router/normalize-route-path.mjs";
6
- import fs from "node:fs";
7
6
  import path from "node:path";
7
+ import fs from "node:fs";
8
8
  import { parse } from "@babel/parser";
9
9
 
10
10
  //#region ../web/src/build/generate-pages-barrel.ts
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
2
+ import fs from "node:fs";
3
3
 
4
4
  //#region ../web/src/build/page-routes-manifest.ts
5
5
  const PAGE_ROUTES_MANIFEST_FILE = "page-routes.manifest.json";
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
2
+ import fs from "node:fs";
3
3
 
4
4
  //#region ../web/src/build/public-files.ts
5
5
  /**
@@ -1,4 +1,5 @@
1
1
  import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../routing/data-request.mjs";
2
+ import { isHydrationPayload } from "../../hydration-payload.mjs";
2
3
 
3
4
  //#region ../web/src/client/navigation/fetch-page-data.ts
4
5
  /**
@@ -34,14 +35,6 @@ import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../r
34
35
  function isPayloadResponse(response) {
35
36
  return (response.headers.get("content-type") ?? "").includes(DATA_RESPONSE_CONTENT_TYPE);
36
37
  }
37
- /**
38
- * The shape check, kept deliberately narrow: `name` is the only field the tree
39
- * builder cannot proceed without — it selects the page. The data fields are
40
- * page-defined and may legitimately be anything, including `null`.
41
- */
42
- function isPayloadShape(value) {
43
- return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.locale === "string" && value.locale.length > 0;
44
- }
45
38
  async function fetchPageData(url) {
46
39
  let response;
47
40
  try {
@@ -80,10 +73,10 @@ async function fetchPageData(url) {
80
73
  reason: `malformed JSON: ${String(error)}`
81
74
  };
82
75
  }
83
- if (!isPayloadShape(parsed)) return {
76
+ if (!isHydrationPayload(parsed)) return {
84
77
  type: "hard-navigate",
85
78
  url,
86
- reason: "payload is missing required navigation identity"
79
+ reason: "payload is malformed"
87
80
  };
88
81
  return {
89
82
  type: "payload",
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\" &&\n typeof (value as { locale?: unknown }).locale === \"string\" &&\n (value as { locale: string }).locale.length > 0\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n // Deliberately recover through a full document load: malformed navigation\n // data must not crash the client when the server can still render the URL.\n return {\n type: \"hard-navigate\",\n url,\n reason: \"payload is missing required navigation identity\",\n };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS,YAC9C,OAAQ,MAA+B,WAAW,YACjD,MAA6B,OAAO,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GAGxB,OAAO;EACL,MAAM;EACN;EACA,QAAQ;CACV;CAMF,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
1
+ {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport { isHydrationPayload, type HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isHydrationPayload(parsed)) {\n // Deliberately recover through a full document load: malformed navigation\n // data must not crash the client when the server can still render the URL.\n return {\n type: \"hard-navigate\",\n url,\n reason: \"payload is malformed\",\n };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,mBAAmB,MAAM,GAG5B,OAAO;EACL,MAAM;EACN;EACA,QAAQ;CACV;CAMF,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
@@ -1,12 +1,12 @@
1
1
  import { hydrateShared } from "../../shared.mjs";
2
2
  import { LocaleProvider } from "../../localization.mjs";
3
+ import { DocumentContext } from "../../components/document-context.mjs";
3
4
  import { fetchPageData } from "./fetch-page-data.mjs";
4
5
  import { takePrefetchedPageData } from "./prefetch.mjs";
5
6
  import { connectNavigator } from "../../routing/navigator.mjs";
6
7
  import { routerEvents } from "../../routing/router-events.mjs";
7
8
  import { recordCurrentRoute } from "./current-route.mjs";
8
9
  import { connectRefresher, createRefresher } from "./refresh.mjs";
9
- import { DocumentContext } from "../../components/document-context.mjs";
10
10
  import { fragmentOf, samePageFragment, withFragmentFrom, withoutFragment } from "../../routing/url-fragment.mjs";
11
11
  import { applyDocumentMetadata } from "./document-metadata.mjs";
12
12
  import { scrollToFragment } from "./scroll-to-fragment.mjs";
@@ -65,19 +65,27 @@ function requireErrorPagePayload(value) {
65
65
  if (error.stack !== void 0 && typeof error.stack !== "string") malformedPayload();
66
66
  if (typeof errorPage.status !== "number" || !Number.isInteger(errorPage.status) || errorPage.status < 500 || errorPage.status > 599) malformedPayload();
67
67
  }
68
- function requireHydrationPayload(value) {
69
- if (!isPlainObject(value)) malformedPayload();
70
- for (const key of REQUIRED_PAYLOAD_KEYS) if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();
71
- if (typeof value.name !== "string") malformedPayload();
68
+ /**
69
+ * The single runtime gate for hydration payloads, regardless of whether they
70
+ * arrived in the document or through a navigation data response.
71
+ */
72
+ function isHydrationPayload(value) {
73
+ if (!isPlainObject(value)) return false;
74
+ for (const key of REQUIRED_PAYLOAD_KEYS) if (!Object.prototype.hasOwnProperty.call(value, key)) return false;
75
+ if (typeof value.name !== "string") return false;
72
76
  const locale = value.locale;
73
- if (typeof locale !== "string" || locale.length === 0) malformedPayload();
77
+ if (typeof locale !== "string" || locale.length === 0) return false;
74
78
  for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {
75
79
  const optional = value[key];
76
- if (optional !== void 0 && !isPlainObject(optional)) malformedPayload();
80
+ if (optional !== void 0 && !isPlainObject(optional)) return false;
77
81
  }
78
82
  const errorPage = value.errorPage;
79
- if (errorPage !== void 0) requireErrorPagePayload(errorPage);
80
- return value;
83
+ if (errorPage !== void 0) try {
84
+ requireErrorPagePayload(errorPage);
85
+ } catch {
86
+ return false;
87
+ }
88
+ return true;
81
89
  }
82
90
  /**
83
91
  * Read the fixed payload script without changing the server-rendered root.
@@ -97,9 +105,10 @@ function readHydrationPayload(documentNode) {
97
105
  } catch {
98
106
  malformedPayload();
99
107
  }
100
- return requireHydrationPayload(parsed);
108
+ if (!isHydrationPayload(parsed)) malformedPayload();
109
+ return parsed;
101
110
  }
102
111
 
103
112
  //#endregion
104
- export { readHydrationPayload };
113
+ export { isHydrationPayload, readHydrationPayload };
105
114
  //# sourceMappingURL=hydration-payload.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n \"locale\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE = `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\nfunction requireHydrationPayload(value: unknown): HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) malformedPayload();\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();\n }\n\n if (typeof (value as Record<string, unknown>).name !== \"string\") malformedPayload();\n const locale = (value as Record<string, unknown>).locale;\n if (typeof locale !== \"string\" || locale.length === 0) malformedPayload();\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) malformedPayload();\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) requireErrorPagePayload(errorPage);\n\n return value as HydrationDocumentPayloadSource;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the SIX required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n return requireHydrationPayload(parsed);\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BAA4B,2CAA2C,kBAAkB;AAE/F,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;AAEA,SAAS,wBAAwB,OAAgD;CAC/E,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,iBAAiB;CAG1E,IAAI,OAAQ,MAAkC,SAAS,UAAU,iBAAiB;CAClF,MAAM,SAAU,MAAkC;CAClD,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG,iBAAiB;CAExE,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,iBAAiB;CAC3E;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAAW,wBAAwB,SAAS;CAE9D,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,OAAO,wBAAwB,MAAM;AACvC"}
1
+ {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n \"locale\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE = `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\n/**\n * The single runtime gate for hydration payloads, regardless of whether they\n * arrived in the document or through a navigation data response.\n */\nexport function isHydrationPayload(value: unknown): value is HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) return false;\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) return false;\n }\n\n if (typeof (value as Record<string, unknown>).name !== \"string\") return false;\n const locale = (value as Record<string, unknown>).locale;\n if (typeof locale !== \"string\" || locale.length === 0) return false;\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) return false;\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) {\n try {\n requireErrorPagePayload(errorPage);\n } catch {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the SIX required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n if (!isHydrationPayload(parsed)) malformedPayload();\n\n return parsed;\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BAA4B,2CAA2C,kBAAkB;AAE/F,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;;;;;AAMA,SAAgB,mBAAmB,OAAyD;CAC1F,IAAI,CAAC,cAAc,KAAK,GAAG,OAAO;CAElC,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,OAAO;CAGhE,IAAI,OAAQ,MAAkC,SAAS,UAAU,OAAO;CACxE,MAAM,SAAU,MAAkC;CAClD,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG,OAAO;CAE9D,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,OAAO;CACjE;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAChB,IAAI;EACF,wBAAwB,SAAS;CACnC,QAAQ;EACN,OAAO;CACT;CAGF,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,IAAI,CAAC,mBAAmB,MAAM,GAAG,iBAAiB;CAElD,OAAO;AACT"}
package/esm/loaders.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { PageContext } from "./context.mjs";
2
- import { RouteDeclaration, RouteValidatedOutput } from "./route.mjs";
2
+ import { RouteDeclaration } from "./route.mjs";
3
3
  import { PageValidation, ValidatedOutput } from "./validation.mjs";
4
4
  import { SharedContext } from "./index.mjs";
5
5
  import { Request, Response } from "@warlock.js/core";
@@ -7,13 +7,18 @@ import { Request, Response } from "@warlock.js/core";
7
7
  //#region ../web/src/loaders.d.ts
8
8
  type PageLoaderContext<TValidation extends PageValidation | undefined, TRoute extends RouteDeclaration | undefined> = {
9
9
  /**
10
- * `validated()` carries BOTH validation surfaces a page may declare: the
11
- * pre-existing top-level `validation` export's flat shape, and
12
- * `route.validate`'s `{ params, query }` merged by intersection so a page
13
- * using either (or, at the type level, both) sees every field typed rather
14
- * than validating again inside the loader (canon `1ca1e8ae`).
10
+ * `validated()` carries the page's ONE validation surface: the top-level
11
+ * `validation` export. `route.validate` was withdrawn after 5.6.0 and is
12
+ * refused at boot, so there is no second shape to merge in.
13
+ *
14
+ * The `RouteValidatedOutput<TRoute>` intersection that used to sit here is
15
+ * gone. It had already been emptied to `Record<string, never>`, which —
16
+ * measured, not assumed — does NOT collapse the declared properties to
17
+ * `never`; it was inert rather than harmful. Inert is still worth removing:
18
+ * a type that reads as a second validation surface is a type that gets
19
+ * treated as one.
15
20
  */
16
- request: Request<ValidatedOutput<TValidation> & RouteValidatedOutput<TRoute>>;
21
+ request: Request<ValidatedOutput<TValidation>>;
17
22
  response: Response;
18
23
  shared: SharedContext;
19
24
  };
package/esm/route.d.mts CHANGED
@@ -1,17 +1,6 @@
1
1
  import { HttpContext } from "@warlock.js/core";
2
- import { Infer } from "@warlock.js/seal";
3
2
 
4
3
  //#region ../web/src/route.d.ts
5
- /**
6
- * One guard the PAGE itself declares on `route.middleware` — the same shape a
7
- * layout's own `middleware` export already runs (`../routing/layout-policy.ts`).
8
- * Ordering is fixed pipeline-wide, not per declaration: every layout on the
9
- * chain runs outermost-first, and the page's OWN middleware — whichever level
10
- * declared it — always runs LAST, closest to the loader
11
- * (`server/execute-page-request.ts`'s `LEVEL_ORDER`). A layout's auth gate can
12
- * therefore never be bypassed by a page's own guard.
13
- */
14
- type RouteMiddleware = (ctx: HttpContext) => unknown | Promise<unknown>;
15
4
  /**
16
5
  * The `route` export's accepted shapes: the configured object
17
6
  * (`{ path, name } as const` — the `as const` is what keeps `path` a literal
@@ -19,30 +8,17 @@ type RouteMiddleware = (ctx: HttpContext) => unknown | Promise<unknown>;
19
8
  * path string for the 2-line minimum page (contact-us.page.tsx:27, where the
20
9
  * name is derived).
21
10
  *
22
- * `validate` and `middleware` are what let a page declare what it ACCEPTS,
23
- * on the route itself rather than in a layout one level upcanon `f2e514c0`:
24
- * anything a page varies on is declared by the page. `validate` is a Seal
25
- * schema run over `{ params, query }` kept as two SEPARATE keys, never
26
- * merged into one bag, so a `:id` path segment and a `?id=` query key can
27
- * never collide or silently shadow one another (canon `b79c4f55`).
11
+ * `route` is about the URL only. What a page ACCEPTS on that URL is declared
12
+ * with `validate` here (a Seal schema run over `{ params, query }`kept as
13
+ * two SEPARATE keys, never merged into one bag, so a `:id` path segment and a
14
+ * `?id=` query key can never collide or silently shadow one another, canon
15
+ * `b79c4f55`). What a page REQUIRES to be reached at all is declared with the
16
+ * page's own top-level `middleware` export, not here.
28
17
  */
29
18
  type RouteDeclaration = string | {
30
19
  readonly path: string;
31
- readonly name?: string; /** A Seal object schema validated against `{ params, query }`. */
32
- readonly validate?: unknown; /** This page's own guards, run LAST in the pipeline's middleware chain. */
33
- readonly middleware?: readonly RouteMiddleware[];
20
+ readonly name?: string;
34
21
  };
35
- /**
36
- * What `request.validated()` types as when a `route.validate` schema is
37
- * declared: `{ params, query }`, each `Infer.Output` of the matching half of
38
- * the schema — never a flattened merge of the two (canon `b79c4f55`, point 1).
39
- * `undefined` (no `validate` declared) types as an empty object, mirroring
40
- * `../validation.ts`'s `ValidatedOutput` for the pre-existing top-level
41
- * `validation` export.
42
- */
43
- type RouteValidatedOutput<TRoute> = TRoute extends {
44
- readonly validate: infer TSchema;
45
- } ? Infer.Output<TSchema> : Record<string, never>;
46
22
  //#endregion
47
- export { RouteDeclaration, RouteValidatedOutput };
23
+ export { RouteDeclaration };
48
24
  //# sourceMappingURL=route.d.mts.map
@@ -2,9 +2,9 @@ import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER, isDataRequest
2
2
  import { isNonHydrating } from "./page-render-bundle.mjs";
3
3
  import { registerModules } from "../register-modules.mjs";
4
4
  import { buildHydrationPayload } from "./build-hydration-payload.mjs";
5
- import { renderPageFailure, renderPageRequest } from "./render-page.mjs";
6
5
  import { applyResponseCacheFloor } from "./response-cache-floor.mjs";
7
6
  import { ensureSetCookieCacheFloorHook, markPageResponse } from "./set-cookie-cache-floor-hook.mjs";
7
+ import { renderPageFailure, renderPageRequest } from "./render-page.mjs";
8
8
  import { Response, container } from "@warlock.js/core";
9
9
 
10
10
  //#region ../web/src/server/create-page-route-handler.ts
@@ -160,12 +160,18 @@ function createPageRouteHandler(options) {
160
160
  };
161
161
  const requestUrl = request.path;
162
162
  const [requestPathname] = requestUrl.split("?");
163
+ const entry = {
164
+ path: matchPath === void 0 ? path : matchPath(requestPathname),
165
+ name,
166
+ triple
167
+ };
168
+ const params = matchPath === void 0 ? request.params : {};
163
169
  const rendered = await renderPageRequest(requestUrl, {
164
- routes: [{
165
- path: matchPath === void 0 ? path : matchPath(requestPathname),
166
- name,
167
- triple
168
- }],
170
+ routes: [entry],
171
+ matched: {
172
+ entry,
173
+ params
174
+ },
169
175
  createHttp: () => ({
170
176
  request,
171
177
  response
@@ -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 \"../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 \"&amp;\";\r\n case \"<\":\r\n return \"&lt;\";\r\n case \">\":\r\n return \"&gt;\";\r\n case '\"':\r\n return \"&quot;\";\r\n default:\r\n return \"&#39;\";\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 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(\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
+ {"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 \"&amp;\";\r\n case \"<\":\r\n return \"&lt;\";\r\n case \">\":\r\n return \"&gt;\";\r\n case '\"':\r\n return \"&quot;\";\r\n default:\r\n return \"&#39;\";\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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"}