@invisibleloop/pulse 0.19.0 → 0.20.1

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.
package/CLAUDE.md CHANGED
@@ -75,8 +75,10 @@ export const mySpec = {
75
75
  // async () => [...] → enumerate a dynamic :param route's URLs
76
76
  sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
77
77
 
78
- // Global store keys this page subscribes to — appears in view's server arg
79
- // Store mutations update all subscribed pages without a server round-trip
78
+ // Global store keys this page subscribes to — appears in view's server arg.
79
+ // The store is defined in pulse.store.js at the project root — AUTO-DISCOVERED
80
+ // by pulse dev/start (no registration step). Mutations update all subscribed
81
+ // pages without a server round-trip.
80
82
  store: ['user', 'cart'],
81
83
 
82
84
  // Initial client state — deep cloned on mount, never mutated directly
@@ -298,6 +300,8 @@ await createServer(
298
300
  '/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
299
301
  '/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
300
302
  }, // checked before routing (GET/HEAD only); validated at startup
303
+ live: true, // SSE store-push channel (default path /__pulse/live) —
304
+ // createServer returns pushStore(partial) for broadcasting
301
305
  csp: { // extra sources merged into the framework's default CSP
302
306
  'style-src': ['https://fonts.googleapis.com'],
303
307
  'font-src': ['https://fonts.gstatic.com'],
@@ -314,6 +318,37 @@ await createServer(
314
318
 
315
319
  All specs are validated at startup — bad specs throw before the server accepts connections.
316
320
 
321
+ ## Live Store Push (SSE)
322
+
323
+ With `live: true`, `createServer` returns a **`pushStore(partial)`** function that broadcasts a store patch to every connected browser. Pages subscribed via `spec.store` re-render immediately — no polling, no client code:
324
+
325
+ ```js
326
+ // Enable in pulse.config.js (CLI-managed apps) or createServer options:
327
+ export default { live: true }
328
+ ```
329
+
330
+ ```js
331
+ // Broadcast from any spec file — e.g. a webhook that updates stock:
332
+ import { pushStore } from '@invisibleloop/pulse'
333
+
334
+ export default {
335
+ route: '/hooks/stock',
336
+ contentType: 'text/plain',
337
+ state: {},
338
+ render: async (ctx) => {
339
+ const { sku, stock } = await ctx.json()
340
+ pushStore({ stock }) // every page with store: ['stock'] re-renders
341
+ return 'ok'
342
+ },
343
+ }
344
+ ```
345
+
346
+ When you own the server entry, `createServer` also returns the handle directly: `const { pushStore } = await createServer(pages, { live: true })`.
347
+
348
+ - The client connects automatically — only on pages that declare `store: [...]` keys; one EventSource per tab, survives client navigations, reconnects natively.
349
+ - **Broadcast channel: shared data only** (stock counts, announcements, live scores). Never push per-user data — every connected client receives every patch.
350
+ - Views must handle keys that haven't arrived yet: `server.stock ?? '—'`.
351
+
317
352
  ## Multi-brand Sites
318
353
 
319
354
  Pass `resolveBrand: async (host) => brandConfig` to `createServer`. The result is cached per host for 60 seconds and attached to `ctx.brand`. It is available in `guard`, `server` fetchers, and any `meta` field that is a function.
@@ -52,6 +52,7 @@ await createServer(entries, options)`, 'js'))}
52
52
  ['<code>sitemap</code>', '<code>boolean | { origin }</code>', '<code>false</code>', 'Auto-serve <code>/sitemap.xml</code> + <code>robots.txt</code> from the registered routes. Dynamic <code>:param</code> routes contribute via <code>spec.sitemap</code> enumerators. See <a href="/meta#sitemap">Metadata &amp; SEO</a>.'],
53
53
  ['<code>robots</code>', '<code>string | false</code>', 'auto', 'robots.txt behaviour when sitemap is enabled — <code>false</code> disables, a string is served verbatim.'],
54
54
  ['<code>redirects</code>', '<code>object</code>', '<code>null</code>', 'Declarative redirect map for legacy URLs — <code>{ \'/old/:slug\': \'/new/:slug\' }</code>, 301 by default. See <a href="/routing#redirects">Routing</a>.'],
55
+ ['<code>live</code>', '<code>boolean | { path }</code>', '<code>false</code>', 'SSE store-push channel. The returned <code>pushStore(partial)</code> broadcasts store patches; subscribed pages re-render live. See <a href="/store#live">Global Store</a>.'],
55
56
  ['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
56
57
  ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
57
58
  ]
@@ -50,16 +50,15 @@ export default {
50
50
  }`, 'js'))}
51
51
 
52
52
  ${section('registering', 'Registering the store')}
