@nifrajs/web 3.5.0 → 4.0.2

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 (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The provider-neutral half of `@nifrajs/web/cdn`: the path tag every cached page carries, and the
3
+ * purge queue each provider runs its API calls through (coalescing, chunking, `Retry-After`).
4
+ */
5
+ import type { CdnPurgeOutcome, CdnPurgeTarget, ISRPlatform } from "../../isr.ts"
6
+
7
+ /** What a cacheable page tells the CDN: its tags and how long the CDN may serve it. */
8
+ export interface CdnCacheInput {
9
+ readonly tags: readonly string[]
10
+ /** Seconds the CDN may serve the page as fresh. */
11
+ readonly maxAge: number
12
+ /** Seconds past `maxAge` the CDN may serve it while it refetches. */
13
+ readonly staleWhileRevalidate: number
14
+ /** Seconds past `maxAge` the CDN may serve it while the origin errors. */
15
+ readonly staleIfError?: number | undefined
16
+ }
17
+
18
+ /** A CDN nifra can tag pages for and purge. Build one with {@link defineCdnProvider}. */
19
+ export interface CdnProvider extends CdnPurgeTarget {
20
+ readonly name: string
21
+ /** Most tags one response may carry on this CDN. */
22
+ readonly maxResponseTags: number
23
+ /** Headers that let the CDN store a page under `input.tags` (consumed by the CDN, not the browser). */
24
+ cacheHeaders(input: CdnCacheInput): Readonly<Record<string, string>>
25
+ /** Headers that keep the CDN from storing a page, whatever its rules say. */
26
+ noStoreHeaders(): Readonly<Record<string, string>>
27
+ }
28
+
29
+ /** One purge API call's result. A refusal is `retryable` when sending it again may succeed. */
30
+ export type PurgeAttempt =
31
+ | { readonly ok: true }
32
+ | {
33
+ readonly ok: false
34
+ readonly retryable: boolean
35
+ readonly reason: string
36
+ readonly retryAfterMs?: number | undefined
37
+ readonly rateLimited?: boolean | undefined
38
+ }
39
+
40
+ /** What a provider file supplies; {@link defineCdnProvider} adds the queue. */
41
+ export interface CdnProviderDefinition {
42
+ readonly name: string
43
+ /** Most tags one purge API call may name. */
44
+ readonly tagsPerCall: number
45
+ readonly maxResponseTags: number
46
+ cacheHeaders(input: CdnCacheInput): Readonly<Record<string, string>>
47
+ noStoreHeaders(): Readonly<Record<string, string>>
48
+ purgeTags(tags: readonly string[]): Promise<PurgeAttempt>
49
+ }
50
+
51
+ /** A purge that did not go through, as reported to {@link PurgeQueueOptions.onError}. */
52
+ export interface CdnPurgeError {
53
+ readonly code: "NIFRA_CDN_PURGE_FAILED" | "NIFRA_CDN_RATE_LIMITED"
54
+ readonly provider: string
55
+ readonly reason: string
56
+ /** How many tags the refused call named (never the tags or the credentials). */
57
+ readonly tags: number
58
+ /** Whether the queue gave up on them. */
59
+ readonly final: boolean
60
+ }
61
+
62
+ export interface PurgeQueueOptions {
63
+ /** Tags arriving within this window go out in one call (ms). Default 250. */
64
+ readonly debounceMs?: number
65
+ /** Attempts per call before a retryable refusal is given up on. Default 5. */
66
+ readonly maxAttempts?: number
67
+ /** Longest wait between attempts, `Retry-After` included (ms). Default 60000. */
68
+ readonly maxBackoffMs?: number
69
+ /** Most tags waiting or retrying at once; a purge past it is refused as retryable. Default 5000. */
70
+ readonly maxQueuedTags?: number
71
+ /** Where a refused purge is reported. Default: one `console.error` line per refusal. */
72
+ readonly onError?: (error: CdnPurgeError) => void
73
+ /** Timer for the debounce and the backoff; tests pass their own. */
74
+ readonly sleep?: (ms: number) => Promise<void>
75
+ }
76
+
77
+ const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"
78
+
79
+ function base32(bytes: Uint8Array): string {
80
+ let out = ""
81
+ let bits = 0
82
+ let value = 0
83
+ for (const byte of bytes) {
84
+ value = (value << 8) | byte
85
+ bits += 8
86
+ while (bits >= 5) {
87
+ out += BASE32[(value >>> (bits - 5)) & 31]
88
+ bits -= 5
89
+ }
90
+ }
91
+ if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]
92
+ return out
93
+ }
94
+
95
+ /**
96
+ * The tag every page a CDN stores carries for its own path, so a path purge works on CDNs that only
97
+ * purge by tag. It hashes the normalized pathname alone: the query and the host stay out, so one purge
98
+ * reaches every query variant and needs no knowledge of the public origin. Two hosts serving the same
99
+ * path share the tag, which can only make a purge reach more than asked, never serve a wrong page.
100
+ */
101
+ export async function pathTag(path: string): Promise<string> {
102
+ const cut = path.search(/[?#]/)
103
+ // Joined as text, never resolved: `//host/x` stays a path, as the revalidation endpoint keys it.
104
+ const pathname = new URL(`http://n${cut === -1 ? path : path.slice(0, cut)}`).pathname
105
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pathname))
106
+ return `nifra.p:${base32(new Uint8Array(digest)).slice(0, 26)}`
107
+ }
108
+
109
+ /** `Retry-After` as milliseconds: delta seconds or an HTTP date; undefined when absent or unreadable. */
110
+ export function retryAfterMs(value: string | null, now = Date.now()): number | undefined {
111
+ if (value === null) return undefined
112
+ const trimmed = value.trim()
113
+ if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000
114
+ const at = Date.parse(trimmed)
115
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now)
116
+ }
117
+
118
+ /** A purge API reply read the same way for every HTTP provider. */
119
+ export function attemptFromStatus(res: Response, reason: string): PurgeAttempt {
120
+ if (res.ok) return { ok: true }
121
+ const retryAfter = retryAfterMs(res.headers.get("retry-after"))
122
+ if (res.status === 429) {
123
+ return {
124
+ ok: false,
125
+ retryable: true,
126
+ rateLimited: true,
127
+ reason: "rate_limited",
128
+ retryAfterMs: retryAfter,
129
+ }
130
+ }
131
+ if (res.status >= 500) {
132
+ return {
133
+ ok: false,
134
+ retryable: true,
135
+ reason: `${reason}_${res.status}`,
136
+ retryAfterMs: retryAfter,
137
+ }
138
+ }
139
+ return { ok: false, retryable: false, reason: `${reason}_${res.status}` }
140
+ }
141
+
142
+ /** `max-age=..., stale-while-revalidate=...[, stale-if-error=...]` for a CDN TTL header. */
143
+ export function cdnDirectives(input: CdnCacheInput): string {
144
+ const parts = [`max-age=${input.maxAge}`, `stale-while-revalidate=${input.staleWhileRevalidate}`]
145
+ if (input.staleIfError !== undefined) parts.push(`stale-if-error=${input.staleIfError}`)
146
+ return parts.join(", ")
147
+ }
148
+
149
+ function positive(value: number | undefined, fallback: number, name: string): number {
150
+ if (value === undefined) return fallback
151
+ if (!Number.isFinite(value) || value < 0) {
152
+ throw new RangeError(`[nifra/web/cdn] ${name} must be a finite non-negative number`)
153
+ }
154
+ return value
155
+ }
156
+
157
+ interface Waiter {
158
+ readonly tags: ReadonlySet<string>
159
+ readonly platform: ISRPlatform | undefined
160
+ readonly resolve: (outcome: CdnPurgeOutcome) => void
161
+ }
162
+
163
+ type TagState =
164
+ | { state: "accepted" }
165
+ | { state: "queued" }
166
+ | { state: "failed"; retryable: boolean; reason: string }
167
+
168
+ /**
169
+ * A {@link CdnProvider} from a provider's API calls, with the purge queue in front: tags arriving
170
+ * within `debounceMs` go out together, chunked to the provider's per-call limit and sent one call at
171
+ * a time; a 429 or 5xx is retried with capped exponential backoff that honors `Retry-After`. A
172
+ * `purge` resolves once its tags' first attempt is answered: `accepted`, `queued` (a retry is
173
+ * pending, kept alive with `platform.waitUntil` on edge runtimes), or `failed`.
174
+ */
175
+ export function defineCdnProvider(
176
+ definition: CdnProviderDefinition,
177
+ options: PurgeQueueOptions = {},
178
+ ): CdnProvider {
179
+ const debounceMs = positive(options.debounceMs, 250, "debounceMs")
180
+ const maxAttempts = Math.max(1, Math.floor(positive(options.maxAttempts, 5, "maxAttempts")))
181
+ const maxBackoffMs = positive(options.maxBackoffMs, 60_000, "maxBackoffMs")
182
+ const maxQueuedTags = positive(options.maxQueuedTags, 5000, "maxQueuedTags")
183
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
184
+ const report =
185
+ options.onError ??
186
+ ((error: CdnPurgeError) =>
187
+ console.error(
188
+ `[nifra/web/cdn] ${error.code}: ${error.provider} refused a purge of ${error.tags} tag(s) (${error.reason})${error.final ? "" : "; retrying"}`,
189
+ ))
190
+
191
+ let pending = new Set<string>()
192
+ let waiters: Waiter[] = []
193
+ let scheduled = false
194
+ let retrying = 0
195
+ // Flushes run one after another, so the provider sees one purge call at a time from this queue.
196
+ let tail: Promise<void> = Promise.resolve()
197
+
198
+ const attempt = async (chunk: readonly string[]): Promise<PurgeAttempt> => {
199
+ try {
200
+ return await definition.purgeTags(chunk)
201
+ } catch {
202
+ // The provider's own error text can quote the request; only the kind leaves this function.
203
+ return { ok: false, retryable: true, reason: "network_error" }
204
+ }
205
+ }
206
+
207
+ const refusal = (
208
+ chunk: readonly string[],
209
+ result: PurgeAttempt & { ok: false },
210
+ final: boolean,
211
+ ) => {
212
+ try {
213
+ report({
214
+ code: result.rateLimited === true ? "NIFRA_CDN_RATE_LIMITED" : "NIFRA_CDN_PURGE_FAILED",
215
+ provider: definition.name,
216
+ reason: result.reason,
217
+ tags: chunk.length,
218
+ final,
219
+ })
220
+ } catch {
221
+ // A reporter that throws must not strand the purge or the callers waiting on it.
222
+ }
223
+ }
224
+
225
+ const retry = async (
226
+ chunk: readonly string[],
227
+ first: PurgeAttempt & { ok: false },
228
+ ): Promise<void> => {
229
+ retrying += chunk.length
230
+ try {
231
+ let last = first
232
+ for (let n = 2; n <= maxAttempts; n++) {
233
+ const backoff = Math.min(maxBackoffMs, 1000 * 2 ** (n - 2))
234
+ await sleep(Math.min(maxBackoffMs, Math.max(backoff, last.retryAfterMs ?? 0)))
235
+ const result = await attempt(chunk)
236
+ if (result.ok) return
237
+ last = result
238
+ if (!result.retryable || n === maxAttempts) {
239
+ refusal(chunk, result, true)
240
+ return
241
+ }
242
+ refusal(chunk, result, false)
243
+ }
244
+ } finally {
245
+ retrying -= chunk.length
246
+ }
247
+ }
248
+
249
+ const flush = async (): Promise<void> => {
250
+ const batch = [...pending]
251
+ const settled = waiters
252
+ pending = new Set()
253
+ waiters = []
254
+ scheduled = false
255
+ const states = new Map<string, TagState>()
256
+ const background: Promise<void>[] = []
257
+ for (let i = 0; i < batch.length; i += definition.tagsPerCall) {
258
+ const chunk = batch.slice(i, i + definition.tagsPerCall)
259
+ const result = await attempt(chunk)
260
+ let state: TagState
261
+ if (result.ok) state = { state: "accepted" }
262
+ else if (result.retryable && maxAttempts > 1) {
263
+ refusal(chunk, result, false)
264
+ background.push(retry(chunk, result))
265
+ state = { state: "queued" }
266
+ } else {
267
+ refusal(chunk, result, true)
268
+ state = { state: "failed", retryable: result.retryable, reason: result.reason }
269
+ }
270
+ for (const tag of chunk) states.set(tag, state)
271
+ }
272
+ const retries = background.length === 0 ? undefined : Promise.all(background)
273
+ for (const waiter of settled) {
274
+ if (retries !== undefined) waiter.platform?.waitUntil?.(retries)
275
+ let failed: (TagState & { state: "failed" }) | undefined
276
+ let queued = false
277
+ for (const tag of waiter.tags) {
278
+ const state = states.get(tag)
279
+ if (state?.state === "failed") failed = failed?.retryable === false ? failed : state
280
+ else if (state?.state === "queued") queued = true
281
+ }
282
+ waiter.resolve(
283
+ failed !== undefined
284
+ ? { cdn: "failed", retryable: failed.retryable, error: failed.reason }
285
+ : queued
286
+ ? { cdn: "queued", retryable: true }
287
+ : { cdn: "accepted", retryable: false },
288
+ )
289
+ }
290
+ }
291
+
292
+ return {
293
+ name: definition.name,
294
+ maxResponseTags: definition.maxResponseTags,
295
+ cacheHeaders: (input) => definition.cacheHeaders(input),
296
+ noStoreHeaders: () => definition.noStoreHeaders(),
297
+ async purge(target, platform) {
298
+ const tags = new Set(target.tags ?? [])
299
+ for (const path of target.paths ?? []) tags.add(await pathTag(path))
300
+ if (tags.size === 0) return { cdn: "accepted", retryable: false }
301
+ if (pending.size + retrying + tags.size > maxQueuedTags) {
302
+ return { cdn: "failed", retryable: true, error: "queue_full" }
303
+ }
304
+ const outcome = new Promise<CdnPurgeOutcome>((resolve) => {
305
+ for (const tag of tags) pending.add(tag)
306
+ waiters.push({ tags, platform, resolve })
307
+ })
308
+ if (!scheduled) {
309
+ scheduled = true
310
+ tail = tail.then(() => sleep(debounceMs)).then(flush)
311
+ platform?.waitUntil?.(tail)
312
+ }
313
+ return outcome
314
+ },
315
+ }
316
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Fastly: `Surrogate-Key` (space-separated) and `Surrogate-Control`, purged in batches of up to 256
3
+ * keys. Soft purge by default, so a purged page is served stale while Fastly refetches it.
4
+ */
5
+ import {
6
+ attemptFromStatus,
7
+ type CdnProvider,
8
+ cdnDirectives,
9
+ defineCdnProvider,
10
+ type PurgeQueueOptions,
11
+ } from "./core.ts"
12
+
13
+ export interface FastlyOptions {
14
+ readonly serviceId: string
15
+ /** A Fastly API token with the `purge_select` scope. Keep it in an env var. */
16
+ readonly apiToken: string
17
+ /** Mark purged pages stale instead of dropping them. Default `true`. */
18
+ readonly soft?: boolean
19
+ readonly queue?: PurgeQueueOptions
20
+ /** The fetch purge calls go through; tests pass their own. */
21
+ readonly fetch?: typeof fetch
22
+ }
23
+
24
+ export function fastly(options: FastlyOptions): CdnProvider {
25
+ if (!/^[A-Za-z0-9]+$/.test(options.serviceId)) {
26
+ throw new TypeError("[nifra/web/cdn] fastly: serviceId must be the service's alphanumeric id")
27
+ }
28
+ if (typeof options.apiToken !== "string" || options.apiToken === "") {
29
+ throw new TypeError("[nifra/web/cdn] fastly: apiToken is empty (is the env var set?)")
30
+ }
31
+ const send = options.fetch ?? fetch
32
+ const endpoint = `https://api.fastly.com/service/${options.serviceId}/purge`
33
+ const soft = options.soft !== false
34
+ return defineCdnProvider(
35
+ {
36
+ name: "fastly",
37
+ tagsPerCall: 256,
38
+ maxResponseTags: 1000,
39
+ cacheHeaders: (input) => ({
40
+ "surrogate-key": input.tags.join(" "),
41
+ "surrogate-control": cdnDirectives(input),
42
+ }),
43
+ noStoreHeaders: () => ({ "surrogate-control": "no-store" }),
44
+ async purgeTags(tags) {
45
+ const headers: Record<string, string> = {
46
+ "fastly-key": options.apiToken,
47
+ "content-type": "application/json",
48
+ accept: "application/json",
49
+ }
50
+ if (soft) headers["fastly-soft-purge"] = "1"
51
+ const res = await send(endpoint, {
52
+ method: "POST",
53
+ headers,
54
+ body: JSON.stringify({ surrogate_keys: tags }),
55
+ })
56
+ await res.body?.cancel()
57
+ return attemptFromStatus(res, "fastly")
58
+ },
59
+ },
60
+ options.queue,
61
+ )
62
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Vercel's CDN: `Vercel-Cache-Tag` and `Vercel-CDN-Cache-Control`, both consumed by Vercel and kept
3
+ * from the browser. Tags are case-sensitive; at most 16 a purge call and 128 a response.
4
+ */
5
+ import {
6
+ attemptFromStatus,
7
+ type CdnProvider,
8
+ cdnDirectives,
9
+ defineCdnProvider,
10
+ type PurgeQueueOptions,
11
+ } from "./core.ts"
12
+
13
+ type TagPurge = (tags: string | string[]) => Promise<unknown>
14
+
15
+ export type VercelOptions = (
16
+ | {
17
+ /** `invalidateByTag` from `@vercel/functions`: inside a Vercel Function, no token needed. */
18
+ readonly invalidateByTag: TagPurge
19
+ /** `dangerouslyDeleteByTag` from `@vercel/functions`, for `mode: "delete"`. */
20
+ readonly dangerouslyDeleteByTag?: TagPurge
21
+ }
22
+ | {
23
+ /** A Vercel access token for the REST API. Keep it in an env var. */
24
+ readonly token: string
25
+ readonly projectId: string
26
+ readonly teamId?: string
27
+ /** Purge one environment only; default both. */
28
+ readonly target?: "production" | "preview"
29
+ /** The fetch purge calls go through; tests pass their own. */
30
+ readonly fetch?: typeof fetch
31
+ }
32
+ ) & {
33
+ /**
34
+ * `invalidate` (default) marks tagged pages stale: the next visitor gets the old page while Vercel
35
+ * refetches. `delete` drops them, so the next visitors wait on the origin together.
36
+ */
37
+ readonly mode?: "invalidate" | "delete"
38
+ readonly queue?: PurgeQueueOptions
39
+ }
40
+
41
+ export function vercel(options: VercelOptions): CdnProvider {
42
+ const mode = options.mode ?? "invalidate"
43
+ let purgeTags: (
44
+ tags: readonly string[],
45
+ ) => ReturnType<Parameters<typeof defineCdnProvider>[0]["purgeTags"]>
46
+ if ("invalidateByTag" in options) {
47
+ const fn = mode === "delete" ? options.dangerouslyDeleteByTag : options.invalidateByTag
48
+ if (typeof fn !== "function") {
49
+ throw new TypeError(
50
+ `[nifra/web/cdn] vercel: pass ${mode === "delete" ? "dangerouslyDeleteByTag" : "invalidateByTag"} from @vercel/functions`,
51
+ )
52
+ }
53
+ purgeTags = async (tags) => {
54
+ await fn([...tags])
55
+ return { ok: true }
56
+ }
57
+ } else {
58
+ if (typeof options.token !== "string" || options.token === "") {
59
+ throw new TypeError("[nifra/web/cdn] vercel: token is empty (is the env var set?)")
60
+ }
61
+ if (typeof options.projectId !== "string" || options.projectId === "") {
62
+ throw new TypeError("[nifra/web/cdn] vercel: projectId is required")
63
+ }
64
+ const send = options.fetch ?? fetch
65
+ const query = new URLSearchParams({ projectIdOrName: options.projectId })
66
+ if (options.teamId !== undefined) query.set("teamId", options.teamId)
67
+ const path = mode === "delete" ? "dangerously-delete-by-tags" : "invalidate-by-tags"
68
+ const endpoint = `https://api.vercel.com/v1/edge-cache/${path}?${query}`
69
+ const token = options.token
70
+ const target = options.target
71
+ purgeTags = async (tags) => {
72
+ const res = await send(endpoint, {
73
+ method: "POST",
74
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
75
+ body: JSON.stringify(target === undefined ? { tags } : { tags, target }),
76
+ })
77
+ await res.body?.cancel()
78
+ return attemptFromStatus(res, "vercel")
79
+ }
80
+ }
81
+ return defineCdnProvider(
82
+ {
83
+ name: "vercel",
84
+ tagsPerCall: 16,
85
+ maxResponseTags: 128,
86
+ cacheHeaders: (input) => ({
87
+ "vercel-cache-tag": input.tags.join(","),
88
+ "vercel-cdn-cache-control": cdnDirectives(input),
89
+ }),
90
+ noStoreHeaders: () => ({ "vercel-cdn-cache-control": "no-store" }),
91
+ purgeTags,
92
+ },
93
+ options.queue,
94
+ )
95
+ }
@@ -0,0 +1,5 @@
1
+ /** Code-unit order, the same on every runtime and in every locale. A build artifact or a report sorts
2
+ * with this, so its bytes and its order do not depend on the machine that produced it. */
3
+ export function codeUnitOrder(a: string, b: string): number {
4
+ return a < b ? -1 : a > b ? 1 : 0
5
+ }