@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,1764 @@
1
+ import { markRepeatedPrefix } from "@nifrajs/core/response-observer"
2
+ import type {
3
+ BoundaryError,
4
+ BoundaryRequestCtx,
5
+ BoundaryState,
6
+ BoundaryStates,
7
+ SharedHoleCtx,
8
+ } from "../../boundary.ts"
9
+ import { type CspPolicy, cspHeaderWithSources, prepareCspPolicy } from "../../csp.ts"
10
+ import { defer } from "../../deferred.ts"
11
+ import type { RenderAdapter } from "../../render-seam.ts"
12
+ import { HOLE_ERROR_HTML } from "../hole-runtime.ts"
13
+ import { isControlFlow, isStatusSignal, notFound } from "../render-document.ts"
14
+ import { HoleAdmission, type HoleRun, type HoleSlot, watchAbort } from "./admission.ts"
15
+ import type { ShellCheckRecord } from "./compile-shell.ts"
16
+ import {
17
+ type HoleRegistration,
18
+ type PprRequest,
19
+ type PprServer,
20
+ PprShellBusy,
21
+ PprShellCold,
22
+ PprShellUnchecked,
23
+ type SharedHole,
24
+ serverHoleComponent,
25
+ variationQuery,
26
+ } from "./contract.ts"
27
+ import {
28
+ assertHoleName,
29
+ encodeHoleFrame,
30
+ frameBodyOf,
31
+ holeFallbackInserts,
32
+ requestScriptAround,
33
+ validateHoleHtml,
34
+ } from "./frames.ts"
35
+ import { type ResolvedPprOptions, totalDeadlineMs } from "./options.ts"
36
+ import { PPR_BOOT, PPR_RUNTIME, pprScriptHashes } from "./runtime.ts"
37
+ import type { SharedShellStore, StoredEntry } from "./shell-store.ts"
38
+
39
+ export {
40
+ type DynamicHole,
41
+ type HoleRegistration,
42
+ type HoleStatesMode,
43
+ PPR_CANCEL_PATH,
44
+ PPR_SERVER,
45
+ type PprRequest,
46
+ type PprServer,
47
+ type PprServerStats,
48
+ PprShellBusy,
49
+ PprShellCold,
50
+ PprShellUnavailable,
51
+ PprShellUnchecked,
52
+ type SharedHole,
53
+ variationQuery,
54
+ } from "./contract.ts"
55
+
56
+ /** The redacted state a blocking render shows for a hole that did not load, as `boundary.ts` does. */
57
+ const HOLE_ERROR_STATE: BoundaryError = { name: "Error", message: "Boundary failed" }
58
+
59
+ const DOCUMENT_END = "</body></html>"
60
+
61
+ const MODULE_SCRIPT_OPEN = '<script type="module" src="'
62
+ const MODULE_SCRIPT_CLOSE = '"></script>'
63
+ const RUNTIME_SCRIPT = `<script>${PPR_RUNTIME}</script>`
64
+ const BOOT_SCRIPT = `<script>${PPR_BOOT}</script>`
65
+
66
+ const ENCODER = new TextEncoder()
67
+
68
+ /** A built shell, shared by every visitor whose request maps to its key. */
69
+ const NO_BYTES = new Uint8Array(0)
70
+
71
+ interface ShellEntry {
72
+ readonly routeId: string
73
+ /**
74
+ * The document up to (not including) `</body></html>` and the request script, encoded once with
75
+ * room for the token at {@link tokenAt}: each response copies it and writes its token in.
76
+ */
77
+ readonly first: Uint8Array
78
+ readonly tokenAt: number
79
+ /** The shell loader's output, for a soft navigation's data request. */
80
+ readonly data: unknown
81
+ readonly bytes: number
82
+ readonly builtAt: number
83
+ readonly tags: readonly string[]
84
+ /** The shell loader's `notFound()`, rethrown for every request until the entry expires. */
85
+ readonly notFound?: unknown
86
+ /** Background rebuilds that failed in a row; the next waits `2 ** failures` seconds. */
87
+ failures: number
88
+ retryAt: number
89
+ /** When this process last saw it in the shared store; unset for an entry kept only here. */
90
+ checkedAt: number | undefined
91
+ }
92
+
93
+ /** A shared hole's frame, built once for every visitor whose request maps to its key. */
94
+ interface HoleEntry {
95
+ readonly routeId: string
96
+ /** The finished frame, encoded once. */
97
+ readonly frame: Uint8Array
98
+ /** The loader's output after its `output` projection, for a data request or a blocking render. */
99
+ readonly data: unknown
100
+ readonly bytes: number
101
+ readonly builtAt: number
102
+ readonly tags: readonly string[]
103
+ /** The live keys its loader declared, watched for every visitor it serves. */
104
+ readonly keys: readonly string[]
105
+ failures: number
106
+ retryAt: number
107
+ checkedAt: number | undefined
108
+ }
109
+
110
+ type Entry = ShellEntry | HoleEntry
111
+
112
+ export interface PprServerConfig {
113
+ readonly options: ResolvedPprOptions
114
+ readonly adapter: RenderAdapter
115
+ readonly csp?: CspPolicy
116
+ /** Part of every shell key, so a shell built by one deployment is never served by another. */
117
+ readonly deployment: string
118
+ /** The client entry `shellChecks` was recorded for; `deployment` when not given. */
119
+ readonly clientEntry?: string
120
+ readonly cancelUrl: string
121
+ /**
122
+ * The routes whose shell import check passed, for this deployment. A route not listed, or a record
123
+ * made for another deployment, gets no shared shell (fail closed). `"unchecked-development"` waives
124
+ * the check and is refused in production.
125
+ */
126
+ readonly shellChecks: ShellCheckRecord | "unchecked-development"
127
+ /** Milliseconds, for shell freshness. Tests pass their own clock. */
128
+ readonly now?: () => number
129
+ /** Shells shared with other processes, behind this process's own; signed entries only. */
130
+ readonly shared?: SharedShellStore | undefined
131
+ }
132
+
133
+ /** One hole of a response, started: exactly one of `hit`, `entry` and `run` is set. */
134
+ type HoleStart =
135
+ | {
136
+ readonly hole: HoleRegistration
137
+ /** A shared hole's entry held here when the hole started: its frame goes out with the shell. */
138
+ readonly hit: HoleEntry
139
+ readonly entry?: undefined
140
+ readonly run?: undefined
141
+ }
142
+ | {
143
+ readonly hole: HoleRegistration
144
+ /** A shared hole's entry, being read or built; aborted on the visitor's deadline. */
145
+ readonly entry: Promise<HoleRun<HoleEntry>>
146
+ readonly hit?: undefined
147
+ readonly run?: undefined
148
+ }
149
+ | {
150
+ readonly hole: HoleRegistration
151
+ /** The hole's loader, run for this visitor. */
152
+ readonly run: Promise<HoleRun<unknown>>
153
+ readonly hit?: undefined
154
+ readonly entry?: undefined
155
+ }
156
+
157
+ /** One streamed response's own queue of frames its client has not read yet. */
158
+ interface StreamState {
159
+ readonly queue: Uint8Array[]
160
+ queued: number
161
+ overflowed: boolean
162
+ ending: boolean
163
+ dead: boolean
164
+ /** The reader is waiting on a read the queue could not fill: the next frame goes straight to it. */
165
+ wanted: boolean
166
+ kill(reason: unknown): void
167
+ }
168
+
169
+ const pendingStates = (holes: readonly HoleRegistration[]): BoundaryStates => {
170
+ const states: Record<string, BoundaryState & { readonly ppr: true }> = {}
171
+ for (const hole of holes)
172
+ states[hole.name] = { name: hole.name, mode: hole.mode, status: "pending", ppr: true }
173
+ return states
174
+ }
175
+
176
+ // The pathname, not the matched params, keys the shell: two spellings of one path (percent-encoding)
177
+ // match the same params but render different `path`s. The per-route cap bounds the spellings.
178
+ const shellKey = (deployment: string, request: PprRequest): string =>
179
+ `ppr:v1|${deployment}|${request.routeId}|${request.locale ?? ""}|${request.path}|${variationQuery(request.variations)}`
180
+
181
+ const isProduction = (): boolean =>
182
+ typeof process !== "undefined" && process.env?.NODE_ENV === "production"
183
+
184
+ const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
185
+ let length = 0
186
+ for (const chunk of chunks) length += chunk.length
187
+ const joined = new Uint8Array(length)
188
+ let at = 0
189
+ for (const chunk of chunks) {
190
+ joined.set(chunk, at)
191
+ at += chunk.length
192
+ }
193
+ return joined
194
+ }
195
+
196
+ const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
197
+ /** A token is 16 random bytes in unpadded base64url: always this many ASCII characters. */
198
+ const TOKEN_LENGTH = 22
199
+ const tokenBytes = new Uint8Array(16)
200
+ /** Where a shell's bytes hold each response's token. */
201
+ const TOKEN_ROOM = "A".repeat(TOKEN_LENGTH)
202
+
203
+ const randomToken = (): string => {
204
+ crypto.getRandomValues(tokenBytes)
205
+ let token = ""
206
+ for (let at = 0; at < 15; at += 3) {
207
+ const n =
208
+ ((tokenBytes[at] ?? 0) << 16) | ((tokenBytes[at + 1] ?? 0) << 8) | (tokenBytes[at + 2] ?? 0)
209
+ token +=
210
+ BASE64URL.charAt(n >> 18) +
211
+ BASE64URL.charAt((n >> 12) & 63) +
212
+ BASE64URL.charAt((n >> 6) & 63) +
213
+ BASE64URL.charAt(n & 63)
214
+ }
215
+ const last = tokenBytes[15] ?? 0
216
+ return token + BASE64URL.charAt(last >> 2) + BASE64URL.charAt((last & 3) << 4)
217
+ }
218
+
219
+ const frozenParts = new WeakMap<
220
+ Omit<BoundaryRequestCtx, "signal" | "live">,
221
+ Pick<BoundaryRequestCtx, "params" | "search">
222
+ >()
223
+
224
+ /** `ctx.live` outside a live load: keys are declared for nobody. */
225
+ const NO_LIVE = (): void => {}
226
+
227
+ /** The server component a dynamic hole shows, if it shows one. */
228
+ const componentOf = (hole: HoleRegistration): object | undefined =>
229
+ hole.mode === "dynamic" ? serverHoleComponent(hole.render) : undefined
230
+
231
+ /** A frozen context per invocation, so no hole changes what a sibling sees. The frozen params and
232
+ * search are copied once per request and shared. */
233
+ const holeCtx = (
234
+ context: Omit<BoundaryRequestCtx, "signal" | "live">,
235
+ signal: AbortSignal,
236
+ live: (...keys: string[]) => void,
237
+ ): BoundaryRequestCtx => {
238
+ let parts = frozenParts.get(context)
239
+ if (parts === undefined) {
240
+ parts = {
241
+ params: Object.freeze({ ...context.params }),
242
+ search: Object.freeze({ ...context.search }),
243
+ }
244
+ frozenParts.set(context, parts)
245
+ }
246
+ return Object.freeze({
247
+ request: context.request,
248
+ params: parts.params,
249
+ api: context.api,
250
+ env: context.env,
251
+ draft: context.draft,
252
+ search: parts.search,
253
+ signal,
254
+ live,
255
+ })
256
+ }
257
+
258
+ /** What a response's holes stop for: the response's own aborts, or a request's signal on the data path. */
259
+ interface AbortSource {
260
+ readonly aborted: boolean
261
+ readonly reason: unknown
262
+ watch(onAbort: () => void): void
263
+ }
264
+
265
+ const signalSource = (signal: AbortSignal): AbortSource => ({
266
+ get aborted() {
267
+ return signal.aborted
268
+ },
269
+ get reason() {
270
+ return signal.reason
271
+ },
272
+ watch(onAbort) {
273
+ watchAbort(signal, onAbort)
274
+ },
275
+ })
276
+
277
+ interface HoleDeadline {
278
+ readonly signal: AbortSignal
279
+ /** Calls `onAbort` once the signal aborts, until the returned function is called. */
280
+ watch(onAbort: () => void): () => void
281
+ /** One of the holes settled; the last one clears the timer. */
282
+ leave(): void
283
+ }
284
+
285
+ interface HoleDeadlines {
286
+ join(timeoutMs: number): HoleDeadline
287
+ }
288
+
289
+ /**
290
+ * Holes that start together with one timeout share its signal and timer: a controller, listener and
291
+ * timer per hole cost more under load than the holes. Not `AbortSignal.timeout`: its timer stays
292
+ * pending after the holes settle. Only this code aborts the signal, so its watchers are called here
293
+ * and the signal gets no native listener.
294
+ */
295
+ const holeDeadlines = (response: AbortSource): HoleDeadlines => {
296
+ const byTimeout = new Map<number, HoleDeadline & { joined: number }>()
297
+ return {
298
+ join(timeoutMs) {
299
+ let deadline = byTimeout.get(timeoutMs)
300
+ if (deadline === undefined) {
301
+ const own = new AbortController()
302
+ const watchers = new Set<() => void>()
303
+ const stop = (reason: unknown): void => {
304
+ if (own.signal.aborted) return
305
+ own.abort(reason)
306
+ for (const watcher of watchers) watcher()
307
+ }
308
+ if (response.aborted) own.abort(response.reason)
309
+ else response.watch(() => stop(response.reason))
310
+ const timer = setTimeout(
311
+ () => stop(new DOMException("The operation timed out.", "TimeoutError")),
312
+ timeoutMs,
313
+ )
314
+ const created = {
315
+ signal: own.signal,
316
+ joined: 0,
317
+ watch(onAbort: () => void): () => void {
318
+ watchers.add(onAbort)
319
+ return () => {
320
+ watchers.delete(onAbort)
321
+ }
322
+ },
323
+ leave() {
324
+ if (--created.joined === 0) clearTimeout(timer)
325
+ },
326
+ }
327
+ deadline = created
328
+ byTimeout.set(timeoutMs, created)
329
+ }
330
+ deadline.joined++
331
+ return deadline
332
+ },
333
+ }
334
+ }
335
+
336
+ // Error frames are a few hundred bytes whatever the hole, so no frame limit applies to them.
337
+ const errorFrame = (name: string): string =>
338
+ encodeHoleFrame(name, { error: true, html: HOLE_ERROR_HTML }, Number.POSITIVE_INFINITY)
339
+
340
+ /**
341
+ * WebKit paints a loading document only once it holds about this many characters of text, so a
342
+ * sparser shell shows nothing in Safari until its slowest hole ends (measured in WebKit 2026-10: a
343
+ * 171-character paragraph stayed blank, 228 painted at once). Fewer than this never paints early.
344
+ */
345
+ const WEBKIT_PAINT_CHARS = 200
346
+
347
+ const SKIPPED_ELEMENTS: ReadonlySet<string> = new Set(["script", "style", "template", "noscript"])
348
+
349
+ /** Characters of text in `html` with whitespace collapsed, outside tags, comments and scripts.
350
+ * An `indexOf` scan, not a lazy regex, so an unclosed opener stays linear. */
351
+ const textLength = (html: string): number => {
352
+ let count = 0
353
+ let blank = true
354
+ let at = 0
355
+ while (at < html.length) {
356
+ const open = html.indexOf("<", at)
357
+ const end = open === -1 ? html.length : open
358
+ for (let i = at; i < end; i++) {
359
+ const code = html.charCodeAt(i)
360
+ const space = code === 32 || (code >= 9 && code <= 13)
361
+ if (!space || !blank) count++
362
+ blank = space
363
+ }
364
+ if (open === -1) break
365
+ if (html.startsWith("<!--", open)) {
366
+ const close = html.indexOf("-->", open + 4)
367
+ at = close === -1 ? html.length : close + 3
368
+ continue
369
+ }
370
+ const close = html.indexOf(">", open)
371
+ if (close === -1) break
372
+ at = close + 1
373
+ const name = /^<([a-zA-Z]+)/.exec(html.slice(open, open + 12))?.[1]?.toLowerCase()
374
+ if (name !== undefined && SKIPPED_ELEMENTS.has(name)) {
375
+ const endTag = html.indexOf(`</${name}`, at)
376
+ at = endTag === -1 ? html.length : endTag
377
+ }
378
+ }
379
+ return count
380
+ }
381
+
382
+ const SHED: HoleRun<never> = Object.freeze({ ok: false, reason: "denied" })
383
+
384
+ // Keys a full store has seen fewer than `shellAfterRequests` times, forgotten together once this many
385
+ // wait, so a crawl of unique paths holds a bounded map.
386
+ const MAX_SIGHTINGS = 10_000
387
+
388
+ /** What an entry counts against the store's byte budget beside its key, document and data. */
389
+ const ENTRY_BYTES = 256
390
+
391
+ // Roughly how JSC and V8 lay out a loader's output: a header per object and per string, a slot per
392
+ // element or property, a byte per character (a two-byte string holds twice that).
393
+ const OBJECT_BYTES = 32
394
+ const STRING_BYTES = 24
395
+ const SLOT_BYTES = 8
396
+ // Deeper than any document nests, so only a cycle reaches it.
397
+ const MAX_DATA_DEPTH = 256
398
+
399
+ const isHolder = (value: unknown): value is Readonly<Record<string, unknown>> =>
400
+ typeof value === "object" && value !== null
401
+
402
+ /**
403
+ * About how many heap bytes a shell loader's output holds. The count stops once it passes `room`, so
404
+ * an output too large for the store costs no more than the store's budget to measure.
405
+ */
406
+ function heldBytes(value: unknown, room: number, depth = 0): number {
407
+ if (typeof value === "string") return STRING_BYTES + value.length
408
+ if (!isHolder(value)) return SLOT_BYTES
409
+ if (depth === MAX_DATA_DEPTH) return Number.POSITIVE_INFINITY
410
+ let bytes = OBJECT_BYTES
411
+ if (Array.isArray(value)) {
412
+ for (let i = 0; i < value.length && bytes <= room; i++)
413
+ bytes += SLOT_BYTES + heldBytes(value[i], room - bytes, depth + 1)
414
+ return bytes
415
+ }
416
+ for (const key in value) {
417
+ bytes += 2 * SLOT_BYTES + heldBytes(value[key], room - bytes, depth + 1)
418
+ if (bytes > room) break
419
+ }
420
+ return bytes
421
+ }
422
+
423
+ const notFoundSignal = (): unknown => {
424
+ try {
425
+ return notFound()
426
+ } catch (signal) {
427
+ return signal
428
+ }
429
+ }
430
+
431
+ const DECODER = new TextDecoder()
432
+
433
+ // How long a shared store keeps a shell that has no `revalidate`: a year, as good as forever.
434
+ const KEEP_UNREVALIDATED_SECONDS = 365 * 24 * 60 * 60
435
+
436
+ // Taken when this module is evaluated, before any request: a build run through it sees no visitor's
437
+ // async context, even in an app created while a request was being handled.
438
+ const detached: <T>(fn: () => T) => T =
439
+ globalThis.process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage?.snapshot?.() ??
440
+ ((fn) => fn())
441
+
442
+ /** A 404 entry answers as the shell loader's own `notFound()` did. */
443
+ const served = (entry: ShellEntry): ShellEntry | Promise<never> =>
444
+ entry.notFound === undefined ? entry : Promise.reject(entry.notFound)
445
+
446
+ interface BuildDeadline {
447
+ readonly signal: AbortSignal
448
+ /**
449
+ * `work`, or the deadline's reason once it passes first: a loader that ignores its signal leaves no
450
+ * request waiting on it, and its late result reaches no one.
451
+ */
452
+ bound<T>(work: Promise<T>): Promise<T>
453
+ clear(): void
454
+ }
455
+
456
+ /**
457
+ * A build's deadline. One timer aborts the signal and fails the bound work, so a build registers no
458
+ * listener. Not `AbortSignal.timeout`: under a run of misses its timers would stay pending long after
459
+ * the builds settle.
460
+ */
461
+ const buildDeadline = (ms: number): BuildDeadline => {
462
+ const controller = new AbortController()
463
+ let expire: (reason: unknown) => void = () => {}
464
+ const timer = setTimeout(() => {
465
+ const reason = new DOMException("The operation timed out.", "TimeoutError")
466
+ controller.abort(reason)
467
+ expire(reason)
468
+ }, ms)
469
+ return {
470
+ signal: controller.signal,
471
+ bound: (work) =>
472
+ new Promise((resolve, reject) => {
473
+ expire = reject
474
+ work.then(resolve, reject)
475
+ }),
476
+ clear: () => clearTimeout(timer),
477
+ }
478
+ }
479
+
480
+ /** `read`, or a miss once `ms` pass: a shared store that stalls is answered by a build, not a wait. */
481
+ const missAfter = <T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> => {
482
+ let timer: ReturnType<typeof setTimeout> | undefined
483
+ const late = new Promise<undefined>((resolve) => {
484
+ timer = setTimeout(resolve, ms, undefined)
485
+ })
486
+ return Promise.race([read, late]).finally(() => clearTimeout(timer))
487
+ }
488
+
489
+ export function createPprServer(config: PprServerConfig): PprServer {
490
+ const { options, adapter, deployment, shellChecks } = config
491
+ // With the beacon off, the request script names no address, so the runtime sends none.
492
+ const cancelUrl = options.cancelBeacon ? config.cancelUrl : ""
493
+ const renderFragment = adapter.renderFragment
494
+ if (renderFragment === undefined) {
495
+ throw new TypeError("[nifra/web] ppr: the render adapter does not implement renderFragment")
496
+ }
497
+ if (shellChecks === "unchecked-development" && isProduction()) {
498
+ throw new TypeError("[nifra/web] ppr: production needs the build's shell check records")
499
+ }
500
+ const ownRecord = (): boolean =>
501
+ shellChecks !== "unchecked-development" &&
502
+ shellChecks.deployment === (config.clientEntry ?? deployment)
503
+ const checked = (routeId: string): boolean =>
504
+ shellChecks === "unchecked-development" ||
505
+ (ownRecord() && Object.hasOwn(shellChecks.routes, routeId))
506
+ /** Whether the build checked this shared hole's loader with its route's shell. */
507
+ const checkedShared = (routeId: string, name: string): boolean =>
508
+ shellChecks === "unchecked-development" ||
509
+ (ownRecord() &&
510
+ Object.hasOwn(shellChecks.routes, routeId) &&
511
+ shellChecks.sharedHoles?.[routeId]?.includes(name) === true)
512
+ const now = config.now ?? Date.now
513
+ const shared = config.shared
514
+ // One shared-store read per key at a time; concurrent misses wait on it.
515
+ const shellLookups = new Map<string, Promise<ShellEntry | undefined>>()
516
+ const holeLookups = new Map<string, Promise<HoleEntry | undefined>>()
517
+ const untaggableWarned = new Set<string>()
518
+ const uncheckedHoleWarned = new Set<string>()
519
+ // Shells and shared hole frames, under one byte budget and one entry cap per route.
520
+ const settled = new Map<string, Entry>()
521
+ const sightings = new Map<string, number>()
522
+ const routeCounts = new Map<string, number>()
523
+ let storedBytes = 0
524
+ const builds = new Map<string, Promise<ShellEntry>>()
525
+ const holeBuilds = new Map<string, Promise<HoleEntry>>()
526
+ // Shell and shared hole builds, which share the route's limit.
527
+ const buildsByRoute = new Map<string, number>()
528
+ // A build records the count when it starts and is not stored if one of its tags was invalidated
529
+ // since. Only running builds read the map, so it empties whenever none run.
530
+ let invalidations = 0
531
+ const invalidatedAt = new Map<string, number>()
532
+ const live = new Map<string, (reason: unknown) => void>()
533
+ const cspHeaders = new Map<string, Promise<string | undefined>>()
534
+ const thinWarned = new Set<string>()
535
+ const oversizeWarned = new Set<string>()
536
+ const emptyWarned = new Map<string, Set<string>>()
537
+ const fallbackHtml = new WeakMap<HoleRegistration, Promise<string>>()
538
+ // A route's request script, with room for the token, serialized once per set of hole names.
539
+ const requestScripts = new Map<
540
+ string,
541
+ { readonly text: string; readonly bytes: number; readonly tokenAt: number }
542
+ >()
543
+ const requestScriptOf = (holes: readonly HoleRegistration[]) => {
544
+ const names = holes.map((hole) => hole.name)
545
+ const sharedNames = holes.filter((hole) => hole.mode === "shared").map((hole) => hole.name)
546
+ const key = `${names.join(",")}|${sharedNames.join(",")}`
547
+ let script = requestScripts.get(key)
548
+ if (script === undefined) {
549
+ const [before, after] = requestScriptAround(cancelUrl, names, sharedNames)
550
+ const text = before + TOKEN_ROOM + after
551
+ script = { text, bytes: ENCODER.encode(text).length, tokenAt: ENCODER.encode(before).length }
552
+ requestScripts.set(key, script)
553
+ }
554
+ return script
555
+ }
556
+
557
+ // A fallback comes from its registration alone and every shell holds it, so it renders once.
558
+ const fallbackOf = (hole: HoleRegistration): Promise<string> => {
559
+ let html = fallbackHtml.get(hole)
560
+ if (html === undefined) {
561
+ const fallback = hole.fallback
562
+ html =
563
+ fallback === undefined
564
+ ? Promise.resolve("")
565
+ : Promise.resolve().then(() => renderFragment.call(adapter, () => fallback, {}))
566
+ fallbackHtml.set(hole, html)
567
+ html.catch(() => fallbackHtml.delete(hole))
568
+ }
569
+ return html
570
+ }
571
+ const admission = new HoleAdmission(
572
+ options,
573
+ isProduction()
574
+ ? undefined
575
+ : (key, ms) =>
576
+ console.warn(
577
+ `[nifra/web] ppr: boundary "${key}" settled ${Math.round(ms)} ms after its abort; pass ctx.signal to the work its loader starts`,
578
+ ),
579
+ )
580
+ const streams = new Set<StreamState>()
581
+ let unsentBytes = 0
582
+ let slowAborted = 0
583
+
584
+ const evict = (key: string): void => {
585
+ const entry = settled.get(key)
586
+ if (entry === undefined) return
587
+ settled.delete(key)
588
+ storedBytes -= entry.bytes
589
+ routeCounts.set(entry.routeId, (routeCounts.get(entry.routeId) ?? 1) - 1)
590
+ }
591
+
592
+ const store = (key: string, entry: Entry): void => {
593
+ evict(key)
594
+ settled.set(key, entry)
595
+ storedBytes += entry.bytes
596
+ routeCounts.set(entry.routeId, (routeCounts.get(entry.routeId) ?? 0) + 1)
597
+ // Map order is insertion order, refreshed on every hit, so iteration meets the least recent first.
598
+ // Over the byte budget anything older goes; over the route's entry cap, that route's oldest.
599
+ for (const [oldKey, old] of settled) {
600
+ const overBytes = storedBytes > options.shellStoreBytes
601
+ const overRoute = (routeCounts.get(entry.routeId) ?? 0) > options.shellEntriesPerRoute
602
+ if (!overBytes && !overRoute) break
603
+ if (oldKey !== key && (overBytes || old.routeId === entry.routeId)) evict(oldKey)
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Whether a held entry is due a read of the shared store: another process may have purged it there,
609
+ * and reading it again is how that purge reaches this one.
610
+ */
611
+ const recheckDue = (entry: Entry): boolean =>
612
+ entry.checkedAt !== undefined && now() - entry.checkedAt >= options.sharedRecheckMs
613
+
614
+ const finishShell = async (
615
+ key: string,
616
+ html: string,
617
+ request: PprRequest,
618
+ data: unknown,
619
+ ): Promise<ShellEntry> => {
620
+ // A component that copied the nonce into the shell would hand it to every visitor.
621
+ if (html.includes(" nonce=")) {
622
+ throw new Error(`[nifra/web] ppr: route "${request.routeId}" rendered a nonce into its shell`)
623
+ }
624
+ if (!html.endsWith(DOCUMENT_END)) {
625
+ throw new Error(`[nifra/web] ppr: route "${request.routeId}" shell does not end its document`)
626
+ }
627
+ const headEnd = html.indexOf("</head>")
628
+ if (headEnd === -1)
629
+ throw new Error(`[nifra/web] ppr: route "${request.routeId}" shell has no head`)
630
+ const bodyEnd = html.length - DOCUMENT_END.length
631
+ // Every change to the rendered document, made in one pass that copies it once.
632
+ const edits: Array<{ readonly from: number; readonly to: number; readonly html: string }> = [
633
+ { from: headEnd, to: headEnd, html: RUNTIME_SCRIPT },
634
+ ]
635
+ // The client entry must not wait for the end of the document, which is the slowest hole.
636
+ for (
637
+ let at = html.indexOf(MODULE_SCRIPT_OPEN);
638
+ at !== -1 && at < bodyEnd;
639
+ at = html.indexOf(MODULE_SCRIPT_OPEN, at + 1)
640
+ ) {
641
+ const srcEnd = html.indexOf('"', at + MODULE_SCRIPT_OPEN.length)
642
+ const end = srcEnd + MODULE_SCRIPT_CLOSE.length
643
+ if (srcEnd === -1 || !html.startsWith(MODULE_SCRIPT_CLOSE, srcEnd)) continue
644
+ if (at < headEnd && end > headEnd) continue
645
+ const src = html.slice(at + MODULE_SCRIPT_OPEN.length, srcEnd)
646
+ edits.push({
647
+ from: at,
648
+ to: end,
649
+ html: `<link rel="modulepreload" href="${src}">${BOOT_SCRIPT}`,
650
+ })
651
+ }
652
+ const fallbacks = new Map<string, string>()
653
+ for (const hole of request.holes) {
654
+ const fallback = await fallbackOf(hole)
655
+ fallbacks.set(hole.name, fallback)
656
+ if (fallback.trim() !== "" || isProduction()) continue
657
+ const warned = emptyWarned.get(request.routeId) ?? new Set<string>()
658
+ if (warned.has(hole.name)) continue
659
+ emptyWarned.set(request.routeId, warned.add(hole.name))
660
+ console.warn(
661
+ `[nifra/web] ppr: hole "${hole.name}" of "${request.routeId}" has an empty fallback, so a browser without JavaScript shows nothing in its place and the page moves when it fills`,
662
+ )
663
+ }
664
+ for (const insert of holeFallbackInserts(html, fallbacks))
665
+ edits.push({ from: insert.at, to: insert.at, html: insert.html })
666
+ edits.sort((a, b) => a.from - b.from)
667
+ let prefix = ""
668
+ let from = 0
669
+ for (const edit of edits) {
670
+ prefix += html.slice(from, edit.from) + edit.html
671
+ from = edit.to
672
+ }
673
+ prefix += html.slice(from, bodyEnd)
674
+ if (!isProduction() && !thinWarned.has(request.routeId)) {
675
+ const text = textLength(prefix.slice(Math.max(prefix.indexOf("<body"), 0)))
676
+ if (text < WEBKIT_PAINT_CHARS) {
677
+ thinWarned.add(request.routeId)
678
+ console.warn(
679
+ `[nifra/web] ppr: the shell of "${request.routeId}" has ${text} characters of text. Safari paints a loading document only once it holds about ${WEBKIT_PAINT_CHARS}, so it shows this page only when its slowest hole ends`,
680
+ )
681
+ }
682
+ }
683
+ const script = requestScriptOf(request.holes)
684
+ // One encode: the request script's bytes are known, so the prefix's are the rest.
685
+ let first = ENCODER.encode(prefix + script.text)
686
+ const short = options.minShellBytes - (first.length - script.bytes)
687
+ if (short > 0) first = ENCODER.encode(`${prefix}<!--${" ".repeat(short)}-->${script.text}`)
688
+ const bytes = ENTRY_BYTES + key.length + first.length
689
+ return {
690
+ routeId: request.routeId,
691
+ first,
692
+ tokenAt: first.length - script.bytes + script.tokenAt,
693
+ data,
694
+ bytes: bytes + heldBytes(data, options.shellStoreBytes - bytes),
695
+ builtAt: now(),
696
+ tags: request.tags(),
697
+ failures: 0,
698
+ retryAt: 0,
699
+ checkedAt: undefined,
700
+ }
701
+ }
702
+
703
+ /**
704
+ * The shared store's copy of an entry, unless its tags could never be dropped there. True when the
705
+ * write started, so the entry is one a purge on another process can reach.
706
+ */
707
+ const share = (
708
+ key: string,
709
+ stored: StoredEntry,
710
+ keepSeconds: number,
711
+ request: PprRequest,
712
+ fence: Promise<number | undefined>,
713
+ ): boolean => {
714
+ if (shared === undefined) return false
715
+ if (stored.tags.length > 0 && shared.invalidateTag === undefined) {
716
+ if (!untaggableWarned.has(stored.routeId)) {
717
+ untaggableWarned.add(stored.routeId)
718
+ console.warn(
719
+ `[nifra/web] ppr: route "${stored.routeId}" lists revalidateTags, which the shared store cannot drop, so its shells and shared holes stay in this process`,
720
+ )
721
+ }
722
+ return false
723
+ }
724
+ const write = fence
725
+ .then((at) => shared.set(key, stored, keepSeconds, at))
726
+ .catch((error: unknown) =>
727
+ console.error("[nifra/web] ppr: writing to the shared store failed", {
728
+ route: stored.routeId,
729
+ kind: error instanceof Error ? "error" : typeof error,
730
+ }),
731
+ )
732
+ request.waitUntil?.(write)
733
+ return true
734
+ }
735
+
736
+ const shareShell = (
737
+ key: string,
738
+ entry: ShellEntry,
739
+ request: PprRequest,
740
+ fence: Promise<number | undefined>,
741
+ ): boolean =>
742
+ share(
743
+ key,
744
+ {
745
+ routeId: entry.routeId,
746
+ html: DECODER.decode(entry.first),
747
+ tokenAt: entry.tokenAt,
748
+ data: entry.data,
749
+ builtAt: entry.builtAt,
750
+ tags: entry.tags,
751
+ notFound: entry.notFound !== undefined,
752
+ },
753
+ entry.notFound !== undefined
754
+ ? options.notFoundTtlSeconds
755
+ : request.revalidate === undefined
756
+ ? KEEP_UNREVALIDATED_SECONDS
757
+ : staleLimitMs(request.revalidate) / 1000,
758
+ request,
759
+ fence,
760
+ )
761
+
762
+ /** A shell read back from the shared store, as this process keeps it. */
763
+ const entryOf = (key: string, stored: StoredEntry): ShellEntry => {
764
+ const first = stored.notFound ? NO_BYTES : ENCODER.encode(stored.html)
765
+ const bytes = ENTRY_BYTES + key.length + first.length
766
+ return {
767
+ routeId: stored.routeId,
768
+ first,
769
+ tokenAt: stored.tokenAt,
770
+ data: stored.data,
771
+ bytes: bytes + heldBytes(stored.data, options.shellStoreBytes - bytes),
772
+ builtAt: stored.builtAt,
773
+ tags: stored.tags,
774
+ notFound: stored.notFound ? notFoundSignal() : undefined,
775
+ failures: 0,
776
+ retryAt: 0,
777
+ checkedAt: now(),
778
+ }
779
+ }
780
+
781
+ const leaveBuild = (routeId: string): void => {
782
+ const running = (buildsByRoute.get(routeId) ?? 1) - 1
783
+ if (running === 0) buildsByRoute.delete(routeId)
784
+ else buildsByRoute.set(routeId, running)
785
+ if (builds.size === 0 && holeBuilds.size === 0) invalidatedAt.clear()
786
+ }
787
+
788
+ const startBuild = (key: string, request: PprRequest): Promise<ShellEntry> => {
789
+ const routeId = request.routeId
790
+ buildsByRoute.set(routeId, (buildsByRoute.get(routeId) ?? 0) + 1)
791
+ const startedAt = invalidations
792
+ // Read before the build starts, so a purge the shared store sees during it keeps it out.
793
+ const fence: Promise<number | undefined> =
794
+ shared === undefined ? Promise.resolve(undefined) : shared.fence().catch(() => undefined)
795
+ const deadline = buildDeadline(options.maxHoleTimeoutMs)
796
+ const loading = detached(() =>
797
+ request.loadShell(deadline.signal).then(
798
+ async (data) =>
799
+ finishShell(
800
+ key,
801
+ await request.renderShellDocument(data, pendingStates(request.holes)),
802
+ request,
803
+ data,
804
+ ),
805
+ (error: unknown): ShellEntry => {
806
+ // A missing record is cached briefly, so a flood of unknown ids runs no loader twice.
807
+ if (!isStatusSignal(error) || error.status !== 404) throw error
808
+ return {
809
+ routeId,
810
+ first: NO_BYTES,
811
+ tokenAt: 0,
812
+ data: undefined,
813
+ bytes: ENTRY_BYTES + key.length,
814
+ builtAt: now(),
815
+ tags: request.tags(),
816
+ notFound: error,
817
+ failures: 0,
818
+ retryAt: 0,
819
+ checkedAt: undefined,
820
+ }
821
+ },
822
+ ),
823
+ )
824
+ const building = deadline.bound(loading)
825
+ builds.set(key, building)
826
+ // The route's build is held until the loader itself ends: one that ignores its signal past the
827
+ // deadline still counts, so it cannot leave room for unbounded replacements.
828
+ loading.then(
829
+ () => leaveBuild(routeId),
830
+ () => leaveBuild(routeId),
831
+ )
832
+ const done = (): void => {
833
+ deadline.clear()
834
+ if (builds.get(key) === building) builds.delete(key)
835
+ }
836
+ building.then((entry) => {
837
+ const fenced = entry.tags.some((tag) => (invalidatedAt.get(tag) ?? -1) > startedAt)
838
+ done()
839
+ if (fenced) return
840
+ if (shareShell(key, entry, request, fence)) entry.checkedAt = now()
841
+ if (entry.bytes <= options.shellStoreBytes) return store(key, entry)
842
+ // Kept, it would empty the store and still overrun it; a stale entry it replaces goes too.
843
+ evict(key)
844
+ if (!isProduction() && !oversizeWarned.has(routeId)) {
845
+ oversizeWarned.add(routeId)
846
+ console.warn(
847
+ `[nifra/web] ppr: a shell of "${routeId}" holds more than shellStoreBytes with its loader data, so it is built for every request`,
848
+ )
849
+ }
850
+ }, done)
851
+ return building
852
+ }
853
+
854
+ /** Behind a stale shell: one rebuild per key, within the route's build limit, backing off on failure. */
855
+ const rebuild = (key: string, request: PprRequest, stale: ShellEntry): void => {
856
+ if (builds.has(key) || now() < stale.retryAt) return
857
+ if ((buildsByRoute.get(request.routeId) ?? 0) >= options.shellBuildConcurrency) return
858
+ const building = startBuild(key, request)
859
+ // No visitor waits on it, so a platform that stops work at the response's end must be told.
860
+ request.waitUntil?.(building.catch(() => undefined))
861
+ building.catch((error: unknown) => {
862
+ stale.failures++
863
+ stale.retryAt = now() + Math.min(2 ** stale.failures, request.revalidate ?? 1) * 1000
864
+ console.error("[nifra/web] ppr: shell rebuild failed; serving the stale shell", {
865
+ route: request.routeId,
866
+ kind: error instanceof Error ? "error" : typeof error,
867
+ })
868
+ })
869
+ }
870
+
871
+ const staleLimitMs = (revalidate: number): number =>
872
+ (revalidate +
873
+ Math.min(revalidate * options.staleOnErrorFactor, options.maxStaleOnErrorSeconds)) *
874
+ 1000
875
+
876
+ /** Whether a new shell of the route would likely push another out: an average entry overruns. */
877
+ const full = (routeId: string): boolean =>
878
+ (settled.size > 0 && storedBytes + storedBytes / settled.size > options.shellStoreBytes) ||
879
+ (routeCounts.get(routeId) ?? 0) >= options.shellEntriesPerRoute
880
+
881
+ /**
882
+ * Counts a request for a key with no shell; true once the key may build one. Only a full store
883
+ * counts, so with room every key builds at once, and when full a path seen once cannot evict.
884
+ */
885
+ const due = (key: string, routeId: string): boolean => {
886
+ if (options.shellAfterRequests === 1 || settled.has(key) || builds.has(key)) return true
887
+ if (!full(routeId)) return true
888
+ const seen = (sightings.get(key) ?? 0) + 1
889
+ if (seen >= options.shellAfterRequests) {
890
+ sightings.delete(key)
891
+ return true
892
+ }
893
+ if (sightings.size >= MAX_SIGHTINGS) sightings.clear()
894
+ sightings.set(key, seen)
895
+ return false
896
+ }
897
+
898
+ /** Serves `entry` if it is still usable for `request`, rebuilding behind a stale one. */
899
+ const serveEntry = (
900
+ key: string,
901
+ entry: ShellEntry,
902
+ request: PprRequest,
903
+ ): Promise<ShellEntry> | undefined => {
904
+ const age = now() - entry.builtAt
905
+ if (entry.notFound !== undefined) {
906
+ return age < options.notFoundTtlSeconds * 1000 ? Promise.reject(entry.notFound) : undefined
907
+ }
908
+ if (request.revalidate === undefined || age < request.revalidate * 1000) {
909
+ return Promise.resolve(entry)
910
+ }
911
+ if (age < staleLimitMs(request.revalidate)) {
912
+ rebuild(key, request, entry)
913
+ return Promise.resolve(entry)
914
+ }
915
+ return undefined
916
+ }
917
+
918
+ const build = (key: string, request: PprRequest): Promise<ShellEntry> => {
919
+ const running = builds.get(key)
920
+ if (running !== undefined) return running.then(served)
921
+ if ((buildsByRoute.get(request.routeId) ?? 0) >= options.shellBuildConcurrency) {
922
+ return Promise.reject(new PprShellBusy(request.routeId))
923
+ }
924
+ for (const hole of request.holes) assertHoleName(hole.name)
925
+ return startBuild(key, request).then(served)
926
+ }
927
+
928
+ /** The shared store's shell for `key`, kept here once read; a failed or missing read builds one. */
929
+ const fromShared = (
930
+ sharedStore: SharedShellStore,
931
+ key: string,
932
+ request: PprRequest,
933
+ ): Promise<ShellEntry> => {
934
+ let lookup = shellLookups.get(key)
935
+ if (lookup === undefined) {
936
+ const read = missAfter(sharedStore.get(key), options.holeTimeoutMs).then(
937
+ (found) => (found === undefined ? undefined : entryOf(key, found)),
938
+ () => undefined,
939
+ )
940
+ lookup = read
941
+ shellLookups.set(key, read)
942
+ read.then(() => shellLookups.delete(key))
943
+ }
944
+ return lookup.then((entry) => {
945
+ if (entry !== undefined && entry.routeId === request.routeId) {
946
+ const usable = serveEntry(key, entry, request)
947
+ if (usable !== undefined) {
948
+ if (!settled.has(key) && entry.bytes <= options.shellStoreBytes) store(key, entry)
949
+ return usable
950
+ }
951
+ }
952
+ return build(key, request)
953
+ })
954
+ }
955
+
956
+ const shellFor = (
957
+ request: PprRequest,
958
+ key = shellKey(deployment, request),
959
+ ): Promise<ShellEntry> => {
960
+ const entry = settled.get(key)
961
+ if (entry !== undefined && "first" in entry) {
962
+ settled.delete(key)
963
+ settled.set(key, entry)
964
+ const usable = recheckDue(entry) ? undefined : serveEntry(key, entry, request)
965
+ if (usable !== undefined) return usable
966
+ evict(key)
967
+ }
968
+ const running = builds.get(key)
969
+ if (running !== undefined) return running.then(served)
970
+ if (!checked(request.routeId)) return Promise.reject(new PprShellUnchecked(request.routeId))
971
+ return shared === undefined ? build(key, request) : fromShared(shared, key, request)
972
+ }
973
+
974
+ /** What a shared hole's loader gets: the shell's context, under the build's own signal. */
975
+ const sharedHoleCtx = (
976
+ request: PprRequest,
977
+ signal: AbortSignal,
978
+ live: (...keys: string[]) => void,
979
+ ): SharedHoleCtx => Object.freeze({ ...request.shellContext, signal, live })
980
+
981
+ /** A shared hole's frame and data, rendered once for every visitor. */
982
+ const finishHole = async (
983
+ key: string,
984
+ request: PprRequest,
985
+ hole: SharedHole,
986
+ data: unknown,
987
+ keys: readonly string[],
988
+ ): Promise<HoleEntry> => {
989
+ // The visitor's status and headers went out with the shell, so a hole has none to give.
990
+ if (isControlFlow(data) || isStatusSignal(data))
991
+ throw new TypeError("[nifra/web] ppr: a shared hole returned a redirect or status")
992
+ // The id prefix is the hole's own, never a request's, so every visitor gets the same bytes.
993
+ const html = await renderFragment.call(
994
+ adapter,
995
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: defineBoundary binds render to its own load's output; the registration erases that generic, and the value came from that load.
996
+ () => hole.render(data as never),
997
+ { idPrefix: `nifra-${hole.name}-`, hydrates: hole.interactive === true },
998
+ )
999
+ validateHoleHtml(html)
1000
+ // A component that copied the nonce into the frame would hand it to every visitor.
1001
+ if (html.includes(" nonce=")) {
1002
+ throw new Error(
1003
+ `[nifra/web] ppr: shared hole "${hole.name}" of "${request.routeId}" rendered a nonce`,
1004
+ )
1005
+ }
1006
+ const frame = ENCODER.encode(
1007
+ encodeHoleFrame(
1008
+ hole.name,
1009
+ hole.interactive === true ? { html, props: data } : { html },
1010
+ options.frameBytes,
1011
+ ),
1012
+ )
1013
+ const bytes = ENTRY_BYTES + key.length + frame.length
1014
+ return {
1015
+ routeId: request.routeId,
1016
+ frame,
1017
+ data,
1018
+ bytes: bytes + heldBytes(data, options.shellStoreBytes - bytes),
1019
+ builtAt: now(),
1020
+ tags: request.holeTags(hole),
1021
+ keys,
1022
+ failures: 0,
1023
+ retryAt: 0,
1024
+ checkedAt: undefined,
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Build one shared hole's entry, detached from the visitor, under its own deadline, and keep it.
1030
+ * With `slot`, the build is hole work under admission instead of one of the route's builds.
1031
+ */
1032
+ const startHoleBuild = (
1033
+ key: string,
1034
+ request: PprRequest,
1035
+ hole: SharedHole,
1036
+ slot: HoleSlot | undefined,
1037
+ ): Promise<HoleEntry> => {
1038
+ const routeId = request.routeId
1039
+ if (slot === undefined) buildsByRoute.set(routeId, (buildsByRoute.get(routeId) ?? 0) + 1)
1040
+ const startedAt = invalidations
1041
+ const fence: Promise<number | undefined> =
1042
+ shared === undefined ? Promise.resolve(undefined) : shared.fence().catch(() => undefined)
1043
+ const deadline = buildDeadline(options.maxHoleTimeoutMs)
1044
+ const signal = deadline.signal
1045
+ // Keys count only when declared before the loader settles, as in a visitor's live load.
1046
+ const keys = new Set<string>()
1047
+ let declaring = true
1048
+ const declare = (...declared: string[]): void => {
1049
+ if (declaring) for (const declaredKey of declared) keys.add(String(declaredKey))
1050
+ }
1051
+ const load = (): Promise<HoleEntry> =>
1052
+ detached(() =>
1053
+ Promise.resolve()
1054
+ .then(() => hole.load?.(sharedHoleCtx(request, signal, declare)))
1055
+ .finally(() => {
1056
+ declaring = false
1057
+ })
1058
+ .then((data) => finishHole(key, request, hole, data, [...keys])),
1059
+ )
1060
+ const loading =
1061
+ slot === undefined
1062
+ ? load()
1063
+ : slot
1064
+ .run(`${routeId}|${hole.name}`, signal, load)
1065
+ .then((run) =>
1066
+ run.ok ? run.value : Promise.reject(run.error ?? new PprShellBusy(routeId)),
1067
+ )
1068
+ const building = deadline.bound(loading)
1069
+ holeBuilds.set(key, building)
1070
+ // As for a shell: the route's build is held until the loader itself ends.
1071
+ const leave = (): void => {
1072
+ if (slot === undefined) leaveBuild(routeId)
1073
+ else if (builds.size === 0 && holeBuilds.size === 0) invalidatedAt.clear()
1074
+ }
1075
+ loading.then(leave, leave)
1076
+ const done = (): void => {
1077
+ deadline.clear()
1078
+ if (holeBuilds.get(key) === building) holeBuilds.delete(key)
1079
+ }
1080
+ building.then(
1081
+ (entry) => {
1082
+ const fenced = entry.tags.some((tag) => (invalidatedAt.get(tag) ?? -1) > startedAt)
1083
+ done()
1084
+ if (fenced) return
1085
+ const written = share(
1086
+ key,
1087
+ {
1088
+ routeId,
1089
+ html: DECODER.decode(entry.frame),
1090
+ tokenAt: 0,
1091
+ data: entry.data,
1092
+ builtAt: entry.builtAt,
1093
+ tags: entry.tags,
1094
+ keys: entry.keys,
1095
+ notFound: false,
1096
+ },
1097
+ staleLimitMs(hole.revalidate) / 1000,
1098
+ request,
1099
+ fence,
1100
+ )
1101
+ if (written) entry.checkedAt = now()
1102
+ if (entry.bytes <= options.shellStoreBytes) store(key, entry)
1103
+ else evict(key)
1104
+ },
1105
+ (error: unknown) => {
1106
+ done()
1107
+ console.error("[nifra/web] ppr: shared hole build failed", {
1108
+ route: routeId,
1109
+ hole: hole.name,
1110
+ kind: error instanceof Error ? "error" : typeof error,
1111
+ })
1112
+ },
1113
+ )
1114
+ return building
1115
+ }
1116
+
1117
+ /**
1118
+ * A shared hole's build. Over the route's build limit it still runs, as hole work under admission:
1119
+ * it reads no request, so storing it is as safe as any build, and the next visitor hits it instead
1120
+ * of calling a slow upstream again.
1121
+ */
1122
+ const buildHole = (key: string, request: PprRequest, hole: SharedHole): Promise<HoleEntry> => {
1123
+ const running = holeBuilds.get(key)
1124
+ if (running !== undefined) return running
1125
+ if ((buildsByRoute.get(request.routeId) ?? 0) < options.shellBuildConcurrency)
1126
+ return startHoleBuild(key, request, hole, undefined)
1127
+ const slot = admission.slot()
1128
+ if (slot === undefined) return Promise.reject(new PprShellBusy(request.routeId))
1129
+ const building = startHoleBuild(key, request, hole, slot)
1130
+ slot.seal()
1131
+ return building
1132
+ }
1133
+
1134
+ /** `entry` if it is still usable, rebuilding behind a stale one within the route's build limit. */
1135
+ const usableHole = (
1136
+ key: string,
1137
+ entry: HoleEntry,
1138
+ request: PprRequest,
1139
+ hole: SharedHole,
1140
+ ): HoleEntry | undefined => {
1141
+ const age = now() - entry.builtAt
1142
+ if (age < hole.revalidate * 1000) return entry
1143
+ if (age >= staleLimitMs(hole.revalidate)) return undefined
1144
+ const routeId = request.routeId
1145
+ if (
1146
+ !holeBuilds.has(key) &&
1147
+ now() >= entry.retryAt &&
1148
+ (buildsByRoute.get(routeId) ?? 0) < options.shellBuildConcurrency
1149
+ ) {
1150
+ const building = startHoleBuild(key, request, hole, undefined)
1151
+ // No visitor waits on it, so a platform that stops work at the response's end must be told.
1152
+ request.waitUntil?.(building.catch(() => undefined))
1153
+ building.catch(() => {
1154
+ entry.failures++
1155
+ entry.retryAt = now() + Math.min(2 ** entry.failures, hole.revalidate) * 1000
1156
+ })
1157
+ }
1158
+ return entry
1159
+ }
1160
+
1161
+ /** The shared hole's entry this process holds, if it is usable now: a hit costs no promise. */
1162
+ const heldHole = (key: string, request: PprRequest, hole: SharedHole): HoleEntry | undefined => {
1163
+ const entry = settled.get(key)
1164
+ if (entry === undefined || "first" in entry) return undefined
1165
+ settled.delete(key)
1166
+ settled.set(key, entry)
1167
+ const usable = recheckDue(entry) ? undefined : usableHole(key, entry, request, hole)
1168
+ if (usable === undefined) evict(key)
1169
+ return usable
1170
+ }
1171
+
1172
+ /** The shared hole's entry: being built, in the shared store, or built now. */
1173
+ const holeEntry = (key: string, request: PprRequest, hole: SharedHole): Promise<HoleEntry> => {
1174
+ const running = holeBuilds.get(key)
1175
+ if (running !== undefined) return running
1176
+ if (shared === undefined) return buildHole(key, request, hole)
1177
+ let lookup = holeLookups.get(key)
1178
+ if (lookup === undefined) {
1179
+ const read = missAfter(shared.get(key), options.holeTimeoutMs).then(
1180
+ (found): HoleEntry | undefined => {
1181
+ if (found === undefined || found.notFound) return undefined
1182
+ const frame = ENCODER.encode(found.html)
1183
+ const bytes = ENTRY_BYTES + key.length + frame.length
1184
+ return {
1185
+ routeId: found.routeId,
1186
+ frame,
1187
+ data: found.data,
1188
+ bytes: bytes + heldBytes(found.data, options.shellStoreBytes - bytes),
1189
+ builtAt: found.builtAt,
1190
+ tags: found.tags,
1191
+ keys: found.keys ?? [],
1192
+ failures: 0,
1193
+ retryAt: 0,
1194
+ checkedAt: now(),
1195
+ }
1196
+ },
1197
+ () => undefined,
1198
+ )
1199
+ lookup = read
1200
+ holeLookups.set(key, read)
1201
+ read.then(() => holeLookups.delete(key))
1202
+ }
1203
+ return lookup.then((entry) => {
1204
+ if (entry !== undefined && entry.routeId === request.routeId) {
1205
+ const usable = usableHole(key, entry, request, hole)
1206
+ if (usable !== undefined) {
1207
+ if (!settled.has(key) && entry.bytes <= options.shellStoreBytes) store(key, entry)
1208
+ return usable
1209
+ }
1210
+ }
1211
+ return buildHole(key, request, hole)
1212
+ })
1213
+ }
1214
+
1215
+ /**
1216
+ * The key of `hole`'s shared entry for `request`, or `undefined` when this request must not read
1217
+ * or fill one: a draft, a hole the build did not check, or a key a full store has seen too rarely.
1218
+ */
1219
+ const sharedKey = (request: PprRequest, hole: SharedHole): string | undefined => {
1220
+ if (request.holeContext.draft) return undefined
1221
+ if (!checkedShared(request.routeId, hole.name)) {
1222
+ const warning = `${request.routeId}|${hole.name}`
1223
+ if (checked(request.routeId) && !isProduction() && !uncheckedHoleWarned.has(warning)) {
1224
+ uncheckedHoleWarned.add(warning)
1225
+ console.warn(
1226
+ `[nifra/web] ppr: shared hole "${hole.name}" of "${request.routeId}" has no passing build check, so it loads for every visitor. Write its boundaryLoaders entry inline, with revalidate`,
1227
+ )
1228
+ }
1229
+ return undefined
1230
+ }
1231
+ const key = `ppr-hole:v1|${deployment}|${request.routeId}|${hole.name}|${request.locale ?? ""}|${variationQuery(request.holeContext.params)}|${variationQuery(request.variations)}`
1232
+ return due(key, request.routeId) ? key : undefined
1233
+ }
1234
+
1235
+ /** Wait for a shared hole's entry until the hole's deadline; the build itself goes on. */
1236
+ const awaitHole = (
1237
+ key: string,
1238
+ request: PprRequest,
1239
+ hole: SharedHole,
1240
+ deadlines: HoleDeadlines,
1241
+ ): Promise<HoleRun<HoleEntry>> => {
1242
+ const deadline = deadlines.join(hole.timeout ?? options.holeTimeoutMs)
1243
+ return new Promise((resolve) => {
1244
+ let unwatch = (): void => {}
1245
+ let answered = false
1246
+ const answer = (run: HoleRun<HoleEntry>): void => {
1247
+ if (answered) return
1248
+ answered = true
1249
+ unwatch()
1250
+ deadline.leave()
1251
+ resolve(run)
1252
+ }
1253
+ const aborted = (): void =>
1254
+ answer({ ok: false, reason: "aborted", error: deadline.signal.reason })
1255
+ if (deadline.signal.aborted) return aborted()
1256
+ unwatch = deadline.watch(aborted)
1257
+ holeEntry(key, request, hole).then(
1258
+ (value) => answer({ ok: true, value }),
1259
+ (error: unknown) => answer({ ok: false, reason: "failed", error }),
1260
+ )
1261
+ })
1262
+ }
1263
+
1264
+ // The log carries the failure's kind only: a thrown message can hold credentials or user data.
1265
+ const logFailure = (request: PprRequest, hole: HoleRegistration, error: unknown): void =>
1266
+ console.error("[nifra/web] ppr: hole failed", {
1267
+ route: request.routeId,
1268
+ hole: hole.name,
1269
+ kind: error instanceof Error ? "error" : typeof error,
1270
+ })
1271
+
1272
+ /** Invoke one hole loader under admission, with its timeout's deadline under the response. */
1273
+ const runHole = (
1274
+ slot: HoleSlot | undefined,
1275
+ request: PprRequest,
1276
+ hole: HoleRegistration,
1277
+ deadlines: HoleDeadlines,
1278
+ ): Promise<HoleRun<unknown>> => {
1279
+ if (hole.load === undefined) return Promise.resolve({ ok: true, value: undefined })
1280
+ if (slot === undefined) return Promise.resolve(SHED)
1281
+ const deadline = deadlines.join(hole.timeout ?? options.holeTimeoutMs)
1282
+ const signal = deadline.signal
1283
+ const live = request.live?.collector(hole.name) ?? NO_LIVE
1284
+ const component = componentOf(hole)
1285
+ // A shared hole that is not stored for this request still loads as it is built: no request.
1286
+ const invoke =
1287
+ hole.mode === "shared"
1288
+ ? () => detached(() => hole.load?.(sharedHoleCtx(request, signal, live)))
1289
+ : component === undefined
1290
+ ? () => hole.load?.(holeCtx(request.holeContext, signal, live))
1291
+ : // The component runs under the hole's own deadline and admission, after its loader.
1292
+ async () => {
1293
+ const input = await hole.load?.(holeCtx(request.holeContext, signal, live))
1294
+ if (isControlFlow(input) || isStatusSignal(input)) return input
1295
+ if (request.component === undefined) {
1296
+ throw new TypeError(
1297
+ `[nifra/web] ppr: hole "${hole.name}" shows a server component that the app's serverComponents does not list under holes for "${request.routeId}"`,
1298
+ )
1299
+ }
1300
+ return request.component(hole.name, component, input, signal)
1301
+ }
1302
+ return slot
1303
+ .run(`${request.routeId}|${hole.name}`, signal, invoke, deadline.watch)
1304
+ .then((run) => {
1305
+ deadline.leave()
1306
+ // The shell's status and headers are already sent, so a hole has no redirect or status to give.
1307
+ const settled: HoleRun<unknown> =
1308
+ run.ok && (isControlFlow(run.value) || isStatusSignal(run.value))
1309
+ ? { ok: false, reason: "failed", error: new TypeError("returned a redirect or status") }
1310
+ : run
1311
+ if (!settled.ok && settled.reason === "failed") logFailure(request, hole, settled.error)
1312
+ return settled
1313
+ })
1314
+ }
1315
+
1316
+ /**
1317
+ * Start every hole of one response. A shared hole's held entry is a hit at once; any loader run for
1318
+ * this visitor needs the response's slot, and with none free, each such hole gets its error.
1319
+ */
1320
+ const startHoles = (
1321
+ request: PprRequest,
1322
+ response: AbortSource,
1323
+ holes: readonly HoleRegistration[] = request.holes,
1324
+ ): HoleStart[] => {
1325
+ let slot: HoleSlot | undefined
1326
+ let slotTaken = false
1327
+ let deadlines: HoleDeadlines | undefined
1328
+ const starts = holes.map((hole): HoleStart => {
1329
+ if (hole.mode === "shared") {
1330
+ const key = sharedKey(request, hole)
1331
+ if (key !== undefined) {
1332
+ const hit = heldHole(key, request, hole)
1333
+ if (hit !== undefined) return { hole, hit }
1334
+ deadlines ??= holeDeadlines(response)
1335
+ return { hole, entry: awaitHole(key, request, hole, deadlines) }
1336
+ }
1337
+ }
1338
+ if (!slotTaken) {
1339
+ slotTaken = true
1340
+ slot = admission.slot()
1341
+ }
1342
+ deadlines ??= holeDeadlines(response)
1343
+ return { hole, run: runHole(slot, request, hole, deadlines) }
1344
+ })
1345
+ slot?.seal()
1346
+ return starts
1347
+ }
1348
+
1349
+ const frameOf = async (
1350
+ request: PprRequest,
1351
+ hole: HoleRegistration,
1352
+ run: HoleRun<unknown>,
1353
+ ): Promise<string> => {
1354
+ if (!run.ok) return errorFrame(hole.name)
1355
+ try {
1356
+ if (componentOf(hole) !== undefined) {
1357
+ const value: unknown = run.value
1358
+ const html: unknown =
1359
+ typeof value === "object" && value !== null ? Reflect.get(value, "html") : null
1360
+ if (typeof value !== "object" || value === null || typeof html !== "string") {
1361
+ throw new TypeError("a server component rendered no HTML")
1362
+ }
1363
+ validateHoleHtml(html)
1364
+ // The frame carries the HTML once; the record the islands mount from is the rest.
1365
+ const component = Object.fromEntries(
1366
+ Object.entries(value).filter(([key]) => key !== "html"),
1367
+ )
1368
+ return encodeHoleFrame(hole.name, { html, component }, options.frameBytes)
1369
+ }
1370
+ const html = await renderFragment.call(
1371
+ adapter,
1372
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: defineBoundary binds render to its own load's output; the registration erases that generic, and the value came from that load.
1373
+ () => hole.render(run.value as never),
1374
+ { idPrefix: `nifra-${hole.name}-`, hydrates: hole.interactive === true },
1375
+ )
1376
+ if (options.holeHtmlValidation === "always" || !isProduction()) validateHoleHtml(html)
1377
+ return encodeHoleFrame(
1378
+ hole.name,
1379
+ hole.interactive === true ? { html, props: run.value } : { html },
1380
+ options.frameBytes,
1381
+ )
1382
+ } catch (error) {
1383
+ logFailure(request, hole, error)
1384
+ return errorFrame(hole.name)
1385
+ }
1386
+ }
1387
+
1388
+ const cspHeader = (request: PprRequest): Promise<string | undefined> => {
1389
+ const policy = config.csp
1390
+ if (policy === undefined) return Promise.resolve(undefined)
1391
+ let header = cspHeaders.get(request.routeId)
1392
+ if (header === undefined) {
1393
+ header = Promise.resolve(prepareCspPolicy(policy, adapter))
1394
+ .then(() => pprScriptHashes(request.holes.map((hole) => hole.name)))
1395
+ .then((hashes) => cspHeaderWithSources(policy, adapter, hashes.join(" ")))
1396
+ cspHeaders.set(request.routeId, header)
1397
+ header.catch(() => cspHeaders.delete(request.routeId))
1398
+ }
1399
+ return header
1400
+ }
1401
+
1402
+ // A route's policy is fixed (hash sources, no nonce), so there is one entry per route at most.
1403
+ // `Response` copies its init headers, so no response can change a shared one.
1404
+ const headersByPolicy = new Map<string | undefined, Headers>()
1405
+ const documentHeaders = (csp: string | undefined): Headers => {
1406
+ let headers = headersByPolicy.get(csp)
1407
+ if (headers === undefined) {
1408
+ headers = new Headers({
1409
+ "content-type": "text/html; charset=utf-8",
1410
+ "cache-control": "private, no-store",
1411
+ "x-accel-buffering": "no",
1412
+ })
1413
+ if (csp !== undefined) headers.set("content-security-policy", csp)
1414
+ headersByPolicy.set(csp, headers)
1415
+ }
1416
+ return headers
1417
+ }
1418
+
1419
+ /** Abort the streams with the most unsent bytes until `bytes` more fit the process budget. */
1420
+ const makeRoom = (bytes: number): void => {
1421
+ while (unsentBytes + bytes > options.maxUnsentBytes) {
1422
+ let largest: StreamState | undefined
1423
+ for (const stream of streams)
1424
+ if (largest === undefined || stream.queued > largest.queued) largest = stream
1425
+ if (largest === undefined || largest.queued === 0) return
1426
+ slowAborted++
1427
+ largest.kill(new DOMException("PPR unsent byte budget", "AbortError"))
1428
+ }
1429
+ }
1430
+
1431
+ return {
1432
+ async respond(request) {
1433
+ const key = shellKey(deployment, request)
1434
+ // Before any hole starts: a key with no shell yet renders whole and runs its holes there.
1435
+ if (!due(key, request.routeId)) throw new PprShellCold(request.routeId)
1436
+ if (request.request.method === "HEAD") {
1437
+ const [, csp] = await Promise.all([shellFor(request, key), cspHeader(request)])
1438
+ return new Response(null, { status: 200, headers: documentHeaders(csp) })
1439
+ }
1440
+ const token = randomToken()
1441
+ // Every abort of the response (disconnect, deadline, cancel, write grace) comes through here, so
1442
+ // the token retires at once and the holes need no controller or native listener of their own.
1443
+ const watchers: Array<() => void> = []
1444
+ const response: { aborted: boolean; reason: unknown } & AbortSource = {
1445
+ aborted: false,
1446
+ reason: undefined,
1447
+ watch(onAbort: () => void): void {
1448
+ watchers.push(onAbort)
1449
+ },
1450
+ }
1451
+ const abort = (reason: unknown): void => {
1452
+ if (response.aborted) return
1453
+ response.aborted = true
1454
+ response.reason = reason
1455
+ live.delete(token)
1456
+ for (const watcher of watchers) watcher()
1457
+ }
1458
+ live.set(token, abort)
1459
+ const unwatchDisconnect = watchAbort(request.request.signal, () =>
1460
+ abort(request.request.signal.reason),
1461
+ )
1462
+ const deadlineMs = totalDeadlineMs(
1463
+ options,
1464
+ request.holes.map((hole) => hole.timeout ?? options.holeTimeoutMs),
1465
+ )
1466
+ // One timer: the total deadline, then the write grace, which runs from the deadline or from the
1467
+ // stream's start, whichever is later.
1468
+ let killStream: ((reason: unknown) => void) | undefined
1469
+ let graceSpent = false
1470
+ const graceOver = (): void => {
1471
+ graceSpent = true
1472
+ killStream?.(new DOMException("PPR write grace", "TimeoutError"))
1473
+ }
1474
+ let timer = setTimeout(() => {
1475
+ abort(new DOMException("PPR total deadline", "TimeoutError"))
1476
+ timer = setTimeout(graceOver, options.writeGraceMs)
1477
+ }, deadlineMs)
1478
+ const cleanup = (): void => {
1479
+ clearTimeout(timer)
1480
+ live.delete(token)
1481
+ unwatchDisconnect()
1482
+ }
1483
+ // A cached shell is known at once, so holes start only once it is known to be a page; on a
1484
+ // miss they start beside the build, which is where starting early pays. The shell's build is
1485
+ // claimed first: a shared hole's build must not take the route's last one from it.
1486
+ const cached = settled.has(key)
1487
+ const shellAndCsp = Promise.all([shellFor(request, key), cspHeader(request)])
1488
+ let runs = cached ? undefined : startHoles(request, response)
1489
+ let shell: ShellEntry
1490
+ let csp: string | undefined
1491
+ try {
1492
+ ;[shell, csp] = await shellAndCsp
1493
+ } catch (error) {
1494
+ abort(error)
1495
+ cleanup()
1496
+ throw error
1497
+ }
1498
+ runs ??= startHoles(request, response)
1499
+
1500
+ let sink: ReadableStreamDefaultController<Uint8Array> | undefined
1501
+ const state: StreamState = {
1502
+ queue: [],
1503
+ queued: 0,
1504
+ overflowed: false,
1505
+ ending: false,
1506
+ dead: false,
1507
+ wanted: false,
1508
+ kill(reason) {
1509
+ if (state.dead) return
1510
+ state.dead = true
1511
+ unsentBytes -= state.queued
1512
+ state.queued = 0
1513
+ state.queue.length = 0
1514
+ streams.delete(state)
1515
+ abort(reason)
1516
+ cleanup()
1517
+ try {
1518
+ sink?.error(reason)
1519
+ } catch {
1520
+ // Already closed or cancelled by the reader: nothing left to error.
1521
+ }
1522
+ },
1523
+ }
1524
+ // Everything queued goes out as one chunk: one write per read, not one per frame.
1525
+ const send = (stream: ReadableStreamDefaultController<Uint8Array>): void => {
1526
+ const chunk = state.queue.length > 1 ? concat(state.queue) : state.queue[0]
1527
+ state.queue.length = 0
1528
+ if (chunk !== undefined) {
1529
+ state.queued -= chunk.length
1530
+ unsentBytes -= chunk.length
1531
+ stream.enqueue(chunk)
1532
+ }
1533
+ if (state.ending) {
1534
+ state.dead = true
1535
+ streams.delete(state)
1536
+ stream.close()
1537
+ cleanup()
1538
+ }
1539
+ }
1540
+ const wake = (): void => {
1541
+ if (!state.wanted || sink === undefined) return
1542
+ state.wanted = false
1543
+ send(sink)
1544
+ }
1545
+ const push = (name: string, frame: string | Uint8Array): void => {
1546
+ if (state.dead) return
1547
+ // A shared frame's bytes serve every visitor, and a reader may write into a chunk.
1548
+ let bytes = typeof frame === "string" ? ENCODER.encode(frame) : frame.slice()
1549
+ // Past the per-response cap every later hole sends its error frame, a few hundred bytes each.
1550
+ if (state.overflowed || state.queued + bytes.length > options.maxUnsentBytesPerResponse) {
1551
+ state.overflowed = true
1552
+ bytes = ENCODER.encode(errorFrame(name))
1553
+ }
1554
+ makeRoom(bytes.length)
1555
+ if (state.dead) return
1556
+ state.queue.push(bytes)
1557
+ state.queued += bytes.length
1558
+ unsentBytes += bytes.length
1559
+ wake()
1560
+ }
1561
+ streams.add(state)
1562
+ killStream = state.kill
1563
+ if (graceSpent) timer = setTimeout(graceOver, options.writeGraceMs)
1564
+ const pushEnd = (): void => {
1565
+ if (state.dead) return
1566
+ const end = ENCODER.encode(DOCUMENT_END)
1567
+ state.queue.push(end)
1568
+ state.queued += end.length
1569
+ unsentBytes += end.length
1570
+ state.ending = true
1571
+ wake()
1572
+ }
1573
+ // The end is queued in the same turn as the last frame, so the two go out as one write.
1574
+ let unframed = runs.length
1575
+ // Shared frames this process holds go out with the shell, in its one chunk.
1576
+ const hits: Uint8Array[] = []
1577
+ for (const start of runs) {
1578
+ if (start.hit !== undefined) {
1579
+ hits.push(start.hit.frame)
1580
+ unframed--
1581
+ continue
1582
+ }
1583
+ const { hole } = start
1584
+ const frame: Promise<string | Uint8Array> =
1585
+ start.entry !== undefined
1586
+ ? start.entry.then((outcome) =>
1587
+ outcome.ok ? outcome.value.frame : errorFrame(hole.name),
1588
+ )
1589
+ : start.run.then((outcome) => frameOf(request, hole, outcome))
1590
+ frame.then((framed) => {
1591
+ push(hole.name, framed)
1592
+ if (--unframed === 0) pushEnd()
1593
+ })
1594
+ }
1595
+ if (unframed === 0) pushEnd()
1596
+
1597
+ const body = new ReadableStream<Uint8Array>(
1598
+ {
1599
+ start(stream) {
1600
+ sink = stream
1601
+ // A copy: a reader may write into a chunk, and the shell's bytes serve every visitor.
1602
+ const first = hits.length === 0 ? shell.first.slice() : concat([shell.first, ...hits])
1603
+ for (let at = 0; at < TOKEN_LENGTH; at++)
1604
+ first[shell.tokenAt + at] = token.charCodeAt(at)
1605
+ // Every visitor gets the same bytes up to the token, so compression() compresses them once.
1606
+ markRepeatedPrefix(first, shell.first, shell.tokenAt)
1607
+ stream.enqueue(first)
1608
+ },
1609
+ // Called only for a read the stream's queue cannot fill, so unread frames stay counted in
1610
+ // `state.queue`. Synchronous: an async pull and a promise per frame cost more than the frame.
1611
+ pull(stream) {
1612
+ if (state.dead) return
1613
+ if (state.queue.length === 0 && !state.ending) state.wanted = true
1614
+ else send(stream)
1615
+ },
1616
+ cancel(reason) {
1617
+ state.kill(reason)
1618
+ },
1619
+ },
1620
+ { highWaterMark: 0 },
1621
+ )
1622
+ return new Response(body, { status: 200, headers: documentHeaders(csp) })
1623
+ },
1624
+
1625
+ async shellData(request) {
1626
+ const key = shellKey(deployment, request)
1627
+ if (!due(key, request.routeId)) throw new PprShellCold(request.routeId)
1628
+ return (await shellFor(request, key)).data
1629
+ },
1630
+
1631
+ async holeStates(request, mode, signal) {
1632
+ const { live, kept } = request
1633
+ // A host the client holds reloads as a fill. A server component's hole is always a host: no
1634
+ // browser can render it.
1635
+ const fills = (hole: HoleRegistration): boolean =>
1636
+ (mode !== "omitted" && componentOf(hole) !== undefined) ||
1637
+ (mode === "deferred" && request.fill?.has(hole.name) === true)
1638
+ const keptStates: Record<string, BoundaryState> = {}
1639
+ const holes =
1640
+ kept === undefined || kept.size === 0
1641
+ ? request.holes
1642
+ : request.holes.filter((hole) => {
1643
+ if (!kept.has(hole.name)) return true
1644
+ keptStates[hole.name] = fills(hole)
1645
+ ? { name: hole.name, mode: hole.mode, status: "pending", ppr: true, kept: true }
1646
+ : { name: hole.name, mode: hole.mode, status: "pending", kept: true }
1647
+ return false
1648
+ })
1649
+ if (mode === "omitted") {
1650
+ live?.done([])
1651
+ return { ...pendingStates(holes), ...keptStates }
1652
+ }
1653
+ const loaded: string[] = []
1654
+ const settles: Promise<void>[] = []
1655
+ /** A hole that loaded owns its keys in the live load; one that did not has no owner. */
1656
+ const settle = (name: string, ok: boolean, keys: readonly string[] = []): void => {
1657
+ if (live === undefined) return
1658
+ try {
1659
+ if (ok && keys.length > 0) live.collector(name)(...keys)
1660
+ if (ok) loaded.push(name)
1661
+ } catch (error) {
1662
+ // A stored entry's keys over the load's budget: the hole loads but is not watched.
1663
+ console.error("[nifra/web] ppr: a shared hole's live keys were refused", {
1664
+ route: request.routeId,
1665
+ hole: name,
1666
+ reason: error instanceof Error ? error.message : typeof error,
1667
+ })
1668
+ }
1669
+ live.seal(name)
1670
+ }
1671
+ const entries = await Promise.all(
1672
+ startHoles(request, signalSource(signal), holes).map(
1673
+ async (start): Promise<[string, BoundaryState]> => {
1674
+ const { hole } = start
1675
+ const base: BoundaryState = { name: hole.name, mode: hole.mode, status: "pending" }
1676
+ if (hole.errorId !== undefined) Object.assign(base, { errorId: hole.errorId })
1677
+ // A held shared entry is ready at once, so a soft navigation renders it with the shell.
1678
+ if (start.hit !== undefined) {
1679
+ const hit = start.hit
1680
+ settle(hole.name, true, hit.keys)
1681
+ if (fills(hole))
1682
+ return [
1683
+ hole.name,
1684
+ { ...base, ppr: true, fill: frameBodyOf(DECODER.decode(hit.frame)) },
1685
+ ]
1686
+ return [
1687
+ hole.name,
1688
+ hit.data === undefined
1689
+ ? { ...base, status: "ready" }
1690
+ : { ...base, status: "ready", data: hit.data },
1691
+ ]
1692
+ }
1693
+ const run: Promise<HoleRun<unknown>> =
1694
+ start.entry === undefined
1695
+ ? start.run
1696
+ : start.entry.then((outcome) =>
1697
+ outcome.ok ? { ok: true, value: outcome.value.data } : outcome,
1698
+ )
1699
+ settles.push(
1700
+ start.entry === undefined
1701
+ ? start.run.then((outcome) => settle(hole.name, outcome.ok))
1702
+ : start.entry.then((outcome) =>
1703
+ settle(hole.name, outcome.ok, outcome.ok ? outcome.value.keys : undefined),
1704
+ ),
1705
+ )
1706
+ if (fills(hole)) {
1707
+ const frame =
1708
+ start.entry === undefined
1709
+ ? start.run.then((outcome) => frameOf(request, hole, outcome))
1710
+ : start.entry.then((outcome) =>
1711
+ outcome.ok ? DECODER.decode(outcome.value.frame) : errorFrame(hole.name),
1712
+ )
1713
+ const body = frame.then(frameBodyOf)
1714
+ // A blocking render puts the HTML in the host on the server.
1715
+ return [
1716
+ hole.name,
1717
+ { ...base, ppr: true, fill: mode === "settled" ? await body : defer(body) },
1718
+ ]
1719
+ }
1720
+ if (mode === "deferred") {
1721
+ const value = run.then((outcome) => {
1722
+ if (outcome.ok) return outcome.value
1723
+ // The request's own abort reason, so the stream sees a client that left, not a failure.
1724
+ throw signal.aborted
1725
+ ? signal.reason
1726
+ : new Error(`[nifra/web] ppr: hole "${hole.name}" is unavailable`)
1727
+ })
1728
+ return [hole.name, { ...base, data: defer(value) }]
1729
+ }
1730
+ const outcome = await run
1731
+ if (!outcome.ok)
1732
+ return [hole.name, { ...base, status: "error", error: HOLE_ERROR_STATE }]
1733
+ return [
1734
+ hole.name,
1735
+ outcome.value === undefined
1736
+ ? { ...base, status: "ready" }
1737
+ : { ...base, status: "ready", data: outcome.value },
1738
+ ]
1739
+ },
1740
+ ),
1741
+ )
1742
+ if (live !== undefined) void Promise.all(settles).then(() => live.done(loaded))
1743
+ return { ...Object.fromEntries(entries), ...keptStates }
1744
+ },
1745
+
1746
+ async invalidateTag(tag) {
1747
+ invalidations++
1748
+ if (builds.size > 0 || holeBuilds.size > 0) invalidatedAt.set(tag, invalidations)
1749
+ for (const [key, entry] of settled) if (entry.tags.includes(tag)) evict(key)
1750
+ await shared?.invalidateTag?.(tag)
1751
+ },
1752
+
1753
+ cancel(token) {
1754
+ const abort = live.get(token)
1755
+ if (abort === undefined) return false
1756
+ abort(new DOMException("PPR cancelled by the client", "AbortError"))
1757
+ return true
1758
+ },
1759
+
1760
+ stats() {
1761
+ return { ...admission.stats(), slowAborted, unsentBytes }
1762
+ },
1763
+ }
1764
+ }