@takazudo/zfb-runtime 0.1.0-next.9 → 0.1.0-next.90

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 (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +220 -15
  3. package/dist/client-router/events.d.ts +0 -1
  4. package/dist/client-router/events.js +2 -2
  5. package/dist/client-router/events.js.map +1 -1
  6. package/dist/client-router/index.d.ts +2 -3
  7. package/dist/client-router/index.js +2 -2
  8. package/dist/client-router/index.js.map +1 -1
  9. package/dist/client-router/prefetch.d.ts +0 -1
  10. package/dist/client-router/prefetch.js +126 -53
  11. package/dist/client-router/prefetch.js.map +1 -1
  12. package/dist/client-router/router.d.ts +28 -2
  13. package/dist/client-router/router.js +364 -30
  14. package/dist/client-router/router.js.map +1 -1
  15. package/dist/client-router/swap-functions.d.ts +0 -1
  16. package/dist/client-router/swap-functions.js +71 -11
  17. package/dist/client-router/swap-functions.js.map +1 -1
  18. package/dist/client-router/types.d.ts +4 -1
  19. package/dist/client-router/types.js.map +1 -1
  20. package/dist/client-router.d.ts +38 -2
  21. package/dist/client-router.js +56 -20
  22. package/dist/client-router.js.map +1 -1
  23. package/dist/framework.d.ts +0 -1
  24. package/dist/framework.js.map +1 -1
  25. package/dist/index.d.ts +2 -4
  26. package/dist/index.js +13 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/router.d.ts +32 -2
  29. package/dist/router.js +163 -33
  30. package/dist/router.js.map +1 -1
  31. package/dist/server.d.ts +2 -0
  32. package/dist/server.js +14 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/snapshot.d.ts +0 -1
  35. package/dist/snapshot.js +3 -2
  36. package/dist/snapshot.js.map +1 -1
  37. package/dist/view-transitions.d.ts +0 -1
  38. package/dist/view-transitions.js.map +1 -1
  39. package/package.json +26 -4
  40. package/dist/client-router/cssesc.d.ts +0 -9
  41. package/dist/client-router/cssesc.d.ts.map +0 -1
  42. package/dist/client-router/cssesc.js +0 -95
  43. package/dist/client-router/cssesc.js.map +0 -1
  44. package/dist/client-router/events.d.ts.map +0 -1
  45. package/dist/client-router/index.d.ts.map +0 -1
  46. package/dist/client-router/prefetch.d.ts.map +0 -1
  47. package/dist/client-router/router.d.ts.map +0 -1
  48. package/dist/client-router/swap-functions.d.ts.map +0 -1
  49. package/dist/client-router/types.d.ts.map +0 -1
  50. package/dist/client-router.d.ts.map +0 -1
  51. package/dist/framework.d.ts.map +0 -1
  52. package/dist/index.d.ts.map +0 -1
  53. package/dist/router.d.ts.map +0 -1
  54. package/dist/snapshot.d.ts.map +0 -1
  55. package/dist/view-transitions.d.ts.map +0 -1
@@ -12,21 +12,29 @@
12
12
  // via `init()`. The component calls `init()` as a side effect on first import.
13
13
  // No inline <script> dangerouslySetInnerHTML is emitted.
14
14
  //
15
- // Named-cause deviation no JSX (W1B: "follow existing component-emission pattern"):
16
- // `@takazudo/zfb-runtime` does not depend on Preact and has no JSX compiler
17
- // options configured in its tsconfig. The existing component-emission pattern
18
- // (established by `Island` in @takazudo/zfb, and `ViewTransitions` in this
19
- // package) uses plain VNode-shaped objects with `constructor: undefined` the
20
- // sentinel recognised by both Preact and React renderers. Following that pattern
21
- // avoids adding Preact as a dependency and avoids tsconfig JSX changes.
15
+ // Framework-agnostic element minting (no JSX syntax):
16
+ // `@takazudo/zfb-runtime` does not depend on a framework runtime. Head nodes
17
+ // are minted by calling `jsx` from `react/jsx-runtime` directly — NOT JSX
18
+ // syntax, so this stays a plain `.ts` file with no tsconfig JSX changes. The
19
+ // engine alias-rewrites `react/jsx-runtime` `preact/jsx-runtime` in Preact
20
+ // mode (bundler.rs ~2886) and resolves it natively in React mode, so the same
21
+ // call mints a real element for whichever framework the project configured.
22
+ // The previous approach (a hand-rolled `{ type, props, key, constructor:
23
+ // undefined }` object literal — the Preact diff-path sentinel) only worked for
24
+ // Preact: React's renderer rejects such an object as a child with React error
25
+ // #31 ("Objects are not valid as a React child"), because a real React element
26
+ // carries `$$typeof: Symbol.for("react.element")` a literal cannot fake. Same
27
+ // migration as `Island` in @takazudo/zfb.
22
28
  //
23
- // The component renders three sibling elements to <head>:
29
+ // The component renders three base sibling elements to <head> (plus optional
30
+ // metas emitted conditionally — see `prefetchDisabled` and `preserveHtmlAttrs`):
24
31
  // 1. A <style> tag with the `.zfb-route-announcer` ARIA helper class.
25
32
  // 2. <meta name="zfb-view-transitions-enabled" content="true" />
26
33
  // 3. <meta name="zfb-view-transitions-fallback" content={fallback} />
27
34
  //
28
35
  // The route-announcer <div> is injected into <body> by `announce()` in
29
36
  // `client-router/router.ts` on every navigation.
37
+ import { jsx } from "react/jsx-runtime";
30
38
  import { init } from "./client-router/router.js";
31
39
  import { init as prefetchInit } from "./client-router/prefetch.js";
32
40
  // Side-effect: wire click + submit intercepts on first import of this component.
@@ -39,14 +47,23 @@ if (typeof document !== "undefined") {
39
47
  // lifetime even if <ClientRouter prefetchAll /> is mounted multiple times (#276).
40
48
  let prefetchBootstrapped = false;
41
49
  /**
42
- * Construct a VNode-shaped object. Sets `constructor: undefined` so that both
43
- * Preact's and React's renderers treat it as a structural VNode rather than
44
- * foreign data. (Mirrors the `makeVNode` pattern in `@takazudo/zfb`'s Island.)
50
+ * Mint a head element through the per-project JSX runtime.
51
+ *
52
+ * Calls `jsx` from `react/jsx-runtime` (alias-rewritten to
53
+ * `preact/jsx-runtime` in Preact mode by the engine, native in React mode)
54
+ * so the returned value is a real element for whichever framework the
55
+ * project configured — NOT a hand-rolled `{ type, props, key }` literal,
56
+ * which only Preact accepts and which makes React throw error #31. A stable
57
+ * `key` is passed because `ClientRouter()` returns these nodes in a plain
58
+ * array (React warns about keyless list children otherwise). Mirrors the
59
+ * `Island` migration in `@takazudo/zfb`.
45
60
  */
46
- function makeVNode(type, props) {
47
- const v = { type, props, key: null };
48
- v.constructor = undefined;
49
- return v;
61
+ function makeVNode(type, props, key) {
62
+ // `jsx`'s `type` param is typed `ElementType` (string-literal intrinsic
63
+ // tags or component types), which rejects an arbitrary runtime `string`.
64
+ // The tag is dynamic here, so cast to the factory's own first-param type —
65
+ // robust whether the engine aliases `jsx` to react or preact at build time.
66
+ return jsx(type, props, key);
50
67
  }
51
68
  // CSS for the route-announcer element. Ported verbatim from Astro's
52
69
  // `<style is:global>` block in ClientRouter.astro (lines 11–23), renaming
@@ -81,7 +98,7 @@ const announcerCss = `
81
98
  * <ClientRouter fallback="animate" />
82
99
  * ```
83
100
  */
84
- export function ClientRouter({ fallback = "animate", prefetchAll: prefetchAllProp = false, } = {}) {
101
+ export function ClientRouter({ fallback = "animate", prefetchAll: prefetchAllProp = false, preserveHtmlAttrs = [], traverseRefetch = false, } = {}) {
85
102
  // Bootstrap prefetch exactly once on the client when prefetchAll is true.
86
103
  // The initialized flag inside prefetchInit() provides a second safety layer
87
104
  // in case of concurrent hydration or manual callers (#276).
@@ -91,11 +108,11 @@ export function ClientRouter({ fallback = "animate", prefetchAll: prefetchAllPro
91
108
  }
92
109
  const nodes = [
93
110
  // Global styles for the ARIA route-announcer div injected into <body>.
94
- makeVNode("style", { dangerouslySetInnerHTML: { __html: announcerCss } }),
111
+ makeVNode("style", { dangerouslySetInnerHTML: { __html: announcerCss } }, "zfb-vt-style"),
95
112
  // Opt-in meta tag: router checks for this to decide whether to intercept navigations.
96
- makeVNode("meta", { name: "zfb-view-transitions-enabled", content: "true" }),
113
+ makeVNode("meta", { name: "zfb-view-transitions-enabled", content: "true" }, "zfb-vt-enabled"),
97
114
  // Fallback strategy meta tag: read by getFallback() in router.ts.
98
- makeVNode("meta", { name: "zfb-view-transitions-fallback", content: fallback }),
115
+ makeVNode("meta", { name: "zfb-view-transitions-fallback", content: fallback }, "zfb-vt-fallback"),
99
116
  ];
100
117
  // Prefetch-disabled meta tag (#277): emitted when the bundler set
101
118
  // `globalThis.__zfb.prefetchDisabled = true` (from `zfb.config.ts`
@@ -110,7 +127,26 @@ export function ClientRouter({ fallback = "animate", prefetchAll: prefetchAllPro
110
127
  // Pin the contract verbatim — the attribute names and content value are
111
128
  // shared with the sibling prefetch-core sub-issue (#276).
112
129
  if (globalThis.__zfb?.prefetchDisabled === true) {
113
- nodes.push(makeVNode("meta", { name: "zfb-prefetch-disabled", content: "true" }));
130
+ nodes.push(makeVNode("meta", { name: "zfb-prefetch-disabled", content: "true" }, "zfb-prefetch-disabled"));
131
+ }
132
+ // Consumer-extensible <html> attribute preserve-list (#1103). swapRootAttributes
133
+ // reads this meta and re-applies the listed attributes' runtime values after each
134
+ // swap, so persisted-island state on <html> (data-theme, data-sidebar-hidden, …)
135
+ // survives navigation. Emitted only when non-empty, so non-opt-in output stays
136
+ // byte-identical. Same conditional-emission shape as the prefetch-disabled meta.
137
+ const preserveList = preserveHtmlAttrs.filter(Boolean);
138
+ if (preserveList.length > 0) {
139
+ nodes.push(makeVNode("meta", { name: "zfb-preserve-html-attrs", content: preserveList.join(" ") }, "zfb-preserve-html-attrs"));
140
+ }
141
+ // Traverse-refetch opt-out meta (#1376). A same-page Back/Forward traversal
142
+ // skips the re-fetch/re-swap by default (the router's traverse fast-path);
143
+ // this meta opts a per-request SSR page (`prerender = false`) back INTO the
144
+ // fetch, since skipping it would pin stale server-rendered content. Emitted
145
+ // only when opted in, so non-opt-in output stays byte-identical. Same
146
+ // conditional-emission shape as the prefetch-disabled / preserve-html-attrs
147
+ // metas; the router reads meta[name="zfb-traverse-refetch"][content="true"].
148
+ if (traverseRefetch) {
149
+ nodes.push(makeVNode("meta", { name: "zfb-traverse-refetch", content: "true" }, "zfb-traverse-refetch"));
114
150
  }
115
151
  return nodes;
116
152
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client-router.js","sourceRoot":"","sources":["../src/client-router.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,EAAE;AACF,wDAAwD;AACxD,uDAAuD;AACvD,yEAAyE;AACzE,EAAE;AACF,2DAA2D;AAC3D,2EAA2E;AAC3E,6EAA6E;AAC7E,2EAA2E;AAC3E,+EAA+E;AAC/E,iFAAiF;AACjF,2DAA2D;AAC3D,EAAE;AACF,sFAAsF;AACtF,8EAA8E;AAC9E,gFAAgF;AAChF,6EAA6E;AAC7E,iFAAiF;AACjF,mFAAmF;AACnF,0EAA0E;AAC1E,EAAE;AACF,0DAA0D;AAC1D,wEAAwE;AACxE,mEAAmE;AACnE,wEAAwE;AACxE,EAAE;AACF,uEAAuE;AACvE,iDAAiD;AAEjD,OAAO,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AACjD,OAAO,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAEnE,iFAAiF;AACjF,gFAAgF;AAChF,oEAAoE;AACpE,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;IACpC,IAAI,EAAE,CAAC;AACT,CAAC;AAED,8EAA8E;AAC9E,kFAAkF;AAClF,IAAI,oBAAoB,GAAG,KAAK,CAAC;AAuBjC;;;;GAIG;AACH,SAAS,SAAS,CAAC,IAAY,EAAE,KAA8B;IAC7D,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAKjC,CAAC;IACF,CAAC,CAAC,WAAW,GAAG,SAAS,CAAC;IAC1B,OAAO,CAAwB,CAAC;AAClC,CAAC;AAED,oEAAoE;AACpE,0EAA0E;AAC1E,gEAAgE;AAChE,uEAAuE;AACvE,+EAA+E;AAC/E,MAAM,YAAY,GAAG;;;;;;;;;;;;CAYpB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,EAC3B,QAAQ,GAAG,SAAS,EACpB,WAAW,EAAE,eAAe,GAAG,KAAK,MACf,EAAE;IACvB,0EAA0E;IAC1E,4EAA4E;IAC5E,4DAA4D;IAC5D,IAAI,OAAO,QAAQ,KAAK,WAAW,IAAI,eAAe,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAChF,oBAAoB,GAAG,IAAI,CAAC;QAC5B,YAAY,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;IACtC,CAAC;IAED,MAAM,KAAK,GAA0B;QACnC,uEAAuE;QACvE,SAAS,CAAC,OAAO,EAAE,EAAE,uBAAuB,EAAE,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,CAAC;QACzE,sFAAsF;QACtF,SAAS,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,8BAA8B,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAC5E,kEAAkE;QAClE,SAAS,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,+BAA+B,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;KAChF,CAAC;IAEF,kEAAkE;IAClE,mEAAmE;IACnE,0EAA0E;IAC1E,iFAAiF;IACjF,gDAAgD;IAChD,EAAE;IACF,yEAAyE;IACzE,+EAA+E;IAC/E,iBAAiB;IACjB,EAAE;IACF,wEAAwE;IACxE,0DAA0D;IAC1D,IAAK,UAAyD,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,EAAE,CAAC;QAChG,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;IACpF,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"client-router.js","sourceRoot":"","sources":["../src/client-router.ts"],"names":[],"mappings":"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","sourcesContent":["// `@takazudo/zfb-runtime` — `<ClientRouter />` component.\n//\n// Ported from Astro's `ClientRouter.astro` (155 lines).\n// Source: packages/astro/components/ClientRouter.astro\n// Issue: zudolab/zudo-doc#1519 (W3D), parent epic zudolab/zudo-doc#1510.\n//\n// Named-cause deviation — inline script split (W1B §13.6):\n// Astro's <script> block is processed by Vite at build time as a bundled\n// module with virtual-module imports (`astro:transitions/client`). The zfb\n// port emits meta tags + global styles from the JSX component, while the\n// click/form intercepts live in `client-router/router.ts` and are registered\n// via `init()`. The component calls `init()` as a side effect on first import.\n// No inline <script> dangerouslySetInnerHTML is emitted.\n//\n// Framework-agnostic element minting (no JSX syntax):\n// `@takazudo/zfb-runtime` does not depend on a framework runtime. Head nodes\n// are minted by calling `jsx` from `react/jsx-runtime` directly — NOT JSX\n// syntax, so this stays a plain `.ts` file with no tsconfig JSX changes. The\n// engine alias-rewrites `react/jsx-runtime` → `preact/jsx-runtime` in Preact\n// mode (bundler.rs ~2886) and resolves it natively in React mode, so the same\n// call mints a real element for whichever framework the project configured.\n// The previous approach (a hand-rolled `{ type, props, key, constructor:\n// undefined }` object literal — the Preact diff-path sentinel) only worked for\n// Preact: React's renderer rejects such an object as a child with React error\n// #31 (\"Objects are not valid as a React child\"), because a real React element\n// carries `$$typeof: Symbol.for(\"react.element\")` a literal cannot fake. Same\n// migration as `Island` in @takazudo/zfb.\n//\n// The component renders three base sibling elements to <head> (plus optional\n// metas emitted conditionally — see `prefetchDisabled` and `preserveHtmlAttrs`):\n// 1. A <style> tag with the `.zfb-route-announcer` ARIA helper class.\n// 2. <meta name=\"zfb-view-transitions-enabled\" content=\"true\" />\n// 3. <meta name=\"zfb-view-transitions-fallback\" content={fallback} />\n//\n// The route-announcer <div> is injected into <body> by `announce()` in\n// `client-router/router.ts` on every navigation.\n\nimport { jsx } from \"react/jsx-runtime\";\n\nimport { init } from \"./client-router/router.js\";\nimport { init as prefetchInit } from \"./client-router/prefetch.js\";\n\n// Side-effect: wire click + submit intercepts on first import of this component.\n// Guarded by the idempotent `initialized` flag in router.ts — safe for multiple\n// <ClientRouter /> mounts and HMR re-runs. (W3C3 init idempotency.)\nif (typeof document !== \"undefined\") {\n init();\n}\n\n// Module-level guard: prefetch bootstrap is triggered at most once per module\n// lifetime even if <ClientRouter prefetchAll /> is mounted multiple times (#276).\nlet prefetchBootstrapped = false;\n\nexport interface ClientRouterProps {\n /** Fallback animation strategy when native View Transitions are not supported. */\n fallback?: \"none\" | \"animate\" | \"swap\";\n /**\n * When true, opts every same-origin link into the default prefetch strategy\n * (hover) by calling prefetchInit({ prefetchAll: true }) once on the client.\n */\n prefetchAll?: boolean;\n /**\n * Extra `<html>` attribute names to preserve across SPA swaps. By default the\n * client router copies the incoming server-rendered document's `<html>`\n * attributes onto the live root, dropping any current attribute that isn't\n * internal to the transition machinery — so a *runtime* attribute a consumer\n * sets from a persisted island (e.g. `data-theme` or `data-sidebar-hidden`\n * driven from `localStorage`) is lost on every navigation. List those names\n * here and the router re-applies their current value after each swap. Emitted\n * as a `<meta name=\"zfb-preserve-html-attrs\">` tag that `swapRootAttributes`\n * reads at swap time.\n *\n * Mount with the **same list on every page** that participates in SPA\n * navigation: the preserve-list is read from the *current* (outgoing) page's\n * meta at swap time, so a page that omits an entry drops that attribute when\n * navigating away from it. Names are matched case-insensitively (DOM attribute\n * names are lowercased). For dynamic/computed cases, mutate\n * `event.newDocument.documentElement` in a `zfb:before-swap` listener instead.\n * @see https://github.com/Takazudo/zudo-front-builder/issues/1103\n */\n preserveHtmlAttrs?: string[];\n /**\n * Opt this page OUT of the same-page traverse fast-path (default `false` —\n * fast-path ON). By default a Back/Forward traversal between two history\n * entries sharing the same `pathname + search` is served instantly from the\n * live DOM — no re-fetch, no re-swap, so island/client state is preserved.\n *\n * Set this on a **per-request SSR page** (`prerender = false`) whose\n * server-rendered content can legitimately differ between two visits to the\n * same URL: with the fast-path skipped such a traverse would pin the stale\n * first-render content. Emitted as\n * `<meta name=\"zfb-traverse-refetch\" content=\"true\">`, which the router reads\n * on the current (target) page to force the fetch back on. Mount with the\n * **same value on every page** that participates in SPA navigation, mirroring\n * the `preserveHtmlAttrs` guidance.\n * @see https://github.com/Takazudo/zudo-front-builder/issues/1376\n */\n traverseRefetch?: boolean;\n}\n\n/**\n * Public element shape for each node returned by `<ClientRouter />`.\n * Structural type — intentionally matches the Preact/React VNode object shape\n * so consumers do not type-infer through the internal representation.\n */\nexport type ClientRouterElement = {\n readonly type: string;\n readonly props: Readonly<Record<string, unknown>>;\n readonly key: unknown;\n};\n\n/**\n * Mint a head element through the per-project JSX runtime.\n *\n * Calls `jsx` from `react/jsx-runtime` (alias-rewritten to\n * `preact/jsx-runtime` in Preact mode by the engine, native in React mode)\n * so the returned value is a real element for whichever framework the\n * project configured — NOT a hand-rolled `{ type, props, key }` literal,\n * which only Preact accepts and which makes React throw error #31. A stable\n * `key` is passed because `ClientRouter()` returns these nodes in a plain\n * array (React warns about keyless list children otherwise). Mirrors the\n * `Island` migration in `@takazudo/zfb`.\n */\nfunction makeVNode(type: string, props: Record<string, unknown>, key: string): ClientRouterElement {\n // `jsx`'s `type` param is typed `ElementType` (string-literal intrinsic\n // tags or component types), which rejects an arbitrary runtime `string`.\n // The tag is dynamic here, so cast to the factory's own first-param type —\n // robust whether the engine aliases `jsx` to react or preact at build time.\n return jsx(type as Parameters<typeof jsx>[0], props, key) as unknown as ClientRouterElement;\n}\n\n// CSS for the route-announcer element. Ported verbatim from Astro's\n// `<style is:global>` block in ClientRouter.astro (lines 11–23), renaming\n// `.astro-route-announcer` → `.zfb-route-announcer` per W1B §5.\n// This is a global (non-scoped) <style> because the announcer <div> is\n// appended to document.body at runtime, outside any Preact-controlled subtree.\nconst announcerCss = `\n.zfb-route-announcer {\n\tposition: absolute;\n\tleft: 0;\n\ttop: 0;\n\tclip: rect(0 0 0 0);\n\tclip-path: inset(50%);\n\toverflow: hidden;\n\twhite-space: nowrap;\n\twidth: 1px;\n\theight: 1px;\n}\n`;\n\n/**\n * `<ClientRouter />` — SPA soft-swap navigation with View Transition animations.\n *\n * Mount once in your page `<head>`. Emits the opt-in meta tags and the global\n * `.zfb-route-announcer` stylesheet that the route-announcer ARIA div needs.\n * Click and form-submit intercepts are registered as a side effect of importing\n * this component (idempotent — safe to mount multiple times).\n *\n * @example\n * ```tsx\n * import { ClientRouter } from \"@takazudo/zfb-runtime\";\n * // In your page <head>:\n * <ClientRouter fallback=\"animate\" />\n * ```\n */\nexport function ClientRouter({\n fallback = \"animate\",\n prefetchAll: prefetchAllProp = false,\n preserveHtmlAttrs = [],\n traverseRefetch = false,\n}: ClientRouterProps = {}): readonly ClientRouterElement[] {\n // Bootstrap prefetch exactly once on the client when prefetchAll is true.\n // The initialized flag inside prefetchInit() provides a second safety layer\n // in case of concurrent hydration or manual callers (#276).\n if (typeof document !== \"undefined\" && prefetchAllProp && !prefetchBootstrapped) {\n prefetchBootstrapped = true;\n prefetchInit({ prefetchAll: true });\n }\n\n const nodes: ClientRouterElement[] = [\n // Global styles for the ARIA route-announcer div injected into <body>.\n makeVNode(\"style\", { dangerouslySetInnerHTML: { __html: announcerCss } }, \"zfb-vt-style\"),\n // Opt-in meta tag: router checks for this to decide whether to intercept navigations.\n makeVNode(\"meta\", { name: \"zfb-view-transitions-enabled\", content: \"true\" }, \"zfb-vt-enabled\"),\n // Fallback strategy meta tag: read by getFallback() in router.ts.\n makeVNode(\n \"meta\",\n { name: \"zfb-view-transitions-fallback\", content: fallback },\n \"zfb-vt-fallback\",\n ),\n ];\n\n // Prefetch-disabled meta tag (#277): emitted when the bundler set\n // `globalThis.__zfb.prefetchDisabled = true` (from `zfb.config.ts`\n // `prefetch: { disabled: true }`). The sibling prefetch-core module reads\n // `document.querySelector('meta[name=\"zfb-prefetch-disabled\"][content=\"true\"]')`\n // at `init()` time and short-circuits if found.\n //\n // The flag is site-wide and static — set once at bundle-emit time, never\n // per-page. This meta tag appears on every page that mounts `<ClientRouter />`\n // or not at all.\n //\n // Pin the contract verbatim — the attribute names and content value are\n // shared with the sibling prefetch-core sub-issue (#276).\n if ((globalThis as { __zfb?: { prefetchDisabled?: boolean } }).__zfb?.prefetchDisabled === true) {\n nodes.push(\n makeVNode(\n \"meta\",\n { name: \"zfb-prefetch-disabled\", content: \"true\" },\n \"zfb-prefetch-disabled\",\n ),\n );\n }\n\n // Consumer-extensible <html> attribute preserve-list (#1103). swapRootAttributes\n // reads this meta and re-applies the listed attributes' runtime values after each\n // swap, so persisted-island state on <html> (data-theme, data-sidebar-hidden, …)\n // survives navigation. Emitted only when non-empty, so non-opt-in output stays\n // byte-identical. Same conditional-emission shape as the prefetch-disabled meta.\n const preserveList = preserveHtmlAttrs.filter(Boolean);\n if (preserveList.length > 0) {\n nodes.push(\n makeVNode(\n \"meta\",\n { name: \"zfb-preserve-html-attrs\", content: preserveList.join(\" \") },\n \"zfb-preserve-html-attrs\",\n ),\n );\n }\n\n // Traverse-refetch opt-out meta (#1376). A same-page Back/Forward traversal\n // skips the re-fetch/re-swap by default (the router's traverse fast-path);\n // this meta opts a per-request SSR page (`prerender = false`) back INTO the\n // fetch, since skipping it would pin stale server-rendered content. Emitted\n // only when opted in, so non-opt-in output stays byte-identical. Same\n // conditional-emission shape as the prefetch-disabled / preserve-html-attrs\n // metas; the router reads meta[name=\"zfb-traverse-refetch\"][content=\"true\"].\n if (traverseRefetch) {\n nodes.push(\n makeVNode(\"meta\", { name: \"zfb-traverse-refetch\", content: \"true\" }, \"zfb-traverse-refetch\"),\n );\n }\n\n return nodes;\n}\n"]}
@@ -14,4 +14,3 @@ export interface FrameworkAdapter {
14
14
  */
15
15
  renderToString: (vnode: unknown) => string;
16
16
  }
17
- //# sourceMappingURL=framework.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"framework.js","sourceRoot":"","sources":["../src/framework.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,EAAE;AACF,wEAAwE;AACxE,iEAAiE;AACjE,wEAAwE;AACxE,8DAA8D;AAC9D,EAAE;AACF,0EAA0E;AAC1E,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,EAAE;AACF,0EAA0E;AAC1E,kEAAkE;AAClE,4EAA4E"}
1
+ {"version":3,"file":"framework.js","sourceRoot":"","sources":["../src/framework.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,EAAE;AACF,wEAAwE;AACxE,iEAAiE;AACjE,wEAAwE;AACxE,8DAA8D;AAC9D,EAAE;AACF,0EAA0E;AAC1E,0EAA0E;AAC1E,2EAA2E;AAC3E,8EAA8E;AAC9E,EAAE;AACF,0EAA0E;AAC1E,kEAAkE;AAClE,4EAA4E","sourcesContent":["// `@takazudo/zfb-runtime` — framework adapter contract.\n//\n// The runtime is JSX-runtime-agnostic: it never imports preact or react\n// directly. Instead, the caller of [`createPageRouter`] passes a\n// [`FrameworkAdapter`] that pins how a JSX vnode (the value returned by\n// a page module's `default` export) becomes a string of HTML.\n//\n// This mirrors ADR-002's framework-adapter pattern on the Rust side — the\n// runtime owns the page-module evaluation lifecycle, the adapter owns the\n// JSX-runtime-specific render call. Wave 2 (T6) wires the embedded V8 host\n// with an adapter built on `preact-render-to-string` (or `react-dom/server`).\n//\n// BCI-5: `hydrate` reservation field removed — it was never called by the\n// runtime and no real consumer set it; adding it back when a real\n// implementation exists is a smaller change than carrying dead API surface.\n\n/**\n * Pluggable per-framework SSR contract.\n *\n * `renderToString` takes whatever shape the framework's JSX pragma\n * produces (Preact's VNode, React's element, etc.) and returns a string\n * of HTML. The runtime treats both the input and the return value as\n * opaque — it neither inspects the vnode nor escapes the output.\n */\nexport interface FrameworkAdapter {\n /**\n * Render a JSX vnode to an HTML string. Must be synchronous; SSR\n * happens inside a Hono request handler that resolves the response\n * body in one step.\n */\n renderToString: (vnode: unknown) => string;\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -1,14 +1,12 @@
1
- export { createPageRouter } from "./router.js";
2
1
  export type { CreatePageRouterOptions, PageDefinition, PageHeading, PageModule, PageRouter, } from "./router.js";
3
2
  export type { FrameworkAdapter } from "./framework.js";
4
3
  export type { ContentSnapshot, EntrySnapshot } from "./snapshot.js";
5
4
  export { ViewTransitions, type ViewTransitionsElement } from "./view-transitions.js";
6
5
  export { ClientRouter, type ClientRouterProps } from "./client-router.js";
7
- export { navigate, supportsViewTransitions, transitionEnabledOnThisPage, } from "./client-router/router.js";
6
+ export { navigate, supportsViewTransitions, transitionEnabledOnThisPage, syncHistoryEntry, } from "./client-router/router.js";
8
7
  export { prefetch, init as prefetchInit } from "./client-router/prefetch.js";
9
8
  export type { PrefetchStrategy, PrefetchInitOptions, PrefetchOptions, } from "./client-router/prefetch.js";
10
9
  export { TRANSITION_BEFORE_PREPARATION, TRANSITION_AFTER_PREPARATION, TRANSITION_BEFORE_SWAP, TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD, TRANSITION_NAVIGATION_ABORTED, TransitionBeforePreparationEvent, TransitionBeforeSwapEvent, isTransitionBeforePreparationEvent, isTransitionBeforeSwapEvent, } from "./client-router/events.js";
11
10
  export { swapFunctions, swap } from "./client-router/swap-functions.js";
12
- export type { Direction, Fallback, NavigationTypeString, Options } from "./client-router/types.js";
11
+ export type { Direction, Fallback, NavigationTypeString, Options, SyncHistoryEntryOptions, } from "./client-router/types.js";
13
12
  export type { ZfbPlugin, ZfbPluginLogger, ZfbBuildHookContext, ZfbDevMiddlewareContext, ZfbDevMiddlewareHandler, ZfbDevMiddlewareRequest, ZfbDevMiddlewareResponse, ZfbRouteEntry, ZfbRouteManifest, ZfbSetupContext, ZfbVirtualModuleLoader, } from "@takazudo/zfb/plugins";
14
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,9 +1,19 @@
1
1
  // Public entry point for `@takazudo/zfb-runtime`.
2
2
  //
3
- // Worker bundles produced by T3 import this module and invoke
3
+ // This is the **client-safe** barrel: everything re-exported here must be
4
+ // bundleable for `--platform=browser` without pulling a server-only
5
+ // dependency. In particular `createPageRouter` (and the Hono server router
6
+ // it builds) is intentionally NOT re-exported here — it lives at the
7
+ // server-only subpath `@takazudo/zfb-runtime/server`. Re-exporting it from
8
+ // this barrel makes any island that does `import ... from "@takazudo/zfb-runtime"`
9
+ // drag `hono` into the browser graph, which esbuild must then resolve
10
+ // (issue #1298). The page-router *types* below are `export type` only, so
11
+ // esbuild strips them and no runtime edge to `./router.js` survives.
12
+ //
13
+ // Worker bundles produced by T3 import the server subpath and invoke
4
14
  // `createPageRouter` once, at module top, to obtain the fetch handler:
5
15
  //
6
- // import { createPageRouter } from "@takazudo/zfb-runtime";
16
+ // import { createPageRouter } from "@takazudo/zfb-runtime/server";
7
17
  //
8
18
  // const router = createPageRouter({
9
19
  // pages: [...],
@@ -14,12 +24,11 @@
14
24
  // export default { fetch: router };
15
25
  //
16
26
  // The README documents the bundle shape T6 (embedded V8 host) consumes.
17
- export { createPageRouter } from "./router.js";
18
27
  export { ViewTransitions } from "./view-transitions.js";
19
28
  // Client-router public surface (W3D — mirrors @takazudo/zfb-runtime/client-router barrel).
20
29
  // See W1B §2 for the full public API spec.
21
30
  export { ClientRouter } from "./client-router.js";
22
- export { navigate, supportsViewTransitions, transitionEnabledOnThisPage, } from "./client-router/router.js";
31
+ export { navigate, supportsViewTransitions, transitionEnabledOnThisPage, syncHistoryEntry, } from "./client-router/router.js";
23
32
  // Prefetch public surface (#276).
24
33
  // `init` from prefetch.ts is re-exported as `prefetchInit` to avoid collision
25
34
  // with the router's `init`.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,EAAE;AACF,8DAA8D;AAC9D,uEAAuE;AACvE,EAAE;AACF,8DAA8D;AAC9D,EAAE;AACF,sCAAsC;AACtC,oBAAoB;AACpB,4EAA4E;AAC5E,iFAAiF;AACjF,QAAQ;AACR,EAAE;AACF,sCAAsC;AACtC,EAAE;AACF,wEAAwE;AAExE,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAU/C,OAAO,EAAE,eAAe,EAA+B,MAAM,uBAAuB,CAAC;AAErF,2FAA2F;AAC3F,2CAA2C;AAC3C,OAAO,EAAE,YAAY,EAA0B,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AAEnC,kCAAkC;AAClC,8EAA8E;AAC9E,4BAA4B;AAC5B,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAM7E,OAAO,EACL,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,EAChC,yBAAyB,EACzB,kCAAkC,EAClC,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,mCAAmC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,EAAE;AACF,0EAA0E;AAC1E,oEAAoE;AACpE,2EAA2E;AAC3E,qEAAqE;AACrE,2EAA2E;AAC3E,mFAAmF;AACnF,sEAAsE;AACtE,0EAA0E;AAC1E,qEAAqE;AACrE,EAAE;AACF,qEAAqE;AACrE,uEAAuE;AACvE,EAAE;AACF,qEAAqE;AACrE,EAAE;AACF,sCAAsC;AACtC,oBAAoB;AACpB,4EAA4E;AAC5E,iFAAiF;AACjF,QAAQ;AACR,EAAE;AACF,sCAAsC;AACtC,EAAE;AACF,wEAAwE;AAWxE,OAAO,EAAE,eAAe,EAA+B,MAAM,uBAAuB,CAAC;AAErF,2FAA2F;AAC3F,2CAA2C;AAC3C,OAAO,EAAE,YAAY,EAA0B,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EACL,QAAQ,EACR,uBAAuB,EACvB,2BAA2B,EAC3B,gBAAgB,GACjB,MAAM,2BAA2B,CAAC;AAEnC,kCAAkC;AAClC,8EAA8E;AAC9E,4BAA4B;AAC5B,OAAO,EAAE,QAAQ,EAAE,IAAI,IAAI,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAM7E,OAAO,EACL,6BAA6B,EAC7B,4BAA4B,EAC5B,sBAAsB,EACtB,qBAAqB,EACrB,oBAAoB,EACpB,6BAA6B,EAC7B,gCAAgC,EAChC,yBAAyB,EACzB,kCAAkC,EAClC,2BAA2B,GAC5B,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,mCAAmC,CAAC","sourcesContent":["// Public entry point for `@takazudo/zfb-runtime`.\n//\n// This is the **client-safe** barrel: everything re-exported here must be\n// bundleable for `--platform=browser` without pulling a server-only\n// dependency. In particular `createPageRouter` (and the Hono server router\n// it builds) is intentionally NOT re-exported here — it lives at the\n// server-only subpath `@takazudo/zfb-runtime/server`. Re-exporting it from\n// this barrel makes any island that does `import ... from \"@takazudo/zfb-runtime\"`\n// drag `hono` into the browser graph, which esbuild must then resolve\n// (issue #1298). The page-router *types* below are `export type` only, so\n// esbuild strips them and no runtime edge to `./router.js` survives.\n//\n// Worker bundles produced by T3 import the server subpath and invoke\n// `createPageRouter` once, at module top, to obtain the fetch handler:\n//\n// import { createPageRouter } from \"@takazudo/zfb-runtime/server\";\n//\n// const router = createPageRouter({\n// pages: [...],\n// contentSnapshot: __ZFB_CONTENT_SNAPSHOT__, // embedded by the bundler\n// framework: { renderToString: render }, // preact-render-to-string etc.\n// });\n//\n// export default { fetch: router };\n//\n// The README documents the bundle shape T6 (embedded V8 host) consumes.\n\nexport type {\n CreatePageRouterOptions,\n PageDefinition,\n PageHeading,\n PageModule,\n PageRouter,\n} from \"./router.js\";\nexport type { FrameworkAdapter } from \"./framework.js\";\nexport type { ContentSnapshot, EntrySnapshot } from \"./snapshot.js\";\nexport { ViewTransitions, type ViewTransitionsElement } from \"./view-transitions.js\";\n\n// Client-router public surface (W3D — mirrors @takazudo/zfb-runtime/client-router barrel).\n// See W1B §2 for the full public API spec.\nexport { ClientRouter, type ClientRouterProps } from \"./client-router.js\";\nexport {\n navigate,\n supportsViewTransitions,\n transitionEnabledOnThisPage,\n syncHistoryEntry,\n} from \"./client-router/router.js\";\n\n// Prefetch public surface (#276).\n// `init` from prefetch.ts is re-exported as `prefetchInit` to avoid collision\n// with the router's `init`.\nexport { prefetch, init as prefetchInit } from \"./client-router/prefetch.js\";\nexport type {\n PrefetchStrategy,\n PrefetchInitOptions,\n PrefetchOptions,\n} from \"./client-router/prefetch.js\";\nexport {\n TRANSITION_BEFORE_PREPARATION,\n TRANSITION_AFTER_PREPARATION,\n TRANSITION_BEFORE_SWAP,\n TRANSITION_AFTER_SWAP,\n TRANSITION_PAGE_LOAD,\n TRANSITION_NAVIGATION_ABORTED,\n TransitionBeforePreparationEvent,\n TransitionBeforeSwapEvent,\n isTransitionBeforePreparationEvent,\n isTransitionBeforeSwapEvent,\n} from \"./client-router/events.js\";\nexport { swapFunctions, swap } from \"./client-router/swap-functions.js\";\nexport type {\n Direction,\n Fallback,\n NavigationTypeString,\n Options,\n SyncHistoryEntryOptions,\n} from \"./client-router/types.js\";\n\n// Plugin lifecycle types (#255). The runtime package re-exports the\n// `@takazudo/zfb/plugins` surface so consumers writing plugins from a\n// project that only depends on `@takazudo/zfb-runtime` can still\n// import the canonical types (e.g. `import type { ZfbPlugin } from\n// \"@takazudo/zfb-runtime\"`).\nexport type {\n ZfbPlugin,\n ZfbPluginLogger,\n ZfbBuildHookContext,\n ZfbDevMiddlewareContext,\n ZfbDevMiddlewareHandler,\n ZfbDevMiddlewareRequest,\n ZfbDevMiddlewareResponse,\n ZfbRouteEntry,\n ZfbRouteManifest,\n ZfbSetupContext,\n ZfbVirtualModuleLoader,\n} from \"@takazudo/zfb/plugins\";\n"]}
package/dist/router.d.ts CHANGED
@@ -16,7 +16,10 @@ export interface PageHeading {
16
16
  * - `default`: the JSX page component. Called with the props returned by
17
17
  * `getStaticProps` (if exported) or the `props` from the matching
18
18
  * `paths()` entry (for dynamic routes). The return value is fed straight
19
- * to the framework adapter's `renderToString`.
19
+ * to the framework adapter's `renderToString`. A dynamic route with no
20
+ * `paths()` export is called with `{ params: urlParams }` instead of
21
+ * `{}` — see `createPageRouter`'s JSDoc for the full componentInput
22
+ * derivation table.
20
23
  * - `prerender`: literal `false` opts a route OUT of build-time SSG (T5
21
24
  * contract). The page router still serves it under the embedded V8 host
22
25
  * so dev mode behaves identically; SSG callers filter the route list before
@@ -30,6 +33,12 @@ export interface PageHeading {
30
33
  * for this route template. May be async. Returns an array of
31
34
  * `{ params, props? }` objects identical in shape to the Astro/zfb
32
35
  * `paths()` contract.
36
+ * **Evaluated once per router instance** — the result is memoised and
37
+ * shared across the `/__paths__` handler and all per-page render
38
+ * requests. This matches the build-time-enumerator contract: every
39
+ * entry rendered within a single build sees the same paths() snapshot.
40
+ * Dev mode is safe because each file-save triggers a fresh bundle,
41
+ * which creates a new router instance with a clean memo.
33
42
  * - `getStaticProps`: optional async function for static routes that need
34
43
  * to fetch data at build/render time. Called once per request (before
35
44
  * `default`). Must return `{ props: Record<string, unknown> }`. The
@@ -69,6 +78,17 @@ export interface CreatePageRouterOptions {
69
78
  readonly contentSnapshot: ContentSnapshot;
70
79
  /** Framework adapter pinning the SSR call. */
71
80
  readonly framework: FrameworkAdapter;
81
+ /**
82
+ * When `true`, the 500 body for render errors includes the full JS stack
83
+ * trace. When `false`, only the message + route are included. When
84
+ * omitted, the runtime checks `globalThis.__zfb.ssrDebug` at request time:
85
+ * that flag is set by the embedded V8 build/dev host (`globals_shim.js`)
86
+ * and is absent on the production Cloudflare Workers runtime.
87
+ *
88
+ * Default is effectively OFF for production (no flag ⇒ message + route only).
89
+ * Useful for explicit injection in unit tests without global mutation.
90
+ */
91
+ readonly includeErrorStack?: boolean;
72
92
  }
73
93
  /**
74
94
  * Fetch-handler shape returned by [`createPageRouter`]. Shaped as a plain
@@ -89,9 +109,19 @@ export type PageRouter = (request: Request) => Promise<Response>;
89
109
  * `framework.renderToString(module.default({}))`, and returns the
90
110
  * string in a `Response` with the appropriate `Content-Type`.
91
111
  *
112
+ * Per-route `componentInput` (the object passed to `module.default(...)`) is
113
+ * derived from the route pattern and the page module's exports:
114
+ * - Dynamic route + `paths()` export: match the URL params against the
115
+ * `paths()` entries and pass `{ params, ...props }` (404 on no match).
116
+ * - Static route + `getStaticProps()` export: pass the returned `props`.
117
+ * - Dynamic route with NO `paths()` export (e.g. a per-request SSR page
118
+ * whose slugs can't be enumerated ahead of time): pass `{ params:
119
+ * urlParams }` so the component still knows which URL params it is
120
+ * serving, rather than being invoked with `{}`.
121
+ * - Anything else (static route, no `getStaticProps`): pass `{}`.
122
+ *
92
123
  * The returned function is a plain `(request) => Promise<Response>` so a
93
124
  * Worker entry point can `export default { fetch: createPageRouter(...) }`
94
125
  * directly.
95
126
  */
96
127
  export declare function createPageRouter(opts: CreatePageRouterOptions): PageRouter;
97
- //# sourceMappingURL=router.d.ts.map
package/dist/router.js CHANGED
@@ -77,6 +77,26 @@ function ensureHtml5Doctype(body, contentType) {
77
77
  }
78
78
  return body;
79
79
  }
80
+ /**
81
+ * Evaluate `pathsFn` once per `routeKey`, sharing the result across all
82
+ * concurrent and subsequent callers via a Promise stored in `memo`.
83
+ *
84
+ * Rejections are evicted from the memo so that a transient paths() failure
85
+ * does not permanently poison the cache — the next request retries.
86
+ */
87
+ async function getOrEvalPaths(memo, routeKey, pathsFn) {
88
+ const existing = memo.get(routeKey);
89
+ if (existing !== undefined) {
90
+ return existing;
91
+ }
92
+ const promise = Promise.resolve(pathsFn()).then((v) => v, (err) => {
93
+ // Evict on rejection so the next request retries.
94
+ memo.delete(routeKey);
95
+ return Promise.reject(err);
96
+ });
97
+ memo.set(routeKey, promise);
98
+ return promise;
99
+ }
80
100
  /**
81
101
  * Build a page router for the SSG-first architecture (ADR-005).
82
102
  *
@@ -90,6 +110,17 @@ function ensureHtml5Doctype(body, contentType) {
90
110
  * `framework.renderToString(module.default({}))`, and returns the
91
111
  * string in a `Response` with the appropriate `Content-Type`.
92
112
  *
113
+ * Per-route `componentInput` (the object passed to `module.default(...)`) is
114
+ * derived from the route pattern and the page module's exports:
115
+ * - Dynamic route + `paths()` export: match the URL params against the
116
+ * `paths()` entries and pass `{ params, ...props }` (404 on no match).
117
+ * - Static route + `getStaticProps()` export: pass the returned `props`.
118
+ * - Dynamic route with NO `paths()` export (e.g. a per-request SSR page
119
+ * whose slugs can't be enumerated ahead of time): pass `{ params:
120
+ * urlParams }` so the component still knows which URL params it is
121
+ * serving, rather than being invoked with `{}`.
122
+ * - Anything else (static route, no `getStaticProps`): pass `{}`.
123
+ *
93
124
  * The returned function is a plain `(request) => Promise<Response>` so a
94
125
  * Worker entry point can `export default { fetch: createPageRouter(...) }`
95
126
  * directly.
@@ -104,6 +135,21 @@ export function createPageRouter(opts) {
104
135
  for (const page of opts.pages) {
105
136
  pagesByRoute.set(page.route, page);
106
137
  }
138
+ // Per-router-instance memo for paths() results.
139
+ //
140
+ // Keyed on page.route (e.g. "/blog/:slug"). Stores the in-flight or
141
+ // settled Promise so that concurrent requests share one evaluation.
142
+ // Rejections are NOT cached: if paths() throws the Promise is removed
143
+ // from the map so the next request retries rather than re-propagating
144
+ // the same error indefinitely.
145
+ //
146
+ // Production SSR isolates evaluate paths() once per isolate, which
147
+ // matches the documented build-time-enumerator contract. Dev mode is
148
+ // safe because each re-bundle creates a fresh router instance
149
+ // (dev.rs reload_renderer), so stale paths() results never persist
150
+ // across a file-save cycle. Watch-time invalidation is explicitly
151
+ // out of scope per issue #507.
152
+ const pathsMemo = new Map();
107
153
  // Sanity check: a user-authored route that happens to start with
108
154
  // `/__paths__` (or a top-level catchall like `/:slug{.+}`) would
109
155
  // shadow the synthetic endpoint registered below if Hono dispatched
@@ -153,7 +199,7 @@ export function createPageRouter(opts) {
153
199
  }
154
200
  let result;
155
201
  try {
156
- result = await mod.paths();
202
+ result = await getOrEvalPaths(pathsMemo, routeKey, mod.paths);
157
203
  }
158
204
  catch (err) {
159
205
  const msg = err instanceof Error ? err.message : String(err);
@@ -193,14 +239,28 @@ export function createPageRouter(opts) {
193
239
  // an empty object — the component signature has no required props.
194
240
  // For dynamic routes whose URL params do not match any paths()
195
241
  // entry, we return a 404 rather than rendering with empty props.
196
- const rawUrlParams = c.req.param();
197
- const urlParams = (rawUrlParams ?? {});
198
- const hasDynamicParams = Object.keys(urlParams).length > 0;
242
+ //
243
+ // Whether the route is dynamic is derived from the ROUTE PATTERN,
244
+ // not from the captured params: for an optional catchall
245
+ // (`/docs/:slug{.+}?`), Hono matches the bare `/docs` with NO
246
+ // params captured at all, so `Object.keys(c.req.param())` would
247
+ // be empty and the old gate skipped paths() entirely — rendering
248
+ // with `{}` instead of the entry whose `slug` is `[]`.
249
+ const declaredParams = routeParamSpecs(page.route);
250
+ const rawUrlParams = c.req.param() ?? {};
251
+ const urlParams = {};
252
+ for (const [k, v] of Object.entries(rawUrlParams)) {
253
+ // Unmatched optional params may surface as undefined — drop them
254
+ // so "param absent" is represented uniformly.
255
+ if (typeof v === "string")
256
+ urlParams[k] = v;
257
+ }
258
+ const isDynamicRoute = declaredParams.length > 0;
199
259
  let componentInput = {};
200
- if (hasDynamicParams && typeof mod.paths === "function") {
260
+ if (isDynamicRoute && typeof mod.paths === "function") {
201
261
  let pathsResult;
202
262
  try {
203
- pathsResult = await mod.paths();
263
+ pathsResult = await getOrEvalPaths(pathsMemo, page.route, mod.paths);
204
264
  }
205
265
  catch (err) {
206
266
  const msg = err instanceof Error ? err.message : String(err);
@@ -226,15 +286,27 @@ export function createPageRouter(opts) {
226
286
  // /docs/[...slug]), Hono returns a slash-joined string (e.g.
227
287
  // "guides/install"), so we compare against the paths() entry's
228
288
  // params.slug joined with "/".
289
+ //
290
+ // Matching iterates the params DECLARED by the route pattern so
291
+ // the zero-segment optional-catchall case is covered: when Hono
292
+ // matched `/docs` for `/docs/:slug{.+}?` no `slug` param exists
293
+ // in the URL, and the matching entry is the one whose param is
294
+ // the explicit empty array (`{ slug: [] }`).
229
295
  const match = pathsResult.find((entry) => {
230
- return Object.entries(urlParams).every(([k, v]) => {
231
- const paramVal = entry.params[k];
296
+ return declaredParams.every(({ name, optionalCatchall }) => {
297
+ const urlVal = urlParams[name];
298
+ const paramVal = entry.params[name];
299
+ if (urlVal === undefined) {
300
+ // Param absent from the URL: only valid for an optional
301
+ // catchall, and only against the explicit `[]` entry.
302
+ return optionalCatchall && Array.isArray(paramVal) && paramVal.length === 0;
303
+ }
232
304
  if (Array.isArray(paramVal)) {
233
305
  // catchall: paths() emits slug as string[] but Hono
234
306
  // provides it as a "/"-joined string
235
- return paramVal.join("/") === v;
307
+ return paramVal.join("/") === urlVal;
236
308
  }
237
- return String(paramVal) === v;
309
+ return String(paramVal) === urlVal;
238
310
  });
239
311
  });
240
312
  if (!match) {
@@ -253,7 +325,17 @@ export function createPageRouter(opts) {
253
325
  ...(match.props ?? {}),
254
326
  };
255
327
  }
256
- else if (!hasDynamicParams && typeof mod.getStaticProps === "function") {
328
+ else if (isDynamicRoute) {
329
+ // Dynamic route with no `paths()` export: the natural shape for a
330
+ // per-request SSR page whose slugs can't be enumerated ahead of
331
+ // time. Neither branch above applies (no paths() to match against,
332
+ // and getStaticProps only fires for static routes), so without this
333
+ // the component would be invoked with `{}` — no params, no
334
+ // diagnostic, and no way to know which slug it is serving. Pass the
335
+ // URL params through directly instead.
336
+ componentInput = { params: urlParams };
337
+ }
338
+ else if (!isDynamicRoute && typeof mod.getStaticProps === "function") {
257
339
  // Static route with `getStaticProps`: call it to fetch build-time
258
340
  // data and pass the returned props to the default component. This
259
341
  // supports the `export async function getStaticProps()` pattern
@@ -279,24 +361,44 @@ export function createPageRouter(opts) {
279
361
  // `await` so async page modules (e.g. API routes typed as
280
362
  // `(): Promise<Response>`) resolve before we inspect the value.
281
363
  // For sync pages that return a VNode/string the await is a no-op.
282
- const result = await mod.default(componentInput);
283
- // API route short-circuit: a page module that returns a Response
284
- // directly (e.g. `pages/api/*.tsx` handlers that use Web Fetch
285
- // primitives instead of returning JSX) is responsible for its own
286
- // status, headers, and body return it as-is rather than running
287
- // it through the framework SSR path.
288
- if (result instanceof Response) {
289
- return result;
364
+ //
365
+ // Wrapped in a try/catch so that any error thrown by the component
366
+ // or by renderToString surfaces as a descriptive 500 rather than
367
+ // escaping to Hono's generic error handler (which discards the real
368
+ // message). Mirrors the getStaticProps catch above.
369
+ try {
370
+ const result = await mod.default(componentInput);
371
+ // API route short-circuit: a page module that returns a Response
372
+ // directly (e.g. `pages/api/*.tsx` handlers that use Web Fetch
373
+ // primitives instead of returning JSX) is responsible for its own
374
+ // status, headers, and body — return it as-is rather than running
375
+ // it through the framework SSR path. A `return` from inside a
376
+ // `try` does not trigger the `catch`, so this passes through
377
+ // correctly without special-casing.
378
+ if (result instanceof Response) {
379
+ return result;
380
+ }
381
+ // Non-HTML routes (e.g. `sitemap.xml.tsx`, `feed.xml.tsx`) commonly
382
+ // return their body as a pre-serialised `string` instead of a
383
+ // VNode. Routing those through `framework.renderToString` would
384
+ // HTML-escape the angle brackets and ampersands, producing
385
+ // garbage XML. Pass strings through verbatim; only wrap actual
386
+ // VNodes.
387
+ const html = typeof result === "string" ? result : opts.framework.renderToString(result);
388
+ const contentType = mod.contentType ?? DEFAULT_CONTENT_TYPE;
389
+ return c.body(ensureHtml5Doctype(html, contentType), 200, { "Content-Type": contentType });
390
+ }
391
+ catch (err) {
392
+ const msg = err instanceof Error ? err.message : String(err);
393
+ // __zfb.ssrDebug is set only by the embedded V8 build/dev host
394
+ // (globals_shim.js), never by the production Workers runtime.
395
+ const includeStack = opts.includeErrorStack ??
396
+ globalThis.__zfb?.ssrDebug === true;
397
+ const stack = includeStack && err instanceof Error && err.stack ? `\n${err.stack}` : "";
398
+ return c.body(`[zfb-runtime] render threw for "${page.route}": ${msg}${stack}`, 500, {
399
+ "Content-Type": "text/plain; charset=utf-8",
400
+ });
290
401
  }
291
- // Non-HTML routes (e.g. `sitemap.xml.tsx`, `feed.xml.tsx`) commonly
292
- // return their body as a pre-serialised `string` instead of a
293
- // VNode. Routing those through `framework.renderToString` would
294
- // HTML-escape the angle brackets and ampersands, producing
295
- // garbage XML. Pass strings through verbatim; only wrap actual
296
- // VNodes.
297
- const html = typeof result === "string" ? result : opts.framework.renderToString(result);
298
- const contentType = mod.contentType ?? DEFAULT_CONTENT_TYPE;
299
- return c.body(ensureHtml5Doctype(html, contentType), 200, { "Content-Type": contentType });
300
402
  });
301
403
  }
302
404
  // Hono's `app.fetch` returns `Response | Promise<Response>`. The
@@ -317,14 +419,39 @@ function isPathsEntry(x) {
317
419
  const params = x.params;
318
420
  return typeof params === "object" && params !== null;
319
421
  }
422
+ /**
423
+ * Parse the params declared by a Hono route pattern (the `route` string
424
+ * the build pipeline registers, e.g. `/blog/:slug`, `/docs/:slug{.+}`,
425
+ * `/docs/:slug{.+}?`). Static routes return an empty list.
426
+ *
427
+ * Only the pattern shapes emitted by `bracket_to_hono` (zfb-build) are
428
+ * recognised — `:name`, `:name{.+}`, `:name{.+}?` — which is the entire
429
+ * grammar the file router produces.
430
+ */
431
+ function routeParamSpecs(route) {
432
+ const specs = [];
433
+ for (const seg of route.split("/")) {
434
+ if (!seg.startsWith(":"))
435
+ continue;
436
+ const optionalCatchall = seg.endsWith("?");
437
+ const body = optionalCatchall ? seg.slice(1, -1) : seg.slice(1);
438
+ const braceIdx = body.indexOf("{");
439
+ const name = braceIdx === -1 ? body : body.slice(0, braceIdx);
440
+ if (name.length > 0) {
441
+ specs.push({ name, optionalCatchall });
442
+ }
443
+ }
444
+ return specs;
445
+ }
320
446
  /**
321
447
  * Heuristic check for user-authored routes that may shadow the
322
448
  * synthetic `/__paths__/<encoded-route-key>` endpoint.
323
449
  *
324
450
  * Returns true when the route literal contains `/__paths__` (an
325
451
  * obvious collision) or when its first segment is a Hono catchall
326
- * (`:name{.+}`) or a file-system catchall (`[...name]`) at the root —
327
- * those would match `/__paths__/...` once Hono dispatches to them.
452
+ * (`:name{.+}`, optionally `:name{.+}?`) or a file-system catchall
453
+ * (`[...name]` / `[[...name]]`) at the root those would match
454
+ * `/__paths__/...` once Hono dispatches to them.
328
455
  */
329
456
  function routeShadowsPathsEndpoint(route) {
330
457
  if (route.includes("/__paths__")) {
@@ -334,14 +461,17 @@ function routeShadowsPathsEndpoint(route) {
334
461
  // anything deeper cannot match `/__paths__` because the literal
335
462
  // first segment differs.
336
463
  const firstSeg = route.replace(/^\/+/, "").split("/")[0] ?? "";
337
- // Hono regex-quantifier catchall: `:name{.+}`.
338
- if (/^:[A-Za-z_][\w]*\{\.[+*]\}$/.test(firstSeg)) {
464
+ // Hono regex-quantifier catchall: `:name{.+}` / optional `:name{.+}?`.
465
+ if (/^:[A-Za-z_][\w]*\{\.[+*]\}\??$/.test(firstSeg)) {
339
466
  return true;
340
467
  }
341
- // File-system catchall (pre-bracket-to-hono): `[...name]`.
468
+ // File-system catchall (pre-bracket-to-hono): `[...name]` / `[[...name]]`.
342
469
  if (/^\[\.\.\.[A-Za-z_][\w]*\]$/.test(firstSeg)) {
343
470
  return true;
344
471
  }
472
+ if (/^\[\[\.\.\.[A-Za-z_][\w]*\]\]$/.test(firstSeg)) {
473
+ return true;
474
+ }
345
475
  return false;
346
476
  }
347
477
  //# sourceMappingURL=router.js.map