@warlock.js/web 5.2.4 → 5.3.1
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/discover-pages.mjs +5 -7
- package/esm/build/discover-pages.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/components/document-context.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/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/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/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/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/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/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/web-connector.d.mts +1 -1
- package/esm/server/web-connector.mjs +23 -4
- package/esm/server/web-connector.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
|
@@ -1,68 +1,124 @@
|
|
|
1
|
+
import { deriveFilesystemRouteName } from "./filesystem-route.mjs";
|
|
2
|
+
import { PageRoutePathNotSupportedError, classifyPageRoutePath } from "./page-route-grammar.mjs";
|
|
3
|
+
|
|
1
4
|
//#region ../web/src/routing/route-identity.ts
|
|
2
5
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Route identity — the single, pure implementation of "what is this page's
|
|
7
|
+
* route path and name". The dev installer
|
|
8
|
+
* (`web/src/server/install-page-routes.ts`), the production manifest
|
|
9
|
+
* installer (`web/src/server/install-page-routes-from-manifest.ts`) and
|
|
10
|
+
* discovery (`web/src/build/discover-pages.ts`) each hand-derived this on
|
|
11
|
+
* their own until all three were made to delegate here.
|
|
12
|
+
*
|
|
13
|
+
* {@link resolvePageRouteName} is the ONE answer to "what is this page's
|
|
14
|
+
* route name": an explicit `name` on the declared `route` export wins,
|
|
15
|
+
* otherwise the name comes from the page's own FILE PATH
|
|
16
|
+
* (`deriveFilesystemRouteName`) — never from `route.path`. The route name is
|
|
17
|
+
* an identity key (`routing/route-table.ts`'s lookup key, `components/link.ts`,
|
|
18
|
+
* `server/render-page.ts`, the generated client registry and the hydration
|
|
19
|
+
* payload all address a page by it), and an identity key must be stable under
|
|
20
|
+
* the change most likely to happen to a page — its URL, renamed for SEO,
|
|
21
|
+
* localization or restructuring. A file path is also unique by construction,
|
|
22
|
+
* while a declared `path: "/"` yields no usable name at all.
|
|
23
|
+
*
|
|
24
|
+
* Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input
|
|
25
|
+
* this module accepts is already CANONICAL — a POSIX, app-root-relative
|
|
26
|
+
* source path (e.g. `"src/web/index.page.tsx"`).
|
|
27
|
+
*
|
|
28
|
+
* {@link canonicalizeRouteExport} is also the ONE seam every declared
|
|
29
|
+
* `route.path` passes through on its way into either installer
|
|
30
|
+
* (`install-page-routes.ts`, `install-page-routes-from-manifest.ts`), so it is
|
|
31
|
+
* where `../routing/page-route-grammar.ts`'s `classifyPageRoutePath` is
|
|
32
|
+
* applied: a rejected path raises {@link PageRoutePathNotSupportedError}
|
|
33
|
+
* naming the offending page file, rather than being published literally.
|
|
34
|
+
*
|
|
35
|
+
* Well-formedness of the declared `route` export itself (is it a string or an
|
|
36
|
+
* object, does the object have a `path`) is the extractor's problem — already
|
|
37
|
+
* rejected at build before either derivation function here is called.
|
|
38
|
+
*
|
|
39
|
+
* DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing
|
|
40
|
+
* here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in
|
|
41
|
+
* this directory receive canonical values and trust nothing — they assert
|
|
42
|
+
* rather than trust, but they never repair. A module that needs the
|
|
43
|
+
* filesystem does not belong here. The purity is deliberate: it keeps these
|
|
44
|
+
* modules consumable from the dev server, the build, the production runtime,
|
|
45
|
+
* and — if ever needed — the browser client, without dragging any of those
|
|
46
|
+
* environments along.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* Raised when a page's `route.cache` is present but malformed — most notably
|
|
50
|
+
* `public: true` with no `maxAge`. The framework refuses to invent a
|
|
51
|
+
* freshness window (see {@link PageCacheOptIn}), so this is a BOOT-TIME
|
|
52
|
+
* failure rather than a silent fallback to `no-store`: a developer who wrote
|
|
53
|
+
* `cache: { public: true }` meant for the route to be cacheable, and serving
|
|
54
|
+
* it `no-store` without a word would be the exact silent-failure class this
|
|
55
|
+
* release exists to kill.
|
|
6
56
|
*/
|
|
7
|
-
var
|
|
8
|
-
|
|
9
|
-
constructor(
|
|
10
|
-
super(`
|
|
11
|
-
this.
|
|
12
|
-
this.name = "
|
|
57
|
+
var InvalidPageCacheOptInError = class extends Error {
|
|
58
|
+
pageFile;
|
|
59
|
+
constructor(pageFile) {
|
|
60
|
+
super(`"${pageFile}" declares \`route.cache\` without a valid opt-in. Both keys are required: write \`cache: { public: true, maxAge: <seconds> }\` — for example \`cache: { public: true, maxAge: 60 }\`. Remove \`cache\` entirely to keep the route \`no-store\` (the default) instead.`);
|
|
61
|
+
this.pageFile = pageFile;
|
|
62
|
+
this.name = "InvalidPageCacheOptInError";
|
|
13
63
|
}
|
|
14
64
|
};
|
|
15
65
|
/**
|
|
66
|
+
* Resolves and validates a declared `route` export's `cache` opt-in.
|
|
67
|
+
*
|
|
68
|
+
* `undefined` — the common case — means "no opt-in", which the caller (the
|
|
69
|
+
* response-cache-floor seam) treats as `no-store`, not as "cacheable with no
|
|
70
|
+
* limit". A malformed opt-in throws {@link InvalidPageCacheOptInError} rather
|
|
71
|
+
* than being coerced or ignored, so a typo in a route's `cache` field fails
|
|
72
|
+
* the build/boot instead of quietly shipping an unintended cache policy.
|
|
73
|
+
*/
|
|
74
|
+
function resolvePageRouteCache(route, pageFile) {
|
|
75
|
+
if (route === void 0 || typeof route === "string") return void 0;
|
|
76
|
+
const { cache } = route;
|
|
77
|
+
if (cache === void 0) return void 0;
|
|
78
|
+
if (cache.public !== true || typeof cache.maxAge !== "number") throw new InvalidPageCacheOptInError(pageFile);
|
|
79
|
+
return cache;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
16
82
|
* Canonicalizes a declared `route` export — string or `{ path, name? }` —
|
|
17
83
|
* into `{ path, name? }`. Requires well-formed input; an export that is
|
|
18
84
|
* neither a string nor an object with a `path` is the extractor's problem,
|
|
19
85
|
* already rejected before this function is ever called.
|
|
86
|
+
*
|
|
87
|
+
* This is also where the declared `path` is validated: `pageFile` names the
|
|
88
|
+
* page whose `route` export is being canonicalized, and a `path` that
|
|
89
|
+
* {@link classifyPageRoutePath} rejects raises
|
|
90
|
+
* {@link PageRoutePathNotSupportedError} naming it — rather than being
|
|
91
|
+
* published literally. Both installers reach every declared path through
|
|
92
|
+
* here, so this is the one place that check has to live.
|
|
20
93
|
*/
|
|
21
|
-
function canonicalizeRouteExport(route) {
|
|
94
|
+
function canonicalizeRouteExport(route, pageFile) {
|
|
95
|
+
const path = typeof route === "string" ? route : route.path;
|
|
96
|
+
const verdict = classifyPageRoutePath(path);
|
|
97
|
+
if (verdict.type === "rejected") throw new PageRoutePathNotSupportedError(pageFile, path, verdict.reason);
|
|
22
98
|
if (typeof route === "string") return { path: route };
|
|
23
99
|
return route.name === void 0 ? { path: route.path } : {
|
|
24
100
|
path: route.path,
|
|
25
101
|
name: route.name
|
|
26
102
|
};
|
|
27
103
|
}
|
|
28
|
-
/** Strips leading and trailing "." characters — mirrors `trim(value, ".")` as used by the installer's `deriveRouteName`. */
|
|
29
|
-
function trimDots(value) {
|
|
30
|
-
return value.replace(/^\.+|\.+$/g, "");
|
|
31
|
-
}
|
|
32
104
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
function moduleSegmentFor(sourceFile) {
|
|
39
|
-
const segments = sourceFile.split("/");
|
|
40
|
-
return segments[1] === "app" ? segments[2] : void 0;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Derives the fallback route name for a page whose declared route carries no
|
|
44
|
-
* `name` — the same derivation the dev installer's `deriveRouteName` applies
|
|
45
|
-
* today (`web/src/server/install-page-routes.ts`), expressed against
|
|
46
|
-
* canonical inputs instead of an absolute file path plus an `appSrcRoot`.
|
|
47
|
-
*
|
|
48
|
-
* The installer only ever calls its derivation for a page under
|
|
49
|
-
* `<appSrcRoot>/app/**`, so a global (`src/web/**`) page has no installer
|
|
50
|
-
* behaviour to mirror; for that case this function instead follows
|
|
51
|
-
* discovery's own convention (`routeNameFor` in `discover-pages.ts`) — no
|
|
52
|
-
* module prefix, and `"index"` when there is nothing left to say at all.
|
|
105
|
+
* Resolves a page's route NAME — the single derivation `install-page-routes.ts`,
|
|
106
|
+
* `install-page-routes-from-manifest.ts` and `discover-pages.ts` all delegate
|
|
107
|
+
* to, so dev, the production manifest installer and build discovery cannot
|
|
108
|
+
* derive three different names for the same page (see the module doc comment
|
|
109
|
+
* for why the file path, not `route.path`, is the source of truth).
|
|
53
110
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
111
|
+
* An explicit `name` on `route` always wins. Otherwise the name is derived
|
|
112
|
+
* from `pageFile` via {@link deriveFilesystemRouteName} — `pageFile` must
|
|
113
|
+
* already be canonical: a POSIX path relative to the page's web root (e.g.
|
|
114
|
+
* `"blog/archive.page.tsx"`), the same value each caller already computes as
|
|
115
|
+
* `filesystemPageFileFor`/`webRelativeSourceFile`/`relativePageFile`.
|
|
56
116
|
*/
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const moduleName = moduleSegmentFor(sourceFile);
|
|
61
|
-
const suffix = trimDots(routePath.replace(/\//g, "."));
|
|
62
|
-
if (moduleName !== void 0) return suffix ? `${moduleName}.${suffix}` : moduleName;
|
|
63
|
-
return suffix || "index";
|
|
117
|
+
function resolvePageRouteName(route, pageFile) {
|
|
118
|
+
if (route === void 0) return deriveFilesystemRouteName(pageFile);
|
|
119
|
+
return canonicalizeRouteExport(route, pageFile).name ?? deriveFilesystemRouteName(pageFile);
|
|
64
120
|
}
|
|
65
121
|
|
|
66
122
|
//#endregion
|
|
67
|
-
export { canonicalizeRouteExport,
|
|
123
|
+
export { canonicalizeRouteExport, resolvePageRouteCache, resolvePageRouteName };
|
|
68
124
|
//# sourceMappingURL=route-identity.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-identity.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-identity.ts"],"sourcesContent":["/**\n * Route identity — the single, pure implementation of \"what is this page's\n * route path and name\". The dev installer\n * (`web/src/server/install-page-routes.ts`), the production manifest\n * installer (`web/src/server/install-page-routes-from-manifest.ts`) and\n * discovery (`web/src/build/discover-pages.ts`) each hand-derived this on\n * their own until all three were made to delegate here.\n *\n * Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input\n * this module accepts is already CANONICAL — a POSIX, app-root-relative\n * source path (e.g. `\"src/app/users/web/account/settings.page.tsx\"` or\n * `\"src/web/index.page.tsx\"`) and an already-validated declared route export.\n * Turning an absolute, OS-specific file path into that canonical form is the\n * caller's job; this module refuses (see {@link NonPosixSourceFilePathError})\n * rather than guess at a normalization.\n *\n * Well-formedness of the declared `route` export itself (is it a string or an\n * object, does the object have a `path`) is the extractor's problem — already\n * rejected at build before either derivation function here is called.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in\n * this directory receive canonical values and trust nothing — they assert\n * rather than trust, but they never repair. A module that needs the\n * filesystem does not belong here. The purity is deliberate: it keeps these\n * modules consumable from the dev server, the build, the production runtime,\n * and — if ever needed — the browser client, without dragging any of those\n * environments along.\n */\n\n/** The shape a page's `route` export may declare — mirrors `PageRouteExport` in `install-page-routes.ts`. */\nexport type DeclaredRouteExport = string | { path: string; name?: string };\n\n/** The canonical form every declared `route` export resolves to. */\nexport type CanonicalRoute = {\n path: string;\n name?: string;\n};\n\n/** The canonical inputs {@link deriveFallbackRouteName} requires — see the module doc comment for what \"canonical\" means. */\nexport type RouteNameFallbackInput = {\n /** The page's declared route path, e.g. `\"/settings\"` or `\"/\"`. */\n routePath: string;\n /** The page's app-root-relative POSIX source path, e.g. `\"src/app/main/web/contact-us.page.tsx\"`. */\n sourceFile: string;\n};\n\n/**\n * Raised when a caller passes a `sourceFile` containing a backslash.\n * Canonical means canonical: this module does not normalize Windows path\n * separators on the caller's behalf.\n */\nexport class NonPosixSourceFilePathError extends Error {\n public constructor(public readonly sourceFile: string) {\n super(\n `route-identity: sourceFile \"${sourceFile}\" contains a backslash. This module's inputs ` +\n \"must already be canonical app-root-relative POSIX paths (forward slashes only) — \" +\n 'normalize with `value.replace(/\\\\\\\\/g, \"/\")` before calling deriveFallbackRouteName.',\n );\n this.name = \"NonPosixSourceFilePathError\";\n }\n}\n\n/**\n * Canonicalizes a declared `route` export — string or `{ path, name? }` —\n * into `{ path, name? }`. Requires well-formed input; an export that is\n * neither a string nor an object with a `path` is the extractor's problem,\n * already rejected before this function is ever called.\n */\nexport function canonicalizeRouteExport(route: DeclaredRouteExport): CanonicalRoute {\n if (typeof route === \"string\") {\n return { path: route };\n }\n\n return route.name === undefined ? { path: route.path } : { path: route.path, name: route.name };\n}\n\n/** Strips leading and trailing \".\" characters — mirrors `trim(value, \".\")` as used by the installer's `deriveRouteName`. */\nfunction trimDots(value: string): string {\n return value.replace(/^\\.+|\\.+$/g, \"\");\n}\n\n/**\n * The module segment a canonical source path declares, or `undefined` for a\n * global (`src/web/**`) page. `sourceFile` is `<srcDir>/app/<module>/web/...`\n * or `<srcDir>/web/...` — the first segment is the (arbitrarily named) src\n * dir, so the module test looks at the SECOND segment.\n */\nfunction moduleSegmentFor(sourceFile: string): string | undefined {\n const segments = sourceFile.split(\"/\");\n\n return segments[1] === \"app\" ? segments[2] : undefined;\n}\n\n/**\n * Derives the fallback route name for a page whose declared route carries no\n * `name` — the same derivation the dev installer's `deriveRouteName` applies\n * today (`web/src/server/install-page-routes.ts`), expressed against\n * canonical inputs instead of an absolute file path plus an `appSrcRoot`.\n *\n * The installer only ever calls its derivation for a page under\n * `<appSrcRoot>/app/**`, so a global (`src/web/**`) page has no installer\n * behaviour to mirror; for that case this function instead follows\n * discovery's own convention (`routeNameFor` in `discover-pages.ts`) — no\n * module prefix, and `\"index\"` when there is nothing left to say at all.\n *\n * Throws {@link NonPosixSourceFilePathError} when `sourceFile` contains a\n * backslash.\n */\nexport function deriveFallbackRouteName(input: RouteNameFallbackInput): string {\n const { routePath, sourceFile } = input;\n\n if (sourceFile.includes(\"\\\\\")) {\n throw new NonPosixSourceFilePathError(sourceFile);\n }\n\n const moduleName = moduleSegmentFor(sourceFile);\n const suffix = trimDots(routePath.replace(/\\//g, \".\"));\n\n if (moduleName !== undefined) {\n return suffix ? `${moduleName}.${suffix}` : moduleName;\n }\n\n return suffix || \"index\";\n}\n"],"mappings":";;;;;;AAoDA,IAAa,8BAAb,cAAiD,MAAM;CAClB;CAAnC,AAAO,YAAY,AAAgB,YAAoB;EACrD,MACE,+BAA+B,WAAW,qNAG5C;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,SAAgB,wBAAwB,OAA4C;CAClF,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM,MAAM;CAGvB,OAAO,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,KAAK,IAAI;EAAE,MAAM,MAAM;EAAM,MAAM,MAAM;CAAK;AAChG;;AAGA,SAAS,SAAS,OAAuB;CACvC,OAAO,MAAM,QAAQ,cAAc,EAAE;AACvC;;;;;;;AAQA,SAAS,iBAAiB,YAAwC;CAChE,MAAM,WAAW,WAAW,MAAM,GAAG;CAErC,OAAO,SAAS,OAAO,QAAQ,SAAS,KAAK;AAC/C;;;;;;;;;;;;;;;;AAiBA,SAAgB,wBAAwB,OAAuC;CAC7E,MAAM,EAAE,WAAW,eAAe;CAElC,IAAI,WAAW,SAAS,IAAI,GAC1B,MAAM,IAAI,4BAA4B,UAAU;CAGlD,MAAM,aAAa,iBAAiB,UAAU;CAC9C,MAAM,SAAS,SAAS,UAAU,QAAQ,OAAO,GAAG,CAAC;CAErD,IAAI,eAAe,QACjB,OAAO,SAAS,GAAG,WAAW,GAAG,WAAW;CAG9C,OAAO,UAAU;AACnB"}
|
|
1
|
+
{"version":3,"file":"route-identity.mjs","names":[],"sources":["../../../../../../../web/src/routing/route-identity.ts"],"sourcesContent":["/**\r\n * Route identity — the single, pure implementation of \"what is this page's\r\n * route path and name\". The dev installer\r\n * (`web/src/server/install-page-routes.ts`), the production manifest\r\n * installer (`web/src/server/install-page-routes-from-manifest.ts`) and\r\n * discovery (`web/src/build/discover-pages.ts`) each hand-derived this on\r\n * their own until all three were made to delegate here.\r\n *\r\n * {@link resolvePageRouteName} is the ONE answer to \"what is this page's\r\n * route name\": an explicit `name` on the declared `route` export wins,\r\n * otherwise the name comes from the page's own FILE PATH\r\n * (`deriveFilesystemRouteName`) — never from `route.path`. The route name is\r\n * an identity key (`routing/route-table.ts`'s lookup key, `components/link.ts`,\r\n * `server/render-page.ts`, the generated client registry and the hydration\r\n * payload all address a page by it), and an identity key must be stable under\r\n * the change most likely to happen to a page — its URL, renamed for SEO,\r\n * localization or restructuring. A file path is also unique by construction,\r\n * while a declared `path: \"/\"` yields no usable name at all.\r\n *\r\n * Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input\r\n * this module accepts is already CANONICAL — a POSIX, app-root-relative\r\n * source path (e.g. `\"src/web/index.page.tsx\"`).\r\n *\r\n * {@link canonicalizeRouteExport} is also the ONE seam every declared\r\n * `route.path` passes through on its way into either installer\r\n * (`install-page-routes.ts`, `install-page-routes-from-manifest.ts`), so it is\r\n * where `../routing/page-route-grammar.ts`'s `classifyPageRoutePath` is\r\n * applied: a rejected path raises {@link PageRoutePathNotSupportedError}\r\n * naming the offending page file, rather than being published literally.\r\n *\r\n * Well-formedness of the declared `route` export itself (is it a string or an\r\n * object, does the object have a `path`) is the extractor's problem — already\r\n * rejected at build before either derivation function here is called.\r\n *\r\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\r\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in\r\n * this directory receive canonical values and trust nothing — they assert\r\n * rather than trust, but they never repair. A module that needs the\r\n * filesystem does not belong here. The purity is deliberate: it keeps these\r\n * modules consumable from the dev server, the build, the production runtime,\r\n * and — if ever needed — the browser client, without dragging any of those\r\n * environments along.\r\n */\r\n\r\nimport { deriveFilesystemRouteName } from \"./filesystem-route\";\r\nimport { classifyPageRoutePath, PageRoutePathNotSupportedError } from \"./page-route-grammar\";\r\n\r\n/**\r\n * A page's opt-in into shared-cache storage for its document AND its data\r\n * representation (`x-warlock-data`) — the two must never diverge, because a\r\n * cacheable data payload leaks exactly what an uncacheable document was\r\n * protecting (`../server/create-page-route-handler.ts`).\r\n *\r\n * `public: true` is not a flag with a `false` counterpart: the framework is\r\n * closed by default (`../server/response-cache-floor.ts`), so the only\r\n * meaningful state this object can express is \"yes, cache me\" — a page that\r\n * wants the default simply omits `cache` entirely. `maxAge` has no framework\r\n * default and never will: a route's freshness window is a decision only the\r\n * route's author can make safely, and guessing one would be exactly the kind\r\n * of silent, environment-dependent behaviour this feature exists to remove.\r\n * Both keys are required — see {@link InvalidPageCacheOptInError}.\r\n *\r\n * Shaped as an object, not a boolean or a bare number, so a later addition\r\n * (e.g. CDN surrogate keys) extends it without a breaking change.\r\n */\r\nexport type PageCacheOptIn = {\r\n public: true;\r\n /** Freshness window in seconds, emitted as `Cache-Control: public, max-age=<maxAge>`. */\r\n maxAge: number;\r\n};\r\n\r\n/** The shape a page's `route` export may declare — mirrors `PageRouteExport` in `install-page-routes.ts`. */\r\nexport type DeclaredRouteExport = string | { path: string; name?: string; cache?: PageCacheOptIn };\r\n\r\n/** The canonical form every declared `route` export resolves to. */\r\nexport type CanonicalRoute = {\r\n path: string;\r\n name?: string;\r\n};\r\n\r\n/**\r\n * Raised when a page's `route.cache` is present but malformed — most notably\r\n * `public: true` with no `maxAge`. The framework refuses to invent a\r\n * freshness window (see {@link PageCacheOptIn}), so this is a BOOT-TIME\r\n * failure rather than a silent fallback to `no-store`: a developer who wrote\r\n * `cache: { public: true }` meant for the route to be cacheable, and serving\r\n * it `no-store` without a word would be the exact silent-failure class this\r\n * release exists to kill.\r\n */\r\nexport class InvalidPageCacheOptInError extends Error {\r\n public constructor(public readonly pageFile: string) {\r\n super(\r\n `\"${pageFile}\" declares \\`route.cache\\` without a valid opt-in. Both keys are required: ` +\r\n \"write `cache: { public: true, maxAge: <seconds> }` — for example `cache: { public: \" +\r\n \"true, maxAge: 60 }`. Remove `cache` entirely to keep the route `no-store` (the default) \" +\r\n \"instead.\",\r\n );\r\n this.name = \"InvalidPageCacheOptInError\";\r\n }\r\n}\r\n\r\n/**\r\n * Resolves and validates a declared `route` export's `cache` opt-in.\r\n *\r\n * `undefined` — the common case — means \"no opt-in\", which the caller (the\r\n * response-cache-floor seam) treats as `no-store`, not as \"cacheable with no\r\n * limit\". A malformed opt-in throws {@link InvalidPageCacheOptInError} rather\r\n * than being coerced or ignored, so a typo in a route's `cache` field fails\r\n * the build/boot instead of quietly shipping an unintended cache policy.\r\n */\r\nexport function resolvePageRouteCache(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): PageCacheOptIn | undefined {\r\n if (route === undefined || typeof route === \"string\") return undefined;\r\n\r\n const { cache } = route;\r\n\r\n if (cache === undefined) return undefined;\r\n\r\n if (cache.public !== true || typeof cache.maxAge !== \"number\") {\r\n throw new InvalidPageCacheOptInError(pageFile);\r\n }\r\n\r\n return cache;\r\n}\r\n\r\n/**\r\n * Canonicalizes a declared `route` export — string or `{ path, name? }` —\r\n * into `{ path, name? }`. Requires well-formed input; an export that is\r\n * neither a string nor an object with a `path` is the extractor's problem,\r\n * already rejected before this function is ever called.\r\n *\r\n * This is also where the declared `path` is validated: `pageFile` names the\r\n * page whose `route` export is being canonicalized, and a `path` that\r\n * {@link classifyPageRoutePath} rejects raises\r\n * {@link PageRoutePathNotSupportedError} naming it — rather than being\r\n * published literally. Both installers reach every declared path through\r\n * here, so this is the one place that check has to live.\r\n */\r\nexport function canonicalizeRouteExport(\r\n route: DeclaredRouteExport,\r\n pageFile: string,\r\n): CanonicalRoute {\r\n const path = typeof route === \"string\" ? route : route.path;\r\n const verdict = classifyPageRoutePath(path);\r\n\r\n if (verdict.type === \"rejected\") {\r\n throw new PageRoutePathNotSupportedError(pageFile, path, verdict.reason);\r\n }\r\n\r\n if (typeof route === \"string\") {\r\n return { path: route };\r\n }\r\n\r\n return route.name === undefined ? { path: route.path } : { path: route.path, name: route.name };\r\n}\r\n\r\n/**\r\n * Resolves a page's route NAME — the single derivation `install-page-routes.ts`,\r\n * `install-page-routes-from-manifest.ts` and `discover-pages.ts` all delegate\r\n * to, so dev, the production manifest installer and build discovery cannot\r\n * derive three different names for the same page (see the module doc comment\r\n * for why the file path, not `route.path`, is the source of truth).\r\n *\r\n * An explicit `name` on `route` always wins. Otherwise the name is derived\r\n * from `pageFile` via {@link deriveFilesystemRouteName} — `pageFile` must\r\n * already be canonical: a POSIX path relative to the page's web root (e.g.\r\n * `\"blog/archive.page.tsx\"`), the same value each caller already computes as\r\n * `filesystemPageFileFor`/`webRelativeSourceFile`/`relativePageFile`.\r\n */\r\nexport function resolvePageRouteName(\r\n route: DeclaredRouteExport | undefined,\r\n pageFile: string,\r\n): string {\r\n if (route === undefined) {\r\n return deriveFilesystemRouteName(pageFile);\r\n }\r\n\r\n return canonicalizeRouteExport(route, pageFile).name ?? deriveFilesystemRouteName(pageFile);\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyFA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,IAAI,SAAS,uQAIf;EANiC;EAOjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;AAWA,SAAgB,sBACd,OACA,UAC4B;CAC5B,IAAI,UAAU,UAAa,OAAO,UAAU,UAAU,OAAO;CAE7D,MAAM,EAAE,UAAU;CAElB,IAAI,UAAU,QAAW,OAAO;CAEhC,IAAI,MAAM,WAAW,QAAQ,OAAO,MAAM,WAAW,UACnD,MAAM,IAAI,2BAA2B,QAAQ;CAG/C,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAgB,wBACd,OACA,UACgB;CAChB,MAAM,OAAO,OAAO,UAAU,WAAW,QAAQ,MAAM;CACvD,MAAM,UAAU,sBAAsB,IAAI;CAE1C,IAAI,QAAQ,SAAS,YACnB,MAAM,IAAI,+BAA+B,UAAU,MAAM,QAAQ,MAAM;CAGzE,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM,MAAM;CAGvB,OAAO,MAAM,SAAS,SAAY,EAAE,MAAM,MAAM,KAAK,IAAI;EAAE,MAAM,MAAM;EAAM,MAAM,MAAM;CAAK;AAChG;;;;;;;;;;;;;;AAeA,SAAgB,qBACd,OACA,UACQ;CACR,IAAI,UAAU,QACZ,OAAO,0BAA0B,QAAQ;CAG3C,OAAO,wBAAwB,OAAO,QAAQ,CAAC,CAAC,QAAQ,0BAA0B,QAAQ;AAC5F"}
|
|
@@ -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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"}
|
|
@@ -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
|