@warlock.js/web 5.0.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/esm/build/contribution.d.mts +10 -0
  3. package/esm/build/contribution.mjs +36 -0
  4. package/esm/build/contribution.mjs.map +1 -1
  5. package/esm/build/discover-pages.mjs +226 -12
  6. package/esm/build/discover-pages.mjs.map +1 -1
  7. package/esm/build/generate-client-registry.mjs.map +1 -1
  8. package/esm/client/navigation/navigation-root.mjs +43 -6
  9. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  10. package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
  11. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
  12. package/esm/metadata.d.mts +14 -0
  13. package/esm/metadata.mjs +45 -0
  14. package/esm/metadata.mjs.map +1 -0
  15. package/esm/routing/url-fragment.mjs +120 -0
  16. package/esm/routing/url-fragment.mjs.map +1 -0
  17. package/esm/server/create-page-route-handler.d.mts +27 -0
  18. package/esm/server/create-page-route-handler.mjs +12 -10
  19. package/esm/server/create-page-route-handler.mjs.map +1 -1
  20. package/esm/server/index.d.mts +2 -1
  21. package/esm/server/index.mjs +2 -1
  22. package/esm/server/install-page-routes-from-manifest.mjs +23 -1
  23. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  24. package/esm/server/install-page-routes.d.mts +4 -2
  25. package/esm/server/install-page-routes.mjs +33 -5
  26. package/esm/server/install-page-routes.mjs.map +1 -1
  27. package/esm/server/install-production-page-routes.mjs +6 -1
  28. package/esm/server/install-production-page-routes.mjs.map +1 -1
  29. package/esm/server/not-found-page.d.mts +126 -0
  30. package/esm/server/not-found-page.mjs +157 -0
  31. package/esm/server/not-found-page.mjs.map +1 -0
  32. package/esm/server/web-connector-factory.mjs +2 -1
  33. package/esm/server/web-connector-factory.mjs.map +1 -1
  34. package/esm/server/web-connector.mjs +123 -5
  35. package/esm/server/web-connector.mjs.map +1 -1
  36. package/esm/vite/hydration-entries.mjs +8 -4
  37. package/esm/vite/hydration-entries.mjs.map +1 -1
  38. package/esm/vite/page-registry-plugin.mjs +211 -0
  39. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  40. package/llms-full.txt +33 -5
  41. package/llms.txt +4 -2
  42. package/package.json +3 -3
  43. package/skills/create-a-page/SKILL.md +18 -2
  44. package/skills/navigate-on-the-client/SKILL.md +2 -0
  45. package/skills/use-layouts/SKILL.md +6 -0
  46. package/skills/write-the-root/SKILL.md +2 -0
@@ -0,0 +1,120 @@
1
+ //#region ../web/src/routing/url-fragment.ts
2
+ /**
3
+ * The URL FRAGMENT, kept as a first-class part of a navigation.
4
+ *
5
+ * ## The defect this file exists for
6
+ *
7
+ * A client navigation asks the server for page data with `fetch`, and a
8
+ * fragment is a CLIENT-SIDE construct: it is never sent, and `response.url` —
9
+ * the URL history is written from, because it reflects any redirect that was
10
+ * followed — therefore never carries one. So `<Link href="/docs#install">`
11
+ * pushed `/docs` and the fragment the author wrote was silently gone from the
12
+ * address bar: not shareable, not bookmarkable, not restorable on reload.
13
+ *
14
+ * Everything here is pure string work over `URL`, deliberately: the navigation
15
+ * runtime is the only place that may touch `window`, and these answers have to
16
+ * be provable in a suite with no browser.
17
+ *
18
+ * ## The redirect rule, which is the browser's rule
19
+ *
20
+ * When a request is redirected and the `Location` carries no fragment of its
21
+ * own, the ORIGINAL fragment is carried onto the destination (RFC 7231 §7.1.2).
22
+ * A `Location` that DOES name one wins outright. {@link withFragmentFrom} is
23
+ * that rule and nothing else, so a client navigation through a redirect lands
24
+ * where a full page load would have landed.
25
+ */
26
+ /**
27
+ * The fragment of `url`, WITHOUT its leading `#`.
28
+ *
29
+ * `undefined` means there was no `#` at all, and `""` means there was one with
30
+ * nothing after it. The two are kept apart because they are different requests:
31
+ * `/docs` says nothing about a fragment, `/docs#` says "no target" — and only
32
+ * the second should end up written to the address bar as `#`.
33
+ *
34
+ * Plain string work rather than `new URL()`: this is asked of RELATIVE URLs
35
+ * (`/docs#install`, `#install`), which `URL` cannot parse without a base, and
36
+ * the base is `window`'s — not available on the server, and not this module's
37
+ * to reach for.
38
+ */
39
+ function fragmentOf(url) {
40
+ const index = url.indexOf("#");
41
+ return index === -1 ? void 0 : url.slice(index + 1);
42
+ }
43
+ /**
44
+ * `url` with everything from its `#` onward removed.
45
+ */
46
+ function withoutFragment(url) {
47
+ const index = url.indexOf("#");
48
+ return index === -1 ? url : url.slice(0, index);
49
+ }
50
+ /**
51
+ * Carry the fragment the caller ASKED for onto the URL the response came from.
52
+ *
53
+ * The resolved URL wins when it names a fragment itself — a redirect that says
54
+ * `Location: /docs/v5#moved` meant it. Otherwise the requested fragment rides
55
+ * along, which for the overwhelmingly common case (no redirect) simply puts
56
+ * back what `fetch` dropped.
57
+ */
58
+ function withFragmentFrom(resolvedUrl, requestedUrl) {
59
+ if (fragmentOf(resolvedUrl) !== void 0) return resolvedUrl;
60
+ const fragment = fragmentOf(requestedUrl);
61
+ return fragment === void 0 ? resolvedUrl : `${resolvedUrl}#${fragment}`;
62
+ }
63
+ /**
64
+ * The `id` a fragment names, decoded.
65
+ *
66
+ * A fragment travels PERCENT-ENCODED — `#a%20b`, and every non-ASCII id is
67
+ * encoded by the browser the moment it reaches the address bar — while the
68
+ * `id` attribute in the document holds the decoded characters. Looking up the
69
+ * raw fragment therefore misses every id with a space or a non-Latin letter,
70
+ * which is a silent no-scroll indistinguishable from the bug this fixes.
71
+ *
72
+ * A malformed escape (`#100%`) is NOT an error here: `decodeURIComponent`
73
+ * throws on it, and a throw during a navigation would cost the page for the
74
+ * sake of a scroll. The raw text is returned instead, which is exactly what an
75
+ * `id="100%"` in the document would match.
76
+ */
77
+ function fragmentTargetId(fragment) {
78
+ try {
79
+ return decodeURIComponent(fragment);
80
+ } catch {
81
+ return fragment;
82
+ }
83
+ }
84
+ /**
85
+ * The fragment of a destination that is THIS page with a fragment on it —
86
+ * `#section`, or the current path written out in full with one appended.
87
+ *
88
+ * `undefined` means "not that": a different page, no fragment, or a URL that
89
+ * cannot be resolved at all. The caller navigates as it otherwise would.
90
+ *
91
+ * Such a click must NOT run a client navigation. Re-fetching the page the user
92
+ * is already looking at would throw away its DOM and every piece of state in
93
+ * it — a scrolled container, an open menu, a playing video — to arrive at the
94
+ * same page, and the round trip means the jump does not happen until the
95
+ * network answers. The browser does not do that for a plain `<a href="#x">`
96
+ * and neither do we: the URL is updated and the page scrolls, in that order.
97
+ *
98
+ * The comparison is on origin, path and QUERY: `?page=2#top` from `?page=1#top`
99
+ * is a real navigation to different data that happens to share a fragment.
100
+ *
101
+ * An EMPTY fragment (`/here#`) does not qualify — there is no target to scroll
102
+ * to and nothing to distinguish it from a plain re-navigation to the same page.
103
+ */
104
+ function samePageFragment(url, currentHref) {
105
+ let destination;
106
+ let current;
107
+ try {
108
+ destination = new URL(url, currentHref);
109
+ current = new URL(currentHref);
110
+ } catch {
111
+ return;
112
+ }
113
+ const fragment = destination.hash.slice(1);
114
+ if (fragment === "") return void 0;
115
+ return destination.origin === current.origin && destination.pathname === current.pathname && destination.search === current.search ? fragment : void 0;
116
+ }
117
+
118
+ //#endregion
119
+ export { fragmentOf, fragmentTargetId, samePageFragment, withFragmentFrom, withoutFragment };
120
+ //# sourceMappingURL=url-fragment.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-fragment.mjs","names":[],"sources":["../../../../../../../web/src/routing/url-fragment.ts"],"sourcesContent":["/**\n * The URL FRAGMENT, kept as a first-class part of a navigation.\n *\n * ## The defect this file exists for\n *\n * A client navigation asks the server for page data with `fetch`, and a\n * fragment is a CLIENT-SIDE construct: it is never sent, and `response.url` —\n * the URL history is written from, because it reflects any redirect that was\n * followed — therefore never carries one. So `<Link href=\"/docs#install\">`\n * pushed `/docs` and the fragment the author wrote was silently gone from the\n * address bar: not shareable, not bookmarkable, not restorable on reload.\n *\n * Everything here is pure string work over `URL`, deliberately: the navigation\n * runtime is the only place that may touch `window`, and these answers have to\n * be provable in a suite with no browser.\n *\n * ## The redirect rule, which is the browser's rule\n *\n * When a request is redirected and the `Location` carries no fragment of its\n * own, the ORIGINAL fragment is carried onto the destination (RFC 7231 §7.1.2).\n * A `Location` that DOES name one wins outright. {@link withFragmentFrom} is\n * that rule and nothing else, so a client navigation through a redirect lands\n * where a full page load would have landed.\n */\n\n/**\n * The fragment of `url`, WITHOUT its leading `#`.\n *\n * `undefined` means there was no `#` at all, and `\"\"` means there was one with\n * nothing after it. The two are kept apart because they are different requests:\n * `/docs` says nothing about a fragment, `/docs#` says \"no target\" — and only\n * the second should end up written to the address bar as `#`.\n *\n * Plain string work rather than `new URL()`: this is asked of RELATIVE URLs\n * (`/docs#install`, `#install`), which `URL` cannot parse without a base, and\n * the base is `window`'s — not available on the server, and not this module's\n * to reach for.\n */\nexport function fragmentOf(url: string): string | undefined {\n const index = url.indexOf(\"#\");\n\n return index === -1 ? undefined : url.slice(index + 1);\n}\n\n/**\n * `url` with everything from its `#` onward removed.\n */\nexport function withoutFragment(url: string): string {\n const index = url.indexOf(\"#\");\n\n return index === -1 ? url : url.slice(0, index);\n}\n\n/**\n * Carry the fragment the caller ASKED for onto the URL the response came from.\n *\n * The resolved URL wins when it names a fragment itself — a redirect that says\n * `Location: /docs/v5#moved` meant it. Otherwise the requested fragment rides\n * along, which for the overwhelmingly common case (no redirect) simply puts\n * back what `fetch` dropped.\n */\nexport function withFragmentFrom(resolvedUrl: string, requestedUrl: string): string {\n if (fragmentOf(resolvedUrl) !== undefined) return resolvedUrl;\n\n const fragment = fragmentOf(requestedUrl);\n\n return fragment === undefined ? resolvedUrl : `${resolvedUrl}#${fragment}`;\n}\n\n/**\n * The `id` a fragment names, decoded.\n *\n * A fragment travels PERCENT-ENCODED — `#a%20b`, and every non-ASCII id is\n * encoded by the browser the moment it reaches the address bar — while the\n * `id` attribute in the document holds the decoded characters. Looking up the\n * raw fragment therefore misses every id with a space or a non-Latin letter,\n * which is a silent no-scroll indistinguishable from the bug this fixes.\n *\n * A malformed escape (`#100%`) is NOT an error here: `decodeURIComponent`\n * throws on it, and a throw during a navigation would cost the page for the\n * sake of a scroll. The raw text is returned instead, which is exactly what an\n * `id=\"100%\"` in the document would match.\n */\nexport function fragmentTargetId(fragment: string): string {\n try {\n return decodeURIComponent(fragment);\n } catch {\n return fragment;\n }\n}\n\n/**\n * The fragment of a destination that is THIS page with a fragment on it —\n * `#section`, or the current path written out in full with one appended.\n *\n * `undefined` means \"not that\": a different page, no fragment, or a URL that\n * cannot be resolved at all. The caller navigates as it otherwise would.\n *\n * Such a click must NOT run a client navigation. Re-fetching the page the user\n * is already looking at would throw away its DOM and every piece of state in\n * it — a scrolled container, an open menu, a playing video — to arrive at the\n * same page, and the round trip means the jump does not happen until the\n * network answers. The browser does not do that for a plain `<a href=\"#x\">`\n * and neither do we: the URL is updated and the page scrolls, in that order.\n *\n * The comparison is on origin, path and QUERY: `?page=2#top` from `?page=1#top`\n * is a real navigation to different data that happens to share a fragment.\n *\n * An EMPTY fragment (`/here#`) does not qualify — there is no target to scroll\n * to and nothing to distinguish it from a plain re-navigation to the same page.\n */\nexport function samePageFragment(url: string, currentHref: string): string | undefined {\n let destination: URL;\n let current: URL;\n\n try {\n destination = new URL(url, currentHref);\n current = new URL(currentHref);\n } catch {\n return undefined;\n }\n\n const fragment = destination.hash.slice(1);\n\n if (fragment === \"\") return undefined;\n\n const samePage =\n destination.origin === current.origin &&\n destination.pathname === current.pathname &&\n destination.search === current.search;\n\n return samePage ? fragment : undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCA,SAAgB,WAAW,KAAiC;CAC1D,MAAM,QAAQ,IAAI,QAAQ,GAAG;CAE7B,OAAO,UAAU,KAAK,SAAY,IAAI,MAAM,QAAQ,CAAC;AACvD;;;;AAKA,SAAgB,gBAAgB,KAAqB;CACnD,MAAM,QAAQ,IAAI,QAAQ,GAAG;CAE7B,OAAO,UAAU,KAAK,MAAM,IAAI,MAAM,GAAG,KAAK;AAChD;;;;;;;;;AAUA,SAAgB,iBAAiB,aAAqB,cAA8B;CAClF,IAAI,WAAW,WAAW,MAAM,QAAW,OAAO;CAElD,MAAM,WAAW,WAAW,YAAY;CAExC,OAAO,aAAa,SAAY,cAAc,GAAG,YAAY,GAAG;AAClE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,UAA0B;CACzD,IAAI;EACF,OAAO,mBAAmB,QAAQ;CACpC,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,iBAAiB,KAAa,aAAyC;CACrF,IAAI;CACJ,IAAI;CAEJ,IAAI;EACF,cAAc,IAAI,IAAI,KAAK,WAAW;EACtC,UAAU,IAAI,IAAI,WAAW;CAC/B,QAAQ;EACN;CACF;CAEA,MAAM,WAAW,YAAY,KAAK,MAAM,CAAC;CAEzC,IAAI,aAAa,IAAI,OAAO;CAO5B,OAJE,YAAY,WAAW,QAAQ,UAC/B,YAAY,aAAa,QAAQ,YACjC,YAAY,WAAW,QAAQ,SAEf,WAAW;AAC/B"}
@@ -24,6 +24,33 @@ type PageRouteHandlerOptions = {
24
24
  */
25
25
  stylesheetUrls?: readonly string[]; /** Same helper `dev-server.ts` exports — passed in, never imported. */
26
26
  applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;
27
+ /**
28
+ * The pattern stage 1 matches `request.path` against, when it differs from
29
+ * the REGISTERED path. Defaults to `path`, which is right for every route
30
+ * whose URL is its own.
31
+ *
32
+ * Exactly one route needs it: the not-found page, registered on the catch-all
33
+ * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and
34
+ * has no wildcard token, so a route registered as `*` matches NOTHING — the
35
+ * pipeline reports no match and `renderPageRequest` answers `{ html: "",
36
+ * status: 404 }`. Correct status, empty document: a 404 page that never
37
+ * renders its own body. Handing it `requestPath => requestPath` makes the
38
+ * requested URL the route's pattern for that one request, so the match is
39
+ * trivially true and the page renders for the URL the visitor actually asked
40
+ * for.
41
+ */
42
+ matchPath?: (requestPath: string) => string;
43
+ /**
44
+ * The status this route answers with when the pipeline settles on a plain
45
+ * `200` — the not-found route's `404`, and nothing else uses it.
46
+ *
47
+ * Applied ONLY to `200`, never as a blanket override: a `200` from this
48
+ * pipeline means "the document rendered and nobody objected", which for this
49
+ * route is precisely the not-found case. Any other settled status is a real
50
+ * outcome that the page or the boundary decided — a 500 from a failed render,
51
+ * a redirect — and overwriting it would report a broken page as a missing one.
52
+ */
53
+ statusForRenderedOk?: number;
27
54
  };
