@nifrajs/web 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (457) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/boundary.d.ts +67 -7
  3. package/dist/boundary.d.ts.map +1 -1
  4. package/dist/boundary.js +6 -4
  5. package/dist/boundary.js.map +1 -1
  6. package/dist/build-plan.d.ts +3 -0
  7. package/dist/build-plan.d.ts.map +1 -1
  8. package/dist/build-plan.js.map +1 -1
  9. package/dist/build-vite.d.ts.map +1 -1
  10. package/dist/build-vite.js +2 -0
  11. package/dist/build-vite.js.map +1 -1
  12. package/dist/build.d.ts +9 -2
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.js +9 -0
  15. package/dist/build.js.map +1 -1
  16. package/dist/client.d.ts +2 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +140 -12
  19. package/dist/client.js.map +1 -1
  20. package/dist/csp.d.ts +8 -0
  21. package/dist/csp.d.ts.map +1 -1
  22. package/dist/csp.js +11 -2
  23. package/dist/csp.js.map +1 -1
  24. package/dist/deferred.d.ts +6 -4
  25. package/dist/deferred.d.ts.map +1 -1
  26. package/dist/deferred.js +10 -6
  27. package/dist/deferred.js.map +1 -1
  28. package/dist/dev-feed.d.ts.map +1 -1
  29. package/dist/dev-feed.js +11 -2
  30. package/dist/dev-feed.js.map +1 -1
  31. package/dist/dev-session.d.ts.map +1 -1
  32. package/dist/dev-session.js +13 -6
  33. package/dist/dev-session.js.map +1 -1
  34. package/dist/dev.d.ts +4 -1
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +12 -5
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic-catalog.d.ts +4 -0
  39. package/dist/diagnostic-catalog.d.ts.map +1 -1
  40. package/dist/diagnostic-catalog.js +48 -0
  41. package/dist/diagnostic-catalog.js.map +1 -1
  42. package/dist/diagnostic.d.ts +1 -1
  43. package/dist/diagnostic.d.ts.map +1 -1
  44. package/dist/diagnostic.js +1 -1
  45. package/dist/diagnostic.js.map +1 -1
  46. package/dist/fn.d.ts +5 -3
  47. package/dist/fn.d.ts.map +1 -1
  48. package/dist/fn.js +20 -5
  49. package/dist/fn.js.map +1 -1
  50. package/dist/fs.d.ts.map +1 -1
  51. package/dist/fs.js +13 -3
  52. package/dist/fs.js.map +1 -1
  53. package/dist/identity.d.ts +22 -0
  54. package/dist/identity.d.ts.map +1 -0
  55. package/dist/identity.js +60 -0
  56. package/dist/identity.js.map +1 -0
  57. package/dist/index.d.ts +7 -6
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/internal/cdn/cloudflare.d.ts +2 -1
  62. package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
  63. package/dist/internal/cdn/cloudflare.js +2 -1
  64. package/dist/internal/cdn/cloudflare.js.map +1 -1
  65. package/dist/internal/certified-components.d.ts +1 -1
  66. package/dist/internal/certified-components.js +1 -1
  67. package/dist/internal/codegen.d.ts +11 -10
  68. package/dist/internal/codegen.d.ts.map +1 -1
  69. package/dist/internal/codegen.js +65 -8
  70. package/dist/internal/codegen.js.map +1 -1
  71. package/dist/internal/dev-websocket.d.ts +73 -0
  72. package/dist/internal/dev-websocket.d.ts.map +1 -0
  73. package/dist/internal/dev-websocket.js +145 -0
  74. package/dist/internal/dev-websocket.js.map +1 -0
  75. package/dist/internal/hole-runtime.d.ts +66 -0
  76. package/dist/internal/hole-runtime.d.ts.map +1 -0
  77. package/dist/internal/hole-runtime.js +211 -0
  78. package/dist/internal/hole-runtime.js.map +1 -0
  79. package/dist/internal/identity-epoch.d.ts +50 -0
  80. package/dist/internal/identity-epoch.d.ts.map +1 -0
  81. package/dist/internal/identity-epoch.js +179 -0
  82. package/dist/internal/identity-epoch.js.map +1 -0
  83. package/dist/internal/invalidation-contract.d.ts +23 -0
  84. package/dist/internal/invalidation-contract.d.ts.map +1 -0
  85. package/dist/internal/invalidation-contract.js +11 -0
  86. package/dist/internal/invalidation-contract.js.map +1 -0
  87. package/dist/internal/invalidation.d.ts +62 -0
  88. package/dist/internal/invalidation.d.ts.map +1 -0
  89. package/dist/internal/invalidation.js +149 -0
  90. package/dist/internal/invalidation.js.map +1 -0
  91. package/dist/internal/island-bindings.d.ts +51 -0
  92. package/dist/internal/island-bindings.d.ts.map +1 -0
  93. package/dist/internal/island-bindings.js +147 -0
  94. package/dist/internal/island-bindings.js.map +1 -0
  95. package/dist/internal/island-list.d.ts +26 -0
  96. package/dist/internal/island-list.d.ts.map +1 -0
  97. package/dist/internal/island-list.js +78 -0
  98. package/dist/internal/island-list.js.map +1 -0
  99. package/dist/internal/island-mount.d.ts +4 -0
  100. package/dist/internal/island-mount.d.ts.map +1 -0
  101. package/dist/internal/island-mount.js +66 -0
  102. package/dist/internal/island-mount.js.map +1 -0
  103. package/dist/internal/island-owner.d.ts +5 -0
  104. package/dist/internal/island-owner.d.ts.map +1 -0
  105. package/dist/internal/island-owner.js +20 -0
  106. package/dist/internal/island-owner.js.map +1 -0
  107. package/dist/internal/island-resource.d.ts +30 -0
  108. package/dist/internal/island-resource.d.ts.map +1 -0
  109. package/dist/internal/island-resource.js +57 -0
  110. package/dist/internal/island-resource.js.map +1 -0
  111. package/dist/internal/island-signals.d.ts +51 -0
  112. package/dist/internal/island-signals.d.ts.map +1 -0
  113. package/dist/internal/island-signals.js +220 -0
  114. package/dist/internal/island-signals.js.map +1 -0
  115. package/dist/internal/keep-alive-table.d.ts +23 -0
  116. package/dist/internal/keep-alive-table.d.ts.map +1 -0
  117. package/dist/internal/keep-alive-table.js +53 -0
  118. package/dist/internal/keep-alive-table.js.map +1 -0
  119. package/dist/internal/live-contract.d.ts +96 -0
  120. package/dist/internal/live-contract.d.ts.map +1 -0
  121. package/dist/internal/live-contract.js +5 -0
  122. package/dist/internal/live-contract.js.map +1 -0
  123. package/dist/internal/live-queries.d.ts +22 -0
  124. package/dist/internal/live-queries.d.ts.map +1 -0
  125. package/dist/internal/live-queries.js +54 -0
  126. package/dist/internal/live-queries.js.map +1 -0
  127. package/dist/internal/page-events.d.ts +10 -0
  128. package/dist/internal/page-events.d.ts.map +1 -0
  129. package/dist/internal/page-events.js +26 -0
  130. package/dist/internal/page-events.js.map +1 -0
  131. package/dist/internal/page-execution.d.ts +14 -1
  132. package/dist/internal/page-execution.d.ts.map +1 -1
  133. package/dist/internal/page-execution.js +415 -31
  134. package/dist/internal/page-execution.js.map +1 -1
  135. package/dist/internal/ppr/admission.d.ts +83 -0
  136. package/dist/internal/ppr/admission.d.ts.map +1 -0
  137. package/dist/internal/ppr/admission.js +221 -0
  138. package/dist/internal/ppr/admission.js.map +1 -0
  139. package/dist/internal/ppr/canary.d.ts +24 -0
  140. package/dist/internal/ppr/canary.d.ts.map +1 -0
  141. package/dist/internal/ppr/canary.js +102 -0
  142. package/dist/internal/ppr/canary.js.map +1 -0
  143. package/dist/internal/ppr/compile-shell.d.ts +39 -0
  144. package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
  145. package/dist/internal/ppr/compile-shell.js +298 -0
  146. package/dist/internal/ppr/compile-shell.js.map +1 -0
  147. package/dist/internal/ppr/contract.d.ts +177 -0
  148. package/dist/internal/ppr/contract.d.ts.map +1 -0
  149. package/dist/internal/ppr/contract.js +80 -0
  150. package/dist/internal/ppr/contract.js.map +1 -0
  151. package/dist/internal/ppr/frames.d.ts +71 -0
  152. package/dist/internal/ppr/frames.d.ts.map +1 -0
  153. package/dist/internal/ppr/frames.js +119 -0
  154. package/dist/internal/ppr/frames.js.map +1 -0
  155. package/dist/internal/ppr/integration.d.ts +19 -0
  156. package/dist/internal/ppr/integration.d.ts.map +1 -0
  157. package/dist/internal/ppr/integration.js +106 -0
  158. package/dist/internal/ppr/integration.js.map +1 -0
  159. package/dist/internal/ppr/options.d.ts +96 -0
  160. package/dist/internal/ppr/options.d.ts.map +1 -0
  161. package/dist/internal/ppr/options.js +125 -0
  162. package/dist/internal/ppr/options.js.map +1 -0
  163. package/dist/internal/ppr/route-rules.d.ts +12 -0
  164. package/dist/internal/ppr/route-rules.d.ts.map +1 -0
  165. package/dist/internal/ppr/route-rules.js +53 -0
  166. package/dist/internal/ppr/route-rules.js.map +1 -0
  167. package/dist/internal/ppr/runtime.d.ts +29 -0
  168. package/dist/internal/ppr/runtime.d.ts.map +1 -0
  169. package/dist/internal/ppr/runtime.js +32 -0
  170. package/dist/internal/ppr/runtime.js.map +1 -0
  171. package/dist/internal/ppr/server.d.ts +29 -0
  172. package/dist/internal/ppr/server.d.ts.map +1 -0
  173. package/dist/internal/ppr/server.js +1407 -0
  174. package/dist/internal/ppr/server.js.map +1 -0
  175. package/dist/internal/ppr/shell-store.d.ts +38 -0
  176. package/dist/internal/ppr/shell-store.d.ts.map +1 -0
  177. package/dist/internal/ppr/shell-store.js +90 -0
  178. package/dist/internal/ppr/shell-store.js.map +1 -0
  179. package/dist/internal/prerendered-pages.d.ts +1 -1
  180. package/dist/internal/prerendered-pages.d.ts.map +1 -1
  181. package/dist/internal/prerendered-pages.js +7 -4
  182. package/dist/internal/prerendered-pages.js.map +1 -1
  183. package/dist/internal/query-digest.d.ts +9 -0
  184. package/dist/internal/query-digest.d.ts.map +1 -0
  185. package/dist/internal/query-digest.js +23 -0
  186. package/dist/internal/query-digest.js.map +1 -0
  187. package/dist/internal/query-runtime.d.ts +6 -1
  188. package/dist/internal/query-runtime.d.ts.map +1 -1
  189. package/dist/internal/query-runtime.js +9 -0
  190. package/dist/internal/query-runtime.js.map +1 -1
  191. package/dist/internal/query-seam.d.ts +29 -0
  192. package/dist/internal/query-seam.d.ts.map +1 -0
  193. package/dist/internal/query-seam.js +23 -0
  194. package/dist/internal/query-seam.js.map +1 -0
  195. package/dist/internal/render-document.d.ts +28 -1
  196. package/dist/internal/render-document.d.ts.map +1 -1
  197. package/dist/internal/render-document.js +83 -18
  198. package/dist/internal/render-document.js.map +1 -1
  199. package/dist/internal/request-abort.d.ts +8 -0
  200. package/dist/internal/request-abort.d.ts.map +1 -0
  201. package/dist/internal/request-abort.js +22 -0
  202. package/dist/internal/request-abort.js.map +1 -0
  203. package/dist/internal/server-boundary.d.ts +2 -0
  204. package/dist/internal/server-boundary.d.ts.map +1 -1
  205. package/dist/internal/server-boundary.js +8 -1
  206. package/dist/internal/server-boundary.js.map +1 -1
  207. package/dist/internal/share-data.d.ts +10 -0
  208. package/dist/internal/share-data.d.ts.map +1 -0
  209. package/dist/internal/share-data.js +135 -0
  210. package/dist/internal/share-data.js.map +1 -0
  211. package/dist/internal/store-signature.d.ts +15 -0
  212. package/dist/internal/store-signature.d.ts.map +1 -0
  213. package/dist/internal/store-signature.js +114 -0
  214. package/dist/internal/store-signature.js.map +1 -0
  215. package/dist/internal/vite-plugin-compat.d.ts +2 -2
  216. package/dist/internal/vite-plugin-compat.js +2 -2
  217. package/dist/internal/web-app.d.ts +25 -0
  218. package/dist/internal/web-app.d.ts.map +1 -1
  219. package/dist/internal/web-app.js +78 -3
  220. package/dist/internal/web-app.js.map +1 -1
  221. package/dist/islands-swap.d.ts +41 -0
  222. package/dist/islands-swap.d.ts.map +1 -0
  223. package/dist/islands-swap.js +270 -0
  224. package/dist/islands-swap.js.map +1 -0
  225. package/dist/islands.d.ts +86 -24
  226. package/dist/islands.d.ts.map +1 -1
  227. package/dist/islands.js +138 -78
  228. package/dist/islands.js.map +1 -1
  229. package/dist/isr.d.ts +67 -13
  230. package/dist/isr.d.ts.map +1 -1
  231. package/dist/isr.js +247 -99
  232. package/dist/isr.js.map +1 -1
  233. package/dist/keep-alive.d.ts +147 -0
  234. package/dist/keep-alive.d.ts.map +1 -0
  235. package/dist/keep-alive.js +1069 -0
  236. package/dist/keep-alive.js.map +1 -0
  237. package/dist/live/bus.d.ts +56 -0
  238. package/dist/live/bus.d.ts.map +1 -0
  239. package/dist/live/bus.js +32 -0
  240. package/dist/live/bus.js.map +1 -0
  241. package/dist/live/core.d.ts +25 -0
  242. package/dist/live/core.d.ts.map +1 -0
  243. package/dist/live/core.js +29 -0
  244. package/dist/live/core.js.map +1 -0
  245. package/dist/live/history.d.ts +38 -0
  246. package/dist/live/history.d.ts.map +1 -0
  247. package/dist/live/history.js +104 -0
  248. package/dist/live/history.js.map +1 -0
  249. package/dist/live/hub-protocol.d.ts +46 -0
  250. package/dist/live/hub-protocol.d.ts.map +1 -0
  251. package/dist/live/hub-protocol.js +108 -0
  252. package/dist/live/hub-protocol.js.map +1 -0
  253. package/dist/live/hub.d.ts +181 -0
  254. package/dist/live/hub.d.ts.map +1 -0
  255. package/dist/live/hub.js +539 -0
  256. package/dist/live/hub.js.map +1 -0
  257. package/dist/live/keyring.d.ts +87 -0
  258. package/dist/live/keyring.d.ts.map +1 -0
  259. package/dist/live/keyring.js +260 -0
  260. package/dist/live/keyring.js.map +1 -0
  261. package/dist/live/read-capped.d.ts +6 -0
  262. package/dist/live/read-capped.d.ts.map +1 -0
  263. package/dist/live/read-capped.js +42 -0
  264. package/dist/live/read-capped.js.map +1 -0
  265. package/dist/live/revocations.d.ts +52 -0
  266. package/dist/live/revocations.d.ts.map +1 -0
  267. package/dist/live/revocations.js +116 -0
  268. package/dist/live/revocations.js.map +1 -0
  269. package/dist/live/settings.d.ts +14 -0
  270. package/dist/live/settings.d.ts.map +1 -0
  271. package/dist/live/settings.js +38 -0
  272. package/dist/live/settings.js.map +1 -0
  273. package/dist/live-client.d.ts +34 -0
  274. package/dist/live-client.d.ts.map +1 -0
  275. package/dist/live-client.js +840 -0
  276. package/dist/live-client.js.map +1 -0
  277. package/dist/live-hub.d.ts +130 -0
  278. package/dist/live-hub.d.ts.map +1 -0
  279. package/dist/live-hub.js +507 -0
  280. package/dist/live-hub.js.map +1 -0
  281. package/dist/live-redis.d.ts +48 -0
  282. package/dist/live-redis.d.ts.map +1 -0
  283. package/dist/live-redis.js +201 -0
  284. package/dist/live-redis.js.map +1 -0
  285. package/dist/live.d.ts +94 -0
  286. package/dist/live.d.ts.map +1 -0
  287. package/dist/live.js +869 -0
  288. package/dist/live.js.map +1 -0
  289. package/dist/manifest.d.ts +113 -2
  290. package/dist/manifest.d.ts.map +1 -1
  291. package/dist/manifest.js +109 -5
  292. package/dist/manifest.js.map +1 -1
  293. package/dist/navigation.d.ts +9 -3
  294. package/dist/navigation.d.ts.map +1 -1
  295. package/dist/navigation.js +13 -3
  296. package/dist/navigation.js.map +1 -1
  297. package/dist/plugins/vite-zone-guard.d.ts +6 -0
  298. package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
  299. package/dist/plugins/vite-zone-guard.js +8 -2
  300. package/dist/plugins/vite-zone-guard.js.map +1 -1
  301. package/dist/ppr.d.ts +73 -0
  302. package/dist/ppr.d.ts.map +1 -0
  303. package/dist/ppr.js +76 -0
  304. package/dist/ppr.js.map +1 -0
  305. package/dist/query.d.ts +8 -13
  306. package/dist/query.d.ts.map +1 -1
  307. package/dist/query.js +112 -18
  308. package/dist/query.js.map +1 -1
  309. package/dist/render-seam.d.ts +30 -0
  310. package/dist/render-seam.d.ts.map +1 -1
  311. package/dist/render-seam.js +5 -0
  312. package/dist/render-seam.js.map +1 -1
  313. package/dist/route-types.d.ts.map +1 -1
  314. package/dist/route-types.js +2 -2
  315. package/dist/route-types.js.map +1 -1
  316. package/dist/router.d.ts +72 -4
  317. package/dist/router.d.ts.map +1 -1
  318. package/dist/router.js +374 -99
  319. package/dist/router.js.map +1 -1
  320. package/dist/server-components/build.d.ts.map +1 -1
  321. package/dist/server-components/build.js +3 -3
  322. package/dist/server-components/build.js.map +1 -1
  323. package/dist/server-components/client.d.ts +23 -1
  324. package/dist/server-components/client.d.ts.map +1 -1
  325. package/dist/server-components/client.js +92 -5
  326. package/dist/server-components/client.js.map +1 -1
  327. package/dist/server-components/hole.d.ts +8 -0
  328. package/dist/server-components/hole.d.ts.map +1 -0
  329. package/dist/server-components/hole.js +20 -0
  330. package/dist/server-components/hole.js.map +1 -0
  331. package/dist/server-components/html-policy.d.ts.map +1 -1
  332. package/dist/server-components/html-policy.js +62 -4
  333. package/dist/server-components/html-policy.js.map +1 -1
  334. package/dist/server-components/html-scan.d.ts +17 -0
  335. package/dist/server-components/html-scan.d.ts.map +1 -0
  336. package/dist/server-components/html-scan.js +180 -0
  337. package/dist/server-components/html-scan.js.map +1 -0
  338. package/dist/server-components/html.d.ts.map +1 -1
  339. package/dist/server-components/html.js +19 -4
  340. package/dist/server-components/html.js.map +1 -1
  341. package/dist/server-components/router-integration.d.ts +4 -1
  342. package/dist/server-components/router-integration.d.ts.map +1 -1
  343. package/dist/server-components/router-integration.js +23 -2
  344. package/dist/server-components/router-integration.js.map +1 -1
  345. package/dist/server-components/runtime.d.ts +6 -1
  346. package/dist/server-components/runtime.d.ts.map +1 -1
  347. package/dist/server-components/runtime.js +86 -28
  348. package/dist/server-components/runtime.js.map +1 -1
  349. package/dist/server-components/state.d.ts +3 -1
  350. package/dist/server-components/state.d.ts.map +1 -1
  351. package/dist/server-components/state.js +50 -38
  352. package/dist/server-components/state.js.map +1 -1
  353. package/dist/server-components/types.d.ts +9 -0
  354. package/dist/server-components/types.d.ts.map +1 -1
  355. package/dist/vite.d.ts +5 -1
  356. package/dist/vite.d.ts.map +1 -1
  357. package/dist/vite.js +58 -4
  358. package/dist/vite.js.map +1 -1
  359. package/package.json +48 -8
  360. package/src/boundary.ts +77 -5
  361. package/src/build-plan.ts +3 -0
  362. package/src/build-vite.ts +2 -0
  363. package/src/build.ts +19 -1
  364. package/src/client.ts +147 -10
  365. package/src/csp.ts +16 -2
  366. package/src/deferred.ts +11 -5
  367. package/src/dev-feed.ts +11 -2
  368. package/src/dev-session.ts +22 -11
  369. package/src/dev.ts +29 -8
  370. package/src/diagnostic-catalog.ts +53 -0
  371. package/src/diagnostic.ts +1 -0
  372. package/src/fn.ts +24 -7
  373. package/src/fs.ts +22 -8
  374. package/src/identity.ts +61 -0
  375. package/src/index.ts +19 -0
  376. package/src/internal/cdn/cloudflare.ts +2 -1
  377. package/src/internal/certified-components.ts +1 -1
  378. package/src/internal/codegen.ts +87 -8
  379. package/src/internal/dev-websocket.ts +230 -0
  380. package/src/internal/hole-runtime.ts +286 -0
  381. package/src/internal/identity-epoch.ts +235 -0
  382. package/src/internal/invalidation-contract.ts +35 -0
  383. package/src/internal/invalidation.ts +213 -0
  384. package/src/internal/island-bindings.ts +191 -0
  385. package/src/internal/island-list.ts +92 -0
  386. package/src/internal/island-mount.ts +68 -0
  387. package/src/internal/island-owner.ts +20 -0
  388. package/src/internal/island-resource.ts +76 -0
  389. package/src/internal/island-signals.ts +249 -0
  390. package/src/internal/keep-alive-table.ts +72 -0
  391. package/src/internal/live-contract.ts +106 -0
  392. package/src/internal/live-queries.ts +60 -0
  393. package/src/internal/page-events.ts +27 -0
  394. package/src/internal/page-execution.ts +579 -40
  395. package/src/internal/ppr/admission.ts +300 -0
  396. package/src/internal/ppr/canary.ts +122 -0
  397. package/src/internal/ppr/compile-shell.ts +379 -0
  398. package/src/internal/ppr/contract.ts +265 -0
  399. package/src/internal/ppr/frames.ts +170 -0
  400. package/src/internal/ppr/integration.ts +137 -0
  401. package/src/internal/ppr/options.ts +221 -0
  402. package/src/internal/ppr/route-rules.ts +77 -0
  403. package/src/internal/ppr/runtime.ts +36 -0
  404. package/src/internal/ppr/server.ts +1764 -0
  405. package/src/internal/ppr/shell-store.ts +139 -0
  406. package/src/internal/prerendered-pages.ts +7 -4
  407. package/src/internal/query-digest.ts +34 -0
  408. package/src/internal/query-runtime.ts +14 -1
  409. package/src/internal/query-seam.ts +48 -0
  410. package/src/internal/render-document.ts +149 -22
  411. package/src/internal/request-abort.ts +26 -0
  412. package/src/internal/server-boundary.ts +10 -1
  413. package/src/internal/share-data.ts +136 -0
  414. package/src/internal/store-signature.ts +149 -0
  415. package/src/internal/vite-plugin-compat.ts +2 -2
  416. package/src/internal/web-app.ts +121 -3
  417. package/src/islands-swap.ts +283 -0
  418. package/src/islands.ts +169 -72
  419. package/src/isr.ts +348 -106
  420. package/src/keep-alive.ts +1315 -0
  421. package/src/live/bus.ts +77 -0
  422. package/src/live/core.ts +52 -0
  423. package/src/live/history.ts +109 -0
  424. package/src/live/hub-protocol.ts +133 -0
  425. package/src/live/hub.ts +615 -0
  426. package/src/live/keyring.ts +379 -0
  427. package/src/live/read-capped.ts +36 -0
  428. package/src/live/revocations.ts +155 -0
  429. package/src/live/settings.ts +46 -0
  430. package/src/live-client.ts +973 -0
  431. package/src/live-hub.ts +658 -0
  432. package/src/live-redis.ts +259 -0
  433. package/src/live.ts +1096 -0
  434. package/src/manifest.ts +274 -6
  435. package/src/navigation.ts +14 -2
  436. package/src/plugins/vite-zone-guard.ts +15 -2
  437. package/src/ppr.ts +153 -0
  438. package/src/query.ts +147 -20
  439. package/src/render-seam.ts +35 -0
  440. package/src/route-types.ts +3 -1
  441. package/src/router.ts +494 -101
  442. package/src/server-components/build.ts +9 -3
  443. package/src/server-components/client.ts +89 -5
  444. package/src/server-components/hole.ts +23 -0
  445. package/src/server-components/html-policy.ts +63 -4
  446. package/src/server-components/html-scan.ts +159 -0
  447. package/src/server-components/html.ts +18 -4
  448. package/src/server-components/router-integration.ts +21 -2
  449. package/src/server-components/runtime.ts +112 -30
  450. package/src/server-components/state.ts +66 -44
  451. package/src/server-components/types.ts +16 -0
  452. package/src/vite.ts +75 -6
  453. package/dist/nano.d.ts +0 -125
  454. package/dist/nano.d.ts.map +0 -1
  455. package/dist/nano.js +0 -190
  456. package/dist/nano.js.map +0 -1
  457. package/src/nano.ts +0 -246
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The server half of the identity epoch: a response that signs a visitor in or out also rotates the
3
+ * readable epoch cookie, so every tab drops the pages it kept alive for the previous visitor.
4
+ *
5
+ * A header-only hook reads each response's `Set-Cookie`: when an identity cookie's value differs from
6
+ * the request's, or the cookie is deleted, the response also sets a fresh epoch with the same lifetime,
7
+ * so the epoch expires with an idle session. A rolling session re-sending the same value with a new
8
+ * lifetime re-sends the epoch it holds with that lifetime, so the two keep expiring together.
9
+ */
10
+ import { type CookieOptions, parseCookies } from "@nifrajs/core/server"
11
+ import { EPOCH_COOKIE, LOCAL_EPOCH_COOKIE } from "../identity.ts"
12
+
13
+ /** `createWebApp`'s `keepAlive` option: which cookies say who is signed in. */
14
+ export interface KeepAliveServerOptions {
15
+ /**
16
+ * Cookies whose change means a different visitor, on top of the session cookies of
17
+ * `@nifrajs/auth`, Better Auth and Auth.js under their default names. Name a custom session cookie
18
+ * here, or kept pages survive a sign-in or sign-out through it.
19
+ */
20
+ readonly identityCookies?: readonly string[]
21
+ /** `HttpOnly` cookies that say nothing about who is signed in; development stops asking about them. */
22
+ readonly ignoreCookies?: readonly string[]
23
+ }
24
+
25
+ /** The request and response a header hook sees. */
26
+ interface HeaderView {
27
+ get(name: string): string | null
28
+ append(name: string, value: string): void
29
+ }
30
+ interface RequestView {
31
+ readonly url: string
32
+ header(name: string): string | null | undefined
33
+ }
34
+
35
+ // Session cookies by their default names, without a `__Secure-` / `__Host-` prefix.
36
+ const IDENTITY_DEFAULTS = [
37
+ "nifra_session",
38
+ "better-auth.session_token",
39
+ "authjs.session-token",
40
+ "next-auth.session-token",
41
+ ]
42
+ // `HttpOnly` cookies the same integrations write that carry no identity of their own.
43
+ const IGNORE_DEFAULTS = [
44
+ "better-auth.session_data",
45
+ "better-auth.dont_remember",
46
+ "authjs.csrf-token",
47
+ "authjs.callback-url",
48
+ "authjs.pkce.code_verifier",
49
+ "authjs.state",
50
+ "authjs.nonce",
51
+ "next-auth.csrf-token",
52
+ "next-auth.callback-url",
53
+ "next-auth.pkce.code_verifier",
54
+ "next-auth.state",
55
+ "next-auth.nonce",
56
+ "__nifra_draft",
57
+ ]
58
+
59
+ // `__Secure-a.0` and `__Host-a` are the cookie `a`: a prefix, then Auth.js's chunk index.
60
+ const baseName = (name: string): string =>
61
+ name.replace(/^__(?:secure|host)-/i, "").replace(/\.\d+$/, "")
62
+
63
+ interface SetCookie {
64
+ readonly name: string
65
+ readonly value: string
66
+ readonly deleted: boolean
67
+ readonly httpOnly: boolean
68
+ readonly secure: boolean
69
+ readonly maxAge: string | undefined
70
+ readonly expires: string | undefined
71
+ }
72
+
73
+ // Splits a `Set-Cookie` list joined with ", ": only a comma followed by `name=` starts a cookie, so
74
+ // the comma inside an `Expires` date stays where it is.
75
+ const SPLIT = /,\s*(?=[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/
76
+
77
+ const parseSetCookies = (joined: string, now: number): SetCookie[] => {
78
+ const cookies: SetCookie[] = []
79
+ for (const line of joined.split(SPLIT)) {
80
+ const [pair = "", ...attributes] = line.split(";")
81
+ const at = pair.indexOf("=")
82
+ if (at <= 0) continue
83
+ let maxAge: string | undefined
84
+ let expires: string | undefined
85
+ let httpOnly = false
86
+ let secure = false
87
+ for (const attribute of attributes) {
88
+ const eq = attribute.indexOf("=")
89
+ const key = (eq === -1 ? attribute : attribute.slice(0, eq)).trim().toLowerCase()
90
+ const value = eq === -1 ? "" : attribute.slice(eq + 1).trim()
91
+ if (key === "max-age") maxAge = value
92
+ else if (key === "expires") expires = value
93
+ else if (key === "httponly") httpOnly = true
94
+ else if (key === "secure") secure = true
95
+ }
96
+ const name = pair.slice(0, at).trim()
97
+ const age = maxAge === undefined ? Number.NaN : Number(maxAge)
98
+ const until = expires === undefined ? Number.NaN : Date.parse(expires)
99
+ cookies.push({
100
+ name,
101
+ // Decoded as core decodes the request's cookies, so an encoded value compares equal to itself.
102
+ value: parseCookies(pair.trim())[name] ?? "",
103
+ deleted: age <= 0 || until <= now,
104
+ httpOnly,
105
+ secure: secure || /^__(?:secure|host)-/i.test(name),
106
+ maxAge,
107
+ expires,
108
+ })
109
+ }
110
+ return cookies
111
+ }
112
+
113
+ const randomEpoch = (): string => {
114
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
115
+ let hex = ""
116
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0")
117
+ return hex
118
+ }
119
+
120
+ const EPOCH_VALUE = /^[0-9a-f]{32}$/
121
+
122
+ // A forwarded protocol only picks the cookie's name: a client that lies about it loses its own epoch.
123
+ // A browser sends a `__Host-` cookie only over https, so carrying one says so behind a TLS proxy that
124
+ // forwards nothing, and the client, which reads that one first, must see the new value there.
125
+ const servedOverHttps = (request: RequestView, sent: Readonly<Record<string, string>>): boolean => {
126
+ if (request.url.startsWith("https:") || sent[EPOCH_COOKIE] !== undefined) return true
127
+ const forwarded = request.header("x-forwarded-proto")
128
+ return forwarded?.split(",")[0]?.trim().toLowerCase() === "https"
129
+ }
130
+
131
+ const epochCookie = (
132
+ secure: boolean,
133
+ value: string,
134
+ lifetime: { readonly maxAge?: string | undefined; readonly expires?: string | undefined },
135
+ ): string => {
136
+ let cookie = `${secure ? EPOCH_COOKIE : LOCAL_EPOCH_COOKIE}=${value}; Path=/; SameSite=Lax`
137
+ if (secure) cookie += "; Secure"
138
+ if (lifetime.maxAge !== undefined) cookie += `; Max-Age=${lifetime.maxAge}`
139
+ if (lifetime.expires !== undefined) cookie += `; Expires=${lifetime.expires}`
140
+ return cookie
141
+ }
142
+
143
+ /**
144
+ * The response hook `createWebApp` installs for an app with a `keepAlive` route. With `development`,
145
+ * it also says once per cookie when an `HttpOnly` cookie it does not know changes.
146
+ */
147
+ export function identityEpochHook(
148
+ options: KeepAliveServerOptions,
149
+ development: boolean,
150
+ ): (headers: HeaderView, request: RequestView) => void {
151
+ const identity = new Set([...IDENTITY_DEFAULTS, ...(options.identityCookies ?? []).map(baseName)])
152
+ const ignored = new Set([...IGNORE_DEFAULTS, ...(options.ignoreCookies ?? []).map(baseName)])
153
+ const reported = new Set<string>()
154
+ return (headers, request) => {
155
+ const joined = headers.get("set-cookie")
156
+ if (joined === null || joined === "") return
157
+ const sent = parseCookies(request.header("cookie") ?? null)
158
+ let changed: SetCookie | undefined
159
+ let renewed: SetCookie | undefined
160
+ for (const cookie of parseSetCookies(joined, Date.now())) {
161
+ if (cookie.name === EPOCH_COOKIE || cookie.name === LOCAL_EPOCH_COOKIE) return
162
+ const now = cookie.deleted ? "" : cookie.value
163
+ const base = baseName(cookie.name)
164
+ if (now === (sent[cookie.name] ?? "")) {
165
+ if (
166
+ renewed === undefined &&
167
+ !cookie.deleted &&
168
+ (cookie.maxAge !== undefined || cookie.expires !== undefined) &&
169
+ identity.has(base)
170
+ )
171
+ renewed = cookie
172
+ continue
173
+ }
174
+ if (identity.has(base)) {
175
+ // The first identity cookie still set gives the lifetime; a sign-out deletes them all.
176
+ if (changed === undefined || (changed.deleted && !cookie.deleted)) changed = cookie
177
+ continue
178
+ }
179
+ if (!development || !cookie.httpOnly || ignored.has(base) || reported.has(base)) continue
180
+ reported.add(base)
181
+ console.warn(
182
+ `[nifra/web] NIFRA_KEEPALIVE_IDENTITY_COOKIE: this app keeps pages alive, and a response changed the HttpOnly cookie "${cookie.name}", which nifra does not know as a sign-in cookie. If it says who is signed in, add it to createWebApp({ keepAlive: { identityCookies: ["${base}"] } }) so a sign-in or sign-out drops the pages kept for the previous visitor; if it does not, add it to keepAlive.ignoreCookies.`,
183
+ )
184
+ }
185
+ if (changed !== undefined) {
186
+ headers.append(
187
+ "set-cookie",
188
+ epochCookie(
189
+ changed.secure || servedOverHttps(request, sent),
190
+ randomEpoch(),
191
+ changed.deleted ? {} : { maxAge: changed.maxAge, expires: changed.expires },
192
+ ),
193
+ )
194
+ return
195
+ }
196
+ if (renewed === undefined) return
197
+ const lifetime = { maxAge: renewed.maxAge, expires: renewed.expires }
198
+ for (const secure of [true, false]) {
199
+ const held = sent[secure ? EPOCH_COOKIE : LOCAL_EPOCH_COOKIE]
200
+ if (held === undefined || !EPOCH_VALUE.test(held)) continue
201
+ headers.append("set-cookie", epochCookie(secure, held, lifetime))
202
+ return
203
+ }
204
+ // An epoch that already expired, or was never set: a fresh one, so pages kept without one drop.
205
+ headers.append(
206
+ "set-cookie",
207
+ epochCookie(renewed.secure || servedOverHttps(request, sent), randomEpoch(), lifetime),
208
+ )
209
+ }
210
+ }
211
+
212
+ /** The response-cookie surface {@link rotateIdentityEpoch} needs: nifra's handler context. */
213
+ export interface IdentityEpochContext {
214
+ readonly request: Request
215
+ readonly set: { cookie(name: string, value: string, options?: CookieOptions): void }
216
+ }
217
+
218
+ /**
219
+ * Rotate the identity epoch from a flow that changes who is signed in without changing a cookie
220
+ * `createWebApp` watches - say, a session kept server-side under a cookie whose value stays the
221
+ * same. Every tab then drops the pages it kept alive for the previous visitor.
222
+ */
223
+ export function rotateIdentityEpoch(c: IdentityEpochContext): void {
224
+ const request = c.request
225
+ const secure = servedOverHttps(
226
+ { url: request.url, header: (name) => request.headers.get(name) },
227
+ parseCookies(request.headers.get("cookie")),
228
+ )
229
+ c.set.cookie(secure ? EPOCH_COOKIE : LOCAL_EPOCH_COOKIE, randomEpoch(), {
230
+ path: "/",
231
+ sameSite: "lax",
232
+ secure,
233
+ httpOnly: false,
234
+ })
235
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The seam between `createWebApp` and a cache wrapper for an action's invalidation: types and one
3
+ * symbol, so an app pays for the pipeline only once something runs it.
4
+ */
5
+ import type { RevalidateResult } from "./render-document.ts"
6
+
7
+ export interface InvalidationOp {
8
+ /** Names the operation in logs; never key text. */
9
+ readonly id: string
10
+ readonly paths: readonly string[]
11
+ readonly tags: readonly string[]
12
+ readonly keys: readonly string[]
13
+ }
14
+
15
+ /** What a `createWebApp` app hands a cache wrapper that owns its invalidation. */
16
+ export interface InvalidationHandoff {
17
+ /** The operation the action behind `request` recorded, once. */
18
+ take(request: Request): InvalidationOp | undefined
19
+ /** The app's live publish, when it configures `live`. */
20
+ readonly publish: ((keys: readonly string[]) => Promise<void>) | undefined
21
+ }
22
+
23
+ export const INVALIDATION: unique symbol = Symbol.for("nifra.web.invalidation")
24
+
25
+ /** The operation an action's `revalidate()` names: its paths, cache tags and live keys. */
26
+ export function invalidationOf(revalidation: RevalidateResult<unknown>): InvalidationOp {
27
+ return {
28
+ id: crypto.randomUUID(),
29
+ paths: revalidation.__nifraRevalidate.filter(
30
+ (path) => typeof path === "string" && path.startsWith("/"),
31
+ ),
32
+ tags: revalidation.__nifraInvalidate?.tags ?? [],
33
+ keys: revalidation.__nifraInvalidate?.keys ?? [],
34
+ }
35
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * One invalidation pipeline for everything a write makes stale, in a fixed order: data caches (tags),
3
+ * then the page store (paths and tags), then the live publish (keys and tags), then the CDN. A failure
4
+ * in either cache stage stops before the publish: clients told to reload would read the stale data and
5
+ * move their watermarks past the change. Every stage is idempotent, so a retry repeats from the stage
6
+ * that failed.
7
+ *
8
+ * `withISR` owns the pipeline when it wraps the app; without it, `createWebApp` runs the publish
9
+ * stage alone. Never both.
10
+ */
11
+ import type { CacheStore, CdnPurgeTarget, ISRPlatform } from "../isr.ts"
12
+ import type { InvalidationOp } from "./invalidation-contract.ts"
13
+
14
+ export type { InvalidationOp } from "./invalidation-contract.ts"
15
+
16
+ export type InvalidationStage = "data" | "pages" | "publish" | "cdn"
17
+
18
+ export interface StageReport {
19
+ readonly stage: InvalidationStage
20
+ readonly ok: boolean
21
+ readonly retryable: boolean
22
+ readonly error?: string
23
+ /** The CDN took the purge but has not applied it yet (rate limit or retry). */
24
+ readonly queued?: true
25
+ }
26
+
27
+ export interface InvalidationReport {
28
+ readonly id: string
29
+ readonly ok: boolean
30
+ readonly stages: readonly StageReport[]
31
+ }
32
+
33
+ /** What the pipeline calls on a page store: any {@link CacheStore} fits. */
34
+ export interface PagePurge {
35
+ delete(key: string): Promise<void>
36
+ readonly invalidateTag?: ((tag: string) => Promise<void>) | undefined
37
+ }
38
+
39
+ export interface InvalidationTargets {
40
+ readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[] | undefined
41
+ readonly store?: PagePurge | undefined
42
+ /** The page store key of a path, as the cache wrapper computes it; `null` when never stored. */
43
+ readonly keyOf?: ((path: string, req: Request) => string | null) | undefined
44
+ readonly publish?: ((keys: readonly string[]) => Promise<void>) | undefined
45
+ readonly cdn?: CdnPurgeTarget | undefined
46
+ }
47
+
48
+ const ORDER: readonly InvalidationStage[] = ["data", "pages", "publish", "cdn"]
49
+ /** Retry delays after a retryable failure; inline retries stop at {@link INLINE_DEADLINE_MS}. */
50
+ const RETRY_MS = [200, 600, 1800]
51
+ export const INLINE_DEADLINE_MS = 2000
52
+
53
+ /** Run the stages from `from` once. */
54
+ export async function runInvalidation(
55
+ op: InvalidationOp,
56
+ targets: InvalidationTargets,
57
+ req: Request,
58
+ platform: ISRPlatform | undefined,
59
+ from: InvalidationStage = "data",
60
+ ): Promise<InvalidationReport> {
61
+ const stages: StageReport[] = []
62
+ for (const stage of ORDER.slice(ORDER.indexOf(from))) {
63
+ // Decided before any await: a stage with nothing to do costs no turn of the event loop.
64
+ const running = startStage(stage, op, targets, req, platform)
65
+ if (running === undefined) continue
66
+ const report = await running
67
+ stages.push(report)
68
+ // A cache stage that failed leaves data a reload would read: nothing after it may run.
69
+ if (!report.ok && (stage === "data" || stage === "pages")) break
70
+ }
71
+ return { id: op.id, ok: stages.every((report) => report.ok), stages }
72
+ }
73
+
74
+ /** Start a stage's work for this operation; `undefined` when it has none. Never rejects. */
75
+ function startStage(
76
+ stage: InvalidationStage,
77
+ op: InvalidationOp,
78
+ targets: InvalidationTargets,
79
+ req: Request,
80
+ platform: ISRPlatform | undefined,
81
+ ): Promise<StageReport> | undefined {
82
+ const done: StageReport = { stage, ok: true, retryable: false }
83
+ let work: Promise<StageReport>
84
+ switch (stage) {
85
+ case "data": {
86
+ const caches = targets.dataCaches
87
+ if (op.tags.length === 0 || caches === undefined) return undefined
88
+ work = (async () => {
89
+ for (const cache of caches) {
90
+ for (const tag of op.tags) await cache.invalidateTag(tag)
91
+ }
92
+ return done
93
+ })()
94
+ break
95
+ }
96
+ case "pages": {
97
+ const store = targets.store
98
+ if (store === undefined || op.paths.length + op.tags.length === 0) return undefined
99
+ work = (async (): Promise<StageReport> => {
100
+ if (op.tags.length > 0 && store.invalidateTag === undefined) {
101
+ return { stage, ok: false, retryable: false, error: "tag_invalidation_not_supported" }
102
+ }
103
+ for (const path of op.paths) {
104
+ const key = targets.keyOf?.(path, req) ?? null
105
+ if (key !== null) await store.delete(key)
106
+ }
107
+ for (const tag of op.tags) await store.invalidateTag?.(tag)
108
+ return done
109
+ })()
110
+ break
111
+ }
112
+ case "publish": {
113
+ const publish = targets.publish
114
+ if (publish === undefined || op.keys.length + op.tags.length === 0) return undefined
115
+ work = (async () => {
116
+ await publish([...new Set([...op.keys, ...op.tags])])
117
+ return done
118
+ })()
119
+ break
120
+ }
121
+ case "cdn": {
122
+ const cdn = targets.cdn
123
+ if (cdn === undefined || op.paths.length + op.tags.length === 0) return undefined
124
+ work = (async (): Promise<StageReport> => {
125
+ const outcome = await cdn.purge({ tags: op.tags, paths: op.paths }, platform)
126
+ return outcome.cdn === "failed"
127
+ ? {
128
+ stage,
129
+ ok: false,
130
+ retryable: outcome.retryable,
131
+ error: outcome.error ?? "cdn_purge_failed",
132
+ }
133
+ : outcome.cdn === "queued"
134
+ ? { stage, ok: true, retryable: false, queued: true }
135
+ : done
136
+ })()
137
+ break
138
+ }
139
+ }
140
+ return work.catch(() => ({ stage, ok: false, retryable: true, error: `${stage}_failed` }))
141
+ }
142
+
143
+ export interface SettleOptions {
144
+ readonly onError?: ((report: InvalidationReport) => void) | undefined
145
+ /** Tests drive time; production waits on timers. */
146
+ readonly clock?: { now(): number; sleep(ms: number): Promise<void> }
147
+ }
148
+
149
+ const realClock = {
150
+ now: () => Date.now(),
151
+ sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
152
+ }
153
+
154
+ /**
155
+ * Run the pipeline for a write; never throws and never changes the response. A retryable failure
156
+ * retries from the failed stage, under `waitUntil` when the platform has it, else inline until
157
+ * {@link INLINE_DEADLINE_MS}. A failure that remains is reported once.
158
+ */
159
+ export async function settleInvalidation(
160
+ op: InvalidationOp,
161
+ targets: InvalidationTargets,
162
+ req: Request,
163
+ platform: ISRPlatform | undefined,
164
+ options: SettleOptions = {},
165
+ ): Promise<InvalidationReport> {
166
+ const onError = options.onError ?? logInvalidation
167
+ const clock = options.clock ?? realClock
168
+ const started = clock.now()
169
+ const first = await runInvalidation(op, targets, req, platform)
170
+ if (first.ok) return first
171
+ const failed = first.stages.find((report) => !report.ok)
172
+ if (failed === undefined || !failed.retryable) {
173
+ report(onError, first)
174
+ return first
175
+ }
176
+ const background = typeof platform?.waitUntil === "function"
177
+ const retry = async (): Promise<InvalidationReport> => {
178
+ let last = first
179
+ let stage = failed.stage
180
+ for (const delay of RETRY_MS) {
181
+ if (!background && clock.now() - started + delay >= INLINE_DEADLINE_MS) break
182
+ await clock.sleep(delay)
183
+ last = await runInvalidation(op, targets, req, platform, stage)
184
+ const next = last.stages.find((report) => !report.ok)
185
+ if (next === undefined) return last
186
+ if (!next.retryable) break
187
+ stage = next.stage
188
+ }
189
+ report(onError, last)
190
+ return last
191
+ }
192
+ if (background) {
193
+ platform?.waitUntil?.(retry())
194
+ return first
195
+ }
196
+ return retry()
197
+ }
198
+
199
+ function report(onError: (report: InvalidationReport) => void, value: InvalidationReport): void {
200
+ try {
201
+ onError(value)
202
+ } catch {
203
+ // A failing reporter must not reach the response.
204
+ }
205
+ }
206
+
207
+ /** The default report: the operation id and the stage, never a path, tag or key. */
208
+ export function logInvalidation(report: InvalidationReport): void {
209
+ const failed = report.stages.find((stage) => !stage.ok)
210
+ console.error(
211
+ `[nifra/web] invalidation ${report.id} gave up at the ${failed?.stage ?? "unknown"} stage (${failed?.error ?? "failed"})`,
212
+ )
213
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The declarative binding walker - attaches signal-driven behavior to SERVER-RENDERED markup.
3
+ * No VDOM, no hydration re-render: the HTML the server sent (e.g. via `@nifrajs/web-vanilla`) is
4
+ * already the initial state; bindings update it surgically when signals change. `bindIsland` runs it.
5
+ *
6
+ * The attribute set is CLOSED and documented - six bindings, no expression language. Values are
7
+ * signal/handler NAMES looked up in the island's scope, never evaluated code, so markup can't
8
+ * inject behavior:
9
+ *
10
+ * data-bind-text="count" textContent ← String(signal())
11
+ * data-bind-show="isOpen" hidden ← !signal()
12
+ * data-bind-class="active:isOpen,b:sigB" classList.toggle per pair
13
+ * data-bind-attr="aria-expanded:isOpen" setAttribute / removeAttribute (false/null remove);
14
+ * never an `on*` handler or `srcdoc`, and a URL attribute
15
+ * takes only an http(s)/mailto/tel or relative URL
16
+ * data-bind-value="query" two-way <input>/<select>/<textarea> (input and change)
17
+ * data-bind-on="click:inc,submit:save" addEventListener per pair
18
+ *
19
+ * Nothing inside a `data-island-ignore` element binds or mounts: render user HTML there, so its
20
+ * markup cannot reach the island's handlers or signals.
21
+ *
22
+ * A binding belongs to its nearest island: an island never binds inside a nested island's host. The
23
+ * nested host element itself is still the outer island's markup, so the outer island may bind it.
24
+ */
25
+
26
+ import { type Signal, watch } from "./island-signals.ts"
27
+
28
+ export type IslandScope = {
29
+ readonly signals: Readonly<Record<string, Signal<unknown>>>
30
+ readonly handlers: Readonly<Record<string, (event: Event) => void>>
31
+ }
32
+
33
+ const warned = new Set<string>()
34
+ /** Unknown names warn once (loud in dev, resilient in prod - progressive enhancement must not
35
+ * throw over a typo'd attribute) and the binding is skipped. */
36
+ function warnOnce(kind: string, name: string): void {
37
+ const key = `${kind}:${name}`
38
+ if (warned.has(key)) return
39
+ warned.add(key)
40
+ console.warn(`[nifra/islands] unknown ${kind} ${JSON.stringify(name)} - binding skipped`)
41
+ }
42
+
43
+ // Own keys only: a name such as `constructor` must not resolve to what a plain object inherits.
44
+ function named<V>(kind: string, record: Readonly<Record<string, V>>, name: string): V | undefined {
45
+ if (Object.hasOwn(record, name)) return record[name]
46
+ warnOnce(kind, name)
47
+ return undefined
48
+ }
49
+
50
+ /** Parse a `"key:name,key2:name2"` pair list (whitespace-tolerant). Malformed entries skip. */
51
+ function pairs(spec: string): Array<[string, string]> {
52
+ const out: Array<[string, string]> = []
53
+ for (const part of spec.split(",")) {
54
+ const sep = part.indexOf(":")
55
+ if (sep <= 0) continue
56
+ const key = part.slice(0, sep).trim()
57
+ const name = part.slice(sep + 1).trim()
58
+ if (key && name) out.push([key, name])
59
+ }
60
+ return out
61
+ }
62
+
63
+ // A bound value is data (often from island state the markup carries), so an attribute that would run
64
+ // it as code, or navigate to it as a script URL, is refused.
65
+ const CODE_ATTRIBUTE = /^(?:on|srcdoc$)/i
66
+ const URL_ATTRIBUTE =
67
+ /^(?:href|src|action|formaction|xlink:href|poster|data|background|cite|ping)$/i
68
+ const SAFE_URL = /^(?:(?:https?|mailto|tel):|[^:/?#]*(?:[/?#]|$))/i
69
+
70
+ /** The element surface the walker needs - structural, so tests can drive it without a real DOM. */
71
+ export interface BindableElement {
72
+ getAttribute(name: string): string | null
73
+ setAttribute(name: string, value: string): void
74
+ removeAttribute(name: string): void
75
+ textContent: string | null
76
+ hidden: boolean
77
+ value?: string
78
+ readonly classList: { toggle(name: string, force?: boolean): unknown }
79
+ addEventListener(type: string, listener: (event: Event) => void): void
80
+ removeEventListener(type: string, listener: (event: Event) => void): void
81
+ }
82
+
83
+ export interface BindableRoot {
84
+ querySelectorAll(selector: string): Iterable<BindableElement>
85
+ }
86
+
87
+ /** Walk `root` and attach every `data-bind-*` binding against `scope`. Each binding's disposer
88
+ * (reactive effect or event listener) goes onto `stops` as it attaches, so a binding that throws
89
+ * midway leaves the earlier ones disposable; `stops` is returned. */
90
+ export function bindScope(
91
+ root: BindableRoot,
92
+ scope: IslandScope,
93
+ stops: Array<() => void> = [],
94
+ ): Array<() => void> {
95
+ // Each element carrying `data-bind-<kind>` outside any ignored subtree and any nested island, with
96
+ // that attribute's value.
97
+ const each = (kind: string): Array<[BindableElement, string]> => {
98
+ const attribute = `data-bind-${kind}`
99
+ return [
100
+ ...root.querySelectorAll(
101
+ `[${attribute}]:not([data-island-ignore],[data-island-ignore] *,:scope [data-island] *)`,
102
+ ),
103
+ ].map((el) => [el, el.getAttribute(attribute) ?? ""])
104
+ }
105
+
106
+ for (const [el, name] of each("text")) {
107
+ const s = named("signal", scope.signals, name)
108
+ if (s) {
109
+ stops.push(
110
+ watch(s, (v) => {
111
+ el.textContent = String(v)
112
+ }),
113
+ )
114
+ }
115
+ }
116
+
117
+ for (const [el, name] of each("show")) {
118
+ const s = named("signal", scope.signals, name)
119
+ if (s) {
120
+ stops.push(
121
+ watch(s, (v) => {
122
+ el.hidden = !v
123
+ }),
124
+ )
125
+ }
126
+ }
127
+
128
+ for (const [el, spec] of each("class")) {
129
+ for (const [className, name] of pairs(spec)) {
130
+ const s = named("signal", scope.signals, name)
131
+ if (s) {
132
+ stops.push(
133
+ watch(s, (v) => {
134
+ el.classList.toggle(className, !!v)
135
+ }),
136
+ )
137
+ }
138
+ }
139
+ }
140
+
141
+ for (const [el, spec] of each("attr")) {
142
+ for (const [attr, name] of pairs(spec)) {
143
+ if (CODE_ATTRIBUTE.test(attr)) {
144
+ warnOnce("attribute", attr)
145
+ continue
146
+ }
147
+ const s = named("signal", scope.signals, name)
148
+ if (s) {
149
+ const isUrl = URL_ATTRIBUTE.test(attr)
150
+ stops.push(
151
+ watch(s, (v) => {
152
+ if (v === false || v == null || (isUrl && !SAFE_URL.test(String(v).trim())))
153
+ el.removeAttribute(attr)
154
+ else el.setAttribute(attr, String(v))
155
+ }),
156
+ )
157
+ }
158
+ }
159
+ }
160
+
161
+ for (const [el, name] of each("value")) {
162
+ const s = named("signal", scope.signals, name)
163
+ if (s) {
164
+ stops.push(
165
+ watch(s, (v) => {
166
+ const next = String(v)
167
+ // Skip same-value writes: resetting an input's value mid-typing moves the caret.
168
+ if (el.value !== next) el.value = next
169
+ }),
170
+ )
171
+ const input = (): void => s.set(el.value ?? "")
172
+ // A <select> commits on `change`, and browsers disagree on whether `input` comes first. The
173
+ // second event of a pair writes the same value, which the signal skips.
174
+ for (const type of ["input", "change"]) {
175
+ el.addEventListener(type, input)
176
+ stops.push(() => el.removeEventListener(type, input))
177
+ }
178
+ }
179
+ }
180
+
181
+ for (const [el, spec] of each("on")) {
182
+ for (const [event, name] of pairs(spec)) {
183
+ const handler = named("handler", scope.handlers, name)
184
+ if (handler === undefined) continue
185
+ el.addEventListener(event, handler)
186
+ stops.push(() => el.removeEventListener(event, handler))
187
+ }
188
+ }
189
+
190
+ return stops
191
+ }