@nifrajs/web 2.1.0 → 2.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 (194) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +68 -0
  3. package/dist/build-vite.d.ts.map +1 -0
  4. package/dist/build-vite.js +433 -0
  5. package/dist/build-vite.js.map +1 -0
  6. package/dist/build.d.ts +174 -65
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +376 -135
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +83 -0
  11. package/dist/bun-dev-entry.d.ts.map +1 -0
  12. package/dist/bun-dev-entry.js +136 -0
  13. package/dist/bun-dev-entry.js.map +1 -0
  14. package/dist/chunk-names.js +1 -1
  15. package/dist/client.d.ts +3 -9
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +209 -27
  18. package/dist/client.js.map +1 -1
  19. package/dist/conformance.d.ts +2 -2
  20. package/dist/conformance.js +3 -3
  21. package/dist/deferred.d.ts +11 -11
  22. package/dist/deferred.d.ts.map +1 -1
  23. package/dist/deferred.js +16 -16
  24. package/dist/dev-error.d.ts +2 -2
  25. package/dist/dev-error.js +3 -3
  26. package/dist/dev-port.d.ts +36 -0
  27. package/dist/dev-port.d.ts.map +1 -0
  28. package/dist/dev-port.js +48 -0
  29. package/dist/dev-port.js.map +1 -0
  30. package/dist/dev.d.ts +62 -5
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/dev.js +419 -60
  33. package/dist/dev.js.map +1 -1
  34. package/dist/draft.d.ts +44 -7
  35. package/dist/draft.d.ts.map +1 -1
  36. package/dist/draft.js +92 -9
  37. package/dist/draft.js.map +1 -1
  38. package/dist/fn-state.d.ts +56 -0
  39. package/dist/fn-state.d.ts.map +1 -0
  40. package/dist/fn-state.js +77 -0
  41. package/dist/fn-state.js.map +1 -0
  42. package/dist/fn.d.ts +111 -0
  43. package/dist/fn.d.ts.map +1 -0
  44. package/dist/fn.js +125 -0
  45. package/dist/fn.js.map +1 -0
  46. package/dist/fonts-google.d.ts +9 -9
  47. package/dist/fonts-google.js +10 -10
  48. package/dist/fonts.d.ts +7 -7
  49. package/dist/fonts.js +1 -1
  50. package/dist/forms.d.ts +8 -8
  51. package/dist/fs.d.ts +15 -2
  52. package/dist/fs.d.ts.map +1 -1
  53. package/dist/fs.js +8 -2
  54. package/dist/fs.js.map +1 -1
  55. package/dist/index.d.ts +266 -74
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +869 -179
  58. package/dist/index.js.map +1 -1
  59. package/dist/internal/head-attributes.d.ts +15 -0
  60. package/dist/internal/head-attributes.d.ts.map +1 -0
  61. package/dist/internal/head-attributes.js +99 -0
  62. package/dist/internal/head-attributes.js.map +1 -0
  63. package/dist/internal/script-types.d.ts +14 -0
  64. package/dist/internal/script-types.d.ts.map +1 -0
  65. package/dist/internal/script-types.js +17 -0
  66. package/dist/internal/script-types.js.map +1 -0
  67. package/dist/internal/server-boundary.d.ts +60 -0
  68. package/dist/internal/server-boundary.d.ts.map +1 -0
  69. package/dist/internal/server-boundary.js +174 -0
  70. package/dist/internal/server-boundary.js.map +1 -0
  71. package/dist/internal/timing-safe-equal.d.ts +16 -0
  72. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  73. package/dist/internal/timing-safe-equal.js +26 -0
  74. package/dist/internal/timing-safe-equal.js.map +1 -0
  75. package/dist/internal/vite-import.d.ts +63 -0
  76. package/dist/internal/vite-import.d.ts.map +1 -0
  77. package/dist/internal/vite-import.js +94 -0
  78. package/dist/internal/vite-import.js.map +1 -0
  79. package/dist/islands.d.ts +5 -5
  80. package/dist/islands.js +5 -5
  81. package/dist/isr.d.ts +15 -15
  82. package/dist/isr.d.ts.map +1 -1
  83. package/dist/isr.js +8 -21
  84. package/dist/isr.js.map +1 -1
  85. package/dist/llms-txt.d.ts.map +1 -1
  86. package/dist/llms-txt.js +12 -6
  87. package/dist/llms-txt.js.map +1 -1
  88. package/dist/manifest.d.ts +121 -40
  89. package/dist/manifest.d.ts.map +1 -1
  90. package/dist/manifest.js +136 -26
  91. package/dist/manifest.js.map +1 -1
  92. package/dist/module-graph.d.ts +91 -0
  93. package/dist/module-graph.d.ts.map +1 -0
  94. package/dist/module-graph.js +92 -0
  95. package/dist/module-graph.js.map +1 -0
  96. package/dist/navigation.d.ts +120 -5
  97. package/dist/navigation.d.ts.map +1 -1
  98. package/dist/navigation.js +37 -5
  99. package/dist/navigation.js.map +1 -1
  100. package/dist/plugins/css-modules.d.ts +3 -3
  101. package/dist/plugins/css-modules.d.ts.map +1 -1
  102. package/dist/plugins/css-modules.js +0 -0
  103. package/dist/plugins/css-modules.js.map +1 -1
  104. package/dist/plugins/kit.d.ts +8 -8
  105. package/dist/plugins/kit.js +9 -9
  106. package/dist/plugins/scss.js +3 -3
  107. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  108. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  109. package/dist/plugins/vite-leak-guard.js +67 -0
  110. package/dist/plugins/vite-leak-guard.js.map +1 -0
  111. package/dist/plugins/vite-server-fn.d.ts +23 -0
  112. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  113. package/dist/plugins/vite-server-fn.js +51 -0
  114. package/dist/plugins/vite-server-fn.js.map +1 -0
  115. package/dist/plugins/vite-server-only.d.ts +49 -0
  116. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  117. package/dist/plugins/vite-server-only.js +57 -0
  118. package/dist/plugins/vite-server-only.js.map +1 -0
  119. package/dist/prerender.d.ts +5 -5
  120. package/dist/prerender.js +7 -7
  121. package/dist/public-dir.d.ts +36 -0
  122. package/dist/public-dir.d.ts.map +1 -0
  123. package/dist/public-dir.js +89 -0
  124. package/dist/public-dir.js.map +1 -0
  125. package/dist/query.d.ts +10 -10
  126. package/dist/query.js +15 -15
  127. package/dist/route-manifest.d.ts +91 -0
  128. package/dist/route-manifest.d.ts.map +1 -0
  129. package/dist/route-manifest.js +128 -0
  130. package/dist/route-manifest.js.map +1 -0
  131. package/dist/router.d.ts +94 -22
  132. package/dist/router.d.ts.map +1 -1
  133. package/dist/router.js +170 -58
  134. package/dist/router.js.map +1 -1
  135. package/dist/search.d.ts +94 -0
  136. package/dist/search.d.ts.map +1 -0
  137. package/dist/search.js +232 -0
  138. package/dist/search.js.map +1 -0
  139. package/dist/server-only.d.ts +6 -6
  140. package/dist/server-only.js +7 -7
  141. package/dist/service-worker.d.ts +56 -0
  142. package/dist/service-worker.d.ts.map +1 -0
  143. package/dist/service-worker.js +127 -0
  144. package/dist/service-worker.js.map +1 -0
  145. package/dist/vite.d.ts +25 -17
  146. package/dist/vite.d.ts.map +1 -1
  147. package/dist/vite.js +75 -33
  148. package/dist/vite.js.map +1 -1
  149. package/package.json +48 -4
  150. package/server-manifest.d.ts +31 -0
  151. package/src/build-vite.ts +553 -0
  152. package/src/build.ts +563 -172
  153. package/src/bun-dev-entry.ts +184 -0
  154. package/src/chunk-names.ts +1 -1
  155. package/src/client.ts +221 -30
  156. package/src/conformance.ts +3 -3
  157. package/src/deferred.ts +17 -17
  158. package/src/dev-error.ts +3 -3
  159. package/src/dev-port.ts +63 -0
  160. package/src/dev.ts +468 -66
  161. package/src/draft.ts +124 -12
  162. package/src/fn-state.ts +107 -0
  163. package/src/fn.ts +199 -0
  164. package/src/fonts-google.ts +13 -13
  165. package/src/fonts.ts +7 -7
  166. package/src/forms.ts +8 -8
  167. package/src/fs.ts +24 -6
  168. package/src/index.ts +1174 -225
  169. package/src/internal/head-attributes.ts +109 -0
  170. package/src/internal/script-types.ts +18 -0
  171. package/src/internal/server-boundary.ts +187 -0
  172. package/src/internal/timing-safe-equal.ts +24 -0
  173. package/src/internal/vite-import.ts +92 -0
  174. package/src/islands.ts +7 -7
  175. package/src/isr.ts +18 -30
  176. package/src/llms-txt.ts +13 -5
  177. package/src/manifest.ts +264 -48
  178. package/src/module-graph.ts +146 -0
  179. package/src/navigation.ts +151 -5
  180. package/src/plugins/css-modules.ts +0 -0
  181. package/src/plugins/kit.ts +11 -11
  182. package/src/plugins/scss.ts +3 -3
  183. package/src/plugins/vite-leak-guard.ts +110 -0
  184. package/src/plugins/vite-server-fn.ts +67 -0
  185. package/src/plugins/vite-server-only.ts +75 -0
  186. package/src/prerender.ts +10 -10
  187. package/src/public-dir.ts +108 -0
  188. package/src/query.ts +22 -22
  189. package/src/route-manifest.ts +240 -0
  190. package/src/router.ts +288 -74
  191. package/src/search.ts +297 -0
  192. package/src/server-only.ts +7 -7
  193. package/src/service-worker.ts +165 -0
  194. package/src/vite.ts +103 -49
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `@nifrajs/web/bun-dev-entry` - discovering the client-entry URL Bun's dev server assigns.
3
+ *
4
+ * ## Why this file exists at all
5
+ *
6
+ * Bun's dev server bundles an HTML route and rewrites its `<script src="./entry.tsx">` into a
7
+ * content-hashed URL under `/_bun/client/`. That rewrite is the whole point: the hash changes per build,
8
+ * so the URL cannot be predicted or hard-coded.
9
+ *
10
+ * nifra needs that URL, because nifra renders the document. Bun never sees the page it would inject the
11
+ * script into - `createWebApp` builds the HTML from the route's own output and needs `clientEntry` to
12
+ * emit `<script type="module" src=…>` and the matching `modulepreload`. So the two halves have to meet:
13
+ * Bun knows the URL, nifra needs it, and there is no supported API connecting them. Verified against Bun
14
+ * 1.3.14: the HTML import evaluates to `{}` with no keys, the `Bun.serve` instance exposes nothing
15
+ * asset-related, and `/_bun/manifest`, `/_bun/client/manifest.json` and `/_bun/assets` all 404.
16
+ *
17
+ * The only route is to ask the dev server for a page it *did* bundle and read the URL back out of it.
18
+ *
19
+ * ## Why it is a module instead of four lines inline
20
+ *
21
+ * A regex over another tool's output is a dependency on an unspecified format, and that is true whether
22
+ * it lives here or inline in the dev server. What changes is the blast radius. Isolated here it is one
23
+ * function with one caller, covered by tests that pin the exact tag shape Bun emits today - so a Bun
24
+ * upgrade that changes the format fails a test with a diff of the markup instead of producing a dev
25
+ * server that boots fine and serves pages whose scripts 404. And if Bun ever exposes a real accessor,
26
+ * this is the only body that changes.
27
+ *
28
+ * Detection is layered so a cosmetic change degrades instead of breaking: the `data-bun-dev-server-script`
29
+ * attribute is the precise signal, an unambiguous single module script under `/_bun/` is the fallback,
30
+ * and anything else is a named error that says what was searched for and what was found.
31
+ */
32
+
33
+ /** One `<script>` tag lifted out of a served page: its raw attribute text and parsed `src`. */
34
+ interface ScriptTag {
35
+ readonly attrs: string
36
+ readonly src: string | undefined
37
+ }
38
+
39
+ // `<script …>` open tags. Attribute text is captured raw; `src` is parsed out of it separately so the
40
+ // two are independent of attribute ORDER (Bun emits `crossorigin src=… data-bun-dev-server-script`, but
41
+ // nothing in HTML makes that order stable).
42
+ const SCRIPT_OPEN = /<script\b([^>]*)>/gi
43
+ const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
44
+ // The attribute Bun marks its own bundled entry with. Valueless today; matched tolerantly so a future
45
+ // `data-bun-dev-server-script="1"` still hits.
46
+ const MARKER_ATTR = /\bdata-bun-dev-server-script\b/i
47
+ // Bun serves every bundled client asset under this prefix.
48
+ const BUN_CLIENT_PREFIX = "/_bun/"
49
+
50
+ /** Pull every `<script>` open tag out of an HTML document, in source order. */
51
+ function scriptTags(html: string): ScriptTag[] {
52
+ const tags: ScriptTag[] = []
53
+ SCRIPT_OPEN.lastIndex = 0
54
+ for (let m = SCRIPT_OPEN.exec(html); m !== null; m = SCRIPT_OPEN.exec(html)) {
55
+ const attrs = m[1] ?? ""
56
+ const src = SRC_ATTR.exec(attrs)
57
+ tags.push({ attrs, src: src?.[1] ?? src?.[2] ?? src?.[3] })
58
+ }
59
+ return tags
60
+ }
61
+
62
+ /** How the entry URL was found - reported so a fallback hit is visible rather than silent. */
63
+ export type DevEntryVia = "marker" | "single-bun-script"
64
+
65
+ /** A located client entry: the URL to hand nifra, its stylesheets, and which signal found it. */
66
+ export interface DevEntryMatch {
67
+ readonly src: string
68
+ /**
69
+ * Stylesheet URLs Bun extracted from the entry's CSS imports.
70
+ *
71
+ * These are not a nicety. Bun's bundler pulls `import "./app.css"` out of the JS graph and links it
72
+ * from the HTML *it* bundled - the throwaway probe page, which nobody ever sees. nifra renders the real
73
+ * pages, so unless these are carried across, a Bun-pipeline dev session serves every page unstyled
74
+ * while the production build (which reads CSS from the build manifest) is fine. That is the worst
75
+ * possible shape for a bug: dev-only, silent, and looks like the app's own CSS is broken.
76
+ */
77
+ readonly styles: readonly string[]
78
+ readonly via: DevEntryVia
79
+ }
80
+
81
+ // `<link rel="stylesheet" href="…">`. `rel` and `href` are matched independently of order, same reason
82
+ // as the script tags: attribute order in generated markup is not a contract.
83
+ const LINK_OPEN = /<link\b([^>]*)>/gi
84
+ const HREF_ATTR = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
85
+ const REL_STYLESHEET =
86
+ /\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|stylesheet)/i
87
+
88
+ /** Every stylesheet URL linked from a document, in source order. */
89
+ export function parseDevStyles(html: string): string[] {
90
+ const hrefs: string[] = []
91
+ LINK_OPEN.lastIndex = 0
92
+ for (let m = LINK_OPEN.exec(html); m !== null; m = LINK_OPEN.exec(html)) {
93
+ const attrs = m[1] ?? ""
94
+ if (!REL_STYLESHEET.test(attrs)) continue
95
+ const href = HREF_ATTR.exec(attrs)
96
+ const url = href?.[1] ?? href?.[2] ?? href?.[3]
97
+ if (url !== undefined && url !== "") hrefs.push(url)
98
+ }
99
+ return hrefs
100
+ }
101
+
102
+ /**
103
+ * Find the bundled client-entry URL in a page Bun's dev server rendered. Pure - the whole format
104
+ * dependency lives in this function, which is what makes it testable against captured real output.
105
+ *
106
+ * Returns `undefined` rather than throwing so the caller can attach the context a useful error needs
107
+ * (which URL was probed, what the server returned).
108
+ */
109
+ export function parseDevEntry(html: string): DevEntryMatch | undefined {
110
+ const tags = scriptTags(html)
111
+ const styles = parseDevStyles(html)
112
+ // 1. The precise signal: Bun's own marker attribute.
113
+ for (const tag of tags) {
114
+ if (MARKER_ATTR.test(tag.attrs) && tag.src !== undefined && tag.src !== "") {
115
+ return { src: tag.src, styles, via: "marker" }
116
+ }
117
+ }
118
+ // 2. Fallback: exactly one script pointing into Bun's client-asset prefix. Requiring uniqueness is what
119
+ // keeps this a fallback and not a guess - with two candidates there is no basis for picking one, so
120
+ // it declines and lets the caller raise a real error.
121
+ const bunScripts = tags.filter((t) => t.src?.startsWith(BUN_CLIENT_PREFIX))
122
+ const only = bunScripts.length === 1 ? bunScripts[0] : undefined
123
+ if (only?.src !== undefined) return { src: only.src, styles, via: "single-bun-script" }
124
+ return undefined
125
+ }
126
+
127
+ /** The error thrown when Bun's dev server serves a page nothing in it can be recognised as the entry. */
128
+ export function devEntryNotFoundMessage(probeUrl: string, html: string): string {
129
+ const scripts = scriptTags(html)
130
+ const found =
131
+ scripts.length === 0
132
+ ? "the page contains no <script> tags at all"
133
+ : `the page's script tags are: ${scripts.map((s) => `<script${s.attrs}>`).join(", ")}`
134
+ return (
135
+ `[nifra] could not find Bun's bundled client entry in the page served at ${probeUrl}.\n` +
136
+ ` Looked for a <script> carrying \`data-bun-dev-server-script\`, then for a single module script ` +
137
+ `under \`${BUN_CLIENT_PREFIX}\`; ${found}.\n` +
138
+ ` This is how Bun's dev server labelled its bundled entry when this was written, so a Bun upgrade ` +
139
+ `that changes the markup lands here first. Run the Bun pipeline dev server's tests to see the ` +
140
+ `current shape, or use the Vite pipeline (\`nifra dev\`) meanwhile.`
141
+ )
142
+ }
143
+
144
+ /** Structural slice of a running server this needs: just the port it bound. */
145
+ export interface ServedOn {
146
+ readonly port: number
147
+ }
148
+
149
+ export interface ResolveDevEntryOptions {
150
+ /** Path of the throwaway route serving Bun's bundled HTML (e.g. `/__nifra/entry`). */
151
+ readonly probePath: string
152
+ /** Injected for tests; defaults to global `fetch`. */
153
+ readonly fetchImpl?: typeof fetch
154
+ }
155
+
156
+ /**
157
+ * Ask Bun's dev server for the current bundled entry URL. One loopback request against a route the app
158
+ * never exposes.
159
+ *
160
+ * **The answer expires.** The URL is a content hash over the whole client graph, so editing any file it
161
+ * reaches - a route, a component, a stylesheet - re-hashes it. Caching the startup value and injecting it
162
+ * forever produces a spectacular failure: Bun answers a superseded chunk URL with a 104-byte
163
+ * `location.reload()` stub under a 405, so the page reloads, gets the same dead URL from SSR, and reloads
164
+ * again, forever. No console output survives the loop to explain it. Callers must re-resolve rather than
165
+ * remember - see how `@nifrajs/web/dev` serves a stable URL that redirects here per request.
166
+ */
167
+ export async function resolveDevEntry(
168
+ server: ServedOn,
169
+ options: ResolveDevEntryOptions,
170
+ ): Promise<DevEntryMatch> {
171
+ const doFetch = options.fetchImpl ?? fetch
172
+ const probeUrl = `http://127.0.0.1:${server.port}${options.probePath}`
173
+ const response = await doFetch(probeUrl)
174
+ if (!response.ok) {
175
+ throw new Error(
176
+ `[nifra] Bun's dev server returned ${response.status} for the client-entry probe at ${probeUrl}. ` +
177
+ "The generated HTML route did not build - the error above this line is Bun's.",
178
+ )
179
+ }
180
+ const html = await response.text()
181
+ const match = parseDevEntry(html)
182
+ if (match === undefined) throw new Error(devEntryNotFoundMessage(probeUrl, html))
183
+ return match
184
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * URL-safe output chunk naming. Bun names a code-split chunk after its source file's basename, so a
3
- * dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js` — the `[ ]` make a URL a static server
3
+ * dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js` - the `[ ]` make a URL a static server
4
4
  * (server-bun, CF Pages) reject with 400, so the lazy import fails and the route silently never
5
5
  * hydrates (SSR looks perfect; all interactivity is dead). `sanitizeOutputNames` renames every output
6
6
  * whose basename isn't URL-path-safe (`[slug]` → `_slug_`) and rewrites the references to it inside the
package/src/client.ts CHANGED
@@ -1,11 +1,23 @@
1
1
  /**
2
- * `@nifrajs/web/client` — the agnostic browser layer for client-side navigation. It wires the pure
2
+ * `@nifrajs/web/client` - the agnostic browser layer for client-side navigation. It wires the pure
3
3
  * router store ({@link ClientRouter}) to the browser: `pushState` on navigate, `popstate` →
4
4
  * navigate, and delegated interception of same-origin `<a>` clicks (client transition instead of
5
- * a full page load). DOM-only — never imported on the server, so the store stays SSR-safe.
5
+ * a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
6
6
  */
7
+ import { trustedHeadAttributes } from "./internal/head-attributes.ts"
8
+ import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
7
9
  import type { Meta } from "./manifest.ts"
8
- import { type BrowserNavigate, setBrowserNavigate } from "./navigation.ts"
10
+ import {
11
+ type Blocker,
12
+ type BlockerController,
13
+ type BlockerFunction,
14
+ type BlockerLocation,
15
+ type BlockerState,
16
+ type BrowserNavigate,
17
+ IDLE_BLOCKER,
18
+ setBlockerController,
19
+ setBrowserNavigate,
20
+ } from "./navigation.ts"
9
21
  import type { ClientRouter } from "./router.ts"
10
22
 
11
23
  export interface InstallHistoryOptions {
@@ -31,6 +43,22 @@ export function installHistory(
31
43
  // fragment target (`#id`) to scroll to once the cross-page navigation's content renders.
32
44
  type PendingScroll = { readonly pos: readonly [number, number] } | { readonly hash: string }
33
45
  let pendingScroll: PendingScroll | null = null
46
+
47
+ // Navigation-guard state (useBlocker). `index` is our position in the history line, stored in
48
+ // `history.state.nifraIndex`; it exists solely to reverse a blocked back/forward (a popstate can't be
49
+ // cancelled, so we read the delta from the index and `history.go(-delta)` back). `here` is the location
50
+ // we're currently on - after a popstate `location` is already the destination, so "where we were" has
51
+ // to be tracked, not read. `reversing` swallows the popstate our own restoring `history.go` fires.
52
+ let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
53
+ let here = location.pathname + location.search + (location.hash ?? "")
54
+ let reversing = false
55
+ type Registration = {
56
+ readonly shouldBlock: BlockerFunction
57
+ readonly emit: (blocker: Blocker) => void
58
+ state: BlockerState
59
+ }
60
+ // A single active guard (like react-router, one blocker at a time); the latest registration wins.
61
+ let registration: Registration | undefined
34
62
  const scrollOf = (state: unknown): [number, number] => {
35
63
  const saved = (state as { nifraScroll?: [number, number] } | null)?.nifraScroll
36
64
  return Array.isArray(saved) ? saved : [0, 0]
@@ -50,7 +78,7 @@ export function installHistory(
50
78
  return document.getElementById(id)
51
79
  }
52
80
 
53
- // Wrap a navigation's render in a View Transition when the browser supports it — a graceful
81
+ // Wrap a navigation's render in a View Transition when the browser supports it - a graceful
54
82
  // enhancement (no-op elsewhere). The captured "before" is held across the data fetch, so pair
55
83
  // with link prefetch (hover/focus warms the cache) to keep most transitions instant.
56
84
  type ViewTransition = {
@@ -69,35 +97,112 @@ export function installHistory(
69
97
  }
70
98
  const vt = doc.startViewTransition(run)
71
99
  // Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
72
- // promises. Reasons vary by engine — InvalidStateError "Transition was aborted because of
100
+ // promises. Reasons vary by engine - InvalidStateError "Transition was aborted because of
73
101
  // invalid state", or AbortError "Transition was skipped". The navigation still completes, so
74
102
  // we swallow these expected aborts rather than let them surface as unhandled rejections.
75
103
  for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
76
104
  }
77
105
 
78
- const go = (path: string, mode: "push" | "replace"): void => {
106
+ // Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
107
+ const locationOf = (path: string): BlockerLocation => {
79
108
  const url = new URL(path, location.origin)
109
+ return { pathname: url.pathname, search: url.search, hash: url.hash }
110
+ }
111
+
112
+ // The single funnel every navigation asks before it commits. Returns true (navigation held) when a
113
+ // guard is armed and blocks; false when it may proceed. `retry` performs the navigation and is what
114
+ // `proceed` replays. While a prompt is already open (`blocked`) further navigations are swallowed; the
115
+ // replay from `proceed` (`proceeding`) is let straight through.
116
+ const guard = (nextPath: string, retry: () => void): boolean => {
117
+ const reg = registration
118
+ if (reg === undefined || reg.state === "proceeding") return false
119
+ if (reg.state === "blocked") return true
120
+ if (!reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(nextPath) }))
121
+ return false
122
+ reg.state = "blocked"
123
+ reg.emit({
124
+ state: "blocked",
125
+ proceed: () => {
126
+ if (reg.state !== "blocked") return
127
+ reg.state = "proceeding"
128
+ reg.emit({ state: "proceeding", proceed: undefined, reset: undefined })
129
+ retry()
130
+ },
131
+ reset: () => {
132
+ if (reg.state !== "blocked") return
133
+ reg.state = "unblocked"
134
+ reg.emit(IDLE_BLOCKER)
135
+ },
136
+ })
137
+ return true
138
+ }
139
+
140
+ // Once a proceeded navigation has committed, return the guard to idle so it can block the next one.
141
+ const settle = (): void => {
142
+ const reg = registration
143
+ if (reg !== undefined && reg.state === "proceeding") {
144
+ reg.state = "unblocked"
145
+ reg.emit(IDLE_BLOCKER)
146
+ }
147
+ }
148
+
149
+ // Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
150
+ // becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
151
+ // router intentionally kept rendering the old route.
152
+ const parseNavigationUrl = (path: string): URL | null => {
153
+ try {
154
+ const url = new URL(path, location.origin)
155
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null
156
+ if (url.origin !== location.origin) return null
157
+ return url
158
+ } catch {
159
+ return null
160
+ }
161
+ }
162
+
163
+ const commit = (path: string, url: URL, mode: "push" | "replace"): void => {
164
+ const routePath = url.pathname + url.search
165
+ if (router.match(routePath) === null) {
166
+ settle()
167
+ fallback(path)
168
+ return
169
+ }
80
170
  if (mode === "replace") {
81
- // Replace the current entry with the new URL. The entry we're leaving is discarded, so its scroll
82
- // isn't worth saving; the new route scrolls to its fragment target, else the top.
83
- history.replaceState({}, "", path)
171
+ // Replace the current entry (same index) with the new URL. The entry we're leaving is discarded, so
172
+ // its scroll isn't worth saving; the new route scrolls to its fragment target, else the top.
173
+ history.replaceState({ nifraIndex: index }, "", path)
84
174
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
85
175
  } else {
86
- // Save the leaving entry's scroll, push a fresh entry (URL incl. any #hash), then scroll the new
87
- // route: to the fragment target if one was given, else to the top.
176
+ // Save the leaving entry's scroll (spread keeps its nifraIndex), push a fresh higher-indexed entry
177
+ // (URL incl. any #hash), then scroll the new route: to the fragment target if given, else the top.
88
178
  history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
89
- history.pushState({}, "", path)
179
+ index += 1
180
+ history.pushState({ nifraIndex: index }, "", path)
90
181
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
91
182
  }
183
+ here = url.pathname + url.search + url.hash
92
184
  // The data layer fetches by path+search; the #hash is client-only (never sent to the server).
93
- transition(() => router.navigate(url.pathname + url.search).catch(() => fallback(path)))
185
+ transition(() => router.navigate(routePath).catch(() => fallback(path)))
186
+ settle()
187
+ }
188
+
189
+ const go = (path: string, mode: "push" | "replace"): void => {
190
+ // Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
191
+ // malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
192
+ const url = parseNavigationUrl(path)
193
+ if (url === null) {
194
+ settle()
195
+ return
196
+ }
197
+ if (guard(path, () => commit(path, url, mode))) return
198
+ commit(path, url, mode)
94
199
  }
95
200
 
96
201
  // Programmatic navigation for adapter `useNavigate` bindings, published through the DOM-free bridge
97
202
  // (`@nifrajs/web`'s `getBrowserNavigate`). A string path pushes (or replaces); a number is a history
98
- // delta (`-1` back / `1` forward), matching `history.go`. An off-route / cross-origin push still goes
99
- // through `go`, whose `router.navigate` no-ops an unmatched path and whose fetch-failure falls back to
100
- // a full load — same graceful degradation as a link click.
203
+ // delta (`-1` back / `1` forward), matching `history.go`. Off-route same-origin destinations hard-load
204
+ // without first creating a stale in-document history entry. Cross-origin and active-scheme strings are
205
+ // rejected; callers that intentionally leave the app use a normal `<a>`.
101
206
  const navigate: BrowserNavigate = (to, navOptions) => {
102
207
  if (typeof to === "number") {
103
208
  history.go(to)
@@ -107,6 +212,20 @@ export function installHistory(
107
212
  }
108
213
  setBrowserNavigate(navigate)
109
214
 
215
+ // Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
216
+ // adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
217
+ // it's still the current one (so a stale unmount doesn't wipe a newer guard).
218
+ const controller: BlockerController = {
219
+ register(shouldBlock, onChange) {
220
+ const reg: Registration = { shouldBlock, emit: onChange, state: "unblocked" }
221
+ registration = reg
222
+ return () => {
223
+ if (registration === reg) registration = undefined
224
+ }
225
+ },
226
+ }
227
+ setBlockerController(controller)
228
+
110
229
  // Resolve an event target to an in-app route path, or null (cross-origin, unknown route, or an
111
230
  // anchor opting out via target/download/rel=external). Shared by click + hover/focus prefetch.
112
231
  const inAppHref = (target: EventTarget | null): string | null => {
@@ -118,9 +237,9 @@ export function installHistory(
118
237
  const url = new URL(anchor.href)
119
238
  if (url.origin !== location.origin) return null
120
239
  if (router.match(url.pathname) === null) return null
121
- // A same-page fragment link (`#section`, `/here#section` — only the hash differs) → null: let the
240
+ // A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
122
241
  // browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
123
- // and force a scroll-to-top (a fresh push restores [0,0]) — i.e. break every in-page anchor (AUDIT
242
+ // and force a scroll-to-top (a fresh push restores [0,0]) - i.e. break every in-page anchor (AUDIT
124
243
  // H2). (A same-page link with NO hash still soft-navigates, as before.)
125
244
  if (url.pathname === location.pathname && url.search === location.search && url.hash !== "")
126
245
  return null
@@ -146,12 +265,49 @@ export function installHistory(
146
265
  void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
147
266
  }
148
267
 
149
- // Back/forward: the entry exists (no push); restore its saved scroll after it renders.
268
+ // Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
269
+ // be cancelled - so a guard here can only restore-then-prompt: read the delta from our index, and if the
270
+ // guard holds, `history.go(-delta)` back to where we were (ignoring the popstate that reversal fires via
271
+ // `reversing`) while `proceed` re-issues `history.go(delta)` to redo the move. Unguarded, it's the old
272
+ // path: restore the entry's saved scroll after it renders.
150
273
  const onPopState = (): void => {
274
+ if (reversing) {
275
+ reversing = false
276
+ return
277
+ }
278
+ const newIndex = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
279
+ const dest = location.pathname + location.search + (location.hash ?? "")
280
+ const delta = newIndex - index
281
+ if (guard(dest, () => history.go(delta))) {
282
+ reversing = true
283
+ history.go(-delta)
284
+ return
285
+ }
286
+ index = newIndex
287
+ here = dest
151
288
  pendingScroll = { pos: scrollOf(history.state) }
152
289
  transition(() =>
153
290
  router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
154
291
  )
292
+ settle()
293
+ }
294
+
295
+ // Close / reload / hard navigation away: no in-app destination exists, so consult the guard with the
296
+ // current location as both sides. A dirtiness guard (a boolean, or one that checks its own state) then
297
+ // triggers the browser's native "Leave site?" prompt; a purely destination-based guard won't. Browsers
298
+ // ignore any custom message - the string is set only because legacy engines require `returnValue`.
299
+ const onBeforeUnload = (event: BeforeUnloadEvent): void => {
300
+ const reg = registration
301
+ if (reg === undefined || reg.state === "proceeding") return
302
+ // `blocked` already proves the guard found unsaved state. Re-check only while idle; suppressing the
303
+ // native prompt merely because an in-app confirmation is open creates a close/reload data-loss gap.
304
+ if (
305
+ reg.state === "unblocked" &&
306
+ !reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(here) })
307
+ )
308
+ return
309
+ event.preventDefault()
310
+ event.returnValue = ""
155
311
  }
156
312
 
157
313
  // After a navigation settles (content rendered), apply the pending scroll target on the next frame:
@@ -177,37 +333,49 @@ export function installHistory(
177
333
  document.addEventListener("pointerover", onPrefetch)
178
334
  document.addEventListener("focusin", onPrefetch)
179
335
  window.addEventListener("popstate", onPopState)
336
+ window.addEventListener("beforeunload", onBeforeUnload)
180
337
  return () => {
181
338
  unsubscribe()
182
339
  setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
340
+ setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
183
341
  document.removeEventListener("click", onClick)
184
342
  document.removeEventListener("pointerover", onPrefetch)
185
343
  document.removeEventListener("focusin", onPrefetch)
186
344
  window.removeEventListener("popstate", onPopState)
345
+ window.removeEventListener("beforeunload", onBeforeUnload)
187
346
  }
188
347
  }
189
348
 
190
349
  /**
191
350
  * Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
192
- * submit via the router (no full reload — POST the action, then revalidate the active loader). On
351
+ * submit via the router (no full reload - POST the action, then revalidate the active loader). On
193
352
  * failure it falls back to a native submit, so the form still works. Returns a teardown function.
194
353
  */
195
354
  /**
196
355
  * Sync the document head to a route's resolved {@link Meta} on client navigation. Sets the title
197
- * (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags — static head
356
+ * (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags - static head
198
357
  * content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
199
358
  * the first navigation cleanly takes over from the server-rendered head.
200
359
  */
201
360
  export function applyHead(head: Meta): void {
202
361
  if (head.title !== undefined) document.title = head.title
362
+ // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
363
+ // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
364
+ // removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
365
+ // `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
366
+ const root = document.documentElement
367
+ root.setAttribute("lang", head.lang ?? "en")
368
+ if (head.dir === undefined) root.removeAttribute("dir")
369
+ else root.setAttribute("dir", head.dir)
203
370
  for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
204
371
  // Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
205
- // `true` sets the bare boolean attribute, `false`/`undefined` skip it — so a soft-nav head matches
372
+ // `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches
206
373
  // the server-rendered one exactly (no hydration drift on the managed tags).
207
- const add = (tag: "meta" | "link", attrs: Record<string, string | boolean | undefined>): void => {
374
+ const add = (tag: "meta" | "link", attrs: Readonly<object>): void => {
375
+ const trusted = trustedHeadAttributes(tag, attrs)
376
+ if (trusted === null) return
208
377
  const el = document.createElement(tag)
209
- for (const [name, value] of Object.entries(attrs)) {
210
- if (value === undefined || value === false) continue
378
+ for (const [name, value] of trusted) {
211
379
  el.setAttribute(name, value === true ? "" : value)
212
380
  }
213
381
  el.setAttribute("data-nifra", "")
@@ -215,13 +383,36 @@ export function applyHead(head: Meta): void {
215
383
  }
216
384
  for (const m of head.meta ?? []) add("meta", m)
217
385
  for (const l of head.link ?? []) add("link", l)
218
- // `<script>` slot (JSON-LD etc.) — soft-nav parity with the SSR `headTags` script render. Set the body
386
+ // `<script>` slot (JSON-LD etc.) - soft-nav parity with the SSR `headTags` script render. Set the body
219
387
  // via `textContent`, NOT `innerHTML`: `textContent` assigns the raw string without HTML re-parsing, so
220
- // a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out — the DOM-native equivalent of
388
+ // a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out - the DOM-native equivalent of
221
389
  // the server's `escapeScriptContent`. `type` defaults to `application/ld+json` (matching the SSR side).
222
390
  for (const s of head.script ?? []) {
391
+ const type = s.type ?? "application/ld+json"
392
+ if (!INERT_SCRIPT_TYPES.has(type)) {
393
+ throw new TypeError("[nifra/web] refusing executable content in the inert head.script slot")
394
+ }
395
+ const el = document.createElement("script")
396
+ el.setAttribute("type", type)
397
+ el.textContent = s.content
398
+ el.setAttribute("data-nifra", "")
399
+ document.head.appendChild(el)
400
+ }
401
+ for (const s of head.unsafeScript ?? []) {
402
+ if (s.unsafe !== true || s.nonce.trim() === "") {
403
+ throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
404
+ }
405
+ // Same allowlist the server applies. `setAttribute` cannot be broken out of the way the server's
406
+ // template literal could, so this is not about injection here - it is about the two halves
407
+ // accepting the same documents, so a head that renders cannot fail on the next soft-nav.
408
+ if (!EXECUTABLE_SCRIPT_TYPES.has(s.type)) {
409
+ throw new TypeError(
410
+ `[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
411
+ )
412
+ }
223
413
  const el = document.createElement("script")
224
- el.setAttribute("type", s.type ?? "application/ld+json")
414
+ el.setAttribute("type", s.type)
415
+ el.setAttribute("nonce", s.nonce)
225
416
  el.textContent = s.content
226
417
  el.setAttribute("data-nifra", "")
227
418
  document.head.appendChild(el)
@@ -242,7 +433,7 @@ export function installForms(router: ClientRouter): () => void {
242
433
  // actionData drives the update); absent or any other value keeps the default revalidation.
243
434
  const revalidate = form.dataset.nifraRevalidate !== "false"
244
435
  router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
245
- form.submit() // data submit failed — fall back to a full-page POST
436
+ form.submit() // data submit failed - fall back to a full-page POST
246
437
  })
247
438
  }
248
439
  document.addEventListener("submit", onSubmit)
@@ -254,7 +445,7 @@ export function installForms(router: ClientRouter): () => void {
254
445
  * and fires a one-shot `nifra:hydrated` event. The generated client entry calls this on the next frame
255
446
  * after the adapter mounts (so every framework binding gets it), letting apps gate a custom JS-only
256
447
  * interaction that would otherwise fire before its handler is attached. Idempotent. nifra's own
257
- * progressive-enhancement forms/links don't need it — only hand-wired onClick/onSubmit handlers with no
448
+ * progressive-enhancement forms/links don't need it - only hand-wired onClick/onSubmit handlers with no
258
449
  * native fallback do. See the Hydration guide.
259
450
  */
260
451
  export function signalHydrated(): void {
@@ -93,8 +93,8 @@ const readHtml = async (
93
93
  * and hydration-head output is a stable string. It rejects with
94
94
  * {@link RenderAdapterConformanceError} naming the first failed check.
95
95
  *
96
- * Framework semantics outside the interface—Suspense scheduling, compiler plugins, DOM hydration,
97
- * error boundaries, and framework-specific primitives—remain the adapter package's local tests.
96
+ * Framework semantics outside the interface-Suspense scheduling, compiler plugins, DOM hydration,
97
+ * error boundaries, and framework-specific primitives-remain the adapter package's local tests.
98
98
  */
99
99
  export async function assertRenderAdapterConformance(
100
100
  adapter: RenderAdapter,
@@ -135,7 +135,7 @@ export async function assertRenderAdapterConformance(
135
135
  if (typeof buffered !== "string") fail("buffered render", "renderToString must return a string")
136
136
  const bufferedHtml = buffered as string
137
137
  if (fixture.deferred) {
138
- // Deferred content differs between streaming and buffered renders — only require every marker.
138
+ // Deferred content differs between streaming and buffered renders - only require every marker.
139
139
  for (const marker of [
140
140
  markers.outer,
141
141
  markers.inner,