28
55
  type PageRouteHandler = (context: HttpContext) => Promise<void>;
29
56
  //#endregion
@@ -58,21 +58,22 @@ function installStylesheets(html, stylesheetUrls) {
58
58
  * the router's error path — which is exactly where it went before.
59
59
  */
60
60
  function createPageRouteHandler(options) {
61
- const { path, name, appFile, pageFile, layoutFile, loadModule, hydrationClientModuleUrl, stylesheetUrls, applyBufferedCookie } = options;
61
+ const { path, name, appFile, pageFile, layoutFile, loadModule, hydrationClientModuleUrl, stylesheetUrls, applyBufferedCookie, matchPath, statusForRenderedOk } = options;
62
62
  return async ({ request, response }) => {
63
63
  const [appModule, layoutModule, ownPageModule] = await Promise.all([
64
64
  loadModule(appFile),
65
65
  layoutFile ? loadModule(layoutFile) : Promise.resolve({}),
66
66
  loadModule(pageFile)
67
67
  ]);
68
+ const triple = {
69
+ app: appModule,
70
+ layout: layoutModule,
71
+ page: ownPageModule
72
+ };
68
73
  const routes = [{
69
- path,
74
+ path: matchPath === void 0 ? path : matchPath(request.path),
70
75
  name,
71
- triple: {
72
- app: appModule,
73
- layout: layoutModule,
74
- page: ownPageModule
75
- }
76
+ triple
76
77
  }];
77
78
  const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, void 0));
78
79
  const rendered = await renderPageRequest(request.path, {
@@ -82,23 +83,24 @@ function createPageRouteHandler(options) {
82
83
  response
83
84
  })
84
85
  });
86
+ const status = rendered.status === 200 && statusForRenderedOk !== void 0 ? statusForRenderedOk : rendered.status;
85
87
  if (wantsData) {
86
88
  for (const cookie of rendered.cookies) applyBufferedCookie(response, cookie);
87
89
  response.headers(rendered.headers);
88
90
  response.header("Vary", WARLOCK_DATA_REQUEST_HEADER);
89
91
  if (rendered.bundle === void 0) {
90
92
  response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
91
- await response.send(JSON.stringify({ error: "not_found" }), rendered.status);
93
+ await response.send(JSON.stringify({ error: "not_found" }), status);
92
94
  return;
93
95
  }
94
96
  response.setContentType(DATA_RESPONSE_CONTENT_TYPE);
95
- await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), rendered.status);
97
+ await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), status);
96
98
  return;
97
99
  }
98
100
  const html = installHydrationClientModule(installStylesheets(rendered.html, stylesheetUrls ?? []), hydrationClientModuleUrl, hydrationClientModuleUrl === void 0 ? void 0 : request.nonce);
99
101
  for (const cookie of rendered.cookies) applyBufferedCookie(response, cookie);
100
102
  response.headers(rendered.headers);
101
- await response.html(html, rendered.status);
103
+ await response.html(html, status);
102
104
  };
103
105
  }
104
106
 
