@nifrajs/web 3.5.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (390) hide show
  1. package/dist/backend-only.d.ts +15 -0
  2. package/dist/backend-only.d.ts.map +1 -0
  3. package/dist/backend-only.js +16 -0
  4. package/dist/backend-only.js.map +1 -0
  5. package/dist/boundary.d.ts +5 -0
  6. package/dist/boundary.d.ts.map +1 -1
  7. package/dist/boundary.js +11 -2
  8. package/dist/boundary.js.map +1 -1
  9. package/dist/build-plan.d.ts +18 -6
  10. package/dist/build-plan.d.ts.map +1 -1
  11. package/dist/build-plan.js +82 -34
  12. package/dist/build-plan.js.map +1 -1
  13. package/dist/build-vite.d.ts +4 -0
  14. package/dist/build-vite.d.ts.map +1 -1
  15. package/dist/build-vite.js +68 -13
  16. package/dist/build-vite.js.map +1 -1
  17. package/dist/build.d.ts +57 -23
  18. package/dist/build.d.ts.map +1 -1
  19. package/dist/build.js +356 -85
  20. package/dist/build.js.map +1 -1
  21. package/dist/bun-dev-entry.d.ts +1 -1
  22. package/dist/bun-dev-entry.js +1 -1
  23. package/dist/cdn.d.ts +74 -0
  24. package/dist/cdn.d.ts.map +1 -0
  25. package/dist/cdn.js +132 -0
  26. package/dist/cdn.js.map +1 -0
  27. package/dist/client.d.ts +3 -1
  28. package/dist/client.d.ts.map +1 -1
  29. package/dist/client.js +136 -35
  30. package/dist/client.js.map +1 -1
  31. package/dist/conformance.d.ts +4 -3
  32. package/dist/conformance.d.ts.map +1 -1
  33. package/dist/conformance.js +12 -3
  34. package/dist/conformance.js.map +1 -1
  35. package/dist/csp.d.ts +93 -0
  36. package/dist/csp.d.ts.map +1 -0
  37. package/dist/csp.js +122 -0
  38. package/dist/csp.js.map +1 -0
  39. package/dist/dev-client.d.ts +76 -0
  40. package/dist/dev-client.d.ts.map +1 -0
  41. package/dist/dev-client.js +280 -0
  42. package/dist/dev-client.js.map +1 -0
  43. package/dist/dev-diagnostics.d.ts +26 -2
  44. package/dist/dev-diagnostics.d.ts.map +1 -1
  45. package/dist/dev-diagnostics.js +38 -7
  46. package/dist/dev-diagnostics.js.map +1 -1
  47. package/dist/dev-error.d.ts +4 -13
  48. package/dist/dev-error.d.ts.map +1 -1
  49. package/dist/dev-error.js +46 -4
  50. package/dist/dev-error.js.map +1 -1
  51. package/dist/dev-feed.d.ts +298 -0
  52. package/dist/dev-feed.d.ts.map +1 -0
  53. package/dist/dev-feed.js +797 -0
  54. package/dist/dev-feed.js.map +1 -0
  55. package/dist/dev-indicator.d.ts +38 -0
  56. package/dist/dev-indicator.d.ts.map +1 -0
  57. package/dist/dev-indicator.js +93 -0
  58. package/dist/dev-indicator.js.map +1 -0
  59. package/dist/dev-session.d.ts +77 -0
  60. package/dist/dev-session.d.ts.map +1 -0
  61. package/dist/dev-session.js +580 -0
  62. package/dist/dev-session.js.map +1 -0
  63. package/dist/dev-sourcemap.d.ts +27 -0
  64. package/dist/dev-sourcemap.d.ts.map +1 -0
  65. package/dist/dev-sourcemap.js +235 -0
  66. package/dist/dev-sourcemap.js.map +1 -0
  67. package/dist/dev-ssr-graph.d.ts.map +1 -1
  68. package/dist/dev-ssr-graph.js +13 -5
  69. package/dist/dev-ssr-graph.js.map +1 -1
  70. package/dist/dev.d.ts +15 -2
  71. package/dist/dev.d.ts.map +1 -1
  72. package/dist/dev.js +69 -44
  73. package/dist/dev.js.map +1 -1
  74. package/dist/diagnostic-catalog.d.ts +35 -0
  75. package/dist/diagnostic-catalog.d.ts.map +1 -0
  76. package/dist/diagnostic-catalog.js +203 -0
  77. package/dist/diagnostic-catalog.js.map +1 -0
  78. package/dist/diagnostic-prompt.d.ts +49 -0
  79. package/dist/diagnostic-prompt.d.ts.map +1 -0
  80. package/dist/diagnostic-prompt.js +303 -0
  81. package/dist/diagnostic-prompt.js.map +1 -0
  82. package/dist/diagnostic.d.ts +17 -23
  83. package/dist/diagnostic.d.ts.map +1 -1
  84. package/dist/diagnostic.js +31 -39
  85. package/dist/diagnostic.js.map +1 -1
  86. package/dist/draft.d.ts.map +1 -1
  87. package/dist/draft.js +4 -1
  88. package/dist/draft.js.map +1 -1
  89. package/dist/fn.d.ts +5 -0
  90. package/dist/fn.d.ts.map +1 -1
  91. package/dist/fn.js +13 -3
  92. package/dist/fn.js.map +1 -1
  93. package/dist/fs.d.ts.map +1 -1
  94. package/dist/fs.js +4 -2
  95. package/dist/fs.js.map +1 -1
  96. package/dist/index.d.ts +13 -15
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +11 -12
  99. package/dist/index.js.map +1 -1
  100. package/dist/internal/cdn/cloudflare.d.ts +48 -0
  101. package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
  102. package/dist/internal/cdn/cloudflare.js +94 -0
  103. package/dist/internal/cdn/cloudflare.js.map +1 -0
  104. package/dist/internal/cdn/core.d.ts +91 -0
  105. package/dist/internal/cdn/core.d.ts.map +1 -0
  106. package/dist/internal/cdn/core.js +220 -0
  107. package/dist/internal/cdn/core.js.map +1 -0
  108. package/dist/internal/cdn/fastly.d.ts +17 -0
  109. package/dist/internal/cdn/fastly.d.ts.map +1 -0
  110. package/dist/internal/cdn/fastly.js +43 -0
  111. package/dist/internal/cdn/fastly.js.map +1 -0
  112. package/dist/internal/cdn/vercel.d.ts +31 -0
  113. package/dist/internal/cdn/vercel.d.ts.map +1 -0
  114. package/dist/internal/cdn/vercel.js +56 -0
  115. package/dist/internal/cdn/vercel.js.map +1 -0
  116. package/dist/internal/code-unit-order.d.ts +4 -0
  117. package/dist/internal/code-unit-order.d.ts.map +1 -0
  118. package/dist/internal/code-unit-order.js +6 -0
  119. package/dist/internal/code-unit-order.js.map +1 -0
  120. package/dist/internal/codegen.d.ts.map +1 -1
  121. package/dist/internal/codegen.js +141 -25
  122. package/dist/internal/codegen.js.map +1 -1
  123. package/dist/internal/dev-csp.d.ts +24 -0
  124. package/dist/internal/dev-csp.d.ts.map +1 -0
  125. package/dist/internal/dev-csp.js +160 -0
  126. package/dist/internal/dev-csp.js.map +1 -0
  127. package/dist/internal/dev-reserved.d.ts +25 -0
  128. package/dist/internal/dev-reserved.d.ts.map +1 -0
  129. package/dist/internal/dev-reserved.js +39 -0
  130. package/dist/internal/dev-reserved.js.map +1 -0
  131. package/dist/internal/html-spans.d.ts +10 -0
  132. package/dist/internal/html-spans.d.ts.map +1 -0
  133. package/dist/internal/html-spans.js +46 -0
  134. package/dist/internal/html-spans.js.map +1 -0
  135. package/dist/internal/loading-runtime.d.ts +44 -0
  136. package/dist/internal/loading-runtime.d.ts.map +1 -0
  137. package/dist/internal/loading-runtime.js +206 -0
  138. package/dist/internal/loading-runtime.js.map +1 -0
  139. package/dist/internal/matches-runtime.d.ts +8 -0
  140. package/dist/internal/matches-runtime.d.ts.map +1 -0
  141. package/dist/internal/matches-runtime.js +61 -0
  142. package/dist/internal/matches-runtime.js.map +1 -0
  143. package/dist/internal/mount-shadow.d.ts +36 -0
  144. package/dist/internal/mount-shadow.d.ts.map +1 -0
  145. package/dist/internal/mount-shadow.js +65 -0
  146. package/dist/internal/mount-shadow.js.map +1 -0
  147. package/dist/internal/node-builtins.d.ts +3 -0
  148. package/dist/internal/node-builtins.d.ts.map +1 -0
  149. package/dist/internal/node-builtins.js +79 -0
  150. package/dist/internal/node-builtins.js.map +1 -0
  151. package/dist/internal/output-guard.d.ts +65 -0
  152. package/dist/internal/output-guard.d.ts.map +1 -0
  153. package/dist/internal/output-guard.js +630 -0
  154. package/dist/internal/output-guard.js.map +1 -0
  155. package/dist/internal/page-execution.d.ts +20 -8
  156. package/dist/internal/page-execution.d.ts.map +1 -1
  157. package/dist/internal/page-execution.js +571 -105
  158. package/dist/internal/page-execution.js.map +1 -1
  159. package/dist/internal/parity.d.ts +21 -0
  160. package/dist/internal/parity.d.ts.map +1 -1
  161. package/dist/internal/parity.js +104 -22
  162. package/dist/internal/parity.js.map +1 -1
  163. package/dist/internal/private-env.d.ts +12 -0
  164. package/dist/internal/private-env.d.ts.map +1 -0
  165. package/dist/internal/private-env.js +335 -0
  166. package/dist/internal/private-env.js.map +1 -0
  167. package/dist/internal/public-url.d.ts +3 -0
  168. package/dist/internal/public-url.d.ts.map +1 -0
  169. package/dist/internal/public-url.js +18 -0
  170. package/dist/internal/public-url.js.map +1 -0
  171. package/dist/internal/render-document.d.ts +25 -19
  172. package/dist/internal/render-document.d.ts.map +1 -1
  173. package/dist/internal/render-document.js +77 -61
  174. package/dist/internal/render-document.js.map +1 -1
  175. package/dist/internal/response-controls.d.ts +75 -0
  176. package/dist/internal/response-controls.d.ts.map +1 -0
  177. package/dist/internal/response-controls.js +256 -0
  178. package/dist/internal/response-controls.js.map +1 -0
  179. package/dist/internal/runtime-contract.d.ts +11 -16
  180. package/dist/internal/runtime-contract.d.ts.map +1 -1
  181. package/dist/internal/runtime-contract.js.map +1 -1
  182. package/dist/internal/secret-scan.d.ts +104 -0
  183. package/dist/internal/secret-scan.d.ts.map +1 -0
  184. package/dist/internal/secret-scan.js +503 -0
  185. package/dist/internal/secret-scan.js.map +1 -0
  186. package/dist/internal/server-boundary.d.ts +6 -29
  187. package/dist/internal/server-boundary.d.ts.map +1 -1
  188. package/dist/internal/server-boundary.js +6 -66
  189. package/dist/internal/server-boundary.js.map +1 -1
  190. package/dist/internal/source-map.d.ts +47 -0
  191. package/dist/internal/source-map.d.ts.map +1 -0
  192. package/dist/internal/source-map.js +225 -0
  193. package/dist/internal/source-map.js.map +1 -0
  194. package/dist/internal/target-compat.d.ts +27 -0
  195. package/dist/internal/target-compat.d.ts.map +1 -0
  196. package/dist/internal/target-compat.js +77 -0
  197. package/dist/internal/target-compat.js.map +1 -0
  198. package/dist/internal/timing-safe-equal.d.ts +6 -0
  199. package/dist/internal/timing-safe-equal.d.ts.map +1 -1
  200. package/dist/internal/timing-safe-equal.js +10 -0
  201. package/dist/internal/timing-safe-equal.js.map +1 -1
  202. package/dist/internal/vitals-runtime.d.ts +18 -0
  203. package/dist/internal/vitals-runtime.d.ts.map +1 -0
  204. package/dist/internal/vitals-runtime.js +38 -0
  205. package/dist/internal/vitals-runtime.js.map +1 -0
  206. package/dist/internal/web-app.d.ts +38 -9
  207. package/dist/internal/web-app.d.ts.map +1 -1
  208. package/dist/internal/web-app.js +199 -59
  209. package/dist/internal/web-app.js.map +1 -1
  210. package/dist/internal/zone-graph.d.ts +89 -0
  211. package/dist/internal/zone-graph.d.ts.map +1 -0
  212. package/dist/internal/zone-graph.js +492 -0
  213. package/dist/internal/zone-graph.js.map +1 -0
  214. package/dist/isr.d.ts +83 -6
  215. package/dist/isr.d.ts.map +1 -1
  216. package/dist/isr.js +263 -36
  217. package/dist/isr.js.map +1 -1
  218. package/dist/llms-txt.d.ts.map +1 -1
  219. package/dist/llms-txt.js +31 -6
  220. package/dist/llms-txt.js.map +1 -1
  221. package/dist/manifest.d.ts +220 -13
  222. package/dist/manifest.d.ts.map +1 -1
  223. package/dist/manifest.js +394 -9
  224. package/dist/manifest.js.map +1 -1
  225. package/dist/module-graph.d.ts +26 -5
  226. package/dist/module-graph.d.ts.map +1 -1
  227. package/dist/module-graph.js +17 -7
  228. package/dist/module-graph.js.map +1 -1
  229. package/dist/navigation.d.ts +17 -3
  230. package/dist/navigation.d.ts.map +1 -1
  231. package/dist/navigation.js +11 -5
  232. package/dist/navigation.js.map +1 -1
  233. package/dist/nonce.d.ts +47 -0
  234. package/dist/nonce.d.ts.map +1 -0
  235. package/dist/nonce.js +63 -0
  236. package/dist/nonce.js.map +1 -0
  237. package/dist/plugins/css-modules.d.ts.map +1 -1
  238. package/dist/plugins/css-modules.js +4 -3
  239. package/dist/plugins/css-modules.js.map +1 -1
  240. package/dist/plugins/kit.d.ts +9 -0
  241. package/dist/plugins/kit.d.ts.map +1 -1
  242. package/dist/plugins/kit.js +18 -0
  243. package/dist/plugins/kit.js.map +1 -1
  244. package/dist/plugins/svg.d.ts +22 -3
  245. package/dist/plugins/svg.d.ts.map +1 -1
  246. package/dist/plugins/svg.js +112 -1
  247. package/dist/plugins/svg.js.map +1 -1
  248. package/dist/plugins/vite-leak-guard.d.ts +107 -3
  249. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  250. package/dist/plugins/vite-leak-guard.js +378 -17
  251. package/dist/plugins/vite-leak-guard.js.map +1 -1
  252. package/dist/plugins/vite-zone-guard.d.ts +61 -0
  253. package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
  254. package/dist/plugins/vite-zone-guard.js +208 -0
  255. package/dist/plugins/vite-zone-guard.js.map +1 -0
  256. package/dist/plugins/zone-guard.d.ts +27 -0
  257. package/dist/plugins/zone-guard.d.ts.map +1 -0
  258. package/dist/plugins/zone-guard.js +80 -0
  259. package/dist/plugins/zone-guard.js.map +1 -0
  260. package/dist/prerender.d.ts +10 -0
  261. package/dist/prerender.d.ts.map +1 -1
  262. package/dist/prerender.js +35 -3
  263. package/dist/prerender.js.map +1 -1
  264. package/dist/public-dir.d.ts +9 -0
  265. package/dist/public-dir.d.ts.map +1 -1
  266. package/dist/public-dir.js +67 -9
  267. package/dist/public-dir.js.map +1 -1
  268. package/dist/pwa-manifest.d.ts +68 -0
  269. package/dist/pwa-manifest.d.ts.map +1 -0
  270. package/dist/pwa-manifest.js +178 -0
  271. package/dist/pwa-manifest.js.map +1 -0
  272. package/dist/query.js +4 -4
  273. package/dist/query.js.map +1 -1
  274. package/dist/render-seam.d.ts +42 -2
  275. package/dist/render-seam.d.ts.map +1 -1
  276. package/dist/render-seam.js +6 -0
  277. package/dist/render-seam.js.map +1 -1
  278. package/dist/route-manifest.d.ts +3 -0
  279. package/dist/route-manifest.d.ts.map +1 -1
  280. package/dist/route-manifest.js +26 -3
  281. package/dist/route-manifest.js.map +1 -1
  282. package/dist/route-types.d.ts +24 -0
  283. package/dist/route-types.d.ts.map +1 -0
  284. package/dist/route-types.js +195 -0
  285. package/dist/route-types.js.map +1 -0
  286. package/dist/router.d.ts +28 -6
  287. package/dist/router.d.ts.map +1 -1
  288. package/dist/router.js +129 -40
  289. package/dist/router.js.map +1 -1
  290. package/dist/vitals.d.ts +33 -0
  291. package/dist/vitals.d.ts.map +1 -0
  292. package/dist/vitals.js +16 -0
  293. package/dist/vitals.js.map +1 -0
  294. package/dist/vite.d.ts +16 -1
  295. package/dist/vite.d.ts.map +1 -1
  296. package/dist/vite.js +248 -43
  297. package/dist/vite.js.map +1 -1
  298. package/dist/zones.d.ts +70 -0
  299. package/dist/zones.d.ts.map +1 -0
  300. package/dist/zones.js +365 -0
  301. package/dist/zones.js.map +1 -0
  302. package/package.json +67 -14
  303. package/src/backend-only.ts +16 -0
  304. package/src/boundary.ts +18 -5
  305. package/src/build-plan.ts +90 -35
  306. package/src/build-vite.ts +77 -11
  307. package/src/build.ts +466 -96
  308. package/src/bun-dev-entry.ts +1 -1
  309. package/src/cdn.ts +216 -0
  310. package/src/client.ts +135 -36
  311. package/src/conformance.ts +16 -3
  312. package/src/csp.ts +194 -0
  313. package/src/dev-client.ts +349 -0
  314. package/src/dev-diagnostics.ts +60 -7
  315. package/src/dev-error.ts +49 -5
  316. package/src/dev-feed.ts +1140 -0
  317. package/src/dev-indicator.ts +113 -0
  318. package/src/dev-session.ts +729 -0
  319. package/src/dev-sourcemap.ts +281 -0
  320. package/src/dev-ssr-graph.ts +16 -5
  321. package/src/dev.ts +86 -43
  322. package/src/diagnostic-catalog.ts +243 -0
  323. package/src/diagnostic-prompt.ts +355 -0
  324. package/src/diagnostic.ts +63 -66
  325. package/src/draft.ts +4 -1
  326. package/src/fn.ts +19 -3
  327. package/src/fs.ts +13 -2
  328. package/src/index.ts +42 -10
  329. package/src/internal/cdn/cloudflare.ts +146 -0
  330. package/src/internal/cdn/core.ts +316 -0
  331. package/src/internal/cdn/fastly.ts +62 -0
  332. package/src/internal/cdn/vercel.ts +95 -0
  333. package/src/internal/code-unit-order.ts +5 -0
  334. package/src/internal/codegen.ts +161 -24
  335. package/src/internal/dev-csp.ts +176 -0
  336. package/src/internal/dev-reserved.ts +52 -0
  337. package/src/internal/html-spans.ts +50 -0
  338. package/src/internal/loading-runtime.ts +244 -0
  339. package/src/internal/matches-runtime.ts +62 -0
  340. package/src/internal/mount-shadow.ts +88 -0
  341. package/src/internal/node-builtins.ts +79 -0
  342. package/src/internal/output-guard.ts +701 -0
  343. package/src/internal/page-execution.ts +795 -185
  344. package/src/internal/parity.ts +150 -36
  345. package/src/internal/private-env.ts +334 -0
  346. package/src/internal/public-url.ts +22 -0
  347. package/src/internal/render-document.ts +114 -67
  348. package/src/internal/response-controls.ts +284 -0
  349. package/src/internal/runtime-contract.ts +10 -15
  350. package/src/internal/secret-scan.ts +638 -0
  351. package/src/internal/server-boundary.ts +6 -69
  352. package/src/internal/source-map.ts +256 -0
  353. package/src/internal/target-compat.ts +92 -0
  354. package/src/internal/timing-safe-equal.ts +13 -0
  355. package/src/internal/vitals-runtime.ts +53 -0
  356. package/src/internal/web-app.ts +262 -84
  357. package/src/internal/zone-graph.ts +593 -0
  358. package/src/isr.ts +325 -37
  359. package/src/llms-txt.ts +28 -4
  360. package/src/manifest.ts +682 -16
  361. package/src/module-graph.ts +53 -9
  362. package/src/navigation.ts +29 -6
  363. package/src/nonce.ts +104 -0
  364. package/src/plugins/css-modules.ts +5 -3
  365. package/src/plugins/kit.ts +24 -0
  366. package/src/plugins/svg.ts +135 -4
  367. package/src/plugins/vite-leak-guard.ts +529 -16
  368. package/src/plugins/vite-zone-guard.ts +263 -0
  369. package/src/plugins/zone-guard.ts +119 -0
  370. package/src/prerender.ts +49 -3
  371. package/src/public-dir.ts +69 -8
  372. package/src/pwa-manifest.ts +226 -0
  373. package/src/query.ts +4 -4
  374. package/src/render-seam.ts +46 -1
  375. package/src/route-manifest.ts +15 -3
  376. package/src/route-types.ts +264 -0
  377. package/src/router.ts +163 -63
  378. package/src/vitals.ts +44 -0
  379. package/src/vite.ts +257 -42
  380. package/src/zones.ts +430 -0
  381. package/dist/plugins/vite-server-only.d.ts +0 -49
  382. package/dist/plugins/vite-server-only.d.ts.map +0 -1
  383. package/dist/plugins/vite-server-only.js +0 -57
  384. package/dist/plugins/vite-server-only.js.map +0 -1
  385. package/dist/server-only.d.ts +0 -21
  386. package/dist/server-only.d.ts.map +0 -1
  387. package/dist/server-only.js +0 -25
  388. package/dist/server-only.js.map +0 -1
  389. package/src/plugins/vite-server-only.ts +0 -75
  390. package/src/server-only.ts +0 -25
