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