@nifrajs/web 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (407) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
@@ -0,0 +1,729 @@
1
+ /**
2
+ * One dev server's agent session, shared by both dev-server adapters (`./dev.ts`, `./vite.ts`) the way
3
+ * `./dev-diagnostics.ts` is: the feed, its HTTP surface, request tracking, the discovery record, console
4
+ * capture and the crash monitor live here once, so the two servers cannot drift on what an agent sees.
5
+ *
6
+ * Every agent read requires a loopback `Host` (a DNS-rebinding page cannot reach it) AND the token from
7
+ * the discovery record (owner-only file), so a script running in the app's own pages cannot read logs
8
+ * either. `/__nifra/last-error` keeps its older, token-free contract behind the same Host check.
9
+ */
10
+ import { performance } from "node:perf_hooks"
11
+ import {
12
+ type BrowserError,
13
+ CLIENT_BATCH_MAX_BYTES,
14
+ type ClientEvent,
15
+ type DevClientConfig,
16
+ devClientTag,
17
+ injectIntoHtml,
18
+ injectIntoStream,
19
+ isInjectablePage,
20
+ parseClientBatch,
21
+ } from "./dev-client.ts"
22
+ import { createDevDiagnostics, createSourceGate, type DevDiagnostics } from "./dev-diagnostics.ts"
23
+ import {
24
+ captureInto,
25
+ createDevFeed,
26
+ createDevToken,
27
+ currentDevRequest,
28
+ DEV_ERROR_CATEGORIES,
29
+ DEV_FEED_HEADER,
30
+ DEV_FEED_PATHS,
31
+ DEV_FEED_SCHEMA,
32
+ DEV_LOG_LEVELS,
33
+ DEV_REQUEST_ID_HEADER,
34
+ DEV_TOKEN_HEADER,
35
+ type DevErrorEntry,
36
+ type DevFeed,
37
+ type DevPipeline,
38
+ type DevServerIdentity,
39
+ type LogMeta,
40
+ removeDevServerRecord,
41
+ runWithDevRequest,
42
+ writeDevServerRecord,
43
+ } from "./dev-feed.ts"
44
+ import { DEV_INDICATOR_MAX_ISSUES, DEV_INDICATOR_SOURCE, type DevIssue } from "./dev-indicator.ts"
45
+ import { createSourceMapper } from "./dev-sourcemap.ts"
46
+ import { fixPrompts, isHydrationMismatch, LAST_ERROR_PATH, promptPath } from "./diagnostic.ts"
47
+ import { diagnosticHeadline } from "./diagnostic-prompt.ts"
48
+ import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
49
+ import { ISR_STATUS_HEADER } from "./isr.ts"
50
+ import { DATA_HEADER } from "./router.ts"
51
+
52
+ export interface DevSessionOptions {
53
+ readonly root: string
54
+ readonly pipeline: DevPipeline
55
+ readonly publicEnvPrefix?: string | undefined
56
+ /**
57
+ * Write the discovery record and the persisted log under `<root>/.nifra/` (default true). Tools find a
58
+ * running server through the record; turn it off only for a server nothing should discover.
59
+ */
60
+ readonly record?: boolean | undefined
61
+ /** Show browser errors in an in-page indicator with agent prompts (default true). */
62
+ readonly indicator?: boolean | undefined
63
+ }
64
+
65
+ /** What a dev server hands `createApp` so the app reports into the session. */
66
+ export interface DevAppHooks {
67
+ /** Pass to `createWebApp({ onLoaderError })`: failures an `_error` boundary renders reach the feed. */
68
+ readonly onLoaderError: (
69
+ err: unknown,
70
+ ctx: { readonly request: Request; readonly route: string; readonly params?: unknown },
71
+ ) => void
72
+ }
73
+
74
+ export interface DevSession {
75
+ readonly feed: DevFeed
76
+ /** The agent token tools present as `x-nifra-dev-token`. */
77
+ readonly token: string
78
+ readonly diagnostics: DevDiagnostics
79
+ /** Answer a `/__nifra/` agent path, or `undefined` for anything else. */
80
+ handle(request: Request): Promise<Response | undefined>
81
+ /**
82
+ * Run one app request as a tracked request: id header, trace, and entries tagged with its id. A page
83
+ * load the app answers with a bare 5xx after recording an error comes back as the overlay instead.
84
+ */
85
+ track(request: Request, handler: () => Promise<Response>): Promise<Response>
86
+ /** True for an overlay {@link track} returned: serve it as is, with no dev scripts added. */
87
+ isOverlay(response: Response): boolean
88
+ /**
89
+ * The page response with the browser-capture script first in its `<head>` (streamed, not buffered)
90
+ * and its CSP admitting the script. Anything that is not an HTML page comes back untouched.
91
+ */
92
+ decoratePage(request: Request, response: Response): Response
93
+ /**
94
+ * {@link decoratePage} for a page already in memory (the Vite pipeline, which nonces every script
95
+ * after this): rewrites `headers` in place and leaves script admission to that nonce.
96
+ */
97
+ decorateHtml(request: Request, html: string, headers: Headers, requestId?: string | null): string
98
+ /** Record a failure that escaped the app and return the overlay HTML for it. */
99
+ failure(err: unknown, request: { readonly method: string; readonly url: string }): string
100
+ /** For `createWebApp({ onLoaderError })`: loader/action/render failures a boundary answered. */
101
+ onLoaderError(
102
+ err: unknown,
103
+ ctx: { readonly request: Request; readonly route: string; readonly params?: unknown },
104
+ ): void
105
+ buildFailed(err: unknown, label: string): void
106
+ buildPassed(): void
107
+ markChange(): void
108
+ listening(port: number): void
109
+ stop(): void
110
+ }
111
+
112
+ const JSON_HEADERS: Readonly<Record<string, string>> = {
113
+ "content-type": "application/json; charset=utf-8",
114
+ "cache-control": "no-store",
115
+ "x-content-type-options": "nosniff",
116
+ [DEV_FEED_HEADER]: "true",
117
+ }
118
+
119
+ /** A Host header naming this machine: localhost, `*.localhost`, 127.0.0.0/8, or `[::1]`. */
120
+ export function isLoopbackHost(host: string | null | undefined): boolean {
121
+ if (host === null || host === undefined || host.length === 0) return false
122
+ let name = host.trim().toLowerCase()
123
+ if (name.startsWith("[")) {
124
+ const end = name.indexOf("]")
125
+ if (end === -1) return false
126
+ name = name.slice(1, end)
127
+ } else {
128
+ const colon = name.lastIndexOf(":")
129
+ if (colon !== -1) name = name.slice(0, colon)
130
+ }
131
+ return (
132
+ name === "localhost" ||
133
+ name.endsWith(".localhost") ||
134
+ name === "::1" ||
135
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)
136
+ )
137
+ }
138
+
139
+ const AGENT_PATHS: ReadonlySet<string> = new Set([
140
+ DEV_FEED_PATHS.identity,
141
+ DEV_FEED_PATHS.errors,
142
+ DEV_FEED_PATHS.logs,
143
+ DEV_FEED_PATHS.requests,
144
+ DEV_FEED_PATHS.clientEvent,
145
+ DEV_FEED_PATHS.indicator,
146
+ LAST_ERROR_PATH,
147
+ ])
148
+
149
+ /** Whether `pathname` is one the session answers (so an adapter hands it over before its own routing). */
150
+ export function isDevAgentPath(pathname: string): boolean {
151
+ return AGENT_PATHS.has(pathname)
152
+ }
153
+
154
+ const INDICATOR_HEADERS: Readonly<Record<string, string>> = {
155
+ "content-type": "text/javascript; charset=utf-8",
156
+ "cache-control": "no-store",
157
+ "x-content-type-options": "nosniff",
158
+ [DEV_FEED_HEADER]: "true",
159
+ }
160
+
161
+ const json = (body: unknown, status = 200): Response =>
162
+ new Response(JSON.stringify(body), { status, headers: JSON_HEADERS })
163
+
164
+ const intParam = (url: URL, name: string): number | undefined => {
165
+ const raw = url.searchParams.get(name)
166
+ if (raw === null || raw === "") return undefined
167
+ const value = Number(raw)
168
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
169
+ }
170
+
171
+ const textParam = (url: URL, name: string): string | undefined => {
172
+ const raw = url.searchParams.get(name)
173
+ return raw === null || raw === "" ? undefined : raw
174
+ }
175
+
176
+ const listParam = <T extends string>(
177
+ url: URL,
178
+ name: string,
179
+ allowed: readonly T[],
180
+ ): T[] | undefined => {
181
+ const raw = url.searchParams.get(name)
182
+ if (raw === null || raw === "") return undefined
183
+ return raw
184
+ .split(",")
185
+ .map((part) => part.trim())
186
+ .filter((part): part is T => allowed.some((value) => value === part))
187
+ }
188
+
189
+ const messageOf = (part: unknown): string => {
190
+ if (part instanceof Error) return part.message
191
+ if (typeof part === "object" && part !== null && "message" in part) {
192
+ if (typeof part.message === "string") return part.message
193
+ }
194
+ return String(part)
195
+ }
196
+
197
+ /** A Bun BuildMessage's `position` (file, 1-based line, 0-based column), when it carries one. */
198
+ const positionOf = (
199
+ part: unknown,
200
+ ): { readonly file: string; readonly line: number; readonly column: number } | undefined => {
201
+ if (typeof part !== "object" || part === null || !("position" in part)) return undefined
202
+ const position: unknown = part.position
203
+ if (typeof position !== "object" || position === null) return undefined
204
+ if (!("file" in position) || typeof position.file !== "string") return undefined
205
+ if (!("line" in position) || typeof position.line !== "number") return undefined
206
+ const column = "column" in position && typeof position.column === "number" ? position.column : 0
207
+ return { file: position.file, line: position.line, column }
208
+ }
209
+
210
+ /**
211
+ * A bundler failure as an Error a Diagnostic can locate. Bun rejects with an AggregateError of
212
+ * BuildMessages whose `position` names the file; that position becomes the top frame so the codeframe
213
+ * points at the offending line.
214
+ */
215
+ export function buildFailureError(err: unknown, label: string): Error {
216
+ const parts: unknown[] =
217
+ err instanceof AggregateError && err.errors.length > 0 ? err.errors : [err]
218
+ const messages = parts.map(messageOf)
219
+ const position = parts.map(positionOf).find((pos) => pos !== undefined)
220
+ const error = new Error(`${label}\n${messages.map((m) => ` ${m}`).join("\n")}`)
221
+ error.name = "BuildError"
222
+ const frames =
223
+ position !== undefined
224
+ ? ` at ${position.file}:${position.line}:${position.column + 1}`
225
+ : err instanceof Error && typeof err.stack === "string"
226
+ ? err.stack
227
+ .split("\n")
228
+ .filter((line) => /^\s*at\s/.test(line))
229
+ .join("\n")
230
+ : ""
231
+ error.stack = `BuildError: ${error.message}${frames === "" ? "" : `\n${frames}`}`
232
+ return error
233
+ }
234
+
235
+ /** The origin the browser used for this request (its own Host, which the session already vetted). */
236
+ const originOf = (request: Request): string =>
237
+ `http://${request.headers.get("host") ?? new URL(request.url).host}`
238
+
239
+ /** A request from a page on this same origin: its Origin names the Host it was sent to. */
240
+ function sameOrigin(request: Request): boolean {
241
+ const origin = request.headers.get("origin")
242
+ const host = request.headers.get("host")
243
+ if (origin === null || host === null) return false
244
+ const site = request.headers.get("sec-fetch-site")
245
+ if (site !== null && site !== "same-origin") return false
246
+ try {
247
+ const url = new URL(origin)
248
+ return url.protocol === "http:" && url.host.toLowerCase() === host.toLowerCase()
249
+ } catch {
250
+ return false
251
+ }
252
+ }
253
+
254
+ /** The body as text, or undefined once it passes `max` bytes (declared or actual). */
255
+ async function readCapped(request: Request, max: number): Promise<string | undefined> {
256
+ const declared = Number(request.headers.get("content-length") ?? "0")
257
+ if (declared > max) return undefined
258
+ if (request.body === null) return ""
259
+ const reader = request.body.getReader()
260
+ const chunks: Uint8Array[] = []
261
+ let total = 0
262
+ for (;;) {
263
+ const { done, value } = await reader.read()
264
+ if (done) break
265
+ total += value.byteLength
266
+ if (total > max) {
267
+ await reader.cancel()
268
+ return undefined
269
+ }
270
+ chunks.push(value)
271
+ }
272
+ return Buffer.concat(chunks).toString("utf8")
273
+ }
274
+
275
+ /** Create the session a dev server owns from construction to `stop()`. */
276
+ export function createDevSession(options: DevSessionOptions): DevSession {
277
+ const { root, pipeline } = options
278
+ const record = options.record !== false
279
+ const indicator = options.indicator !== false
280
+ const feed = createDevFeed({
281
+ root,
282
+ persist: record,
283
+ showSource: createSourceGate(root),
284
+ publicEnvPrefix: options.publicEnvPrefix,
285
+ })
286
+ const token = createDevToken()
287
+ // Embedded in every page, so it proves only "a page this server served": it admits event batches
288
+ // and nothing else. The agent token never leaves the discovery record.
289
+ const pageToken = createDevToken()
290
+ const startedAt = new Date().toISOString()
291
+ let port = 0
292
+ let requestCounter = 0
293
+ let stopped = false
294
+
295
+ const diagnostics = createDevDiagnostics(root, (err, request) => {
296
+ const entry = feed.recordError(err, { category: "ssr", request })
297
+ return {
298
+ diagnostic: entry.diagnostic,
299
+ entry: { id: entry.id, seq: entry.seq },
300
+ requestId: entry.requestId,
301
+ category: entry.category,
302
+ }
303
+ })
304
+
305
+ const detachCapture = captureInto(feed)
306
+ const mapper = createSourceMapper({
307
+ root,
308
+ origin: () => (port === 0 ? undefined : `http://127.0.0.1:${port}`),
309
+ })
310
+
311
+ // Observes without changing what a crash does: the process still dies exactly as it would have.
312
+ const onCrash = (err: unknown): void => {
313
+ try {
314
+ feed.recordError(err, { category: "process" })
315
+ feed.flush()
316
+ } catch {
317
+ // Recording a crash must never mask it.
318
+ }
319
+ }
320
+ process.on("uncaughtExceptionMonitor", onCrash)
321
+
322
+ const identity = (): DevServerIdentity => ({
323
+ schema: DEV_FEED_SCHEMA,
324
+ pid: process.pid,
325
+ port,
326
+ pipeline,
327
+ root,
328
+ startedAt,
329
+ cursor: feed.cursor,
330
+ generation: feed.generation,
331
+ counts: feed.counts(),
332
+ })
333
+
334
+ const authorized = (request: Request): boolean =>
335
+ timingSafeEqual(request.headers.get(DEV_TOKEN_HEADER) ?? "", token)
336
+
337
+ const handle = async (request: Request): Promise<Response | undefined> => {
338
+ const url = new URL(request.url)
339
+ const path = url.pathname
340
+ if (!AGENT_PATHS.has(path)) return undefined
341
+ if (!isLoopbackHost(request.headers.get("host") ?? url.host)) {
342
+ return json(
343
+ { code: "NIFRA_DEV_FORBIDDEN", message: "dev endpoints answer loopback hosts only" },
344
+ 403,
345
+ )
346
+ }
347
+ if (path === LAST_ERROR_PATH) {
348
+ const { body, headers } = diagnostics.lastError()
349
+ return new Response(body, { headers })
350
+ }
351
+ if (path === DEV_FEED_PATHS.clientEvent) return ingestClientEvents(request)
352
+ if (path === DEV_FEED_PATHS.indicator) {
353
+ if (!indicator) return json({ code: "NIFRA_DEV_NOT_FOUND", message: "indicator off" }, 404)
354
+ return new Response(DEV_INDICATOR_SOURCE, { headers: INDICATOR_HEADERS })
355
+ }
356
+ if (request.method !== "GET" && request.method !== "HEAD") {
357
+ return json({ code: "NIFRA_DEV_METHOD", message: "use GET" }, 405)
358
+ }
359
+ if (!authorized(request)) {
360
+ return json(
361
+ {
362
+ code: "NIFRA_DEV_UNAUTHORIZED",
363
+ message: `send the token from ${root}/.nifra/dev-server.json as ${DEV_TOKEN_HEADER}`,
364
+ },
365
+ 401,
366
+ )
367
+ }
368
+ if (path === DEV_FEED_PATHS.identity) return json(identity())
369
+ const since = intParam(url, "since")
370
+ const limit = intParam(url, "limit")
371
+ const requestId = textParam(url, "requestId")
372
+ if (path === DEV_FEED_PATHS.errors) {
373
+ return json(
374
+ feed.errors({
375
+ since,
376
+ limit,
377
+ requestId,
378
+ categories: listParam(url, "category", DEV_ERROR_CATEGORIES),
379
+ includeStale: url.searchParams.get("stale") !== "false",
380
+ includeResolved: url.searchParams.get("resolved") === "true",
381
+ }),
382
+ )
383
+ }
384
+ if (path === DEV_FEED_PATHS.logs) {
385
+ const source = url.searchParams.get("source")
386
+ return json(
387
+ feed.logs({
388
+ since,
389
+ limit,
390
+ requestId,
391
+ levels: listParam(url, "level", DEV_LOG_LEVELS),
392
+ source: source === "server" || source === "browser" ? source : undefined,
393
+ grep: textParam(url, "grep"),
394
+ }),
395
+ )
396
+ }
397
+ return json(feed.requests({ since, limit, requestId, path: textParam(url, "path") }))
398
+ }
399
+
400
+ // A page can loop on an error (a render that throws every frame): the bucket keeps it from flooding
401
+ // the feed. Refills at 30 events a second, holds 300.
402
+ const bucket = { tokens: 300, at: performance.now() }
403
+ let lastDropNotice = Number.NEGATIVE_INFINITY
404
+ const admit = (wanted: number): number => {
405
+ const now = performance.now()
406
+ bucket.tokens = Math.min(300, bucket.tokens + ((now - bucket.at) / 1000) * 30)
407
+ bucket.at = now
408
+ const granted = Math.min(wanted, Math.floor(bucket.tokens))
409
+ bucket.tokens -= granted
410
+ return granted
411
+ }
412
+
413
+ /** A browser error as an entry, its stack mapped back to source. */
414
+ const recordBrowserError = async (
415
+ error: BrowserError,
416
+ meta: LogMeta,
417
+ ): Promise<{ readonly head: string; readonly entry: DevErrorEntry }> => {
418
+ const head = `${error.name}: ${error.message}`
419
+ // Chrome leads a stack with its message; Firefox and Safari send frames only. Rebuild one shape.
420
+ const mapped = error.stack === "" ? "" : await mapper.mapStack(error.stack)
421
+ const frames = mapped
422
+ .split("\n")
423
+ .filter((line) => /^\s*at\s/.test(line))
424
+ .join("\n")
425
+ const rebuilt = new Error(error.message)
426
+ rebuilt.name = error.name
427
+ rebuilt.stack = frames === "" ? head : `${head}\n${frames}`
428
+ const entry = feed.recordError(rebuilt, {
429
+ ...meta,
430
+ category: isHydrationMismatch(error.message) ? "hydration" : "browser",
431
+ })
432
+ return { head, entry }
433
+ }
434
+
435
+ /** Record one browser event; returns the error entry it produced, if any. */
436
+ const recordClientEvent = async (event: ClientEvent): Promise<DevErrorEntry | undefined> => {
437
+ const meta: LogMeta = { source: "browser", page: event.page, requestId: event.requestId }
438
+ if (event.kind === "console") {
439
+ feed.recordLog(event.level, event.message, meta)
440
+ if (event.error !== undefined) return (await recordBrowserError(event.error, meta)).entry
441
+ // React 18, Vue and Svelte report a mismatch through the console rather than by throwing.
442
+ if (
443
+ (event.level === "error" || event.level === "warn") &&
444
+ isHydrationMismatch(event.message)
445
+ ) {
446
+ const recorded = await recordBrowserError(
447
+ { name: "HydrationMismatch", message: event.message, stack: "" },
448
+ meta,
449
+ )
450
+ return recorded.entry
451
+ }
452
+ return undefined
453
+ }
454
+ if (event.kind === "resource") {
455
+ const { head, entry } = await recordBrowserError(
456
+ { name: "ResourceError", message: `failed to load <${event.tag}> ${event.url}`, stack: "" },
457
+ meta,
458
+ )
459
+ feed.recordLog("error", head, meta)
460
+ return entry
461
+ }
462
+ const { head, entry } = await recordBrowserError(event, meta)
463
+ feed.recordLog(
464
+ "error",
465
+ `Uncaught ${event.kind === "rejection" ? "(in promise) " : ""}${head}`,
466
+ meta,
467
+ )
468
+ return entry
469
+ }
470
+
471
+ /** What the indicator shows for an entry: the redacted diagnostic, paths relative, and prompts. */
472
+ const issueOf = (entry: DevErrorEntry): DevIssue => {
473
+ const { diagnostic } = entry
474
+ const top = diagnostic.codeframe ?? diagnostic.frames.find((f) => f.file !== undefined)
475
+ return {
476
+ id: entry.id,
477
+ seq: entry.seq,
478
+ count: entry.count,
479
+ code: diagnostic.code,
480
+ category: entry.category,
481
+ message: diagnosticHeadline(diagnostic).slice(0, 2000),
482
+ page: entry.page,
483
+ at:
484
+ top?.file === undefined
485
+ ? undefined
486
+ : `${promptPath(top.file, root)}:${top.line ?? 0}${top.column === undefined ? "" : `:${top.column}`}`,
487
+ codeframe:
488
+ diagnostic.codeframe === undefined ? undefined : { lines: diagnostic.codeframe.lines },
489
+ cause: diagnostic.cause,
490
+ fix: diagnostic.fix,
491
+ docs:
492
+ diagnostic.docsAnchor === undefined
493
+ ? undefined
494
+ : `https://nifra.dev/docs/${diagnostic.docsAnchor}`,
495
+ prompts: fixPrompts(diagnostic, {
496
+ surface: "indicator",
497
+ root,
498
+ entry: { id: entry.id, seq: entry.seq },
499
+ requestId: entry.requestId,
500
+ page: entry.page,
501
+ category: entry.category,
502
+ }),
503
+ }
504
+ }
505
+
506
+ const ingestClientEvents = async (request: Request): Promise<Response> => {
507
+ if (request.method !== "POST")
508
+ return json({ code: "NIFRA_DEV_METHOD", message: "use POST" }, 405)
509
+ // Only this server's own pages: a cross-site form or `fetch` POST carries a foreign Origin.
510
+ if (!sameOrigin(request))
511
+ return json({ code: "NIFRA_DEV_FORBIDDEN", message: "same-origin pages only" }, 403)
512
+ const text = await readCapped(request, CLIENT_BATCH_MAX_BYTES)
513
+ if (text === undefined)
514
+ return json({ code: "NIFRA_DEV_TOO_LARGE", message: "batch too large" }, 413)
515
+ let value: unknown
516
+ try {
517
+ value = JSON.parse(text)
518
+ } catch {
519
+ return json({ code: "NIFRA_DEV_BAD_BATCH", message: "batch is not JSON" }, 400)
520
+ }
521
+ const batch = parseClientBatch(value)
522
+ if (batch === undefined)
523
+ return json({ code: "NIFRA_DEV_BAD_BATCH", message: "batch has the wrong shape" }, 400)
524
+ if (!timingSafeEqual(batch.token, pageToken))
525
+ return json({ code: "NIFRA_DEV_UNAUTHORIZED", message: "unknown page token" }, 401)
526
+ const granted = admit(batch.events.length)
527
+ const recorded: DevErrorEntry[] = []
528
+ for (const event of batch.events.slice(0, granted)) {
529
+ const entry = await recordClientEvent(event)
530
+ if (entry !== undefined) recorded.push(entry)
531
+ }
532
+ if (granted < batch.events.length) {
533
+ const now = performance.now()
534
+ if (now - lastDropNotice > 10_000) {
535
+ lastDropNotice = now
536
+ feed.recordLog(
537
+ "warn",
538
+ "[nifra] the browser is reporting faster than 30 events a second; dropping the excess",
539
+ )
540
+ }
541
+ return json({ code: "NIFRA_DEV_RATE_LIMITED", message: "slow down" }, 429)
542
+ }
543
+ // The page gets back only what it just reported, so this answer reveals nothing it did not send.
544
+ if (indicator && recorded.length > 0) {
545
+ return json({ issues: recorded.slice(-DEV_INDICATOR_MAX_ISSUES).map(issueOf) })
546
+ }
547
+ return new Response(null, { status: 204, headers: { [DEV_FEED_HEADER]: "true" } })
548
+ }
549
+
550
+ const clientConfig = (
551
+ request: Request,
552
+ requestId: string | null | undefined,
553
+ ): DevClientConfig => {
554
+ const url = new URL(request.url)
555
+ return {
556
+ ingestPath: DEV_FEED_PATHS.clientEvent,
557
+ pageToken,
558
+ indicatorPath: indicator ? DEV_FEED_PATHS.indicator : undefined,
559
+ requestId: requestId ?? undefined,
560
+ documentPath: `${url.pathname}${url.search}`,
561
+ }
562
+ }
563
+
564
+ const decoratePage = (request: Request, response: Response): Response => {
565
+ const body = response.body
566
+ if (body === null || !isInjectablePage(response, request.method)) return response
567
+ const headers = new Headers(response.headers)
568
+ const requestId = currentDevRequest()?.requestId ?? response.headers.get(DEV_REQUEST_ID_HEADER)
569
+ const tag = devClientTag(clientConfig(request, requestId), headers, originOf(request))
570
+ if (tag === undefined) return response
571
+ headers.delete("content-length") // the body grows by the script
572
+ return new Response(injectIntoStream(body, tag), {
573
+ status: response.status,
574
+ statusText: response.statusText,
575
+ headers,
576
+ })
577
+ }
578
+
579
+ const decorateHtml = (
580
+ request: Request,
581
+ html: string,
582
+ headers: Headers,
583
+ requestId?: string | null,
584
+ ): string => {
585
+ const tag = devClientTag(clientConfig(request, requestId), headers, originOf(request), true)
586
+ return tag === undefined ? html : injectIntoHtml(html, tag)
587
+ }
588
+
589
+ const isDocumentRequest = (request: Request): boolean => {
590
+ if (request.method !== "GET" && request.method !== "HEAD") return false
591
+ if (request.headers.has(DATA_HEADER)) return false
592
+ const dest = request.headers.get("sec-fetch-dest")
593
+ if (dest !== null) return dest === "document" || dest === "iframe"
594
+ return (request.headers.get("accept") ?? "").includes("text/html")
595
+ }
596
+
597
+ const overlays = new WeakSet<Response>()
598
+ // Core answers a page render that throws with a bare JSON 500, and its log line has already put the
599
+ // error in the feed under this request. A browser loading that page gets the overlay for it instead.
600
+ const overlayFor = async (
601
+ request: Request,
602
+ requestId: string,
603
+ response: Response,
604
+ ): Promise<Response | undefined> => {
605
+ if (response.status < 500 || !isDocumentRequest(request)) return undefined
606
+ if ((response.headers.get("content-type") ?? "").includes("text/html")) return undefined
607
+ const entry = feed.errors({ requestId }).errors.at(-1)
608
+ if (entry === undefined) return undefined
609
+ await response.body?.cancel()
610
+ const html = diagnostics.show({
611
+ diagnostic: entry.diagnostic,
612
+ entry: { id: entry.id, seq: entry.seq },
613
+ requestId,
614
+ category: entry.category,
615
+ })
616
+ const overlay = new Response(html, {
617
+ status: response.status,
618
+ headers: { "content-type": "text/html; charset=utf-8" },
619
+ })
620
+ overlays.add(overlay)
621
+ return overlay
622
+ }
623
+
624
+ const track = async (request: Request, handler: () => Promise<Response>): Promise<Response> => {
625
+ requestCounter += 1
626
+ const requestId = `r${requestCounter}`
627
+ const url = new URL(request.url)
628
+ const path = `${url.pathname}${url.search}`
629
+ feed.startRequest(requestId, request.method, path)
630
+ const started = performance.now()
631
+ let response: Response
632
+ try {
633
+ response = await runWithDevRequest(
634
+ { feedId: feed.id, requestId, method: request.method, path },
635
+ handler,
636
+ )
637
+ } catch (err) {
638
+ feed.finishRequest(requestId, { status: 500 }, performance.now() - started)
639
+ throw err
640
+ }
641
+ response = (await overlayFor(request, requestId, response)) ?? response
642
+ const length = response.headers.get("content-length")
643
+ feed.finishRequest(
644
+ requestId,
645
+ {
646
+ status: response.status,
647
+ bytes: length !== null && /^\d+$/.test(length) ? Number(length) : undefined,
648
+ isr: response.headers.get(ISR_STATUS_HEADER) ?? undefined,
649
+ },
650
+ performance.now() - started,
651
+ )
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
+ }
665
+ }
666
+
667
+ return {
668
+ feed,
669
+ token,
670
+ diagnostics,
671
+ handle,
672
+ track,
673
+ isOverlay: (response) => overlays.has(response),
674
+ decoratePage,
675
+ decorateHtml,
676
+ failure: (err, request) => diagnostics.capture(err, request),
677
+ onLoaderError: (err, ctx) => {
678
+ const url = new URL(ctx.request.url)
679
+ feed.recordError(err, {
680
+ category: "page",
681
+ route: ctx.route,
682
+ request: { method: ctx.request.method, url: `${url.pathname}${url.search}` },
683
+ })
684
+ },
685
+ buildFailed: (err, label) => {
686
+ feed.recordError(buildFailureError(err, label), { category: "build" })
687
+ },
688
+ buildPassed: () => feed.resolve("build"),
689
+ markChange: () => {
690
+ feed.markChange()
691
+ mapper.clear()
692
+ },
693
+ listening: (bound) => {
694
+ port = bound
695
+ if (!record) return
696
+ try {
697
+ writeDevServerRecord(root, {
698
+ schema: DEV_FEED_SCHEMA,
699
+ pid: process.pid,
700
+ port: bound,
701
+ url: `http://127.0.0.1:${bound}`,
702
+ pipeline,
703
+ root,
704
+ startedAt,
705
+ token,
706
+ })
707
+ } catch (err) {
708
+ feed.recordLog(
709
+ "warn",
710
+ `[nifra] could not write ${root}/.nifra/dev-server.json; tools will need an explicit port: ${err instanceof Error ? err.message : String(err)}`,
711
+ )
712
+ }
713
+ },
714
+ stop: () => {
715
+ if (stopped) return
716
+ stopped = true
717
+ detachCapture()
718
+ process.off("uncaughtExceptionMonitor", onCrash)
719
+ if (record) {
720
+ try {
721
+ removeDevServerRecord(root, token)
722
+ } catch {
723
+ // Already gone, or the directory is; nothing to clean.
724
+ }
725
+ }
726
+ feed.close()
727
+ },
728
+ }
729
+ }