package/src/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,6 +31,7 @@ import {
30
31
  detectServerOnlyInClient,
31
32
  formatNodeBuiltinLeak,
32
33
  formatServerOnlyLeak,
34
+ parseBuildTarget,
33
35
  parseManifestClientEntry,
34
36
  parseManifestCssLoading,
35
37
  parseManifestRouteStyles,
@@ -48,16 +50,42 @@ import {
48
50
  assertIdentityParity,
49
51
  collectDevelopmentParityInput,
50
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"
51
67
  import {
52
68
  generateServerFnStub,
53
69
  SERVER_FN_MODULE,
54
- SERVER_ONLY_MODULE,
55
- SERVER_ONLY_REPLACEMENT,
56
70
  serverFnNamespace,
57
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"
58
85
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
59
- import { fromBunMetafile } from "./module-graph.ts"
60
86
  import { prerenderRoutes } from "./prerender.ts"
87
+ import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
88
+ import { createZoneClassifier } from "./zones.ts"
61
89
 
62
90
  export * from "./build-plan.ts"
63
91
 
@@ -82,6 +110,8 @@ interface BunMetafile {
82
110
  >
83
111
  }
84
112
 
113
+ // The secret scan every client build, `public/` copy and prerender runs.
114
+ export type { SecretExemption, SecretRule } from "./internal/secret-scan.ts"
85
115
  // Build-time SSG: prerender opted-in static + dynamic routes to `index.html` (+ static `_data.json`),
86
116
  // run after `buildClient`.
87
117
  export {
@@ -135,6 +165,12 @@ export interface BuildClientOptions {
135
165
  * wins over an auto-exposed var (it's layered last). Sourced from `Bun.env` (falls back to
136
166
  * `process.env`) at build time. */
137
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[]
138
174
  }
139
175
 
140
176
  /**
@@ -159,22 +195,6 @@ export function publicEnvDefines(
159
195
  return defines
160
196
  }
161
197
 
162
- /**
163
- * Percent-encode one path segment exactly the way a browser encodes it into a request URL.
164
- *
165
- * NOT `encodeURIComponent`. That escapes the sub-delimiters `, @ + = & ; $`, which a browser sends raw -
166
- * so a file named `report,2026.csv` would be recorded as `/report%2C2026.csv` while the request arrives
167
- * as `/report,2026.csv`, the allowlist lookup misses, and the file 404s in production only. `encodeURI`
168
- * agrees with `URL.pathname` on every character except `?` and `#`, which terminate a path and so must
169
- * be escaped explicitly here.
170
- */
171
- function encodePathSegment(segment: string): string {
172
- return encodeURI(segment).replace(
173
- /[?#]/g,
174
- (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
175
- )
176
- }
177
-
178
198
  /**
179
199
  * Cloudflare Pages rejects a `_routes.json` with more than 100 include+exclude rules combined, or any
180
200
  * single rule longer than 100 characters.
@@ -247,7 +267,7 @@ function collapsibleDirs(
247
267
  }
248
268
 
249
269
  /**
250
- * 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.
251
271
  *
252
272
  * `exclude` is what Pages serves straight from the CDN instead of invoking the worker, so naming every
253
273
  * public file is ideal - and impossible past ~99 of them. A `public/` of icons, fonts and share images
@@ -334,7 +354,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
334
354
  const target = join(to, rel)
335
355
  await mkdir(join(target, ".."), { recursive: true })
336
356
  await cp(candidateReal, target)
337
- copied.push(`/${rel.split(sep).map(encodePathSegment).join("/")}`)
357
+ copied.push(publicUrlPath(rel))
338
358
  }
339
359
  return copied.sort()
340
360
  }
@@ -411,6 +431,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
411
431
  // layered after these in the `define` object). `Bun` may be absent under non-Bun typecheck - guard it.
412
432
  const buildEnv = (typeof Bun !== "undefined" ? Bun.env : undefined) ?? process.env
413
433
  const publicDefines = publicEnvDefines(options.publicEnvPrefix ?? "PUBLIC_", buildEnv)
434
+ const privateEnv = privateEnvCheck(options.publicEnvPrefix ?? "PUBLIC_")
414
435
  // Keep the generated source beside the project, not inside `outDir`: module resolution starts at the
415
436
  // importing file, so an absolute `--out /tmp/deploy` must still resolve the app's dependencies.
416
437
  // A unique directory also avoids clobbering a user file or colliding with parallel builds.
@@ -432,6 +453,10 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
432
453
  ...routeManifest.routes.map((r) => r.file),
433
454
  ...Object.values(routeManifest.layouts).map((l) => l.file),
434
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),
435
460
  ]),
436
461
  ].sort()
437
462
 
@@ -440,12 +465,14 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
440
465
  // entry→CSS link for per-route splitting: keyed by the unique source path, so it survives
441
466
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. Not yet in
442
467
  // `@types/bun`'s `BuildConfig`, so spread it in (spread props skip the excess-property check).
443
- 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.
444
472
  const result = await (async () => {
445
473
  try {
446
474
  return await Bun.build({
447
475
  entrypoints: [entryFile, ...routeFiles.map(resolve)],
448
- outdir: outDir,
449
476
  target: "browser",
450
477
  naming: "[name]-[hash].[ext]",
451
478
  publicPath,
@@ -456,11 +483,16 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
456
483
  ...buildExtras,
457
484
  minify: options.minify ?? true,
458
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
+ }),
459
492
  ...declaredSingleCopyPlugins(root),
460
493
  reactDedupePlugin(routesDir),
461
494
  preactDedupePlugin(routesDir),
462
495
  svelteDedupePlugin(routesDir),
463
- serverOnlyEmptyPlugin(),
464
496
  serverFnStubPlugin(),
465
497
  ...(options.plugins ?? []),
466
498
  ],
@@ -468,10 +500,11 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
468
500
  // Replace `process.env.*` at compile time so an app module reading config off `process.env` doesn't
469
501
  // hit a `process is not defined` crash in the browser. Bun does longest-match: NODE_ENV resolves to
470
502
  // the build mode (React's prod/dev branch); each PUBLIC_* var resolves to its baked VALUE; every
471
- // other `process.env.X` becomes undefined (the bare `process.env` → `({})` fallback - so secrets
472
- // 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.
473
506
  define: {
474
- "process.env": "({})",
507
+ "process.env": "{}",
475
508
  "process.env.NODE_ENV": JSON.stringify(mode),
476
509
  ...publicDefines,
477
510
  ...options.define,
@@ -481,40 +514,125 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
481
514
  rmSync(entryDir, { recursive: true, force: true })
482
515
  }
483
516
  })()
517
+ const cwd = process.cwd()
518
+ const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
484
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))
485
535
  throw new Error(
486
536
  `[nifra/web] client build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
487
537
  )
488
538
  }
489
539
 
490
- // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes
491
- // a browser polyfill) but breaks/leaks at runtime. Fail the build with a named, actionable error
492
- // instead - caught at build time, not by a confused user in the browser. Graph-based (the metafile's
493
- // per-output `inputs`), so it can't false-positive on a `"node:..."` string literal and survives
494
- // minification. Only the client build runs this; the server build's `node:` imports are legitimate.
495
- const clientMeta = (result as unknown as { metafile?: BunMetafile }).metafile
496
- 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)
497
577
  // #4: a `node:` builtin (e.g. `node:crypto`) pulled into a CLIENT chunk builds fine (Bun substitutes a
498
578
  // browser polyfill) but breaks/leaks at runtime. Fail with the chain (entry → … → builtin), through the
499
579
  // SHARED formatter so the Vite pipeline's identical guard reads byte-for-byte the same.
500
580
  const nodeBuiltinLeak = formatNodeBuiltinLeak(detectNodeBuiltinsInClient(clientGraph))
501
581
  if (nodeBuiltinLeak !== undefined) throw new Error(nodeBuiltinLeak)
502
582
 
503
- // §3.3/§5.1: a module that opted into the `server-only` marker yet reached a CLIENT chunk - catches
504
- // pure-server logic (a secret, a server-only API call) carrying no `node:` import and not named
505
- // `*.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.
506
585
  const serverOnlyLeak = formatServerOnlyLeak(detectServerOnlyInClient(clientGraph))
507
586
  if (serverOnlyLeak !== undefined) throw new Error(serverOnlyLeak)
508
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
+
509
627
  // Rename any chunk whose basename isn't URL-safe (dynamic-route files become `[slug]-hash.js`) and
510
628
  // rewrite the references - otherwise the lazy import 404s and the route silently never hydrates.
511
- const renamed = sanitizeOutputNames(result.outputs)
629
+ const renamed = sanitizeOutputNames(outputs)
512
630
  const toUrl = (path: string): string =>
513
631
  `${publicPath}${renamed.get(basename(path)) ?? basename(path)}`
514
632
  // Entry-point outputs come back in entrypoint order: [bootstrap, ...routeFiles]. Map each route file
515
633
  // to its chunk URL by that order (guarded against drift), then a route's chunks = its layout chain +
516
634
  // own file.
517
- const entryPoints = result.outputs.filter((o) => o.kind === "entry-point")
635
+ const entryPoints = outputs.filter((o) => o.kind === "entry-point")
518
636
  const bootstrap = entryPoints[0]
519
637
  if (bootstrap === undefined) throw new Error("[nifra/web] build produced no entry-point output")
520
638
  if (entryPoints.length !== routeFiles.length + 1) {
@@ -553,7 +671,7 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
553
671
  // per-route `cssBundle` outputs follow. Dropping those per-route outputs (the old `aggregate`-only
554
672
  // shape) left them in `manifest.assets` but not `css`, so a route-scoped stylesheet normalized to
555
673
  // `asset:css` and tripped the module-graph parity contract - a bundler-dependent divergence.
556
- 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"))
557
675
  const css: readonly string[] = [
558
676
  ...cssAssets.filter((o) => cssNameOf(o.path) === bootstrapName),
559
677
  ...cssAssets.filter((o) => cssNameOf(o.path) !== bootstrapName),
@@ -565,7 +683,6 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
565
683
  // same-basename collisions (`index.tsx` + `blog/index.tsx`) that a filename match can't. A page then
566
684
  // links only its layout chain + own CSS (deduped); an empty array means the page needs no CSS at all.
567
685
  // Absent (→ aggregate fallback) only if Bun emits no metafile/cssBundle - never silently incomplete.
568
- const cwd = process.cwd()
569
686
  const cssByEntry = new Map<string, string>()
570
687
  for (const out of Object.values(clientMeta?.outputs ?? {})) {
571
688
  if (out.entryPoint !== undefined && out.cssBundle !== undefined) {
@@ -585,24 +702,35 @@ export async function buildClient(options: BuildClientOptions): Promise<BuildMan
585
702
  // SSR pages unstyled. Keep routeStyles absent until at least one authored route entry owns CSS.
586
703
  const routeCssEntries = [...cssByEntry.keys()].filter((entry) => entry !== resolvePath(entryFile))
587
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)
588
708
  for (const route of routeManifest.routes) {
589
709
  routeStyles[route.id] = stylesFor([
590
710
  ...route.layoutIds.map((id) => routeManifest.layouts[id]?.file ?? ""),
591
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,
592
722
  ])
593
723
  }
594
- if (routeManifest.notFound) routeStyles._404 = stylesFor([routeManifest.notFound.file])
595
724
  }
596
725
 
597
726
  // Copy `public/` into the output next to the hashed assets. A missing directory is normal (most
598
727
  // apps have none) and must not fail the build.
599
- const publicDir = options.publicDir === false ? undefined : (options.publicDir ?? "public")
600
728
  const publicFiles =
601
729
  publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
602
730
 
603
731
  const manifest: BuildManifest = {
604
732
  entry: toUrl(bootstrap.path),
605
- assets: result.outputs.map((o) => toUrl(o.path)),
733
+ assets: outputs.map((o) => toUrl(o.path)),
606
734
  routes,
607
735
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
608
736
  ...(css.length > 0 ? { css } : {}),
@@ -661,6 +789,14 @@ export interface BuildServerOptions {
661
789
  * - on Cloudflare, ship them with wrangler's `no_bundle` + `find_additional_modules` + an ESModule
662
790
  * `rule` (Node/Deno import the chunks natively). Eager (one self-contained file) stays the default. */
663
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
664
800
  }
665
801
 
666
802
  /** The built worker bundle - point your `wrangler.toml`'s `main` at `worker`. */
@@ -751,12 +887,55 @@ const SVELTE_DEDUPE_PATTERN = dedupePolicyFor("svelte").bunPattern ?? /^svelte($
751
887
  * fixed subpath list), Svelte has many internal subpaths, so each matched import is resolved dynamically.
752
888
  * `svelte/compiler` (build-time only, not in the bundle) doesn't match the filter and is left alone. No-op
753
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.
754
896
  */
755
897
  export const svelteDedupePlugin = (from: string): BunPlugin => ({
756
898
  name: "nifra-svelte-dedupe",
757
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
+ }
758
929
  build.onResolve({ filter: SVELTE_DEDUPE_PATTERN }, (args) => {
759
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
+ }
760
939
  return { path: Bun.resolveSync(args.path, from) }
761
940
  } catch {
762
941
  return undefined // not resolvable from the app root - leave Bun's default resolution
@@ -765,6 +944,19 @@ export const svelteDedupePlugin = (from: string): BunPlugin => ({
765
944
  },
766
945
  })
767
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
+
768
960
  /**
769
961
  * The app's declared single-copy rule, applied to the bundle.
770
962
  *
@@ -781,33 +973,60 @@ const declaredSingleCopyPlugins = (root: string): readonly BunPlugin[] =>
781
973
  readSingleCopyDeclaration(root) === undefined ? [] : [declaredSingleCopyPlugin({ cwd: root })]
782
974
 
783
975
  /**
784
- * Remix-style `.server` convention for the CLIENT build. A module named `*.server.ts(x)` (`db.server.ts`,
785
- * `auth.server.ts`, …) is server-only - empty it in the browser bundle so its (possibly `node:` / native /
786
- * Capacitor) import subtree never reaches the client. The body is CJS-with-a-Proxy so any named OR default
787
- * import resolves to `undefined` rather than a "missing export" bundle error (verified), and the real
788
- * import subtree is gone. The complement to the node-builtin guard: when a server-only import is co-located
789
- * in a route file (so it can't be tree-shaken out and the guard fails loud), moving it into a `*.server`
790
- * 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.
791
979
  */
792
- export const serverOnlyEmptyPlugin = (): BunPlugin => ({
793
- name: "nifra-server-only-empty",
794
- setup(build) {
795
- build.onLoad({ filter: SERVER_ONLY_MODULE }, () => ({
796
- contents: SERVER_ONLY_REPLACEMENT,
797
- loader: "js",
798
- }))
799
- },
800
- })
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"
801
1022
 
802
1023
  /**
803
1024
  * Server functions in the CLIENT build: replace each `*.fn.ts` module with stubs that call the routes
804
1025
  * the server mounted, so the function bodies - and everything they import - never reach a browser.
805
1026
  *
806
- * The sibling of {@link serverOnlyEmptyPlugin}, and a deliberate contrast: a `*.server` module is
807
- * EMPTIED because nothing may call it from the client, while a `*.fn` module is REPLACED because the
808
- * client is supposed to call it, just over HTTP. The generation itself is in
809
- * `internal/server-boundary.ts` so the Vite pipeline emits identical stubs from the same code; two
810
- * 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.
811
1030
  *
812
1031
  * CLIENT-only. The server build keeps the real module, which is what `serverFunctions()` mounts.
813
1032
  */
@@ -886,6 +1105,7 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
886
1105
  entrypoints: [serverEntry],
887
1106
  outdir: outDir,
888
1107
  target,
1108
+ metafile: true,
889
1109
  conditions: [...conditions],
890
1110
  define: {
891
1111
  ...(options.define ?? { "process.env.NODE_ENV": '"production"' }),
@@ -915,6 +1135,48 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
915
1135
  `[nifra/web] server build failed:\n${result.logs.map((l) => String(l)).join("\n")}`,
916
1136
  )
917
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
+ }
918
1180
  const entryOutput = result.outputs.find((o) => o.kind === "entry-point")
919
1181
  if (entryOutput === undefined) {
920
1182
  throw new Error("[nifra/web] server build produced no entry-point output")
@@ -931,12 +1193,37 @@ export async function buildServer(options: BuildServerOptions): Promise<ServerBu
931
1193
  // …) - so we GENERATE it here (per target) instead of asking each app to ship five near-identical files.
932
1194
  // ===================================================================================================
933
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
+
934
1221
  /**
935
1222
  * Codegen the per-target **server entry** module (source text) for `buildServer` to bundle. It imports
936
1223
  * the app's `adapter` (from `framework.ts`), the optional `backend` (from `backend.ts`), and the
937
1224
  * generated `{ manifest, clientEntry }` (from `./server-manifest`), builds `createWebApp`, then wires
938
1225
  * the right host:
939
- * - `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).
940
1227
  * - `deno`: same fetch-handler default, plus `Deno.serve` self-host when run directly.
941
1228
  * - `bun` / `node`: a self-hosting server that ALSO serves the client bundle from disk (those
942
1229
  * runtimes have a filesystem; the static `/assets/*` sit next to the entry).
@@ -953,19 +1240,42 @@ export function generateServerEntry(options: {
953
1240
  /** Import specifier for the module exporting `use`, or `undefined`. Must resolve to the
954
1241
  * same edge-safe module as `adapterImport`. */
955
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
956
1246
  /** Document `<title>` passed to `createWebApp`. */
957
1247
  readonly title?: string
958
1248
  /** Encoded root-relative public file paths copied into the deploy directory. */
959
1249
  readonly publicFiles?: readonly string[]
1250
+ /** Trust the edge platform's client-address header for `c.clientIp`. See {@link ServerEntryClientIp}. */
1251
+ readonly clientIp?: ServerEntryClientIp
960
1252
  }): string {
961
1253
  const {
962
1254
  target,
963
1255
  adapterImport,
964
1256
  backendImport,
965
1257
  useImport,
1258
+ optionImports = {},
966
1259
  title = "nifra",
967
1260
  publicFiles = [],
1261
+ clientIp,
968
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
+ }
969
1279
  if (target === "static") {
970
1280
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
971
1281
  }
@@ -973,21 +1283,24 @@ export function generateServerEntry(options: {
973
1283
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
974
1284
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
975
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
+ }
976
1289
  if (backendImport !== undefined) {
977
1290
  lines.push(`import { backend } from ${JSON.stringify(backendImport)}`)
978
1291
  }
979
1292
  lines.push(
980
1293
  'import { clientEntry, cssLoading, manifest, styles, routeStyles } from "./server-manifest"',
981
1294
  )
982
- // cf-pages/vercel/deno need the fetch-handler shape; bun/node call app.fetch directly.
983
- const usesToFetch = target === "cf-pages" || target === "vercel" || target === "deno"
984
- if (usesToFetch) lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
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"')
985
1297
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
986
1298
  lines.push(
987
1299
  "",
988
1300
  "const app = createWebApp({",
989
1301
  " adapter,",
990
1302
  ...(useImport !== undefined ? [" use,"] : []),
1303
+ ...importedOptions.map((name) => ` ${name},`),
991
1304
  " manifest,",
992
1305
  " clientEntry,",
993
1306
  " styles,",
@@ -995,11 +1308,15 @@ export function generateServerEntry(options: {
995
1308
  " cssLoading,",
996
1309
  ...(backendImport !== undefined ? [" api: inProcessClient(backend),"] : []),
997
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
+ : []),
998
1315
  "})",
999
1316
  "",
1000
1317
  )
1001
1318
 
1002
- if (target === "cf-pages") {
1319
+ if (target === "cloudflare") {
1003
1320
  // Cloudflare Pages advanced mode: `_routes.json` keeps static paths off the worker entirely, and
1004
1321
  // everything else falls through to this handler (SSR).
1005
1322
  //
@@ -1049,22 +1366,34 @@ export function generateServerEntry(options: {
1049
1366
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1050
1367
  " return segments.every(safe) ? '.' + pathname : undefined",
1051
1368
  "}",
1052
- '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
+ "})",
1053
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'
1054
1382
  if (target === "bun") {
1055
1383
  lines.push(
1056
1384
  "const server = Bun.serve({",
1057
1385
  " port: Number(Bun.env.PORT ?? 3000),",
1058
- " async fetch(req) {",
1386
+ " async fetch(req, server) {",
1059
1387
  " const { pathname } = new URL(req.url)",
1060
1388
  " const filePath = staticPath(pathname)",
1061
1389
  " if (filePath !== undefined) {",
1062
1390
  " const file = Bun.file(new URL(filePath, STATIC_ROOT))",
1063
1391
  ' if (!(await file.exists())) return new Response("not found", { status: 404 })',
1064
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1065
- ' return new Response(file, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1392
+ " return new Response(file, { headers: staticHeaders(pathname) })",
1066
1393
  " }",
1067
- " 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 } })",
1068
1397
  " },",
1069
1398
  "})",
1070
1399
  // The `${...}` here is literal OUTPUT (a template in the GENERATED file), not a template in this
@@ -1078,19 +1407,18 @@ export function generateServerEntry(options: {
1078
1407
  'import { readFile } from "node:fs/promises"',
1079
1408
  "await serve(",
1080
1409
  " {",
1081
- " async fetch(req) {",
1410
+ " async fetch(req, platform) {",
1082
1411
  " const { pathname } = new URL(req.url)",
1083
1412
  " const filePath = staticPath(pathname)",
1084
1413
  " if (filePath !== undefined) {",
1085
1414
  " try {",
1086
1415
  " const body = await readFile(new URL(filePath, STATIC_ROOT))",
1087
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1088
- ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1416
+ " return new Response(body, { headers: staticHeaders(pathname) })",
1089
1417
  " } catch {",
1090
1418
  ' return new Response("not found", { status: 404 })',
1091
1419
  " }",
1092
1420
  " }",
1093
- " return app.fetch(req)",
1421
+ " return app.fetch(req, platform)",
1094
1422
  " },",
1095
1423
  " },",
1096
1424
  " { port: Number(process.env.PORT ?? 3000) },",
@@ -1100,22 +1428,21 @@ export function generateServerEntry(options: {
1100
1428
  }
1101
1429
  // deno
1102
1430
  lines.push(
1103
- "const handler = toFetchHandler(app)",
1104
1431
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1105
- 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req) => {',
1432
+ 'Deno.serve({ port: Number(Deno.env.get("PORT") ?? "3000") }, async (req, info) => {',
1106
1433
  " const { pathname } = new URL(req.url)",
1107
1434
  " const filePath = staticPath(pathname)",
1108
1435
  " if (filePath !== undefined) {",
1109
1436
  " try {",
1110
1437
  " // @ts-ignore - Deno.readFile is present on the Deno runtime.",
1111
1438
  " const body = await Deno.readFile(new URL(filePath, STATIC_ROOT))",
1112
- ' const ext = pathname.slice(pathname.lastIndexOf(".") + 1)',
1113
- ' return new Response(body, { headers: { "content-type": TYPES[ext] ?? "application/octet-stream" } })',
1439
+ " return new Response(body, { headers: staticHeaders(pathname) })",
1114
1440
  " } catch {",
1115
1441
  ' return new Response("not found", { status: 404 })',
1116
1442
  " }",
1117
1443
  " }",
1118
- " return handler.fetch(req)",
1444
+ ` ${markFramed}`,
1445
+ " return app.fetch(req, { clientIp: info.remoteAddr.hostname })",
1119
1446
  "})",
1120
1447
  )
1121
1448
  return `${lines.join("\n")}\n`
@@ -1155,6 +1482,9 @@ export interface BuildTargetOptions {
1155
1482
  * applied before page routes are declared), or `undefined`. Must resolve to the same edge-safe
1156
1483
  * module as `adapterImport`. */
1157
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
1158
1488
  /** Factory that builds the app for `static` prerendering, GIVEN the client build's manifest - so the
1159
1489
  * emitted hydration `<script src>` uses the REAL content-hashed entry (`client.entry`) plus the same
1160
1490
  * styles/route-preload the server targets use. A pre-built instance can't work here: the hash isn't known
@@ -1173,6 +1503,8 @@ export interface BuildTargetOptions {
1173
1503
  readonly publicDir?: string | false
1174
1504
  /** Prefix of environment variables allowed into the client bundle (default `"PUBLIC_"`). */
1175
1505
  readonly publicEnvPrefix?: string
1506
+ /** Reviewed false positives of the secret scan over the bundle, `public/` and prerendered pages. */
1507
+ readonly secretExemptions?: readonly SecretExemption[]
1176
1508
  /**
1177
1509
  * Vite-only CSS output policy. `false` emits one aggregate stylesheet. The native Bun build does not
1178
1510
  * support this switch and fails closed if it is supplied instead of silently ignoring it.
@@ -1187,6 +1519,9 @@ export interface BuildTargetOptions {
1187
1519
  readonly cssLoading?: CssLoadingMode
1188
1520
  /** Document `<title>` for the generated server entry. */
1189
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
1190
1525
  }
1191
1526
 
1192
1527
  /** Minimal app surface `buildTarget`'s static path needs - a fetch handler (a built `createWebApp`). */
@@ -1214,15 +1549,13 @@ export interface BuildTargetResult {
1214
1549
  * `<outDir>/assets/*`, then per target:
1215
1550
  * - `static`: prerenders opted-in routes (`prerenderRoutes`) to `<outDir>/<path>/index.html` (+
1216
1551
  * `_data.json`); needs `prerenderApp`. No server.
1217
- * - `cf-pages`: a `_worker.js` (edge bundle) + a `_routes.json` excluding /assets/* from the worker.
1218
- * - `vercel`: a `.vercel/output`-shaped function isn't emitted here - `vercel` emits the bundled edge
1219
- * 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`.
1220
1556
  * - `deno`/`node`/`bun`: the self-hosting server bundle (`server.js`) next to the assets.
1221
1557
  * The server entry is GENERATED (`generateServerEntry`) and bundled (`buildServer`); the app supplies
1222
1558
  * only adapter/backend/routes. Returns the manifest + a size report. Throws on any build failure.
1223
- *
1224
- * Note: the heavier platform wrappers (`.vercel/output` v3 layout, wrangler ISR `find_additional_modules`)
1225
- * remain app-owned scripts; this command targets the common single-bundle deploys. See the CLI docs.
1226
1559
  */
1227
1560
  /** The default (Bun) strategy - `buildClient`/`buildServer` from this module. */
1228
1561
  export const bunBundler: Bundler = {
@@ -1243,6 +1576,7 @@ export const bunBundler: Bundler = {
1243
1576
  ...(input.cssCodeSplit !== undefined ? { cssCodeSplit } : {}),
1244
1577
  ...(input.publicDir !== undefined ? { publicDir: input.publicDir } : {}),
1245
1578
  ...(input.publicEnvPrefix !== undefined ? { publicEnvPrefix: input.publicEnvPrefix } : {}),
1579
+ ...(input.secretExemptions !== undefined ? { secretExemptions: input.secretExemptions } : {}),
1246
1580
  })
1247
1581
  },
1248
1582
  buildServer: (input) =>
@@ -1257,6 +1591,9 @@ export const bunBundler: Bundler = {
1257
1591
  ...(input.plugins ? { plugins: input.plugins as BunPlugin[] } : {}),
1258
1592
  ...(input.define ? { define: input.define } : {}),
1259
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 } : {}),
1260
1597
  }),
1261
1598
  }
1262
1599
 
@@ -1284,15 +1621,16 @@ export async function buildTargetWith(
1284
1621
  options.cssCodeSplit === undefined ? undefined : normalizeCssCodeSplit(options.cssCodeSplit)
1285
1622
  const requestedCssLoading =
1286
1623
  options.cssLoading === undefined ? undefined : normalizeCssLoading(options.cssLoading)
1287
- const targetPlan = planBuildTarget(target, outDir)
1624
+ const targetPlan = planBuildTarget(parseBuildTarget(target), outDir)
1288
1625
  const { rmSync } = await import("node:fs")
1289
1626
  rmSync(outDir, { recursive: true, force: true })
1290
1627
  rmSync(workDir, { recursive: true, force: true })
1291
- const assetsDir = `${outDir}/assets`
1628
+ const staticRoot = targetPlan.staticDir === "" ? outDir : `${outDir}/${targetPlan.staticDir}`
1629
+ const assetsDir = `${staticRoot}/assets`
1292
1630
  mkdirSync(assetsDir, { recursive: true })
1293
1631
  mkdirSync(workDir, { recursive: true })
1294
1632
 
1295
- // (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).
1296
1634
  let client = await bundler.buildClient({
1297
1635
  routesDir,
1298
1636
  outDir: assetsDir,
@@ -1302,6 +1640,9 @@ export async function buildTargetWith(
1302
1640
  define: { "process.env.NODE_ENV": '"production"', ...(options.define ?? {}) },
1303
1641
  publicDir: false,
1304
1642
  ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1643
+ ...(options.secretExemptions !== undefined
1644
+ ? { secretExemptions: options.secretExemptions }
1645
+ : {}),
1305
1646
  ...(requestedCssCodeSplit !== undefined ? { cssCodeSplit: requestedCssCodeSplit } : {}),
1306
1647
  ...(requestedCssLoading !== undefined ? { cssLoading: requestedCssLoading } : {}),
1307
1648
  root: resolvePath(dirname(routesDir)),
@@ -1316,8 +1657,21 @@ export async function buildTargetWith(
1316
1657
  options.publicDir === false
1317
1658
  ? undefined
1318
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
+ }
1319
1671
  const publicFiles =
1320
- publicDir !== undefined && existsSync(publicDir) ? await copyPublicDir(publicDir, outDir) : []
1672
+ publicDir !== undefined && existsSync(publicDir)
1673
+ ? await copyPublicDir(publicDir, staticRoot)
1674
+ : []
1321
1675
  if (publicFiles.length > 0) {
1322
1676
  client = { ...client, publicFiles }
1323
1677
  }
@@ -1343,6 +1697,7 @@ export async function buildTargetWith(
1343
1697
  app,
1344
1698
  routes: manifest.routes,
1345
1699
  outDir,
1700
+ secrets: secretScan,
1346
1701
  })
1347
1702
  if (result.prerendered.length === 0) {
1348
1703
  // A static build that renders nothing is almost always a misconfig (no `prerender = true` / no
@@ -1375,9 +1730,11 @@ export async function buildTargetWith(
1375
1730
  target,
1376
1731
  adapterImport: options.adapterImport,
1377
1732
  ...(options.useImport !== undefined ? { useImport: options.useImport } : {}),
1733
+ ...(options.optionImports !== undefined ? { optionImports: options.optionImports } : {}),
1378
1734
  ...(options.backendImport !== undefined ? { backendImport: options.backendImport } : {}),
1379
1735
  ...(options.title !== undefined ? { title: options.title } : {}),
1380
1736
  ...(publicFiles.length > 0 ? { publicFiles } : {}),
1737
+ ...(options.clientIp !== undefined ? { clientIp: options.clientIp } : {}),
1381
1738
  }),
1382
1739
  )
1383
1740
  const { worker } = await bundler.buildServer({
@@ -1396,11 +1753,13 @@ export async function buildTargetWith(
1396
1753
  ? { cssLoading: requestedCssLoading }
1397
1754
  : {}),
1398
1755
  root: resolvePath(dirname(routesDir)),
1756
+ generatedFiles: [resolvePath(workDir, options.adapterImport)],
1757
+ ...(options.publicEnvPrefix !== undefined ? { publicEnvPrefix: options.publicEnvPrefix } : {}),
1399
1758
  })
1400
1759
 
1401
1760
  // (3) Assemble the deploy dir for the target.
1402
1761
  const { cpSync } = await import("node:fs")
1403
- if (targetPlan.target === "cf-pages") {
1762
+ if (targetPlan.target === "cloudflare") {
1404
1763
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1405
1764
  // The app's real patterns, so a directory is only collapsed into a glob once the route table
1406
1765
  // proves nothing can be served beneath it.
@@ -1422,7 +1781,18 @@ export async function buildTargetWith(
1422
1781
  )
1423
1782
  }
1424
1783
  } else if (targetPlan.target === "vercel") {
1784
+ const fn = dirname(`${outDir}/${targetPlan.outputFile}`)
1785
+ mkdirSync(fn, { recursive: true })
1425
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
+ )
1426
1796
  } else {
1427
1797
  cpSync(worker, `${outDir}/${targetPlan.outputFile}`)
1428
1798
  }