@nifrajs/web 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (407) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +35 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +89 -35
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +18 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +97 -17
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +72 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +411 -98
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +21 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +226 -15
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/css-contract.d.ts +21 -0
  40. package/dist/css-contract.d.ts.map +1 -0
  41. package/dist/css-contract.js +28 -0
  42. package/dist/css-contract.js.map +1 -0
  43. package/dist/dev-client.d.ts +76 -0
  44. package/dist/dev-client.d.ts.map +1 -0
  45. package/dist/dev-client.js +280 -0
  46. package/dist/dev-client.js.map +1 -0
  47. package/dist/dev-diagnostics.d.ts +26 -2
  48. package/dist/dev-diagnostics.d.ts.map +1 -1
  49. package/dist/dev-diagnostics.js +38 -7
  50. package/dist/dev-diagnostics.js.map +1 -1
  51. package/dist/dev-error.d.ts +4 -13
  52. package/dist/dev-error.d.ts.map +1 -1
  53. package/dist/dev-error.js +46 -4
  54. package/dist/dev-error.js.map +1 -1
  55. package/dist/dev-feed.d.ts +298 -0
  56. package/dist/dev-feed.d.ts.map +1 -0
  57. package/dist/dev-feed.js +797 -0
  58. package/dist/dev-feed.js.map +1 -0
  59. package/dist/dev-indicator.d.ts +38 -0
  60. package/dist/dev-indicator.d.ts.map +1 -0
  61. package/dist/dev-indicator.js +93 -0
  62. package/dist/dev-indicator.js.map +1 -0
  63. package/dist/dev-session.d.ts +77 -0
  64. package/dist/dev-session.d.ts.map +1 -0
  65. package/dist/dev-session.js +580 -0
  66. package/dist/dev-session.js.map +1 -0
  67. package/dist/dev-sourcemap.d.ts +27 -0
  68. package/dist/dev-sourcemap.d.ts.map +1 -0
  69. package/dist/dev-sourcemap.js +235 -0
  70. package/dist/dev-sourcemap.js.map +1 -0
  71. package/dist/dev-ssr-graph.d.ts.map +1 -1
  72. package/dist/dev-ssr-graph.js +13 -5
  73. package/dist/dev-ssr-graph.js.map +1 -1
  74. package/dist/dev.d.ts +15 -2
  75. package/dist/dev.d.ts.map +1 -1
  76. package/dist/dev.js +69 -44
  77. package/dist/dev.js.map +1 -1
  78. package/dist/diagnostic-catalog.d.ts +35 -0
  79. package/dist/diagnostic-catalog.d.ts.map +1 -0
  80. package/dist/diagnostic-catalog.js +203 -0
  81. package/dist/diagnostic-catalog.js.map +1 -0
  82. package/dist/diagnostic-prompt.d.ts +49 -0
  83. package/dist/diagnostic-prompt.d.ts.map +1 -0
  84. package/dist/diagnostic-prompt.js +303 -0
  85. package/dist/diagnostic-prompt.js.map +1 -0
  86. package/dist/diagnostic.d.ts +17 -23
  87. package/dist/diagnostic.d.ts.map +1 -1
  88. package/dist/diagnostic.js +31 -39
  89. package/dist/diagnostic.js.map +1 -1
  90. package/dist/draft.d.ts.map +1 -1
  91. package/dist/draft.js +4 -1
  92. package/dist/draft.js.map +1 -1
  93. package/dist/fn.d.ts +5 -0
  94. package/dist/fn.d.ts.map +1 -1
  95. package/dist/fn.js +13 -3
  96. package/dist/fn.js.map +1 -1
  97. package/dist/fs.d.ts.map +1 -1
  98. package/dist/fs.js +4 -2
  99. package/dist/fs.js.map +1 -1
  100. package/dist/index.d.ts +15 -15
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +13 -12
  103. package/dist/index.js.map +1 -1
  104. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  105. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  106. package/dist/internal/cdn/cloudflare.js +94 -0
  107. package/dist/internal/cdn/cloudflare.js.map +1 -0
  108. package/dist/internal/cdn/core.d.ts +91 -0
  109. package/dist/internal/cdn/core.d.ts.map +1 -0
  110. package/dist/internal/cdn/core.js +220 -0
  111. package/dist/internal/cdn/core.js.map +1 -0
  112. package/dist/internal/cdn/fastly.d.ts +17 -0
  113. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  114. package/dist/internal/cdn/fastly.js +43 -0
  115. package/dist/internal/cdn/fastly.js.map +1 -0
  116. package/dist/internal/cdn/vercel.d.ts +31 -0
  117. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  118. package/dist/internal/cdn/vercel.js +56 -0
  119. package/dist/internal/cdn/vercel.js.map +1 -0
  120. package/dist/internal/code-unit-order.d.ts +4 -0
  121. package/dist/internal/code-unit-order.d.ts.map +1 -0
  122. package/dist/internal/code-unit-order.js +6 -0
  123. package/dist/internal/code-unit-order.js.map +1 -0
  124. package/dist/internal/codegen.d.ts +3 -0
  125. package/dist/internal/codegen.d.ts.map +1 -1
  126. package/dist/internal/codegen.js +151 -30
  127. package/dist/internal/codegen.js.map +1 -1
  128. package/dist/internal/dev-csp.d.ts +24 -0
  129. package/dist/internal/dev-csp.d.ts.map +1 -0
  130. package/dist/internal/dev-csp.js +160 -0
  131. package/dist/internal/dev-csp.js.map +1 -0
  132. package/dist/internal/dev-reserved.d.ts +25 -0
  133. package/dist/internal/dev-reserved.d.ts.map +1 -0
  134. package/dist/internal/dev-reserved.js +39 -0
  135. package/dist/internal/dev-reserved.js.map +1 -0
  136. package/dist/internal/html-spans.d.ts +10 -0
  137. package/dist/internal/html-spans.d.ts.map +1 -0
  138. package/dist/internal/html-spans.js +46 -0
  139. package/dist/internal/html-spans.js.map +1 -0
  140. package/dist/internal/loading-runtime.d.ts +44 -0
  141. package/dist/internal/loading-runtime.d.ts.map +1 -0
  142. package/dist/internal/loading-runtime.js +206 -0
  143. package/dist/internal/loading-runtime.js.map +1 -0
  144. package/dist/internal/matches-runtime.d.ts +8 -0
  145. package/dist/internal/matches-runtime.d.ts.map +1 -0
  146. package/dist/internal/matches-runtime.js +61 -0
  147. package/dist/internal/matches-runtime.js.map +1 -0
  148. package/dist/internal/mount-shadow.d.ts +36 -0
  149. package/dist/internal/mount-shadow.d.ts.map +1 -0
  150. package/dist/internal/mount-shadow.js +65 -0
  151. package/dist/internal/mount-shadow.js.map +1 -0
  152. package/dist/internal/node-builtins.d.ts +3 -0
  153. package/dist/internal/node-builtins.d.ts.map +1 -0
  154. package/dist/internal/node-builtins.js +79 -0
  155. package/dist/internal/node-builtins.js.map +1 -0
  156. package/dist/internal/output-guard.d.ts +65 -0
  157. package/dist/internal/output-guard.d.ts.map +1 -0
  158. package/dist/internal/output-guard.js +630 -0
  159. package/dist/internal/output-guard.js.map +1 -0
  160. package/dist/internal/page-execution.d.ts +69 -0
  161. package/dist/internal/page-execution.d.ts.map +1 -0
  162. package/dist/internal/page-execution.js +1021 -0
  163. package/dist/internal/page-execution.js.map +1 -0
  164. package/dist/internal/parity.d.ts +21 -0
  165. package/dist/internal/parity.d.ts.map +1 -1
  166. package/dist/internal/parity.js +104 -22
  167. package/dist/internal/parity.js.map +1 -1
  168. package/dist/internal/private-env.d.ts +12 -0
  169. package/dist/internal/private-env.d.ts.map +1 -0
  170. package/dist/internal/private-env.js +335 -0
  171. package/dist/internal/private-env.js.map +1 -0
  172. package/dist/internal/public-url.d.ts +3 -0
  173. package/dist/internal/public-url.d.ts.map +1 -0
  174. package/dist/internal/public-url.js +18 -0
  175. package/dist/internal/public-url.js.map +1 -0
  176. package/dist/internal/render-document.d.ts +37 -21
  177. package/dist/internal/render-document.d.ts.map +1 -1
  178. package/dist/internal/render-document.js +104 -65
  179. package/dist/internal/render-document.js.map +1 -1
  180. package/dist/internal/request-url.d.ts +3 -0
  181. package/dist/internal/request-url.d.ts.map +1 -0
  182. package/dist/internal/request-url.js +14 -0
  183. package/dist/internal/request-url.js.map +1 -0
  184. package/dist/internal/response-controls.d.ts +75 -0
  185. package/dist/internal/response-controls.d.ts.map +1 -0
  186. package/dist/internal/response-controls.js +256 -0
  187. package/dist/internal/response-controls.js.map +1 -0
  188. package/dist/internal/runtime-contract.d.ts +11 -16
  189. package/dist/internal/runtime-contract.d.ts.map +1 -1
  190. package/dist/internal/runtime-contract.js.map +1 -1
  191. package/dist/internal/secret-scan.d.ts +104 -0
  192. package/dist/internal/secret-scan.d.ts.map +1 -0
  193. package/dist/internal/secret-scan.js +485 -0
  194. package/dist/internal/secret-scan.js.map +1 -0
  195. package/dist/internal/server-boundary.d.ts +6 -29
  196. package/dist/internal/server-boundary.d.ts.map +1 -1
  197. package/dist/internal/server-boundary.js +6 -66
  198. package/dist/internal/server-boundary.js.map +1 -1
  199. package/dist/internal/source-map.d.ts +47 -0
  200. package/dist/internal/source-map.d.ts.map +1 -0
  201. package/dist/internal/source-map.js +225 -0
  202. package/dist/internal/source-map.js.map +1 -0
  203. package/dist/internal/target-compat.d.ts +27 -0
  204. package/dist/internal/target-compat.d.ts.map +1 -0
  205. package/dist/internal/target-compat.js +77 -0
  206. package/dist/internal/target-compat.js.map +1 -0
  207. package/dist/internal/timing-safe-equal.d.ts +6 -0
  208. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  209. package/dist/internal/timing-safe-equal.js +10 -0
  210. package/dist/internal/timing-safe-equal.js.map +1 -1
  211. package/dist/internal/vitals-runtime.d.ts +18 -0
  212. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  213. package/dist/internal/vitals-runtime.js +38 -0
  214. package/dist/internal/vitals-runtime.js.map +1 -0
  215. package/dist/internal/web-app.d.ts +56 -8
  216. package/dist/internal/web-app.d.ts.map +1 -1
  217. package/dist/internal/web-app.js +215 -772
  218. package/dist/internal/web-app.js.map +1 -1
  219. package/dist/internal/zone-graph.d.ts +89 -0
  220. package/dist/internal/zone-graph.d.ts.map +1 -0
  221. package/dist/internal/zone-graph.js +492 -0
  222. package/dist/internal/zone-graph.js.map +1 -0
  223. package/dist/isr.d.ts +83 -6
  224. package/dist/isr.d.ts.map +1 -1
  225. package/dist/isr.js +301 -39
  226. package/dist/isr.js.map +1 -1
  227. package/dist/llms-txt.d.ts +3 -0
  228. package/dist/llms-txt.d.ts.map +1 -1
  229. package/dist/llms-txt.js +37 -9
  230. package/dist/llms-txt.js.map +1 -1
  231. package/dist/manifest.d.ts +221 -14
  232. package/dist/manifest.d.ts.map +1 -1
  233. package/dist/manifest.js +386 -9
  234. package/dist/manifest.js.map +1 -1
  235. package/dist/module-graph.d.ts +26 -5
  236. package/dist/module-graph.d.ts.map +1 -1
  237. package/dist/module-graph.js +17 -7
  238. package/dist/module-graph.js.map +1 -1
  239. package/dist/navigation.d.ts +17 -3
  240. package/dist/navigation.d.ts.map +1 -1
  241. package/dist/navigation.js +11 -5
  242. package/dist/navigation.js.map +1 -1
  243. package/dist/nonce.d.ts +47 -0
  244. package/dist/nonce.d.ts.map +1 -0
  245. package/dist/nonce.js +63 -0
  246. package/dist/nonce.js.map +1 -0
  247. package/dist/plugins/css-modules.d.ts.map +1 -1
  248. package/dist/plugins/css-modules.js +4 -3
  249. package/dist/plugins/css-modules.js.map +1 -1
  250. package/dist/plugins/kit.d.ts +9 -0
  251. package/dist/plugins/kit.d.ts.map +1 -1
  252. package/dist/plugins/kit.js +18 -0
  253. package/dist/plugins/kit.js.map +1 -1
  254. package/dist/plugins/svg.d.ts +22 -3
  255. package/dist/plugins/svg.d.ts.map +1 -1
  256. package/dist/plugins/svg.js +112 -1
  257. package/dist/plugins/svg.js.map +1 -1
  258. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  259. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  260. package/dist/plugins/vite-leak-guard.js +378 -17
  261. package/dist/plugins/vite-leak-guard.js.map +1 -1
  262. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  263. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  264. package/dist/plugins/vite-zone-guard.js +208 -0
  265. package/dist/plugins/vite-zone-guard.js.map +1 -0
  266. package/dist/plugins/zone-guard.d.ts +27 -0
  267. package/dist/plugins/zone-guard.d.ts.map +1 -0
  268. package/dist/plugins/zone-guard.js +72 -0
  269. package/dist/plugins/zone-guard.js.map +1 -0
  270. package/dist/prerender.d.ts +10 -0
  271. package/dist/prerender.d.ts.map +1 -1
  272. package/dist/prerender.js +35 -3
  273. package/dist/prerender.js.map +1 -1
  274. package/dist/public-dir.d.ts +9 -0
  275. package/dist/public-dir.d.ts.map +1 -1
  276. package/dist/public-dir.js +131 -48
  277. package/dist/public-dir.js.map +1 -1
  278. package/dist/pwa-manifest.d.ts +68 -0
  279. package/dist/pwa-manifest.d.ts.map +1 -0
  280. package/dist/pwa-manifest.js +178 -0
  281. package/dist/pwa-manifest.js.map +1 -0
  282. package/dist/query.js +4 -4
  283. package/dist/query.js.map +1 -1
  284. package/dist/render-seam.d.ts +42 -2
  285. package/dist/render-seam.d.ts.map +1 -1
  286. package/dist/render-seam.js +6 -0
  287. package/dist/render-seam.js.map +1 -1
  288. package/dist/route-manifest.d.ts +3 -0
  289. package/dist/route-manifest.d.ts.map +1 -1
  290. package/dist/route-manifest.js +26 -3
  291. package/dist/route-manifest.js.map +1 -1
  292. package/dist/route-types.d.ts +24 -0
  293. package/dist/route-types.d.ts.map +1 -0
  294. package/dist/route-types.js +195 -0
  295. package/dist/route-types.js.map +1 -0
  296. package/dist/router.d.ts +28 -6
  297. package/dist/router.d.ts.map +1 -1
  298. package/dist/router.js +129 -40
  299. package/dist/router.js.map +1 -1
  300. package/dist/trusted-html.d.ts +34 -0
  301. package/dist/trusted-html.d.ts.map +1 -0
  302. package/dist/trusted-html.js +35 -0
  303. package/dist/trusted-html.js.map +1 -0
  304. package/dist/vitals.d.ts +33 -0
  305. package/dist/vitals.d.ts.map +1 -0
  306. package/dist/vitals.js +16 -0
  307. package/dist/vitals.js.map +1 -0
  308. package/dist/vite.d.ts +16 -1
  309. package/dist/vite.d.ts.map +1 -1
  310. package/dist/vite.js +187 -36
  311. package/dist/vite.js.map +1 -1
  312. package/dist/zones.d.ts +70 -0
  313. package/dist/zones.d.ts.map +1 -0
  314. package/dist/zones.js +365 -0
  315. package/dist/zones.js.map +1 -0
  316. package/package.json +67 -14
  317. package/src/backend-only.ts +16 -0
  318. package/src/boundary.ts +18 -5
  319. package/src/build-plan.ts +113 -36
  320. package/src/build-vite.ts +124 -15
  321. package/src/build.ts +535 -102
  322. package/src/bun-dev-entry.ts +1 -1
  323. package/src/cdn.ts +216 -0
  324. package/src/client.ts +251 -18
  325. package/src/conformance.ts +16 -3
  326. package/src/csp.ts +194 -0
  327. package/src/css-contract.ts +44 -0
  328. package/src/dev-client.ts +349 -0
  329. package/src/dev-diagnostics.ts +60 -7
  330. package/src/dev-error.ts +49 -5
  331. package/src/dev-feed.ts +1140 -0
  332. package/src/dev-indicator.ts +113 -0
  333. package/src/dev-session.ts +729 -0
  334. package/src/dev-sourcemap.ts +281 -0
  335. package/src/dev-ssr-graph.ts +16 -5
  336. package/src/dev.ts +86 -43
  337. package/src/diagnostic-catalog.ts +243 -0
  338. package/src/diagnostic-prompt.ts +355 -0
  339. package/src/diagnostic.ts +63 -66
  340. package/src/draft.ts +4 -1
  341. package/src/fn.ts +19 -3
  342. package/src/fs.ts +13 -2
  343. package/src/index.ts +57 -10
  344. package/src/internal/cdn/cloudflare.ts +146 -0
  345. package/src/internal/cdn/core.ts +316 -0
  346. package/src/internal/cdn/fastly.ts +62 -0
  347. package/src/internal/cdn/vercel.ts +95 -0
  348. package/src/internal/code-unit-order.ts +5 -0
  349. package/src/internal/codegen.ts +180 -29
  350. package/src/internal/dev-csp.ts +176 -0
  351. package/src/internal/dev-reserved.ts +52 -0
  352. package/src/internal/html-spans.ts +50 -0
  353. package/src/internal/loading-runtime.ts +244 -0
  354. package/src/internal/matches-runtime.ts +62 -0
  355. package/src/internal/mount-shadow.ts +88 -0
  356. package/src/internal/node-builtins.ts +79 -0
  357. package/src/internal/output-guard.ts +701 -0
  358. package/src/internal/page-execution.ts +1371 -0
  359. package/src/internal/parity.ts +150 -36
  360. package/src/internal/private-env.ts +334 -0
  361. package/src/internal/public-url.ts +22 -0
  362. package/src/internal/render-document.ts +153 -73
  363. package/src/internal/request-url.ts +14 -0
  364. package/src/internal/response-controls.ts +284 -0
  365. package/src/internal/runtime-contract.ts +10 -15
  366. package/src/internal/secret-scan.ts +623 -0
  367. package/src/internal/server-boundary.ts +6 -69
  368. package/src/internal/source-map.ts +256 -0
  369. package/src/internal/target-compat.ts +92 -0
  370. package/src/internal/timing-safe-equal.ts +13 -0
  371. package/src/internal/vitals-runtime.ts +53 -0
  372. package/src/internal/web-app.ts +290 -879
  373. package/src/internal/zone-graph.ts +593 -0
  374. package/src/isr.ts +366 -39
  375. package/src/llms-txt.ts +44 -8
  376. package/src/manifest.ts +671 -17
  377. package/src/module-graph.ts +53 -9
  378. package/src/navigation.ts +29 -6
  379. package/src/nonce.ts +104 -0
  380. package/src/plugins/css-modules.ts +5 -3
  381. package/src/plugins/kit.ts +24 -0
  382. package/src/plugins/svg.ts +135 -4
  383. package/src/plugins/vite-leak-guard.ts +529 -16
  384. package/src/plugins/vite-zone-guard.ts +263 -0
  385. package/src/plugins/zone-guard.ts +110 -0
  386. package/src/prerender.ts +49 -3
  387. package/src/public-dir.ts +135 -49
  388. package/src/pwa-manifest.ts +226 -0
  389. package/src/query.ts +4 -4
  390. package/src/render-seam.ts +46 -1
  391. package/src/route-manifest.ts +15 -3
  392. package/src/route-types.ts +264 -0
  393. package/src/router.ts +163 -63
  394. package/src/trusted-html.ts +44 -0
  395. package/src/vitals.ts +44 -0
  396. package/src/vite.ts +207 -36
  397. package/src/zones.ts +430 -0
  398. package/dist/plugins/vite-server-only.d.ts +0 -49
  399. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  400. package/dist/plugins/vite-server-only.js +0 -57
  401. package/dist/plugins/vite-server-only.js.map +0 -1
  402. package/dist/server-only.d.ts +0 -21
  403. package/dist/server-only.d.ts.map +0 -1
  404. package/dist/server-only.js +0 -25
  405. package/dist/server-only.js.map +0 -1
  406. package/src/plugins/vite-server-only.ts +0 -75
  407. package/src/server-only.ts +0 -25
