@nifrajs/web 3.5.0 → 4.0.2

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 +136 -35
  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 +503 -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 +394 -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 +80 -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 +248 -43
  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 +135 -36
  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 +638 -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 +682 -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 +119 -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 +257 -42
  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
@@ -193,6 +206,23 @@ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void>
193
206
  return Promise.all(promises).then(() => undefined)
194
207
  }
195
208
 
209
+ /**
210
+ * Resolve `href` against this page and keep it only when history may move there without a load: same
211
+ * scheme and host as the document, or null. Compared by protocol + host rather than `URL.origin`,
212
+ * which is `"null"` for every non-special scheme - a native webview's `capacitor://localhost` page and
213
+ * a `javascript:` target alike. An opaque or `file:` document may not push another path at all, so its
214
+ * links load natively.
215
+ */
216
+ function sameDocumentUrl(href: string): URL | null {
217
+ if (location.origin === "null" || location.protocol === "file:") return null
218
+ try {
219
+ const url = new URL(href, location.href)
220
+ return url.protocol === location.protocol && url.host === location.host ? url : null
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
196
226
  /**
197
227
  * Attach history + link interception to a router. Returns a teardown function that removes the
198
228
  * listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
@@ -220,6 +250,8 @@ export function installHistory(
220
250
  let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
221
251
  let here = location.pathname + location.search + (location.hash ?? "")
222
252
  let reversing = false
253
+ // The path the router was last seen loading, so a redirect it follows shows up as a change.
254
+ let expected: string | undefined
223
255
  type Registration = {
224
256
  readonly shouldBlock: BlockerFunction
225
257
  readonly emit: (blocker: Blocker) => void
@@ -273,7 +305,7 @@ export function installHistory(
273
305
 
274
306
  // Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
275
307
  const locationOf = (path: string): BlockerLocation => {
276
- const url = new URL(path, location.origin)
308
+ const url = new URL(path, location.href)
277
309
  return { pathname: url.pathname, search: url.search, hash: url.hash }
278
310
  }
279
311
 
@@ -317,17 +349,6 @@ export function installHistory(
317
349
  // Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
318
350
  // becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
319
351
  // router intentionally kept rendering the old route.
320
- const parseNavigationUrl = (path: string): URL | null => {
321
- try {
322
- const url = new URL(path, location.origin)
323
- if (url.protocol !== "http:" && url.protocol !== "https:") return null
324
- if (url.origin !== location.origin) return null
325
- return url
326
- } catch {
327
- return null
328
- }
329
- }
330
-
331
352
  const commit = (path: string, url: URL, mode: "push" | "replace", state?: unknown): void => {
332
353
  const routePath = url.pathname + url.search
333
354
  if (router.match(routePath) === null) {
@@ -355,15 +376,24 @@ export function installHistory(
355
376
  pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
356
377
  }
357
378
  here = url.pathname + url.search + url.hash
379
+ expected = routePath
358
380
  // 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)))
381
+ transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
360
382
  settle()
361
383
  }
362
384
 
385
+ // A redirect out of the app replaces the entry it answered, as the browser's own redirect would; any
386
+ // other failure loads the page as a document.
387
+ const leave = (error: unknown, path: string): void => {
388
+ const to = redirectOf(error)
389
+ if (to === undefined) fallback(path)
390
+ else location.replace(to)
391
+ }
392
+
363
393
  const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
364
394
  // Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
365
395
  // malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
366
- const url = parseNavigationUrl(path)
396
+ const url = sameDocumentUrl(path)
367
397
  if (url === null) {
368
398
  settle()
369
399
  return
@@ -384,7 +414,7 @@ export function installHistory(
384
414
  }
385
415
  go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
386
416
  }
387
- setBrowserNavigate(navigate)
417
+ setBrowserNavigate(navigate, router)
388
418
 
389
419
  // Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
390
420
  // adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
@@ -408,8 +438,8 @@ export function installHistory(
408
438
  if (anchor.target !== "" && anchor.target !== "_self") return null
409
439
  if (anchor.hasAttribute("download")) return null
410
440
  if (anchor.getAttribute("rel")?.split(/\s+/).includes("external")) return null
411
- const url = new URL(anchor.href)
412
- if (url.origin !== location.origin) return null
441
+ const url = sameDocumentUrl(anchor.href)
442
+ if (url === null) return null
413
443
  if (router.match(url.pathname) === null) return null
414
444
  // A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
415
445
  // browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
@@ -431,12 +461,45 @@ export function installHistory(
431
461
  go(href, "push")
432
462
  }
433
463
 
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)
464
+ // `data-nifra-prefetch` on a link or any ancestor (the nearest wins) says when the link warms its
465
+ // route's chunk and data: `intent` (the default) on hover or focus, `viewport` once it scrolls into
466
+ // view, `render` as soon as a page shows it, `none` never.
467
+ const prefetchModeOf = (el: Element): string | undefined =>
468
+ (el.closest("[data-nifra-prefetch]") as HTMLElement | null)?.dataset.nifraPrefetch
469
+
470
+ // Warm an in-app link's route (the store dedupes the spam). The page on screen has nothing to warm.
471
+ const warm = (target: EventTarget | null): void => {
472
+ const href = inAppHref(target)
437
473
  if (href === null) return
438
- const url = new URL(href, location.origin)
439
- void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
474
+ const url = new URL(href, location.href)
475
+ const path = url.pathname + url.search // the #hash isn't data
476
+ if (path !== location.pathname + location.search) void router.prefetch(path)
477
+ }
478
+
479
+ const onPrefetch = (event: Event): void => {
480
+ const target = event.target
481
+ if (target instanceof Element && prefetchModeOf(target) !== "none") warm(target)
482
+ }
483
+
484
+ // `viewport` and `render` links are looked for when history is installed (the server-rendered page)
485
+ // and whenever the router settles (a navigation, a submit's revalidation). A link the page adds in
486
+ // between warms on intent until the next settle.
487
+ let inView: IntersectionObserver | undefined
488
+ const scan = (): void => {
489
+ inView?.disconnect()
490
+ for (const anchor of document.querySelectorAll(
491
+ "a[data-nifra-prefetch],[data-nifra-prefetch] a",
492
+ )) {
493
+ const mode = prefetchModeOf(anchor)
494
+ if (mode === "render") warm(anchor)
495
+ else if (mode === "viewport") {
496
+ // Scrolling back into view asks again; the store holds a fresh prefetch, so nothing refetches.
497
+ inView ??= new IntersectionObserver((entries) => {
498
+ for (const entry of entries) if (entry.isIntersecting) warm(entry.target)
499
+ })
500
+ inView.observe(anchor)
501
+ }
502
+ }
440
503
  }
441
504
 
442
505
  // Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
@@ -460,9 +523,9 @@ export function installHistory(
460
523
  index = newIndex
461
524
  here = dest
462
525
  pendingScroll = { pos: scrollOf(history.state) }
463
- transition(() =>
464
- router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
465
- )
526
+ const routePath = location.pathname + location.search
527
+ expected = routePath
528
+ transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
466
529
  settle()
467
530
  }
468
531
 
@@ -484,24 +547,50 @@ export function installHistory(
484
547
  event.returnValue = ""
485
548
  }
486
549
 
550
+ // The router is loading a page the address bar does not show: a redirect it follows. That replaces the
551
+ // entry a navigation pushed, or adds one after a form post, as the browser would. A redirect from a
552
+ // form post back to the page it was on adds nothing.
553
+ const follow = (path: string): void => {
554
+ if (path !== location.pathname + location.search) {
555
+ if (expected === undefined) {
556
+ history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
557
+ index += 1
558
+ history.pushState({ nifraIndex: index }, "", path)
559
+ } else {
560
+ history.replaceState(history.state, "", path + location.hash)
561
+ }
562
+ here = location.pathname + location.search + location.hash
563
+ pendingScroll = location.hash !== "" ? { hash: hashId(location.hash) } : { pos: [0, 0] }
564
+ }
565
+ expected = path
566
+ }
567
+
487
568
  // After a navigation settles (content rendered), apply the pending scroll target on the next frame:
488
569
  // 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
570
+ // A settle with no navigation (a submit's revalidation) scrolls nothing. Either way the rendered
571
+ // page is then scanned for links to warm.
572
+ const settled = (): void => {
573
+ const { pending, pendingPath } = router.snapshot()
574
+ if (pending) {
575
+ if (pendingPath !== undefined && pendingPath !== expected) follow(pendingPath)
576
+ return
577
+ }
578
+ expected = undefined
492
579
  const target = pendingScroll
493
580
  pendingScroll = null
494
581
  requestAnimationFrame(() => {
495
- if ("hash" in target) {
582
+ if (target !== null && "hash" in target) {
496
583
  const el = findAnchor(target.hash)
497
584
  if (el !== null) el.scrollIntoView()
498
585
  else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
499
- } else {
586
+ } else if (target !== null) {
500
587
  window.scrollTo(target.pos[0], target.pos[1])
501
588
  }
589
+ scan()
502
590
  })
503
591
  }
504
- const unsubscribe = router.subscribe(restoreScroll)
592
+ const unsubscribe = router.subscribe(settled)
593
+ scan()
505
594
 
506
595
  document.addEventListener("click", onClick)
507
596
  document.addEventListener("pointerover", onPrefetch)
@@ -510,6 +599,7 @@ export function installHistory(
510
599
  window.addEventListener("beforeunload", onBeforeUnload)
511
600
  return () => {
512
601
  unsubscribe()
602
+ inView?.disconnect()
513
603
  setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
514
604
  setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
515
605
  document.removeEventListener("click", onClick)
@@ -532,6 +622,7 @@ export function installHistory(
532
622
  * the first navigation cleanly takes over from the server-rendered head.
533
623
  */
534
624
  export function applyHead(head: Meta): void {
625
+ const documentNonce = currentDocumentNonce()
535
626
  if (head.title !== undefined) document.title = head.title
536
627
  // `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
537
628
  // route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
@@ -584,6 +675,11 @@ export function applyHead(head: Meta): void {
584
675
  `[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
585
676
  )
586
677
  }
678
+ if (documentNonce !== undefined && s.nonce !== documentNonce) {
679
+ throw new TypeError(
680
+ "[nifra/web] executable head scripts must use the same CSP nonce as the document",
681
+ )
682
+ }
587
683
  const el = document.createElement("script")
588
684
  el.setAttribute("type", s.type)
589
685
  el.setAttribute("nonce", s.nonce)
@@ -599,15 +695,18 @@ export function installForms(router: ClientRouter): () => void {
599
695
  const form = event.target
600
696
  if (!(form instanceof HTMLFormElement)) return
601
697
  if (form.method.toLowerCase() !== "post") return // GET forms submit natively
602
- const url = new URL(form.action) // resolved (or the current document URL if unset)
603
- if (url.origin !== location.origin) return
698
+ const url = sameDocumentUrl(form.action) // resolved (or the current document URL if unset)
699
+ if (url === null) return
604
700
  if (router.match(url.pathname) === null) return // not an app route → native submit
605
701
  event.preventDefault()
606
702
  // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
607
703
  // actionData drives the update); absent or any other value keeps the default revalidation.
608
704
  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
705
+ router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch((error) => {
706
+ const to = redirectOf(error)
707
+ // The action ran: load the page that shows its result rather than run it again.
708
+ if (to !== undefined) location.assign(to)
709
+ else form.submit() // data submit failed - fall back to a full-page POST
611
710
  })
612
711
  }
613
712
  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 {