@warlock.js/web 5.2.3 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/esm/build/contribution.mjs.map +1 -1
  2. package/esm/build/discover-pages.mjs +5 -7
  3. package/esm/build/discover-pages.mjs.map +1 -1
  4. package/esm/build/generate-client-registry.mjs.map +1 -1
  5. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  6. package/esm/build/page-default-export.mjs.map +1 -1
  7. package/esm/build/page-routes-manifest.mjs.map +1 -1
  8. package/esm/build/public-files.mjs.map +1 -1
  9. package/esm/build/read-route-exports.mjs.map +1 -1
  10. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  11. package/esm/client/hydrate-page.mjs +4 -3
  12. package/esm/client/hydrate-page.mjs.map +1 -1
  13. package/esm/client/navigation/fetch-page-data.mjs +2 -2
  14. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  15. package/esm/client/navigation/navigation-root.mjs +5 -1
  16. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  17. package/esm/client/navigation/prefetch.mjs.map +1 -1
  18. package/esm/client/runtime/manifest.mjs.map +1 -1
  19. package/esm/client/runtime/matcher.mjs.map +1 -1
  20. package/esm/components/document-context.mjs.map +1 -1
  21. package/esm/components/link.mjs.map +1 -1
  22. package/esm/hydration-payload.mjs +6 -2
  23. package/esm/hydration-payload.mjs.map +1 -1
  24. package/esm/index.d.mts +4 -2
  25. package/esm/index.mjs +2 -1
  26. package/esm/localization.d.mts +21 -0
  27. package/esm/localization.mjs +28 -0
  28. package/esm/localization.mjs.map +1 -0
  29. package/esm/routing/data-request.mjs +5 -3
  30. package/esm/routing/data-request.mjs.map +1 -1
  31. package/esm/routing/filesystem-route.mjs +36 -7
  32. package/esm/routing/filesystem-route.mjs.map +1 -1
  33. package/esm/routing/layout-policy.mjs.map +1 -1
  34. package/esm/routing/page-file-segment.mjs +66 -0
  35. package/esm/routing/page-file-segment.mjs.map +1 -0
  36. package/esm/routing/page-route-grammar.mjs +79 -0
  37. package/esm/routing/page-route-grammar.mjs.map +1 -0
  38. package/esm/routing/query-string.mjs.map +1 -1
  39. package/esm/routing/route-identity.d.mts +69 -0
  40. package/esm/routing/route-identity.mjs +100 -44
  41. package/esm/routing/route-identity.mjs.map +1 -1
  42. package/esm/routing/route-table.mjs.map +1 -1
  43. package/esm/server/build-hydration-payload.mjs +3 -2
  44. package/esm/server/build-hydration-payload.mjs.map +1 -1
  45. package/esm/server/create-page-route-handler.d.mts +34 -1
  46. package/esm/server/create-page-route-handler.mjs +35 -4
  47. package/esm/server/create-page-route-handler.mjs.map +1 -1
  48. package/esm/server/execute-page-request.mjs.map +1 -1
  49. package/esm/server/framework-default-not-found-stylesheet.mjs +102 -0
  50. package/esm/server/framework-default-not-found-stylesheet.mjs.map +1 -0
  51. package/esm/server/install-page-routes-from-manifest.mjs +13 -16
  52. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  53. package/esm/server/install-page-routes.d.mts +3 -1
  54. package/esm/server/install-page-routes.mjs +11 -11
  55. package/esm/server/install-page-routes.mjs.map +1 -1
  56. package/esm/server/match-page-route.mjs.map +1 -1
  57. package/esm/server/not-found-page.d.mts +1 -13
  58. package/esm/server/not-found-page.mjs +50 -5
  59. package/esm/server/not-found-page.mjs.map +1 -1
  60. package/esm/server/page-file-change.mjs.map +1 -1
  61. package/esm/server/page-route-reload.mjs.map +1 -1
  62. package/esm/server/register-production-public-files.mjs +16 -1
  63. package/esm/server/register-production-public-files.mjs.map +1 -1
  64. package/esm/server/render-page.d.mts +1 -8
  65. package/esm/server/render-page.mjs +24 -21
  66. package/esm/server/render-page.mjs.map +1 -1
  67. package/esm/server/response-cache-floor.mjs +79 -0
  68. package/esm/server/response-cache-floor.mjs.map +1 -0
  69. package/esm/server/set-cookie-cache-floor-hook.mjs +41 -0
  70. package/esm/server/set-cookie-cache-floor-hook.mjs.map +1 -0
  71. package/esm/server/settle-page-response.mjs.map +1 -1
  72. package/esm/server/stylesheet-urls.mjs.map +1 -1
  73. package/esm/server/unregistered-pages.mjs.map +1 -1
  74. package/esm/server/web-connector-factory.mjs.map +1 -1
  75. package/esm/server/web-connector.d.mts +1 -1
  76. package/esm/server/web-connector.mjs +23 -4
  77. package/esm/server/web-connector.mjs.map +1 -1
  78. package/esm/shared.mjs.map +1 -1
  79. package/esm/vite/build-client.mjs.map +1 -1
  80. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  81. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  82. package/esm/vite/gate-c-verify.mjs.map +1 -1
  83. package/esm/vite/hydration-entries.mjs.map +1 -1
  84. package/esm/vite/index.mjs.map +1 -1
  85. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  86. package/esm/vite/projection.mjs.map +1 -1
  87. package/llms-full.txt +62 -33
  88. package/llms.txt +1 -1
  89. package/package.json +4 -3
  90. package/skills/create-a-page/SKILL.md +59 -28
  91. package/skills/navigate-on-the-client/SKILL.md +16 -11
  92. package/skills/serve-styles/SKILL.md +2 -1
  93. package/skills/use-layouts/SKILL.md +3 -6
  94. package/skills/write-the-root/SKILL.md +0 -2
@@ -1 +1 @@
1
- {"version":3,"file":"page-route-reload.mjs","names":[],"sources":["../../../../../../../web/src/server/page-route-reload.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { Router } from \"@warlock.js/core\";\nimport type { ViteDevServer } from \"vite\";\nimport type { InstalledPageRoute, PageModuleShape } from \"./install-page-routes\";\nimport { resolvePageRouteIdentity } from \"./install-page-routes\";\nimport { isNotFoundPageFile } from \"./not-found-page\";\nimport type { PageFileChanges } from \"./page-file-change\";\nimport { isErrorPageFilePath } from \"./page-file-change\";\n\nfunction pathKey(file: string): string {\n const normalized = path.resolve(file).replace(/\\\\/g, \"/\");\n\n return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\n/**\n * Every real page file currently represented in the route table. This includes\n * a custom `404.page.tsx`, which is intentionally absent from the public page\n * table because it has no linkable URL of its own.\n */\nexport function registeredPageFiles(\n routes: ReturnType<Router[\"list\"]>,\n appSrcRoot: string,\n): string[] {\n const sourceRoot = path.dirname(appSrcRoot);\n\n return [\n ...new Set(\n routes\n .filter((route) => route.isPage && route.sourceFile && !route.sourceFile.startsWith(\"\\0\"))\n .map((route) => path.resolve(sourceRoot, route.sourceFile)),\n ),\n ];\n}\n\n/** Exact route-source owners replaced by a live page-table transaction. */\nexport function pageRouteSourceFiles(routes: ReturnType<Router[\"list\"]>): string[] {\n return [\n ...new Set(\n routes\n .filter((route) => route.isPage && route.sourceFile)\n .map((route) => route.sourceFile),\n ),\n ];\n}\n\n/**\n * Inspect only in-place page edits. Vite's SSR graph is invalidated explicitly\n * before evaluation because core's file watcher and Vite's watcher have no\n * ordering contract. Component-only edits compare equal and stay in Fast\n * Refresh; route/name changes request an atomic table replacement.\n */\nexport async function pageRoutesNeedReplacement(\n changes: PageFileChanges,\n options: {\n vite: ViteDevServer;\n appSrcRoot: string;\n installedPages: readonly InstalledPageRoute[];\n },\n): Promise<boolean> {\n let replace = changes.added.length > 0 || changes.removed.length > 0;\n\n const installedByFile = new Map(\n options.installedPages.map((page) => [pathKey(page.file), page] as const),\n );\n\n for (const file of changes.inspectionNeeded) {\n // An error boundary has no installed router route to compare against. Its\n // namespace is projected into every client composition, so any edit must\n // replace the generated table; do not load it here just to inspect it.\n if (isErrorPageFilePath(file)) return true;\n\n options.vite.environments.ssr.moduleGraph.onFileChange(file);\n const pageModule = (await options.vite.ssrLoadModule(file)) as PageModuleShape;\n\n // A valid 404 page has no route identity. Its component body remains Vite\n // HMR territory; adding an illegal route export must still enter the\n // transaction so the installer can reject it without touching live routes.\n if (isNotFoundPageFile(file)) {\n if (pageModule.route !== undefined) replace = true;\n continue;\n }\n\n const installed = installedByFile.get(pathKey(file));\n if (installed === undefined) {\n replace = true;\n continue;\n }\n\n const next = resolvePageRouteIdentity(pageModule.route, file, options.appSrcRoot);\n if (next.declaredPath !== installed.declaredPath || next.name !== installed.name) {\n replace = true;\n }\n }\n\n return replace;\n}\n"],"mappings":";;;;;;AASA,SAAS,QAAQ,MAAsB;CACrC,MAAM,aAAa,KAAK,QAAQ,IAAI,EAAE,QAAQ,OAAO,GAAG;CAExD,OAAO,QAAQ,aAAa,UAAU,WAAW,YAAY,IAAI;AACnE;;;;;;AAOA,SAAgB,oBACd,QACA,YACU;CACV,MAAM,aAAa,KAAK,QAAQ,UAAU;CAE1C,OAAO,CACL,GAAG,IAAI,IACL,OACG,QAAQ,UAAU,MAAM,UAAU,MAAM,cAAc,CAAC,MAAM,WAAW,WAAW,IAAI,CAAC,EACxF,KAAK,UAAU,KAAK,QAAQ,YAAY,MAAM,UAAU,CAAC,CAC9D,CACF;AACF;;AAGA,SAAgB,qBAAqB,QAA8C;CACjF,OAAO,CACL,GAAG,IAAI,IACL,OACG,QAAQ,UAAU,MAAM,UAAU,MAAM,UAAU,EAClD,KAAK,UAAU,MAAM,UAAU,CACpC,CACF;AACF;;;;;;;AAQA,eAAsB,0BACpB,SACA,SAKkB;CAClB,IAAI,UAAU,QAAQ,MAAM,SAAS,KAAK,QAAQ,QAAQ,SAAS;CAEnE,MAAM,kBAAkB,IAAI,IAC1B,QAAQ,eAAe,KAAK,SAAS,CAAC,QAAQ,KAAK,IAAI,GAAG,IAAI,CAAU,CAC1E;CAEA,KAAK,MAAM,QAAQ,QAAQ,kBAAkB;EAI3C,IAAI,oBAAoB,IAAI,GAAG,OAAO;EAEtC,QAAQ,KAAK,aAAa,IAAI,YAAY,aAAa,IAAI;EAC3D,MAAM,aAAc,MAAM,QAAQ,KAAK,cAAc,IAAI;EAKzD,IAAI,mBAAmB,IAAI,GAAG;GAC5B,IAAI,WAAW,UAAU,QAAW,UAAU;GAC9C;EACF;EAEA,MAAM,YAAY,gBAAgB,IAAI,QAAQ,IAAI,CAAC;EACnD,IAAI,cAAc,QAAW;GAC3B,UAAU;GACV;EACF;EAEA,MAAM,OAAO,yBAAyB,WAAW,OAAO,MAAM,QAAQ,UAAU;EAChF,IAAI,KAAK,iBAAiB,UAAU,gBAAgB,KAAK,SAAS,UAAU,MAC1E,UAAU;CAEd;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"page-route-reload.mjs","names":[],"sources":["../../../../../../../web/src/server/page-route-reload.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { Router } from \"@warlock.js/core\";\nimport type { ViteDevServer } from \"vite\";\nimport type { InstalledPageRoute, PageModuleShape } from \"./install-page-routes\";\nimport { resolvePageRouteIdentity } from \"./install-page-routes\";\nimport { isNotFoundPageFile } from \"./not-found-page\";\nimport type { PageFileChanges } from \"./page-file-change\";\nimport { isErrorPageFilePath } from \"./page-file-change\";\n\nfunction pathKey(file: string): string {\n const normalized = path.resolve(file).replace(/\\\\/g, \"/\");\n\n return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\n/**\n * Every real page file currently represented in the route table. This includes\n * a custom `404.page.tsx`, which is intentionally absent from the public page\n * table because it has no linkable URL of its own.\n */\nexport function registeredPageFiles(\n routes: ReturnType<Router[\"list\"]>,\n appSrcRoot: string,\n): string[] {\n const sourceRoot = path.dirname(appSrcRoot);\n\n return [\n ...new Set(\n routes\n .filter((route) => route.isPage && route.sourceFile && !route.sourceFile.startsWith(\"\\0\"))\n .map((route) => path.resolve(sourceRoot, route.sourceFile)),\n ),\n ];\n}\n\n/** Exact route-source owners replaced by a live page-table transaction. */\nexport function pageRouteSourceFiles(routes: ReturnType<Router[\"list\"]>): string[] {\n return [\n ...new Set(\n routes\n .filter((route) => route.isPage && route.sourceFile)\n .map((route) => route.sourceFile),\n ),\n ];\n}\n\n/**\n * Inspect only in-place page edits. Vite's SSR graph is invalidated explicitly\n * before evaluation because core's file watcher and Vite's watcher have no\n * ordering contract. Component-only edits compare equal and stay in Fast\n * Refresh; route/name changes request an atomic table replacement.\n */\nexport async function pageRoutesNeedReplacement(\n changes: PageFileChanges,\n options: {\n vite: ViteDevServer;\n appSrcRoot: string;\n installedPages: readonly InstalledPageRoute[];\n },\n): Promise<boolean> {\n let replace = changes.added.length > 0 || changes.removed.length > 0;\n\n const installedByFile = new Map(\n options.installedPages.map((page) => [pathKey(page.file), page] as const),\n );\n\n for (const file of changes.inspectionNeeded) {\n // An error boundary has no installed router route to compare against. Its\n // namespace is projected into every client composition, so any edit must\n // replace the generated table; do not load it here just to inspect it.\n if (isErrorPageFilePath(file)) return true;\n\n options.vite.environments.ssr.moduleGraph.onFileChange(file);\n const pageModule = (await options.vite.ssrLoadModule(file)) as PageModuleShape;\n\n // A valid 404 page has no route identity. Its component body remains Vite\n // HMR territory; adding an illegal route export must still enter the\n // transaction so the installer can reject it without touching live routes.\n if (isNotFoundPageFile(file)) {\n if (pageModule.route !== undefined) replace = true;\n continue;\n }\n\n const installed = installedByFile.get(pathKey(file));\n if (installed === undefined) {\n replace = true;\n continue;\n }\n\n const next = resolvePageRouteIdentity(pageModule.route, file, options.appSrcRoot);\n if (next.declaredPath !== installed.declaredPath || next.name !== installed.name) {\n replace = true;\n }\n }\n\n return replace;\n}\n"],"mappings":";;;;;;AASA,SAAS,QAAQ,MAAsB;CACrC,MAAM,aAAa,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,OAAO,GAAG;CAExD,OAAO,QAAQ,aAAa,UAAU,WAAW,YAAY,IAAI;AACnE;;;;;;AAOA,SAAgB,oBACd,QACA,YACU;CACV,MAAM,aAAa,KAAK,QAAQ,UAAU;CAE1C,OAAO,CACL,GAAG,IAAI,IACL,OACG,QAAQ,UAAU,MAAM,UAAU,MAAM,cAAc,CAAC,MAAM,WAAW,WAAW,IAAI,CAAC,CAAC,CACzF,KAAK,UAAU,KAAK,QAAQ,YAAY,MAAM,UAAU,CAAC,CAC9D,CACF;AACF;;AAGA,SAAgB,qBAAqB,QAA8C;CACjF,OAAO,CACL,GAAG,IAAI,IACL,OACG,QAAQ,UAAU,MAAM,UAAU,MAAM,UAAU,CAAC,CACnD,KAAK,UAAU,MAAM,UAAU,CACpC,CACF;AACF;;;;;;;AAQA,eAAsB,0BACpB,SACA,SAKkB;CAClB,IAAI,UAAU,QAAQ,MAAM,SAAS,KAAK,QAAQ,QAAQ,SAAS;CAEnE,MAAM,kBAAkB,IAAI,IAC1B,QAAQ,eAAe,KAAK,SAAS,CAAC,QAAQ,KAAK,IAAI,GAAG,IAAI,CAAU,CAC1E;CAEA,KAAK,MAAM,QAAQ,QAAQ,kBAAkB;EAI3C,IAAI,oBAAoB,IAAI,GAAG,OAAO;EAEtC,QAAQ,KAAK,aAAa,IAAI,YAAY,aAAa,IAAI;EAC3D,MAAM,aAAc,MAAM,QAAQ,KAAK,cAAc,IAAI;EAKzD,IAAI,mBAAmB,IAAI,GAAG;GAC5B,IAAI,WAAW,UAAU,QAAW,UAAU;GAC9C;EACF;EAEA,MAAM,YAAY,gBAAgB,IAAI,QAAQ,IAAI,CAAC;EACnD,IAAI,cAAc,QAAW;GAC3B,UAAU;GACV;EACF;EAEA,MAAM,OAAO,yBAAyB,WAAW,OAAO,MAAM,QAAQ,UAAU;EAChF,IAAI,KAAK,iBAAiB,UAAU,gBAAgB,KAAK,SAAS,UAAU,MAC1E,UAAU;CAEd;CAEA,OAAO;AACT"}
@@ -14,6 +14,21 @@ var MissingProductionPublicFileError = class extends Error {
14
14
  this.name = "MissingProductionPublicFileError";
15
15
  }
