@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,1386 @@
1
+ /**
2
+ * Pulse — HTTP Server
3
+ *
4
+ * Takes a map of specs, handles routing, streams responses.
5
+ * Pure Node.js http module — no Express, no dependencies.
6
+ *
7
+ * Usage:
8
+ * import { createServer } from './src/server/index.js'
9
+ * import { contactSpec } from './specs/contact.js'
10
+ *
11
+ * createServer([contactSpec], { port: 3000 })
12
+ */
13
+
14
+ import http from 'http'
15
+ import fs from 'fs'
16
+ import path from 'path'
17
+ import zlib from 'zlib'
18
+ import crypto from 'crypto'
19
+ import { promisify } from 'util'
20
+ import { renderToString, renderToStream, wrapDocument, resolveServerState } from '../runtime/ssr.js'
21
+ import { validateSpec } from '../spec/schema.js'
22
+ import { validateStore, resolveStoreState } from '../store/index.js'
23
+
24
+ const gzipAsync = promisify(zlib.gzip)
25
+ const brotliAsync = promisify(zlib.brotliCompress)
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Security headers — applied to every response
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const SECURITY_HEADERS = {
32
+ 'X-Content-Type-Options': 'nosniff',
33
+ 'X-Frame-Options': 'DENY',
34
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
35
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
36
+ 'Cross-Origin-Opener-Policy': 'same-origin',
37
+ 'Cross-Origin-Resource-Policy': 'same-origin',
38
+ }
39
+
40
+ /**
41
+ * Return Strict-Transport-Security header when the request is over HTTPS.
42
+ * Detects HTTPS via the x-forwarded-proto header (CDN/proxy) or the socket.
43
+ */
44
+ function httpsHeaders(req) {
45
+ const isHttps = req.headers['x-forwarded-proto'] === 'https' || req.socket?.encrypted
46
+ return isHttps ? { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' } : {}
47
+ }
48
+
49
+ /**
50
+ * Base CSP directives — applied to every page response.
51
+ * Keys are directive names; values are arrays of sources.
52
+ * Callers may extend individual directives by merging extra sources.
53
+ */
54
+ const BASE_CSP = {
55
+ 'default-src': ["'none'"],
56
+ 'script-src': ["'self'"], // nonce appended at request time
57
+ 'style-src': ["'self'"],
58
+ 'style-src-attr': ["'unsafe-inline'"], // scoped: UI components use inline style= for CSS vars
59
+ 'img-src': ["'self'", 'data:'],
60
+ 'font-src': ["'self'"],
61
+ 'connect-src': ["'self'"],
62
+ 'frame-ancestors':["'none'"],
63
+ 'base-uri': ["'self'"],
64
+ 'form-action': ["'self'"],
65
+ }
66
+
67
+ function serializeCsp(directives) {
68
+ return Object.entries(directives).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ')
69
+ }
70
+
71
+ /**
72
+ * Build the Content-Security-Policy header for a page response.
73
+ * @param {string} nonce Per-request nonce for inline scripts.
74
+ * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
75
+ */
76
+ function buildCsp(nonce, ext = {}) {
77
+ const d = { ...BASE_CSP, 'script-src': ["'self'", `'nonce-${nonce}'`] }
78
+ for (const [k, sources] of Object.entries(ext)) {
79
+ d[k] = [...(d[k] || []), ...sources]
80
+ }
81
+ return serializeCsp(d)
82
+ }
83
+
84
+ /**
85
+ * Build the nonce-free CSP for cached responses.
86
+ * Safe because cached pages have no inline scripts.
87
+ * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
88
+ */
89
+ function buildCachedCsp(ext = {}) {
90
+ const d = { ...BASE_CSP }
91
+ for (const [k, sources] of Object.entries(ext)) {
92
+ d[k] = [...(d[k] || []), ...sources]
93
+ }
94
+ return serializeCsp(d)
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Compression helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Pick the best supported encoding from the Accept-Encoding header. */
102
+ function negotiateEncoding(req) {
103
+ const accept = req.headers['accept-encoding'] || ''
104
+ if (accept.includes('br')) return 'br'
105
+ if (accept.includes('gzip')) return 'gzip'
106
+ return null
107
+ }
108
+
109
+ /** Compress a Buffer using the given encoding. Returns the original if none. */
110
+ async function compressBuffer(buf, encoding) {
111
+ if (encoding === 'br') return brotliAsync(buf)
112
+ if (encoding === 'gzip') return gzipAsync(buf)
113
+ return buf
114
+ }
115
+
116
+ /** Create a transform stream for the given encoding, or null. */
117
+ function createCompressor(encoding) {
118
+ if (encoding === 'br') return zlib.createBrotliCompress()
119
+ if (encoding === 'gzip') return zlib.createGzip()
120
+ return null
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Multipart form data parser
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Parse a multipart/form-data body Buffer into a plain object.
129
+ *
130
+ * Regular text fields → string values.
131
+ * File fields → { filename, type, data: Buffer, size }.
132
+ * Repeated field names → array of values.
133
+ *
134
+ * @param {Buffer} buf - Raw request body
135
+ * @param {string} boundary - Boundary string from the Content-Type header
136
+ * @returns {Record<string, unknown>}
137
+ */
138
+ function parseMultipart(buf, boundary) {
139
+ const result = {}
140
+ const delim = Buffer.from(`\r\n--${boundary}`)
141
+ const first = Buffer.from(`--${boundary}`)
142
+ const CRLFX2 = Buffer.from('\r\n\r\n')
143
+
144
+ let pos = buf.indexOf(first)
145
+ if (pos === -1) return result
146
+ pos += first.length
147
+
148
+ while (pos < buf.length) {
149
+ // After each boundary: \r\n = more parts, -- = final boundary
150
+ if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break
151
+ if (buf[pos] === 0x0d && buf[pos + 1] === 0x0a) pos += 2
152
+ else break
153
+
154
+ // Find next boundary — marks the end of this part's body
155
+ const next = buf.indexOf(delim, pos)
156
+ if (next === -1) break
157
+
158
+ const part = buf.subarray(pos, next)
159
+ const headerEnd = part.indexOf(CRLFX2)
160
+ if (headerEnd === -1) { pos = next + delim.length; continue }
161
+
162
+ // Parse part headers
163
+ const headerStr = part.subarray(0, headerEnd).toString('utf8')
164
+ const body = part.subarray(headerEnd + 4)
165
+ const headers = {}
166
+ for (const line of headerStr.split('\r\n')) {
167
+ const colon = line.indexOf(':')
168
+ if (colon === -1) continue
169
+ headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim()
170
+ }
171
+
172
+ // Extract name / filename from Content-Disposition
173
+ const cd = headers['content-disposition'] || ''
174
+ const nameMatch = cd.match(/\bname="([^"]*)"/)
175
+ const fileMatch = cd.match(/\bfilename="([^"]*)"/)
176
+ if (!nameMatch) { pos = next + delim.length; continue }
177
+
178
+ const name = nameMatch[1]
179
+ const value = fileMatch
180
+ ? { filename: fileMatch[1], type: headers['content-type'] || 'application/octet-stream', data: body, size: body.length }
181
+ : body.toString('utf8')
182
+
183
+ // Support repeated names (e.g. checkboxes, multi-file inputs)
184
+ const existing = result[name]
185
+ if (existing !== undefined) {
186
+ result[name] = Array.isArray(existing) ? [...existing, value] : [existing, value]
187
+ } else {
188
+ result[name] = value
189
+ }
190
+
191
+ pos = next + delim.length
192
+ }
193
+
194
+ return result
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // In-process TTL cache — server data memoization
199
+ // ---------------------------------------------------------------------------
200
+
201
+ class TtlCache {
202
+ constructor() { this._store = new Map() }
203
+
204
+ get(key) {
205
+ const entry = this._store.get(key)
206
+ if (!entry) return undefined
207
+ if (Date.now() > entry.expiresAt) { this._store.delete(key); return undefined }
208
+ return entry.value
209
+ }
210
+
211
+ set(key, value, ttlSeconds) {
212
+ this._store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 })
213
+ }
214
+ }
215
+
216
+ const serverDataCache = new TtlCache()
217
+ const pageHtmlCache = new TtlCache()
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Cache-Control builder
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Normalise a cache config value.
225
+ * Accepts a number (maxAge seconds), a boolean (true = public, maxAge=3600),
226
+ * or an object { public, maxAge, staleWhileRevalidate }.
227
+ */
228
+ function resolveCache(value) {
229
+ if (!value) return null
230
+ if (value === true) return { public: true, maxAge: 3600, staleWhileRevalidate: 86400 }
231
+ if (typeof value === 'number') return { public: true, maxAge: value }
232
+ return value
233
+ }
234
+
235
+ /**
236
+ * Build the Cache-Control value for an HTML response.
237
+ * In dev mode always returns no-store.
238
+ * spec.cache takes precedence over the server-level defaultCache.
239
+ *
240
+ * spec.cache / defaultCache:
241
+ * true → public, max-age=3600, stale-while-revalidate=86400
242
+ * number → public, max-age={n}
243
+ * { public, maxAge, staleWhileRevalidate }
244
+ */
245
+ function buildCacheControl(spec, dev, defaultCache = null) {
246
+ if (dev) return 'no-store'
247
+
248
+ const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
249
+ if (!cfg) return 'no-store'
250
+
251
+ const { public: isPublic = false, maxAge = 0, staleWhileRevalidate } = cfg
252
+ const parts = [isPublic ? 'public' : 'private']
253
+ if (maxAge > 0) parts.push(`max-age=${maxAge}`)
254
+ if (staleWhileRevalidate) parts.push(`stale-while-revalidate=${staleWhileRevalidate}`)
255
+ return parts.join(', ')
256
+ }
257
+
258
+ /**
259
+ * Return the TTL in seconds for the in-process page cache,
260
+ * or 0 if this response should not be cached in-process.
261
+ */
262
+ function pageCacheTtl(spec, dev, defaultCache) {
263
+ if (dev) return 0
264
+ const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
265
+ return cfg?.maxAge || 0
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Dev import map — lets browser resolve @invisibleloop/pulse/* bare specifiers
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * In dev mode, spec files use bare package specifiers (e.g. @invisibleloop/pulse/image)
274
+ * so Node.js can resolve them during SSR. The browser can't resolve bare specifiers
275
+ * without an import map, so we inject one in dev HTML responses.
276
+ *
277
+ * Must appear in <head> before any <script type="module">.
278
+ */
279
+ /** Dev import map — lets browser resolve bare specifiers. Requires nonce for CSP. */
280
+ function devImportMap(nonce) {
281
+ return `<script type="importmap" nonce="${nonce}">
282
+ {
283
+ "imports": {
284
+ "@invisibleloop/pulse/image": "/@pulse/runtime/image.js",
285
+ "@invisibleloop/pulse/ui": "/@pulse/ui/index.js"
286
+ }
287
+ }
288
+ </script>`
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Cached render — wraps renderToString with optional server-data TTL cache
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Render a spec to a string, optionally caching server data for `spec.serverTtl` seconds.
297
+ *
298
+ * In dev mode (or when serverTtl is not set) this is a pass-through to `renderToString`.
299
+ * In prod with serverTtl set, server data fetcher results are memoized in-process and
300
+ * the page is re-rendered with cached data on subsequent requests within the TTL window.
301
+ *
302
+ * @param {Object} spec
303
+ * @param {Object} ctx
304
+ * @param {boolean} dev
305
+ * @returns {Promise<{ html: string, serverState: Object, timing: Object }>}
306
+ */
307
+ async function cachedRenderToString(spec, ctx, dev) {
308
+ if (dev || !spec.serverTtl) {
309
+ return renderToString(spec, ctx)
310
+ }
311
+
312
+ // Build a cache key scoped to this route + request parameters
313
+ const key = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
314
+
315
+ const cached = serverDataCache.get(key)
316
+ if (cached) return cached
317
+
318
+ const result = await renderToString(spec, ctx)
319
+ serverDataCache.set(key, result, spec.serverTtl)
320
+ return result
321
+ }
322
+
323
+ const MIME_TYPES = {
324
+ '.html': 'text/html; charset=utf-8',
325
+ '.css': 'text/css; charset=utf-8',
326
+ '.js': 'application/javascript',
327
+ '.json': 'application/json',
328
+ '.svg': 'image/svg+xml',
329
+ '.png': 'image/png',
330
+ '.jpg': 'image/jpeg',
331
+ '.jpeg': 'image/jpeg',
332
+ '.ico': 'image/x-icon',
333
+ '.woff2':'font/woff2',
334
+ '.woff': 'font/woff',
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // createServer
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * @typedef {Object} ServerOptions
343
+ * @property {number} [port=3000]
344
+ * @property {boolean} [stream=true] - Use streaming SSR by default
345
+ * @property {'remove'|'add'|'allow'} [trailingSlash='remove']
346
+ * - 'remove' — redirect /about/ → /about (301), canonical = no-slash (default)
347
+ * - 'add' — redirect /about → /about/ (301), canonical = slash
348
+ * - 'allow' — serve both, no redirect, canonical = no-slash
349
+ * @property {function} [onError] - Error handler (err, req, res) => void
350
+ * @property {function} [onRequest] - Request hook (req, res) => void | false
351
+ * Return false to short-circuit routing
352
+ */
353
+
354
+ /**
355
+ * Create and start a Pulse HTTP server.
356
+ *
357
+ * @param {import('../spec/schema.js').PulseSpec[]} specs
358
+ * @param {ServerOptions} [options]
359
+ * @returns {http.Server}
360
+ */
361
+ export function createServer(specs, options = {}) {
362
+ const {
363
+ port = 3000,
364
+ stream = true,
365
+ staticDir = null,
366
+ manifest = null, // path to manifest.json or plain object
367
+ trailingSlash = 'remove', // 'remove' | 'add' | 'allow'
368
+ extraBody = '', // extra HTML injected before </body> on every page
369
+ dev = false, // dev mode — show detailed error pages
370
+ store = null, // global store definition (pulse.store.js default export)
371
+ resolveBrand = null, // async (host) => brandConfig — keyed by domain
372
+ defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
373
+ fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
374
+ maxBody = 1024 * 1024, // max request body size in bytes (default 1 MB)
375
+ shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
376
+ healthCheck = '/healthz', // path for health check endpoint, or false to disable
377
+ csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
378
+ onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
379
+ onRequest
380
+ } = options
381
+
382
+ const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
383
+
384
+ // Validate store at startup — fail fast before the server accepts connections
385
+ if (store) {
386
+ const { valid, errors } = validateStore(store)
387
+ if (!valid) {
388
+ throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
389
+ }
390
+ }
391
+
392
+ // Per-host brand cache — avoids hitting the data store on every request
393
+ const brandCache = new TtlCache()
394
+
395
+ // Load manifest — maps source hydrate paths to production bundle paths
396
+ const hydrateMap = loadManifest(manifest, staticDir)
397
+ const runtimeBundle = hydrateMap['_runtime'] || ''
398
+
399
+ // Validate all specs upfront — fail at startup, not at request time
400
+ for (const spec of specs) {
401
+ const { valid, errors } = validateSpec(spec)
402
+ const routeErrors = []
403
+ if (!spec.route || typeof spec.route !== 'string') {
404
+ routeErrors.push('spec.route is required and must be a string (e.g. "/contact")')
405
+ } else if (!spec.route.startsWith('/')) {
406
+ routeErrors.push('spec.route must start with "/" (e.g. "/contact")')
407
+ }
408
+ const allErrors = [...errors, ...routeErrors]
409
+ if (!valid || routeErrors.length > 0) {
410
+ throw new Error(
411
+ `Invalid spec for route "${spec?.route}":\n` +
412
+ allErrors.map(e => ` — ${e}`).join('\n')
413
+ )
414
+ }
415
+ }
416
+
417
+ // Build route table — let so it can be swapped on hot reload
418
+ let router = buildRouter(specs)
419
+
420
+ const server = http.createServer(async (req, res) => {
421
+ try {
422
+ // Parse URL — needed before health check and routing
423
+ const url = new URL(req.url, `http://localhost:${port}`)
424
+ const pathname = url.pathname
425
+
426
+ // Health check — before onRequest and routing so load balancers always get a response
427
+ if (healthPath && pathname === healthPath && (req.method === 'GET' || req.method === 'HEAD')) {
428
+ const body = JSON.stringify({ status: 'ok', uptime: process.uptime() })
429
+ res.writeHead(200, {
430
+ 'Content-Type': 'application/json',
431
+ 'Cache-Control': 'no-store',
432
+ ...SECURITY_HEADERS,
433
+ })
434
+ res.end(req.method === 'HEAD' ? undefined : body)
435
+ return
436
+ }
437
+
438
+ // Request hook — allows middleware-like behaviour
439
+ if (onRequest) {
440
+ const result = onRequest(req, res)
441
+ if (result === false) return
442
+ }
443
+
444
+ // Static file serving — GET/HEAD only
445
+ if (staticDir && (req.method === 'GET' || req.method === 'HEAD')) {
446
+ const served = serveStatic(req, res, staticDir, dev)
447
+ if (served) return
448
+ }
449
+
450
+ // Trailing slash normalisation — GET/HEAD only (redirects don't apply to POST etc.)
451
+ if ((req.method === 'GET' || req.method === 'HEAD') && pathname !== '/') {
452
+ if (trailingSlash === 'remove' && pathname.endsWith('/')) {
453
+ const target = pathname.slice(0, -1) + (url.search || '')
454
+ res.writeHead(301, { Location: target, ...SECURITY_HEADERS, ...httpsHeaders(req) })
455
+ res.end()
456
+ return
457
+ }
458
+ if (trailingSlash === 'add' && !pathname.endsWith('/')) {
459
+ const target = pathname + '/' + (url.search || '')
460
+ res.writeHead(301, { Location: target, ...SECURITY_HEADERS, ...httpsHeaders(req) })
461
+ res.end()
462
+ return
463
+ }
464
+ // 'allow' — no redirect
465
+ }
466
+
467
+ // Match route
468
+ const match = matchRoute(router, pathname)
469
+
470
+ if (!match) {
471
+ res.writeHead(404, { 'Content-Type': 'text/html', ...SECURITY_HEADERS, ...httpsHeaders(req) })
472
+ res.end(notFoundHtml(pathname))
473
+ return
474
+ }
475
+
476
+ // Method gating — raw response specs accept any HTTP method.
477
+ // Page specs default to GET/HEAD only; opt in to other methods via spec.methods.
478
+ if (!match.spec.contentType) {
479
+ const allowed = match.spec.methods
480
+ ? match.spec.methods.map(m => m.toUpperCase())
481
+ : ['GET', 'HEAD']
482
+ if (!allowed.includes(req.method)) {
483
+ res.writeHead(405, {
484
+ 'Content-Type': 'text/plain',
485
+ 'Allow': allowed.join(', '),
486
+ ...SECURITY_HEADERS, ...httpsHeaders(req)
487
+ })
488
+ res.end('Method Not Allowed')
489
+ return
490
+ }
491
+ }
492
+
493
+ // Per-request CSP nonce — fresh cryptographic random value for every response
494
+ const nonce = crypto.randomBytes(16).toString('base64url')
495
+
496
+ // Build request context passed to guard, server data fetchers, and render
497
+ const ctx = buildContext(req, url, match.params, nonce, maxBody)
498
+
499
+ // Brand resolution — attach ctx.brand before guard or server data runs
500
+ if (resolveBrand) {
501
+ const host = req.headers.host || ''
502
+ const cached = brandCache.get(host)
503
+ if (cached !== undefined) {
504
+ ctx.brand = cached
505
+ } else {
506
+ ctx.brand = await resolveBrand(host)
507
+ brandCache.set(host, ctx.brand, 60)
508
+ }
509
+ }
510
+
511
+ // Global store — resolve once per request, attach to ctx before guard/server fetchers.
512
+ // Use global fetcherTimeout for store fetchers; spec-level override applied below.
513
+ ctx.fetcherTimeout = fetcherTimeout ?? null
514
+ if (store) {
515
+ ctx.store = await resolveStoreState(store, ctx)
516
+ }
517
+
518
+ const spec = resolveSpec(match.spec, hydrateMap)
519
+
520
+ // Per-spec timeout overrides the global default
521
+ if (spec.serverTimeout != null) ctx.fetcherTimeout = spec.serverTimeout
522
+
523
+ // Guard — per-route authorization check runs before any data fetching.
524
+ // Can return:
525
+ // { redirect: '/path' } — 302 redirect
526
+ // { status, body, headers?, json? } — custom response (e.g. 422 + error JSON)
527
+ if (typeof spec.guard === 'function') {
528
+ const result = await spec.guard(ctx)
529
+ if (result?.redirect) {
530
+ res.writeHead(302, mergeCtxHeaders(ctx, { Location: result.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
531
+ res.end()
532
+ return
533
+ }
534
+ if (result?.status) {
535
+ const body = result.json != null ? JSON.stringify(result.json) : (result.body ?? '')
536
+ const ct = result.json != null ? 'application/json' : (result.headers?.['Content-Type'] ?? 'text/plain')
537
+ const headers = { 'Content-Type': ct, ...SECURITY_HEADERS, ...httpsHeaders(req), ...(result.headers || {}) }
538
+ res.writeHead(result.status, mergeCtxHeaders(ctx, headers))
539
+ res.end(body)
540
+ return
541
+ }
542
+ }
543
+
544
+ // Build canonical URL — prefer spec.meta.canonical, otherwise derive from request.
545
+ // Canonical path follows the trailingSlash mode so the <link> is consistent with redirects.
546
+ const proto = req.headers['x-forwarded-proto'] || 'http'
547
+ const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`
548
+ const canonicalPath = trailingSlash === 'add' && pathname !== '/'
549
+ ? (pathname.endsWith('/') ? pathname : pathname + '/')
550
+ : (pathname !== '/' && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname)
551
+ const canonicalUrl = spec.meta?.canonical || `${proto}://${host}${canonicalPath}`
552
+
553
+ // Raw content spec (RSS, sitemap, JSON API, webhooks) — bypass HTML pipeline
554
+ if (spec.contentType) {
555
+ await handleRawResponse(spec, ctx, req, res, dev)
556
+ return
557
+ }
558
+
559
+ // Client-side navigation request — return JSON fragment, not a full document
560
+ if (req.headers['x-pulse-navigate'] === 'true') {
561
+ await handleNavResponse(spec, ctx, res, dev)
562
+ return
563
+ }
564
+
565
+ if (stream) {
566
+ await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
567
+ } else {
568
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalUrl, nonce, runtimeBundle, defaultCache, store, csp)
569
+ }
570
+
571
+ } catch (err) {
572
+ if (err?.status === 413) {
573
+ if (!res.headersSent) {
574
+ res.writeHead(413, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS })
575
+ res.end('Request body too large')
576
+ }
577
+ return
578
+ }
579
+ onError(err, req, res)
580
+ }
581
+ })
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Connection tracking for graceful shutdown
585
+ // ---------------------------------------------------------------------------
586
+
587
+ // Map<socket, isActive> — true while a request is being handled
588
+ const connections = new Map()
589
+
590
+ server.on('connection', socket => {
591
+ connections.set(socket, false)
592
+ socket.on('close', () => connections.delete(socket))
593
+ })
594
+
595
+ // Mark socket active at request start, idle at response finish.
596
+ // If we're already draining, destroy the socket as soon as it goes idle.
597
+ server.on('request', (req, res) => {
598
+ const socket = req.socket
599
+ connections.set(socket, true)
600
+ res.on('finish', () => {
601
+ connections.set(socket, false)
602
+ if (draining) socket.destroy()
603
+ })
604
+ })
605
+
606
+ let draining = false
607
+
608
+ function shutdown() {
609
+ if (draining) return
610
+ draining = true
611
+
612
+ console.log('⚡ Pulse shutting down gracefully…')
613
+
614
+ // Stop accepting new connections; exit once all connections are closed
615
+ server.close(() => process.exit(0))
616
+
617
+ // Destroy sockets that are idle (keep-alive but between requests)
618
+ for (const [socket, active] of connections) {
619
+ if (!active) socket.destroy()
620
+ }
621
+
622
+ // Force-exit after shutdownTimeout so a stuck request can't block a deploy
623
+ setTimeout(() => {
624
+ console.error(`⚡ Pulse force-exiting after ${shutdownTimeout}ms shutdown timeout`)
625
+ process.exit(1)
626
+ }, shutdownTimeout).unref()
627
+ }
628
+
629
+ process.on('SIGTERM', shutdown)
630
+ process.on('SIGINT', shutdown)
631
+
632
+ server.listen(port, () => {
633
+ console.log(`⚡ Pulse running at http://localhost:${port}`)
634
+ })
635
+
636
+ return {
637
+ server,
638
+ shutdown,
639
+ updateSpecs(newSpecs) {
640
+ router = buildRouter(newSpecs)
641
+ }
642
+ }
643
+ }
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Response handlers
647
+ // ---------------------------------------------------------------------------
648
+
649
+ /**
650
+ * Client-side navigation — render the body fragment and return it as JSON.
651
+ * The browser swaps #pulse-root with the html, updates the title, re-mounts.
652
+ */
653
+ async function handleNavResponse(spec, ctx, res, dev = false) {
654
+ const { html, serverState } = await cachedRenderToString(spec, ctx, dev)
655
+ const meta = resolveMeta(spec.meta, ctx)
656
+
657
+ const payload = JSON.stringify({
658
+ html,
659
+ title: meta.title || 'Pulse',
660
+ styles: meta.styles || [],
661
+ scripts: meta.scripts || [],
662
+ hydrate: spec.hydrate || null,
663
+ serverState: Object.keys(serverState).length > 0 ? serverState : undefined,
664
+ storeState: ctx.store && Object.keys(ctx.store).length > 0 ? ctx.store : undefined,
665
+ })
666
+
667
+ res.writeHead(200, {
668
+ 'Content-Type': 'application/json',
669
+ 'Cache-Control': buildCacheControl(spec, dev),
670
+ ...SECURITY_HEADERS,
671
+ })
672
+ res.end(payload)
673
+ }
674
+
675
+ /**
676
+ * Render to a complete string then send — simpler, easier to cache.
677
+ * Checks the in-process page cache before rendering; stores result after.
678
+ */
679
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
680
+ const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
681
+ // Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
682
+ const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
683
+
684
+ let html, serverTimingValue, fromCache = false
685
+
686
+ const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
687
+ if (cached) {
688
+ html = cached.html
689
+ fromCache = true
690
+ } else {
691
+ const { html: content, serverState, timing } = await cachedRenderToString(spec, ctx, dev)
692
+ const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
693
+ const resolvedSpec = { ...spec, meta: resolveMeta(spec.meta, ctx) }
694
+ const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
695
+ const wrapped = wrapDocument({ content, spec: resolvedSpec, serverState, storeState: ctx.store || null, storeDef: store || null, timing, extraBody: resolvedExtraBody, extraHead: (dev ? devImportMap(nonce) + '\n ' : '') + canonicalTag, nonce, runtimeBundle })
696
+ html = wrapped.html
697
+ serverTimingValue = wrapped.serverTimingValue
698
+ if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
699
+ }
700
+
701
+ const encoding = negotiateEncoding(req)
702
+ const buf = await compressBuffer(Buffer.from(html, 'utf8'), encoding)
703
+
704
+ const headers = mergeCtxHeaders(ctx, {
705
+ 'Content-Type': 'text/html; charset=utf-8',
706
+ 'Cache-Control': buildCacheControl(spec, dev, defaultCache),
707
+ 'Content-Security-Policy': fromCache ? buildCachedCsp(csp) : buildCsp(nonce, csp),
708
+ 'Vary': 'Accept-Encoding',
709
+ ...SECURITY_HEADERS,
710
+ ...httpsHeaders(req),
711
+ })
712
+ if (encoding) headers['Content-Encoding'] = encoding
713
+ if (serverTimingValue) headers['Server-Timing'] = serverTimingValue
714
+
715
+ res.writeHead(200, headers)
716
+ res.end(buf)
717
+ }
718
+
719
+ /**
720
+ * Stream the response — shell first, deferred segments follow.
721
+ * On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
722
+ */
723
+ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalUrl = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}) {
724
+ // Serve from in-process page cache when available — skip streaming overhead.
725
+ // Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
726
+ const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
727
+ const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
728
+ const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
729
+
730
+ if (cached) {
731
+ const encoding = negotiateEncoding(req)
732
+ const buf = await compressBuffer(Buffer.from(cached.html, 'utf8'), encoding)
733
+ const headers = mergeCtxHeaders(ctx, {
734
+ 'Content-Type': 'text/html; charset=utf-8',
735
+ 'Cache-Control': buildCacheControl(spec, dev, defaultCache),
736
+ 'Content-Security-Policy': buildCachedCsp(csp),
737
+ 'Vary': 'Accept-Encoding',
738
+ ...SECURITY_HEADERS,
739
+ ...httpsHeaders(req),
740
+ })
741
+ if (encoding) headers['Content-Encoding'] = encoding
742
+ res.writeHead(200, headers)
743
+ res.end(buf)
744
+ return
745
+ }
746
+ const t0 = performance.now()
747
+
748
+ // Write the document opening immediately so the browser starts parsing
749
+ const meta = resolveMeta(spec.meta, ctx)
750
+ const title = meta.title || 'Pulse'
751
+
752
+ const stylePreloads = (meta.styles || [])
753
+ .map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
754
+ .join('\n')
755
+
756
+ const runtimePreload = runtimeBundle && spec.hydrate?.startsWith('/dist/')
757
+ ? ` <link rel="modulepreload" as="script" href="${escHtml(runtimeBundle)}">`
758
+ : ''
759
+
760
+ const metaTags = [
761
+ canonicalUrl ? ` <link rel="canonical" href="${escHtml(canonicalUrl)}">` : '',
762
+ meta.description ? ` <meta name="description" content="${escHtml(meta.description)}">` : '',
763
+ meta.ogTitle ? ` <meta property="og:title" content="${escHtml(meta.ogTitle)}">` : '',
764
+ meta.ogImage ? ` <meta property="og:image" content="${escHtml(meta.ogImage)}">` : '',
765
+ ...(meta.styles || []).map((href, i) => ` <link rel="stylesheet" href="${escHtml(href)}"${i === 0 ? ' fetchpriority="high"' : ''}>`),
766
+ (meta.deferredStyles || []).length > 0
767
+ ? ` <script nonce="${nonce}">(function(){${
768
+ (meta.deferredStyles || []).map(href =>
769
+ `var l=document.createElement('link');l.rel='stylesheet';l.href='${escHtml(href)}';document.head.appendChild(l);`
770
+ ).join('')
771
+ }})();</script>`
772
+ : '',
773
+ meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
774
+ ].filter(Boolean).join('\n')
775
+
776
+ const bodyAttr = meta.theme ? ` data-theme="${escHtml(meta.theme)}"` : ''
777
+
778
+ const docOpen = `<!DOCTYPE html>
779
+ <html lang="en">
780
+ <head>
781
+ <meta charset="UTF-8">
782
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
783
+ <link rel="icon" href="data:,">
784
+ <title>${escHtml(title)}</title>
785
+ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
786
+ </head>
787
+ <body${bodyAttr}>
788
+ <div id="pulse-root">`
789
+
790
+ const storeImport = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
791
+ ? `\n import store from '${escHtml(store.hydrate)}'`
792
+ : ''
793
+ const storeArg = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
794
+ ? ', { ssr: true, store }'
795
+ : ', { ssr: true }'
796
+
797
+ const hydrateScript = spec.hydrate
798
+ ? spec.hydrate.startsWith('/dist/')
799
+ ? `\n <script type="module" src="${escHtml(spec.hydrate)}"></script>`
800
+ : `\n <script type="module" nonce="${nonce}">
801
+ import spec from '${escHtml(spec.hydrate)}'
802
+ import { mount } from '/@pulse/runtime/index.js'
803
+ import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
804
+ const root = document.getElementById('pulse-root')
805
+ mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
806
+ initNavigation(root, mount)
807
+ </script>`
808
+ : ''
809
+
810
+ const scriptTags = (meta.scripts || [])
811
+ .map(src => ` <script src="${escHtml(src)}" defer></script>`)
812
+ .join('\n')
813
+
814
+ const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
815
+
816
+ // Emit window.__PULSE_STORE__ so the client store singleton can be initialised.
817
+ // Also exposes __updatePulseStore__ for navigate.js to refresh store on navigation.
818
+ const storeScript = ctx.store && Object.keys(ctx.store).length > 0
819
+ ? `\n <script nonce="${nonce}">window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
820
+ : ''
821
+
822
+ const docClose = `
823
+ </div>
824
+ ${scriptTags}${storeScript}${hydrateScript}
825
+ ${resolvedExtraBody}
826
+ </body>
827
+ </html>`
828
+
829
+ const encoding = negotiateEncoding(req)
830
+ const compressor = createCompressor(encoding)
831
+
832
+ const headers = mergeCtxHeaders(ctx, {
833
+ 'Content-Type': 'text/html; charset=utf-8',
834
+ 'Transfer-Encoding': 'chunked',
835
+ 'Cache-Control': buildCacheControl(spec, dev, defaultCache),
836
+ 'Content-Security-Policy': buildCsp(nonce, csp),
837
+ 'Vary': 'Accept-Encoding',
838
+ ...SECURITY_HEADERS,
839
+ ...httpsHeaders(req),
840
+ })
841
+ if (encoding) headers['Content-Encoding'] = encoding
842
+
843
+ res.writeHead(200, headers)
844
+
845
+ // Route writes through the compressor (if any) → response
846
+ const out = compressor ?? res
847
+ if (compressor) compressor.pipe(res)
848
+ const write = (chunk) => out.write(chunk)
849
+ const end = (chunk) => out.end(chunk)
850
+
851
+ // Buffer chunks so we can store the full HTML in the page cache after sending
852
+ const chunks = ttl > 0 ? [docOpen] : null
853
+
854
+ write(docOpen)
855
+
856
+ try {
857
+ // Pipe the spec's stream into the response
858
+ const stream = renderToStream(spec, ctx, nonce)
859
+ const reader = stream.getReader()
860
+ const decoder = new TextDecoder()
861
+
862
+ while (true) {
863
+ const { done, value } = await reader.read()
864
+ if (done) break
865
+ const chunk = decoder.decode(value)
866
+ write(chunk)
867
+ chunks?.push(chunk)
868
+ }
869
+
870
+ // Inject server timing for client resumption
871
+ const total = (performance.now() - t0).toFixed(2)
872
+ const timingScript = `\n <script nonce="${nonce}">window.__PULSE_TIMING__ = { total: ${total} };</script>`
873
+ write(timingScript)
874
+ // Timing script has a nonce — exclude from cache so cached HTML has no inline scripts
875
+ chunks?.push(docClose)
876
+
877
+ end(docClose)
878
+
879
+ // Store assembled HTML in page cache for subsequent requests
880
+ if (chunks) pageHtmlCache.set(cacheKey, { html: chunks.join('') }, ttl)
881
+ } catch (err) {
882
+ // Headers already sent — inject an error script that replaces pulse-root content.
883
+ // JSON.stringify safely encodes the HTML for insertion into a script context.
884
+ try {
885
+ write(streamErrorScript(err, dev, nonce))
886
+ end('\n</body>\n</html>')
887
+ } catch {
888
+ res.destroy()
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Raw content response — serves non-HTML content (RSS, sitemaps, JSON APIs, etc.)
895
+ * Bypasses the HTML pipeline entirely. Resolves spec.server data, then calls
896
+ * spec.render(ctx, serverState) to produce the response body.
897
+ *
898
+ * Supports: spec.server, spec.cache, spec.serverTtl — same as page specs.
899
+ * Compresses text/*, *\/xml, and *\/json content types automatically.
900
+ */
901
+ async function handleRawResponse(spec, ctx, req, res, dev) {
902
+ let content
903
+
904
+ if (!dev && spec.serverTtl) {
905
+ const key = 'raw:' + spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
906
+ content = serverDataCache.get(key)
907
+ if (!content) {
908
+ const serverState = await resolveServerState(spec, ctx)
909
+ content = await spec.render(ctx, serverState)
910
+ serverDataCache.set(key, content, spec.serverTtl)
911
+ }
912
+ } else {
913
+ const serverState = await resolveServerState(spec, ctx)
914
+ content = await spec.render(ctx, serverState)
915
+ }
916
+
917
+ // render() may return { redirect } instead of a string — used for auth callbacks etc.
918
+ if (content && typeof content === 'object' && content.redirect) {
919
+ res.writeHead(302, mergeCtxHeaders(ctx, { Location: content.redirect, ...SECURITY_HEADERS, ...httpsHeaders(req) }))
920
+ res.end()
921
+ return
922
+ }
923
+
924
+ const ct = spec.contentType
925
+ const compressible = /text\/|\/xml|\/json/.test(ct)
926
+ const encoding = compressible ? negotiateEncoding(req) : null
927
+ const buf = await compressBuffer(Buffer.from(content, 'utf8'), encoding)
928
+
929
+ const headers = mergeCtxHeaders(ctx, {
930
+ 'Content-Type': ct,
931
+ 'Cache-Control': buildCacheControl(spec, dev),
932
+ ...SECURITY_HEADERS,
933
+ ...httpsHeaders(req),
934
+ })
935
+ if (encoding) headers['Content-Encoding'] = encoding
936
+ if (compressible) headers['Vary'] = 'Accept-Encoding'
937
+
938
+ res.writeHead(200, headers)
939
+ res.end(buf)
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Router
944
+ // ---------------------------------------------------------------------------
945
+
946
+ /**
947
+ * Build a router from an array of specs.
948
+ * Supports static routes (/about) and param routes (/products/:id).
949
+ *
950
+ * @param {import('../spec/schema.js').PulseSpec[]} specs
951
+ * @returns {{ pattern: RegExp, params: string[], spec: Object }[]}
952
+ */
953
+ function buildRouter(specs) {
954
+ return specs.map(spec => {
955
+ const { pattern, params } = routeToRegex(spec.route)
956
+ return { pattern, params, spec }
957
+ })
958
+ }
959
+
960
+ /**
961
+ * Match a pathname against the router.
962
+ * Returns the first match with extracted params, or null.
963
+ *
964
+ * @param {ReturnType<typeof buildRouter>} router
965
+ * @param {string} pathname
966
+ * @returns {{ spec: Object, params: Object } | null}
967
+ */
968
+ function matchRoute(router, pathname) {
969
+ for (const route of router) {
970
+ const match = pathname.match(route.pattern)
971
+ if (!match) continue
972
+
973
+ const params = {}
974
+ route.params.forEach((name, i) => {
975
+ params[name] = decodeURIComponent(match[i + 1])
976
+ })
977
+
978
+ return { spec: route.spec, params }
979
+ }
980
+ return null
981
+ }
982
+
983
+ /**
984
+ * Convert a route pattern to a regex.
985
+ * /products/:id → /^\/products\/([^/]+)\/?$/
986
+ * /about → /^\/about\/?$/
987
+ *
988
+ * @param {string} route
989
+ * @returns {{ pattern: RegExp, params: string[] }}
990
+ */
991
+ function routeToRegex(route) {
992
+ const params = []
993
+ const pattern = route
994
+ .replace(/:([^/]+)/g, (_, name) => { params.push(name); return '([^/]+)' })
995
+ .replace(/\//g, '\\/')
996
+
997
+ return {
998
+ pattern: new RegExp(`^${pattern}\\/?$`),
999
+ params
1000
+ }
1001
+ }
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // Request context
1005
+ // ---------------------------------------------------------------------------
1006
+
1007
+ /**
1008
+ * Build the context object passed to server data fetchers.
1009
+ * Includes parsed URL, route params, headers, and convenience helpers.
1010
+ *
1011
+ * @param {http.IncomingMessage} req
1012
+ * @param {URL} url
1013
+ * @param {Object} params
1014
+ * @returns {Object}
1015
+ */
1016
+ function buildContext(req, url, params, nonce = '', maxBody = 1024 * 1024) {
1017
+ const _responseHeaders = []
1018
+
1019
+ // Memoised body buffer — reads the request stream exactly once.
1020
+ // Returns a rejected promise if the body exceeds maxBody bytes.
1021
+ let _bodyPromise = null
1022
+ function _readBody() {
1023
+ if (_bodyPromise) return _bodyPromise
1024
+ _bodyPromise = new Promise((resolve, reject) => {
1025
+ const chunks = []
1026
+ let size = 0
1027
+ req.on('data', chunk => {
1028
+ size += chunk.length
1029
+ if (size > maxBody) {
1030
+ return reject(Object.assign(new Error(`Request body exceeds ${maxBody} bytes`), { status: 413 }))
1031
+ }
1032
+ chunks.push(chunk)
1033
+ })
1034
+ req.on('end', () => resolve(Buffer.concat(chunks)))
1035
+ req.on('error', reject)
1036
+ })
1037
+ return _bodyPromise
1038
+ }
1039
+
1040
+ return {
1041
+ // Route params — e.g. { id: '42' } for /products/:id
1042
+ params,
1043
+
1044
+ // Query string — e.g. { q: 'widget' } for ?q=widget
1045
+ query: Object.fromEntries(url.searchParams),
1046
+
1047
+ // Raw headers
1048
+ headers: req.headers,
1049
+
1050
+ // Convenience
1051
+ pathname: url.pathname,
1052
+ method: req.method,
1053
+ nonce,
1054
+
1055
+ // Cookie helper — returns parsed cookies as a plain object
1056
+ get cookies() {
1057
+ return parseCookies(req.headers.cookie || '')
1058
+ },
1059
+
1060
+ // ---------------------------------------------------------------------------
1061
+ // Body parsers — async, memoised, safe to call from guard or server fetchers
1062
+ // ---------------------------------------------------------------------------
1063
+
1064
+ // Parse the request body as JSON.
1065
+ // Returns null when the body is empty.
1066
+ async json() {
1067
+ const buf = await _readBody()
1068
+ if (!buf.length) return null
1069
+ return JSON.parse(buf.toString('utf8'))
1070
+ },
1071
+
1072
+ // Return the request body as a plain string.
1073
+ async text() {
1074
+ const buf = await _readBody()
1075
+ return buf.toString('utf8')
1076
+ },
1077
+
1078
+ // Parse the request body into a plain object.
1079
+ // Handles application/x-www-form-urlencoded and multipart/form-data.
1080
+ // Multipart file fields are returned as { filename, type, data: Buffer, size }.
1081
+ // Returns null when the body is empty.
1082
+ async formData() {
1083
+ const buf = await _readBody()
1084
+ if (!buf.length) return null
1085
+ const ct = req.headers['content-type'] || ''
1086
+ if (ct.includes('multipart/form-data')) {
1087
+ const m = ct.match(/boundary=([^\s;]+)/)
1088
+ if (!m) return null
1089
+ return parseMultipart(buf, m[1].replace(/^"|"$/g, ''))
1090
+ }
1091
+ return Object.fromEntries(new URLSearchParams(buf.toString('utf8')))
1092
+ },
1093
+
1094
+ // Return the raw request body as a Buffer.
1095
+ async buffer() {
1096
+ return _readBody()
1097
+ },
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Response helpers
1101
+ // ---------------------------------------------------------------------------
1102
+
1103
+ // Set an arbitrary response header (e.g. Location, custom headers)
1104
+ setHeader(name, value) {
1105
+ _responseHeaders.push([name, value])
1106
+ },
1107
+
1108
+ // Set a Set-Cookie response header with common options
1109
+ // opts: { httpOnly, secure, path, maxAge, sameSite, domain }
1110
+ setCookie(name, value, opts = {}) {
1111
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
1112
+ if (opts.path !== undefined) cookie += `; Path=${opts.path}`
1113
+ else cookie += `; Path=/`
1114
+ if (opts.maxAge !== undefined) cookie += `; Max-Age=${opts.maxAge}`
1115
+ if (opts.domain !== undefined) cookie += `; Domain=${opts.domain}`
1116
+ cookie += `; SameSite=${opts.sameSite ?? 'Lax'}`
1117
+ if (opts.httpOnly) cookie += `; HttpOnly`
1118
+ if (opts.secure) cookie += `; Secure`
1119
+ _responseHeaders.push(['Set-Cookie', cookie])
1120
+ },
1121
+
1122
+ // Internal — consumed by the response handlers
1123
+ _responseHeaders,
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * Merge response headers set via ctx.setHeader / ctx.setCookie into a headers
1129
+ * object suitable for res.writeHead(). Handles multiple Set-Cookie values.
1130
+ */
1131
+ function mergeCtxHeaders(ctx, headers) {
1132
+ if (!ctx._responseHeaders.length) return headers
1133
+ const result = { ...headers }
1134
+ for (const [name, value] of ctx._responseHeaders) {
1135
+ const key = name.toLowerCase() === 'set-cookie' ? 'Set-Cookie' : name
1136
+ if (key === 'Set-Cookie') {
1137
+ const existing = result['Set-Cookie']
1138
+ result['Set-Cookie'] = existing
1139
+ ? (Array.isArray(existing) ? [...existing, value] : [existing, value])
1140
+ : [value]
1141
+ } else {
1142
+ result[key] = value
1143
+ }
1144
+ }
1145
+ return result
1146
+ }
1147
+
1148
+ // ---------------------------------------------------------------------------
1149
+ // Manifest & spec resolution
1150
+ // ---------------------------------------------------------------------------
1151
+
1152
+ /**
1153
+ * Load a hydrate manifest.
1154
+ * Accepts a manifest object, a path to a JSON file, or auto-detects from
1155
+ * staticDir/dist/manifest.json when staticDir is set.
1156
+ *
1157
+ * @param {Object|string|null} manifest
1158
+ * @param {string|null} staticDir
1159
+ * @returns {Object} map of source paths → bundle paths
1160
+ */
1161
+ function loadManifest(manifest, staticDir) {
1162
+ if (!manifest && !staticDir) return {}
1163
+
1164
+ if (manifest && typeof manifest === 'object') return manifest
1165
+
1166
+ const manifestPath = typeof manifest === 'string'
1167
+ ? manifest
1168
+ : staticDir ? path.join(staticDir, 'dist', 'manifest.json') : null
1169
+
1170
+ if (!manifestPath) return {}
1171
+
1172
+ try {
1173
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
1174
+ } catch {
1175
+ return {}
1176
+ }
1177
+ }
1178
+
1179
+ /**
1180
+ * Return a copy of the spec with the hydrate path and any meta.styles paths
1181
+ * resolved to their production bundle paths (if manifest entries exist).
1182
+ *
1183
+ * @param {Object} spec
1184
+ * @param {Object} hydrateMap
1185
+ * @returns {Object}
1186
+ */
1187
+ function resolveSpec(spec, hydrateMap) {
1188
+ let resolved = spec
1189
+
1190
+ if (spec.hydrate && hydrateMap[spec.hydrate]) {
1191
+ resolved = { ...resolved, hydrate: hydrateMap[spec.hydrate] }
1192
+ }
1193
+
1194
+ if (Array.isArray(spec.meta?.styles)) {
1195
+ const resolvedStyles = spec.meta.styles.map(href => hydrateMap[href] || href)
1196
+ if (resolvedStyles.some((s, i) => s !== spec.meta.styles[i])) {
1197
+ resolved = { ...resolved, meta: { ...resolved.meta, styles: resolvedStyles } }
1198
+ }
1199
+ }
1200
+
1201
+ return resolved
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // Static file serving
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ /**
1209
+ * Attempt to serve a static file from staticDir.
1210
+ * Returns true if the file was served, false if not found.
1211
+ *
1212
+ * @param {http.IncomingMessage} req
1213
+ * @param {http.ServerResponse} res
1214
+ * @param {string} staticDir - Absolute path to the static files directory
1215
+ * @returns {boolean}
1216
+ */
1217
+ function serveStatic(req, res, staticDir, dev = false) {
1218
+ const url = new URL(req.url, 'http://localhost')
1219
+ const pathname = decodeURIComponent(url.pathname)
1220
+
1221
+ // Prevent directory traversal
1222
+ const filePath = path.join(staticDir, pathname)
1223
+ if (!filePath.startsWith(staticDir + path.sep) && filePath !== staticDir) return false
1224
+
1225
+ let stat
1226
+ try { stat = fs.statSync(filePath) } catch { return false }
1227
+ if (!stat.isFile()) return false
1228
+
1229
+ const ext = path.extname(filePath).toLowerCase()
1230
+ const mime = MIME_TYPES[ext] || 'application/octet-stream'
1231
+
1232
+ // Content-hashed bundles under /dist/ can be cached indefinitely.
1233
+ // In dev, all other files get no-store so CSS/JS changes are reflected immediately.
1234
+ const isImmutable = pathname.startsWith('/dist/')
1235
+ const cache = isImmutable
1236
+ ? 'public, max-age=31536000, immutable'
1237
+ : dev ? 'no-store' : 'public, max-age=3600'
1238
+
1239
+ // Only compress compressible text types
1240
+ const compressible = ['.js', '.css', '.html', '.json', '.svg'].includes(ext)
1241
+ const encoding = compressible ? negotiateEncoding(req) : null
1242
+ const compressor = createCompressor(encoding)
1243
+
1244
+ const headers = {
1245
+ 'Content-Type': mime,
1246
+ 'Cache-Control': cache,
1247
+ 'Vary': 'Accept-Encoding',
1248
+ ...SECURITY_HEADERS,
1249
+ }
1250
+ if (encoding) headers['Content-Encoding'] = encoding
1251
+
1252
+ res.writeHead(200, headers)
1253
+ const fileStream = fs.createReadStream(filePath)
1254
+ if (compressor) {
1255
+ fileStream.pipe(compressor).pipe(res)
1256
+ } else {
1257
+ fileStream.pipe(res)
1258
+ }
1259
+ return true
1260
+ }
1261
+
1262
+ // ---------------------------------------------------------------------------
1263
+ // Utilities
1264
+ // ---------------------------------------------------------------------------
1265
+
1266
+ /**
1267
+ * Resolve any function values in spec.meta by calling them with ctx.
1268
+ * Allows meta.title, meta.styles, meta.description etc. to be per-request
1269
+ * functions — useful for multi-brand sites where values vary by domain.
1270
+ *
1271
+ * @param {Object|undefined} meta
1272
+ * @param {Object} ctx
1273
+ * @returns {Object}
1274
+ */
1275
+ function resolveMeta(meta, ctx) {
1276
+ if (!meta) return {}
1277
+ const resolved = {}
1278
+ for (const [key, val] of Object.entries(meta)) {
1279
+ resolved[key] = typeof val === 'function' ? val(ctx) : val
1280
+ }
1281
+ return resolved
1282
+ }
1283
+
1284
+ function parseCookies(cookieHeader) {
1285
+ if (!cookieHeader) return {}
1286
+ return Object.fromEntries(
1287
+ cookieHeader.split(';').map(pair => {
1288
+ const [key, ...rest] = pair.trim().split('=')
1289
+ return [key.trim(), decodeURIComponent(rest.join('=').trim())]
1290
+ })
1291
+ )
1292
+ }
1293
+
1294
+ function escHtml(str) {
1295
+ return String(str)
1296
+ .replace(/&/g, '&amp;')
1297
+ .replace(/</g, '&lt;')
1298
+ .replace(/>/g, '&gt;')
1299
+ .replace(/"/g, '&quot;')
1300
+ }
1301
+
1302
+ function notFoundHtml(pathname) {
1303
+ return `<!DOCTYPE html>
1304
+ <html lang="en">
1305
+ <head><meta charset="UTF-8"><title>404 — Not Found</title></head>
1306
+ <body>
1307
+ <h1>404</h1>
1308
+ <p>No route found for <code>${escHtml(pathname)}</code></p>
1309
+ </body>
1310
+ </html>`
1311
+ }
1312
+
1313
+ function defaultErrorHandler(err, _req, res, dev = false) {
1314
+ console.error('[Pulse] Server error:', err)
1315
+ if (res.headersSent) return
1316
+
1317
+ const html = dev ? errorPage(err) : errorPage500()
1318
+ res.writeHead(500, {
1319
+ 'Content-Type': 'text/html; charset=utf-8',
1320
+ ...SECURITY_HEADERS,
1321
+ })
1322
+ res.end(html)
1323
+ }
1324
+
1325
+ function errorPage(err) {
1326
+ const message = escHtml(err?.message || String(err))
1327
+ const stack = escHtml(err?.stack || '')
1328
+ return `<!DOCTYPE html>
1329
+ <html lang="en">
1330
+ <head>
1331
+ <meta charset="UTF-8">
1332
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1333
+ <title>Pulse Error</title>
1334
+ <style>
1335
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
1336
+ body { font-family: system-ui, sans-serif; background: #111; color: #f0f0f0; padding: 2rem; line-height: 1.5; }
1337
+ h1 { font-size: 1.25rem; color: #ff6b6b; margin-bottom: 1rem; display: flex; align-items: center; gap: .5rem; }
1338
+ h1::before { content: '⚠'; }
1339
+ .message { font-size: 1rem; color: #ffd6d6; background: #1e1010; border-left: 3px solid #ff6b6b; padding: .75rem 1rem; margin-bottom: 1.5rem; border-radius: 0 4px 4px 0; word-break: break-word; }
1340
+ pre { font-size: .8rem; color: #999; background: #1a1a1a; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
1341
+ .label { font-size: .7rem; color: #555; text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
1342
+ </style>
1343
+ </head>
1344
+ <body>
1345
+ <h1>Pulse render error</h1>
1346
+ <div class="message">${message}</div>
1347
+ <div class="label">Stack trace</div>
1348
+ <pre>${stack}</pre>
1349
+ </body>
1350
+ </html>`
1351
+ }
1352
+
1353
+ function streamErrorScript(err, dev, nonce = '') {
1354
+ const inner = dev
1355
+ ? `<div style="padding:1.5rem;font-family:system-ui,sans-serif;background:#1e1010;border-left:3px solid #ff6b6b;border-radius:0 4px 4px 0;margin:1rem">` +
1356
+ `<p style="color:#ff6b6b;font-weight:600;margin:0 0 .5rem">Pulse render error</p>` +
1357
+ `<p style="color:#ffd6d6;margin:0 0 1rem;word-break:break-word">${escHtml(err?.message || String(err))}</p>` +
1358
+ `<pre style="font-size:.75rem;color:#999;white-space:pre-wrap;word-break:break-word;margin:0">${escHtml(err?.stack || '')}</pre>` +
1359
+ `</div>`
1360
+ : `<div style="padding:2rem;text-align:center;font-family:system-ui,sans-serif;color:#666">Something went wrong.</div>`
1361
+
1362
+ return `<script nonce="${nonce}">(function(){var r=document.getElementById('pulse-root');if(r)r.innerHTML=${JSON.stringify(inner)};})()</script>`
1363
+ }
1364
+
1365
+ function errorPage500() {
1366
+ return `<!DOCTYPE html>
1367
+ <html lang="en">
1368
+ <head>
1369
+ <meta charset="UTF-8">
1370
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1371
+ <title>500 — Server Error</title>
1372
+ <style>
1373
+ body { font-family: system-ui, sans-serif; background: #111; color: #f0f0f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
1374
+ .box { text-align: center; }
1375
+ h1 { font-size: 4rem; color: #333; }
1376
+ p { color: #666; margin-top: .5rem; }
1377
+ </style>
1378
+ </head>
1379
+ <body>
1380
+ <div class="box">
1381
+ <h1>500</h1>
1382
+ <p>Something went wrong.</p>
1383
+ </div>
1384
+ </body>
1385
+ </html>`
1386
+ }