@nifrajs/web 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/README.md +11 -0
  2. package/dist/build-vite.d.ts +19 -0
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +31 -10
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +8 -10
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +90 -31
  9. package/dist/build.js.map +1 -1
  10. package/dist/cdn.d.ts +8 -0
  11. package/dist/cdn.d.ts.map +1 -1
  12. package/dist/cdn.js +14 -15
  13. package/dist/cdn.js.map +1 -1
  14. package/dist/client.d.ts +1 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +25 -7
  17. package/dist/client.js.map +1 -1
  18. package/dist/deferred.d.ts +2 -2
  19. package/dist/deferred.d.ts.map +1 -1
  20. package/dist/deferred.js +28 -7
  21. package/dist/deferred.js.map +1 -1
  22. package/dist/dev-feed.d.ts.map +1 -1
  23. package/dist/dev-feed.js +24 -5
  24. package/dist/dev-feed.js.map +1 -1
  25. package/dist/dev-session.d.ts.map +1 -1
  26. package/dist/dev-session.js +4 -14
  27. package/dist/dev-session.js.map +1 -1
  28. package/dist/dev.d.ts +7 -17
  29. package/dist/dev.d.ts.map +1 -1
  30. package/dist/dev.js +13 -1
  31. package/dist/dev.js.map +1 -1
  32. package/dist/draft.d.ts +8 -5
  33. package/dist/draft.d.ts.map +1 -1
  34. package/dist/draft.js +30 -13
  35. package/dist/draft.js.map +1 -1
  36. package/dist/fonts-google.d.ts.map +1 -1
  37. package/dist/fonts-google.js +7 -0
  38. package/dist/fonts-google.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -2
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/codegen.d.ts +1 -0
  44. package/dist/internal/codegen.d.ts.map +1 -1
  45. package/dist/internal/codegen.js +56 -29
  46. package/dist/internal/codegen.js.map +1 -1
  47. package/dist/internal/disposable-dir.d.ts +8 -0
  48. package/dist/internal/disposable-dir.d.ts.map +1 -0
  49. package/dist/internal/disposable-dir.js +25 -0
  50. package/dist/internal/disposable-dir.js.map +1 -0
  51. package/dist/internal/entry-chunks.d.ts +9 -0
  52. package/dist/internal/entry-chunks.d.ts.map +1 -0
  53. package/dist/internal/entry-chunks.js +15 -0
  54. package/dist/internal/entry-chunks.js.map +1 -0
  55. package/dist/internal/evidence-traversal.d.ts +12 -0
  56. package/dist/internal/evidence-traversal.d.ts.map +1 -0
  57. package/dist/internal/evidence-traversal.js +48 -0
  58. package/dist/internal/evidence-traversal.js.map +1 -0
  59. package/dist/internal/head-merge.d.ts +6 -0
  60. package/dist/internal/head-merge.d.ts.map +1 -1
  61. package/dist/internal/head-merge.js +9 -0
  62. package/dist/internal/head-merge.js.map +1 -1
  63. package/dist/internal/js-string.d.ts +2 -0
  64. package/dist/internal/js-string.d.ts.map +1 -1
  65. package/dist/internal/js-string.js +5 -1
  66. package/dist/internal/js-string.js.map +1 -1
  67. package/dist/internal/mount-shadow.js +1 -1
  68. package/dist/internal/mount-shadow.js.map +1 -1
  69. package/dist/internal/output-guard.d.ts +7 -0
  70. package/dist/internal/output-guard.d.ts.map +1 -1
  71. package/dist/internal/output-guard.js +80 -8
  72. package/dist/internal/output-guard.js.map +1 -1
  73. package/dist/internal/page-execution.d.ts +6 -0
  74. package/dist/internal/page-execution.d.ts.map +1 -1
  75. package/dist/internal/page-execution.js +377 -108
  76. package/dist/internal/page-execution.js.map +1 -1
  77. package/dist/internal/progressive-cookies.d.ts +14 -0
  78. package/dist/internal/progressive-cookies.d.ts.map +1 -0
  79. package/dist/internal/progressive-cookies.js +43 -0
  80. package/dist/internal/progressive-cookies.js.map +1 -0
  81. package/dist/internal/public-env.d.ts +2 -0
  82. package/dist/internal/public-env.d.ts.map +1 -0
  83. package/dist/internal/public-env.js +13 -0
  84. package/dist/internal/public-env.js.map +1 -0
  85. package/dist/internal/render-document.d.ts +1 -0
  86. package/dist/internal/render-document.d.ts.map +1 -1
  87. package/dist/internal/render-document.js +28 -5
  88. package/dist/internal/render-document.js.map +1 -1
  89. package/dist/internal/response-controls.d.ts +2 -0
  90. package/dist/internal/response-controls.d.ts.map +1 -1
  91. package/dist/internal/response-controls.js +26 -16
  92. package/dist/internal/response-controls.js.map +1 -1
  93. package/dist/internal/revive-dates.d.ts +16 -0
  94. package/dist/internal/revive-dates.d.ts.map +1 -0
  95. package/dist/internal/revive-dates.js +32 -0
  96. package/dist/internal/revive-dates.js.map +1 -0
  97. package/dist/internal/route-path.d.ts +8 -0
  98. package/dist/internal/route-path.d.ts.map +1 -0
  99. package/dist/internal/route-path.js +8 -0
  100. package/dist/internal/route-path.js.map +1 -0
  101. package/dist/internal/secret-scan.d.ts.map +1 -1
  102. package/dist/internal/secret-scan.js +88 -20
  103. package/dist/internal/secret-scan.js.map +1 -1
  104. package/dist/internal/server-boundary.d.ts.map +1 -1
  105. package/dist/internal/server-boundary.js +33 -5
  106. package/dist/internal/server-boundary.js.map +1 -1
  107. package/dist/internal/web-app.d.ts +21 -16
  108. package/dist/internal/web-app.d.ts.map +1 -1
  109. package/dist/internal/web-app.js +73 -21
  110. package/dist/internal/web-app.js.map +1 -1
  111. package/dist/internal/wire-date.d.ts +3 -0
  112. package/dist/internal/wire-date.d.ts.map +1 -0
  113. package/dist/internal/wire-date.js +14 -0
  114. package/dist/internal/wire-date.js.map +1 -0
  115. package/dist/internal/zone-graph.d.ts +4 -0
  116. package/dist/internal/zone-graph.d.ts.map +1 -1
  117. package/dist/internal/zone-graph.js +54 -14
  118. package/dist/internal/zone-graph.js.map +1 -1
  119. package/dist/isr.d.ts +58 -2
  120. package/dist/isr.d.ts.map +1 -1
  121. package/dist/isr.js +296 -39
  122. package/dist/isr.js.map +1 -1
  123. package/dist/llms-txt.d.ts +2 -0
  124. package/dist/llms-txt.d.ts.map +1 -1
  125. package/dist/llms-txt.js +29 -3
  126. package/dist/llms-txt.js.map +1 -1
  127. package/dist/manifest.d.ts +28 -4
  128. package/dist/manifest.d.ts.map +1 -1
  129. package/dist/manifest.js +3 -0
  130. package/dist/manifest.js.map +1 -1
  131. package/dist/open-resolved.d.ts +18 -0
  132. package/dist/open-resolved.d.ts.map +1 -0
  133. package/dist/open-resolved.js +62 -0
  134. package/dist/open-resolved.js.map +1 -0
  135. package/dist/plugins/kit.d.ts +1 -0
  136. package/dist/plugins/kit.d.ts.map +1 -1
  137. package/dist/plugins/kit.js +26 -11
  138. package/dist/plugins/kit.js.map +1 -1
  139. package/dist/plugins/vite-leak-guard.d.ts +3 -1
  140. package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
  141. package/dist/plugins/vite-leak-guard.js +4 -0
  142. package/dist/plugins/vite-leak-guard.js.map +1 -1
  143. package/dist/public-dir.d.ts +8 -1
  144. package/dist/public-dir.d.ts.map +1 -1
  145. package/dist/public-dir.js +17 -6
  146. package/dist/public-dir.js.map +1 -1
  147. package/dist/query.d.ts +13 -4
  148. package/dist/query.d.ts.map +1 -1
  149. package/dist/query.js +89 -26
  150. package/dist/query.js.map +1 -1
  151. package/dist/render-seam.d.ts +2 -0
  152. package/dist/render-seam.d.ts.map +1 -1
  153. package/dist/render-seam.js +1 -0
  154. package/dist/render-seam.js.map +1 -1
  155. package/dist/route-types.d.ts.map +1 -1
  156. package/dist/route-types.js +20 -9
  157. package/dist/route-types.js.map +1 -1
  158. package/dist/router.d.ts +9 -2
  159. package/dist/router.d.ts.map +1 -1
  160. package/dist/router.js +39 -46
  161. package/dist/router.js.map +1 -1
  162. package/dist/server-components/build-info.d.ts +3 -0
  163. package/dist/server-components/build-info.d.ts.map +1 -0
  164. package/dist/server-components/build-info.js +7 -0
  165. package/dist/server-components/build-info.js.map +1 -0
  166. package/dist/server-components/build-target.d.ts +12 -0
  167. package/dist/server-components/build-target.d.ts.map +1 -0
  168. package/dist/server-components/build-target.js +153 -0
  169. package/dist/server-components/build-target.js.map +1 -0
  170. package/dist/server-components/build.d.ts +24 -0
  171. package/dist/server-components/build.d.ts.map +1 -0
  172. package/dist/server-components/build.js +366 -0
  173. package/dist/server-components/build.js.map +1 -0
  174. package/dist/server-components/bundle-proof.d.ts +3 -0
  175. package/dist/server-components/bundle-proof.d.ts.map +1 -0
  176. package/dist/server-components/bundle-proof.js +23 -0
  177. package/dist/server-components/bundle-proof.js.map +1 -0
  178. package/dist/server-components/client.d.ts +46 -0
  179. package/dist/server-components/client.d.ts.map +1 -0
  180. package/dist/server-components/client.js +594 -0
  181. package/dist/server-components/client.js.map +1 -0
  182. package/dist/server-components/definition.d.ts +11 -0
  183. package/dist/server-components/definition.d.ts.map +1 -0
  184. package/dist/server-components/definition.js +94 -0
  185. package/dist/server-components/definition.js.map +1 -0
  186. package/dist/server-components/dev-http.d.ts +5 -0
  187. package/dist/server-components/dev-http.d.ts.map +1 -0
  188. package/dist/server-components/dev-http.js +71 -0
  189. package/dist/server-components/dev-http.js.map +1 -0
  190. package/dist/server-components/error.d.ts +9 -0
  191. package/dist/server-components/error.d.ts.map +1 -0
  192. package/dist/server-components/error.js +24 -0
  193. package/dist/server-components/error.js.map +1 -0
  194. package/dist/server-components/frames.d.ts +7 -0
  195. package/dist/server-components/frames.d.ts.map +1 -0
  196. package/dist/server-components/frames.js +169 -0
  197. package/dist/server-components/frames.js.map +1 -0
  198. package/dist/server-components/html-policy.d.ts +6 -0
  199. package/dist/server-components/html-policy.d.ts.map +1 -0
  200. package/dist/server-components/html-policy.js +75 -0
  201. package/dist/server-components/html-policy.js.map +1 -0
  202. package/dist/server-components/html.d.ts +4 -0
  203. package/dist/server-components/html.d.ts.map +1 -0
  204. package/dist/server-components/html.js +67 -0
  205. package/dist/server-components/html.js.map +1 -0
  206. package/dist/server-components/islands.d.ts +44 -0
  207. package/dist/server-components/islands.d.ts.map +1 -0
  208. package/dist/server-components/islands.js +42 -0
  209. package/dist/server-components/islands.js.map +1 -0
  210. package/dist/server-components/native.d.ts +4 -0
  211. package/dist/server-components/native.d.ts.map +1 -0
  212. package/dist/server-components/native.js +81 -0
  213. package/dist/server-components/native.js.map +1 -0
  214. package/dist/server-components/portal.d.ts +6 -0
  215. package/dist/server-components/portal.d.ts.map +1 -0
  216. package/dist/server-components/portal.js +35 -0
  217. package/dist/server-components/portal.js.map +1 -0
  218. package/dist/server-components/props.d.ts +3 -0
  219. package/dist/server-components/props.d.ts.map +1 -0
  220. package/dist/server-components/props.js +14 -0
  221. package/dist/server-components/props.js.map +1 -0
  222. package/dist/server-components/protocol.d.ts +44 -0
  223. package/dist/server-components/protocol.d.ts.map +1 -0
  224. package/dist/server-components/protocol.js +139 -0
  225. package/dist/server-components/protocol.js.map +1 -0
  226. package/dist/server-components/range.d.ts +24 -0
  227. package/dist/server-components/range.d.ts.map +1 -0
  228. package/dist/server-components/range.js +263 -0
  229. package/dist/server-components/range.js.map +1 -0
  230. package/dist/server-components/registry.d.ts +6 -0
  231. package/dist/server-components/registry.d.ts.map +1 -0
  232. package/dist/server-components/registry.js +30 -0
  233. package/dist/server-components/registry.js.map +1 -0
  234. package/dist/server-components/response.d.ts +4 -0
  235. package/dist/server-components/response.d.ts.map +1 -0
  236. package/dist/server-components/response.js +32 -0
  237. package/dist/server-components/response.js.map +1 -0
  238. package/dist/server-components/router-integration.d.ts +9 -0
  239. package/dist/server-components/router-integration.d.ts.map +1 -0
  240. package/dist/server-components/router-integration.js +29 -0
  241. package/dist/server-components/router-integration.js.map +1 -0
  242. package/dist/server-components/runtime.d.ts +14 -0
  243. package/dist/server-components/runtime.d.ts.map +1 -0
  244. package/dist/server-components/runtime.js +276 -0
  245. package/dist/server-components/runtime.js.map +1 -0
  246. package/dist/server-components/scheduler.d.ts +14 -0
  247. package/dist/server-components/scheduler.d.ts.map +1 -0
  248. package/dist/server-components/scheduler.js +74 -0
  249. package/dist/server-components/scheduler.js.map +1 -0
  250. package/dist/server-components/schema.d.ts +4 -0
  251. package/dist/server-components/schema.d.ts.map +1 -0
  252. package/dist/server-components/schema.js +77 -0
  253. package/dist/server-components/schema.js.map +1 -0
  254. package/dist/server-components/server-entry.d.ts +12 -0
  255. package/dist/server-components/server-entry.d.ts.map +1 -0
  256. package/dist/server-components/server-entry.js +90 -0
  257. package/dist/server-components/server-entry.js.map +1 -0
  258. package/dist/server-components/source.d.ts +15 -0
  259. package/dist/server-components/source.d.ts.map +1 -0
  260. package/dist/server-components/source.js +269 -0
  261. package/dist/server-components/source.js.map +1 -0
  262. package/dist/server-components/state.d.ts +4 -0
  263. package/dist/server-components/state.d.ts.map +1 -0
  264. package/dist/server-components/state.js +123 -0
  265. package/dist/server-components/state.js.map +1 -0
  266. package/dist/server-components/stream.d.ts +6 -0
  267. package/dist/server-components/stream.d.ts.map +1 -0
  268. package/dist/server-components/stream.js +70 -0
  269. package/dist/server-components/stream.js.map +1 -0
  270. package/dist/server-components/trusted-html.d.ts +3 -0
  271. package/dist/server-components/trusted-html.d.ts.map +1 -0
  272. package/dist/server-components/trusted-html.js +14 -0
  273. package/dist/server-components/trusted-html.js.map +1 -0
  274. package/dist/server-components/types.d.ts +107 -0
  275. package/dist/server-components/types.d.ts.map +1 -0
  276. package/dist/server-components/types.js +2 -0
  277. package/dist/server-components/types.js.map +1 -0
  278. package/dist/server-components/typescript.d.ts +11 -0
  279. package/dist/server-components/typescript.d.ts.map +1 -0
  280. package/dist/server-components/typescript.js +28 -0
  281. package/dist/server-components/typescript.js.map +1 -0
  282. package/dist/server-components/value.d.ts +5 -0
  283. package/dist/server-components/value.d.ts.map +1 -0
  284. package/dist/server-components/value.js +77 -0
  285. package/dist/server-components/value.js.map +1 -0
  286. package/dist/server-components/vite.d.ts +12 -0
  287. package/dist/server-components/vite.d.ts.map +1 -0
  288. package/dist/server-components/vite.js +197 -0
  289. package/dist/server-components/vite.js.map +1 -0
  290. package/dist/server-components.d.ts +5 -0
  291. package/dist/server-components.d.ts.map +1 -0
  292. package/dist/server-components.js +4 -0
  293. package/dist/server-components.js.map +1 -0
  294. package/dist/service-worker.d.ts +8 -1
  295. package/dist/service-worker.d.ts.map +1 -1
  296. package/dist/service-worker.js +20 -6
  297. package/dist/service-worker.js.map +1 -1
  298. package/dist/vite.d.ts +12 -0
  299. package/dist/vite.d.ts.map +1 -1
  300. package/dist/vite.js +7 -1
  301. package/dist/vite.js.map +1 -1
  302. package/dist/zones.d.ts +40 -0
  303. package/dist/zones.d.ts.map +1 -1
  304. package/dist/zones.js +170 -6
  305. package/dist/zones.js.map +1 -1
  306. package/package.json +97 -4
  307. package/src/build-vite.ts +31 -9
  308. package/src/build.ts +109 -31
  309. package/src/cdn.ts +18 -12
  310. package/src/client.ts +25 -6
  311. package/src/deferred.ts +27 -9
  312. package/src/dev-feed.ts +24 -5
  313. package/src/dev-session.ts +4 -13
  314. package/src/dev.ts +29 -1
  315. package/src/draft.ts +35 -14
  316. package/src/fonts-google.ts +10 -0
  317. package/src/index.ts +3 -2
  318. package/src/internal/codegen.ts +60 -29
  319. package/src/internal/disposable-dir.ts +24 -0
  320. package/src/internal/entry-chunks.ts +18 -0
  321. package/src/internal/evidence-traversal.ts +57 -0
  322. package/src/internal/head-merge.ts +16 -0
  323. package/src/internal/js-string.ts +6 -1
  324. package/src/internal/mount-shadow.ts +1 -1
  325. package/src/internal/output-guard.ts +88 -7
  326. package/src/internal/page-execution.ts +506 -108
  327. package/src/internal/progressive-cookies.ts +60 -0
  328. package/src/internal/public-env.ts +14 -0
  329. package/src/internal/render-document.ts +26 -11
  330. package/src/internal/response-controls.ts +31 -14
  331. package/src/internal/revive-dates.ts +35 -0
  332. package/src/internal/route-path.ts +8 -0
  333. package/src/internal/secret-scan.ts +90 -18
  334. package/src/internal/server-boundary.ts +29 -5
  335. package/src/internal/web-app.ts +116 -37
  336. package/src/internal/wire-date.ts +16 -0
  337. package/src/internal/zone-graph.ts +59 -19
  338. package/src/isr.ts +351 -43
  339. package/src/llms-txt.ts +32 -3
  340. package/src/manifest.ts +28 -4
  341. package/src/open-resolved.ts +64 -0
  342. package/src/plugins/kit.ts +31 -11
  343. package/src/plugins/vite-leak-guard.ts +5 -1
  344. package/src/public-dir.ts +27 -8
  345. package/src/query.ts +107 -26
  346. package/src/render-seam.ts +2 -0
  347. package/src/route-types.ts +34 -7
  348. package/src/router.ts +58 -55
  349. package/src/server-components/build-info.ts +11 -0
  350. package/src/server-components/build-target.ts +194 -0
  351. package/src/server-components/build.ts +419 -0
  352. package/src/server-components/bundle-proof.ts +33 -0
  353. package/src/server-components/client.ts +663 -0
  354. package/src/server-components/definition.ts +125 -0
  355. package/src/server-components/dev-http.ts +75 -0
  356. package/src/server-components/error.ts +24 -0
  357. package/src/server-components/frames.ts +181 -0
  358. package/src/server-components/html-policy.ts +94 -0
  359. package/src/server-components/html.ts +81 -0
  360. package/src/server-components/islands.ts +111 -0
  361. package/src/server-components/native.ts +101 -0
  362. package/src/server-components/portal.ts +42 -0
  363. package/src/server-components/props.ts +19 -0
  364. package/src/server-components/protocol.ts +167 -0
  365. package/src/server-components/range.ts +313 -0
  366. package/src/server-components/registry.ts +34 -0
  367. package/src/server-components/response.ts +44 -0
  368. package/src/server-components/router-integration.ts +40 -0
  369. package/src/server-components/runtime.ts +376 -0
  370. package/src/server-components/scheduler.ts +84 -0
  371. package/src/server-components/schema.ts +69 -0
  372. package/src/server-components/server-entry.ts +98 -0
  373. package/src/server-components/source.ts +324 -0
  374. package/src/server-components/state.ts +143 -0
  375. package/src/server-components/stream.ts +71 -0
  376. package/src/server-components/trusted-html.ts +21 -0
  377. package/src/server-components/types.ts +138 -0
  378. package/src/server-components/typescript.ts +39 -0
  379. package/src/server-components/value.ts +82 -0
  380. package/src/server-components/vite.ts +242 -0
  381. package/src/server-components.ts +32 -0
  382. package/src/service-worker.ts +24 -7
  383. package/src/vite.ts +8 -1
  384. package/src/zones.ts +208 -7
