@nifrajs/web 4.0.0 → 5.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 (388) hide show
  1. package/README.md +11 -0
  2. package/dist/build-vite.d.ts +19 -0
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +31 -10
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +8 -10
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +90 -31
  9. package/dist/build.js.map +1 -1
  10. package/dist/cdn.d.ts +8 -0
  11. package/dist/cdn.d.ts.map +1 -1
  12. package/dist/cdn.js +14 -15
  13. package/dist/cdn.js.map +1 -1
  14. package/dist/client.d.ts +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +49 -26
  17. package/dist/client.js.map +1 -1
  18. package/dist/deferred.d.ts +2 -2
  19. package/dist/deferred.d.ts.map +1 -1
  20. package/dist/deferred.js +28 -7
  21. package/dist/deferred.js.map +1 -1
  22. package/dist/dev-feed.d.ts.map +1 -1
  23. package/dist/dev-feed.js +24 -5
  24. package/dist/dev-feed.js.map +1 -1
  25. package/dist/dev-session.d.ts.map +1 -1
  26. package/dist/dev-session.js +4 -14
  27. package/dist/dev-session.js.map +1 -1
  28. package/dist/dev.d.ts +7 -17
  29. package/dist/dev.d.ts.map +1 -1
  30. package/dist/dev.js +13 -1
  31. package/dist/dev.js.map +1 -1
  32. package/dist/draft.d.ts +8 -5
  33. package/dist/draft.d.ts.map +1 -1
  34. package/dist/draft.js +30 -13
  35. package/dist/draft.js.map +1 -1
  36. package/dist/fonts-google.d.ts.map +1 -1
  37. package/dist/fonts-google.js +7 -0
  38. package/dist/fonts-google.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/codegen.d.ts +1 -0
  44. package/dist/internal/codegen.d.ts.map +1 -1
  45. package/dist/internal/codegen.js +56 -29
  46. package/dist/internal/codegen.js.map +1 -1
  47. package/dist/internal/disposable-dir.d.ts +8 -0
  48. package/dist/internal/disposable-dir.d.ts.map +1 -0
  49. package/dist/internal/disposable-dir.js +25 -0
  50. package/dist/internal/disposable-dir.js.map +1 -0
  51. package/dist/internal/entry-chunks.d.ts +9 -0
  52. package/dist/internal/entry-chunks.d.ts.map +1 -0
  53. package/dist/internal/entry-chunks.js +15 -0
  54. package/dist/internal/entry-chunks.js.map +1 -0
  55. package/dist/internal/evidence-traversal.d.ts +12 -0
  56. package/dist/internal/evidence-traversal.d.ts.map +1 -0
  57. package/dist/internal/evidence-traversal.js +48 -0
  58. package/dist/internal/evidence-traversal.js.map +1 -0
  59. package/dist/internal/head-merge.d.ts +6 -0
  60. package/dist/internal/head-merge.d.ts.map +1 -1
  61. package/dist/internal/head-merge.js +9 -0
  62. package/dist/internal/head-merge.js.map +1 -1
  63. package/dist/internal/js-string.d.ts +2 -0
  64. package/dist/internal/js-string.d.ts.map +1 -1
  65. package/dist/internal/js-string.js +5 -1
  66. package/dist/internal/js-string.js.map +1 -1
  67. package/dist/internal/mount-shadow.js +1 -1
  68. package/dist/internal/mount-shadow.js.map +1 -1
  69. package/dist/internal/output-guard.d.ts +7 -0
  70. package/dist/internal/output-guard.d.ts.map +1 -1
  71. package/dist/internal/output-guard.js +80 -8
  72. package/dist/internal/output-guard.js.map +1 -1
  73. package/dist/internal/page-execution.d.ts +6 -0
  74. package/dist/internal/page-execution.d.ts.map +1 -1
  75. package/dist/internal/page-execution.js +377 -108
  76. package/dist/internal/page-execution.js.map +1 -1
  77. package/dist/internal/progressive-cookies.d.ts +14 -0
  78. package/dist/internal/progressive-cookies.d.ts.map +1 -0
  79. package/dist/internal/progressive-cookies.js +43 -0
  80. package/dist/internal/progressive-cookies.js.map +1 -0
  81. package/dist/internal/public-env.d.ts +2 -0
  82. package/dist/internal/public-env.d.ts.map +1 -0
  83. package/dist/internal/public-env.js +13 -0
  84. package/dist/internal/public-env.js.map +1 -0
  85. package/dist/internal/render-document.d.ts +1 -0
  86. package/dist/internal/render-document.d.ts.map +1 -1
  87. package/dist/internal/render-document.js +28 -5
  88. package/dist/internal/render-document.js.map +1 -1
  89. package/dist/internal/response-controls.d.ts +2 -0
  90. package/dist/internal/response-controls.d.ts.map +1 -1
  91. package/dist/internal/response-controls.js +26 -16
  92. package/dist/internal/response-controls.js.map +1 -1
  93. package/dist/internal/revive-dates.d.ts +16 -0
  94. package/dist/internal/revive-dates.d.ts.map +1 -0
  95. package/dist/internal/revive-dates.js +32 -0
  96. package/dist/internal/revive-dates.js.map +1 -0
  97. package/dist/internal/route-path.d.ts +8 -0
  98. package/dist/internal/route-path.d.ts.map +1 -0
  99. package/dist/internal/route-path.js +8 -0
  100. package/dist/internal/route-path.js.map +1 -0
  101. package/dist/internal/secret-scan.d.ts.map +1 -1
  102. package/dist/internal/secret-scan.js +105 -19
  103. package/dist/internal/secret-scan.js.map +1 -1
  104. package/dist/internal/server-boundary.d.ts.map +1 -1
  105. package/dist/internal/server-boundary.js +33 -5
  106. package/dist/internal/server-boundary.js.map +1 -1
  107. package/dist/internal/web-app.d.ts +21 -16
  108. package/dist/internal/web-app.d.ts.map +1 -1
  109. package/dist/internal/web-app.js +73 -21
  110. package/dist/internal/web-app.js.map +1 -1
  111. package/dist/internal/wire-date.d.ts +3 -0
  112. package/dist/internal/wire-date.d.ts.map +1 -0
  113. package/dist/internal/wire-date.js +14 -0
  114. package/dist/internal/wire-date.js.map +1 -0
  115. package/dist/internal/zone-graph.d.ts +4 -0
  116. package/dist/internal/zone-graph.d.ts.map +1 -1
  117. package/dist/internal/zone-graph.js +54 -14
  118. package/dist/internal/zone-graph.js.map +1 -1
  119. package/dist/isr.d.ts +58 -2
  120. package/dist/isr.d.ts.map +1 -1
  121. package/dist/isr.js +296 -39
  122. package/dist/isr.js.map +1 -1
  123. package/dist/llms-txt.d.ts +2 -0
  124. package/dist/llms-txt.d.ts.map +1 -1
  125. package/dist/llms-txt.js +29 -3
  126. package/dist/llms-txt.js.map +1 -1
  127. package/dist/manifest.d.ts +28 -4
  128. package/dist/manifest.d.ts.map +1 -1
  129. package/dist/manifest.js +14 -3
  130. package/dist/manifest.js.map +1 -1
  131. package/dist/open-resolved.d.ts +18 -0
  132. package/dist/open-resolved.d.ts.map +1 -0
  133. package/dist/open-resolved.js +62 -0
  134. package/dist/open-resolved.js.map +1 -0
  135. package/dist/plugins/kit.d.ts +1 -0
  136. package/dist/plugins/kit.d.ts.map +1 -1
  137. package/dist/plugins/kit.js +26 -11
  138. package/dist/plugins/kit.js.map +1 -1
  139. package/dist/plugins/vite-leak-guard.d.ts +3 -1
  140. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  141. package/dist/plugins/vite-leak-guard.js +4 -0
  142. package/dist/plugins/vite-leak-guard.js.map +1 -1
  143. package/dist/plugins/zone-guard.d.ts.map +1 -1
  144. package/dist/plugins/zone-guard.js +10 -2
  145. package/dist/plugins/zone-guard.js.map +1 -1
  146. package/dist/public-dir.d.ts +8 -1
  147. package/dist/public-dir.d.ts.map +1 -1
  148. package/dist/public-dir.js +17 -6
  149. package/dist/public-dir.js.map +1 -1
  150. package/dist/query.d.ts +13 -4
  151. package/dist/query.d.ts.map +1 -1
  152. package/dist/query.js +89 -26
  153. package/dist/query.js.map +1 -1
  154. package/dist/render-seam.d.ts +2 -0
  155. package/dist/render-seam.d.ts.map +1 -1
  156. package/dist/render-seam.js +1 -0
  157. package/dist/render-seam.js.map +1 -1
  158. package/dist/route-types.d.ts.map +1 -1
  159. package/dist/route-types.js +20 -9
  160. package/dist/route-types.js.map +1 -1
  161. package/dist/router.d.ts +9 -2
  162. package/dist/router.d.ts.map +1 -1
  163. package/dist/router.js +39 -46
  164. package/dist/router.js.map +1 -1
  165. package/dist/server-components/build-info.d.ts +3 -0
  166. package/dist/server-components/build-info.d.ts.map +1 -0
  167. package/dist/server-components/build-info.js +7 -0
  168. package/dist/server-components/build-info.js.map +1 -0
  169. package/dist/server-components/build-target.d.ts +12 -0
  170. package/dist/server-components/build-target.d.ts.map +1 -0
  171. package/dist/server-components/build-target.js +153 -0
  172. package/dist/server-components/build-target.js.map +1 -0
  173. package/dist/server-components/build.d.ts +24 -0
  174. package/dist/server-components/build.d.ts.map +1 -0
  175. package/dist/server-components/build.js +366 -0
  176. package/dist/server-components/build.js.map +1 -0
  177. package/dist/server-components/bundle-proof.d.ts +3 -0
  178. package/dist/server-components/bundle-proof.d.ts.map +1 -0
  179. package/dist/server-components/bundle-proof.js +23 -0
  180. package/dist/server-components/bundle-proof.js.map +1 -0
  181. package/dist/server-components/client.d.ts +46 -0
  182. package/dist/server-components/client.d.ts.map +1 -0
  183. package/dist/server-components/client.js +594 -0
  184. package/dist/server-components/client.js.map +1 -0
  185. package/dist/server-components/definition.d.ts +11 -0
  186. package/dist/server-components/definition.d.ts.map +1 -0
  187. package/dist/server-components/definition.js +94 -0
  188. package/dist/server-components/definition.js.map +1 -0
  189. package/dist/server-components/dev-http.d.ts +5 -0
  190. package/dist/server-components/dev-http.d.ts.map +1 -0
  191. package/dist/server-components/dev-http.js +71 -0
  192. package/dist/server-components/dev-http.js.map +1 -0
  193. package/dist/server-components/error.d.ts +9 -0
  194. package/dist/server-components/error.d.ts.map +1 -0
  195. package/dist/server-components/error.js +24 -0
  196. package/dist/server-components/error.js.map +1 -0
  197. package/dist/server-components/frames.d.ts +7 -0
  198. package/dist/server-components/frames.d.ts.map +1 -0
  199. package/dist/server-components/frames.js +169 -0
  200. package/dist/server-components/frames.js.map +1 -0
  201. package/dist/server-components/html-policy.d.ts +6 -0
  202. package/dist/server-components/html-policy.d.ts.map +1 -0
  203. package/dist/server-components/html-policy.js +75 -0
  204. package/dist/server-components/html-policy.js.map +1 -0
  205. package/dist/server-components/html.d.ts +4 -0
  206. package/dist/server-components/html.d.ts.map +1 -0
  207. package/dist/server-components/html.js +67 -0
  208. package/dist/server-components/html.js.map +1 -0
  209. package/dist/server-components/islands.d.ts +44 -0
  210. package/dist/server-components/islands.d.ts.map +1 -0
  211. package/dist/server-components/islands.js +42 -0
  212. package/dist/server-components/islands.js.map +1 -0
  213. package/dist/server-components/native.d.ts +4 -0
  214. package/dist/server-components/native.d.ts.map +1 -0
  215. package/dist/server-components/native.js +81 -0
  216. package/dist/server-components/native.js.map +1 -0
  217. package/dist/server-components/portal.d.ts +6 -0
  218. package/dist/server-components/portal.d.ts.map +1 -0
  219. package/dist/server-components/portal.js +35 -0
  220. package/dist/server-components/portal.js.map +1 -0
  221. package/dist/server-components/props.d.ts +3 -0
  222. package/dist/server-components/props.d.ts.map +1 -0
  223. package/dist/server-components/props.js +14 -0
  224. package/dist/server-components/props.js.map +1 -0
  225. package/dist/server-components/protocol.d.ts +44 -0
  226. package/dist/server-components/protocol.d.ts.map +1 -0
  227. package/dist/server-components/protocol.js +139 -0
  228. package/dist/server-components/protocol.js.map +1 -0
  229. package/dist/server-components/range.d.ts +24 -0
  230. package/dist/server-components/range.d.ts.map +1 -0
  231. package/dist/server-components/range.js +263 -0
  232. package/dist/server-components/range.js.map +1 -0
  233. package/dist/server-components/registry.d.ts +6 -0
  234. package/dist/server-components/registry.d.ts.map +1 -0
  235. package/dist/server-components/registry.js +30 -0
  236. package/dist/server-components/registry.js.map +1 -0
  237. package/dist/server-components/response.d.ts +4 -0
  238. package/dist/server-components/response.d.ts.map +1 -0
  239. package/dist/server-components/response.js +32 -0
  240. package/dist/server-components/response.js.map +1 -0
  241. package/dist/server-components/router-integration.d.ts +9 -0
  242. package/dist/server-components/router-integration.d.ts.map +1 -0
  243. package/dist/server-components/router-integration.js +29 -0
  244. package/dist/server-components/router-integration.js.map +1 -0
  245. package/dist/server-components/runtime.d.ts +14 -0
  246. package/dist/server-components/runtime.d.ts.map +1 -0
  247. package/dist/server-components/runtime.js +276 -0
  248. package/dist/server-components/runtime.js.map +1 -0
  249. package/dist/server-components/scheduler.d.ts +14 -0
  250. package/dist/server-components/scheduler.d.ts.map +1 -0
  251. package/dist/server-components/scheduler.js +74 -0
  252. package/dist/server-components/scheduler.js.map +1 -0
  253. package/dist/server-components/schema.d.ts +4 -0
  254. package/dist/server-components/schema.d.ts.map +1 -0
  255. package/dist/server-components/schema.js +77 -0
  256. package/dist/server-components/schema.js.map +1 -0
  257. package/dist/server-components/server-entry.d.ts +12 -0
  258. package/dist/server-components/server-entry.d.ts.map +1 -0
  259. package/dist/server-components/server-entry.js +90 -0
  260. package/dist/server-components/server-entry.js.map +1 -0
  261. package/dist/server-components/source.d.ts +15 -0
  262. package/dist/server-components/source.d.ts.map +1 -0
  263. package/dist/server-components/source.js +269 -0
  264. package/dist/server-components/source.js.map +1 -0
  265. package/dist/server-components/state.d.ts +4 -0
  266. package/dist/server-components/state.d.ts.map +1 -0
  267. package/dist/server-components/state.js +123 -0
  268. package/dist/server-components/state.js.map +1 -0
  269. package/dist/server-components/stream.d.ts +6 -0
  270. package/dist/server-components/stream.d.ts.map +1 -0
  271. package/dist/server-components/stream.js +70 -0
  272. package/dist/server-components/stream.js.map +1 -0
  273. package/dist/server-components/trusted-html.d.ts +3 -0
  274. package/dist/server-components/trusted-html.d.ts.map +1 -0
  275. package/dist/server-components/trusted-html.js +14 -0
  276. package/dist/server-components/trusted-html.js.map +1 -0
  277. package/dist/server-components/types.d.ts +107 -0
  278. package/dist/server-components/types.d.ts.map +1 -0
  279. package/dist/server-components/types.js +2 -0
  280. package/dist/server-components/types.js.map +1 -0
  281. package/dist/server-components/typescript.d.ts +11 -0
  282. package/dist/server-components/typescript.d.ts.map +1 -0
  283. package/dist/server-components/typescript.js +28 -0
  284. package/dist/server-components/typescript.js.map +1 -0
  285. package/dist/server-components/value.d.ts +5 -0
  286. package/dist/server-components/value.d.ts.map +1 -0
  287. package/dist/server-components/value.js +77 -0
  288. package/dist/server-components/value.js.map +1 -0
  289. package/dist/server-components/vite.d.ts +12 -0
  290. package/dist/server-components/vite.d.ts.map +1 -0
  291. package/dist/server-components/vite.js +197 -0
  292. package/dist/server-components/vite.js.map +1 -0
  293. package/dist/server-components.d.ts +5 -0
  294. package/dist/server-components.d.ts.map +1 -0
  295. package/dist/server-components.js +4 -0
  296. package/dist/server-components.js.map +1 -0
  297. package/dist/service-worker.d.ts +8 -1
  298. package/dist/service-worker.d.ts.map +1 -1
  299. package/dist/service-worker.js +20 -6
  300. package/dist/service-worker.js.map +1 -1
  301. package/dist/vite.d.ts +12 -0
  302. package/dist/vite.d.ts.map +1 -1
  303. package/dist/vite.js +77 -17
  304. package/dist/vite.js.map +1 -1
  305. package/dist/zones.d.ts +40 -0
  306. package/dist/zones.d.ts.map +1 -1
  307. package/dist/zones.js +170 -6
  308. package/dist/zones.js.map +1 -1
  309. package/package.json +97 -4
  310. package/src/build-vite.ts +31 -9
  311. package/src/build.ts +109 -31
  312. package/src/cdn.ts +18 -12
  313. package/src/client.ts +48 -23
  314. package/src/deferred.ts +27 -9
  315. package/src/dev-feed.ts +24 -5
  316. package/src/dev-session.ts +4 -13
  317. package/src/dev.ts +29 -1
  318. package/src/draft.ts +35 -14
  319. package/src/fonts-google.ts +10 -0
  320. package/src/index.ts +3 -2
  321. package/src/internal/codegen.ts +60 -29
  322. package/src/internal/disposable-dir.ts +24 -0
  323. package/src/internal/entry-chunks.ts +18 -0
  324. package/src/internal/evidence-traversal.ts +57 -0
  325. package/src/internal/head-merge.ts +16 -0
  326. package/src/internal/js-string.ts +6 -1
  327. package/src/internal/mount-shadow.ts +1 -1
  328. package/src/internal/output-guard.ts +88 -7
  329. package/src/internal/page-execution.ts +506 -108
  330. package/src/internal/progressive-cookies.ts +60 -0
  331. package/src/internal/public-env.ts +14 -0
  332. package/src/internal/render-document.ts +26 -11
  333. package/src/internal/response-controls.ts +31 -14
  334. package/src/internal/revive-dates.ts +35 -0
  335. package/src/internal/route-path.ts +8 -0
  336. package/src/internal/secret-scan.ts +104 -17
  337. package/src/internal/server-boundary.ts +29 -5
  338. package/src/internal/web-app.ts +116 -37
  339. package/src/internal/wire-date.ts +16 -0
  340. package/src/internal/zone-graph.ts +59 -19
  341. package/src/isr.ts +351 -43
  342. package/src/llms-txt.ts +32 -3
  343. package/src/manifest.ts +43 -7
  344. package/src/open-resolved.ts +64 -0
  345. package/src/plugins/kit.ts +31 -11
  346. package/src/plugins/vite-leak-guard.ts +5 -1
  347. package/src/plugins/zone-guard.ts +11 -2
  348. package/src/public-dir.ts +27 -8
  349. package/src/query.ts +107 -26
  350. package/src/render-seam.ts +2 -0
  351. package/src/route-types.ts +34 -7
  352. package/src/router.ts +58 -55
  353. package/src/server-components/build-info.ts +11 -0
  354. package/src/server-components/build-target.ts +194 -0
  355. package/src/server-components/build.ts +419 -0
  356. package/src/server-components/bundle-proof.ts +33 -0
  357. package/src/server-components/client.ts +663 -0
  358. package/src/server-components/definition.ts +125 -0
  359. package/src/server-components/dev-http.ts +75 -0
  360. package/src/server-components/error.ts +24 -0
  361. package/src/server-components/frames.ts +181 -0
  362. package/src/server-components/html-policy.ts +94 -0
  363. package/src/server-components/html.ts +81 -0
  364. package/src/server-components/islands.ts +111 -0
  365. package/src/server-components/native.ts +101 -0
  366. package/src/server-components/portal.ts +42 -0
  367. package/src/server-components/props.ts +19 -0
  368. package/src/server-components/protocol.ts +167 -0
  369. package/src/server-components/range.ts +313 -0
  370. package/src/server-components/registry.ts +34 -0
  371. package/src/server-components/response.ts +44 -0
  372. package/src/server-components/router-integration.ts +40 -0
  373. package/src/server-components/runtime.ts +376 -0
  374. package/src/server-components/scheduler.ts +84 -0
  375. package/src/server-components/schema.ts +69 -0
  376. package/src/server-components/server-entry.ts +98 -0
  377. package/src/server-components/source.ts +324 -0
  378. package/src/server-components/state.ts +143 -0
  379. package/src/server-components/stream.ts +71 -0
  380. package/src/server-components/trusted-html.ts +21 -0
  381. package/src/server-components/types.ts +138 -0
  382. package/src/server-components/typescript.ts +39 -0
  383. package/src/server-components/value.ts +82 -0
  384. package/src/server-components/vite.ts +242 -0
  385. package/src/server-components.ts +32 -0
  386. package/src/service-worker.ts +24 -7
  387. package/src/vite.ts +68 -17
  388. package/src/zones.ts +208 -7