16
16
  };
17
+ /**
18
+ * Seconds, not milliseconds — `Router.file`'s `cacheTime` is the response's
19
+ * own `Cache-Control: max-age` value (`core/src/http/response.ts`), unlike
20
+ * `@fastify/static`'s millisecond `maxAge`.
21
+ *
22
+ * These files are copied verbatim from `app/public` at build time: the URL is
23
+ * the developer's chosen filename, not a content hash, so a rebuild can change
24
+ * a file's bytes without changing its URL. `immutable` would tell the browser
25
+ * to skip revalidation forever, which is wrong here — this is a plain
26
+ * `max-age`, so a stale copy is served for at most this long and then
27
+ * revalidated. Five minutes bounds staleness after a deploy to something a
28
+ * developer would not notice, without paying a revalidation round trip on
29
+ * every load the way `max-age=0` does.
30
+ */
31
+ const PUBLIC_FILE_CACHE_MAX_AGE_SECONDS = 300;
17
32
  function assertRelativePublicFile(publicFile) {
18
33
  const segments = publicFile.split("/");
19
34
  if (publicFile.length === 0 || publicFile.startsWith("/") || publicFile.includes("\\") || segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) throw new InvalidProductionPublicFileError(publicFile);
@@ -31,7 +46,7 @@ function registerProductionPublicFiles(router, clientDir, publicFiles) {
31
46
  throw new MissingProductionPublicFileError(publicFile, absoluteFile);
32
47
  }
33
48
  if (!stat.isFile()) throw new MissingProductionPublicFileError(publicFile, absoluteFile);
34
- router.file(`/${publicFile}`, absoluteFile);
49
+ router.file(`/${publicFile}`, absoluteFile, PUBLIC_FILE_CACHE_MAX_AGE_SECONDS);
35
50
  }
36
51
  }
37
52
 
@@ -1 +1 @@
1
- {"version":3,"file":"register-production-public-files.mjs","names":[],"sources":["../../../../../../../web/src/server/register-production-public-files.ts"],"sourcesContent":["import fs from \"node:fs\";\nimport path from \"node:path\";\nimport type { Router } from \"@warlock.js/core\";\n\nexport class InvalidProductionPublicFileError extends Error {\n public constructor(publicFile: string) {\n super(\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\n \"the build manifest must contain a non-empty relative POSIX path with no \" +\n \"traversal segments.\",\n );\n this.name = \"InvalidProductionPublicFileError\";\n }\n}\n\nexport class MissingProductionPublicFileError extends Error {\n public constructor(publicFile: string, absoluteFile: string) {\n super(\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\n \"the successful build manifest recorded it, but \" +\n `${JSON.stringify(absoluteFile)} is missing or not a file. ` +\n \"Run `warlock build` again to replace the incomplete artifact.\",\n );\n this.name = \"MissingProductionPublicFileError\";\n }\n}\n\nfunction assertRelativePublicFile(publicFile: string): string[] {\n const segments = publicFile.split(\"/\");\n\n if (\n publicFile.length === 0 ||\n publicFile.startsWith(\"/\") ||\n publicFile.includes(\"\\\\\") ||\n segments.some((segment) => segment.length === 0 || segment === \".\" || segment === \"..\")\n ) {\n throw new InvalidProductionPublicFileError(publicFile);\n }\n\n return segments;\n}\n\n/** Register the exact app-public files recorded by the successful build. */\nexport function registerProductionPublicFiles(\n router: Router,\n clientDir: string,\n publicFiles: readonly string[],\n): void {\n const publicRoot = path.join(clientDir, \"public\");\n\n for (const publicFile of publicFiles) {\n const absoluteFile = path.join(publicRoot, ...assertRelativePublicFile(publicFile));\n\n let stat: fs.Stats;\n\n try {\n stat = fs.statSync(absoluteFile);\n } catch {\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\n }\n\n if (!stat.isFile()) {\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\n }\n\n router.file(`/${publicFile}`, absoluteFile);\n }\n}\n"],"mappings":";;;;AAIA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB;EACrC,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,8FAGvE;EACA,KAAK,OAAO;CACd;AACF;AAEA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB,cAAsB;EAC3D,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,mDAEhE,KAAK,UAAU,YAAY,EAAE,2FAEpC;EACA,KAAK,OAAO;CACd;AACF;AAEA,SAAS,yBAAyB,YAA8B;CAC9D,MAAM,WAAW,WAAW,MAAM,GAAG;CAErC,IACE,WAAW,WAAW,KACtB,WAAW,WAAW,GAAG,KACzB,WAAW,SAAS,IAAI,KACxB,SAAS,MAAM,YAAY,QAAQ,WAAW,KAAK,YAAY,OAAO,YAAY,IAAI,GAEtF,MAAM,IAAI,iCAAiC,UAAU;CAGvD,OAAO;AACT;;AAGA,SAAgB,8BACd,QACA,WACA,aACM;CACN,MAAM,aAAa,KAAK,KAAK,WAAW,QAAQ;CAEhD,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,eAAe,KAAK,KAAK,YAAY,GAAG,yBAAyB,UAAU,CAAC;EAElF,IAAI;EAEJ,IAAI;GACF,OAAO,GAAG,SAAS,YAAY;EACjC,QAAQ;GACN,MAAM,IAAI,iCAAiC,YAAY,YAAY;EACrE;EAEA,IAAI,CAAC,KAAK,OAAO,GACf,MAAM,IAAI,iCAAiC,YAAY,YAAY;EAGrE,OAAO,KAAK,IAAI,cAAc,YAAY;CAC5C;AACF"}
1
+ {"version":3,"file":"register-production-public-files.mjs","names":[],"sources":["../../../../../../../web/src/server/register-production-public-files.ts"],"sourcesContent":["import fs from \"node:fs\";\r\nimport path from \"node:path\";\r\nimport type { Router } from \"@warlock.js/core\";\r\n\r\nexport class InvalidProductionPublicFileError extends Error {\r\n public constructor(publicFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the build manifest must contain a non-empty relative POSIX path with no \" +\r\n \"traversal segments.\",\r\n );\r\n this.name = \"InvalidProductionPublicFileError\";\r\n }\r\n}\r\n\r\nexport class MissingProductionPublicFileError extends Error {\r\n public constructor(publicFile: string, absoluteFile: string) {\r\n super(\r\n `Cannot register production public file ${JSON.stringify(publicFile)}: ` +\r\n \"the successful build manifest recorded it, but \" +\r\n `${JSON.stringify(absoluteFile)} is missing or not a file. ` +\r\n \"Run `warlock build` again to replace the incomplete artifact.\",\r\n );\r\n this.name = \"MissingProductionPublicFileError\";\r\n }\r\n}\r\n\r\n/**\r\n * Seconds, not milliseconds — `Router.file`'s `cacheTime` is the response's\r\n * own `Cache-Control: max-age` value (`core/src/http/response.ts`), unlike\r\n * `@fastify/static`'s millisecond `maxAge`.\r\n *\r\n * These files are copied verbatim from `app/public` at build time: the URL is\r\n * the developer's chosen filename, not a content hash, so a rebuild can change\r\n * a file's bytes without changing its URL. `immutable` would tell the browser\r\n * to skip revalidation forever, which is wrong here — this is a plain\r\n * `max-age`, so a stale copy is served for at most this long and then\r\n * revalidated. Five minutes bounds staleness after a deploy to something a\r\n * developer would not notice, without paying a revalidation round trip on\r\n * every load the way `max-age=0` does.\r\n */\r\nconst PUBLIC_FILE_CACHE_MAX_AGE_SECONDS = 300;\r\n\r\nfunction assertRelativePublicFile(publicFile: string): string[] {\r\n const segments = publicFile.split(\"/\");\r\n\r\n if (\r\n publicFile.length === 0 ||\r\n publicFile.startsWith(\"/\") ||\r\n publicFile.includes(\"\\\\\") ||\r\n segments.some((segment) => segment.length === 0 || segment === \".\" || segment === \"..\")\r\n ) {\r\n throw new InvalidProductionPublicFileError(publicFile);\r\n }\r\n\r\n return segments;\r\n}\r\n\r\n/** Register the exact app-public files recorded by the successful build. */\r\nexport function registerProductionPublicFiles(\r\n router: Router,\r\n clientDir: string,\r\n publicFiles: readonly string[],\r\n): void {\r\n const publicRoot = path.join(clientDir, \"public\");\r\n\r\n for (const publicFile of publicFiles) {\r\n const absoluteFile = path.join(publicRoot, ...assertRelativePublicFile(publicFile));\r\n\r\n let stat: fs.Stats;\r\n\r\n try {\r\n stat = fs.statSync(absoluteFile);\r\n } catch {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n if (!stat.isFile()) {\r\n throw new MissingProductionPublicFileError(publicFile, absoluteFile);\r\n }\r\n\r\n router.file(`/${publicFile}`, absoluteFile, PUBLIC_FILE_CACHE_MAX_AGE_SECONDS);\r\n }\r\n}\r\n"],"mappings":";;;;AAIA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB;EACrC,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,8FAGvE;EACA,KAAK,OAAO;CACd;AACF;AAEA,IAAa,mCAAb,cAAsD,MAAM;CAC1D,AAAO,YAAY,YAAoB,cAAsB;EAC3D,MACE,0CAA0C,KAAK,UAAU,UAAU,EAAE,mDAEhE,KAAK,UAAU,YAAY,EAAE,2FAEpC;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,MAAM,oCAAoC;AAE1C,SAAS,yBAAyB,YAA8B;CAC9D,MAAM,WAAW,WAAW,MAAM,GAAG;CAErC,IACE,WAAW,WAAW,KACtB,WAAW,WAAW,GAAG,KACzB,WAAW,SAAS,IAAI,KACxB,SAAS,MAAM,YAAY,QAAQ,WAAW,KAAK,YAAY,OAAO,YAAY,IAAI,GAEtF,MAAM,IAAI,iCAAiC,UAAU;CAGvD,OAAO;AACT;;AAGA,SAAgB,8BACd,QACA,WACA,aACM;CACN,MAAM,aAAa,KAAK,KAAK,WAAW,QAAQ;CAEhD,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,eAAe,KAAK,KAAK,YAAY,GAAG,yBAAyB,UAAU,CAAC;EAElF,IAAI;EAEJ,IAAI;GACF,OAAO,GAAG,SAAS,YAAY;EACjC,QAAQ;GACN,MAAM,IAAI,iCAAiC,YAAY,YAAY;EACrE;EAEA,IAAI,CAAC,KAAK,OAAO,GACf,MAAM,IAAI,iCAAiC,YAAY,YAAY;EAGrE,OAAO,KAAK,IAAI,cAAc,cAAc,iCAAiC;CAC/E;AACF"}
@@ -33,14 +33,7 @@ type PageRoutesRegistry = {
33
33
  declare function connectPageRoutes(registry: PageRoutesRegistry | undefined): PageRoutesRegistry | undefined;
34
34
  type RenderPageOptions = {
35
35
  params?: Record<string, string>;
36
- query?: Record<string, string>;
37
- /**
38
- * Impersonation for tests: assigned to `request.user` right after the
39
- * request pair is constructed — `user` is a plain public property on core's
40
- * Request (core/src/http/request.ts:92) and this is exactly the write auth
41
- * middleware would have performed.
42
- */
43
- as?: unknown; /** Per-call overrides of the connected registry (tests, mostly). */
36
+ query?: Record<string, string>; /** Per-call overrides of the connected registry (tests, mostly). */
44
37
  routes?: readonly PageRouteEntry[];
45
38
  createHttp?: ExecutePageRequestOptions["createHttp"]; /** Loaded only after the ordinary boundary chain has been exhausted. */
46
39
  loadErrorPage?: ErrorPageModuleLoader;
@@ -1,3 +1,4 @@
1
+ import { LocaleProvider } from "../localization.mjs";
1
2
  import { DocumentContext, PAYLOAD_SCRIPT_ID, escapePayload } from "../components/document-context.mjs";
2
3
  import { markNonHydrating } from "./page-render-bundle.mjs";
3
4
  import { registerModules } from "../runtime/register-modules.mjs";
@@ -140,29 +141,26 @@ function emitDocument(body) {
140
141
  }
141
142
  /**
142
143
  * Wrap the caller's createHttp to capture the real pair (for the document
143
- * slots, `documentSlotsFrom` below), the matched entry (the only place a
144
- * URL-based caller learns which triple to render), and to apply `as` —
145
- * `user` is a plain public property on core's Request
146
- * (core/src/http/request.ts:92), exactly the write auth middleware performs.
144
+ * slots, `documentSlotsFrom` below) and the matched entry (the only place a
145
+ * URL-based caller learns which triple to render).
147
146
  */
148
- function capturingCreateHttp(registry, as) {
147
+ function capturingCreateHttp(registry) {
149
148
  const state = {};
150
149
  return {
151
150
  state,
152
151
  createHttp(match) {
153
152
  state.match = match;
154
153
  state.captured = registry.createHttp(match);
155
- if (as != null) state.captured.request.user = as;
156
154
  return state.captured;
157
155
  }
158
156
  };
159
157
  }
160
158
  /** Reads document slots directly from core's Request. */
161
159
  function documentSlotsFrom(captured) {
162
- const request = captured?.request;
160
+ if (captured === void 0) throw new Error("The page pipeline reached rendering without its request context.");
163
161
  return {
164
- nonce: request?.nonce,
165
- lang: request?.locale
162
+ nonce: captured.request.nonce,
163
+ locale: captured.request.locale
166
164
  };
167
165
  }
168
166
  async function finishRender(triple, bundle, documentSlots, response, loadErrorPage) {
@@ -176,17 +174,19 @@ async function finishRender(triple, bundle, documentSlots, response, loadErrorPa
176
174
  data: bundle.pageData,
177
175
  bundle
178
176
  };
179
- if (headers["cache-control"] === void 0) headers["cache-control"] = "private";
180
177
  const { renderToString } = await import("react-dom/server");
181
178
  let documentValue = {
182
179
  metadata: bundle.metadata,
183
- payload: buildHydrationPayload(bundle),
180
+ payload: buildHydrationPayload(bundle, documentSlots.locale),
184
181
  nonce: documentSlots.nonce,
185
- lang: documentSlots.lang
182
+ lang: documentSlots.locale
186
183
  };
187
184
  const renderWithContext = (element) => renderToString(createElement(DocumentContext.Provider, {
188
185
  value: documentValue,
189
- children: element
186
+ children: createElement(LocaleProvider, {
187
+ locale: documentValue.payload.locale,
188
+ children: element
189
+ })
190
190
  }));
191
191
  let currentError = bundle.error;
192
192
  let body;
@@ -197,7 +197,7 @@ async function finishRender(triple, bundle, documentSlots, response, loadErrorPa
197
197
  documentValue = {
198
198
  ...documentValue,
199
199
  metadata: bundle.metadata,
200
- payload: buildHydrationPayload(bundle)
200
+ payload: buildHydrationPayload(bundle, documentSlots.locale)
201
201
  };
202
202
  return renderFrameworkRoot();
203
203
  };
@@ -214,7 +214,7 @@ async function finishRender(triple, bundle, documentSlots, response, loadErrorPa
214
214
  documentValue = {
215
215
  ...documentValue,
216
216
  metadata: bundle.metadata,
217
- payload: buildHydrationPayload(bundle)
217
+ payload: buildHydrationPayload(bundle, documentSlots.locale)
218
218
  };
219
219
  return renderWithContext(wrapRootward(triple, bundle, "page", errorPageElement(module, props)));
220
220
  };
@@ -276,16 +276,19 @@ async function renderPageFailure(options) {
276
276
  request,
277
277
  response
278
278
  });
279
- const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle));
279
+ const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle, slots.locale));
280
280
  let value = {
281
281
  metadata: void 0,
282
282
  payload: frameworkPayload,
283
283
  nonce: slots.nonce,
284
- lang: slots.lang
284
+ lang: slots.locale
285
285
  };
