@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,48 @@
|
|
|
1
|
+
//#region ../web/src/server/match-page-route.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stage 1 — turn a URL into a route entry plus its params.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ **This is a SECOND matcher, and on the HTTP path it is redundant.** Core's
|
|
6
|
+
* router has already matched by the time a page handler runs, and
|
|
7
|
+
* `create-page-route-handler.ts` ignores the match it is handed. The one caller
|
|
8
|
+
* that genuinely needs this is `renderPage(name, options)`, which synthesizes a
|
|
9
|
+
* URL with no HTTP request behind it — and that path is not wired up
|
|
10
|
+
* (`connectPageRoutes()` is never called).
|
|
11
|
+
*
|
|
12
|
+
* Removing it from the HTTP path is carded. Two things must be proven first:
|
|
13
|
+
* that these params agree with core's, since `bundle.route.params` reaches the
|
|
14
|
+
* hydration payload; and that dropping `bundle.route.query` — a public type
|
|
15
|
+
* member — is announced rather than slipped in.
|
|
16
|
+
*/
|
|
17
|
+
function splitSegments(path) {
|
|
18
|
+
return path.split("/").filter((segment) => segment.length > 0);
|
|
19
|
+
}
|
|
20
|
+
function matchPath(pattern, pathname) {
|
|
21
|
+
const patternSegments = splitSegments(pattern);
|
|
22
|
+
const pathSegments = splitSegments(pathname);
|
|
23
|
+
if (patternSegments.length !== pathSegments.length) return void 0;
|
|
24
|
+
const params = {};
|
|
25
|
+
for (let index = 0; index < patternSegments.length; index++) {
|
|
26
|
+
const patternSegment = patternSegments[index];
|
|
27
|
+
const pathSegment = pathSegments[index];
|
|
28
|
+
if (patternSegment.startsWith(":")) {
|
|
29
|
+
params[patternSegment.slice(1)] = decodeURIComponent(pathSegment);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (patternSegment !== pathSegment) return void 0;
|
|
33
|
+
}
|
|
34
|
+
return params;
|
|
35
|
+
}
|
|
36
|
+
function matchRoute(pathname, routes) {
|
|
37
|
+
for (const entry of routes) {
|
|
38
|
+
const params = matchPath(entry.path, pathname);
|
|
39
|
+
if (params) return {
|
|
40
|
+
entry,
|
|
41
|
+
params
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { matchRoute };
|
|
48
|
+
//# sourceMappingURL=match-page-route.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"match-page-route.mjs","names":[],"sources":["../../../../../../../web/src/server/match-page-route.ts"],"sourcesContent":["import type { PageRouteEntry } from \"./execute-page-request.types\";\n\n/**\n * Stage 1 — turn a URL into a route entry plus its params.\n *\n * ⚠ **This is a SECOND matcher, and on the HTTP path it is redundant.** Core's\n * router has already matched by the time a page handler runs, and\n * `create-page-route-handler.ts` ignores the match it is handed. The one caller\n * that genuinely needs this is `renderPage(name, options)`, which synthesizes a\n * URL with no HTTP request behind it — and that path is not wired up\n * (`connectPageRoutes()` is never called).\n *\n * Removing it from the HTTP path is carded. Two things must be proven first:\n * that these params agree with core's, since `bundle.route.params` reaches the\n * hydration payload; and that dropping `bundle.route.query` — a public type\n * member — is announced rather than slipped in.\n */\n\nfunction splitSegments(path: string): string[] {\n return path.split(\"/\").filter(segment => segment.length > 0);\n}\n\nexport function matchPath(\n pattern: string,\n pathname: string,\n): Record<string, string> | undefined {\n const patternSegments = splitSegments(pattern);\n const pathSegments = splitSegments(pathname);\n\n if (patternSegments.length !== pathSegments.length) return undefined;\n\n const params: Record<string, string> = {};\n\n for (let index = 0; index < patternSegments.length; index++) {\n const patternSegment = patternSegments[index];\n const pathSegment = pathSegments[index];\n\n if (patternSegment.startsWith(\":\")) {\n params[patternSegment.slice(1)] = decodeURIComponent(pathSegment);\n continue;\n }\n\n if (patternSegment !== pathSegment) return undefined;\n }\n\n return params;\n}\n\nexport function matchRoute(\n pathname: string,\n routes: readonly PageRouteEntry[],\n): { entry: PageRouteEntry; params: Record<string, string> } | undefined {\n for (const entry of routes) {\n const params = matchPath(entry.path, pathname);\n\n if (params) return { entry, params };\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,SAAS,cAAc,MAAwB;CAC7C,OAAO,KAAK,MAAM,GAAG,CAAC,CAAC,QAAO,YAAW,QAAQ,SAAS,CAAC;AAC7D;AAEA,SAAgB,UACd,SACA,UACoC;CACpC,MAAM,kBAAkB,cAAc,OAAO;CAC7C,MAAM,eAAe,cAAc,QAAQ;CAE3C,IAAI,gBAAgB,WAAW,aAAa,QAAQ,OAAO;CAE3D,MAAM,SAAiC,CAAC;CAExC,KAAK,IAAI,QAAQ,GAAG,QAAQ,gBAAgB,QAAQ,SAAS;EAC3D,MAAM,iBAAiB,gBAAgB;EACvC,MAAM,cAAc,aAAa;EAEjC,IAAI,eAAe,WAAW,GAAG,GAAG;GAClC,OAAO,eAAe,MAAM,CAAC,KAAK,mBAAmB,WAAW;GAChE;EACF;EAEA,IAAI,mBAAmB,aAAa,OAAO;CAC7C;CAEA,OAAO;AACT;AAEA,SAAgB,WACd,UACA,QACuE;CACvE,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,SAAS,UAAU,MAAM,MAAM,QAAQ;EAE7C,IAAI,QAAQ,OAAO;GAAE;GAAO;EAAO;CACrC;AAGF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region ../web/src/server/page-context.ts
|
|
2
|
+
/**
|
|
3
|
+
* Boot-time wiring for the per-request context, once per process.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline never owns an `AsyncLocalStorage` — it borrows core's. A VALUE
|
|
6
|
+
* import of core from web risks resolving a second core instance, and two
|
|
7
|
+
* copies of core means two stores, so `core` stays a type-only peer and the
|
|
8
|
+
* code that already has `requestContext` in scope hands it over:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* connectPageContext(requestContext);
|
|
12
|
+
* connectSharedStore(() => requestContext.getStore());
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
let pageContextRunner;
|
|
16
|
+
let additionalSharedScopeEntry;
|
|
17
|
+
/** Returns the previous runner so tests can restore it. */
|
|
18
|
+
function connectPageContext(runner) {
|
|
19
|
+
const previous = pageContextRunner;
|
|
20
|
+
pageContextRunner = runner;
|
|
21
|
+
return previous;
|
|
22
|
+
}
|
|
23
|
+
function enterAdditionalSharedScope(store) {
|
|
24
|
+
additionalSharedScopeEntry?.(store);
|
|
25
|
+
}
|
|
26
|
+
function requireRunner() {
|
|
27
|
+
if (!pageContextRunner) throw new Error("executePageRequest() has no request context connected (web/src/server/page-context.ts). The pipeline opens the per-request AsyncLocalStorage frame with CORE's own context — it never owns one itself. Fix: the server bootstrap must call connectPageContext(requestContext) (and connectSharedStore(() => requestContext.getStore())) before any page request runs.");
|
|
28
|
+
return pageContextRunner;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { connectPageContext, enterAdditionalSharedScope, requireRunner };
|
|
33
|
+
//# sourceMappingURL=page-context.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-context.mjs","names":[],"sources":["../../../../../../../web/src/server/page-context.ts"],"sourcesContent":["import type { PageContextRunner, PipelineStore } from \"./execute-page-request.types\";\n\n/**\n * Boot-time wiring for the per-request context, once per process.\n *\n * The pipeline never owns an `AsyncLocalStorage` — it borrows core's. A VALUE\n * import of core from web risks resolving a second core instance, and two\n * copies of core means two stores, so `core` stays a type-only peer and the\n * code that already has `requestContext` in scope hands it over:\n *\n * ```ts\n * connectPageContext(requestContext);\n * connectSharedStore(() => requestContext.getStore());\n * ```\n */\n\nlet pageContextRunner: PageContextRunner | undefined;\nlet additionalSharedScopeEntry: ((store: PipelineStore) => void) | undefined;\n\n/** Returns the previous runner so tests can restore it. */\nexport function connectPageContext(\n runner: PageContextRunner | undefined,\n): PageContextRunner | undefined {\n const previous = pageContextRunner;\n\n pageContextRunner = runner;\n\n return previous;\n}\n\n/**\n * Connect an additional shared-module instance that must enter every request.\n * Production has one module graph and needs none; the dev server uses this seam\n * for the Vite SSR graph that evaluates app code.\n */\nexport function connectPageSharedScope(\n enter: ((store: PipelineStore) => void) | undefined,\n): ((store: PipelineStore) => void) | undefined {\n const previous = additionalSharedScopeEntry;\n\n additionalSharedScopeEntry = enter;\n\n return previous;\n}\n\nexport function enterAdditionalSharedScope(store: PipelineStore): void {\n additionalSharedScopeEntry?.(store);\n}\n\nexport function requireRunner(): PageContextRunner {\n if (!pageContextRunner) {\n throw new Error(\n \"executePageRequest() has no request context connected \" +\n \"(web/src/server/page-context.ts). The pipeline opens the per-request \" +\n \"AsyncLocalStorage frame with CORE's own context — it never owns one \" +\n \"itself. Fix: the server bootstrap must call \" +\n \"connectPageContext(requestContext) (and \" +\n \"connectSharedStore(() => requestContext.getStore())) before any \" +\n \"page request runs.\",\n );\n }\n\n return pageContextRunner;\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,IAAI;AACJ,IAAI;;AAGJ,SAAgB,mBACd,QAC+B;CAC/B,MAAM,WAAW;CAEjB,oBAAoB;CAEpB,OAAO;AACT;AAiBA,SAAgB,2BAA2B,OAA4B;CACrE,6BAA6B,KAAK;AACpC;AAEA,SAAgB,gBAAmC;CACjD,IAAI,CAAC,mBACH,MAAM,IAAI,MACR,uWAOF;CAGF,OAAO;AACT"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region ../web/src/server/page-manifest.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The build→runtime handoff table for production pages.
|
|
4
|
+
*
|
|
5
|
+
* The generated `<productionDir>/pages.ts` barrel — written by
|
|
6
|
+
* `web/src/build/generate-pages-barrel.ts` — statically imports every page,
|
|
7
|
+
* layout and the app root, then calls {@link providePageManifest} exactly
|
|
8
|
+
* once. The connector reads it back at boot with {@link consumePageManifest}.
|
|
9
|
+
*
|
|
10
|
+
* DEPENDENCY-FREE ON PURPOSE: this module is re-exported from web's root
|
|
11
|
+
* barrel (`web/src/index.ts`) so the generated barrel — which lives in the
|
|
12
|
+
* consuming app's production dir — can reach it by package specifier. Adding
|
|
13
|
+
* an import here would add that import to every app that loads
|
|
14
|
+
* `@warlock.js/web`.
|
|
15
|
+
*
|
|
16
|
+
* Entries carry MODULES + FILESYSTEM FACTS ONLY — never route paths. A page's
|
|
17
|
+
* route is read off its module (`route` export) at boot and composed with its
|
|
18
|
+
* layout `prefix`, exactly as `install-page-routes.ts:123-129,213-221` does in
|
|
19
|
+
* dev, so explicit-wins semantics live in one place for both modes.
|
|
20
|
+
*/
|
|
21
|
+
type PageManifestLayoutEntry = {
|
|
22
|
+
/** The imported layout module namespace object (default export = component, optional `prefix`). */module: Record<string, unknown>; /** App-root-relative POSIX source path, e.g. "src/app/main/web/layout.tsx". */
|
|
23
|
+
sourceFile: string;
|
|
24
|
+
};
|
|
25
|
+
type PageManifestPageEntry = {
|
|
26
|
+
/** The imported page module namespace object (default export = component, optional `route`). */module: Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* App-root-relative POSIX source path, e.g. "src/app/main/web/home.page.tsx"
|
|
29
|
+
* — feeds FS-derived route/name fallback. The anchor is the APP ROOT, not the
|
|
30
|
+
* repo root; the two differ the moment the app does not sit at the repo root.
|
|
31
|
+
* Consumers compare these ids VERBATIM — the barrel that wrote them is the
|
|
32
|
+
* authority on their form, and re-resolving or normalising one on the way in
|
|
33
|
+
* turns a lookup miss into a silent mis-match.
|
|
34
|
+
*/
|
|
35
|
+
sourceFile: string; /** Layout chain, outermost first. */
|
|
36
|
+
layouts: readonly PageManifestLayoutEntry[];
|
|
37
|
+
};
|
|
38
|
+
type PageManifest = {
|
|
39
|
+
/**
|
|
40
|
+
* Where the client bundle was written, as an APP-ROOT-RELATIVE POSIX path
|
|
41
|
+
* (`"dist/client"`). Resolved against the process cwd at boot, the same
|
|
42
|
+
* anchor `sourceFile` uses.
|
|
43
|
+
*
|
|
44
|
+
* BAKED AT BUILD TIME because the runtime has no other way to learn it. The
|
|
45
|
+
* directory is `<build.outdir>/client`, and `build.outdir` is configurable —
|
|
46
|
+
* but it lives in `warlock.config.ts`, a BUILD-TIME file that is never
|
|
47
|
+
* shipped and cannot be loaded by the plain `node dist/app.js` process
|
|
48
|
+
* `warlock start` spawns. Reading it at runtime through
|
|
49
|
+
* `resolveBuildConfig()` is what used to throw
|
|
50
|
+
* `WarlockConfig not loaded` during connector boot, taking the whole server
|
|
51
|
+
* down before it could listen.
|
|
52
|
+
*
|
|
53
|
+
* OPTIONAL for the same reason `app` is: a zero-page build skips the client
|
|
54
|
+
* bundle entirely, so there is no directory to name. Present whenever
|
|
55
|
+
* `pages` is non-empty.
|
|
56
|
+
*/
|
|
57
|
+
clientDir?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The application root component every page renders inside.
|
|
60
|
+
*
|
|
61
|
+
* OPTIONAL because it is only meaningful when pages exist: a build that
|
|
62
|
+
* discovered zero pages provides `{ pages: [] }` and has no root to wrap
|
|
63
|
+
* anything in. The build enforces the other direction — pages without an
|
|
64
|
+
* app root fail generation — so `app` absent means `pages` is empty. Callers
|
|
65
|
+
* must still branch on `pages.length` before reading `app`: the invariant is
|
|
66
|
+
* the generator's, and a consumer that assumes it without checking turns a
|
|
67
|
+
* malformed manifest into a crash deep in rendering instead of a named error.
|
|
68
|
+
*/
|
|
69
|
+
app?: {
|
|
70
|
+
module: Record<string, unknown>;
|
|
71
|
+
sourceFile: string;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The page table. EMPTY IS A REAL STATE, not a missing one: an empty array
|
|
75
|
+
* means "this bundle was built with web, and it has no pages", which is what
|
|
76
|
+
* separates it from {@link consumePageManifest} returning `undefined`.
|
|
77
|
+
*/
|
|
78
|
+
pages: readonly PageManifestPageEntry[];
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Called exactly once by the generated production/pages.ts barrel.
|
|
82
|
+
*
|
|
83
|
+
* A second call throws: two barrels in one process means either a stale
|
|
84
|
+
* generated file survived into the bundle or the build ran twice, and the
|
|
85
|
+
* second table silently replacing the first is precisely the drift this
|
|
86
|
+
* contract exists to make impossible.
|
|
87
|
+
*/
|
|
88
|
+
declare function providePageManifest(manifest: PageManifest): void;
|
|
89
|
+
/**
|
|
90
|
+
* Connector-side read (WebConnector.boot, prod mode).
|
|
91
|
+
*
|
|
92
|
+
* `undefined` = no barrel ran → the app was not built with web. A manifest
|
|
93
|
+
* whose `pages` is empty is the OTHER answer — built with web, no pages — and
|
|
94
|
+
* the two are deliberately distinguishable here rather than collapsed into one
|
|
95
|
+
* falsy result. The registry itself never throws on absence — absence is a
|
|
96
|
+
* fact, not an error; the connector branches on MODE (prod: boot error; dev:
|
|
97
|
+
* normal) so `undefined` never means two things at one call site.
|
|
98
|
+
*/
|
|
99
|
+
declare function consumePageManifest(): PageManifest | undefined;
|
|
100
|
+
//#endregion
|
|
101
|
+
export { PageManifest, PageManifestLayoutEntry, PageManifestPageEntry, consumePageManifest, providePageManifest };
|
|
102
|
+
//# sourceMappingURL=page-manifest.d.mts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region ../web/src/server/page-manifest.ts
|
|
2
|
+
let providedManifest;
|
|
3
|
+
/**
|
|
4
|
+
* Called exactly once by the generated production/pages.ts barrel.
|
|
5
|
+
*
|
|
6
|
+
* A second call throws: two barrels in one process means either a stale
|
|
7
|
+
* generated file survived into the bundle or the build ran twice, and the
|
|
8
|
+
* second table silently replacing the first is precisely the drift this
|
|
9
|
+
* contract exists to make impossible.
|
|
10
|
+
*/
|
|
11
|
+
function providePageManifest(manifest) {
|
|
12
|
+
if (providedManifest !== void 0) throw new Error("providePageManifest: a page manifest was already provided (web/src/server/page-manifest.ts). The generated production/pages.ts barrel must run exactly once per process.");
|
|
13
|
+
providedManifest = manifest;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Connector-side read (WebConnector.boot, prod mode).
|
|
17
|
+
*
|
|
18
|
+
* `undefined` = no barrel ran → the app was not built with web. A manifest
|
|
19
|
+
* whose `pages` is empty is the OTHER answer — built with web, no pages — and
|
|
20
|
+
* the two are deliberately distinguishable here rather than collapsed into one
|
|
21
|
+
* falsy result. The registry itself never throws on absence — absence is a
|
|
22
|
+
* fact, not an error; the connector branches on MODE (prod: boot error; dev:
|
|
23
|
+
* normal) so `undefined` never means two things at one call site.
|
|
24
|
+
*/
|
|
25
|
+
function consumePageManifest() {
|
|
26
|
+
return providedManifest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { consumePageManifest, providePageManifest };
|
|
31
|
+
//# sourceMappingURL=page-manifest.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page-manifest.mjs","names":[],"sources":["../../../../../../../web/src/server/page-manifest.ts"],"sourcesContent":["/**\n * The build→runtime handoff table for production pages.\n *\n * The generated `<productionDir>/pages.ts` barrel — written by\n * `web/src/build/generate-pages-barrel.ts` — statically imports every page,\n * layout and the app root, then calls {@link providePageManifest} exactly\n * once. The connector reads it back at boot with {@link consumePageManifest}.\n *\n * DEPENDENCY-FREE ON PURPOSE: this module is re-exported from web's root\n * barrel (`web/src/index.ts`) so the generated barrel — which lives in the\n * consuming app's production dir — can reach it by package specifier. Adding\n * an import here would add that import to every app that loads\n * `@warlock.js/web`.\n *\n * Entries carry MODULES + FILESYSTEM FACTS ONLY — never route paths. A page's\n * route is read off its module (`route` export) at boot and composed with its\n * layout `prefix`, exactly as `install-page-routes.ts:123-129,213-221` does in\n * dev, so explicit-wins semantics live in one place for both modes.\n */\n\nexport type PageManifestLayoutEntry = {\n /** The imported layout module namespace object (default export = component, optional `prefix`). */\n module: Record<string, unknown>;\n /** App-root-relative POSIX source path, e.g. \"src/app/main/web/layout.tsx\". */\n sourceFile: string;\n};\n\nexport type PageManifestPageEntry = {\n /** The imported page module namespace object (default export = component, optional `route`). */\n module: Record<string, unknown>;\n /**\n * App-root-relative POSIX source path, e.g. \"src/app/main/web/home.page.tsx\"\n * — feeds FS-derived route/name fallback. The anchor is the APP ROOT, not the\n * repo root; the two differ the moment the app does not sit at the repo root.\n * Consumers compare these ids VERBATIM — the barrel that wrote them is the\n * authority on their form, and re-resolving or normalising one on the way in\n * turns a lookup miss into a silent mis-match.\n */\n sourceFile: string;\n /** Layout chain, outermost first. */\n layouts: readonly PageManifestLayoutEntry[];\n};\n\nexport type PageManifest = {\n /**\n * Where the client bundle was written, as an APP-ROOT-RELATIVE POSIX path\n * (`\"dist/client\"`). Resolved against the process cwd at boot, the same\n * anchor `sourceFile` uses.\n *\n * BAKED AT BUILD TIME because the runtime has no other way to learn it. The\n * directory is `<build.outdir>/client`, and `build.outdir` is configurable —\n * but it lives in `warlock.config.ts`, a BUILD-TIME file that is never\n * shipped and cannot be loaded by the plain `node dist/app.js` process\n * `warlock start` spawns. Reading it at runtime through\n * `resolveBuildConfig()` is what used to throw\n * `WarlockConfig not loaded` during connector boot, taking the whole server\n * down before it could listen.\n *\n * OPTIONAL for the same reason `app` is: a zero-page build skips the client\n * bundle entirely, so there is no directory to name. Present whenever\n * `pages` is non-empty.\n */\n clientDir?: string;\n /**\n * The application root component every page renders inside.\n *\n * OPTIONAL because it is only meaningful when pages exist: a build that\n * discovered zero pages provides `{ pages: [] }` and has no root to wrap\n * anything in. The build enforces the other direction — pages without an\n * app root fail generation — so `app` absent means `pages` is empty. Callers\n * must still branch on `pages.length` before reading `app`: the invariant is\n * the generator's, and a consumer that assumes it without checking turns a\n * malformed manifest into a crash deep in rendering instead of a named error.\n */\n app?: { module: Record<string, unknown>; sourceFile: string };\n /**\n * The page table. EMPTY IS A REAL STATE, not a missing one: an empty array\n * means \"this bundle was built with web, and it has no pages\", which is what\n * separates it from {@link consumePageManifest} returning `undefined`.\n */\n pages: readonly PageManifestPageEntry[];\n};\n\nlet providedManifest: PageManifest | undefined;\n\n/**\n * Called exactly once by the generated production/pages.ts barrel.\n *\n * A second call throws: two barrels in one process means either a stale\n * generated file survived into the bundle or the build ran twice, and the\n * second table silently replacing the first is precisely the drift this\n * contract exists to make impossible.\n */\nexport function providePageManifest(manifest: PageManifest): void {\n if (providedManifest !== undefined) {\n throw new Error(\n \"providePageManifest: a page manifest was already provided \" +\n \"(web/src/server/page-manifest.ts). The generated production/pages.ts barrel \" +\n \"must run exactly once per process.\",\n );\n }\n\n providedManifest = manifest;\n}\n\n/**\n * Connector-side read (WebConnector.boot, prod mode).\n *\n * `undefined` = no barrel ran → the app was not built with web. A manifest\n * whose `pages` is empty is the OTHER answer — built with web, no pages — and\n * the two are deliberately distinguishable here rather than collapsed into one\n * falsy result. The registry itself never throws on absence — absence is a\n * fact, not an error; the connector branches on MODE (prod: boot error; dev:\n * normal) so `undefined` never means two things at one call site.\n */\nexport function consumePageManifest(): PageManifest | undefined {\n return providedManifest;\n}\n"],"mappings":";AAmFA,IAAI;;;;;;;;;AAUJ,SAAgB,oBAAoB,UAA8B;CAChE,IAAI,qBAAqB,QACvB,MAAM,IAAI,MACR,0KAGF;CAGF,mBAAmB;AACrB;;;;;;;;;;;AAYA,SAAgB,sBAAgD;CAC9D,OAAO;AACT"}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { DocumentContext, PAYLOAD_SCRIPT_ID, escapePayload } from "../components/document-context.mjs";
|
|
2
|
+
import DefaultApp from "../components/default-app.mjs";
|
|
3
|
+
import { buildHydrationPayload } from "./build-hydration-payload.mjs";
|
|
4
|
+
import { buildErrorRecord, designateBoundary } from "./settle-page-response.mjs";
|
|
5
|
+
import { executePageRequest } from "./execute-page-request.mjs";
|
|
6
|
+
import { createElement } from "react";
|
|
7
|
+
|
|
8
|
+
//#region ../web/src/server/render-page.ts
|
|
9
|
+
let pageRoutesRegistry;
|
|
10
|
+
function requireRegistry(options) {
|
|
11
|
+
const routes = options.routes ?? pageRoutesRegistry?.routes;
|
|
12
|
+
const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;
|
|
13
|
+
if (!routes || !createHttp) throw new Error("renderPage()/renderPageRequest() has no route registry connected (web/src/server/render-page.ts). Both resolve against the page manifest, which the server bootstrap owns. Fix: call connectPageRoutes({ routes, createHttp }) at boot (tests: in beforeAll), or pass { routes, createHttp } to this call.");
|
|
14
|
+
return {
|
|
15
|
+
routes,
|
|
16
|
+
createHttp
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The framework-owned terminal boundary (P1 §4: designation falls back to
|
|
21
|
+
* `app` even when no level exports one — "the framework owns a root
|
|
22
|
+
* boundary"). Deliberately generic: the error itself is server knowledge and
|
|
23
|
+
* never serialized into the document.
|
|
24
|
+
*/
|
|
25
|
+
function FrameworkRootBoundary() {
|
|
26
|
+
return createElement("main", { role: "alert" }, "Something went wrong.");
|
|
27
|
+
}
|
|
28
|
+
const DATA_KEYS = {
|
|
29
|
+
app: "appData",
|
|
30
|
+
layout: "layoutData",
|
|
31
|
+
page: "pageData"
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each
|
|
35
|
+
* level receiving ITS OWN loader data and the same sealed `shared` — the
|
|
36
|
+
* exact props the M1 contract declares (web/src/props.ts) and never
|
|
37
|
+
* request/response (the component also renders on a machine where neither
|
|
38
|
+
* exists, props.ts:19-22).
|
|
39
|
+
*
|
|
40
|
+
* A level with no default export contributes no DOM and passes children
|
|
41
|
+
* through — that is `layout.tsx` omitting its default export to be a guard
|
|
42
|
+
* with no DOM.
|
|
43
|
+
*/
|
|
44
|
+
function buildPageElement(triple, bundle) {
|
|
45
|
+
return wrapRootward(triple, bundle, "page", buildLeaf(triple.page, bundle, "page"));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The error path renders the DESIGNATED boundary in place of the level it
|
|
49
|
+
* covers, still wrapped by every level rootward of it — a page-level throw
|
|
50
|
+
* keeps its App and Layout chrome, whose data survived the settle rules
|
|
51
|
+
* (P1 §4: fulfilled sibling data stays in the bundle).
|
|
52
|
+
*
|
|
53
|
+
* `record` is explicit rather than read from `bundle.error` — a render-time
|
|
54
|
+
* throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.
|
|
55
|
+
*/
|
|
56
|
+
function buildBoundaryElement(triple, bundle, record) {
|
|
57
|
+
const { boundary, error } = record;
|
|
58
|
+
const Boundary = triple[boundary.boundaryLevel].ErrorBoundary;
|
|
59
|
+
const element = Boundary ? createElement(Boundary, { error }) : createElement(FrameworkRootBoundary, {});
|
|
60
|
+
const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);
|
|
61
|
+
return boundary.boundaryLevel === "app" ? createElement(DefaultApp, { children: wrapped }) : wrapped;
|
|
62
|
+
}
|
|
63
|
+
function buildLeaf(module, bundle, level) {
|
|
64
|
+
const Component = module.default;
|
|
65
|
+
if (!Component) return null;
|
|
66
|
+
return createElement(Component, {
|
|
67
|
+
data: bundle[DATA_KEYS[level]],
|
|
68
|
+
shared: bundle.shared
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function wrapRootward(triple, bundle, from, leaf) {
|
|
72
|
+
const wrappers = from === "page" ? ["layout", "app"] : from === "layout" ? ["app"] : [];
|
|
73
|
+
let element = leaf;
|
|
74
|
+
for (const level of wrappers) {
|
|
75
|
+
const Component = triple[level].default;
|
|
76
|
+
if (!Component) {
|
|
77
|
+
if (level === "app") element = createElement(DefaultApp, { children: element });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
element = createElement(Component, {
|
|
81
|
+
data: bundle[DATA_KEYS[level]],
|
|
82
|
+
shared: bundle.shared,
|
|
83
|
+
children: element
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return element;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The root (App or the framework default) now ALWAYS renders a complete
|
|
90
|
+
* `<html>…</html>` document itself — `<Head/>`/
|
|
91
|
+
* `<Scripts/>` read the metadata/payload from `DocumentContext` (provided
|
|
92
|
+
* around the element in `finishRender`, below) and emit real elements.
|
|
93
|
+
* There is nothing left for this stage to assemble by string surgery; it
|
|
94
|
+
* only prepends the doctype `renderToString` never includes.
|
|
95
|
+
*/
|
|
96
|
+
function emitDocument(body) {
|
|
97
|
+
return "<!DOCTYPE html>" + body;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Wrap the caller's createHttp to capture the real pair (for the document
|
|
101
|
+
* slots, `documentSlotsFrom` below), the matched entry (the only place a
|
|
102
|
+
* URL-based caller learns which triple to render), and to apply `as` —
|
|
103
|
+
* `user` is a plain public property on core's Request
|
|
104
|
+
* (core/src/http/request.ts:92), exactly the write auth middleware performs.
|
|
105
|
+
*/
|
|
106
|
+
function capturingCreateHttp(registry, as) {
|
|
107
|
+
const state = {};
|
|
108
|
+
return {
|
|
109
|
+
state,
|
|
110
|
+
createHttp(match) {
|
|
111
|
+
state.match = match;
|
|
112
|
+
state.captured = registry.createHttp(match);
|
|
113
|
+
if (as !== void 0) state.captured.request.user = as;
|
|
114
|
+
return state.captured;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Structural read of the two core `Request` fields the slots need. Neither
|
|
120
|
+
* `nonce` nor `locale` is declared on `PipelineRequest`/`WebRequest` (the
|
|
121
|
+
* loader-facing facade only declares `validated`/`input`/`user`,
|
|
122
|
+
* context.ts:30-68) — a narrow typed intersection at the one call site that
|
|
123
|
+
* needs it, the same pattern `execute-page-request.ts` uses for its own
|
|
124
|
+
* `(response as PipelineResponse & { statusCode?: number })` read
|
|
125
|
+
* (execute-page-request.ts:517).
|
|
126
|
+
*/
|
|
127
|
+
function documentSlotsFrom(captured) {
|
|
128
|
+
const request = captured?.request;
|
|
129
|
+
return {
|
|
130
|
+
nonce: request?.nonce,
|
|
131
|
+
lang: request?.locale
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function finishRender(triple, bundle, documentSlots) {
|
|
135
|
+
const headers = {};
|
|
136
|
+
for (const header of bundle.commit?.headers ?? []) headers[header.key.toLowerCase()] = header.value;
|
|
137
|
+
const cookies = bundle.commit?.cookies ?? [];
|
|
138
|
+
if (bundle.shortCircuit) return {
|
|
139
|
+
html: "",
|
|
140
|
+
status: bundle.shortCircuit.stage === "validation" ? bundle.shortCircuit.status : bundle.shortCircuit.stage === "loaders" ? bundle.shortCircuit.statusCode : bundle.shortCircuit.statusCode ?? 200,
|
|
141
|
+
headers,
|
|
142
|
+
cookies,
|
|
143
|
+
data: bundle.pageData,
|
|
144
|
+
bundle
|
|
145
|
+
};
|
|
146
|
+
if (headers["cache-control"] === void 0) headers["cache-control"] = "private";
|
|
147
|
+
const { renderToString } = await import("react-dom/server");
|
|
148
|
+
const documentValue = {
|
|
149
|
+
metadata: bundle.metadata,
|
|
150
|
+
payload: buildHydrationPayload(bundle),
|
|
151
|
+
nonce: documentSlots.nonce,
|
|
152
|
+
lang: documentSlots.lang
|
|
153
|
+
};
|
|
154
|
+
const renderWithContext = (element) => renderToString(createElement(DocumentContext.Provider, {
|
|
155
|
+
value: documentValue,
|
|
156
|
+
children: element
|
|
157
|
+
}));
|
|
158
|
+
let currentError = bundle.error;
|
|
159
|
+
let renderTimeThrow = false;
|
|
160
|
+
let body;
|
|
161
|
+
for (;;) try {
|
|
162
|
+
body = renderWithContext(currentError ? buildBoundaryElement(triple, bundle, currentError) : buildPageElement(triple, bundle));
|
|
163
|
+
break;
|
|
164
|
+
} catch (thrown) {
|
|
165
|
+
renderTimeThrow = true;
|
|
166
|
+
if (currentError?.boundary.boundaryLevel === "app") {
|
|
167
|
+
body = renderWithContext(createElement(DefaultApp, { children: createElement(FrameworkRootBoundary, {}) }));
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
currentError = buildErrorRecord(thrown, designateBoundary(currentError?.boundary.boundaryLevel === "layout" ? "app" : currentError ? "layout" : "page", triple));
|
|
171
|
+
}
|
|
172
|
+
const status = renderTimeThrow ? currentError.boundary.boundaryLevel === "app" ? 500 : bundle.commit?.statusCode ?? 200 : bundle.error ? bundle.commit?.statusCode ?? 500 : bundle.commit?.statusCode ?? 200;
|
|
173
|
+
return {
|
|
174
|
+
html: emitDocument(body),
|
|
175
|
+
status,
|
|
176
|
+
headers,
|
|
177
|
+
cookies,
|
|
178
|
+
data: bundle.pageData,
|
|
179
|
+
bundle
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The URL-based sibling of `renderPage` — the production render surface: a
|
|
184
|
+
* real HTTP server has a URL, not a route name. The url goes STRAIGHT to
|
|
185
|
+
* executePageRequest's stage-1 matcher (no buildUrl), then the same shared
|
|
186
|
+
* tail renders and emits.
|
|
187
|
+
*
|
|
188
|
+
* No-match here is NOT the manifest bug renderPage throws on: an arbitrary
|
|
189
|
+
* URL matching no route is a legitimate 404, and a server must ANSWER it —
|
|
190
|
+
* `{ html: "", status: 404 }` with an undefined `bundle` (see RenderedPage).
|
|
191
|
+
*/
|
|
192
|
+
async function renderPageRequest(url, options = {}) {
|
|
193
|
+
const registry = requireRegistry(options);
|
|
194
|
+
const { state, createHttp } = capturingCreateHttp(registry, options.as);
|
|
195
|
+
const rendered = await executePageRequest({
|
|
196
|
+
url,
|
|
197
|
+
routes: registry.routes,
|
|
198
|
+
createHttp,
|
|
199
|
+
finish: (bundle) => finishRender(state.match.entry.triple, bundle, documentSlotsFrom(state.captured))
|
|
200
|
+
});
|
|
201
|
+
if (!rendered) return {
|
|
202
|
+
html: "",
|
|
203
|
+
status: 404,
|
|
204
|
+
headers: {},
|
|
205
|
+
cookies: [],
|
|
206
|
+
data: void 0,
|
|
207
|
+
bundle: void 0
|
|
208
|
+
};
|
|
209
|
+
return rendered;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//#endregion
|
|
213
|
+
export { renderPageRequest };
|
|
214
|
+
//# sourceMappingURL=render-page.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-page.mjs","names":[],"sources":["../../../../../../../web/src/server/render-page.ts"],"sourcesContent":["import { createElement, type ComponentType, type ReactNode } from \"react\";\nimport DefaultApp from \"../components/default-app\";\nimport {\n DocumentContext,\n escapePayload,\n PAYLOAD_SCRIPT_ID,\n type DocumentContextValue,\n} from \"../components/document-context\";\nimport type { SharedContext } from \"../index\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport type { BufferedCookie } from \"./buffered-response\";\nimport {\n buildErrorRecord,\n designateBoundary,\n executePageRequest,\n type ExecutePageRequestOptions,\n type PageDataBundle,\n type PageErrorRecord,\n type PageLevelName,\n type PageRouteEntry,\n type PageRouteMatch,\n type PageTripleModule,\n type PipelineRequest,\n type PipelineResponse,\n} from \"./execute-page-request\";\n\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\n\n/**\n * Pipeline stages 9–10: RENDER the page tree from the\n * data bundle stages 1–8 produced, then return finalized { html, status,\n * headers, cookies }. Stage 10 happens at the CALL SITE in two halves —\n * 10a the caller applies status + headers (the single live-response write,\n * after render, before anything flushes), 10b it flushes\n * the document. Nothing in this module writes the live response. It never\n * re-runs any earlier stage — `renderPage` calls `executePageRequest` and\n * everything here consumes its bundle as-is.\n *\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\n * the production orchestrator AND the test helper. Because a loader IS a\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\n * returns `{ html, status, headers, data }` in one call — asserting a page's\n * data and its response headers is a unit test, no browser, no server boot.\n */\n\n// ---------------------------------------------------------------------------\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\n// ---------------------------------------------------------------------------\n\nexport type PageRoutesRegistry = {\n routes: readonly PageRouteEntry[];\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n};\n\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\n\n/**\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\n * without each call site carrying the manifest. Returns the previous registry\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\n */\nexport function connectPageRoutes(\n registry: PageRoutesRegistry | undefined,\n): PageRoutesRegistry | undefined {\n const previous = pageRoutesRegistry;\n pageRoutesRegistry = registry;\n return previous;\n}\n\n// ---------------------------------------------------------------------------\n// renderPage surface\n// ---------------------------------------------------------------------------\n\nexport type RenderPageOptions = {\n params?: Record<string, string>;\n query?: Record<string, string>;\n /**\n * Impersonation for tests: assigned to `request.user` right after the\n * request pair is constructed — `user` is a plain public property on core's\n * Request (core/src/http/request.ts:92) and this is exactly the write auth\n * middleware would have performed.\n */\n as?: unknown;\n /** Per-call overrides of the connected registry (tests, mostly). */\n routes?: readonly PageRouteEntry[];\n createHttp?: ExecutePageRequestOptions[\"createHttp\"];\n};\n\n/**\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\n * name-based sugar buildUrl consumes) have no meaning here — everything else\n * is the same seam.\n */\nexport type RenderPageRequestOptions = Omit<RenderPageOptions, \"params\" | \"query\">;\n\nexport type RenderedPage = {\n /** The full document (\"\" when the pipeline short-circuited before render). */\n html: string;\n status: number;\n /** Committed response headers, lowercased key → value. */\n headers: Record<string, string>;\n /**\n * Committed cookies in commit order, attribute-faithful: each entry carries\n * the loader's raw value (pre-serialization) AND its options\n * (`httpOnly`/`secure`/`sameSite`/`path`/`expires`/…). Never flattened to a\n * name→value map — a map cannot express the attributes, and a Set-Cookie\n * built without them is a security defect, not a convenience.\n */\n cookies: BufferedCookie[];\n /**\n * The PAGE loader's data — `data.product.name` reads as the dx story\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\n */\n data: unknown;\n /**\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\n * `renderPage` always carries one (its no-match throws instead).\n */\n bundle: PageDataBundle | undefined;\n};\n\nfunction requireRegistry(\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\n): PageRoutesRegistry {\n const routes = options.routes ?? pageRoutesRegistry?.routes;\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\n\n if (!routes || !createHttp) {\n throw new Error(\n \"renderPage()/renderPageRequest() has no route registry connected \" +\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\n \"manifest, which the server bootstrap owns. Fix: \" +\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\n \"beforeAll), or pass { routes, createHttp } to this call.\",\n );\n }\n\n return { routes, createHttp };\n}\n\nfunction buildUrl(\n entry: PageRouteEntry,\n params: Record<string, string>,\n query: Record<string, string>,\n): string {\n const path = entry.path\n .split(\"/\")\n .map(segment => {\n if (!segment.startsWith(\":\")) return segment;\n\n const name = segment.slice(1);\n const value = params[name];\n\n if (value === undefined) {\n throw new Error(\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\n `param \"${name}\" and the call did not provide it ` +\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\n `\\`params: { ${name}: … }\\`.`,\n );\n }\n\n return encodeURIComponent(value);\n })\n .join(\"/\");\n\n const queryString = new URLSearchParams(query).toString();\n\n return queryString ? `${path}?${queryString}` : path;\n}\n\n// ---------------------------------------------------------------------------\n// Stage 9 — RENDER\n// ---------------------------------------------------------------------------\n\n/**\n * The framework-owned terminal boundary (P1 §4: designation falls back to\n * `app` even when no level exports one — \"the framework owns a root\n * boundary\"). Deliberately generic: the error itself is server knowledge and\n * never serialized into the document.\n */\nfunction FrameworkRootBoundary(): ReactNode {\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\n}\n\ntype LevelProps = {\n data: unknown;\n shared: Readonly<SharedContext> | undefined;\n children?: ReactNode;\n};\n\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n};\n\n/**\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\n * level receiving ITS OWN loader data and the same sealed `shared` — the\n * exact props the M1 contract declares (web/src/props.ts) and never\n * request/response (the component also renders on a machine where neither\n * exists, props.ts:19-22).\n *\n * A level with no default export contributes no DOM and passes children\n * through — that is `layout.tsx` omitting its default export to be a guard\n * with no DOM.\n */\nfunction buildPageElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n): ReactNode {\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle, \"page\"));\n}\n\n/**\n * The error path renders the DESIGNATED boundary in place of the level it\n * covers, still wrapped by every level rootward of it — a page-level throw\n * keeps its App and Layout chrome, whose data survived the settle rules\n * (P1 §4: fulfilled sibling data stays in the bundle).\n *\n * `record` is explicit rather than read from `bundle.error` — a render-time\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\n */\nfunction buildBoundaryElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n record: PageErrorRecord,\n): ReactNode {\n const { boundary, error } = record;\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\n | ((props: { error: unknown }) => ReactNode)\n | undefined;\n\n const element = Boundary\n ? createElement(Boundary, { error })\n : createElement(FrameworkRootBoundary, {});\n\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\n\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\n // unwrapped when the boundary covers the app level itself — but the\n // pipeline always emits a complete document, so the\n // framework default supplies the shell here even though the app's own\n // (broken) root is what's being bypassed.\n return boundary.boundaryLevel === \"app\"\n ? createElement(DefaultApp, { children: wrapped })\n : wrapped;\n}\n\nfunction buildLeaf(\n module: PageTripleModule,\n bundle: PageDataBundle,\n level: PageLevelName,\n): ReactNode {\n const Component = module.default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) return null;\n\n return createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n });\n}\n\nfunction wrapRootward(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n from: PageLevelName,\n leaf: ReactNode,\n): ReactNode {\n const wrappers: PageLevelName[] = from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\n\n let element = leaf;\n\n for (const level of wrappers) {\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) {\n // \"App\" is the root: no App export means no custom document, but the\n // pipeline always emits a complete one — the\n // framework default App supplies it. Layout has no such fallback: an\n // omitted layout default export stays a no-DOM passthrough,\n // unchanged from before.\n if (level === \"app\") {\n element = createElement(DefaultApp, { children: element });\n }\n\n continue;\n }\n\n element = createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n children: element,\n });\n }\n\n return element;\n}\n\n// ---------------------------------------------------------------------------\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\n// ---------------------------------------------------------------------------\n\n/**\n * The root (App or the framework default) now ALWAYS renders a complete\n * `<html>…</html>` document itself — `<Head/>`/\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\n * around the element in `finishRender`, below) and emit real elements.\n * There is nothing left for this stage to assemble by string surgery; it\n * only prepends the doctype `renderToString` never includes.\n */\nfunction emitDocument(body: string): string {\n return \"<!DOCTYPE html>\" + body;\n}\n\n// ---------------------------------------------------------------------------\n// The shared tail (stages 9–10) — both orchestrators end here\n// ---------------------------------------------------------------------------\n\n/**\n * The real request/response pair `capturingCreateHttp` captured for this\n * call. `finishRender` itself no longer takes this (D1: the short-circuit\n * status now lives on `bundle.shortCircuit`/`bundle.commit`) — it is used at\n * the two orchestrator call sites for the `as` impersonation write\n * (`state.captured.request.user = as`, below) and to read the document\n * slots (`documentSlotsFrom`, below).\n */\ntype CapturedHttp = {\n request: PipelineRequest;\n response: PipelineResponse;\n};\n\n/**\n * Wrap the caller's createHttp to capture the real pair (for the document\n * slots, `documentSlotsFrom` below), the matched entry (the only place a\n * URL-based caller learns which triple to render), and to apply `as` —\n * `user` is a plain public property on core's Request\n * (core/src/http/request.ts:92), exactly the write auth middleware performs.\n */\nfunction capturingCreateHttp(\n registry: PageRoutesRegistry,\n as: unknown,\n): {\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n} {\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\n\n return {\n state,\n createHttp(match) {\n state.match = match;\n state.captured = registry.createHttp(match);\n\n if (as !== undefined) state.captured.request.user = as;\n\n return state.captured;\n },\n };\n}\n\n/**\n * The two request-derived document slots (`nonce`/`lang` on\n * `DocumentContextValue`), extracted at the orchestrator call sites\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\n * here: core's Request has no dir-like field (checked\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\n * exist) — an app supplies `dir` via its own convention.\n */\ntype DocumentSlots = {\n nonce?: string;\n lang?: string;\n};\n\n/**\n * Structural read of the two core `Request` fields the slots need. Neither\n * `nonce` nor `locale` is declared on `PipelineRequest`/`WebRequest` (the\n * loader-facing facade only declares `validated`/`input`/`user`,\n * context.ts:30-68) — a narrow typed intersection at the one call site that\n * needs it, the same pattern `execute-page-request.ts` uses for its own\n * `(response as PipelineResponse & { statusCode?: number })` read\n * (execute-page-request.ts:517).\n */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n const request = captured?.request as (PipelineRequest & { nonce?: string; locale?: string }) | undefined;\n\n return { nonce: request?.nonce, lang: request?.locale };\n}\n\nasync function finishRender(\n triple: PageRouteEntry[\"triple\"],\n bundle: PageDataBundle,\n documentSlots: DocumentSlots,\n): Promise<RenderedPage> {\n const headers: Record<string, string> = {};\n\n for (const header of bundle.commit?.headers ?? []) {\n headers[header.key.toLowerCase()] = header.value;\n }\n\n // The commit already deduplicated per name and ordered root→leaf\n // (execute-page-request.ts settle/commit) — pass it through untransformed so\n // every attribute survives to the caller's Set-Cookie.\n const cookies: BufferedCookie[] = bundle.commit?.cookies ?? [];\n\n // Short-circuit paths emit no document: the status IS the answer\n // (redirect/notFound/guard/422 — P1 §4), nothing renders to describe.\n if (bundle.shortCircuit) {\n const status =\n bundle.shortCircuit.stage === \"validation\"\n ? bundle.shortCircuit.status\n : bundle.shortCircuit.stage === \"loaders\"\n ? bundle.shortCircuit.statusCode\n // D1: the middleware variant now carries its own statusCode,\n // captured at stage 3 where the pipeline legitimately touches the\n // live response (execute-page-request.ts:514-518) — no live read here.\n : (bundle.shortCircuit.statusCode ?? 200);\n\n return { html: \"\", status, headers, cookies, data: bundle.pageData, bundle };\n }\n\n // The framework's closed-by-default answer (README rule 8): every document\n // is `Cache-Control: private` unless a loader's committed headers already\n // answered for the key. Map-only — `finishRender` never writes the live\n // response; the caller applies the returned headers.\n if (headers[\"cache-control\"] === undefined) {\n headers[\"cache-control\"] = \"private\";\n }\n\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\n // Lazy import: react-dom is a peer used only on this path, so merely\n // loading the server barrel never requires it.\n const { renderToString } = await import(\"react-dom/server\");\n\n // JSON.stringify omits object properties whose value is undefined. Loader\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\n // both already final by this point (stages 1-8 are done), so there is\n // nothing left for the root to await.\n //\n // The payload comes from `buildHydrationPayload` rather than being assembled\n // here, so that this document and the `_loader` route hand the browser the\n // SAME object. See that module for why the two must not drift.\n const documentValue: DocumentContextValue = {\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n nonce: documentSlots.nonce,\n lang: documentSlots.lang,\n };\n\n const renderWithContext = (element: ReactNode): string =>\n renderToString(createElement(DocumentContext.Provider, { value: documentValue, children: element }));\n\n // A boundary that throws while rendering escalates to\n // the next enclosing boundary rootward; if none survives, the framework's\n // last-resort terminal renders. `currentError` starts as whatever stage\n // 1-8 already designated (`bundle.error`, undefined for a normal page\n // render) and is replaced by each escalation — `bundle.error` itself is\n // never mutated, staying a truthful stage 1-8 record.\n let currentError = bundle.error;\n let renderTimeThrow = false;\n let body: string;\n\n for (;;) {\n try {\n const element = currentError\n ? buildBoundaryElement(triple, bundle, currentError)\n : buildPageElement(triple, bundle);\n\n body = renderWithContext(element);\n break;\n } catch (thrown) {\n renderTimeThrow = true;\n\n if (currentError?.boundary.boundaryLevel === \"app\") {\n // The floor: the app-level boundary's own render just threw, so\n // there is nothing rootward of `app` to escalate to (§2's \"none\n // survives\"). Render the framework's trivial boundary directly —\n // bypassing the app's ErrorBoundary/App component, since that is\n // what just failed — wrapped in DefaultApp so the response is still\n // a complete `<html>` document (default-app.tsx:22-46) rather than\n // a bare `<main>` fragment.\n body = renderWithContext(createElement(DefaultApp, { children: createElement(FrameworkRootBoundary, {}) }));\n break;\n }\n\n // Escalate from the level rootward of whatever just threw — searching\n // from the SAME level would re-select the boundary that just failed.\n // A throw not yet attributable to a level (a normal page render, no\n // prior designation) starts the search at `page`.\n const throwingLevel: PageLevelName =\n currentError?.boundary.boundaryLevel === \"layout\" ? \"app\" : currentError ? \"layout\" : \"page\";\n\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\n }\n }\n\n // Status is chosen after render — the last thing that can change the\n // outcome — and RETURNED, never applied: `finishRender` writes the live\n // response zero times (and now zero live response READS either — D1 deleted the last one). The caller applies\n // status + headers at one site and flushes immediately after (stage\n // 10a/10b). A render-time throw follows the same status rule as a\n // pre-render one: a NESTED boundary catch\n // (page/layout) keeps the committed status; the app boundary or the\n // framework terminal forces 500. Without a render-time throw, the\n // pre-render designation already carries this rule — P1's commit forces\n // 500 only when it designated `app` (execute-page-request.ts:625-637).\n const status = renderTimeThrow\n ? currentError!.boundary.boundaryLevel === \"app\"\n ? 500\n : (bundle.commit?.statusCode ?? 200)\n : bundle.error\n ? (bundle.commit?.statusCode ?? 500)\n : (bundle.commit?.statusCode ?? 200);\n\n const html = emitDocument(body);\n\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\n}\n\n// ---------------------------------------------------------------------------\n// The orchestrators\n// ---------------------------------------------------------------------------\n\nexport async function renderPage(\n routeName: string,\n options: RenderPageOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const entry = registry.routes.find(candidate => candidate.name === routeName);\n\n if (!entry) {\n const known = registry.routes.map(candidate => `\"${candidate.name}\"`).join(\", \");\n\n throw new Error(\n `renderPage(\"${routeName}\"): no route with that name ` +\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\n \"Fix: use a name from the manifest, or connect the manifest that \" +\n \"declares this one.\",\n );\n }\n\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n throw new Error(\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\n );\n }\n\n return rendered;\n}\n\n/**\n * The URL-based sibling of `renderPage` — the production render surface: a\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\n * tail renders and emits.\n *\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\n * `{ html: \"\", status: 404 }` with an undefined `bundle` (see RenderedPage).\n */\nexport async function renderPageRequest(\n url: string,\n options: RenderPageRequestOptions = {},\n): Promise<RenderedPage> {\n const registry = requireRegistry(options);\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: bundle => finishRender(state.match!.entry.triple, bundle, documentSlotsFrom(state.captured)),\n });\n\n if (!rendered) {\n return { html: \"\", status: 404, headers: {}, cookies: [], data: undefined, bundle: undefined };\n }\n\n // executePageRequest only produces a bundle after createHttp ran for the\n // match, so the captured entry is present whenever the bundle is.\n return rendered;\n}\n"],"mappings":";;;;;;;;AAuDA,IAAI;AAqEJ,SAAS,gBACP,SACoB;CACpB,MAAM,SAAS,QAAQ,UAAU,oBAAoB;CACrD,MAAM,aAAa,QAAQ,cAAc,oBAAoB;CAE7D,IAAI,CAAC,UAAU,CAAC,YACd,MAAM,IAAI,MACR,4SAKF;CAGF,OAAO;EAAE;EAAQ;CAAW;AAC9B;;;;;;;AA2CA,SAAS,wBAAmC;CAC1C,OAAO,cAAc,QAAQ,EAAE,MAAM,QAAQ,GAAG,uBAAuB;AACzE;AAQA,MAAM,YAA0E;CAC9E,KAAK;CACL,QAAQ;CACR,MAAM;AACR;;;;;;;;;;;;AAaA,SAAS,iBACP,QACA,QACW;CACX,OAAO,aAAa,QAAQ,QAAQ,QAAQ,UAAU,OAAO,MAAM,QAAQ,MAAM,CAAC;AACpF;;;;;;;;;;AAWA,SAAS,qBACP,QACA,QACA,QACW;CACX,MAAM,EAAE,UAAU,UAAU;CAC5B,MAAM,WAAW,OAAO,SAAS,cAAc,CAAC;CAIhD,MAAM,UAAU,WACZ,cAAc,UAAU,EAAE,MAAM,CAAC,IACjC,cAAc,uBAAuB,CAAC,CAAC;CAE3C,MAAM,UAAU,aAAa,QAAQ,QAAQ,SAAS,eAAe,OAAO;CAO5E,OAAO,SAAS,kBAAkB,QAC9B,cAAc,YAAY,EAAE,UAAU,QAAQ,CAAC,IAC/C;AACN;AAEA,SAAS,UACP,QACA,QACA,OACW;CACX,MAAM,YAAY,OAAO;CAEzB,IAAI,CAAC,WAAW,OAAO;CAEvB,OAAO,cAAc,WAAwC;EAC3D,MAAM,OAAO,UAAU;EACvB,QAAQ,OAAO;CACjB,CAAC;AACH;AAEA,SAAS,aACP,QACA,QACA,MACA,MACW;CACX,MAAM,WAA4B,SAAS,SAAS,CAAC,UAAU,KAAK,IAAI,SAAS,WAAW,CAAC,KAAK,IAAI,CAAC;CAEvG,IAAI,UAAU;CAEd,KAAK,MAAM,SAAS,UAAU;EAC5B,MAAM,YAAY,OAAO,MAAM,CAAC;EAEhC,IAAI,CAAC,WAAW;GAMd,IAAI,UAAU,OACZ,UAAU,cAAc,YAAY,EAAE,UAAU,QAAQ,CAAC;GAG3D;EACF;EAEA,UAAU,cAAc,WAAwC;GAC9D,MAAM,OAAO,UAAU;GACvB,QAAQ,OAAO;GACf,UAAU;EACZ,CAAC;CACH;CAEA,OAAO;AACT;;;;;;;;;AAcA,SAAS,aAAa,MAAsB;CAC1C,OAAO,oBAAoB;AAC7B;;;;;;;;AA0BA,SAAS,oBACP,UACA,IAIA;CACA,MAAM,QAA6D,CAAC;CAEpE,OAAO;EACL;EACA,WAAW,OAAO;GAChB,MAAM,QAAQ;GACd,MAAM,WAAW,SAAS,WAAW,KAAK;GAE1C,IAAI,OAAO,QAAW,MAAM,SAAS,QAAQ,OAAO;GAEpD,OAAO,MAAM;EACf;CACF;AACF;;;;;;;;;;AAwBA,SAAS,kBAAkB,UAAmD;CAC5E,MAAM,UAAU,UAAU;CAE1B,OAAO;EAAE,OAAO,SAAS;EAAO,MAAM,SAAS;CAAO;AACxD;AAEA,eAAe,aACb,QACA,QACA,eACuB;CACvB,MAAM,UAAkC,CAAC;CAEzC,KAAK,MAAM,UAAU,OAAO,QAAQ,WAAW,CAAC,GAC9C,QAAQ,OAAO,IAAI,YAAY,KAAK,OAAO;CAM7C,MAAM,UAA4B,OAAO,QAAQ,WAAW,CAAC;CAI7D,IAAI,OAAO,cAWT,OAAO;EAAE,MAAM;EAAI,QATjB,OAAO,aAAa,UAAU,eAC1B,OAAO,aAAa,SACpB,OAAO,aAAa,UAAU,YAC5B,OAAO,aAAa,aAInB,OAAO,aAAa,cAAc;EAEhB;EAAS;EAAS,MAAM,OAAO;EAAU;CAAO;CAO7E,IAAI,QAAQ,qBAAqB,QAC/B,QAAQ,mBAAmB;CAM7B,MAAM,EAAE,mBAAmB,MAAM,OAAO;CAUxC,MAAM,gBAAsC;EAC1C,UAAU,OAAO;EACjB,SAAS,sBAAsB,MAAM;EACrC,OAAO,cAAc;EACrB,MAAM,cAAc;CACtB;CAEA,MAAM,qBAAqB,YACzB,eAAe,cAAc,gBAAgB,UAAU;EAAE,OAAO;EAAe,UAAU;CAAQ,CAAC,CAAC;CAQrG,IAAI,eAAe,OAAO;CAC1B,IAAI,kBAAkB;CACtB,IAAI;CAEJ,SACE,IAAI;EAKF,OAAO,kBAJS,eACZ,qBAAqB,QAAQ,QAAQ,YAAY,IACjD,iBAAiB,QAAQ,MAAM,CAEH;EAChC;CACF,SAAS,QAAQ;EACf,kBAAkB;EAElB,IAAI,cAAc,SAAS,kBAAkB,OAAO;GAQlD,OAAO,kBAAkB,cAAc,YAAY,EAAE,UAAU,cAAc,uBAAuB,CAAC,CAAC,EAAE,CAAC,CAAC;GAC1G;EACF;EASA,eAAe,iBAAiB,QAAQ,kBAFtC,cAAc,SAAS,kBAAkB,WAAW,QAAQ,eAAe,WAAW,QAEf,MAAM,CAAC;CAClF;CAaF,MAAM,SAAS,kBACX,aAAc,SAAS,kBAAkB,QACvC,MACC,OAAO,QAAQ,cAAc,MAChC,OAAO,QACJ,OAAO,QAAQ,cAAc,MAC7B,OAAO,QAAQ,cAAc;CAIpC,OAAO;EAAE,MAFI,aAAa,IAEd;EAAG;EAAQ;EAAS;EAAS,MAAM,OAAO;EAAU;CAAO;AACzE;;;;;;;;;;;AAuDA,eAAsB,kBACpB,KACA,UAAoC,CAAC,GACd;CACvB,MAAM,WAAW,gBAAgB,OAAO;CACxC,MAAM,EAAE,OAAO,eAAe,oBAAoB,UAAU,QAAQ,EAAE;CAEtE,MAAM,WAAW,MAAM,mBAAmB;EACxC;EACA,QAAQ,SAAS;EACjB;EACA,SAAQ,WAAU,aAAa,MAAM,MAAO,MAAM,QAAQ,QAAQ,kBAAkB,MAAM,QAAQ,CAAC;CACrG,CAAC;CAED,IAAI,CAAC,UACH,OAAO;EAAE,MAAM;EAAI,QAAQ;EAAK,SAAS,CAAC;EAAG,SAAS,CAAC;EAAG,MAAM;EAAW,QAAQ;CAAU;CAK/F,OAAO;AACT"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region ../web/src/server/resolve-page-metadata.ts
|
|
2
|
+
/**
|
|
3
|
+
* What `<head>` gets when the page did not render.
|
|
4
|
+
*
|
|
5
|
+
* `robots` is the load-bearing member, not `title`. An error render is a
|
|
6
|
+
* transient server state that happens to be reachable at a real URL; letting a
|
|
7
|
+
* crawler index it puts "Something went wrong" in a search result for a page
|
|
8
|
+
* that works. `title` is a fallback a boundary is free to improve on.
|
|
9
|
+
*/
|
|
10
|
+
const ERROR_PAGE_METADATA = Object.freeze({
|
|
11
|
+
title: "Something went wrong",
|
|
12
|
+
robots: "noindex"
|
|
13
|
+
});
|
|
14
|
+
function resolvePageMetadata(input) {
|
|
15
|
+
if (input.failed) return { metadata: ERROR_PAGE_METADATA };
|
|
16
|
+
const { metadata } = input;
|
|
17
|
+
if (typeof metadata !== "function") return { metadata };
|
|
18
|
+
try {
|
|
19
|
+
return { metadata: metadata({
|
|
20
|
+
data: input.data,
|
|
21
|
+
shared: input.shared
|
|
22
|
+
}) };
|
|
23
|
+
} catch (thrown) {
|
|
24
|
+
return {
|
|
25
|
+
metadata: void 0,
|
|
26
|
+
thrown
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { resolvePageMetadata };
|
|
33
|
+
//# sourceMappingURL=resolve-page-metadata.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-page-metadata.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-page-metadata.ts"],"sourcesContent":["/**\r\n * Stage 8 of the page pipeline — METADATA.\r\n *\r\n * Its own file because the behaviour that matters here is about ERRORS, and\r\n * reaching an error path through the full ten-stage pipeline means standing up\r\n * a real Request/Response pair to observe a `try`/`catch`. See\r\n * `resolve-page-metadata.spec.ts`.\r\n *\r\n * ## Why a page's `metadata` never runs after a failed loader\r\n *\r\n * It used to. `design/request-lifecycle.md` said \"exactly one of data/error\r\n * set\", and stage 8 duly called `metadata({ data: undefined, error })` when a\r\n * loader rejected — while `PageMetadata` declared `data` as always present.\r\n * **The type lied**, so the natural way to write a metadata function was also\r\n * the broken way:\r\n *\r\n * ```ts\r\n * export const metadata: PageMetadata<typeof loader> = ({ data }) => ({\r\n * title: \"Home\",\r\n * description: `${data.products.length} products in stock`,\r\n * });\r\n * ```\r\n *\r\n * All three function-form metadata exports in the reference app were written\r\n * exactly like that, and all three turned a loader's real error — a\r\n * `MissingDataSourceError`, say — into `TypeError: Cannot read properties of\r\n * undefined`, reported against a different file in a different subsystem. The\r\n * cost was never the crash; it was that every future debugging session on a\r\n * failed loader would start by investigating the wrong thing.\r\n *\r\n * The alternative was to widen `data` to `| undefined` and make every author\r\n * handle a path almost none of them care about — and without a discriminant,\r\n * TypeScript cannot narrow `data` from `if (error)` anyway, so authors would\r\n * have reached for `data!` and re-created the lie with extra syntax.\r\n *\r\n * So: a page describes a page it actually has. When there is no data there is\r\n * no page-authored description, and the framework supplies\r\n * {@link ERROR_PAGE_METADATA} instead.\r\n *\r\n * ## Partial failure\r\n *\r\n * Stage 7 runs app, layout and page loaders under `allSettled`, so the page's\r\n * own loader can resolve while an ANCESTOR's rejects. `failed` is still true\r\n * then, and the page's metadata still does not run — deliberately. Any recorded\r\n * error means a boundary renders instead of the page, and metadata describes\r\n * what is on screen. Describing a page the visitor never received is the same\r\n * defect in a quieter form.\r\n *\r\n * If per-page error metadata is ever wanted, it is a separate `errorMetadata`\r\n * export — a distinct signature for a distinct situation, not an arm of this\r\n * one. Nothing has asked for it.\r\n */\r\n\r\nimport type { SharedContext } from \"../index\";\r\nimport type { MetadataOutput, PageMetadata } from \"../metadata\";\r\nimport type { PipelineLoader } from \"./execute-page-request\";\r\n\r\n/**\r\n * What `<head>` gets when the page did not render.\r\n *\r\n * `robots` is the load-bearing member, not `title`. An error render is a\r\n * transient server state that happens to be reachable at a real URL; letting a\r\n * crawler index it puts \"Something went wrong\" in a search result for a page\r\n * that works. `title` is a fallback a boundary is free to improve on.\r\n */\r\nexport const ERROR_PAGE_METADATA: MetadataOutput = Object.freeze({\r\n title: \"Something went wrong\",\r\n robots: \"noindex\",\r\n});\r\n\r\nexport type ResolvePageMetadataInput = {\r\n /** The page module's `metadata` export — absent, object, or function. */\r\n metadata: PageMetadata<PipelineLoader> | undefined;\r\n /** `bundle.pageData`. Read only when `failed` is false. */\r\n data: unknown;\r\n /** The value an earlier stage already recorded. Diagnostics only. */\r\n error: unknown;\r\n /** Whether an earlier stage recorded an error at all. */\r\n failed: boolean;\r\n shared: Readonly<SharedContext>;\r\n};\r\n\r\nexport type ResolvedPageMetadata = {\r\n metadata: MetadataOutput | undefined;\r\n /**\r\n * Set only when the page's own metadata function threw on the SUCCESS path.\r\n * Returned rather than rethrown so the caller can record it the same way a\r\n * loader throw is recorded — the boundary renders and the framework keeps\r\n * ownership of the status, instead of an exception escaping stage 8.\r\n *\r\n * Never set on the error path: nothing runs there that could throw, and an\r\n * error arriving at stage 8 has already won.\r\n */\r\n thrown?: unknown;\r\n};\r\n\r\n/** The function arm of `PageMetadata`, for the one call this module makes. */\r\ntype PageMetadataFunction = Extract<PageMetadata<PipelineLoader>, (...args: never) => unknown>;\r\n\r\nexport function resolvePageMetadata(input: ResolvePageMetadataInput): ResolvedPageMetadata {\r\n if (input.failed) {\r\n return { metadata: ERROR_PAGE_METADATA };\r\n }\r\n\r\n const { metadata } = input;\r\n\r\n if (typeof metadata !== \"function\") {\r\n return { metadata };\r\n }\r\n\r\n try {\r\n return {\r\n metadata: (metadata as PageMetadataFunction)({\r\n data: input.data as Parameters<PageMetadataFunction>[0][\"data\"],\r\n shared: input.shared,\r\n }),\r\n };\r\n } catch (thrown) {\r\n return { metadata: undefined, thrown };\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;AAiEA,MAAa,sBAAsC,OAAO,OAAO;CAC/D,OAAO;CACP,QAAQ;AACV,CAAC;AA+BD,SAAgB,oBAAoB,OAAuD;CACzF,IAAI,MAAM,QACR,OAAO,EAAE,UAAU,oBAAoB;CAGzC,MAAM,EAAE,aAAa;CAErB,IAAI,OAAO,aAAa,YACtB,OAAO,EAAE,SAAS;CAGpB,IAAI;EACF,OAAO,EACL,UAAW,SAAkC;GAC3C,MAAM,MAAM;GACZ,QAAQ,MAAM;EAChB,CAAC,EACH;CACF,SAAS,QAAQ;EACf,OAAO;GAAE,UAAU;GAAW;EAAO;CACvC;AACF"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region ../web/src/server/resolve-validation-data.ts
|
|
2
|
+
/** The four names a page may list in `validation.validating`. */
|
|
3
|
+
const SOURCES = {
|
|
4
|
+
body: (request) => request.body,
|
|
5
|
+
query: (request) => request.query,
|
|
6
|
+
params: (request) => request.params,
|
|
7
|
+
headers: (request) => request.headers
|
|
8
|
+
};
|
|
9
|
+
function resolveValidationData(validating, request) {
|
|
10
|
+
if (!validating || validating.length === 0) return {
|
|
11
|
+
...request.query,
|
|
12
|
+
...request.params
|
|
13
|
+
};
|
|
14
|
+
let data = {};
|
|
15
|
+
for (const source of validating) {
|
|
16
|
+
const read = SOURCES[source];
|
|
17
|
+
if (read) data = {
|
|
18
|
+
...data,
|
|
19
|
+
...read(request) ?? {}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
export { resolveValidationData };
|
|
27
|
+
//# sourceMappingURL=resolve-validation-data.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-validation-data.mjs","names":[],"sources":["../../../../../../../web/src/server/resolve-validation-data.ts"],"sourcesContent":["/**\n * Stage 4's input — what the page's `validation.schema` is handed.\n *\n * ## One request, one parse, one grammar\n *\n * This used to draw its four sources from **two** places. `body` and `headers`\n * came from the request; `query` and `params` came from a match object that\n * `execute-page-request.ts` built by re-parsing the URL itself with\n * `Object.fromEntries(new URLSearchParams(...))`.\n *\n * Core's parser is bracket-aware and preserves repeated keys. That one is\n * neither:\n *\n * ```\n * ?tags=a&tags=b&filter[status]=active\n * core → { tags: [\"a\",\"b\"], filter: { status: \"active\" } }\n * match → { tags: \"b\", \"filter[status]\": \"active\" }\n * ```\n *\n * **So a page's validation rules were checking different data than its loader\n * received.** A rule on `filter.status` could never fire — validation saw a key\n * literally named `filter[status]` — and where a key was sent twice, only the\n * last value was ever examined. A field the author believed was validated was\n * not, and nothing about that was visible from reading the validation rules.\n *\n * Every source is now the request. Core has already parsed the URL by the time\n * this runs; parsing it a second time can only ever produce a second answer.\n *\n * Mirrors core's `validateAll.ts:8-31`, with the page default: **params +\n * query, not body** — a page load is a GET.\n */\n\nexport type ValidationRequest = {\n body?: Record<string, unknown>;\n headers?: Record<string, unknown>;\n query?: Record<string, unknown>;\n params?: Record<string, unknown>;\n};\n\n/** The four names a page may list in `validation.validating`. */\nconst SOURCES = {\n body: (request: ValidationRequest) => request.body,\n query: (request: ValidationRequest) => request.query,\n params: (request: ValidationRequest) => request.params,\n headers: (request: ValidationRequest) => request.headers,\n} as const;\n\nexport function resolveValidationData(\n validating: readonly string[] | undefined,\n request: ValidationRequest,\n): Record<string, unknown> {\n // The page default. `params` last so a route parameter wins a query key of\n // the same name — the URL path is the more authoritative of the two.\n if (!validating || validating.length === 0) {\n return { ...request.query, ...request.params };\n }\n\n let data: Record<string, unknown> = {};\n\n for (const source of validating) {\n const read = SOURCES[source as keyof typeof SOURCES];\n\n // An unknown source name contributes nothing rather than throwing: the\n // list is app-authored, and stage 4 is not the place to litigate it.\n if (read) data = { ...data, ...(read(request) ?? {}) };\n }\n\n return data;\n}\n"],"mappings":";;AAwCA,MAAM,UAAU;CACd,OAAO,YAA+B,QAAQ;CAC9C,QAAQ,YAA+B,QAAQ;CAC/C,SAAS,YAA+B,QAAQ;CAChD,UAAU,YAA+B,QAAQ;AACnD;AAEA,SAAgB,sBACd,YACA,SACyB;CAGzB,IAAI,CAAC,cAAc,WAAW,WAAW,GACvC,OAAO;EAAE,GAAG,QAAQ;EAAO,GAAG,QAAQ;CAAO;CAG/C,IAAI,OAAgC,CAAC;CAErC,KAAK,MAAM,UAAU,YAAY;EAC/B,MAAM,OAAO,QAAQ;EAIrB,IAAI,MAAM,OAAO;GAAE,GAAG;GAAM,GAAI,KAAK,OAAO,KAAK,CAAC;EAAG;CACvD;CAEA,OAAO;AACT"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|