@nifrajs/web 5.5.0 → 5.6.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 (457) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/boundary.d.ts +67 -7
  3. package/dist/boundary.d.ts.map +1 -1
  4. package/dist/boundary.js +6 -4
  5. package/dist/boundary.js.map +1 -1
  6. package/dist/build-plan.d.ts +3 -0
  7. package/dist/build-plan.d.ts.map +1 -1
  8. package/dist/build-plan.js.map +1 -1
  9. package/dist/build-vite.d.ts.map +1 -1
  10. package/dist/build-vite.js +2 -0
  11. package/dist/build-vite.js.map +1 -1
  12. package/dist/build.d.ts +9 -2
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +9 -0
  15. package/dist/build.js.map +1 -1
  16. package/dist/client.d.ts +2 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +140 -12
  19. package/dist/client.js.map +1 -1
  20. package/dist/csp.d.ts +8 -0
  21. package/dist/csp.d.ts.map +1 -1
  22. package/dist/csp.js +11 -2
  23. package/dist/csp.js.map +1 -1
  24. package/dist/deferred.d.ts +6 -4
  25. package/dist/deferred.d.ts.map +1 -1
  26. package/dist/deferred.js +10 -6
  27. package/dist/deferred.js.map +1 -1
  28. package/dist/dev-feed.d.ts.map +1 -1
  29. package/dist/dev-feed.js +11 -2
  30. package/dist/dev-feed.js.map +1 -1
  31. package/dist/dev-session.d.ts.map +1 -1
  32. package/dist/dev-session.js +13 -6
  33. package/dist/dev-session.js.map +1 -1
  34. package/dist/dev.d.ts +4 -1
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +12 -5
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic-catalog.d.ts +4 -0
  39. package/dist/diagnostic-catalog.d.ts.map +1 -1
  40. package/dist/diagnostic-catalog.js +48 -0
  41. package/dist/diagnostic-catalog.js.map +1 -1
  42. package/dist/diagnostic.d.ts +1 -1
  43. package/dist/diagnostic.d.ts.map +1 -1
  44. package/dist/diagnostic.js +1 -1
  45. package/dist/diagnostic.js.map +1 -1
  46. package/dist/fn.d.ts +5 -3
  47. package/dist/fn.d.ts.map +1 -1
  48. package/dist/fn.js +20 -5
  49. package/dist/fn.js.map +1 -1
  50. package/dist/fs.d.ts.map +1 -1
  51. package/dist/fs.js +13 -3
  52. package/dist/fs.js.map +1 -1
  53. package/dist/identity.d.ts +22 -0
  54. package/dist/identity.d.ts.map +1 -0
  55. package/dist/identity.js +60 -0
  56. package/dist/identity.js.map +1 -0
  57. package/dist/index.d.ts +7 -6
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/internal/cdn/cloudflare.d.ts +2 -1
  62. package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
  63. package/dist/internal/cdn/cloudflare.js +2 -1
  64. package/dist/internal/cdn/cloudflare.js.map +1 -1
  65. package/dist/internal/certified-components.d.ts +1 -1
  66. package/dist/internal/certified-components.js +1 -1
  67. package/dist/internal/codegen.d.ts +11 -10
  68. package/dist/internal/codegen.d.ts.map +1 -1
  69. package/dist/internal/codegen.js +65 -8
  70. package/dist/internal/codegen.js.map +1 -1
  71. package/dist/internal/dev-websocket.d.ts +73 -0
  72. package/dist/internal/dev-websocket.d.ts.map +1 -0
  73. package/dist/internal/dev-websocket.js +145 -0
  74. package/dist/internal/dev-websocket.js.map +1 -0
  75. package/dist/internal/hole-runtime.d.ts +66 -0
  76. package/dist/internal/hole-runtime.d.ts.map +1 -0
  77. package/dist/internal/hole-runtime.js +211 -0
  78. package/dist/internal/hole-runtime.js.map +1 -0
  79. package/dist/internal/identity-epoch.d.ts +50 -0
  80. package/dist/internal/identity-epoch.d.ts.map +1 -0
  81. package/dist/internal/identity-epoch.js +179 -0
  82. package/dist/internal/identity-epoch.js.map +1 -0
  83. package/dist/internal/invalidation-contract.d.ts +23 -0
  84. package/dist/internal/invalidation-contract.d.ts.map +1 -0
  85. package/dist/internal/invalidation-contract.js +11 -0
  86. package/dist/internal/invalidation-contract.js.map +1 -0
  87. package/dist/internal/invalidation.d.ts +62 -0
  88. package/dist/internal/invalidation.d.ts.map +1 -0
  89. package/dist/internal/invalidation.js +149 -0
  90. package/dist/internal/invalidation.js.map +1 -0
  91. package/dist/internal/island-bindings.d.ts +51 -0
  92. package/dist/internal/island-bindings.d.ts.map +1 -0
  93. package/dist/internal/island-bindings.js +147 -0
  94. package/dist/internal/island-bindings.js.map +1 -0
  95. package/dist/internal/island-list.d.ts +26 -0
  96. package/dist/internal/island-list.d.ts.map +1 -0
  97. package/dist/internal/island-list.js +78 -0
  98. package/dist/internal/island-list.js.map +1 -0
  99. package/dist/internal/island-mount.d.ts +4 -0
  100. package/dist/internal/island-mount.d.ts.map +1 -0
  101. package/dist/internal/island-mount.js +66 -0
  102. package/dist/internal/island-mount.js.map +1 -0
  103. package/dist/internal/island-owner.d.ts +5 -0
  104. package/dist/internal/island-owner.d.ts.map +1 -0
  105. package/dist/internal/island-owner.js +20 -0
  106. package/dist/internal/island-owner.js.map +1 -0
  107. package/dist/internal/island-resource.d.ts +30 -0
  108. package/dist/internal/island-resource.d.ts.map +1 -0
  109. package/dist/internal/island-resource.js +57 -0
  110. package/dist/internal/island-resource.js.map +1 -0
  111. package/dist/internal/island-signals.d.ts +51 -0
  112. package/dist/internal/island-signals.d.ts.map +1 -0
  113. package/dist/internal/island-signals.js +220 -0
  114. package/dist/internal/island-signals.js.map +1 -0
  115. package/dist/internal/keep-alive-table.d.ts +23 -0
  116. package/dist/internal/keep-alive-table.d.ts.map +1 -0
  117. package/dist/internal/keep-alive-table.js +53 -0
  118. package/dist/internal/keep-alive-table.js.map +1 -0
  119. package/dist/internal/live-contract.d.ts +96 -0
  120. package/dist/internal/live-contract.d.ts.map +1 -0
  121. package/dist/internal/live-contract.js +5 -0
  122. package/dist/internal/live-contract.js.map +1 -0
  123. package/dist/internal/live-queries.d.ts +22 -0
  124. package/dist/internal/live-queries.d.ts.map +1 -0
  125. package/dist/internal/live-queries.js +54 -0
  126. package/dist/internal/live-queries.js.map +1 -0
  127. package/dist/internal/page-events.d.ts +10 -0
  128. package/dist/internal/page-events.d.ts.map +1 -0
  129. package/dist/internal/page-events.js +26 -0
  130. package/dist/internal/page-events.js.map +1 -0
  131. package/dist/internal/page-execution.d.ts +14 -1
  132. package/dist/internal/page-execution.d.ts.map +1 -1
  133. package/dist/internal/page-execution.js +415 -31
  134. package/dist/internal/page-execution.js.map +1 -1
  135. package/dist/internal/ppr/admission.d.ts +83 -0
  136. package/dist/internal/ppr/admission.d.ts.map +1 -0
  137. package/dist/internal/ppr/admission.js +221 -0
  138. package/dist/internal/ppr/admission.js.map +1 -0
  139. package/dist/internal/ppr/canary.d.ts +24 -0
  140. package/dist/internal/ppr/canary.d.ts.map +1 -0
  141. package/dist/internal/ppr/canary.js +102 -0
  142. package/dist/internal/ppr/canary.js.map +1 -0
  143. package/dist/internal/ppr/compile-shell.d.ts +39 -0
  144. package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
  145. package/dist/internal/ppr/compile-shell.js +298 -0
  146. package/dist/internal/ppr/compile-shell.js.map +1 -0
  147. package/dist/internal/ppr/contract.d.ts +177 -0
  148. package/dist/internal/ppr/contract.d.ts.map +1 -0
  149. package/dist/internal/ppr/contract.js +80 -0
  150. package/dist/internal/ppr/contract.js.map +1 -0
  151. package/dist/internal/ppr/frames.d.ts +71 -0
  152. package/dist/internal/ppr/frames.d.ts.map +1 -0
  153. package/dist/internal/ppr/frames.js +119 -0
  154. package/dist/internal/ppr/frames.js.map +1 -0
  155. package/dist/internal/ppr/integration.d.ts +19 -0
  156. package/dist/internal/ppr/integration.d.ts.map +1 -0
  157. package/dist/internal/ppr/integration.js +106 -0
  158. package/dist/internal/ppr/integration.js.map +1 -0
  159. package/dist/internal/ppr/options.d.ts +96 -0
  160. package/dist/internal/ppr/options.d.ts.map +1 -0
  161. package/dist/internal/ppr/options.js +125 -0
  162. package/dist/internal/ppr/options.js.map +1 -0
  163. package/dist/internal/ppr/route-rules.d.ts +12 -0
  164. package/dist/internal/ppr/route-rules.d.ts.map +1 -0
  165. package/dist/internal/ppr/route-rules.js +53 -0
  166. package/dist/internal/ppr/route-rules.js.map +1 -0
  167. package/dist/internal/ppr/runtime.d.ts +29 -0
  168. package/dist/internal/ppr/runtime.d.ts.map +1 -0
  169. package/dist/internal/ppr/runtime.js +32 -0
  170. package/dist/internal/ppr/runtime.js.map +1 -0
  171. package/dist/internal/ppr/server.d.ts +29 -0
  172. package/dist/internal/ppr/server.d.ts.map +1 -0
  173. package/dist/internal/ppr/server.js +1407 -0
  174. package/dist/internal/ppr/server.js.map +1 -0
  175. package/dist/internal/ppr/shell-store.d.ts +38 -0
  176. package/dist/internal/ppr/shell-store.d.ts.map +1 -0
  177. package/dist/internal/ppr/shell-store.js +90 -0
  178. package/dist/internal/ppr/shell-store.js.map +1 -0
  179. package/dist/internal/prerendered-pages.d.ts +1 -1
  180. package/dist/internal/prerendered-pages.d.ts.map +1 -1
  181. package/dist/internal/prerendered-pages.js +7 -4
  182. package/dist/internal/prerendered-pages.js.map +1 -1
  183. package/dist/internal/query-digest.d.ts +9 -0
  184. package/dist/internal/query-digest.d.ts.map +1 -0
  185. package/dist/internal/query-digest.js +23 -0
  186. package/dist/internal/query-digest.js.map +1 -0
  187. package/dist/internal/query-runtime.d.ts +6 -1
  188. package/dist/internal/query-runtime.d.ts.map +1 -1
  189. package/dist/internal/query-runtime.js +9 -0
  190. package/dist/internal/query-runtime.js.map +1 -1
  191. package/dist/internal/query-seam.d.ts +29 -0
  192. package/dist/internal/query-seam.d.ts.map +1 -0
  193. package/dist/internal/query-seam.js +23 -0
  194. package/dist/internal/query-seam.js.map +1 -0
  195. package/dist/internal/render-document.d.ts +28 -1
  196. package/dist/internal/render-document.d.ts.map +1 -1
  197. package/dist/internal/render-document.js +83 -18
  198. package/dist/internal/render-document.js.map +1 -1
  199. package/dist/internal/request-abort.d.ts +8 -0
  200. package/dist/internal/request-abort.d.ts.map +1 -0
  201. package/dist/internal/request-abort.js +22 -0
  202. package/dist/internal/request-abort.js.map +1 -0
  203. package/dist/internal/server-boundary.d.ts +2 -0
  204. package/dist/internal/server-boundary.d.ts.map +1 -1
  205. package/dist/internal/server-boundary.js +8 -1
  206. package/dist/internal/server-boundary.js.map +1 -1
  207. package/dist/internal/share-data.d.ts +10 -0
  208. package/dist/internal/share-data.d.ts.map +1 -0
  209. package/dist/internal/share-data.js +135 -0
  210. package/dist/internal/share-data.js.map +1 -0
  211. package/dist/internal/store-signature.d.ts +15 -0
  212. package/dist/internal/store-signature.d.ts.map +1 -0
  213. package/dist/internal/store-signature.js +114 -0
  214. package/dist/internal/store-signature.js.map +1 -0
  215. package/dist/internal/vite-plugin-compat.d.ts +2 -2
  216. package/dist/internal/vite-plugin-compat.js +2 -2
  217. package/dist/internal/web-app.d.ts +25 -0
  218. package/dist/internal/web-app.d.ts.map +1 -1
  219. package/dist/internal/web-app.js +78 -3
  220. package/dist/internal/web-app.js.map +1 -1
  221. package/dist/islands-swap.d.ts +41 -0
  222. package/dist/islands-swap.d.ts.map +1 -0
  223. package/dist/islands-swap.js +270 -0
  224. package/dist/islands-swap.js.map +1 -0
  225. package/dist/islands.d.ts +86 -24
  226. package/dist/islands.d.ts.map +1 -1
  227. package/dist/islands.js +138 -78
  228. package/dist/islands.js.map +1 -1
  229. package/dist/isr.d.ts +67 -13
  230. package/dist/isr.d.ts.map +1 -1
  231. package/dist/isr.js +247 -99
  232. package/dist/isr.js.map +1 -1
  233. package/dist/keep-alive.d.ts +147 -0
  234. package/dist/keep-alive.d.ts.map +1 -0
  235. package/dist/keep-alive.js +1069 -0
  236. package/dist/keep-alive.js.map +1 -0
  237. package/dist/live/bus.d.ts +56 -0
  238. package/dist/live/bus.d.ts.map +1 -0
  239. package/dist/live/bus.js +32 -0
  240. package/dist/live/bus.js.map +1 -0
  241. package/dist/live/core.d.ts +25 -0
  242. package/dist/live/core.d.ts.map +1 -0
  243. package/dist/live/core.js +29 -0
  244. package/dist/live/core.js.map +1 -0
  245. package/dist/live/history.d.ts +38 -0
  246. package/dist/live/history.d.ts.map +1 -0
  247. package/dist/live/history.js +104 -0
  248. package/dist/live/history.js.map +1 -0
  249. package/dist/live/hub-protocol.d.ts +46 -0
  250. package/dist/live/hub-protocol.d.ts.map +1 -0
  251. package/dist/live/hub-protocol.js +108 -0
  252. package/dist/live/hub-protocol.js.map +1 -0
  253. package/dist/live/hub.d.ts +181 -0
  254. package/dist/live/hub.d.ts.map +1 -0
  255. package/dist/live/hub.js +539 -0
  256. package/dist/live/hub.js.map +1 -0
  257. package/dist/live/keyring.d.ts +87 -0
  258. package/dist/live/keyring.d.ts.map +1 -0
  259. package/dist/live/keyring.js +260 -0
  260. package/dist/live/keyring.js.map +1 -0
  261. package/dist/live/read-capped.d.ts +6 -0
  262. package/dist/live/read-capped.d.ts.map +1 -0
  263. package/dist/live/read-capped.js +42 -0
  264. package/dist/live/read-capped.js.map +1 -0
  265. package/dist/live/revocations.d.ts +52 -0
  266. package/dist/live/revocations.d.ts.map +1 -0
  267. package/dist/live/revocations.js +116 -0
  268. package/dist/live/revocations.js.map +1 -0
  269. package/dist/live/settings.d.ts +14 -0
  270. package/dist/live/settings.d.ts.map +1 -0
  271. package/dist/live/settings.js +38 -0
  272. package/dist/live/settings.js.map +1 -0
  273. package/dist/live-client.d.ts +34 -0
  274. package/dist/live-client.d.ts.map +1 -0
  275. package/dist/live-client.js +840 -0
  276. package/dist/live-client.js.map +1 -0
  277. package/dist/live-hub.d.ts +130 -0
  278. package/dist/live-hub.d.ts.map +1 -0
  279. package/dist/live-hub.js +507 -0
  280. package/dist/live-hub.js.map +1 -0
  281. package/dist/live-redis.d.ts +48 -0
  282. package/dist/live-redis.d.ts.map +1 -0
  283. package/dist/live-redis.js +201 -0
  284. package/dist/live-redis.js.map +1 -0
  285. package/dist/live.d.ts +94 -0
  286. package/dist/live.d.ts.map +1 -0
  287. package/dist/live.js +869 -0
  288. package/dist/live.js.map +1 -0
  289. package/dist/manifest.d.ts +113 -2
  290. package/dist/manifest.d.ts.map +1 -1
  291. package/dist/manifest.js +109 -5
  292. package/dist/manifest.js.map +1 -1
  293. package/dist/navigation.d.ts +9 -3
  294. package/dist/navigation.d.ts.map +1 -1
  295. package/dist/navigation.js +13 -3
  296. package/dist/navigation.js.map +1 -1
  297. package/dist/plugins/vite-zone-guard.d.ts +6 -0
  298. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  299. package/dist/plugins/vite-zone-guard.js +8 -2
  300. package/dist/plugins/vite-zone-guard.js.map +1 -1
  301. package/dist/ppr.d.ts +73 -0
  302. package/dist/ppr.d.ts.map +1 -0
  303. package/dist/ppr.js +76 -0
  304. package/dist/ppr.js.map +1 -0
  305. package/dist/query.d.ts +8 -13
  306. package/dist/query.d.ts.map +1 -1
  307. package/dist/query.js +112 -18
  308. package/dist/query.js.map +1 -1
  309. package/dist/render-seam.d.ts +30 -0
  310. package/dist/render-seam.d.ts.map +1 -1
  311. package/dist/render-seam.js +5 -0
  312. package/dist/render-seam.js.map +1 -1
  313. package/dist/route-types.d.ts.map +1 -1
  314. package/dist/route-types.js +2 -2
  315. package/dist/route-types.js.map +1 -1
  316. package/dist/router.d.ts +72 -4
  317. package/dist/router.d.ts.map +1 -1
  318. package/dist/router.js +374 -99
  319. package/dist/router.js.map +1 -1
  320. package/dist/server-components/build.d.ts.map +1 -1
  321. package/dist/server-components/build.js +3 -3
  322. package/dist/server-components/build.js.map +1 -1
  323. package/dist/server-components/client.d.ts +23 -1
  324. package/dist/server-components/client.d.ts.map +1 -1
  325. package/dist/server-components/client.js +92 -5
  326. package/dist/server-components/client.js.map +1 -1
  327. package/dist/server-components/hole.d.ts +8 -0
  328. package/dist/server-components/hole.d.ts.map +1 -0
  329. package/dist/server-components/hole.js +20 -0
  330. package/dist/server-components/hole.js.map +1 -0
  331. package/dist/server-components/html-policy.d.ts.map +1 -1
  332. package/dist/server-components/html-policy.js +62 -4
  333. package/dist/server-components/html-policy.js.map +1 -1
  334. package/dist/server-components/html-scan.d.ts +17 -0
  335. package/dist/server-components/html-scan.d.ts.map +1 -0
  336. package/dist/server-components/html-scan.js +180 -0
  337. package/dist/server-components/html-scan.js.map +1 -0
  338. package/dist/server-components/html.d.ts.map +1 -1
  339. package/dist/server-components/html.js +19 -4
  340. package/dist/server-components/html.js.map +1 -1
  341. package/dist/server-components/router-integration.d.ts +4 -1
  342. package/dist/server-components/router-integration.d.ts.map +1 -1
  343. package/dist/server-components/router-integration.js +23 -2
  344. package/dist/server-components/router-integration.js.map +1 -1
  345. package/dist/server-components/runtime.d.ts +6 -1
  346. package/dist/server-components/runtime.d.ts.map +1 -1
  347. package/dist/server-components/runtime.js +86 -28
  348. package/dist/server-components/runtime.js.map +1 -1
  349. package/dist/server-components/state.d.ts +3 -1
  350. package/dist/server-components/state.d.ts.map +1 -1
  351. package/dist/server-components/state.js +50 -38
  352. package/dist/server-components/state.js.map +1 -1
  353. package/dist/server-components/types.d.ts +9 -0
  354. package/dist/server-components/types.d.ts.map +1 -1
  355. package/dist/vite.d.ts +5 -1
  356. package/dist/vite.d.ts.map +1 -1
  357. package/dist/vite.js +58 -4
  358. package/dist/vite.js.map +1 -1
  359. package/package.json +48 -8
  360. package/src/boundary.ts +77 -5
  361. package/src/build-plan.ts +3 -0
  362. package/src/build-vite.ts +2 -0
  363. package/src/build.ts +19 -1
  364. package/src/client.ts +147 -10
  365. package/src/csp.ts +16 -2
  366. package/src/deferred.ts +11 -5
  367. package/src/dev-feed.ts +11 -2
  368. package/src/dev-session.ts +22 -11
  369. package/src/dev.ts +29 -8
  370. package/src/diagnostic-catalog.ts +53 -0
  371. package/src/diagnostic.ts +1 -0
  372. package/src/fn.ts +24 -7
  373. package/src/fs.ts +22 -8
  374. package/src/identity.ts +61 -0
  375. package/src/index.ts +19 -0
  376. package/src/internal/cdn/cloudflare.ts +2 -1
  377. package/src/internal/certified-components.ts +1 -1
  378. package/src/internal/codegen.ts +87 -8
  379. package/src/internal/dev-websocket.ts +230 -0
  380. package/src/internal/hole-runtime.ts +286 -0
  381. package/src/internal/identity-epoch.ts +235 -0
  382. package/src/internal/invalidation-contract.ts +35 -0
  383. package/src/internal/invalidation.ts +213 -0
  384. package/src/internal/island-bindings.ts +191 -0
  385. package/src/internal/island-list.ts +92 -0
  386. package/src/internal/island-mount.ts +68 -0
  387. package/src/internal/island-owner.ts +20 -0
  388. package/src/internal/island-resource.ts +76 -0
  389. package/src/internal/island-signals.ts +249 -0
  390. package/src/internal/keep-alive-table.ts +72 -0
  391. package/src/internal/live-contract.ts +106 -0
  392. package/src/internal/live-queries.ts +60 -0
  393. package/src/internal/page-events.ts +27 -0
  394. package/src/internal/page-execution.ts +579 -40
  395. package/src/internal/ppr/admission.ts +300 -0
  396. package/src/internal/ppr/canary.ts +122 -0
  397. package/src/internal/ppr/compile-shell.ts +379 -0
  398. package/src/internal/ppr/contract.ts +265 -0
  399. package/src/internal/ppr/frames.ts +170 -0
  400. package/src/internal/ppr/integration.ts +137 -0
  401. package/src/internal/ppr/options.ts +221 -0
  402. package/src/internal/ppr/route-rules.ts +77 -0
  403. package/src/internal/ppr/runtime.ts +36 -0
  404. package/src/internal/ppr/server.ts +1764 -0
  405. package/src/internal/ppr/shell-store.ts +139 -0
  406. package/src/internal/prerendered-pages.ts +7 -4
  407. package/src/internal/query-digest.ts +34 -0
  408. package/src/internal/query-runtime.ts +14 -1
  409. package/src/internal/query-seam.ts +48 -0
  410. package/src/internal/render-document.ts +149 -22
  411. package/src/internal/request-abort.ts +26 -0
  412. package/src/internal/server-boundary.ts +10 -1
  413. package/src/internal/share-data.ts +136 -0
  414. package/src/internal/store-signature.ts +149 -0
  415. package/src/internal/vite-plugin-compat.ts +2 -2
  416. package/src/internal/web-app.ts +121 -3
  417. package/src/islands-swap.ts +283 -0
  418. package/src/islands.ts +169 -72
  419. package/src/isr.ts +348 -106
  420. package/src/keep-alive.ts +1315 -0
  421. package/src/live/bus.ts +77 -0
  422. package/src/live/core.ts +52 -0
  423. package/src/live/history.ts +109 -0
  424. package/src/live/hub-protocol.ts +133 -0
  425. package/src/live/hub.ts +615 -0
  426. package/src/live/keyring.ts +379 -0
  427. package/src/live/read-capped.ts +36 -0
  428. package/src/live/revocations.ts +155 -0
  429. package/src/live/settings.ts +46 -0
  430. package/src/live-client.ts +973 -0
  431. package/src/live-hub.ts +658 -0
  432. package/src/live-redis.ts +259 -0
  433. package/src/live.ts +1096 -0
  434. package/src/manifest.ts +274 -6
  435. package/src/navigation.ts +14 -2
  436. package/src/plugins/vite-zone-guard.ts +15 -2
  437. package/src/ppr.ts +153 -0
  438. package/src/query.ts +147 -20
  439. package/src/render-seam.ts +35 -0
  440. package/src/route-types.ts +3 -1
  441. package/src/router.ts +494 -101
  442. package/src/server-components/build.ts +9 -3
  443. package/src/server-components/client.ts +89 -5
  444. package/src/server-components/hole.ts +23 -0
  445. package/src/server-components/html-policy.ts +63 -4
  446. package/src/server-components/html-scan.ts +159 -0
  447. package/src/server-components/html.ts +18 -4
  448. package/src/server-components/router-integration.ts +21 -2
  449. package/src/server-components/runtime.ts +112 -30
  450. package/src/server-components/state.ts +66 -44
  451. package/src/server-components/types.ts +16 -0
  452. package/src/vite.ts +75 -6
  453. package/dist/nano.d.ts +0 -125
  454. package/dist/nano.d.ts.map +0 -1
  455. package/dist/nano.js +0 -190
  456. package/dist/nano.js.map +0 -1
  457. package/src/nano.ts +0 -246