package/src/cdn.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * for any other. Backend-only: a browser build that reaches this module fails.
12
12
  */
13
13
  import "@nifrajs/web/backend-only"
14
+ import { writableResponse } from "@nifrajs/core/server"
14
15
  import { isDraftEnabled } from "./draft.ts"
15
16
  import type { CdnProvider } from "./internal/cdn/core.ts"
16
17
  import { pathTag } from "./internal/cdn/core.ts"
@@ -84,15 +85,9 @@ const isHtml = (res: Response): boolean =>
84
85
  (res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
85
86
 
86
87
  function withHeaders(res: Response, set: Readonly<Record<string, string>>): Response {
87
- try {
88
- for (const [name, value] of Object.entries(set)) res.headers.set(name, value)
89
- return res
90
- } catch {
91
- // A `fetch()` response has immutable headers: rebuild it around the same body instead.
92
- const headers = new Headers(res.headers)
93
- for (const [name, value] of Object.entries(set)) headers.set(name, value)
94
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
95
- }
88
+ const target = writableResponse(res)
89
+ for (const [name, value] of Object.entries(set)) target.headers.set(name, value)
90
+ return target
96
91
  }
97
92
 
98
93
  /**
@@ -150,6 +145,12 @@ export interface InvalidatorOptions {
150
145
  readonly key?: (path: string) => string | null
151
146
  /** The `query` policy given to `withISR`. Default `"bypass"`. Ignored with `key`. */
152
147
  readonly query?: ISRQuery
148
+ /**
149
+ * Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding
150
+ * loader data. Tags reach each of them before the store, so a page regenerated mid-purge cannot be
151
+ * rebuilt from stale data; a failure rejects before the store is touched. Paths do not reach them.
152
+ */
153
+ readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
153
154
  }
