@invisibleloop/pulse 0.20.11 → 0.20.19

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 (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -350
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -331,15 +331,36 @@ function resolveCache(value) {
331
331
  *
332
332
  * spec.cache / defaultCache:
333
333
  * true → public, max-age=3600, stale-while-revalidate=86400
334
- * number → public, max-age={n}
334
+ * number → public, max-age={n}
335
335
  * { public, maxAge, staleWhileRevalidate }
336
+ *
337
+ * Pages with no cache config get 'no-cache', not 'no-store' — this is a
338
+ * deliberate choice, not a lesser default. 'no-store' unconditionally blocks
339
+ * the browser's back/forward cache (confirmed against a real Lighthouse
340
+ * audit: "Page prevented back/forward cache restoration ... cache-control:
341
+ * no-store header"), which without this fix meant every Pulse page failed
342
+ * bfcache eligibility unless it explicitly opted into caching. 'no-cache'
343
+ * still forces revalidation on every direct load/refresh — it does NOT
344
+ * serve a stale copy without checking first — it just permits the browser
345
+ * to keep an in-memory snapshot for back/forward navigation, which never
346
+ * touches the network at all. No risk of stale dynamic content on a normal
347
+ * page load; this only affects the back/forward navigation path.
336
348
  */
337
349
  function buildCacheControl(spec, dev, defaultCache = null) {
338
350
  if (dev) return 'no-store'
339
351
 
340
352
  const cfg = resolveCache(spec?.cache) ?? resolveCache(defaultCache)
341
- if (!cfg) return 'no-store'
353
+ if (!cfg) return 'no-cache'
354
+
355
+ return buildCacheControlFromConfig(cfg)
356
+ }
342
357
 
358
+ /**
359
+ * Format an already-resolved cache config (see resolveCache) into a
360
+ * Cache-Control header value. Split out of buildCacheControl so static-file
361
+ * serving can reuse the same formatting without a spec object.
362
+ */
363
+ function buildCacheControlFromConfig(cfg) {
343
364
  const { public: isPublic = false, maxAge = 0, staleWhileRevalidate } = cfg
344
365
  const parts = [isPublic ? 'public' : 'private']
345
366
  if (maxAge > 0) parts.push(`max-age=${maxAge}`)
@@ -492,12 +513,24 @@ const MIME_TYPES = {
492
513
 
493
514
  /**
494
515
  * Returns true if the spec needs a client-side hydration script.
495
- * Specs with no mutations, actions, or persist are purely server-rendered.
516
+ * Specs with no mutations, actions, persist, or store are purely
517
+ * server-rendered.
518
+ *
519
+ * spec.store alone counts even with no local mutations/actions: mount() is
520
+ * what wires up data-store-event dispatch AND the live SSE store-push
521
+ * subscription (initLiveStore) — a store-only page with no hydration script
522
+ * would silently never receive pushStore() broadcasts and have a dead
523
+ * data-store-event button, with no error surfaced anywhere (server-rendered
524
+ * HTML looks correct). Mirrors the same fix in discover.js's needsHydration
525
+ * and build.js's needsClientBundle — this is the third, independent copy of
526
+ * this check (the one createServer itself uses when auto-deriving
527
+ * spec.hydrate for URL entries) and previously had the same gap.
496
528
  */
497
529
  function needsHydration(spec) {
498
530
  return !!(spec.mutations && Object.keys(spec.mutations).length) ||
499
531
  !!(spec.actions && Object.keys(spec.actions).length) ||
500
- !!spec.persist
532
+ !!spec.persist ||
533
+ !!(spec.store && spec.store.length)
501
534
  }
502
535
 
503
536
  /**
@@ -562,6 +595,9 @@ export async function createServer(entries, options = {}) {
562
595
  root = null, // URL/string — project root for deriving browser paths from file: URLs
563
596
  resolveBrand = null, // async (host) => brandConfig — keyed by domain
564
597
  defaultCache = null, // default page cache: true | seconds | { public, maxAge, swr }
598
+ staticCache = null, // cache for static files served from staticDir (excludes /dist/,
599
+ // which is always immutable/1yr): true | seconds | { public, maxAge, swr }
600
+ // — default without this option is public, max-age=3600
565
601
  fetcherTimeout = null, // ms before any server fetcher times out (null = no limit)
566
602
  maxBody = 1024 * 1024, // max request body size in bytes (default 1 MB)
567
603
  shutdownTimeout = 30000, // ms to wait for in-flight requests before force-exit
@@ -582,7 +618,7 @@ export async function createServer(entries, options = {}) {
582
618
  // clients; pages subscribed via spec.store re-render immediately.
583
619
  // Broadcast = SHARED data only (stock, announcements), never
584
620
  // per-user data. Default path: /__pulse/live
585
- onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
621
+ onError = (err, req, res) => defaultErrorHandler(err, req, res, dev, errorJournalPath),
586
622
  onRequest,
587
623
  agentMode = false, // show agent-active indicator in banner
588
624
  quiet = false, // suppress request logging
@@ -593,6 +629,10 @@ export async function createServer(entries, options = {}) {
593
629
 
594
630
  const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
595
631
 
632
+ // Error journal path — resolved once per createServer call from this
633
+ // instance's real root, not cached at module load. See writeErrorEntry.
634
+ const errorJournalPath = resolveErrorJournalPath(root)
635
+
596
636
  // Store definition — `let` so the dev server can hot-swap it via updateStore()
597
637
  // when pulse.store.js changes, the same way updateSpecs() swaps page specs.
598
638
  let storeDef = store
@@ -665,6 +705,7 @@ export async function createServer(entries, options = {}) {
665
705
  // Build route table — let so it can be swapped on hot reload.
666
706
  // The '*' spec is excluded: it is not a route, it is the fallback.
667
707
  let router = buildRouter(specs.filter(s => s.route !== '*'))
708
+ warnOnRouteShadowing(router)
668
709
 
669
710
  // CSRF secret — per-boot random unless pinned via options.secret. Tokens are
670
711
  // tied to a cookie, so a restart only means in-flight forms re-render once.
@@ -775,6 +816,25 @@ export async function createServer(entries, options = {}) {
775
816
  return
776
817
  }
777
818
 
819
+ // Client-side error reporting — dev only. The runtime POSTs view/action
820
+ // failures here so they land in the same journal as server-side errors;
821
+ // pulse_diagnose reads one file regardless of where the error occurred.
822
+ // Not registered in production — a stray POST there just 404s harmlessly,
823
+ // so the client runtime can call it unconditionally with no dev check.
824
+ if (dev && pathname === '/__pulse/error' && req.method === 'POST') {
825
+ let body = ''
826
+ req.on('data', chunk => { body += chunk })
827
+ req.on('end', () => {
828
+ try {
829
+ const { route, phase, message, stack } = JSON.parse(body || '{}')
830
+ writeErrorEntry(errorJournalPath, { route, phase: phase === 'action' ? 'action' : 'view', message, stack })
831
+ } catch { /* malformed payload — drop it, this is best-effort */ }
832
+ res.writeHead(204, SECURITY_HEADERS)
833
+ res.end()
834
+ })
835
+ return
836
+ }
837
+
778
838
  // Request hook — allows middleware-like behaviour
