@nifrajs/web 3.5.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 (390) 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 +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  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 +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +111 -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/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +485 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +386 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +72 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +187 -36
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +111 -18
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +623 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +670 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +110 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +207 -36
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. 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
@@ -220,6 +233,8 @@ export function installHistory(
220
233
  let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
221
234
  let here = location.pathname + location.search + (location.hash ?? "")
222
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
223
238
  type Registration = {
224
239
  readonly shouldBlock: BlockerFunction
225
240
  readonly emit: (blocker: Blocker) => void
@@ -355,11 +370,20 @@ export function installHistory(
355
370
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
356
371
  }
357
372
  here = url.pathname + url.search + url.hash
373
+ expected = routePath
358
374
  // The data layer fetches by path+search; the #hash is client-only (never sent to the server).
359
- transition(() => router.navigate(routePath).catch(() => fallback(path)))
375
+ transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
360
376
  settle()
361
377
  }
362
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
+
363
387
  const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
364
388
  // Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
365
389
  // malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
@@ -384,7 +408,7 @@ export function installHistory(
384
408
  }
385
409
  go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
386
410
  }
387
- setBrowserNavigate(navigate)
411
+ setBrowserNavigate(navigate, router)
388
412
 
389
413
  // Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
390
414
  // adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
@@ -431,12 +455,45 @@ export function installHistory(
431
455
  go(href, "push")
432
456
  }
433
457
 
434
- // Hover/focus an in-app link → warm its chunk + data (the store dedupes the spam).
435
- const onPrefetch = (event: Event): void => {
436
- 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)
437
467
  if (href === null) return
438
468
  const url = new URL(href, location.origin)
439
- 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
+ }
440
497
  }
441
498
 
442
499
  // Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
@@ -460,9 +517,9 @@ export function installHistory(
460
517
  index = newIndex
461
518
  here = dest
462
519
  pendingScroll = { pos: scrollOf(history.state) }
463
- transition(() =>
464
- router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
465
- )
520
+ const routePath = location.pathname + location.search
521
+ expected = routePath
522
+ transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
466
523
  settle()
467
524
  }
468
525
 
@@ -484,24 +541,50 @@ export function installHistory(
484
541
  event.returnValue = ""
485
542
  }
486
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
+
487
562
  // After a navigation settles (content rendered), apply the pending scroll target on the next frame:
488
563
  // a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