@@ -4,6 +4,7 @@ import type { LayoutEntry, Manifest } from "../manifest.ts"
4
4
  import {
5
5
  ACTION_GLOBAL,
6
6
  BOUNDARY_GLOBAL,
7
+ COMPONENT_GLOBAL,
7
8
  DATA_GLOBAL,
8
9
  HANDOVER_ID,
9
10
  LAYOUT_DATA_GLOBAL,
@@ -11,7 +12,7 @@ import {
11
12
  ROUTE_GLOBAL,
12
13
  } from "../render-seam.ts"
13
14
  import { PRERENDERED_GLOBAL } from "../router.ts"
14
- import { jsStringLiteral } from "./js-string.ts"
15
+ import { jsStringLiteral, jsValueLiteral } from "./js-string.ts"
15
16
 
16
17
  /** The globals the page-state handover may set - nothing else in it reaches `window`. */
17
18
  const HANDOVER_GLOBALS = [
@@ -20,6 +21,7 @@ const HANDOVER_GLOBALS = [
20
21
  ROUTE_GLOBAL,
21
22
  ACTION_GLOBAL,
22
23
  BOUNDARY_GLOBAL,
24
+ COMPONENT_GLOBAL,
23
25
  PRERENDERED_GLOBAL,
24
26
  ]
25
27
  export interface GenerateClientEntryOptions {
@@ -36,6 +38,7 @@ export interface GenerateClientEntryOptions {
36
38
  readonly clientModule: string
37
39
  /** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
38
40
  readonly resolve: (file: string) => string
41
+ readonly serverComponents?: boolean
39
42
  }
40
43
 
41
44
  /**
@@ -81,21 +84,43 @@ export function generateClientEntry(
81
84
  files.push(errorFile)
82
85
  errorRouteIds.push(route.id)
83
86
  }
84
- loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`)
87
+ loaderRows.push(` ${jsStringLiteral(route.id)}: ${lazyLoader(files)},`)
85
88
  if (route.layoutIds.length > 0) layoutIdsOf[route.id] = route.layoutIds
86
89
  patternRows.push(
87
- ` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`,
90
+ ` { routeId: ${jsStringLiteral(route.id)}, pattern: ${jsStringLiteral(route.pattern)} },`,
88
91
  )
89
92
  }
93
+ // A root status page renders inside the root layout unless it exports `inLayouts = false`, which
94
+ // only its module says - so the layout loads after the page, and only for a page that does not opt out.
95
+ const rootLayout = manifest.layouts._layout
96
+ const statusLoader = (routeId: string, file: string): string =>
97
+ rootLayout === undefined
98
+ ? lazyLoader([file])
99
+ : `() => import(${jsStringLiteral(resolve(file))}).then((page) => withRootLayout(${jsStringLiteral(routeId)}, page))`
90
100
  if (manifest.notFound !== undefined) {
91
- loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
101
+ loaderRows.push(` "_404": ${statusLoader("_404", manifest.notFound.file)},`)
92
102
  statusRoutes[404] = "_404"
93
103
  }
94
104
  for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
95
105
  const routeId = `_${status}`
96
- loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
106
+ loaderRows.push(` ${jsStringLiteral(routeId)}: ${statusLoader(routeId, entry.file)},`)
97
107
  statusRoutes[Number(status)] = routeId
98
108
  }
109
+ const layoutStatusLines =
110
+ rootLayout === undefined || Object.keys(statusRoutes).length === 0
111
+ ? []
112
+ : [
113
+ "const layoutStatusRoutes = {}",
114
+ "const withRootLayout = (id, page) => {",
115
+ " if (page.inLayouts === false) return [page]",
116
+ ` return import(${jsStringLiteral(resolve(rootLayout.file))}).then((layout) => {`,
117
+ " layoutStatusRoutes[id] = true",
118
+ ' layoutIdsOf[id] = ["_layout"]',
119
+ " return [layout, page]",
120
+ " })",
121
+ "}",
122
+ ]
123
+ const routerStatusOption = layoutStatusLines.length === 0 ? "" : ", layoutStatusRoutes"
99
124
  // `_loading` pages. Emitted only when the app has one, so an app without them imports nothing extra.
100
125
  // Each is its own lazy chunk; a route row carries its layout ids and the loading pages above it with
101
126
  // the number of those layouts each sits under - all the runtime needs to choose one.
@@ -110,14 +135,14 @@ export function generateClientEntry(
110
135
  ])
111
136
  routeRows.set(
112
137
  route.id,
113
- ` ${JSON.stringify(route.id)}: ${JSON.stringify([route.layoutIds, above])},`,
138
+ ` ${jsStringLiteral(route.id)}: ${jsValueLiteral([route.layoutIds, above])},`,
114
139
  )
115
140
  }
116
141
  loadingLines.push(
117
142
  "const loadingModules = {",
118
143
  ...loadings.map(
119
144
  ([id, entry]) =>
120
- ` ${JSON.stringify(id)}: () => import(${jsStringLiteral(resolve(entry.file))}),`,
145
+ ` ${jsStringLiteral(id)}: () => import(${jsStringLiteral(resolve(entry.file))}),`,
121
146
  ),
122
147
  "}",
123
148
  "const loadingRoutes = {",
@@ -129,14 +154,17 @@ export function generateClientEntry(
129
154
  return `${[
130
155
  // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
131
156
  // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
132
- 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
157
+ 'import { createClientRouter, createMatcher, mergeHeads, resolveChainMeta } from "@nifrajs/web/client"',
133
158
  'import { applyHead, currentDocumentNonce, installForms, installHistory, signalHydrated, waitForStyles } from "@nifrajs/web/client"',
159
+ ...(options.serverComponents
160
+ ? ['import { bindComponentRouter } from "@nifrajs/web/server-components/client"']
161
+ : []),
134
162
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
135
163
  // access yields `undefined` if absent - unlike a named import, which would be a link error.
136
164
  ...(loadings.length > 0
137
165
  ? ['import { withLoading } from "@nifrajs/web/internal/loading-runtime"']
138
166
  : []),
139
- `import * as __adapter from ${JSON.stringify(clientModule)}`,
167
+ `import * as __adapter from ${jsStringLiteral(clientModule)}`,
140
168
  "const { mountRouter } = __adapter",
141
169
  // The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
142
170
  // hydration runners get a runtime identity from the exact adapter module that mounted the page. The
@@ -152,8 +180,8 @@ export function generateClientEntry(
152
180
  // names actually exports `mountRouter`. Without this the miss surfaces as
153
181
  // "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
154
182
  // module nor the requirement - a self-executing entry passes every build and fails only here.
155
- `if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
156
- `[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
183
+ `if (typeof mountRouter !== "function") throw new Error(${jsValueLiteral(
184
+ `[nifra/web] clientModule ${jsStringLiteral(clientModule)} does not export \`mountRouter\`. ` +
157
185
  "A client module must export `mountRouter({ router, routes, container })` - it is called by " +
158
186
  "the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
159
187
  '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
@@ -169,10 +197,11 @@ export function generateClientEntry(
169
197
  // to act on. An adapter that knows its framework is in that position says so, and the entry turns
170
198
  // the swallowed update into an honest full reload.
171
199
  'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
172
- `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
200
+ `const errorRouteIds = new Set(${jsValueLiteral(errorRouteIds)})`,
173
201
  // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
174
202
  // route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
175
203
  // visited. loadModule caches the [layouts…, page] component chain + the chain's meta list per id.
204
+ ...layoutStatusLines,
176
205
  "const loaders = {",
177
206
  ...loaderRows,
178
207
  "}",
@@ -191,7 +220,7 @@ export function generateClientEntry(
191
220
  // boundary), with the page's `HydrateFallback` - or an empty leaf - where the component goes.
192
221
  "const holds = {}",
193
222
  // routeId → the ids and `handle` exports `useMatches` reports (layouts, then the page).
194
- `const layoutIdsOf = ${JSON.stringify(layoutIdsOf)}`,
223
+ `const layoutIdsOf = ${jsValueLiteral(layoutIdsOf)}`,
195
224
  "const matchChains = {}",
196
225
  "const loadModule = async (id) => {",
197
226
  " if (chains[id]) return",
@@ -235,11 +264,11 @@ export function generateClientEntry(
235
264
  // router, the deferred mapper and the adapters read, before any of them runs. Only the LAST
236
265
  // matching <script> counts, and only known keys: page HTML (sanitized user content) can carry an
237
266
  // 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}"]`)})`,
267
+ `const handovers = document.querySelectorAll(${jsValueLiteral(`script[type="application/json"][id="${HANDOVER_ID}"]`)})`,
239
268
  "const handover = handovers[handovers.length - 1]",
240
269
  "if (handover !== undefined) {",
241
270
  ' const state = JSON.parse(handover.textContent || "{}")',
242
- ` for (const key of ${JSON.stringify(HANDOVER_GLOBALS)}) {`,
271
+ ` for (const key of ${jsValueLiteral(HANDOVER_GLOBALS)}) {`,
243
272
  " if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
244
273
  " }",
245
274
  "}",
@@ -249,7 +278,7 @@ export function generateClientEntry(
249
278
  // Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
250
279
  // server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
251
280
  "const matched = createMatcher(patterns)(location.pathname)",
252
- `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
281
+ `const statusRoutes = ${jsValueLiteral(statusRoutes)}`,
253
282
  `const serverRoute = window.${ROUTE_GLOBAL} ?? ""`,
254
283
  // A terminal status page the server rendered (`_404`, `_410`, ...) outranks the URL match: a
255
284
  // loader that throws notFound() on `/learn/bad-slug` leaves a URL that still matches
@@ -270,6 +299,7 @@ export function generateClientEntry(
270
299
  // Undefined on a page-only app, which is exactly what the router treats as "no layout data".
271
300
  ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
272
301
  ` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
302
+ ` serverComponents: window.${COMPONENT_GLOBAL},`,
273
303
  // actionData (only set after a form POST) is in the initial state so the binding hydrates
274
304
  // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
275
305
  // action's placeholders become registry markers (a no-op when the action didn't defer).
@@ -280,8 +310,9 @@ export function generateClientEntry(
280
310
  // With `_loading` pages the store is wrapped: a slow navigation then names a loading chain (the
281
311
  // shared layouts + the `_loading` component) for the mounted view to render in the meantime.
282
312
  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 })",
313
+ ? `const router = withLoading(createClientRouter({ patterns, initial, loadModule, statusRoutes${routerStatusOption}, searchClientKeys, routeHooks }), { routes: loadingRoutes, modules: loadingModules, chains, searchSchemas, matchChains })`
314
+ : `const router = createClientRouter({ patterns, initial, loadModule, statusRoutes${routerStatusOption}, searchClientKeys, routeHooks })`,
315
+ ...(options.serverComponents ? ["bindComponentRouter(router)"] : []),
285
316
  "installHistory(router)",
286
317
  "installForms(router)",
287
318
  // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
@@ -292,7 +323,7 @@ export function generateClientEntry(
292
323
  `const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
293
324
  // Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
294
325
  // opened a container in, so reaching here means the document is not the one it was built for.
295
- `if (!root) throw new Error(${JSON.stringify(
326
+ `if (!root) throw new Error(${jsValueLiteral(
296
327
  "[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
297
328
  `renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
298
329
  "is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
@@ -351,8 +382,8 @@ export function generateClientEntry(
351
382
  // `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
352
383
  // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
353
384
  " const nonce = currentDocumentNonce()",
354
- " const args = { data: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
355
- " applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
385
+ " const args = { data: s.data, pageData: s.data, params: s.params, origin: location.origin, ...(nonce === undefined ? {} : { nonce }) }",
386
+ " applyHead(mergeHeads(resolveChainMeta(metas[s.routeId] ?? [undefined], s.layoutData, args)))",
356
387
  " }",
357
388
  " })",
358
389
  "}).catch((error) => console.error('[nifra/web] hydration failed:', error))",
@@ -391,13 +422,13 @@ export function generateRouteSearchTypes(
391
422
  .sort((a, b) => (a[0] < b[0] ? -1 : 1))
392
423
  .map(
393
424
  ([pattern, spec]) =>
394
- ` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`,
425
+ ` ${jsStringLiteral(pattern)}: SearchOf<typeof import(${jsStringLiteral(spec)})>`,
395
426
  )
396
427
  return `${[
397
428
  "// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
398
429
  "// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
399
430
  'import type { SearchOf } from "@nifrajs/web"',
400
- `declare module ${JSON.stringify(module)} {`,
431
+ `declare module ${jsStringLiteral(module)} {`,
401
432
  " interface RouteSearch {",
402
433
  ...entries,
403
434
  " }",
@@ -504,12 +535,12 @@ export function generateServerManifest(
504
535
  "// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
505
536
  'import { buildManifest, type RouteModule } from "@nifrajs/web"',
506
537
  ]
507
- const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
538
+ const clientEntryLine = `export const clientEntry = ${jsStringLiteral(clientEntry)}`
508
539
  // Baked stylesheet URLs so the server entry can pass them to createWebApp → <link rel="stylesheet">.
509
540
  // Always emitted (default empty) so consumers can `import { styles, routeStyles }` unconditionally.
510
- const stylesLine = `export const styles = ${JSON.stringify(styles ?? [])}`
511
- const routeStylesLine = `export const routeStyles = ${JSON.stringify(routeStyles ?? {})}`
512
- const cssLoadingLine = `export const cssLoading = ${JSON.stringify(cssLoading)}`
541
+ const stylesLine = `export const styles = ${jsValueLiteral(styles ?? [])}`
542
+ const routeStylesLine = `export const routeStyles = ${jsValueLiteral(routeStyles ?? {})}`
543
+ const cssLoadingLine = `export const cssLoading = ${jsStringLiteral(cssLoading)}`
513
544
  if (lazy) {
514
545
  // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
515
546
  // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
@@ -533,9 +564,9 @@ export function generateServerManifest(
533
564
  // Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
534
565
  // identifiers are collision-proof regardless of filename.
535
566
  const imports = files.map(
536
- (file, i) => `import * as m${i} from ${JSON.stringify(moduleSpecifier(resolve(file)))}`,
567
+ (file, i) => `import * as m${i} from ${jsStringLiteral(moduleSpecifier(resolve(file)))}`,
537
568
  )
538
- const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
569
+ const entries = files.map((file, i) => ` ${jsStringLiteral(file)}: m${i},`)
539
570
  return `${[
540
571
  ...header,
541
572
  ...imports,
@@ -0,0 +1,24 @@
1
+ import { existsSync, realpathSync } from "node:fs"
2
+ import { isAbsolute, parse, relative, sep } from "node:path"
3
+
4
+ /**
5
+ * Throws unless `dir` is safe to delete recursively: not a filesystem root, and neither equal to nor
6
+ * an ancestor of any path in `keep` (the sources a build reads, the working directory). Both sides are
7
+ * compared after resolving symlinks, so a link pointing at the project is refused too. A `dir` that
8
+ * does not exist yet is always safe.
9
+ */
10
+ export function assertDisposableDir(option: string, dir: string, keep: readonly string[]): void {
11
+ if (!existsSync(dir)) return
12
+ const real = realpathSync.native(dir)
13
+ if (parse(real).root === real)
14
+ throw new Error(`[nifra/web] ${option} (${dir}) is a filesystem root; refusing to clear it.`)
15
+ for (const path of keep) {
16
+ if (!existsSync(path)) continue
17
+ const rel = relative(real, realpathSync.native(path))
18
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) continue
19
+ throw new Error(
20
+ `[nifra/web] ${option} (${dir}) ${rel === "" ? "is" : "contains"} ${path}; the build clears ` +
21
+ `${option} first, which would delete it. Point ${option} at a directory of its own.`,
22
+ )
23
+ }
24
+ }
@@ -0,0 +1,18 @@
1
+ import { resolve } from "node:path"
2
+
3
+ /**
4
+ * Each entry's emitted JS chunk, keyed by the absolute source path the build metafile records for it.
5
+ * Bun's API does not promise that `outputs` follow `entrypoints` order, so a route's preload is read
6
+ * from the output that names the route's own source as its entry, never from a position.
7
+ */
8
+ export function entryChunks(
9
+ outputs: Readonly<Record<string, { readonly entryPoint?: string }>>,
10
+ cwd: string,
11
+ ): ReadonlyMap<string, string> {
12
+ const chunks = new Map<string, string>()
13
+ for (const [path, output] of Object.entries(outputs)) {
14
+ if (output.entryPoint !== undefined && /\.[cm]?js$/.test(path))
15
+ chunks.set(resolve(cwd, output.entryPoint), path)
16
+ }
17
+ return chunks
18
+ }
@@ -0,0 +1,57 @@
1
+ import type { AsyncLocalStorage } from "node:async_hooks"
2
+ import type { BackendEvidenceProvider } from "@nifrajs/core/mount"
3
+
4
+ /** The providers composing the current evidence read, outermost first. */
5
+ export type EvidenceAncestry = ReadonlySet<BackendEvidenceProvider>
6
+
7
+ // Looked up, never imported: a self-contained Worker bundle hoists an import of node:async_hooks,
8
+ // and a Worker on an older compatibility date without nodejs_compat then fails to start. Created
9
+ // on the first evidence read, so serving an app never constructs an AsyncLocalStorage.
10
+ let storage: AsyncLocalStorage<EvidenceAncestry> | undefined
11
+
12
+ // Without async context, a cycle through a provider that drops the chain reads like concurrent
13
+ // reads. A nested read never settles before its children, so such a cycle piles up in-flight reads
14
+ // of one provider without bound, while real concurrency stays at a handful (llms.txt memoizes).
15
+ const MAX_UNTRACKED_READS = 64
16
+ const untrackedReads = new WeakMap<BackendEvidenceProvider, number>()
17
+
18
+ function ancestryStorage(): AsyncLocalStorage<EvidenceAncestry> | undefined {
19
+ const hooks = globalThis.process?.getBuiltinModule?.("node:async_hooks")
20
+ if (hooks?.AsyncLocalStorage === undefined) return undefined
21
+ storage ??= new hooks.AsyncLocalStorage()
22
+ return storage
23
+ }
24
+
25
+ /**
26
+ * Run `read` with `provider` joined to the providers already composing this read, refusing a
27
+ * provider that is its own ancestor. The async context follows a cycle through any provider; the
28
+ * `passed` chain, which framework providers hand to the providers they compose, is the only record
29
+ * on a runtime without AsyncLocalStorage, where a cap on in-flight reads of one provider stops a
30
+ * cycle the chain cannot see.
31
+ */
32
+ export async function traverseEvidence<T>(
33
+ provider: BackendEvidenceProvider,
34
+ passed: unknown,
35
+ read: (ancestry: EvidenceAncestry) => Promise<T>,
36
+ ): Promise<T> {
37
+ const context = ancestryStorage()
38
+ const parents = new Set(context?.getStore())
39
+ if (passed instanceof Set) for (const parent of passed) parents.add(parent)
40
+ if (parents.has(provider)) {
41
+ throw new Error("[nifra/web] cyclic composed assurance evidence mount")
42
+ }
43
+ const ancestry: EvidenceAncestry = parents.add(provider)
44
+ if (context !== undefined) return context.run(ancestry, read, ancestry)
45
+ const reads = untrackedReads.get(provider) ?? 0
46
+ if (reads >= MAX_UNTRACKED_READS) {
47
+ throw new Error(
48
+ `[nifra/web] cyclic composed assurance evidence mount: more than ${MAX_UNTRACKED_READS} reads of one app in flight. This runtime has no AsyncLocalStorage (on Cloudflare, enable nodejs_compat), so a cycle through a custom provider is caught only by this cap.`,
49
+ )
50
+ }
51
+ untrackedReads.set(provider, reads + 1)
52
+ try {
53
+ return await read(ancestry)
54
+ } finally {
55
+ untrackedReads.set(provider, (untrackedReads.get(provider) ?? 1) - 1)
56
+ }
57
+ }
@@ -45,6 +45,22 @@ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
45
45
  return meta
46
46
  }
47
47
 
48
+ /**
49
+ * Resolve a route's `meta` chain, outermost layout first and the page last. The page's `meta` gets
50
+ * `args`; a layout's gets its own loader data as `data` (`null` when it has no loader), the value its
51
+ * component renders. The SSR head and a soft navigation both resolve through this, so they match.
52
+ */
53
+ export function resolveChainMeta(
54
+ metas: readonly (MetaInput | undefined)[],
55
+ layoutData: readonly unknown[] | undefined,
56
+ args: MetaArgs,
57
+ ): Meta[] {
58
+ const last = metas.length - 1
59
+ return metas.map((meta, i) =>
60
+ resolveMeta(meta, i < last ? { ...args, data: layoutData?.[i] ?? null } : args),
61
+ )
62
+ }
63
+
48
64
  /**
49
65
  * Merge a layout chain's heads, outermost first: `title`/`lang`/`dir` are nearest-wins, and
50
66
  * `meta`/`link`/`script` concatenate in chain order.
@@ -12,7 +12,12 @@ const LINE_SEPARATOR = String.fromCharCode(0x2028)
12
12
  const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029)
13
13
 
14
14
  export function jsStringLiteral(value: string): string {
15
- return JSON.stringify(value)
15
+ return jsValueLiteral(value)
16
+ }
17
+
18
+ /** A JSON value embedded in generated source, with the same script-boundary protection. */
19
+ export function jsValueLiteral(value: unknown): string {
20
+ return (JSON.stringify(value) ?? "undefined")
16
21
  .replaceAll("<", "\\u003C")
17
22
  .replaceAll(LINE_SEPARATOR, "\\u2028")
18
23
  .replaceAll(PARAGRAPH_SEPARATOR, "\\u2029")
@@ -83,6 +83,6 @@ export function formatShadowedPages(pages: readonly ShadowedPage[]): string {
83
83
  return (
84
84
  `${pages.length === 1 ? "1 page route" : `${pages.length} page routes`} can never render: a mount answers every request under its path before page routing, and its 404 is final.\n` +
85
85
  `${lines.join("\n")}\n` +
86
- 'Move each file out of the mounted path, or serve that URL from the mounted app. The backend\'s mount path is `apiPrefix` (default "/api"; export it from framework.ts, or set it to "" to turn the mount off).'
86
+ "Move each file out of the mounted path, or serve that URL from the mounted app. The backend's mount path is the `apiPrefix` exported from framework.ts; without one the backend is not mounted."
87
87
  )
88
88
  }
@@ -14,6 +14,7 @@
14
14
  * its own `validate` output, as zod and valibot objects do by default.
15
15
  */
16
16
  import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
17
+ import { wireDate } from "./wire-date.ts"
17
18
 
18
19
  // Registry symbols shared with core and `@nifrajs/schema`, so two copies of either still agree.
19
20
  const RESPONSE_RESULT = Symbol.for("nifra.response.result")
@@ -23,6 +24,7 @@ const DEFERRED_VALIDATE = Symbol.for("nifra.schema.deferredValidate")
23
24
 
24
25
  export const DECLASSIFIED_KEY = "x-nifra-declassified"
25
26
  const DEFERRED_KEY = "x-nifra-deferred"
27
+ const DATE_KEY = "x-nifra-date"
26
28
 
27
29
  /** A field name that usually holds a credential or personal identifier. */
28
30
  export function isSensitiveFieldName(name: string): boolean {
@@ -80,6 +82,8 @@ interface CompiledGuard {
80
82
  /** Undefined when the schema exposes no JSON Schema to project by. */
81
83
  readonly project: Project | undefined
82
84
  readonly validate: StandardSchemaV1["~standard"]["validate"]
85
+ /** Dev only: name the keys the guard dropped, so a schema missing a field is not a silent blank. */
86
+ readonly warnDropped: boolean
83
87
  }
84
88
 
85
89
  interface Deferred {
@@ -101,6 +105,10 @@ const isPlain = (value: object): boolean => {
101
105
  return proto === Object.prototype || proto === null
102
106
  }
103
107
 
108
+ // Read once per schema at route load, never per request.
109
+ const inDevelopment = (): boolean =>
110
+ typeof process !== "undefined" && process.env?.NODE_ENV === "development"
111
+
104
112
  /** Re-root the issues children pushed since `from` under `key`. Runs only on failure. */
105
113
  function prefix(state: RunState, from: number, key: string | number): void {
106
114
  for (let i = from; i < state.issues.length; i++) state.issues[i] = `/${key}${state.issues[i]}`
@@ -142,6 +150,20 @@ const UNKNOWN: Project = (value, state) => {
142
150
  */
143
151
  const LEAF: Project = (value) => (value instanceof Date ? value.toJSON() : value)
144
152
 
153
+ const ISO_DATE =
154
+ /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2})?)?$/
155
+ // An invalid Date stays as it is, so the schema's own check refuses it by path.
156
+ const toWireDate = (value: Date): unknown =>
157
+ Number.isNaN(value.getTime()) ? value : wireDate(value.getTime())
158
+
159
+ /** A `t.date()` field: a `Date` or an ISO 8601 string becomes a tagged `Date` the browser rebuilds. */
160
+ const DATE: Project = (value) =>
161
+ value instanceof Date
162
+ ? toWireDate(value)
163
+ : typeof value === "string" && ISO_DATE.test(value)
164
+ ? toWireDate(new Date(value))
165
+ : value
166
+
145
167
  /** An array whose items are all scalars, passed through whole. */
146
168
  const LEAF_ARRAY: Project = (value) => value
147
169
 
@@ -151,8 +173,11 @@ const typesOf = (node: Node): readonly unknown[] | undefined =>
151
173
  const COMBINATORS = ["anyOf", "oneOf", "allOf"] as const
152
174
 
153
175
  /** What a schema node (with no `$ref` or combinator) projects as. */
154
- function shapeOf(node: Node): "deferred" | "object" | "array" | "both" | "leaf" | "unknown" {
176
+ function shapeOf(
177
+ node: Node,
178
+ ): "deferred" | "date" | "object" | "array" | "both" | "leaf" | "unknown" {
155
179
  if (node[DEFERRED_KEY] === true) return "deferred"
180
+ if (node[DATE_KEY] === true) return "date"
156
181
  const types = typesOf(node)
157
182
  const object =
158
183
  types?.includes("object") === true ||
@@ -229,6 +254,8 @@ class Compiler {
229
254
  switch (shapeOf(node)) {
230
255
  case "deferred":
231
256
  return this.deferred(node, where)
257
+ case "date":
258
+ return DATE
232
259
  case "both":
233
260
  return this.dispatch(this.object(node, where), this.array(node, where), undefined)
234
261
  case "object":
@@ -411,6 +438,8 @@ class Compiler {
411
438
  objects.length === 0 ? undefined : this.object(mergeObjects(objects), where),
412
439
  arrays.length === 0 ? undefined : this.array(mergeArrays(arrays), where),
413
440
  deferred === undefined ? undefined : this.compile(deferred, where),
441
+ // A union's string branch makes an ISO string ambiguous, so only a Date becomes a date here.
442
+ kinds.includes("date"),
414
443
  )
415
444
  }
416
445
 
@@ -418,10 +447,11 @@ class Compiler {
418
447
  object: Project | undefined,
419
448
  array: Project | undefined,
420
449
  deferred: Project | undefined,
450
+ date = false,
421
451
  ): Project {
422
452
  return (value, state) => {
423
453
  if (typeof value !== "object" || value === null) return value
424
- if (value instanceof Date) return value.toJSON()
454
+ if (value instanceof Date) return date ? toWireDate(value) : value.toJSON()
425
455
  if (Array.isArray(value)) return array === undefined ? value : array(value, state)
426
456
  if (isDeferred(value)) {
427
457
  if (deferred !== undefined) return deferred(value, state)
@@ -442,6 +472,7 @@ class Compiler {
442
472
  const inner: CompiledGuard = {
443
473
  project: this.compile(node.inner, where),
444
474
  validate: validate as CompiledGuard["validate"],
475
+ warnDropped: inDevelopment(),
445
476
  }
446
477
  return (value, state) => {
447
478
  if (!isDeferred(value)) return value
@@ -526,7 +557,7 @@ function mergeArrays(branches: readonly Node[]): Node {
526
557
  }
527
558
 
528
559
  /** The JSON Schema a schema exposes to project by, if any. */
529
- function jsonSchemaOf(schema: StandardSchemaV1): Node | undefined {
560
+ export function jsonSchemaOf(schema: StandardSchemaV1): Node | undefined {
530
561
  const own = (schema as { readonly jsonSchema?: unknown }).jsonSchema
531
562
  if (isNode(own)) return own
532
563
  const standard = schema["~standard"] as {
@@ -570,11 +601,52 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
570
601
  )
571
602
  }
572
603
  }
573
- const guard: CompiledGuard = { project, validate: standard["~standard"].validate }
604
+ const guard: CompiledGuard = {
605
+ project,
606
+ validate: standard["~standard"].validate,
607
+ warnDropped: inDevelopment(),
608
+ }
574
609
  compiled.set(schema, guard)
575
610
  return guard
576
611
  }
577
612
 
613
+ /** Where `before` held a key `after` lacks. Array indices collapse to `*`, so one field reads once. */
614
+ function droppedPaths(before: unknown, after: unknown, path: string, out: Set<string>): void {
615
+ if (before === after || typeof before !== "object" || before === null) return
616
+ if (typeof after !== "object" || after === null || before instanceof Date) return
617
+ if (Array.isArray(before)) {
618
+ if (!Array.isArray(after)) return
619
+ const length = Math.min(before.length, after.length)
620
+ for (let i = 0; i < length; i++) droppedPaths(before[i], after[i], `${path}/*`, out)
621
+ return
622
+ }
623
+ const kept: Readonly<Record<string, unknown>> = Object.fromEntries(Object.entries(after))
624
+ for (const [key, value] of Object.entries(before)) {
625
+ // JSON drops an undefined value anyway; the guard dropping it changes nothing the page sees.
626
+ if (value === undefined) continue
627
+ if (!Object.hasOwn(kept, key)) out.add(`${path}/${key}`)
628
+ else droppedPaths(value, kept[key], `${path}/${key}`, out)
629
+ }
630
+ }
631
+
632
+ const warnedDrops = new Set<string>()
633
+
634
+ function warnDropped(label: string, before: unknown, after: unknown): void {
635
+ const found = new Set<string>()
636
+ droppedPaths(before, after, "", found)
637
+ if (found.size === 0) return
638
+ const paths = [...found]
639
+ const key = `${label}\n${paths.join("\n")}`
640
+ if (warnedDrops.has(key)) return
641
+ if (warnedDrops.size >= 1000) warnedDrops.clear()
642
+ warnedDrops.add(key)
643
+ const shown = paths.slice(0, 10).join(", ")
644
+ const more = paths.length > 10 ? ` and ${paths.length - 10} more` : ""
645
+ console.warn(
646
+ `[nifra/web] ${label} returned field(s) its output schema does not declare, so they were dropped before the browser: ${shown}${more}. Declare a field in the schema if the page needs it. (Shown in development only.)`,
647
+ )
648
+ }
649
+
578
650
  const pathOf = (issue: { readonly path?: ReadonlyArray<unknown> | undefined }): string =>
579
651
  (issue.path ?? [])
580
652
  .map(
@@ -583,7 +655,12 @@ const pathOf = (issue: { readonly path?: ReadonlyArray<unknown> | undefined }):
583
655
  )
584
656
  .join("") || "/"
585
657
 
586
- function settle(guard: CompiledGuard, result: StandardResult<unknown>, label: string): unknown {
658
+ function settle(
659
+ guard: CompiledGuard,
660
+ result: StandardResult<unknown>,
661
+ label: string,
662
+ input: unknown,
663
+ ): unknown {
587
664
  if (result.issues !== undefined) {
588
665
  const paths = [...new Set(result.issues.map(pathOf))]
589
666
  throw new OutputGuardError(
@@ -595,6 +672,8 @@ function settle(guard: CompiledGuard, result: StandardResult<unknown>, label: st
595
672
  const state: RunState = { label, issues: [] }
596
673
  UNKNOWN(result.value, state)
597
674
  if (state.issues.length > 0) throw undeclaredDeferred(label, state.issues)
675
+ // No JSON Schema to project by: the schema's own validate stripped what it does not declare.
676
+ if (guard.warnDropped) warnDropped(label, input, result.value)
598
677
  }
599
678
  return result.value
600
679
  }
@@ -612,11 +691,13 @@ export function runGuard(guard: CompiledGuard, value: unknown, label: string): u
612
691
  const state: RunState = { label, issues: [] }
613
692
  projected = guard.project(value, state)
614
693
  if (state.issues.length > 0) throw undeclaredDeferred(label, state.issues)
694
+ // Copy-on-write: the same reference back means nothing was dropped anywhere below.
695
+ if (guard.warnDropped && projected !== value) warnDropped(label, value, projected)
615
696
  }
616
697
  const result = guard.validate(projected)
617
698
  return result instanceof Promise
618
- ? result.then((settled) => settle(guard, settled, label))
619
- : settle(guard, result, label)
699
+ ? result.then((settled) => settle(guard, settled, label, projected))
700
+ : settle(guard, result, label, projected)
620
701
  }
621
702
 
622
703
  /** What a browser-bound channel sends, and how it is named in errors. */