@nifrajs/web 3.3.0 → 4.0.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 (407) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ## Why this file exists at all
5
5
  *
6
- * Bun's dev server bundles an HTML route and rewrites its `<script src="./entry.tsx">` into a
6
+ * Bun's dev server bundles an HTML route and rewrites its `<script src="./nifra-dev-entry.tsx">` into a
7
7
  * content-hashed URL under `/_bun/client/`. That rewrite is the whole point: the hash changes per build,
8
8
  * so the URL cannot be predicted or hard-coded.
9
9
  *
package/src/cdn.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `@nifrajs/web/cdn` - put a CDN in front of nifra pages and purge it by tag.
3
+ *
4
+ * `withCdn` wraps the app (or a `withISR` handler) and, on every page a shared cache may store, sets
5
+ * the CDN's tag and TTL headers: the route's `revalidateTags` plus a tag for the page's own path, and
6
+ * the route's `revalidate` as the CDN's freshness. Browsers get `max-age=0, must-revalidate`, because
7
+ * a purge reaches the CDN, never a browser. Every other HTML response is marked no-store for the CDN.
8
+ * `revalidateEndpoint({ cdn })` and `createInvalidator` purge the origin store first, then the CDN.
9
+ *
10
+ * Providers: Cloudflare (a proxied zone, or Workers Cache), Vercel and Fastly; `defineCdnProvider`
11
+ * for any other. Backend-only: a browser build that reaches this module fails.
12
+ */
13
+ import "@nifrajs/web/backend-only"
14
+ import { isDraftEnabled } from "./draft.ts"
15
+ import type { CdnProvider } from "./internal/cdn/core.ts"
16
+ import { pathTag } from "./internal/cdn/core.ts"
17
+ import {
18
+ type CacheStore,
19
+ type CdnPurgeOutcome,
20
+ type CdnPurgeTarget,
21
+ ISR_REVALIDATE_HEADER,
22
+ ISR_REVALIDATE_TAGS_HEADER,
23
+ type ISRApp,
24
+ type ISRPlatform,
25
+ type ISRQuery,
26
+ isCacheablePage,
27
+ MAX_REVALIDATE_PATHS,
28
+ MAX_REVALIDATE_TAGS,
29
+ normalizeTags,
30
+ openCacheChannel,
31
+ tagsFromHeader,
32
+ urlKeyOf,
33
+ withoutCacheChannel,
34
+ } from "./isr.ts"
35
+
36
+ export {
37
+ type CloudflareWorkersCacheOptions,
38
+ type CloudflareZoneOptions,
39
+ cloudflareWorkersCache,
40
+ cloudflareZone,
41
+ type WorkersCacheLike,
42
+ } from "./internal/cdn/cloudflare.ts"
43
+ export {
44
+ type CdnCacheInput,
45
+ type CdnProvider,
46
+ type CdnProviderDefinition,
47
+ type CdnPurgeError,
48
+ defineCdnProvider,
49
+ type PurgeAttempt,
50
+ type PurgeQueueOptions,
51
+ pathTag,
52
+ } from "./internal/cdn/core.ts"
53
+ export { type FastlyOptions, fastly } from "./internal/cdn/fastly.ts"
54
+ export { type VercelOptions, vercel } from "./internal/cdn/vercel.ts"
55
+ export type { CdnPurgeOutcome, CdnPurgeTarget }
56
+
57
+ /** What browsers are told about a page the CDN may store: revalidate every time. */
58
+ export const CDN_BROWSER_CACHE_CONTROL = "public, max-age=0, must-revalidate"
59
+
60
+ type Handler = (req: Request, platform?: ISRPlatform) => Response | Promise<Response>
61
+
62
+ export interface WithCdnOptions {
63
+ readonly provider: CdnProvider
64
+ /**
65
+ * Seconds the CDN may keep serving a page past its freshness while it refetches. Default: the page's
66
+ * own freshness window. With `withISR` underneath, a page can be this much older than ISR's own.
67
+ */
68
+ readonly staleWhileRevalidate?: number
69
+ /** Seconds the CDN may serve a page past its freshness while the origin errors. Default: unset. */
70
+ readonly staleIfError?: number
71
+ /** The draft secret given to `createWebApp`: a draft render is never stored by the CDN. */
72
+ readonly draftSecret?: string
73
+ }
74
+
75
+ const nonNegative = (value: number | undefined, name: string): number | undefined => {
76
+ if (value === undefined) return undefined
77
+ if (!Number.isInteger(value) || value < 0) {
78
+ throw new RangeError(`[nifra/web/cdn] ${name} must be a non-negative integer number of seconds`)
79
+ }
80
+ return value
81
+ }
82
+
83
+ const isHtml = (res: Response): boolean =>
84
+ (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
85
+
86
+ function withHeaders(res: Response, set: Readonly<Record<string, string>>): Response {
87
+ try {
88
+ for (const [name, value] of Object.entries(set)) res.headers.set(name, value)
89
+ return res
90
+ } catch {
91
+ // A `fetch()` response has immutable headers: rebuild it around the same body instead.
92
+ const headers = new Headers(res.headers)
93
+ for (const [name, value] of Object.entries(set)) headers.set(name, value)
94
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Wrap `app` (a `createWebApp` app or a `withISR` handler) so a CDN stores exactly the pages a shared
100
+ * cache may serve to anyone, tagged for purging, for as long as the route's `revalidate` allows.
101
+ * Non-HTML responses (assets, JSON, navigation data) pass through with the app's own headers.
102
+ */
103
+ export function withCdn(
104
+ app: ISRApp | Handler,
105
+ options: WithCdnOptions,
106
+ ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
107
+ const { provider, draftSecret } = options
108
+ const swr = nonNegative(options.staleWhileRevalidate, "staleWhileRevalidate")
109
+ const staleIfError = nonNegative(options.staleIfError, "staleIfError")
110
+ openCacheChannel(app)
111
+ const fetchOf: Handler =
112
+ typeof app === "function" ? app : (req, platform) => app.fetch(req, platform)
113
+ return async (req, platform) => {
114
+ const res = await fetchOf(req, platform)
115
+ const advertised = res.headers.get(ISR_REVALIDATE_HEADER)
116
+ const tags = tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
117
+ const out = withoutCacheChannel(res)
118
+ if (!isHtml(out)) return out
119
+ const seconds = advertised === null ? Number.NaN : Math.floor(Number(advertised))
120
+ const storable =
121
+ Number.isFinite(seconds) &&
122
+ seconds > 0 &&
123
+ isCacheablePage(req, out) &&
124
+ !(draftSecret !== undefined && (await isDraftEnabled(req, draftSecret)))
125
+ if (!storable) return withHeaders(out, provider.noStoreHeaders())
126
+ const pageTags = [...tags, await pathTag(new URL(req.url).pathname)]
127
+ return withHeaders(out, {
128
+ ...provider.cacheHeaders({
129
+ tags: pageTags.slice(-provider.maxResponseTags),
130
+ maxAge: seconds,
131
+ staleWhileRevalidate: swr ?? seconds,
132
+ staleIfError,
133
+ }),
134
+ "cache-control": CDN_BROWSER_CACHE_CONTROL,
135
+ })
136
+ }
137
+ }
138
+
139
+ export interface InvalidatorOptions {
140
+ /** The ISR store to purge first. */
141
+ readonly store?: CacheStore
142
+ /** The CDN to purge after the store. */
143
+ readonly cdn?: CdnPurgeTarget
144
+ /**
145
+ * The site's public origin (`https://example.com`), for the store keys of purged paths under
146
+ * `withISR`'s default key. Not needed with `key`, or without a store.
147
+ */
148
+ readonly origin?: string
149
+ /** A store key for a path; must match the `withISR` `key`. Return `null` for a path never cached. */
150
+ readonly key?: (path: string) => string | null
151
+ /** The `query` policy given to `withISR`. Default `"bypass"`. Ignored with `key`. */
152
+ readonly query?: ISRQuery
153
+ }
154
+
155
+ export interface InvalidateResult {
156
+ readonly origin: "done" | "skipped"
157
+ readonly cdn: CdnPurgeOutcome["cdn"] | "skipped"
158
+ readonly retryable: boolean
159
+ readonly error?: string
160
+ }
161
+
162
+ /**
163
+ * Purge pages after a mutation, from app code: `await invalidate({ tags: ["product:42"] })`. The
164
+ * origin store goes first, so a CDN refetch cannot repopulate from a stale origin entry. At most 32
165
+ * tags and 100 paths a call.
166
+ */
167
+ export function createInvalidator(options: InvalidatorOptions): {
168
+ invalidate(
169
+ target: { readonly tags?: readonly string[]; readonly paths?: readonly string[] },
170
+ platform?: ISRPlatform,
171
+ ): Promise<InvalidateResult>
172
+ } {
173
+ const { store, cdn } = options
174
+ let keyOf: ((path: string) => string | null) | undefined = options.key
175
+ if (keyOf === undefined && store !== undefined) {
176
+ if (options.origin === undefined) {
177
+ throw new TypeError(
178
+ "[nifra/web/cdn] createInvalidator: a store needs `origin` (or `key`) to find a path's entry",
179
+ )
180
+ }
181
+ const origin = new URL(options.origin).origin
182
+ const urlKey = urlKeyOf(options.query)
183
+ keyOf = (path) => urlKey(new URL(origin + path))
184
+ }
185
+ return {
186
+ async invalidate(target, platform) {
187
+ const tags = normalizeTags(target.tags ?? [])
188
+ const paths = [...new Set(target.paths ?? [])]
189
+ if (tags.length > MAX_REVALIDATE_TAGS || paths.length > MAX_REVALIDATE_PATHS) {
190
+ throw new RangeError(
191
+ "[nifra/web/cdn] invalidate takes at most 32 tags and 100 paths a call",
192
+ )
193
+ }
194
+ for (const path of paths) {
195
+ if (typeof path !== "string" || !path.startsWith("/")) {
196
+ throw new TypeError("[nifra/web/cdn] invalidate paths must start with /")
197
+ }
198
+ }
199
+ let origin: InvalidateResult["origin"] = "skipped"
200
+ if (store !== undefined) {
201
+ if (tags.length > 0 && store.invalidateTag === undefined) {
202
+ throw new TypeError("[nifra/web/cdn] this store cannot invalidate by tag")
203
+ }
204
+ for (const path of paths) {
205
+ const key = keyOf?.(path)
206
+ if (key !== null && key !== undefined) await store.delete(key)
207
+ }
208
+ for (const tag of tags) await store.invalidateTag?.(tag)
209
+ origin = "done"
210
+ }
211
+ if (cdn === undefined) return { origin, cdn: "skipped", retryable: false }
212
+ const outcome = await cdn.purge({ tags, paths }, platform)
213
+ return { origin, ...outcome }
214
+ },
215
+ }
216
+ }
package/src/client.ts CHANGED
@@ -18,7 +18,20 @@ import {
18
18
  setBlockerController,
19
19
  setBrowserNavigate,
20
20
  } from "./navigation.ts"
21
- import type { ClientRouter } from "./router.ts"
21
+ import { type ClientRouter, redirectOf } from "./router.ts"
22
+
23
+ /** Read the nonce carried by the current server-rendered document, if any. */
24
+ export function currentDocumentNonce(): string | undefined {
25
+ if (typeof document === "undefined") return undefined
26
+ const documentLike = document as unknown as {
27
+ querySelector?: (selector: string) => Element | null
28
+ }
29
+ const script = documentLike.querySelector?.("script[nonce]")
30
+ if (script === null || script === undefined) return undefined
31
+ const property = (script as Element & { readonly nonce?: unknown }).nonce
32
+ if (typeof property === "string" && property !== "") return property
33
+ return script.getAttribute("nonce") ?? undefined
34
+ }
22
35
 
23
36
  /**
24
37
  * Everything the generated client entry needs, re-exported here so that entry imports ONE module and
@@ -53,6 +66,146 @@ export interface InstallHistoryOptions {
53
66
  readonly fallback?: (path: string) => void
54
67
  }
55
68
 
69
+ /** Options for {@link waitForStyles}. */
70
+ export interface WaitForStylesOptions {
71
+ /**
72
+ * Maximum time to wait for one framework-owned deferred stylesheet before hydrating anyway.
73
+ * Defaults to 4000 ms and is capped at 30000 ms so a bad value cannot leave the app inert forever.
74
+ */
75
+ readonly timeoutMs?: number
76
+ }
77
+
78
+ const DEFAULT_CSS_WAIT_MS = 4_000
79
+ const MAX_CSS_WAIT_MS = 30_000
80
+ type CssLinkState = "loaded" | "error" | "timeout"
81
+
82
+ // Per-link promises keep repeated calls idempotent without retaining an entire document after a hard
83
+ // navigation. A WeakMap also lets tests and embedded documents replace `globalThis.document` safely.
84
+ const stylesheetWaits = new WeakMap<HTMLLinkElement, Promise<void>>()
85
+
86
+ const cssLinkStateOf = (link: HTMLLinkElement): CssLinkState | undefined => {
87
+ const state = link.getAttribute("data-nifra-css-state")
88
+ return state === "loaded" || state === "error" || state === "timeout" ? state : undefined
89
+ }
90
+
91
+ const hasLoadedStylesheet = (link: HTMLLinkElement): boolean => {
92
+ // Accessing `sheet` is safe for cross-origin stylesheets, but a browser may still throw while it is
93
+ // tearing down a document. Treat that as "not observed yet"; the load/error listener or the bounded
94
+ // timer is the authoritative fallback.
95
+ try {
96
+ return link.sheet !== null
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ const stylesheetLabel = (link: HTMLLinkElement): string =>
103
+ link.getAttribute("href") ?? link.href ?? "<unknown>"
104
+
105
+ /**
106
+ * Wait for one server-emitted deferred stylesheet. The promise ALWAYS settles: a failed or slow CSS
107
+ * request must not leave the server-rendered page's loader visible forever. `media="all"` is applied on
108
+ * every terminal state so a late network completion cannot leave a link permanently in print media.
109
+ */
110
+ const waitForStylesheet = (link: HTMLLinkElement, timeoutMs: number): Promise<void> =>
111
+ new Promise<void>((resolve) => {
112
+ let settled = false
113
+ let timer: ReturnType<typeof setTimeout> | undefined
114
+
115
+ const cleanup = (): void => {
116
+ link.removeEventListener("load", onLoad)
117
+ link.removeEventListener("error", onError)
118
+ if (timer !== undefined) clearTimeout(timer)
119
+ }
120
+ const finish = (state: CssLinkState): void => {
121
+ if (settled) return
122
+ settled = true
123
+ cleanup()
124
+ link.setAttribute("data-nifra-css-state", state)
125
+ // Only the coordinator promotes the framework-owned link, after the resource has settled.
126
+ link.media = "all"
127
+ resolve()
128
+ }
129
+ const onLoad = (): void => finish("loaded")
130
+ const onError = (): void => {
131
+ console.error(`[nifra/web] stylesheet failed to load: ${stylesheetLabel(link)}`)
132
+ finish("error")
133
+ }
134
+
135
+ const prior = cssLinkStateOf(link)
136
+ if (prior !== undefined) {
137
+ finish(prior)
138
+ return
139
+ }
140
+ if (hasLoadedStylesheet(link)) {
141
+ finish("loaded")
142
+ return
143
+ }
144
+
145
+ // Attach listeners before the second loaded-sheet check: a cached resource can transition between
146
+ // the first check and listener registration, and the second check closes that small race.
147
+ link.addEventListener("load", onLoad, { once: true })
148
+ link.addEventListener("error", onError, { once: true })
149
+ timer = setTimeout(() => {
150
+ console.warn(
151
+ `[nifra/web] stylesheet load timed out after ${timeoutMs}ms: ${stylesheetLabel(link)}`,
152
+ )
153
+ finish("timeout")
154
+ }, timeoutMs)
155
+ if (hasLoadedStylesheet(link)) finish("loaded")
156
+ })
157
+
158
+ const validateCssWaitMs = (value: number): number => {
159
+ if (!Number.isFinite(value) || value <= 0) {
160
+ throw new RangeError(
161
+ `[nifra/web] css wait timeout must be a finite positive number, received ${value}`,
162
+ )
163
+ }
164
+ return Math.min(value, MAX_CSS_WAIT_MS)
165
+ }
166
+
167
+ /**
168
+ * Wait for the framework-owned deferred stylesheet links in the initial document, then promote them to
169
+ * `media="all"`. It is safe to call more than once: repeated calls for one document share the same
170
+ * per-link waits, and duplicate framework links are removed before waiting. Blocking links are not
171
+ * touched.
172
+ *
173
+ * This function lives in the browser-only `@nifrajs/web/client` entry. It is nevertheless defensive
174
+ * when imported in a non-DOM environment so an accidental import cannot crash a server process.
175
+ */
176
+ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void> {
177
+ if (typeof document === "undefined") return Promise.resolve()
178
+ const timeoutMs = validateCssWaitMs(options.timeoutMs ?? DEFAULT_CSS_WAIT_MS)
179
+ const links = Array.from(
180
+ document.querySelectorAll<HTMLLinkElement>('link[data-nifra-css="deferred"]'),
181
+ ).filter(
182
+ (link) =>
183
+ link.getAttribute("data-nifra-css") === "deferred" && link.rel.toLowerCase() === "stylesheet",
184
+ )
185
+ const unique: HTMLLinkElement[] = []
186
+ const seen = new Set<string>()
187
+ for (const link of links) {
188
+ const key = stylesheetLabel(link)
189
+ if (seen.has(key)) {
190
+ // Only remove a duplicate carrying the framework marker; author-owned links are outside this
191
+ // selector and cannot be affected. Keeping the first link preserves its event state and cache.
192
+ link.remove()
193
+ continue
194
+ }
195
+ seen.add(key)
196
+ unique.push(link)
197
+ }
198
+ if (unique.length === 0) return Promise.resolve()
199
+ const promises = unique.map((link) => {
200
+ const existing = stylesheetWaits.get(link)
201
+ if (existing !== undefined) return existing
202
+ const promise = waitForStylesheet(link, timeoutMs)
203
+ stylesheetWaits.set(link, promise)
204
+ return promise
205
+ })
206
+ return Promise.all(promises).then(() => undefined)
207
+ }
208
+
56
209
  /**
57
210
  * Attach history + link interception to a router. Returns a teardown function that removes the
58
211
  * listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
@@ -80,6 +233,8 @@ export function installHistory(
80
233
  let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
81
234
  let here = location.pathname + location.search + (location.hash ?? "")
82
235
  let reversing = false
236
+ // The path the router was last seen loading, so a redirect it follows shows up as a change.
237
+ let expected: string | undefined
83
238
  type Registration = {
84
239
  readonly shouldBlock: BlockerFunction
85
240
  readonly emit: (blocker: Blocker) => void
@@ -215,11 +370,20 @@ export function installHistory(
215
370
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
216
371
  }
217
372
  here = url.pathname + url.search + url.hash
373
+ expected = routePath
218
374
  // The data layer fetches by path+search; the #hash is client-only (never sent to the server).
219
- transition(() => router.navigate(routePath).catch(() => fallback(path)))
375
+ transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
220
376
  settle()
221
377
  }
222
378
 
379
+ // A redirect out of the app replaces the entry it answered, as the browser's own redirect would; any
380
+ // other failure loads the page as a document.
381
+ const leave = (error: unknown, path: string): void => {
382
+ const to = redirectOf(error)
383
+ if (to === undefined) fallback(path)
384
+ else location.replace(to)
385
+ }
386
+
223
387
  const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
224
388
  // Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
225
389
  // malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
@@ -244,7 +408,7 @@ export function installHistory(
244
408
  }
245
409
  go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
246
410
  }
247
- setBrowserNavigate(navigate)
411
+ setBrowserNavigate(navigate, router)
248
412
 
249
413
  // Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
250
414
  // adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
@@ -291,12 +455,45 @@ export function installHistory(
291
455
  go(href, "push")
292
456
  }
293
457
 
294
- // Hover/focus an in-app link → warm its chunk + data (the store dedupes the spam).
295
- const onPrefetch = (event: Event): void => {
296
- const href = inAppHref(event.target)
458
+ // `data-nifra-prefetch` on a link or any ancestor (the nearest wins) says when the link warms its
459
+ // route's chunk and data: `intent` (the default) on hover or focus, `viewport` once it scrolls into
460
+ // view, `render` as soon as a page shows it, `none` never.
461
+ const prefetchModeOf = (el: Element): string | undefined =>
462
+ (el.closest("[data-nifra-prefetch]") as HTMLElement | null)?.dataset.nifraPrefetch
463
+
464
+ // Warm an in-app link's route (the store dedupes the spam). The page on screen has nothing to warm.
465
+ const warm = (target: EventTarget | null): void => {
466
+ const href = inAppHref(target)
297
467
  if (href === null) return
298
468
  const url = new URL(href, location.origin)
299
- void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
469
+ const path = url.pathname + url.search // the #hash isn't data
470
+ if (path !== location.pathname + location.search) void router.prefetch(path)
471
+ }
472
+
473
+ const onPrefetch = (event: Event): void => {
474
+ const target = event.target
475
+ if (target instanceof Element && prefetchModeOf(target) !== "none") warm(target)
476
+ }
477
+
478
+ // `viewport` and `render` links are looked for when history is installed (the server-rendered page)
479
+ // and whenever the router settles (a navigation, a submit's revalidation). A link the page adds in
480
+ // between warms on intent until the next settle.
481
+ let inView: IntersectionObserver | undefined
482
+ const scan = (): void => {
483
+ inView?.disconnect()
484
+ for (const anchor of document.querySelectorAll(
485
+ "a[data-nifra-prefetch],[data-nifra-prefetch] a",
486
+ )) {
487
+ const mode = prefetchModeOf(anchor)
488
+ if (mode === "render") warm(anchor)
489
+ else if (mode === "viewport") {
490
+ // Scrolling back into view asks again; the store holds a fresh prefetch, so nothing refetches.
491
+ inView ??= new IntersectionObserver((entries) => {
492
+ for (const entry of entries) if (entry.isIntersecting) warm(entry.target)
493
+ })
494
+ inView.observe(anchor)
495
+ }
496
+ }
300
497
  }
301
498
 
302
499
  // Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
@@ -320,9 +517,9 @@ export function installHistory(
320
517
  index = newIndex
321
518
  here = dest
322
519
  pendingScroll = { pos: scrollOf(history.state) }
323
- transition(() =>
324
- router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
325
- )
520
+ const routePath = location.pathname + location.search
521
+ expected = routePath
522
+ transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
326
523
  settle()
327
524
  }
328
525
 
@@ -344,24 +541,50 @@ export function installHistory(
344
541
  event.returnValue = ""
345
542
  }
346
543
 
544
+ // The router is loading a page the address bar does not show: a redirect it follows. That replaces the
545
+ // entry a navigation pushed, or adds one after a form post, as the browser would. A redirect from a
546
+ // form post back to the page it was on adds nothing.
547
+ const follow = (path: string): void => {
548
+ if (path !== location.pathname + location.search) {
549
+ if (expected === undefined) {
550
+ history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
551
+ index += 1
552
+ history.pushState({ nifraIndex: index }, "", path)
553
+ } else {
554
+ history.replaceState(history.state, "", path + location.hash)
555
+ }
556
+ here = location.pathname + location.search + location.hash
557
+ pendingScroll = location.hash !== "" ? { hash: hashId(location.hash) } : { pos: [0, 0] }
558
+ }
559
+ expected = path
560
+ }
561
+
347
562
  // After a navigation settles (content rendered), apply the pending scroll target on the next frame:
348
563
  // a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
349
- // Skipped for submits (pending).
350
- const restoreScroll = (): void => {
351
- if (router.snapshot().pending || pendingScroll === null) return
564
+ // A settle with no navigation (a submit's revalidation) scrolls nothing. Either way the rendered
565
+ // page is then scanned for links to warm.
566
+ const settled = (): void => {
567
+ const { pending, pendingPath } = router.snapshot()
568
+ if (pending) {
569
+ if (pendingPath !== undefined && pendingPath !== expected) follow(pendingPath)
570
+ return
571
+ }
572
+ expected = undefined
352
573
  const target = pendingScroll
353
574
  pendingScroll = null
354
575
  requestAnimationFrame(() => {
355
- if ("hash" in target) {
576
+ if (target !== null && "hash" in target) {
356
577
  const el = findAnchor(target.hash)
357
578
  if (el !== null) el.scrollIntoView()
358
579
  else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
359
- } else {
580
+ } else if (target !== null) {
360
581
  window.scrollTo(target.pos[0], target.pos[1])
361
582
  }
583
+ scan()
362
584
  })
363
585
  }
364
- const unsubscribe = router.subscribe(restoreScroll)
586
+ const unsubscribe = router.subscribe(settled)
587
+ scan()
365
588
 
366
589
  document.addEventListener("click", onClick)
367
590
  document.addEventListener("pointerover", onPrefetch)
@@ -370,6 +593,7 @@ export function installHistory(
370
593
  window.addEventListener("beforeunload", onBeforeUnload)
371
594
  return () => {
372
595
  unsubscribe()
596
+ inView?.disconnect()
373
597
  setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
374
598
  setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
375
599
  document.removeEventListener("click", onClick)
@@ -392,6 +616,7 @@ export function installHistory(
392
616
  * the first navigation cleanly takes over from the server-rendered head.
393
617
  */
394
618
  export function applyHead(head: Meta): void {
619
+ const documentNonce = currentDocumentNonce()
395
620
  if (head.title !== undefined) document.title = head.title
396
621
  // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
397
622
  // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
@@ -444,6 +669,11 @@ export function applyHead(head: Meta): void {
444
669
  `[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
445
670
  )
446
671
  }
672
+ if (documentNonce !== undefined && s.nonce !== documentNonce) {
673
+ throw new TypeError(
674
+ "[nifra/web] executable head scripts must use the same CSP nonce as the document",
675
+ )
676
+ }
447
677
  const el = document.createElement("script")
448
678
  el.setAttribute("type", s.type)
449
679
  el.setAttribute("nonce", s.nonce)
@@ -466,8 +696,11 @@ export function installForms(router: ClientRouter): () => void {
466
696
  // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
467
697
  // actionData drives the update); absent or any other value keeps the default revalidation.
468
698
  const revalidate = form.dataset.nifraRevalidate !== "false"
469
- router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
470
- form.submit() // data submit failed - fall back to a full-page POST
699
+ router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch((error) => {
700
+ const to = redirectOf(error)
701
+ // The action ran: load the page that shows its result rather than run it again.
702
+ if (to !== undefined) location.assign(to)
703
+ else form.submit() // data submit failed - fall back to a full-page POST
471
704
  })
472
705
  }
473
706
  document.addEventListener("submit", onSubmit)
@@ -88,9 +88,10 @@ const readHtml = async (
88
88
  * Execute the observable {@link RenderAdapter} interface against a framework-specific fixture.
89
89
  *
90
90
  * The promise resolves only when the adapter proves all shared invariants: page props reach a
91
- * page-only render, streams contain byte chunks, layout chains nest outermost-first, optional
92
- * buffered rendering is byte-for-byte hydration-equivalent to streaming for non-deferred content,
93
- * and hydration-head output is a stable string. It rejects with
91
+ * page-only render, streams contain byte chunks, layout chains nest outermost-first, a leaf that
92
+ * renders nothing leaves its layouts standing, optional buffered rendering is byte-for-byte
93
+ * hydration-equivalent to streaming for non-deferred content, and hydration-head output is a stable
94
+ * string. It rejects with
94
95
  * {@link RenderAdapterConformanceError} naming the first failed check.
95
96
  *
96
97
  * Framework semantics outside the interface-Suspense scheduling, compiler plugins, DOM hydration,
@@ -125,6 +126,18 @@ export async function assertRenderAdapterConformance(
125
126
  fail("layout order", "expected outer layout → inner layout → page source order")
126
127
  }
127
128
 
129
+ // The page slot of an `ssr = false` route with no `HydrateFallback`: a plain function returning
130
+ // `null`, in every framework. The layouts must render around it and the slot must be empty.
131
+ const emptyHtml = await readHtml(
132
+ () => adapter.renderToStream([outerLayout, innerLayout, () => null], props),
133
+ "empty leaf",
134
+ )
135
+ contains(emptyHtml, markers.outer, "empty leaf")
136
+ contains(emptyHtml, markers.inner, "empty leaf")
137
+ if (emptyHtml.includes(markers.page)) {
138
+ fail("empty leaf", "a leaf that renders nothing must leave the page slot empty")
139
+ }
140
+
128
141
  if (adapter.renderToString !== undefined) {
129
142
  let buffered: unknown
130
143
  try {