@@ -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 type { HttpContext, Response } 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 { buildHydrationPayload } from \"./build-hydration-payload\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport type { PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\r\nimport { renderPageRequest } from \"./render-page\";\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 /** 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 applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n};\r\n\r\nexport type PageRouteHandler = (context: HttpContext) => Promise<void>;\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 \"&amp;\";\r\n case \"<\":\r\n return \"&lt;\";\r\n case \">\":\r\n return \"&gt;\";\r\n case '\"':\r\n return \"&quot;\";\r\n default:\r\n return \"&#39;\";\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, applies the committed\r\n * cookies and headers, and flushes the 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 failure, the missing-`</body>` throw above) belongs to\r\n * the router's error path — which is exactly where it went 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 hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n } = options;\r\n\r\n return async ({ request, response }: HttpContext) => {\r\n const [appModule, layoutModule, ownPageModule] = await Promise.all([\r\n loadModule(appFile),\r\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\r\n loadModule(pageFile),\r\n ]);\r\n\r\n const triple: PageRouteEntry[\"triple\"] = {\r\n app: appModule as PageTripleModule,\r\n layout: layoutModule as PageTripleModule,\r\n page: ownPageModule as PageTripleModule,\r\n };\r\n\r\n const routes: PageRouteEntry[] = [{ path, name, triple }];\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 wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\r\n\r\n const rendered = await renderPageRequest(request.path, {\r\n routes,\r\n createHttp: () => ({ request, response }),\r\n });\r\n\r\n if (wantsData) {\r\n // Cookies and headers FIRST, exactly as the document path does below and\r\n // for the same reason: a client navigation must be able to log a user in,\r\n // set a flash cookie or be redirected, and dropping those on this path\r\n // would make a navigation behave differently from a page load of the same\r\n // URL — the one difference this branch is not allowed to introduce.\r\n for (const cookie of rendered.cookies) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n\r\n response.headers(rendered.headers);\r\n\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\" }), rendered.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)), rendered.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 // THE single site that puts a committed cookie on the wire. The commit\r\n // stage used to mirror the same list onto the live response as well\r\n // (`commitBuffers`, execute-page-request.ts), and because fastify's\r\n // `setCookie` APPENDS rather than sets, every page response carried two\r\n // identical `Set-Cookie` headers — happy path included. The mirror's\r\n // cookie half is gone; this loop is what remains, and it is the right\r\n // one: it runs at stage 10a, after the render, alongside the headers and\r\n // the final status, so it applies the answer the pipeline actually\r\n // settled on rather than the one it had at stage 7.\r\n for (const cookie of rendered.cookies) {\r\n applyBufferedCookie(response, cookie);\r\n }\r\n\r\n response.headers(rendered.headers);\r\n\r\n await response.html(html, rendered.status);\r\n };\r\n}\r\n"],"mappings":";;;;;AAoEA,SAAS,oBAAoB,OAAuB;CAClD,OAAO,MAAM,QAAQ,aAAa,cAAc;EAC9C,QAAQ,WAAR;GACE,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,MACH,OAAO;GACT,SACE,OAAO;EACX;CACF,CAAC;AACH;AAEA,SAAS,6BACP,MACA,WACA,OACQ;CACR,IAAI,cAAc,UAAa,SAAS,IAAI,OAAO;CAEnD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CACnD,IAAI,qBAAqB,IACvB,MAAM,IAAI,MACR,yHACF;CAIF,MAAM,SAAS,wBADQ,UAAU,SAAY,KAAK,WAAW,oBAAoB,KAAK,EAAE,GAClC,QAAQ,oBAAoB,SAAS,EAAE;CAC7F,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,SAAS,KAAK,MAAM,gBAAgB;AAClF;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,mBAAmB,MAAc,gBAA2C;CACnF,IAAI,eAAe,WAAW,KAAK,SAAS,IAAI,OAAO;CAEvD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CAQnD,IAAI,qBAAqB,IAAI,OAAO;CAEpC,MAAM,QAAQ,eACX,KAAK,QAAQ,gCAAgC,oBAAoB,GAAG,EAAE,GAAG,CAAC,CAC1E,KAAK,EAAE;CAEV,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,QAAQ,KAAK,MAAM,gBAAgB;AACjF;;;;;;;;;;;;AAaA,SAAgB,uBAAuB,SAAoD;CACzF,MAAM,EACJ,MACA,MACA,SACA,UACA,YACA,YACA,0BACA,gBACA,wBACE;CAEJ,OAAO,OAAO,EAAE,SAAS,eAA4B;EACnD,MAAM,CAAC,WAAW,cAAc,iBAAiB,MAAM,QAAQ,IAAI;GACjE,WAAW,OAAO;GAClB,aAAa,WAAW,UAAU,IAAI,QAAQ,QAAQ,CAAC,CAAC;GACxD,WAAW,QAAQ;EACrB,CAAC;EAQD,MAAM,SAA2B,CAAC;GAAE;GAAM;GAAM;IAL9C,KAAK;IACL,QAAQ;IACR,MAAM;GAG6C;EAAE,CAAC;EAMxD,MAAM,YAAY,cAAc,QAAQ,OAAO,6BAA6B,MAAS,CAAC;EAEtF,MAAM,WAAW,MAAM,kBAAkB,QAAQ,MAAM;GACrD;GACA,mBAAmB;IAAE;IAAS;GAAS;EACzC,CAAC;EAED,IAAI,WAAW;GAMb,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;GAGtC,SAAS,QAAQ,SAAS,OAAO;GAKjC,SAAS,OAAO,QAAQ,2BAA2B;GAOnD,IAAI,SAAS,WAAW,QAAW;IACjC,SAAS,eAAe,0BAA0B;IAClD,MAAM,SAAS,KAAK,KAAK,UAAU,EAAE,OAAO,YAAY,CAAC,GAAG,SAAS,MAAM;IAE3E;GACF;GAiBA,SAAS,eAAe,0BAA0B;GAClD,MAAM,SAAS,KAAK,KAAK,UAAU,sBAAsB,SAAS,MAAM,CAAC,GAAG,SAAS,MAAM;GAE3F;EACF;EAQA,MAAM,OAAO,6BAFE,mBAAmB,SAAS,MAAM,kBAAkB,CAAC,CAG7D,GACL,0BACA,6BAA6B,SAAY,SAAY,QAAQ,KAC/D;EAWA,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;EAGtC,SAAS,QAAQ,SAAS,OAAO;EAEjC,MAAM,SAAS,KAAK,MAAM,SAAS,MAAM;CAC3C;AACF"}
1
+ {"version":3,"file":"create-page-route-handler.mjs","names":[],"sources":["../../../../../../../web/src/server/create-page-route-handler.ts"],"sourcesContent":["/**\n * The page handler, as a named seam.\n *\n * This is the request handler `installPageRoutes` used to inline into its\n * `router.get(...)` call (`install-page-routes.ts:236-275` before this\n * extraction; the pre-extraction copy is `scratchpad/install-page-routes.ts.orig`).\n * The behaviour is unchanged, byte for byte — what changes is that it is now\n * a named, exported, independently constructible function instead of a closure\n * over eight ambient bindings of `installPageRoutes`.\n *\n * WHY IT TAKES `loadModule` AND NOT A `ViteDevServer`: loading a module is the\n * only capability the handler ever needed, and the two runtimes answer it\n * differently — dev goes through Vite's SSR graph\n * (`vite.ssrLoadModule`, `install-page-routes.ts:207`), production reads the\n * already-built page manifest (`page-manifest.ts`). Taking \"how to load a\n * module\" as an INPUT is what lets the same handler serve both, and what lets\n * a test construct it with a plain async function — no Vite, no dev server, no\n * `app/` directory on disk.\n *\n * Scope: this file creates a seam and nothing else. It does not implement\n * `type: \"page\"` routing, HTML error pages, or any other new capability.\n */\nimport type { HttpContext, Response } from \"@warlock.js/core\";\n\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n isDataRequest,\n WARLOCK_DATA_REQUEST_HEADER,\n} from \"../routing/data-request\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport type { BufferedCookie } from \"./buffered-response\";\nimport type { PageRouteEntry, PageTripleModule } from \"./execute-page-request\";\nimport { renderPageRequest } from \"./render-page\";\n\n/**\n * How the handler obtains a page/layout/app module, by the same id\n * (`appFile`/`layoutFile`/`pageFile`) the caller registered it under. In dev\n * this is `moduleId => vite.ssrLoadModule(moduleId)`; the connector already\n * owns the dev/prod split, so the handler never learns which one it got.\n */\nexport type PageModuleLoader = (moduleId: string) => Promise<unknown>;\n\nexport type PageRouteHandlerOptions = {\n /** The composed, registered route path — `composeRoutePath`'s output. */\n path: string;\n /** The resolved route name; shared namespace with API routes. */\n name: string;\n /** The single global app-root file, e.g. `<appSrcRoot>/web/root.tsx`. */\n appFile: string;\n /** The page module's id. */\n pageFile: string;\n /** The page's own-directory `layout.tsx`, when it has one. */\n layoutFile?: string | undefined;\n loadModule: PageModuleLoader;\n /** Browser module appended after the server-rendered document. */\n hydrationClientModuleUrl?: string;\n /**\n * Stylesheet URLs for this page, emitted into `<head>` so the FIRST paint is\n * styled. Absent or empty means the application has no CSS — it never means\n * a stylesheet failed to resolve, which is the build's job to report.\n */\n stylesheetUrls?: readonly string[];\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\n /**\n * The pattern stage 1 matches `request.path` against, when it differs from\n * the REGISTERED path. Defaults to `path`, which is right for every route\n * whose URL is its own.\n *\n * Exactly one route needs it: the not-found page, registered on the catch-all\n * `*`. `matchRoute` compares segment by segment (`./match-page-route.ts`) and\n * has no wildcard token, so a route registered as `*` matches NOTHING — the\n * pipeline reports no match and `renderPageRequest` answers `{ html: \"\",\n * status: 404 }`. Correct status, empty document: a 404 page that never\n * renders its own body. Handing it `requestPath => requestPath` makes the\n * requested URL the route's pattern for that one request, so the match is\n * trivially true and the page renders for the URL the visitor actually asked\n * for.\n */\n matchPath?: (requestPath: string) => string;\n /**\n * The status this route answers with when the pipeline settles on a plain\n * `200` — the not-found route's `404`, and nothing else uses it.\n *\n * Applied ONLY to `200`, never as a blanket override: a `200` from this\n * pipeline means \"the document rendered and nobody objected\", which for this\n * route is precisely the not-found case. Any other settled status is a real\n * outcome that the page or the boundary decided — a 500 from a failed render,\n * a redirect — and overwriting it would report a broken page as a missing one.\n */\n statusForRenderedOk?: number;\n};\n\nexport type PageRouteHandler = (context: HttpContext) => Promise<void>;\n\nfunction escapeHtmlAttribute(value: string): string {\n return value.replace(/[&<>\"']/g, (character) => {\n switch (character) {\n case \"&\":\n return \"&amp;\";\n case \"<\":\n return \"&lt;\";\n case \">\":\n return \"&gt;\";\n case '\"':\n return \"&quot;\";\n default:\n return \"&#39;\";\n }\n });\n}\n\nfunction installHydrationClientModule(\n html: string,\n moduleUrl: string | undefined,\n nonce: string | undefined,\n): string {\n if (moduleUrl === undefined || html === \"\") return html;\n\n const closingBodyIndex = html.lastIndexOf(\"</body>\");\n if (closingBodyIndex === -1) {\n throw new Error(\n \"installPageRoutes: cannot install the hydration client module because the rendered document has no closing </body> tag.\",\n );\n }\n\n const nonceAttribute = nonce === undefined ? \"\" : ` nonce=\"${escapeHtmlAttribute(nonce)}\"`;\n const script = `<script type=\"module\"${nonceAttribute} src=\"${escapeHtmlAttribute(moduleUrl)}\"></script>`;\n return `${html.slice(0, closingBodyIndex)}${script}${html.slice(closingBodyIndex)}`;\n}\n\n/**\n * Put the page's stylesheets in `<head>`, so the first paint is styled.\n *\n * Without this the document carries no CSS at all. The stylesheet reaches the\n * browser only because the CLIENT bundle imports it, which means it is applied\n * by JavaScript after the module graph loads — the page renders unstyled first\n * and restyles a moment later. Correct markup, wrong-looking page, and nothing\n * in the console to explain it.\n *\n * A `<link>` in `<head>` is render-blocking, which is exactly what is wanted\n * here: the browser holds the first paint until the CSS is in, so there is no\n * flash rather than a faster ugly one.\n *\n * Inserted before `</head>` rather than after `<head>` so an application's own\n * `<link>`/`<style>` in the root document still comes FIRST and can be\n * overridden by these — matching how the framework's tags are documented to\n * behave, and keeping cascade order predictable.\n */\nfunction installStylesheets(html: string, stylesheetUrls: readonly string[]): string {\n if (stylesheetUrls.length === 0 || html === \"\") return html;\n\n const closingHeadIndex = html.lastIndexOf(\"</head>\");\n\n // No `<head>` is not an error the way a missing `</body>` is: a root that\n // renders no head is unusual but legal, and losing the stylesheet is a\n // cosmetic failure where losing hydration is a broken page. Silently\n // dropping it would be the wrong trade the other way, though — so the\n // document is left exactly as rendered and the caller's own missing-`</body>`\n // check remains the loud one.\n if (closingHeadIndex === -1) return html;\n\n const links = stylesheetUrls\n .map((url) => `<link rel=\"stylesheet\" href=\"${escapeHtmlAttribute(url)}\">`)\n .join(\"\");\n\n return `${html.slice(0, closingHeadIndex)}${links}${html.slice(closingHeadIndex)}`;\n}\n\n/**\n * Build the handler for ONE page route. Per request it loads the App + layout\n * + page triple (concurrently, in that order), renders the URL through\n * `renderPageRequest`, splices in the hydration module, applies the committed\n * cookies and headers, and flushes the document.\n *\n * No try/catch, deliberately: loader/render throws are already absorbed by the\n * pipeline's boundary machinery inside `renderPageRequest`, and anything that\n * escapes (a module-load failure, the missing-`</body>` throw above) belongs to\n * the router's error path — which is exactly where it went before.\n */\nexport function createPageRouteHandler(options: PageRouteHandlerOptions): PageRouteHandler {\n const {\n path,\n name,\n appFile,\n pageFile,\n layoutFile,\n loadModule,\n hydrationClientModuleUrl,\n stylesheetUrls,\n applyBufferedCookie,\n matchPath,\n statusForRenderedOk,\n } = options;\n\n return async ({ request, response }: HttpContext) => {\n const [appModule, layoutModule, ownPageModule] = await Promise.all([\n loadModule(appFile),\n layoutFile ? loadModule(layoutFile) : Promise.resolve({}),\n loadModule(pageFile),\n ]);\n\n const triple: PageRouteEntry[\"triple\"] = {\n app: appModule as PageTripleModule,\n layout: layoutModule as PageTripleModule,\n page: ownPageModule as PageTripleModule,\n };\n\n const routes: PageRouteEntry[] = [\n { path: matchPath === undefined ? path : matchPath(request.path), name, triple },\n ];\n\n // A DATA request runs everything above and below this line identically —\n // it is the same route, the same match and the same pipeline — and differs\n // only in what gets written at the end. Decided here, before the render, so\n // the branch is visibly about REPRESENTATION and not about behaviour.\n const wantsData = isDataRequest(request.header(WARLOCK_DATA_REQUEST_HEADER, undefined));\n\n const rendered = await renderPageRequest(request.path, {\n routes,\n createHttp: () => ({ request, response }),\n });\n\n // See `statusForRenderedOk`: a settled 200 is the only status this route is\n // allowed to restate, and both the document and the data branch below must\n // restate it the same way — a client navigation that received 200 with a\n // not-found payload would push the URL into history as a real page.\n const status =\n rendered.status === 200 && statusForRenderedOk !== undefined\n ? statusForRenderedOk\n : rendered.status;\n\n if (wantsData) {\n // Cookies and headers FIRST, exactly as the document path does below and\n // for the same reason: a client navigation must be able to log a user in,\n // set a flash cookie or be redirected, and dropping those on this path\n // would make a navigation behave differently from a page load of the same\n // URL — the one difference this branch is not allowed to introduce.\n for (const cookie of rendered.cookies) {\n applyBufferedCookie(response, cookie);\n }\n\n response.headers(rendered.headers);\n\n // So a shared cache can never serve a document to a client that asked for\n // JSON, or the reverse. See `data-request.ts` on why this stays even\n // while page responses are `no-store`.\n response.header(\"Vary\", WARLOCK_DATA_REQUEST_HEADER);\n\n // `bundle` is absent on exactly one path: nothing matched, so no pipeline\n // ran and there is no payload to build. Fastify already matched this\n // route to get here, so reaching it means `request.path` did not satisfy\n // the entry's own pattern — answered as the 404 it is, rather than\n // synthesising an empty payload the client would try to render as a page.\n if (rendered.bundle === undefined) {\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\n await response.send(JSON.stringify({ error: \"not_found\" }), status);\n\n return;\n }\n\n // SERIALIZED HERE, and handed over as a STRING on purpose.\n //\n // `response.send(object)` runs the body through core's `Response.parse`,\n // which recurses the object, calls `toJSON()` on anything that has one\n // (assigning `request` onto it as it goes) and rebuilds arrays. That is\n // the right behaviour for a controller returning Resources; it is the\n // wrong behaviour here, because the DOCUMENT path serializes this exact\n // object with a plain `JSON.stringify` into `#__WARLOCK_DATA__`. Routing\n // one path through a transformer and not the other is precisely the\n // drift `build-hydration-payload.ts` exists to prevent — the browser\n // would build one tree on a page load and a different one on a\n // navigation to the same URL.\n //\n // A string body also bypasses `parseBody()` entirely, so the content type\n // has to be declared rather than inferred from an object body.\n response.setContentType(DATA_RESPONSE_CONTENT_TYPE);\n await response.send(JSON.stringify(buildHydrationPayload(rendered.bundle)), status);\n\n return;\n }\n\n // Stylesheets first: they go in `<head>`, the hydration module goes before\n // `</body>`, and doing the head work on the already-rendered string keeps\n // both splices in one place rather than threading CSS through the React\n // render just to reach the same bytes.\n const styled = installStylesheets(rendered.html, stylesheetUrls ?? []);\n\n const html = installHydrationClientModule(\n styled,\n hydrationClientModuleUrl,\n hydrationClientModuleUrl === undefined ? undefined : request.nonce,\n );\n\n // THE single site that puts a committed cookie on the wire. The commit\n // stage used to mirror the same list onto the live response as well\n // (`commitBuffers`, execute-page-request.ts), and because fastify's\n // `setCookie` APPENDS rather than sets, every page response carried two\n // identical `Set-Cookie` headers — happy path included. The mirror's\n // cookie half is gone; this loop is what remains, and it is the right\n // one: it runs at stage 10a, after the render, alongside the headers and\n // the final status, so it applies the answer the pipeline actually\n // settled on rather than the one it had at stage 7.\n for (const cookie of rendered.cookies) {\n applyBufferedCookie(response, cookie);\n }\n\n response.headers(rendered.headers);\n\n await response.html(html, status);\n };\n}\n"],"mappings":";;;;;AA+FA,SAAS,oBAAoB,OAAuB;CAClD,OAAO,MAAM,QAAQ,aAAa,cAAc;EAC9C,QAAQ,WAAR;GACE,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,KACH,OAAO;GACT,KAAK,MACH,OAAO;GACT,SACE,OAAO;EACX;CACF,CAAC;AACH;AAEA,SAAS,6BACP,MACA,WACA,OACQ;CACR,IAAI,cAAc,UAAa,SAAS,IAAI,OAAO;CAEnD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CACnD,IAAI,qBAAqB,IACvB,MAAM,IAAI,MACR,yHACF;CAIF,MAAM,SAAS,wBADQ,UAAU,SAAY,KAAK,WAAW,oBAAoB,KAAK,EAAE,GAClC,QAAQ,oBAAoB,SAAS,EAAE;CAC7F,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,SAAS,KAAK,MAAM,gBAAgB;AAClF;;;;;;;;;;;;;;;;;;;AAoBA,SAAS,mBAAmB,MAAc,gBAA2C;CACnF,IAAI,eAAe,WAAW,KAAK,SAAS,IAAI,OAAO;CAEvD,MAAM,mBAAmB,KAAK,YAAY,SAAS;CAQnD,IAAI,qBAAqB,IAAI,OAAO;CAEpC,MAAM,QAAQ,eACX,KAAK,QAAQ,gCAAgC,oBAAoB,GAAG,EAAE,GAAG,CAAC,CAC1E,KAAK,EAAE;CAEV,OAAO,GAAG,KAAK,MAAM,GAAG,gBAAgB,IAAI,QAAQ,KAAK,MAAM,gBAAgB;AACjF;;;;;;;;;;;;AAaA,SAAgB,uBAAuB,SAAoD;CACzF,MAAM,EACJ,MACA,MACA,SACA,UACA,YACA,YACA,0BACA,gBACA,qBACA,WACA,wBACE;CAEJ,OAAO,OAAO,EAAE,SAAS,eAA4B;EACnD,MAAM,CAAC,WAAW,cAAc,iBAAiB,MAAM,QAAQ,IAAI;GACjE,WAAW,OAAO;GAClB,aAAa,WAAW,UAAU,IAAI,QAAQ,QAAQ,CAAC,CAAC;GACxD,WAAW,QAAQ;EACrB,CAAC;EAED,MAAM,SAAmC;GACvC,KAAK;GACL,QAAQ;GACR,MAAM;EACR;EAEA,MAAM,SAA2B,CAC/B;GAAE,MAAM,cAAc,SAAY,OAAO,UAAU,QAAQ,IAAI;GAAG;GAAM;EAAO,CACjF;EAMA,MAAM,YAAY,cAAc,QAAQ,OAAO,6BAA6B,MAAS,CAAC;EAEtF,MAAM,WAAW,MAAM,kBAAkB,QAAQ,MAAM;GACrD;GACA,mBAAmB;IAAE;IAAS;GAAS;EACzC,CAAC;EAMD,MAAM,SACJ,SAAS,WAAW,OAAO,wBAAwB,SAC/C,sBACA,SAAS;EAEf,IAAI,WAAW;GAMb,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;GAGtC,SAAS,QAAQ,SAAS,OAAO;GAKjC,SAAS,OAAO,QAAQ,2BAA2B;GAOnD,IAAI,SAAS,WAAW,QAAW;IACjC,SAAS,eAAe,0BAA0B;IAClD,MAAM,SAAS,KAAK,KAAK,UAAU,EAAE,OAAO,YAAY,CAAC,GAAG,MAAM;IAElE;GACF;GAiBA,SAAS,eAAe,0BAA0B;GAClD,MAAM,SAAS,KAAK,KAAK,UAAU,sBAAsB,SAAS,MAAM,CAAC,GAAG,MAAM;GAElF;EACF;EAQA,MAAM,OAAO,6BAFE,mBAAmB,SAAS,MAAM,kBAAkB,CAAC,CAG7D,GACL,0BACA,6BAA6B,SAAY,SAAY,QAAQ,KAC/D;EAWA,KAAK,MAAM,UAAU,SAAS,SAC5B,oBAAoB,UAAU,MAAM;EAGtC,SAAS,QAAQ,SAAS,OAAO;EAEjC,MAAM,SAAS,KAAK,MAAM,MAAM;CAClC;AACF"}
@@ -9,5 +9,6 @@ import { InstallPageRoutesOptions, InstalledPageRoute, LayoutModuleShape, PageMo
9
9
  import { PageRoutesRegistry, RenderPageOptions, RenderPageRequestOptions, RenderedPage, connectPageRoutes, renderPage, renderPageRequest } from "./render-page.mjs";
10
10
  import { PageModuleNotInManifestError, createPageModuleLoader } from "./create-page-module-loader.mjs";
11
11
  import { InstallPageRoutesFromManifestOptions, InstalledManifestPageRoute, PageRouteHandlerFactory, installPageRoutesFromManifest } from "./install-page-routes-from-manifest.mjs";
12
+ import { DuplicateNotFoundPageError, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, NotFoundRouteHandlerOptions, RegisteredRouteShape, UnmatchedRequestKind, acceptsHtmlExplicitly, classifyUnmatchedRequest, createNotFoundRouteHandler, frameworkDefaultNotFoundDocument, isNotFoundPageFile } from "./not-found-page.mjs";
12
13
  import { VITE_DIRECT_CSS_QUERY, devStylesheetUrls, productionStylesheetUrls } from "./stylesheet-urls.mjs";
13
- export { type BufferedCookie, type BufferedHeader, type BufferedWebResponse, type ExecutePageRequestOptions, type InstallPageRoutesFromManifestOptions, type InstallPageRoutesOptions, type InstalledManifestPageRoute, type InstalledPageRoute, LOADER_SHORT_CIRCUIT, type LayoutModuleShape, type LoaderShortCircuitSignal, PAYLOAD_SCRIPT_ID, type PageBoundaryDesignation, type PageContextRunner, type PageDataBundle, type PageLevelName, PageModuleNotInManifestError, type PageModuleShape, type PageResponseCommit, type PageRouteEntry, type PageRouteExport, type PageRouteHandlerFactory, type PageRouteMatch, type PageRoutesRegistry, type PageShortCircuit, type PageTripleModule, type PipelineLoader, type PipelineMiddleware, type PipelineStore, type RenderPageOptions, type RenderPageRequestOptions, type RenderedPage, type ResponseBuffer, VITE_DIRECT_CSS_QUERY, composeRoutePath, connectPageContext, connectPageRoutes, connectPageSharedScope, connectSharedStore, createBufferedResponse, createPageModuleLoader, devStylesheetUrls, escapePayload, executePageRequest, installPageRoutes, installPageRoutesFromManifest, isLoaderShortCircuit, productionStylesheetUrls, renderPage, renderPageRequest };
14
+ export { type BufferedCookie, type BufferedHeader, type BufferedWebResponse, DuplicateNotFoundPageError, type ExecutePageRequestOptions, type InstallPageRoutesFromManifestOptions, type InstallPageRoutesOptions, type InstalledManifestPageRoute, type InstalledPageRoute, LOADER_SHORT_CIRCUIT, type LayoutModuleShape, type LoaderShortCircuitSignal, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, type NotFoundRouteHandlerOptions, PAYLOAD_SCRIPT_ID, type PageBoundaryDesignation, type PageContextRunner, type PageDataBundle, type PageLevelName, PageModuleNotInManifestError, type PageModuleShape, type PageResponseCommit, type PageRouteEntry, type PageRouteExport, type PageRouteHandlerFactory, type PageRouteMatch, type PageRoutesRegistry, type PageShortCircuit, type PageTripleModule, type PipelineLoader, type PipelineMiddleware, type PipelineStore, type RegisteredRouteShape, type RenderPageOptions, type RenderPageRequestOptions, type RenderedPage, type ResponseBuffer, type UnmatchedRequestKind, VITE_DIRECT_CSS_QUERY, acceptsHtmlExplicitly, classifyUnmatchedRequest, composeRoutePath, connectPageContext, connectPageRoutes, connectPageSharedScope, connectSharedStore, createBufferedResponse, createNotFoundRouteHandler, createPageModuleLoader, devStylesheetUrls, escapePayload, executePageRequest, frameworkDefaultNotFoundDocument, installPageRoutes, installPageRoutesFromManifest, isLoaderShortCircuit, isNotFoundPageFile, productionStylesheetUrls, renderPage, renderPageRequest };
@@ -6,8 +6,9 @@ import { executePageRequest } from "./execute-page-request.mjs";
6
6
  import { connectPageRoutes, renderPage, renderPageRequest } from "./render-page.mjs";
7
7
  import { PageModuleNotInManifestError, createPageModuleLoader } from "./create-page-module-loader.mjs";
8
8
  import { composeRoutePath } from "../routing/compose-route-path.mjs";
9
+ import { DuplicateNotFoundPageError, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, acceptsHtmlExplicitly, classifyUnmatchedRequest, createNotFoundRouteHandler, frameworkDefaultNotFoundDocument, isNotFoundPageFile } from "./not-found-page.mjs";
9
10
  import { installPageRoutesFromManifest } from "./install-page-routes-from-manifest.mjs";
10
11
  import { installPageRoutes } from "./install-page-routes.mjs";
11
12
  import { VITE_DIRECT_CSS_QUERY, devStylesheetUrls, productionStylesheetUrls } from "./stylesheet-urls.mjs";
12
13
 
13
- export { LOADER_SHORT_CIRCUIT, PAYLOAD_SCRIPT_ID, PageModuleNotInManifestError, VITE_DIRECT_CSS_QUERY, composeRoutePath, connectPageContext, connectPageRoutes, connectPageSharedScope, connectSharedStore, createBufferedResponse, createPageModuleLoader, devStylesheetUrls, escapePayload, executePageRequest, installPageRoutes, installPageRoutesFromManifest, isLoaderShortCircuit, productionStylesheetUrls, renderPage, renderPageRequest };
14
+ export { DuplicateNotFoundPageError, LOADER_SHORT_CIRCUIT, NOT_FOUND_PAGE_FILENAME, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, PAYLOAD_SCRIPT_ID, PageModuleNotInManifestError, VITE_DIRECT_CSS_QUERY, acceptsHtmlExplicitly, classifyUnmatchedRequest, composeRoutePath, connectPageContext, connectPageRoutes, connectPageSharedScope, connectSharedStore, createBufferedResponse, createNotFoundRouteHandler, createPageModuleLoader, devStylesheetUrls, escapePayload, executePageRequest, frameworkDefaultNotFoundDocument, installPageRoutes, installPageRoutesFromManifest, isLoaderShortCircuit, isNotFoundPageFile, productionStylesheetUrls, renderPage, renderPageRequest };
@@ -4,6 +4,7 @@ import { composeRoutePath } from "../routing/compose-route-path.mjs";
4
4
  import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
5
5
  import { canonicalizeRouteExport, deriveFallbackRouteName } from "../routing/route-identity.mjs";
6
6
  import { createPageRouteHandler } from "./create-page-route-handler.mjs";
7
+ import { DuplicateNotFoundPageError, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, createNotFoundRouteHandler, isNotFoundPageFile } from "./not-found-page.mjs";
7
8
 
8
9
  //#region ../web/src/server/install-page-routes-from-manifest.ts
9
10
  /**
@@ -91,9 +92,14 @@ function installPageRoutesFromManifest(options) {
91
92
  const app = manifest.app;
92
93
  if (app === void 0) throw new Error(`installPageRoutesFromManifest: this build's page manifest carries ${manifest.pages.length} page(s) but no application root. Every page renders inside the app component, so no page can be registered without it. Re-run the build so the generated pages barrel provides an \`app\` entry.`);
93
94
  const loadModule = createPageModuleLoader(manifest);
95
+ const notFoundPages = manifest.pages.filter((page) => isNotFoundPageFile(page.sourceFile));
96
+ const pages = manifest.pages.filter((page) => !isNotFoundPageFile(page.sourceFile));
97
+ if (notFoundPages.length > 1) throw new DuplicateNotFoundPageError(notFoundPages.map((page) => page.sourceFile));
98
+ const notFoundPage = notFoundPages[0];
99
+ if (notFoundPage !== void 0 && notFoundPage.module.route !== void 0) throw new NotFoundPageDeclaresRouteError(notFoundPage.sourceFile);
94
100
  const installed = [];
95
101
  const fileByPath = /* @__PURE__ */ new Map();
96
- for (const page of manifest.pages) {
102
+ for (const page of pages) {
97
103
  const { host: layout, prefix: layoutPrefix } = layoutLevelOf(page);
98
104
  const routeExport = page.module.route;
99
105
  if (routeExport === void 0) continue;
@@ -124,6 +130,22 @@ function installPageRoutesFromManifest(options) {
124
130
  layoutFile: layout?.sourceFile
125
131
  });
126
132
  }
133
+ router.get("*", createNotFoundRouteHandler({ renderPage: notFoundPage === void 0 ? void 0 : createHandler({
134
+ path: "*",
135
+ name: NOT_FOUND_ROUTE_NAME,
136
+ appFile: app.sourceFile,
137
+ pageFile: notFoundPage.sourceFile,
138
+ layoutFile: void 0,
139
+ loadModule,
140
+ hydrationClientModuleUrl,
141
+ stylesheetUrls,
142
+ applyBufferedCookie,
143
+ matchPath: (requestPath) => requestPath,
144
+ statusForRenderedOk: 404
145
+ }) }), {
146
+ name: NOT_FOUND_ROUTE_NAME,
147
+ isPage: true
148
+ });
127
149
  publishRouteTable(installed, "installPageRoutesFromManifest (production)");
128
150
  return installed;
129
151
  }
