@nifrajs/web 5.5.1 → 5.7.0

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