@warlock.js/web 5.0.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 (182) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/esm/build/contribution.d.mts +55 -0
  5. package/esm/build/contribution.mjs +132 -0
  6. package/esm/build/contribution.mjs.map +1 -0
  7. package/esm/build/discover-pages.mjs +294 -0
  8. package/esm/build/discover-pages.mjs.map +1 -0
  9. package/esm/build/generate-client-registry.mjs +123 -0
  10. package/esm/build/generate-client-registry.mjs.map +1 -0
  11. package/esm/build/generate-pages-barrel.mjs +330 -0
  12. package/esm/build/generate-pages-barrel.mjs.map +1 -0
  13. package/esm/build/read-route-exports.mjs +212 -0
  14. package/esm/build/read-route-exports.mjs.map +1 -0
  15. package/esm/client/build-hydrated-tree.mjs +113 -0
  16. package/esm/client/build-hydrated-tree.mjs.map +1 -0
  17. package/esm/client/hydrate-page.mjs +73 -0
  18. package/esm/client/hydrate-page.mjs.map +1 -0
  19. package/esm/client/navigation/current-route.d.mts +91 -0
  20. package/esm/client/navigation/current-route.mjs +66 -0
  21. package/esm/client/navigation/current-route.mjs.map +1 -0
  22. package/esm/client/navigation/fetch-page-data.mjs +97 -0
  23. package/esm/client/navigation/fetch-page-data.mjs.map +1 -0
  24. package/esm/client/navigation/navigation-root.mjs +196 -0
  25. package/esm/client/navigation/navigation-root.mjs.map +1 -0
  26. package/esm/client/navigation/prefetch.mjs +110 -0
  27. package/esm/client/navigation/prefetch.mjs.map +1 -0
  28. package/esm/client/navigation/refresh.d.mts +40 -0
  29. package/esm/client/navigation/refresh.mjs +146 -0
  30. package/esm/client/navigation/refresh.mjs.map +1 -0
  31. package/esm/client/navigation/verbs.d.mts +55 -0
  32. package/esm/client/navigation/verbs.mjs +66 -0
  33. package/esm/client/navigation/verbs.mjs.map +1 -0
  34. package/esm/client/runtime/index.d.mts +4 -0
  35. package/esm/client/runtime/index.mjs +4 -0
  36. package/esm/client/runtime/manifest.d.mts +8 -0
  37. package/esm/client/runtime/manifest.mjs +77 -0
  38. package/esm/client/runtime/manifest.mjs.map +1 -0
  39. package/esm/client/runtime/matcher.d.mts +7 -0
  40. package/esm/client/runtime/matcher.mjs +169 -0
  41. package/esm/client/runtime/matcher.mjs.map +1 -0
  42. package/esm/client/runtime/types.d.mts +22 -0
  43. package/esm/components/default-app.mjs +32 -0
  44. package/esm/components/default-app.mjs.map +1 -0
  45. package/esm/components/document-context.d.mts +1 -0
  46. package/esm/components/document-context.mjs +24 -0
  47. package/esm/components/document-context.mjs.map +1 -0
  48. package/esm/components/head.d.mts +14 -0
  49. package/esm/components/head.mjs +63 -0
  50. package/esm/components/head.mjs.map +1 -0
  51. package/esm/components/link.d.mts +157 -0
  52. package/esm/components/link.mjs +194 -0
  53. package/esm/components/link.mjs.map +1 -0
  54. package/esm/components/scripts.d.mts +14 -0
  55. package/esm/components/scripts.mjs +21 -0
  56. package/esm/components/scripts.mjs.map +1 -0
  57. package/esm/connector/index.d.mts +6 -0
  58. package/esm/connector/index.mjs +7 -0
  59. package/esm/context.d.mts +107 -0
  60. package/esm/hydration/index.d.mts +1 -0
  61. package/esm/hydration/index.mjs +33 -0
  62. package/esm/hydration/index.mjs.map +1 -0
  63. package/esm/hydration-payload.d.mts +1 -0
  64. package/esm/hydration-payload.mjs +72 -0
  65. package/esm/hydration-payload.mjs.map +1 -0
  66. package/esm/index.d.mts +35 -0
  67. package/esm/index.mjs +12 -0
  68. package/esm/loaders.d.mts +47 -0
  69. package/esm/metadata.d.mts +54 -0
  70. package/esm/props.d.mts +42 -0
  71. package/esm/route.d.mts +30 -0
  72. package/esm/routing/compose-route-path.mjs +33 -0
  73. package/esm/routing/compose-route-path.mjs.map +1 -0
  74. package/esm/routing/data-request.mjs +52 -0
  75. package/esm/routing/data-request.mjs.map +1 -0
  76. package/esm/routing/layout-policy.mjs +78 -0
  77. package/esm/routing/layout-policy.mjs.map +1 -0
  78. package/esm/routing/navigator.mjs +21 -0
  79. package/esm/routing/navigator.mjs.map +1 -0
  80. package/esm/routing/query-string.d.mts +250 -0
  81. package/esm/routing/query-string.mjs +340 -0
  82. package/esm/routing/query-string.mjs.map +1 -0
  83. package/esm/routing/route-identity.mjs +68 -0
  84. package/esm/routing/route-identity.mjs.map +1 -0
  85. package/esm/routing/route-table.d.mts +34 -0
  86. package/esm/routing/route-table.mjs +182 -0
  87. package/esm/routing/route-table.mjs.map +1 -0
  88. package/esm/routing/router-events.d.mts +159 -0
  89. package/esm/routing/router-events.mjs +83 -0
  90. package/esm/routing/router-events.mjs.map +1 -0
  91. package/esm/server/buffered-response.mjs +115 -0
  92. package/esm/server/buffered-response.mjs.map +1 -0
  93. package/esm/server/build-hydration-payload.mjs +27 -0
  94. package/esm/server/build-hydration-payload.mjs.map +1 -0
  95. package/esm/server/client-asset-url-prefix.d.mts +22 -0
  96. package/esm/server/client-asset-url-prefix.mjs +23 -0
  97. package/esm/server/client-asset-url-prefix.mjs.map +1 -0
  98. package/esm/server/create-page-module-loader.mjs +49 -0
  99. package/esm/server/create-page-module-loader.mjs.map +1 -0
  100. package/esm/server/create-page-route-handler.mjs +107 -0
  101. package/esm/server/create-page-route-handler.mjs.map +1 -0
  102. package/esm/server/dev-server.mjs +167 -0
  103. package/esm/server/dev-server.mjs.map +1 -0
  104. package/esm/server/execute-page-request.d.mts +1 -0
  105. package/esm/server/execute-page-request.mjs +166 -0
  106. package/esm/server/execute-page-request.mjs.map +1 -0
  107. package/esm/server/execute-page-request.types.d.mts +1 -0
  108. package/esm/server/hydration-client-url.d.mts +37 -0
  109. package/esm/server/hydration-client-url.mjs +101 -0
  110. package/esm/server/hydration-client-url.mjs.map +1 -0
  111. package/esm/server/index.mjs +13 -0
  112. package/esm/server/install-page-routes-from-manifest.mjs +133 -0
  113. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -0
  114. package/esm/server/install-page-routes.d.mts +1 -0
  115. package/esm/server/install-page-routes.mjs +6 -0
  116. package/esm/server/install-production-page-routes.mjs +55 -0
  117. package/esm/server/install-production-page-routes.mjs.map +1 -0
  118. package/esm/server/match-page-route.mjs +48 -0
  119. package/esm/server/match-page-route.mjs.map +1 -0
  120. package/esm/server/page-context.d.mts +1 -0
  121. package/esm/server/page-context.mjs +33 -0
  122. package/esm/server/page-context.mjs.map +1 -0
  123. package/esm/server/page-manifest.d.mts +102 -0
  124. package/esm/server/page-manifest.mjs +31 -0
  125. package/esm/server/page-manifest.mjs.map +1 -0
  126. package/esm/server/render-page.mjs +214 -0
  127. package/esm/server/render-page.mjs.map +1 -0
  128. package/esm/server/resolve-page-metadata.mjs +33 -0
  129. package/esm/server/resolve-page-metadata.mjs.map +1 -0
  130. package/esm/server/resolve-validation-data.mjs +27 -0
  131. package/esm/server/resolve-validation-data.mjs.map +1 -0
  132. package/esm/server/settle-page-response.d.mts +1 -0
  133. package/esm/server/settle-page-response.mjs +90 -0
  134. package/esm/server/settle-page-response.mjs.map +1 -0
  135. package/esm/server/stylesheet-urls.mjs +65 -0
  136. package/esm/server/stylesheet-urls.mjs.map +1 -0
  137. package/esm/server/web-connector-factory.d.mts +25 -0
  138. package/esm/server/web-connector-factory.mjs +121 -0
  139. package/esm/server/web-connector-factory.mjs.map +1 -0
  140. package/esm/server/web-connector.d.mts +34 -0
  141. package/esm/server/web-connector.mjs +516 -0
  142. package/esm/server/web-connector.mjs.map +1 -0
  143. package/esm/shared.d.mts +22 -0
  144. package/esm/shared.mjs +223 -0
  145. package/esm/shared.mjs.map +1 -0
  146. package/esm/validation.d.mts +31 -0
  147. package/esm/vite/app-convention-aliases.mjs +43 -0
  148. package/esm/vite/app-convention-aliases.mjs.map +1 -0
  149. package/esm/vite/build-client.d.mts +38 -0
  150. package/esm/vite/build-client.mjs +72 -0
  151. package/esm/vite/build-client.mjs.map +1 -0
  152. package/esm/vite/gate-a-resolve.d.mts +57 -0
  153. package/esm/vite/gate-a-resolve.mjs +834 -0
  154. package/esm/vite/gate-a-resolve.mjs.map +1 -0
  155. package/esm/vite/gate-b-secrets.d.mts +57 -0
  156. package/esm/vite/gate-b-secrets.mjs +299 -0
  157. package/esm/vite/gate-b-secrets.mjs.map +1 -0
  158. package/esm/vite/gate-c-verify.d.mts +118 -0
  159. package/esm/vite/gate-c-verify.mjs +263 -0
  160. package/esm/vite/gate-c-verify.mjs.map +1 -0
  161. package/esm/vite/hydration-entries.d.mts +17 -0
  162. package/esm/vite/hydration-entries.mjs +45 -0
  163. package/esm/vite/hydration-entries.mjs.map +1 -0
  164. package/esm/vite/index.d.mts +126 -0
  165. package/esm/vite/index.mjs +146 -0
  166. package/esm/vite/index.mjs.map +1 -0
  167. package/esm/vite/page-registry-plugin.d.mts +43 -0
  168. package/esm/vite/page-registry-plugin.mjs +138 -0
  169. package/esm/vite/page-registry-plugin.mjs.map +1 -0
  170. package/esm/vite/projection.d.mts +31 -0
  171. package/esm/vite/projection.mjs +384 -0
  172. package/esm/vite/projection.mjs.map +1 -0
  173. package/llms-full.txt +1041 -0
  174. package/llms.txt +21 -0
  175. package/package.json +77 -0
  176. package/skills/add-web-to-an-app/SKILL.md +103 -0
  177. package/skills/create-a-page/SKILL.md +141 -0
  178. package/skills/load-page-data/SKILL.md +183 -0
  179. package/skills/navigate-on-the-client/SKILL.md +182 -0
  180. package/skills/serve-styles/SKILL.md +145 -0
  181. package/skills/use-layouts/SKILL.md +121 -0
  182. package/skills/write-the-root/SKILL.md +131 -0