53
- <p>Pass your store to <code>createServer</code> via the <code>store</code> option. The store is validated at startup — bad fetchers throw before the server accepts connections:</p>
53
+ <p><strong>There is no registration step.</strong> <code>pulse dev</code> and <code>pulse start</code> auto-discover <code>pulse.store.js</code> from the project root — the same way pages are discovered from <code>src/pages/</code>. The store is loaded server-side, served to the browser for mutations, validated at startup (bad fetchers fail the boot), and hot-reloaded in dev when you edit it.</p>
54
+ <p>Only when you run your own server entry do you pass it manually:</p>
54
55
  ${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
55
56
  import store from './pulse.store.js'
56
- import { dashboardSpec } from './src/pages/dashboard.js'
57
- import { settingsSpec } from './src/pages/settings.js'
58
57
 
59
- createServer([dashboardSpec, settingsSpec], {
58
+ createServer(pages, {
60
59
  port: 3000,
61
60
  staticDir: 'public',
62
- store, // ← register the global store
61
+ store, // ← manual server entry only
63
62
  })`, 'js'))}
64
63
 
65
64
  ${section('using', 'Using store data in a page')}
@@ -163,6 +162,28 @@ export default {
163
162
  <p>Any other page that has <code>store: ['settings']</code> will re-render with the new theme value the moment <code>_storeUpdate</code> is dispatched — without navigating away or refreshing.</p>
164
163
  ${callout('note', '<code>_storeUpdate</code> is stripped from the local page state — it is only forwarded to the store. The rest of the <code>onSuccess</code> return is merged into the page\'s own state as usual.')}
165
164
 
165
+ ${section('live', 'Live store push — server-initiated updates')}
166
+ <p>For data that changes on the <strong>server</strong> — stock levels, live scores, announcements — enable the live channel and broadcast with <code>pushStore</code>. Subscribed pages re-render the moment a patch arrives, with no polling and no client code:</p>
167
+ ${codeBlock(highlight(`// pulse.config.js — enable the channel
168
+ export default { live: true }`, 'js'))}
169
+ ${codeBlock(highlight(`// Broadcast from any spec file — e.g. a webhook that updates stock:
170
+ import { pushStore } from '@invisibleloop/pulse'
171
+
172
+ export default {
173
+ route: '/hooks/stock',
174
+ contentType: 'text/plain',
175
+ state: {},
176
+ render: async (ctx) => {
177
+ const { stock } = await ctx.json()
178
+ pushStore({ stock }) // pages with store: ['stock'] re-render
179
+ return 'ok'
180
+ },
181
+ }`, 'js'))}
182
+ <p>When you own the server entry, <code>createServer</code> also returns the handle directly: <code>const { pushStore } = await createServer(pages, { live: true })</code>.</p>
183
+ <p>The browser side is automatic: pages that declare <code>store: [...]</code> keys open a single <code>EventSource</code> connection per tab (default endpoint <code>/__pulse/live</code>). The connection survives client-side navigations and reconnects natively after network interruptions. Pages without store keys never connect.</p>
184
+ ${callout('warning', 'The live channel is a <strong>broadcast</strong> — every connected client receives every patch. Push <strong>shared</strong> data only (stock, announcements, scores). For per-user updates, use page actions with <code>_storeUpdate</code> instead.')}
185
+ ${callout('note', 'Views must handle keys that haven\'t arrived yet — the first patch may land seconds after page load. Write <code>server.stock ?? \'—\'</code>, not <code>server.stock</code>. The page must also be hydrated (have mutations or actions); purely server-rendered pages have no JavaScript to receive patches.')}
186
+
166
187
  ${section('caching', 'Caching and performance')}
167
188
  <p>Store fetchers run once per request, in parallel. They share the same request context as page server fetchers, so they can read cookies, params, and headers to scope data to the current user.</p>
168
189
  <p>If your store data changes infrequently (nav items from a CMS, feature flags), consider adding a <code>serverTtl</code> to the relevant page spec to cache the full rendered HTML — or caching inside the fetcher itself:</p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.19.0
1
+ 0.20.1
package/scripts/build.js CHANGED
@@ -34,7 +34,10 @@ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
34
34
  * from being shipped to and evaluated in the browser
35
35
  * - avoids bundling errors when server imports use Node-only modules
36
36
  */
37
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
37
+ // NOTE: spec.submit is also server-only but must NOT be listed here — the key
38
+ // scanner is line-anchored at any nesting depth, and `submit:` is the most
39
+ // common ACTION name (actions: { submit: {…} }), which would get stripped too.
40
+ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
38
41
 
39
42
  /**
40
43
  * Pulse subpath exports that are server-only (use Node built-ins).
@@ -351,17 +354,22 @@ function scanTemplateLiteral(src, pos) {
351
354
 
352
355
  /**
353
356
  * esbuild plugin — strips server-only spec properties before bundling.
354
- * Only transforms files under src/pages/ to avoid touching runtime/server code.
357
+ * Transforms files under src/pages/ plus the global store file (pulse.store.js
358
+ * carries server fetchers that must never reach the browser) — never touches
359
+ * runtime/server code.
355
360
  *
356
- * @param {string} pagesDir Absolute path to src/pages/
361
+ * @param {string} pagesDir Absolute path to src/pages/
362
+ * @param {string|null} storePath Absolute path to pulse.store.js, if it exists
357
363
  */
358
- function createStripServerPlugin(pagesDir) {
364
+ function createStripServerPlugin(pagesDir, storePath = null) {
359
365
  return {
360
366
  name: 'pulse-strip-server',
361
367
  setup(build) {
362
368
  build.onLoad({ filter: /\.js$/ }, (args) => {
363
- if (!args.path.startsWith(pagesDir + path.sep) &&
364
- args.path !== pagesDir.replace(/\/$/, '') + '.js') return undefined
369
+ const isPage = args.path.startsWith(pagesDir + path.sep) ||
370
+ args.path === pagesDir.replace(/\/$/, '') + '.js'
371
+ const isStore = storePath && args.path === storePath
372
+ if (!isPage && !isStore) return undefined
365
373
 
366
374
  const source = fs.readFileSync(args.path, 'utf8')
367
375
  const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
@@ -435,6 +443,11 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
435
443
 
436
444
  const PAGES_DIR = path.join(ROOT, 'src', 'pages')
437
445
 
446
+ // Global store — bundled into boot files (server fetchers stripped) when present
447
+ const STORE_PATH = fs.existsSync(path.join(ROOT, 'pulse.store.js'))
448
+ ? path.join(ROOT, 'pulse.store.js')
449
+ : null
450
+
438
451
  const bootstrapFiles = pages.flatMap(({ filePath }) => {
439
452
  // Strip server-only keys/imports to check what's left for the client.
440
453
  // Skip specs with no view — these are API routes or raw content specs
@@ -453,15 +466,22 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
453
466
  const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
454
467
  const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
455
468
 
469
+ // Global store — when pulse.store.js exists, every boot bundle imports it and
470
+ // passes it to mount() so client store mutations register in production.
471
+ // The strip plugin removes its server fetchers from the browser bundle;
472
+ // esbuild's shared-chunk splitting keeps it from being duplicated per page.
473
+ const storeImport = STORE_PATH ? `\nimport store from '${path.relative(TMP_DIR, STORE_PATH)}'` : ''
474
+ const mountOpts = STORE_PATH ? `{ ssr: true, store }` : `{ ssr: true }`
475
+
456
476
  fs.writeFileSync(bootstrapPath, `\
457
477
  import spec from '${relSpec}'
458
478
  import { mount } from '${relRuntime}'
459
- import { initNavigation } from '${relNavigate}'
479
+ import { initNavigation } from '${relNavigate}'${storeImport}
460
480
 
461
481
  const root = document.getElementById('pulse-root')
462
482
  if (root && !root.dataset.pulseMounted) {
463
483
  root.dataset.pulseMounted = '1'
464
- mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
484
+ mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
465
485
  initNavigation(root, mount)
466
486
  }
467
487
 
@@ -488,7 +508,7 @@ const result = await esbuild.build({
488
508
  metafile: true,
489
509
  sourcemap: false,
490
510
  treeShaking: true,
491
- plugins: [createStripServerPlugin(PAGES_DIR)],
511
+ plugins: [createStripServerPlugin(PAGES_DIR, STORE_PATH)],
492
512
  define: {
493
513
  'process.env.NODE_ENV': '"production"'
494
514
  }
@@ -7,7 +7,7 @@
7
7
  // Inline the scanner functions (duplicated here to keep test self-contained)
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
10
+ const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
11
11
  const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
12
12
 
13
13
  function stripServerOnlyKeys(source) {
@@ -398,5 +398,24 @@ devTest('leaves module-level constants intact',
398
398
 
399
399
  // ---------------------------------------------------------------------------
400
400
 
401
+ console.log('\nStore + new spec fields\n')
402
+
403
+ test('sitemap enumerator is stripped (server-only)',
404
+ `export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
405
+ `export default {\n route: '/blog/:slug',\n view: () => '',\n}`
406
+ )
407
+
408
+ test('submit is NOT stripped — it collides with the common submit action name',
409
+ `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
410
+ `export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
411
+ )
412
+
413
+ test('store definition: server fetchers stripped, state and mutations survive',
414
+ `export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
415
+ `export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
416
+ )
417
+
418
+ // ---------------------------------------------------------------------------
419
+
401
420
  console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
402
421
  if (fail > 0) process.exit(1)
@@ -18,12 +18,10 @@ Only declared keys are saved. Restored values that differ from the spec default
18
18
 
19
19
  The global store is a shared data layer. Server fetchers run per request; mutations run on the client and broadcast to all subscribed pages without a server round-trip.
20
20
 
21
- **Define the store** in `pulse.store.js`:
21
+ **Define the store** in `pulse.store.js` at the project root — that's the whole setup. The CLI auto-discovers it (like pages in `src/pages/`), loads it server-side, serves it to the browser, and hot-reloads it on edit:
22
22
  ```js
23
23
  // pulse.store.js
24
24
  export default {
25
- hydrate: '/pulse.store.js', // browser-importable path — required for mutations
26
-
27
25
  state: { // default/fallback values
28
26
  user: null,
29
27
  settings: { theme: 'dark', lang: 'en' },
@@ -45,10 +43,10 @@ export default {
45
43
  }
46
44
  ```
47
45
 
48
- **Register the store** in your server file:
46
+ **No registration step is needed** with `pulse dev` / `pulse start` — `pulse.store.js` is auto-discovered. Only when you call `createServer` yourself do you pass it manually:
49
47
  ```js
50
48
  import store from './pulse.store.js'
51
- createServer([...specs], { store })
49
+ createServer([...specs], { store }) // manual server entry only
52
50
  ```
53
51
 
54
52
  **Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
@@ -102,6 +100,50 @@ actions: {
102
100
  ```
103
101
  `_storeUpdate` is stripped from local page state — it is only forwarded to the store. The rest of `onSuccess` merges into the page's own state as normal.
104
102
 
103
+ ### Live store push — server-initiated updates (SSE)
104
+
105
+ For data that changes on the **server** (stock levels, live scores, announcements), enable the live channel and broadcast with `pushStore` — subscribed pages re-render without polling or any client code:
106
+
107
+ ```js
108
+ // pulse.config.js — enable the channel (CLI-managed apps)
109
+ export default { live: true }
110
+ ```
111
+
112
+ ```js
113
+ // any spec file — broadcast via the module-level import (no server handle needed)
114
+ import { pushStore } from '@invisibleloop/pulse'
115
+
116
+ export default {
117
+ route: '/hooks/stock',
118
+ contentType: 'text/plain',
119
+ state: {},
120
+ render: async (ctx) => {
121
+ const { stock } = await ctx.json()
122
+ pushStore({ stock }) // every mounted page with store: ['stock'] re-renders
123
+ return 'ok'
124
+ },
125
+ }
126
+ ```
127
+
128
+ When you own the server entry, `createServer` also returns the handle: `const { pushStore } = await createServer(pages, { live: true })`.
129
+
130
+ ```js
131
+ // page spec — subscribes like any store key
132
+ export default {
133
+ route: '/product',
134
+ store: ['stock'],
135
+ state: {},
136
+ mutations: { noop: (s) => s }, // any mutation/action → page is hydrated
137
+ view: (state, server) => `<p>${server.stock ?? '—'} in stock</p>`,
138
+ }
139
+ ```
140
+
141
+ **Rules:**
142
+ - **Shared data only.** The channel is a broadcast — every connected client receives every patch. Never push per-user data (use page actions + `_storeUpdate` for that).
143
+ - Views must handle keys that haven't arrived yet (`server.stock ?? '—'`) — the first patch may land seconds after page load.
144
+ - The page must be hydrated (have mutations/actions) — purely server-rendered pages have no JS to receive patches.
145
+ - The connection is automatic and lazy: only pages with `store: [...]` keys open it, one EventSource per tab, native auto-reconnect.
146
+
105
147
  ## Server-side forms — `spec.submit` (works without client JS)
106
148
 
107
149
  A spec with a `submit` handler accepts POST on its own route. The form works with JavaScript disabled — hydrated `actions` become an enhancement, not a requirement.
@@ -105,6 +105,59 @@ for (const skill of requiredSkills) {
105
105
  })
106
106
  }
107
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Store auto-discovery (loadStore)
110
+ // ---------------------------------------------------------------------------
111
+ // Regression: pulse.store.js was documented but never loaded by the CLI —
112
+ // dev/start servers got no store, so spec.store keys were always empty.
113
+
114
+ console.log('\nStore auto-discovery\n')
115
+
116
+ {
117
+ const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
118
+ const { tmpdir } = await import('node:os')
119
+ const { loadStore } = await import('./discover.js')
120
+
121
+ const dir = mkdtempSync(path.join(tmpdir(), 'pulse-store-'))
122
+ try {
123
+ // No store file → null
124
+ const none = await loadStore(dir)
125
+ test('loadStore returns null when pulse.store.js is absent', () => {
126
+ if (none !== null) throw new Error(`Expected null, got ${JSON.stringify(none)}`)
127
+ })
128
+
129
+ // Valid store file → definition with browser hydrate path set
130
+ writeFileSync(path.join(dir, 'pulse.store.js'),
131
+ `export default { state: { basket: [] }, server: { nav: async () => ['Home'] }, mutations: { add: (s, i) => ({ basket: [...s.basket, i] }) } }`)
132
+ const def = await loadStore(dir)
133
+ test('loadStore loads pulse.store.js and sets the browser hydrate path', () => {
134
+ if (!def) throw new Error('Expected a store definition')
135
+ if (def.hydrate !== '/pulse.store.js') throw new Error(`Expected hydrate /pulse.store.js, got ${def.hydrate}`)
136
+ if (typeof def.server?.nav !== 'function') throw new Error('server fetchers missing')
137
+ if (typeof def.mutations?.add !== 'function') throw new Error('mutations missing')
138
+ if (!Array.isArray(def.state?.basket)) throw new Error('state missing')
139
+ })
140
+
141
+ // Cache-busted re-import returns the edited module (dev hot reload)
142
+ writeFileSync(path.join(dir, 'pulse.store.js'),
143
+ `export default { state: { basket: [], promo: true }, mutations: {} }`)
144
+ const fresh = await loadStore(dir, Date.now())
145
+ test('loadStore cache-busts for hot reload', () => {
146
+ if (fresh?.state?.promo !== true) throw new Error('Expected fresh store definition after edit')
147
+ })
148
+
149
+ // Bad export → clear error
150
+ writeFileSync(path.join(dir, 'pulse.store.js'), `export default 42`)
151
+ let threw = false
152
+ try { await loadStore(dir, Date.now() + 1) } catch (e) { threw = /store object/.test(e.message) }
153
+ test('loadStore throws a clear error for a non-object export', () => {
154
+ if (!threw) throw new Error('Expected a descriptive throw for bad default export')
155
+ })
156
+ } finally {
157
+ rmSync(dir, { recursive: true, force: true })
158
+ }
159
+ }
160
+
108
161
  // ---------------------------------------------------------------------------
109
162
 
110
163
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
package/src/cli/dev.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import path from 'path'
12
12
  import fs from 'fs'
13
13
  import { createServer } from '../server/index.js'
14
- import { loadPages } from './discover.js'
14
+ import { loadPages, loadStore } from './discover.js'
15
15
  import * as log from './logger.js'
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -109,6 +109,10 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
109
109
 
110
110
  const specs = await loadPages(ROOT)
111
111
 
112
+ // Auto-discover the global store — pulse.store.js in the project root.
113
+ // Mirrors page discovery; no custom server entry needed.
114
+ const store = await loadStore(ROOT)
115
+
112
116
  if (specs.length === 0) {
113
117
  log.error('No pages found in src/pages/. Create a page to get started.')
114
118
  process.exit(1)
@@ -192,6 +196,9 @@ async function triggerReload(label = 'File changed') {
192
196
  try {
193
197
  const fresh = await loadPages(ROOT, now)
194
198
  updateSpecs(fresh)
199
+ // Hot-swap the store too — pulse.store.js edits take effect like spec edits
200
+ const freshStore = await loadStore(ROOT, now)
201
+ updateStore(freshStore)
195
202
  log.info(`${label} — specs reloaded`)
196
203
  checkUndefinedClasses(fresh)
197
204
  } catch (err) {
@@ -204,6 +211,11 @@ async function triggerReload(label = 'File changed') {
204
211
  // Watch src/ for changes to existing files (spec edits, component edits)
205
212
  fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
206
213
 
214
+ // Watch the store file — it lives in the project root, outside src/
215
+ if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
216
+ fs.watch(path.join(ROOT, 'pulse.store.js'), () => triggerReload('Store changed'))
217
+ }
218
+
207
219
  // Also watch the pages directory explicitly so macOS reliably fires on new file creation.
208
220
  // On macOS, fs.watch with recursive=true watches subdirectory contents but may not fire
209
221
  // for the parent dir entry when a brand-new file is created. Watching the dir non-recursively
@@ -227,7 +239,7 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
227
239
  })();
228
240
  </script>`
229
241
 
230
- const { updateSpecs } = await createServer(specs, {
242
+ const { updateSpecs, updateStore } = await createServer(specs, {
231
243
  port: PORT,
232
244
  stream: true,
233
245
  staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
@@ -235,7 +247,12 @@ const { updateSpecs } = await createServer(specs, {
235
247
  extraBody: reloadScript,
236
248
  dev: true,
237
249
  agentMode: !!process.env.PULSE_AGENT_MODE,
250
+ ...(store ? { store } : {}),
238
251
  ...(_config.csp ? { csp: _config.csp } : {}),
252
+ // Server options forwarded verbatim from pulse.config.js so dev behaves like
253
+ // production — keep in sync with start.js PASSTHROUGH_OPTIONS
254
+ ...(['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody'].reduce(
255
+ (acc, k) => (_config[k] !== undefined ? { ...acc, [k]: _config[k] } : acc), {})),
239
256
 
240
257
  onRequest(req, res) {
241
258
  const url = req.url.split('?')[0]
@@ -281,6 +298,11 @@ const { updateSpecs } = await createServer(specs, {
281
298
  return serveFile(filePath)
282
299
  }
283
300
 
301
+ // Global store — pulse.store.js lives in the project root (not src/), and the
302
+ // dev hydrate bootstrap imports it so client store mutations register.
303
+ // serveFile strips node:/server-only imports, same treatment as page specs.
304
+ if (url === '/pulse.store.js' && serveFile(path.join(ROOT, 'pulse.store.js'))) return false
305
+
284
306
  // Project source — /src/pages/, /src/components/, /src/lib/, etc.
285
307
  // Falls back to framework source for imports like /src/ui/ that sub-projects
286
308
  // resolve from relative paths (e.g. docs component pages → ../../../../src/ui/).
@@ -111,3 +111,29 @@ export async function loadPages(projectRoot, bust = 0) {
111
111
 
112
112
  return specs
113
113
  }
114
+
115
+ /**
116
+ * Auto-discover the global store definition from pulse.store.js in the
117
+ * project root. Returns the store definition with `hydrate` set to its
118
+ * browser-importable path (used by the dev inline bootstrap), or null when
119
+ * the file doesn't exist.
120
+ *
121
+ * Mirrors page auto-discovery: pulse.store.js is the documented filename
122
+ * convention, so CLI-managed projects (pulse dev / pulse start) get the
123
+ * store without writing a custom server entry.
124
+ *
125
+ * @param {string} projectRoot
126
+ * @returns {Promise<object|null>}
127
+ */
128
+ export async function loadStore(projectRoot, cacheBust = null) {
129
+ const storePath = path.join(projectRoot, 'pulse.store.js')
130
+ if (!fs.existsSync(storePath)) return null
131
+
132
+ const url = 'file://' + storePath + (cacheBust ? `?t=${cacheBust}` : '')
133
+ const mod = await import(url)
134
+ const def = mod.default
135
+ if (!def || typeof def !== 'object') {
136
+ throw new Error('pulse.store.js must export a default store object ({ state?, server?, mutations? })')
137
+ }
138
+ return { ...def, hydrate: def.hydrate || '/pulse.store.js' }
139
+ }
package/src/cli/start.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import path from 'path'
12
12
  import fs from 'fs'
13
13
  import { createServer } from '../server/index.js'
14
- import { loadPages } from './discover.js'
14
+ import { loadPages, loadStore } from './discover.js'
15
15
 
16
16
  const args = process.argv.slice(2)
17
17
  const rootArg = args.indexOf('--root')
@@ -44,6 +44,10 @@ if (!fs.existsSync(manifestPath)) {
44
44
  let port = portArg !== -1 ? parseInt(args[portArg + 1], 10) : null
45
45
  let defaultCache = null
46
46
  let csp = null
47
+ let passthrough = {}
48
+
49
+ // Server options forwarded verbatim from pulse.config.js — keep in sync with dev.js
50
+ const PASSTHROUGH_OPTIONS = ['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody']
47
51
 
48
52
  const configPath = path.join(ROOT, 'pulse.config.js')
49
53
  if (fs.existsSync(configPath)) {
@@ -52,6 +56,9 @@ if (fs.existsSync(configPath)) {
52
56
  port = port || mod.default?.port || null
53
57
  defaultCache = mod.default?.defaultCache ?? null
54
58
  csp = mod.default?.csp ?? null
59
+ for (const key of PASSTHROUGH_OPTIONS) {
60
+ if (mod.default?.[key] !== undefined) passthrough[key] = mod.default[key]
61
+ }
55
62
  } catch { /* ignore */ }
56
63
  }
57
64
 
@@ -64,6 +71,9 @@ port = parseInt(process.env.PORT, 10) || port || 3000
64
71
 
65
72
  const specs = await loadPages(ROOT)
66
73
 
74
+ // Auto-discover the global store — pulse.store.js in the project root
75
+ const store = await loadStore(ROOT)
76
+
67
77
  if (specs.length === 0) {
68
78
  console.error('No pages found in src/pages/.')
69
79
  process.exit(1)
@@ -74,5 +84,7 @@ createServer(specs, {
74
84
  stream: true,
75
85
  staticDir: PUBLIC_DIR,
76
86
  defaultCache,
87
+ ...(store ? { store } : {}),
77
88
  ...(csp ? { csp } : {}),
89
+ ...passthrough,
78
90
  })
@@ -7,7 +7,7 @@
7
7
  * No framework. No virtual DOM. No dependencies.
8
8
  */
9
9
 
10
- import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation } from './store.js'
10
+ import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation, initLiveStore } from './store.js'
11
11
  import { trustedHTML, trustedScriptURL } from './tt.js'
12
12
 
13
13
  // Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
@@ -43,6 +43,13 @@ export function mount(spec, el, serverState = {}, options = {}) {
43
43
  // Separate page-level server state from store keys so they can be tracked
44
44
  // independently — store values come from the live singleton, not the snapshot.
45
45
  const _storeKeys = new Set(spec.store || [])
46
+
47
+ // Live store push — connect to the server's SSE channel, but only when this
48
+ // page actually subscribes to store keys. Singleton: survives navigations,
49
+ // repeat mounts are no-ops. Pages without store keys never open a connection.
50
+ if (typeof window !== 'undefined' && window.__PULSE_LIVE__ && _storeKeys.size > 0) {
51
+ initLiveStore(window.__PULSE_LIVE__)
52
+ }
46
53
  const _pageServerState = {}
47
54
  for (const [k, v] of Object.entries(serverState)) {
48
55
  if (!_storeKeys.has(k)) _pageServerState[k] = v
@@ -570,7 +570,7 @@ test('spec without persist does not write to localStorage', () => {
570
570
 
571
571
  console.log('\nStore mutations\n')
572
572
 
573
- import { registerStoreMutations, dispatchStoreMutation, getStoreState, updateStore } from './store.js'
573
+ import { registerStoreMutations, dispatchStoreMutation, getStoreState, updateStore, initLiveStore } from './store.js'
574
574
 
575
575
  // Register all mutations once — the singleton ignores subsequent calls (by design)
576
576
  registerStoreMutations({
@@ -621,6 +621,35 @@ test('registerStoreMutations is a no-op after first call', () => {
621
621
  assert(getStoreState().count === before + 1, `Should still use original mutation, got ${getStoreState().count}`)
622
622
  })
623
623
 
624
+ console.log('\nLive store push (client)\n')
625
+
626
+ test('initLiveStore connects once, merges SSE store events, survives bad frames', () => {
627
+ const instances = []
628
+ globalThis.EventSource = class {
629
+ constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
630
+ addEventListener(type, fn) { this._listeners[type] = fn }
631
+ }
632
+
633
+ initLiveStore('/__pulse/live')
634
+ initLiveStore('/__pulse/live') // singleton — second call is a no-op
635
+ assert(instances.length === 1, `Expected one EventSource, got ${instances.length}`)
636
+ assert(instances[0].url === '/__pulse/live', `Wrong URL: ${instances[0].url}`)
637
+
638
+ // A store event merges into the client store and notifies subscribers
639
+ const el = new FakeElement()
640
+ mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
641
+ instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
642
+ assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
643
+ assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
644
+
645
+ // Malformed frames must never throw or break the page
646
+ instances[0]._listeners.store({ data: 'not-json' })
647
+ instances[0]._listeners.store({ data: '"a-string"' })
648
+ assert(getStoreState().stock === 7, 'Bad frames must not corrupt the store')
649
+
650
+ delete globalThis.EventSource
651
+ })
652
+
624
653
  // ---------------------------------------------------------------------------
625
654
 
626
655
  console.log('\nDebounce / throttle\n')
@@ -269,12 +269,15 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
269
269
  * read these globals, and omitting the inline script keeps cached HTML nonce-free
270
270
  * so it can be served with a nonce-free CSP without a CSP violation.
271
271
  */
272
- export function buildStoreScript(spec, storeState, nonce) {
272
+ export function buildStoreScript(spec, storeState, nonce, livePath = '') {
273
273
  if (!spec.hydrate) return ''
274
+ // __PULSE_LIVE__ tells the runtime where the SSE store-push channel lives;
275
+ // mount() connects only when the page actually subscribes to store keys.
276
+ const liveFlag = livePath ? `window.__PULSE_LIVE__='${livePath}';` : ''
274
277
  if (storeState && Object.keys(storeState).length > 0) {
275
- return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
278
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
276
279
  }
277
- return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
280
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}</script>`
278
281
  }
279
282
 
280
283
  /**
@@ -310,7 +313,7 @@ export function buildHydrateScript(spec, storeDef, nonce) {
310
313
  * @param {Object} [options.timing] - Server-Timing values
311
314
  * @returns {string}
312
315
  */
313
- export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '', faviconHref = '' }) {
316
+ export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '', faviconHref = '', livePath = '' }) {
314
317
  const meta = spec.meta || {}
315
318
  const title = meta.title || 'Pulse'
316
319
 
@@ -362,7 +365,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
362
365
  ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
363
366
  : ''
364
367
 
365
- const storeStateScript = buildStoreScript(spec, storeState, nonce)
368
+ const storeStateScript = buildStoreScript(spec, storeState, nonce, livePath)
366
369
  const hydrateScript = buildHydrateScript(spec, storeDef, nonce)
367
370
 
368
371
  // Server-Timing header value (caller is responsible for setting the header)
@@ -628,11 +631,14 @@ function encode(str) {
628
631
  * bundle (i.e. resolved via the manifest) rather than a raw source file.
629
632
  * Bundles live under /dist/ and carry a content hash in their filename.
630
633
  *
631
- * @param {string} hydratePath
634
+ * Null-safe: purely server-rendered pages (no mutations/actions/persist) have
635
+ * no hydrate path at all — they are never bundles.
636
+ *
637
+ * @param {string|undefined} hydratePath
632
638
  * @returns {boolean}
633
639
  */
634
640
  function isBundle(hydratePath) {
635
- return hydratePath.startsWith('/dist/')
641
+ return typeof hydratePath === 'string' && hydratePath.startsWith('/dist/')
636
642
  }
637
643
 
638
644
  /**
@@ -73,6 +73,34 @@ export function registerStoreMutations(mutations) {
73
73
  _mutationsRegistered = true
74
74
  }
75
75
 
76
+ // ---------------------------------------------------------------------------
77
+ // Live store push — SSE channel from the server (createServer { live: true })
78
+ // ---------------------------------------------------------------------------
79
+
80
+ let _liveSource = null
81
+
82
+ /**
83
+ * Connect to the server's live store-push channel (SSE). Singleton per tab —
84
+ * survives client-side navigations; repeat calls are no-ops. Patches arrive as
85
+ * `store` events and merge via updateStore(), so every page subscribed through
86
+ * spec.store re-renders immediately. EventSource reconnects automatically.
87
+ *
88
+ * Called by mount() only when the page subscribes to store keys and the server
89
+ * set window.__PULSE_LIVE__ — pages without store keys never open a connection.
90
+ *
91
+ * @param {string} url - the SSE endpoint path (e.g. '/__pulse/live')
92
+ */
93
+ export function initLiveStore(url) {
94
+ if (_liveSource || typeof EventSource === 'undefined' || !url) return
95
+ _liveSource = new EventSource(url)
96
+ _liveSource.addEventListener('store', (e) => {
97
+ try {
98
+ const patch = JSON.parse(e.data)
99
+ if (patch && typeof patch === 'object') updateStore(patch)
100
+ } catch { /* malformed frame — ignore, never break the page */ }
101
+ })
102
+ }
103
+
76
104
  /**
77
105
  * Dispatch a store mutation by name.
78
106
  * The mutation receives the current store state and an optional payload,
@@ -279,6 +279,29 @@ export class TtlCache {
279
279
  }
280
280
  }
281
281
 
282
+ // ---------------------------------------------------------------------------
283
+ // Module-level pushStore — lets spec files broadcast without a server handle
284
+ // ---------------------------------------------------------------------------
285
+ // Scaffolded apps never call createServer themselves (the CLI owns it), so the
286
+ // broadcast function is also exported at module level. createServer wires it up
287
+ // when `live` is enabled; a webhook spec can then simply:
288
+ //
289
+ // import { pushStore } from '@invisibleloop/pulse'
290
+ // export default { route: '/hooks/stock', contentType: 'text/plain',
291
+ // state: {}, render: async (ctx) => { pushStore(await ctx.json()); return 'ok' } }
292
+ //
293
+ // With multiple servers in one process (tests), the most recent live server
294
+ // wins; use the handle returned by createServer for instance precision.
295
+
296
+ let _livePushStore = null
297
+
298
+ export function pushStore(partial) {
299
+ if (!_livePushStore) {
300
+ throw new Error('pushStore requires a running server started with live: true (set `live: true` in pulse.config.js or createServer options)')
301
+ }
302
+ return _livePushStore(partial)
303
+ }
304
+
282
305
  const serverDataCache = new TtlCache()
283
306
  const pageHtmlCache = new TtlCache()
284
307
 
@@ -554,6 +577,11 @@ export async function createServer(entries, options = {}) {
554
577
  redirects = null, // declarative redirect map for legacy URLs (SEO-safe migrations):
555
578
  // { '/old-blog/:slug': '/blog/:slug', — 301 (default)
556
579
  // '/promo': { to: '/pricing', status: 302 } } — custom status
580
+ live = false, // true | { path } — SSE store-push channel. createServer returns
581
+ // pushStore(partial): broadcasts a store patch to all connected
582
+ // clients; pages subscribed via spec.store re-render immediately.
583
+ // Broadcast = SHARED data only (stock, announcements), never
584
+ // per-user data. Default path: /__pulse/live
557
585
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
558
586
  onRequest,
559
587
  agentMode = false, // show agent-active indicator in banner
@@ -565,9 +593,13 @@ export async function createServer(entries, options = {}) {
565
593
 
566
594
  const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
567
595
 
596
+ // Store definition — `let` so the dev server can hot-swap it via updateStore()
597
+ // when pulse.store.js changes, the same way updateSpecs() swaps page specs.
598
+ let storeDef = store
599
+
568
600
  // Validate store at startup — fail fast before the server accepts connections
569
- if (store) {
570
- const { valid, errors } = validateStore(store)
601
+ if (storeDef) {
602
+ const { valid, errors } = validateStore(storeDef)
571
603
  if (!valid) {
572
604
  throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
573
605
  }
@@ -622,6 +654,39 @@ export async function createServer(entries, options = {}) {
622
654
  // tied to a cookie, so a restart only means in-flight forms re-render once.
623
655
  const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
624
656
 
657
+ // Live store push — SSE clients and the broadcast function. The heartbeat
658
+ // comment keeps proxies from reaping idle connections; unref'd so it never
659
+ // holds the process open.
660
+ const livePath = live ? ((typeof live === 'object' && live.path) || '/__pulse/live') : null
661
+ const liveClients = new Set()
662
+ const liveHeartbeat = livePath
663
+ ? setInterval(() => {
664
+ for (const res of liveClients) { try { res.write(':hb\n\n') } catch {} }
665
+ }, 25_000).unref()
666
+ : null
667
+
668
+ /**
669
+ * Broadcast a store patch to every connected live client. Subscribed pages
670
+ * (spec.store) merge it via updateStore() and re-render immediately.
671
+ * Returns the number of clients the patch was sent to.
672
+ */
673
+ function pushStore(partial) {
674
+ if (!livePath) throw new Error('pushStore requires createServer option live: true')
675
+ if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
676
+ throw new Error('pushStore(partial) expects a plain object of store keys to merge')
677
+ }
678
+ const frame = `event: store\ndata: ${JSON.stringify(partial)}\n\n`
679
+ let sent = 0
680
+ for (const res of liveClients) {
681
+ try { res.write(frame); sent++ } catch { liveClients.delete(res) }
682
+ }
683
+ return sent
684
+ }
685
+
686
+ // Wire the module-level pushStore export to this instance — spec files can
687
+ // `import { pushStore } from '@invisibleloop/pulse'` without a server handle.
688
+ if (livePath) _livePushStore = pushStore
689
+
625
690
  // Redirects — compile the declarative map once at startup (throws on bad
626
691
  // entries) and warn when a redirect source shadows a registered page route.
627
692
  const redirectTable = compileRedirects(redirects)
@@ -678,6 +743,22 @@ export async function createServer(entries, options = {}) {
678
743
  return
679
744
  }
680
745
 
746
+ // Live store push — SSE channel. Clients (the runtime's initLiveStore)
747
+ // connect here; pushStore() broadcasts store patches down the stream.
748
+ if (livePath && pathname === livePath && req.method === 'GET') {
749
+ res.writeHead(200, {
750
+ 'Content-Type': 'text/event-stream',
751
+ 'Cache-Control': 'no-store',
752
+ 'Connection': 'keep-alive',
753
+ 'X-Accel-Buffering': 'no',
754
+ ...SECURITY_HEADERS,
755
+ })
756
+ res.write(':connected\n\n')
757
+ liveClients.add(res)
758
+ req.on('close', () => liveClients.delete(res))
759
+ return
760
+ }
761
+
681
762
  // Request hook — allows middleware-like behaviour
682
763
  if (onRequest) {
683
764
  const result = onRequest(req, res)
@@ -820,8 +901,8 @@ export async function createServer(entries, options = {}) {
820
901
  // Global store — resolve once per request, attach to ctx before guard/server fetchers.
821
902
  // Use global fetcherTimeout for store fetchers; spec-level override applied below.
822
903
  ctx.fetcherTimeout = fetcherTimeout ?? null
823
- if (store) {
824
- ctx.store = await resolveStoreState(store, ctx)
904
+ if (storeDef) {
905
+ ctx.store = await resolveStoreState(storeDef, ctx)
825
906
  }
826
907
 
827
908
  const spec = resolveSpec(match.spec, hydrateMap)
@@ -943,11 +1024,11 @@ export async function createServer(entries, options = {}) {
943
1024
  // string path — they are small, must carry an explicit status, and POST
944
1025
  // bodies are already consumed.
945
1026
  if (responseStatus !== 200 || req.method === 'POST') {
946
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus)
1027
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, responseStatus, livePath)
947
1028
  } else if (stream) {
948
- await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
1029
+ await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, livePath)
949
1030
  } else {
950
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
1031
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, 200, livePath)
951
1032
  }
952
1033
 
953
1034
  } catch (err) {
@@ -998,6 +1079,11 @@ export async function createServer(entries, options = {}) {
998
1079
  pageHtmlCache.stopEviction()
999
1080
  brandCache.stopEviction()
1000
1081
 
1082
+ // Close live SSE connections so they can't hold the drain open
1083
+ if (liveHeartbeat) clearInterval(liveHeartbeat)
1084
+ for (const res of [...liveClients]) { try { res.end() } catch {} }
1085
+ liveClients.clear()
1086
+
1001
1087
  // Stop accepting new connections; exit once all connections are closed
1002
1088
  server.close(() => process.exit(0))
1003
1089
 
@@ -1032,10 +1118,19 @@ export async function createServer(entries, options = {}) {
1032
1118
  return {
1033
1119
  server,
1034
1120
  shutdown,
1121
+ pushStore,
1035
1122
  updateSpecs(newSpecs) {
1036
1123
  notFoundSpec = newSpecs.find(s => s.route === '*') || null
1037
1124
  router = buildRouter(newSpecs.filter(s => s.route !== '*'))
1038
1125
  allSpecs = newSpecs
1126
+ },
1127
+ /** Swap the store definition at runtime (dev hot reload of pulse.store.js). */
1128
+ updateStore(newStoreDef) {
1129
+ if (newStoreDef) {
1130
+ const { valid, errors } = validateStore(newStoreDef)
1131
+ if (!valid) throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
1132
+ }
1133
+ storeDef = newStoreDef || null
1039
1134
  }
1040
1135
  }
1041
1136
  }
@@ -1113,7 +1208,7 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
1113
1208
  * Render to a complete string then send — simpler, easier to cache.
1114
1209
  * Checks the in-process page cache before rendering; stores result after.
1115
1210
  */
1116
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200) {
1211
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200, livePath = '') {
1117
1212
  const cacheKey = buildCacheKey(spec, ctx)
1118
1213
  // Never cache POST re-renders or non-200 responses (custom 404s) — POST output
1119
1214
  // varies per submission and a cached 404 body would be served with a 200 later.
@@ -1137,7 +1232,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1137
1232
  const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
1138
1233
  const resolvedSpec = { ...spec, meta: await resolveMeta(spec.meta, ctx) }
1139
1234
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
1140
- 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, faviconHref: faviconPath || '' })
1235
+ 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, faviconHref: faviconPath || '', livePath })
1141
1236
  html = wrapped.html
1142
1237
  serverTimingValue = wrapped.serverTimingValue
1143
1238
  if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
@@ -1165,7 +1260,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1165
1260
  * Stream the response — shell first, deferred segments follow.
1166
1261
  * On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
1167
1262
  */
1168
- async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
1263
+ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, livePath = '') {
1169
1264
  // Serve from in-process page cache when available — skip streaming overhead.
1170
1265
  const cacheKey = buildCacheKey(spec, ctx)
1171
1266
  const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
@@ -1253,7 +1348,7 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
1253
1348
  const scriptTags = (meta.scripts || [])
1254
1349
  .map(src => ` <script src="${escHtml(src)}" defer></script>`)
1255
1350
  .join('\n')
1256
- const storeScript = buildStoreScript(spec, ctx.store || null, nonce)
1351
+ const storeScript = buildStoreScript(spec, ctx.store || null, nonce, livePath)
1257
1352
  const hydrateScript = buildHydrateScript(spec, store, nonce)
1258
1353
 
1259
1354
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
@@ -1294,6 +1294,30 @@ await test('without a "*" spec the default 404 still works', async () => {
1294
1294
  })
1295
1295
  })
1296
1296
 
1297
+ await test('custom 404 works in production mode (manifest with runtime bundle present)', async () => {
1298
+ // Regression: in production the manifest sets runtimeBundle, which made
1299
+ // wrapDocument call isBundle(spec.hydrate) — undefined for non-hydrated pages
1300
+ // like a typical 404 spec → TypeError → 500 instead of the custom 404.
1301
+ // Dev never hit it because runtimeBundle is '' there.
1302
+ const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
1303
+ await withServer([helloSpec, notFoundPageSpec], { stream: true, manifest: prodManifest }, async (port) => {
1304
+ const { status, body } = await get(port, '/nope')
1305
+ assert(status === 404, `Expected 404 in production mode, got ${status}`)
1306
+ assert(body.includes('Custom not found'), `Expected custom 404 content: ${body.slice(0, 200)}`)
1307
+ })
1308
+ })
1309
+
1310
+ await test('non-hydrated pages render on the buffered path in production mode', async () => {
1311
+ // Same root cause, different surface: stream: false + production manifest
1312
+ // crashed EVERY zero-JS page, not just the 404.
1313
+ const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
1314
+ await withServer([helloSpec], { stream: false, manifest: prodManifest }, async (port) => {
1315
+ const { status, body } = await get(port, '/hello')
1316
+ assert(status === 200, `Expected 200, got ${status}`)
1317
+ assert(body.includes('Hello world'), 'Expected page content')
1318
+ })
1319
+ })
1320
+
1297
1321
  // ---------------------------------------------------------------------------
1298
1322
 
1299
1323
  console.log('\nServer-side forms (spec.submit) + CSRF\n')
@@ -1646,6 +1670,138 @@ await test('a failing enumerator does not take down the sitemap', async () => {
1646
1670
 
1647
1671
  // ---------------------------------------------------------------------------
1648
1672
 
1673
+ console.log('\nLive store push (SSE)\n')
1674
+
1675
+ /** Open an SSE connection and return a handle that accumulates received text. */
1676
+ function openSse(port, path) {
1677
+ return new Promise((resolve, reject) => {
1678
+ const req = http.get({ hostname: 'localhost', port, path, headers: { Accept: 'text/event-stream' } }, (res) => {
1679
+ let buf = ''
1680
+ res.on('data', c => { buf += c.toString() })
1681
+ resolve({ status: res.statusCode, headers: res.headers, read: () => buf, destroy: () => req.destroy() })
1682
+ })
1683
+ req.on('error', reject)
1684
+ })
1685
+ }
1686
+
1687
+ const wait = (ms) => new Promise(r => setTimeout(r, ms))
1688
+
1689
+ await test('live: true serves the SSE endpoint', async () => {
1690
+ const port = nextPort++
1691
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1692
+ try {
1693
+ const sse = await openSse(port, '/__pulse/live')
1694
+ assert(sse.status === 200, `Expected 200, got ${sse.status}`)
1695
+ assert(sse.headers['content-type'].includes('text/event-stream'), `Got ${sse.headers['content-type']}`)
1696
+ await wait(50)
1697
+ assert(sse.read().includes(':connected'), 'Expected connection comment')
1698
+ sse.destroy()
1699
+ } finally {
1700
+ server.closeAllConnections?.()
1701
+ await new Promise(r => server.close(r))
1702
+ }
1703
+ })
1704
+
1705
+ await test('pushStore broadcasts a patch to all connected clients', async () => {
1706
+ const port = nextPort++
1707
+ const { server, pushStore } = await createServer([helloSpec], { port, quiet: true, live: true })
1708
+ try {
1709
+ const a = await openSse(port, '/__pulse/live')
1710
+ const b = await openSse(port, '/__pulse/live')
1711
+ await wait(50)
1712
+ const sent = pushStore({ stock: 5, banner: 'Sale on now' })
1713
+ assert(sent === 2, `Expected 2 clients, got ${sent}`)
1714
+ await wait(50)
1715
+ for (const sse of [a, b]) {
1716
+ assert(sse.read().includes('event: store'), 'Missing event name')
1717
+ assert(sse.read().includes('"stock":5'), `Missing payload: ${sse.read()}`)
1718
+ }
1719
+ a.destroy(); b.destroy()
1720
+ } finally {
1721
+ server.closeAllConnections?.()
1722
+ await new Promise(r => server.close(r))
1723
+ }
1724
+ })
1725
+
1726
+ await test('pushStore requires live: true and a plain-object patch', async () => {
1727
+ const port = nextPort++
1728
+ const off = await createServer([helloSpec], { port, quiet: true })
1729
+ try {
1730
+ let threw = false
1731
+ try { off.pushStore({ x: 1 }) } catch { threw = true }
1732
+ assert(threw, 'Expected throw without live: true')
1733
+ } finally {
1734
+ off.server.closeAllConnections?.()
1735
+ await new Promise(r => off.server.close(r))
1736
+ }
1737
+ const port2 = nextPort++
1738
+ const on = await createServer([helloSpec], { port: port2, quiet: true, live: true })
1739
+ try {
1740
+ let threw = false
1741
+ try { on.pushStore('not-an-object') } catch { threw = true }
1742
+ assert(threw, 'Expected throw for non-object patch')
1743
+ } finally {
1744
+ on.server.closeAllConnections?.()
1745
+ await new Promise(r => on.server.close(r))
1746
+ }
1747
+ })
1748
+
1749
+ await test('module-level pushStore export reaches the live server (spec-file usage)', async () => {
1750
+ const { pushStore: globalPush } = await import('./index.js')
1751
+ const port = nextPort++
1752
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1753
+ try {
1754
+ const sse = await openSse(port, '/__pulse/live')
1755
+ await wait(50)
1756
+ const sent = globalPush({ score: 99 })
1757
+ assert(sent === 1, `Expected module-level pushStore to reach 1 client, got ${sent}`)
1758
+ await wait(50)
1759
+ assert(sse.read().includes('"score":99'), `Missing payload via module export: ${sse.read()}`)
1760
+ sse.destroy()
1761
+ } finally {
1762
+ server.closeAllConnections?.()
1763
+ await new Promise(r => server.close(r))
1764
+ }
1765
+ })
1766
+
1767
+ await test('endpoint is 404 when live is not enabled', async () => {
1768
+ await withServer([helloSpec], { stream: false }, async (port) => {
1769
+ const { status } = await get(port, '/__pulse/live')
1770
+ assert(status === 404, `Expected 404, got ${status}`)
1771
+ })
1772
+ })
1773
+
1774
+ await test('custom endpoint path via { path }', async () => {
1775
+ const port = nextPort++
1776
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: { path: '/_realtime' } })
1777
+ try {
1778
+ const sse = await openSse(port, '/_realtime')
1779
+ assert(sse.status === 200, `Expected 200 on custom path, got ${sse.status}`)
1780
+ sse.destroy()
1781
+ } finally {
1782
+ server.closeAllConnections?.()
1783
+ await new Promise(r => server.close(r))
1784
+ }
1785
+ })
1786
+
1787
+ await test('hydrated pages receive the __PULSE_LIVE__ flag; disabled servers do not', async () => {
1788
+ const hydrated = {
1789
+ route: '/live-page', hydrate: '/src/pages/live-page.js', store: ['stock'],
1790
+ state: { n: 0 }, mutations: { inc: (s) => ({ n: s.n + 1 }) },
1791
+ view: (state, server) => `<main id="main-content">${server.stock ?? '—'}</main>`,
1792
+ }
1793
+ await withServer([hydrated], { stream: false, live: true }, async (port) => {
1794
+ const { body } = await get(port, '/live-page')
1795
+ assert(body.includes(`window.__PULSE_LIVE__='/__pulse/live'`), `Flag missing: ${body.slice(0, 600)}`)
1796
+ })
1797
+ await withServer([hydrated], { stream: false }, async (port) => {
1798
+ const { body } = await get(port, '/live-page')
1799
+ assert(!body.includes('__PULSE_LIVE__'), 'Flag must be absent when live is disabled')
1800
+ })
1801
+ })
1802
+
1803
+ // ---------------------------------------------------------------------------
1804
+
1649
1805
  console.log('\nMalformed request handling\n')
1650
1806
 
1651
1807
  await test('malformed percent-encoding in path returns 404, not 500', async () => {
package/types/server.d.ts CHANGED
@@ -108,6 +108,15 @@ export interface ServerOptions {
108
108
  */
109
109
  redirects?: Record<string, string | { to: string; status?: 301 | 302 | 307 | 308 }> | null
110
110
 
111
+ /**
112
+ * Enable the SSE store-push channel. createServer's return value gains a
113
+ * working pushStore(partial) that broadcasts store patches to all connected
114
+ * clients; pages subscribed via spec.store re-render immediately. Pass
115
+ * { path } to move the endpoint (default '/__pulse/live'). The channel is a
116
+ * broadcast — push SHARED data only (stock, announcements), never per-user data.
117
+ */
118
+ live?: boolean | { path?: string }
119
+
111
120
  /**
112
121
  * Extra CSP sources to merge into the framework's default Content-Security-Policy.
113
122
  * Use this to allow external stylesheets, fonts, or API origins.
@@ -140,6 +149,16 @@ export interface PulseStoreDefinition {
140
149
  // createServer return value
141
150
  // ---------------------------------------------------------------------------
142
151
 
152
+ /**
153
+ * Module-level broadcast to the running live server — for spec files that have
154
+ * no server handle (the CLI owns createServer in scaffolded apps).
155
+ * Throws unless a server with `live: true` is running.
156
+ * @example
157
+ * import { pushStore } from '@invisibleloop/pulse'
158
+ * pushStore({ stock: 5 })
159
+ */
160
+ export declare function pushStore(partial: Record<string, unknown>): number
161
+
143
162
  export interface ServerInstance {
144
163
  /** The underlying Node.js http.Server */
145
164
  server: Server
@@ -155,6 +174,18 @@ export interface ServerInstance {
155
174
 
156
175
  /** Swap the spec list at runtime (used by the dev server for hot reload). */
157
176
  updateSpecs(specs: PulseSpec[]): void
177
+
178
+ /** Swap the store definition at runtime (dev hot reload of pulse.store.js). Validates; throws on a bad definition. */
179
+ updateStore(storeDef: PulseStoreDefinition | null): void
180
+
181
+ /**
182
+ * Broadcast a store patch to every connected live client (requires the
183
+ * `live` option). Pages subscribed via spec.store merge the patch and
184
+ * re-render immediately. Returns the number of clients reached.
185
+ * Broadcast channel — push SHARED data only, never per-user data.
186
+ * @example pushStore({ stock: 5 })
187
+ */
188
+ pushStore(partial: Record<string, unknown>): number
158
189
  }
159
190
 
160
191
  // ---------------------------------------------------------------------------