489
- // Skipped for submits (pending).
490
- const restoreScroll = (): void => {
491
- 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
492
573
  const target = pendingScroll
493
574
  pendingScroll = null
494
575
  requestAnimationFrame(() => {
495
- if ("hash" in target) {
576
+ if (target !== null && "hash" in target) {
496
577
  const el = findAnchor(target.hash)
497
578
  if (el !== null) el.scrollIntoView()
498
579
  else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
499
- } else {
580
+ } else if (target !== null) {
500
581
  window.scrollTo(target.pos[0], target.pos[1])
501
582
  }
583
+ scan()
502
584
  })
503
585
  }
504
- const unsubscribe = router.subscribe(restoreScroll)
586
+ const unsubscribe = router.subscribe(settled)
587
+ scan()
505
588
 
506
589
  document.addEventListener("click", onClick)
507
590
  document.addEventListener("pointerover", onPrefetch)
@@ -510,6 +593,7 @@ export function installHistory(
510
593
  window.addEventListener("beforeunload", onBeforeUnload)
511
594
  return () => {
512
595
  unsubscribe()
596
+ inView?.disconnect()
513
597
  setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
514
598
  setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
515
599
  document.removeEventListener("click", onClick)
@@ -532,6 +616,7 @@ export function installHistory(
532
616
  * the first navigation cleanly takes over from the server-rendered head.
533
617
  */
534
618
  export function applyHead(head: Meta): void {
619
+ const documentNonce = currentDocumentNonce()
535
620
  if (head.title !== undefined) document.title = head.title
536
621
  // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
537
622
  // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
@@ -584,6 +669,11 @@ export function applyHead(head: Meta): void {
584
669
  `[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
585
670
  )
586
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
+ }
587
677
  const el = document.createElement("script")
588
678
  el.setAttribute("type", s.type)
589
679
  el.setAttribute("nonce", s.nonce)
@@ -606,8 +696,11 @@ export function installForms(router: ClientRouter): () => void {
606
696
  // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
607
697
  // actionData drives the update); absent or any other value keeps the default revalidation.
608
698
  const revalidate = form.dataset.nifraRevalidate !== "false"
609
- router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
610
- 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
611
704
  })
612
705
  }
613
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 {
package/src/csp.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { PRE_HYDRATION_GUARD } from "./internal/runtime-contract.ts"
2
+ import { createNonceResolver, type NonceResolver } from "./nonce.ts"
3
+ import type { RenderAdapter } from "./render-seam.ts"
4
+
5
+ /** What {@link CreateCspPolicyOptions.header} receives for one document. */
6
+ export interface CspHeaderContext {
7
+ /**
8
+ * The `script-src` sources this document needs, space-separated: a `'sha256-…'` hash for each
9
+ * constant inline script nifra writes, plus `'nonce-…'` when the document carries a script that is
10
+ * specific to this request. External scripts (the client entry, route chunks, islands) are not
11
+ * listed: allow their origin, usually `'self'`.
12
+ */
13
+ readonly sources: string
14
+ /** The nonce, when this document carries one. Such a document is `private, no-store`. */
15
+ readonly nonce?: string
16
+ }
17
+
18
+ export interface CreateCspPolicyOptions {
19
+ /**
20
+ * Build the complete Content-Security-Policy value for one document, or `undefined` to send none:
21
+ *
22
+ * ```ts
23
+ * header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`
24
+ * ```
25
+ */
26
+ readonly header: (context: CspHeaderContext) => string | undefined
27
+ /** Generate the nonce for a document that needs one. Default: 122 random bits, CSP base64-safe. */
28
+ readonly generate?: () => string
29
+ }
30
+
31
+ const CSP_POLICY: unique symbol = Symbol.for("nifra.web.cspPolicy")
32
+
33
+ /** How a `createWebApp` app's documents meet a CSP: `"nonce"` (every document carries one, so none is
34
+ * cacheable) or `"hash"` (a {@link CspPolicy}). Read by wrappers that cache documents. */
35
+ export const DOCUMENT_POLICY: unique symbol = Symbol.for("nifra.web.documentPolicy")
36
+
37
+ /** The hash sources for one adapter, and the hydration head they were computed from. */
38
+ interface AdapterSources {
39
+ readonly head: string
40
+ readonly sources: string
41
+ }
42
+
43
+ interface CspPolicyState {
44
+ readonly header: (context: CspHeaderContext) => string | undefined
45
+ readonly generate: () => string
46
+ readonly adapters: WeakMap<RenderAdapter, AdapterSources | Promise<AdapterSources>>
47
+ }
48
+
49
+ /**
50
+ * A hash-based Content-Security-Policy for nifra documents - see {@link createCspPolicy}. Opaque: pass
51
+ * it to `createWebApp({ csp })` or `renderPage({ csp })`.
52
+ */
53
+ export interface CspPolicy {
54
+ readonly [CSP_POLICY]: CspPolicyState
55
+ }
56
+
57
+ /**
58
+ * Create a hash-based Content-Security-Policy for nifra documents, so a page can carry a strict CSP
59
+ * and still be cached.
60
+ *
61
+ * A per-request nonce makes every document unique: nifra marks a nonce-bearing document
62
+ * `private, no-store`, so no shared cache (ISR, a CDN) may store it. Under this policy a document
63
+ * gets a nonce only when it carries a script specific to this request: a page that `defer()`s streams
64
+ * one per settled value, and `unsafeInlineScript` names the nonce. Every other document is nonce-free.
65
+ * Its inline scripts are the constant ones nifra writes, listed by hash, so the CSP header is the
66
+ * same on every request and the page stays cacheable. Page data rides in an inert JSON script, which
67
+ * a CSP does not govern.
68
+ *
69
+ * ```ts
70
+ * const csp = createCspPolicy({
71
+ * header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`,
72
+ * })
73
+ * const app = createWebApp({ adapter, manifest, clientEntry, csp })
74
+ * ```
75
+ */
76
+ export function createCspPolicy(options: CreateCspPolicyOptions): CspPolicy {
77
+ if (typeof options?.header !== "function") {
78
+ throw new TypeError("[nifra/web] createCspPolicy requires a header function")
79
+ }
80
+ return {
81
+ [CSP_POLICY]: {
82
+ header: options.header,
83
+ // A UUID's hyphens are outside the CSP base64-value grammar; the remaining 122 random bits are
84
+ // plenty for a per-response nonce, on every fetch runtime.
85
+ generate: options.generate ?? (() => crypto.randomUUID().replaceAll("-", "")),
86
+ adapters: new WeakMap(),
87
+ },
88
+ }
89
+ }
90
+
91
+ /** True for a value made by {@link createCspPolicy}. */
92
+ export const isCspPolicy = (value: unknown): value is CspPolicy =>
93
+ typeof value === "object" && value !== null && CSP_POLICY in value
94
+
95
+ // A closing tag ends at any space, slash or `>` after its name, as a browser reads it.
96
+ const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script(?=[\s/>])[^>]*>/gi
97
+
98
+ /** The bodies of the executable inline scripts in a fragment of markup (no `src`, no inert type). */
99
+ const inlineScriptBodies = (html: string): string[] => {
100
+ const bodies: string[] = []
101
+ for (const match of html.matchAll(INLINE_SCRIPT)) {
102
+ const attrs = match[1] ?? ""
103
+ if (/\bsrc\s*=/i.test(attrs) || /\btype\s*=\s*["']?application\/(ld\+)?json/i.test(attrs))
104
+ continue
105
+ bodies.push(match[2] ?? "")
106
+ }
107
+ return bodies
108
+ }
109
+
110
+ const ENCODER = new TextEncoder()
111
+
112
+ /** A CSP hash source for one inline script body: `'sha256-<base64 of the UTF-8 bytes>'`. */
113
+ async function hashSource(body: string): Promise<string> {
114
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", ENCODER.encode(body)))
115
+ let binary = ""
116
+ for (const byte of digest) binary += String.fromCharCode(byte)
117
+ return `'sha256-${btoa(binary)}'`
118
+ }
119
+
120
+ /**
121
+ * The `'sha256-…'` sources for the constant inline scripts nifra writes into a document rendered
122
+ * with `adapter`: the pre-hydration form guard and the adapter's hydration head. For a CSP set outside
123
+ * the app (a proxy, a CDN rule); {@link createCspPolicy} computes the same list itself.
124
+ */
125
+ export async function nifraScriptHashes(adapter: RenderAdapter): Promise<readonly string[]> {
126
+ const bodies = [PRE_HYDRATION_GUARD, ...inlineScriptBodies(adapter.hydrationHead())]
127
+ return Promise.all([...new Set(bodies)].map(hashSource))
128
+ }
129
+
130
+ /**
131
+ * Compute (once per adapter) the policy's hash sources. Returns `undefined` once they are ready, so
132
+ * the renderer stays synchronous after the first document.
133
+ */
134
+ export function prepareCspPolicy(
135
+ policy: CspPolicy,
136
+ adapter: RenderAdapter,
137
+ ): Promise<void> | undefined {
138
+ const state = policy[CSP_POLICY]
139
+ const known = state.adapters.get(adapter)
140
+ if (known !== undefined) return known instanceof Promise ? known.then(() => undefined) : undefined
141
+ const head = adapter.hydrationHead()
142
+ const pending = nifraScriptHashes(adapter).then((hashes) => {
143
+ const sources = { head, sources: hashes.join(" ") }
144
+ state.adapters.set(adapter, sources)
145
+ return sources
146
+ })
147
+ state.adapters.set(adapter, pending)
148
+ // A failed digest must not pin a rejected promise: the next document retries.
149
+ pending.catch(() => state.adapters.delete(adapter))
150
+ return pending.then(() => undefined)
151
+ }
152
+
153
+ const preparedSources = (policy: CspPolicy, adapter: RenderAdapter): AdapterSources => {
154
+ const known = policy[CSP_POLICY].adapters.get(adapter)
155
+ if (known === undefined || known instanceof Promise) {
156
+ throw new Error("[nifra/web] CSP policy used before prepareCspPolicy resolved")
157
+ }
158
+ return known
159
+ }
160
+
161
+ /**
162
+ * The nonce one document carries under `policy`: `undefined` unless the document needs one -
163
+ * `needsNonce` (a deferred value or a head tag naming the nonce), or an adapter whose hydration head
164
+ * no longer matches the one the policy hashed. A needed nonce reuses the caller's, else is generated.
165
+ */
166
+ export function cspDocumentNonce(
167
+ policy: CspPolicy,
168
+ adapter: RenderAdapter,
169
+ hydrate: boolean,
170
+ needsNonce: boolean,
171
+ requested: string | undefined,
172
+ ): string | undefined {
173
+ const varies = hydrate && adapter.hydrationHead() !== preparedSources(policy, adapter).head
174
+ if (!needsNonce && !varies) return undefined
175
+ return requested ?? policy[CSP_POLICY].generate()
176
+ }
177
+
178
+ /** The Content-Security-Policy value for one document, or `undefined` when the policy sends none. */
179
+ export function cspHeaderValue(
180
+ policy: CspPolicy,
181
+ adapter: RenderAdapter,
182
+ nonce: string | undefined,
183
+ ): string | undefined {
184
+ const { sources } = preparedSources(policy, adapter)
185
+ const state = policy[CSP_POLICY]
186
+ return nonce === undefined
187
+ ? state.header({ sources })
188
+ : state.header({ sources: `${sources} 'nonce-${nonce}'`, nonce })
189
+ }
190
+
191
+ /** The per-request nonce source for a page executor under `policy`: generated eagerly so `meta` can
192
+ * name it in `unsafeInlineScript`, and dropped by the renderer from documents that do not need it. */
193
+ export const cspNonceResolver = (policy: CspPolicy): NonceResolver =>
194
+ createNonceResolver({ generate: () => policy[CSP_POLICY].generate() })