package/src/build.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  * are Bun-specific and never on the request path (own subpath, like `@nifrajs/web/fs`); the *output*
6
6
  * runs on any runtime.
7
7
  */
8
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
9
9
  import { cp, lstat, mkdir, realpath } from "node:fs/promises"
10
10
  import {
11
11
  dirname,
12
12
  join,
13
13
  basename as pathBasename,
14
+ posix,
14
15
  relative,
15
16
  resolve as resolvePath,
16
17
  sep,
@@ -30,7 +31,9 @@ import {
30
31
  detectServerOnlyInClient,
31
32
  formatNodeBuiltinLeak,
32
33
  formatServerOnlyLeak,
34
+ parseBuildTarget,
33
35
  parseManifestClientEntry,
36
+ parseManifestCssLoading,
34
37
  parseManifestRouteStyles,
35
38
  parseManifestStyles,
36
39
  planBuildTarget,
@@ -38,6 +41,7 @@ import {
38
41
  type SizeReport,
39
42
  } from "./build-plan.ts"
40
43
  import { sanitizeOutputNames } from "./chunk-names.ts"
44
+ import { type CssLoadingMode, normalizeCssCodeSplit, normalizeCssLoading } from "./css-contract.ts"
41
45
  import { discoverRoutes } from "./fs.ts"
42
46
  import { generateClientEntry, generateServerManifest } from "./index.ts"
43
47
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
@@ -46,16 +50,42 @@ import {
46
50
  assertIdentityParity,
47
51
  collectDevelopmentParityInput,
48
52
  } from "./internal/parity.ts"
53
+ import { privateEnvCheck } from "./internal/private-env.ts"
54
+ import { publicUrlPath } from "./internal/public-url.ts"
55
+ import {
56
+ assertNoSecrets,
57
+ buildEnvironment,
58
+ emittedScanFiles,
59
+ formatSecretFindings,
60
+ graphScanInput,
61
+ originName,
62
+ publicScanFiles,
63
+ type SecretExemption,
64
+ scanForSecrets,
65
+ textOf,
66
+ } from "./internal/secret-scan.ts"
49
67
  import {
50
68
  generateServerFnStub,
51
69
  SERVER_FN_MODULE,
52
- SERVER_ONLY_MODULE,
53
- SERVER_ONLY_REPLACEMENT,
54
70
  serverFnNamespace,
55
71
  } from "./internal/server-boundary.ts"
72
+ import { formatUnsupportedBuiltins, unsupportedBuiltins } from "./internal/target-compat.ts"
73
+ import {
74
+ accountEmittedFiles,
75
+ bunModuleSource,
76
+ type EmittedFile,
77
+ formatClientGraphVerdict,
78
+ formatServerGraphVerdict,
79
+ formatUnaccountedOutput,
80
+ verifyClientGraph,
81
+ verifyServerGraph,
82
+ } from "./internal/zone-graph.ts"
83
+ import { type ClientModuleGraph, fromBunMetafile } from "./module-graph.ts"
84
+ import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
56
85
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
57
- import { fromBunMetafile } from "./module-graph.ts"
58
86
  import { prerenderRoutes } from "./prerender.ts"
87
+ import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
88
+ import { createZoneClassifier } from "./zones.ts"
59
89
 
60
90
  export * from "./build-plan.ts"
61
91
 
@@ -80,6 +110,8 @@ interface BunMetafile {
80
110
  >
81
111
  }
82
112
 
113
+ // The secret scan every client build, `public/` copy and prerender runs.
114
+ export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
83
115
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
84
116
  // run after `buildClient`.
85
117
  export {
@@ -133,6 +165,12 @@ export interface BuildClientOptions {
133
165
  * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
134
166
  * `process.env`) at build time. */
135
167
  readonly publicEnvPrefix?: string
168
+ /**
169
+ * Reviewed false positives of the secret scan, which fails the build when a bundle, source map or
170
+ * `public/` file carries what looks like a credential or the value of a non-public environment
171
+ * variable. Each names its rule, its file (or, for an environment value, its variable) and a reason.
172
+ */
173
+ readonly secretExemptions?: readonly SecretExemption[]
136
174
  }
137
175
 
138
176
  /**
@@ -157,22 +195,6 @@ export function publicEnvDefines(
157
195
  return defines
158
196
  }
159
197
 
160
- /**
161
- * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
162
- *
163
- * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
164
- * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
165
- * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
166
- * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
167
- * be escaped explicitly here.
168
- */
169
- function encodePathSegment(segment: string): string {
170
- return encodeURI(segment).replace(
171
- /[?#]/g,
172
- (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
173
- )
174
- }
175
-
176
198
  /**
177
199
  * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
178
200
  * single rule longer than 100 characters.
@@ -245,7 +267,7 @@ function collapsibleDirs(
245
267
  }
246
268
 
247
269
  /**
248
- * Build the cf-pages `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
270
+ * Build the cloudflare `_routes.json` rules for a set of copied public files, within Cloudflare's budget.
249
271
  *
250
272
  * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
251
273
  * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
@@ -332,7 +354,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
332
354
  const target = join(to, rel)
333
355
  await mkdir(join(target, ".."), { recursive: true })
334
356
  await cp(candidateReal, target)
335
- copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
357
+ copied.push(publicUrlPath(rel))
336
358
  }
337
359
  return copied.sort()
338
360
  }
@@ -350,11 +372,13 @@ export function resyncServerManifestSource(
350
372
  manifest: Parameters<typeof generateServerManifest>[0],
351
373
  routesPrefix: string,
352
374
  ): string {
375
+ const cssLoading = parseManifestCssLoading(source)
353
376
  return generateServerManifest(manifest, {
354
377
  resolve: (file) => `${routesPrefix}${file}`,
355
378
  clientEntry: parseManifestClientEntry(source) ?? "",
356
379
  styles: parseManifestStyles(source),
357
380
  routeStyles: parseManifestRouteStyles(source),
381
+ ...(cssLoading !== undefined ? { cssLoading } : {}),
358
382
  lazy: MANIFEST_IS_LAZY.test(source),
359
383
  })
360
384
  }
@@ -407,6 +431,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
407
431
  // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
408
432
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
409
433
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
434
+ const privateEnv = privateEnvCheck(options.publicEnvPrefix ?? "PUBLIC_")
410
435
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
411
436
  // importing file, so an absolute `--out /tmp/deploy` must still resolve the app's dependencies.
412
437
  // A unique directory also avoids clobbering a user file or colliding with parallel builds.
@@ -428,6 +453,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
428
453
  ...routeManifest.routes.map((r) => r.file),
429
454
  ...Object.values(routeManifest.layouts).map((l) => l.file),
430
455
  ...(routeManifest.notFound ? [routeManifest.notFound.file] : []),
456
+ // A nested `_404` is an entry only for its stylesheet: it renders on the server, unhydrated.
457
+ ...Object.values(routeManifest.notFounds ?? {}).map((page) => page.file),
458
+ // A `_loading` page is an entry for its stylesheet too: the bootstrap imports it lazily.
459
+ ...Object.values(routeManifest.loadings ?? {}).map((page) => page.file),
431
460
  ]),
432
461
  ].sort()
433
462
 
@@ -436,12 +465,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
436
465
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
437
466
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
438
467
  // `@types/bun`'s `BuildConfig`, so spread it in (spread props skip the excess-property check).
439
- const buildExtras = { metafile: true }
468
+ const buildExtras = { metafile: true, throw: false }
469
+ const classifier = createZoneClassifier({ appRoot: root, routesDir, generatedFiles: [entryFile] })
470
+ const refused = new Map<string, string>()
471
+ // No `outdir`: the bundle stays in memory until the graph proves it holds browser code only.
440
472
  const result = await (async () => {
441
473
  try {
442
474
  return await Bun.build({
443
475
  entrypoints: [entryFile, ...routeFiles.map(resolve)],
444
- outdir: outDir,
445
476
  target: "browser",
446
477
  naming: "[name]-[hash].[ext]",
447
478
  publicPath,
@@ -452,11 +483,16 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
452
483
  ...buildExtras,
453
484
  minify: options.minify ?? true,
454
485
  plugins: [
486
+ // First, so it sees every file before another plugin's `onLoad` claims it.
487
+ zoneGuardPlugin({
488
+ appRoot: root,
489
+ classifier,
490
+ onDenied: (file, reason) => refused.set(file, reason),
491
+ }),
455
492
  ...declaredSingleCopyPlugins(root),
456
493
  reactDedupePlugin(routesDir),
457
494
  preactDedupePlugin(routesDir),
458
495
  svelteDedupePlugin(routesDir),
459
- serverOnlyEmptyPlugin(),
460
496
  serverFnStubPlugin(),
461
497
  ...(options.plugins ?? []),
462
498
  ],
@@ -464,10 +500,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
464
500
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
465
501
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
466
502
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
467
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
468
- // never leak). Callers can override any of these via `options.define` (layered last).
503
+ // other `process.env.X` becomes undefined (the bare `process.env` → `{}` fallback - so secrets
504
+ // never leak). Callers can override any of these via `options.define` (layered last). The value
505
+ // must be JSON: Bun inlines anything else (`({})`) as a string literal.
469
506
  define: {
470
- "process.env": "({})",
507
+ "process.env": "{}",
471
508
  "process.env.NODE_ENV": JSON.stringify(mode),
472
509
  ...publicDefines,
473
510
  ...options.define,
@@ -477,40 +514,125 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
477
514
  rmSync(entryDir, { recursive: true, force: true })
478
515
  }
479
516
  })()
517
+ const cwd = process.cwd()
518
+ const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
480
519
  if (!result.success) {
520
+ // A refused file can be what broke the build (a backend module importing a server-only built-in),
521
+ // so the refusal is the error to show, not the bundler's symptom.
522
+ const verdict =
523
+ refused.size > 0 && clientMeta !== undefined
524
+ ? formatClientGraphVerdict(
525
+ verifyClientGraph(fromBunMetafile(clientMeta), {
526
+ classifier,
527
+ sourceOf: bunModuleSource(cwd),
528
+ privateEnv,
529
+ refused: new Set(refused.keys()),
530
+ }),
531
+ )
532
+ : undefined
533
+ if (verdict !== undefined) throw new Error(verdict)
534
+ if (refused.size > 0) throw new Error(formatRefusedFiles(refused, root))
481
535
  throw new Error(
482
536
  `[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
483
537
  )
484
538
  }
485
539
 
486
- // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
487
- // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
488
- // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
489
- // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
490
- // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
491
- const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
492
- const clientGraph = fromBunMetafile(clientMeta)
540
+ const emitted: EmittedFile[] = await Promise.all(
541
+ result.outputs.map(async (out): Promise<EmittedFile> => {
542
+ if (out.kind === "sourcemap") return { name: out.path, kind: "map", text: await out.text() }
543
+ if (out.kind === "asset") {
544
+ if (out.path.endsWith(".css"))
545
+ return { name: out.path, kind: "css", text: await out.text() }
546
+ const text = textOf(new Uint8Array(await out.arrayBuffer()))
547
+ return { name: out.path, kind: "asset", ...(text === undefined ? {} : { text }) }
548
+ }
549
+ return { name: out.path, kind: "code", text: await out.text() }
550
+ }),
551
+ )
552
+ const clientGraph = withEmittedImports(fromBunMetafile(clientMeta), emitted, publicPath)
553
+ const verdict = verifyClientGraph(clientGraph, {
554
+ classifier,
555
+ sourceOf: bunModuleSource(cwd),
556
+ privateEnv,
557
+ refused: new Set(refused.keys()),
558
+ })
559
+ // Two independent records of one fact: a file the plugin refused that the graph never shows means the
560
+ // graph is missing evidence, and the build cannot prove anything else about it either.
561
+ const reported = new Set(verdict.leaks.map((leak) => resolvePath(root, leak.module)))
562
+ const unseen = [...refused.keys()].filter((file) => !reported.has(file))
563
+ const graphMessage = formatClientGraphVerdict(
564
+ unseen.length === 0
565
+ ? verdict
566
+ : {
567
+ ...verdict,
568
+ gaps: [
569
+ ...verdict.gaps,
570
+ ...unseen.map(
571
+ (file) => `${file} was refused while loading but is missing from the graph`,
572
+ ),
573
+ ],
574
+ },
575
+ )
576
+ if (graphMessage !== undefined) throw new Error(graphMessage)
493
577
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
494
578
  // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
495
579
  // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
496
580
  const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
497
581
  if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
498
582
 
499
- // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
500
- // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
501
- // `*.server`, so neither other guard fires. Same shared formatter as above.
583
+ // A module that imports the `backend-only` marker yet reached a client chunk: library code the zones
584
+ // cannot place on a side by path alone. Same shared formatter as the Vite pipeline.
502
585
  const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
503
586
  if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
504
587
 
588
+ const unaccounted = formatUnaccountedOutput(
589
+ accountEmittedFiles(emitted, clientGraph, {
590
+ classifier,
591
+ sourceOf: bunModuleSource(cwd),
592
+ outDir: resolvePath(outDir),
593
+ }),
594
+ )
595
+ if (unaccounted !== undefined) throw new Error(unaccounted)
596
+
597
+ const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
598
+ const { sources, originsOf } = graphScanInput(clientGraph, bunModuleSource(cwd), classifier)
599
+ const secrets = formatSecretFindings(
600
+ scanForSecrets({
601
+ sources,
602
+ artifacts: [
603
+ ...emitted.flatMap((file) => {
604
+ if (file.text === undefined) return []
605
+ const name = file.name.replace(/^\.\//, "")
606
+ return emittedScanFiles(name, file.text, originsOf(name))
607
+ }),
608
+ ...(publicDir !== undefined && existsSync(publicDir)
609
+ ? publicScanFiles(publicDir, originName(root, resolvePath(publicDir)))
610
+ : []),
611
+ ],
612
+ env: buildEnv,
613
+ publicEnvPrefix: options.publicEnvPrefix ?? "PUBLIC_",
614
+ ...(options.secretExemptions ? { exemptions: options.secretExemptions } : {}),
615
+ }),
616
+ )
617
+ if (secrets !== undefined) throw new Error(secrets)
618
+
619
+ const outputs = await Promise.all(
620
+ result.outputs.map(async (out) => {
621
+ const path = join(outDir, out.path)
622
+ await Bun.write(path, out)
623
+ return { kind: out.kind, path }
624
+ }),
625
+ )
626
+
505
627
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
506
628
  // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
507
- const renamed = sanitizeOutputNames(result.outputs)
629
+ const renamed = sanitizeOutputNames(outputs)
508
630
  const toUrl = (path: string): string =>
509
631
  `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
510
632
  // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
511
633
  // to its chunk URL by that order (guarded against drift), then a route's chunks = its layout chain +
512
634
  // own file.
513
- const entryPoints = result.outputs.filter((o) => o.kind === "entry-point")
635
+ const entryPoints = outputs.filter((o) => o.kind === "entry-point")
514
636
  const bootstrap = entryPoints[0]
515
637
  if (bootstrap === undefined) throw new Error("[nifra/web] build produced no entry-point output")
516
638
  if (entryPoints.length !== routeFiles.length + 1) {
@@ -549,7 +671,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
549
671
  // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
550
672
  // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
551
673
  // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
552
- const cssAssets = result.outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"))
674
+ const cssAssets = outputs.filter((o) => o.kind === "asset" && o.path.endsWith(".css"))
553
675
  const css: readonly string[] = [
554
676
  ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
555
677
  ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
@@ -561,7 +683,6 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
561
683
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
562
684
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
563
685
  // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
564
- const cwd = process.cwd()
565
686
  const cssByEntry = new Map<string, string>()
566
687
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
567
688
  if (out.entryPoint !== undefined && out.cssBundle !== undefined) {
@@ -581,24 +702,35 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
581
702
  // SSR pages unstyled. Keep routeStyles absent until at least one authored route entry owns CSS.
582
703
  const routeCssEntries = [...cssByEntry.keys()].filter((entry) => entry !== resolvePath(entryFile))
583
704
  if (css.length > 0 && routeCssEntries.length > 0) {
705
+ // A `_loading` page can show on a navigation from ANY page, and a document links only its own
706
+ // route's stylesheets - so every page that can start a navigation carries the loading pages' too.
707
+ const loadingFiles = Object.values(routeManifest.loadings ?? {}).map((page) => page.file)
584
708
  for (const route of routeManifest.routes) {
585
709
  routeStyles[route.id] = stylesFor([
586
710
  ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""),
587
711
  route.file,
712
+ ...loadingFiles,
713
+ ])
714
+ }
715
+ if (routeManifest.notFound) {
716
+ routeStyles._404 = stylesFor([routeManifest.notFound.file, ...loadingFiles])
717
+ }
718
+ for (const [id, page] of Object.entries(routeManifest.notFounds ?? {})) {
719
+ routeStyles[id] = stylesFor([
720
+ ...page.layoutIds.map((layoutId) => routeManifest.layouts[layoutId]?.file ?? ""),
721
+ page.file,
588
722
  ])
589
723
  }
590
- if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
591
724
  }
592
725
 
593
726
  // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
594
727
  // apps have none) and must not fail the build.
595
- const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
596
728
  const publicFiles =
597
729
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
598
730
 
599
731
  const manifest: BuildManifest = {
600
732
  entry: toUrl(bootstrap.path),
601
- assets: result.outputs.map((o) => toUrl(o.path)),
733
+ assets: outputs.map((o) => toUrl(o.path)),
602
734
  routes,
603
735
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
604
736
  ...(css.length > 0 ? { css } : {}),
@@ -626,6 +758,8 @@ export interface BuildServerOptions {
626
758
  readonly styles?: readonly string[] | undefined
627
759
  /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked alongside `styles`. */
628
760
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
761
+ /** How framework-owned stylesheet links should activate before hydration (default `"blocking"`). */
762
+ readonly cssLoading?: CssLoadingMode
629
763
  /** Route/layout file → import specifier in the generated manifest (default: a relative path from the
630
764
  * manifest's location - written next to `serverEntry` - to `routesDir`). */
631
765
  readonly resolve?: (file: string) => string
@@ -655,6 +789,14 @@ export interface BuildServerOptions {
655
789
  * - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
656
790
  * `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */
657
791
  readonly lazy?: boolean
792
+ /** The app root the zone rules classify against (default: the directory holding `routesDir`). */
793
+ readonly root?: string
794
+ /** Modules a build tool wrote or names as an entry (the adapter module a generated entry imports).
795
+ * Like `serverEntry` and the generated manifest, they may import both halves of a route. */
796
+ readonly generatedFiles?: readonly string[]
797
+ /** The public-env prefix the app's browser code may read (default `"PUBLIC_"`). Shared code in
798
+ * the server bundle is held to it too. */
799
+ readonly publicEnvPrefix?: string
658
800
  }
659
801
 
660
802
  /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
@@ -745,12 +887,55 @@ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($
745
887
  * fixed subpath list), Svelte has many internal subpaths, so each matched import is resolved dynamically.
746
888
  * `svelte/compiler` (build-time only, not in the bundle) doesn't match the filter and is left alone. No-op
747
889
  * when Svelte isn't used / isn't resolvable from `from`.
890
+ *
891
+ * Svelte is NOT condition-agnostic, unlike React and Preact: its bare entry maps `browser` to the client
892
+ * runtime and `default` to the server one. `Bun.resolveSync` answers for the process running the build -
893
+ * a server runtime - so pinning its answer put the SERVER entry into a browser bundle, where `hydrate`
894
+ * throws and the page never becomes interactive. For a browser bundle the package is therefore pinned by
895
+ * DIRECTORY and its export map is read with the bundle's own conditions.
748
896
  */
749
897
  export const svelteDedupePlugin = (from: string): BunPlugin => ({
750
898
  name: "nifra-svelte-dedupe",
751
899
  setup(build) {
900
+ // Absent when the plugin runs as a runtime plugin rather than inside `Bun.build`; a build with no
901
+ // `target` is a browser build (Bun's default).
902
+ const config = (
903
+ build as { config?: { target?: string; conditions?: string | readonly string[] } }
904
+ ).config
905
+ const browser = config !== undefined && (config.target ?? "browser") === "browser"
906
+ const conditions = new Set<string>([
907
+ "browser",
908
+ "import",
909
+ ...(typeof config?.conditions === "string"
910
+ ? [config.conditions]
911
+ : (config?.conditions ?? [])),
912
+ ])
913
+ // The pinned copy's export map, read once. `null` when Svelte is not resolvable from the app root.
914
+ let pinned: { readonly root: string; readonly map: Record<string, unknown> } | null | undefined
915
+ const pin = (): { readonly root: string; readonly map: Record<string, unknown> } | null => {
916
+ if (pinned !== undefined) return pinned
917
+ try {
918
+ const manifest = Bun.resolveSync("svelte/package.json", from)
919
+ const map = (JSON.parse(readFileSync(manifest, "utf8")) as { exports?: unknown }).exports
920
+ pinned =
921
+ typeof map === "object" && map !== null
922
+ ? { root: dirname(manifest), map: map as Record<string, unknown> }
923
+ : null
924
+ } catch {
925
+ pinned = null
926
+ }
927
+ return pinned
928
+ }
752
929
  build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
753
930
  try {
931
+ if (browser) {
932
+ const copy = pin()
933
+ const target =
934
+ copy === null
935
+ ? undefined
936
+ : exportTarget(copy.map[`.${args.path.slice("svelte".length)}`], conditions)
937
+ if (copy !== null && target !== undefined) return { path: join(copy.root, target) }
938
+ }
754
939
  return { path: Bun.resolveSync(args.path, from) }
755
940
  } catch {
756
941
  return undefined // not resolvable from the app root - leave Bun's default resolution
@@ -759,6 +944,19 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
759
944
  },
760
945
  })
761
946
 
947
+ /** Walk one `exports` entry the way a resolver does: the first key, in the package's own order, that is
948
+ * an active condition (or `default`) wins. */
949
+ function exportTarget(entry: unknown, conditions: ReadonlySet<string>): string | undefined {
950
+ if (typeof entry === "string") return entry
951
+ if (typeof entry !== "object" || entry === null) return undefined
952
+ for (const [key, value] of Object.entries(entry)) {
953
+ if (key !== "default" && !conditions.has(key)) continue
954
+ const target = exportTarget(value, conditions)
955
+ if (target !== undefined) return target
956
+ }
957
+ return undefined
958
+ }
959
+
762
960
  /**
763
961
  * The app's declared single-copy rule, applied to the bundle.
764
962
  *
@@ -775,33 +973,60 @@ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] =>
775
973
  readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })]
776
974
 
777
975
  /**
778
- * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
779
- * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
780
- * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
781
- * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
782
- * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
783
- * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
784
- * module is the fix. CLIENT-only - buildServer keeps the real module, which runs server-side.
976
+ * Give each output chunk the specifiers its emitted code imports. Bun's metafile does not record them
977
+ * (its output `imports` stay empty even for an external the code keeps), so they are read from the code.
978
+ * A specifier under `publicPath` or relative to the chunk is mapped back to the output it names.
785
979
  */
786
- export const serverOnlyEmptyPlugin = (): BunPlugin => ({
787
- name: "nifra-server-only-empty",
788
- setup(build) {
789
- build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
790
- contents: SERVER_ONLY_REPLACEMENT,
791
- loader: "js",
792
- }))
793
- },
794
- })
980
+ function withEmittedImports(
981
+ graph: ClientModuleGraph,
982
+ emitted: readonly EmittedFile[],
983
+ publicPath?: string,
984
+ ): ClientModuleGraph {
985
+ const scanner = new Bun.Transpiler({ loader: "js" })
986
+ const imports = new Map<string, string[]>()
987
+ for (const file of emitted) {
988
+ if (file.kind !== "code" || file.text === undefined) {
989
+ imports.set(posix.normalize(file.name), [])
990
+ continue
991
+ }
992
+ imports.set(
993
+ posix.normalize(file.name),
994
+ scanner.scanImports(file.text).map(({ path }) => {
995
+ if (publicPath !== undefined && path.startsWith(publicPath))
996
+ return path.slice(publicPath.length)
997
+ if (path.startsWith("./") || path.startsWith("../"))
998
+ return posix.join(posix.dirname(posix.normalize(file.name)), path)
999
+ return path
1000
+ }),
1001
+ )
1002
+ }
1003
+ const chunks: Record<string, ClientModuleGraph["chunks"][string]> = {}
1004
+ // A chunk with no emitted file keeps no `imports`, which the verifiers treat as missing evidence.
1005
+ for (const [path, chunk] of Object.entries(graph.chunks)) {
1006
+ const kept = imports.get(posix.normalize(path))
1007
+ chunks[path] = kept === undefined ? chunk : { ...chunk, imports: kept }
1008
+ }
1009
+ return { ...graph, chunks }
1010
+ }
1011
+
1012
+ /** Refusals the bundler stopped on before it produced a graph, so without import chains. */
1013
+ function formatRefusedFiles(refused: ReadonlyMap<string, string>, root: string): string {
1014
+ const lines = [...refused].map(([file, reason]) => {
1015
+ const rel = relative(root, file).replaceAll("\\", "/")
1016
+ return ` - ${rel.startsWith("..") ? file : rel}: ${reason}`
1017
+ })
1018
+ return `[nifra/web] the browser build reached code that may not ship to a browser:\n${lines.join("\n")}`
1019
+ }
1020
+
1021
+ export { zoneGuardPlugin } from "./plugins/zone-guard.ts"
795
1022
 
796
1023
  /**
797
1024
  * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
798
1025
  * the server mounted, so the function bodies - and everything they import - never reach a browser.
799
1026
  *
800
- * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
801
- * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
802
- * client is supposed to call it, just over HTTP. The generation itself is in
803
- * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
804
- * hand-written copies would be a client that works in dev and 404s in production.
1027
+ * The generation itself is in `internal/server-boundary.ts` so the Vite pipeline emits identical stubs
1028
+ * from the same code; two hand-written copies would be a client that works in dev and 404s in
1029
+ * production.
805
1030
  *
806
1031
  * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
807
1032
  */
@@ -847,7 +1072,7 @@ const solidWebServerPlugin = (from: string): BunPlugin => ({
847
1072
  * silently ships a broken worker.
848
1073
  */
849
1074
  export async function buildServer(options: BuildServerOptions): Promise<ServerBuild> {
850
- const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles } = options
1075
+ const { routesDir, serverEntry, outDir, clientEntry, styles, routeStyles, cssLoading } = options
851
1076
  const entryDir = dirname(serverEntry)
852
1077
  const manifestFile = options.manifestFile ?? "server-manifest.ts"
853
1078
  // Default: import routes relative from the generated manifest (next to serverEntry) to routesDir.
@@ -866,13 +1091,21 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
866
1091
  const manifest = discoverRoutes(routesDir)
867
1092
  writeFileSync(
868
1093
  `${entryDir}/${manifestFile}`,
869
- generateServerManifest(manifest, { resolve, clientEntry, styles, routeStyles, lazy }),
1094
+ generateServerManifest(manifest, {
1095
+ resolve,
1096
+ clientEntry,
1097
+ styles,
1098
+ routeStyles,
1099
+ ...(cssLoading !== undefined ? { cssLoading } : {}),
1100
+ lazy,
1101
+ }),
870
1102
  )
871
1103
 
872
1104
  const result = await Bun.build({
873
1105
  entrypoints: [serverEntry],
874
1106
  outdir: outDir,
875
1107
  target,
1108
+ metafile: true,
876
1109
  conditions: [...conditions],
877
1110
  define: {
878
1111
  ...(options.define ?? { "process.env.NODE_ENV": '"production"' }),
@@ -902,6 +1135,48 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
902
1135
  `[nifra/web] server build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
903
1136
  )
904
1137
  }
1138
+ // Named as the metafile names outputs: relative to `outdir`.
1139
+ const nameOf = (path: string): string => relative(outDir, path).replaceAll("\\", "/")
1140
+ const emitted = await Promise.all(
1141
+ result.outputs.map(
1142
+ async (out): Promise<EmittedFile> =>
1143
+ out.kind === "sourcemap" || out.kind === "asset"
1144
+ ? { name: nameOf(out.path), kind: out.kind === "sourcemap" ? "map" : "asset" }
1145
+ : { name: nameOf(out.path), kind: "code", text: await out.text() },
1146
+ ),
1147
+ )
1148
+ const serverGraph = withEmittedImports(
1149
+ fromBunMetafile((result as unknown as { metafile?: BunMetafile }).metafile),
1150
+ emitted,
1151
+ )
1152
+ const appRoot = resolvePath(options.root ?? dirname(routesDir))
1153
+ const classifier = createZoneClassifier({
1154
+ appRoot,
1155
+ routesDir,
1156
+ generatedFiles: [
1157
+ serverEntry,
1158
+ join(entryDir, manifestFile),
1159
+ ...(options.generatedFiles ?? []),
1160
+ ].map((file) => resolvePath(file)),
1161
+ })
1162
+ const sourceOf = bunModuleSource(process.cwd())
1163
+ const labelOf = (id: string): string => {
1164
+ const source = sourceOf(id)
1165
+ return source.kind === "file" ? relative(appRoot, source.file).replaceAll("\\", "/") : id
1166
+ }
1167
+ const refusal =
1168
+ formatServerGraphVerdict(
1169
+ verifyServerGraph(serverGraph, {
1170
+ classifier,
1171
+ sourceOf,
1172
+ privateEnv: privateEnvCheck(options.publicEnvPrefix ?? "PUBLIC_"),
1173
+ }),
1174
+ ) ?? formatUnsupportedBuiltins(unsupportedBuiltins(serverGraph, target, labelOf), target)
1175
+ if (refusal !== undefined) {
1176
+ // Bun wrote the bundle already; a refused build leaves nothing a deploy step could pick up.
1177
+ for (const output of result.outputs) rmSync(output.path, { force: true })
1178
+ throw new Error(refusal)
1179
+ }
905
1180
  const entryOutput = result.outputs.find((o) => o.kind === "entry-point")
906
1181
  if (entryOutput === undefined) {
907
1182
  throw new Error("[nifra/web] server build produced no entry-point output")
@@ -918,12 +1193,37 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
918
1193
  // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
919
1194
  // ===================================================================================================
920
1195
 
1196
+ /** The `createWebApp` options a generated server entry can import from the app's framework module. */
1197
+ export const SERVER_ENTRY_OPTIONS = ["apiPrefix", "apiStrip", "mounts", "csp", "nonce"] as const
1198
+ export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
1199
+ /** Each importable option mapped to the specifier of the module that exports it. */
1200
+ export type ServerEntryOptionImports = Readonly<Partial<Record<ServerEntryOption, string>>>
1201
+
1202
+ /**
1203
+ * Where `c.clientIp` comes from in a generated server entry. Left out (the default), an edge target has
1204
+ * no caller address at all and a self-hosting target uses the socket peer. `"platform"` also trusts the
1205
+ * header an edge target's platform overwrites at its edge (see {@link PLATFORM_CLIENT_IP_HEADERS}); it
1206
+ * changes nothing on a self-hosting target, whose socket peer is already the platform's answer.
1207
+ */
1208
+ export type ServerEntryClientIp = "platform"
1209
+
1210
+ /**
1211
+ * The header each edge platform sets to the caller's address, replacing any value the client sent.
1212
+ * That replacement is the whole trust: the same bundle served without the platform's edge in front
1213
+ * (`wrangler pages dev`, a self-hosted workerd) hands the header straight from the client, which is
1214
+ * why it is only believed on an explicit `clientIp: "platform"`.
1215
+ */
1216
+ const PLATFORM_CLIENT_IP_HEADERS: Readonly<Partial<Record<BuildTarget, string>>> = {
1217
+ cloudflare: "cf-connecting-ip",
1218
+ vercel: "x-real-ip",
1219
+ }
1220
+
921
1221
  /**
922
1222
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
923
1223
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
924
1224
  * generated `{ manifest, clientEntry }` (from `./server-manifest`), builds `createWebApp`, then wires
925
1225
  * the right host:
926
- * - `cf-pages` / `vercel`: `export default` the fetch handler (the platform serves /assets/* itself).
1226
+ * - `cloudflare` / `vercel`: `export default` the fetch handler (the platform serves /assets/* itself).
927
1227
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
928
1228
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
929
1229
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
@@ -940,19 +1240,42 @@ export function generateServerEntry(options: {
940
1240
  /** Import specifier for the module exporting `use`, or `undefined`. Must resolve to the
941
1241
  * same edge-safe module as `adapterImport`. */
942
1242
  readonly useImport?: string
1243
+ /** `createWebApp` options to import by name, each mapped to the specifier of the edge-safe module
1244
+ * that exports it. */
1245
+ readonly optionImports?: ServerEntryOptionImports
943
1246
  /** Document `<title>` passed to `createWebApp`. */
944
1247
  readonly title?: string
945
1248
  /** Encoded root-relative public file paths copied into the deploy directory. */
946
1249
  readonly publicFiles?: readonly string[]
1250
+ /** Trust the edge platform's client-address header for `c.clientIp`. See {@link ServerEntryClientIp}. */
1251
+ readonly clientIp?: ServerEntryClientIp
947
1252
  }): string {
948
1253
  const {
949
1254
  target,
950
1255
  adapterImport,
951
1256
  backendImport,
952
1257
  useImport,
1258
+ optionImports = {},
953
1259
  title = "nifra",
954
1260
  publicFiles = [],
1261
+ clientIp,
955
1262
  } = options
1263
+ if (clientIp !== undefined && clientIp !== "platform") {
1264
+ throw new Error(
1265
+ `[nifra/web] generateServerEntry: clientIp must be "platform" or left out, got ${JSON.stringify(clientIp)}`,
1266
+ )
1267
+ }
1268
+ const clientIpHeader = clientIp === "platform" ? PLATFORM_CLIENT_IP_HEADERS[target] : undefined
1269
+ // Identifiers come only from the fixed list, never from the caller's keys, so generated code names
1270
+ // nothing the list does not.
1271
+ const importedOptions = SERVER_ENTRY_OPTIONS.filter((name) => optionImports[name] !== undefined)
1272
+ const optionModules = new Map<string, string[]>()
1273
+ for (const name of importedOptions) {
1274
+ const from = optionImports[name] as string
1275
+ const names = optionModules.get(from)
1276
+ if (names === undefined) optionModules.set(from, [name])
1277
+ else names.push(name)
1278
+ }
956
1279
  if (target === "static") {
957
1280
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
958
1281
  }
@@ -960,30 +1283,40 @@ export function generateServerEntry(options: {
960
1283
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
961
1284
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
962
1285
  if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
1286
+ for (const [from, names] of optionModules) {
1287
+ lines.push(`import { ${names.join(", ")} } from ${JSON.stringify(from)}`)
1288
+ }
963
1289
  if (backendImport !== undefined) {
964
1290
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
965
1291
  }
966
- lines.push('import { clientEntry, manifest, styles, routeStyles } from "./server-manifest"')
967
- // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
968
- const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno"
969
- if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1292
+ lines.push(
1293
+ 'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
1294
+ )
1295
+ // Cloudflare needs the fetch-handler shape; the others call app.fetch directly.
1296
+ if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
970
1297
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
971
1298
  lines.push(
972
1299
  "",
973
1300
  "const app = createWebApp({",
974
1301
  " adapter,",
975
1302
  ...(useImport !== undefined ? [" use,"] : []),
1303
+ ...importedOptions.map((name) => ` ${name},`),
976
1304
  " manifest,",
977
1305
  " clientEntry,",
978
1306
  " styles,",
979
1307
  " routeStyles,",
1308
+ " cssLoading,",
980
1309
  ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
981
1310
  ` title: ${JSON.stringify(title)},`,
1311
+ // Core's own `{ header }` trust, so the mounted backend and every loader see the derived caller.
1312
+ ...(clientIpHeader !== undefined
1313
+ ? [` server: { clientIp: { header: ${JSON.stringify(clientIpHeader)} } },`]
1314
+ : []),
982
1315
  "})",
983
1316
  "",
984
1317
  )
985
1318
 
986
- if (target === "cf-pages") {
1319
+ if (target === "cloudflare") {
987
1320
  // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
988
1321
  // everything else falls through to this handler (SSR).
989
1322
  //
@@ -1033,22 +1366,34 @@ export function generateServerEntry(options: {
1033
1366
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1034
1367
  " return segments.every(safe) ? '.' + pathname : undefined",
1035
1368
  "}",
1036
- 'const TYPES = { js: "text/javascript", css: "text/css", map: "application/json" }',
1369
+ // The type table and cache policy `servePublicDir` uses. Without a known type, Bun answers a file
1370
+ // as an `application/octet-stream` attachment, which a browser downloads instead of showing.
1371
+ `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1372
+ "const staticHeaders = (pathname: string): Record<string, string> => ({",
1373
+ ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1374
+ ` "cache-control": pathname.startsWith("/assets/") ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1375
+ ' "x-content-type-options": "nosniff",',
1376
+ "})",
1037
1377
  )
1378
+ // Bun.serve and Deno.serve delimit the body themselves, so its declared length is the transport
1379
+ // frame - the guarantee core's own listen() and the Deno adapter mark (see markTrustedBodyFraming).
1380
+ const markFramed =
1381
+ '(req as unknown as Record<symbol, unknown>)[Symbol.for("nifra.body.trustedFraming")] = true'
1038
1382
  if (target === "bun") {
1039
1383
  lines.push(
1040
1384
  "const server = Bun.serve({",
1041
1385
  " port: Number(Bun.env.PORT ?? 3000),",
1042
- " async fetch(req) {",
1386
+ " async fetch(req, server) {",
1043
1387
  " const { pathname } = new URL(req.url)",
1044
1388
  " const filePath = staticPath(pathname)",
1045
1389
  " if (filePath !== undefined) {",
1046
1390
  " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1047
1391
  ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1048
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1049
- ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1392
+ " return new Response(file, { headers: staticHeaders(pathname) })",
1050
1393
  " }",
1051
- " return app.fetch(req)",
1394
+ ` ${markFramed}`,
1395
+ " // The socket peer, looked up only when something reads it (as core's own listen() does).",
1396
+ " return app.fetch(req, { get clientIp() { return server.requestIP(req)?.address } })",
1052
1397
  " },",
1053
1398
  "})",
1054
1399
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
@@ -1062,19 +1407,18 @@ export function generateServerEntry(options: {
1062
1407
  'import { readFile } from "node:fs/promises"',
1063
1408
  "await serve(",
1064
1409
  " {",
1065
- " async fetch(req) {",
1410
+ " async fetch(req, platform) {",
1066
1411
  " const { pathname } = new URL(req.url)",
1067
1412
  " const filePath = staticPath(pathname)",
1068
1413
  " if (filePath !== undefined) {",
1069
1414
  " try {",
1070
1415
  " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1071
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1072
- ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1416
+ " return new Response(body, { headers: staticHeaders(pathname) })",
1073
1417
  " } catch {",
1074
1418
  ' return new Response("not found", { status: 404 })',
1075
1419
  " }",
1076
1420
  " }",
1077
- " return app.fetch(req)",
1421
+ " return app.fetch(req, platform)",
1078
1422
  " },",
1079
1423
  " },",
1080
1424
  " { port: Number(process.env.PORT ?? 3000) },",
@@ -1084,22 +1428,21 @@ export function generateServerEntry(options: {
1084
1428
  }
1085
1429
  // deno
1086
1430
  lines.push(
1087
- "const handler = toFetchHandler(app)",
1088
1431
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1089
- 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1432
+ 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {',
1090
1433
  " const { pathname } = new URL(req.url)",
1091
1434
  " const filePath = staticPath(pathname)",
1092
1435
  " if (filePath !== undefined) {",
1093
1436
  " try {",
1094
1437
  " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1095
1438
  " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1096
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1097
- ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1439
+ " return new Response(body, { headers: staticHeaders(pathname) })",
1098
1440
  " } catch {",
1099
1441
  ' return new Response("not found", { status: 404 })',
1100
1442
  " }",
1101
1443
  " }",
1102
- " return handler.fetch(req)",
1444
+ ` ${markFramed}`,
1445
+ " return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
1103
1446
  "})",
1104
1447
  )
1105
1448
  return `${lines.join("\n")}\n`
@@ -1139,6 +1482,9 @@ export interface BuildTargetOptions {
1139
1482
  * applied before page routes are declared), or `undefined`. Must resolve to the same edge-safe
1140
1483
  * module as `adapterImport`. */
1141
1484
  readonly useImport?: string
1485
+ /** `createWebApp` options the server entry imports by name (`apiPrefix`, `mounts`, `csp`, ...), each
1486
+ * mapped to the specifier (resolvable from `workDir`) of the same edge-safe module as `adapterImport`. */
1487
+ readonly optionImports?: ServerEntryOptionImports
1142
1488
  /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the
1143
1489
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1144
1490
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
@@ -1157,8 +1503,25 @@ export interface BuildTargetOptions {
1157
1503
  readonly publicDir?: string | false
1158
1504
  /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
1159
1505
  readonly publicEnvPrefix?: string
1506
+ /** Reviewed false positives of the secret scan over the bundle, `public/` and prerendered pages. */
1507
+ readonly secretExemptions?: readonly SecretExemption[]
1508
+ /**
1509
+ * Vite-only CSS output policy. `false` emits one aggregate stylesheet. The native Bun build does not
1510
+ * support this switch and fails closed if it is supplied instead of silently ignoring it.
1511
+ */
1512
+ readonly cssCodeSplit?: boolean
1513
+ /**
1514
+ * Activation policy for framework-owned SSR stylesheet links. Defaults to `"blocking"`; `"deferred"`
1515
+ * fetches with `media="print"` and promotes before hydration. With Vite, pair it with
1516
+ * `cssCodeSplit: false`; the native Bun build supports the activation policy but keeps its existing
1517
+ * CSS output strategy.
1518
+ */
1519
+ readonly cssLoading?: CssLoadingMode
1160
1520
  /** Document `<title>` for the generated server entry. */
1161
1521
  readonly title?: string
1522
+ /** Trust the edge platform's client-address header for `c.clientIp` (`cloudflare`, `vercel`). Off by
1523
+ * default, leaving an edge app with no caller address. See {@link ServerEntryClientIp}. */
1524
+ readonly clientIp?: ServerEntryClientIp
1162
1525
  }
1163
1526
 
1164
1527
  /** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */
@@ -1186,29 +1549,36 @@ export interface BuildTargetResult {
1186
1549
  * `<outDir>/assets/*`, then per target:
1187
1550
  * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
1188
1551
  * `_data.json`); needs `prerenderApp`. No server.
1189
- * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1190
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
1191
- * entry as `<outDir>/index.js` (the CLI's docs point at `vercel`'s Build Output wrapper). [see note]
1552
+ * - `cloudflare`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1553
+ * - `vercel`: Vercel's Build Output API v3 - `config.json`, the client bundle and public files under
1554
+ * `static/`, and the edge function at `functions/index.func/index.js`; `vercel deploy --prebuilt`
1555
+ * uploads it as it is when `outDir` is `.vercel/output`.
1192
1556
  * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
1193
1557
  * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
1194
1558
  * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
1195
- *
1196
- * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1197
- * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1198
1559
  */
1199
1560
  /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1200
1561
  export const bunBundler: Bundler = {
1201
- buildClient: (input) =>
1202
- buildClient({
1562
+ buildClient: (input) => {
1563
+ const cssCodeSplit = normalizeCssCodeSplit(input.cssCodeSplit)
1564
+ if (cssCodeSplit === false) {
1565
+ throw new Error(
1566
+ "[nifra/web] cssCodeSplit: false is a Vite-only production option. Use buildTargetVite or remove it for the native Bun build.",
1567
+ )
1568
+ }
1569
+ return buildClient({
1203
1570
  routesDir: input.routesDir,
1204
1571
  outDir: input.outDir,
1205
1572
  clientModule: input.clientModule,
1206
1573
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1207
1574
  ...(input.conditions ? { conditions: input.conditions } : {}),
1208
1575
  ...(input.define ? { define: input.define } : {}),
1576
+ ...(input.cssCodeSplit !== undefined ? { cssCodeSplit } : {}),
1209
1577
  ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1210
1578
  ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1211
- }),
1579
+ ...(input.secretExemptions !== undefined ? { secretExemptions: input.secretExemptions } : {}),
1580
+ })
1581
+ },
1212
1582
  buildServer: (input) =>
1213
1583
  buildServer({
1214
1584
  routesDir: input.routesDir,
@@ -1216,8 +1586,14 @@ export const bunBundler: Bundler = {
1216
1586
  outDir: input.outDir,
1217
1587
  clientEntry: input.clientEntry,
1218
1588
  target: input.target,
1589
+ ...(input.styles !== undefined ? { styles: input.styles } : {}),
1590
+ ...(input.routeStyles !== undefined ? { routeStyles: input.routeStyles } : {}),
1219
1591
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1220
1592
  ...(input.define ? { define: input.define } : {}),
1593
+ ...(input.cssLoading !== undefined ? { cssLoading: input.cssLoading } : {}),
1594
+ ...(input.root !== undefined ? { root: input.root } : {}),
1595
+ ...(input.generatedFiles !== undefined ? { generatedFiles: input.generatedFiles } : {}),
1596
+ ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1221
1597
  }),
1222
1598
  }
1223
1599
 
@@ -1241,15 +1617,20 @@ export async function buildTargetWith(
1241
1617
  bundler: Bundler,
1242
1618
  ): Promise<BuildTargetResult> {
1243
1619
  const { routesDir, outDir, workDir } = options
1244
- const targetPlan = planBuildTarget(target, outDir)
1620
+ const requestedCssCodeSplit =
1621
+ options.cssCodeSplit === undefined ? undefined : normalizeCssCodeSplit(options.cssCodeSplit)
1622
+ const requestedCssLoading =
1623
+ options.cssLoading === undefined ? undefined : normalizeCssLoading(options.cssLoading)
1624
+ const targetPlan = planBuildTarget(parseBuildTarget(target), outDir)
1245
1625
  const { rmSync } = await import("node:fs")
1246
1626
  rmSync(outDir, { recursive: true, force: true })
1247
1627
  rmSync(workDir, { recursive: true, force: true })
1248
- const assetsDir = `${outDir}/assets`
1628
+ const staticRoot = targetPlan.staticDir === "" ? outDir : `${outDir}/${targetPlan.staticDir}`
1629
+ const assetsDir = `${staticRoot}/assets`
1249
1630
  mkdirSync(assetsDir, { recursive: true })
1250
1631
  mkdirSync(workDir, { recursive: true })
1251
1632
 
1252
- // (1) Client bundle → <outDir>/assets/* (every target ships the same hashed client bundle).
1633
+ // (1) Client bundle → <static root>/assets/* (every target ships the same hashed client bundle).
1253
1634
  let client = await bundler.buildClient({
1254
1635
  routesDir,
1255
1636
  outDir: assetsDir,
@@ -1259,16 +1640,45 @@ export async function buildTargetWith(
1259
1640
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1260
1641
  publicDir: false,
1261
1642
  ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1643
+ ...(options.secretExemptions !== undefined
1644
+ ? { secretExemptions: options.secretExemptions }
1645
+ : {}),
1646
+ ...(requestedCssCodeSplit !== undefined ? { cssCodeSplit: requestedCssCodeSplit } : {}),
1647
+ ...(requestedCssLoading !== undefined ? { cssLoading: requestedCssLoading } : {}),
1262
1648
  root: resolvePath(dirname(routesDir)),
1263
1649
  })
1650
+ // Bun's client builder has no CSS-loading field, but the server-side document policy is bundler
1651
+ // independent. Carry the requested policy into the result so static prerender callbacks and the
1652
+ // generated server entry see the same choice on both build strategies.
1653
+ if (requestedCssLoading !== undefined && client.cssLoading !== requestedCssLoading) {
1654
+ client = { ...client, cssLoading: requestedCssLoading }
1655
+ }
1264
1656
  const publicDir =
1265
1657
  options.publicDir === false
1266
1658
  ? undefined
1267
1659
  : resolvePath(options.publicDir ?? join(dirname(routesDir), "public"))
1660
+ const secretScan = {
1661
+ env: buildEnvironment(),
1662
+ publicEnvPrefix: options.publicEnvPrefix ?? "PUBLIC_",
1663
+ ...(options.secretExemptions !== undefined ? { exemptions: options.secretExemptions } : {}),
1664
+ }
1665
+ if (publicDir !== undefined && existsSync(publicDir)) {
1666
+ assertNoSecrets({
1667
+ ...secretScan,
1668
+ artifacts: publicScanFiles(publicDir, originName(dirname(routesDir), publicDir)),
1669
+ })
1670
+ }
1268
1671
  const publicFiles =
1269
- publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1672
+ publicDir !== undefined && existsSync(publicDir)
1673
+ ? await copyPublicDir(publicDir, staticRoot)
1674
+ : []
1270
1675
  if (publicFiles.length > 0) {
1271
1676
  client = { ...client, publicFiles }
1677
+ }
1678
+ // `buildClient` writes its first manifest before the target orchestration knows the activation policy
1679
+ // (the native Bun client builder has no CSS-loading option). Persist the target-level additions even
1680
+ // when the app has no public/ files; otherwise the returned manifest and the deploy artifact disagree.
1681
+ if (requestedCssLoading !== undefined || publicFiles.length > 0) {
1272
1682
  writeFileSync(`${assetsDir}/manifest.json`, JSON.stringify(client, null, 2))
1273
1683
  }
1274
1684
 
@@ -1287,6 +1697,7 @@ export async function buildTargetWith(
1287
1697
  app,
1288
1698
  routes: manifest.routes,
1289
1699
  outDir,
1700
+ secrets: secretScan,
1290
1701
  })
1291
1702
  if (result.prerendered.length === 0) {
1292
1703
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
@@ -1319,9 +1730,11 @@ export async function buildTargetWith(
1319
1730
  target,
1320
1731
  adapterImport: options.adapterImport,
1321
1732
  ...(options.useImport !== undefined ? { useImport: options.useImport } : {}),
1733
+ ...(options.optionImports !== undefined ? { optionImports: options.optionImports } : {}),
1322
1734
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1323
1735
  ...(options.title !== undefined ? { title: options.title } : {}),
1324
1736
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1737
+ ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1325
1738
  }),
1326
1739
  )
1327
1740
  const { worker } = await bundler.buildServer({
@@ -1330,14 +1743,23 @@ export async function buildTargetWith(
1330
1743
  outDir: `${workDir}/server`,
1331
1744
  clientEntry: client.entry,
1332
1745
  target: targetPlan.serverTarget,
1746
+ ...(client.css !== undefined ? { styles: client.css } : {}),
1747
+ ...(client.routeStyles !== undefined ? { routeStyles: client.routeStyles } : {}),
1333
1748
  ...(options.serverPlugins ? { plugins: options.serverPlugins } : {}),
1334
1749
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1750
+ ...(client.cssLoading !== undefined
1751
+ ? { cssLoading: client.cssLoading }
1752
+ : requestedCssLoading !== undefined
1753
+ ? { cssLoading: requestedCssLoading }
1754
+ : {}),
1335
1755
  root: resolvePath(dirname(routesDir)),
1756
+ generatedFiles: [resolvePath(workDir, options.adapterImport)],
1757
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1336
1758
  })
1337
1759
 
1338
1760
  // (3) Assemble the deploy dir for the target.
1339
1761
  const { cpSync } = await import("node:fs")
1340
- if (targetPlan.target === "cf-pages") {
1762
+ if (targetPlan.target === "cloudflare") {
1341
1763
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1342
1764
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
1343
1765
  // proves nothing can be served beneath it.
@@ -1359,7 +1781,18 @@ export async function buildTargetWith(
1359
1781
  )
1360
1782
  }
1361
1783
  } else if (targetPlan.target === "vercel") {
1784
+ const fn = dirname(`${outDir}/${targetPlan.outputFile}`)
1785
+ mkdirSync(fn, { recursive: true })
1362
1786
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1787
+ writeFileSync(
1788
+ `${fn}/.vc-config.json`,
1789
+ `${JSON.stringify({ runtime: "edge", entrypoint: "index.js" }, null, 2)}\n`,
1790
+ )
1791
+ // Files the build wrote are served first; every other path is the SSR function's.
1792
+ writeFileSync(
1793
+ `${outDir}/config.json`,
1794
+ `${JSON.stringify({ version: 3, routes: [{ handle: "filesystem" }, { src: "/(.*)", dest: "/index" }] }, null, 2)}\n`,
1795
+ )
1363
1796
  } else {
1364
1797
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1365
1798
  }