@@ -0,0 +1,113 @@
1
+ import { loadClientRouteComposition } from "./runtime/manifest.mjs";
2
+ import { createElement } from "react";
3
+
4
+ //#region ../web/src/client/build-hydrated-tree.ts
5
+ /**
6
+ * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.
7
+ *
8
+ * It takes the registry as an ARGUMENT and touches no browser global, which is
9
+ * the whole point of it living apart from `index.ts`: every rule below is
10
+ * testable with a hand-built registry, no bundler, no virtual module, no DOM.
11
+ *
12
+ * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry
13
+ * the SERVER matched for this exact request (document-context.ts's `name`
14
+ * field). Re-deriving it from `location.pathname` with `matchClientRoute`
15
+ * would be a second implementation of route semantics running against the one
16
+ * request it is hydrating, free to disagree with the server that produced the
17
+ * markup. `matchClientRoute` is for client-side NAVIGATION, where no server
18
+ * answer exists yet.
19
+ */
20
+ function describeKnownNames(knownPageNames) {
21
+ if (knownPageNames.length === 0) return "The client page registry is empty.";
22
+ return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(", ")}.`;
23
+ }
24
+ /**
25
+ * The THIRD hydration failure case, beside an absent and a malformed payload.
26
+ *
27
+ * It fails CLOSED — no default entry, no nearest-path fallback, no silent
28
+ * no-op. A registry that quietly substitutes a page produces a browser showing
29
+ * one page's markup running another page's code, which is precisely the defect
30
+ * this entry point was rewritten to remove; a fallback would reintroduce it
31
+ * wearing a recovery costume. Throwing leaves the server-rendered markup on
32
+ * screen and un-hydrated, which is degraded but honest.
33
+ */
34
+ var UnknownHydrationPageNameError = class extends Error {
35
+ pageName;
36
+ knownPageNames;
37
+ constructor(pageName, knownPageNames) {
38
+ super(`Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is not in the client page registry. ${describeKnownNames(knownPageNames)} The server rendered a page this browser bundle does not carry, so the server and client were built from different page graphs. To fix: rebuild the client bundle, or check that the page's file still exports a \`route\` discovery can see.`);
39
+ this.pageName = pageName;
40
+ this.knownPageNames = knownPageNames;
41
+ this.name = "UnknownHydrationPageNameError";
42
+ }
43
+ };
44
+ function findEntryByName(pages, name) {
45
+ const entry = pages.find((candidate) => candidate.name === name);
46
+ if (entry === void 0) throw new UnknownHydrationPageNameError(name, pages.map((candidate) => candidate.name));
47
+ return entry;
48
+ }
49
+ /**
50
+ * A level's component, or undefined when the module exports no default.
51
+ *
52
+ * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing
53
+ * default as a passthrough server-side, and the client tree has to match the
54
+ * markup React is hydrating against — introducing a level here that the server
55
+ * did not render is a hydration mismatch, not a repair.
56
+ */
57
+ function componentOf(module) {
58
+ const component = module.default;
59
+ return typeof component === "function" ? component : void 0;
60
+ }
61
+ function wrap(module, data, shared, children) {
62
+ const Component = componentOf(module);
63
+ if (Component === void 0) return children;
64
+ return createElement(Component, {
65
+ data,
66
+ shared,
67
+ children
68
+ });
69
+ }
70
+ /**
71
+ * Compose the tree the server rendered inside `#root`: ordered layouts wrapping
72
+ * the Page, layouts OUTERMOST FIRST as `ClientRouteComposition` declares them,
73
+ * each level receiving `{ data, shared }` and every wrapper additionally
74
+ * `children`.
75
+ *
76
+ * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────
77
+ * `ClientRouteComposition.App` and `payload.appData` still exist and are still
78
+ * carried; they are contracts owned elsewhere. They are simply not part of THIS
79
+ * tree, because App is not part of the markup this tree hydrates against:
80
+ *
81
+ * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in
82
+ * `["layout", "app"]` (`render-page.ts:274`), so the document React renders
83
+ * is `App( Layout( Page ) )`.
84
+ * - The app root is the level that owns `<html>`/`<body>` and renders
85
+ * `<div id="root">{children}</div>` inside the body. So App CONTAINS the
86
+ * mount point — the markup actually inside `#root` is `Layout( Page )`.
87
+ * - `hydrate-page.tsx` mounts at `#root` and nowhere else.
88
+ *
89
+ * Composing App here would therefore hydrate a whole `<html>` document inside a
90
+ * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If
91
+ * you arrived here from the optional `App?` on the composition type and are
92
+ * about to "complete" the tree with it — that would be the defect, not the
93
+ * omission.
94
+ *
95
+ * `load()` is awaited exactly ONCE per hydration and its result reused for all
96
+ * levels — the composition arrives whole, so calling it per layout would be
97
+ * one network waterfall per level for no new information.
98
+ */
99
+ async function buildHydratedTree(pages, payload) {
100
+ const composition = await loadClientRouteComposition(findEntryByName(pages, payload.name));
101
+ const { shared } = payload;
102
+ const Page = componentOf(composition.Page);
103
+ let element = Page === void 0 ? null : createElement(Page, {
104
+ data: payload.pageData,
105
+ shared
106
+ });
107
+ for (let index = composition.layouts.length - 1; index >= 0; index -= 1) element = wrap(composition.layouts[index], payload.layoutData, shared, element);
108
+ return element;
109
+ }
110
+
111
+ //#endregion
112
+ export { buildHydratedTree };
113
+ //# sourceMappingURL=build-hydrated-tree.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type { HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf(\n module: ClientProjectedModule,\n): ComponentType<HydratedLevelProps> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<HydratedLevelProps>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the Page, layouts OUTERMOST FIRST as `ClientRouteComposition` declares them,\n * each level receiving `{ data, shared }` and every wrapper additionally\n * `children`.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const { shared } = payload;\n\n const Page = componentOf(composition.Page);\n let element: ReactNode =\n Page === undefined ? null : createElement(Page, { data: payload.pageData, shared });\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(composition.layouts[index]!, payload.layoutData, shared, element);\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QAC+C;CAC/C,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAY,MAAM;CAEpC,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,EAAE,WAAW;CAEnB,MAAM,OAAO,YAAY,YAAY,IAAI;CACzC,IAAI,UACF,SAAS,SAAY,OAAO,cAAc,MAAM;EAAE,MAAM,QAAQ;EAAU;CAAO,CAAC;CAGpF,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KAAK,YAAY,QAAQ,QAAS,QAAQ,YAAY,QAAQ,OAAO;CAGjF,OAAO;AACT"}
@@ -0,0 +1,73 @@
1
+ import { hydrateShared } from "../shared.mjs";
2
+ import { DocumentContext } from "../components/document-context.mjs";
3
+ import { readHydrationPayload } from "../hydration-payload.mjs";
4
+ import { hydrateRoot } from "react-dom/client";
5
+ import { jsx } from "react/jsx-runtime";
6
+
7
+ //#region ../web/src/client/hydrate-page.tsx
8
+ /**
9
+ * The hydration MOUNT point — a different id from the payload script's id.
10
+ * Not exported anywhere as a named constant (`default-app.tsx:39` only
11
+ * renders the literal `<div id="root">`), so a local literal is fine here:
12
+ * the contract's no-duplicate-literal rule is specifically about the payload
13
+ * script id, which `readHydrationPayload` already owns exclusively
14
+ * (hydration-payload-contract-2026-08-22.md §4).
15
+ */
16
+ const MOUNT_ELEMENT_ID = "root";
17
+ function isPromise(value) {
18
+ return typeof value === "object" && value !== null && typeof value.then === "function";
19
+ }
20
+ /**
21
+ * The awaited path's failure sink. By the time a `buildTree` promise rejects
22
+ * the synchronous stack is gone, so there is no caller left to throw at — and
23
+ * an un-attached rejection is a blank console, which is the silent-failure
24
+ * class this pipeline keeps regressing into. Reported loudly, and deliberately
25
+ * WITHOUT touching `#root`: the server's markup is correct and visible, it is
26
+ * only un-hydrated, so clearing it would turn a degraded page into a blank one.
27
+ */
28
+ function reportHydrationFailure(error) {
29
+ console.error("Warlock hydration failed after the page tree was requested. The server-rendered markup is left on screen un-hydrated; #root was not cleared.", error);
30
+ }
31
+ /**
32
+ * The one hydration entry point. Mounts at `#root` only — the page subtree —
33
+ * never `document`/`html`/`head`/`body` (hydration-payload-contract-2026-08-22.md
34
+ * §2): `metadata`, `lang`, `dir`, `nonce` are not in the client payload and are
35
+ * never touched here. `readHydrationPayload` (web/src/hydration-payload.ts,
36
+ * Vega's slice B) is the one place ABSENT/MALFORMED are decided, so this
37
+ * function does not re-implement that check — reusing it is what keeps the
38
+ * two throw messages from drifting apart at a second site. On ABSENT/MALFORMED
39
+ * it throws before touching `#root`, so the server-rendered markup stays
40
+ * visible; nothing is cleared or re-rendered.
41
+ *
42
+ * Order is load-bearing and unchanged by the async tree: payload validated,
43
+ * shared snapshot installed, `#root` resolved and its absence thrown on — all
44
+ * SYNCHRONOUSLY, so those three failures still reject the call itself — and
45
+ * only then is the tree built and, if it is a promise, awaited. Nothing is
46
+ * cleared on any failure path.
47
+ */
48
+ function hydratePage(buildTree) {
49
+ const payload = readHydrationPayload(document);
50
+ hydrateShared(payload.shared);
51
+ const mountElement = document.getElementById(MOUNT_ELEMENT_ID);
52
+ if (mountElement === null) throw new Error(`Warlock hydration aborted: no element with id "${MOUNT_ELEMENT_ID}" was found. The server is expected to render <div id="root"> as the hydration mount point (web/src/components/default-app.tsx:39).`);
53
+ const value = {
54
+ metadata: void 0,
55
+ payload
56
+ };
57
+ const mount = (tree) => {
58
+ hydrateRoot(mountElement, /* @__PURE__ */ jsx(DocumentContext.Provider, {
59
+ value,
60
+ children: tree
61
+ }));
62
+ };
63
+ const tree = buildTree(payload);
64
+ if (isPromise(tree)) {
65
+ tree.then(mount, reportHydrationFailure);
66
+ return;
67
+ }
68
+ mount(tree);
69
+ }
70
+
71
+ //#endregion
72
+ export { hydratePage };
73
+ //# sourceMappingURL=hydrate-page.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hydrate-page.mjs","names":[],"sources":["../../../../../../../web/src/client/hydrate-page.tsx"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { DocumentContext, type DocumentContextValue } from \"../components/document-context\";\nimport { readHydrationPayload, type HydrationDocumentPayloadSource } from \"../hydration-payload\";\nimport { hydrateShared } from \"../shared\";\n\n/**\n * The hydration MOUNT point — a different id from the payload script's id.\n * Not exported anywhere as a named constant (`default-app.tsx:39` only\n * renders the literal `<div id=\"root\">`), so a local literal is fine here:\n * the contract's no-duplicate-literal rule is specifically about the payload\n * script id, which `readHydrationPayload` already owns exclusively\n * (hydration-payload-contract-2026-08-22.md §4).\n */\nconst MOUNT_ELEMENT_ID = \"root\";\n\n/**\n * Receives the VALIDATED payload and returns the ReactNode to hydrate. A\n * callback rather than a ready-made ReactNode: composing the real Layout(Page)\n * tree needs `payload.layoutData`/`pageData`/`shared`, which only exist after\n * `readHydrationPayload()` has already succeeded — building the tree first and\n * validating second would get the order backwards.\n *\n * A Promise is allowed because the real composer resolves the page's chunk\n * through the registry's dynamic `import()`. Returning it does NOT move the\n * payload check later: the callback is still only reached once the payload has\n * validated, and only the tree it produces is awaited.\n */\nexport type BuildHydratedTree = (\n payload: HydrationDocumentPayloadSource,\n) => ReactNode | Promise<ReactNode>;\n\nfunction isPromise(value: ReactNode | Promise<ReactNode>): value is Promise<ReactNode> {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/**\n * The awaited path's failure sink. By the time a `buildTree` promise rejects\n * the synchronous stack is gone, so there is no caller left to throw at — and\n * an un-attached rejection is a blank console, which is the silent-failure\n * class this pipeline keeps regressing into. Reported loudly, and deliberately\n * WITHOUT touching `#root`: the server's markup is correct and visible, it is\n * only un-hydrated, so clearing it would turn a degraded page into a blank one.\n */\nfunction reportHydrationFailure(error: unknown): void {\n console.error(\n \"Warlock hydration failed after the page tree was requested. The server-rendered \" +\n \"markup is left on screen un-hydrated; #root was not cleared.\",\n error,\n );\n}\n\n/**\n * The one hydration entry point. Mounts at `#root` only — the page subtree —\n * never `document`/`html`/`head`/`body` (hydration-payload-contract-2026-08-22.md\n * §2): `metadata`, `lang`, `dir`, `nonce` are not in the client payload and are\n * never touched here. `readHydrationPayload` (web/src/hydration-payload.ts,\n * Vega's slice B) is the one place ABSENT/MALFORMED are decided, so this\n * function does not re-implement that check — reusing it is what keeps the\n * two throw messages from drifting apart at a second site. On ABSENT/MALFORMED\n * it throws before touching `#root`, so the server-rendered markup stays\n * visible; nothing is cleared or re-rendered.\n *\n * Order is load-bearing and unchanged by the async tree: payload validated,\n * shared snapshot installed, `#root` resolved and its absence thrown on — all\n * SYNCHRONOUSLY, so those three failures still reject the call itself — and\n * only then is the tree built and, if it is a promise, awaited. Nothing is\n * cleared on any failure path.\n */\nexport function hydratePage(buildTree: BuildHydratedTree): void {\n const payload = readHydrationPayload(document);\n\n hydrateShared(payload.shared);\n\n const mountElement = document.getElementById(MOUNT_ELEMENT_ID);\n\n if (mountElement === null) {\n throw new Error(\n `Warlock hydration aborted: no element with id \"${MOUNT_ELEMENT_ID}\" was found. The ` +\n 'server is expected to render <div id=\"root\"> as the hydration mount point ' +\n \"(web/src/components/default-app.tsx:39).\",\n );\n }\n\n const value: DocumentContextValue = { metadata: undefined, payload };\n\n const mount = (tree: ReactNode): void => {\n hydrateRoot(\n mountElement,\n <DocumentContext.Provider value={value}>{tree}</DocumentContext.Provider>,\n );\n };\n\n const tree = buildTree(payload);\n\n if (isPromise(tree)) {\n // `void` on an ALREADY-handled chain: the rejection handler is attached\n // here, so nothing escapes as an unhandled rejection.\n void tree.then(mount, reportHydrationFailure);\n\n return;\n }\n\n mount(tree);\n}"],"mappings":";;;;;;;;;;;;;;;AAcA,MAAM,mBAAmB;AAkBzB,SAAS,UAAU,OAAoE;CACrF,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;;;;;;;;;AAUA,SAAS,uBAAuB,OAAsB;CACpD,QAAQ,MACN,gJAEA,KACF;AACF;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,YAAY,WAAoC;CAC9D,MAAM,UAAU,qBAAqB,QAAQ;CAE7C,cAAc,QAAQ,MAAM;CAE5B,MAAM,eAAe,SAAS,eAAe,gBAAgB;CAE7D,IAAI,iBAAiB,MACnB,MAAM,IAAI,MACR,kDAAkD,iBAAiB,oIAGrE;CAGF,MAAM,QAA8B;EAAE,UAAU;EAAW;CAAQ;CAEnE,MAAM,SAAS,SAA0B;EACvC,YACE,cACA,oBAAC,gBAAgB,UAAjB;GAAiC;aAAQ;EAA+B,EAC1E;CACF;CAEA,MAAM,OAAO,UAAU,OAAO;CAE9B,IAAI,UAAU,IAAI,GAAG;EAGnB,AAAK,KAAK,KAAK,OAAO,sBAAsB;EAE5C;CACF;CAEA,MAAM,IAAI;AACZ"}
@@ -0,0 +1,91 @@
1
+ //#region ../web/src/client/navigation/current-route.d.ts
2
+ /**
3
+ * "Which route am I on?", under the name MRR spells it
4
+ * (`@mongez/react-router` — `src/utilities.tsx`).
5
+ *
6
+ * ## The shift, and it is the whole point of this file
7
+ *
8
+ * MRR's `currentRoute()` returns the object ITS MATCHER produced: MRR is a CSR
9
+ * router, so the browser matched the URL and the answer is the browser's own
10
+ * conclusion. Warlock has no client matcher and must never grow one (canon
11
+ * `9c8f878b`) — the SERVER router is the only matcher, and a second one can
12
+ * disagree with it about the very request it is hydrating.
13
+ *
14
+ * So the name is MRR's because the QUESTION is the same one, but the ANSWER
15
+ * comes from somewhere else: the matched entry's name AND ITS PARAMS travel on
16
+ * the hydration payload (`web/src/components/document-context.ts`), and this
17
+ * module reports what arrived. Nothing here parses a path, compares a URL to a
18
+ * pattern, or knows that routes have shapes.
19
+ *
20
+ * The practical consequence for a caller: this is the server's match, so it is
21
+ * as correct as the page on screen and it cannot drift from it — including on
22
+ * the very first render, before any client navigation, which is most page
23
+ * views.
24
+ *
25
+ * ## The params, and where they come from
26
+ *
27
+ * `bundle.route.params` (`web/src/server/execute-page-request.ts:288`) is the
28
+ * router's own answer; `buildHydrationPayload` puts it on the payload and the
29
+ * projection below hands it over unchanged. Deriving `{ id: "42" }` from
30
+ * `location.pathname` here would BE the second matcher this file exists to
31
+ * refuse — a URL and a pattern are exactly what it must never compare.
32
+ *
33
+ * ## Why nothing here touches `window`
34
+ *
35
+ * Both readers are importable from universal modules, so both can be CALLED
36
+ * during the server render and in the gap between first paint and hydration.
37
+ * They read module state and nothing else, so there is no browser to be missing
38
+ * — the answer in those contexts is `undefined`, meaning "no page has been
39
+ * rendered into this module", which is exactly true.
40
+ */
41
+ /**
42
+ * What the SERVER matched for the page currently on screen.
43
+ *
44
+ * Deliberately not `ClientRouteMatch` (`web/src/client/runtime/types.ts`): that
45
+ * is the deprecated client matcher's output shape, entry object and all, and
46
+ * this is the opposite claim — a name the server sent us.
47
+ */
48
+ type MatchedRoute = {
49
+ /** The matched page manifest entry's stable `name`, e.g. `products.details`. */readonly name: string;
50
+ /**
51
+ * The params the SERVER matched, e.g. `{ id: "42" }` for `/users/:id`, and
52
+ * `{}` for a route with no dynamic segments.
53
+ *
54
+ * `undefined` means the payload carried no `params` key — an older build, or
55
+ * a document cached across a deploy. It is NOT the same answer as `{}`, and
56
+ * this module will not collapse the two: `{}` is the server saying "this
57
+ * route has no params", `undefined` is the server not having said. Inventing
58
+ * the first from the second would be a lie a caller cannot detect, which is
59
+ * the same standard {@link previousRoute} is held to below. Every payload a
60
+ * current server produces carries the key.
61
+ *
62
+ * A COPY of the payload's object, so a caller writing to it cannot reach the
63
+ * payload the page was built from.
64
+ */
65
+ readonly params?: Readonly<Record<string, string>>;
66
+ };
67
+ /**
68
+ * @returns what the SERVER matched for the page on screen — see this file's
69
+ * header for why that is the answer and not a client-side match. Correct from
70
+ * the first render of the initial page, since the hydration payload carried the
71
+ * match with it.
72
+ *
73
+ * `undefined` means no page has been rendered into this module: a server
74
+ * render, or an import evaluated before hydration mounted. Safe to call in
75
+ * either — it does not throw and does not touch `window`.
76
+ */
77
+ declare function currentRoute(): MatchedRoute | undefined;
78
+ /**
79
+ * @returns the entry that was on screen BEFORE the current one, or `undefined`
80
+ * when the current page is the one the user landed on. That `undefined` is a
81
+ * real answer, not a missing one — there is no previous route on a first visit,
82
+ * and reporting the current one would be a lie a caller cannot detect.
83
+ *
84
+ * This is the previously SWAPPED page, not the previous history entry: pressing
85
+ * Back is itself a navigation here, so going A → B → Back leaves the previous
86
+ * route as B. Safe to call with no browser.
87
+ */
88
+ declare function previousRoute(): MatchedRoute | undefined;
89
+ //#endregion
90
+ export { MatchedRoute, currentRoute, previousRoute };
91
+ //# sourceMappingURL=current-route.d.mts.map
@@ -0,0 +1,66 @@
1
+ //#region ../web/src/client/navigation/current-route.ts
2
+ let current;
3
+ let previous;
4
+ /**
5
+ * The payload `current` was projected from, kept ONLY to recognise it again.
6
+ *
7
+ * `NavigationRoot` records on every render pass, because a page component must
8
+ * be able to call `currentRoute()` while it is itself rendering — including on
9
+ * the initial mount, where no effect has run yet. Renders are not navigations
10
+ * though: StrictMode invokes them twice, and a parent re-render invokes them
11
+ * again for free. One swap is one payload OBJECT, so identity is what separates
12
+ * "we moved" from "we rendered again". Comparing names instead would both miss
13
+ * a `/users/1` → `/users/2` move and invent one out of a double render.
14
+ */
15
+ let source;
16
+ /**
17
+ * Record the payload the page on screen was built from.
18
+ *
19
+ * Called by `NavigationRoot` during render — at mount with the hydration
20
+ * payload, and after each swap with the fetched one. Not part of the public
21
+ * surface: the payload is the navigation runtime's to hand over, and a caller
22
+ * setting the current route by hand would be asserting a match that never
23
+ * happened.
24
+ *
25
+ * Idempotent per payload object, so re-rendering the same page never shifts
26
+ * {@link previousRoute}.
27
+ */
28
+ function recordCurrentRoute(payload) {
29
+ if (source === payload) return;
30
+ source = payload;
31
+ previous = current;
32
+ current = payload.params === void 0 ? { name: payload.name } : {
33
+ name: payload.name,
34
+ params: { ...payload.params }
35
+ };
36
+ }
37
+ /**
38
+ * @returns what the SERVER matched for the page on screen — see this file's
39
+ * header for why that is the answer and not a client-side match. Correct from
40
+ * the first render of the initial page, since the hydration payload carried the
41
+ * match with it.
42
+ *
43
+ * `undefined` means no page has been rendered into this module: a server
44
+ * render, or an import evaluated before hydration mounted. Safe to call in
45
+ * either — it does not throw and does not touch `window`.
46
+ */
47
+ function currentRoute() {
48
+ return current;
49
+ }
50
+ /**
51
+ * @returns the entry that was on screen BEFORE the current one, or `undefined`
52
+ * when the current page is the one the user landed on. That `undefined` is a
53
+ * real answer, not a missing one — there is no previous route on a first visit,
54
+ * and reporting the current one would be a lie a caller cannot detect.
55
+ *
56
+ * This is the previously SWAPPED page, not the previous history entry: pressing
57
+ * Back is itself a navigation here, so going A → B → Back leaves the previous
58
+ * route as B. Safe to call with no browser.
59
+ */
60
+ function previousRoute() {
61
+ return previous;
62
+ }
63
+
64
+ //#endregion
65
+ export { currentRoute, previousRoute, recordCurrentRoute };
66
+ //# sourceMappingURL=current-route.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"current-route.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/current-route.ts"],"sourcesContent":["import type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\n/**\n * \"Which route am I on?\", under the name MRR spells it\n * (`@mongez/react-router` — `src/utilities.tsx`).\n *\n * ## The shift, and it is the whole point of this file\n *\n * MRR's `currentRoute()` returns the object ITS MATCHER produced: MRR is a CSR\n * router, so the browser matched the URL and the answer is the browser's own\n * conclusion. Warlock has no client matcher and must never grow one (canon\n * `9c8f878b`) — the SERVER router is the only matcher, and a second one can\n * disagree with it about the very request it is hydrating.\n *\n * So the name is MRR's because the QUESTION is the same one, but the ANSWER\n * comes from somewhere else: the matched entry's name AND ITS PARAMS travel on\n * the hydration payload (`web/src/components/document-context.ts`), and this\n * module reports what arrived. Nothing here parses a path, compares a URL to a\n * pattern, or knows that routes have shapes.\n *\n * The practical consequence for a caller: this is the server's match, so it is\n * as correct as the page on screen and it cannot drift from it — including on\n * the very first render, before any client navigation, which is most page\n * views.\n *\n * ## The params, and where they come from\n *\n * `bundle.route.params` (`web/src/server/execute-page-request.ts:288`) is the\n * router's own answer; `buildHydrationPayload` puts it on the payload and the\n * projection below hands it over unchanged. Deriving `{ id: \"42\" }` from\n * `location.pathname` here would BE the second matcher this file exists to\n * refuse — a URL and a pattern are exactly what it must never compare.\n *\n * ## Why nothing here touches `window`\n *\n * Both readers are importable from universal modules, so both can be CALLED\n * during the server render and in the gap between first paint and hydration.\n * They read module state and nothing else, so there is no browser to be missing\n * — the answer in those contexts is `undefined`, meaning \"no page has been\n * rendered into this module\", which is exactly true.\n */\n\n/**\n * What the SERVER matched for the page currently on screen.\n *\n * Deliberately not `ClientRouteMatch` (`web/src/client/runtime/types.ts`): that\n * is the deprecated client matcher's output shape, entry object and all, and\n * this is the opposite claim — a name the server sent us.\n */\nexport type MatchedRoute = {\n /** The matched page manifest entry's stable `name`, e.g. `products.details`. */\n readonly name: string;\n /**\n * The params the SERVER matched, e.g. `{ id: \"42\" }` for `/users/:id`, and\n * `{}` for a route with no dynamic segments.\n *\n * `undefined` means the payload carried no `params` key — an older build, or\n * a document cached across a deploy. It is NOT the same answer as `{}`, and\n * this module will not collapse the two: `{}` is the server saying \"this\n * route has no params\", `undefined` is the server not having said. Inventing\n * the first from the second would be a lie a caller cannot detect, which is\n * the same standard {@link previousRoute} is held to below. Every payload a\n * current server produces carries the key.\n *\n * A COPY of the payload's object, so a caller writing to it cannot reach the\n * payload the page was built from.\n */\n readonly params?: Readonly<Record<string, string>>;\n};\n\nlet current: MatchedRoute | undefined;\nlet previous: MatchedRoute | undefined;\n\n/**\n * The payload `current` was projected from, kept ONLY to recognise it again.\n *\n * `NavigationRoot` records on every render pass, because a page component must\n * be able to call `currentRoute()` while it is itself rendering — including on\n * the initial mount, where no effect has run yet. Renders are not navigations\n * though: StrictMode invokes them twice, and a parent re-render invokes them\n * again for free. One swap is one payload OBJECT, so identity is what separates\n * \"we moved\" from \"we rendered again\". Comparing names instead would both miss\n * a `/users/1` → `/users/2` move and invent one out of a double render.\n */\nlet source: HydrationDocumentPayloadSource | undefined;\n\n/**\n * Record the payload the page on screen was built from.\n *\n * Called by `NavigationRoot` during render — at mount with the hydration\n * payload, and after each swap with the fetched one. Not part of the public\n * surface: the payload is the navigation runtime's to hand over, and a caller\n * setting the current route by hand would be asserting a match that never\n * happened.\n *\n * Idempotent per payload object, so re-rendering the same page never shifts\n * {@link previousRoute}.\n */\nexport function recordCurrentRoute(payload: HydrationDocumentPayloadSource): void {\n if (source === payload) return;\n\n source = payload;\n previous = current;\n // A COPY when the payload carried params, and no key at all when it did not\n // — the projection reports what arrived and never fills a gap in.\n current =\n payload.params === undefined\n ? { name: payload.name }\n : { name: payload.name, params: { ...payload.params } };\n}\n\n/**\n * @returns what the SERVER matched for the page on screen — see this file's\n * header for why that is the answer and not a client-side match. Correct from\n * the first render of the initial page, since the hydration payload carried the\n * match with it.\n *\n * `undefined` means no page has been rendered into this module: a server\n * render, or an import evaluated before hydration mounted. Safe to call in\n * either — it does not throw and does not touch `window`.\n */\nexport function currentRoute(): MatchedRoute | undefined {\n return current;\n}\n\n/**\n * @returns the entry that was on screen BEFORE the current one, or `undefined`\n * when the current page is the one the user landed on. That `undefined` is a\n * real answer, not a missing one — there is no previous route on a first visit,\n * and reporting the current one would be a lie a caller cannot detect.\n *\n * This is the previously SWAPPED page, not the previous history entry: pressing\n * Back is itself a navigation here, so going A → B → Back leaves the previous\n * route as B. Safe to call with no browser.\n */\nexport function previousRoute(): MatchedRoute | undefined {\n return previous;\n}\n"],"mappings":";AAsEA,IAAI;AACJ,IAAI;;;;;;;;;;;;AAaJ,IAAI;;;;;;;;;;;;;AAcJ,SAAgB,mBAAmB,SAA+C;CAChF,IAAI,WAAW,SAAS;CAExB,SAAS;CACT,WAAW;CAGX,UACE,QAAQ,WAAW,SACf,EAAE,MAAM,QAAQ,KAAK,IACrB;EAAE,MAAM,QAAQ;EAAM,QAAQ,EAAE,GAAG,QAAQ,OAAO;CAAE;AAC5D;;;;;;;;;;;AAYA,SAAgB,eAAyC;CACvD,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,gBAA0C;CACxD,OAAO;AACT"}
@@ -0,0 +1,97 @@
1
+ import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../routing/data-request.mjs";
2
+
3
+ //#region ../web/src/client/navigation/fetch-page-data.ts
4
+ /**
5
+ * Ask the server for a URL's page data instead of its document.
6
+ *
7
+ * This is the browser half of the representation split: the same route the user
8
+ * would have loaded, asked for as JSON via the `x-warlock-data` marker. What
9
+ * comes back is exactly the payload a full page load embeds, so the caller can
10
+ * rebuild the tree from it with no second code path.
11
+ *
12
+ * ## Every failure degrades to a REAL navigation, never to an error screen
13
+ *
14
+ * A client navigation is an OPTIMISATION over what the browser already does
15
+ * perfectly well. So nothing here reports a failure to the user — it reports
16
+ * `hard-navigate`, and the caller hands the URL back to the browser. The user
17
+ * gets the page; they just get it the slow way.
18
+ *
19
+ * That is what makes the whole feature safe to add: the worst case of a bug in
20
+ * this file is the behaviour we had before the file existed. Rendering our own
21
+ * "navigation failed" state would be strictly worse than the fallback we
22
+ * already have, and would turn every unhandled edge — an auth redirect to an
23
+ * external IdP, a maintenance page, a proxy that strips the header, a deploy
24
+ * that changed the payload shape mid-session — into a dead end.
25
+ */
26
+ /**
27
+ * Whether the body is the payload we asked for.
28
+ *
29
+ * Checked rather than assumed because a 200 does not mean "this came from the
30
+ * page pipeline": a captive portal, an SSO interstitial or a proxy error page
31
+ * all answer 200 with HTML. Parsing that as JSON would throw; treating a
32
+ * successful parse of *something else* as a payload would render garbage.
33
+ */
34
+ function isPayloadResponse(response) {
35
+ return (response.headers.get("content-type") ?? "").includes(DATA_RESPONSE_CONTENT_TYPE);
36
+ }
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";
44
+ }
45
+ async function fetchPageData(url) {
46
+ let response;
47
+ try {
48
+ response = await fetch(url, {
49
+ headers: {
50
+ [WARLOCK_DATA_REQUEST_HEADER]: "1",
51
+ accept: DATA_RESPONSE_CONTENT_TYPE
52
+ },
53
+ credentials: "same-origin",
54
+ redirect: "follow"
55
+ });
56
+ } catch (error) {
57
+ return {
58
+ type: "hard-navigate",
59
+ url,
60
+ reason: `request failed: ${String(error)}`
61
+ };
62
+ }
63
+ if (!response.ok) return {
64
+ type: "hard-navigate",
65
+ url,
66
+ reason: `status ${response.status}`
67
+ };
68
+ if (!isPayloadResponse(response)) return {
69
+ type: "hard-navigate",
70
+ url,
71
+ reason: `unexpected content-type "${response.headers.get("content-type") ?? "none"}"`
72
+ };
73
+ let parsed;
74
+ try {
75
+ parsed = await response.json();
76
+ } catch (error) {
77
+ return {
78
+ type: "hard-navigate",
79
+ url,
80
+ reason: `malformed JSON: ${String(error)}`
81
+ };
82
+ }
83
+ if (!isPayloadShape(parsed)) return {
84
+ type: "hard-navigate",
85
+ url,
86
+ reason: "payload has no route name"
87
+ };
88
+ return {
89
+ type: "payload",
90
+ payload: parsed,
91
+ url: response.url || url
92
+ };
93
+ }
94
+
95
+ //#endregion
96
+ export { fetchPageData };
97
+ //# sourceMappingURL=fetch-page-data.mjs.map
@@ -0,0 +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 );\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 return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\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;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,GACxB,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ;CAA4B;CAM3E,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}