779
839
  if (onRequest) {
780
840
  const result = onRequest(req, res)
@@ -783,7 +843,7 @@ export async function createServer(entries, options = {}) {
783
843
 
784
844
  // Static file serving — GET/HEAD only
785
845
  if (staticDir && (req.method === 'GET' || req.method === 'HEAD')) {
786
- const served = serveStatic(req, res, staticDir, dev)
846
+ const served = serveStatic(req, res, staticDir, dev, staticCache)
787
847
  if (served) return
788
848
  }
789
849
 
@@ -1513,6 +1573,37 @@ function buildRouter(specs) {
1513
1573
  })
1514
1574
  }
1515
1575
 
1576
+ /**
1577
+ * Route matching is first-match-wins in registration order (see matchRoute) —
1578
+ * there is no path-specificity ranking. Registration order comes from file
1579
+ * discovery order, which is not guaranteed alphabetical across platforms. A
1580
+ * dynamic route registered before a same-depth static route will silently
1581
+ * swallow every request to that static route — no error, no warning, just a
1582
+ * 404 or the wrong page rendering, discovered only by noticing a route never
1583
+ * gets hit. This is a static, best-effort check: it tests each dynamic
1584
+ * route's pattern against a synthetic literal built from each later static
1585
+ * route's own path segments, so it only catches routes actually registered
1586
+ * (nothing exhaustive about every possible URL).
1587
+ */
1588
+ function warnOnRouteShadowing(router) {
1589
+ for (let i = 0; i < router.length; i++) {
1590
+ const earlier = router[i]
1591
+ if (earlier.params.length === 0) continue // static routes can't shadow anything
1592
+ for (let j = i + 1; j < router.length; j++) {
1593
+ const later = router[j]
1594
+ if (later.params.length > 0) continue // only static routes can be shadowed this way
1595
+ if (earlier.pattern.test(later.spec.route)) {
1596
+ console.warn(
1597
+ `⚠ Pulse: route "${earlier.spec.route}" is registered before "${later.spec.route}" and matches it — ` +
1598
+ `"${later.spec.route}" will never be reached. Route matching is first-match-wins with no ` +
1599
+ `specificity ranking; rename the file backing "${earlier.spec.route}" so it sorts after ` +
1600
+ `"${later.spec.route}", or give one of them an explicit route that no longer overlaps.`
1601
+ )
1602
+ }
1603
+ }
1604
+ }
1605
+ }
1606
+
1516
1607
  /**
1517
1608
  * Match a pathname against the router.
1518
1609
  * Returns the first match with extracted params, or null.
@@ -1754,9 +1845,12 @@ function resolveSpec(spec, hydrateMap) {
1754
1845
  * @param {http.IncomingMessage} req
1755
1846
  * @param {http.ServerResponse} res
1756
1847
  * @param {string} staticDir - Absolute path to the static files directory
1848
+ * @param {boolean} dev
1849
+ * @param {*} staticCache - true | seconds | { public, maxAge, staleWhileRevalidate };
1850
+ * overrides the default public, max-age=3600 for non-/dist/ static files
1757
1851
  * @returns {boolean}
1758
1852
  */
