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