@nifrajs/web 3.3.0 → 4.0.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 (407) 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 +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  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 +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  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/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
package/src/isr.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
8
8
  */
9
9
  import { isDraftEnabled } from "./draft.ts"
10
- import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
10
+ import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
11
11
 
12
12
  /** A cached SSR response - the bytes + metadata a {@link CacheStore} persists. */
13
13
  export interface CachedResponse {
@@ -51,7 +51,7 @@ function normalizeTag(tag: string): string {
51
51
  return tag
52
52
  }
53
53
 
54
- function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
54
+ export function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
55
55
  if (tags === undefined) return []
56
56
  if (!Array.isArray(tags) || tags.length > MAX_ISR_TAGS) {
57
57
  throw new TypeError("[nifra/web] ISR tags must contain at most 32 bounded tokens")
@@ -61,7 +61,8 @@ function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
61
61
  return Object.freeze([...unique])
62
62
  }
63
63
 
64
- function tagsFromHeader(value: string | null): readonly string[] {
64
+ /** The route tags a response carries on {@link ISR_REVALIDATE_TAGS_HEADER}; malformed means none. */
65
+ export function tagsFromHeader(value: string | null): readonly string[] {
65
66
  if (value === null || value.trim() === "") return []
66
67
  try {
67
68
  return normalizeTags(value.split(",").map((tag) => tag.trim()))
@@ -78,6 +79,51 @@ export function serializeISRTags(tags: readonly string[]): string | undefined {
78
79
  return normalized.length === 0 ? undefined : normalized.join(",")
79
80
  }
80
81
 
82
+ /** What a `revalidateTags` function is given: the URL and route params only, both already public. */
83
+ export interface RevalidateTagsInput {
84
+ readonly params: Readonly<Record<string, string>>
85
+ readonly url: URL
86
+ }
87
+
88
+ /**
89
+ * A route's `revalidateTags`: a fixed list, or one computed per request from its params and URL. The
90
+ * function is declared as a method so a route may annotate its own params (`{ params: { id: string } }`).
91
+ */
92
+ export type RevalidateTags =
93
+ | readonly string[]
94
+ | { tags(input: RevalidateTagsInput): readonly string[] }["tags"]
95
+
96
+ const warnedTagRoutes = new Set<string>()
97
+
98
+ /**
99
+ * The tags `declared` gives this request. A fixed list is returned as declared (it is validated when
100
+ * the header is written). A function's result keeps its valid, distinct tags, at most 32; anything
101
+ * else is dropped with one warning per route, naming the route but never the tag.
102
+ */
103
+ export function routeTags(
104
+ declared: RevalidateTags,
105
+ input: RevalidateTagsInput,
106
+ routeId: string,
107
+ ): readonly string[] {
108
+ if (typeof declared !== "function") return declared
109
+ const result: unknown = declared(input)
110
+ const listed: readonly unknown[] = Array.isArray(result) ? result : []
111
+ const kept = new Set<string>()
112
+ let dropped = 0
113
+ for (const tag of listed) {
114
+ if (typeof tag === "string" && ISR_TAG_PATTERN.test(tag) && kept.size < MAX_ISR_TAGS) {
115
+ kept.add(tag)
116
+ } else dropped++
117
+ }
118
+ if ((dropped > 0 || !Array.isArray(result)) && !warnedTagRoutes.has(routeId)) {
119
+ warnedTagRoutes.add(routeId)
120
+ console.warn(
121
+ `[nifra/web] NIFRA_CDN_TAG_INVALID: revalidateTags of route "${routeId}" returned ${Array.isArray(result) ? `${dropped} tag(s) that are not a letter followed by up to 127 of A-Z a-z 0-9 . _ : / -, or past the first 32` : "something other than an array"}; those were dropped.`,
122
+ )
123
+ }
124
+ return [...kept]
125
+ }
126
+
81
127
  export interface MemoryCacheStoreOptions {
82
128
  /** Allow the in-memory store in production. Off by default - per-instance caching means revalidation
83
129
  * won't propagate across instances and each instance caches separately. */
@@ -389,6 +435,34 @@ export const ISR_REVALIDATE_TAGS_HEADER = "x-nifra-isr-tags"
389
435
  */
390
436
  export const ISR_REVALIDATE_HEADER = "x-nifra-isr-revalidate"
391
437
 
438
+ /** A `createWebApp` app answers this with a function that turns its ISR headers on. A wrapper that
439
+ * reads them calls it, so an app nothing caches never sends a route's freshness or tags. */
440
+ export const CACHE_CHANNEL: unique symbol = Symbol.for("nifra.web.cacheChannel")
441
+
442
+ /** Ask `app` to emit its ISR headers; an app without the channel emits whatever it emits. */
443
+ export function openCacheChannel(app: object): void {
444
+ const open: unknown = Reflect.get(app, CACHE_CHANNEL)
445
+ if (typeof open === "function") open()
446
+ }
447
+
448
+ /** Drop the ISR headers from a response that leaves the cache layer. */
449
+ export function withoutCacheChannel(res: Response): Response {
450
+ if (!res.headers.has(ISR_REVALIDATE_HEADER) && !res.headers.has(ISR_REVALIDATE_TAGS_HEADER)) {
451
+ return res
452
+ }
453
+ try {
454
+ res.headers.delete(ISR_REVALIDATE_HEADER)
455
+ res.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
456
+ return res
457
+ } catch {
458
+ // A `fetch()` response has immutable headers: rebuild it around the same body instead.
459
+ const headers = new Headers(res.headers)
460
+ headers.delete(ISR_REVALIDATE_HEADER)
461
+ headers.delete(ISR_REVALIDATE_TAGS_HEADER)
462
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
463
+ }
464
+ }
465
+
392
466
  export interface ISROptions {
393
467
  readonly store: CacheStore
394
468
  /** Default freshness window (**seconds**) for a cached page; older ⇒ stale (served, regenerated
@@ -397,21 +471,68 @@ export interface ISROptions {
397
471
  readonly revalidate: number
398
472
  /** Monotonic clock (ms) - injected for testability; production passes `() => Date.now()`. */
399
473
  readonly now: () => number
400
- /** Cache key for a request. Default: `origin + pathname + search` so host-routed apps do not share
401
- * entries across tenants. Return `null` to bypass the cache for this request (it goes straight to the
402
- * app, uncached). */
474
+ /** Cache key for a request. Default: `origin + pathname`, plus the query parameters `query` admits,
475
+ * so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
476
+ * this request (it goes straight to the app, uncached). Replaces `query` entirely. */
403
477
  readonly key?: (req: Request) => string | null
478
+ /**
479
+ * How the query string reaches the default key. Default `"bypass"`: a request carrying any query
480
+ * parameter skips the cache - rendered fresh, never stored - so `?a=1`, `?a=2`, ... cannot each
481
+ * store a full page. A list caches by those parameters only, in any order (`?b=2&a=1` and
482
+ * `?a=1&b=2` share an entry); a request carrying any other parameter still bypasses, because a
483
+ * loader that reads it would otherwise be served another request's page. `"all"` keys on the whole
484
+ * query string, so every distinct query is a new entry.
485
+ */
486
+ readonly query?: ISRQuery
404
487
  /** Draft/preview secret (the same one given to `createWebApp({ draftSecret })` + `enableDraft`). When
405
488
  * set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
406
489
  * fresh, and a draft render is never written to the store (it can't poison the public cache). */
407
490
  readonly draftSecret?: string
408
491
  }
409
492
 
410
- const defaultKey = (req: Request): string => {
411
- const url = new URL(req.url)
412
- return url.origin + url.pathname + url.search
493
+ /** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
494
+ * through the global symbol registry so a cache wrapper never imports the CSP module. */
495
+ const DOCUMENT_POLICY = Symbol.for("nifra.web.documentPolicy")
496
+
497
+ /** Remembered `no-store` keys per `withISR` wrapper. */
498
+ const MAX_UNCACHEABLE_KEYS = 1024
499
+
500
+ /** Which query parameters an ISR key carries - see {@link ISROptions.query}. */
501
+ export type ISRQuery = "bypass" | "all" | readonly string[]
502
+
503
+ /** The default key for a URL under a `query` policy, or `null` when the URL must bypass the cache. */
504
+ export const urlKeyOf = (query: ISRQuery = "bypass"): ((url: URL) => string | null) => {
505
+ if (query === "all") return (url) => url.origin + url.pathname + url.search
506
+ if (query !== "bypass" && !Array.isArray(query)) {
507
+ throw new TypeError(
508
+ '[nifra/web] ISR query must be "bypass", "all", or a list of parameter names',
509
+ )
510
+ }
511
+ const allowed = new Set<string>(query === "bypass" ? [] : query)
512
+ for (const name of allowed) {
513
+ if (typeof name !== "string" || name === "") {
514
+ throw new TypeError("[nifra/web] ISR query parameter names must be non-empty strings")
515
+ }
516
+ }
517
+ return (url) => {
518
+ if (url.search === "") return url.origin + url.pathname
519
+ if (allowed.size === 0) return null
520
+ const entries: [string, string][] = []
521
+ for (const entry of url.searchParams) {
522
+ if (!allowed.has(entry[0])) return null
523
+ entries.push(entry)
524
+ }
525
+ // Stable by name, so a repeated name keeps its value order (`getAll` can tell them apart).
526
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
527
+ return `${url.origin}${url.pathname}?${new URLSearchParams(entries)}`
528
+ }
413
529
  }
414
530
 
531
+ const requestKeyOf =
532
+ (urlKey: (url: URL) => string | null) =>
533
+ (req: Request): string | null =>
534
+ urlKey(new URL(req.url))
535
+
415
536
  function assertRevalidate(value: number, name: string): void {
416
537
  if (!Number.isFinite(value) || value < 0) {
417
538
  throw new RangeError(`[nifra/web] ${name} must be a finite non-negative number`)
@@ -439,12 +560,34 @@ const requestCarriesPrivateState = (req: Request): boolean =>
439
560
  const responseIsExplicitlyPublic = (res: Response): boolean =>
440
561
  cacheControlHas(res.headers, ["public"])
441
562
 
442
- const isCacheablePage = (req: Request, res: Response): boolean => {
563
+ // `x-nifra-data` alone is not a variation the URL key misses: a navigation data request never reads
564
+ // or writes the store, so every stored entry is the document.
565
+ const responseDeclaresVary = (res: Response): boolean => {
566
+ const value = res.headers.get("vary")
567
+ if (value === null) return false
568
+ for (const part of value.split(",")) {
569
+ const name = part.trim().toLowerCase()
570
+ if (name !== "" && name !== "x-nifra-data") return true
571
+ }
572
+ return false
573
+ }
574
+
575
+ /**
576
+ * Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
577
+ * full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
578
+ * the data header, and, for a request carrying a cookie or Authorization, an explicit `public`.
579
+ * The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
580
+ */
581
+ export const isCacheablePage = (req: Request, res: Response): boolean => {
443
582
  if (req.method !== "GET") return false
444
583
  if (req.headers.get("x-nifra-data") !== null) return false
445
584
  if (res.status !== 200) return false
446
585
  if (!(res.headers.get("content-type") ?? "").includes("text/html")) return false
447
586
  if (hasSetCookie(res.headers)) return false
587
+ // The default ISR key is URL-only. A response that varies on request headers must either use a
588
+ // caller-supplied key that includes those headers or bypass ISR; otherwise one language/tenant
589
+ // representation can be served to every request for the URL.
590
+ if (responseDeclaresVary(res)) return false
448
591
  if (cacheControlHas(res.headers, ["private", "no-store"])) return false
449
592
  // Cookie/Authorization requests often personalize HTML without setting a new cookie. Cache them only
450
593
  // when the route explicitly declares the response public.
@@ -452,22 +595,46 @@ const isCacheablePage = (req: Request, res: Response): boolean => {
452
595
  return true
453
596
  }
454
597
 
598
+ // Keep representation and security policy headers, plus `Vary` for legacy entries. Transport,
599
+ // request-specific, framework-control, and unknown internal headers stay out of the shared cache.
455
600
  const CACHEABLE_RESPONSE_HEADERS = new Set([
456
601
  "cache-control",
457
602
  "content-language",
603
+ "content-security-policy",
458
604
  "content-type",
605
+ "cross-origin-embedder-policy",
606
+ "cross-origin-opener-policy",
607
+ "cross-origin-resource-policy",
459
608
  "etag",
460
609
  "last-modified",
461
610
  "link",
611
+ "origin-agent-cluster",
612
+ "permissions-policy",
613
+ "referrer-policy",
614
+ "strict-transport-security",
615
+ "vary",
616
+ "x-content-type-options",
617
+ "x-frame-options",
618
+ "x-robots-tag",
462
619
  ])
463
620
 
621
+ const isCacheableResponseHeader = (key: string): boolean => {
622
+ const normalized = key.toLowerCase()
623
+ return (
624
+ CACHEABLE_RESPONSE_HEADERS.has(normalized) ||
625
+ normalized.startsWith("content-security-policy-") ||
626
+ normalized.startsWith("cross-origin-") ||
627
+ normalized.startsWith("permissions-policy-")
628
+ )
629
+ }
630
+
464
631
  const tagsOf = (res: Response): readonly string[] =>
465
632
  tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
466
633
 
467
634
  const headersOf = (res: Response): Record<string, string> => {
468
635
  const out: Record<string, string> = {}
469
636
  res.headers.forEach((value, key) => {
470
- if (CACHEABLE_RESPONSE_HEADERS.has(key)) out[key] = value
637
+ if (isCacheableResponseHeader(key)) out[key] = value
471
638
  })
472
639
  return out
473
640
  }
@@ -481,12 +648,14 @@ const responseFrom = (entry: CachedResponse, status: "hit" | "stale"): Response
481
648
  function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
482
649
  const out: Record<string, string> = {}
483
650
  for (const [key, value] of Object.entries(headers)) {
484
- const normalized = key.toLowerCase()
485
- if (CACHEABLE_RESPONSE_HEADERS.has(normalized)) out[normalized] = value
651
+ if (isCacheableResponseHeader(key)) out[key.toLowerCase()] = value
486
652
  }
487
653
  return out
488
654
  }
489
655
 
656
+ const isInvalidationResponse = (res: Response): boolean =>
657
+ res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400)
658
+
490
659
  /**
491
660
  * Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
492
661
  * {@link CacheStore} when fresh, served **stale while a fresh copy regenerates in the background**
@@ -504,9 +673,47 @@ export function withISR(
504
673
  ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
505
674
  const { store, now } = options
506
675
  assertRevalidate(options.revalidate, "revalidate")
507
- const keyOf = options.key ?? defaultKey
676
+ const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
508
677
  const draftSecret = options.draftSecret
509
678
  const regenerating = new Set<string>()
679
+ openCacheChannel(app)
680
+ // An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
681
+ // much freshness they have left and their tags, so the CDN never holds a page longer than ISR would.
682
+ let channelOpen = false
683
+ const advertise = (headers: Headers, remainingMs: number, tags: readonly string[]): void => {
684
+ if (!channelOpen) return
685
+ headers.set(ISR_REVALIDATE_HEADER, String(Math.max(0, Math.floor(remainingMs / 1000))))
686
+ const serialized = tags.length === 0 ? undefined : tags.join(",")
687
+ if (serialized !== undefined) headers.set(ISR_REVALIDATE_TAGS_HEADER, serialized)
688
+ }
689
+ const served = (entry: CachedResponse, status: "hit" | "stale"): Response => {
690
+ const res = responseFrom(entry, status)
691
+ advertise(
692
+ res.headers,
693
+ status === "stale" ? 0 : entry.revalidate - (now() - entry.storedAt),
694
+ entry.tags ?? [],
695
+ )
696
+ return res
697
+ }
698
+ if ((app as unknown as Record<symbol, unknown>)[DOCUMENT_POLICY] === "nonce") {
699
+ console.warn(
700
+ "[nifra/web] withISR wraps an app whose every document carries a CSP nonce. Such documents are " +
701
+ "`private, no-store`, so this cache will never store a page. Use " +
702
+ "`createWebApp({ csp: createCspPolicy(...) })` instead of `nonce` to cache pages under a CSP.",
703
+ )
704
+ }
705
+ // Keys whose last render declared itself `private` or `no-store`, with when that memo expires. A
706
+ // remembered key skips the store lookup (a network round trip on a shared store) and renders fresh;
707
+ // a render that turns out cacheable is stored and forgets the key. So the memo can only cost a cache
708
+ // hit, never serve the wrong page. Bounded, oldest first, so a URL flood cannot grow it.
709
+ const uncacheable = new Map<string, number>()
710
+ const rememberUncacheable = (key: string, res: Response): void => {
711
+ if (options.revalidate === 0 || !cacheControlHas(res.headers, ["private", "no-store"])) return
712
+ if (uncacheable.size >= MAX_UNCACHEABLE_KEYS) {
713
+ uncacheable.delete(uncacheable.keys().next().value as string)
714
+ }
715
+ uncacheable.set(key, now() + options.revalidate * 1000)
716
+ }
510
717
 
511
718
  // Per-page TTL (ms): the app's `x-nifra-isr-revalidate` header (seconds) if present, else the default.
512
719
  const ttlMs = (res: Response): number => {
@@ -522,7 +729,11 @@ export function withISR(
522
729
  key: string,
523
730
  ): Promise<Response> => {
524
731
  const res = await app.fetch(req, platform)
525
- if (!isCacheablePage(req, res)) return res
732
+ if (!isCacheablePage(req, res)) {
733
+ rememberUncacheable(key, res)
734
+ return withoutCacheChannel(res)
735
+ }
736
+ uncacheable.delete(key)
526
737
  const body = await res.text()
527
738
  const tags = tagsOf(res)
528
739
  const entry: CachedResponse = {
@@ -534,10 +745,12 @@ export function withISR(
534
745
  ...(tags.length === 0 ? {} : { tags }),
535
746
  }
536
747
  await store.set(key, entry)
537
- return new Response(body, {
748
+ const fresh = new Response(body, {
538
749
  status: res.status,
539
750
  headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
540
751
  })
752
+ advertise(fresh.headers, entry.revalidate, tags)
753
+ return fresh
541
754
  }
542
755
 
543
756
  // Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
@@ -561,6 +774,11 @@ export function withISR(
561
774
  revalidate: ttlMs(res),
562
775
  ...(tags.length === 0 ? {} : { tags }),
563
776
  })
777
+ } else if (isInvalidationResponse(res)) {
778
+ // A successful deletion or redirect is authoritative. Keeping the old entry would make a
779
+ // withdrawn page stay publicly visible forever because every stale request would retry the same
780
+ // non-cacheable response while continuing to serve the old body.
781
+ await store.delete(key)
564
782
  } else {
565
783
  await res.body?.cancel()
566
784
  }
@@ -571,35 +789,68 @@ export function withISR(
571
789
  }
572
790
  }
573
791
 
574
- return async (req, platform) => {
792
+ const handler = async (req: Request, platform?: ISRPlatform): Promise<Response> => {
575
793
  // A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
576
794
  // by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
577
795
  // the loader payload. (The write path already refuses to cache data-mode responses.)
578
796
  const key = req.method === "GET" && req.headers.get("x-nifra-data") === null ? keyOf(req) : null
579
- if (key === null) return app.fetch(req, platform)
797
+ if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
580
798
 
581
799
  // Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
582
800
  // unpublished content can't leak into the public cache (and the editor isn't served a stale page).
583
801
  if (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret))) {
584
- return app.fetch(req, platform)
802
+ return withoutCacheChannel(await app.fetch(req, platform))
803
+ }
804
+
805
+ const until = uncacheable.get(key)
806
+ if (until !== undefined) {
807
+ if (now() < until) return render(req, platform, key)
808
+ uncacheable.delete(key)
585
809
  }
586
810
 
587
811
  const hit = await store.get(key)
588
812
  if (hit !== undefined) {
589
- if (now() - hit.storedAt < hit.revalidate) return responseFrom(hit, "hit")
813
+ if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
590
814
  // Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
591
815
  const task = regenerate(req, platform, key)
592
816
  if (typeof platform?.waitUntil === "function") platform.waitUntil(task)
593
817
  else void task.catch(() => {})
594
- return responseFrom(hit, "stale")
818
+ return served(hit, "stale")
595
819
  }
596
820
  return render(req, platform, key)
597
821
  }
822
+ Object.defineProperty(handler, CACHE_CHANNEL, {
823
+ value: () => {
824
+ channelOpen = true
825
+ },
826
+ })
827
+ return handler
598
828
  }
599
829
 
600
830
  const jsonError = (status: number, error: string): Response =>
601
831
  Response.json({ ok: false, error }, { status })
602
832
 
833
+ /** What a CDN purge came to: sent and accepted, queued behind a rate limit or retry, or refused. */
834
+ export interface CdnPurgeOutcome {
835
+ readonly cdn: "accepted" | "queued" | "failed"
836
+ /** Whether the same purge may succeed if sent again. */
837
+ readonly retryable: boolean
838
+ /** A short reason when it did not succeed (never the provider's credentials). */
839
+ readonly error?: string
840
+ }
841
+
842
+ /** A CDN a revalidation purges after the origin store; every `@nifrajs/web/cdn` provider is one. */
843
+ export interface CdnPurgeTarget {
844
+ purge(
845
+ target: { readonly tags?: readonly string[]; readonly paths?: readonly string[] },
846
+ platform?: ISRPlatform,
847
+ ): Promise<CdnPurgeOutcome>
848
+ }
849
+
850
+ /** Most paths and tags one revalidation request may name, so a leaked token cannot fan out. */
851
+ export const MAX_REVALIDATE_PATHS = 100
852
+ export const MAX_REVALIDATE_TAGS = MAX_ISR_TAGS
853
+
603
854
  export interface RevalidateEndpointOptions {
604
855
  readonly store: CacheStore
605
856
  /** Shared secret; the request's token must match it (constant-time). */
@@ -609,22 +860,46 @@ export interface RevalidateEndpointOptions {
609
860
  /** Map a to-purge path → its cache key - MUST match the `withISR` `key` fn. The default uses the
610
861
  * revalidation request's origin plus the purged path, matching `withISR`'s default host-aware key. */
611
862
  readonly key?: (path: string, req: Request) => string
863
+ /** The `query` policy given to `withISR`, so a purged path's query string is keyed the same way.
864
+ * Default `"bypass"`. A path whose query `withISR` never caches is refused with `400`. Ignored
865
+ * when `key` is supplied. */
866
+ readonly query?: ISRQuery
867
+ /**
868
+ * A CDN to purge after the origin store (a `@nifrajs/web/cdn` provider). The origin goes first so a
869
+ * CDN refetch cannot repopulate from a stale origin entry. The reply is `200` when the CDN accepted
870
+ * the purge, `202` when it is queued (rate limit or retry), and `502` when the CDN refused it.
871
+ */
872
+ readonly cdn?: CdnPurgeTarget
873
+ }
874
+
875
+ const stringList = (value: unknown): string[] | null | undefined => {
876
+ if (value === undefined) return undefined
877
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null
878
+ return value
612
879
  }
613
880
 
614
881
  /**
615
- * An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
882
+ * An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops cached pages by path
616
883
  * or invalidates every entry carrying a tag. `POST` with the secret in the token header and either
617
- * `?path=/blog/x`, `?tag=products`, or a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body.
884
+ * `?path=/blog/x`, `?tag=products`, a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body, or a
885
+ * batch `{ "paths": [...], "tags": [...] }` of at most 100 paths and 32 tags.
618
886
  * The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
619
887
  * non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
620
888
  * `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
621
889
  */
622
890
  export function revalidateEndpoint(
623
891
  options: RevalidateEndpointOptions,
624
- ): (req: Request) => Promise<Response> {
892
+ ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
893
+ // An empty secret matches a request that omits the header (`"" === ""`), so an unset env var passed
894
+ // through `?? ""` would open the purge to anyone. Refuse it at construction instead.
895
+ assertTokenSecret(options.secret, "revalidateEndpoint")
625
896
  const tokenHeader = options.tokenHeader ?? "x-nifra-revalidate-token"
626
- const keyOf = options.key ?? ((path: string, req: Request) => new URL(req.url).origin + path)
627
- return async (req) => {
897
+ const urlKey = options.key === undefined ? urlKeyOf(options.query) : undefined
898
+ // Joined as text, never resolved against the origin: `//host/x` must stay a path on this origin.
899
+ const keyOf =
900
+ options.key ??
901
+ ((path: string, req: Request) => urlKey?.(new URL(new URL(req.url).origin + path)) ?? null)
902
+ return async (req, platform) => {
628
903
  if (req.method !== "POST") return jsonError(405, "method_not_allowed")
629
904
  if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
630
905
  return jsonError(401, "unauthorized")
@@ -632,29 +907,81 @@ export function revalidateEndpoint(
632
907
  const url = new URL(req.url)
633
908
  let path = url.searchParams.get("path")
634
909
  let tag = url.searchParams.get("tag")
910
+ let batch: { paths: string[]; tags: string[] } | undefined
635
911
  if (path === null && tag === null) {
636
912
  const body: unknown = await req.json().catch(() => null)
637
913
  if (typeof body === "object" && body !== null) {
638
- const candidate = body as { path?: unknown; tag?: unknown }
639
- path = typeof candidate.path === "string" ? candidate.path : null
640
- tag = typeof candidate.tag === "string" ? candidate.tag : null
914
+ const candidate = body as { path?: unknown; tag?: unknown; paths?: unknown; tags?: unknown }
915
+ if (candidate.paths !== undefined || candidate.tags !== undefined) {
916
+ const paths = stringList(candidate.paths)
917
+ const tags = stringList(candidate.tags)
918
+ if (paths === null || tags === null) return jsonError(400, "invalid_batch")
919
+ if ((paths?.length ?? 0) > MAX_REVALIDATE_PATHS) return jsonError(400, "too_many_paths")
920
+ if ((tags?.length ?? 0) > MAX_REVALIDATE_TAGS) return jsonError(400, "too_many_tags")
921
+ batch = { paths: paths ?? [], tags: tags ?? [] }
922
+ if (batch.paths.length + batch.tags.length === 0) return jsonError(400, "empty_batch")
923
+ } else {
924
+ path = typeof candidate.path === "string" ? candidate.path : null
925
+ tag = typeof candidate.tag === "string" ? candidate.tag : null
926
+ }
641
927
  }
642
928
  }
643
- if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
644
- if (tag !== null) {
929
+ if (batch === undefined) {
930
+ if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
931
+ if (path === null && tag === null) return jsonError(400, "invalid_path")
932
+ batch = { paths: path === null ? [] : [path], tags: tag === null ? [] : [tag] }
933
+ }
934
+
935
+ const tags = [...new Set(batch.tags)]
936
+ for (const candidate of tags) {
645
937
  try {
646
- normalizeTag(tag)
938
+ normalizeTag(candidate)
647
939
  } catch {
648
940
  return jsonError(400, "invalid_tag")
649
941
  }
650
- if (options.store.invalidateTag === undefined) {
651
- return jsonError(501, "tag_invalidation_not_supported")
942
+ }
943
+ const paths = [...new Set(batch.paths)]
944
+ const keys: string[] = []
945
+ for (const candidate of paths) {
946
+ if (!candidate.startsWith("/")) return jsonError(400, "invalid_path")
947
+ const key = keyOf(candidate, req)
948
+ if (key === null) return jsonError(400, "uncached_query")
949
+ keys.push(key)
950
+ }
951
+ const invalidateTag = options.store.invalidateTag
952
+ if (tags.length > 0 && invalidateTag === undefined) {
953
+ return jsonError(501, "tag_invalidation_not_supported")
954
+ }
955
+
956
+ for (const key of keys) await options.store.delete(key)
957
+ for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
958
+ const done =
959
+ path !== null
960
+ ? { revalidated: path }
961
+ : tag !== null
962
+ ? { revalidatedTag: tag }
963
+ : { revalidated: paths, revalidatedTags: tags }
964
+ if (options.cdn === undefined) return Response.json(done)
965
+
966
+ let outcome: CdnPurgeOutcome
967
+ try {
968
+ outcome = await options.cdn.purge({ tags, paths }, platform)
969
+ } catch {
970
+ outcome = { cdn: "failed", retryable: true, error: "cdn_purge_threw" }
971
+ }
972
+ if (outcome.cdn === "failed") {
973
+ const failure: Record<string, unknown> = {
974
+ ok: false,
975
+ error: "cdn_purge_failed",
976
+ origin: "done",
977
+ retryable: outcome.retryable,
652
978
  }
653
- await options.store.invalidateTag(tag)
654
- return Response.json({ revalidatedTag: tag })
979
+ if (outcome.error !== undefined) failure.reason = outcome.error
980
+ return Response.json(failure, { status: 502 })
655
981
  }
656
- if (path === null || !path.startsWith("/")) return jsonError(400, "invalid_path")
657
- await options.store.delete(keyOf(path, req))
658
- return Response.json({ revalidated: path })
982
+ return Response.json(
983
+ { ...done, cdn: outcome.cdn },
984
+ { status: outcome.cdn === "queued" ? 202 : 200 },
985
+ )
659
986
  }
660
987
  }