@nifrajs/web 2.2.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 (163) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-error.d.ts +2 -2
  23. package/dist/dev-error.js +3 -3
  24. package/dist/dev-port.d.ts +2 -2
  25. package/dist/dev-port.js +1 -1
  26. package/dist/dev.d.ts +3 -3
  27. package/dist/dev.js +9 -9
  28. package/dist/draft.d.ts +12 -12
  29. package/dist/draft.js +11 -11
  30. package/dist/fn-state.d.ts +56 -0
  31. package/dist/fn-state.d.ts.map +1 -0
  32. package/dist/fn-state.js +77 -0
  33. package/dist/fn-state.js.map +1 -0
  34. package/dist/fn.d.ts +111 -0
  35. package/dist/fn.d.ts.map +1 -0
  36. package/dist/fn.js +125 -0
  37. package/dist/fn.js.map +1 -0
  38. package/dist/fonts-google.d.ts +9 -9
  39. package/dist/fonts-google.js +10 -10
  40. package/dist/fonts.d.ts +7 -7
  41. package/dist/fonts.js +1 -1
  42. package/dist/forms.d.ts +8 -8
  43. package/dist/fs.d.ts +1 -1
  44. package/dist/fs.js +1 -1
  45. package/dist/index.d.ts +151 -70
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +317 -139
  48. package/dist/index.js.map +1 -1
  49. package/dist/internal/head-attributes.d.ts +15 -0
  50. package/dist/internal/head-attributes.d.ts.map +1 -0
  51. package/dist/internal/head-attributes.js +99 -0
  52. package/dist/internal/head-attributes.js.map +1 -0
  53. package/dist/internal/script-types.d.ts +14 -0
  54. package/dist/internal/script-types.d.ts.map +1 -0
  55. package/dist/internal/script-types.js +17 -0
  56. package/dist/internal/script-types.js.map +1 -0
  57. package/dist/internal/server-boundary.d.ts +60 -0
  58. package/dist/internal/server-boundary.d.ts.map +1 -0
  59. package/dist/internal/server-boundary.js +174 -0
  60. package/dist/internal/server-boundary.js.map +1 -0
  61. package/dist/internal/timing-safe-equal.d.ts +1 -1
  62. package/dist/internal/timing-safe-equal.js +1 -1
  63. package/dist/internal/vite-import.d.ts +63 -0
  64. package/dist/internal/vite-import.d.ts.map +1 -0
  65. package/dist/internal/vite-import.js +94 -0
  66. package/dist/internal/vite-import.js.map +1 -0
  67. package/dist/islands.d.ts +5 -5
  68. package/dist/islands.js +5 -5
  69. package/dist/isr.d.ts +15 -15
  70. package/dist/isr.js +7 -7
  71. package/dist/manifest.d.ts +73 -35
  72. package/dist/manifest.d.ts.map +1 -1
  73. package/dist/manifest.js +10 -16
  74. package/dist/manifest.js.map +1 -1
  75. package/dist/module-graph.d.ts +5 -5
  76. package/dist/module-graph.js +4 -4
  77. package/dist/navigation.d.ts +120 -5
  78. package/dist/navigation.d.ts.map +1 -1
  79. package/dist/navigation.js +37 -5
  80. package/dist/navigation.js.map +1 -1
  81. package/dist/plugins/css-modules.d.ts +3 -3
  82. package/dist/plugins/css-modules.js +0 -0
  83. package/dist/plugins/kit.d.ts +8 -8
  84. package/dist/plugins/kit.js +9 -9
  85. package/dist/plugins/scss.js +3 -3
  86. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  87. package/dist/plugins/vite-leak-guard.js +1 -1
  88. package/dist/plugins/vite-server-fn.d.ts +23 -0
  89. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  90. package/dist/plugins/vite-server-fn.js +51 -0
  91. package/dist/plugins/vite-server-fn.js.map +1 -0
  92. package/dist/plugins/vite-server-only.d.ts +49 -0
  93. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  94. package/dist/plugins/vite-server-only.js +57 -0
  95. package/dist/plugins/vite-server-only.js.map +1 -0
  96. package/dist/prerender.d.ts +5 -5
  97. package/dist/prerender.js +7 -7
  98. package/dist/query.d.ts +10 -10
  99. package/dist/query.js +15 -15
  100. package/dist/route-manifest.d.ts +1 -1
  101. package/dist/route-manifest.js +2 -2
  102. package/dist/router.d.ts +30 -21
  103. package/dist/router.d.ts.map +1 -1
  104. package/dist/router.js +57 -28
  105. package/dist/router.js.map +1 -1
  106. package/dist/search.d.ts +94 -0
  107. package/dist/search.d.ts.map +1 -0
  108. package/dist/search.js +232 -0
  109. package/dist/search.js.map +1 -0
  110. package/dist/server-only.d.ts +6 -6
  111. package/dist/server-only.js +7 -7
  112. package/dist/service-worker.d.ts +56 -0
  113. package/dist/service-worker.d.ts.map +1 -0
  114. package/dist/service-worker.js +127 -0
  115. package/dist/service-worker.js.map +1 -0
  116. package/dist/vite.d.ts +15 -13
  117. package/dist/vite.d.ts.map +1 -1
  118. package/dist/vite.js +31 -21
  119. package/dist/vite.js.map +1 -1
  120. package/package.json +33 -4
  121. package/server-manifest.d.ts +31 -0
  122. package/src/build-vite.ts +24 -77
  123. package/src/build.ts +137 -105
  124. package/src/bun-dev-entry.ts +1 -1
  125. package/src/chunk-names.ts +1 -1
  126. package/src/client.ts +215 -32
  127. package/src/conformance.ts +3 -3
  128. package/src/deferred.ts +17 -17
  129. package/src/dev-error.ts +3 -3
  130. package/src/dev-port.ts +2 -2
  131. package/src/dev.ts +12 -12
  132. package/src/draft.ts +15 -15
  133. package/src/fn-state.ts +107 -0
  134. package/src/fn.ts +199 -0
  135. package/src/fonts-google.ts +13 -13
  136. package/src/fonts.ts +7 -7
  137. package/src/forms.ts +8 -8
  138. package/src/fs.ts +2 -2
  139. package/src/index.ts +460 -181
  140. package/src/internal/head-attributes.ts +109 -0
  141. package/src/internal/script-types.ts +18 -0
  142. package/src/internal/server-boundary.ts +187 -0
  143. package/src/internal/timing-safe-equal.ts +1 -1
  144. package/src/internal/vite-import.ts +92 -0
  145. package/src/islands.ts +7 -7
  146. package/src/isr.ts +17 -17
  147. package/src/manifest.ts +83 -42
  148. package/src/module-graph.ts +6 -6
  149. package/src/navigation.ts +151 -5
  150. package/src/plugins/css-modules.ts +0 -0
  151. package/src/plugins/kit.ts +11 -11
  152. package/src/plugins/scss.ts +3 -3
  153. package/src/plugins/vite-leak-guard.ts +2 -2
  154. package/src/plugins/vite-server-fn.ts +67 -0
  155. package/src/plugins/vite-server-only.ts +75 -0
  156. package/src/prerender.ts +10 -10
  157. package/src/query.ts +22 -22
  158. package/src/route-manifest.ts +3 -3
  159. package/src/router.ts +91 -41
  160. package/src/search.ts +297 -0
  161. package/src/server-only.ts +7 -7
  162. package/src/service-worker.ts +165 -0
  163. package/src/vite.ts +41 -29
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web/bun-dev-entry` — discovering the client-entry URL Bun's dev server assigns.
2
+ * `@nifrajs/web/bun-dev-entry` - discovering the client-entry URL Bun's dev server assigns.
3
3
  *
4
4
  * ## Why this file exists at all
5
5
  *
@@ -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,32 +333,35 @@ 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
203
- // `<html lang>`/`<html dir>` — applied AUTHORITATIVELY (unlike `title`, which is left alone when the
362
+ // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
204
363
  // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
205
- // removed when unset. Anything laxer drifts on a multilingual site — navigating /ur → /en would leave
364
+ // removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
206
365
  // `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
