@warlock.js/web 5.2.3 → 5.3.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/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +5 -7
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-default-export.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs.map +1 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/hydrate-page.mjs +4 -3
- package/esm/client/hydrate-page.mjs.map +1 -1
- package/esm/client/navigation/fetch-page-data.mjs +2 -2
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +5 -1
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/prefetch.mjs.map +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/components/document-context.mjs.map +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/hydration-payload.mjs +6 -2
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +4 -2
- package/esm/index.mjs +2 -1
- package/esm/localization.d.mts +21 -0
- package/esm/localization.mjs +28 -0
- package/esm/localization.mjs.map +1 -0
- package/esm/routing/data-request.mjs +5 -3
- package/esm/routing/data-request.mjs.map +1 -1
- package/esm/routing/filesystem-route.mjs +36 -7
- package/esm/routing/filesystem-route.mjs.map +1 -1
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/page-file-segment.mjs +66 -0
- package/esm/routing/page-file-segment.mjs.map +1 -0
- package/esm/routing/page-route-grammar.mjs +79 -0
- package/esm/routing/page-route-grammar.mjs.map +1 -0
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.d.mts +69 -0
- package/esm/routing/route-identity.mjs +100 -44
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/server/build-hydration-payload.mjs +3 -2
- package/esm/server/build-hydration-payload.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +34 -1
- package/esm/server/create-page-route-handler.mjs +35 -4
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/framework-default-not-found-stylesheet.mjs +102 -0
- package/esm/server/framework-default-not-found-stylesheet.mjs.map +1 -0
- package/esm/server/install-page-routes-from-manifest.mjs +13 -16
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +3 -1
- package/esm/server/install-page-routes.mjs +11 -11
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -13
- package/esm/server/not-found-page.mjs +50 -5
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +16 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +1 -8
- package/esm/server/render-page.mjs +24 -21
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/response-cache-floor.mjs +79 -0
- package/esm/server/response-cache-floor.mjs.map +1 -0
- package/esm/server/set-cookie-cache-floor-hook.mjs +41 -0
- package/esm/server/set-cookie-cache-floor-hook.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.d.mts +1 -1
- package/esm/server/web-connector.mjs +23 -4
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/llms-full.txt +62 -33
- package/llms.txt +1 -1
- package/package.json +4 -3
- package/skills/create-a-page/SKILL.md +59 -28
- package/skills/navigate-on-the-client/SKILL.md +16 -11
- package/skills/serve-styles/SKILL.md +2 -1
- package/skills/use-layouts/SKILL.md +3 -6
- package/skills/write-the-root/SKILL.md +0 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
function serializableData(data) {
|
|
11
11
|
return data === void 0 ? {} : data;
|
|
12
12
|
}
|
|
13
|
-
function buildHydrationPayload(bundle) {
|
|
13
|
+
function buildHydrationPayload(bundle, locale) {
|
|
14
14
|
return {
|
|
15
15
|
appData: serializableData(bundle.appData),
|
|
16
16
|
layoutData: serializableData(bundle.layoutData),
|
|
@@ -19,7 +19,8 @@ function buildHydrationPayload(bundle) {
|
|
|
19
19
|
params: bundle.route.params,
|
|
20
20
|
...bundle.metadata === void 0 ? {} : { metadata: bundle.metadata },
|
|
21
21
|
...bundle.errorPage === void 0 ? {} : { errorPage: bundle.errorPage },
|
|
22
|
-
name: bundle.route.name
|
|
22
|
+
name: bundle.route.name,
|
|
23
|
+
locale
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-hydration-payload.mjs","names":[],"sources":["../../../../../../../web/src/server/build-hydration-payload.ts"],"sourcesContent":["/**\n * The ONE place the hydration payload's shape is decided.\n *\n * Two callers need the identical object and must never drift:\n *\n * `render-page.ts` embeds it in the document as `#__WARLOCK_DATA__`, which\n * is what a FULL page load hands the browser.\n * the `_loader` route returns it as JSON, which is what a CLIENT navigation\n * fetches instead of re-rendering the document.\n *\n * Drift between those two is not a cosmetic bug: the browser builds the same\n * React tree from either source, so a key present on one path and absent on the\n * other produces a page that works when you land on it and breaks when you\n * navigate to it — or the reverse, which is worse, because the first visit is\n * the one everybody tests.\n *\n * Extracted rather than duplicated for exactly that reason. It was previously\n * assembled inline inside the renderer, where the loader route could not reach\n * it without copying five lines that would then be free to diverge.\n */\nimport type { HydrationDocumentPayloadSource } from \"../components/document-context\";\nimport type { PageDataBundle } from \"./execute-page-request\";\n\n/**\n * Levels without a loader resolve to `undefined`, but the hydration contract\n * requires every data key to be PRESENT.\n *\n * An intentional `null` is preserved — a loader that returned `null` said\n * something, and flattening it would erase that. Only \"no data at all\" becomes\n * an empty object.\n */\nfunction serializableData(data: unknown): unknown {\n return data === undefined ? {} : data;\n}\n\nexport function buildHydrationPayload(bundle: PageDataBundle): HydrationDocumentPayloadSource {\n return {\n appData: serializableData(bundle.appData),\n layoutData: serializableData(bundle.layoutData),\n pageData: serializableData(bundle.pageData),\n shared: serializableData(bundle.shared),\n // The server's own match, carried for the same reason `name` is: the params\n // are an ANSWER the router already gave, and re-deriving them in the\n // browser from `location.pathname` would be a second matcher disagreeing\n // with the server about the request it is hydrating. `{}` for a route with\n // no dynamic segments — a real answer, not a missing one.\n params: bundle.route.params,\n // Spread, so \"the page produced no metadata\" is the SAME shape here and on\n // the wire. `metadata: undefined` would be a key in the in-process object\n // and no key at all after `JSON.stringify` — one type, two payload shapes,\n // which is precisely the drift this file exists to prevent. Carried whole:\n // `<Head/>` renders every member of `MetadataOutput`, so anything narrowed\n // out here is a tag the first request has and a navigation does not.\n ...(bundle.metadata === undefined ? {} : { metadata: bundle.metadata }),\n ...(bundle.errorPage === undefined ? {} : { errorPage: bundle.errorPage }),\n // The matched entry's own name, carried untransformed from stage 1\n // (`bundle.route.name` IS `matched.entry.name`, execute-page-request.ts).\n // The browser reads it to look up the page the server resolved rather than\n // re-matching the pathname — a second matcher can disagree with the server\n // about the very request it is hydrating, and on a client navigation it\n // would be disagreeing about a request the server already answered.\n name: bundle.route.name,\n };\n}\n"],"mappings":";;;;;;;;;AA+BA,SAAS,iBAAiB,MAAwB;CAChD,OAAO,SAAS,SAAY,CAAC,IAAI;AACnC;AAEA,SAAgB,
|
|
1
|
+
{"version":3,"file":"build-hydration-payload.mjs","names":[],"sources":["../../../../../../../web/src/server/build-hydration-payload.ts"],"sourcesContent":["/**\n * The ONE place the hydration payload's shape is decided.\n *\n * Two callers need the identical object and must never drift:\n *\n * `render-page.ts` embeds it in the document as `#__WARLOCK_DATA__`, which\n * is what a FULL page load hands the browser.\n * the `_loader` route returns it as JSON, which is what a CLIENT navigation\n * fetches instead of re-rendering the document.\n *\n * Drift between those two is not a cosmetic bug: the browser builds the same\n * React tree from either source, so a key present on one path and absent on the\n * other produces a page that works when you land on it and breaks when you\n * navigate to it — or the reverse, which is worse, because the first visit is\n * the one everybody tests.\n *\n * Extracted rather than duplicated for exactly that reason. It was previously\n * assembled inline inside the renderer, where the loader route could not reach\n * it without copying five lines that would then be free to diverge.\n */\nimport type { HydrationDocumentPayloadSource } from \"../components/document-context\";\nimport type { PageDataBundle } from \"./execute-page-request\";\n\n/**\n * Levels without a loader resolve to `undefined`, but the hydration contract\n * requires every data key to be PRESENT.\n *\n * An intentional `null` is preserved — a loader that returned `null` said\n * something, and flattening it would erase that. Only \"no data at all\" becomes\n * an empty object.\n */\nfunction serializableData(data: unknown): unknown {\n return data === undefined ? {} : data;\n}\n\nexport function buildHydrationPayload(\n bundle: PageDataBundle,\n locale: string,\n): HydrationDocumentPayloadSource {\n return {\n appData: serializableData(bundle.appData),\n layoutData: serializableData(bundle.layoutData),\n pageData: serializableData(bundle.pageData),\n shared: serializableData(bundle.shared),\n // The server's own match, carried for the same reason `name` is: the params\n // are an ANSWER the router already gave, and re-deriving them in the\n // browser from `location.pathname` would be a second matcher disagreeing\n // with the server about the request it is hydrating. `{}` for a route with\n // no dynamic segments — a real answer, not a missing one.\n params: bundle.route.params,\n // Spread, so \"the page produced no metadata\" is the SAME shape here and on\n // the wire. `metadata: undefined` would be a key in the in-process object\n // and no key at all after `JSON.stringify` — one type, two payload shapes,\n // which is precisely the drift this file exists to prevent. Carried whole:\n // `<Head/>` renders every member of `MetadataOutput`, so anything narrowed\n // out here is a tag the first request has and a navigation does not.\n ...(bundle.metadata === undefined ? {} : { metadata: bundle.metadata }),\n ...(bundle.errorPage === undefined ? {} : { errorPage: bundle.errorPage }),\n // The matched entry's own name, carried untransformed from stage 1\n // (`bundle.route.name` IS `matched.entry.name`, execute-page-request.ts).\n // The browser reads it to look up the page the server resolved rather than\n // re-matching the pathname — a second matcher can disagree with the server\n // about the very request it is hydrating, and on a client navigation it\n // would be disagreeing about a request the server already answered.\n name: bundle.route.name,\n locale,\n };\n}\n"],"mappings":";;;;;;;;;AA+BA,SAAS,iBAAiB,MAAwB;CAChD,OAAO,SAAS,SAAY,CAAC,IAAI;AACnC;AAEA,SAAgB,sBACd,QACA,QACgC;CAChC,OAAO;EACL,SAAS,iBAAiB,OAAO,OAAO;EACxC,YAAY,iBAAiB,OAAO,UAAU;EAC9C,UAAU,iBAAiB,OAAO,QAAQ;EAC1C,QAAQ,iBAAiB,OAAO,MAAM;EAMtC,QAAQ,OAAO,MAAM;EAOrB,GAAI,OAAO,aAAa,SAAY,CAAC,IAAI,EAAE,UAAU,OAAO,SAAS;EACrE,GAAI,OAAO,cAAc,SAAY,CAAC,IAAI,EAAE,WAAW,OAAO,UAAU;EAOxE,MAAM,OAAO,MAAM;EACnB;CACF;AACF"}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { RegisterableModuleNamespace } from "../runtime/register-modules.mjs";
|
|
2
|
+
import { PageCacheOptIn } from "../routing/route-identity.mjs";
|
|
2
3
|
import { BufferedCookie } from "./settle-page-response.mjs";
|
|
3
4
|
import { ErrorPageModuleLoader } from "./error-page.mjs";
|
|
4
|
-
import { HttpContext, Response } from "@warlock.js/core";
|
|
5
|
+
import { FastifyInstance, HttpContext, Response } from "@warlock.js/core";
|
|
5
6
|
|
|
6
7
|
//#region ../web/src/server/create-page-route-handler.d.ts
|
|
8
|
+
declare module "@warlock.js/core" {
|
|
9
|
+
interface RequestLocals {
|
|
10
|
+
/**
|
|
11
|
+
* Set by this file's route handler, on every page-route response
|
|
12
|
+
* (document and data representations alike) — never inferred from URL
|
|
13
|
+
* shape or content-type. `set-cookie-cache-floor-hook.ts`'s `onSend` hook
|
|
14
|
+
* reads this to scope its effect to page responses only.
|
|
15
|
+
*/
|
|
16
|
+
isPageResponse?: boolean;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
7
19
|
/**
|
|
8
20
|
* How the handler obtains a page/layout/app module, by the same id
|
|
9
21
|
* (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev
|
|
@@ -76,6 +88,27 @@ type PageRouteHandlerOptions = {
|
|
|
76
88
|
* the call.
|
|
77
89
|
*/
|
|
78
90
|
applyBufferedCookie?: (response: Response, cookie: BufferedCookie) => void;
|
|
91
|
+
/**
|
|
92
|
+
* The Fastify instance to register the `Set-Cookie` cache-floor `onSend`
|
|
93
|
+
* hook on (`ensureSetCookieCacheFloorHook`, `set-cookie-cache-floor-hook.ts`).
|
|
94
|
+
* Defaults to `container.get("http.server")` — the same instance
|
|
95
|
+
* `HttpConnector` publishes during its own `boot()`, which runs before
|
|
96
|
+
* `WebConnector.boot()` calls this factory. Injectable so a test can hand
|
|
97
|
+
* this factory a self-contained Fastify instance it built and booted
|
|
98
|
+
* itself, with no framework connector graph involved.
|
|
99
|
+
*/
|
|
100
|
+
httpServer?: FastifyInstance;
|
|
101
|
+
/**
|
|
102
|
+
* This route's resolved `cache` opt-in, already validated
|
|
103
|
+
* ({@link resolvePageRouteCache}) by whichever installer (dev's
|
|
104
|
+
* `install-page-routes.ts` or production's
|
|
105
|
+
* `install-page-routes-from-manifest.ts`) built these options — `undefined`
|
|
106
|
+
* means the route declared no `cache` at all. Read by
|
|
107
|
+
* `applyResponseCacheFloor` (`response-cache-floor.ts`) at the same seam
|
|
108
|
+
* that applies the `Set-Cookie`/auth-derived floor, so the document and the
|
|
109
|
+
* data representation can never disagree on `Cache-Control`.
|
|
110
|
+
*/
|
|
111
|
+
cache?: PageCacheOptIn;
|
|
79
112
|
};
|
|
80
113
|
type PageRouteHandler = (context: HttpContext) => Promise<void | Response>;
|
|
81
114
|
//#endregion
|
|
@@ -3,7 +3,9 @@ import { isNonHydrating } from "./page-render-bundle.mjs";
|
|
|
3
3
|
import { registerModules } from "../runtime/register-modules.mjs";
|
|
4
4
|
import { buildHydrationPayload } from "./build-hydration-payload.mjs";
|
|
5
5
|
import { renderPageFailure, renderPageRequest } from "./render-page.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { applyResponseCacheFloor } from "./response-cache-floor.mjs";
|
|
7
|
+
import { ensureSetCookieCacheFloorHook, markPageResponse } from "./set-cookie-cache-floor-hook.mjs";
|
|
8
|
+
import { Response, container } from "@warlock.js/core";
|
|
7
9
|
|
|
8
10
|
//#region ../web/src/server/create-page-route-handler.ts
|
|
9
11
|
/**
|
|
@@ -29,6 +31,25 @@ import { Response } from "@warlock.js/core";
|
|
|
29
31
|
* `type: "page"` routing, HTML error pages, or any other new capability.
|
|
30
32
|
*/
|
|
31
33
|
/**
|
|
34
|
+
* Raised when a page route handler is constructed WITHOUT an `httpServer`
|
|
35
|
+
* option AND the framework container has no `"http.server"` binding either —
|
|
36
|
+
* i.e. there is no way, deliberate or ambient, to register the `Set-Cookie`
|
|
37
|
+
* cache-floor hook. `container.get("http.server")` (`core/src/container/index.ts`)
|
|
38
|
+
* is a bare `Map.get` that TypeScript types as always returning a
|
|
39
|
+
* `FastifyInstance`, so a silently-missing binding used to read as "no
|
|
40
|
+
* server" and skip the hook with no signal at all. This throws instead of
|
|
41
|
+
* repeating that mistake. To fix: register `http.server` in the container
|
|
42
|
+
* before this factory runs (the ordinary `HttpConnector.boot()` path), or —
|
|
43
|
+
* if this handler genuinely has no server on purpose, such as a unit test —
|
|
44
|
+
* pass `httpServer: undefined` explicitly to say so.
|
|
45
|
+
*/
|
|
46
|
+
var MissingHttpServerForPageRouteError = class extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("createPageRouteHandler: no \"httpServer\" option was supplied and the container has no \"http.server\" binding, so the Set-Cookie cache-floor hook on page responses cannot be registered. Register `http.server` in the container before this factory runs, or pass `httpServer: undefined` explicitly if this handler is meant to have no server.");
|
|
49
|
+
this.name = "MissingHttpServerForPageRouteError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
32
53
|
* Replay ONE committed cookie through core's own `Response.cookie()` — the
|
|
33
54
|
* same serializer every ordinary controller's cookie goes through, so there
|
|
34
55
|
* is nothing here for a second implementation to drift from. The one-liner
|
|
@@ -107,7 +128,12 @@ function installStylesheets(html, stylesheetUrls) {
|
|
|
107
128
|
* before.
|
|
108
129
|
*/
|
|
109
130
|
function createPageRouteHandler(options) {
|
|
110
|
-
const { path, name, appFile, pageFile, layoutFile, loadModule, loadErrorPage, loadRegistrationLayouts, hydrationClientModuleUrl, stylesheetUrls, matchPath, statusForRenderedOk, skipPageLoader = false, applyBufferedCookie = defaultApplyBufferedCookie } = options;
|
|
131
|
+
const { path, name, appFile, pageFile, layoutFile, loadModule, loadErrorPage, loadRegistrationLayouts, hydrationClientModuleUrl, stylesheetUrls, matchPath, statusForRenderedOk, skipPageLoader = false, applyBufferedCookie = defaultApplyBufferedCookie, cache } = options;
|
|
132
|
+
let httpServer;
|
|
133
|
+
if ("httpServer" in options) httpServer = options.httpServer;
|
|
134
|
+
else if (container.has("http.server")) httpServer = container.get("http.server");
|
|
135
|
+
else throw new MissingHttpServerForPageRouteError();
|
|
136
|
+
if (httpServer) ensureSetCookieCacheFloorHook(httpServer);
|
|
111
137
|
return async ({ request, response }) => {
|
|
112
138
|
const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, void 0));
|
|
113
139
|
try {
|
|
@@ -149,6 +175,11 @@ function createPageRouteHandler(options) {
|
|
|
149
175
|
if (rendered instanceof Response) return rendered;
|
|
150
176
|
const status = rendered.status === 200 && statusForRenderedOk !== void 0 ? statusForRenderedOk : rendered.status;
|
|
151
177
|
applyCommit(response, rendered, applyBufferedCookie);
|
|
178
|
+
markPageResponse(request);
|
|
179
|
+
applyResponseCacheFloor(response, {
|
|
180
|
+
authDerived: request.locals === void 0 ? void 0 : request.locals.authDerived === true,
|
|
181
|
+
cache
|
|
182
|
+
});
|
|
152
183
|
if (wantsData) {
|
|
153
184
|
response.header("Vary", WARLOCK_DATA_REQUEST_HEADER);
|
|
154
185
|
if (rendered.bundle === void 0) {
|
|
@@ -157,7 +188,7 @@ function createPageRouteHandler(options) {
|
|
|
157
188
|
return;
|
|
158
189
|
}
|
|
159
190
|
response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
|
|
160
|
-
await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), status);
|
|
191
|
+
await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle, request.locale)), status);
|
|
161
192
|
return;
|
|
162
193
|
}
|
|
163
194
|
const html = installHydrationClientModule(installStylesheets(rendered.html, stylesheetUrls ?? []), hydrationClientModuleUrl, hydrationClientModuleUrl === void 0 ? void 0 : request.nonce);
|
|
@@ -176,7 +207,7 @@ function createPageRouteHandler(options) {
|
|
|
176
207
|
if (wantsData) {
|
|
177
208
|
response.header("Vary", WARLOCK_DATA_REQUEST_HEADER);
|
|
178
209
|
response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
|
|
179
|
-
await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), 500);
|
|
210
|
+
await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle, request.locale)), 500);
|
|
180
211
|
return;
|
|
181
212
|
}
|
|
182
213
|
const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\r\n * The page handler, as a named seam.\r\n *\r\n * This is the request handler `installPageRoutes` used to inline into its\r\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\r\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\r\n * The behaviour is unchanged, byte for byte — what changes is that it is now\r\n * a named, exported, independently constructible function instead of a closure\r\n * over eight ambient bindings of `installPageRoutes`.\r\n *\r\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\r\n * only capability the handler ever needed, and the two runtimes answer it\r\n * differently — dev goes through Vite's SSR graph\r\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\r\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\r\n * module\" as an INPUT is what lets the same handler serve both, and what lets\r\n * a test construct it with a plain async function — no Vite, no dev server, no\r\n * `app/` directory on disk.\r\n *\r\n * Scope: this file creates a seam and nothing else. It does not implement\r\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\r\n */\r\nimport { Response, type HttpContext } from \"@warlock.js/core\";\r\n\r\nimport {\r\n DATA_RESPONSE_CONTENT_TYPE,\r\n isDataRequest,\r\n WARLOCK_DATA_REQUEST_HEADER,\r\n} from \"../routing/data-request\";\r\nimport {\r\n registerModules,\r\n type RegisterableModuleNamespace,\r\n} from \"../runtime/register-modules\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport type { BufferedCookie, PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { isNonHydrating } from \"./page-render-bundle\";\r\nimport { renderPageFailure, renderPageRequest, type RenderedPage } from \"./render-page\";\r\nimport type { ErrorPageModuleLoader } from \"./error-page\";\r\n\r\n/**\r\n * Replay ONE committed cookie through core's own `Response.cookie()` — the\r\n * same serializer every ordinary controller's cookie goes through, so there\r\n * is nothing here for a second implementation to drift from. The one-liner\r\n * `dev-server.ts` wires as the production default; passed in (`applyBufferedCookie`\r\n * option, below) rather than imported so this file stays free of anything\r\n * Vite-shaped.\r\n */\r\nfunction defaultApplyBufferedCookie(response: Response, cookie: BufferedCookie): void {\r\n response.cookie(cookie.name, cookie.value as never, cookie.options ?? {});\r\n}\r\n\r\n/**\r\n * Stage 10a — apply the stage 7 commit (headers, then cookies) to the LIVE\r\n * response, once, before either terminal write (10b: `html()` or `send()`).\r\n * Both the document and data representations of a page route go through this\r\n * so a client navigation never drops a `Set-Cookie` a full load would have\r\n * kept (`create-page-route-handler.spec.ts` — \"applies committed cookies and\r\n * headers exactly as the document path does\").\r\n */\r\nfunction applyCommit(\r\n response: Response,\r\n rendered: Pick<RenderedPage, \"headers\" | \"cookies\">,\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void,\r\n): void {\r\n response.headers(rendered.headers ?? {});\r\n\r\n for (const cookie of rendered.cookies ?? []) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n}\r\n\r\n/**\r\n * How the handler obtains a page/layout/app module, by the same id\r\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\r\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\r\n * owns the dev/prod split, so the handler never learns which one it got.\r\n */\r\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\r\n\r\nexport type PageRouteHandlerOptions = {\r\n /** The composed, registered route path — `composeRoutePath`'s output. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\r\n appFile: string;\r\n /** The page module's id. */\r\n pageFile: string;\r\n /** The page's own-directory `layout.tsx`, when it has one. */\r\n layoutFile?: string | undefined;\r\n loadModule: PageModuleLoader;\r\n /** Optional lazy application `error.page.tsx` loader. Never called on success. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n /**\r\n * Load the REAL layout module namespaces, outermost first, for universal\r\n * registration. This stays separate from `loadModule(layoutFile)` because\r\n * dev may answer that id with a synthetic wrapper whose middleware is the\r\n * composition of several layouts. That wrapper is a render-pipeline detail,\r\n * not a module identity, and must never enter `registerModules`' WeakSet.\r\n */\r\n loadRegistrationLayouts?: () => Promise<readonly RegisterableModuleNamespace[]>;\r\n /** Browser module appended after the server-rendered document. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\r\n * styled. Absent or empty means the application has no CSS — it never means\r\n * a stylesheet failed to resolve, which is the build's job to report.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n /**\r\n * The pattern stage 1 matches `request.path` against, when it differs from\r\n * the REGISTERED path. Defaults to `path`, which is right for every route\r\n * whose URL is its own.\r\n *\r\n * Exactly one route needs it: the not-found page, registered on the catch-all\r\n * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and\r\n * has no wildcard token, so a route registered as `*` matches NOTHING — the\r\n * pipeline reports no match and `renderPageRequest` answers `{ html: \"\",\r\n * status: 404 }`. Correct status, empty document: a 404 page that never\r\n * renders its own body. Handing it `requestPath => requestPath` makes the\r\n * requested URL the route's pattern for that one request, so the match is\r\n * trivially true and the page renders for the URL the visitor actually asked\r\n * for.\r\n */\r\n matchPath?: (requestPath: string) => string;\r\n /**\r\n * The status this route answers with when the pipeline settles on a plain\r\n * `200` — the not-found route's `404`, and nothing else uses it.\r\n *\r\n * Applied ONLY to `200`, never as a blanket override: a `200` from this\r\n * pipeline means \"the document rendered and nobody objected\", which for this\r\n * route is precisely the not-found case. Any other settled status is a real\r\n * outcome that the page or the boundary decided — a 500 from a failed render,\r\n * a redirect — and overwriting it would report a broken page as a missing one.\r\n */\r\n statusForRenderedOk?: number;\n /**\n * Exclude the page module's loader from the request triple while preserving\n * the real namespace for `register()` and rendering. Used only by the\n * catch-all 404 page: a missing URL must not run application data work or\n * turn a simple miss into a second failure path.\n */\n skipPageLoader?: boolean;\n /**\r\n * Replays one committed cookie through core's `Response.cookie()`. Defaults\r\n * to doing exactly that (`defaultApplyBufferedCookie`, above); injectable so\r\n * a caller with a different `Response` shape (or a test) can observe/replace\r\n * the call.\r\n */\r\n applyBufferedCookie?: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void | Response>;\r\n\r\nfunction escapeHtmlAttribute(value: string): string {\r\n return value.replace(/[&<>\"']/g, (character) => {\r\n switch (character) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\nfunction installHydrationClientModule(\r\n html: string,\r\n moduleUrl: string | undefined,\r\n nonce: string | undefined,\r\n): string {\r\n if (moduleUrl === undefined || html === \"\") return html;\r\n\r\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\r\n if (closingBodyIndex === -1) {\r\n throw new Error(\r\n \"installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\r\n );\r\n }\r\n\r\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\r\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\r\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\r\n}\r\n\r\n/**\r\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\r\n *\r\n * Without this the document carries no CSS at all. The stylesheet reaches the\r\n * browser only because the CLIENT bundle imports it, which means it is applied\r\n * by JavaScript after the module graph loads — the page renders unstyled first\r\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\r\n * in the console to explain it.\r\n *\r\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\r\n * here: the browser holds the first paint until the CSS is in, so there is no\r\n * flash rather than a faster ugly one.\r\n *\r\n * Inserted before `</head>` rather than after `<head>` so an application's own\r\n * `<link>`/`<style>` in the root document still comes FIRST and can be\r\n * overridden by these — matching how the framework's tags are documented to\r\n * behave, and keeping cascade order predictable.\r\n */\r\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\r\n if (stylesheetUrls.length === 0 || html === \"\") return html;\r\n\r\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\r\n\r\n // No `<head>` is not an error the way a missing `</body>` is: a root that\r\n // renders no head is unusual but legal, and losing the stylesheet is a\r\n // cosmetic failure where losing hydration is a broken page. Silently\r\n // dropping it would be the wrong trade the other way, though — so the\r\n // document is left exactly as rendered and the caller's own missing-`</body>`\r\n // check remains the loud one.\r\n if (closingHeadIndex === -1) return html;\r\n\r\n const links = stylesheetUrls\r\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\r\n .join(\"\");\r\n\r\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\r\n}\r\n\r\n/**\r\n * Build the handler for ONE page route. Per request it loads the App + layout\r\n * + page triple (concurrently, in that order), renders the URL through\r\n * `renderPageRequest`, splices in the hydration module, and flushes the\r\n * document.\r\n *\r\n * No try/catch, deliberately: loader/render throws are already absorbed by the\r\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\r\n * escapes (a module-load or register failure, the missing-`</body>` throw\r\n * above) belongs to the router's error path — which is exactly where it went\r\n * before.\r\n */\r\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\r\n const {\r\n path,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n loadModule,\r\n loadErrorPage,\r\n loadRegistrationLayouts,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n matchPath,\n statusForRenderedOk,\n skipPageLoader = false,\n applyBufferedCookie = defaultApplyBufferedCookie,\n } = options;\n\r\n return async ({ request, response }: HttpContext) => {\r\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n try {\r\n const [appModule, layoutModule, ownPageModule, registrationLayouts] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n loadRegistrationLayouts?.() ?? Promise.resolve([]),\r\n ]);\r\n\r\n // Registration is the first lifecycle action after all module namespaces\r\n // have loaded and before `renderPageRequest` can run middleware, loaders or\r\n // render. App/page are already their real namespaces. Layouts deliberately\r\n // come from the separate raw chain above, never from `layoutModule`, which\r\n // may be the synthetic composed middleware wrapper used by dev.\r\n registerModules([\r\n appModule as RegisterableModuleNamespace,\r\n ...registrationLayouts,\r\n ownPageModule as RegisterableModuleNamespace,\r\n ]);\r\n\r\n const pageModule = ownPageModule as PageTripleModule;\n const triple: PageRouteEntry[\"triple\"] = {\n app: appModule as PageTripleModule,\n layout: layoutModule as PageTripleModule,\n // Registration above deliberately receives the REAL namespace. Only the\n // pipeline view is projected: spreading preserves the component,\n // metadata, middleware and boundary exports while making a custom 404's\n // loader uncallable.\n page: skipPageLoader\n ? {\n ...pageModule,\n // Vite and native ESM loaders hand us module namespace objects,\n // whose export descriptors are not an object-spread contract.\n // Keep the rendering export explicitly while hiding only loader.\n default: pageModule.default,\n loader: undefined,\n }\n : pageModule,\n };\n\r\n const requestUrl = request.path;\n const [requestPathname] = requestUrl.split(\"?\");\n const routes: PageRouteEntry[] = [\n { path: matchPath === undefined ? path : matchPath(requestPathname), name, triple },\n ];\n\r\n // A DATA request runs everything above and below this line identically —\r\n // it is the same route, the same match and the same pipeline — and differs\r\n // only in what gets written at the end. Decided here, before the render, so\r\n // the branch is visibly about REPRESENTATION and not about behaviour.\r\n const rendered = await renderPageRequest(requestUrl, {\n routes,\r\n createHttp: () => ({ request, response }),\r\n loadErrorPage,\r\n });\r\n\r\n if (rendered instanceof Response) return rendered;\r\n\r\n // See `statusForRenderedOk`: a settled 200 is the only status this route is\r\n // allowed to restate, and both the document and the data branch below must\r\n // restate it the same way — a client navigation that received 200 with a\r\n // not-found payload would push the URL into history as a real page.\r\n const status =\r\n rendered.status === 200 && statusForRenderedOk !== undefined\r\n ? statusForRenderedOk\r\n : rendered.status;\r\n\r\n // Stage 10a: the stage 7 commit (headers, then cookies), applied ONCE,\r\n // identically for the document and the data representation — see\r\n // `applyCommit`.\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n if (wantsData) {\r\n // So a shared cache can never serve a document to a client that asked for\r\n // JSON, or the reverse. See `data-request.ts` on why this stays even\r\n // while page responses are `no-store`.\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n\r\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\r\n // ran and there is no payload to build. Fastify already matched this\r\n // route to get here, so reaching it means `request.path` did not satisfy\r\n // the entry's own pattern — answered as the 404 it is, rather than\r\n // synthesising an empty payload the client would try to render as a page.\r\n if (rendered.bundle === undefined) {\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify({ error: \"not_found\" }), status);\r\n\r\n return;\r\n }\r\n\r\n // SERIALIZED HERE, and handed over as a STRING on purpose.\r\n //\r\n // `response.send(object)` runs the body through core's `Response.parse`,\r\n // which recurses the object, calls `toJSON()` on anything that has one\r\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\r\n // the right behaviour for a controller returning Resources; it is the\r\n // wrong behaviour here, because the DOCUMENT path serializes this exact\r\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\r\n // one path through a transformer and not the other is precisely the\r\n // drift `build-hydration-payload.ts` exists to prevent — the browser\r\n // would build one tree on a page load and a different one on a\r\n // navigation to the same URL.\r\n //\r\n // A string body also bypasses `parseBody()` entirely, so the content type\r\n // has to be declared rather than inferred from an object body.\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), status);\r\n\r\n return;\r\n }\r\n\r\n // Stylesheets first: they go in `<head>`, the hydration module goes before\r\n // `</body>`, and doing the head work on the already-rendered string keeps\r\n // both splices in one place rather than threading CSS through the React\r\n // render just to reach the same bytes.\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n const html = installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n\r\n await response.html(html, status);\r\n } catch (thrown) {\r\n // This is outside the page pipeline: loading/registering a module can\r\n // fail before a triple exists for its authored boundaries to handle.\r\n // Reuse this request/response pair so headers, nonce and response\r\n // ownership remain exactly the same as the ordinary path.\r\n //\r\n // Nested try/catch, deliberately: this block's own job is to render a\r\n // NICER answer for `thrown` — it must never let a failure IN THAT\r\n // ATTEMPT (`renderPageFailure` itself throwing, or misbehaving) replace\r\n // `thrown` with a less useful error. If rendering the failure page\r\n // fails too, the original throw escapes exactly as it would have with\r\n // no try/catch at all (the file header's stated contract) — the\r\n // router's own error path is still the answer, just one throw later.\r\n try {\r\n const rendered = await renderPageFailure({\r\n name,\r\n path: request.path,\r\n request,\r\n response,\r\n thrown,\r\n loadErrorPage,\r\n });\r\n\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n if (wantsData) {\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle!)), 500);\r\n return;\r\n }\r\n\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n // `renderPageFailure` marks its bundle non-hydrating (page-render-bundle.ts):\r\n // there is no triple, so there is nothing on the client the hydration\r\n // module could attach to. Injecting it anyway would ship a script that\r\n // hydrates against a composition the server never trusted.\r\n const html = isNonHydrating(rendered.bundle)\r\n ? styled\r\n : installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n await response.html(html, 500);\r\n } catch {\r\n throw thrown;\r\n }\r\n }\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\r\n * The page handler, as a named seam.\r\n *\r\n * This is the request handler `installPageRoutes` used to inline into its\r\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\r\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\r\n * The behaviour is unchanged, byte for byte — what changes is that it is now\r\n * a named, exported, independently constructible function instead of a closure\r\n * over eight ambient bindings of `installPageRoutes`.\r\n *\r\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\r\n * only capability the handler ever needed, and the two runtimes answer it\r\n * differently — dev goes through Vite's SSR graph\r\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\r\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\r\n * module\" as an INPUT is what lets the same handler serve both, and what lets\r\n * a test construct it with a plain async function — no Vite, no dev server, no\r\n * `app/` directory on disk.\r\n *\r\n * Scope: this file creates a seam and nothing else. It does not implement\r\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\r\n */\r\nimport { container, Response, type FastifyInstance, type HttpContext } from \"@warlock.js/core\";\r\n\r\nimport {\r\n DATA_RESPONSE_CONTENT_TYPE,\r\n isDataRequest,\r\n WARLOCK_DATA_REQUEST_HEADER,\r\n} from \"../routing/data-request\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../runtime/register-modules\";\r\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport { applyResponseCacheFloor } from \"./response-cache-floor\";\r\nimport type { PageCacheOptIn } from \"../routing/route-identity\";\r\nimport { ensureSetCookieCacheFloorHook, markPageResponse } from \"./set-cookie-cache-floor-hook\";\r\nimport type { BufferedCookie, PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { isNonHydrating } from \"./page-render-bundle\";\r\nimport { renderPageFailure, renderPageRequest, type RenderedPage } from \"./render-page\";\r\n\r\ndeclare module \"@warlock.js/core\" {\r\n interface RequestLocals {\r\n /**\r\n * Set by this file's route handler, on every page-route response\r\n * (document and data representations alike) — never inferred from URL\r\n * shape or content-type. `set-cookie-cache-floor-hook.ts`'s `onSend` hook\r\n * reads this to scope its effect to page responses only.\r\n */\r\n isPageResponse?: boolean;\r\n }\r\n}\r\nimport type { ErrorPageModuleLoader } from \"./error-page\";\r\n\r\n/**\r\n * Raised when a page route handler is constructed WITHOUT an `httpServer`\r\n * option AND the framework container has no `\"http.server\"` binding either —\r\n * i.e. there is no way, deliberate or ambient, to register the `Set-Cookie`\r\n * cache-floor hook. `container.get(\"http.server\")` (`core/src/container/index.ts`)\r\n * is a bare `Map.get` that TypeScript types as always returning a\r\n * `FastifyInstance`, so a silently-missing binding used to read as \"no\r\n * server\" and skip the hook with no signal at all. This throws instead of\r\n * repeating that mistake. To fix: register `http.server` in the container\r\n * before this factory runs (the ordinary `HttpConnector.boot()` path), or —\r\n * if this handler genuinely has no server on purpose, such as a unit test —\r\n * pass `httpServer: undefined` explicitly to say so.\r\n */\r\nexport class MissingHttpServerForPageRouteError extends Error {\r\n public constructor() {\r\n super(\r\n 'createPageRouteHandler: no \"httpServer\" option was supplied and the container has no ' +\r\n '\"http.server\" binding, so the Set-Cookie cache-floor hook on page responses cannot be ' +\r\n \"registered. Register `http.server` in the container before this factory runs, or pass \" +\r\n \"`httpServer: undefined` explicitly if this handler is meant to have no server.\",\r\n );\r\n this.name = \"MissingHttpServerForPageRouteError\";\r\n }\r\n}\r\n\r\n/**\r\n * Replay ONE committed cookie through core's own `Response.cookie()` — the\r\n * same serializer every ordinary controller's cookie goes through, so there\r\n * is nothing here for a second implementation to drift from. The one-liner\r\n * `dev-server.ts` wires as the production default; passed in (`applyBufferedCookie`\r\n * option, below) rather than imported so this file stays free of anything\r\n * Vite-shaped.\r\n */\r\nfunction defaultApplyBufferedCookie(response: Response, cookie: BufferedCookie): void {\r\n response.cookie(cookie.name, cookie.value as never, cookie.options ?? {});\r\n}\r\n\r\n/**\r\n * Stage 10a — apply the stage 7 commit (headers, then cookies) to the LIVE\r\n * response, once, before either terminal write (10b: `html()` or `send()`).\r\n * Both the document and data representations of a page route go through this\r\n * so a client navigation never drops a `Set-Cookie` a full load would have\r\n * kept (`create-page-route-handler.spec.ts` — \"applies committed cookies and\r\n * headers exactly as the document path does\").\r\n */\r\nfunction applyCommit(\r\n response: Response,\r\n rendered: Pick<RenderedPage, \"headers\" | \"cookies\">,\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void,\r\n): void {\r\n response.headers(rendered.headers ?? {});\r\n\r\n for (const cookie of rendered.cookies ?? []) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n}\r\n\r\n/**\r\n * How the handler obtains a page/layout/app module, by the same id\r\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\r\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\r\n * owns the dev/prod split, so the handler never learns which one it got.\r\n */\r\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\r\n\r\nexport type PageRouteHandlerOptions = {\r\n /** The composed, registered route path — `composeRoutePath`'s output. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\r\n appFile: string;\r\n /** The page module's id. */\r\n pageFile: string;\r\n /** The page's own-directory `layout.tsx`, when it has one. */\r\n layoutFile?: string | undefined;\r\n loadModule: PageModuleLoader;\r\n /** Optional lazy application `error.page.tsx` loader. Never called on success. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n /**\r\n * Load the REAL layout module namespaces, outermost first, for universal\r\n * registration. This stays separate from `loadModule(layoutFile)` because\r\n * dev may answer that id with a synthetic wrapper whose middleware is the\r\n * composition of several layouts. That wrapper is a render-pipeline detail,\r\n * not a module identity, and must never enter `registerModules`' WeakSet.\r\n */\r\n loadRegistrationLayouts?: () => Promise<readonly RegisterableModuleNamespace[]>;\r\n /** Browser module appended after the server-rendered document. */\r\n hydrationClientModuleUrl?: string;\r\n /**\r\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\r\n * styled. Absent or empty means the application has no CSS — it never means\r\n * a stylesheet failed to resolve, which is the build's job to report.\r\n */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n /**\r\n * The pattern stage 1 matches `request.path` against, when it differs from\r\n * the REGISTERED path. Defaults to `path`, which is right for every route\r\n * whose URL is its own.\r\n *\r\n * Exactly one route needs it: the not-found page, registered on the catch-all\r\n * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and\r\n * has no wildcard token, so a route registered as `*` matches NOTHING — the\r\n * pipeline reports no match and `renderPageRequest` answers `{ html: \"\",\r\n * status: 404 }`. Correct status, empty document: a 404 page that never\r\n * renders its own body. Handing it `requestPath => requestPath` makes the\r\n * requested URL the route's pattern for that one request, so the match is\r\n * trivially true and the page renders for the URL the visitor actually asked\r\n * for.\r\n */\r\n matchPath?: (requestPath: string) => string;\r\n /**\r\n * The status this route answers with when the pipeline settles on a plain\r\n * `200` — the not-found route's `404`, and nothing else uses it.\r\n *\r\n * Applied ONLY to `200`, never as a blanket override: a `200` from this\r\n * pipeline means \"the document rendered and nobody objected\", which for this\r\n * route is precisely the not-found case. Any other settled status is a real\r\n * outcome that the page or the boundary decided — a 500 from a failed render,\r\n * a redirect — and overwriting it would report a broken page as a missing one.\r\n */\r\n statusForRenderedOk?: number;\r\n /**\r\n * Exclude the page module's loader from the request triple while preserving\r\n * the real namespace for `register()` and rendering. Used only by the\r\n * catch-all 404 page: a missing URL must not run application data work or\r\n * turn a simple miss into a second failure path.\r\n */\r\n skipPageLoader?: boolean;\r\n /**\r\n * Replays one committed cookie through core's `Response.cookie()`. Defaults\r\n * to doing exactly that (`defaultApplyBufferedCookie`, above); injectable so\r\n * a caller with a different `Response` shape (or a test) can observe/replace\r\n * the call.\r\n */\r\n applyBufferedCookie?: (response: Response, cookie: BufferedCookie) => void;\r\n /**\r\n * The Fastify instance to register the `Set-Cookie` cache-floor `onSend`\r\n * hook on (`ensureSetCookieCacheFloorHook`, `set-cookie-cache-floor-hook.ts`).\r\n * Defaults to `container.get(\"http.server\")` — the same instance\r\n * `HttpConnector` publishes during its own `boot()`, which runs before\r\n * `WebConnector.boot()` calls this factory. Injectable so a test can hand\r\n * this factory a self-contained Fastify instance it built and booted\r\n * itself, with no framework connector graph involved.\r\n */\r\n httpServer?: FastifyInstance;\r\n /**\r\n * This route's resolved `cache` opt-in, already validated\r\n * ({@link resolvePageRouteCache}) by whichever installer (dev's\r\n * `install-page-routes.ts` or production's\r\n * `install-page-routes-from-manifest.ts`) built these options — `undefined`\r\n * means the route declared no `cache` at all. Read by\r\n * `applyResponseCacheFloor` (`response-cache-floor.ts`) at the same seam\r\n * that applies the `Set-Cookie`/auth-derived floor, so the document and the\r\n * data representation can never disagree on `Cache-Control`.\r\n */\r\n cache?: PageCacheOptIn;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void | Response>;\r\n\r\nfunction escapeHtmlAttribute(value: string): string {\r\n return value.replace(/[&<>\"']/g, (character) => {\r\n switch (character) {\r\n case \"&\":\r\n return \"&\";\r\n case \"<\":\r\n return \"<\";\r\n case \">\":\r\n return \">\";\r\n case '\"':\r\n return \""\";\r\n default:\r\n return \"'\";\r\n }\r\n });\r\n}\r\n\r\nfunction installHydrationClientModule(\r\n html: string,\r\n moduleUrl: string | undefined,\r\n nonce: string | undefined,\r\n): string {\r\n if (moduleUrl === undefined || html === \"\") return html;\r\n\r\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\r\n if (closingBodyIndex === -1) {\r\n throw new Error(\r\n \"installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\r\n );\r\n }\r\n\r\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\r\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\r\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\r\n}\r\n\r\n/**\r\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\r\n *\r\n * Without this the document carries no CSS at all. The stylesheet reaches the\r\n * browser only because the CLIENT bundle imports it, which means it is applied\r\n * by JavaScript after the module graph loads — the page renders unstyled first\r\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\r\n * in the console to explain it.\r\n *\r\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\r\n * here: the browser holds the first paint until the CSS is in, so there is no\r\n * flash rather than a faster ugly one.\r\n *\r\n * Inserted before `</head>` rather than after `<head>` so an application's own\r\n * `<link>`/`<style>` in the root document still comes FIRST and can be\r\n * overridden by these — matching how the framework's tags are documented to\r\n * behave, and keeping cascade order predictable.\r\n */\r\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\r\n if (stylesheetUrls.length === 0 || html === \"\") return html;\r\n\r\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\r\n\r\n // No `<head>` is not an error the way a missing `</body>` is: a root that\r\n // renders no head is unusual but legal, and losing the stylesheet is a\r\n // cosmetic failure where losing hydration is a broken page. Silently\r\n // dropping it would be the wrong trade the other way, though — so the\r\n // document is left exactly as rendered and the caller's own missing-`</body>`\r\n // check remains the loud one.\r\n if (closingHeadIndex === -1) return html;\r\n\r\n const links = stylesheetUrls\r\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\r\n .join(\"\");\r\n\r\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\r\n}\r\n\r\n/**\r\n * Build the handler for ONE page route. Per request it loads the App + layout\r\n * + page triple (concurrently, in that order), renders the URL through\r\n * `renderPageRequest`, splices in the hydration module, and flushes the\r\n * document.\r\n *\r\n * No try/catch, deliberately: loader/render throws are already absorbed by the\r\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\r\n * escapes (a module-load or register failure, the missing-`</body>` throw\r\n * above) belongs to the router's error path — which is exactly where it went\r\n * before.\r\n */\r\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\r\n const {\r\n path,\r\n name,\r\n appFile,\r\n pageFile,\r\n layoutFile,\r\n loadModule,\r\n loadErrorPage,\r\n loadRegistrationLayouts,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n matchPath,\r\n statusForRenderedOk,\r\n skipPageLoader = false,\r\n applyBufferedCookie = defaultApplyBufferedCookie,\r\n cache,\r\n } = options;\r\n\r\n // Distinguish \"not supplied\" (fall back to the container, and REQUIRE the\r\n // container to have it) from \"supplied as `undefined`\" (a deliberate \"this\r\n // handler has no server\" — the escape hatch unit tests use). Collapsing\r\n // both into one optional-with-a-default, as this used to, let a genuinely\r\n // missing `http.server` container binding masquerade as the deliberate\r\n // no-server case with no signal at all — see `MissingHttpServerForPageRouteError`.\r\n let httpServer: FastifyInstance | undefined;\r\n\r\n if (\"httpServer\" in options) {\r\n httpServer = options.httpServer;\r\n } else if (container.has(\"http.server\")) {\r\n httpServer = container.get(\"http.server\");\r\n } else {\r\n throw new MissingHttpServerForPageRouteError();\r\n }\r\n\r\n // Registration-time, not request-time: this runs once per page route, while\r\n // `WebConnector.boot()` installs routes — after `HttpConnector.boot()` has\r\n // already registered `@fastify/cookie` (`set-cookie-cache-floor-hook.ts`\r\n // explains why that ordering is what makes the hook able to see the\r\n // header). `httpServer` is `undefined` here only when it was supplied that\r\n // way explicitly (checked above) — nothing to register the hook on, and\r\n // nothing that will ever mark a request as a page response either, so\r\n // skipping is correct, not just safe.\r\n if (httpServer) {\r\n ensureSetCookieCacheFloorHook(httpServer);\r\n }\r\n\r\n return async ({ request, response }: HttpContext) => {\r\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n try {\r\n const [appModule, layoutModule, ownPageModule, registrationLayouts] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n loadRegistrationLayouts?.() ?? Promise.resolve([]),\r\n ]);\r\n\r\n // Registration is the first lifecycle action after all module namespaces\r\n // have loaded and before `renderPageRequest` can run middleware, loaders or\r\n // render. App/page are already their real namespaces. Layouts deliberately\r\n // come from the separate raw chain above, never from `layoutModule`, which\r\n // may be the synthetic composed middleware wrapper used by dev.\r\n registerModules([\r\n appModule as RegisterableModuleNamespace,\r\n ...registrationLayouts,\r\n ownPageModule as RegisterableModuleNamespace,\r\n ]);\r\n\r\n const pageModule = ownPageModule as PageTripleModule;\r\n const triple: PageRouteEntry[\"triple\"] = {\r\n app: appModule as PageTripleModule,\r\n layout: layoutModule as PageTripleModule,\r\n // Registration above deliberately receives the REAL namespace. Only the\r\n // pipeline view is projected: spreading preserves the component,\r\n // metadata, middleware and boundary exports while making a custom 404's\r\n // loader uncallable.\r\n page: skipPageLoader\r\n ? {\r\n ...pageModule,\r\n // Vite and native ESM loaders hand us module namespace objects,\r\n // whose export descriptors are not an object-spread contract.\r\n // Keep the rendering export explicitly while hiding only loader.\r\n default: pageModule.default,\r\n loader: undefined,\r\n }\r\n : pageModule,\r\n };\r\n\r\n const requestUrl = request.path;\r\n const [requestPathname] = requestUrl.split(\"?\");\r\n const routes: PageRouteEntry[] = [\r\n { path: matchPath === undefined ? path : matchPath(requestPathname), name, triple },\r\n ];\r\n\r\n // A DATA request runs everything above and below this line identically —\r\n // it is the same route, the same match and the same pipeline — and differs\r\n // only in what gets written at the end. Decided here, before the render, so\r\n // the branch is visibly about REPRESENTATION and not about behaviour.\r\n const rendered = await renderPageRequest(requestUrl, {\r\n routes,\r\n createHttp: () => ({ request, response }),\r\n loadErrorPage,\r\n });\r\n\r\n if (rendered instanceof Response) return rendered;\r\n\r\n // See `statusForRenderedOk`: a settled 200 is the only status this route is\r\n // allowed to restate, and both the document and the data branch below must\r\n // restate it the same way — a client navigation that received 200 with a\r\n // not-found payload would push the URL into history as a real page.\r\n const status =\r\n rendered.status === 200 && statusForRenderedOk !== undefined\r\n ? statusForRenderedOk\r\n : rendered.status;\r\n\r\n // Stage 10a: the stage 7 commit (headers, then cookies), applied ONCE,\r\n // identically for the document and the data representation — see\r\n // `applyCommit`.\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n // Marks this request for `set-cookie-cache-floor-hook.ts`'s `onSend`\r\n // hook, which runs LATER than this seam — after `@fastify/cookie` has\r\n // flushed a parked `setCookie()`/`clearCookie()` call onto the real\r\n // header. Must happen before either terminal write below, same as\r\n // `applyResponseCacheFloor` just below it.\r\n markPageResponse(request);\r\n\r\n // `request.locals.authDerived` (core `Request`) is set the moment `user`\r\n // or `decodedAccessToken` is assigned, and never cleared. Overriding\r\n // `Cache-Control` here — after `applyCommit`'s default `private` and\r\n // before EITHER terminal write below — closes two gaps `private` alone\r\n // leaves open: a browser (not a shared cache; `private` already stops\r\n // those) holding an authenticated page in its own disk/back-forward\r\n // cache with no freshness directive, AND a `Set-Cookie` response held in\r\n // a shared cache handing the same cookie to every later visitor\r\n // (session fixation — see `response-cache-floor.ts`). Read once,\r\n // applied identically to both representations, so neither can carry a\r\n // weaker header than the other.\r\n //\r\n // TRI-STATE, deliberately, not `=== true`: several existing unit tests\r\n // hand this handler a plain `{ path, header }` mock with no `locals` at\r\n // all, never a real core `Request` — that is the auth mark mechanism\r\n // being genuinely UNOBSERVABLE on this request, not the mechanism\r\n // having fired `false`. Collapsing both into one boolean via\r\n // `request.locals?.authDerived === true` used to read \"unobservable\" as\r\n // \"provably clean\", which let an opted-in route serve `public,\r\n // max-age=N` to a request nobody could actually vouch for. The ruling\r\n // for the per-route cache opt-in is fail-CLOSED — unproven means\r\n // revoked — so `undefined` is passed through as its own state here and\r\n // it is `applyResponseCacheFloor` (`response-cache-floor.ts`) that\r\n // decides what each of the three states does to the opt-in; this seam\r\n // only reports what it actually knows.\r\n applyResponseCacheFloor(response, {\r\n authDerived: request.locals === undefined ? undefined : request.locals.authDerived === true,\r\n cache,\r\n });\r\n\r\n if (wantsData) {\r\n // So a shared cache can never serve a document to a client that asked for\r\n // JSON, or the reverse. See `data-request.ts` on why this stays even\r\n // while page responses are `no-store`.\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n\r\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\r\n // ran and there is no payload to build. Fastify already matched this\r\n // route to get here, so reaching it means `request.path` did not satisfy\r\n // the entry's own pattern — answered as the 404 it is, rather than\r\n // synthesising an empty payload the client would try to render as a page.\r\n if (rendered.bundle === undefined) {\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(JSON.stringify({ error: \"not_found\" }), status);\r\n\r\n return;\r\n }\r\n\r\n // SERIALIZED HERE, and handed over as a STRING on purpose.\r\n //\r\n // `response.send(object)` runs the body through core's `Response.parse`,\r\n // which recurses the object, calls `toJSON()` on anything that has one\r\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\r\n // the right behaviour for a controller returning Resources; it is the\r\n // wrong behaviour here, because the DOCUMENT path serializes this exact\r\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\r\n // one path through a transformer and not the other is precisely the\r\n // drift `build-hydration-payload.ts` exists to prevent — the browser\r\n // would build one tree on a page load and a different one on a\r\n // navigation to the same URL.\r\n //\r\n // A string body also bypasses `parseBody()` entirely, so the content type\r\n // has to be declared rather than inferred from an object body.\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\n JSON.stringify(buildHydrationPayload(rendered.bundle, request.locale)),\n status,\n );\n\r\n return;\r\n }\r\n\r\n // Stylesheets first: they go in `<head>`, the hydration module goes before\r\n // `</body>`, and doing the head work on the already-rendered string keeps\r\n // both splices in one place rather than threading CSS through the React\r\n // render just to reach the same bytes.\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n const html = installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n\r\n await response.html(html, status);\r\n } catch (thrown) {\r\n // This is outside the page pipeline: loading/registering a module can\r\n // fail before a triple exists for its authored boundaries to handle.\r\n // Reuse this request/response pair so headers, nonce and response\r\n // ownership remain exactly the same as the ordinary path.\r\n //\r\n // Nested try/catch, deliberately: this block's own job is to render a\r\n // NICER answer for `thrown` — it must never let a failure IN THAT\r\n // ATTEMPT (`renderPageFailure` itself throwing, or misbehaving) replace\r\n // `thrown` with a less useful error. If rendering the failure page\r\n // fails too, the original throw escapes exactly as it would have with\r\n // no try/catch at all (the file header's stated contract) — the\r\n // router's own error path is still the answer, just one throw later.\r\n try {\r\n const rendered = await renderPageFailure({\r\n name,\r\n path: request.path,\r\n request,\r\n response,\r\n thrown,\r\n loadErrorPage,\r\n });\r\n\r\n applyCommit(response, rendered, applyBufferedCookie);\r\n\r\n if (wantsData) {\r\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\r\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\r\n await response.send(\n JSON.stringify(buildHydrationPayload(rendered.bundle!, request.locale)),\n 500,\n );\n return;\r\n }\r\n\r\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\r\n\r\n // `renderPageFailure` marks its bundle non-hydrating (page-render-bundle.ts):\r\n // there is no triple, so there is nothing on the client the hydration\r\n // module could attach to. Injecting it anyway would ship a script that\r\n // hydrates against a composition the server never trusted.\r\n const html = isNonHydrating(rendered.bundle)\r\n ? styled\r\n : installHydrationClientModule(\r\n styled,\r\n hydrationClientModuleUrl,\r\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\r\n );\r\n await response.html(html, 500);\r\n } catch {\r\n throw thrown;\r\n }\r\n }\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execute-page-request.mjs","names":[],"sources":["../../../../../../../web/src/server/execute-page-request.ts"],"sourcesContent":["import { Response } from \"@warlock.js/core\";\nimport { v } from \"@warlock.js/seal\";\nimport { enterSharedScope, sealShared } from \"../shared\";\nimport { enterAdditionalSharedScope, requireRunner } from \"./page-context\";\nimport { matchRoute } from \"./match-page-route\";\nimport { resolvePageMetadata } from \"./resolve-page-metadata\";\nimport { resolveValidationData } from \"./resolve-validation-data\";\nimport {\n buildErrorRecord,\n commitBuffers,\n createBufferedResponse,\n createLevelBuffer,\n designateBoundary,\n isLoaderShortCircuit,\n LEVEL_ORDER,\n type LevelBuffer,\n type PageResponseCommit,\n} from \"./settle-page-response\";\nimport type {\n ExecutePageRequestOptions,\n PageDataBundle,\n PageLevelName,\n PageRouteMatch,\n PipelineStore,\n} from \"./execute-page-request.types\";\n\nexport * from \"./execute-page-request.types\";\nexport { connectPageContext } from \"./page-context\";\nexport {\n buildErrorRecord,\n designateBoundary,\n type BufferedCookie,\n type BufferedHeader,\n type LoaderShortCircuitKind,\n type PageResponseCommit,\n} from \"./settle-page-response\";\n\n/** Widens `PageDataBundle` with the two fields stage 6/7 populate. */\ntype Bundle = PageDataBundle & {\n commit?: PageResponseCommit;\n shortCircuit?: PageDataBundle[\"shortCircuit\"] & {\n kind?: \"redirect\" | \"notFound\";\n url?: string;\n body?: unknown;\n };\n};\n\nexport async function executePageRequest<TResult = PageDataBundle>(\n options: ExecutePageRequestOptions<TResult>,\n): Promise<TResult | Response | undefined> {\n const runner = requireRunner();\n const [pathname, queryString] = options.url.split(\"?\");\n const matched = matchRoute(pathname, options.routes);\n\n if (!matched) return undefined;\n\n const query = Object.fromEntries(new URLSearchParams(queryString ?? \"\"));\n const match: PageRouteMatch = { entry: matched.entry, params: matched.params, query };\n const { triple } = matched.entry;\n const { request, response } = options.createHttp(match);\n const store: PipelineStore = runner.buildStore\n ? runner.buildStore({ request, response })\n : { request, response };\n\n return runner.run(store, async () => {\n enterSharedScope(store);\n enterAdditionalSharedScope(store);\n\n const finish = async (bundle: PageDataBundle): Promise<TResult> =>\n options.finish ? await options.finish(bundle) : (bundle as TResult);\n\n const bundle: Bundle = {\n route: {\n name: matched.entry.name,\n path: matched.entry.path,\n params: match.params,\n query,\n },\n };\n\n for (const level of LEVEL_ORDER) {\n for (const middleware of triple[level].middleware ?? []) {\n let output: unknown;\n\n try {\n output = await middleware({ request, response });\n } catch (thrown) {\n bundle.error = buildErrorRecord(thrown, designateBoundary(level, triple), pathname);\n response.setStatusCode(500);\n return finish(bundle);\n }\n\n if (output !== undefined) {\n bundle.shortCircuit = {\n stage: \"middleware\",\n level,\n value: output,\n statusCode: response.statusCode,\n };\n return finish(bundle);\n }\n }\n }\n\n const validation = triple.page.validation;\n\n if (validation?.schema) {\n const data = resolveValidationData(validation.validating, request);\n const result = await v.validate(validation.schema, data);\n\n if (result.isValid && result.data) {\n request.setValidatedData(result.data);\n }\n\n if (!result.isValid) {\n bundle.shortCircuit = { stage: \"validation\", status: 422, errors: result.errors };\n return finish(bundle);\n }\n }\n\n const sealedShared = await sealShared(store);\n bundle.shared = sealedShared;\n\n const dataKeys: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n };\n\n // Stage 6 — LOADERS, root to leaf. Every level gets its OWN buffer (never\n // the live response), and a terminal result or throw prevents every lower\n // loader from starting.\n const buffers: Record<PageLevelName, LevelBuffer> = {\n app: createLevelBuffer(),\n layout: createLevelBuffer(),\n page: createLevelBuffer(),\n };\n\n let signalIndex = -1;\n let signalKind: \"throw\" | \"shortCircuit\" | undefined;\n let signalThrown: unknown;\n let signalCircuit:\n | { kind: \"redirect\" | \"notFound\"; statusCode: number; url?: string; body?: unknown }\n | undefined;\n\n for (let index = 0; index < LEVEL_ORDER.length; index++) {\n const level = LEVEL_ORDER[index];\n const loader = triple[level].loader;\n\n if (!loader) continue;\n\n let value: unknown;\n\n try {\n value = await loader({\n request,\n response: createBufferedResponse(buffers[level]),\n shared: sealedShared,\n });\n } catch (thrown) {\n signalIndex = index;\n signalKind = \"throw\";\n signalThrown = thrown;\n break;\n }\n\n if (value instanceof Response) return value;\n\n if (isLoaderShortCircuit(value)) {\n signalIndex = index;\n signalKind = \"shortCircuit\";\n signalCircuit = value;\n break;\n }\n\n bundle[dataKeys[level]] = value;\n }\n\n let committedLevels: PageLevelName[];\n /** Set only when a THROW escalated to the app boundary — forces 500. */\n let forcedStatusCode: number | undefined;\n\n if (signalIndex === -1) {\n committedLevels = [...LEVEL_ORDER];\n } else if (signalKind === \"throw\") {\n // The throwing level's buffer is discarded; lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex);\n\n const boundary = designateBoundary(LEVEL_ORDER[signalIndex], triple);\n bundle.error = buildErrorRecord(signalThrown, boundary, pathname);\n\n if (boundary.boundaryLevel === \"app\") {\n response.setStatusCode(500);\n forcedStatusCode = 500;\n }\n } else {\n // Short-circuit: the signalling level's OWN buffer commits too\n // (inclusive); lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex + 1);\n\n const circuit = signalCircuit!;\n\n bundle.shortCircuit = {\n stage: \"loaders\",\n level: LEVEL_ORDER[signalIndex],\n kind: circuit.kind,\n statusCode: circuit.statusCode,\n url: circuit.url,\n body: circuit.body,\n } as unknown as PageDataBundle[\"shortCircuit\"];\n }\n\n bundle.commit = commitBuffers(response, buffers, committedLevels);\n\n // Forced AFTER the fold: an app-boundary escalation forces 500\n // regardless of what the surviving (rootward) buffers happened to set —\n // it is the framework's answer, not a loader's.\n if (forcedStatusCode !== undefined) bundle.commit.statusCode = forcedStatusCode;\n\n // Stage 8 — METADATA. Skipped entirely for a short-circuit (there is no\n // page to describe); a throw still runs it, same as before this stage 6/7\n // rewrite (a boundary still needs a title/robots answer).\n if (bundle.shortCircuit) {\n return finish(bundle);\n }\n\n const resolved = resolvePageMetadata({\n metadata: triple.page.metadata,\n data: bundle.pageData,\n error: bundle.error?.error,\n failed: Boolean(bundle.error),\n shared: sealedShared,\n });\n\n bundle.metadata = resolved.metadata;\n\n if (resolved.thrown !== undefined) {\n const boundary = designateBoundary(\"page\", triple);\n bundle.error = buildErrorRecord(resolved.thrown, boundary, bundle.route.path);\n\n if (boundary.boundaryLevel === \"app\") response.setStatusCode(500);\n }\n\n return finish(bundle);\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"execute-page-request.mjs","names":[],"sources":["../../../../../../../web/src/server/execute-page-request.ts"],"sourcesContent":["import { Response } from \"@warlock.js/core\";\nimport { v } from \"@warlock.js/seal\";\nimport { enterSharedScope, sealShared } from \"../shared\";\nimport { enterAdditionalSharedScope, requireRunner } from \"./page-context\";\nimport { matchRoute } from \"./match-page-route\";\nimport { resolvePageMetadata } from \"./resolve-page-metadata\";\nimport { resolveValidationData } from \"./resolve-validation-data\";\nimport {\n buildErrorRecord,\n commitBuffers,\n createBufferedResponse,\n createLevelBuffer,\n designateBoundary,\n isLoaderShortCircuit,\n LEVEL_ORDER,\n type LevelBuffer,\n type PageResponseCommit,\n} from \"./settle-page-response\";\nimport type {\n ExecutePageRequestOptions,\n PageDataBundle,\n PageLevelName,\n PageRouteMatch,\n PipelineStore,\n} from \"./execute-page-request.types\";\n\nexport * from \"./execute-page-request.types\";\nexport { connectPageContext } from \"./page-context\";\nexport {\n buildErrorRecord,\n designateBoundary,\n type BufferedCookie,\n type BufferedHeader,\n type LoaderShortCircuitKind,\n type PageResponseCommit,\n} from \"./settle-page-response\";\n\n/** Widens `PageDataBundle` with the two fields stage 6/7 populate. */\ntype Bundle = PageDataBundle & {\n commit?: PageResponseCommit;\n shortCircuit?: PageDataBundle[\"shortCircuit\"] & {\n kind?: \"redirect\" | \"notFound\";\n url?: string;\n body?: unknown;\n };\n};\n\nexport async function executePageRequest<TResult = PageDataBundle>(\n options: ExecutePageRequestOptions<TResult>,\n): Promise<TResult | Response | undefined> {\n const runner = requireRunner();\n const [pathname, queryString] = options.url.split(\"?\");\n const matched = matchRoute(pathname, options.routes);\n\n if (!matched) return undefined;\n\n const query = Object.fromEntries(new URLSearchParams(queryString ?? \"\"));\n const match: PageRouteMatch = { entry: matched.entry, params: matched.params, query };\n const { triple } = matched.entry;\n const { request, response } = options.createHttp(match);\n const store: PipelineStore = runner.buildStore\n ? runner.buildStore({ request, response })\n : { request, response };\n\n return runner.run(store, async () => {\n enterSharedScope(store);\n enterAdditionalSharedScope(store);\n\n const finish = async (bundle: PageDataBundle): Promise<TResult> =>\n options.finish ? await options.finish(bundle) : (bundle as TResult);\n\n const bundle: Bundle = {\n route: {\n name: matched.entry.name,\n path: matched.entry.path,\n params: match.params,\n query,\n },\n };\n\n for (const level of LEVEL_ORDER) {\n for (const middleware of triple[level].middleware ?? []) {\n let output: unknown;\n\n try {\n output = await middleware({ request, response });\n } catch (thrown) {\n bundle.error = buildErrorRecord(thrown, designateBoundary(level, triple), pathname);\n response.setStatusCode(500);\n return finish(bundle);\n }\n\n if (output !== undefined) {\n bundle.shortCircuit = {\n stage: \"middleware\",\n level,\n value: output,\n statusCode: response.statusCode,\n };\n return finish(bundle);\n }\n }\n }\n\n const validation = triple.page.validation;\n\n if (validation?.schema) {\n const data = resolveValidationData(validation.validating, request);\n const result = await v.validate(validation.schema, data);\n\n if (result.isValid && result.data) {\n request.setValidatedData(result.data);\n }\n\n if (!result.isValid) {\n bundle.shortCircuit = { stage: \"validation\", status: 422, errors: result.errors };\n return finish(bundle);\n }\n }\n\n const sealedShared = await sealShared(store);\n bundle.shared = sealedShared;\n\n const dataKeys: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n };\n\n // Stage 6 — LOADERS, root to leaf. Every level gets its OWN buffer (never\n // the live response), and a terminal result or throw prevents every lower\n // loader from starting.\n const buffers: Record<PageLevelName, LevelBuffer> = {\n app: createLevelBuffer(),\n layout: createLevelBuffer(),\n page: createLevelBuffer(),\n };\n\n let signalIndex = -1;\n let signalKind: \"throw\" | \"shortCircuit\" | undefined;\n let signalThrown: unknown;\n let signalCircuit:\n | { kind: \"redirect\" | \"notFound\"; statusCode: number; url?: string; body?: unknown }\n | undefined;\n\n for (let index = 0; index < LEVEL_ORDER.length; index++) {\n const level = LEVEL_ORDER[index];\n const loader = triple[level].loader;\n\n if (!loader) continue;\n\n let value: unknown;\n\n try {\n value = await loader({\n request,\n response: createBufferedResponse(buffers[level]),\n shared: sealedShared,\n });\n } catch (thrown) {\n signalIndex = index;\n signalKind = \"throw\";\n signalThrown = thrown;\n break;\n }\n\n if (value instanceof Response) return value;\n\n if (isLoaderShortCircuit(value)) {\n signalIndex = index;\n signalKind = \"shortCircuit\";\n signalCircuit = value;\n break;\n }\n\n bundle[dataKeys[level]] = value;\n }\n\n let committedLevels: PageLevelName[];\n /** Set only when a THROW escalated to the app boundary — forces 500. */\n let forcedStatusCode: number | undefined;\n\n if (signalIndex === -1) {\n committedLevels = [...LEVEL_ORDER];\n } else if (signalKind === \"throw\") {\n // The throwing level's buffer is discarded; lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex);\n\n const boundary = designateBoundary(LEVEL_ORDER[signalIndex], triple);\n bundle.error = buildErrorRecord(signalThrown, boundary, pathname);\n\n if (boundary.boundaryLevel === \"app\") {\n response.setStatusCode(500);\n forcedStatusCode = 500;\n }\n } else {\n // Short-circuit: the signalling level's OWN buffer commits too\n // (inclusive); lower levels never ran.\n committedLevels = LEVEL_ORDER.slice(0, signalIndex + 1);\n\n const circuit = signalCircuit!;\n\n bundle.shortCircuit = {\n stage: \"loaders\",\n level: LEVEL_ORDER[signalIndex],\n kind: circuit.kind,\n statusCode: circuit.statusCode,\n url: circuit.url,\n body: circuit.body,\n } as unknown as PageDataBundle[\"shortCircuit\"];\n }\n\n bundle.commit = commitBuffers(response, buffers, committedLevels);\n\n // Forced AFTER the fold: an app-boundary escalation forces 500\n // regardless of what the surviving (rootward) buffers happened to set —\n // it is the framework's answer, not a loader's.\n if (forcedStatusCode !== undefined) bundle.commit.statusCode = forcedStatusCode;\n\n // Stage 8 — METADATA. Skipped entirely for a short-circuit (there is no\n // page to describe); a throw still runs it, same as before this stage 6/7\n // rewrite (a boundary still needs a title/robots answer).\n if (bundle.shortCircuit) {\n return finish(bundle);\n }\n\n const resolved = resolvePageMetadata({\n metadata: triple.page.metadata,\n data: bundle.pageData,\n error: bundle.error?.error,\n failed: Boolean(bundle.error),\n shared: sealedShared,\n });\n\n bundle.metadata = resolved.metadata;\n\n if (resolved.thrown !== undefined) {\n const boundary = designateBoundary(\"page\", triple);\n bundle.error = buildErrorRecord(resolved.thrown, boundary, bundle.route.path);\n\n if (boundary.boundaryLevel === \"app\") response.setStatusCode(500);\n }\n\n return finish(bundle);\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
//#region ../web/src/server/framework-default-not-found-stylesheet.ts
|
|
2
|
+
/**
|
|
3
|
+
* The framework fallback 404 document's stylesheet, duplicated here as a
|
|
4
|
+
* plain string constant.
|
|
5
|
+
*
|
|
6
|
+
* `framework-default-not-found.css` remains the source of truth for this
|
|
7
|
+
* stylesheet's content — this file is a build-time necessity, not a second
|
|
8
|
+
* design surface. The production server build refuses to compile
|
|
9
|
+
* static-asset imports (`?raw`, `?url`, `import.meta.url`) in the page
|
|
10
|
+
* graph, and says so in its own diagnostic: see the `REMEDY` text in
|
|
11
|
+
* `../build/generate-pages-barrel.ts`. `not-found-page.ts` is part of that
|
|
12
|
+
* server build, so it cannot import the `.css` file directly and must read
|
|
13
|
+
* its text from a plain module instead.
|
|
14
|
+
*
|
|
15
|
+
* `not-found-page-stylesheet.spec.ts` asserts this constant stays
|
|
16
|
+
* byte-identical to the `.css` file, so a future edit to one cannot silently
|
|
17
|
+
* drift from the other.
|
|
18
|
+
*/
|
|
19
|
+
const FRAMEWORK_DEFAULT_NOT_FOUND_STYLESHEET = `:root {
|
|
20
|
+
color-scheme: light dark;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
* {
|
|
24
|
+
box-sizing: border-box;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
margin: 0;
|
|
29
|
+
min-block-size: 100vh;
|
|
30
|
+
display: grid;
|
|
31
|
+
place-items: center;
|
|
32
|
+
padding-block: 2rem;
|
|
33
|
+
padding-inline: 1.5rem;
|
|
34
|
+
background: #fafafa;
|
|
35
|
+
color: #18181b;
|
|
36
|
+
font-family: system-ui, sans-serif;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main {
|
|
40
|
+
inline-size: min(100%, 34rem);
|
|
41
|
+
text-align: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.rule {
|
|
45
|
+
block-size: 1px;
|
|
46
|
+
inline-size: 3rem;
|
|
47
|
+
margin-block: 0 1.5rem;
|
|
48
|
+
margin-inline: auto;
|
|
49
|
+
background: #facc15;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
h1 {
|
|
53
|
+
margin: 0;
|
|
54
|
+
font-size: clamp(4rem, 16vw, 7rem);
|
|
55
|
+
font-weight: 700;
|
|
56
|
+
letter-spacing: -0.06em;
|
|
57
|
+
line-height: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
p {
|
|
61
|
+
margin: 1.25rem 0 0;
|
|
62
|
+
color: #52525b;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
line-height: 1.5;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
a {
|
|
68
|
+
display: inline-block;
|
|
69
|
+
margin-block-start: 1.5rem;
|
|
70
|
+
color: inherit;
|
|
71
|
+
text-underline-offset: 0.2em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
a:focus-visible {
|
|
75
|
+
outline: 2px solid #facc15;
|
|
76
|
+
outline-offset: 4px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@media (prefers-color-scheme: dark) {
|
|
80
|
+
body {
|
|
81
|
+
background: #18181b;
|
|
82
|
+
color: #fafafa;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
p {
|
|
86
|
+
color: #d4d4d8;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
90
|
+
/**
|
|
91
|
+
* Build the `data:text/css` URL the fallback document's `<link>` points at.
|
|
92
|
+
*
|
|
93
|
+
* Base64-encoded so the CSS's own characters (`"`, `<`, newlines) never need
|
|
94
|
+
* escaping for use inside an HTML attribute.
|
|
95
|
+
*/
|
|
96
|
+
function buildFrameworkDefaultNotFoundStylesheetUrl() {
|
|
97
|
+
return `data:text/css;base64,${Buffer.from(FRAMEWORK_DEFAULT_NOT_FOUND_STYLESHEET, "utf8").toString("base64")}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
export { buildFrameworkDefaultNotFoundStylesheetUrl };
|
|
102
|
+
//# sourceMappingURL=framework-default-not-found-stylesheet.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"framework-default-not-found-stylesheet.mjs","names":[],"sources":["../../../../../../../web/src/server/framework-default-not-found-stylesheet.ts"],"sourcesContent":["/**\n * The framework fallback 404 document's stylesheet, duplicated here as a\n * plain string constant.\n *\n * `framework-default-not-found.css` remains the source of truth for this\n * stylesheet's content — this file is a build-time necessity, not a second\n * design surface. The production server build refuses to compile\n * static-asset imports (`?raw`, `?url`, `import.meta.url`) in the page\n * graph, and says so in its own diagnostic: see the `REMEDY` text in\n * `../build/generate-pages-barrel.ts`. `not-found-page.ts` is part of that\n * server build, so it cannot import the `.css` file directly and must read\n * its text from a plain module instead.\n *\n * `not-found-page-stylesheet.spec.ts` asserts this constant stays\n * byte-identical to the `.css` file, so a future edit to one cannot silently\n * drift from the other.\n */\nexport const FRAMEWORK_DEFAULT_NOT_FOUND_STYLESHEET = `:root {\n color-scheme: light dark;\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n min-block-size: 100vh;\n display: grid;\n place-items: center;\n padding-block: 2rem;\n padding-inline: 1.5rem;\n background: #fafafa;\n color: #18181b;\n font-family: system-ui, sans-serif;\n}\n\nmain {\n inline-size: min(100%, 34rem);\n text-align: center;\n}\n\n.rule {\n block-size: 1px;\n inline-size: 3rem;\n margin-block: 0 1.5rem;\n margin-inline: auto;\n background: #facc15;\n}\n\nh1 {\n margin: 0;\n font-size: clamp(4rem, 16vw, 7rem);\n font-weight: 700;\n letter-spacing: -0.06em;\n line-height: 1;\n}\n\np {\n margin: 1.25rem 0 0;\n color: #52525b;\n font-size: 1rem;\n line-height: 1.5;\n}\n\na {\n display: inline-block;\n margin-block-start: 1.5rem;\n color: inherit;\n text-underline-offset: 0.2em;\n}\n\na:focus-visible {\n outline: 2px solid #facc15;\n outline-offset: 4px;\n}\n\n@media (prefers-color-scheme: dark) {\n body {\n background: #18181b;\n color: #fafafa;\n }\n\n p {\n color: #d4d4d8;\n }\n}\n`;\n\n/**\n * Build the `data:text/css` URL the fallback document's `<link>` points at.\n *\n * Base64-encoded so the CSS's own characters (`\"`, `<`, newlines) never need\n * escaping for use inside an HTML attribute.\n */\nexport function buildFrameworkDefaultNotFoundStylesheetUrl(): string {\n const base64 = Buffer.from(FRAMEWORK_DEFAULT_NOT_FOUND_STYLESHEET, \"utf8\").toString(\"base64\");\n\n return `data:text/css;base64,${base64}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiBA,MAAa,yCAAyC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8EtD,SAAgB,6CAAqD;CAGnE,OAAO,wBAFQ,OAAO,KAAK,wCAAwC,MAAM,CAAC,CAAC,SAAS,QAEhD;AACtC"}
|
|
@@ -2,9 +2,9 @@ import { publishRouteTable } from "../routing/route-table.mjs";
|
|
|
2
2
|
import { isLoaderShortCircuit } from "./settle-page-response.mjs";
|
|
3
3
|
import { createPageModuleLoader } from "./create-page-module-loader.mjs";
|
|
4
4
|
import { composeRoutePath } from "../routing/compose-route-path.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { deriveFilesystemRoutePath } from "../routing/filesystem-route.mjs";
|
|
6
6
|
import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
|
|
7
|
-
import { canonicalizeRouteExport,
|
|
7
|
+
import { canonicalizeRouteExport, resolvePageRouteCache, resolvePageRouteName } from "../routing/route-identity.mjs";
|
|
8
8
|
import { createPageRouteHandler } from "./create-page-route-handler.mjs";
|
|
9
9
|
import { productionStylesheetUrls } from "./stylesheet-urls.mjs";
|
|
10
10
|
import { DuplicateNotFoundPageError, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, createNotFoundRouteHandler, isNotFoundPageFile } from "./not-found-page.mjs";
|
|
@@ -48,21 +48,16 @@ import { Response } from "@warlock.js/core";
|
|
|
48
48
|
function webRelativeSourceFile(sourceFile) {
|
|
49
49
|
return sourceFile.split("/").slice(2).join("/");
|
|
50
50
|
}
|
|
51
|
+
/** Exported for `../routing/route-name-parity.spec.ts`, which proves this and dev's `resolvePageRouteIdentity` agree. */
|
|
51
52
|
function resolveRoute(routeExport, sourceFile) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
const canonical = canonicalizeRouteExport(routeExport);
|
|
53
|
+
const pageFile = webRelativeSourceFile(sourceFile);
|
|
54
|
+
if (routeExport === void 0) return {
|
|
55
|
+
path: deriveFilesystemRoutePath({ pageFile }),
|
|
56
|
+
name: resolvePageRouteName(routeExport, pageFile)
|
|
57
|
+
};
|
|
60
58
|
return {
|
|
61
|
-
path:
|
|
62
|
-
name:
|
|
63
|
-
routePath: canonical.path,
|
|
64
|
-
sourceFile
|
|
65
|
-
})
|
|
59
|
+
path: canonicalizeRouteExport(routeExport, sourceFile).path,
|
|
60
|
+
name: resolvePageRouteName(routeExport, pageFile)
|
|
66
61
|
};
|
|
67
62
|
}
|
|
68
63
|
/**
|
|
@@ -154,6 +149,7 @@ function installPageRoutesFromManifest(options) {
|
|
|
154
149
|
const { host: layout, prefix: layoutPrefix } = layoutLevelOf(page);
|
|
155
150
|
const routeExport = page.module.route;
|
|
156
151
|
const { path: routePath, name } = resolveRoute(routeExport, page.sourceFile);
|
|
152
|
+
const cache = resolvePageRouteCache(routeExport, page.sourceFile);
|
|
157
153
|
const effectivePath = routeExport === void 0 ? deriveFilesystemRoutePath({
|
|
158
154
|
pageFile: webRelativeSourceFile(page.sourceFile),
|
|
159
155
|
layoutPrefixes: layoutPrefixesOf(page)
|
|
@@ -177,7 +173,8 @@ function installPageRoutesFromManifest(options) {
|
|
|
177
173
|
loadRegistrationLayouts: () => Promise.resolve(page.layouts.map((layout) => layout.module)),
|
|
178
174
|
hydrationClientModuleUrl,
|
|
179
175
|
loadErrorPage,
|
|
180
|
-
stylesheetUrls
|
|
176
|
+
stylesheetUrls,
|
|
177
|
+
cache
|
|
181
178
|
}), {
|
|
182
179
|
name,
|
|
183
180
|
isPage: true
|