@invisibleloop/pulse 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -294,6 +294,12 @@ await createServer(
294
294
  // dynamic :param routes contribute via spec.sitemap enumerators;
295
295
  // a physical sitemap.xml/robots.txt in staticDir always wins
296
296
  robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
297
+ redirects: { // declarative redirects for legacy URLs — 301 by default,
298
+ '/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
299
+ '/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
300
+ }, // checked before routing (GET/HEAD only); validated at startup
301
+ live: true, // SSE store-push channel (default path /__pulse/live) —
302
+ // createServer returns pushStore(partial) for broadcasting
297
303
  csp: { // extra sources merged into the framework's default CSP
298
304
  'style-src': ['https://fonts.googleapis.com'],
299
305
  'font-src': ['https://fonts.gstatic.com'],
@@ -310,6 +316,37 @@ await createServer(
310
316
 
311
317
  All specs are validated at startup — bad specs throw before the server accepts connections.
312
318
 
319
+ ## Live Store Push (SSE)
320
+
321
+ 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:
322
+
323
+ ```js
324
+ // Enable in pulse.config.js (CLI-managed apps) or createServer options:
325
+ export default { live: true }
326
+ ```
327
+
328
+ ```js
329
+ // Broadcast from any spec file — e.g. a webhook that updates stock:
330
+ import { pushStore } from '@invisibleloop/pulse'
331
+
332
+ export default {
333
+ route: '/hooks/stock',
334
+ contentType: 'text/plain',
335
+ state: {},
336
+ render: async (ctx) => {
337
+ const { sku, stock } = await ctx.json()
338
+ pushStore({ stock }) // every page with store: ['stock'] re-renders
339
+ return 'ok'
340
+ },
341
+ }
342
+ ```
343
+
344
+ When you own the server entry, `createServer` also returns the handle directly: `const { pushStore } = await createServer(pages, { live: true })`.
345
+
346
+ - The client connects automatically — only on pages that declare `store: [...]` keys; one EventSource per tab, survives client navigations, reconnects natively.
347
+ - **Broadcast channel: shared data only** (stock counts, announcements, live scores). Never push per-user data — every connected client receives every patch.
348
+ - Views must handle keys that haven't arrived yet: `server.stock ?? '—'`.
349
+
313
350
  ## Multi-brand Sites
314
351
 
315
352
  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.
@@ -79,6 +79,19 @@ server: {
79
79
  },
80
80
  }`, 'js'))}
81
81
 
82
+ ${section('redirects', 'Redirects')}
83
+ <p>Migrating an existing site? Preserve the old URLs with the <code>redirects</code> map — legacy links and search rankings survive the move:</p>
84
+ ${codeBlock(highlight(`await createServer(pages, {
85
+ redirects: {
86
+ '/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
87
+ '/pricing-2024': '/pricing', // 301 (default)
88
+ '/promo': { to: '/sale', status: 302 }, // temporary redirect
89
+ '/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
90
+ },
91
+ })`, 'js'))}
92
+ <p>Redirects respond <strong>301</strong> by default (permanent — search engines transfer ranking to the target). Use <code>{ to, status }</code> for <code>302</code>, <code>307</code>, or <code>308</code>. The query string is preserved, redirects apply to GET/HEAD only, and the map is validated at startup — a bad entry fails the boot rather than silently misrouting traffic.</p>
93
+ ${callout('note', 'Redirects are checked before route matching, so a redirect source that equals a registered route shadows the page — the server logs a startup warning when that happens. Occasionally intentional (retiring a page), usually a mistake.')}
94
+
82
95
  ${section('not-found', '404 handling')}
83
96
  <p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
84
97
  ${codeBlock(highlight(`export default {
@@ -51,6 +51,8 @@ await createServer(entries, options)`, 'js'))}
51
51
  ['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
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
+ ['<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>.'],
54
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.'],
55
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>.'],
56
58
  ]
@@ -163,6 +163,28 @@ export default {
163
163
  <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
164
  ${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
165
 
166
+ ${section('live', 'Live store push — server-initiated updates')}
167
+ <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>
168
+ ${codeBlock(highlight(`// pulse.config.js — enable the channel
169
+ export default { live: true }`, 'js'))}
170
+ ${codeBlock(highlight(`// Broadcast from any spec file — e.g. a webhook that updates stock:
171
+ import { pushStore } from '@invisibleloop/pulse'
172
+
173
+ export default {
174
+ route: '/hooks/stock',
175
+ contentType: 'text/plain',
176
+ state: {},
177
+ render: async (ctx) => {
178
+ const { stock } = await ctx.json()
179
+ pushStore({ stock }) // pages with store: ['stock'] re-render
180
+ return 'ok'
181
+ },
182
+ }`, 'js'))}
183
+ <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>
184
+ <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>
185
+ ${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.')}
186
+ ${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.')}
187
+
166
188
  ${section('caching', 'Caching and performance')}
167
189
  <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
190
  <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.18.2",
3
+ "version": "0.20.0",
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.18.2
1
+ 0.20.0
@@ -147,6 +147,25 @@ export default {
147
147
  - **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
148
148
  - 500 errors are customised via `createServer`'s `onError` option, not a spec.
149
149
 
150
+ ## Declarative redirects (legacy URLs)
151
+
152
+ When migrating an existing site to Pulse, preserve the old URLs with the `redirects` map in `pulse.config.js` / `createServer` — never let legacy URLs 404:
153
+
154
+ ```js
155
+ export default {
156
+ redirects: {
157
+ '/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
158
+ '/pricing-2024': '/pricing', // 301
159
+ '/promo': { to: '/sale', status: 302 }, // temporary
160
+ '/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
161
+ },
162
+ }
163
+ ```
164
+
165
+ - **301 by default** (permanent — search engines transfer ranking). Use `{ to, status }` for 302/307/308.
166
+ - Query strings are preserved; checked before route matching (GET/HEAD only); validated at startup — bad entries fail the boot.
167
+ - A redirect source that equals a registered route shadows the page (startup warning) — that is sometimes intentional (retiring a page), usually a mistake.
168
+
150
169
  ## Sitemap & robots.txt
151
170
 
152
171
  Enable in `pulse.config.js` / `createServer` — the framework serves `/sitemap.xml` and `/robots.txt` generated from the registered routes:
@@ -102,6 +102,50 @@ actions: {
102
102
  ```
103
103
  `_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
104
 
105
+ ### Live store push — server-initiated updates (SSE)
106
+
107
+ 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:
108
+
109
+ ```js
110
+ // pulse.config.js — enable the channel (CLI-managed apps)
111
+ export default { live: true }
112
+ ```
113
+
114
+ ```js
115
+ // any spec file — broadcast via the module-level import (no server handle needed)
116
+ import { pushStore } from '@invisibleloop/pulse'
117
+
118
+ export default {
119
+ route: '/hooks/stock',
120
+ contentType: 'text/plain',
121
+ state: {},
122
+ render: async (ctx) => {
123
+ const { stock } = await ctx.json()
124
+ pushStore({ stock }) // every mounted page with store: ['stock'] re-renders
125
+ return 'ok'
126
+ },
127
+ }
128
+ ```
129
+
130
+ When you own the server entry, `createServer` also returns the handle: `const { pushStore } = await createServer(pages, { live: true })`.
131
+
132
+ ```js
133
+ // page spec — subscribes like any store key
134
+ export default {
135
+ route: '/product',
136
+ store: ['stock'],
137
+ state: {},
138
+ mutations: { noop: (s) => s }, // any mutation/action → page is hydrated
139
+ view: (state, server) => `<p>${server.stock ?? '—'} in stock</p>`,
140
+ }
141
+ ```
142
+
143
+ **Rules:**
144
+ - **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).
145
+ - Views must handle keys that haven't arrived yet (`server.stock ?? '—'`) — the first patch may land seconds after page load.
146
+ - The page must be hydrated (have mutations/actions) — purely server-rendered pages have no JS to receive patches.
147
+ - The connection is automatic and lazy: only pages with `store: [...]` keys open it, one EventSource per tab, native auto-reconnect.
148
+
105
149
  ## Server-side forms — `spec.submit` (works without client JS)
106
150
 
107
151
  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.
package/src/cli/dev.js CHANGED
@@ -236,6 +236,10 @@ const { updateSpecs } = await createServer(specs, {
236
236
  dev: true,
237
237
  agentMode: !!process.env.PULSE_AGENT_MODE,
238
238
  ...(_config.csp ? { csp: _config.csp } : {}),
239
+ // Server options forwarded verbatim from pulse.config.js so dev behaves like
240
+ // production — keep in sync with start.js PASSTHROUGH_OPTIONS
241
+ ...(['redirects', 'sitemap', 'robots', 'secret', 'live', 'trailingSlash', 'fetcherTimeout', 'maxBody'].reduce(
242
+ (acc, k) => (_config[k] !== undefined ? { ...acc, [k]: _config[k] } : acc), {})),
239
243
 
240
244
  onRequest(req, res) {
241
245
  const url = req.url.split('?')[0]
package/src/cli/start.js CHANGED
@@ -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
 
@@ -75,4 +82,5 @@ createServer(specs, {
75
82
  staticDir: PUBLIC_DIR,
76
83
  defaultCache,
77
84
  ...(csp ? { csp } : {}),
85
+ ...passthrough,
78
86
  })
@@ -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)
@@ -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
 
@@ -551,6 +574,14 @@ export async function createServer(entries, options = {}) {
551
574
  // spec.sitemap = async () => ['/blog/a', ...]
552
575
  robots = null, // robots.txt content when sitemap is enabled:
553
576
  // null = auto-generate, false = disable, string = serve verbatim
577
+ redirects = null, // declarative redirect map for legacy URLs (SEO-safe migrations):
578
+ // { '/old-blog/:slug': '/blog/:slug', — 301 (default)
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
554
585
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
555
586
  onRequest,
556
587
  agentMode = false, // show agent-active indicator in banner
@@ -619,6 +650,49 @@ export async function createServer(entries, options = {}) {
619
650
  // tied to a cookie, so a restart only means in-flight forms re-render once.
620
651
  const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
621
652
 
653
+ // Live store push — SSE clients and the broadcast function. The heartbeat
654
+ // comment keeps proxies from reaping idle connections; unref'd so it never
655
+ // holds the process open.
656
+ const livePath = live ? ((typeof live === 'object' && live.path) || '/__pulse/live') : null
657
+ const liveClients = new Set()
658
+ const liveHeartbeat = livePath
659
+ ? setInterval(() => {
660
+ for (const res of liveClients) { try { res.write(':hb\n\n') } catch {} }
661
+ }, 25_000).unref()
662
+ : null
663
+
664
+ /**
665
+ * Broadcast a store patch to every connected live client. Subscribed pages
666
+ * (spec.store) merge it via updateStore() and re-render immediately.
667
+ * Returns the number of clients the patch was sent to.
668
+ */
669
+ function pushStore(partial) {
670
+ if (!livePath) throw new Error('pushStore requires createServer option live: true')
671
+ if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
672
+ throw new Error('pushStore(partial) expects a plain object of store keys to merge')
673
+ }
674
+ const frame = `event: store\ndata: ${JSON.stringify(partial)}\n\n`
675
+ let sent = 0
676
+ for (const res of liveClients) {
677
+ try { res.write(frame); sent++ } catch { liveClients.delete(res) }
678
+ }
679
+ return sent
680
+ }
681
+
682
+ // Wire the module-level pushStore export to this instance — spec files can
683
+ // `import { pushStore } from '@invisibleloop/pulse'` without a server handle.
684
+ if (livePath) _livePushStore = pushStore
685
+
686
+ // Redirects — compile the declarative map once at startup (throws on bad
687
+ // entries) and warn when a redirect source shadows a registered page route.
688
+ const redirectTable = compileRedirects(redirects)
689
+ if (redirectTable.length > 0 && !quiet) {
690
+ const shadowed = redirectTable.filter(r => specs.some(s => s.route === r.source))
691
+ if (shadowed.length > 0) {
692
+ console.log(` ⚠ redirects: ${shadowed.map(r => r.source).join(', ')} also match registered page routes — the redirect wins and the page is unreachable`)
693
+ }
694
+ }
695
+
622
696
  // Sitemap — track the live spec list (updated on hot reload) and surface a
623
697
  // startup hint for dynamic routes that can't be enumerated automatically.
624
698
  const sitemapEnabled = !!sitemap
@@ -665,6 +739,22 @@ export async function createServer(entries, options = {}) {
665
739
  return
666
740
  }
667
741
 
742
+ // Live store push — SSE channel. Clients (the runtime's initLiveStore)
743
+ // connect here; pushStore() broadcasts store patches down the stream.
744
+ if (livePath && pathname === livePath && req.method === 'GET') {
745
+ res.writeHead(200, {
746
+ 'Content-Type': 'text/event-stream',
747
+ 'Cache-Control': 'no-store',
748
+ 'Connection': 'keep-alive',
749
+ 'X-Accel-Buffering': 'no',
750
+ ...SECURITY_HEADERS,
751
+ })
752
+ res.write(':connected\n\n')
753
+ liveClients.add(res)
754
+ req.on('close', () => liveClients.delete(res))
755
+ return
756
+ }
757
+
668
758
  // Request hook — allows middleware-like behaviour
669
759
  if (onRequest) {
670
760
  const result = onRequest(req, res)
@@ -727,6 +817,27 @@ export async function createServer(entries, options = {}) {
727
817
  // 'allow' — no redirect
728
818
  }
729
819
 
820
+ // Declarative redirects — checked before route matching so legacy URLs
821
+ // redirect even when a spec also matches (a startup warning flags that).
822
+ // GET/HEAD only: redirecting a POST silently drops the body.
823
+ if (redirectTable.length > 0 && (req.method === 'GET' || req.method === 'HEAD')) {
824
+ for (const r of redirectTable) {
825
+ const m = pathname.match(r.pattern)
826
+ if (!m) continue
827
+ // Substitute captured :params into the target, keeping their original
828
+ // percent-encoding intact (no decode/re-encode round trip). Longest
829
+ // names first so :s never clobbers part of :slug.
830
+ let target = r.target
831
+ ;[...r.params.entries()]
832
+ .sort((a, b) => b[1].length - a[1].length)
833
+ .forEach(([i, name]) => { target = target.split(`:${name}`).join(m[i + 1]) })
834
+ if (target === pathname) break // self-redirect — fall through to normal routing
835
+ res.writeHead(r.status, { Location: target + (url.search || ''), ...SECURITY_HEADERS, ...httpsHeaders(req) })
836
+ res.end()
837
+ return
838
+ }
839
+ }
840
+
730
841
  // Match route
731
842
  let match = matchRoute(router, pathname)
732
843
  let responseStatus = 200
@@ -909,11 +1020,11 @@ export async function createServer(entries, options = {}) {
909
1020
  // string path — they are small, must carry an explicit status, and POST
910
1021
  // bodies are already consumed.
911
1022
  if (responseStatus !== 200 || req.method === 'POST') {
912
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus)
1023
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, responseStatus, livePath)
913
1024
  } else if (stream) {
914
- await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
1025
+ await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, livePath)
915
1026
  } else {
916
- await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath)
1027
+ await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, store, csp, faviconPath, 200, livePath)
917
1028
  }
918
1029
 
919
1030
  } catch (err) {
@@ -964,6 +1075,11 @@ export async function createServer(entries, options = {}) {
964
1075
  pageHtmlCache.stopEviction()
965
1076
  brandCache.stopEviction()
966
1077
 
1078
+ // Close live SSE connections so they can't hold the drain open
1079
+ if (liveHeartbeat) clearInterval(liveHeartbeat)
1080
+ for (const res of [...liveClients]) { try { res.end() } catch {} }
1081
+ liveClients.clear()
1082
+
967
1083
  // Stop accepting new connections; exit once all connections are closed
968
1084
  server.close(() => process.exit(0))
969
1085
 
@@ -998,6 +1114,7 @@ export async function createServer(entries, options = {}) {
998
1114
  return {
999
1115
  server,
1000
1116
  shutdown,
1117
+ pushStore,
1001
1118
  updateSpecs(newSpecs) {
1002
1119
  notFoundSpec = newSpecs.find(s => s.route === '*') || null
1003
1120
  router = buildRouter(newSpecs.filter(s => s.route !== '*'))
@@ -1079,7 +1196,7 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
1079
1196
  * Render to a complete string then send — simpler, easier to cache.
1080
1197
  * Checks the in-process page cache before rendering; stores result after.
1081
1198
  */
1082
- async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200) {
1199
+ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, status = 200, livePath = '') {
1083
1200
  const cacheKey = buildCacheKey(spec, ctx)
1084
1201
  // Never cache POST re-renders or non-200 responses (custom 404s) — POST output
1085
1202
  // varies per submission and a cached 404 body would be served with a 200 later.
@@ -1103,7 +1220,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1103
1220
  const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
1104
1221
  const resolvedSpec = { ...spec, meta: await resolveMeta(spec.meta, ctx) }
1105
1222
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
1106
- 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 || '' })
1223
+ 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 })
1107
1224
  html = wrapped.html
1108
1225
  serverTimingValue = wrapped.serverTimingValue
1109
1226
  if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
@@ -1131,7 +1248,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
1131
1248
  * Stream the response — shell first, deferred segments follow.
1132
1249
  * On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
1133
1250
  */
1134
- async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
1251
+ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null, livePath = '') {
1135
1252
  // Serve from in-process page cache when available — skip streaming overhead.
1136
1253
  const cacheKey = buildCacheKey(spec, ctx)
1137
1254
  const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
@@ -1219,7 +1336,7 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
1219
1336
  const scriptTags = (meta.scripts || [])
1220
1337
  .map(src => ` <script src="${escHtml(src)}" defer></script>`)
1221
1338
  .join('\n')
1222
- const storeScript = buildStoreScript(spec, ctx.store || null, nonce)
1339
+ const storeScript = buildStoreScript(spec, ctx.store || null, nonce, livePath)
1223
1340
  const hydrateScript = buildHydrateScript(spec, store, nonce)
1224
1341
 
1225
1342
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
@@ -1724,6 +1841,47 @@ function escHtml(str) {
1724
1841
  .replace(/'/g, '&#39;')
1725
1842
  }
1726
1843
 
1844
+ /**
1845
+ * Compile the declarative redirects map into matchable entries.
1846
+ * Validates at startup — a bad entry throws before the server accepts connections.
1847
+ *
1848
+ * Sources are route patterns ('/old/:slug'); targets are paths or absolute URLs
1849
+ * and may reference the same :params. Values are a target string (301) or
1850
+ * { to, status } for 302/307/308.
1851
+ *
1852
+ * @param {Record<string, string | { to: string, status?: number }> | null} redirects
1853
+ * @returns {Array<{ source: string, pattern: RegExp, params: string[], target: string, status: number }>}
1854
+ */
1855
+ function compileRedirects(redirects) {
1856
+ if (!redirects) return []
1857
+ const VALID_STATUS = new Set([301, 302, 307, 308])
1858
+ const table = []
1859
+
1860
+ for (const [source, value] of Object.entries(redirects)) {
1861
+ if (!source.startsWith('/')) {
1862
+ throw new Error(`Invalid redirect source "${source}" — sources must start with "/" (e.g. '/old-blog/:slug')`)
1863
+ }
1864
+ const target = typeof value === 'string' ? value : value?.to
1865
+ const status = typeof value === 'string' ? 301 : (value?.status ?? 301)
1866
+ if (typeof target !== 'string' || !(target.startsWith('/') || /^https?:\/\//.test(target))) {
1867
+ throw new Error(`Invalid redirect target for "${source}" — targets must start with "/" or be an absolute http(s) URL`)
1868
+ }
1869
+ if (!VALID_STATUS.has(status)) {
1870
+ throw new Error(`Invalid redirect status ${status} for "${source}" — use 301, 302, 307, or 308`)
1871
+ }
1872
+ // Targets may only reference params that the source captures
1873
+ const { pattern, params } = routeToRegex(source)
1874
+ const targetParams = [...target.matchAll(/:([a-zA-Z][\w-]*)/g)].map(m => m[1])
1875
+ const unknown = targetParams.filter(p => !params.includes(p))
1876
+ if (unknown.length > 0) {
1877
+ throw new Error(`Redirect target for "${source}" references :${unknown[0]} which the source does not capture`)
1878
+ }
1879
+ table.push({ source, pattern, params, target, status })
1880
+ }
1881
+
1882
+ return table
1883
+ }
1884
+
1727
1885
  /**
1728
1886
  * Collect sitemap entries from the registered specs.
1729
1887
  *
@@ -1446,6 +1446,92 @@ await test('submit pages are excluded from the in-process page cache', async ()
1446
1446
 
1447
1447
  // ---------------------------------------------------------------------------
1448
1448
 
1449
+ console.log('\nDeclarative redirects\n')
1450
+
1451
+ await test('plain redirect responds 301 with Location', async () => {
1452
+ await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1453
+ const { status, headers } = await get(port, '/old-hello')
1454
+ assert(status === 301, `Expected 301, got ${status}`)
1455
+ assert(headers.location === '/hello', `Expected /hello, got ${headers.location}`)
1456
+ })
1457
+ })
1458
+
1459
+ await test(':params are substituted into the target', async () => {
1460
+ await withServer([helloSpec], { stream: false, redirects: { '/old-blog/:slug': '/blog/:slug' } }, async (port) => {
1461
+ const { status, headers } = await get(port, '/old-blog/my-post')
1462
+ assert(status === 301)
1463
+ assert(headers.location === '/blog/my-post', `Expected /blog/my-post, got ${headers.location}`)
1464
+ })
1465
+ })
1466
+
1467
+ await test('percent-encoded params keep their encoding through substitution', async () => {
1468
+ await withServer([helloSpec], { stream: false, redirects: { '/old/:slug': '/new/:slug' } }, async (port) => {
1469
+ const { headers } = await get(port, '/old/caf%C3%A9')
1470
+ assert(headers.location === '/new/caf%C3%A9', `Encoding mangled: ${headers.location}`)
1471
+ })
1472
+ })
1473
+
1474
+ await test('query string is preserved on redirect', async () => {
1475
+ await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1476
+ const { headers } = await get(port, '/old-hello?utm=x&page=2')
1477
+ assert(headers.location === '/hello?utm=x&page=2', `Query lost: ${headers.location}`)
1478
+ })
1479
+ })
1480
+
1481
+ await test('custom status via { to, status }', async () => {
1482
+ await withServer([helloSpec], { stream: false, redirects: { '/promo': { to: '/hello', status: 302 } } }, async (port) => {
1483
+ const { status, headers } = await get(port, '/promo')
1484
+ assert(status === 302, `Expected 302, got ${status}`)
1485
+ assert(headers.location === '/hello')
1486
+ })
1487
+ })
1488
+
1489
+ await test('absolute URL targets work (domain moves)', async () => {
1490
+ await withServer([helloSpec], { stream: false, redirects: { '/moved/:slug': 'https://newsite.com/blog/:slug' } }, async (port) => {
1491
+ const { headers } = await get(port, '/moved/post-1')
1492
+ assert(headers.location === 'https://newsite.com/blog/post-1', `Got ${headers.location}`)
1493
+ })
1494
+ })
1495
+
1496
+ await test('redirect wins over a registered route at the same path', async () => {
1497
+ await withServer([helloSpec], { stream: false, quiet: true, redirects: { '/hello': '/elsewhere' } }, async (port) => {
1498
+ const { status, headers } = await get(port, '/hello')
1499
+ assert(status === 301 && headers.location === '/elsewhere', `Expected redirect to win, got ${status}`)
1500
+ })
1501
+ })
1502
+
1503
+ await test('POST is not redirected', async () => {
1504
+ await withServer([helloSpec], { stream: false, redirects: { '/old-api': '/hello' } }, async (port) => {
1505
+ const { status } = await requestFull(port, 'POST', '/old-api', {})
1506
+ assert(status === 404, `Expected POST to skip redirects (404), got ${status}`)
1507
+ })
1508
+ })
1509
+
1510
+ await test('self-redirects fall through to normal routing instead of looping', async () => {
1511
+ await withServer([helloSpec], { stream: false, redirects: { '/hello/:x': '/hello/:x' } }, async (port) => {
1512
+ const { status } = await get(port, '/hello/abc')
1513
+ assert(status === 404, `Expected fall-through to routing (404 here), got ${status}`)
1514
+ const ok = await get(port, '/hello')
1515
+ assert(ok.status === 200, 'Unrelated routes unaffected')
1516
+ })
1517
+ })
1518
+
1519
+ await test('invalid redirect config throws at startup', async () => {
1520
+ const cases = [
1521
+ { redirects: { 'no-slash': '/x' } },
1522
+ { redirects: { '/x': 'no-slash-target' } },
1523
+ { redirects: { '/x': { to: '/y', status: 418 } } },
1524
+ { redirects: { '/x': '/y/:missing' } },
1525
+ ]
1526
+ for (const opts of cases) {
1527
+ let threw = false
1528
+ try { await createServer([helloSpec], { ...opts, port: nextPort++, quiet: true }) } catch { threw = true }
1529
+ assert(threw, `Expected startup throw for ${JSON.stringify(opts.redirects)}`)
1530
+ }
1531
+ })
1532
+
1533
+ // ---------------------------------------------------------------------------
1534
+
1449
1535
  console.log('\nSitemap + robots.txt\n')
1450
1536
 
1451
1537
  const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
@@ -1560,6 +1646,138 @@ await test('a failing enumerator does not take down the sitemap', async () => {
1560
1646
 
1561
1647
  // ---------------------------------------------------------------------------
1562
1648
 
1649
+ console.log('\nLive store push (SSE)\n')
1650
+
1651
+ /** Open an SSE connection and return a handle that accumulates received text. */
1652
+ function openSse(port, path) {
1653
+ return new Promise((resolve, reject) => {
1654
+ const req = http.get({ hostname: 'localhost', port, path, headers: { Accept: 'text/event-stream' } }, (res) => {
1655
+ let buf = ''
1656
+ res.on('data', c => { buf += c.toString() })
1657
+ resolve({ status: res.statusCode, headers: res.headers, read: () => buf, destroy: () => req.destroy() })
1658
+ })
1659
+ req.on('error', reject)
1660
+ })
1661
+ }
1662
+
1663
+ const wait = (ms) => new Promise(r => setTimeout(r, ms))
1664
+
1665
+ await test('live: true serves the SSE endpoint', async () => {
1666
+ const port = nextPort++
1667
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1668
+ try {
1669
+ const sse = await openSse(port, '/__pulse/live')
1670
+ assert(sse.status === 200, `Expected 200, got ${sse.status}`)
1671
+ assert(sse.headers['content-type'].includes('text/event-stream'), `Got ${sse.headers['content-type']}`)
1672
+ await wait(50)
1673
+ assert(sse.read().includes(':connected'), 'Expected connection comment')
1674
+ sse.destroy()
1675
+ } finally {
1676
+ server.closeAllConnections?.()
1677
+ await new Promise(r => server.close(r))
1678
+ }
1679
+ })
1680
+
1681
+ await test('pushStore broadcasts a patch to all connected clients', async () => {
1682
+ const port = nextPort++
1683
+ const { server, pushStore } = await createServer([helloSpec], { port, quiet: true, live: true })
1684
+ try {
1685
+ const a = await openSse(port, '/__pulse/live')
1686
+ const b = await openSse(port, '/__pulse/live')
1687
+ await wait(50)
1688
+ const sent = pushStore({ stock: 5, banner: 'Sale on now' })
1689
+ assert(sent === 2, `Expected 2 clients, got ${sent}`)
1690
+ await wait(50)
1691
+ for (const sse of [a, b]) {
1692
+ assert(sse.read().includes('event: store'), 'Missing event name')
1693
+ assert(sse.read().includes('"stock":5'), `Missing payload: ${sse.read()}`)
1694
+ }
1695
+ a.destroy(); b.destroy()
1696
+ } finally {
1697
+ server.closeAllConnections?.()
1698
+ await new Promise(r => server.close(r))
1699
+ }
1700
+ })
1701
+
1702
+ await test('pushStore requires live: true and a plain-object patch', async () => {
1703
+ const port = nextPort++
1704
+ const off = await createServer([helloSpec], { port, quiet: true })
1705
+ try {
1706
+ let threw = false
1707
+ try { off.pushStore({ x: 1 }) } catch { threw = true }
1708
+ assert(threw, 'Expected throw without live: true')
1709
+ } finally {
1710
+ off.server.closeAllConnections?.()
1711
+ await new Promise(r => off.server.close(r))
1712
+ }
1713
+ const port2 = nextPort++
1714
+ const on = await createServer([helloSpec], { port: port2, quiet: true, live: true })
1715
+ try {
1716
+ let threw = false
1717
+ try { on.pushStore('not-an-object') } catch { threw = true }
1718
+ assert(threw, 'Expected throw for non-object patch')
1719
+ } finally {
1720
+ on.server.closeAllConnections?.()
1721
+ await new Promise(r => on.server.close(r))
1722
+ }
1723
+ })
1724
+
1725
+ await test('module-level pushStore export reaches the live server (spec-file usage)', async () => {
1726
+ const { pushStore: globalPush } = await import('./index.js')
1727
+ const port = nextPort++
1728
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: true })
1729
+ try {
1730
+ const sse = await openSse(port, '/__pulse/live')
1731
+ await wait(50)
1732
+ const sent = globalPush({ score: 99 })
1733
+ assert(sent === 1, `Expected module-level pushStore to reach 1 client, got ${sent}`)
1734
+ await wait(50)
1735
+ assert(sse.read().includes('"score":99'), `Missing payload via module export: ${sse.read()}`)
1736
+ sse.destroy()
1737
+ } finally {
1738
+ server.closeAllConnections?.()
1739
+ await new Promise(r => server.close(r))
1740
+ }
1741
+ })
1742
+
1743
+ await test('endpoint is 404 when live is not enabled', async () => {
1744
+ await withServer([helloSpec], { stream: false }, async (port) => {
1745
+ const { status } = await get(port, '/__pulse/live')
1746
+ assert(status === 404, `Expected 404, got ${status}`)
1747
+ })
1748
+ })
1749
+
1750
+ await test('custom endpoint path via { path }', async () => {
1751
+ const port = nextPort++
1752
+ const { server } = await createServer([helloSpec], { port, quiet: true, live: { path: '/_realtime' } })
1753
+ try {
1754
+ const sse = await openSse(port, '/_realtime')
1755
+ assert(sse.status === 200, `Expected 200 on custom path, got ${sse.status}`)
1756
+ sse.destroy()
1757
+ } finally {
1758
+ server.closeAllConnections?.()
1759
+ await new Promise(r => server.close(r))
1760
+ }
1761
+ })
1762
+
1763
+ await test('hydrated pages receive the __PULSE_LIVE__ flag; disabled servers do not', async () => {
1764
+ const hydrated = {
1765
+ route: '/live-page', hydrate: '/src/pages/live-page.js', store: ['stock'],
1766
+ state: { n: 0 }, mutations: { inc: (s) => ({ n: s.n + 1 }) },
1767
+ view: (state, server) => `<main id="main-content">${server.stock ?? '—'}</main>`,
1768
+ }
1769
+ await withServer([hydrated], { stream: false, live: true }, async (port) => {
1770
+ const { body } = await get(port, '/live-page')
1771
+ assert(body.includes(`window.__PULSE_LIVE__='/__pulse/live'`), `Flag missing: ${body.slice(0, 600)}`)
1772
+ })
1773
+ await withServer([hydrated], { stream: false }, async (port) => {
1774
+ const { body } = await get(port, '/live-page')
1775
+ assert(!body.includes('__PULSE_LIVE__'), 'Flag must be absent when live is disabled')
1776
+ })
1777
+ })
1778
+
1779
+ // ---------------------------------------------------------------------------
1780
+
1563
1781
  console.log('\nMalformed request handling\n')
1564
1782
 
1565
1783
  await test('malformed percent-encoding in path returns 404, not 500', async () => {
package/types/server.d.ts CHANGED
@@ -94,6 +94,29 @@ export interface ServerOptions {
94
94
  */
95
95
  robots?: string | false | null
96
96
 
97
+ /**
98
+ * Declarative redirect map for legacy URLs (SEO-safe migrations).
99
+ * Sources are route patterns; targets are paths or absolute URLs and may
100
+ * reference the same :params. A string target redirects with 301; use
101
+ * { to, status } for 302/307/308. Checked before route matching (GET/HEAD
102
+ * only); the query string is preserved. Validated at startup.
103
+ * @example
104
+ * redirects: {
105
+ * '/old-blog/:slug': '/blog/:slug',
106
+ * '/promo': { to: '/pricing', status: 302 },
107
+ * }
108
+ */
109
+ redirects?: Record<string, string | { to: string; status?: 301 | 302 | 307 | 308 }> | null
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
+
97
120
  /**
98
121
  * Extra CSP sources to merge into the framework's default Content-Security-Policy.
99
122
  * Use this to allow external stylesheets, fonts, or API origins.
@@ -126,6 +149,16 @@ export interface PulseStoreDefinition {
126
149
  // createServer return value
127
150
  // ---------------------------------------------------------------------------
128
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
+
129
162
  export interface ServerInstance {
130
163
  /** The underlying Node.js http.Server */
131
164
  server: Server
@@ -141,6 +174,15 @@ export interface ServerInstance {
141
174
 
142
175
  /** Swap the spec list at runtime (used by the dev server for hot reload). */
143
176
  updateSpecs(specs: PulseSpec[]): void
177
+
178
+ /**
179
+ * Broadcast a store patch to every connected live client (requires the
180
+ * `live` option). Pages subscribed via spec.store merge the patch and
181
+ * re-render immediately. Returns the number of clients reached.
182
+ * Broadcast channel — push SHARED data only, never per-user data.
183
+ * @example pushStore({ stock: 5 })
184
+ */
185
+ pushStore(partial: Record<string, unknown>): number
144
186
  }
145
187
 
146
188
  // ---------------------------------------------------------------------------