@invisibleloop/pulse 0.19.0 → 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 +33 -0
- package/docs/src/pages/server-api.js +1 -0
- package/docs/src/pages/store.js +22 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/src/agent/guide-server.md +44 -0
- package/src/cli/dev.js +4 -0
- package/src/cli/start.js +8 -0
- package/src/runtime/index.js +8 -1
- package/src/runtime/runtime.test.js +30 -1
- package/src/runtime/ssr.js +8 -5
- package/src/runtime/store.js +28 -0
- package/src/server/index.js +90 -7
- package/src/server/server.test.js +132 -0
- package/types/server.d.ts +28 -0
package/CLAUDE.md
CHANGED
|
@@ -298,6 +298,8 @@ await createServer(
|
|
|
298
298
|
'/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
|
|
299
299
|
'/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
|
|
300
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
|
|
301
303
|
csp: { // extra sources merged into the framework's default CSP
|
|
302
304
|
'style-src': ['https://fonts.googleapis.com'],
|
|
303
305
|
'font-src': ['https://fonts.gstatic.com'],
|
|
@@ -314,6 +316,37 @@ await createServer(
|
|
|
314
316
|
|
|
315
317
|
All specs are validated at startup — bad specs throw before the server accepts connections.
|
|
316
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
|
+
|
|
317
350
|
## Multi-brand Sites
|
|
318
351
|
|
|
319
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.
|
|
@@ -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 & 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
|
]
|
package/docs/src/pages/store.js
CHANGED
|
@@ -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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.20.0
|
|
@@ -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
|
})
|
package/src/runtime/index.js
CHANGED
|
@@ -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')
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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}'
|
|
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}'
|
|
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)
|
package/src/runtime/store.js
CHANGED
|
@@ -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,
|
package/src/server/index.js
CHANGED
|
@@ -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
|
|
@@ -622,6 +650,39 @@ export async function createServer(entries, options = {}) {
|
|
|
622
650
|
// tied to a cookie, so a restart only means in-flight forms re-render once.
|
|
623
651
|
const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
|
|
624
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
|
+
|
|
625
686
|
// Redirects — compile the declarative map once at startup (throws on bad
|
|
626
687
|
// entries) and warn when a redirect source shadows a registered page route.
|
|
627
688
|
const redirectTable = compileRedirects(redirects)
|
|
@@ -678,6 +739,22 @@ export async function createServer(entries, options = {}) {
|
|
|
678
739
|
return
|
|
679
740
|
}
|
|
680
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
|
+
|
|
681
758
|
// Request hook — allows middleware-like behaviour
|
|
682
759
|
if (onRequest) {
|
|
683
760
|
const result = onRequest(req, res)
|
|
@@ -943,11 +1020,11 @@ export async function createServer(entries, options = {}) {
|
|
|
943
1020
|
// string path — they are small, must carry an explicit status, and POST
|
|
944
1021
|
// bodies are already consumed.
|
|
945
1022
|
if (responseStatus !== 200 || req.method === 'POST') {
|
|
946
|
-
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)
|
|
947
1024
|
} else if (stream) {
|
|
948
|
-
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)
|
|
949
1026
|
} else {
|
|
950
|
-
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)
|
|
951
1028
|
}
|
|
952
1029
|
|
|
953
1030
|
} catch (err) {
|
|
@@ -998,6 +1075,11 @@ export async function createServer(entries, options = {}) {
|
|
|
998
1075
|
pageHtmlCache.stopEviction()
|
|
999
1076
|
brandCache.stopEviction()
|
|
1000
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
|
+
|
|
1001
1083
|
// Stop accepting new connections; exit once all connections are closed
|
|
1002
1084
|
server.close(() => process.exit(0))
|
|
1003
1085
|
|
|
@@ -1032,6 +1114,7 @@ export async function createServer(entries, options = {}) {
|
|
|
1032
1114
|
return {
|
|
1033
1115
|
server,
|
|
1034
1116
|
shutdown,
|
|
1117
|
+
pushStore,
|
|
1035
1118
|
updateSpecs(newSpecs) {
|
|
1036
1119
|
notFoundSpec = newSpecs.find(s => s.route === '*') || null
|
|
1037
1120
|
router = buildRouter(newSpecs.filter(s => s.route !== '*'))
|
|
@@ -1113,7 +1196,7 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
|
|
|
1113
1196
|
* Render to a complete string then send — simpler, easier to cache.
|
|
1114
1197
|
* Checks the in-process page cache before rendering; stores result after.
|
|
1115
1198
|
*/
|
|
1116
|
-
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 = '') {
|
|
1117
1200
|
const cacheKey = buildCacheKey(spec, ctx)
|
|
1118
1201
|
// Never cache POST re-renders or non-200 responses (custom 404s) — POST output
|
|
1119
1202
|
// varies per submission and a cached 404 body would be served with a 200 later.
|
|
@@ -1137,7 +1220,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
1137
1220
|
const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${escHtml(canonicalUrl)}">` : ''
|
|
1138
1221
|
const resolvedSpec = { ...spec, meta: await resolveMeta(spec.meta, ctx) }
|
|
1139
1222
|
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 || '' })
|
|
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 })
|
|
1141
1224
|
html = wrapped.html
|
|
1142
1225
|
serverTimingValue = wrapped.serverTimingValue
|
|
1143
1226
|
if (ttl > 0) pageHtmlCache.set(cacheKey, { html }, ttl)
|
|
@@ -1165,7 +1248,7 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
1165
1248
|
* Stream the response — shell first, deferred segments follow.
|
|
1166
1249
|
* On a page-cache hit, serves the buffered HTML as a string (no streaming needed).
|
|
1167
1250
|
*/
|
|
1168
|
-
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 = '') {
|
|
1169
1252
|
// Serve from in-process page cache when available — skip streaming overhead.
|
|
1170
1253
|
const cacheKey = buildCacheKey(spec, ctx)
|
|
1171
1254
|
const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
|
|
@@ -1253,7 +1336,7 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
|
|
|
1253
1336
|
const scriptTags = (meta.scripts || [])
|
|
1254
1337
|
.map(src => ` <script src="${escHtml(src)}" defer></script>`)
|
|
1255
1338
|
.join('\n')
|
|
1256
|
-
const storeScript = buildStoreScript(spec, ctx.store || null, nonce)
|
|
1339
|
+
const storeScript = buildStoreScript(spec, ctx.store || null, nonce, livePath)
|
|
1257
1340
|
const hydrateScript = buildHydrateScript(spec, store, nonce)
|
|
1258
1341
|
|
|
1259
1342
|
const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
|
|
@@ -1646,6 +1646,138 @@ await test('a failing enumerator does not take down the sitemap', async () => {
|
|
|
1646
1646
|
|
|
1647
1647
|
// ---------------------------------------------------------------------------
|
|
1648
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
|
+
|
|
1649
1781
|
console.log('\nMalformed request handling\n')
|
|
1650
1782
|
|
|
1651
1783
|
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,15 @@ 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
|
+
/**
|
|
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
|
|
158
186
|
}
|
|
159
187
|
|
|
160
188
|
// ---------------------------------------------------------------------------
|