207
366
  const root = document.documentElement
208
367
  root.setAttribute("lang", head.lang ?? "en")
@@ -210,12 +369,13 @@ export function applyHead(head: Meta): void {
210
369
  else root.setAttribute("dir", head.dir)
211
370
  for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
212
371
  // Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
213
- // `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
214
373
  // the server-rendered one exactly (no hydration drift on the managed tags).
215
- 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
216
377
  const el = document.createElement(tag)
217
- for (const [name, value] of Object.entries(attrs)) {
218
- if (value === undefined || value === false) continue
378
+ for (const [name, value] of trusted) {
219
379
  el.setAttribute(name, value === true ? "" : value)
220
380
  }
221
381
  el.setAttribute("data-nifra", "")
@@ -223,13 +383,36 @@ export function applyHead(head: Meta): void {
223
383
  }
224
384
  for (const m of head.meta ?? []) add("meta", m)
225
385
  for (const l of head.link ?? []) add("link", l)
226
- // `<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
227
387
  // via `textContent`, NOT `innerHTML`: `textContent` assigns the raw string without HTML re-parsing, so
228
- // 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
229
389
  // the server's `escapeScriptContent`. `type` defaults to `application/ld+json` (matching the SSR side).
230
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
+ }
231
413
  const el = document.createElement("script")
232
- el.setAttribute("type", s.type ?? "application/ld+json")
414
+ el.setAttribute("type", s.type)
415
+ el.setAttribute("nonce", s.nonce)
233
416
  el.textContent = s.content
234
417
  el.setAttribute("data-nifra", "")
235
418
  document.head.appendChild(el)
@@ -250,7 +433,7 @@ export function installForms(router: ClientRouter): () => void {
250
433
  // actionData drives the update); absent or any other value keeps the default revalidation.
251
434
  const revalidate = form.dataset.nifraRevalidate !== "false"
252
435
  router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
253
- 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
254
437
  })
