@nifrajs/web 5.5.1 → 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 +154 -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/live.ts ADDED
@@ -0,0 +1,1096 @@
1
+ /**
2
+ * Live data refresh, server side. Loaders declare what their data depends on with `ctx.live(...keys)`;
3
+ * a live load returns an encrypted grant for those keys, and the client reloads through the same
4
+ * authorized path whenever one of them changes. Data never travels with a change notice.
5
+ *
6
+ * ```ts
7
+ * import { live } from "@nifrajs/web/live"
8
+ *
9
+ * const liveRuntime = live({ secret: process.env.LIVE_SECRET, subject: (ctx) => sessionUserId(ctx.request) })
10
+ * createWebApp({ ...options, live: liveRuntime })
11
+ * ```
12
+ *
13
+ * Every live load is a `POST` to the load endpoint that runs the target page as a `GET` through the
14
+ * whole app: every hook, middleware, gate and loader sees the target path, exactly as a navigation.
15
+ * Changes reach open pages over one WebSocket per tab, as opaque tags of the keys they watch.
16
+ */
17
+ import { isSameOriginForm, replacedRequestOf } from "@nifrajs/core/server"
18
+ import { websocket } from "@nifrajs/core/ws"
19
+ import {
20
+ LIVE_ATTACH,
21
+ type LiveAttachInput,
22
+ type LiveAttachment,
23
+ type LiveEnvelope,
24
+ type LiveFinish,
25
+ type LiveLoad,
26
+ type LiveRuntimeHandle,
27
+ } from "./internal/live-contract.ts"
28
+ import { queryDigester, querySalt } from "./internal/query-digest.ts"
29
+ import { type LiveBus, type LiveBusEvent, type LiveBusInput, memoryBus } from "./live/bus.ts"
30
+ import { applyEvent, type LiveCore } from "./live/core.ts"
31
+ import { History, keyHasher } from "./live/history.ts"
32
+ import { CLOSE_CAPACITY, LiveHub, type LiveSocket, tagCache, unref } from "./live/hub.ts"
33
+ import {
34
+ HUB_APPEND_PATH,
35
+ HUB_AUTHORITY_PATH,
36
+ HUB_CONFIG_HEADER,
37
+ HUB_ORIGIN,
38
+ hubConfigOf,
39
+ hubName,
40
+ type LiveHubNamespace,
41
+ MAX_APPEND_KEYS,
42
+ parseAuthorityReply,
43
+ parseSeq,
44
+ } from "./live/hub-protocol.ts"
45
+ import {
46
+ assertLiveSecrets,
47
+ createKeyring,
48
+ type GrantClaims,
49
+ type GrantOwner,
50
+ KEY_PATTERN,
51
+ type Keyring,
52
+ MAX_GRANT_CHARS,
53
+ MAX_KEYS_PER_FRAME,
54
+ type OpenedGrant,
55
+ SID_PATTERN,
56
+ toBase64Url,
57
+ } from "./live/keyring.ts"
58
+ import { readCapped } from "./live/read-capped.ts"
59
+ import { type RevocationCheck, Revocations, RevocationView } from "./live/revocations.ts"
60
+ import {
61
+ appIdOf,
62
+ DEFAULT_MAX_SOCKETS,
63
+ DEFAULT_RELOAD_RATE,
64
+ grantTtlOf,
65
+ positive,
66
+ spreadFloorOf,
67
+ } from "./live/settings.ts"
68
+ import type { LoaderContext } from "./manifest.ts"
69
+ import {
70
+ DATA_HEADER,
71
+ HOLE_FILL_HEADER,
72
+ KEEP_HOLES_HEADER,
73
+ NAV_FROM_HEADER,
74
+ RETAIN_HEADER,
75
+ } from "./router.ts"
76
+
77
+ export type {
78
+ LiveBus,
79
+ LiveBusEvent,
80
+ LiveBusInput,
81
+ LiveBusListener,
82
+ LiveBusSubscription,
83
+ LiveChange,
84
+ LiveRevocation,
85
+ } from "./live/bus.ts"
86
+ export { memoryBus, nextSeq } from "./live/bus.ts"
87
+
88
+ export interface LiveOptions {
89
+ /**
90
+ * The grant secret, or a rotation list with the newest first (at most 3, each at least 32 bytes).
91
+ * Required in production; development falls back to a random per-process secret.
92
+ */
93
+ readonly secret?: string | readonly string[] | undefined
94
+ /**
95
+ * Who the viewer is: a stable id, or `null` for an anonymous viewer. Called once per live load,
96
+ * after middleware. A grant is only honored for the subject it was issued to.
97
+ */
98
+ readonly subject: (ctx: LoaderContext) => string | null | Promise<string | null>
99
+ /** Separates this app's tags from another app's that shares a secret. Default `"nifra"`. */
100
+ readonly appId?: string
101
+ /** Grant lifetime in seconds, at most 3600. Default 600. */
102
+ readonly grantTtl?: number
103
+ /** The live load endpoint. Default `/_nifra/live/load`. */
104
+ readonly loadPath?: string
105
+ /** The live socket. Default `/_nifra/live`. */
106
+ readonly path?: string
107
+ /** Origins besides the app's own that may call the load endpoint and open the socket. */
108
+ readonly trustedOrigins?: readonly string[]
109
+ /** Live loads running at once in this process; past it the endpoint answers 503. Default 64. */
110
+ readonly maxConcurrentLoads?: number
111
+ /** Live sockets in this process; past it a socket is closed with 1013. Default 10,000. */
112
+ readonly maxSockets?: number
113
+ /** Refuse new sockets while the process's resident memory is above this many bytes. */
114
+ readonly maxRssBytes?: number
115
+ /**
116
+ * The ordered log every process serving the app shares. Default: an in-process bus, right for one
117
+ * process only.
118
+ */
119
+ readonly bus?: LiveBus
120
+ /**
121
+ * Cloudflare Workers: the Durable Object namespace of the app's live hub (`liveHub()` from
122
+ * `@nifrajs/web/live-hub`). The hub owns the log and holds the sockets; this isolate forwards the
123
+ * socket handshake to it, asks it for the seq and the revocations each load needs, and publishes
124
+ * through it. Exclusive with `bus`.
125
+ */
126
+ readonly hub?: LiveHubNamespace
127
+ /** Processes serving the app, for spreading reloads. Default 1. */
128
+ readonly instances?: number
129
+ /** Live loads per second the deployment absorbs after one change, for spreading reloads. Default 500. */
130
+ readonly reloadRate?: number
131
+ /** The least time, in ms, clients spread their reloads over. Default 0. */
132
+ readonly spreadFloor?: number
133
+ /**
134
+ * Re-check the keys of owners a load keeps without running their loaders. With it, `renew`
135
+ * re-signs a grant without loaders (every third renewal still runs them all, so a lost change is
136
+ * found within about 27 minutes); without it, `renew` runs a full load. `false` issues no grant.
137
+ */
138
+ readonly authorize?: (keys: readonly string[], ctx: LoaderContext) => boolean | Promise<boolean>
139
+ /**
140
+ * Resolves once a committed write is readable by every loader, such as a read replica catching up.
141
+ * `publish` waits for it, so no client reloads before its data can show the change. Replica-backed
142
+ * reads need it; without it a publish goes out as soon as it is called.
143
+ */
144
+ readonly visible?: () => void | Promise<void>
145
+ /**
146
+ * Let query hooks refetch on the keys a page's loaders grant (`useQuery(key, fn, { live })`). Each
147
+ * live load then carries a salted digest of every key it grants: page code can confirm a guess at
148
+ * one of them, so leave it off when key names hold anything the viewer should not see.
149
+ */
150
+ readonly queryKeys?: boolean
151
+ }
152
+
153
+ /** A live runtime: pass it to `createWebApp({ live })`. */
154
+ export interface Live extends LiveRuntimeHandle {
155
+ /** Resolves once the secrets are derived and the bus is read; rejects on a duplicate secret id. */
156
+ readonly ready: Promise<void>
157
+ /**
158
+ * `keys` changed: every open page watching one reloads it. Call it after the write is visible to
159
+ * loaders; resolves once this process has applied it, or 2 s after the bus took it if this process
160
+ * has not read it back by then.
161
+ */
162
+ publish(...keys: string[]): Promise<void>
163
+ /** Refuse every grant issued to `subject` before now, and close its sockets. */
164
+ revoke(subject: string): Promise<void>
165
+ /** Refuse every grant issued before now, and close every socket. */
166
+ revokeAll(): Promise<void>
167
+ /**
168
+ * Access to `key` was removed (for everyone, or for one subject): every watcher gets one last change
169
+ * for it and stops watching it, and no grant issued before now keeps or re-signs it, so the next load
170
+ * runs that owner's loader, which decides again.
171
+ */
172
+ revokeKey(key: string, subject?: string): Promise<void>
173
+ }
174
+
175
+ const DEFAULT_LOAD_PATH = "/_nifra/live/load"
176
+ const DEFAULT_SOCKET_PATH = "/_nifra/live"
177
+ const DEFAULT_MAX_LOADS = 64
178
+ const MAX_BODY_BYTES = 65_536
179
+ const MAX_PATH_CHARS = 2048
180
+ const MAX_GRANTS = 3
181
+ const MAX_RETAIN = 64
182
+ const MAX_OWNER_CHARS = 512
183
+ /** A partially prerendered page has at most 32 holes, each named in at most 32 characters. */
184
+ const MAX_HOSTS = 32
185
+ const MAX_HOST_CHARS = 32
186
+ const HOST = /^[a-z0-9-]+$/
187
+ const PAGE_FRAME = "page"
188
+ /** Renewals re-signed without loaders before one runs them all. */
189
+ const MAX_QUIET_RENEWALS = 2
190
+ /** How long a publish waits to read its own event back; the event is in the log either way. */
191
+ const APPLY_WAIT_MS = 2000
192
+
193
+ const HOP_BY_HOP = [
194
+ "connection",
195
+ "keep-alive",
196
+ "proxy-connection",
197
+ "te",
198
+ "trailer",
199
+ "transfer-encoding",
200
+ "upgrade",
201
+ ]
202
+ const BODY_HEADERS = ["content-length", "content-type", "content-encoding"]
203
+ const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
204
+ const NO_STORE = "private, no-store"
205
+
206
+ interface LoadBody {
207
+ readonly path: string
208
+ readonly sid: string
209
+ readonly frame: string
210
+ readonly mode: "load" | "renew"
211
+ readonly retain: readonly string[]
212
+ readonly grants: readonly string[]
213
+ /** The holes whose hosts the page holds: each reloads as a fill. */
214
+ readonly fill: readonly string[]
215
+ }
216
+
217
+ /** What the runtime holds once the keyring is derived and the bus subscribed. */
218
+ type Core = LiveCore
219
+
220
+ interface AuthorityState {
221
+ /** A load's watermark: every owner it loads fresh is at least this new. */
222
+ readonly seq: number
223
+ /** The log is unreachable: nothing presented is honored and no grant is issued. */
224
+ readonly down: boolean
225
+ readonly revocations: RevocationCheck
226
+ }
227
+
228
+ interface Authority {
229
+ readonly ring: Keyring
230
+ check(claims: readonly GrantClaims[]): Promise<AuthorityState>
231
+ }
232
+
233
+ const REFUSE_ALL: RevocationCheck = { grantRevoked: () => true, keyRevoked: () => true }
234
+
235
+ type Queued =
236
+ | { readonly kind: "event"; readonly event: LiveBusEvent }
237
+ | { readonly kind: "gap"; readonly head: number }
238
+ | { readonly kind: "down" }
239
+
240
+ /** Create the live runtime for one app. */
241
+ export function live(options: LiveOptions): Live {
242
+ if (typeof options.subject !== "function") {
243
+ throw new TypeError("[nifra/web] live: `subject` must be a function returning a string or null")
244
+ }
245
+ const ttl = grantTtlOf(options.grantTtl)
246
+ const maxLoads = positive(options.maxConcurrentLoads, DEFAULT_MAX_LOADS, "maxConcurrentLoads")
247
+ const maxSockets = positive(options.maxSockets, DEFAULT_MAX_SOCKETS, "maxSockets")
248
+ const instances = positive(options.instances, 1, "instances")
249
+ const reloadRate = positive(options.reloadRate, DEFAULT_RELOAD_RATE, "reloadRate")
250
+ const spreadFloor = spreadFloorOf(options.spreadFloor)
251
+ const maxRssBytes = options.maxRssBytes
252
+ if (maxRssBytes !== undefined) positive(maxRssBytes, 1, "maxRssBytes")
253
+ const loadPath = endpointPath(options.loadPath ?? DEFAULT_LOAD_PATH, "loadPath")
254
+ const socketPath = endpointPath(options.path ?? DEFAULT_SOCKET_PATH, "path")
255
+ if (socketPath === loadPath) {
256
+ throw new TypeError("[nifra/web] live: `path` and `loadPath` must differ")
257
+ }
258
+ const appId = appIdOf(options.appId)
259
+ const hubNamespace = options.hub
260
+ if (hubNamespace !== undefined && noSecret(options.secret)) {
261
+ // A per-process fallback secret would differ from the hub's: no grant would ever open there.
262
+ throw new TypeError("[nifra/web] live: `hub` needs `secret`, the same one the hub is given")
263
+ }
264
+ const secrets = secretsOf(options.secret)
265
+ assertLiveSecrets(secrets)
266
+ const trusted = new Set(options.trustedOrigins ?? [])
267
+ const authorize = options.authorize
268
+ const visible = options.visible
269
+ const subjectOf = options.subject
270
+ const queryKeys = options.queryKeys === true
271
+ const production = isProduction()
272
+ if (hubNamespace !== undefined) {
273
+ if (options.bus !== undefined) {
274
+ throw new TypeError("[nifra/web] live: `hub` and `bus` are exclusive; the hub owns the log")
275
+ }
276
+ if (typeof hubNamespace?.idFromName !== "function" || typeof hubNamespace.get !== "function") {
277
+ throw new TypeError("[nifra/web] live: `hub` must be the live hub's Durable Object namespace")
278
+ }
279
+ }
280
+ const bus = options.bus ?? memoryBus()
281
+ const hash = keyHasher()
282
+ const publishers = new Set<(topic: string, frame: string) => void>()
283
+
284
+ // Bus events wait here until the core exists, then apply in seq order: their tags are derived
285
+ // first (async), then each is recorded and fanned out in one synchronous step.
286
+ const queue: Queued[] = []
287
+ let draining = false
288
+ let core: Core | undefined
289
+ /** The last bus seq this process applied: a load reads it before any loader runs. */
290
+ let seen = 0
291
+ const waiters: { readonly seq: number; readonly resolve: () => void }[] = []
292
+ /** A publish already found this process behind the bus; the next applied event clears it. */
293
+ let lagging = false
294
+
295
+ const keyring = createKeyring({ secrets, appId, ttl })
296
+ // One cache for the tags a load hands out and the tags this process's sockets join.
297
+ const tags = keyring.then((ring) => tagCache(ring))
298
+ tags.catch(() => {})
299
+ const started: Promise<Core> = (async () => {
300
+ const ring = await keyring
301
+ // On Workers the hub holds the log: this isolate reads nothing.
302
+ if (hubNamespace !== undefined) return new Promise<Core>(() => {})
303
+ const subscription = await bus.subscribe({
304
+ event: (event) => enqueue({ kind: "event", event }),
305
+ gap: (head) => enqueue({ kind: "gap", head }),
306
+ down: () => enqueue({ kind: "down" }),
307
+ })
308
+ seen = subscription.head
309
+ const history = new History(subscription.head)
310
+ const revocations = new Revocations(subscription.head, ttl * 1000)
311
+ const hub = new LiveHub(
312
+ ring,
313
+ history,
314
+ revocations,
315
+ {
316
+ appId,
317
+ trusted,
318
+ maxSockets,
319
+ maxRssBytes,
320
+ reloadRate,
321
+ instances,
322
+ spreadFloor,
323
+ hash,
324
+ tags: await tags,
325
+ },
326
+ publishers,
327
+ )
328
+ core = { ring, history, revocations, hub }
329
+ void drain()
330
+ return core
331
+ })()
332
+ // `ready` carries a failed start; nothing else may leave it unhandled.
333
+ started.catch(() => {})
334
+
335
+ /** What a load checks its grants against: this process's own core, or the Workers hub. */
336
+ const authority: Promise<Authority> =
337
+ hubNamespace === undefined
338
+ ? started.then((c) => ({
339
+ ring: c.ring,
340
+ check: async () => ({ seq: seen, down: c.hub.down, revocations: c.revocations }),
341
+ }))
342
+ : keyring.then((ring) => ({ ring, check: (claims) => hubCheck(hubNamespace, ring, claims) }))
343
+ authority.catch(() => {})
344
+
345
+ /** One call through the binding; `undefined` when the hub is unreachable or answers wrong. */
346
+ async function hubCall(
347
+ namespace: LiveHubNamespace,
348
+ ring: Keyring,
349
+ path: string,
350
+ body: unknown,
351
+ ): Promise<unknown> {
352
+ try {
353
+ const stub = namespace.get(namespace.idFromName(hubName(appId)))
354
+ const res = await stub.fetch(
355
+ new Request(`${HUB_ORIGIN}${path}`, {
356
+ method: "POST",
357
+ headers: { "content-type": "application/json", [HUB_CONFIG_HEADER]: hubConfigOf(ring) },
358
+ body: JSON.stringify(body),
359
+ }),
360
+ )
361
+ if (!res.ok) {
362
+ await res.body?.cancel()
363
+ return undefined
364
+ }
365
+ return await res.json()
366
+ } catch {
367
+ return undefined
368
+ }
369
+ }
370
+
371
+ async function hubCheck(
372
+ namespace: LiveHubNamespace,
373
+ ring: Keyring,
374
+ claims: readonly GrantClaims[],
375
+ ): Promise<AuthorityState> {
376
+ const subjects = [
377
+ ...new Set(claims.flatMap((claim) => (claim.sub === null ? [] : [claim.sub]))),
378
+ ]
379
+ const keys = [
380
+ ...new Set(
381
+ claims.flatMap((claim) => Object.values(claim.owners).flatMap((owner) => owner.k)),
382
+ ),
383
+ ]
384
+ const reply = parseAuthorityReply(
385
+ await hubCall(namespace, ring, HUB_AUTHORITY_PATH, { subjects, keys }),
386
+ )
387
+ // Unreachable: revocations are unknown, so nothing presented is honored and no grant is issued.
388
+ if (reply === undefined) return { seq: 0, down: true, revocations: REFUSE_ALL }
389
+ return { seq: reply.seq, down: false, revocations: new RevocationView(reply) }
390
+ }
391
+
392
+ /** The handshake goes to the hub, which holds every socket; it checks the Origin itself. */
393
+ async function forwardSocket(namespace: LiveHubNamespace, req: Request): Promise<Response> {
394
+ try {
395
+ return await namespace.get(namespace.idFromName(hubName(appId))).fetch(req)
396
+ } catch {
397
+ return Response.json({ error: "unavailable" }, { status: 503 })
398
+ }
399
+ }
400
+
401
+ function enqueue(item: Queued): void {
402
+ queue.push(item)
403
+ void drain()
404
+ }
405
+
406
+ async function drain(): Promise<void> {
407
+ if (draining || core === undefined) return
408
+ draining = true
409
+ try {
410
+ for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
411
+ await apply(core, item)
412
+ }
413
+ } finally {
414
+ draining = false
415
+ }
416
+ }
417
+
418
+ async function apply(c: Core, item: Queued): Promise<void> {
419
+ if (item.kind === "down") {
420
+ c.hub.down = true
421
+ c.hub.closeAll(CLOSE_CAPACITY, "unavailable")
422
+ return
423
+ }
424
+ if (item.kind === "gap") return forgetUntil(c, item.head)
425
+ const event = item.event
426
+ // At-least-once delivery: a repeat is already applied.
427
+ if (!Number.isSafeInteger(event.seq) || event.seq <= seen) return
428
+ try {
429
+ await applyEvent(c, event, hash)
430
+ settle(event.seq)
431
+ } catch (err) {
432
+ // An event this process could not apply leaves its history and revocations unknown.
433
+ console.error("[nifra/web] live: a bus event could not be applied; closing live sockets", err)
434
+ forgetUntil(c, event.seq)
435
+ }
436
+ }
437
+
438
+ /** Everything up to `head` is unknown: refuse older grants, report all owners stale, reconnect. */
439
+ function forgetUntil(c: Core, head: number): void {
440
+ // Never behind what this process applied: loads already issued watermarks up to `seen`.
441
+ const at = Math.max(head, seen)
442
+ c.history.reset(at)
443
+ c.revocations.raise(at)
444
+ c.hub.down = false
445
+ c.hub.closeAll(CLOSE_CAPACITY, "resync")
446
+ settle(at)
447
+ }
448
+
449
+ function settle(seq: number): void {
450
+ if (seq > seen) seen = seq
451
+ lagging = false
452
+ for (let i = waiters.length - 1; i >= 0; i--) {
453
+ const waiter = waiters[i]
454
+ if (waiter !== undefined && waiter.seq <= seen) {
455
+ waiters.splice(i, 1)
456
+ waiter.resolve()
457
+ }
458
+ }
459
+ }
460
+
461
+ /** Append to the bus, then wait until this process has applied it, at most {@link APPLY_WAIT_MS}. */
462
+ async function append(input: Parameters<LiveBus["append"]>[0]): Promise<void> {
463
+ if (hubNamespace !== undefined) return appendToHub(hubNamespace, input)
464
+ await started
465
+ const seq = await bus.append(input)
466
+ if (seq <= seen) return
467
+ await new Promise<void>((resolve) => {
468
+ const waiter = {
469
+ seq,
470
+ resolve: () => {
471
+ clearTimeout(timer)
472
+ resolve()
473
+ },
474
+ }
475
+ // A reader that stalled would hold every publish, and every action awaiting one, for good.
476
+ const timer = unref(
477
+ setTimeout(() => {
478
+ const at = waiters.indexOf(waiter)
479
+ if (at !== -1) waiters.splice(at, 1)
480
+ if (!lagging) {
481
+ lagging = true
482
+ console.error(
483
+ `[nifra/web] live: this process did not read a publish back from the bus within ${APPLY_WAIT_MS} ms; its sockets miss changes until the bus reader recovers`,
484
+ )
485
+ }
486
+ resolve()
487
+ }, APPLY_WAIT_MS),
488
+ )
489
+ waiters.push(waiter)
490
+ })
491
+ }
492
+
493
+ /** The hub takes at most {@link MAX_APPEND_KEYS} keys a call: a larger change goes as several. */
494
+ async function appendToHub(namespace: LiveHubNamespace, input: LiveBusInput): Promise<void> {
495
+ const ring = await keyring
496
+ const parts: LiveBusInput[] = []
497
+ if (input.type === "change") {
498
+ for (let at = 0; at < input.keys.length; at += MAX_APPEND_KEYS) {
499
+ parts.push({ type: "change", keys: input.keys.slice(at, at + MAX_APPEND_KEYS) })
500
+ }
501
+ } else parts.push(input)
502
+ for (let at = 0; at < parts.length; at++) {
503
+ if (parseSeq(await hubCall(namespace, ring, HUB_APPEND_PATH, parts[at])) === undefined) {
504
+ const published = at * MAX_APPEND_KEYS
505
+ throw new Error(
506
+ published === 0
507
+ ? "[nifra/web] live: the hub did not take the event; nothing was published"
508
+ : `[nifra/web] live: the hub did not take the event; the first ${published} keys were published, the rest were not`,
509
+ )
510
+ }
511
+ }
512
+ }
513
+
514
+ let inflight = 0
515
+ let entering = 0
516
+
517
+ const attach = (input: LiveAttachInput): LiveAttachment => {
518
+ const loads = new WeakMap<Request, LoadState>()
519
+ const app = input.app
520
+ if (hubNamespace === undefined) registerSocket(app)
521
+
522
+ function registerSocket(app: LiveAttachInput["app"]): void {
523
+ publishers.add(weakPublisher(app, publishers))
524
+ // The socket carries no authority before a watch, so it is registered before any app guard.
525
+ app.use(websocket())
526
+ app.ws<LiveSocket>(socketPath, {
527
+ // Decided in `upgrade`, which has the request: a missing or `null` Origin is refused there.
528
+ allowedOrigins: () => true,
529
+ upgrade: async (c) => (await started).hub.upgrade(c.req),
530
+ // A socket opens only after its `upgrade` awaited the start, so the core exists by then.
531
+ open: (ws) => core?.hub.open(ws),
532
+ message: (ws, data) => core?.hub.message(ws, data),
533
+ close: (ws) => core?.hub.close(ws),
534
+ })
535
+ }
536
+
537
+ const admitLoad = async (req: Request): Promise<Request | Response> => {
538
+ const origin = req.headers.get("origin")
539
+ if (
540
+ origin === null ||
541
+ !(trusted.has(origin) || isSameOriginForm(origin, req.headers.get("sec-fetch-site"), req))
542
+ ) {
543
+ return refusal(403, "forbidden_origin")
544
+ }
545
+ const type = req.headers.get("content-type")
546
+ if (type === null || mediaType(type) !== "application/json") {
547
+ return refusal(415, "unsupported_media_type")
548
+ }
549
+ if (inflight + entering >= maxLoads) return busy()
550
+ // Reading the body and opening grants count too: forged grants cannot buy unbounded key
551
+ // derivation.
552
+ entering++
553
+ try {
554
+ return await prepareLoad(req)
555
+ } finally {
556
+ entering--
557
+ }
558
+ }
559
+
560
+ const prepareLoad = async (req: Request): Promise<Request | Response> => {
561
+ const text = await readCapped(req, MAX_BODY_BYTES)
562
+ if (typeof text === "number") return refusal(text, text === 413 ? "too_large" : "bad_request")
563
+ const body = parseBody(text)
564
+ if (body === undefined) return refusal(400, "bad_request")
565
+ const path = canonicalPath(body.path, req.url)
566
+ if (path === undefined) return refusal(400, "bad_path")
567
+ const auth = await authority
568
+ const match = input.matchRoute(path)
569
+ const now = Date.now()
570
+ const presented: OpenedGrant[] = []
571
+ for (const token of body.grants) {
572
+ const opened = await auth.ring.open(token, now)
573
+ if (opened === undefined) continue
574
+ const claims = opened.claims
575
+ if (claims.sid !== body.sid || claims.frame !== body.frame) continue
576
+ // A navigation presents the grant of the page it leaves: only its layouts can carry over, and
577
+ // only where the layout itself would keep its data. A renewal is for the same page.
578
+ if (claims.path !== path && body.mode === "renew") continue
579
+ presented.push(opened)
580
+ }
581
+ // Read before any hook, middleware or loader runs: every owner loaded fresh is at least this new.
582
+ const state = await auth.check(presented.map((opened) => opened.claims))
583
+ const loadSeq = state.seq
584
+ // With the log unreachable, revocations are unknown: nothing presented is honored.
585
+ const grant = state.down
586
+ ? undefined
587
+ : presented.find((opened) => !state.revocations.grantRevoked(opened.claims))
588
+ const requested =
589
+ grant === undefined
590
+ ? []
591
+ : body.mode === "load"
592
+ ? body.retain
593
+ : authorize === undefined || grant.claims.r >= MAX_QUIET_RENEWALS
594
+ ? []
595
+ : Object.keys(grant.claims.owners)
596
+ const layouts: number[] = []
597
+ const holes: string[] = []
598
+ let retainPage = false
599
+ if (grant !== undefined && match !== null) {
600
+ const claims = grant.claims
601
+ for (const owner of requested) {
602
+ const held = Object.hasOwn(claims.owners, owner) ? claims.owners[owner] : undefined
603
+ if (held === undefined) continue
604
+ // A key revoked since issuance is never kept or re-signed: its loader runs and decides again.
605
+ if (held.k.some((key) => state.revocations.keyRevoked(key, claims))) continue
606
+ if (owner.startsWith("L:")) {
607
+ const index = match.layoutIds.indexOf(owner.slice(2))
608
+ if (index !== -1 && !layouts.includes(index)) layouts.push(index)
609
+ } else if (owner === `P:${match.routeId}` && claims.path === path) {
610
+ retainPage = true
611
+ } else if (claims.path === path) {
612
+ const hole = holeOf(owner, match.routeId)
613
+ if (hole !== undefined && !holes.includes(hole)) holes.push(hole)
614
+ }
615
+ }
616
+ }
617
+ const headers = internalHeaders(req.headers)
618
+ if (grant !== undefined && layouts.length > 0) {
619
+ // The page the grant was issued for: the retention rule compares each layout's params there
620
+ // with the target's, exactly as for a router navigation.
621
+ headers.set(RETAIN_HEADER, layouts.sort((a, b) => a - b).join(","))
622
+ headers.set(NAV_FROM_HEADER, grant.claims.path)
623
+ }
624
+ if (holes.length > 0) headers.set(KEEP_HOLES_HEADER, holes.join(","))
625
+ if (body.fill.length > 0) headers.set(HOLE_FILL_HEADER, body.fill.join(","))
626
+ const target = new Request(new URL(path, req.url), {
627
+ method: "GET",
628
+ headers,
629
+ signal: req.signal,
630
+ })
631
+ loads.set(
632
+ target,
633
+ new LoadState(
634
+ auth.ring,
635
+ state.down,
636
+ body,
637
+ path,
638
+ loadSeq,
639
+ grant,
640
+ match?.routeId ?? "",
641
+ retainPage,
642
+ ),
643
+ )
644
+ return target
645
+ }
646
+
647
+ return {
648
+ onRequest: (req) => {
649
+ const pathname = pathnameOf(req.url)
650
+ if (req.method === "POST" && pathname === loadPath) return admitLoad(req)
651
+ if (hubNamespace !== undefined && req.method === "GET" && pathname === socketPath) {
652
+ return forwardSocket(hubNamespace, req)
653
+ }
654
+ return undefined
655
+ },
656
+ loadOf: (req) => {
657
+ // An app hook after the endpoint may hand on a replacement; the load is its original's.
658
+ let current: Request | undefined = req
659
+ for (let hops = 0; current !== undefined && hops < 16; hops++) {
660
+ const load = loads.get(current)
661
+ if (load !== undefined) return load
662
+ current = replacedRequestOf(current)
663
+ }
664
+ return undefined
665
+ },
666
+ }
667
+ }
668
+
669
+ class LoadState implements LiveLoad {
670
+ retainPage: boolean
671
+ private subject: string | null = null
672
+ private admitted = false
673
+ private released = false
674
+ private declared = 0
675
+ /** Keys past the frame budget: counted for the log, never stored. */
676
+ private dropped = 0
677
+ private readonly collectors = new Map<string, { keys: Set<string>; sealed: boolean }>()
678
+
679
+ constructor(
680
+ private readonly ring: Keyring,
681
+ /** No grant is issued: no socket would accept one, and revocations are unknown. */
682
+ private readonly down: boolean,
683
+ private readonly body: LoadBody,
684
+ private readonly path: string,
685
+ private readonly seq: number,
686
+ private readonly grant: OpenedGrant | undefined,
687
+ readonly routeId: string,
688
+ retainPage: boolean,
689
+ ) {
690
+ this.retainPage = retainPage
691
+ }
692
+
693
+ admit(): Response | undefined {
694
+ if (inflight >= maxLoads) return busy()
695
+ inflight++
696
+ this.admitted = true
697
+ return undefined
698
+ }
699
+
700
+ release(): void {
701
+ if (!this.admitted || this.released) return
702
+ this.released = true
703
+ inflight--
704
+ }
705
+
706
+ settle(response: Response): Response {
707
+ if (
708
+ response.body !== null &&
709
+ mediaType(response.headers.get("content-type") ?? "") === "application/x-ndjson"
710
+ ) {
711
+ // Deferred values are still loading while the body streams: the slot is held until it ends.
712
+ return new Response(
713
+ releasing(response.body, () => this.release()),
714
+ response,
715
+ )
716
+ }
717
+ this.release()
718
+ return response
719
+ }
720
+
721
+ async bind(ctx: LoaderContext): Promise<boolean> {
722
+ const subject = await subjectOf(ctx)
723
+ if (subject !== null && typeof subject !== "string") {
724
+ throw new TypeError("[nifra/web] live: `subject` must return a string or null")
725
+ }
726
+ this.subject = subject
727
+ if (this.grant === undefined || this.grant.claims.sub === subject) return true
728
+ this.retainPage = false
729
+ return false
730
+ }
731
+
732
+ collector(owner: string): (...keys: string[]) => void {
733
+ let entry = this.collectors.get(owner)
734
+ if (entry === undefined) {
735
+ entry = { keys: new Set(), sealed: false }
736
+ this.collectors.set(owner, entry)
737
+ }
738
+ const collected = entry
739
+ return (...keys) => {
740
+ if (collected.sealed) {
741
+ throw new TypeError(
742
+ `[nifra/web] ctx.live() was called after the loader of "${owner}" settled; declare keys before the loader returns`,
743
+ )
744
+ }
745
+ for (const key of keys) {
746
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
747
+ throw new TypeError(
748
+ "[nifra/web] ctx.live() keys are a letter followed by up to 127 of A-Z a-z 0-9 . _ : / -",
749
+ )
750
+ }
751
+ if (collected.keys.has(key)) continue
752
+ if (this.declared >= MAX_KEYS_PER_FRAME) {
753
+ if (!production) {
754
+ throw new RangeError(
755
+ `[nifra/web] a live frame holds at most ${MAX_KEYS_PER_FRAME} keys; "${owner}" declared one more`,
756
+ )
757
+ }
758
+ this.dropped++
759
+ continue
760
+ }
761
+ collected.keys.add(key)
762
+ this.declared++
763
+ }
764
+ }
765
+ }
766
+
767
+ seal(owner: string): void {
768
+ const entry = this.collectors.get(owner)
769
+ if (entry !== undefined) entry.sealed = true
770
+ }
771
+
772
+ /** A renewal that kept owners counts one more; a load that ran every loader starts over. */
773
+ private renewals(kept: boolean): number {
774
+ const previous = this.grant?.claims.r ?? 0
775
+ if (!kept) return 0
776
+ return this.body.mode === "renew" ? previous + 1 : previous
777
+ }
778
+
779
+ async finish(input: LiveFinish): Promise<LiveEnvelope> {
780
+ for (const entry of this.collectors.values()) entry.sealed = true
781
+ const ring = this.ring
782
+ const subjects = await ring.subjectTags(this.subject)
783
+ const pageRetained = input.retained.some((owner) => owner.startsWith("P:"))
784
+ const bare: LiveEnvelope = pageRetained ? { subjects, page: "retained" } : { subjects }
785
+ // No socket would accept a grant now, and revocations are unknown: the client polls.
786
+ if (this.down || (core?.hub.down ?? false)) return bare
787
+ const owners: Record<string, GrantOwner> = {}
788
+ const carried = new Set<string>()
789
+ for (const owner of input.retained) {
790
+ const held =
791
+ this.grant !== undefined && Object.hasOwn(this.grant.claims.owners, owner)
792
+ ? this.grant.claims.owners[owner]
793
+ : undefined
794
+ // Kept data with no grant behind it cannot be vouched for.
795
+ if (held === undefined) return bare
796
+ owners[owner] = { k: held.k, w: held.w }
797
+ for (const key of held.k) carried.add(key)
798
+ }
799
+ if (
800
+ carried.size > 0 &&
801
+ authorize !== undefined &&
802
+ !(await authorize([...carried], input.ctx))
803
+ ) {
804
+ return bare
805
+ }
806
+ let count = this.dropped
807
+ for (const owner of input.fresh) {
808
+ const keys = [...(this.collectors.get(owner)?.keys ?? [])]
809
+ owners[owner] = { k: keys, w: this.seq }
810
+ }
811
+ for (const owner of Object.values(owners)) count += owner.k.length
812
+ if (count > MAX_KEYS_PER_FRAME) {
813
+ if (!production) {
814
+ throw new RangeError(
815
+ `[nifra/web] a live frame holds at most ${MAX_KEYS_PER_FRAME} keys; route "${this.routeId}" declared ${count}`,
816
+ )
817
+ }
818
+ console.error(
819
+ `[nifra/web] live: route "${this.routeId}" declared ${count} keys, over the ${MAX_KEYS_PER_FRAME}-key frame budget; no grant issued, the client polls`,
820
+ )
821
+ return bare
822
+ }
823
+ const grant = await ring.seal(
824
+ {
825
+ sid: this.body.sid,
826
+ sub: this.subject,
827
+ path: this.path,
828
+ frame: this.body.frame,
829
+ owners,
830
+ s: this.seq,
831
+ r: this.renewals(input.retained.length > 0),
832
+ },
833
+ Date.now(),
834
+ )
835
+ if (grant === undefined) {
836
+ console.error(
837
+ `[nifra/web] live: no grant issued for route "${this.routeId}" (over ${MAX_GRANT_CHARS} characters, or the subkey's seal limit); the client polls`,
838
+ )
839
+ return bare
840
+ }
841
+ // Each key to the owners holding it, in owner order.
842
+ const owning = new Map<string, string[]>()
843
+ for (const [name, owner] of Object.entries(owners)) {
844
+ for (const key of owner.k) {
845
+ const list = owning.get(key)
846
+ if (list === undefined) owning.set(key, [name])
847
+ else if (list.at(-1) !== name) list.push(name)
848
+ }
849
+ }
850
+ const keys = [...owning.keys()]
851
+ const salt = queryKeys ? querySalt() : undefined
852
+ const tagOf = await tags
853
+ const [wire, digested] = await Promise.all([
854
+ Promise.all(keys.map((key) => tagOf(key, 0))),
855
+ salt === undefined
856
+ ? undefined
857
+ : queryDigester(salt).then((digest) => Promise.all(keys.map(digest))),
858
+ ])
859
+ const tagMap: Record<string, string[]> = {}
860
+ const digests: Record<string, string> = {}
861
+ keys.forEach((key, at) => {
862
+ const tag = wire[at] ?? ""
863
+ tagMap[tag] = owning.get(key) ?? []
864
+ const digest = digested?.[at]
865
+ if (digest !== undefined) digests[tag] = digest
866
+ })
867
+ const envelope: LiveEnvelope = { ...bare, grant, tags: tagMap, ttl }
868
+ if (salt !== undefined) {
869
+ return {
870
+ ...envelope,
871
+ query: production ? { salt, keys: digests } : { salt, keys: digests, dev: true },
872
+ }
873
+ }
874
+ return production ? envelope : { ...envelope, query: { dev: true } }
875
+ }
876
+ }
877
+
878
+ const checkKey = (key: unknown, method: string): string => {
879
+ if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
880
+ throw new TypeError(`[nifra/web] live.${method} takes keys in the ctx.live() charset`)
881
+ }
882
+ return key
883
+ }
884
+
885
+ const clientEntry: { path?: string; loadPath?: string } = {}
886
+ if (socketPath !== DEFAULT_SOCKET_PATH) clientEntry.path = socketPath
887
+ if (loadPath !== DEFAULT_LOAD_PATH) clientEntry.loadPath = loadPath
888
+
889
+ return {
890
+ clientEntry: Object.freeze(clientEntry),
891
+ ready: authority.then(() => undefined),
892
+ async publish(...keys) {
893
+ for (const key of keys) checkKey(key, "publish")
894
+ if (keys.length === 0) return
895
+ await visible?.()
896
+ await append({ type: "change", keys: [...new Set(keys)] })
897
+ },
898
+ async revoke(subject) {
899
+ if (typeof subject !== "string")
900
+ throw new TypeError("[nifra/web] live.revoke takes a subject")
901
+ await append({ type: "revoke", sub: subject, key: null })
902
+ },
903
+ async revokeAll() {
904
+ await append({ type: "revoke", sub: null, key: null })
905
+ },
906
+ async revokeKey(key, subject) {
907
+ checkKey(key, "revokeKey")
908
+ if (subject !== undefined && typeof subject !== "string") {
909
+ throw new TypeError("[nifra/web] live.revokeKey takes an optional subject string")
910
+ }
911
+ await append({ type: "revoke", sub: subject ?? null, key })
912
+ },
913
+ [LIVE_ATTACH]: attach,
914
+ }
915
+ }
916
+
917
+ /**
918
+ * `app.publish`, holding the app weakly: a dev server builds a new app on every edit, and a discarded
919
+ * one must not stay reachable, nor keep being published to, through the runtime. Built out here so
920
+ * the closure's scope holds nothing else of the app.
921
+ */
922
+ function weakPublisher(
923
+ app: { publish(topic: string, frame: string): unknown },
924
+ publishers: Set<(topic: string, frame: string) => void>,
925
+ ): (topic: string, frame: string) => void {
926
+ const held = new WeakRef(app)
927
+ const publisher = (topic: string, frame: string): void => {
928
+ const attached = held.deref()
929
+ if (attached === undefined) publishers.delete(publisher)
930
+ else attached.publish(topic, frame)
931
+ }
932
+ return publisher
933
+ }
934
+
935
+ function endpointPath(path: string, name: string): string {
936
+ if (
937
+ typeof path !== "string" ||
938
+ !path.startsWith("/") ||
939
+ path.startsWith("//") ||
940
+ /[?#\s]/.test(path)
941
+ ) {
942
+ throw new TypeError(`[nifra/web] live: \`${name}\` must be an absolute path with no query`)
943
+ }
944
+ return path
945
+ }
946
+
947
+ const noSecret = (secret: LiveOptions["secret"]): boolean =>
948
+ secret === undefined || (Array.isArray(secret) && secret.length === 0) || secret === ""
949
+
950
+ function secretsOf(secret: LiveOptions["secret"]): readonly unknown[] {
951
+ if (secret !== undefined && !noSecret(secret))
952
+ return typeof secret === "string" ? [secret] : secret
953
+ if (isProduction()) {
954
+ throw new TypeError(
955
+ "[nifra/web] live: `secret` is required in production. Generate one with: openssl rand -base64 32",
956
+ )
957
+ }
958
+ return [toBase64Url(crypto.getRandomValues(new Uint8Array(32)))]
959
+ }
960
+
961
+ /**
962
+ * An environment this cannot read counts as production, so a secret is required: Deno throws on
963
+ * `process.env` without `--allow-env`, and a runtime with no `process` says nothing either way.
964
+ */
965
+ function isProduction(): boolean {
966
+ try {
967
+ return typeof process === "undefined" || process.env?.NODE_ENV === "production"
968
+ } catch {
969
+ return true
970
+ }
971
+ }
972
+
973
+ const mediaType = (value: string): string => {
974
+ const end = value.indexOf(";")
975
+ return (end === -1 ? value : value.slice(0, end)).trim().toLowerCase()
976
+ }
977
+
978
+ function pathnameOf(url: string): string {
979
+ const start = url.indexOf("/", url.indexOf("//") + 2)
980
+ if (start === -1) return "/"
981
+ const query = url.indexOf("?", start)
982
+ return query === -1 ? url.slice(start) : url.slice(start, query)
983
+ }
984
+
985
+ function refusal(status: number, error: string): Response {
986
+ return Response.json({ error }, { status, headers: { "cache-control": NO_STORE } })
987
+ }
988
+
989
+ function busy(): Response {
990
+ return Response.json(
991
+ { error: "live_load_busy" },
992
+ {
993
+ status: 503,
994
+ headers: {
995
+ "cache-control": NO_STORE,
996
+ "retry-after": String(1 + Math.floor(Math.random() * 3)),
997
+ },
998
+ },
999
+ )
1000
+ }
1001
+
1002
+ /** The hole a `B:<hole>:<routeId>` owner of route `routeId` names. */
1003
+ function holeOf(owner: string, routeId: string): string | undefined {
1004
+ const suffix = `:${routeId}`
1005
+ if (!owner.startsWith("B:") || !owner.endsWith(suffix)) return undefined
1006
+ const hole = owner.slice(2, owner.length - suffix.length)
1007
+ return hole === "" || hole.includes(":") || hole.includes(",") ? undefined : hole
1008
+ }
1009
+
1010
+ function parseBody(text: string): LoadBody | undefined {
1011
+ let value: unknown
1012
+ try {
1013
+ value = JSON.parse(text)
1014
+ } catch {
1015
+ return undefined
1016
+ }
1017
+ if (!isRecord(value)) return undefined
1018
+ const { path, sid, frame, mode, retain = [], grants = [], fill = [] } = value
1019
+ if (typeof path !== "string" || typeof sid !== "string" || !SID_PATTERN.test(sid))
1020
+ return undefined
1021
+ // Only the page frame is served; boundary frames arrive with the client's frame support.
1022
+ if (frame !== PAGE_FRAME || (mode !== "load" && mode !== "renew")) return undefined
1023
+ if (!isStringList(retain, MAX_RETAIN, MAX_OWNER_CHARS)) return undefined
1024
+ if (!isStringList(grants, MAX_GRANTS, MAX_GRANT_CHARS)) return undefined
1025
+ if (!isStringList(fill, MAX_HOSTS, MAX_HOST_CHARS) || !fill.every((name) => HOST.test(name)))
1026
+ return undefined
1027
+ return { path, sid, frame, mode, retain, grants, fill }
1028
+ }
1029
+
1030
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1031
+ typeof value === "object" && value !== null && !Array.isArray(value)
1032
+
1033
+ const isStringList = (value: unknown, max: number, chars: number): value is readonly string[] =>
1034
+ Array.isArray(value) &&
1035
+ value.length <= max &&
1036
+ value.every((item) => typeof item === "string" && item.length <= chars)
1037
+
1038
+ /** `path` when it is already the canonical same-origin `pathname + search`; anything else is refused. */
1039
+ function canonicalPath(path: string, base: string): string | undefined {
1040
+ if (path.length > MAX_PATH_CHARS || !path.startsWith("/") || path.startsWith("//"))
1041
+ return undefined
1042
+ // An absolute path against a request URL always parses: the parser normalises, it never throws.
1043
+ const url = new URL(path, base)
1044
+ const canonical = url.pathname + url.search
1045
+ return url.origin === new URL(base).origin && canonical === path ? canonical : undefined
1046
+ }
1047
+
1048
+ /**
1049
+ * The headers the router's own data request for the target would carry: the POST's, without the
1050
+ * hop-by-hop, body and `origin` headers a same-origin GET does not send, and without any `x-nifra-*`
1051
+ * the client sent. `sec-fetch-*` pass through: a browser's fetch sends the same values for both.
1052
+ */
1053
+ function internalHeaders(post: Headers): Headers {
1054
+ const headers = new Headers(post)
1055
+ const nominated = headers.get("connection")
1056
+ if (nominated !== null) {
1057
+ for (const name of nominated.split(",")) {
1058
+ const trimmed = name.trim()
1059
+ if (TOKEN.test(trimmed)) headers.delete(trimmed)
1060
+ }
1061
+ }
1062
+ for (const name of HOP_BY_HOP) headers.delete(name)
1063
+ for (const name of BODY_HEADERS) headers.delete(name)
1064
+ headers.delete("origin")
1065
+ const own: string[] = []
1066
+ headers.forEach((_value, name) => {
1067
+ if (name.startsWith("x-nifra-")) own.push(name)
1068
+ })
1069
+ for (const name of own) headers.delete(name)
1070
+ headers.set(DATA_HEADER, "1")
1071
+ return headers
1072
+ }
1073
+
1074
+ function releasing(body: ReadableStream<Uint8Array>, done: () => void): ReadableStream<Uint8Array> {
1075
+ const reader = body.getReader()
1076
+ return new ReadableStream<Uint8Array>({
1077
+ async pull(controller) {
1078
+ // Released on the last chunk and on a read that throws: only an enqueue keeps the slot.
1079
+ let finished = true
1080
+ try {
1081
+ const chunk = await reader.read()
1082
+ if (chunk.done) controller.close()
1083
+ else {
1084
+ finished = false
1085
+ controller.enqueue(chunk.value)
1086
+ }
1087
+ } finally {
1088
+ if (finished) done()
1089
+ }
1090
+ },
1091
+ cancel(reason) {
1092
+ done()
1093
+ return reader.cancel(reason)
1094
+ },
1095
+ })
1096
+ }