@@ -1 +1 @@
1
- {"version":3,"file":"install-page-routes-from-manifest.mjs","names":[],"sources":["../../../../../../../web/src/server/install-page-routes-from-manifest.ts"],"sourcesContent":["/**\r\n * Page-route registration for a built application.\r\n *\r\n * `installPageRoutes` answers \"which pages exist?\" by walking the filesystem\r\n * and \"what is this module?\" by asking Vite to evaluate it. Neither question\r\n * can be asked of a running production process: there is no `app/` tree beside\r\n * the bundle and no Vite. Both answers were therefore moved to build time — the\r\n * generated `pages.ts` barrel statically imported every page, layout and the\r\n * app root and handed them over as a {@link PageManifest}, and this module\r\n * turns that table into registered routes.\r\n *\r\n * WHAT IS DELIBERATELY IDENTICAL TO DEVELOPMENT: the route a page ends up on,\r\n * and the guards that run before it renders. A page's `route` export and the\r\n * `prefix` and `middleware` exports of EVERY layout on its path are read off the\r\n * module namespaces here, at boot, and composed by the same rules dev composes\r\n * them by ({@link layoutLevelOf}, {@link composeLayoutLevel}) — so the URL a page\r\n * answers on and the chain that guards it are decided by the page's own source\r\n * in both modes, and a build cannot quietly disagree with the dev server about\r\n * either.\r\n *\r\n * WHAT IS DELIBERATELY DIFFERENT: this is synchronous. Every module is already\r\n * in memory, so registration has nothing to await; the loader handed to each\r\n * handler is a lookup over the same table, not an evaluation step.\r\n */\r\nimport { composeRoutePath } from \"../routing/compose-route-path\";\r\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\r\nimport { canonicalizeRouteExport, deriveFallbackRouteName } from \"../routing/route-identity\";\r\nimport { publishRouteTable } from \"../routing/route-table\";\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport { createPageModuleLoader } from \"./create-page-module-loader\";\r\nimport {\r\n createPageRouteHandler,\r\n type PageRouteHandler,\r\n type PageRouteHandlerOptions,\r\n} from \"./create-page-route-handler\";\r\nimport type { PipelineMiddleware } from \"./execute-page-request\";\r\nimport type { PageManifest, PageManifestLayoutEntry, PageManifestPageEntry } from \"./page-manifest\";\r\n\r\n/** A page declares either a bare path or a path plus an explicit route name. */\r\ntype PageRouteExport = string | { path: string; name?: string };\r\n\r\n/** The only export this module reads off a page module namespace. */\r\ntype PageModuleShape = {\r\n route?: PageRouteExport;\r\n};\r\n\r\n/** The exports this module reads off a layout module namespace. */\r\ntype LayoutModuleShape = {\r\n prefix?: string;\r\n /**\r\n * The default export — the thing that puts an element in the document, and\r\n * therefore the ONLY export that decides whether a layout counts against the\r\n * single-rendering-layout rule (`../routing/layout-policy.ts`). The manifest\r\n * carries LOADED modules, so this is a fact rather than a guess, exactly as it\r\n * is in dev (`install-page-routes.ts:145-150`).\r\n */\r\n default?: unknown;\r\n /** The layout's guards, in the order it declared them. */\r\n middleware?: readonly PipelineMiddleware[];\r\n};\r\n\r\n/**\r\n * How a handler is built for one page. Defaults to `createPageRouteHandler`;\r\n * taking it as an input keeps this module's own job — reading the manifest and\r\n * registering routes — provable without a render pipeline behind it.\r\n */\r\nexport type PageRouteHandlerFactory = (options: PageRouteHandlerOptions) => PageRouteHandler;\r\n\r\nexport type InstalledManifestPageRoute = {\r\n /** The composed path the route was registered on. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The page's manifest `sourceFile`. */\r\n file: string;\r\n /** The layout's manifest `sourceFile`, when the page has one. */\r\n layoutFile: string | undefined;\r\n};\r\n\r\nexport type InstallPageRoutesFromManifestOptions = {\r\n router: Router;\r\n /** The table the generated production barrel provided at import time. */\r\n manifest: PageManifest;\r\n /** Browser module loaded after the server-rendered application and payload. */\r\n hydrationClientModuleUrl?: string;\r\n /** Stylesheet URLs emitted into every page's `<head>`. */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n createHandler?: PageRouteHandlerFactory;\r\n};\r\n\r\nfunction resolveRoute(\r\n routeExport: PageRouteExport,\r\n sourceFile: string,\r\n): { path: string; name: string } {\r\n const canonical = canonicalizeRouteExport(routeExport);\r\n\r\n return {\r\n path: canonical.path,\r\n name: canonical.name ?? deriveFallbackRouteName({ routePath: canonical.path, sourceFile }),\r\n };\r\n}\r\n\r\n/**\r\n * The page's layout LEVEL, resolved from the whole chain the manifest carries\r\n * rather than from the one layout nearest to it — the same resolution dev makes\r\n * (`install-page-routes.ts:138-164`), against loaded modules instead of Vite's.\r\n *\r\n * The manifest carries the FULL chain, outermost first, and the render pipeline\r\n * has exactly one layout slot per page (`execute-page-request.ts`'s\r\n * `PageRouteEntry[\"triple\"]`), so the chain has to be collapsed into one module\r\n * before it reaches a handler. Two things collapse differently and both matter:\r\n *\r\n * - RENDERING is a selection: at most one layout on the chain may render, and\r\n * the policy picks it. `renders` is read off the loaded module\r\n * (`typeof module.default !== \"undefined\"`), never off the entry's presence in\r\n * the chain — a `middleware`-only layout has no default export and is not a\r\n * wrapper. Passing bare `sourceFile` strings had every layout read as a\r\n * rendering one, so boot refused a middleware-only guard chain that the build\r\n * had already accepted: an application that builds and will not start.\r\n * - MIDDLEWARE and PREFIX are compositions: every layout on the path\r\n * contributes, outermost first. A guard on an outer layout that the page's own\r\n * directory knows nothing about is exactly the guard that must still run, and\r\n * a prefix nobody composed is a URL nobody wrote down.\r\n *\r\n * A chain with more than one RENDERING layout is still refused here, at boot,\r\n * before a single request can observe the wrong document. Like the missing\r\n * app-root refusal below, that arm defends against stale or hand-edited build\r\n * artifacts: the build refuses to emit such a chain, but a manifest can reach a\r\n * running process without that build having produced it.\r\n */\r\ntype LayoutLevel = {\r\n /**\r\n * The layout entry the handler's layout slot is registered under, or\r\n * `undefined` when the page has no layout at all: the layout that RENDERS,\r\n * or — when none does — the nearest one, which is the slot production has\r\n * always used and so the choice that changes nothing but the middleware for a\r\n * chain with no wrapper in it.\r\n */\r\n host: PageManifestLayoutEntry | undefined;\r\n /** Every layout's `prefix`, composed outermost first — `discoverPages`' own reduction. */\r\n prefix: string;\r\n};\r\n\r\nfunction layoutLevelOf(page: PageManifestPageEntry): LayoutLevel {\r\n const selection = selectPageLayout(\r\n page.layouts.map((layout) => ({\r\n layout: layout.sourceFile,\r\n renders: typeof (layout.module as LayoutModuleShape).default !== \"undefined\",\r\n })),\r\n );\r\n\r\n if (selection.type === \"rejected\") {\r\n throw new NestedLayoutsNotSupportedError(page.sourceFile, selection.layouts);\r\n }\r\n\r\n return {\r\n host:\r\n selection.type === \"selected\"\r\n ? page.layouts.find((layout) => layout.sourceFile === selection.layout)\r\n : page.layouts.at(-1),\r\n prefix: page.layouts.reduce(\r\n (composed, layout) =>\r\n composeRoutePath(composed, (layout.module as LayoutModuleShape).prefix ?? \"/\"),\r\n \"/\",\r\n ),\r\n };\r\n}\r\n\r\n/**\r\n * The layout slot's module for one page: the slot host's own namespace, with the\r\n * whole chain's middleware in place of its own — outermost first, which is the\r\n * order stage 3 runs the array in (`execute-page-request.ts:519-524`) and the\r\n * order an outer `optionalAuth` needs in order to have resolved an identity\r\n * before an inner `gate()` checks it.\r\n *\r\n * Deliberately NOT core's route-level `middleware` option: that runs before the\r\n * pipeline's App-level middleware, which would invert outermost-first — the one\r\n * property this composition exists to guarantee.\r\n *\r\n * Built once at registration, not per request: unlike dev, every module here is\r\n * already in memory and cannot change under a running process.\r\n */\r\nfunction composeLayoutLevel(\r\n page: PageManifestPageEntry,\r\n host: PageManifestLayoutEntry,\r\n): Record<string, unknown> {\r\n return {\r\n ...host.module,\r\n middleware: page.layouts.flatMap((layout) => [\r\n ...((layout.module as LayoutModuleShape).middleware ?? []),\r\n ]),\r\n };\r\n}\r\n\r\n/**\r\n * Registers every page the manifest carries into `options.router`.\r\n *\r\n * An empty manifest registers nothing and is not an error: \"built with web, no\r\n * pages\" is a legal state of a built application, and treating it as a failure\r\n * would make an empty project unbootable. A manifest that DOES carry pages but\r\n * no app root is the opposite — every page renders inside the application root,\r\n * so that combination is a broken table rather than an empty one, and it is\r\n * refused before any route exists to serve a request with a missing root.\r\n *\r\n * Two pages composing to the same path is refused the moment the second one is\r\n * seen, naming both — a registration-time failure, rather than a route one of\r\n * them silently loses at runtime.\r\n */\r\nexport function installPageRoutesFromManifest(\r\n options: InstallPageRoutesFromManifestOptions,\r\n): InstalledManifestPageRoute[] {\r\n const {\r\n router,\r\n manifest,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n createHandler = createPageRouteHandler,\r\n } = options;\r\n\r\n if (manifest.pages.length === 0) return [];\r\n\r\n const app = manifest.app;\r\n\r\n if (app === undefined) {\r\n throw new Error(\r\n `installPageRoutesFromManifest: this build's page manifest carries ${manifest.pages.length} ` +\r\n \"page(s) but no application root. Every page renders inside the app component, so no \" +\r\n \"page can be registered without it. Re-run the build so the generated pages barrel \" +\r\n \"provides an `app` entry.\",\r\n );\r\n }\r\n\r\n // Ids are the manifest's own `sourceFile` strings and are passed on untouched:\r\n // the loader below matches them by exact string equality, so resolving,\r\n // joining or swapping separators on one side of that comparison would turn\r\n // every lookup into a miss.\r\n const loadModule = createPageModuleLoader(manifest);\r\n\r\n const installed: InstalledManifestPageRoute[] = [];\r\n const fileByPath = new Map<string, string>();\r\n\r\n for (const page of manifest.pages) {\r\n const { host: layout, prefix: layoutPrefix } = layoutLevelOf(page);\r\n const routeExport = (page.module as PageModuleShape).route;\r\n\r\n // A page that declares no route has no public URL to be registered under —\r\n // the same page discovery skips in development.\r\n if (routeExport === undefined) continue;\r\n\r\n const { path: routePath, name } = resolveRoute(routeExport, page.sourceFile);\r\n const effectivePath = composeRoutePath(layoutPrefix, routePath);\r\n const existingFile = fileByPath.get(effectivePath);\r\n\r\n if (existingFile) {\r\n throw new Error(\r\n `installPageRoutesFromManifest: composed route path \"${effectivePath}\" (layout ` +\r\n `prefix \"${layoutPrefix}\" + route.path \"${routePath}\") is declared by two pages — ` +\r\n `\"${existingFile}\" and \"${page.sourceFile}\". Every page's composed route path must ` +\r\n \"be unique.\",\r\n );\r\n }\r\n\r\n fileByPath.set(effectivePath, page.sourceFile);\r\n\r\n // The layout slot's id resolves to the COMPOSED level — every layout's\r\n // middleware, in chain order — and every other id goes straight to the\r\n // manifest lookup. A one-layout chain has nothing to compose, so it is left\r\n // to resolve as the exact namespace object the manifest carries, untouched.\r\n const composedLayout =\r\n page.layouts.length > 1 && layout !== undefined\r\n ? composeLayoutLevel(page, layout)\r\n : undefined;\r\n\r\n router.get(\r\n effectivePath,\r\n createHandler({\r\n path: effectivePath,\r\n name,\r\n appFile: app.sourceFile,\r\n pageFile: page.sourceFile,\r\n layoutFile: layout?.sourceFile,\r\n loadModule:\r\n composedLayout === undefined\r\n ? loadModule\r\n : (moduleId) =>\r\n moduleId === layout?.sourceFile\r\n ? Promise.resolve(composedLayout)\r\n : loadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n }),\r\n // `isPage` marks this route as SSR-served. Pages and API routes share one\r\n // router and one route-name namespace, so the router's duplicate-name\r\n // error reads this flag to say which claimant is the page.\r\n { name, isPage: true },\r\n );\r\n\r\n installed.push({\r\n path: effectivePath,\r\n name,\r\n file: page.sourceFile,\r\n layoutFile: layout?.sourceFile,\r\n });\r\n }\r\n\r\n /*\r\n Same publish as the dev installer, for the same reason: `href()` and the\r\n router must agree, and they only can if both read the one loop that\r\n registered the routes. Production installs once at boot, so the wholesale\r\n replacement is a single write before the first request.\r\n */\r\n publishRouteTable(installed, \"installPageRoutesFromManifest (production)\");\r\n\r\n return installed;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6FA,SAAS,aACP,aACA,YACgC;CAChC,MAAM,YAAY,wBAAwB,WAAW;CAErD,OAAO;EACL,MAAM,UAAU;EAChB,MAAM,UAAU,QAAQ,wBAAwB;GAAE,WAAW,UAAU;GAAM;EAAW,CAAC;CAC3F;AACF;AA2CA,SAAS,cAAc,MAA0C;CAC/D,MAAM,YAAY,iBAChB,KAAK,QAAQ,KAAK,YAAY;EAC5B,QAAQ,OAAO;EACf,SAAS,OAAQ,OAAO,OAA6B,YAAY;CACnE,EAAE,CACJ;CAEA,IAAI,UAAU,SAAS,YACrB,MAAM,IAAI,+BAA+B,KAAK,YAAY,UAAU,OAAO;CAG7E,OAAO;EACL,MACE,UAAU,SAAS,aACf,KAAK,QAAQ,MAAM,WAAW,OAAO,eAAe,UAAU,MAAM,IACpE,KAAK,QAAQ,GAAG,EAAE;EACxB,QAAQ,KAAK,QAAQ,QAClB,UAAU,WACT,iBAAiB,UAAW,OAAO,OAA6B,UAAU,GAAG,GAC/E,GACF;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAS,mBACP,MACA,MACyB;CACzB,OAAO;EACL,GAAG,KAAK;EACR,YAAY,KAAK,QAAQ,SAAS,WAAW,CAC3C,GAAK,OAAO,OAA6B,cAAc,CAAC,CAC1D,CAAC;CACH;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,8BACd,SAC8B;CAC9B,MAAM,EACJ,QACA,UACA,0BACA,gBACA,qBACA,gBAAgB,2BACd;CAEJ,IAAI,SAAS,MAAM,WAAW,GAAG,OAAO,CAAC;CAEzC,MAAM,MAAM,SAAS;CAErB,IAAI,QAAQ,QACV,MAAM,IAAI,MACR,qEAAqE,SAAS,MAAM,OAAO,kMAI7F;CAOF,MAAM,aAAa,uBAAuB,QAAQ;CAElD,MAAM,YAA0C,CAAC;CACjD,MAAM,6BAAa,IAAI,IAAoB;CAE3C,KAAK,MAAM,QAAQ,SAAS,OAAO;EACjC,MAAM,EAAE,MAAM,QAAQ,QAAQ,iBAAiB,cAAc,IAAI;EACjE,MAAM,cAAe,KAAK,OAA2B;EAIrD,IAAI,gBAAgB,QAAW;EAE/B,MAAM,EAAE,MAAM,WAAW,SAAS,aAAa,aAAa,KAAK,UAAU;EAC3E,MAAM,gBAAgB,iBAAiB,cAAc,SAAS;EAC9D,MAAM,eAAe,WAAW,IAAI,aAAa;EAEjD,IAAI,cACF,MAAM,IAAI,MACR,uDAAuD,cAAc,oBACxD,aAAa,kBAAkB,UAAU,iCAChD,aAAa,SAAS,KAAK,WAAW,oDAE9C;EAGF,WAAW,IAAI,eAAe,KAAK,UAAU;EAM7C,MAAM,iBACJ,KAAK,QAAQ,SAAS,KAAK,WAAW,SAClC,mBAAmB,MAAM,MAAM,IAC/B;EAEN,OAAO,IACL,eACA,cAAc;GACZ,MAAM;GACN;GACA,SAAS,IAAI;GACb,UAAU,KAAK;GACf,YAAY,QAAQ;GACpB,YACE,mBAAmB,SACf,cACC,aACC,aAAa,QAAQ,aACjB,QAAQ,QAAQ,cAAc,IAC9B,WAAW,QAAQ;GAC/B;GACA;GACA;EACF,CAAC,GAID;GAAE;GAAM,QAAQ;EAAK,CACvB;EAEA,UAAU,KAAK;GACb,MAAM;GACN;GACA,MAAM,KAAK;GACX,YAAY,QAAQ;EACtB,CAAC;CACH;CAQA,kBAAkB,WAAW,4CAA4C;CAEzE,OAAO;AACT"}
1
+ {"version":3,"file":"install-page-routes-from-manifest.mjs","names":[],"sources":["../../../../../../../web/src/server/install-page-routes-from-manifest.ts"],"sourcesContent":["/**\r\n * Page-route registration for a built application.\r\n *\r\n * `installPageRoutes` answers \"which pages exist?\" by walking the filesystem\r\n * and \"what is this module?\" by asking Vite to evaluate it. Neither question\r\n * can be asked of a running production process: there is no `app/` tree beside\r\n * the bundle and no Vite. Both answers were therefore moved to build time — the\r\n * generated `pages.ts` barrel statically imported every page, layout and the\r\n * app root and handed them over as a {@link PageManifest}, and this module\r\n * turns that table into registered routes.\r\n *\r\n * WHAT IS DELIBERATELY IDENTICAL TO DEVELOPMENT: the route a page ends up on,\r\n * and the guards that run before it renders. A page's `route` export and the\r\n * `prefix` and `middleware` exports of EVERY layout on its path are read off the\r\n * module namespaces here, at boot, and composed by the same rules dev composes\r\n * them by ({@link layoutLevelOf}, {@link composeLayoutLevel}) — so the URL a page\r\n * answers on and the chain that guards it are decided by the page's own source\r\n * in both modes, and a build cannot quietly disagree with the dev server about\r\n * either.\r\n *\r\n * WHAT IS DELIBERATELY DIFFERENT: this is synchronous. Every module is already\r\n * in memory, so registration has nothing to await; the loader handed to each\r\n * handler is a lookup over the same table, not an evaluation step.\r\n */\r\nimport { composeRoutePath } from \"../routing/compose-route-path\";\r\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\r\nimport { canonicalizeRouteExport, deriveFallbackRouteName } from \"../routing/route-identity\";\r\nimport { publishRouteTable } from \"../routing/route-table\";\r\nimport type { Response, Router } from \"@warlock.js/core\";\r\nimport type { BufferedCookie } from \"./buffered-response\";\r\nimport { createPageModuleLoader } from \"./create-page-module-loader\";\r\nimport {\r\n createPageRouteHandler,\r\n type PageRouteHandler,\r\n type PageRouteHandlerOptions,\r\n} from \"./create-page-route-handler\";\r\nimport type { PipelineMiddleware } from \"./execute-page-request\";\r\nimport {\r\n createNotFoundRouteHandler,\r\n DuplicateNotFoundPageError,\r\n isNotFoundPageFile,\r\n NotFoundPageDeclaresRouteError,\r\n NOT_FOUND_ROUTE_NAME,\r\n NOT_FOUND_ROUTE_PATH,\r\n type RegisteredRouteShape,\r\n} from \"./not-found-page\";\r\nimport type { PageManifest, PageManifestLayoutEntry, PageManifestPageEntry } from \"./page-manifest\";\r\n\r\n/** A page declares either a bare path or a path plus an explicit route name. */\r\ntype PageRouteExport = string | { path: string; name?: string };\r\n\r\n/** The only export this module reads off a page module namespace. */\r\ntype PageModuleShape = {\r\n route?: PageRouteExport;\r\n};\r\n\r\n/** The exports this module reads off a layout module namespace. */\r\ntype LayoutModuleShape = {\r\n prefix?: string;\r\n /**\r\n * The default export — the thing that puts an element in the document, and\r\n * therefore the ONLY export that decides whether a layout counts against the\r\n * single-rendering-layout rule (`../routing/layout-policy.ts`). The manifest\r\n * carries LOADED modules, so this is a fact rather than a guess, exactly as it\r\n * is in dev (`install-page-routes.ts:145-150`).\r\n */\r\n default?: unknown;\r\n /** The layout's guards, in the order it declared them. */\r\n middleware?: readonly PipelineMiddleware[];\r\n};\r\n\r\n/**\r\n * How a handler is built for one page. Defaults to `createPageRouteHandler`;\r\n * taking it as an input keeps this module's own job — reading the manifest and\r\n * registering routes — provable without a render pipeline behind it.\r\n */\r\nexport type PageRouteHandlerFactory = (options: PageRouteHandlerOptions) => PageRouteHandler;\r\n\r\nexport type InstalledManifestPageRoute = {\r\n /** The composed path the route was registered on. */\r\n path: string;\r\n /** The resolved route name; shared namespace with API routes. */\r\n name: string;\r\n /** The page's manifest `sourceFile`. */\r\n file: string;\r\n /** The layout's manifest `sourceFile`, when the page has one. */\r\n layoutFile: string | undefined;\r\n};\r\n\r\nexport type InstallPageRoutesFromManifestOptions = {\r\n router: Router;\r\n /** The table the generated production barrel provided at import time. */\r\n manifest: PageManifest;\r\n /** Browser module loaded after the server-rendered application and payload. */\r\n hydrationClientModuleUrl?: string;\r\n /** Stylesheet URLs emitted into every page's `<head>`. */\r\n stylesheetUrls?: readonly string[];\r\n /** Same helper `dev-server.ts` exports — passed in, never imported. */\r\n applyBufferedCookie: (response: Response, cookie: BufferedCookie) => void;\r\n createHandler?: PageRouteHandlerFactory;\r\n};\r\n\r\nfunction resolveRoute(\r\n routeExport: PageRouteExport,\r\n sourceFile: string,\r\n): { path: string; name: string } {\r\n const canonical = canonicalizeRouteExport(routeExport);\r\n\r\n return {\r\n path: canonical.path,\r\n name: canonical.name ?? deriveFallbackRouteName({ routePath: canonical.path, sourceFile }),\r\n };\r\n}\r\n\r\n/**\r\n * The page's layout LEVEL, resolved from the whole chain the manifest carries\r\n * rather than from the one layout nearest to it — the same resolution dev makes\r\n * (`install-page-routes.ts:138-164`), against loaded modules instead of Vite's.\r\n *\r\n * The manifest carries the FULL chain, outermost first, and the render pipeline\r\n * has exactly one layout slot per page (`execute-page-request.ts`'s\r\n * `PageRouteEntry[\"triple\"]`), so the chain has to be collapsed into one module\r\n * before it reaches a handler. Two things collapse differently and both matter:\r\n *\r\n * - RENDERING is a selection: at most one layout on the chain may render, and\r\n * the policy picks it. `renders` is read off the loaded module\r\n * (`typeof module.default !== \"undefined\"`), never off the entry's presence in\r\n * the chain — a `middleware`-only layout has no default export and is not a\r\n * wrapper. Passing bare `sourceFile` strings had every layout read as a\r\n * rendering one, so boot refused a middleware-only guard chain that the build\r\n * had already accepted: an application that builds and will not start.\r\n * - MIDDLEWARE and PREFIX are compositions: every layout on the path\r\n * contributes, outermost first. A guard on an outer layout that the page's own\r\n * directory knows nothing about is exactly the guard that must still run, and\r\n * a prefix nobody composed is a URL nobody wrote down.\r\n *\r\n * A chain with more than one RENDERING layout is still refused here, at boot,\r\n * before a single request can observe the wrong document. Like the missing\r\n * app-root refusal below, that arm defends against stale or hand-edited build\r\n * artifacts: the build refuses to emit such a chain, but a manifest can reach a\r\n * running process without that build having produced it.\r\n */\r\ntype LayoutLevel = {\r\n /**\r\n * The layout entry the handler's layout slot is registered under, or\r\n * `undefined` when the page has no layout at all: the layout that RENDERS,\r\n * or — when none does — the nearest one, which is the slot production has\r\n * always used and so the choice that changes nothing but the middleware for a\r\n * chain with no wrapper in it.\r\n */\r\n host: PageManifestLayoutEntry | undefined;\r\n /** Every layout's `prefix`, composed outermost first — `discoverPages`' own reduction. */\r\n prefix: string;\r\n};\r\n\r\nfunction layoutLevelOf(page: PageManifestPageEntry): LayoutLevel {\r\n const selection = selectPageLayout(\r\n page.layouts.map((layout) => ({\r\n layout: layout.sourceFile,\r\n renders: typeof (layout.module as LayoutModuleShape).default !== \"undefined\",\r\n })),\r\n );\r\n\r\n if (selection.type === \"rejected\") {\r\n throw new NestedLayoutsNotSupportedError(page.sourceFile, selection.layouts);\r\n }\r\n\r\n return {\r\n host:\r\n selection.type === \"selected\"\r\n ? page.layouts.find((layout) => layout.sourceFile === selection.layout)\r\n : page.layouts.at(-1),\r\n prefix: page.layouts.reduce(\r\n (composed, layout) =>\r\n composeRoutePath(composed, (layout.module as LayoutModuleShape).prefix ?? \"/\"),\r\n \"/\",\r\n ),\r\n };\r\n}\r\n\r\n/**\r\n * The layout slot's module for one page: the slot host's own namespace, with the\r\n * whole chain's middleware in place of its own — outermost first, which is the\r\n * order stage 3 runs the array in (`execute-page-request.ts:519-524`) and the\r\n * order an outer `optionalAuth` needs in order to have resolved an identity\r\n * before an inner `gate()` checks it.\r\n *\r\n * Deliberately NOT core's route-level `middleware` option: that runs before the\r\n * pipeline's App-level middleware, which would invert outermost-first — the one\r\n * property this composition exists to guarantee.\r\n *\r\n * Built once at registration, not per request: unlike dev, every module here is\r\n * already in memory and cannot change under a running process.\r\n */\r\nfunction composeLayoutLevel(\r\n page: PageManifestPageEntry,\r\n host: PageManifestLayoutEntry,\r\n): Record<string, unknown> {\r\n return {\r\n ...host.module,\r\n middleware: page.layouts.flatMap((layout) => [\r\n ...((layout.module as LayoutModuleShape).middleware ?? []),\r\n ]),\r\n };\r\n}\r\n\r\n/**\r\n * Registers every page the manifest carries into `options.router`.\r\n *\r\n * An empty manifest registers nothing and is not an error: \"built with web, no\r\n * pages\" is a legal state of a built application, and treating it as a failure\r\n * would make an empty project unbootable. A manifest that DOES carry pages but\r\n * no app root is the opposite — every page renders inside the application root,\r\n * so that combination is a broken table rather than an empty one, and it is\r\n * refused before any route exists to serve a request with a missing root.\r\n *\r\n * Two pages composing to the same path is refused the moment the second one is\r\n * seen, naming both — a registration-time failure, rather than a route one of\r\n * them silently loses at runtime.\r\n */\r\nexport function installPageRoutesFromManifest(\r\n options: InstallPageRoutesFromManifestOptions,\r\n): InstalledManifestPageRoute[] {\r\n const {\r\n router,\r\n manifest,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n createHandler = createPageRouteHandler,\r\n } = options;\r\n\r\n if (manifest.pages.length === 0) return [];\r\n\r\n const app = manifest.app;\r\n\r\n if (app === undefined) {\r\n throw new Error(\r\n `installPageRoutesFromManifest: this build's page manifest carries ${manifest.pages.length} ` +\r\n \"page(s) but no application root. Every page renders inside the app component, so no \" +\r\n \"page can be registered without it. Re-run the build so the generated pages barrel \" +\r\n \"provides an `app` entry.\",\r\n );\r\n }\r\n\r\n // Ids are the manifest's own `sourceFile` strings and are passed on untouched:\r\n // the loader below matches them by exact string equality, so resolving,\r\n // joining or swapping separators on one side of that comparison would turn\r\n // every lookup into a miss.\r\n const loadModule = createPageModuleLoader(manifest);\r\n\r\n // Same partition development makes, on the same rule (the filename), so the\r\n // two modes cannot disagree about which file is the not-found page. It is\r\n // taken OUT of the registration loop rather than skipped inside it: every step\r\n // in there composes and claims a URL, and `404.page.tsx` has none.\r\n const notFoundPages = manifest.pages.filter((page) => isNotFoundPageFile(page.sourceFile));\r\n const pages = manifest.pages.filter((page) => !isNotFoundPageFile(page.sourceFile));\r\n\r\n if (notFoundPages.length > 1) {\r\n throw new DuplicateNotFoundPageError(notFoundPages.map((page) => page.sourceFile));\r\n }\r\n\r\n const notFoundPage = notFoundPages[0];\r\n\r\n if (notFoundPage !== undefined && (notFoundPage.module as PageModuleShape).route !== undefined) {\r\n throw new NotFoundPageDeclaresRouteError(notFoundPage.sourceFile);\r\n }\r\n\r\n const installed: InstalledManifestPageRoute[] = [];\r\n const fileByPath = new Map<string, string>();\r\n\r\n for (const page of pages) {\r\n const { host: layout, prefix: layoutPrefix } = layoutLevelOf(page);\r\n const routeExport = (page.module as PageModuleShape).route;\r\n\r\n // A page that declares no route has no public URL to be registered under —\r\n // the same page discovery skips in development.\r\n if (routeExport === undefined) continue;\r\n\r\n const { path: routePath, name } = resolveRoute(routeExport, page.sourceFile);\r\n const effectivePath = composeRoutePath(layoutPrefix, routePath);\r\n const existingFile = fileByPath.get(effectivePath);\r\n\r\n if (existingFile) {\r\n throw new Error(\r\n `installPageRoutesFromManifest: composed route path \"${effectivePath}\" (layout ` +\r\n `prefix \"${layoutPrefix}\" + route.path \"${routePath}\") is declared by two pages — ` +\r\n `\"${existingFile}\" and \"${page.sourceFile}\". Every page's composed route path must ` +\r\n \"be unique.\",\r\n );\r\n }\r\n\r\n fileByPath.set(effectivePath, page.sourceFile);\r\n\r\n // The layout slot's id resolves to the COMPOSED level — every layout's\r\n // middleware, in chain order — and every other id goes straight to the\r\n // manifest lookup. A one-layout chain has nothing to compose, so it is left\r\n // to resolve as the exact namespace object the manifest carries, untouched.\r\n const composedLayout =\r\n page.layouts.length > 1 && layout !== undefined\r\n ? composeLayoutLevel(page, layout)\r\n : undefined;\r\n\r\n router.get(\r\n effectivePath,\r\n createHandler({\r\n path: effectivePath,\r\n name,\r\n appFile: app.sourceFile,\r\n pageFile: page.sourceFile,\r\n layoutFile: layout?.sourceFile,\r\n loadModule:\r\n composedLayout === undefined\r\n ? loadModule\r\n : (moduleId) =>\r\n moduleId === layout?.sourceFile\r\n ? Promise.resolve(composedLayout)\r\n : loadModule(moduleId),\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n }),\r\n // `isPage` marks this route as SSR-served. Pages and API routes share one\r\n // router and one route-name namespace, so the router's duplicate-name\r\n // error reads this flag to say which claimant is the page.\r\n { name, isPage: true },\r\n );\r\n\r\n installed.push({\r\n path: effectivePath,\r\n name,\r\n file: page.sourceFile,\r\n layoutFile: layout?.sourceFile,\r\n });\r\n }\r\n\r\n /*\r\n THE CATCH-ALL — the same route dev registers, built the same way, differing\r\n only in where a module comes from. Registered last, and registered even when\r\n the build carried no `404.page.tsx`, so a production deployment answers 404\r\n with the right STATUS whether or not anyone has designed the page yet.\r\n */\r\n router.get(\r\n NOT_FOUND_ROUTE_PATH,\r\n createNotFoundRouteHandler({\r\n renderPage:\r\n notFoundPage === undefined\r\n ? undefined\r\n : createHandler({\r\n path: NOT_FOUND_ROUTE_PATH,\r\n name: NOT_FOUND_ROUTE_NAME,\r\n appFile: app.sourceFile,\r\n pageFile: notFoundPage.sourceFile,\r\n // No layout, and therefore no layout middleware — see the dev\r\n // installer for why the not-found path takes nothing that can\r\n // redirect or throw.\r\n layoutFile: undefined,\r\n loadModule,\r\n hydrationClientModuleUrl,\r\n stylesheetUrls,\r\n applyBufferedCookie,\r\n matchPath: (requestPath) => requestPath,\r\n statusForRenderedOk: 404,\r\n }),\r\n }),\r\n // `isPage` for the same reason the dev installer carries it — the router's\r\n // duplicate-name error reads the flag to say which claimant is the page.\r\n { name: NOT_FOUND_ROUTE_NAME, isPage: true },\r\n );\r\n\r\n /*\r\n Same publish as the dev installer, for the same reason: `href()` and the\r\n router must agree, and they only can if both read the one loop that\r\n registered the routes. Production installs once at boot, so the wholesale\r\n replacement is a single write before the first request.\r\n */\r\n publishRouteTable(installed, \"installPageRoutesFromManifest (production)\");\r\n\r\n return installed;\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsGA,SAAS,aACP,aACA,YACgC;CAChC,MAAM,YAAY,wBAAwB,WAAW;CAErD,OAAO;EACL,MAAM,UAAU;EAChB,MAAM,UAAU,QAAQ,wBAAwB;GAAE,WAAW,UAAU;GAAM;EAAW,CAAC;CAC3F;AACF;AA2CA,SAAS,cAAc,MAA0C;CAC/D,MAAM,YAAY,iBAChB,KAAK,QAAQ,KAAK,YAAY;EAC5B,QAAQ,OAAO;EACf,SAAS,OAAQ,OAAO,OAA6B,YAAY;CACnE,EAAE,CACJ;CAEA,IAAI,UAAU,SAAS,YACrB,MAAM,IAAI,+BAA+B,KAAK,YAAY,UAAU,OAAO;CAG7E,OAAO;EACL,MACE,UAAU,SAAS,aACf,KAAK,QAAQ,MAAM,WAAW,OAAO,eAAe,UAAU,MAAM,IACpE,KAAK,QAAQ,GAAG,EAAE;EACxB,QAAQ,KAAK,QAAQ,QAClB,UAAU,WACT,iBAAiB,UAAW,OAAO,OAA6B,UAAU,GAAG,GAC/E,GACF;CACF;AACF;;;;;;;;;;;;;;;AAgBA,SAAS,mBACP,MACA,MACyB;CACzB,OAAO;EACL,GAAG,KAAK;EACR,YAAY,KAAK,QAAQ,SAAS,WAAW,CAC3C,GAAK,OAAO,OAA6B,cAAc,CAAC,CAC1D,CAAC;CACH;AACF;;;;;;;;;;;;;;;AAgBA,SAAgB,8BACd,SAC8B;CAC9B,MAAM,EACJ,QACA,UACA,0BACA,gBACA,qBACA,gBAAgB,2BACd;CAEJ,IAAI,SAAS,MAAM,WAAW,GAAG,OAAO,CAAC;CAEzC,MAAM,MAAM,SAAS;CAErB,IAAI,QAAQ,QACV,MAAM,IAAI,MACR,qEAAqE,SAAS,MAAM,OAAO,kMAI7F;CAOF,MAAM,aAAa,uBAAuB,QAAQ;CAMlD,MAAM,gBAAgB,SAAS,MAAM,QAAQ,SAAS,mBAAmB,KAAK,UAAU,CAAC;CACzF,MAAM,QAAQ,SAAS,MAAM,QAAQ,SAAS,CAAC,mBAAmB,KAAK,UAAU,CAAC;CAElF,IAAI,cAAc,SAAS,GACzB,MAAM,IAAI,2BAA2B,cAAc,KAAK,SAAS,KAAK,UAAU,CAAC;CAGnF,MAAM,eAAe,cAAc;CAEnC,IAAI,iBAAiB,UAAc,aAAa,OAA2B,UAAU,QACnF,MAAM,IAAI,+BAA+B,aAAa,UAAU;CAGlE,MAAM,YAA0C,CAAC;CACjD,MAAM,6BAAa,IAAI,IAAoB;CAE3C,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,EAAE,MAAM,QAAQ,QAAQ,iBAAiB,cAAc,IAAI;EACjE,MAAM,cAAe,KAAK,OAA2B;EAIrD,IAAI,gBAAgB,QAAW;EAE/B,MAAM,EAAE,MAAM,WAAW,SAAS,aAAa,aAAa,KAAK,UAAU;EAC3E,MAAM,gBAAgB,iBAAiB,cAAc,SAAS;EAC9D,MAAM,eAAe,WAAW,IAAI,aAAa;EAEjD,IAAI,cACF,MAAM,IAAI,MACR,uDAAuD,cAAc,oBACxD,aAAa,kBAAkB,UAAU,iCAChD,aAAa,SAAS,KAAK,WAAW,oDAE9C;EAGF,WAAW,IAAI,eAAe,KAAK,UAAU;EAM7C,MAAM,iBACJ,KAAK,QAAQ,SAAS,KAAK,WAAW,SAClC,mBAAmB,MAAM,MAAM,IAC/B;EAEN,OAAO,IACL,eACA,cAAc;GACZ,MAAM;GACN;GACA,SAAS,IAAI;GACb,UAAU,KAAK;GACf,YAAY,QAAQ;GACpB,YACE,mBAAmB,SACf,cACC,aACC,aAAa,QAAQ,aACjB,QAAQ,QAAQ,cAAc,IAC9B,WAAW,QAAQ;GAC/B;GACA;GACA;EACF,CAAC,GAID;GAAE;GAAM,QAAQ;EAAK,CACvB;EAEA,UAAU,KAAK;GACb,MAAM;GACN;GACA,MAAM,KAAK;GACX,YAAY,QAAQ;EACtB,CAAC;CACH;CAQA,OAAO,SAEL,2BAA2B,EACzB,YACE,iBAAiB,SACb,SACA,cAAc;EACZ;EACA,MAAM;EACN,SAAS,IAAI;EACb,UAAU,aAAa;EAIvB,YAAY;EACZ;EACA;EACA;EACA;EACA,YAAY,gBAAgB;EAC5B,qBAAqB;CACvB,CAAC,EACT,CAAC,GAGD;EAAE,MAAM;EAAsB,QAAQ;CAAK,CAC7C;CAQA,kBAAkB,WAAW,4CAA4C;CAEzE,OAAO;AACT"}
@@ -50,8 +50,10 @@ type InstallPageRoutesOptions = {
50
50
  * `route.path` — a registration-time failure, not a runtime 404 one of them
51
51
  * silently loses.
52
52
  *
53
- * Pages with no `route` export are skipped: discovery cannot invent a public
54
- * URL or route name for an undeclared page.
53
+ * Pages with no `route` export are REFUSED, not skipped, with the same
54
+ * `MissingRouteExportError` the build throws: discovery cannot invent a public
55
+ * URL for an undeclared page, and a dev server that silently drops the file you
56
+ * just wrote is indistinguishable from a typo in the URL.
55
57
  */
56
58
  declare function installPageRoutes(options: InstallPageRoutesOptions): Promise<InstalledPageRoute[]>;
57
59
  //#endregion
@@ -3,7 +3,8 @@ import { composeRoutePath } from "../routing/compose-route-path.mjs";
3
3
  import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
4
4
  import { canonicalizeRouteExport, deriveFallbackRouteName } from "../routing/route-identity.mjs";
5
5
  import { createPageRouteHandler } from "./create-page-route-handler.mjs";
6
- import { discoverPageFiles, layoutChainFor, toPosix } from "../build/discover-pages.mjs";
6
+ import { DuplicateNotFoundPageError, NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, createNotFoundRouteHandler, isNotFoundPageFile } from "./not-found-page.mjs";
7
+ import { MissingRouteExportError, discoverPageFiles, layoutChainFor, toPosix } from "../build/discover-pages.mjs";
7
8
  import path from "node:path";
8
9
 
9
10
  //#region ../web/src/server/install-page-routes.ts
@@ -99,18 +100,23 @@ async function composeLayoutLevel(level, loadLayout) {
99
100
  * `route.path` — a registration-time failure, not a runtime 404 one of them
100
101
  * silently loses.
101
102
  *
102
- * Pages with no `route` export are skipped: discovery cannot invent a public
103
- * URL or route name for an undeclared page.
103
+ * Pages with no `route` export are REFUSED, not skipped, with the same
104
+ * `MissingRouteExportError` the build throws: discovery cannot invent a public
105
+ * URL for an undeclared page, and a dev server that silently drops the file you
106
+ * just wrote is indistinguishable from a typo in the URL.
104
107
  */
105
108
  async function installPageRoutes(options) {
106
109
  const { router, vite, appSrcRoot, appFile, hydrationClientModuleUrl, stylesheetUrls, applyBufferedCookie } = options;
107
- const pageFiles = [...discoverPageFiles(appSrcRoot)].sort((left, right) => left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0);
110
+ const discovered = [...discoverPageFiles(appSrcRoot)].sort((left, right) => left.pageFile < right.pageFile ? -1 : left.pageFile > right.pageFile ? 1 : 0);
111
+ const notFoundPageFiles = discovered.filter((page) => isNotFoundPageFile(page.pageFile));
112
+ const pageFiles = discovered.filter((page) => !isNotFoundPageFile(page.pageFile));
113
+ if (notFoundPageFiles.length > 1) throw new DuplicateNotFoundPageError(notFoundPageFiles.map((page) => page.pageFile));
108
114
  const installed = [];
109
115
  const fileByPath = /* @__PURE__ */ new Map();
110
116
  for (const { pageFile, webRoot } of pageFiles) {
111
117
  const pageModule = await vite.ssrLoadModule(pageFile);
112
- if (pageModule.route === void 0) continue;
113
118
  const sourceFile = canonicalSourceFileFor(pageFile, appSrcRoot);
119
+ if (pageModule.route === void 0) throw new MissingRouteExportError(sourceFile);
114
120
  const { path: routePath, name } = resolveRoute(pageModule.route, sourceFile);
115
121
  const loadLayout = (layoutFile) => vite.ssrLoadModule(layoutFile);
116
122
  const layoutLevel = await resolveLayoutLevel(pageFile, webRoot, loadLayout);
@@ -143,6 +149,28 @@ async function installPageRoutes(options) {
143
149
  layoutFile
144
150
  });
145
151
  }
152
+ if (discovered.length > 0) {
153
+ const notFoundPageFile = notFoundPageFiles[0]?.pageFile;
154
+ if (notFoundPageFile !== void 0) {
155
+ if ((await vite.ssrLoadModule(notFoundPageFile)).route !== void 0) throw new NotFoundPageDeclaresRouteError(notFoundPageFile);
156
+ }
157
+ router.get("*", createNotFoundRouteHandler({ renderPage: notFoundPageFile === void 0 ? void 0 : createPageRouteHandler({
158
+ path: "*",
159
+ name: NOT_FOUND_ROUTE_NAME,
160
+ appFile,
161
+ pageFile: notFoundPageFile,
162
+ layoutFile: void 0,
163
+ loadModule: (moduleId) => vite.ssrLoadModule(moduleId),
164
+ hydrationClientModuleUrl,
165
+ stylesheetUrls,
166
+ applyBufferedCookie,
167
+ matchPath: (requestPath) => requestPath,
168
+ statusForRenderedOk: 404
169
+ }) }), {
170
+ name: NOT_FOUND_ROUTE_NAME,
171
+ isPage: true
172
+ });
173
+ }
146
174
  publishRouteTable(installed, "installPageRoutes (dev)");
147
175
  return installed;
148
176
  }