154
155
 
155
156
  export interface InvalidateResult {
@@ -196,11 +197,16 @@ export function createInvalidator(options: InvalidatorOptions): {
196
197
  throw new TypeError("[nifra/web/cdn] invalidate paths must start with /")
197
198
  }
198
199
  }
200
+ if (store !== undefined && tags.length > 0 && store.invalidateTag === undefined) {
201
+ throw new TypeError("[nifra/web/cdn] this store cannot invalidate by tag")
202
+ }
203
+ if (tags.length > 0) {
204
+ for (const cache of options.dataCaches ?? []) {
205
+ for (const tag of tags) await cache.invalidateTag(tag)
206
+ }
207
+ }
199
208
  let origin: InvalidateResult["origin"] = "skipped"
200
209
  if (store !== undefined) {
201
- if (tags.length > 0 && store.invalidateTag === undefined) {
202
- throw new TypeError("[nifra/web/cdn] this store cannot invalidate by tag")
203
- }
204
210
  for (const path of paths) {
205
211
  const key = keyOf?.(path)
206
212
  if (key !== null && key !== undefined) await store.delete(key)
package/src/client.ts CHANGED
@@ -40,7 +40,7 @@ export function currentDocumentNonce(): string | undefined {
40
40
  * serves modules as written, so the browser evaluated `public-dir.ts` and died on `node:fs/promises`
41
41
  * before hydrating. The fix is for the entry not to name the server module in the first place.
42
42
  */
43
- export { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
43
+ export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
44
44
  export {
45
45
  getBrowserNavigate,
46
46
  IDLE_BLOCKER,
@@ -206,6 +206,23 @@ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void>
206
206
  return Promise.all(promises).then(() => undefined)
207
207
  }
208
208
 
209
+ /**
210
+ * Resolve `href` against this page and keep it only when history may move there without a load: same
211
+ * scheme and host as the document, or null. Compared by protocol + host rather than `URL.origin`,
212
+ * which is `"null"` for every non-special scheme - a native webview's `capacitor://localhost` page and
213
+ * a `javascript:` target alike. An opaque or `file:` document may not push another path at all, so its
214
+ * links load natively.
215
+ */
216
+ function sameDocumentUrl(href: string): URL | null {
217
+ if (location.origin === "null" || location.protocol === "file:") return null
218
+ try {
219
+ const url = new URL(href, location.href)
220
+ return url.protocol === location.protocol && url.host === location.host ? url : null
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
209
226
  /**
210
227
  * Attach history + link interception to a router. Returns a teardown function that removes the
211
228
  * listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
@@ -288,7 +305,7 @@ export function installHistory(
288
305
 
289
306
  // Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
290
307
  const locationOf = (path: string): BlockerLocation => {
291
- const url = new URL(path, location.origin)
308
+ const url = new URL(path, location.href)
292
309
  return { pathname: url.pathname, search: url.search, hash: url.hash }
293
310
  }
294
311
 
@@ -332,17 +349,6 @@ export function installHistory(
332
349
  // Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
333
350
  // becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
334
351
  // router intentionally kept rendering the old route.
335
- const parseNavigationUrl = (path: string): URL | null => {
336
- try {
337
- const url = new URL(path, location.origin)
338
- if (url.protocol !== "http:" && url.protocol !== "https:") return null
339
- if (url.origin !== location.origin) return null
340
- return url
341
- } catch {
342
- return null
343
- }
344
- }
345
-
346
352
  const commit = (path: string, url: URL, mode: "push" | "replace", state?: unknown): void => {
347
353
  const routePath = url.pathname + url.search
348
354
  if (router.match(routePath) === null) {
@@ -387,7 +393,7 @@ export function installHistory(
387
393
  const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
388
394
  // Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
389
395
  // malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
390
- const url = parseNavigationUrl(path)
396
+ const url = sameDocumentUrl(path)
391
397
  if (url === null) {
392
398
  settle()
393
399
  return
@@ -432,8 +438,8 @@ export function installHistory(
432
438
  if (anchor.target !== "" && anchor.target !== "_self") return null
433
439
  if (anchor.hasAttribute("download")) return null
434
440
  if (anchor.getAttribute("rel")?.split(/\s+/).includes("external")) return null
435
- const url = new URL(anchor.href)
436
- if (url.origin !== location.origin) return null
441
+ const url = sameDocumentUrl(anchor.href)
442
+ if (url === null) return null
437
443
  if (router.match(url.pathname) === null) return null
438
444
  // A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
439
445
  // browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
@@ -465,7 +471,7 @@ export function installHistory(
465
471
  const warm = (target: EventTarget | null): void => {
466
472
  const href = inAppHref(target)
467
473
  if (href === null) return
468
- const url = new URL(href, location.origin)
474
+ const url = new URL(href, location.href)
469
475
  const path = url.pathname + url.search // the #hash isn't data
470
476
  if (path !== location.pathname + location.search) void router.prefetch(path)
471
477
  }
@@ -684,23 +690,42 @@ export function applyHead(head: Meta): void {
684
690
  }
685
691
 
686
692
  export function installForms(router: ClientRouter): () => void {
693
+ // Forms whose data submit failed before the server answered: their next submit event is the native
694
+ // post that replaces it.
695
+ const native = new WeakSet<HTMLFormElement>()
687
696
  const onSubmit = (event: SubmitEvent): void => {
688
697
  if (event.defaultPrevented) return
689
698
  const form = event.target
690
699
  if (!(form instanceof HTMLFormElement)) return
691
- if (form.method.toLowerCase() !== "post") return // GET forms submit natively
692
- const url = new URL(form.action) // resolved (or the current document URL if unset)
693
- if (url.origin !== location.origin) return
700
+ if (native.delete(form)) return
701
+ // The button that submitted can override the form's method, action and target, and its
702
+ // name=value is part of the submission (`<button name="intent" value="delete">`).
703
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a submit event's submitter is the submit button or input that fired it, or null
704
+ const submitter = event.submitter as HTMLButtonElement | null
705
+ const method = submitter?.hasAttribute("formmethod") ? submitter.formMethod : form.method
706
+ if (method.toLowerCase() !== "post") return // GET forms submit natively
707
+ const target = submitter?.hasAttribute("formtarget") ? submitter.formTarget : form.target
708
+ if (target !== "" && target !== "_self") return // it loads in another browsing context
709
+ // Resolved (or the current document URL if unset).
710
+ const url = sameDocumentUrl(
711
+ submitter?.hasAttribute("formaction") ? submitter.formAction : form.action,
712
+ )
713
+ if (url === null) return
694
714
  if (router.match(url.pathname) === null) return // not an app route → native submit
695
715
  event.preventDefault()
696
716
  // `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
697
717
  // actionData drives the update); absent or any other value keeps the default revalidation.
698
718
  const revalidate = form.dataset.nifraRevalidate !== "false"
699
- router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch((error) => {
719
+ const body = new FormData(form, submitter)
720
+ router.submit(url.pathname + url.search, body, { revalidate }).catch((error) => {
700
721
  const to = redirectOf(error)
701
- // The action ran: load the page that shows its result rather than run it again.
722
+ // The server answered: load the page that shows the result rather than run the action again.
702
723
  if (to !== undefined) location.assign(to)
703
- else form.submit() // data submit failed - fall back to a full-page POST
724
+ else {
725
+ // Nothing reached the server - post natively, through the same button.
726
+ native.add(form)
727
+ form.requestSubmit(submitter)
728
+ }
704
729
  })
705
730
  }
706
731
  document.addEventListener("submit", onSubmit)
package/src/deferred.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * resolution scripts settle. The per-adapter `<Await>` lives in `@nifrajs/web-{solid,react}/client`.
7
7
  */
8
8
  import { plainJsonCodec, type TransportCodec } from "@nifrajs/core/transport-codec"
9
+ import { parsePageData } from "./internal/revive-dates.ts"
9
10
 
10
11
  /**
11
12
  * A loader value marked to stream in after the shell. The component consumes it with the adapter's
@@ -164,25 +165,29 @@ export function ndjsonStream(
164
165
  deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
165
166
  codec: TransportCodec = plainJsonCodec,
166
167
  ): ReadableStream<Uint8Array> {
168
+ // A client that goes away cancels the body; a value settling after that has nowhere to go.
169
+ let cancelled = false
167
170
  return new ReadableStream({
168
171
  async start(controller) {
169
172
  controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode(forClient)}\n`))
170
173
  await Promise.all(
171
174
  deferred.map(async (d) => {
175
+ let line: string
172
176
  try {
173
- const value = await d.promise
174
- controller.enqueue(NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, v: value })}\n`))
177
+ line = codec.encode({ i: d.id, v: await d.promise })
175
178
  } catch (err) {
176
179
  // Redact: stream a stable opaque code, never the raw error text; log the real reason
177
180
  // server-side. The client's <Await> errorFallback receives this code.
178
181
  console.error("[nifra/web] deferred value rejected:", err)
179
- controller.enqueue(
180
- NDJSON_ENCODER.encode(`${codec.encode({ i: d.id, e: DEFERRED_ERROR_CODE })}\n`),
181
- )
182
+ line = codec.encode({ i: d.id, e: DEFERRED_ERROR_CODE })
182
183
  }
184
+ if (!cancelled) controller.enqueue(NDJSON_ENCODER.encode(`${line}\n`))
183
185
  }),
184
186
  )
185
- controller.close()
187
+ if (!cancelled) controller.close()
188
+ },
189
+ cancel() {
190
+ cancelled = true
186
191
  },
187
192
  })
188
193
  }
@@ -213,7 +218,16 @@ export const DEFERRED_RUNTIME = `(() => {
213
218
  // Tag the promise (status/value/reason) - React's use() reads them and returns SYNCHRONOUSLY at
214
219
  // hydration, so it renders the resolved content directly into the <Suspense> boundary the server
215
220
  // streamed (no re-suspend, no fallback flash). r/j also wake awaiters (Solid uses its own _$HY).
216
- window.__nifraResolve = (id, v) => { const e = get(id); e.p.status = "fulfilled"; e.p.value = v; e.r(v); };
221
+ // A t.date() value arrives tagged; rebuild it before anything reads the settled value.
222
+ const __nifraDates = (d) => {
223
+ if (!d || typeof d !== "object") return d
224
+ if (Array.isArray(d)) { for (let i = 0; i < d.length; i++) d[i] = __nifraDates(d[i]); return d }
225
+ const k = Object.keys(d)
226
+ if (k.length === 1 && k[0] === "__nifra_date" && typeof d.__nifra_date === "string") return new Date(d.__nifra_date)
227
+ for (const x of k) { const v = __nifraDates(d[x]); if (v !== d[x]) Object.defineProperty(d, x, { value: v, enumerable: true, writable: true, configurable: true }) }
228
+ return d
229
+ };
230
+ window.__nifraResolve = (id, v) => { v = __nifraDates(v); const e = get(id); e.p.status = "fulfilled"; e.p.value = v; e.r(v); };
217
231
  window.__nifraReject = (id, x) => { const e = get(id); e.p.status = "rejected"; e.p.reason = x; e.j(x); };
218
232
  })();`
219
233
 
@@ -225,6 +239,7 @@ export const DEFERRED_RUNTIME = `(() => {
225
239
  export const MAP_DEFERRED_SOURCE = `const mapDeferred = (d) => {
226
240
  if (!d || typeof d !== "object") return d
227
241
  if (typeof d.__nifra_deferred === "number") return { __nifra_deferred: true, id: d.__nifra_deferred, promise: window.__nifraDeferred(d.__nifra_deferred) }
242
+ if (typeof d.__nifra_date === "string" && Object.keys(d).length === 1) return new Date(d.__nifra_date)
228
243
  if (Array.isArray(d)) return d.map(mapDeferred)
229
244
  const out = {}
230
245
  for (const k of Object.keys(d)) Object.defineProperty(out, k, { value: mapDeferred(d[k]), enumerable: true, writable: true, configurable: true })
@@ -326,8 +341,11 @@ export async function parseNdjsonData(
326
341
  }
327
342
 
328
343
  const pending: Pending = new Map()
344
+ // Only plain JSON carries t.date() tags; a richer codec decodes its own dates.
345
+ const decode = (line: string): unknown =>
346
+ codec === plainJsonCodec ? parsePageData(line) : codec.decode(line)
329
347
  const first = await readLine()
330
- const data = markersFromPlaceholders(first === null ? null : codec.decode(first), pending)
348
+ const data = markersFromPlaceholders(first === null ? null : decode(first), pending)
331
349
 
332
350
  void (async () => {
333
351
  try {
@@ -335,7 +353,7 @@ export async function parseNdjsonData(
335
353
  const line = await readLine()
336
354
  if (line === null) break
337
355
  if (line === "") continue
338
- const msg = codec.decode(line) as { i: number; v?: unknown; e?: string }
356
+ const msg = decode(line) as { i: number; v?: unknown; e?: string }
339
357
  const entry = pending.get(msg.i)
340
358
  if (entry === undefined) continue
341
359
  pending.delete(msg.i)
package/src/dev-feed.ts CHANGED
@@ -392,6 +392,25 @@ function fnv1a(text: string): string {
392
392
  return (hash >>> 0).toString(16).padStart(8, "0")
393
393
  }
394
394
 
395
+ /**
396
+ * A query value may be a single-use ticket or a token no redaction pattern knows, so the feed keeps
397
+ * which keys a request sent and never their values. `/x?ticket=abc&page` -> `/x?ticket=[redacted]&[redacted]`.
398
+ */
399
+ function queryKeysOnly(target: string): string {
400
+ const at = target.indexOf("?")
401
+ if (at === -1) return target
402
+ const hash = target.indexOf("#", at)
403
+ const parts = target
404
+ .slice(at + 1, hash === -1 ? undefined : hash)
405
+ .split("&")
406
+ .filter((part) => part.length > 0)
407
+ .map((part) => {
408
+ const eq = part.indexOf("=")
409
+ return eq === -1 ? "[redacted]" : `${part.slice(0, eq)}=[redacted]`
410
+ })
411
+ return parts.length === 0 ? target.slice(0, at) : `${target.slice(0, at)}?${parts.join("&")}`
412
+ }
413
+
395
414
  const cap = (text: string, max: number): string =>
396
415
  text.length <= max ? text : `${text.slice(0, max)}... [${text.length - max} more chars]`
397
416
 
@@ -477,7 +496,7 @@ export function createDevFeed(options: DevFeedOptions): DevFeed {
477
496
  if (diagnostic.request !== undefined) {
478
497
  redacted.request = {
479
498
  method: diagnostic.request.method,
480
- url: cap(redactText(diagnostic.request.url), 2048),
499
+ url: cap(redactText(queryKeysOnly(diagnostic.request.url)), 2048),
481
500
  }
482
501
  }
483
502
  if (diagnostic.codeframe !== undefined) {
@@ -530,7 +549,7 @@ export function createDevFeed(options: DevFeedOptions): DevFeed {
530
549
  record.resolvedAt = undefined
531
550
  if (requestId !== undefined) record.requestId = requestId
532
551
  if (meta.route !== undefined) record.route = meta.route
533
- if (meta.page !== undefined) record.page = meta.page
552
+ if (meta.page !== undefined) record.page = cap(redactText(queryKeysOnly(meta.page)), 2048)
534
553
  // Re-insert: Map order is recency order, which eviction relies on.
535
554
  errors.delete(key)
536
555
  errors.set(key, record)
@@ -557,7 +576,7 @@ export function createDevFeed(options: DevFeedOptions): DevFeed {
557
576
  message: text,
558
577
  }
559
578
  if (requestId !== undefined) entry.requestId = requestId
560
- if (meta.page !== undefined) entry.page = meta.page
579
+ if (meta.page !== undefined) entry.page = cap(redactText(queryKeysOnly(meta.page)), 2048)
561
580
  logs.push(entry)
562
581
  logBytes += text.length
563
582
  if (logs.length > limits.logs || logBytes > limits.logBytes) {
@@ -607,7 +626,7 @@ export function createDevFeed(options: DevFeedOptions): DevFeed {
607
626
  seq,
608
627
  requestId,
609
628
  method,
610
- path: cap(redactText(path), 2048),
629
+ path: cap(redactText(queryKeysOnly(path)), 2048),
611
630
  at: nowIso(),
612
631
  status: undefined,
613
632
  durationMs: undefined,
@@ -856,7 +875,7 @@ export function writeDevServerRecord(root: string, record: DevServerRecord): voi
856
875
 
857
876
  /** Remove the record only while it is still this server's: a newer server may have replaced it. */
858
877
  export function removeDevServerRecord(root: string, token: string): void {
859
- // @nifra-gate-reviewed: the server comparing its own token with a file on this machine; no request
878
+ // nifra-expect NF-S002: the server comparing its own token with a file on this machine; no request
860
879
  // reaches this comparison.
861
880
  if (readDevServerRecord(root)?.token !== token) return
862
881
  rmSync(join(root, DEV_SERVER_RECORD_FILE), { force: true })
@@ -8,6 +8,7 @@
8
8
  * either. `/__nifra/last-error` keeps its older, token-free contract behind the same Host check.
9
9
  */
10
10
  import { performance } from "node:perf_hooks"
11
+ import { writableResponse } from "@nifrajs/core/server"
11
12
  import {
12
13
  type BrowserError,
13
14
  CLIENT_BATCH_MAX_BYTES,
@@ -649,19 +650,9 @@ export function createDevSession(options: DevSessionOptions): DevSession {
649
650
  },
650
651
  performance.now() - started,
651
652
  )
652
- try {
653
- response.headers.set(DEV_REQUEST_ID_HEADER, requestId)
654
- return response
655
- } catch {
656
- // `Response.redirect()` and fetched responses carry immutable headers on some runtimes.
657
- const headers = new Headers(response.headers)
658
- headers.set(DEV_REQUEST_ID_HEADER, requestId)
659
- return new Response(response.body, {
660
- status: response.status,
661
- statusText: response.statusText,
662
- headers,
663
- })
664
- }
653
+ const target = writableResponse(response)
654
+ target.headers.set(DEV_REQUEST_ID_HEADER, requestId)
655
+ return target
665
656
  }
666
657
 
667
658
  return {
package/src/dev.ts CHANGED
@@ -58,7 +58,12 @@ import { dirname, relative, resolve } from "node:path"
58
58
  import { type BuildClientOptions, buildClient } from "./build.ts"
59
59
  import { type DevEntryMatch, resolveDevEntry } from "./bun-dev-entry.ts"
60
60
  import { explainBindFailure } from "./dev-port.ts"
61
- import { createDevSession, type DevAppHooks, type DevSession } from "./dev-session.ts"
61
+ import {
62
+ createDevSession,
63
+ type DevAppHooks,
64
+ type DevSession,
65
+ isLoopbackHost,
66
+ } from "./dev-session.ts"
62
67
  import { createSsrGraph, type SsrGraph } from "./dev-ssr-graph.ts"
63
68
  import { discoverRoutes } from "./fs.ts"
64
69
  import { DEFAULT_DEV_PORT, generateClientEntry } from "./index.ts"
@@ -112,6 +117,13 @@ export interface DevServerOptions extends Omit<BuildClientOptions, "minify"> {
112
117
  * fix (default `true`). Off, the errors still reach the feed.
113
118
  */
114
119
  readonly indicator?: boolean
120
+ /**
121
+ * Host names the dev server answers besides loopback ones (`localhost`, `*.localhost`, `127.0.0.1`,
122
+ * `[::1]`), port ignored. Every other `Host` gets a 403: a page on another site whose name a DNS
123
+ * rebind points at 127.0.0.1 would otherwise read the dev server's pages, loader data and error
124
+ * overlay. Add a name here when a hosts-file entry or tunnel reaches the server under it.
125
+ */
126
+ readonly allowedHosts?: readonly string[]
115
127
  }
116
128
 
117
129
  export interface DevServer {
@@ -465,6 +477,7 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
465
477
  }
466
478
  }
467
479
 
480
+ const allowedHosts = new Set((options.allowedHosts ?? []).map(hostName))
468
481
  try {
469
482
  server = serve({
470
483
  port,
@@ -474,6 +487,12 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
474
487
  routes: { [PROBE_PATH]: htmlModule.default },
475
488
  fetch: async (req: Request): Promise<Response> => {
476
489
  const url = new URL(req.url)
490
+ const host = req.headers.get("host") ?? url.host
491
+ if (!isLoopbackHost(host) && !allowedHosts.has(hostName(host)))
492
+ return new Response(
493
+ `[nifra/web/dev] ${hostName(host)} is not a loopback host; add it to allowedHosts if this server should answer it.\n`,
494
+ { status: 403, headers: { "content-type": "text/plain; charset=utf-8" } },
495
+ )
477
496
  // The stable client-entry URL. Pages reference THIS; it redirects to whichever hashed chunk Bun
478
497
  // is serving at the moment the browser asks. A redirect rather than a proxy so the module's final
479
498
  // URL is Bun's own - its HMR client derives its socket and module identity from `import.meta.url`,
@@ -620,6 +639,15 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
620
639
  * whose message is just "Bundle failed" - the actionable part (which file, which import) lives in
621
640
  * `.errors`. Surface it, or the guard reports a failure while hiding the reason.
622
641
  */
642
+ /** A Host header's name: lowercased, without its port or IPv6 brackets. */
643
+ function hostName(host: string): string {
644
+ const name = host.trim().toLowerCase()
645
+ if (name.startsWith("["))
646
+ return name.slice(1, name.indexOf("]") === -1 ? undefined : name.indexOf("]"))
647
+ const colon = name.lastIndexOf(":")
648
+ return colon === -1 ? name : name.slice(0, colon)
649
+ }
650
+
623
651
  export function buildFailureDetail(err: unknown): string {
624
652
  return err instanceof AggregateError && err.errors.length > 0
625
653
  ? err.errors.map((e) => ` ${e instanceof Error ? e.message : String(e)}`).join("\n")
package/src/draft.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Draft / preview mode - let an editor see unpublished content by flipping a signed cookie that
3
3
  * loaders read (`ctx.draft`) and ISR bypasses (fresh render, never cached). The cookie is **HMAC-signed**
4
- * (so a visitor can't forge it) and **HttpOnly** (JS can't read it). You gate `enableDraft` yourself -
4
+ * (so a visitor can't forge it), **HttpOnly** (JS can't read it) and carries its own expiry, which the
5
+ * server checks (a copied cookie stops working when the browser would have dropped it). You gate
6
+ * `enableDraft` yourself -
5
7
  * behind an editor login or a `?token=` check - exactly like Next's `draftMode().enable()`.
6
8
  *
7
9
  * // a route you've protected (e.g. checked ?token= against a secret):
@@ -26,9 +28,23 @@ import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal
26
28
  /** The cookie name nifra uses for draft/preview mode. */
27
29
  export const DRAFT_COOKIE = "__nifra_draft"
28
30
 
29
- // The signed cookie's payload is a fixed marker - a *valid signature* (not the value) is the signal
30
- // that the server issued this via enableDraft(). Nothing secret is stored in it.
31
- const DRAFT_MARKER = "1"
31
+ // The signed payload is `1:<expiry in Unix seconds>`. A valid signature says the server issued it; the
32
+ // expiry bounds a replayed copy, which Max-Age alone cannot, since a client can resend any cookie it holds.
33
+ const DRAFT_PREFIX = "1:"
34
+
35
+ function draftPayload(options: EnableDraftOptions): string {
36
+ return `${DRAFT_PREFIX}${Math.floor(Date.now() / 1000) + draftMaxAge(options)}`
37
+ }
38
+
39
+ function draftMaxAge(options: EnableDraftOptions): number {
40
+ const seconds = options.maxAgeSeconds ?? 3600
41
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) {
42
+ throw new RangeError(
43
+ `[nifra] draft maxAgeSeconds must be a positive whole number of seconds, got ${seconds}`,
44
+ )
45
+ }
46
+ return seconds
47
+ }
32
48
 
33
49
  /** The response-cookie surface `enableDraft`/`disableDraft` need - nifra's `c.set`. Structural, so any
34
50
  * nifra handler context satisfies it without importing the full `Context`. */
@@ -38,7 +54,8 @@ export interface DraftCookieControls {
38
54
  }
39
55
 
40
56
  export interface EnableDraftOptions {
41
- /** Cookie lifetime in **seconds** (default `3600` = 1h). Keep it short - draft is an editor session. */
57
+ /** Draft session lifetime in **seconds** (default `3600` = 1h): the cookie's `Max-Age`, and the expiry
58
+ * signed into the cookie that the server enforces. Keep it short - draft is an editor session. */
42
59
  readonly maxAgeSeconds?: number
43
60
  /** Cookie `Path` (default `"/"`). */
44
61
  readonly path?: string
@@ -57,8 +74,8 @@ export async function enableDraft(
57
74
  secret: string,
58
75
  options: EnableDraftOptions = {},
59
76
  ): Promise<void> {
60
- const signed = await signValue(DRAFT_MARKER, secret)
61
- c.set.cookie(DRAFT_COOKIE, signed, draftCookieOptions(options))
77
+ const cookieOptions = draftCookieOptions(options)
78
+ c.set.cookie(DRAFT_COOKIE, await signValue(draftPayload(options), secret), cookieOptions)
62
79
  }
63
80
 
64
81
  /**
@@ -77,7 +94,7 @@ function draftCookieOptions(options: EnableDraftOptions): CookieOptions {
77
94
  httpOnly: true,
78
95
  sameSite: "lax",
79
96
  path: options.path ?? "/",
80
- maxAge: options.maxAgeSeconds ?? 3600,
97
+ maxAge: draftMaxAge(options),
81
98
  secure: options.secure ?? true,
82
99
  }
83
100
  }
@@ -91,14 +108,17 @@ export function disableDraft(
91
108
  }
92
109
 
93
110
  /**
94
- * Whether `request` carries a **valid** signed draft cookie (constant-time verify via `unsignValue`).
95
- * `createWebApp` uses it to set `ctx.draft`; `withISR` uses it to bypass the cache for editors. A
96
- * missing, forged, or tampered cookie returns `false`.
111
+ * Whether `request` carries a **valid**, unexpired signed draft cookie (constant-time verify via
112
+ * `unsignValue`). `createWebApp` uses it to set `ctx.draft`; `withISR` uses it to bypass the cache for
113
+ * editors. A missing, forged, tampered, or expired cookie returns `false`.
97
114
  */
98
115
  export async function isDraftEnabled(request: Request, secret: string): Promise<boolean> {
99
116
  const cookie = parseCookies(request.headers.get("cookie"))[DRAFT_COOKIE]
100
117
  if (cookie === undefined) return false
101
- return (await unsignValue(cookie, secret)) !== null
118
+ const payload = await unsignValue(cookie, secret)
119
+ if (payload === null || !payload.startsWith(DRAFT_PREFIX)) return false
120
+ const expires = Number(payload.slice(DRAFT_PREFIX.length))
121
+ return Number.isSafeInteger(expires) && Date.now() / 1000 < expires
102
122
  }
103
123
 
104
124
  /**
@@ -173,7 +193,8 @@ export function previewEndpoint(
173
193
  `[nifra] previewEndpoint fallbackPath must be a site-relative path, got ${JSON.stringify(fallbackPath)}`,
174
194
  )
175
195
  }
176
- const cookieOptions = draftCookieOptions(options.cookie ?? {})
196
+ const draftOptions = options.cookie ?? {}
197
+ const cookieOptions = draftCookieOptions(draftOptions)
177
198
  return async (request) => {
178
199
  const url = new URL(request.url)
179
200
  if (!timingSafeEqual(url.searchParams.get(tokenParam) ?? "", options.secret)) {
@@ -184,7 +205,7 @@ export function previewEndpoint(
184
205
  if (destination === null) {
185
206
  return Response.json({ ok: false, error: "invalid_redirect" }, { status: 400 })
186
207
  }
187
- const signed = await signValue(DRAFT_MARKER, options.draftSecret)
208
+ const signed = await signValue(draftPayload(draftOptions), options.draftSecret)
188
209
  return new Response(null, {
189
210
  status: 302,
190
211
  headers: {
@@ -113,6 +113,10 @@ const DEFAULT_MAX_FONT_BYTES = 5 * 1024 * 1024
113
113
  const MAX_CSS_BYTES = 512 * 1024
114
114
  const MAX_TEXT_LEN = 2048
115
115
 
116
+ /** A face's weight as Google writes it: `400`, a variable `100 900`, or a keyword. It lands in the
117
+ * written file's name, so anything else (a tampered `400/../..`) is refused. */
118
+ const FACE_WEIGHT = /^(?:\d{1,4}(?: \d{1,4})?|normal|bold)$/
119
+
116
120
  const VALID_DISPLAY: ReadonlySet<string> = new Set([
117
121
  "auto",
118
122
  "block",
@@ -420,6 +424,12 @@ export async function loadGoogleFont(
420
424
  "the stylesheet may be tampered with",
421
425
  )
422
426
  }
427
+ if (!FACE_WEIGHT.test(face.weight)) {
428
+ throw new Error(
429
+ `loadGoogleFont: refusing an unexpected font-weight ${JSON.stringify(face.weight.slice(0, 40))}; ` +
430
+ "the stylesheet may be tampered with",
431
+ )
432
+ }
423
433
  const bytes = await fetchBounded(fetchImpl, source.url, maxBytes)
424
434
  const hash = await contentHash(bytes)
425
435
  const style = face.style === "italic" ? "italic" : "normal"
package/src/index.ts CHANGED
@@ -85,8 +85,8 @@ export {
85
85
  generateServerManifest,
86
86
  } from "./internal/codegen.ts"
87
87
  // Generated client entries intentionally import the browser router from the client subpath:
88
- // `import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"`.
89
- export { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
88
+ // `import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"`.
89
+ export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
90
90
  export {
91
91
  canonical,
92
92
  gone,
@@ -141,6 +141,7 @@ export {
141
141
  type ISROptions,
142
142
  type ISRPlatform,
143
143
  type ISRQuery,
144
+ type ISRStoreOp,
144
145
  isCacheablePage,
145
146
  KVCacheStore,
146
147
  type KVCacheStoreOptions,