@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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/esm/build/contribution.d.mts +55 -0
- package/esm/build/contribution.mjs +132 -0
- package/esm/build/contribution.mjs.map +1 -0
- package/esm/build/discover-pages.mjs +294 -0
- package/esm/build/discover-pages.mjs.map +1 -0
- package/esm/build/generate-client-registry.mjs +123 -0
- package/esm/build/generate-client-registry.mjs.map +1 -0
- package/esm/build/generate-pages-barrel.mjs +330 -0
- package/esm/build/generate-pages-barrel.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs +212 -0
- package/esm/build/read-route-exports.mjs.map +1 -0
- package/esm/client/build-hydrated-tree.mjs +113 -0
- package/esm/client/build-hydrated-tree.mjs.map +1 -0
- package/esm/client/hydrate-page.mjs +73 -0
- package/esm/client/hydrate-page.mjs.map +1 -0
- package/esm/client/navigation/current-route.d.mts +91 -0
- package/esm/client/navigation/current-route.mjs +66 -0
- package/esm/client/navigation/current-route.mjs.map +1 -0
- package/esm/client/navigation/fetch-page-data.mjs +97 -0
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +196 -0
- package/esm/client/navigation/navigation-root.mjs.map +1 -0
- package/esm/client/navigation/prefetch.mjs +110 -0
- package/esm/client/navigation/prefetch.mjs.map +1 -0
- package/esm/client/navigation/refresh.d.mts +40 -0
- package/esm/client/navigation/refresh.mjs +146 -0
- package/esm/client/navigation/refresh.mjs.map +1 -0
- package/esm/client/navigation/verbs.d.mts +55 -0
- package/esm/client/navigation/verbs.mjs +66 -0
- package/esm/client/navigation/verbs.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +4 -0
- package/esm/client/runtime/index.mjs +4 -0
- package/esm/client/runtime/manifest.d.mts +8 -0
- package/esm/client/runtime/manifest.mjs +77 -0
- package/esm/client/runtime/manifest.mjs.map +1 -0
- package/esm/client/runtime/matcher.d.mts +7 -0
- package/esm/client/runtime/matcher.mjs +169 -0
- package/esm/client/runtime/matcher.mjs.map +1 -0
- package/esm/client/runtime/types.d.mts +22 -0
- package/esm/components/default-app.mjs +32 -0
- package/esm/components/default-app.mjs.map +1 -0
- package/esm/components/document-context.d.mts +1 -0
- package/esm/components/document-context.mjs +24 -0
- package/esm/components/document-context.mjs.map +1 -0
- package/esm/components/head.d.mts +14 -0
- package/esm/components/head.mjs +63 -0
- package/esm/components/head.mjs.map +1 -0
- package/esm/components/link.d.mts +157 -0
- package/esm/components/link.mjs +194 -0
- package/esm/components/link.mjs.map +1 -0
- package/esm/components/scripts.d.mts +14 -0
- package/esm/components/scripts.mjs +21 -0
- package/esm/components/scripts.mjs.map +1 -0
- package/esm/connector/index.d.mts +6 -0
- package/esm/connector/index.mjs +7 -0
- package/esm/context.d.mts +107 -0
- package/esm/hydration/index.d.mts +1 -0
- package/esm/hydration/index.mjs +33 -0
- package/esm/hydration/index.mjs.map +1 -0
- package/esm/hydration-payload.d.mts +1 -0
- package/esm/hydration-payload.mjs +72 -0
- package/esm/hydration-payload.mjs.map +1 -0
- package/esm/index.d.mts +35 -0
- package/esm/index.mjs +12 -0
- package/esm/loaders.d.mts +47 -0
- package/esm/metadata.d.mts +54 -0
- package/esm/props.d.mts +42 -0
- package/esm/route.d.mts +30 -0
- package/esm/routing/compose-route-path.mjs +33 -0
- package/esm/routing/compose-route-path.mjs.map +1 -0
- package/esm/routing/data-request.mjs +52 -0
- package/esm/routing/data-request.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs +78 -0
- package/esm/routing/layout-policy.mjs.map +1 -0
- package/esm/routing/navigator.mjs +21 -0
- package/esm/routing/navigator.mjs.map +1 -0
- package/esm/routing/query-string.d.mts +250 -0
- package/esm/routing/query-string.mjs +340 -0
- package/esm/routing/query-string.mjs.map +1 -0
- package/esm/routing/route-identity.mjs +68 -0
- package/esm/routing/route-identity.mjs.map +1 -0
- package/esm/routing/route-table.d.mts +34 -0
- package/esm/routing/route-table.mjs +182 -0
- package/esm/routing/route-table.mjs.map +1 -0
- package/esm/routing/router-events.d.mts +159 -0
- package/esm/routing/router-events.mjs +83 -0
- package/esm/routing/router-events.mjs.map +1 -0
- package/esm/server/buffered-response.mjs +115 -0
- package/esm/server/buffered-response.mjs.map +1 -0
- package/esm/server/build-hydration-payload.mjs +27 -0
- package/esm/server/build-hydration-payload.mjs.map +1 -0
- package/esm/server/client-asset-url-prefix.d.mts +22 -0
- package/esm/server/client-asset-url-prefix.mjs +23 -0
- package/esm/server/client-asset-url-prefix.mjs.map +1 -0
- package/esm/server/create-page-module-loader.mjs +49 -0
- package/esm/server/create-page-module-loader.mjs.map +1 -0
- package/esm/server/create-page-route-handler.mjs +107 -0
- package/esm/server/create-page-route-handler.mjs.map +1 -0
- package/esm/server/dev-server.mjs +167 -0
- package/esm/server/dev-server.mjs.map +1 -0
- package/esm/server/execute-page-request.d.mts +1 -0
- package/esm/server/execute-page-request.mjs +166 -0
- package/esm/server/execute-page-request.mjs.map +1 -0
- package/esm/server/execute-page-request.types.d.mts +1 -0
- package/esm/server/hydration-client-url.d.mts +37 -0
- package/esm/server/hydration-client-url.mjs +101 -0
- package/esm/server/hydration-client-url.mjs.map +1 -0
- package/esm/server/index.mjs +13 -0
- package/esm/server/install-page-routes-from-manifest.mjs +133 -0
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -0
- package/esm/server/install-page-routes.d.mts +1 -0
- package/esm/server/install-page-routes.mjs +6 -0
- package/esm/server/install-production-page-routes.mjs +55 -0
- package/esm/server/install-production-page-routes.mjs.map +1 -0
- package/esm/server/match-page-route.mjs +48 -0
- package/esm/server/match-page-route.mjs.map +1 -0
- package/esm/server/page-context.d.mts +1 -0
- package/esm/server/page-context.mjs +33 -0
- package/esm/server/page-context.mjs.map +1 -0
- package/esm/server/page-manifest.d.mts +102 -0
- package/esm/server/page-manifest.mjs +31 -0
- package/esm/server/page-manifest.mjs.map +1 -0
- package/esm/server/render-page.mjs +214 -0
- package/esm/server/render-page.mjs.map +1 -0
- package/esm/server/resolve-page-metadata.mjs +33 -0
- package/esm/server/resolve-page-metadata.mjs.map +1 -0
- package/esm/server/resolve-validation-data.mjs +27 -0
- package/esm/server/resolve-validation-data.mjs.map +1 -0
- package/esm/server/settle-page-response.d.mts +1 -0
- package/esm/server/settle-page-response.mjs +90 -0
- package/esm/server/settle-page-response.mjs.map +1 -0
- package/esm/server/stylesheet-urls.mjs +65 -0
- package/esm/server/stylesheet-urls.mjs.map +1 -0
- package/esm/server/web-connector-factory.d.mts +25 -0
- package/esm/server/web-connector-factory.mjs +121 -0
- package/esm/server/web-connector-factory.mjs.map +1 -0
- package/esm/server/web-connector.d.mts +34 -0
- package/esm/server/web-connector.mjs +516 -0
- package/esm/server/web-connector.mjs.map +1 -0
- package/esm/shared.d.mts +22 -0
- package/esm/shared.mjs +223 -0
- package/esm/shared.mjs.map +1 -0
- package/esm/validation.d.mts +31 -0
- package/esm/vite/app-convention-aliases.mjs +43 -0
- package/esm/vite/app-convention-aliases.mjs.map +1 -0
- package/esm/vite/build-client.d.mts +38 -0
- package/esm/vite/build-client.mjs +72 -0
- package/esm/vite/build-client.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +57 -0
- package/esm/vite/gate-a-resolve.mjs +834 -0
- package/esm/vite/gate-a-resolve.mjs.map +1 -0
- package/esm/vite/gate-b-secrets.d.mts +57 -0
- package/esm/vite/gate-b-secrets.mjs +299 -0
- package/esm/vite/gate-b-secrets.mjs.map +1 -0
- package/esm/vite/gate-c-verify.d.mts +118 -0
- package/esm/vite/gate-c-verify.mjs +263 -0
- package/esm/vite/gate-c-verify.mjs.map +1 -0
- package/esm/vite/hydration-entries.d.mts +17 -0
- package/esm/vite/hydration-entries.mjs +45 -0
- package/esm/vite/hydration-entries.mjs.map +1 -0
- package/esm/vite/index.d.mts +126 -0
- package/esm/vite/index.mjs +146 -0
- package/esm/vite/index.mjs.map +1 -0
- package/esm/vite/page-registry-plugin.d.mts +43 -0
- package/esm/vite/page-registry-plugin.mjs +138 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -0
- package/esm/vite/projection.d.mts +31 -0
- package/esm/vite/projection.mjs +384 -0
- package/esm/vite/projection.mjs.map +1 -0
- package/llms-full.txt +1041 -0
- package/llms.txt +21 -0
- package/package.json +77 -0
- package/skills/add-web-to-an-app/SKILL.md +103 -0
- package/skills/create-a-page/SKILL.md +141 -0
- package/skills/load-page-data/SKILL.md +183 -0
- package/skills/navigate-on-the-client/SKILL.md +182 -0
- package/skills/serve-styles/SKILL.md +145 -0
- package/skills/use-layouts/SKILL.md +121 -0
- 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"}
|