@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,464 @@
1
+ /**
2
+ * Pulse — SSR Renderer
3
+ *
4
+ * Takes a spec and an HTTP request context.
5
+ * Resolves server state, executes view functions, returns streamable HTML.
6
+ *
7
+ * Two modes:
8
+ * renderToString — resolves everything, returns a complete HTML string
9
+ * renderToStream — streams shell immediately, deferred segments follow
10
+ *
11
+ * No framework. No dependencies. Pure Node.js streams.
12
+ */
13
+
14
+ import { assertValidSpec, getStreamOrder } from '../spec/schema.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // renderToString
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Render a spec to a complete HTML string.
22
+ * Resolves all server state before rendering — no streaming.
23
+ * Use this for simple pages or when you need the full HTML before sending.
24
+ *
25
+ * @param {import('../spec/schema.js').PulseSpec} spec
26
+ * @param {Object} [ctx] - Request context passed to server data fetchers
27
+ * @param {Object} [pageState] - Optional page-level state (from a parent layout)
28
+ * @returns {Promise<{ html: string, serverState: Object, timing: Object }>}
29
+ */
30
+ export async function renderToString(spec, ctx = {}, pageState = {}) {
31
+ assertValidSpec(spec)
32
+
33
+ const t0 = performance.now()
34
+
35
+ // Resolve all server data in parallel
36
+ const serverState = await resolveServerState(spec, ctx)
37
+
38
+ // Merge declared store keys into server state — store keys lose to page-level keys
39
+ const mergedServerState = mergeStoreKeys(spec, serverState, ctx.store)
40
+
41
+ const tData = performance.now()
42
+
43
+ // Merge initial client state with any page-level state
44
+ const clientState = { ...spec.state, ...pageState }
45
+
46
+ // Render all segments
47
+ const html = renderSegments(spec, clientState, mergedServerState)
48
+
49
+ const tRender = performance.now()
50
+
51
+ return {
52
+ html,
53
+ serverState: mergedServerState,
54
+ timing: {
55
+ data: +(tData - t0).toFixed(2),
56
+ render: +(tRender - tData).toFixed(2),
57
+ total: +(tRender - t0).toFixed(2)
58
+ }
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // renderToStream
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Render a spec to a Node.js Readable stream.
68
+ *
69
+ * Shell segments are written immediately on the first flush.
70
+ * Deferred segments are written as their server data resolves.
71
+ * Each deferred segment is wrapped in a <pulse-chunk> element and
72
+ * replaced client-side by a tiny inline script.
73
+ *
74
+ * @param {import('../spec/schema.js').PulseSpec} spec
75
+ * @param {Object} [ctx]
76
+ * @returns {ReadableStream} - Web Streams API ReadableStream
77
+ */
78
+ export function renderToStream(spec, ctx = {}, nonce = '') {
79
+ assertValidSpec(spec)
80
+
81
+ const { shell, deferred } = getStreamOrder(spec)
82
+ const clientState = { ...spec.state }
83
+
84
+ let controller
85
+
86
+ const stream = new ReadableStream({
87
+ start(c) { controller = c }
88
+ })
89
+
90
+ // Run async — don't await here, stream is returned immediately
91
+ ;(async () => {
92
+ try {
93
+ // Resolve shell server data (only what shell segments need)
94
+ const shellServerState = await resolveServerStateForSegments(spec, ctx, shell)
95
+
96
+ // Merge declared store keys — store keys lose to page-level keys
97
+ const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
98
+
99
+ // Write shell immediately
100
+ const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
101
+ controller.enqueue(encode(shellHtml))
102
+
103
+ // Write deferred segments as they resolve
104
+ if (deferred.length > 0) {
105
+ // Enqueue placeholder elements so the browser knows where to insert
106
+ const placeholders = deferred
107
+ .map(key => `<pulse-deferred id="pd-${key}"></pulse-deferred>`)
108
+ .join('')
109
+ controller.enqueue(encode(placeholders))
110
+
111
+ // Resolve and stream each deferred segment
112
+ await Promise.all(deferred.map(async (key) => {
113
+ const segServerState = await resolveServerStateForSegments(spec, ctx, [key])
114
+ const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
115
+ const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
116
+
117
+ // Inline script replaces the placeholder with the rendered content
118
+ const chunk = `
119
+ <template id="pt-${key}">${segHtml}</template>
120
+ <script nonce="${nonce}">
121
+ (function() {
122
+ var t = document.getElementById('pt-${key}');
123
+ var p = document.getElementById('pd-${key}');
124
+ if (t && p) { p.replaceWith(t.content.cloneNode(true)); t.remove(); }
125
+ })();
126
+ </script>`
127
+
128
+ controller.enqueue(encode(chunk))
129
+ }))
130
+ }
131
+
132
+ // Inject server state so the client hydration can read it without
133
+ // a second request — mirrors what wrapDocument does for the string path.
134
+ // Emit when there is page server data or store data to serialise.
135
+ if (Object.keys(mergedShellState).length > 0) {
136
+ const script = `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedShellState)};</script>`
137
+ controller.enqueue(encode(script))
138
+ }
139
+
140
+ controller.close()
141
+ } catch (err) {
142
+ controller.error(err)
143
+ }
144
+ })()
145
+
146
+ return stream
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Page HTML wrapper
151
+ // ---------------------------------------------------------------------------
152
+
153
+ /**
154
+ * Wrap rendered content in a full HTML document.
155
+ *
156
+ * @param {Object} options
157
+ * @param {string} options.content - The rendered body content
158
+ * @param {Object} [options.spec] - The spec (used for meta, title)
159
+ * @param {Object} [options.serverState] - Serialised for client resumption
160
+ * @param {Object} [options.timing] - Server-Timing values
161
+ * @returns {string}
162
+ */
163
+ export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '' }) {
164
+ const meta = spec.meta || {}
165
+ const title = meta.title || 'Pulse'
166
+
167
+ const bodyAttr = meta.theme ? ` data-theme="${esc(meta.theme)}"` : ''
168
+
169
+ const metaTags = [
170
+ meta.description ? `<meta name="description" content="${esc(meta.description)}">` : '',
171
+ meta.ogTitle ? `<meta property="og:title" content="${esc(meta.ogTitle)}">` : '',
172
+ meta.ogImage ? `<meta property="og:image" content="${esc(meta.ogImage)}">` : '',
173
+ ].filter(Boolean).join('\n ')
174
+
175
+ const stylePreloads = (meta.styles || [])
176
+ .map(href => `<link rel="preload" as="style" href="${esc(href)}">`)
177
+ .join('\n ')
178
+
179
+ const runtimePreload = runtimeBundle && isBundle(spec.hydrate)
180
+ ? `<link rel="modulepreload" as="script" href="${esc(runtimeBundle)}">`
181
+ : ''
182
+
183
+ const styleLinks = (meta.styles || [])
184
+ .map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i === 0 ? ' fetchpriority="high"' : ''}>`)
185
+ .join('\n ')
186
+
187
+ // Deferred styles — injected via a nonce'd script so CSP is respected.
188
+ // Dynamically-inserted <link rel="stylesheet"> elements are non-render-blocking.
189
+ const deferredStyleLinks = (meta.deferredStyles || []).length > 0
190
+ ? `<script nonce="${nonce}">(function(){${
191
+ (meta.deferredStyles || []).map(href =>
192
+ `var l=document.createElement('link');l.rel='stylesheet';l.href='${esc(href)}';document.head.appendChild(l);`
193
+ ).join('')
194
+ }})();</script>`
195
+ : ''
196
+
197
+ const scriptTags = (meta.scripts || [])
198
+ .map(src => `<script src="${esc(src)}" defer></script>`)
199
+ .join('\n ')
200
+
201
+ const schemaScript = meta.schema
202
+ ? `<script type="application/ld+json">${JSON.stringify(meta.schema)}</script>`
203
+ : ''
204
+
205
+ // Serialise server state into the page so the client runtime can read it
206
+ // without making a second request
207
+ const serverStateScript = Object.keys(serverState).length > 0
208
+ ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
209
+ : ''
210
+
211
+ // Serialise store state so the client store singleton can be initialised.
212
+ // Also exposes window.__updatePulseStore__ so navigate.js can refresh the
213
+ // singleton with fresh server data on client-side navigations.
214
+ const storeStateScript = storeState && Object.keys(storeState).length > 0
215
+ ? `<script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
216
+ : ''
217
+
218
+ // Hydration bootstrap — makes the server-rendered HTML interactive.
219
+ // When hydrate points to a self-executing bundle (/dist/…) a single <script>
220
+ // tag is enough; the bundle imports spec + runtime and calls mount() itself.
221
+ // In dev mode (source file path) we emit the explicit inline import block.
222
+ const storeImport = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
223
+ ? `\n import store from '${esc(storeDef.hydrate)}'`
224
+ : ''
225
+ const storeArg = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
226
+ ? ', { ssr: true, store }'
227
+ : ', { ssr: true }'
228
+
229
+ const hydrateScript = spec.hydrate
230
+ ? isBundle(spec.hydrate)
231
+ ? `<script type="module" src="${esc(spec.hydrate)}"></script>`
232
+ : `<script type="module" nonce="${nonce}">
233
+ import spec from '${esc(spec.hydrate)}'
234
+ import { mount } from '/@pulse/runtime/index.js'
235
+ import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
236
+ const root = document.getElementById('pulse-root')
237
+ mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
238
+ initNavigation(root, mount)
239
+ </script>`
240
+ : ''
241
+
242
+ // Server-Timing header value (caller is responsible for setting the header)
243
+ const serverTimingValue = timing.total !== undefined
244
+ ? `data;dur=${timing.data}, render;dur=${timing.render}, total;dur=${timing.total}`
245
+ : null
246
+
247
+ return {
248
+ html: `<!DOCTYPE html>
249
+ <html lang="en">
250
+ <head>
251
+ <meta charset="UTF-8">
252
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
253
+ <link rel="icon" href="data:,">
254
+ <title>${esc(title)}</title>
255
+ ${stylePreloads}
256
+ ${runtimePreload}
257
+ ${extraHead}
258
+ ${metaTags}
259
+ ${styleLinks}
260
+ ${deferredStyleLinks}
261
+ ${schemaScript}
262
+ </head>
263
+ <body${bodyAttr}>
264
+ <a href="#main-content" class="pulse-skip-link">Skip to main content</a>
265
+ <div id="pulse-root">
266
+ ${content}
267
+ </div>
268
+ ${storeStateScript}
269
+ ${serverStateScript}
270
+ ${scriptTags}
271
+ ${hydrateScript}
272
+ ${extraBody}
273
+ </body>
274
+ </html>`,
275
+ serverTimingValue
276
+ }
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Internal — store state merging
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Merge declared store keys into a server state object.
285
+ * Only keys listed in spec.store are included — nothing leaks from the store
286
+ * to pages that don't declare a dependency on it.
287
+ * Page-level server state always wins over store values with the same key.
288
+ *
289
+ * @param {Object} spec
290
+ * @param {Object} serverState
291
+ * @param {Object} [storeState]
292
+ * @returns {Object}
293
+ */
294
+ function mergeStoreKeys(spec, serverState, storeState) {
295
+ if (!spec.store?.length || !storeState) return serverState
296
+ const slice = {}
297
+ for (const key of spec.store) {
298
+ if (storeState[key] !== undefined) slice[key] = storeState[key]
299
+ }
300
+ return { ...slice, ...serverState }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Internal — server state resolution
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * Resolve all server data fetchers in parallel.
309
+ *
310
+ * @param {import('../spec/schema.js').PulseSpec} spec
311
+ * @param {Object} ctx
312
+ * @returns {Promise<Object>}
313
+ */
314
+ export async function resolveServerState(spec, ctx) {
315
+ if (!spec.server) return {}
316
+
317
+ const timeout = ctx.fetcherTimeout ?? null
318
+
319
+ const entries = await Promise.all(
320
+ Object.entries(spec.server).map(async ([key, fn]) => {
321
+ const value = await withTimeout(fn(ctx), timeout, key)
322
+ return [key, value]
323
+ })
324
+ )
325
+
326
+ return Object.fromEntries(entries)
327
+ }
328
+
329
+ /**
330
+ * Resolve server data for a specific set of segments only.
331
+ * Used by the streaming renderer to avoid fetching deferred data
332
+ * before the shell is sent.
333
+ *
334
+ * Currently resolves all server state — segment-level data dependencies
335
+ * will be an opt-in annotation in a future iteration.
336
+ *
337
+ * @param {import('../spec/schema.js').PulseSpec} spec
338
+ * @param {Object} ctx
339
+ * @param {string[]} _segments - Segment keys (reserved for future scoping)
340
+ * @returns {Promise<Object>}
341
+ */
342
+ async function resolveServerStateForSegments(spec, ctx, _segments) {
343
+ return resolveServerState(spec, ctx)
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Internal — view rendering
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /**
351
+ * Render all view segments to a single HTML string.
352
+ *
353
+ * @param {import('../spec/schema.js').PulseSpec} spec
354
+ * @param {Object} clientState
355
+ * @param {Object} serverState
356
+ * @returns {string}
357
+ */
358
+ function renderSegments(spec, clientState, serverState) {
359
+ try {
360
+ if (typeof spec.view === 'function') {
361
+ return spec.view(clientState, serverState)
362
+ }
363
+ return Object.entries(spec.view)
364
+ .map(([, fn]) => fn(clientState, serverState))
365
+ .join('')
366
+ } catch (err) {
367
+ // If the spec declares a custom error renderer, use it and continue.
368
+ // Otherwise rethrow — the server's existing error handler (dev/prod pages) takes over.
369
+ if (spec.onViewError) {
370
+ console.error('[Pulse SSR] View error (caught by onViewError):', err)
371
+ return spec.onViewError(err, clientState, serverState)
372
+ }
373
+ throw err
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Render a named subset of view segments.
379
+ *
380
+ * @param {import('../spec/schema.js').PulseSpec} spec
381
+ * @param {string[]} keys
382
+ * @param {Object} clientState
383
+ * @param {Object} serverState
384
+ * @returns {string}
385
+ */
386
+ function renderNamedSegments(spec, keys, clientState, serverState) {
387
+ try {
388
+ if (typeof spec.view === 'function') {
389
+ return spec.view(clientState, serverState)
390
+ }
391
+ return keys
392
+ .map(key => {
393
+ if (!spec.view[key]) {
394
+ console.warn(`[Pulse SSR] View segment "${key}" not found`)
395
+ return ''
396
+ }
397
+ return spec.view[key](clientState, serverState)
398
+ })
399
+ .join('')
400
+ } catch (err) {
401
+ if (spec.onViewError) {
402
+ console.error('[Pulse SSR] View error (caught by onViewError):', err)
403
+ return spec.onViewError(err, clientState, serverState)
404
+ }
405
+ throw err
406
+ }
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Utilities
411
+ // ---------------------------------------------------------------------------
412
+
413
+ /**
414
+ * Race a promise against a timeout rejection.
415
+ * Returns the promise result if it resolves within `ms`.
416
+ * Rejects with a descriptive error if the timeout fires first.
417
+ * No-op when ms is falsy (null / 0 / undefined).
418
+ *
419
+ * @param {Promise<any>} promise
420
+ * @param {number|null} ms - Timeout in milliseconds
421
+ * @param {string} name - Fetcher key for error messages
422
+ * @returns {Promise<any>}
423
+ */
424
+ export function withTimeout(promise, ms, name) {
425
+ if (!ms) return promise
426
+ return Promise.race([
427
+ promise,
428
+ new Promise((_, reject) =>
429
+ setTimeout(
430
+ () => reject(new Error(`Server fetcher "${name}" timed out after ${ms}ms`)),
431
+ ms
432
+ )
433
+ )
434
+ ])
435
+ }
436
+
437
+ const encoder = new TextEncoder()
438
+
439
+ function encode(str) {
440
+ return encoder.encode(str)
441
+ }
442
+
443
+ /**
444
+ * Returns true when the hydrate path points to a pre-built self-executing
445
+ * bundle (i.e. resolved via the manifest) rather than a raw source file.
446
+ * Bundles live under /dist/ and carry a content hash in their filename.
447
+ *
448
+ * @param {string} hydratePath
449
+ * @returns {boolean}
450
+ */
451
+ function isBundle(hydratePath) {
452
+ return hydratePath.startsWith('/dist/')
453
+ }
454
+
455
+ /**
456
+ * Escape HTML special characters for safe attribute insertion.
457
+ */
458
+ function esc(str) {
459
+ return String(str)
460
+ .replace(/&/g, '&amp;')
461
+ .replace(/</g, '&lt;')
462
+ .replace(/>/g, '&gt;')
463
+ .replace(/"/g, '&quot;')
464
+ }