@nifrajs/web 3.5.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
package/src/csp.ts ADDED
@@ -0,0 +1,194 @@
1
+ import { PRE_HYDRATION_GUARD } from "./internal/runtime-contract.ts"
2
+ import { createNonceResolver, type NonceResolver } from "./nonce.ts"
3
+ import type { RenderAdapter } from "./render-seam.ts"
4
+
5
+ /** What {@link CreateCspPolicyOptions.header} receives for one document. */
6
+ export interface CspHeaderContext {
7
+ /**
8
+ * The `script-src` sources this document needs, space-separated: a `'sha256-…'` hash for each
9
+ * constant inline script nifra writes, plus `'nonce-…'` when the document carries a script that is
10
+ * specific to this request. External scripts (the client entry, route chunks, islands) are not
11
+ * listed: allow their origin, usually `'self'`.
12
+ */
13
+ readonly sources: string
14
+ /** The nonce, when this document carries one. Such a document is `private, no-store`. */
15
+ readonly nonce?: string
16
+ }
17
+
18
+ export interface CreateCspPolicyOptions {
19
+ /**
20
+ * Build the complete Content-Security-Policy value for one document, or `undefined` to send none:
21
+ *
22
+ * ```ts
23
+ * header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`
24
+ * ```
25
+ */
26
+ readonly header: (context: CspHeaderContext) => string | undefined
27
+ /** Generate the nonce for a document that needs one. Default: 122 random bits, CSP base64-safe. */
28
+ readonly generate?: () => string
29
+ }
30
+
31
+ const CSP_POLICY: unique symbol = Symbol.for("nifra.web.cspPolicy")
32
+
33
+ /** How a `createWebApp` app's documents meet a CSP: `"nonce"` (every document carries one, so none is
34
+ * cacheable) or `"hash"` (a {@link CspPolicy}). Read by wrappers that cache documents. */
35
+ export const DOCUMENT_POLICY: unique symbol = Symbol.for("nifra.web.documentPolicy")
36
+
37
+ /** The hash sources for one adapter, and the hydration head they were computed from. */
38
+ interface AdapterSources {
39
+ readonly head: string
40
+ readonly sources: string
41
+ }
42
+
43
+ interface CspPolicyState {
44
+ readonly header: (context: CspHeaderContext) => string | undefined
45
+ readonly generate: () => string
46
+ readonly adapters: WeakMap<RenderAdapter, AdapterSources | Promise<AdapterSources>>
47
+ }
48
+
49
+ /**
50
+ * A hash-based Content-Security-Policy for nifra documents - see {@link createCspPolicy}. Opaque: pass
51
+ * it to `createWebApp({ csp })` or `renderPage({ csp })`.
52
+ */
53
+ export interface CspPolicy {
54
+ readonly [CSP_POLICY]: CspPolicyState
55
+ }
56
+
57
+ /**
58
+ * Create a hash-based Content-Security-Policy for nifra documents, so a page can carry a strict CSP
59
+ * and still be cached.
60
+ *
61
+ * A per-request nonce makes every document unique: nifra marks a nonce-bearing document
62
+ * `private, no-store`, so no shared cache (ISR, a CDN) may store it. Under this policy a document
63
+ * gets a nonce only when it carries a script specific to this request: a page that `defer()`s streams
64
+ * one per settled value, and `unsafeInlineScript` names the nonce. Every other document is nonce-free.
65
+ * Its inline scripts are the constant ones nifra writes, listed by hash, so the CSP header is the
66
+ * same on every request and the page stays cacheable. Page data rides in an inert JSON script, which
67
+ * a CSP does not govern.
68
+ *
69
+ * ```ts
70
+ * const csp = createCspPolicy({
71
+ * header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`,
72
+ * })
73
+ * const app = createWebApp({ adapter, manifest, clientEntry, csp })
74
+ * ```
75
+ */
76
+ export function createCspPolicy(options: CreateCspPolicyOptions): CspPolicy {
77
+ if (typeof options?.header !== "function") {
78
+ throw new TypeError("[nifra/web] createCspPolicy requires a header function")
79
+ }
80
+ return {
81
+ [CSP_POLICY]: {
82
+ header: options.header,
83
+ // A UUID's hyphens are outside the CSP base64-value grammar; the remaining 122 random bits are
84
+ // plenty for a per-response nonce, on every fetch runtime.
85
+ generate: options.generate ?? (() => crypto.randomUUID().replaceAll("-", "")),
86
+ adapters: new WeakMap(),
87
+ },
88
+ }
89
+ }
90
+
91
+ /** True for a value made by {@link createCspPolicy}. */
92
+ export const isCspPolicy = (value: unknown): value is CspPolicy =>
93
+ typeof value === "object" && value !== null && CSP_POLICY in value
94
+
95
+ // A closing tag ends at any space, slash or `>` after its name, as a browser reads it.
96
+ const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script(?=[\s/>])[^>]*>/gi
97
+
98
+ /** The bodies of the executable inline scripts in a fragment of markup (no `src`, no inert type). */
99
+ const inlineScriptBodies = (html: string): string[] => {
100
+ const bodies: string[] = []
101
+ for (const match of html.matchAll(INLINE_SCRIPT)) {
102
+ const attrs = match[1] ?? ""
103
+ if (/\bsrc\s*=/i.test(attrs) || /\btype\s*=\s*["']?application\/(ld\+)?json/i.test(attrs))
104
+ continue
105
+ bodies.push(match[2] ?? "")
106
+ }
107
+ return bodies
108
+ }
109
+
110
+ const ENCODER = new TextEncoder()
111
+
112
+ /** A CSP hash source for one inline script body: `'sha256-<base64 of the UTF-8 bytes>'`. */
113
+ async function hashSource(body: string): Promise<string> {
114
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", ENCODER.encode(body)))
115
+ let binary = ""
116
+ for (const byte of digest) binary += String.fromCharCode(byte)
117
+ return `'sha256-${btoa(binary)}'`
118
+ }
119
+
120
+ /**
121
+ * The `'sha256-…'` sources for the constant inline scripts nifra writes into a document rendered
122
+ * with `adapter`: the pre-hydration form guard and the adapter's hydration head. For a CSP set outside
123
+ * the app (a proxy, a CDN rule); {@link createCspPolicy} computes the same list itself.
124
+ */
125
+ export async function nifraScriptHashes(adapter: RenderAdapter): Promise<readonly string[]> {
126
+ const bodies = [PRE_HYDRATION_GUARD, ...inlineScriptBodies(adapter.hydrationHead())]
127
+ return Promise.all([...new Set(bodies)].map(hashSource))
128
+ }
129
+
130
+ /**
131
+ * Compute (once per adapter) the policy's hash sources. Returns `undefined` once they are ready, so
132
+ * the renderer stays synchronous after the first document.
133
+ */
134
+ export function prepareCspPolicy(
135
+ policy: CspPolicy,
136
+ adapter: RenderAdapter,
137
+ ): Promise<void> | undefined {
138
+ const state = policy[CSP_POLICY]
139
+ const known = state.adapters.get(adapter)
140
+ if (known !== undefined) return known instanceof Promise ? known.then(() => undefined) : undefined
141
+ const head = adapter.hydrationHead()
142
+ const pending = nifraScriptHashes(adapter).then((hashes) => {
143
+ const sources = { head, sources: hashes.join(" ") }
144
+ state.adapters.set(adapter, sources)
145
+ return sources
146
+ })
147
+ state.adapters.set(adapter, pending)
148
+ // A failed digest must not pin a rejected promise: the next document retries.
149
+ pending.catch(() => state.adapters.delete(adapter))
150
+ return pending.then(() => undefined)
151
+ }
152
+
153
+ const preparedSources = (policy: CspPolicy, adapter: RenderAdapter): AdapterSources => {
154
+ const known = policy[CSP_POLICY].adapters.get(adapter)
155
+ if (known === undefined || known instanceof Promise) {
156
+ throw new Error("[nifra/web] CSP policy used before prepareCspPolicy resolved")
157
+ }
158
+ return known
159
+ }
160
+
161
+ /**
162
+ * The nonce one document carries under `policy`: `undefined` unless the document needs one -
163
+ * `needsNonce` (a deferred value or a head tag naming the nonce), or an adapter whose hydration head
164
+ * no longer matches the one the policy hashed. A needed nonce reuses the caller's, else is generated.
165
+ */
166
+ export function cspDocumentNonce(
167
+ policy: CspPolicy,
168
+ adapter: RenderAdapter,
169
+ hydrate: boolean,
170
+ needsNonce: boolean,
171
+ requested: string | undefined,
172
+ ): string | undefined {
173
+ const varies = hydrate && adapter.hydrationHead() !== preparedSources(policy, adapter).head
174
+ if (!needsNonce && !varies) return undefined
175
+ return requested ?? policy[CSP_POLICY].generate()
176
+ }
177
+
178
+ /** The Content-Security-Policy value for one document, or `undefined` when the policy sends none. */
179
+ export function cspHeaderValue(
180
+ policy: CspPolicy,
181
+ adapter: RenderAdapter,
182
+ nonce: string | undefined,
183
+ ): string | undefined {
184
+ const { sources } = preparedSources(policy, adapter)
185
+ const state = policy[CSP_POLICY]
186
+ return nonce === undefined
187
+ ? state.header({ sources })
188
+ : state.header({ sources: `${sources} 'nonce-${nonce}'`, nonce })
189
+ }
190
+
191
+ /** The per-request nonce source for a page executor under `policy`: generated eagerly so `meta` can
192
+ * name it in `unsafeInlineScript`, and dropped by the renderer from documents that do not need it. */
193
+ export const cspNonceResolver = (policy: CspPolicy): NonceResolver =>
194
+ createNonceResolver({ generate: () => policy[CSP_POLICY].generate() })
@@ -0,0 +1,349 @@
1
+ /**
2
+ * The browser half of the dev feed: a small inline script a dev server puts first in each page's
3
+ * `<head>`, so the agent sees what the page did after it left the server - uncaught errors, unhandled
4
+ * rejections, failed script/stylesheet loads, hydration mismatches and console output.
5
+ *
6
+ * Inline and first, rather than a module, so it is listening before any module (the framework, the
7
+ * HMR client, the app) runs. Dev only: nothing here is reachable from a production build.
8
+ */
9
+
10
+ import { createHash } from "node:crypto"
11
+ import { type DevLogLevel, isDevLogLevel } from "./dev-feed.ts"
12
+ import { admitInlineScript } from "./internal/dev-csp.ts"
13
+
14
+ export interface DevClientConfig {
15
+ /** Where the batches go (same origin). */
16
+ readonly ingestPath: string
17
+ /** Proves a batch came from a page this server served. */
18
+ readonly pageToken: string
19
+ /** The request that rendered this document: browser entries from it carry the same id. */
20
+ readonly requestId?: string | undefined
21
+ /** The document's path + search, so a client-side navigation away stops claiming that request. */
22
+ readonly documentPath?: string | undefined
23
+ /** The issues indicator module, loaded on the first error the server answers for; none when off. */
24
+ readonly indicatorPath?: string | undefined
25
+ }
26
+
27
+ // ES2017, no dependencies, one global: `__nifraDevClient`, the idempotence flag that also queues issues
28
+ // for the indicator module. Keep it small: it ships in every dev page, and its hash goes into every CSP
29
+ // that needs one.
30
+ const CLIENT_SOURCE = String.raw`(function(C){
31
+ if(window.__nifraDevClient)return;var S=window.__nifraDevClient={issues:[]};
32
+ var nonce=document.currentScript&&document.currentScript.nonce,loading=0;
33
+ var q=[],timer=0,inside=0,MSG=8000,STACK=16000;
34
+ function cap(s,n){s=String(s);return s.length>n?s.slice(0,n)+"...":s}
35
+ function where(){return location.pathname+location.search}
36
+ function ser(v,d){
37
+ if(typeof v==="string")return v;
38
+ if(v instanceof Error)return v.stack||v.name+": "+v.message;
39
+ if(typeof v==="function")return"[Function "+(v.name||"anonymous")+"]";
40
+ if(typeof v!=="object"||v===null)return String(v);
41
+ if(typeof Element!=="undefined"&&v instanceof Element)return"<"+v.tagName.toLowerCase()+(v.id?"#"+v.id:"")+">";
42
+ if(d>2)return Array.isArray(v)?"[Array]":"[Object]";
43
+ var seen=[];
44
+ try{return JSON.stringify(v,function(k,x){
45
+ if(typeof x==="object"&&x!==null){if(seen.indexOf(x)!==-1)return"[Circular]";seen.push(x)}
46
+ if(typeof x==="bigint")return x+"n";
47
+ if(x instanceof Error)return x.name+": "+x.message;
48
+ return x})}catch(e){return Object.prototype.toString.call(v)}}
49
+ function fmt(a){
50
+ var args=Array.prototype.slice.call(a),out=[];
51
+ if(typeof args[0]==="string"&&args[0].indexOf("%")!==-1){
52
+ var rest=args.slice(1);
53
+ out.push(args[0].replace(/%([sdifoOjc%])/g,function(m,k){
54
+ if(k==="%")return"%";
55
+ if(!rest.length)return m;
56
+ var v=rest.shift();
57
+ if(k==="c")return"";
58
+ if(k==="d"||k==="i")return String(parseInt(v,10));
59
+ if(k==="f")return String(parseFloat(v));
60
+ return ser(v,0)}));
61
+ args=rest}
62
+ for(var i=0;i<args.length;i++)out.push(ser(args[i],0));
63
+ return out.join(" ")}
64
+ function send(keep){
65
+ clearTimeout(timer);timer=0;
66
+ while(q.length){
67
+ var body=JSON.stringify({token:C.t,events:q.splice(0,50)});
68
+ try{fetch(C.u,{method:"POST",body:body,keepalive:!!keep&&body.length<60000,headers:{"content-type":"application/json"},credentials:"same-origin"}).then(function(r){if(r.status===200&&C.i)return r.json().then(issues)}).catch(function(){})}catch(e){}}}
69
+ function issues(j){
70
+ var list=j&&j.issues;if(!list||!list.length)return;
71
+ if(S.indicator){S.indicator.add(list);return}
72
+ S.issues=S.issues.concat(list);if(loading)return;loading=1;
73
+ var s=document.createElement("script");s.type="module";s.src=C.i;if(nonce)s.nonce=nonce;
74
+ (document.head||document.documentElement).appendChild(s)}
75
+ function push(e){
76
+ e.page=cap(where(),2000);
77
+ if(C.r&&e.page===C.p)e.requestId=C.r;
78
+ q.push(e);
79
+ if(q.length>=50)send(false);else if(!timer)timer=setTimeout(send,100)}
80
+ ["debug","info","log","warn","error"].forEach(function(level){
81
+ var orig=console[level];if(typeof orig!=="function")return;
82
+ console[level]=function(){
83
+ if(!inside){inside=1;try{
84
+ var ev={kind:"console",level:level,message:cap(fmt(arguments),MSG)};
85
+ if(level==="error")for(var i=0;i<arguments.length;i++){var x=arguments[i];
86
+ if(x instanceof Error){ev.error={name:cap(x.name,200),message:cap(x.message,MSG),stack:cap(x.stack||"",STACK)};break}}
87
+ push(ev)}catch(e){}inside=0}
88
+ return orig.apply(this,arguments)}});
89
+ addEventListener("error",function(ev){
90
+ var t=ev.target;
91
+ if(t&&t!==window&&t.tagName){
92
+ var tag=t.tagName.toLowerCase(),rel=(t.rel||"").toLowerCase();
93
+ if(tag==="script"||(tag==="link"&&/stylesheet|modulepreload|preload/.test(rel)))push({kind:"resource",tag:tag,url:cap(t.src||t.href||"",2000)});
94
+ return}
95
+ var e=ev.error;
96
+ if(!e&&ev.message==="Script error.")return;
97
+ push({kind:"error",name:cap(e&&e.name?e.name:"Error",200),message:cap(e&&e.message!=null?e.message:ev.message,MSG),
98
+ stack:cap(e&&e.stack?e.stack:(ev.filename?" at "+ev.filename+":"+ev.lineno+":"+ev.colno:""),STACK)})},true);
99
+ addEventListener("unhandledrejection",function(ev){
100
+ var r=ev.reason;
101
+ if(r instanceof Error)push({kind:"rejection",name:cap(r.name,200),message:cap(r.message,MSG),stack:cap(r.stack||"",STACK)});
102
+ else push({kind:"rejection",name:"UnhandledRejection",message:cap(ser(r,0),MSG)})});
103
+ addEventListener("pagehide",function(){send(true)});
104
+ document.addEventListener("visibilitychange",function(){if(document.visibilityState==="hidden")send(true)});
105
+ })`
106
+
107
+ // `</script` inside a JSON value would end the element early; `<!--` can start a script-data escape.
108
+ const scriptSafe = (json: string): string =>
109
+ json
110
+ .replaceAll("<", "\\u003c")
111
+ .replaceAll(">", "\\u003e")
112
+ .replaceAll("\u2028", "\\u2028")
113
+ .replaceAll("\u2029", "\\u2029")
114
+
115
+ /** The script's text (without the `<script>` element). */
116
+ export function devClientSource(config: DevClientConfig): string {
117
+ const settings: Record<string, string> = { u: config.ingestPath, t: config.pageToken }
118
+ if (config.indicatorPath !== undefined) settings.i = config.indicatorPath
119
+ if (config.requestId !== undefined && config.documentPath !== undefined) {
120
+ settings.r = config.requestId
121
+ settings.p = config.documentPath
122
+ }
123
+ return `${CLIENT_SOURCE}(${scriptSafe(JSON.stringify(settings))})`
124
+ }
125
+
126
+ /** The CSP source that admits exactly `source`. */
127
+ export function scriptHash(source: string): string {
128
+ return `'sha256-${createHash("sha256").update(source).digest("base64")}'`
129
+ }
130
+
131
+ /** Whether a response is a page the script belongs in: HTML, with a body nobody has encoded yet. */
132
+ export function isInjectablePage(response: Response, method: string): boolean {
133
+ if (method === "HEAD" || response.body === null) return false
134
+ if (response.status === 204 || response.status === 304) return false
135
+ if (response.headers.has("content-encoding")) return false
136
+ return (response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
137
+ }
138
+
139
+ /**
140
+ * Prepare `headers` for the script: admit it in the page's CSP, by hash unless `nonced` (the caller
141
+ * puts a nonce on every script afterwards). Returns the `<script>` element to insert, or undefined
142
+ * when the page's CSP forbids scripts and nothing should be inserted.
143
+ */
144
+ export function devClientTag(
145
+ config: DevClientConfig,
146
+ headers: Headers,
147
+ origin: string,
148
+ nonced = false,
149
+ ): string | undefined {
150
+ const source = devClientSource(config)
151
+ const hash = nonced ? undefined : scriptHash(source)
152
+ // A nonced client hands its nonce to the module it loads, so only a hashed one needs the URL admitted.
153
+ const indicator =
154
+ nonced || config.indicatorPath === undefined ? undefined : new URL(config.indicatorPath, origin)
155
+ if (!admitInlineScript(headers, hash, new URL(config.ingestPath, origin), indicator))
156
+ return undefined
157
+ return `<script data-nifra-dev>${source}</script>`
158
+ }
159
+
160
+ // Latin-1 views keep string offsets equal to byte offsets, so a match position is a splice point.
161
+ const latin1 = new TextDecoder("latin1")
162
+ const HEAD_OPEN = /<head(?:\s[^>]*)?>/i
163
+ const META_CHARSET = /^\s*<meta\s[^>]*charset\s*=[^>]*>/i
164
+ // Past this, a page without `<head>` is a fragment: stop looking and stream it as it is.
165
+ const HEAD_SEARCH_LIMIT = 64 * 1024
166
+ // Enough to see a `<meta charset>` that immediately follows `<head>`.
167
+ const META_LOOKAHEAD = 256
168
+
169
+ /** Where the script goes in `text`: after `<head>`, and after a `<meta charset>` that leads it. */
170
+ function insertionPoint(text: string, final: boolean): number | "wait" | "none" {
171
+ const head = HEAD_OPEN.exec(text)
172
+ if (head === null) {
173
+ const body = /<body[\s>]/i.test(text)
174
+ return final || body || text.length > HEAD_SEARCH_LIMIT ? "none" : "wait"
175
+ }
176
+ const afterHead = head.index + head[0].length
177
+ const rest = text.slice(afterHead)
178
+ const meta = META_CHARSET.exec(rest)
179
+ if (meta !== null) return afterHead + meta[0].length
180
+ if (!final && rest.length < META_LOOKAHEAD && !/<(?!meta)/i.test(rest.replace(/^\s+/, "")))
181
+ return "wait"
182
+ return afterHead
183
+ }
184
+
185
+ /** `html` with `tag` inserted into its head; unchanged when there is no `<head>`. */
186
+ export function injectIntoHtml(html: string, tag: string): string {
187
+ const at = insertionPoint(html, true)
188
+ return typeof at === "number" ? html.slice(0, at) + tag + html.slice(at) : html
189
+ }
190
+
191
+ function concat(chunks: readonly Uint8Array[], length: number): Uint8Array {
192
+ const out = new Uint8Array(length)
193
+ let offset = 0
194
+ for (const chunk of chunks) {
195
+ out.set(chunk, offset)
196
+ offset += chunk.byteLength
197
+ }
198
+ return out
199
+ }
200
+
201
+ /**
202
+ * Stream `body` with `tag` inserted into its head. Only the prefix up to the insertion point is held
203
+ * back; the rest streams through untouched, so a streamed SSR page keeps its first-byte time.
204
+ */
205
+ export function injectIntoStream(
206
+ body: ReadableStream<Uint8Array>,
207
+ tag: string,
208
+ ): ReadableStream<Uint8Array> {
209
+ const insert = new TextEncoder().encode(tag)
210
+ const held: Uint8Array[] = []
211
+ let heldBytes = 0
212
+ let done = false
213
+ const release = (
214
+ controller: TransformStreamDefaultController<Uint8Array>,
215
+ final: boolean,
216
+ ): void => {
217
+ const bytes = concat(held, heldBytes)
218
+ const at = insertionPoint(latin1.decode(bytes), final)
219
+ if (at === "wait") return
220
+ held.length = 0
221
+ heldBytes = 0
222
+ done = true
223
+ if (at === "none") {
224
+ controller.enqueue(bytes)
225
+ return
226
+ }
227
+ controller.enqueue(bytes.subarray(0, at))
228
+ controller.enqueue(insert)
229
+ controller.enqueue(bytes.subarray(at))
230
+ }
231
+ return body.pipeThrough(
232
+ new TransformStream<Uint8Array, Uint8Array>({
233
+ transform(chunk, controller) {
234
+ if (done) {
235
+ controller.enqueue(chunk)
236
+ return
237
+ }
238
+ held.push(chunk)
239
+ heldBytes += chunk.byteLength
240
+ release(controller, false)
241
+ },
242
+ flush(controller) {
243
+ if (!done && heldBytes > 0) release(controller, true)
244
+ },
245
+ }),
246
+ )
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------------------------------
250
+ // What the script sends
251
+ // ---------------------------------------------------------------------------------------------------
252
+
253
+ /** One batch is bounded by what `fetch(keepalive)` may carry. */
254
+ export const CLIENT_BATCH_MAX_BYTES = 64 * 1024
255
+ export const CLIENT_BATCH_MAX_EVENTS = 50
256
+
257
+ interface EventContext {
258
+ readonly page: string
259
+ readonly requestId?: string | undefined
260
+ }
261
+
262
+ export interface BrowserError {
263
+ readonly name: string
264
+ readonly message: string
265
+ readonly stack: string
266
+ }
267
+
268
+ export type ClientEvent = EventContext &
269
+ (
270
+ | {
271
+ readonly kind: "error" | "rejection"
272
+ readonly name: string
273
+ readonly message: string
274
+ readonly stack: string
275
+ }
276
+ | { readonly kind: "resource"; readonly tag: string; readonly url: string }
277
+ | {
278
+ readonly kind: "console"
279
+ readonly level: DevLogLevel
280
+ readonly message: string
281
+ /** The first Error a `console.error` call was given: frameworks report caught errors so. */
282
+ readonly error?: BrowserError | undefined
283
+ }
284
+ )
285
+
286
+ export interface ClientBatch {
287
+ readonly token: string
288
+ readonly events: readonly ClientEvent[]
289
+ }
290
+
291
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
292
+ typeof value === "object" && value !== null && !Array.isArray(value)
293
+
294
+ const text = (value: unknown, max: number): string | undefined =>
295
+ typeof value === "string" ? value.slice(0, max) : undefined
296
+
297
+ // The script only ever tags an event with the id the server put in it; anything else is dropped.
298
+ const REQUEST_ID = /^r\d{1,12}$/
299
+
300
+ function parseBrowserError(value: unknown): BrowserError | undefined {
301
+ if (!isRecord(value)) return undefined
302
+ const message = text(value.message, 8 * 1024)
303
+ if (message === undefined) return undefined
304
+ return {
305
+ name: text(value.name, 200) || "Error",
306
+ message,
307
+ stack: text(value.stack, 16 * 1024) ?? "",
308
+ }
309
+ }
310
+
311
+ function parseEvent(value: unknown): ClientEvent | undefined {
312
+ if (!isRecord(value)) return undefined
313
+ const page = text(value.page, 2048)
314
+ if (page === undefined || !page.startsWith("/")) return undefined
315
+ const requestId =
316
+ typeof value.requestId === "string" && REQUEST_ID.test(value.requestId)
317
+ ? value.requestId
318
+ : undefined
319
+ if (value.kind === "error" || value.kind === "rejection") {
320
+ const error = parseBrowserError(value)
321
+ return error === undefined ? undefined : { kind: value.kind, ...error, page, requestId }
322
+ }
323
+ if (value.kind === "resource") {
324
+ const tag = text(value.tag, 20)
325
+ const url = text(value.url, 2048)
326
+ if (tag === undefined || url === undefined) return undefined
327
+ return { kind: "resource", tag, url, page, requestId }
328
+ }
329
+ if (value.kind === "console") {
330
+ const message = text(value.message, 8 * 1024)
331
+ if (message === undefined || !isDevLogLevel(value.level)) return undefined
332
+ const error = parseBrowserError(value.error)
333
+ return { kind: "console", level: value.level, message, error, page, requestId }
334
+ }
335
+ return undefined
336
+ }
337
+
338
+ /** A batch the script could have sent, or undefined. Malformed events are dropped, not fatal. */
339
+ export function parseClientBatch(value: unknown): ClientBatch | undefined {
340
+ if (!isRecord(value) || typeof value.token !== "string" || !Array.isArray(value.events))
341
+ return undefined
342
+ if (value.events.length > CLIENT_BATCH_MAX_EVENTS) return undefined
343
+ const events: ClientEvent[] = []
344
+ for (const raw of value.events) {
345
+ const event = parseEvent(raw)
346
+ if (event !== undefined) events.push(event)
347
+ }
348
+ return { token: value.token, events }
349
+ }
@@ -8,8 +8,34 @@
8
8
  *
9
9
  * Vite once shipped without the `/__nifra/last-error` endpoint at all - the drift this module prevents.
10
10
  */
11
+ import { relative } from "node:path"
11
12
  import { renderDiagnosticOverlay } from "./dev-error.ts"
12
- import { buildDiagnostic, type Diagnostic, LAST_ERROR_PATH } from "./diagnostic.ts"
13
+ import { buildDiagnostic, type Diagnostic, fixPrompts, LAST_ERROR_PATH } from "./diagnostic.ts"
14
+ import { browserDenial, createZoneClassifier } from "./zones.ts"
15
+
16
+ const CODEFRAME_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i
17
+
18
+ /**
19
+ * Whether a codeframe may show `file` (the canonical path it would read) to a browser: source in a
20
+ * browser zone. A page reports its own stack frames, so it can name any path: data files and the dev
21
+ * server's own `.nifra/` files (the discovery record holds the agent token) never qualify.
22
+ */
23
+ export function createSourceGate(root: string): (file: string) => boolean {
24
+ const zones = createZoneClassifier({ appRoot: root })
25
+ return (file) => {
26
+ if (!CODEFRAME_SOURCE.test(file)) return false
27
+ if ((relative(zones.appRoot, file).split(/[\\/]/)[0] ?? "").startsWith(".nifra")) return false
28
+ return browserDenial(zones.classify(file)) === undefined
29
+ }
30
+ }
31
+
32
+ /** A failure as the dev feed recorded it: the entry and request let its agent prompt name a check. */
33
+ export interface CapturedFailure {
34
+ readonly diagnostic: Diagnostic
35
+ readonly entry?: { readonly id: string; readonly seq: number } | undefined
36
+ readonly requestId?: string | undefined
37
+ readonly category?: string | undefined
38
+ }
13
39
 
14
40
  export interface DevDiagnostics {
15
41
  /** True when a request path targets the structured last-error endpoint. */
@@ -20,12 +46,36 @@ export interface DevDiagnostics {
20
46
  /** Capture a thrown SSR failure: store it for the endpoint and return the overlay HTML. The overlay a
21
47
  * person sees and the JSON an agent reads come from this one Diagnostic, so they can never disagree. */
22
48
  capture(err: unknown, request: { readonly method: string; readonly url: string }): string
49
+ /** The overlay HTML for a failure the feed already recorded, which also becomes the last error. */
50
+ show(captured: CapturedFailure): string
23
51
  }
24
52
 
25
53
  /** One diagnostics surface per dev server. `root` is the resolved project root; it scopes the codeframe
26
- * to the project (see buildDiagnostic). Both dev servers resolve a concrete root before calling this. */
27
- export function createDevDiagnostics(root: string): DevDiagnostics {
54
+ * to the project (see buildDiagnostic). Both dev servers resolve a concrete root before calling this.
55
+ * `build` replaces the default Diagnostic builder: the dev session passes one that also records the
56
+ * failure in its feed (redacted), so the overlay, this endpoint and the feed show the same object. */
57
+ export function createDevDiagnostics(
58
+ root: string,
59
+ build?: (
60
+ err: unknown,
61
+ request: { readonly method: string; readonly url: string },
62
+ ) => CapturedFailure,
63
+ ): DevDiagnostics {
28
64
  let last: Diagnostic | undefined
65
+ const showSource = createSourceGate(root)
66
+ const show = (captured: CapturedFailure): string => {
67
+ last = captured.diagnostic
68
+ return renderDiagnosticOverlay(
69
+ last,
70
+ fixPrompts(last, {
71
+ surface: "overlay",
72
+ root,
73
+ entry: captured.entry,
74
+ requestId: captured.requestId,
75
+ category: captured.category ?? "ssr",
76
+ }),
77
+ )
78
+ }
29
79
  return {
30
80
  isLastErrorPath: (pathname) => pathname === LAST_ERROR_PATH,
31
81
  lastError: () => ({
@@ -39,9 +89,12 @@ export function createDevDiagnostics(root: string): DevDiagnostics {
39
89
  "x-nifra-diagnostic": "true",
40
90
  },
41
91
  }),
42
- capture: (err, request) => {
43
- last = buildDiagnostic(err, { root, request })
44
- return renderDiagnosticOverlay(last)
45
- },
92
+ capture: (err, request) =>
93
+ show(
94
+ build?.(err, request) ?? {
95
+ diagnostic: buildDiagnostic(err, { root, request, showSource }),
96
+ },
97
+ ),
98
+ show,
46
99
  }
47
100
  }