1759
- function serveStatic(req, res, staticDir, dev = false) {
1853
+ function serveStatic(req, res, staticDir, dev = false, staticCache = null) {
1760
1854
  const url = new URL(req.url, 'http://localhost')
1761
1855
  let pathname
1762
1856
  try { pathname = decodeURIComponent(url.pathname) }
@@ -1775,10 +1869,15 @@ function serveStatic(req, res, staticDir, dev = false) {
1775
1869
 
1776
1870
  // Content-hashed bundles under /dist/ can be cached indefinitely.
1777
1871
  // In dev, all other files get no-store so CSS/JS changes are reflected immediately.
1872
+ // staticCache lets a project raise the default 3600s for its own unhashed
1873
+ // static assets (e.g. /images/*) without pretending they're immutable.
1778
1874
  const isImmutable = pathname.startsWith('/dist/')
1875
+ const staticCacheCfg = resolveCache(staticCache)
1779
1876
  const cache = isImmutable
1780
1877
  ? 'public, max-age=31536000, immutable'
1781
- : dev ? 'no-store' : 'public, max-age=3600'
1878
+ : dev ? 'no-store'
1879
+ : staticCacheCfg ? buildCacheControlFromConfig(staticCacheCfg)
1880
+ : 'public, max-age=3600'
1782
1881
 
1783
1882
  // Only compress compressible text types
1784
1883
  const compressible = ['.js', '.css', '.html', '.json', '.svg'].includes(ext)
@@ -2003,8 +2102,62 @@ function notFoundHtml(pathname) {
2003
2102
  </html>`
2004
2103
  }
2005
2104
 
2006
- function defaultErrorHandler(err, _req, res, dev = false) {
2105
+ // ---------------------------------------------------------------------------
2106
+ // Error journal — dev-only diagnostic log an agent can read via pulse_diagnose.
2107
+ // Captures the failure modes that previously dead-ended at console.error:
2108
+ // server errors, SSR view throws, sitemap enumerator failures, and (via the
2109
+ // /__pulse/error endpoint) post-hydration client view/action failures.
2110
+ // Ring-buffered at 50 entries — this is a build/iterate-time tool, not a
2111
+ // production error tracker, so it is only written when dev === true.
2112
+ // ---------------------------------------------------------------------------
2113
+
2114
+ const ERROR_JOURNAL_LIMIT = 50
2115
+
2116
+ // journalPath is computed per-createServer-call from the real project root
2117
+ // (see resolveErrorJournalPath below) rather than cached at module load —
2118
+ // each createServer instance (e.g. concurrent test servers) gets its own
2119
+ // correct path instead of a stale process.cwd() snapshot from import time.
2120
+ function resolveErrorJournalPath(root) {
2121
+ const dir = root
2122
+ ? (typeof root === 'string' ? root : root.pathname || process.cwd())
2123
+ : process.cwd()
2124
+ return path.join(dir, '.pulse', 'errors.json')
2125
+ }
2126
+
2127
+ function readErrorJournal(journalPath) {
2128
+ try {
2129
+ return JSON.parse(fs.readFileSync(journalPath, 'utf8'))
2130
+ } catch {
2131
+ return []
2132
+ }
2133
+ }
2134
+
2135
+ function writeErrorEntry(journalPath, { route, phase, message, stack, spec = null }) {
2136
+ try {
2137
+ const entries = readErrorJournal(journalPath)
2138
+ entries.push({
2139
+ id: crypto.randomUUID(),
2140
+ ts: new Date().toISOString(),
2141
+ route: route || null,
2142
+ phase, // 'view' | 'action' | 'server' | 'guard' | 'sitemap'
2143
+ message: String(message || '').slice(0, 2000),
2144
+ stack: String(stack || '').slice(0, 4000),
2145
+ spec,
2146
+ resolved: false,
2147
+ })
2148
+ // Ring buffer — keep only the most recent entries
2149
+ const trimmed = entries.slice(-ERROR_JOURNAL_LIMIT)
2150
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true })
2151
+ fs.writeFileSync(journalPath, JSON.stringify(trimmed, null, 2), 'utf8')
2152
+ } catch (journalErr) {
2153
+ // Never let journal-write failures affect the actual error response
2154
+ console.error('[Pulse] failed to write error journal:', journalErr.message)
2155
+ }
2156
+ }
2157
+
2158
+ function defaultErrorHandler(err, _req, res, dev = false, journalPath = null) {
2007
2159
  console.error('[Pulse] Server error:', err)
2160
+ if (dev && journalPath) writeErrorEntry(journalPath, { route: _req?.url || null, phase: 'server', message: err.message, stack: err.stack })
2008
2161
  if (res.headersSent) return
2009
2162
 
2010
2163
  const html = dev ? errorPage(err) : errorPage500()
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import http from 'http'
7
+ import fs from 'fs'
8
+ import os from 'os'
9
+ import path from 'path'
7
10
  import { createServer, TtlCache } from './index.js'
8
11
 
9
12
  // ---------------------------------------------------------------------------
@@ -543,13 +546,17 @@ const throwingSpec = {
543
546
  }
544
547
 
545
548
  await test('dev mode returns HTML error page on render error (string mode)', async () => {
546
- await withServer([throwingSpec], { stream: false, dev: true }, async (port) => {
549
+ // dev: true writes to the error journal at <root>/.pulse/errors.json — pass an
550
+ // isolated tmp root so this doesn't leave a file behind in the package directory.
551
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
552
+ await withServer([throwingSpec], { stream: false, dev: true, root: tmpRoot }, async (port) => {
547
553
  const { status, headers, body } = await get(port, '/boom')
548
554
  assert(status === 500, `Expected 500, got ${status}`)
549
555
  assert(headers['content-type']?.includes('text/html'), `Expected HTML, got ${headers['content-type']}`)
550
556
  assert(body.includes('view exploded'), `Expected error message in page: ${body.slice(0, 200)}`)
551
557
  assert(body.includes('Stack trace'), `Expected stack trace section in page`)
552
558
  })
559
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
553
560
  })
554
561
 
555
562
  await test('production mode returns generic error page (no stack trace)', async () => {
@@ -562,12 +569,14 @@ await test('production mode returns generic error page (no stack trace)', async
562
569
  })
563
570
 
564
571
  await test('dev mode injects error into stream when render throws (stream mode)', async () => {
565
- await withServer([throwingSpec], { stream: true, dev: true }, async (port) => {
572
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-boom-'))
573
+ await withServer([throwingSpec], { stream: true, dev: true, root: tmpRoot }, async (port) => {
566
574
  const { status, body } = await get(port, '/boom')
567
575
  assert(status === 200, `Stream mode commits 200 before error, got ${status}`)
568
576
  assert(body.includes('view exploded'), `Expected error message injected into stream: ${body.slice(0, 400)}`)
569
577
  assert(body.includes('pulse-root'), `Expected pulse-root in partial document`)
570
578
  })
579
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
571
580
  })
572
581
 
573
582
  await test('production stream mode hides error details', async () => {
@@ -986,6 +995,21 @@ await test('CSP nonce differs between requests', async () => {
986
995
  })
987
996
  })
988
997
 
998
+ await test('csp option extends trusted-types, merging with the base pulse policy', async () => {
999
+ // Regression: an agent assumed trusted-types/require-trusted-types-for
1000
+ // weren't extendable via createServer's csp option (unlike other
1001
+ // directives like img-src/style-src) because no guide demonstrated it —
1002
+ // buildCsp's merge loop is actually fully generic across all directive
1003
+ // keys, this was a documentation gap, not a real framework limitation.
1004
+ // Pin the real behavior so it can't silently regress.
1005
+ await withServer([canonicalSpec], { stream: false, csp: { 'trusted-types': ['google'] } }, async (port) => {
1006
+ const { headers } = await request(port, 'GET', '/about')
1007
+ const csp = headers['content-security-policy']
1008
+ assert(csp.includes('trusted-types pulse google'),
1009
+ `Expected trusted-types to merge with the base 'pulse' policy, got: ${csp}`)
1010
+ })
1011
+ })
1012
+
989
1013
  await test('CSP header present on streaming page response', async () => {
990
1014
  await withServer([canonicalSpec], { stream: true }, async (port) => {
991
1015
  const { headers } = await request(port, 'GET', '/about')
@@ -994,6 +1018,38 @@ await test('CSP header present on streaming page response', async () => {
994
1018
  })
995
1019
  })
996
1020
 
1021
+ // Cache-Control
1022
+ await test('HTML with no spec.cache/defaultCache gets no-cache, not no-store', async () => {
1023
+ // Regression: a real Lighthouse audit failed "Page prevented back/forward
1024
+ // cache restoration" with reason "cache-control:no-store" on a page that
1025
+ // had no cache config at all — the framework's old default was no-store,
1026
+ // which unconditionally blocks the browser's bfcache. no-cache still
1027
+ // forces revalidation on every direct load (no risk of serving stale
1028
+ // content) but does not block bfcache, since bfcache restoration never
1029
+ // touches the network.
1030
+ await withServer([canonicalSpec], { stream: false }, async (port) => {
1031
+ const { headers } = await request(port, 'GET', '/about')
1032
+ assert(headers['cache-control'] === 'no-cache',
1033
+ `Expected no-cache, got: ${headers['cache-control']}`)
1034
+ })
1035
+ })
1036
+
1037
+ await test('HTML respects spec.cache / defaultCache when configured', async () => {
1038
+ await withServer([helloSpec], { stream: false, defaultCache: 60 }, async (port) => {
1039
+ const { headers } = await request(port, 'GET', '/hello')
1040
+ assert(headers['cache-control']?.includes('max-age=60'),
1041
+ `Expected max-age=60, got: ${headers['cache-control']}`)
1042
+ })
1043
+ })
1044
+
1045
+ await test('dev mode always returns no-store regardless of cache config', async () => {
1046
+ await withServer([canonicalSpec], { stream: false, dev: true, defaultCache: 3600 }, async (port) => {
1047
+ const { headers } = await request(port, 'GET', '/about')
1048
+ assert(headers['cache-control'] === 'no-store',
1049
+ `Expected no-store in dev mode, got: ${headers['cache-control']}`)
1050
+ })
1051
+ })
1052
+
997
1053
  // HSTS
998
1054
  await test('HSTS header absent on plain HTTP', async () => {
999
1055
  await withServer([canonicalSpec], { stream: false }, async (port) => {
@@ -1800,6 +1856,32 @@ await test('hydrated pages receive the __PULSE_LIVE__ flag; disabled servers do
1800
1856
  })
1801
1857
  })
1802
1858
 
1859
+ await test('a store-only URL-entry spec (no local mutations/actions/persist, no explicit hydrate) still auto-derives a hydrate URL — regression: mount() is what wires up data-store-event dispatch AND the live SSE store-push subscription; without this a store-only page silently ships zero JS and never receives pushStore() broadcasts', async () => {
1860
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-needshydration-store-test-'))
1861
+ const specPath = path.join(dir, 'storey.js')
1862
+ fs.writeFileSync(
1863
+ specPath,
1864
+ `export default {\n route: '/storey',\n store: ['count'],\n state: {},\n view: (state, server) => \`\${server.count ?? 0}\`,\n}\n`
1865
+ )
1866
+ try {
1867
+ const port = nextPort++
1868
+ const { server } = await createServer([new URL('file://' + specPath)], { stream: false, live: true, port, root: new URL('file://' + dir + '/') })
1869
+ await new Promise(resolve => server.once('listening', resolve))
1870
+ try {
1871
+ const { body } = await get(port, '/storey')
1872
+ // A store-only page with no auto-derived hydrate would render with no
1873
+ // module script and no __PULSE_LIVE__ flag at all — both must be present.
1874
+ assert(body.includes('<script type="module"'), `Expected a hydration script tag, got: ${body.slice(0, 800)}`)
1875
+ assert(body.includes('__PULSE_LIVE__'), `Expected the live-store flag (only emitted for hydrated pages), got: ${body.slice(0, 800)}`)
1876
+ } finally {
1877
+ server.closeAllConnections?.()
1878
+ await new Promise(resolve => server.close(resolve))
1879
+ }
1880
+ } finally {
1881
+ fs.rmSync(dir, { recursive: true, force: true })
1882
+ }
1883
+ })
1884
+
1803
1885
  // ---------------------------------------------------------------------------
1804
1886
 
1805
1887
  console.log('\nMalformed request handling\n')
@@ -1906,6 +1988,147 @@ await test('serves brotli-compressed HTML when br is accepted', async () => {
1906
1988
  })
1907
1989
 
1908
1990
  // ---------------------------------------------------------------------------
1991
+ // Route shadowing warning
1992
+ // ---------------------------------------------------------------------------
1993
+ // Regression: found via dogfooding. Route matching is first-match-wins in
1994
+ // registration order with no specificity ranking (see matchRoute) — a dynamic
1995
+ // route registered before a same-depth static route silently swallows every
1996
+ // request to that static route, with no error and no warning. discoverPages'
1997
+ // file-order is not guaranteed alphabetical across platforms, so this is a
1998
+ // real, easy-to-hit trap for any project mixing a catch-all-ish :param route
1999
+ // with static pages at the same path depth.
2000
+
2001
+ console.log('\nRoute shadowing\n')
2002
+
2003
+ const dynamicFirstSpec = { route: '/:code', state: {}, view: () => '' }
2004
+ const staticShadowedSpec = { route: '/settings', state: {}, view: () => '' }
2005
+ const unrelatedSpec = { route: '/about', state: {}, view: () => '' }
2006
+
2007
+ // The warning fires synchronously inside createServer, before it starts
2008
+ // listening — capture around the whole createServer call, then tear the
2009
+ // server down immediately (nothing needs to actually run against it).
2010
+ async function warningsFromCreateServer(specs) {
2011
+ const original = console.warn
2012
+ const messages = []
2013
+ console.warn = (msg) => messages.push(msg)
2014
+ const port = nextPort++
2015
+ let server
2016
+ try {
2017
+ ;({ server } = await createServer(specs, { port, stream: false, quiet: true }))
2018
+ await new Promise(resolve => server.once('listening', resolve))
2019
+ } finally {
2020
+ console.warn = original
2021
+ if (server) {
2022
+ server.closeAllConnections?.()
2023
+ await new Promise(resolve => server.close(resolve))
2024
+ }
2025
+ }
2026
+ return messages
2027
+ }
2028
+
2029
+ await test('warns when a dynamic route is registered before a static route it shadows', async () => {
2030
+ const warnings = await warningsFromCreateServer([dynamicFirstSpec, staticShadowedSpec])
2031
+ assert(warnings.some(w => w.includes('/:code') && w.includes('/settings') && w.includes('never be reached')),
2032
+ `Expected a shadowing warning naming both routes, got: ${JSON.stringify(warnings)}`)
2033
+ })
2034
+
2035
+ await test('no warning when the static route is registered first (correct order)', async () => {
2036
+ const warnings = await warningsFromCreateServer([staticShadowedSpec, dynamicFirstSpec])
2037
+ assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
2038
+ })
2039
+
2040
+ await test('no warning between two unrelated static routes', async () => {
2041
+ const warnings = await warningsFromCreateServer([staticShadowedSpec, unrelatedSpec])
2042
+ assert(warnings.length === 0, `Expected no warnings, got: ${JSON.stringify(warnings)}`)
2043
+ })
2044
+
2045
+ // ---------------------------------------------------------------------------
2046
+
2047
+ console.log('\nError journal\n')
2048
+
2049
+ function makeTmpRoot() {
2050
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-error-journal-'))
2051
+ }
2052
+
2053
+ function readJournal(root) {
2054
+ const p = path.join(root, '.pulse', 'errors.json')
2055
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null
2056
+ }
2057
+
2058
+ const journalThrowingSpec = {
2059
+ route: '/boom-journal',
2060
+ state: {},
2061
+ view: () => { throw new Error('deliberate view failure') },
2062
+ }
2063
+
2064
+ await test('server error in dev mode writes an entry to the journal', async () => {
2065
+ const root = makeTmpRoot()
2066
+ await withServer([journalThrowingSpec], { stream: false, dev: true, root }, async (port) => {
2067
+ await get(port, '/boom-journal')
2068
+ const entries = readJournal(root)
2069
+ assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
2070
+ assert(entries[0].phase === 'server', `Expected phase "server", got ${entries[0].phase}`)
2071
+ assert(entries[0].message.includes('deliberate view failure'), `Message missing detail: ${entries[0].message}`)
2072
+ assert(entries[0].resolved === false, 'New entry should be unresolved')
2073
+ })
2074
+ fs.rmSync(root, { recursive: true, force: true })
2075
+ })
2076
+
2077
+ await test('server error NOT in dev mode does not write a journal entry', async () => {
2078
+ const root = makeTmpRoot()
2079
+ await withServer([journalThrowingSpec], { stream: false, dev: false, root }, async (port) => {
2080
+ await get(port, '/boom-journal')
2081
+ const entries = readJournal(root)
2082
+ assert(entries === null, `Expected no journal file in prod mode, got ${JSON.stringify(entries)}`)
2083
+ })
2084
+ fs.rmSync(root, { recursive: true, force: true })
2085
+ })
2086
+
2087
+ await test('POST /__pulse/error in dev mode writes a client-reported entry', async () => {
2088
+ const root = makeTmpRoot()
2089
+ await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
2090
+ const { status } = await requestWithBody(
2091
+ port, 'POST', '/__pulse/error',
2092
+ JSON.stringify({ route: '/dashboard', phase: 'action', message: 'action "save" failed: boom', stack: 'Error: boom\n at save' }),
2093
+ 'application/json'
2094
+ )
2095
+ assert(status === 204, `Expected 204, got ${status}`)
2096
+ const entries = readJournal(root)
2097
+ assert(entries && entries.length === 1, `Expected 1 journal entry, got ${JSON.stringify(entries)}`)
2098
+ assert(entries[0].route === '/dashboard', `Expected route /dashboard, got ${entries[0].route}`)
2099
+ assert(entries[0].phase === 'action', `Expected phase action, got ${entries[0].phase}`)
2100
+ })
2101
+ fs.rmSync(root, { recursive: true, force: true })
2102
+ })
2103
+
2104
+ await test('POST /__pulse/error in production returns 404 — no dev-only endpoint exposed', async () => {
2105
+ const root = makeTmpRoot()
2106
+ await withServer([helloSpec], { stream: false, dev: false, root }, async (port) => {
2107
+ const { status } = await requestWithBody(port, 'POST', '/__pulse/error', JSON.stringify({}), 'application/json')
2108
+ assert(status === 404, `Expected 404 in production, got ${status}`)
2109
+ assert(readJournal(root) === null, 'No journal file should exist in production')
2110
+ })
2111
+ fs.rmSync(root, { recursive: true, force: true })
2112
+ })
2113
+
2114
+ await test('error journal is capped at 50 entries (ring buffer)', async () => {
2115
+ const root = makeTmpRoot()
2116
+ await withServer([helloSpec], { stream: false, dev: true, root }, async (port) => {
2117
+ for (let i = 0; i < 55; i++) {
2118
+ await requestWithBody(
2119
+ port, 'POST', '/__pulse/error',
2120
+ JSON.stringify({ route: '/x', phase: 'view', message: `error ${i}`, stack: '' }),
2121
+ 'application/json'
2122
+ )
2123
+ }
2124
+ const entries = readJournal(root)
2125
+ assert(entries.length === 50, `Expected 50 entries (capped), got ${entries.length}`)
2126
+ // Oldest entries should have been dropped — the most recent should remain
2127
+ assert(entries[entries.length - 1].message === 'error 54', `Expected newest entry to survive, got ${entries[entries.length - 1].message}`)
2128
+ assert(entries[0].message === 'error 5', `Expected oldest surviving entry to be "error 5", got ${entries[0].message}`)
2129
+ })
2130
+ fs.rmSync(root, { recursive: true, force: true })
2131
+ })
1909
2132
 
