@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
@@ -5,11 +5,23 @@ import {
5
5
  ACTION_GLOBAL,
6
6
  BOUNDARY_GLOBAL,
7
7
  DATA_GLOBAL,
8
+ HANDOVER_ID,
8
9
  LAYOUT_DATA_GLOBAL,
9
10
  ROOT_ATTRIBUTE,
10
11
  ROUTE_GLOBAL,
11
12
  } from "../render-seam.ts"
13
+ import { PRERENDERED_GLOBAL } from "../router.ts"
12
14
  import { jsStringLiteral } from "./js-string.ts"
15
+
16
+ /** The globals the page-state handover may set - nothing else in it reaches `window`. */
17
+ const HANDOVER_GLOBALS = [
18
+ DATA_GLOBAL,
19
+ LAYOUT_DATA_GLOBAL,
20
+ ROUTE_GLOBAL,
21
+ ACTION_GLOBAL,
22
+ BOUNDARY_GLOBAL,
23
+ PRERENDERED_GLOBAL,
24
+ ]
13
25
  export interface GenerateClientEntryOptions {
14
26
  /**
15
27
  * Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
@@ -48,6 +60,8 @@ export function generateClientEntry(
48
60
  // Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
49
61
  // adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
50
62
  const errorRouteIds: string[] = []
63
+ // Route id → its layout ids, for the chain `useMatches` reports. Routes without layouts are omitted.
64
+ const layoutIdsOf: Record<string, readonly string[]> = {}
51
65
  // Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
52
66
  const lazyLoader = (files: readonly string[]): string => {
53
67
  const imports = files.map((f) => `import(${jsStringLiteral(resolve(f))})`).join(", ")
@@ -68,6 +82,7 @@ export function generateClientEntry(
68
82
  errorRouteIds.push(route.id)
69
83
  }
70
84
  loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`)
85
+ if (route.layoutIds.length > 0) layoutIdsOf[route.id] = route.layoutIds
71
86
  patternRows.push(
72
87
  ` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`,
73
88
  )
@@ -81,14 +96,46 @@ export function generateClientEntry(
81
96
  loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
82
97
  statusRoutes[Number(status)] = routeId
83
98
  }
99
+ // `_loading` pages. Emitted only when the app has one, so an app without them imports nothing extra.
100
+ // Each is its own lazy chunk; a route row carries its layout ids and the loading pages above it with
101
+ // the number of those layouts each sits under - all the runtime needs to choose one.
102
+ const loadings = Object.entries(manifest.loadings ?? {})
103
+ const loadingLines: string[] = []
104
+ if (loadings.length > 0) {
105
+ const routeRows = new Map<string, string>()
106
+ for (const route of manifest.routes) {
107
+ const above = (route.loadingIds ?? []).map((id) => [
108
+ id,
109
+ manifest.loadings?.[id]?.layoutIds.length ?? 0,
110
+ ])
111
+ routeRows.set(
112
+ route.id,
113
+ ` ${JSON.stringify(route.id)}: ${JSON.stringify([route.layoutIds, above])},`,
114
+ )
115
+ }
116
+ loadingLines.push(
117
+ "const loadingModules = {",
118
+ ...loadings.map(
119
+ ([id, entry]) =>
120
+ ` ${JSON.stringify(id)}: () => import(${jsStringLiteral(resolve(entry.file))}),`,
121
+ ),
122
+ "}",
123
+ "const loadingRoutes = {",
124
+ ...routeRows.values(),
125
+ "}",
126
+ )
127
+ }
84
128
 
85
129
  return `${[
86
130
  // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
87
131
  // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
88
132
  'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
89
- 'import { applyHead, installForms, installHistory, signalHydrated, waitForStyles } from "@nifrajs/web/client"',
133
+ 'import { applyHead, currentDocumentNonce, installForms, installHistory, signalHydrated, waitForStyles } from "@nifrajs/web/client"',
90
134
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
91
135
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
136
+ ...(loadings.length > 0
137
+ ? ['import { withLoading } from "@nifrajs/web/internal/loading-runtime"']
138
+ : []),
92
139
  `import * as __adapter from ${JSON.stringify(clientModule)}`,
93
140
  "const { mountRouter } = __adapter",
94
141
  // The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
@@ -140,6 +187,12 @@ export function generateClientEntry(
140
187
  // routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
141
188
  // after the route chunk loads, so routes that do not declare them remain ordinary route modules.
142
189
  "const routeHooks = {}",
190
+ // routeId → the chain the server rendered for an `ssr = false` page: the same layouts (and
191
+ // boundary), with the page's `HydrateFallback` - or an empty leaf - where the component goes.
192
+ "const holds = {}",
193
+ // routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
194
+ `const layoutIdsOf = ${JSON.stringify(layoutIdsOf)}`,
195
+ "const matchChains = {}",
143
196
  "const loadModule = async (id) => {",
144
197
  " if (chains[id]) return",
145
198
  " const mods = await loaders[id]()",
@@ -161,20 +214,34 @@ export function generateClientEntry(
161
214
  // `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
162
215
  " searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
163
216
  " searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
217
+ " matchChains[id] = { ids: [...(layoutIdsOf[id] ?? []), id], handles: mods.slice(0, mods.length - 1).map((m) => m.handle) }",
164
218
  " } else {",
165
219
  " chains[id] = mods.map((m) => m.default)",
166
220
  " metas[id] = mods.map((m) => m.meta)",
167
221
  // The chain is every module (layouts + page); client keys come from the page (the last module).
168
222
  " searchSchemas[id] = mods.map((m) => m.searchSchema)",
169
223
  " searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
224
+ " matchChains[id] = { ids: [...(layoutIdsOf[id] ?? []), id], handles: mods.map((m) => m.handle) }",
170
225
  " }",
171
226
  " const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
172
- // Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
173
- // server-side boundary load function (which may close over secrets) is never stored in the client
174
- // hook table or serialized into a browser bundle by this wiring.
227
+ // Only neutral name/mode metadata crosses into the router; a boundary's loader lives in the route's
228
+ // backend half, which no browser bundle contains.
175
229
  " const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
176
- " const boundaries = boundaryMods.flatMap((m) => (m.boundaries ?? []).map((b) => ({ name: b.name, mode: b.mode, hasLoad: b.load !== undefined, ...(b.errorId === undefined ? {} : { errorId: b.errorId }) })))",
230
+ " const boundaries = boundaryMods.flatMap((m) => (m.boundaries ?? []).map((b) => ({ name: b.name, mode: b.mode, ...(b.errorId === undefined ? {} : { errorId: b.errorId }) })))",
177
231
  " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
232
+ " if (page.ssr === false) holds[id] = [...chains[id].slice(0, -1), page.HydrateFallback ?? (() => null)]",
233
+ "}",
234
+ // The server hands page state over as one inert JSON script; lift it onto the globals the
235
+ // router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
236
+ // matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
237
+ // element with the same id, and arbitrary keys copied onto `window` include `location`.
238
+ `const handovers = document.querySelectorAll(${JSON.stringify(`script[type="application/json"][id="${HANDOVER_ID}"]`)})`,
239
+ "const handover = handovers[handovers.length - 1]",
240
+ "if (handover !== undefined) {",
241
+ ' const state = JSON.parse(handover.textContent || "{}")',
242
+ ` for (const key of ${JSON.stringify(HANDOVER_GLOBALS)}) {`,
243
+ " if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
244
+ " }",
178
245
  "}",
179
246
  "const patterns = [",
180
247
  ...patternRows,
@@ -182,12 +249,19 @@ export function generateClientEntry(
182
249
  // Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
183
250
  // server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
184
251
  "const matched = createMatcher(patterns)(location.pathname)",
252
+ `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
253
+ `const serverRoute = window.${ROUTE_GLOBAL} ?? ""`,
254
+ // A terminal status page the server rendered (`_404`, `_410`, ...) outranks the URL match: a
255
+ // loader that throws notFound() on `/learn/bad-slug` leaves a URL that still matches
256
+ // `learn/[slug]`, and hydrating that route with null data over the 404 markup is wrong. Membership
257
+ // in the status table, not a `_` prefix: `routes/_admin/index.tsx` is an ordinary routable id.
258
+ "const terminal = Object.values(statusRoutes).includes(serverRoute)",
185
259
  // Map any `{__nifra_deferred: id}` placeholder in the SSR data to the registry's promise, so the
186
260
  // component receives real promises to `<Await>` (a no-op when a page has no deferred data).
187
261
  MAP_DEFERRED_SOURCE,
188
262
  "const initial = {",
189
- ` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
190
- " params: matched ? matched.params : {},",
263
+ " routeId: matched && !terminal ? matched.routeId : serverRoute,",
264
+ " params: matched && !terminal ? matched.params : {},",
191
265
  // pathname + search (NOT just pathname): the SSR render threads `pathname+search` into
192
266
  // `useLocation`/`useSearchParams`, so the hydrating initial state must carry the query too or a
193
267
  // page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
@@ -202,8 +276,12 @@ export function generateClientEntry(
202
276
  ` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
203
277
  " pending: false,",
204
278
  "}",
205
- `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
206
- "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
279
+ ...loadingLines,
280
+ // With `_loading` pages the store is wrapped: a slow navigation then names a loading chain (the
281
+ // shared layouts + the `_loading` component) for the mounted view to render in the meantime.
282
+ loadings.length > 0
283
+ ? "const router = withLoading(createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks }), { routes: loadingRoutes, modules: loadingModules, chains, searchSchemas, matchChains })"
284
+ : "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
207
285
  "installHistory(router)",
208
286
  "installForms(router)",
209
287
  // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
@@ -223,7 +301,42 @@ export function generateClientEntry(
223
301
  // Wait for framework-owned deferred CSS before loading/mounting the initial route. The coordinator
224
302
  // is a no-op for the default blocking links and always has a bounded fail-open terminal state.
225
303
  "waitForStyles().then(() => loadModule(initial.routeId)).then(() => {",
226
- " mountRouter({ router, routes: chains, searchSchemas, container: root })",
304
+ // An `ssr = false` page: the server rendered its held chain, so the view hydrates THAT - under a
305
+ // chain id no route file can produce - and is handed the real route a task later, once every
306
+ // adapter has hydrated and subscribed. Only the first view is held; a client navigation to such a
307
+ // page has no server markup to match and renders the component directly.
308
+ " let view = router",
309
+ " const hold = holds[initial.routeId]",
310
+ " if (hold) {",
311
+ ' const id = "\\0ssr"',
312
+ " chains[id] = hold",
313
+ " searchSchemas[id] = searchSchemas[initial.routeId]",
314
+ " matchChains[id] = matchChains[initial.routeId]",
315
+ " const listeners = new Set()",
316
+ " let held = true",
317
+ " let base",
318
+ " let derived",
319
+ " view = {",
320
+ " ...router,",
321
+ " snapshot() {",
322
+ " const s = router.snapshot()",
323
+ " if (!held) return s",
324
+ // One derived state per router state: the view's store must hand back a stable reference.
325
+ " if (base !== s) derived = { ...(base = s), routeId: id }",
326
+ " return derived",
327
+ " },",
328
+ " subscribe(listener) {",
329
+ " listeners.add(listener)",
330
+ " const off = router.subscribe(listener)",
331
+ " return () => (listeners.delete(listener), off())",
332
+ " },",
333
+ " }",
334
+ " setTimeout(() => {",
335
+ " held = false",
336
+ " for (const listener of [...listeners]) listener()",
337
+ " }, 0)",
338
+ " }",
339
+ " mountRouter({ router: view, routes: chains, searchSchemas, matchChains, container: root })",
227
340
  // Run the optional client loader only after the adapter has mounted the SSR tree. The initial
228
341
  // server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
229
342
  // duplicate the first request.
@@ -237,7 +350,8 @@ export function generateClientEntry(
237
350
  " if (!s.pending) {",
238
351
  // `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
239
352
  // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
240
- " const args = { data: s.data, params: s.params, origin: location.origin }",
353
+ " const nonce = currentDocumentNonce()",
354
+ " const args = { data: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
241
355
  " applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
242
356
  " }",
243
357
  " })",
@@ -319,7 +433,8 @@ export interface GenerateServerManifestOptions {
319
433
  * Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
320
434
  * Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
321
435
  * and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
322
- * **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
436
+ * **statically-analyzable** imports of every route/layout/`_error`/terminal status page and each one's
437
+ * backend half (so the bundler includes them) and
323
438
  * rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
324
439
  * + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
325
440
  * a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
@@ -338,6 +453,12 @@ function moduleSpecifier(resolved: string): string {
338
453
  return resolved.replace(SOURCE_EXTENSION, "")
339
454
  }
340
455
 
456
+ // A route's namespace is not assignable to `RouteModule` under a strict `tsc` (a backend half has no
457
+ // `default`, a typed `meta` narrows its data), so the table holds plain objects and the cast happens at
458
+ // `buildManifest`'s boundary - the same cast `discoverRoutes` applies to its dynamic import.
459
+ const LAZY_IMPORTER = "(file) => loaders[file] as () => Promise<RouteModule>"
460
+ const EAGER_IMPORTER = "(file) => () => Promise.resolve(modules[file] as RouteModule)"
461
+
341
462
  export function generateServerManifest(
342
463
  manifest: Manifest,
343
464
  options: GenerateServerManifestOptions,
@@ -350,16 +471,32 @@ export function generateServerManifest(
350
471
  cssLoading: requestedCssLoading,
351
472
  lazy = false,
352
473
  } = options
474
+ // A missing entry would bake `clientEntry = undefined` and the build would still succeed, leaving
475
+ // every hydrating page to fail at request time. `""` stays valid: an app with no client script.
476
+ if (typeof clientEntry !== "string") {
477
+ throw new TypeError(
478
+ `[nifra/web] the server build needs clientEntry (the built client entry URL, buildClient's manifest.entry), got ${clientEntry === null ? "null" : typeof clientEntry}; check the option is spelled clientEntry, or pass "" for an app with no client script`,
479
+ )
480
+ }
353
481
  const cssLoading = normalizeCssLoading(requestedCssLoading ?? DEFAULT_CSS_LOADING)
354
- // Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
482
+ // Every unique source file in the manifest, both halves of each, sorted for stable output. This is the
483
+ // one generated module that imports frontend and backend halves together: it is the server's
484
+ // composition, and `buildManifest` pairs the halves again from these keys.
485
+ const sources = [
486
+ ...manifest.routes,
487
+ ...Object.values(manifest.layouts),
488
+ ...Object.values(manifest.errors ?? {}),
489
+ ...(manifest.notFound ? [manifest.notFound] : []),
490
+ ...Object.values(manifest.notFounds ?? {}),
491
+ ...Object.values(manifest.statusPages ?? {}),
492
+ ...Object.values(manifest.middlewares ?? {}),
493
+ ]
355
494
  const files = [
356
- ...new Set([
357
- ...manifest.routes.map((r) => r.file),
358
- ...Object.values(manifest.layouts).map((l) => l.file),
359
- ...Object.values(manifest.errors ?? {}).map((e) => e.file),
360
- ...(manifest.notFound ? [manifest.notFound.file] : []),
361
- ...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
362
- ]),
495
+ ...new Set(
496
+ sources.flatMap((entry) =>
497
+ entry.backend === undefined ? [entry.file] : [entry.file, entry.backend],
498
+ ),
499
+ ),
363
500
  ].sort()
364
501
  const header = [
365
502
  "// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
@@ -383,14 +520,14 @@ export function generateServerManifest(
383
520
  )
384
521
  return `${[
385
522
  ...header,
386
- "const loaders: Record<string, () => Promise<RouteModule>> = {",
523
+ "const loaders: Record<string, () => Promise<object>> = {",
387
524
  ...loaders,
388
525
  "}",
389
526
  clientEntryLine,
390
527
  stylesLine,
391
528
  routeStylesLine,
392
529
  cssLoadingLine,
393
- "export const manifest = buildManifest(Object.keys(loaders), (file) => () => loaders[file]())",
530
+ `export const manifest = buildManifest(Object.keys(loaders), ${LAZY_IMPORTER})`,
394
531
  ].join("\n")}\n`
395
532
  }
396
533
  // Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
@@ -402,13 +539,13 @@ export function generateServerManifest(
402
539
  return `${[
403
540
  ...header,
404
541
  ...imports,
405
- "const modules: Record<string, RouteModule> = {",
542
+ "const modules: Record<string, object> = {",
406
543
  ...entries,
407
544
  "}",
408
545
  clientEntryLine,
409
546
  stylesLine,
410
547
  routeStylesLine,
411
548
  cssLoadingLine,
412
- "export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
549
+ `export const manifest = buildManifest(Object.keys(modules), ${EAGER_IMPORTER})`,
413
550
  ].join("\n")}\n`
414
551
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Vite dev adds its own tags to each SSR'd page: the HMR client, a framework's refresh preamble (an
3
+ * inline script), and at runtime a `<style>` per imported stylesheet. A page whose CSP admits scripts
4
+ * by nonce or hash refuses all of them, so the app would not boot in dev. Dev gives every such tag one
5
+ * nonce - the page's own, or a fresh one for a hash-only page - and names it in each directive that
6
+ * governs them.
7
+ */
8
+
9
+ const POLICY_HEADERS = ["content-security-policy", "content-security-policy-report-only"] as const
10
+
11
+ /** Directives that govern each element kind, most specific first: the first present one applies. */
12
+ const GOVERNING = [
13
+ ["script-src-elem", "script-src", "default-src"],
14
+ ["style-src-elem", "style-src", "default-src"],
15
+ ] as const
16
+
17
+ const NONCE_SOURCE = /'nonce-([^']+)'/
18
+
19
+ /** True when a directive admits every inline element: `'unsafe-inline'`, not voided by a nonce or hash. */
20
+ const allowsInline = (sources: readonly string[]): boolean =>
21
+ sources.includes("'unsafe-inline'") &&
22
+ !sources.some((source) => /^'(?:nonce|sha256|sha384|sha512)-/.test(source))
23
+
24
+ /** One header value (comma-separated policies), with `'nonce-<nonce>'` added where it is needed. */
25
+ function admitNonce(value: string, nonce: string): string {
26
+ const token = `'nonce-${nonce}'`
27
+ return value
28
+ .split(",")
29
+ .map((policy) => {
30
+ const directives = policy
31
+ .split(";")
32
+ .map((directive) => directive.trim())
33
+ .filter((directive) => directive !== "")
34
+ .map((directive) => directive.split(/\s+/))
35
+ for (const names of GOVERNING) {
36
+ const governing = names
37
+ .map((name) => directives.find(([directive]) => directive?.toLowerCase() === name))
38
+ .find((directive) => directive !== undefined)
39
+ if (governing === undefined) continue
40
+ const sources = governing.slice(1)
41
+ if (sources.includes(token) || sources.includes("'none'") || allowsInline(sources)) continue
42
+ governing.push(token)
43
+ }
44
+ return directives.map((directive) => directive.join(" ")).join("; ")
45
+ })
46
+ .join(", ")
47
+ }
48
+
49
+ const RAW_TEXT_START = /<(script|style)\b([^>]*)>/gi
50
+
51
+ /** `html` with `nonce` on each `<script>` / `<style>` that has none; their bodies are left as written. */
52
+ function nonceTags(html: string, nonce: string): string {
53
+ let out = ""
54
+ let at = 0
55
+ const lower = html.toLowerCase()
56
+ RAW_TEXT_START.lastIndex = 0
57
+ for (let match = RAW_TEXT_START.exec(html); match !== null; match = RAW_TEXT_START.exec(html)) {
58
+ const [tag, name = "", attrs = ""] = match
59
+ out += html.slice(at, match.index)
60
+ out += /\snonce\s*=/i.test(attrs) ? tag : `<${name} nonce="${nonce}"${attrs}>`
61
+ const close = lower.indexOf(`</${name.toLowerCase()}`, RAW_TEXT_START.lastIndex)
62
+ at = close === -1 ? html.length : close
63
+ out += html.slice(RAW_TEXT_START.lastIndex, at)
64
+ RAW_TEXT_START.lastIndex = at
65
+ }
66
+ return out + html.slice(at)
67
+ }
68
+
69
+ /**
70
+ * Let a dev page's CSP admit what Vite injected. Rewrites the policy headers in `headers` and returns
71
+ * the page with the nonce on its script and style tags, plus the `csp-nonce` meta Vite's client reads
72
+ * to nonce the styles it adds at runtime. A page with no CSP is returned as it is.
73
+ */
74
+ export function admitViteTags(
75
+ html: string,
76
+ headers: Headers,
77
+ generate: () => string = () => crypto.randomUUID().replaceAll("-", ""),
78
+ ): string {
79
+ const policies = POLICY_HEADERS.map((name) => [name, headers.get(name)] as const)
80
+ if (policies.every(([, value]) => value === null)) return html
81
+ const nonce =
82
+ policies.map(([, value]) => NONCE_SOURCE.exec(value ?? "")?.[1]).find((n) => n !== undefined) ??
83
+ generate()
84
+ for (const [name, value] of policies) {
85
+ if (value !== null) headers.set(name, admitNonce(value, nonce))
86
+ }
87
+ const tagged = nonceTags(html, nonce)
88
+ if (tagged.includes(`property="csp-nonce"`)) return tagged
89
+ const meta = `<meta property="csp-nonce" nonce="${nonce}">`
90
+ const head = /<head\b[^>]*>/i.exec(tagged)
91
+ return head === null
92
+ ? meta + tagged
93
+ : tagged.slice(0, head.index + head[0].length) +
94
+ meta +
95
+ tagged.slice(head.index + head[0].length)
96
+ }
97
+
98
+ const SCRIPT_DIRECTIVES = GOVERNING[0]
99
+ const CONNECT_DIRECTIVES = ["connect-src", "default-src"] as const
100
+
101
+ /** Whether `sources` already let the page reach `url` (a same-origin absolute URL). */
102
+ const reaches = (sources: readonly string[], url: URL): boolean =>
103
+ sources.some((source) => {
104
+ const lower = source.toLowerCase()
105
+ return (
106
+ lower === "'self'" ||
107
+ lower === "*" ||
108
+ lower === `${url.protocol}` ||
109
+ lower === url.origin ||
110
+ lower === `${url.origin}/` ||
111
+ lower === url.href
112
+ )
113
+ })
114
+
115
+ /**
116
+ * Admit nifra's own inline dev script, by `hash`, and its POSTs to `connect`, in each policy header.
117
+ * Without a `hash` the caller nonces the script itself. Returns false when a policy forbids scripts
118
+ * outright (`'none'`): such a page runs no code, so there is nothing to capture and the script stays
119
+ * out. A directive that already admits every inline script is left alone, because adding a hash would
120
+ * switch its `'unsafe-inline'` off. `module` is the one script the inline one may load; it is admitted
121
+ * by its exact URL, except under `'strict-dynamic'`, which ignores URLs and trusts what a trusted
122
+ * script loads anyway.
123
+ */
124
+ export function admitInlineScript(
125
+ headers: Headers,
126
+ hash: string | undefined,
127
+ connect: URL,
128
+ module?: URL,
129
+ ): boolean {
130
+ const updates: [string, string][] = []
131
+ let allowed = true
132
+ for (const name of POLICY_HEADERS) {
133
+ const value = headers.get(name)
134
+ if (value === null) continue
135
+ const next = value
136
+ .split(",")
137
+ .map((policy) => {
138
+ const directives = policy
139
+ .split(";")
140
+ .map((directive) => directive.trim())
141
+ .filter((directive) => directive !== "")
142
+ .map((directive) => directive.split(/\s+/))
143
+ const governing = (names: readonly string[]): string[] | undefined =>
144
+ names
145
+ .map((n) => directives.find(([directive]) => directive?.toLowerCase() === n))
146
+ .find((directive) => directive !== undefined)
147
+ const script = governing(SCRIPT_DIRECTIVES)
148
+ if (script !== undefined) {
149
+ const sources = script.slice(1)
150
+ if (sources.includes("'none'")) allowed = false
151
+ else {
152
+ if (hash !== undefined && !sources.includes(hash) && !allowsInline(sources))
153
+ script.push(hash)
154
+ if (
155
+ module !== undefined &&
156
+ !sources.includes("'strict-dynamic'") &&
157
+ !reaches(sources, module)
158
+ )
159
+ script.push(module.href)
160
+ }
161
+ }
162
+ const connectDirective = governing(CONNECT_DIRECTIVES)
163
+ if (connectDirective !== undefined && !reaches(connectDirective.slice(1), connect)) {
164
+ const none = connectDirective.indexOf("'none'")
165
+ if (none === -1) connectDirective.push(connect.href)
166
+ else connectDirective.splice(none, 1, connect.href)
167
+ }
168
+ return directives.map((directive) => directive.join(" ")).join("; ")
169
+ })
170
+ .join(", ")
171
+ updates.push([name, next])
172
+ }
173
+ if (!allowed) return false
174
+ for (const [name, value] of updates) headers.set(name, value)
175
+ return true
176
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Specifiers no plugin may match on Bun's dev-server bundler.
3
+ *
4
+ * When a `[serve.static]` plugin's `onResolve` FILTER matches an import, Bun sends it down its plugin
5
+ * path, and a handler that declines does not undo that: Bun then resolves the import on a fallback that
6
+ * breaks a nifra dev page in two ways (Bun 1.3.14 through 1.4.3, oven-sh/bun#19951):
7
+ *
8
+ * - an HTML page's `<script src>` keeps its raw specifier in the HMR module table, so the browser throws
9
+ * "Failed to load bundled module" and the page never hydrates;
10
+ * - the fallback skips the dev server's built-in modules, so the React Fast Refresh runtime Bun embeds
11
+ * for an app without `react-refresh` installed fails to resolve, and the dev server does not start.
12
+ *
13
+ * unplugin's Bun adapter registers `onResolve({ filter: /.*\/ })` for every plugin with a `resolveId`
14
+ * hook, so an app plugin can hit both without doing anything unusual. Every plugin nifra hands that
15
+ * bundler registers through {@link reserveDevSpecifiers}.
16
+ */
17
+
18
+ import type { PluginBuilder } from "bun"
19
+
20
+ /** The generated client entry, written beside the probe page that loads it. Named so that no import
21
+ * an app writes can be the reserved specifier. */
22
+ export const DEV_ENTRY_FILE = "nifra-dev-entry.tsx"
23
+
24
+ const RESERVED = [`./${DEV_ENTRY_FILE}`, "react-refresh/runtime/index.js"]
25
+ const RESERVED_SOURCE = RESERVED.map((specifier) =>
26
+ specifier.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
27
+ ).join("|")
28
+
29
+ /** `filter`, except that it never matches a reserved specifier. Any other string matches as before. */
30
+ export function withoutReservedSpecifiers(filter: RegExp): RegExp {
31
+ // The original runs inside a lookahead from the start, so it still matches anywhere in the string
32
+ // and its own anchors, lookbehinds and backreferences keep their meaning. `g` and `y` are dropped:
33
+ // they make `test` depend on `lastIndex`, which a filter has no use for.
34
+ return new RegExp(
35
+ `^(?!(?:${RESERVED_SOURCE})$)(?=[\\s\\S]*?(?:${filter.source}))`,
36
+ filter.flags.replace(/[gy]/g, ""),
37
+ )
38
+ }
39
+
40
+ /** A view of `build` whose `onResolve` filters never match a reserved specifier. */
41
+ export function reserveDevSpecifiers(build: PluginBuilder): PluginBuilder {
42
+ return {
43
+ ...build,
44
+ onResolve(constraints, callback) {
45
+ build.onResolve(
46
+ { ...constraints, filter: withoutReservedSpecifiers(constraints.filter) },
47
+ callback,
48
+ )
49
+ return this
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,50 @@
1
+ // The comment and delimiter readers scan instead of matching one lazy RegExp: a pattern like
2
+ // `<!--[\s\S]*?-->` reads to the end of the source again from every opener that never closes.
3
+
4
+ /**
5
+ * `source` without its HTML comments. A browser ends one at `-->`, and also at `--!>`; an opener
6
+ * with no end after it is kept as text.
7
+ */
8
+ export function withoutComments(source: string): string {
9
+ const end = /--!?>/g
10
+ let kept = ""
11
+ let from = 0
12
+ for (let start = source.indexOf("<!--"); start !== -1; start = source.indexOf("<!--", from)) {
13
+ end.lastIndex = start + 4
14
+ const close = end.exec(source)
15
+ // No later opener has an end after it either.
16
+ if (close === null) break
17
+ kept += source.slice(from, start)
18
+ from = close.index + close[0].length
19
+ }
20
+ return kept + source.slice(from)
21
+ }
22
+
23
+ /** The text between each `open` and the first `close` after it, read on from that `close`. */
24
+ export function delimitedSpans(source: string, open: string, close: string): string[] {
25
+ const spans: string[] = []
26
+ for (let start = source.indexOf(open); start !== -1; ) {
27
+ const end = source.indexOf(close, start + open.length)
28
+ if (end === -1) break
29
+ spans.push(source.slice(start + open.length, end))
30
+ start = source.indexOf(open, end + close.length)
31
+ }
32
+ return spans
33
+ }
34
+
35
+ /** `source` without the spans the global `pattern` matches, each handed to `onMatch` first. */
36
+ export function withoutMatches(
37
+ source: string,
38
+ pattern: RegExp,
39
+ onMatch?: (match: RegExpMatchArray) => void,
40
+ ): string {
41
+ let kept = ""
42
+ let from = 0
43
+ for (const match of source.matchAll(pattern)) {
44
+ onMatch?.(match)
45
+ const start = match.index ?? 0
46
+ kept += source.slice(from, start)
47
+ from = start + match[0].length
48
+ }
49
+ return kept + source.slice(from)
50
+ }