@nifrajs/web 3.5.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
@@ -0,0 +1,1140 @@
1
+ /**
2
+ * `@nifrajs/web/dev-feed` - what a running dev server saw, kept so the agent driving it can ask.
3
+ *
4
+ * Both dev servers (`./dev.ts`, `./vite.ts`) record into one {@link DevFeed}: every error with its
5
+ * structured {@link Diagnostic} (failures a `_error` boundary rendered, backend 500s, build and guard
6
+ * failures, browser errors, crashes), every console line, and every request. `nifra errors`,
7
+ * `nifra logs` and `nifra inspect` (and their MCP tools) read it back over the dev server's own HTTP
8
+ * surface, found through the discovery record this module also owns.
9
+ *
10
+ * Three properties hold for everything stored here:
11
+ * - Redacted on the way in. Secret formats and non-public env values are scrubbed before an entry
12
+ * exists, so nothing unredacted is ever served, persisted, or handed to an agent.
13
+ * - Correlated. An entry recorded while a request is in flight carries that request's id.
14
+ * - Edit-aware. Every entry has a monotonic `seq` (query with `since`) and the `generation` it was last
15
+ * seen in; a file change bumps the generation, so an error recorded before the latest edit reads as
16
+ * `stale` instead of current.
17
+ *
18
+ * Dev-only by construction: no production entry imports this module.
19
+ */
20
+ import { AsyncLocalStorage } from "node:async_hooks"
21
+ import { randomBytes } from "node:crypto"
22
+ import {
23
+ appendFileSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readFileSync,
27
+ renameSync,
28
+ rmSync,
29
+ statSync,
30
+ writeFileSync,
31
+ } from "node:fs"
32
+ import { dirname, join } from "node:path"
33
+ import { formatWithOptions, stripVTControlCharacters } from "node:util"
34
+ import {
35
+ type BuildDiagnosticOptions,
36
+ buildDiagnostic,
37
+ type Diagnostic,
38
+ type DiagnosticFrame,
39
+ } from "./diagnostic.ts"
40
+ import { createRedactor } from "./internal/secret-scan.ts"
41
+ import { originalPosition, ssrSourceMaps } from "./internal/source-map.ts"
42
+
43
+ /** Version of the HTTP + record contract between a dev server and the tools that read it. */
44
+ export const DEV_FEED_SCHEMA = 1
45
+
46
+ /** The dev server's agent-facing endpoints. All live under `/__nifra/`, which no route can produce. */
47
+ export const DEV_FEED_PATHS = Object.freeze({
48
+ identity: "/__nifra/dev",
49
+ errors: "/__nifra/errors",
50
+ logs: "/__nifra/logs",
51
+ requests: "/__nifra/requests",
52
+ clientEvent: "/__nifra/client-event",
53
+ indicator: "/__nifra/dev-indicator.js",
54
+ })
55
+
56
+ /** Request header carrying the agent token from the discovery record. */
57
+ export const DEV_TOKEN_HEADER = "x-nifra-dev-token"
58
+ /** Response header naming the request id every entry recorded during that request carries. */
59
+ export const DEV_REQUEST_ID_HEADER = "x-nifra-request-id"
60
+ /** Response header marking a body as dev-feed JSON (so a tool can tell it from an app route). */
61
+ export const DEV_FEED_HEADER = "x-nifra-dev-feed"
62
+
63
+ /** Discovery record and persisted log, relative to the project root. `.nifra/` is gitignored. */
64
+ export const DEV_SERVER_RECORD_FILE = ".nifra/dev-server.json"
65
+ export const DEV_SERVER_LOG_FILE = ".nifra/dev-server.log"
66
+ /**
67
+ * Every file the feed writes (record, its temp file, log, rotated log): what a file watcher skips. A
68
+ * RegExp, not a glob: a glob's `**` does not cross a dot-directory such as the project's own path.
69
+ */
70
+ export const DEV_SERVER_FILES = /[\\/]\.nifra[\\/]dev-server\./
71
+
72
+ export type DevErrorCategory =
73
+ /** A failure that escaped the app entirely; the dev overlay rendered it. */
74
+ | "ssr"
75
+ /** A loader, action or render failure an `_error` boundary (or a soft-nav 500) answered. */
76
+ | "page"
77
+ /** An unhandled backend route error (a bare 500). */
78
+ | "api"
79
+ /** A client bundle, leak-guard, transform or app-construction failure. */
80
+ | "build"
81
+ /** An uncaught error or rejection in a browser tab. */
82
+ | "browser"
83
+ /** A hydration mismatch reported by the browser. */
84
+ | "hydration"
85
+ /** The dev server process itself crashed. */
86
+ | "process"
87
+
88
+ export const DEV_ERROR_CATEGORIES: readonly DevErrorCategory[] = [
89
+ "ssr",
90
+ "page",
91
+ "api",
92
+ "build",
93
+ "browser",
94
+ "hydration",
95
+ "process",
96
+ ]
97
+
98
+ export type DevEntrySource = "server" | "browser"
99
+ export type DevLogLevel = "debug" | "info" | "log" | "warn" | "error"
100
+ export const DEV_LOG_LEVELS: readonly DevLogLevel[] = ["debug", "info", "log", "warn", "error"]
101
+
102
+ /** Narrow a string to a {@link DevLogLevel}. */
103
+ export function isDevLogLevel(value: unknown): value is DevLogLevel {
104
+ return DEV_LOG_LEVELS.some((level) => level === value)
105
+ }
106
+
107
+ /** Narrow a string to a {@link DevErrorCategory}. */
108
+ export function isDevErrorCategory(value: unknown): value is DevErrorCategory {
109
+ return DEV_ERROR_CATEGORIES.some((category) => category === value)
110
+ }
111
+
112
+ export interface DevErrorEntry {
113
+ /** Sequence number of the latest occurrence; query with `since` to see only newer entries. */
114
+ readonly seq: number
115
+ /** Stable across repeats of the same failure. */
116
+ readonly id: string
117
+ readonly category: DevErrorCategory
118
+ readonly source: DevEntrySource
119
+ readonly diagnostic: Diagnostic
120
+ readonly count: number
121
+ readonly firstAt: string
122
+ readonly lastAt: string
123
+ /** The file-change generation of the latest occurrence. */
124
+ readonly generation: number
125
+ /** True when a file changed after the latest occurrence: re-run the request to confirm it persists. */
126
+ readonly stale: boolean
127
+ readonly requestId?: string
128
+ /** The route pattern, when a page loader/action/render failed. */
129
+ readonly route?: string
130
+ /** The page path a browser entry came from. */
131
+ readonly page?: string
132
+ /** Set when a later success cleared it (build errors). */
133
+ readonly resolvedAt?: string
134
+ }
135
+
136
+ export interface DevLogEntry {
137
+ readonly seq: number
138
+ readonly at: string
139
+ readonly level: DevLogLevel
140
+ readonly source: DevEntrySource
141
+ readonly message: string
142
+ readonly requestId?: string
143
+ readonly page?: string
144
+ }
145
+
146
+ export interface DevRequestTrace {
147
+ readonly seq: number
148
+ readonly requestId: string
149
+ readonly method: string
150
+ readonly path: string
151
+ readonly at: string
152
+ /** Undefined while the request has not produced its response yet. */
153
+ readonly status?: number
154
+ /** Until the response started (a streamed body keeps flowing after). */
155
+ readonly durationMs?: number
156
+ readonly bytes?: number
157
+ readonly isr?: string
158
+ readonly errorIds: readonly string[]
159
+ readonly logCount: number
160
+ }
161
+
162
+ export interface DevErrorsQuery {
163
+ readonly since?: number | undefined
164
+ readonly categories?: readonly DevErrorCategory[] | undefined
165
+ /** Default true. */
166
+ readonly includeStale?: boolean | undefined
167
+ /** Default false. */
168
+ readonly includeResolved?: boolean | undefined
169
+ readonly requestId?: string | undefined
170
+ readonly limit?: number | undefined
171
+ }
172
+
173
+ export interface DevLogsQuery {
174
+ readonly since?: number | undefined
175
+ readonly levels?: readonly DevLogLevel[] | undefined
176
+ readonly source?: DevEntrySource | undefined
177
+ readonly requestId?: string | undefined
178
+ /** Case-insensitive substring. */
179
+ readonly grep?: string | undefined
180
+ readonly limit?: number | undefined
181
+ }
182
+
183
+ export interface DevRequestsQuery {
184
+ readonly since?: number | undefined
185
+ /** Path prefix. */
186
+ readonly path?: string | undefined
187
+ readonly requestId?: string | undefined
188
+ readonly limit?: number | undefined
189
+ }
190
+
191
+ interface PageInfo {
192
+ /** Highest seq in the feed: pass it back as `since` to see only what happens next. */
193
+ readonly cursor: number
194
+ readonly generation: number
195
+ }
196
+ export interface DevErrorsResult extends PageInfo {
197
+ readonly errors: readonly DevErrorEntry[]
198
+ }
199
+ export interface DevLogsResult extends PageInfo {
200
+ readonly logs: readonly DevLogEntry[]
201
+ /** Lines evicted from the ring since the server started. */
202
+ readonly dropped: number
203
+ }
204
+ export interface DevRequestsResult extends PageInfo {
205
+ readonly requests: readonly DevRequestTrace[]
206
+ }
207
+
208
+ export type DevPipeline = "bun" | "vite"
209
+
210
+ /** The identity a dev server reports at {@link DEV_FEED_PATHS.identity}. */
211
+ export interface DevServerIdentity {
212
+ readonly schema: number
213
+ readonly pid: number
214
+ readonly port: number
215
+ readonly pipeline: DevPipeline
216
+ readonly root: string
217
+ readonly startedAt: string
218
+ readonly cursor: number
219
+ readonly generation: number
220
+ readonly counts: { readonly errors: number; readonly logs: number; readonly requests: number }
221
+ }
222
+
223
+ export interface ErrorMeta {
224
+ readonly category: DevErrorCategory
225
+ readonly source?: DevEntrySource | undefined
226
+ readonly requestId?: string | undefined
227
+ readonly route?: string | undefined
228
+ readonly page?: string | undefined
229
+ readonly request?: { readonly method: string; readonly url: string } | undefined
230
+ }
231
+
232
+ export interface LogMeta {
233
+ readonly source?: DevEntrySource | undefined
234
+ readonly requestId?: string | undefined
235
+ readonly page?: string | undefined
236
+ }
237
+
238
+ export interface DevFeedLimits {
239
+ readonly errors: number
240
+ readonly logs: number
241
+ readonly logBytes: number
242
+ readonly requests: number
243
+ readonly message: number
244
+ readonly stack: number
245
+ }
246
+
247
+ export const DEFAULT_DEV_FEED_LIMITS: DevFeedLimits = Object.freeze({
248
+ errors: 100,
249
+ logs: 2000,
250
+ logBytes: 1024 * 1024,
251
+ requests: 500,
252
+ message: 8 * 1024,
253
+ stack: 16 * 1024,
254
+ })
255
+
256
+ export interface DevFeedOptions {
257
+ readonly root: string
258
+ readonly publicEnvPrefix?: string | undefined
259
+ /** The environment whose non-public values are redacted (default `process.env`). */
260
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined
261
+ /** Append redacted entries to {@link DEV_SERVER_LOG_FILE} (default true). */
262
+ readonly persist?: boolean | undefined
263
+ /** Whether a file's source may appear in a codeframe. Dev servers pass the zone check. */
264
+ readonly showSource?: ((file: string) => boolean) | undefined
265
+ readonly limits?: Partial<DevFeedLimits> | undefined
266
+ }
267
+
268
+ export interface DevFeed {
269
+ /** Identifies this feed's requests to the process-wide capture. */
270
+ readonly id: string
271
+ readonly root: string
272
+ readonly generation: number
273
+ readonly cursor: number
274
+ recordError(error: unknown, meta: ErrorMeta): DevErrorEntry
275
+ recordDiagnostic(diagnostic: Diagnostic, meta: ErrorMeta): DevErrorEntry
276
+ recordLog(level: DevLogLevel, message: string, meta?: LogMeta): void
277
+ startRequest(requestId: string, method: string, path: string): void
278
+ finishRequest(
279
+ requestId: string,
280
+ response: {
281
+ readonly status: number
282
+ readonly bytes?: number | undefined
283
+ readonly isr?: string | undefined
284
+ },
285
+ durationMs: number,
286
+ ): void
287
+ /** A watched file changed: everything recorded so far becomes stale. */
288
+ markChange(): void
289
+ /** A later success cleared every open error of `category`. */
290
+ resolve(category: DevErrorCategory): void
291
+ errors(query?: DevErrorsQuery): DevErrorsResult
292
+ logs(query?: DevLogsQuery): DevLogsResult
293
+ requests(query?: DevRequestsQuery): DevRequestsResult
294
+ counts(): DevServerIdentity["counts"]
295
+ redact(text: string): string
296
+ /** Flush persisted entries; the feed stays usable. */
297
+ flush(): void
298
+ /** Flush and stop persisting. */
299
+ close(): void
300
+ }
301
+
302
+ type Writable<T> = { -readonly [K in keyof T]: T[K] }
303
+
304
+ // ---------------------------------------------------------------------------------------------------
305
+ // Request context
306
+ // ---------------------------------------------------------------------------------------------------
307
+
308
+ export interface DevRequestContext {
309
+ readonly feedId: string
310
+ readonly requestId: string
311
+ readonly method: string
312
+ readonly path: string
313
+ }
314
+
315
+ // One store per process: a second copy of this module (a dual install) must still see the request the
316
+ // first one started, or its log lines would land untagged.
317
+ const ALS_KEY = Symbol.for("nifra.devFeed.requestContext")
318
+ const requestContext: AsyncLocalStorage<DevRequestContext> = sharedRequestContext()
319
+
320
+ function sharedRequestContext(): AsyncLocalStorage<DevRequestContext> {
321
+ const existing: unknown = Reflect.get(globalThis, ALS_KEY)
322
+ if (existing instanceof AsyncLocalStorage) return existing
323
+ const created = new AsyncLocalStorage<DevRequestContext>()
324
+ Reflect.set(globalThis, ALS_KEY, created)
325
+ return created
326
+ }
327
+
328
+ /** Run `fn` as the handling of one request: entries recorded inside it carry `ctx.requestId`. */
329
+ export function runWithDevRequest<T>(ctx: DevRequestContext, fn: () => T): T {
330
+ return requestContext.run(ctx, fn)
331
+ }
332
+
333
+ /** The request being handled on this async path, if any. */
334
+ export function currentDevRequest(): DevRequestContext | undefined {
335
+ return requestContext.getStore()
336
+ }
337
+
338
+ /** The request in flight on this async path, when it belongs to the feed `feedId`. */
339
+ function currentRequestFor(feedId: string): DevRequestContext | undefined {
340
+ const ctx = requestContext.getStore()
341
+ return ctx?.feedId === feedId ? ctx : undefined
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------------------------------
345
+ // Store
346
+ // ---------------------------------------------------------------------------------------------------
347
+
348
+ interface ErrorRecord {
349
+ seq: number
350
+ readonly id: string
351
+ readonly category: DevErrorCategory
352
+ readonly source: DevEntrySource
353
+ diagnostic: Diagnostic
354
+ count: number
355
+ readonly firstAt: string
356
+ lastAt: string
357
+ generation: number
358
+ requestId: string | undefined
359
+ route: string | undefined
360
+ page: string | undefined
361
+ resolvedAt: string | undefined
362
+ }
363
+
364
+ interface TraceRecord {
365
+ readonly seq: number
366
+ readonly requestId: string
367
+ readonly method: string
368
+ readonly path: string
369
+ readonly at: string
370
+ status: number | undefined
371
+ durationMs: number | undefined
372
+ bytes: number | undefined
373
+ isr: string | undefined
374
+ readonly errorIds: string[]
375
+ logCount: number
376
+ }
377
+
378
+ const nowIso = (): string => new Date().toISOString()
379
+
380
+ const clampLimit = (value: number | undefined, fallback: number, max: number): number =>
381
+ value === undefined || !Number.isFinite(value) || value < 1
382
+ ? fallback
383
+ : Math.min(Math.floor(value), max)
384
+
385
+ /** FNV-1a: a stable short id for a failure fingerprint. Not a security boundary. */
386
+ function fnv1a(text: string): string {
387
+ let hash = 0x811c9dc5
388
+ for (let i = 0; i < text.length; i++) {
389
+ hash ^= text.charCodeAt(i)
390
+ hash = Math.imul(hash, 0x01000193)
391
+ }
392
+ return (hash >>> 0).toString(16).padStart(8, "0")
393
+ }
394
+
395
+ const cap = (text: string, max: number): string =>
396
+ text.length <= max ? text : `${text.slice(0, max)}... [${text.length - max} more chars]`
397
+
398
+ const firstLine = (text: string): string => {
399
+ const end = text.indexOf("\n")
400
+ return end === -1 ? text : text.slice(0, end)
401
+ }
402
+
403
+ function snapshotError(record: ErrorRecord, currentGeneration: number): DevErrorEntry {
404
+ const entry: Writable<DevErrorEntry> = {
405
+ seq: record.seq,
406
+ id: record.id,
407
+ category: record.category,
408
+ source: record.source,
409
+ diagnostic: record.diagnostic,
410
+ count: record.count,
411
+ firstAt: record.firstAt,
412
+ lastAt: record.lastAt,
413
+ generation: record.generation,
414
+ stale: record.generation < currentGeneration,
415
+ }
416
+ if (record.requestId !== undefined) entry.requestId = record.requestId
417
+ if (record.route !== undefined) entry.route = record.route
418
+ if (record.page !== undefined) entry.page = record.page
419
+ if (record.resolvedAt !== undefined) entry.resolvedAt = record.resolvedAt
420
+ return entry
421
+ }
422
+
423
+ function snapshotTrace(trace: TraceRecord): DevRequestTrace {
424
+ const entry: Writable<DevRequestTrace> = {
425
+ seq: trace.seq,
426
+ requestId: trace.requestId,
427
+ method: trace.method,
428
+ path: trace.path,
429
+ at: trace.at,
430
+ errorIds: [...trace.errorIds],
431
+ logCount: trace.logCount,
432
+ }
433
+ if (trace.status !== undefined) entry.status = trace.status
434
+ if (trace.durationMs !== undefined) entry.durationMs = trace.durationMs
435
+ if (trace.bytes !== undefined) entry.bytes = trace.bytes
436
+ if (trace.isr !== undefined) entry.isr = trace.isr
437
+ return entry
438
+ }
439
+
440
+ function evictOldest<V>(map: Map<string, V>, max: number): void {
441
+ while (map.size > max) {
442
+ const oldest = map.keys().next()
443
+ if (oldest.done === true) return
444
+ map.delete(oldest.value)
445
+ }
446
+ }
447
+
448
+ let feedCounter = 0
449
+
450
+ /** Create the store a dev server records into. */
451
+ export function createDevFeed(options: DevFeedOptions): DevFeed {
452
+ const limits: DevFeedLimits = { ...DEFAULT_DEV_FEED_LIMITS, ...options.limits }
453
+ const root = options.root
454
+ const redactText = createRedactor(options.env ?? process.env, options.publicEnvPrefix)
455
+ const persist = options.persist === false ? undefined : createPersistence(root)
456
+ feedCounter += 1
457
+ const id = `feed${feedCounter}-${randomBytes(3).toString("hex")}`
458
+ const errors = new Map<string, ErrorRecord>()
459
+ const traces = new Map<string, TraceRecord>()
460
+ let logs: DevLogEntry[] = []
461
+ let logBytes = 0
462
+ let dropped = 0
463
+ let seq = 0
464
+ let generation = 0
465
+
466
+ const redactFrame = (frame: DiagnosticFrame): DiagnosticFrame => ({
467
+ ...frame,
468
+ raw: cap(redactText(frame.raw), 1024),
469
+ })
470
+
471
+ const redactDiagnostic = (diagnostic: Diagnostic): Diagnostic => {
472
+ const redacted: Writable<Diagnostic> = {
473
+ ...diagnostic,
474
+ message: cap(redactText(diagnostic.message), limits.message),
475
+ frames: diagnostic.frames.slice(0, 50).map(redactFrame),
476
+ }
477
+ if (diagnostic.request !== undefined) {
478
+ redacted.request = {
479
+ method: diagnostic.request.method,
480
+ url: cap(redactText(diagnostic.request.url), 2048),
481
+ }
482
+ }
483
+ if (diagnostic.codeframe !== undefined) {
484
+ redacted.codeframe = {
485
+ ...diagnostic.codeframe,
486
+ lines: diagnostic.codeframe.lines.map((line) => ({
487
+ ...line,
488
+ text: cap(redactText(line.text), 400),
489
+ })),
490
+ }
491
+ }
492
+ return redacted
493
+ }
494
+
495
+ const recordDiagnostic = (raw: Diagnostic, meta: ErrorMeta): DevErrorEntry => {
496
+ const diagnostic = redactDiagnostic(raw)
497
+ const requestId = meta.requestId ?? currentRequestFor(id)?.requestId
498
+ const top = diagnostic.frames.find((frame) => frame.file !== undefined)
499
+ const fingerprint = [
500
+ meta.category,
501
+ diagnostic.code,
502
+ diagnostic.name,
503
+ firstLine(diagnostic.message).slice(0, 300),
504
+ top === undefined ? "" : `${top.file}:${top.line}`,
505
+ ].join("|")
506
+ const key = `e_${fnv1a(fingerprint)}`
507
+ const at = nowIso()
508
+ seq += 1
509
+ const existing = errors.get(key)
510
+ const record: ErrorRecord = existing ?? {
511
+ seq,
512
+ id: key,
513
+ category: meta.category,
514
+ source: meta.source ?? "server",
515
+ diagnostic,
516
+ count: 0,
517
+ firstAt: at,
518
+ lastAt: at,
519
+ generation,
520
+ requestId: undefined,
521
+ route: undefined,
522
+ page: undefined,
523
+ resolvedAt: undefined,
524
+ }
525
+ record.seq = seq
526
+ record.diagnostic = diagnostic
527
+ record.count += 1
528
+ record.lastAt = at
529
+ record.generation = generation
530
+ record.resolvedAt = undefined
531
+ if (requestId !== undefined) record.requestId = requestId
532
+ if (meta.route !== undefined) record.route = meta.route
533
+ if (meta.page !== undefined) record.page = meta.page
534
+ // Re-insert: Map order is recency order, which eviction relies on.
535
+ errors.delete(key)
536
+ errors.set(key, record)
537
+ evictOldest(errors, limits.errors)
538
+ if (requestId !== undefined) {
539
+ const trace = traces.get(requestId)
540
+ if (trace !== undefined && !trace.errorIds.includes(key)) trace.errorIds.push(key)
541
+ }
542
+ const entry = snapshotError(record, generation)
543
+ persist?.write({ t: "error", ...entry }, true)
544
+ return entry
545
+ }
546
+
547
+ const recordLog = (level: DevLogLevel, message: string, meta: LogMeta = {}): void => {
548
+ const text = cap(redactText(message), limits.message)
549
+ if (text.length === 0) return
550
+ const requestId = meta.requestId ?? currentRequestFor(id)?.requestId
551
+ seq += 1
552
+ const entry: Writable<DevLogEntry> = {
553
+ seq,
554
+ at: nowIso(),
555
+ level,
556
+ source: meta.source ?? "server",
557
+ message: text,
558
+ }
559
+ if (requestId !== undefined) entry.requestId = requestId
560
+ if (meta.page !== undefined) entry.page = meta.page
561
+ logs.push(entry)
562
+ logBytes += text.length
563
+ if (logs.length > limits.logs || logBytes > limits.logBytes) {
564
+ // Past the count cap, evict an extra tenth so a full ring does not re-slice on every line.
565
+ const byCount =
566
+ logs.length > limits.logs
567
+ ? Math.min(logs.length - 1, logs.length - limits.logs + Math.floor(limits.logs / 10))
568
+ : 0
569
+ let cut = 0
570
+ let freed = 0
571
+ while (cut < logs.length - 1 && (cut < byCount || logBytes - freed > limits.logBytes)) {
572
+ freed += logs[cut]?.message.length ?? 0
573
+ cut += 1
574
+ }
575
+ logs = logs.slice(cut)
576
+ logBytes -= freed
577
+ dropped += cut
578
+ }
579
+ if (requestId !== undefined) {
580
+ const trace = traces.get(requestId)
581
+ if (trace !== undefined) trace.logCount += 1
582
+ }
583
+ persist?.write({ t: "log", ...entry }, level === "error")
584
+ }
585
+
586
+ return {
587
+ id,
588
+ root,
589
+ get generation() {
590
+ return generation
591
+ },
592
+ get cursor() {
593
+ return seq
594
+ },
595
+ recordError(error, meta) {
596
+ const diagnosticOptions: Writable<BuildDiagnosticOptions> = { root }
597
+ if (meta.request !== undefined) diagnosticOptions.request = meta.request
598
+ if (options.showSource !== undefined) diagnosticOptions.showSource = options.showSource
599
+ if (meta.source !== "browser") diagnosticOptions.remap = ssrRemap
600
+ return recordDiagnostic(buildDiagnostic(error, diagnosticOptions), meta)
601
+ },
602
+ recordDiagnostic,
603
+ recordLog,
604
+ startRequest(requestId, method, path) {
605
+ seq += 1
606
+ traces.set(requestId, {
607
+ seq,
608
+ requestId,
609
+ method,
610
+ path: cap(redactText(path), 2048),
611
+ at: nowIso(),
612
+ status: undefined,
613
+ durationMs: undefined,
614
+ bytes: undefined,
615
+ isr: undefined,
616
+ errorIds: [],
617
+ logCount: 0,
618
+ })
619
+ evictOldest(traces, limits.requests)
620
+ },
621
+ finishRequest(requestId, response, durationMs) {
622
+ const trace = traces.get(requestId)
623
+ if (trace === undefined) return
624
+ trace.status = response.status
625
+ trace.durationMs = Math.round(durationMs * 100) / 100
626
+ trace.bytes = response.bytes
627
+ trace.isr = response.isr
628
+ },
629
+ markChange() {
630
+ generation += 1
631
+ },
632
+ resolve(category) {
633
+ const at = nowIso()
634
+ for (const record of errors.values()) {
635
+ if (record.category === category && record.resolvedAt === undefined) record.resolvedAt = at
636
+ }
637
+ },
638
+ errors(query = {}) {
639
+ const since = query.since ?? 0
640
+ const selected: DevErrorEntry[] = []
641
+ for (const record of errors.values()) {
642
+ if (record.seq <= since) continue
643
+ if (query.categories !== undefined && !query.categories.includes(record.category)) continue
644
+ if (query.includeResolved !== true && record.resolvedAt !== undefined) continue
645
+ if (query.includeStale === false && record.generation < generation) continue
646
+ if (query.requestId !== undefined && record.requestId !== query.requestId) continue
647
+ selected.push(snapshotError(record, generation))
648
+ }
649
+ const limit = clampLimit(query.limit, 50, limits.errors)
650
+ return { errors: selected.slice(-limit), cursor: seq, generation }
651
+ },
652
+ logs(query = {}) {
653
+ const since = query.since ?? 0
654
+ const needle = query.grep?.toLowerCase()
655
+ const selected = logs.filter(
656
+ (entry) =>
657
+ entry.seq > since &&
658
+ (query.levels === undefined || query.levels.includes(entry.level)) &&
659
+ (query.source === undefined || entry.source === query.source) &&
660
+ (query.requestId === undefined || entry.requestId === query.requestId) &&
661
+ (needle === undefined || entry.message.toLowerCase().includes(needle)),
662
+ )
663
+ const limit = clampLimit(query.limit, 200, limits.logs)
664
+ return { logs: selected.slice(-limit), cursor: seq, generation, dropped }
665
+ },
666
+ requests(query = {}) {
667
+ const since = query.since ?? 0
668
+ const selected: DevRequestTrace[] = []
669
+ for (const trace of traces.values()) {
670
+ if (trace.seq <= since) continue
671
+ if (query.path !== undefined && !trace.path.startsWith(query.path)) continue
672
+ if (query.requestId !== undefined && trace.requestId !== query.requestId) continue
673
+ selected.push(snapshotTrace(trace))
674
+ }
675
+ const limit = clampLimit(query.limit, 50, limits.requests)
676
+ return { requests: selected.slice(-limit), cursor: seq, generation }
677
+ },
678
+ counts: () => ({ errors: errors.size, logs: logs.length, requests: traces.size }),
679
+ redact: redactText,
680
+ flush: () => persist?.flush(),
681
+ close: () => persist?.close(),
682
+ }
683
+ }
684
+
685
+ // ---------------------------------------------------------------------------------------------------
686
+ // Persistence
687
+ // ---------------------------------------------------------------------------------------------------
688
+
689
+ const LOG_ROTATE_BYTES = 2 * 1024 * 1024
690
+ const LOG_FLUSH_MS = 250
691
+
692
+ interface Persistence {
693
+ write(entry: Readonly<Record<string, unknown>>, sync: boolean): void
694
+ flush(): void
695
+ close(): void
696
+ }
697
+
698
+ /**
699
+ * NDJSON on disk so a crashed dev server still leaves a record. Error-level writes are synchronous (the
700
+ * line most worth keeping is the one written just before a crash); the rest is buffered briefly. One
701
+ * rotation (`.1`) bounds the footprint. A write failure disables persistence rather than breaking dev.
702
+ */
703
+ function createPersistence(root: string): Persistence {
704
+ const path = join(root, DEV_SERVER_LOG_FILE)
705
+ let buffer: string[] = []
706
+ let size = 0
707
+ let timer: ReturnType<typeof setTimeout> | undefined
708
+ let disabled = false
709
+ try {
710
+ mkdirSync(dirname(path), { recursive: true })
711
+ size = existsSync(path) ? statSync(path).size : 0
712
+ } catch {
713
+ disabled = true
714
+ }
715
+ const flush = (): void => {
716
+ if (timer !== undefined) {
717
+ clearTimeout(timer)
718
+ timer = undefined
719
+ }
720
+ if (disabled || buffer.length === 0) return
721
+ const chunk = buffer.join("")
722
+ buffer = []
723
+ try {
724
+ if (size + chunk.length > LOG_ROTATE_BYTES) {
725
+ renameSync(path, `${path}.1`)
726
+ size = 0
727
+ }
728
+ appendFileSync(path, chunk)
729
+ size += chunk.length
730
+ } catch {
731
+ disabled = true
732
+ }
733
+ }
734
+ return {
735
+ write(entry, sync) {
736
+ if (disabled) return
737
+ buffer.push(`${JSON.stringify(entry)}\n`)
738
+ if (sync) flush()
739
+ else if (timer === undefined) {
740
+ timer = setTimeout(flush, LOG_FLUSH_MS)
741
+ timer.unref?.()
742
+ }
743
+ },
744
+ flush,
745
+ close: () => {
746
+ flush()
747
+ disabled = true
748
+ },
749
+ }
750
+ }
751
+
752
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
753
+ typeof value === "object" && value !== null && !Array.isArray(value)
754
+
755
+ function isPersistedError(value: unknown): value is DevErrorEntry {
756
+ return (
757
+ isRecord(value) &&
758
+ typeof value.id === "string" &&
759
+ typeof value.seq === "number" &&
760
+ isDevErrorCategory(value.category) &&
761
+ isRecord(value.diagnostic)
762
+ )
763
+ }
764
+
765
+ function isPersistedLog(value: unknown): value is DevLogEntry {
766
+ return (
767
+ isRecord(value) &&
768
+ typeof value.message === "string" &&
769
+ typeof value.seq === "number" &&
770
+ isDevLogLevel(value.level)
771
+ )
772
+ }
773
+
774
+ /** The persisted record of a dev server that may no longer be running: newest last. */
775
+ export function readPersistedFeed(
776
+ root: string,
777
+ limit = 200,
778
+ ): { readonly errors: DevErrorEntry[]; readonly logs: DevLogEntry[] } {
779
+ const path = join(root, DEV_SERVER_LOG_FILE)
780
+ const errors = new Map<string, DevErrorEntry>()
781
+ const logs: DevLogEntry[] = []
782
+ for (const file of [`${path}.1`, path]) {
783
+ let text: string
784
+ try {
785
+ text = readFileSync(file, "utf8")
786
+ } catch {
787
+ continue
788
+ }
789
+ for (const line of text.split("\n")) {
790
+ if (line.length === 0) continue
791
+ let value: unknown
792
+ try {
793
+ value = JSON.parse(line)
794
+ } catch {
795
+ continue
796
+ }
797
+ if (!isRecord(value)) continue
798
+ const { t, ...entry } = value
799
+ if (t === "error" && isPersistedError(entry)) {
800
+ errors.delete(entry.id)
801
+ errors.set(entry.id, entry)
802
+ } else if (t === "log" && isPersistedLog(entry)) {
803
+ logs.push(entry)
804
+ }
805
+ }
806
+ }
807
+ return { errors: [...errors.values()].slice(-limit), logs: logs.slice(-limit) }
808
+ }
809
+
810
+ /**
811
+ * Record a dev server process that died, from the process that supervised it (`nifra dev` re-execs the
812
+ * Bun pipeline and pipes the child's stderr). The child's own monitor never sees an unhandled rejection
813
+ * on Bun, and a native crash print bypasses the console, so the supervisor's view of stderr is the
814
+ * record that survives. Persisted only: there is no live server left to serve it.
815
+ */
816
+ export function recordDevCrash(root: string, exitCode: number | null, stderrTail: string): void {
817
+ const feed = createDevFeed({ root })
818
+ const summary = `the dev server exited with code ${exitCode ?? "unknown"}`
819
+ const error = new Error(summary)
820
+ error.name = "DevServerExit"
821
+ error.stack = `DevServerExit: ${summary}\n${stderrTail.slice(-DEFAULT_DEV_FEED_LIMITS.stack)}`
822
+ feed.recordError(error, { category: "process" })
823
+ feed.close()
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------------------------------
827
+ // Discovery
828
+ // ---------------------------------------------------------------------------------------------------
829
+
830
+ /** What a running dev server writes to {@link DEV_SERVER_RECORD_FILE} so tools can find it. */
831
+ export interface DevServerRecord {
832
+ readonly schema: number
833
+ readonly pid: number
834
+ readonly port: number
835
+ readonly url: string
836
+ readonly pipeline: DevPipeline
837
+ readonly root: string
838
+ readonly startedAt: string
839
+ /** Sent as {@link DEV_TOKEN_HEADER}. Only a reader of this file (mode 0600) can present it. */
840
+ readonly token: string
841
+ }
842
+
843
+ /** A fresh 256-bit token. */
844
+ export function createDevToken(): string {
845
+ return randomBytes(32).toString("base64url")
846
+ }
847
+
848
+ /** Write the record atomically (temp file + rename) and owner-only. */
849
+ export function writeDevServerRecord(root: string, record: DevServerRecord): void {
850
+ const path = join(root, DEV_SERVER_RECORD_FILE)
851
+ mkdirSync(dirname(path), { recursive: true })
852
+ const temp = `${path}.${process.pid}.tmp`
853
+ writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
854
+ renameSync(temp, path)
855
+ }
856
+
857
+ /** Remove the record only while it is still this server's: a newer server may have replaced it. */
858
+ export function removeDevServerRecord(root: string, token: string): void {
859
+ // @nifra-gate-reviewed: the server comparing its own token with a file on this machine; no request
860
+ // reaches this comparison.
861
+ if (readDevServerRecord(root)?.token !== token) return
862
+ rmSync(join(root, DEV_SERVER_RECORD_FILE), { force: true })
863
+ }
864
+
865
+ const isPort = (value: unknown): value is number =>
866
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value < 65536
867
+
868
+ function isDevServerRecord(value: unknown): value is DevServerRecord {
869
+ return (
870
+ isRecord(value) &&
871
+ typeof value.schema === "number" &&
872
+ typeof value.pid === "number" &&
873
+ Number.isSafeInteger(value.pid) &&
874
+ isPort(value.port) &&
875
+ typeof value.url === "string" &&
876
+ (value.pipeline === "bun" || value.pipeline === "vite") &&
877
+ typeof value.root === "string" &&
878
+ typeof value.startedAt === "string" &&
879
+ typeof value.token === "string" &&
880
+ value.token.length >= 32
881
+ )
882
+ }
883
+
884
+ /** The record at `root`, or undefined when absent or malformed. Says nothing about liveness. */
885
+ export function readDevServerRecord(root: string): DevServerRecord | undefined {
886
+ let value: unknown
887
+ try {
888
+ value = JSON.parse(readFileSync(join(root, DEV_SERVER_RECORD_FILE), "utf8"))
889
+ } catch {
890
+ return undefined
891
+ }
892
+ return isDevServerRecord(value) ? value : undefined
893
+ }
894
+
895
+ /** Whether a process with `pid` exists (EPERM means it exists but is not ours). */
896
+ export function isProcessAlive(pid: number): boolean {
897
+ try {
898
+ process.kill(pid, 0)
899
+ return true
900
+ } catch (error) {
901
+ return error instanceof Error && "code" in error && error.code === "EPERM"
902
+ }
903
+ }
904
+
905
+ // ---------------------------------------------------------------------------------------------------
906
+ // Console + stream capture
907
+ // ---------------------------------------------------------------------------------------------------
908
+
909
+ export interface CaptureSink {
910
+ /** The feed this sink records into; a line logged inside one of its requests reaches only it. */
911
+ readonly feedId: string
912
+ line(level: DevLogLevel, message: string, request: DevRequestContext | undefined): void
913
+ /** A structured line from core's JSON logger (`{ level, message, ... }`). */
914
+ structured?(entry: CoreLogEntry, request: DevRequestContext | undefined): void
915
+ }
916
+
917
+ export interface CoreLogEntry {
918
+ readonly level: string
919
+ readonly message: string
920
+ readonly [field: string]: unknown
921
+ }
922
+
923
+ type ConsoleMethod = "debug" | "info" | "log" | "warn" | "error" | "trace"
924
+ const CONSOLE_METHODS: readonly ConsoleMethod[] = ["debug", "info", "log", "warn", "error", "trace"]
925
+ const CONSOLE_LEVELS: Readonly<Record<ConsoleMethod, DevLogLevel>> = {
926
+ debug: "debug",
927
+ info: "info",
928
+ log: "log",
929
+ warn: "warn",
930
+ error: "error",
931
+ trace: "log",
932
+ }
933
+
934
+ type ConsoleFn = (...args: unknown[]) => void
935
+ type WriteFn = (chunk: unknown, ...rest: unknown[]) => boolean
936
+
937
+ interface CaptureState {
938
+ readonly sinks: Set<CaptureSink>
939
+ /** Inside a patched console call: a stream write it makes (Node) is the same line, not a new one. */
940
+ depth: number
941
+ restore: () => void
942
+ }
943
+
944
+ // Structural, not `instanceof`: a second copy of this module must join the first one's patch rather
945
+ // than layer another on top and capture every line twice.
946
+ const isCaptureState = (value: unknown): value is CaptureState =>
947
+ isRecord(value) &&
948
+ value.sinks instanceof Set &&
949
+ typeof value.depth === "number" &&
950
+ typeof value.restore === "function"
951
+
952
+ const CAPTURE_KEY = Symbol.for("nifra.devFeed.capture")
953
+ const MAX_PARTIAL_LINE = 16 * 1024
954
+
955
+ /** Format console arguments the way the console itself would, without color. */
956
+ export function formatConsoleArgs(args: readonly unknown[]): string {
957
+ try {
958
+ return formatWithOptions(
959
+ { colors: false, depth: 4, breakLength: Number.POSITIVE_INFINITY, maxArrayLength: 50 },
960
+ ...args,
961
+ )
962
+ } catch {
963
+ return args.map((arg) => String(arg)).join(" ")
964
+ }
965
+ }
966
+
967
+ const isCoreLogEntry = (value: unknown): value is CoreLogEntry =>
968
+ isRecord(value) && typeof value.level === "string" && typeof value.message === "string"
969
+
970
+ /** Parse a line core's default JSON logger wrote, or undefined for anything else. */
971
+ export function parseCoreLogLine(line: string): CoreLogEntry | undefined {
972
+ if (!line.startsWith("{") || !line.endsWith("}")) return undefined
973
+ try {
974
+ const value: unknown = JSON.parse(line)
975
+ return isCoreLogEntry(value) ? value : undefined
976
+ } catch {
977
+ return undefined
978
+ }
979
+ }
980
+
981
+ /** The thrown error core's `unhandled request error` log line describes, rebuilt for a Diagnostic. */
982
+ export function errorFromCoreLog(entry: CoreLogEntry): Error | undefined {
983
+ if (entry.message !== "unhandled request error") return undefined
984
+ const name = typeof entry.name === "string" ? entry.name : "Error"
985
+ const detail = typeof entry.detail === "string" ? entry.detail : name
986
+ const error = new Error(detail)
987
+ error.name = name
988
+ error.stack = typeof entry.stack === "string" ? entry.stack : `${name}: ${detail}`
989
+ return error
990
+ }
991
+
992
+ /**
993
+ * Tee the process's console and stdout/stderr into `sink`, leaving the real output untouched. Global
994
+ * and reference-counted: several dev servers (tests start many) share one patch, and the originals come
995
+ * back when the last sink detaches. Returns the detach function.
996
+ */
997
+ export function installCapture(sink: CaptureSink): () => void {
998
+ const existing: unknown = Reflect.get(globalThis, CAPTURE_KEY)
999
+ const state = isCaptureState(existing) ? existing : patchProcess()
1000
+ Reflect.set(globalThis, CAPTURE_KEY, state)
1001
+ state.sinks.add(sink)
1002
+ let attached = true
1003
+ return () => {
1004
+ if (!attached) return
1005
+ attached = false
1006
+ state.sinks.delete(sink)
1007
+ if (state.sinks.size === 0 && Reflect.get(globalThis, CAPTURE_KEY) === state) {
1008
+ state.restore()
1009
+ Reflect.deleteProperty(globalThis, CAPTURE_KEY)
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ function patchProcess(): CaptureState {
1015
+ const state: CaptureState = { sinks: new Set(), depth: 0, restore: () => {} }
1016
+ const dispatch = (level: DevLogLevel, message: string, structured?: CoreLogEntry): void => {
1017
+ const request = requestContext.getStore()
1018
+ for (const sink of state.sinks) {
1019
+ if (request !== undefined && request.feedId !== sink.feedId) continue
1020
+ try {
1021
+ if (structured !== undefined && sink.structured !== undefined) {
1022
+ sink.structured(structured, request)
1023
+ } else sink.line(level, message, request)
1024
+ } catch {
1025
+ // A sink must never break the console it observes.
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ const consoleOriginals = new Map<ConsoleMethod, { original: ConsoleFn; patched: ConsoleFn }>()
1031
+ for (const method of CONSOLE_METHODS) {
1032
+ const original: ConsoleFn = console[method]
1033
+ const patched: ConsoleFn = function (this: unknown, ...args: unknown[]): void {
1034
+ state.depth += 1
1035
+ try {
1036
+ original.apply(this, args)
1037
+ } finally {
1038
+ state.depth -= 1
1039
+ }
1040
+ dispatch(CONSOLE_LEVELS[method], stripVTControlCharacters(formatConsoleArgs(args)))
1041
+ }
1042
+ consoleOriginals.set(method, { original, patched })
1043
+ console[method] = patched
1044
+ }
1045
+
1046
+ const streamOriginals: Array<{ stream: object; original: unknown; patched: WriteFn }> = []
1047
+ for (const [stream, level] of [
1048
+ [process.stdout, "log"],
1049
+ [process.stderr, "error"],
1050
+ ] as const) {
1051
+ const original: unknown = Reflect.get(stream, "write")
1052
+ if (typeof original !== "function") continue
1053
+ let partial = ""
1054
+ const patched: WriteFn = function (this: unknown, chunk, ...rest) {
1055
+ if (state.depth === 0) {
1056
+ partial +=
1057
+ typeof chunk === "string"
1058
+ ? chunk
1059
+ : chunk instanceof Uint8Array
1060
+ ? new TextDecoder().decode(chunk)
1061
+ : ""
1062
+ let newline = partial.indexOf("\n")
1063
+ while (newline !== -1) {
1064
+ const line = stripVTControlCharacters(partial.slice(0, newline)).trimEnd()
1065
+ partial = partial.slice(newline + 1)
1066
+ if (line.length > 0) {
1067
+ const structured = parseCoreLogLine(line)
1068
+ dispatch(
1069
+ structured !== undefined && isDevLogLevel(structured.level)
1070
+ ? structured.level
1071
+ : level,
1072
+ line,
1073
+ structured,
1074
+ )
1075
+ }
1076
+ newline = partial.indexOf("\n")
1077
+ }
1078
+ if (partial.length > MAX_PARTIAL_LINE) {
1079
+ dispatch(level, stripVTControlCharacters(partial))
1080
+ partial = ""
1081
+ }
1082
+ }
1083
+ return Reflect.apply(original, this ?? stream, [chunk, ...rest]) !== false
1084
+ }
1085
+ streamOriginals.push({ stream, original, patched })
1086
+ Reflect.set(stream, "write", patched)
1087
+ }
1088
+
1089
+ state.restore = () => {
1090
+ for (const [method, { original, patched }] of consoleOriginals) {
1091
+ // Only undo our own patch: a later patch layered on top stays, still forwarding to ours.
1092
+ if (console[method] === patched) console[method] = original
1093
+ }
1094
+ for (const { stream, original, patched } of streamOriginals) {
1095
+ if (Reflect.get(stream, "write") === patched) Reflect.set(stream, "write", original)
1096
+ }
1097
+ }
1098
+ return state
1099
+ }
1100
+
1101
+ const structuredText = (entry: CoreLogEntry): string => {
1102
+ const { level: _level, message, time: _time, ...fields } = entry
1103
+ return Object.keys(fields).length === 0 ? message : `${message} ${JSON.stringify(fields)}`
1104
+ }
1105
+
1106
+ /** A server frame's authored position, through the map a dev plugin registered for the file it compiled. */
1107
+ const ssrRemap = (
1108
+ file: string,
1109
+ line: number,
1110
+ column: number,
1111
+ ): { readonly line: number; readonly column: number } | undefined => {
1112
+ const map = ssrSourceMaps().get(file.replaceAll("\\", "/"))
1113
+ return map === undefined ? undefined : originalPosition(map, line, column)
1114
+ }
1115
+
1116
+ /**
1117
+ * Record the process's console and stream output into `feed`: every line as a log entry, and core's
1118
+ * `unhandled request error` line additionally as an `api` error with a full Diagnostic. The one sink
1119
+ * both dev servers and `nifra_run` use. Returns the detach function.
1120
+ */
1121
+ export function captureInto(feed: DevFeed): () => void {
1122
+ return installCapture({
1123
+ feedId: feed.id,
1124
+ line: (level, message, request) =>
1125
+ feed.recordLog(level, message, { requestId: request?.requestId }),
1126
+ structured: (entry, request) => {
1127
+ const level = isDevLogLevel(entry.level) ? entry.level : "log"
1128
+ feed.recordLog(level, structuredText(entry), { requestId: request?.requestId })
1129
+ const error = errorFromCoreLog(entry)
1130
+ if (error === undefined) return
1131
+ const method = typeof entry.method === "string" ? entry.method : request?.method
1132
+ const path = typeof entry.path === "string" ? entry.path : request?.path
1133
+ feed.recordError(error, {
1134
+ category: "api",
1135
+ requestId: request?.requestId,
1136
+ request: method !== undefined && path !== undefined ? { method, url: path } : undefined,
1137
+ })
1138
+ },
1139
+ })
1140
+ }