286
286
  const renderWithContext = (element) => renderToString(createElement(DocumentContext.Provider, {
287
287
  value,
288
- children: element
288
+ children: createElement(LocaleProvider, {
289
+ locale: value.payload.locale,
290
+ children: element
291
+ })
289
292
  }));
290
293
  let body;
291
294
  try {
@@ -313,7 +316,7 @@ async function renderPageFailure(options) {
313
316
  value = {
314
317
  ...value,
315
318
  metadata: bundle.metadata,
316
- payload: markNonHydrating(buildHydrationPayload(bundle))
319
+ payload: markNonHydrating(buildHydrationPayload(bundle, slots.locale))
317
320
  };
318
321
  body = renderWithContext(createElement(DefaultApp, { children: createElement(FrameworkRootBoundary, {}) }));
319
322
  }
@@ -334,7 +337,7 @@ async function renderPage(routeName, options = {}) {
334
337
  throw new Error(`renderPage("${routeName}"): no route with that name (web/src/server/render-page.ts). Known route names: ${known}. Fix: use a name from the manifest, or connect the manifest that declares this one.`);
335
338
  }
336
339
  const url = buildUrl(entry, options.params ?? {}, options.query ?? {});
337
- const { state, createHttp } = capturingCreateHttp(registry, options.as);
340
+ const { state, createHttp } = capturingCreateHttp(registry);
338
341
  const rendered = await executePageRequest({
339
342
  url,
340
343
  routes: registry.routes,
@@ -356,7 +359,7 @@ async function renderPage(routeName, options = {}) {
356
359
  */
357
360
  async function renderPageRequest(url, options = {}) {
358
361
  const registry = requireRegistry(options);
359
- const { state, createHttp } = capturingCreateHttp(registry, options.as);
362
+ const { state, createHttp } = capturingCreateHttp(registry);
360
363
  const rendered = await executePageRequest({
361
364
  url,
362
365
  routes: registry.routes,
@@ -1 +1 @@
1
- {"version":3,"file":"render-page.mjs","names":[],"sources":["../../../../../../../web/src/server/render-page.ts"],"sourcesContent":["import { createElement, type ComponentType, type ReactNode } from \"react\";\nimport { Response, type Request } from \"@warlock.js/core\";\nimport DefaultApp from \"../components/default-app\";\nimport {\n DocumentContext,\n escapePayload,\n PAYLOAD_SCRIPT_ID,\n type DocumentContextValue,\n} from \"../components/document-context\";\nimport type { SharedContext } from \"../index\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport {\n hydrationErrorPageProps,\n resolveErrorPageMetadata,\n type ErrorPageModule,\n type ErrorPageModuleLoader,\n} from \"./error-page\";\nimport { ERROR_PAGE_METADATA } from \"./resolve-page-metadata\";\nimport {\n registerModules,\n type RegisterableModuleNamespace,\n} from \"../runtime/register-modules\";\nimport { markNonHydrating } from \"./page-render-bundle\";\nimport type { ServerErrorPageProps } from \"../props\";\nimport {\n buildErrorRecord,\n designateBoundary,\n executePageRequest,\n type BufferedCookie,\n type ExecutePageRequestOptions,\n type PageDataBundle,\n type PageErrorRecord,\n type PageLevelName,\n type PageResponseCommit,\n type PageRouteEntry,\n type PageRouteMatch,\n type PageTripleModule,\n} from \"./execute-page-request\";\n\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\nexport type { BufferedCookie };\n\n/** Widens `PageDataBundle` with the stage 7 commit record — see `execute-page-request.ts`. */\ntype Bundle = PageDataBundle & { commit?: PageResponseCommit };\n\n/** Reads the stage 7 commit into the lowercased header map `RenderedPage` carries. */\nfunction committedHeaders(bundle: PageDataBundle): Record<string, string> {\n const headers: Record<string, string> = {};\n\n for (const header of (bundle as Bundle).commit?.headers ?? []) {\n headers[header.key.toLowerCase()] = header.value;\n }\n\n return headers;\n}\n\n/** Reads the stage 7 commit into the cookie list `RenderedPage` carries. */\nfunction committedCookies(bundle: PageDataBundle): BufferedCookie[] {\n return (bundle as Bundle).commit?.cookies ?? [];\n}\n\n/**\n * Pipeline stages 9–10: RENDER the page tree from the\n * data bundle stages 1–8 produced, then return finalized { html, status,\n * headers }. Stage 10 happens at the CALL SITE in two halves —\n * 10a the caller applies status + headers (the single live-response write,\n * after render, before anything flushes), 10b it flushes\n * the document. Nothing in this module writes the live response. It never\n * re-runs any earlier stage — `renderPage` calls `executePageRequest` and\n * everything here consumes its bundle as-is.\n *\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\n * the production orchestrator AND the test helper. Because a loader IS a\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\n * returns `{ html, status, headers, data }` in one call — asserting a page's\n * data and its response headers is a unit test, no browser, no server boot.\n */\n\n// ---------------------------------------------------------------------------\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\n// ---------------------------------------------------------------------------\n\nexport type PageRoutesRegistry = {\n routes: readonly PageRouteEntry[];\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n};\n\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\n\n/**\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\n * without each call site carrying the manifest. Returns the previous registry\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\n */\nexport function connectPageRoutes(\n registry: PageRoutesRegistry | undefined,\n): PageRoutesRegistry | undefined {\n const previous = pageRoutesRegistry;\n pageRoutesRegistry = registry;\n return previous;\n}\n\n// ---------------------------------------------------------------------------\n// renderPage surface\n// ---------------------------------------------------------------------------\n\nexport type RenderPageOptions = {\n params?: Record<string, string>;\n query?: Record<string, string>;\n /**\n * Impersonation for tests: assigned to `request.user` right after the\n * request pair is constructed — `user` is a plain public property on core's\n * Request (core/src/http/request.ts:92) and this is exactly the write auth\n * middleware would have performed.\n */\n as?: unknown;\n /** Per-call overrides of the connected registry (tests, mostly). */\n routes?: readonly PageRouteEntry[];\n createHttp?: ExecutePageRequestOptions[\"createHttp\"];\n /** Loaded only after the ordinary boundary chain has been exhausted. */\n loadErrorPage?: ErrorPageModuleLoader;\n};\n\n/**\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\n * name-based sugar buildUrl consumes) have no meaning here — everything else\n * is the same seam.\n */\nexport type RenderPageRequestOptions = Omit<\n RenderPageOptions,\n \"params\" | \"query\"\n>;\n\nexport type RenderedPage = {\n /** The full document (\"\" when the pipeline short-circuited before render). */\n html: string;\n status: number;\n /** Committed response headers, lowercased key → value. */\n headers: Record<string, string>;\n /** Committed response cookies, in commit order — stage 7's `bundle.commit.cookies`. */\n cookies: BufferedCookie[];\n /**\n * The PAGE loader's data — `data.product.name` reads as the dx story\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\n */\n data: unknown;\n /**\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\n * `renderPage` always carries one (its no-match throws instead).\n */\n bundle: PageDataBundle | undefined;\n};\n\nexport type RenderPageFailureOptions = {\n name: string;\n path: string;\n request: Request;\n response: Response;\n thrown: unknown;\n loadErrorPage?: ErrorPageModuleLoader;\n};\n\nfunction requireRegistry(\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\n): PageRoutesRegistry {\n const routes = options.routes ?? pageRoutesRegistry?.routes;\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\n\n if (!routes || !createHttp) {\n throw new Error(\n \"renderPage()/renderPageRequest() has no route registry connected \" +\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\n \"manifest, which the server bootstrap owns. Fix: \" +\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\n \"beforeAll), or pass { routes, createHttp } to this call.\",\n );\n }\n\n return { routes, createHttp };\n}\n\nfunction buildUrl(\n entry: PageRouteEntry,\n params: Record<string, string>,\n query: Record<string, string>,\n): string {\n const path = entry.path\n .split(\"/\")\n .map((segment) => {\n if (!segment.startsWith(\":\")) return segment;\n\n const name = segment.slice(1);\n const value = params[name];\n\n if (value === undefined) {\n throw new Error(\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\n `param \"${name}\" and the call did not provide it ` +\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\n `\\`params: { ${name}: … }\\`.`,\n );\n }\n\n return encodeURIComponent(value);\n })\n .join(\"/\");\n\n const queryString = new URLSearchParams(query).toString();\n\n return queryString ? `${path}?${queryString}` : path;\n}\n\n// ---------------------------------------------------------------------------\n// Stage 9 — RENDER\n// ---------------------------------------------------------------------------\n\n/**\n * The framework-owned terminal boundary (P1 §4: designation falls back to\n * `app` even when no level exports one — \"the framework owns a root\n * boundary\"). Deliberately generic: the error itself is server knowledge and\n * never serialized into the document.\n */\nfunction FrameworkRootBoundary(): ReactNode {\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\n}\n\nfunction errorPageElement(\n module: ErrorPageModule,\n props: ServerErrorPageProps,\n): ReactNode {\n const ErrorPage = module.default as\n ((input: ServerErrorPageProps) => ReactNode) | undefined;\n if (!ErrorPage) {\n throw new Error(\n \"The application error.page.tsx module has no default export.\",\n );\n }\n return createElement(ErrorPage, props);\n}\n\ntype LevelProps = {\n data: unknown;\n shared: Readonly<SharedContext> | undefined;\n children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives the route match's params. */\ntype PageLevelProps = {\n data: unknown;\n shared: Readonly<SharedContext> | undefined;\n params: Readonly<Record<string, string>>;\n};\n\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> =\n {\n app: \"appData\",\n layout: \"layoutData\",\n page: \"pageData\",\n };\n\n/**\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\n * level receiving ITS OWN loader data and the same sealed `shared` — the\n * exact props the M1 contract declares (web/src/props.ts) and never\n * request/response (the component also renders on a machine where neither\n * exists, props.ts:19-22).\n *\n * A level with no default export contributes no DOM and passes children\n * through — that is `layout.tsx` omitting its default export to be a guard\n * with no DOM.\n */\nfunction buildPageElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n): ReactNode {\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle));\n}\n\n/**\n * The error path renders the DESIGNATED boundary in place of the level it\n * covers, still wrapped by every level rootward of it — a page-level throw\n * keeps its App and Layout chrome, whose data survived the settle rules\n * (P1 §4: fulfilled sibling data stays in the bundle).\n *\n * `record` is explicit rather than read from `bundle.error` — a render-time\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\n */\nfunction buildBoundaryElement(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n record: PageErrorRecord,\n): ReactNode {\n const { boundary, error } = record;\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\n ((props: { error: unknown }) => ReactNode) | undefined;\n\n const element = Boundary\n ? createElement(Boundary, { error })\n : createElement(FrameworkRootBoundary, {});\n\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\n\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\n // unwrapped when the boundary covers the app level itself — but the\n // pipeline always emits a complete document, so the\n // framework default supplies the shell here even though the app's own\n // (broken) root is what's being bypassed.\n return boundary.boundaryLevel === \"app\"\n ? createElement(DefaultApp, { children: wrapped })\n : wrapped;\n}\n\nfunction buildLeaf(\n module: PageTripleModule,\n bundle: PageDataBundle,\n): ReactNode {\n const Component = module.default as\n ((props: PageLevelProps) => ReactNode) | undefined;\n\n if (!Component) return null;\n\n return createElement(Component as ComponentType<PageLevelProps>, {\n data: bundle.pageData,\n shared: bundle.shared,\n params: bundle.route.params,\n });\n}\n\nfunction wrapRootward(\n triple: Record<PageLevelName, PageTripleModule>,\n bundle: PageDataBundle,\n from: PageLevelName,\n leaf: ReactNode,\n): ReactNode {\n const wrappers: PageLevelName[] =\n from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\n\n let element = leaf;\n\n for (const level of wrappers) {\n const Component = triple[level].default as\n ((props: LevelProps) => ReactNode) | undefined;\n\n if (!Component) {\n // \"App\" is the root: no App export means no custom document, but the\n // pipeline always emits a complete one — the\n // framework default App supplies it. Layout has no such fallback: an\n // omitted layout default export stays a no-DOM passthrough,\n // unchanged from before.\n if (level === \"app\") {\n element = createElement(DefaultApp, { children: element });\n }\n\n continue;\n }\n\n element = createElement(Component as ComponentType<LevelProps>, {\n data: bundle[DATA_KEYS[level]],\n shared: bundle.shared,\n children: element,\n });\n }\n\n return element;\n}\n\n// ---------------------------------------------------------------------------\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\n// ---------------------------------------------------------------------------\n\n/**\n * The root (App or the framework default) now ALWAYS renders a complete\n * `<html>…</html>` document itself — `<Head/>`/\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\n * around the element in `finishRender`, below) and emit real elements.\n * There is nothing left for this stage to assemble by string surgery; it\n * only prepends the doctype `renderToString` never includes.\n */\nfunction emitDocument(body: string): string {\n return \"<!DOCTYPE html>\" + body;\n}\n\n// ---------------------------------------------------------------------------\n// The shared tail (stages 9–10) — both orchestrators end here\n// ---------------------------------------------------------------------------\n\n/**\n * The real request/response pair `capturingCreateHttp` captured for this\n * call. It is used at\n * the two orchestrator call sites for the `as` impersonation write\n * (`state.captured.request.user = as`, below) and to read the document\n * slots (`documentSlotsFrom`, below).\n */\ntype CapturedHttp = {\n request: Request;\n response: Response;\n};\n\n/**\n * Wrap the caller's createHttp to capture the real pair (for the document\n * slots, `documentSlotsFrom` below), the matched entry (the only place a\n * URL-based caller learns which triple to render), and to apply `as` —\n * `user` is a plain public property on core's Request\n * (core/src/http/request.ts:92), exactly the write auth middleware performs.\n */\nfunction capturingCreateHttp(\n registry: PageRoutesRegistry,\n as: unknown,\n): {\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\n} {\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\n\n return {\n state,\n createHttp(match) {\n state.match = match;\n state.captured = registry.createHttp(match);\n\n // `!= null` (not just `!== undefined`): `Request.user` is `RequestUser\n // | undefined` (core/src/http/request.ts:93) — it has no `null` member,\n // so an explicit `as: null` is treated the same as \"no impersonation\"\n // rather than written through.\n if (as != null) state.captured.request.user = as;\n\n return state.captured;\n },\n };\n}\n\n/**\n * The two request-derived document slots (`nonce`/`lang` on\n * `DocumentContextValue`), extracted at the orchestrator call sites\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\n * here: core's Request has no dir-like field (checked\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\n * exist) — an app supplies `dir` via its own convention.\n */\ntype DocumentSlots = {\n nonce?: string;\n lang?: string;\n};\n\n/** Reads document slots directly from core's Request. */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n const request = captured?.request;\n\n return { nonce: request?.nonce, lang: request?.locale };\n}\n\nasync function finishRender(\n triple: PageRouteEntry[\"triple\"],\n bundle: PageDataBundle,\n documentSlots: DocumentSlots,\n response: Response,\n loadErrorPage: ErrorPageModuleLoader | undefined,\n): Promise<RenderedPage> {\n // Read from the stage 7 commit, never live off `response` — this function\n // writes (and now reads) the live response zero times. A bundle with no\n // commit (no loader ran at all) simply has no headers/cookies to report.\n const headers = committedHeaders(bundle);\n const cookies = committedCookies(bundle);\n\n // Middleware and validation short-circuits emit no document. Loader-returned\n // Response instances never reach this function.\n if (bundle.shortCircuit) {\n const status =\n bundle.shortCircuit.stage === \"validation\"\n ? bundle.shortCircuit.status\n : (bundle.shortCircuit.statusCode ?? 200);\n return {\n html: \"\",\n status,\n headers,\n cookies,\n data: bundle.pageData,\n bundle,\n };\n }\n\n // The framework's closed-by-default answer (README rule 8): every document\n // is `Cache-Control: private` unless a loader's committed headers already\n // answered for the key. Map-only — the caller applies the returned headers.\n if (headers[\"cache-control\"] === undefined) {\n headers[\"cache-control\"] = \"private\";\n }\n\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\n // Lazy import: react-dom is a peer used only on this path, so merely\n // loading the server barrel never requires it.\n const { renderToString } = await import(\"react-dom/server\");\n\n // JSON.stringify omits object properties whose value is undefined. Loader\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\n // both already final by this point (stages 1-8 are done), so there is\n // nothing left for the root to await.\n //\n // The payload comes from `buildHydrationPayload` rather than being assembled\n // here, so that this document and the `_loader` route hand the browser the\n // SAME object. See that module for why the two must not drift.\n let documentValue: DocumentContextValue = {\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n nonce: documentSlots.nonce,\n lang: documentSlots.lang,\n };\n\n const renderWithContext = (element: ReactNode): string =>\n renderToString(\n createElement(DocumentContext.Provider, {\n value: documentValue,\n children: element,\n }),\n );\n\n // A boundary that throws while rendering escalates to\n // the next enclosing boundary rootward; if none survives, the framework's\n // last-resort terminal renders. `currentError` starts as whatever stage\n // 1-8 already designated (`bundle.error`, undefined for a normal page\n // render) and is replaced by each escalation — `bundle.error` itself is\n // never mutated, staying a truthful stage 1-8 record.\n let currentError = bundle.error;\n let renderTimeThrow = false;\n let body: string;\n\n const renderFrameworkRoot = (): string =>\n renderWithContext(\n createElement(DefaultApp, {\n children: createElement(FrameworkRootBoundary, {}),\n }),\n );\n const renderFrameworkAfterErrorPageFailure = (): string => {\n bundle.errorPage = undefined;\n bundle.metadata = ERROR_PAGE_METADATA;\n documentValue = {\n ...documentValue,\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n };\n return renderFrameworkRoot();\n };\n\n const renderErrorPage = async (\n thrown: unknown,\n serializableError: unknown = thrown,\n ): Promise<string | undefined> => {\n if (!loadErrorPage) return undefined;\n\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\n const module = await loadErrorPage();\n registerModules([module as RegisterableModuleNamespace]);\n const errorPage = hydrationErrorPageProps(props, serializableError);\n bundle.errorPage = errorPage;\n bundle.metadata = resolveErrorPageMetadata(module, props);\n documentValue = {\n ...documentValue,\n metadata: bundle.metadata,\n payload: buildHydrationPayload(bundle),\n };\n return renderWithContext(\n wrapRootward(triple, bundle, \"page\", errorPageElement(module, props)),\n );\n };\n\n for (;;) {\n try {\n // The application error page is the framework terminal, never a rival\n // to an authored boundary. It is reached only after no app boundary\n // exists (or after that boundary has itself thrown below).\n if (\n currentError?.boundary.boundaryLevel === \"app\" &&\n !triple.app.ErrorBoundary\n ) {\n try {\n body =\n (await renderErrorPage(\n currentError.originalError ?? currentError.error,\n currentError.error,\n )) ?? renderFrameworkRoot();\n } catch {\n body = renderFrameworkAfterErrorPageFailure();\n }\n renderTimeThrow = true;\n break;\n }\n\n const element = currentError\n ? buildBoundaryElement(triple, bundle, currentError)\n : buildPageElement(triple, bundle);\n\n body = renderWithContext(element);\n break;\n } catch (thrown) {\n renderTimeThrow = true;\n\n if (currentError?.boundary.boundaryLevel === \"app\") {\n // The floor: the app-level boundary's own render just threw, so\n // there is nothing rootward of `app` to escalate to (§2's \"none\n // survives\"). Render the framework's trivial boundary directly —\n // bypassing the app's ErrorBoundary/App component, since that is\n // what just failed — wrapped in DefaultApp so the response is still\n // a complete `<html>` document (default-app.tsx:22-46) rather than\n // a bare `<main>` fragment.\n try {\n body = (await renderErrorPage(thrown)) ?? renderFrameworkRoot();\n } catch {\n body = renderFrameworkAfterErrorPageFailure();\n }\n break;\n }\n\n // Escalate from the level rootward of whatever just threw — searching\n // from the SAME level would re-select the boundary that just failed.\n // A throw not yet attributable to a level (a normal page render, no\n // prior designation) starts the search at `page`.\n const throwingLevel: PageLevelName =\n currentError?.boundary.boundaryLevel === \"layout\"\n ? \"app\"\n : currentError\n ? \"layout\"\n : \"page\";\n\n currentError = buildErrorRecord(\n thrown,\n designateBoundary(throwingLevel, triple),\n );\n }\n }\n\n // Status is chosen after render — the last thing that can change the\n // outcome — and RETURNED, never applied: `finishRender` writes the live\n // response zero times. The caller applies status + headers at one site and\n // flushes immediately after (stage 10a/10b). \"The framework owns the status\n // whenever a boundary renders\" (design/request-lifecycle.md stage 7): ANY\n // boundary — nested or app-level, discovered pre-render or escalated during\n // render — forces 500. The boundary's LEVEL only decides which component\n // renders, never the status; the committed status from stage 7\n // (`bundle.commit.statusCode`) stands only for a page with no error at all\n // — read off the commit, never off the live `response`, same as `headers`\n // above. No committed status (no loader called `setStatusCode`) is the\n // ordinary 200.\n const status = currentError\n ? 500\n : ((bundle as Bundle).commit?.statusCode ?? 200);\n\n const html = emitDocument(body);\n\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\n}\n\n/**\n * Render failures that happen before the page pipeline has a triple (notably a\n * module-load or registration throw). This deliberately owns one terminal\n * attempt: an error-page failure falls straight to FrameworkRootBoundary.\n *\n * There is no triple yet, so there is no trustworthy server composition for\n * the browser to hydrate against — every response this function produces is\n * marked `markNonHydrating` (page-render-bundle.ts), on both the bundle and\n * the document payload, whether or not it managed to render the app's own\n * `error.page.tsx`. A normal app error page reached through `finishRender`\n * renders inside a real triple and stays hydratable; this path never does.\n */\nexport async function renderPageFailure(\n options: RenderPageFailureOptions,\n): Promise<RenderedPage> {\n const { request, response, name, path, thrown, loadErrorPage } = options;\n const bundle: PageDataBundle = markNonHydrating({\n route: { name, path, params: {}, query: {} },\n });\n // No pipeline ran (there is no triple), so there is no commit to read —\n // never a live `response.getHeaders()` read either; see `finishRender`.\n const headers: Record<string, string> = { \"cache-control\": \"private\" };\n\n const { renderToString } = await import(\"react-dom/server\");\n const slots = documentSlotsFrom({ request, response });\n const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle));\n let value: DocumentContextValue = {\n metadata: undefined,\n payload: frameworkPayload,\n nonce: slots.nonce,\n lang: slots.lang,\n };\n const renderWithContext = (element: ReactNode): string =>\n renderToString(\n createElement(DocumentContext.Provider, { value, children: element }),\n );\n let body: string;\n\n try {\n if (!loadErrorPage)\n throw new Error(\"No application error page is configured.\");\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\n const module = await loadErrorPage();\n registerModules([module as RegisterableModuleNamespace]);\n const errorPage = hydrationErrorPageProps(props);\n bundle.errorPage = errorPage;\n value = {\n ...value,\n metadata: resolveErrorPageMetadata(module, props),\n payload: markNonHydrating({ ...frameworkPayload, errorPage }),\n };\n body = renderWithContext(\n createElement(DefaultApp, { children: errorPageElement(module, props) }),\n );\n } catch {\n bundle.errorPage = undefined;\n bundle.metadata = ERROR_PAGE_METADATA;\n value = {\n ...value,\n metadata: bundle.metadata,\n payload: markNonHydrating(buildHydrationPayload(bundle)),\n };\n body = renderWithContext(\n createElement(DefaultApp, {\n children: createElement(FrameworkRootBoundary, {}),\n }),\n );\n }\n\n return {\n html: emitDocument(body),\n status: 500,\n headers,\n cookies: [],\n data: undefined,\n bundle,\n };\n}\n\n// ---------------------------------------------------------------------------\n// The orchestrators\n// ---------------------------------------------------------------------------\n\nexport async function renderPage(\n routeName: string,\n options: RenderPageOptions = {},\n): Promise<RenderedPage | Response> {\n const registry = requireRegistry(options);\n const entry = registry.routes.find(\n (candidate) => candidate.name === routeName,\n );\n\n if (!entry) {\n const known = registry.routes\n .map((candidate) => `\"${candidate.name}\"`)\n .join(\", \");\n\n throw new Error(\n `renderPage(\"${routeName}\"): no route with that name ` +\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\n \"Fix: use a name from the manifest, or connect the manifest that \" +\n \"declares this one.\",\n );\n }\n\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: (bundle) =>\n finishRender(\n entry.triple,\n bundle,\n documentSlotsFrom(state.captured),\n state.captured!.response,\n options.loadErrorPage,\n ),\n });\n\n if (!rendered) {\n throw new Error(\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\n );\n }\n\n return rendered;\n}\n\n/**\n * The URL-based sibling of `renderPage` — the production render surface: a\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\n * tail renders and emits.\n *\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\n * `{ html: \"\", status: 404 }` with an undefined `bundle` (see RenderedPage).\n */\nexport async function renderPageRequest(\n url: string,\n options: RenderPageRequestOptions = {},\n): Promise<RenderedPage | Response> {\n const registry = requireRegistry(options);\n const { state, createHttp } = capturingCreateHttp(registry, options.as);\n\n const rendered = await executePageRequest({\n url,\n routes: registry.routes,\n createHttp,\n finish: (bundle) =>\n finishRender(\n state.match!.entry.triple,\n bundle,\n documentSlotsFrom(state.captured),\n state.captured!.response,\n options.loadErrorPage,\n ),\n });\n\n if (!rendered) {\n return {\n html: \"\",\n status: 404,\n headers: {},\n cookies: [],\n data: undefined,\n bundle: undefined,\n };\n }\n\n // executePageRequest only produces a bundle after createHttp ran for the\n // match, so the captured entry is present whenever the bundle is.\n return rendered;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"render-page.mjs","names":[],"sources":["../../../../../../../web/src/server/render-page.ts"],"sourcesContent":["import { createElement, type ComponentType, type ReactNode } from \"react\";\r\nimport { Response, type Request } from \"@warlock.js/core\";\r\nimport DefaultApp from \"../components/default-app\";\r\nimport {\r\n DocumentContext,\r\n escapePayload,\r\n PAYLOAD_SCRIPT_ID,\r\n type DocumentContextValue,\r\n} from \"../components/document-context\";\r\nimport type { SharedContext } from \"../index\";\nimport { LocaleProvider } from \"../localization\";\nimport { buildHydrationPayload } from \"./build-hydration-payload\";\nimport {\r\n hydrationErrorPageProps,\r\n resolveErrorPageMetadata,\r\n type ErrorPageModule,\r\n type ErrorPageModuleLoader,\r\n} from \"./error-page\";\r\nimport { ERROR_PAGE_METADATA } from \"./resolve-page-metadata\";\r\nimport { registerModules, type RegisterableModuleNamespace } from \"../runtime/register-modules\";\r\nimport { markNonHydrating } from \"./page-render-bundle\";\r\nimport type { ServerErrorPageProps } from \"../props\";\r\nimport {\r\n buildErrorRecord,\r\n designateBoundary,\r\n executePageRequest,\r\n type BufferedCookie,\r\n type ExecutePageRequestOptions,\r\n type PageDataBundle,\r\n type PageErrorRecord,\r\n type PageLevelName,\r\n type PageResponseCommit,\r\n type PageRouteEntry,\r\n type PageRouteMatch,\r\n type PageTripleModule,\r\n} from \"./execute-page-request\";\r\n\r\nexport { escapePayload, PAYLOAD_SCRIPT_ID };\r\nexport type { BufferedCookie };\r\n\r\n/** Widens `PageDataBundle` with the stage 7 commit record — see `execute-page-request.ts`. */\r\ntype Bundle = PageDataBundle & { commit?: PageResponseCommit };\r\n\r\n/** Reads the stage 7 commit into the lowercased header map `RenderedPage` carries. */\r\nfunction committedHeaders(bundle: PageDataBundle): Record<string, string> {\r\n const headers: Record<string, string> = {};\r\n\r\n for (const header of (bundle as Bundle).commit?.headers ?? []) {\r\n headers[header.key.toLowerCase()] = header.value;\r\n }\r\n\r\n return headers;\r\n}\r\n\r\n/** Reads the stage 7 commit into the cookie list `RenderedPage` carries. */\r\nfunction committedCookies(bundle: PageDataBundle): BufferedCookie[] {\r\n return (bundle as Bundle).commit?.cookies ?? [];\r\n}\r\n\r\n/**\r\n * Pipeline stages 9–10: RENDER the page tree from the\r\n * data bundle stages 1–8 produced, then return finalized { html, status,\r\n * headers }. Stage 10 happens at the CALL SITE in two halves —\r\n * 10a the caller applies status + headers (the single live-response write,\r\n * after render, before anything flushes), 10b it flushes\r\n * the document. Nothing in this module writes the live response. It never\r\n * re-runs any earlier stage — `renderPage` calls `executePageRequest` and\r\n * everything here consumes its bundle as-is.\r\n *\r\n * `renderPage` is deliberately double-duty (dx-differentiators.md §3): it is\r\n * the production orchestrator AND the test helper. Because a loader IS a\r\n * controller, `renderPage(\"products.details\", { params: { id: \"42\" } })`\r\n * returns `{ html, status, headers, data }` in one call — asserting a page's\r\n * data and its response headers is a unit test, no browser, no server boot.\r\n */\r\n\r\n// ---------------------------------------------------------------------------\r\n// The routes seam (same pattern as connectPageContext: boot wiring, once)\r\n// ---------------------------------------------------------------------------\r\n\r\nexport type PageRoutesRegistry = {\r\n routes: readonly PageRouteEntry[];\r\n /** Same contract as ExecutePageRequestOptions[\"createHttp\"]. */\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n};\r\n\r\nlet pageRoutesRegistry: PageRoutesRegistry | undefined;\r\n\r\n/**\r\n * Boot-time wiring so `renderPage(name, options)` can resolve a route NAME\r\n * without each call site carrying the manifest. Returns the previous registry\r\n * so tests can restore it. A per-call `routes`/`createHttp` override wins.\r\n */\r\nexport function connectPageRoutes(\r\n registry: PageRoutesRegistry | undefined,\r\n): PageRoutesRegistry | undefined {\r\n const previous = pageRoutesRegistry;\r\n pageRoutesRegistry = registry;\r\n return previous;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// renderPage surface\r\n// ---------------------------------------------------------------------------\r\n\r\nexport type RenderPageOptions = {\r\n params?: Record<string, string>;\r\n query?: Record<string, string>;\r\n /** Per-call overrides of the connected registry (tests, mostly). */\r\n routes?: readonly PageRouteEntry[];\r\n createHttp?: ExecutePageRequestOptions[\"createHttp\"];\r\n /** Loaded only after the ordinary boundary chain has been exhausted. */\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\n/**\r\n * `renderPageRequest` takes the URL itself, so `params`/`query` (the\r\n * name-based sugar buildUrl consumes) have no meaning here — everything else\r\n * is the same seam.\r\n */\r\nexport type RenderPageRequestOptions = Omit<RenderPageOptions, \"params\" | \"query\">;\r\n\r\nexport type RenderedPage = {\r\n /** The full document (\"\" when the pipeline short-circuited before render). */\r\n html: string;\r\n status: number;\r\n /** Committed response headers, lowercased key → value. */\r\n headers: Record<string, string>;\r\n /** Committed response cookies, in commit order — stage 7's `bundle.commit.cookies`. */\r\n cookies: BufferedCookie[];\r\n /**\r\n * The PAGE loader's data — `data.product.name` reads as the dx story\r\n * writes it. `unknown`: the pipeline never checks a loader's return shape.\r\n */\r\n data: unknown;\r\n /**\r\n * The full stages-1–8 bundle, for assertions beyond the page's own data.\r\n * Undefined ONLY on `renderPageRequest`'s no-match path: no route matched,\r\n * so no pipeline ran and there is no bundle — the 404 answer stands alone.\r\n * `renderPage` always carries one (its no-match throws instead).\r\n */\r\n bundle: PageDataBundle | undefined;\r\n};\r\n\r\nexport type RenderPageFailureOptions = {\r\n name: string;\r\n path: string;\r\n request: Request;\r\n response: Response;\r\n thrown: unknown;\r\n loadErrorPage?: ErrorPageModuleLoader;\r\n};\r\n\r\nfunction requireRegistry(\r\n options: Pick<RenderPageOptions, \"routes\" | \"createHttp\">,\r\n): PageRoutesRegistry {\r\n const routes = options.routes ?? pageRoutesRegistry?.routes;\r\n const createHttp = options.createHttp ?? pageRoutesRegistry?.createHttp;\r\n\r\n if (!routes || !createHttp) {\r\n throw new Error(\r\n \"renderPage()/renderPageRequest() has no route registry connected \" +\r\n \"(web/src/server/render-page.ts). Both resolve against the page \" +\r\n \"manifest, which the server bootstrap owns. Fix: \" +\r\n \"call connectPageRoutes({ routes, createHttp }) at boot (tests: in \" +\r\n \"beforeAll), or pass { routes, createHttp } to this call.\",\r\n );\r\n }\r\n\r\n return { routes, createHttp };\r\n}\r\n\r\nfunction buildUrl(\r\n entry: PageRouteEntry,\r\n params: Record<string, string>,\r\n query: Record<string, string>,\r\n): string {\r\n const path = entry.path\r\n .split(\"/\")\r\n .map((segment) => {\r\n if (!segment.startsWith(\":\")) return segment;\r\n\r\n const name = segment.slice(1);\r\n const value = params[name];\r\n\r\n if (value === undefined) {\r\n throw new Error(\r\n `renderPage(\"${entry.name}\"): route path \"${entry.path}\" needs ` +\r\n `param \"${name}\" and the call did not provide it ` +\r\n \"(web/src/server/render-page.ts). Fix: pass it in \" +\r\n `\\`params: { ${name}: … }\\`.`,\r\n );\r\n }\r\n\r\n return encodeURIComponent(value);\r\n })\r\n .join(\"/\");\r\n\r\n const queryString = new URLSearchParams(query).toString();\r\n\r\n return queryString ? `${path}?${queryString}` : path;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Stage 9 — RENDER\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The framework-owned terminal boundary (P1 §4: designation falls back to\r\n * `app` even when no level exports one — \"the framework owns a root\r\n * boundary\"). Deliberately generic: the error itself is server knowledge and\r\n * never serialized into the document.\r\n */\r\nfunction FrameworkRootBoundary(): ReactNode {\r\n return createElement(\"main\", { role: \"alert\" }, \"Something went wrong.\");\r\n}\r\n\r\nfunction errorPageElement(module: ErrorPageModule, props: ServerErrorPageProps): ReactNode {\r\n const ErrorPage = module.default as ((input: ServerErrorPageProps) => ReactNode) | undefined;\r\n if (!ErrorPage) {\r\n throw new Error(\"The application error.page.tsx module has no default export.\");\r\n }\r\n return createElement(ErrorPage, props);\r\n}\r\n\r\ntype LevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n children?: ReactNode;\r\n};\r\n\r\n/** The ordinary page leaf alone receives the route match's params. */\r\ntype PageLevelProps = {\r\n data: unknown;\r\n shared: Readonly<SharedContext> | undefined;\r\n params: Readonly<Record<string, string>>;\r\n};\r\n\r\nconst DATA_KEYS: Record<PageLevelName, \"appData\" | \"layoutData\" | \"pageData\"> = {\r\n app: \"appData\",\r\n layout: \"layoutData\",\r\n page: \"pageData\",\r\n};\r\n\r\n/**\r\n * Compose the tree root→leaf: `<App><Layout><Page/></Layout></App>`, each\r\n * level receiving ITS OWN loader data and the same sealed `shared` — the\r\n * exact props the M1 contract declares (web/src/props.ts) and never\r\n * request/response (the component also renders on a machine where neither\r\n * exists, props.ts:19-22).\r\n *\r\n * A level with no default export contributes no DOM and passes children\r\n * through — that is `layout.tsx` omitting its default export to be a guard\r\n * with no DOM.\r\n */\r\nfunction buildPageElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n): ReactNode {\r\n return wrapRootward(triple, bundle, \"page\", buildLeaf(triple.page, bundle));\r\n}\r\n\r\n/**\r\n * The error path renders the DESIGNATED boundary in place of the level it\r\n * covers, still wrapped by every level rootward of it — a page-level throw\r\n * keeps its App and Layout chrome, whose data survived the settle rules\r\n * (P1 §4: fulfilled sibling data stays in the bundle).\r\n *\r\n * `record` is explicit rather than read from `bundle.error` — a render-time\r\n * throw (`finishRender`'s stage 9 escalation loop) designates a NEW boundary on the fly that the stage 1-8 bundle never saw.\r\n */\r\nfunction buildBoundaryElement(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n record: PageErrorRecord,\r\n): ReactNode {\r\n const { boundary, error } = record;\r\n const Boundary = triple[boundary.boundaryLevel].ErrorBoundary as\r\n ((props: { error: unknown }) => ReactNode) | undefined;\r\n\r\n const element = Boundary\r\n ? createElement(Boundary, { error })\r\n : createElement(FrameworkRootBoundary, {});\r\n\r\n const wrapped = wrapRootward(triple, bundle, boundary.boundaryLevel, element);\r\n\r\n // \"App\" has no level rootward of it, so `wrapRootward` returns `wrapped`\r\n // unwrapped when the boundary covers the app level itself — but the\r\n // pipeline always emits a complete document, so the\r\n // framework default supplies the shell here even though the app's own\r\n // (broken) root is what's being bypassed.\r\n return boundary.boundaryLevel === \"app\"\r\n ? createElement(DefaultApp, { children: wrapped })\r\n : wrapped;\r\n}\r\n\r\nfunction buildLeaf(module: PageTripleModule, bundle: PageDataBundle): ReactNode {\r\n const Component = module.default as ((props: PageLevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) return null;\r\n\r\n return createElement(Component as ComponentType<PageLevelProps>, {\r\n data: bundle.pageData,\r\n shared: bundle.shared,\r\n params: bundle.route.params,\r\n });\r\n}\r\n\r\nfunction wrapRootward(\r\n triple: Record<PageLevelName, PageTripleModule>,\r\n bundle: PageDataBundle,\r\n from: PageLevelName,\r\n leaf: ReactNode,\r\n): ReactNode {\r\n const wrappers: PageLevelName[] =\r\n from === \"page\" ? [\"layout\", \"app\"] : from === \"layout\" ? [\"app\"] : [];\r\n\r\n let element = leaf;\r\n\r\n for (const level of wrappers) {\r\n const Component = triple[level].default as ((props: LevelProps) => ReactNode) | undefined;\r\n\r\n if (!Component) {\r\n // \"App\" is the root: no App export means no custom document, but the\r\n // pipeline always emits a complete one — the\r\n // framework default App supplies it. Layout has no such fallback: an\r\n // omitted layout default export stays a no-DOM passthrough,\r\n // unchanged from before.\r\n if (level === \"app\") {\r\n element = createElement(DefaultApp, { children: element });\r\n }\r\n\r\n continue;\r\n }\r\n\r\n element = createElement(Component as ComponentType<LevelProps>, {\r\n data: bundle[DATA_KEYS[level]],\r\n shared: bundle.shared,\r\n children: element,\r\n });\r\n }\r\n\r\n return element;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Document assembly — stage 10 (10a apply + 10b flush) lives at the call site\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The root (App or the framework default) now ALWAYS renders a complete\r\n * `<html>…</html>` document itself — `<Head/>`/\r\n * `<Scripts/>` read the metadata/payload from `DocumentContext` (provided\r\n * around the element in `finishRender`, below) and emit real elements.\r\n * There is nothing left for this stage to assemble by string surgery; it\r\n * only prepends the doctype `renderToString` never includes.\r\n */\r\nfunction emitDocument(body: string): string {\r\n return \"<!DOCTYPE html>\" + body;\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The shared tail (stages 9–10) — both orchestrators end here\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * The real request/response pair `capturingCreateHttp` captured for this\r\n * call. It is used at the two orchestrator call sites to read the document\r\n * slots (`documentSlotsFrom`, below).\r\n */\r\ntype CapturedHttp = {\r\n request: Request;\r\n response: Response;\r\n};\r\n\r\n/**\r\n * Wrap the caller's createHttp to capture the real pair (for the document\r\n * slots, `documentSlotsFrom` below) and the matched entry (the only place a\r\n * URL-based caller learns which triple to render).\r\n */\r\nfunction capturingCreateHttp(registry: PageRoutesRegistry): {\r\n state: { captured?: CapturedHttp; match?: PageRouteMatch };\r\n createHttp: ExecutePageRequestOptions[\"createHttp\"];\r\n} {\r\n const state: { captured?: CapturedHttp; match?: PageRouteMatch } = {};\r\n\r\n return {\r\n state,\r\n createHttp(match) {\r\n state.match = match;\r\n state.captured = registry.createHttp(match);\r\n\r\n return state.captured;\r\n },\r\n };\r\n}\r\n\r\n/**\r\n * The two request-derived document slots (`nonce`/`lang` on\r\n * `DocumentContextValue`), extracted at the orchestrator call sites\r\n * because `finishRender` no longer carries `captured` (D1). `dir` is not\r\n * here: core's Request has no dir-like field (checked\r\n * core/src/http/request.ts — only `nonce` at :177 and `locale` at :343\r\n * exist) — an app supplies `dir` via its own convention.\r\n */\r\ntype DocumentSlots = {\n nonce?: string;\n locale: string;\n};\n\n/** Reads document slots directly from core's Request. */\nfunction documentSlotsFrom(captured: CapturedHttp | undefined): DocumentSlots {\n if (captured === undefined) {\n throw new Error(\"The page pipeline reached rendering without its request context.\");\n }\n\n return { nonce: captured.request.nonce, locale: captured.request.locale };\n}\n\r\nasync function finishRender(\r\n triple: PageRouteEntry[\"triple\"],\r\n bundle: PageDataBundle,\r\n documentSlots: DocumentSlots,\r\n response: Response,\r\n loadErrorPage: ErrorPageModuleLoader | undefined,\r\n): Promise<RenderedPage> {\r\n // Read from the stage 7 commit, never live off `response` — this function\r\n // writes (and now reads) the live response zero times. A bundle with no\r\n // commit (no loader ran at all) simply has no headers/cookies to report.\r\n const headers = committedHeaders(bundle);\r\n const cookies = committedCookies(bundle);\r\n\r\n // Middleware and validation short-circuits emit no document. Loader-returned\r\n // Response instances never reach this function.\r\n if (bundle.shortCircuit) {\r\n const status =\r\n bundle.shortCircuit.stage === \"validation\"\r\n ? bundle.shortCircuit.status\r\n : (bundle.shortCircuit.statusCode ?? 200);\r\n return {\r\n html: \"\",\r\n status,\r\n headers,\r\n cookies,\r\n data: bundle.pageData,\r\n bundle,\r\n };\r\n }\r\n\r\n // `Cache-Control` is NOT decided here. The final value — the floor, an\r\n // opted-in route's `public, max-age`, or the closed-by-default `no-store` —\r\n // is decided once, at the `create-page-route-handler.ts` seam, by\r\n // `applyResponseCacheFloor` (`response-cache-floor.ts`), identically for the\r\n // document and the data representation. Anything this function's `headers`\r\n // map put under `cache-control` is overwritten there on purpose: two sites\r\n // deciding this key is exactly the drift that seam exists to prevent.\r\n\r\n // ── stage 9 · RENDER ─────────────────────────────────────────────────────\r\n // Lazy import: react-dom is a peer used only on this path, so merely\r\n // loading the server barrel never requires it.\r\n const { renderToString } = await import(\"react-dom/server\");\r\n\r\n // JSON.stringify omits object properties whose value is undefined. Loader\r\n // `<Head/>`/`<Scripts/>` read this context — metadata and the payload are\r\n // both already final by this point (stages 1-8 are done), so there is\r\n // nothing left for the root to await.\r\n //\r\n // The payload comes from `buildHydrationPayload` rather than being assembled\r\n // here, so that this document and the `_loader` route hand the browser the\r\n // SAME object. See that module for why the two must not drift.\r\n let documentValue: DocumentContextValue = {\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\n nonce: documentSlots.nonce,\n lang: documentSlots.locale,\n };\r\n\r\n const renderWithContext = (element: ReactNode): string =>\r\n renderToString(\r\n createElement(DocumentContext.Provider, {\r\n value: documentValue,\r\n children: createElement(LocaleProvider, {\n locale: documentValue.payload.locale,\n children: element,\n }),\n }),\r\n );\r\n\r\n // A boundary that throws while rendering escalates to\r\n // the next enclosing boundary rootward; if none survives, the framework's\r\n // last-resort terminal renders. `currentError` starts as whatever stage\r\n // 1-8 already designated (`bundle.error`, undefined for a normal page\r\n // render) and is replaced by each escalation — `bundle.error` itself is\r\n // never mutated, staying a truthful stage 1-8 record.\r\n let currentError = bundle.error;\r\n let renderTimeThrow = false;\r\n let body: string;\r\n\r\n const renderFrameworkRoot = (): string =>\r\n renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n const renderFrameworkAfterErrorPageFailure = (): string => {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\n };\r\n return renderFrameworkRoot();\r\n };\r\n\r\n const renderErrorPage = async (\r\n thrown: unknown,\r\n serializableError: unknown = thrown,\r\n ): Promise<string | undefined> => {\r\n if (!loadErrorPage) return undefined;\r\n\r\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props, serializableError);\r\n bundle.errorPage = errorPage;\r\n bundle.metadata = resolveErrorPageMetadata(module, props);\r\n documentValue = {\r\n ...documentValue,\r\n metadata: bundle.metadata,\r\n payload: buildHydrationPayload(bundle, documentSlots.locale),\n };\r\n return renderWithContext(wrapRootward(triple, bundle, \"page\", errorPageElement(module, props)));\r\n };\r\n\r\n for (;;) {\r\n try {\r\n // The application error page is the framework terminal, never a rival\r\n // to an authored boundary. It is reached only after no app boundary\r\n // exists (or after that boundary has itself thrown below).\r\n if (currentError?.boundary.boundaryLevel === \"app\" && !triple.app.ErrorBoundary) {\r\n try {\r\n body =\r\n (await renderErrorPage(\r\n currentError.originalError ?? currentError.error,\r\n currentError.error,\r\n )) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n renderTimeThrow = true;\r\n break;\r\n }\r\n\r\n const element = currentError\r\n ? buildBoundaryElement(triple, bundle, currentError)\r\n : buildPageElement(triple, bundle);\r\n\r\n body = renderWithContext(element);\r\n break;\r\n } catch (thrown) {\r\n renderTimeThrow = true;\r\n\r\n if (currentError?.boundary.boundaryLevel === \"app\") {\r\n // The floor: the app-level boundary's own render just threw, so\r\n // there is nothing rootward of `app` to escalate to (§2's \"none\r\n // survives\"). Render the framework's trivial boundary directly —\r\n // bypassing the app's ErrorBoundary/App component, since that is\r\n // what just failed — wrapped in DefaultApp so the response is still\r\n // a complete `<html>` document (default-app.tsx:22-46) rather than\r\n // a bare `<main>` fragment.\r\n try {\r\n body = (await renderErrorPage(thrown)) ?? renderFrameworkRoot();\r\n } catch {\r\n body = renderFrameworkAfterErrorPageFailure();\r\n }\r\n break;\r\n }\r\n\r\n // Escalate from the level rootward of whatever just threw — searching\r\n // from the SAME level would re-select the boundary that just failed.\r\n // A throw not yet attributable to a level (a normal page render, no\r\n // prior designation) starts the search at `page`.\r\n const throwingLevel: PageLevelName =\r\n currentError?.boundary.boundaryLevel === \"layout\"\r\n ? \"app\"\r\n : currentError\r\n ? \"layout\"\r\n : \"page\";\r\n\r\n currentError = buildErrorRecord(thrown, designateBoundary(throwingLevel, triple));\r\n }\r\n }\r\n\r\n // Status is chosen after render — the last thing that can change the\r\n // outcome — and RETURNED, never applied: `finishRender` writes the live\r\n // response zero times. The caller applies status + headers at one site and\r\n // flushes immediately after (stage 10a/10b). \"The framework owns the status\r\n // whenever a boundary renders\" (design/request-lifecycle.md stage 7): ANY\r\n // boundary — nested or app-level, discovered pre-render or escalated during\r\n // render — forces 500. The boundary's LEVEL only decides which component\r\n // renders, never the status; the committed status from stage 7\r\n // (`bundle.commit.statusCode`) stands only for a page with no error at all\r\n // — read off the commit, never off the live `response`, same as `headers`\r\n // above. No committed status (no loader called `setStatusCode`) is the\r\n // ordinary 200.\r\n const status = currentError ? 500 : ((bundle as Bundle).commit?.statusCode ?? 200);\r\n\r\n const html = emitDocument(body);\r\n\r\n return { html, status, headers, cookies, data: bundle.pageData, bundle };\r\n}\r\n\r\n/**\r\n * Render failures that happen before the page pipeline has a triple (notably a\r\n * module-load or registration throw). This deliberately owns one terminal\r\n * attempt: an error-page failure falls straight to FrameworkRootBoundary.\r\n *\r\n * There is no triple yet, so there is no trustworthy server composition for\r\n * the browser to hydrate against — every response this function produces is\r\n * marked `markNonHydrating` (page-render-bundle.ts), on both the bundle and\r\n * the document payload, whether or not it managed to render the app's own\r\n * `error.page.tsx`. A normal app error page reached through `finishRender`\r\n * renders inside a real triple and stays hydratable; this path never does.\r\n */\r\nexport async function renderPageFailure(options: RenderPageFailureOptions): Promise<RenderedPage> {\r\n const { request, response, name, path, thrown, loadErrorPage } = options;\r\n const bundle: PageDataBundle = markNonHydrating({\r\n route: { name, path, params: {}, query: {} },\r\n });\r\n // No pipeline ran (there is no triple), so there is no commit to read —\r\n // never a live `response.getHeaders()` read either; see `finishRender`.\r\n const headers: Record<string, string> = { \"cache-control\": \"private\" };\r\n\r\n const { renderToString } = await import(\"react-dom/server\");\r\n const slots = documentSlotsFrom({ request, response });\r\n const frameworkPayload = markNonHydrating(buildHydrationPayload(bundle, slots.locale));\n let value: DocumentContextValue = {\r\n metadata: undefined,\r\n payload: frameworkPayload,\r\n nonce: slots.nonce,\r\n lang: slots.locale,\n };\r\n const renderWithContext = (element: ReactNode): string =>\n renderToString(\n createElement(DocumentContext.Provider, {\n value,\n children: createElement(LocaleProvider, {\n locale: value.payload.locale,\n children: element,\n }),\n }),\n );\n let body: string;\r\n\r\n try {\r\n if (!loadErrorPage) throw new Error(\"No application error page is configured.\");\r\n const props: ServerErrorPageProps = { error: thrown, status: 500 };\r\n const module = await loadErrorPage();\r\n registerModules([module as RegisterableModuleNamespace]);\r\n const errorPage = hydrationErrorPageProps(props);\r\n bundle.errorPage = errorPage;\r\n value = {\r\n ...value,\r\n metadata: resolveErrorPageMetadata(module, props),\r\n payload: markNonHydrating({ ...frameworkPayload, errorPage }),\r\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, { children: errorPageElement(module, props) }),\r\n );\r\n } catch {\r\n bundle.errorPage = undefined;\r\n bundle.metadata = ERROR_PAGE_METADATA;\r\n value = {\r\n ...value,\r\n metadata: bundle.metadata,\r\n payload: markNonHydrating(buildHydrationPayload(bundle, slots.locale)),\n };\r\n body = renderWithContext(\r\n createElement(DefaultApp, {\r\n children: createElement(FrameworkRootBoundary, {}),\r\n }),\r\n );\r\n }\r\n\r\n return {\r\n html: emitDocument(body),\r\n status: 500,\r\n headers,\r\n cookies: [],\r\n data: undefined,\r\n bundle,\r\n };\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The orchestrators\r\n// ---------------------------------------------------------------------------\r\n\r\nexport async function renderPage(\r\n routeName: string,\r\n options: RenderPageOptions = {},\r\n): Promise<RenderedPage | Response> {\r\n const registry = requireRegistry(options);\r\n const entry = registry.routes.find((candidate) => candidate.name === routeName);\r\n\r\n if (!entry) {\r\n const known = registry.routes.map((candidate) => `\"${candidate.name}\"`).join(\", \");\r\n\r\n throw new Error(\r\n `renderPage(\"${routeName}\"): no route with that name ` +\r\n `(web/src/server/render-page.ts). Known route names: ${known}. ` +\r\n \"Fix: use a name from the manifest, or connect the manifest that \" +\r\n \"declares this one.\",\r\n );\r\n }\r\n\r\n const url = buildUrl(entry, options.params ?? {}, options.query ?? {});\r\n const { state, createHttp } = capturingCreateHttp(registry);\r\n\r\n const rendered = await executePageRequest({\r\n url,\r\n routes: registry.routes,\r\n createHttp,\r\n finish: (bundle) =>\r\n finishRender(\r\n entry.triple,\r\n bundle,\r\n documentSlotsFrom(state.captured),\r\n state.captured!.response,\r\n options.loadErrorPage,\r\n ),\r\n });\r\n\r\n if (!rendered) {\r\n throw new Error(\r\n `renderPage(\"${routeName}\"): the built URL \"${url}\" did not match ` +\r\n \"stage 1 (web/src/server/render-page.ts). The name resolved but the \" +\r\n \"matcher disagreed — that is a manifest bug, not a caller bug.\",\r\n );\r\n }\r\n\r\n return rendered;\r\n}\r\n\r\n/**\r\n * The URL-based sibling of `renderPage` — the production render surface: a\r\n * real HTTP server has a URL, not a route name. The url goes STRAIGHT to\r\n * executePageRequest's stage-1 matcher (no buildUrl), then the same shared\r\n * tail renders and emits.\r\n *\r\n * No-match here is NOT the manifest bug renderPage throws on: an arbitrary\r\n * URL matching no route is a legitimate 404, and a server must ANSWER it —\r\n * `{ html: \"\", status: 404 }` with an undefined `bundle` (see RenderedPage).\r\n */\r\nexport async function renderPageRequest(\r\n url: string,\r\n options: RenderPageRequestOptions = {},\r\n): Promise<RenderedPage | Response> {\r\n const registry = requireRegistry(options);\r\n const { state, createHttp } = capturingCreateHttp(registry);\r\n\r\n const rendered = await executePageRequest({\r\n url,\r\n routes: registry.routes,\r\n createHttp,\r\n finish: (bundle) =>\r\n finishRender(\r\n state.match!.entry.triple,\r\n bundle,\r\n documentSlotsFrom(state.captured),\r\n state.captured!.response,\r\n options.loadErrorPage,\r\n ),\r\n });\r\n\r\n if (!rendered) {\r\n return {\r\n html: \"\",\r\n status: 404,\r\n headers: {},\r\n cookies: [],\r\n data: undefined,\r\n bundle: undefined,\r\n };\r\n }\r\n\r\n // executePageRequest only produces a bundle after createHttp ran for the\r\n // match, so the captured entry is present whenever the bundle is.\r\n return rendered;\r\n}\r\n"],"mappings":"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"}
@@ -0,0 +1,79 @@
1
+ import "@warlock.js/core";
2
+
3
+ //#region ../web/src/server/response-cache-floor.ts
4
+ /**
5
+ * Whether the LIVE outgoing response currently carries a `Set-Cookie` header.
6
+ *
7
+ * Reads `response.getHeader("set-cookie")` directly rather than any buffered
8
+ * or replayed representation, so it observes forms 1-4 above uniformly.
9
+ * Several existing unit tests hand the page route handler a plain
10
+ * `{ path, header }` mock with no `getHeader` at all — that is treated as
11
+ * "cannot observe", i.e. no cookie, the same way `request.locals?.authDerived`
12
+ * treats a missing `locals` as "never touched auth state".
13
+ */
14
+ function carriesSetCookie(response) {
15
+ if (typeof response.getHeader !== "function") return false;
16
+ const setCookieHeader = response.getHeader("set-cookie");
17
+ if (Array.isArray(setCookieHeader)) return setCookieHeader.length > 0;
18
+ return typeof setCookieHeader === "string" && setCookieHeader.length > 0;
19
+ }
20
+ /**
21
+ * Decide and apply the FINAL `Cache-Control` for a page response — the one
22
+ * call site both the document and the data representation (`x-warlock-data`)
23
+ * go through, so the two can never disagree (`create-page-route-handler.ts`).
24
+ *
25
+ * `authDerived` is TRI-STATE in effect, not a plain boolean, because the ruling
26
+ * for the per-route cache opt-in is fail-CLOSED: within an opted-in route,
27
+ * unproven means revoked.
28
+ *
29
+ * - `true` — the auth mark mechanism is observable AND it fired: this request
30
+ * touched auth state.
31
+ * - `false` — the auth mark mechanism is observable AND it did NOT fire: this
32
+ * request PROVABLY never touched auth state.
33
+ * - `undefined` — the auth mark mechanism is not observable on this request AT
34
+ * ALL (`request.locals` itself is absent — see `create-page-route-handler.ts`).
35
+ * This is neither of the two states above: "we could not look" is not "we
36
+ * looked and it was clean". Treating an unobservable mark as `false` would
37
+ * let an opted-in route emit `public, max-age=N` for a request that might
38
+ * have carried auth state we simply had no way to see — an incomplete
39
+ * enumeration must fail toward LESS caching, never toward leaking. So only
40
+ * `false` — provably-not-touched — may let an opt-in take effect; `undefined`
41
+ * revokes it, the same as `true` would, just without the stronger `private`
42
+ * claim `true` is entitled to make.
43
+ *
44
+ * Precedence, highest wins:
45
+ *
46
+ * 1. `authDerived === true` or a `Set-Cookie` on the response ⇒
47
+ * `private, no-store`, ALWAYS — this floor beats an explicit `cache`
48
+ * opt-in on purpose. A `Set-Cookie` held in a shared cache hands the SAME
49
+ * cookie to every later visitor (session fixation); an auth-derived page
50
+ * is per-visitor by definition. Neither is safe for a shared cache under
51
+ * any opt-in.
52
+ * 2. A route that declared `cache: { public: true, maxAge }`
53
+ * ({@link PageCacheOptIn}, `../routing/route-identity.ts`) AND whose
54
+ * `authDerived` is `false` (provably not touched, not merely unobserved)
55
+ * ⇒ `public, max-age=<maxAge>`.
56
+ * 3. Everything else ⇒ `no-store` — the framework's closed-by-default answer.
57
+ * This is also where an opted-in route lands when `authDerived` is
58
+ * `undefined`: the opt-in is revoked, not honoured and not upgraded to
59
+ * `private` — plain `no-store` is what a route with no opt-in at all
60
+ * already gets, and an unobservable mark must not read as worse than
61
+ * that. A page that never opts in is never held by a shared cache, no
62
+ * matter what a loader's own committed headers said; only the two
63
+ * mechanisms above can produce anything other than `no-store`.
64
+ */
65
+ function applyResponseCacheFloor(response, options) {
66
+ if (options.authDerived === true || carriesSetCookie(response)) {
67
+ response.header("Cache-Control", "private, no-store");
68
+ return;
69
+ }
70
+ if (options.cache !== void 0 && options.authDerived === false) {
71
+ response.header("Cache-Control", `public, max-age=${options.cache.maxAge}`);
72
+ return;
73
+ }
74
+ response.header("Cache-Control", "no-store");
75
+ }
76
+
77
+ //#endregion
78
+ export { applyResponseCacheFloor, carriesSetCookie };
79
+ //# sourceMappingURL=response-cache-floor.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-cache-floor.mjs","names":[],"sources":["../../../../../../../web/src/server/response-cache-floor.ts"],"sourcesContent":["import { type Response } from \"@warlock.js/core\";\nimport type { PageCacheOptIn } from \"../routing/route-identity\";\n\n/**\n * The `Set-Cookie` floor: any page response that carries a `Set-Cookie`\n * header MUST emit `Cache-Control: private, no-store`, absolutely — a future\n * per-route `cache: { public: true, maxAge: n }` opt-in cannot be allowed to\n * override it. The reason is session fixation: a `Set-Cookie` response held\n * in a shared cache hands the SAME cookie to every later visitor, not just\n * the one who received it first.\n *\n * A `Set-Cookie` can reach a page response through several forms. Checking\n * the LIVE outgoing header at the `create-page-route-handler.ts` seam — after\n * `applyCommit` and before the document/data split — covers forms 1-4 with\n * ONE check, because by that point anything that intended to set a cookie\n * has already written it to the response:\n *\n * 1. `rendered.cookies`, replayed by `applyCommit` via `applyBufferedCookie`\n * → `response.cookie()` → `baseResponse.setCookie`.\n * 2. `rendered.headers` carrying a literal `set-cookie` key, applied by\n * `response.headers(...)`.\n * 3. A cookie set on the LIVE response by middleware/auth BEFORE the handler\n * runs (e.g. a token refresh) — never passes through `applyCommit` at all.\n * 4. `response.clearCookie()` — a deletion is still a `Set-Cookie`.\n *\n * Forms 1 and 4 route through `@fastify/cookie@10.0.1`'s `setCookie`/\n * `clearCookie`, which do NOT write the header synchronously — they park the\n * cookie and it is flushed onto the real `Set-Cookie` header only inside the\n * COOKIE PLUGIN'S OWN `onSend` hook (measured under NODE_ENV=production,\n * test and development: `getHeader(\"set-cookie\")` is `undefined` immediately\n * after `setCookie()`). This seam therefore cannot observe forms 1 and 4 —\n * `set-cookie-cache-floor-hook.ts` covers those, with a SECOND `onSend` hook\n * registered to run after the cookie plugin's, where the header genuinely\n * exists. It reuses `carriesSetCookie` below against the Fastify `reply`\n * rather than duplicating the check.\n */\n\n/**\n * Anything that can answer \"does this outgoing response currently carry a\n * `Set-Cookie` header\" — core's `Response` and a raw Fastify `reply` both\n * satisfy this structurally, which is what lets `carriesSetCookie` serve both\n * the `create-page-route-handler.ts` seam and the `onSend` hook in\n * `set-cookie-cache-floor-hook.ts` without a second implementation to drift\n * from.\n */\nexport interface HeaderReadable {\n getHeader?(key: string): unknown;\n}\n\n/**\n * Whether the LIVE outgoing response currently carries a `Set-Cookie` header.\n *\n * Reads `response.getHeader(\"set-cookie\")` directly rather than any buffered\n * or replayed representation, so it observes forms 1-4 above uniformly.\n * Several existing unit tests hand the page route handler a plain\n * `{ path, header }` mock with no `getHeader` at all — that is treated as\n * \"cannot observe\", i.e. no cookie, the same way `request.locals?.authDerived`\n * treats a missing `locals` as \"never touched auth state\".\n */\nexport function carriesSetCookie(response: HeaderReadable): boolean {\n if (typeof response.getHeader !== \"function\") return false;\n\n const setCookieHeader = response.getHeader(\"set-cookie\");\n\n if (Array.isArray(setCookieHeader)) return setCookieHeader.length > 0;\n\n return typeof setCookieHeader === \"string\" && setCookieHeader.length > 0;\n}\n\n/**\n * Decide and apply the FINAL `Cache-Control` for a page response — the one\n * call site both the document and the data representation (`x-warlock-data`)\n * go through, so the two can never disagree (`create-page-route-handler.ts`).\n *\n * `authDerived` is TRI-STATE in effect, not a plain boolean, because the ruling\n * for the per-route cache opt-in is fail-CLOSED: within an opted-in route,\n * unproven means revoked.\n *\n * - `true` — the auth mark mechanism is observable AND it fired: this request\n * touched auth state.\n * - `false` — the auth mark mechanism is observable AND it did NOT fire: this\n * request PROVABLY never touched auth state.\n * - `undefined` — the auth mark mechanism is not observable on this request AT\n * ALL (`request.locals` itself is absent — see `create-page-route-handler.ts`).\n * This is neither of the two states above: \"we could not look\" is not \"we\n * looked and it was clean\". Treating an unobservable mark as `false` would\n * let an opted-in route emit `public, max-age=N` for a request that might\n * have carried auth state we simply had no way to see — an incomplete\n * enumeration must fail toward LESS caching, never toward leaking. So only\n * `false` — provably-not-touched — may let an opt-in take effect; `undefined`\n * revokes it, the same as `true` would, just without the stronger `private`\n * claim `true` is entitled to make.\n *\n * Precedence, highest wins:\n *\n * 1. `authDerived === true` or a `Set-Cookie` on the response ⇒\n * `private, no-store`, ALWAYS — this floor beats an explicit `cache`\n * opt-in on purpose. A `Set-Cookie` held in a shared cache hands the SAME\n * cookie to every later visitor (session fixation); an auth-derived page\n * is per-visitor by definition. Neither is safe for a shared cache under\n * any opt-in.\n * 2. A route that declared `cache: { public: true, maxAge }`\n * ({@link PageCacheOptIn}, `../routing/route-identity.ts`) AND whose\n * `authDerived` is `false` (provably not touched, not merely unobserved)\n * ⇒ `public, max-age=<maxAge>`.\n * 3. Everything else ⇒ `no-store` — the framework's closed-by-default answer.\n * This is also where an opted-in route lands when `authDerived` is\n * `undefined`: the opt-in is revoked, not honoured and not upgraded to\n * `private` — plain `no-store` is what a route with no opt-in at all\n * already gets, and an unobservable mark must not read as worse than\n * that. A page that never opts in is never held by a shared cache, no\n * matter what a loader's own committed headers said; only the two\n * mechanisms above can produce anything other than `no-store`.\n */\nexport function applyResponseCacheFloor(\n response: Response,\n options: { authDerived: boolean | undefined; cache?: PageCacheOptIn },\n): void {\n if (options.authDerived === true || carriesSetCookie(response)) {\n response.header(\"Cache-Control\", \"private, no-store\");\n return;\n }\n\n if (options.cache !== undefined && options.authDerived === false) {\n response.header(\"Cache-Control\", `public, max-age=${options.cache.maxAge}`);\n return;\n }\n\n response.header(\"Cache-Control\", \"no-store\");\n}\n"],"mappings":";;;;;;;;;;;;;AA2DA,SAAgB,iBAAiB,UAAmC;CAClE,IAAI,OAAO,SAAS,cAAc,YAAY,OAAO;CAErD,MAAM,kBAAkB,SAAS,UAAU,YAAY;CAEvD,IAAI,MAAM,QAAQ,eAAe,GAAG,OAAO,gBAAgB,SAAS;CAEpE,OAAO,OAAO,oBAAoB,YAAY,gBAAgB,SAAS;AACzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+CA,SAAgB,wBACd,UACA,SACM;CACN,IAAI,QAAQ,gBAAgB,QAAQ,iBAAiB,QAAQ,GAAG;EAC9D,SAAS,OAAO,iBAAiB,mBAAmB;EACpD;CACF;CAEA,IAAI,QAAQ,UAAU,UAAa,QAAQ,gBAAgB,OAAO;EAChE,SAAS,OAAO,iBAAiB,mBAAmB,QAAQ,MAAM,QAAQ;EAC1E;CACF;CAEA,SAAS,OAAO,iBAAiB,UAAU;AAC7C"}
@@ -0,0 +1,41 @@
1
+ import { carriesSetCookie } from "./response-cache-floor.mjs";
2
+
3
+ //#region ../web/src/server/set-cookie-cache-floor-hook.ts
4
+ const instrumentedServers = /* @__PURE__ */ new WeakSet();
5
+ /**
6
+ * Mark the current request as a page-pipeline response.
7
+ *
8
+ * Called once per request from `create-page-route-handler.ts`, at the same
9
+ * seam that decides `authDerived`. A no-op when `request.locals` is absent —
10
+ * several existing unit tests hand the page route handler a plain
11
+ * `{ path, header }` mock, never a real core `Request`; treated the same way
12
+ * `applyResponseCacheFloor` treats a missing capability as "not observable".
13
+ */
14
+ function markPageResponse(request) {
15
+ if (!request.locals) return;
16
+ request.locals.isPageResponse = true;
17
+ if (request.baseRequest) request.baseRequest.locals = request.locals;
18
+ }
19
+ /**
20
+ * Register the `Set-Cookie` cache-floor `onSend` hook on `server`, once.
21
+ *
22
+ * Idempotent because `createPageRouteHandler` — the only caller — runs once
23
+ * PER PAGE ROUTE, not once per server; without the guard, a second page route
24
+ * would queue a second, redundant copy of the same hook.
25
+ */
26
+ function ensureSetCookieCacheFloorHook(server) {
27
+ if (instrumentedServers.has(server)) return;
28
+ instrumentedServers.add(server);
29
+ server.addHook("onSend", (request, reply, payload, done) => {
30
+ if (request.locals?.isPageResponse !== true) {
31
+ done(null, payload);
32
+ return;
33
+ }
34
+ if (carriesSetCookie(reply)) reply.header("Cache-Control", "private, no-store");
35
+ done(null, payload);
36
+ });
37
+ }
38
+
39
+ //#endregion
40
+ export { ensureSetCookieCacheFloorHook, markPageResponse };
41
+ //# sourceMappingURL=set-cookie-cache-floor-hook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"set-cookie-cache-floor-hook.mjs","names":[],"sources":["../../../../../../../web/src/server/set-cookie-cache-floor-hook.ts"],"sourcesContent":["/**\n * The `onSend` half of the `Set-Cookie` cache floor.\n *\n * `response-cache-floor.ts`'s `carriesSetCookie` check, run at the\n * `create-page-route-handler.ts` seam, only sees a `Set-Cookie` that was\n * already written to the response BEFORE that seam runs. `@fastify/cookie@10.0.1`\n * does not behave that way for `response.cookie()` / `response.clearCookie()`:\n * `setCookie`/`clearCookie` park the cookie, and it is flushed onto the real\n * `Set-Cookie` header only inside the COOKIE PLUGIN'S OWN `onSend` hook\n * (measured under NODE_ENV=production, test and development —\n * `getHeader(\"set-cookie\")` is `undefined` immediately after `setCookie()`,\n * and the header exists only once that hook has run). The only place this can\n * be observed is a SECOND `onSend` hook, registered to run AFTER the cookie\n * plugin's.\n *\n * REGISTRATION POINT AND WHY IT RUNS AFTER THE COOKIE PLUGIN'S HOOK: Fastify's\n * `addHook(\"onSend\", …)` does not push the hook onto the live list\n * immediately — it queues the push through `this.after(…)` (`fastify.js`'s\n * `addHook`), the same avvio queue `server.register(…)` uses. Avvio drains\n * that queue strictly in the order items were added, and does not move on to\n * the next queued item until the current one — including any nested\n * `addHook`/`register` calls made while it loads — has fully settled. Core's\n * `registerHttpPlugins` (`core/src/http/plugins.ts`) calls\n * `server.register(import(\"@fastify/cookie\"), …)` during `HttpConnector.boot()`.\n * `ensureSetCookieCacheFloorHook` is called from `createPageRouteHandler`,\n * which only ever runs while installing page routes during `WebConnector.boot()`\n * — a LATER `boot()` than `HttpConnector`'s (`web-connector.ts`'s own doc\n * comment on `WEB_CONNECTOR_PRIORITY`, `5.5` vs `5`). So by the time this\n * file's `addHook` call executes, the cookie plugin's registration is already\n * queued ahead of it; avvio resolves the cookie plugin (and the `onSend` hook\n * IT adds from inside its own plugin body) before it ever reaches our\n * later-queued hook. Net effect: cookie's `onSend` hook is pushed onto the\n * hooks array first, ours second, and Fastify runs `onSend` hooks in that\n * push order. `set-cookie-cache-floor.spec.ts`'s registration-order test\n * proves this rather than trusting the reasoning above.\n *\n * SCOPE: registered ONCE, instance-wide, directly on the root Fastify server\n * — not nested inside a `.register()` plugin scope. Fastify only guarantees\n * hook order relative to OTHER items at the SAME encapsulation level, and\n * `@fastify/cookie` is registered on the root instance; nesting ours would\n * put it in an unrelated child context with no ordering guarantee against the\n * root-level plugin at all.\n *\n * Despite being instance-wide, the EFFECT is scoped to page routes only: the\n * hook is a no-op unless the page pipeline explicitly marked the request via\n * `markPageResponse` (called from `create-page-route-handler.ts`, at the same\n * seam that sets `authDerived`). This is deliberate, not a heuristic on URL\n * shape or content-type — inferring \"is this a page route\" from the request\n * shape is exactly the \"one form inspected, a second form reaches the same\n * place unexamined\" defect this floor exists to close. An ordinary API\n * response is therefore untouched by this hook's EFFECT, even though the hook\n * FUNCTION itself runs for every response on the instance — widening that\n * effect to every API response is out of scope for this change.\n */\nimport type { FastifyInstance, FastifyReply, FastifyRequest } from \"fastify\";\nimport type { Request as CoreRequest, RequestLocals } from \"@warlock.js/core\";\nimport { carriesSetCookie } from \"./response-cache-floor\";\n\ndeclare module \"fastify\" {\n interface FastifyRequest {\n /**\n * Mirrors the core `Request`'s `locals` bag onto the raw Fastify request\n * — the only object an `onSend` hook actually receives. Set once, by\n * `markPageResponse`, from the exact same object `request.locals` points\n * at, so later mutations of either reference stay visible through both.\n */\n locals?: RequestLocals;\n }\n}\n\nconst instrumentedServers = new WeakSet<FastifyInstance>();\n\n/**\n * Mark the current request as a page-pipeline response.\n *\n * Called once per request from `create-page-route-handler.ts`, at the same\n * seam that decides `authDerived`. A no-op when `request.locals` is absent —\n * several existing unit tests hand the page route handler a plain\n * `{ path, header }` mock, never a real core `Request`; treated the same way\n * `applyResponseCacheFloor` treats a missing capability as \"not observable\".\n */\nexport function markPageResponse(request: CoreRequest): void {\n if (!request.locals) return;\n\n request.locals.isPageResponse = true;\n\n if (request.baseRequest) {\n request.baseRequest.locals = request.locals;\n }\n}\n\n/**\n * Register the `Set-Cookie` cache-floor `onSend` hook on `server`, once.\n *\n * Idempotent because `createPageRouteHandler` — the only caller — runs once\n * PER PAGE ROUTE, not once per server; without the guard, a second page route\n * would queue a second, redundant copy of the same hook.\n */\nexport function ensureSetCookieCacheFloorHook(server: FastifyInstance): void {\n if (instrumentedServers.has(server)) return;\n\n instrumentedServers.add(server);\n\n server.addHook(\n \"onSend\",\n (request: FastifyRequest, reply: FastifyReply, payload: unknown, done) => {\n if (request.locals?.isPageResponse !== true) {\n done(null, payload);\n return;\n }\n\n if (carriesSetCookie(reply)) {\n reply.header(\"Cache-Control\", \"private, no-store\");\n }\n\n done(null, payload);\n },\n );\n}\n"],"mappings":";;;AAsEA,MAAM,sCAAsB,IAAI,QAAyB;;;;;;;;;;AAWzD,SAAgB,iBAAiB,SAA4B;CAC3D,IAAI,CAAC,QAAQ,QAAQ;CAErB,QAAQ,OAAO,iBAAiB;CAEhC,IAAI,QAAQ,aACV,QAAQ,YAAY,SAAS,QAAQ;AAEzC;;;;;;;;AASA,SAAgB,8BAA8B,QAA+B;CAC3E,IAAI,oBAAoB,IAAI,MAAM,GAAG;CAErC,oBAAoB,IAAI,MAAM;CAE9B,OAAO,QACL,WACC,SAAyB,OAAqB,SAAkB,SAAS;EACxE,IAAI,QAAQ,QAAQ,mBAAmB,MAAM;GAC3C,KAAK,MAAM,OAAO;GAClB;EACF;EAEA,IAAI,iBAAiB,KAAK,GACxB,MAAM,OAAO,iBAAiB,mBAAmB;EAGnD,KAAK,MAAM,OAAO;CACpB,CACF;AACF"}