1910
2133
  console.log('\nGraceful shutdown\n')
1911
2134
 
@@ -428,7 +428,7 @@ export function validateSpec(spec) {
428
428
  // the framework reading spec.meta.vibe directly — this check catches unknown keys)
429
429
  if (spec.meta && typeof spec.meta === 'object' && !Array.isArray(spec.meta)) {
430
430
  const KNOWN_META_KEYS = new Set([
431
- 'title', 'description', 'styles', 'scripts', 'theme', 'vibe',
431
+ 'title', 'description', 'styles', 'deferredStyles', 'scripts', 'theme', 'vibe',
432
432
  'ogTitle', 'ogDescription', 'ogImage', 'canonical', 'schema',
433
433
  'robots', 'viewport', 'charset', 'lang',
434
434
  ])
package/src/ui/index.js CHANGED
@@ -22,8 +22,6 @@ export { button } from './button.js'
22
22
  export { badge } from './badge.js'
23
23
  export { card } from './card.js'
24
24
  export { input } from './input.js'
25
- export { search } from './search.js'
26
- export { fieldset } from './fieldset.js'
27
25
  export { select } from './select.js'
28
26
  export { textarea } from './textarea.js'
29
27
  export { alert } from './alert.js'
@@ -32,7 +30,6 @@ export { avatar } from './avatar.js'
32
30
  export { empty } from './empty.js'
33
31
  export { table } from './table.js'
34
32
  export { hero } from './hero.js'
35
- export { phoneFrame } from './phone-frame.js'
36
33
  export { feature } from './feature.js'
37
34
  export { accordion } from './accordion.js'
38
35
  export { nav } from './nav.js'
@@ -44,7 +41,6 @@ export { stack } from './stack.js'
44
41
  export { cluster } from './cluster.js'
45
42
  export { divider } from './divider.js'
46
43
  export { media } from './media.js'
47
- export { tooltip } from './tooltip.js'
48
44
  export { modal, modalTrigger } from './modal.js'
49
45
  export { cta } from './cta.js'
50
46
  export { footer } from './footer.js'
@@ -55,7 +51,6 @@ export { rating } from './rating.js'
55
51
  export { spinner } from './spinner.js'
56
52
  export { progress } from './progress.js'
57
53
  export { slider } from './slider.js'
58
- export { segmented } from './segmented.js'
59
54
  export { fileUpload } from './fileupload.js'
60
55
  export { breadcrumbs } from './breadcrumbs.js'
61
56
  export { stepper } from './stepper.js'