package/src/isr.ts CHANGED
@@ -9,7 +9,21 @@
9
9
  import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
10
10
  import { readBoundedBytes, TransportCodecError } from "@nifrajs/core/transport-codec"
11
11
  import { isDraftEnabled } from "./draft.ts"
12
+ import {
13
+ type InvalidationReport,
14
+ type InvalidationTargets,
15
+ runInvalidation,
16
+ settleInvalidation,
17
+ } from "./internal/invalidation.ts"
18
+ import { INVALIDATION, type InvalidationHandoff } from "./internal/invalidation-contract.ts"
19
+ import {
20
+ isStoreSecret,
21
+ MIN_STORE_SECRET_LENGTH,
22
+ type StoreSigner,
23
+ storeSigner,
24
+ } from "./internal/store-signature.ts"
12
25
  import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
26
+ import { SWAP_HEADER } from "./render-seam.ts"
13
27
 
14
28
  /** A cached SSR response - the bytes + metadata a {@link CacheStore} persists. */
15
29
  export interface CachedResponse {
@@ -48,7 +62,9 @@ export interface CacheStore {
48
62
  * The current point in this store's purge order. `withISR` takes one before each render and hands it
49
63
  * back as {@link CachedResponse.fence}, and `set` skips that entry when its key or one of its tags
50
64
  * was purged after the point. Without it, a purge that lands while a page renders is undone when the
51
- * render stores the page it built from data read before the purge.
65
+ * render stores the page it built from data read before the purge. A store whose purges reach other
66
+ * instances late must also check on read, as {@link KVCacheStore} does: `set` there cannot see a
67
+ * purge that has not arrived yet.
52
68
  */
53
69
  readonly fence?: () => number | Promise<number>
54
70
  }
@@ -94,6 +110,10 @@ export function serializeISRTags(tags: readonly string[]): string | undefined {
94
110
  /** What a `revalidateTags` function is given: the URL and route params only, both already public. */
95
111
  export interface RevalidateTagsInput {
96
112
  readonly params: Readonly<Record<string, string>>
113
+ /**
114
+ * The request's URL. On a partially prerendered route and its shared holes, the shell's: the
115
+ * configured origin, the path and the declared variations, never the visitor's Host or other query.
116
+ */
97
117
  readonly url: URL
98
118
  }
99
119
 
@@ -144,6 +164,9 @@ export interface MemoryCacheStoreOptions {
144
164
  readonly max?: number
145
165
  }
146
166
 
167
+ // Nothing outside this process can write these, so `withISR` keeps their pages unsigned.
168
+ const inProcessStores = new WeakSet<CacheStore>()
169
+
147
170
  /**
148
171
  * In-process ISR cache. Refuses to run in production unless explicitly allowed (mirrors the
149
172
  * rate-limit `MemoryStore` - a per-instance cache is unsafe across instances). Bounded **LRU**: a
@@ -177,6 +200,7 @@ export class MemoryCacheStore implements CacheStore {
177
200
  if (!Number.isSafeInteger(this.max) || this.max <= 0) {
178
201
  throw new RangeError("[nifra/web] MemoryCacheStore max must be a positive safe integer")
179
202
  }
203
+ inProcessStores.add(this)
180
204
  }
181
205
 
182
206
  get(key: string): Promise<CachedResponse | undefined> {
@@ -360,25 +384,15 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
360
384
  }
361
385
 
362
386
  const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
363
- const KV_PURGED_AT = "__nifraPurgedAt"
364
-
365
- /** When a KV page key was purged, from the marker `delete` leaves; `-Infinity` for a page or nothing. */
366
- function kvPurgedAt(raw: string | null): number {
367
- if (raw === null || !raw.includes(KV_PURGED_AT)) return Number.NEGATIVE_INFINITY
368
- try {
369
- const parsed: unknown = JSON.parse(raw)
370
- if (typeof parsed === "object" && parsed !== null && KV_PURGED_AT in parsed) {
371
- const at = parsed[KV_PURGED_AT]
372
- if (typeof at === "number") return at
373
- }
374
- } catch {
375
- // Not a marker.
376
- }
377
- return Number.NEGATIVE_INFINITY
378
- }
379
-
380
- /** When a tag epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time is older
381
- * than any render. */
387
+ const KV_PATH_EPOCH_PREFIX = "__nifra_isr_path_v1:"
388
+ const KV_PATH_DIGEST_PREFIX = "__nifra_isr_path_v1#"
389
+ /** Cloudflare KV's key limit, in UTF-8 bytes. */
390
+ const KV_KEY_LIMIT = 512
391
+ /** How long (seconds) a path epoch outlives the store's `expirationTtl`. */
392
+ const PATH_EPOCH_SLACK = 86_400
393
+
394
+ /** When a tag or path epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time
395
+ * is older than any render. */
382
396
  function kvEpochTime(epoch: string): number {
383
397
  const at = epoch.indexOf(":")
384
398
  const time = at === -1 ? Number.NaN : Number(epoch.slice(0, at))
@@ -395,8 +409,10 @@ const kvClock = (): number => {
395
409
  return lastKvTime
396
410
  }
397
411
 
398
- type StoredTaggedResponse = CachedResponse & {
412
+ type StoredResponse = CachedResponse & {
399
413
  readonly __nifraTagEpochs?: Readonly<Record<string, string>>
414
+ /** Absent while the path was never purged. */
415
+ readonly __nifraPathEpoch?: string
400
416
  }
401
417
 
402
418
  /**
@@ -404,7 +420,8 @@ type StoredTaggedResponse = CachedResponse & {
404
420
  * binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
405
421
  * hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
406
422
  * to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
407
- * a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
423
+ * a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`,
424
+ * and give `withISR` the `storeSecret` it signs these pages with.
408
425
  *
409
426
  * Cloudflare is the default, not a requirement: {@link KVNamespaceLike} is three structural methods,
410
427
  * so a Redis, Deno KV, or Upstash binding satisfies it (pass `minExpirationTtl` for their TTL floor).
@@ -414,6 +431,7 @@ type StoredTaggedResponse = CachedResponse & {
414
431
  export class KVCacheStore implements CacheStore {
415
432
  private readonly kv: KVNamespaceLike
416
433
  private readonly putOptions: { readonly expirationTtl?: number } | undefined
434
+ private readonly pathEpochOptions: { readonly expirationTtl?: number } | undefined
417
435
 
418
436
  constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
419
437
  const floor = options.minExpirationTtl ?? 60
@@ -441,10 +459,17 @@ export class KVCacheStore implements CacheStore {
441
459
  // Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
442
460
  this.putOptions =
443
461
  options.expirationTtl !== undefined ? { expirationTtl: options.expirationTtl } : undefined
462
+ // An expired path epoch would make pages stored under the one it replaced read as current again.
463
+ // Those were stored before the purge reached their location, so they expire within the TTL plus
464
+ // that delay; without a TTL they never expire, and neither may the epoch.
465
+ this.pathEpochOptions =
466
+ options.expirationTtl !== undefined
467
+ ? { expirationTtl: options.expirationTtl + PATH_EPOCH_SLACK }
468
+ : undefined
444
469
  }
445
470
 
446
471
  async get(key: string): Promise<CachedResponse | undefined> {
447
- const raw = await this.kv.get(key)
472
+ const [raw, pathEpoch] = await Promise.all([this.kv.get(key), this.pathEpoch(key)])
448
473
  if (raw === null) return undefined
449
474
  let parsed: unknown
450
475
  try {
@@ -454,52 +479,65 @@ export class KVCacheStore implements CacheStore {
454
479
  return undefined
455
480
  }
456
481
  if (!isCachedResponse(parsed)) return undefined
457
- const tags = normalizeTags(parsed.tags)
458
- if (tags.length === 0) return parsed
459
- const epochs = (parsed as StoredTaggedResponse).__nifraTagEpochs
482
+ const {
483
+ __nifraTagEpochs: epochs,
484
+ __nifraPathEpoch: storedPathEpoch,
485
+ ...value
486
+ } = parsed as StoredResponse
487
+ // A page another location stored before this path's purge reached it keeps the older epoch.
488
+ if ((storedPathEpoch ?? "0") !== pathEpoch) return undefined
489
+ const tags = normalizeTags(value.tags)
490
+ if (tags.length === 0) return value
460
491
  if (epochs === undefined) return undefined
461
492
  const current = await Promise.all(
462
493
  tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
463
494
  )
464
495
  if (current.some(([tag, epoch]) => epochs[tag] !== epoch)) return undefined
465
- const { __nifraTagEpochs: _ignored, ...value } = parsed as StoredTaggedResponse
466
496
  return value
467
497
  }
468
498
 
469
- /** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so the
470
- * fence holds only for purges this location has already seen. */
499
+ /** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so
500
+ * `set` refuses only purges this location has already seen; a page stored before a purge reached
501
+ * its location keeps the older epoch and reads as a miss once the purge arrives. */
471
502
  fence(): number {
472
503
  return kvClock()
473
504
  }
474
505
 
475
506
  async set(key: string, value: CachedResponse): Promise<void> {
476
507
  const { fence, ...entry } = value
477
- if (fence !== undefined && kvPurgedAt(await this.kv.get(key)) > fence) return
478
508
  const tags = normalizeTags(entry.tags)
479
- if (tags.length === 0) {
480
- await this.kv.put(key, JSON.stringify(entry), this.putOptions)
481
- return
482
- }
483
- const epochs = Object.fromEntries(
484
- await Promise.all(
485
- tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
486
- ),
487
- )
488
- if (fence !== undefined && Object.values(epochs).some((epoch) => kvEpochTime(epoch) > fence)) {
509
+ const [pathEpoch, ...tagEpochs] = await Promise.all([
510
+ this.pathEpoch(key),
511
+ ...tags.map(async (tag) => (await this.kv.get(this.tagKey(tag))) ?? "0"),
512
+ ])
513
+ if (
514
+ fence !== undefined &&
515
+ [pathEpoch, ...tagEpochs].some((epoch) => kvEpochTime(epoch) > fence)
516
+ ) {
489
517
  return
490
518
  }
491
- const stored: StoredTaggedResponse = {
519
+ const stored: StoredResponse = {
492
520
  ...entry,
493
- tags,
494
- __nifraTagEpochs: epochs,
521
+ ...(tags.length === 0
522
+ ? {}
523
+ : {
524
+ tags,
525
+ __nifraTagEpochs: Object.fromEntries(tags.map((tag, at) => [tag, tagEpochs[at]!])),
526
+ }),
527
+ ...(pathEpoch === "0" ? {} : { __nifraPathEpoch: pathEpoch }),
495
528
  }
496
529
  await this.kv.put(key, JSON.stringify(stored), this.putOptions)
497
530
  }
498
531
 
499
- /** Leaves a marker rather than removing the key, so `set` can refuse a render that began before
500
- * the purge. The marker reads as a miss. */
532
+ /** Moves the path's epoch and leaves the page: a page stored under the old epoch reads as a miss,
533
+ * even one another location stored before this purge reached it, and `set` refuses a render begun
534
+ * before the purge. */
501
535
  async delete(key: string): Promise<void> {
502
- await this.kv.put(key, JSON.stringify({ [KV_PURGED_AT]: kvClock() }), this.putOptions)
536
+ await this.kv.put(
537
+ await this.pathKey(key),
538
+ `${kvClock()}:${crypto.randomUUID()}`,
539
+ this.pathEpochOptions,
540
+ )
503
541
  }
504
542
 
505
543
  async invalidateTag(tag: string): Promise<void> {
@@ -513,6 +551,21 @@ export class KVCacheStore implements CacheStore {
513
551
  private tagKey(tag: string): string {
514
552
  return `${KV_TAG_EPOCH_PREFIX}${encodeURIComponent(tag)}`
515
553
  }
554
+
555
+ /** The page key under a prefix, or its digest when that would pass KV's key limit. */
556
+ private async pathKey(key: string): Promise<string> {
557
+ const named = `${KV_PATH_EPOCH_PREFIX}${key}`
558
+ // A UTF-16 unit is at most three UTF-8 bytes, so most keys skip the encode.
559
+ if (named.length * 3 <= KV_KEY_LIMIT) return named
560
+ const encoder = new TextEncoder()
561
+ if (encoder.encode(named).byteLength <= KV_KEY_LIMIT) return named
562
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(key)))
563
+ return `${KV_PATH_DIGEST_PREFIX}${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`
564
+ }
565
+
566
+ private async pathEpoch(key: string): Promise<string> {
567
+ return (await this.kv.get(await this.pathKey(key))) ?? "0"
568
+ }
516
569
  }
517
570
 
518
571
  /** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
@@ -564,6 +617,19 @@ export function withoutCacheChannel(res: Response): Response {
564
617
 
565
618
  export interface ISROptions {
566
619
  readonly store: CacheStore
620
+ /**
621
+ * Signs every page written to `store` and checks the signature on every read, so whoever else can
622
+ * write the store (another service on the same KV namespace, a leaked token) cannot hand visitors a
623
+ * page this app did not render. Required for every store but a {@link MemoryCacheStore}, whose
624
+ * pages never leave the process and stay unsigned. The same in every process, at least 32
625
+ * characters, and random: generate it, such as 32 random bytes as hex, since a guessable secret
626
+ * can be found from any signed page and then signs pages of its own. A page whose signature is
627
+ * missing or wrong reads as a miss and renders again, so a new secret only empties the cache. It
628
+ * cannot stop a store writer from deleting pages, or from putting back an older page this app
629
+ * stored for the same key. That page keeps its age: still within its freshness window, it is a hit
630
+ * until the window ends; past it, it serves stale and regenerates.
631
+ */
632
+ readonly storeSecret?: string
567
633
  /** Default freshness window (**seconds**) for a cached page; older ⇒ stale (served, regenerated
568
634
  * behind). A route overrides it per-page via `export const revalidate` (surfaced as the
569
635
  * `x-nifra-isr-revalidate` response header). */
@@ -574,7 +640,10 @@ export interface ISROptions {
574
640
  * so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
575
641
  * this request (it goes straight to the app, uncached). Replaces `query` entirely. Under the default
576
642
  * key a request carrying `Authorization` or a cookie outside `anonymousCookies` never reads the
577
- * store; a custom key takes that decision over, so include whatever identifies the visitor in it. */
643
+ * store; a custom key takes that decision over, so include whatever identifies the visitor in it.
644
+ * An action's `revalidate(paths)` keys each path with the action request's own headers, so it purges
645
+ * the variant that client reads; a variant keyed on another header value (another language) is not
646
+ * reached - tag those pages and revalidate the tag. */
578
647
  readonly key?: (req: Request) => string | null
579
648
  /**
580
649
  * Cookie names that never change the page, such as a consent or analytics cookie. Under the default
@@ -597,10 +666,11 @@ export interface ISROptions {
597
666
  * fresh, and a draft render is never written to the store (it can't poison the public cache). */
598
667
  readonly draftSecret?: string
599
668
  /**
600
- * Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}. The request
601
- * is served without the cache either way - a failed read is a miss, a failed write still returns the
602
- * rendered page, a failed regeneration keeps the stale entry - so a store outage never changes a
603
- * status. Default: one `console.error` line per operation per minute, never naming the cache key.
669
+ * Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}, or (op
670
+ * `"verify"`) a page it returns fails its {@link storeSecret} signature check. The request is served
671
+ * without the cache either way - a failed read is a miss, a failed write still returns the rendered
672
+ * page, a failed regeneration keeps the stale entry - so a store outage never changes a status.
673
+ * Default: one `console.error` line per operation per minute, never naming the cache key.
604
674
  */
605
675
  readonly onStoreError?: (error: unknown, info: { readonly op: ISRStoreOp }) => void
606
676
  /**
@@ -608,12 +678,27 @@ export interface ISROptions {
608
678
  * the request; set it when the store is a network service.
609
679
  */
610
680
  readonly storeTimeoutMs?: number
681
+ /**
682
+ * Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding loader
683
+ * data. An action's `revalidate(paths, data, { tags })` invalidates them first, then this store, then
684
+ * publishes to live pages, then purges `cdn`; a cache that fails stops the rest, so no client reloads
685
+ * stale data.
686
+ */
687
+ readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
688
+ /** A CDN an action's invalidation purges last (a `@nifrajs/web/cdn` provider). */
689
+ readonly cdn?: CdnPurgeTarget
690
+ /**
691
+ * An action's invalidation that still failed after its retries; the action's own answer never
692
+ * changes. Default: one `console.error` line naming the operation id and stage.
693
+ */
694
+ readonly onInvalidationError?: (report: InvalidationReport) => void
611
695
  }
612
696
 
613
697
  /** The store operation an {@link ISROptions.onStoreError} report is about. */
614
- export type ISRStoreOp = "get" | "set" | "delete" | "fence"
698
+ export type ISRStoreOp = "get" | "set" | "delete" | "fence" | "verify"
615
699
 
616
700
  const STORE_FAILED = Symbol("nifra.isr.store-failed")
701
+ const MAC_HEADER = "x-nifra-isr-mac"
617
702
  const STORE_REPORT_INTERVAL_MS = 60_000
618
703
  const STORE_REPORT_DETAIL_CHARS = 200
619
704
 
@@ -633,6 +718,79 @@ function storeErrorDetail(error: unknown, key: string): string {
633
718
  : redacted
634
719
  }
635
720
 
721
+ /**
722
+ * What a page's MAC covers: everything a replay serves, plus the key, so a page copied to another key
723
+ * never verifies there. Header names and tags are sorted, since a store need not keep their order.
724
+ */
725
+ function signedParts(key: string, entry: CachedResponse): readonly string[] {
726
+ const names = Object.keys(entry.headers)
727
+ .filter((name) => name !== MAC_HEADER)
728
+ .sort()
729
+ const headers = names.map((name) => [name, entry.headers[name]])
730
+ const tags = entry.tags === undefined ? [] : [...entry.tags].sort()
731
+ const meta = JSON.stringify([
732
+ 1,
733
+ key,
734
+ entry.status,
735
+ entry.storedAt,
736
+ entry.revalidate,
737
+ headers,
738
+ tags,
739
+ ])
740
+ return [meta, entry.body]
741
+ }
742
+
743
+ async function signed(
744
+ signer: StoreSigner,
745
+ key: string,
746
+ entry: CachedResponse,
747
+ ): Promise<CachedResponse> {
748
+ const mac = await signer.sign(signedParts(key, entry))
749
+ return { ...entry, headers: { ...entry.headers, [MAC_HEADER]: mac } }
750
+ }
751
+
752
+ async function verified(signer: StoreSigner, key: string, entry: CachedResponse): Promise<boolean> {
753
+ try {
754
+ return await signer.verify(signedParts(key, entry), entry.headers[MAC_HEADER])
755
+ } catch {
756
+ // A store's own malformed record.
757
+ return false
758
+ }
759
+ }
760
+
761
+ function signerFor(store: CacheStore, secret: unknown): StoreSigner | undefined {
762
+ if (secret !== undefined && !isStoreSecret(secret)) {
763
+ throw new TypeError(
764
+ `[nifra/web] ISR storeSecret must be a string of at least ${MIN_STORE_SECRET_LENGTH} characters`,
765
+ )
766
+ }
767
+ if (inProcessStores.has(store)) return undefined
768
+ if (secret === undefined) {
769
+ throw new TypeError(
770
+ `[nifra/web] withISR needs storeSecret, at least ${MIN_STORE_SECRET_LENGTH} characters, to sign the pages it keeps in a store other processes can write. Only a MemoryCacheStore runs without one.`,
771
+ )
772
+ }
773
+ return storeSigner(secret)
774
+ }
775
+
776
+ // Headers only an action or a data request carries; a stored page is always a plain document GET.
777
+ const ACTION_ONLY_HEADERS = [
778
+ "content-type",
779
+ "content-length",
780
+ "content-encoding",
781
+ "transfer-encoding",
782
+ "x-nifra-data",
783
+ "x-nifra-component",
784
+ SWAP_HEADER,
785
+ ]
786
+
787
+ /** The document GET for `path` from the client that sent `req`: what a custom key sees on a read. */
788
+ function documentGet(path: string, req: Request): Request {
789
+ const headers = new Headers(req.headers)
790
+ for (const name of ACTION_ONLY_HEADERS) headers.delete(name)
791
+ return new Request(originOf(req) + path, { headers })
792
+ }
793
+
636
794
  /** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
637
795
  * through the global symbol registry so a cache wrapper never imports the CSP module. */
638
796
  const DOCUMENT_POLICY = Symbol.for("nifra.web.documentPolicy")
@@ -676,6 +834,32 @@ const requestKeyOf =
676
834
  (req: Request): string | null =>
677
835
  urlKey(new URL(req.url))
678
836
 
837
+ /**
838
+ * The key of a request, and of a path on the request's origin. Under the default URL key a path needs
839
+ * no `Request`; a custom `key` is handed one.
840
+ */
841
+ function cacheKeysOf(options: ISROptions): {
842
+ readonly request: (req: Request) => string | null
843
+ readonly path: (path: string, req: Request) => string | null
844
+ } {
845
+ const custom = options.key
846
+ if (custom !== undefined) {
847
+ return { request: custom, path: (path, req) => custom(documentGet(path, req)) }
848
+ }
849
+ const urlKey = urlKeyOf(options.query)
850
+ return {
851
+ request: requestKeyOf(urlKey),
852
+ path: (path, req) => urlKey(new URL(originOf(req) + path)),
853
+ }
854
+ }
855
+
856
+ /** `req.url` up to its path, without a parse: the path joined to it is parsed as one URL anyway. */
857
+ const originOf = (req: Request): string => {
858
+ const url = req.url
859
+ const end = url.indexOf("/", url.indexOf("//") + 2)
860
+ return end === -1 ? url : url.slice(0, end)
861
+ }
862
+
679
863
  function assertRevalidate(value: number, name: string): void {
680
864
  if (!Number.isFinite(value) || value < 0) {
681
865
  throw new RangeError(`[nifra/web] ${name} must be a finite non-negative number`)
@@ -714,14 +898,13 @@ const requestCarriesPrivateState = (req: Request): boolean =>
714
898
  const responseIsExplicitlyPublic = (res: Response): boolean =>
715
899
  cacheControlHas(res.headers, ["public"])
716
900
 
717
- // `x-nifra-data` alone is not a variation the URL key misses: a navigation data request never reads
718
- // or writes the store, so every stored entry is the document.
901
+ // Data and swap requests bypass both cache reads and writes, so these headers cannot vary stored HTML.
719
902
  const responseDeclaresVary = (res: Response): boolean => {
720
903
  const value = res.headers.get("vary")
721
904
  if (value === null) return false
722
905
  for (const part of value.split(",")) {
723
906
  const name = part.trim().toLowerCase()
724
- if (name !== "" && name !== "x-nifra-data") return true
907
+ if (name !== "" && name !== "x-nifra-data" && name !== SWAP_HEADER) return true
725
908
  }
726
909
  return false
727
910
  }
@@ -729,13 +912,13 @@ const responseDeclaresVary = (res: Response): boolean => {
729
912
  /**
730
913
  * Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
731
914
  * full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
732
- * the data header, and, for a request carrying a cookie or Authorization or accepted by an
915
+ * the data/swap headers, and, for a request carrying a cookie or Authorization or accepted by an
733
916
  * authentication stage, an explicit `public`.
734
917
  * The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
735
918
  */
736
919
  export const isCacheablePage = (req: Request, res: Response): boolean => {
737
920
  if (req.method !== "GET") return false
738
- if (req.headers.get("x-nifra-data") !== null) return false
921
+ if (req.headers.get("x-nifra-data") !== null || req.headers.has(SWAP_HEADER)) return false
739
922
  if (res.status !== 200) return false
740
923
  if (!(res.headers.get("content-type") ?? "").includes("text/html")) return false
741
924
  if (hasSetCookie(res.headers)) return false
@@ -842,6 +1025,11 @@ export function withISR(
842
1025
  ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
843
1026
  const { store, now } = options
844
1027
  assertRevalidate(options.revalidate, "revalidate")
1028
+ const signer = signerFor(store, options.storeSecret)
1029
+ const write = (key: string, entry: CachedResponse): Promise<void> =>
1030
+ signer === undefined
1031
+ ? store.set(key, entry)
1032
+ : signed(signer, key, entry).then((value) => store.set(key, value))
845
1033
  const storeTimeoutMs = options.storeTimeoutMs
846
1034
  if (storeTimeoutMs !== undefined && !(Number.isFinite(storeTimeoutMs) && storeTimeoutMs > 0)) {
847
1035
  throw new RangeError("[nifra/web] ISR storeTimeoutMs must be a positive finite number")
@@ -865,28 +1053,32 @@ export function withISR(
865
1053
  }
866
1054
  }
867
1055
  // Runs one store call; a throw, a rejection or a timeout is reported and answered with STORE_FAILED.
1056
+ const withinTimeout = async <T>(op: string, pending: Promise<T>): Promise<T> => {
1057
+ if (storeTimeoutMs === undefined) return pending
1058
+ let timer: ReturnType<typeof setTimeout> | undefined
1059
+ const timeout = new Promise<never>((_, reject) => {
1060
+ timer = setTimeout(() => {
1061
+ const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
1062
+ error.name = "TimeoutError"
1063
+ reject(error)
1064
+ }, storeTimeoutMs)
1065
+ })
1066
+ try {
1067
+ return await Promise.race([pending, timeout])
1068
+ } finally {
1069
+ clearTimeout(timer)
1070
+ }
1071
+ }
868
1072
  const attempt = async <T>(
869
1073
  op: ISRStoreOp,
870
1074
  key: string,
871
1075
  call: () => Promise<T>,
872
1076
  ): Promise<T | typeof STORE_FAILED> => {
873
- let timer: ReturnType<typeof setTimeout> | undefined
874
1077
  try {
875
- const pending = call()
876
- if (storeTimeoutMs === undefined) return await pending
877
- const timeout = new Promise<never>((_, reject) => {
878
- timer = setTimeout(() => {
879
- const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
880
- error.name = "TimeoutError"
881
- reject(error)
882
- }, storeTimeoutMs)
883
- })
884
- return await Promise.race([pending, timeout])
1078
+ return await withinTimeout(op, call())
885
1079
  } catch (error) {
886
1080
  reportStoreError(op, key, error)
887
1081
  return STORE_FAILED
888
- } finally {
889
- if (timer !== undefined) clearTimeout(timer)
890
1082
  }
891
1083
  }
892
1084
  // A store without purge order, or one whose fence failed, stores the render unfenced.
@@ -896,7 +1088,8 @@ export function withISR(
896
1088
  const taken = await attempt("fence", key, async () => fence.call(store))
897
1089
  return taken === STORE_FAILED ? undefined : taken
898
1090
  }
899
- const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
1091
+ const keys = cacheKeysOf(options)
1092
+ const keyOf = keys.request
900
1093
  const draftSecret = options.draftSecret
901
1094
  const anonymousCookies = new Set(options.anonymousCookies ?? [])
902
1095
  // A request whose cookies are all anonymous is judged for storage as if it carried none.
@@ -915,6 +1108,38 @@ export function withISR(
915
1108
  }
916
1109
  const regenerating = new Set<string>()
917
1110
  openCacheChannel(app)
1111
+ // This wrapper owns the app's invalidation pipeline from here on: an action's operation comes back
1112
+ // by its request, and every stage runs in order before the action's answer leaves.
1113
+ const handoff: unknown = Reflect.get(app, INVALIDATION)
1114
+ const taken = isHandoff(handoff) ? handoff : undefined
1115
+ // The pipeline's purges are bounded like every other store call; their failures reach its own report.
1116
+ const invalidateTag = store.invalidateTag
1117
+ const targets: InvalidationTargets = {
1118
+ dataCaches: options.dataCaches,
1119
+ store: {
1120
+ delete: (key) => withinTimeout("delete", store.delete(key)),
1121
+ invalidateTag:
1122
+ invalidateTag === undefined
1123
+ ? undefined
1124
+ : (tag) => withinTimeout("invalidateTag", invalidateTag.call(store, tag)),
1125
+ },
1126
+ keyOf: keys.path,
1127
+ publish: taken?.publish,
1128
+ cdn: options.cdn,
1129
+ }
1130
+ const passThrough = async (
1131
+ req: Request,
1132
+ platform: ISRPlatform | undefined,
1133
+ ): Promise<Response> => {
1134
+ const res = withoutCacheChannel(await app.fetch(req, platform))
1135
+ const op = taken?.take(req)
1136
+ if (op !== undefined) {
1137
+ await settleInvalidation(op, targets, req, platform, {
1138
+ onError: options.onInvalidationError,
1139
+ })
1140
+ }
1141
+ return res
1142
+ }
918
1143
  // An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
919
1144
  // much freshness they have left and their tags, so the CDN never holds a page longer than ISR would.
920
1145
  let channelOpen = false
@@ -988,7 +1213,7 @@ export function withISR(
988
1213
  }
989
1214
  // A failed write still answers with the page, and still hands it to concurrent misses: whether it
990
1215
  // may be shared was decided above, not by the store.
991
- await attempt("set", key, () => store.set(key, entry))
1216
+ await attempt("set", key, () => write(key, entry))
992
1217
  stored?.(entry)
993
1218
  return fresh(entry)
994
1219
  }
@@ -1052,7 +1277,7 @@ export function withISR(
1052
1277
  ...(tags.length === 0 ? {} : { tags }),
1053
1278
  fence,
1054
1279
  }
1055
- await attempt("set", key, () => store.set(key, entry))
1280
+ await attempt("set", key, () => write(key, entry))
1056
1281
  } else if (isInvalidationResponse(judged(req), res)) {
1057
1282
  // A withdrawal is authoritative. Keeping the old entry would make a withdrawn page stay
1058
1283
  // publicly visible forever, because every stale request would retry the same non-cacheable
@@ -1070,24 +1295,25 @@ export function withISR(
1070
1295
  }
1071
1296
 
1072
1297
  const handler = async (req: Request, platform?: ISRPlatform): Promise<Response> => {
1073
- // A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
1074
- // by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
1075
- // the loader payload. (The write path already refuses to cache data-mode responses.)
1298
+ // A data-mode soft-nav GET or a slot swap bypasses the cache entirely: entries are full HTML
1299
+ // documents keyed by URL, and serving one to either would hand the client a document where it
1300
+ // expects the loader payload or one slot. (The write path already refuses both.)
1076
1301
  const privateRoute: unknown = Reflect.get(app, Symbol.for("nifra.web.private-route"))
1077
1302
  const ownsPrivate = typeof privateRoute === "function" && privateRoute(req) === true
1078
1303
  const key =
1079
1304
  req.method === "GET" &&
1080
1305
  req.headers.get("x-nifra-data") === null &&
1081
1306
  !req.headers.has("x-nifra-component") &&
1307
+ !req.headers.has(SWAP_HEADER) &&
1082
1308
  !ownsPrivate
1083
1309
  ? keyOf(req)
1084
1310
  : null
1085
- if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
1311
+ if (key === null) return passThrough(req, platform)
1086
1312
 
1087
1313
  // Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
1088
1314
  // unpublished content can't leak into the public cache (and the editor isn't served a stale page).
1089
1315
  if (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret))) {
1090
- return withoutCacheChannel(await app.fetch(req, platform))
1316
+ return passThrough(req, platform)
1091
1317
  }
1092
1318
 
1093
1319
  // A cookie or Authorization request may be personalized: under the default key it never reads
@@ -1101,7 +1327,11 @@ export function withISR(
1101
1327
  }
1102
1328
 
1103
1329
  const read = personal ? undefined : await attempt("get", key, () => store.get(key))
1104
- const hit = read === STORE_FAILED ? undefined : read
1330
+ let hit = read === STORE_FAILED ? undefined : read
1331
+ if (hit !== undefined && signer !== undefined && !(await verified(signer, key, hit))) {
1332
+ reportStoreError("verify", key, new Error("a stored page failed its signature check"))
1333
+ hit = undefined
1334
+ }
1105
1335
  if (hit !== undefined) {
1106
1336
  if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
1107
1337
  // Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
@@ -1123,6 +1353,9 @@ export function withISR(
1123
1353
  const jsonError = (status: number, error: string): Response =>
1124
1354
  Response.json({ ok: false, error }, { status })
1125
1355
 
1356
+ const isHandoff = (value: unknown): value is InvalidationHandoff =>
1357
+ typeof value === "object" && value !== null && typeof Reflect.get(value, "take") === "function"
1358
+
1126
1359
  /** What a CDN purge came to: sent and accepted, queued behind a rate limit or retry, or refused. */
1127
1360
  export interface CdnPurgeOutcome {
1128
1361
  readonly cdn: "accepted" | "queued" | "failed"
@@ -1172,6 +1405,11 @@ export interface RevalidateEndpointOptions {
1172
1405
  * page store is touched, so a retry purges everything again. Path purges do not reach them.
1173
1406
  */
1174
1407
  readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
1408
+ /**
1409
+ * The app's live runtime: a purge also tells live pages watching a purged tag to reload, after the
1410
+ * caches and before the CDN. A publish that fails answers `502` with `origin: "done"`.
1411
+ */
1412
+ readonly live?: { publish(...keys: string[]): Promise<void> }
1175
1413
  }
1176
1414
 
1177
1415
  const stringList = (value: unknown): string[] | null | undefined => {
@@ -1263,47 +1501,51 @@ export function revalidateEndpoint(
1263
1501
  return jsonError(501, "tag_invalidation_not_supported")
1264
1502
  }
1265
1503
 
1266
- if (tags.length > 0 && options.dataCaches !== undefined) {
1267
- try {
1268
- for (const cache of options.dataCaches) {
1269
- for (const candidate of tags) await cache.invalidateTag(candidate)
1270
- }
1271
- } catch {
1272
- return Response.json(
1273
- { ok: false, error: "data_cache_invalidation_failed" },
1274
- { status: 500 },
1275
- )
1276
- }
1277
- }
1278
- for (const key of keys) await options.store.delete(key)
1279
- for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
1504
+ // The same pipeline an action's `revalidate()` runs: data caches, this store, live, then the CDN.
1505
+ const live = options.live
1506
+ const report = await runInvalidation(
1507
+ { id: crypto.randomUUID(), paths, tags, keys: [] },
1508
+ {
1509
+ dataCaches: options.dataCaches,
1510
+ store: options.store,
1511
+ keyOf: (candidate) => keys[paths.indexOf(candidate)] ?? null,
1512
+ publish: live === undefined ? undefined : (names) => live.publish(...names),
1513
+ cdn: options.cdn,
1514
+ },
1515
+ req,
1516
+ platform,
1517
+ )
1518
+ const stageOf = (stage: string) => report.stages.find((each) => each.stage === stage)
1519
+ if (stageOf("data")?.ok === false) return jsonError(500, "data_cache_invalidation_failed")
1520
+ if (stageOf("pages")?.ok === false) return jsonError(500, "page_store_invalidation_failed")
1280
1521
  const done =
1281
1522
  path !== null
1282
1523
  ? { revalidated: path }
1283
1524
  : tag !== null
1284
1525
  ? { revalidatedTag: tag }
1285
1526
  : { revalidated: paths, revalidatedTags: tags }
1286
- if (options.cdn === undefined) return Response.json(done)
1287
-
1288
- let outcome: CdnPurgeOutcome
1289
- try {
1290
- outcome = await options.cdn.purge({ tags, paths }, platform)
1291
- } catch {
1292
- outcome = { cdn: "failed", retryable: true, error: "cdn_purge_threw" }
1293
- }
1294
- if (outcome.cdn === "failed") {
1527
+ const published = stageOf("publish")
1528
+ const cdn = stageOf("cdn")
1529
+ if (cdn?.ok === false) {
1295
1530
  const failure: Record<string, unknown> = {
1296
1531
  ok: false,
1297
1532
  error: "cdn_purge_failed",
1298
1533
  origin: "done",
1299
- retryable: outcome.retryable,
1534
+ retryable: cdn.retryable,
1300
1535
  }
1301
- if (outcome.error !== undefined) failure.reason = outcome.error
1536
+ if (cdn.error !== undefined && cdn.error !== "cdn_failed") failure.reason = cdn.error
1302
1537
  return Response.json(failure, { status: 502 })
1303
1538
  }
1539
+ if (published?.ok === false) {
1540
+ return Response.json(
1541
+ { ok: false, error: "live_publish_failed", origin: "done", retryable: true },
1542
+ { status: 502 },
1543
+ )
1544
+ }
1545
+ if (cdn === undefined) return Response.json(done)
1304
1546
  return Response.json(
1305
- { ...done, cdn: outcome.cdn },
1306
- { status: outcome.cdn === "queued" ? 202 : 200 },
1547
+ { ...done, cdn: cdn.queued === true ? "queued" : "accepted" },
1548
+ { status: cdn.queued === true ? 202 : 200 },
1307
1549
  )
1308
1550
  }
1309
1551
  }