255
438
  }
256
439
  document.addEventListener("submit", onSubmit)
@@ -262,7 +445,7 @@ export function installForms(router: ClientRouter): () => void {
262
445
  * and fires a one-shot `nifra:hydrated` event. The generated client entry calls this on the next frame
263
446
  * after the adapter mounts (so every framework binding gets it), letting apps gate a custom JS-only
264
447
  * interaction that would otherwise fire before its handler is attached. Idempotent. nifra's own
265
- * 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
266
449
  * native fallback do. See the Hydration guide.
267
450
  */
268
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,
package/src/deferred.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Deferred loader data (`defer()`). A loader may mark slow values as deferred: the critical data
3
3
  * renders in the shell, the deferred data streams in behind a `<Suspense>` (the adapter's `<Await>`
4
4
  * consumes it) and resolves on the client **without a re-fetch**. This module owns the agnostic
5
- * protocol — the marker, the resolved/deferred split, and the tiny client registry that streamed
5
+ * protocol - the marker, the resolved/deferred split, and the tiny client registry that streamed
6
6
  * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
7
  */
8
8
  import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
@@ -10,7 +10,7 @@ import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-cod
10
10
  /**
11
11
  * A loader value marked to stream in after the shell. The component consumes it with the adapter's
12
12
  * `<Await resolve={...}>`; until the promise settles the shell shows the `<Suspense>` fallback.
13
- * `id` is assigned by the server at serialization time — the streamed resolve script keys off it.
13
+ * `id` is assigned by the server at serialization time - the streamed resolve script keys off it.
14
14
  */
15
15
  export interface Deferred<T> {
16
16
  readonly __nifra_deferred: true
@@ -19,8 +19,8 @@ export interface Deferred<T> {
19
19
  }
20
20
 
21
21
  /**
22
- * Mark a loader value as deferred — it streams in after the shell instead of blocking it. Works
23
- * **anywhere** in the loader's returned data — a top-level key, or nested in objects/arrays:
22
+ * Mark a loader value as deferred - it streams in after the shell instead of blocking it. Works
23
+ * **anywhere** in the loader's returned data - a top-level key, or nested in objects/arrays:
24
24
  *
25
25
  * return { user: await api.user.get(), feed: defer(api.user.feed()),
26
26
  * panels: [{ chart: defer(api.metrics()) }] }
@@ -48,8 +48,8 @@ function isDeferred(value: unknown): value is Deferred<unknown> {
48
48
  /**
49
49
  * Split a loader (or action) result into the **component-facing** data (deferred values carry their
50
50
  * assigned `id` + promise, for `<Await>`) and the **client-serializable** data (deferred values
51
- * replaced with a `{__nifra_deferred: id}` placeholder — promises don't serialize). Walks the tree
52
- * **recursively** — `defer()` works at any depth, inside nested objects and arrays — assigning ids in
51
+ * replaced with a `{__nifra_deferred: id}` placeholder - promises don't serialize). Walks the tree
52
+ * **recursively** - `defer()` works at any depth, inside nested objects and arrays - assigning ids in
53
53
  * walk order. `idOffset` continues the id space when a page splits two results (loader data **and**
54
54
  * action data) into one shared client registry, so their ids don't collide. `deferred` lists the
55
55
  * promises for callers that await them. Recurses plain objects + arrays only; the data is expected to
@@ -65,7 +65,7 @@ export function prepareDeferred(
65
65
  } {
66
66
  const deferred: Array<{ id: number; promise: Promise<unknown> }> = []
67
67
  // Structural sharing: a subtree with no deferred value returns the ORIGINAL node for
68
- // both sides (`fc`/`cl` identical to the input), so a deferred-free result — the common case — is
68
+ // both sides (`fc`/`cl` identical to the input), so a deferred-free result - the common case - is
69
69
  // returned by reference with no persisted clone, no downstream copy, and no throwaway clone
70
70
  // allocations. Only the path to a `defer()` marker is rebuilt; unchanged siblings are shared by
71
71
  // reference.
@@ -140,19 +140,19 @@ export function prepareDeferred(
140
140
  }
141
141
 
142
142
  /**
143
- * Stable, non-leaking payload streamed to the client when a deferred value rejects — never the raw
143
+ * Stable, non-leaking payload streamed to the client when a deferred value rejects - never the raw
144
144
  * error text. The real reason is logged server-side; `<Await>`'s `errorFallback` receives
145
145
  * this code. (A future typed `DeferredError` could opt into a public, intentional message.) Shared by
146
146
  * the NDJSON soft-nav transport and the full-document SSR path (`renderPage`'s `streamDocument`).
147
147
  */
148
148
  export const DEFERRED_ERROR_CODE = "deferred_error"
149
- // Shared, stateless — allocated once at module load, not per stream.
149
+ // Shared, stateless - allocated once at module load, not per stream.
150
150
  const NDJSON_ENCODER = new TextEncoder()
151
151
 
152
152
  /**
153
153
  * Stream a loader result as NDJSON for a client (soft) navigation: line 1 is the critical data with
154
154
  * `{__nifra_deferred: id}` placeholders (`forClient`), then one line per deferred as its promise
155
- * settles — `{"i": id, "v": value}` on resolve, `{"i": id, "e": "deferred_error"}` on reject (a
155
+ * settles - `{"i": id, "v": value}` on resolve, `{"i": id, "e": "deferred_error"}` on reject (a
156
156
  * rejection is data, not a stream error; the opaque code never leaks the raw reason). Closes when all
157
157
  * settle. The client (`defaultFetchData`) returns the
158
158
  * data after line 1 and settles `<Await>`'s markers as the resolution lines arrive. `JSON.stringify`
@@ -210,7 +210,7 @@ export const DEFERRED_RUNTIME = `(() => {
210
210
  return e;
211
211
  };
212
212
  window.__nifraDeferred = (id) => get(id).p;
213
- // Tag the promise (status/value/reason) — React's use() reads them and returns SYNCHRONOUSLY at
213
+ // Tag the promise (status/value/reason) - React's use() reads them and returns SYNCHRONOUSLY at
214
214
  // hydration, so it renders the resolved content directly into the <Suspense> boundary the server
215
215
  // streamed (no re-suspend, no fallback flash). r/j also wake awaiters (Solid uses its own _$HY).
216
216
  window.__nifraResolve = (id, v) => { const e = get(id); e.p.status = "fulfilled"; e.p.value = v; e.r(v); };
@@ -219,7 +219,7 @@ export const DEFERRED_RUNTIME = `(() => {
219
219
 
220
220
  /**
221
221
  * Source emitted into the generated client entry: maps serialized `{__nifra_deferred: id}` placeholders
222
- * (at any depth — nested objects/arrays) to the registry's promises, so the component receives real
222
+ * (at any depth - nested objects/arrays) to the registry's promises, so the component receives real
223
223
  * promises to `<Await>`. A no-op for data without placeholders (so non-deferred pages are unchanged).
224
224
  */
225
225
  export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
@@ -233,18 +233,18 @@ export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
233
233
 
234
234
  type Pending = Map<number, { resolve: (v: unknown) => void; reject: (e: unknown) => void }>
235
235
 
236
- /** Replace `{__nifra_deferred: <number>}` placeholders (at any depth — nested objects/arrays) with
236
+ /** Replace `{__nifra_deferred: <number>}` placeholders (at any depth - nested objects/arrays) with
237
237
  * `Deferred` markers whose promises are held open in `pending` (settled later by
238
238
  * {@link parseNdjsonData}'s drain). Mirrors `prepareDeferred`'s recursive walk. */
239
239
  function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
240
- // Markers already created in THIS parse, keyed by id — so a repeated id aliases the first marker.
240
+ // Markers already created in THIS parse, keyed by id - so a repeated id aliases the first marker.
241
241
  const byId = new Map<number, Deferred<unknown>>()
242
242
  const walk = (value: unknown): unknown => {
243
243
  const id = (value as { __nifra_deferred?: unknown } | null)?.__nifra_deferred
244
244
  if (value !== null && typeof value === "object" && typeof id === "number") {
245
245
  // The NDJSON stream is a trust boundary; the server assigns unique ids, but a corrupt/crafted
246
246
  // line-1 could repeat one. Alias a duplicate id to the first marker (same promise) instead of
247
- // overwriting its resolver in `pending` — otherwise the first marker would never settle (a
247
+ // overwriting its resolver in `pending` - otherwise the first marker would never settle (a
248
248
  // permanently-pending promise that leaks on abort). One resolve line then settles both.
249
249
  const seen = byId.get(id)
250
250
  if (seen !== undefined) return seen
@@ -280,7 +280,7 @@ function markersFromPlaceholders(data: unknown, pending: Pending): unknown {
280
280
  * the NDJSON body into a data object whose deferred markers settle as resolution lines arrive.
281
281
  * Returns after **line 1** so the router can apply the critical data + render immediately; the
282
282
  * markers' promises settle/reject in the background. If the stream ends (or `signal` aborts) with
283
- * markers unsettled, they reject — so `<Await>` never hangs.
283
+ * markers unsettled, they reject - so `<Await>` never hangs.
284
284
  */
285
285
  export async function parseNdjsonData(
286
286
  stream: ReadableStream<Uint8Array>,
@@ -331,7 +331,7 @@ export async function parseNdjsonData(
331
331
  else entry.resolve(msg.v)
332
332
  }
333
333
  } catch {
334
- // stream errored — leftovers are rejected below (unless this was an abort)
334
+ // stream errored - leftovers are rejected below (unless this was an abort)
335
335
  } finally {
336
336
  signal?.removeEventListener("abort", onAbort) // no-op if it already fired ({ once: true })
337
337
  // A natural stream end with markers unsettled = truncation → reject (so `<Await>` shows its
package/src/dev-error.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dev-only error overlay. When a loader/action/render throws during `@nifrajs/web/vite` dev, this
3
- * renders a readable full-page overlay — the message, the source-mapped stack (Vite's
4
- * `ssrFixStacktrace` runs first), and the request line — instead of the bare `err.stack` text dump.
3
+ * renders a readable full-page overlay - the message, the source-mapped stack (Vite's
4
+ * `ssrFixStacktrace` runs first), and the request line - instead of the bare `err.stack` text dump.
5
5
  * Dev-only by construction: it's called solely from the Vite middleware's catch, never in
6
6
  * production (production maps errors to the `_error` route boundary).
7
7
  */
@@ -35,7 +35,7 @@ export function renderDevErrorOverlay(err: unknown, req: { method: string; url:
35
35
  frames.length > 0
36
36
  ? `<ol class="frames">${frames.map((f) => `<li>${esc(f)}</li>`).join("")}</ol>`
37
37
  : `<p class="no-frames">No stack frames.</p>`
38
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${name} — nifra dev</title>
38
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${name} - nifra dev</title>
39
39
  <style>
40
40
  :root { color-scheme: dark }
41
41
  body { margin: 0; background: #0b0d10; color: #e6e6e6; font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
package/src/dev-port.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `@nifrajs/web/dev-port` — what a dev server says when it cannot bind.
2
+ * `@nifrajs/web/dev-port` - what a dev server says when it cannot bind.
3
3
  *
4
4
  * Both pipelines need this and neither should own it: the Vite server binds through Node's `http`
5
5
  * (asynchronous `error` event), the Bun server binds through `Bun.serve` (synchronous throw). Two very
@@ -36,7 +36,7 @@ const asError = (err: unknown): Error => (err instanceof Error ? err : new Error
36
36
  export const explainBindFailure = (err: unknown, port: number): Error =>
37
37
  isAddressInUse(err) ? new Error(portInUseMessage(port)) : asError(err)
38
38
 
39
- /** The bits of a Node server {@link listenOrExplain} touches — structural, so a test can fake it. */
39
+ /** The bits of a Node server {@link listenOrExplain} touches - structural, so a test can fake it. */
40
40
  export interface ListenTarget {
41
41
  listen(port: number, cb: () => void): void
42
42
  once(event: "error", cb: (err: unknown) => void): void