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