@invisibleloop/pulse 0.20.9 → 0.20.11
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/docs/public/pulse-ui.css +2 -2
- package/docs/public/theme.css +8 -8
- package/docs/src/pages/home.js +6 -1
- package/docs/src/pages/templates.js +13 -7
- package/examples/public/pulse-ui.css +2 -2
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +2 -2
- package/scripts/build.js +15 -3
- package/src/runtime/index.js +113 -61
- package/src/runtime/runtime.test.js +46 -2
- package/src/runtime/ssr.js +29 -0
- package/src/server/index.js +4 -2
package/docs/public/pulse-ui.css
CHANGED
|
@@ -1719,14 +1719,14 @@ hr.ui-divider {
|
|
|
1719
1719
|
.ui-code-window-filename {
|
|
1720
1720
|
font-size: .75rem;
|
|
1721
1721
|
font-family: var(--ui-mono);
|
|
1722
|
-
color:
|
|
1722
|
+
color: var(--ui-muted);
|
|
1723
1723
|
margin-left: .35rem;
|
|
1724
1724
|
}
|
|
1725
1725
|
|
|
1726
1726
|
.ui-code-window-lang {
|
|
1727
1727
|
font-size: .7rem;
|
|
1728
1728
|
font-family: var(--ui-mono);
|
|
1729
|
-
color:
|
|
1729
|
+
color: var(--ui-muted);
|
|
1730
1730
|
margin-left: auto;
|
|
1731
1731
|
}
|
|
1732
1732
|
|
package/docs/public/theme.css
CHANGED
|
@@ -24,22 +24,22 @@
|
|
|
24
24
|
--border-subtle: #ece7d9;
|
|
25
25
|
--text: var(--brut-ink);
|
|
26
26
|
--muted: var(--brut-muted);
|
|
27
|
-
--muted-2: #
|
|
28
|
-
--accent:
|
|
27
|
+
--muted-2: #736b5c;
|
|
28
|
+
--accent: #8a6300;
|
|
29
29
|
--accent-text: var(--brut-ink);
|
|
30
|
-
--accent-hover: #
|
|
30
|
+
--accent-hover: #7a5700;
|
|
31
31
|
--accent-dim: rgba(201, 151, 0, 0.12);
|
|
32
32
|
--green: #166534;
|
|
33
33
|
--red: #b91c1c;
|
|
34
34
|
|
|
35
35
|
/* Syntax tokens — dark-on-light, tuned for contrast on --surface-2 */
|
|
36
36
|
--tok-kw: #7c3aed;
|
|
37
|
-
--tok-str: #
|
|
38
|
-
--tok-cmt: #
|
|
39
|
-
--tok-num: #
|
|
37
|
+
--tok-str: #166534;
|
|
38
|
+
--tok-cmt: #5c574f;
|
|
39
|
+
--tok-num: #9a3412;
|
|
40
40
|
--tok-fn: #1d4ed8;
|
|
41
|
-
--tok-prop: #
|
|
42
|
-
--tok-op: #
|
|
41
|
+
--tok-prop: #854d0e;
|
|
42
|
+
--tok-op: #0e6280;
|
|
43
43
|
--tok-punct: #57534e;
|
|
44
44
|
|
|
45
45
|
/* Sticky header background — replaces the hardcoded rgba(13,13,16,.9) */
|
package/docs/src/pages/home.js
CHANGED
|
@@ -56,7 +56,12 @@ export default {
|
|
|
56
56
|
title: 'Pulse — The spec-first web framework',
|
|
57
57
|
description: 'Pulse is a server-first Node.js framework with zero runtime dependencies. One spec object per page: server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the architecture.',
|
|
58
58
|
theme: 'light',
|
|
59
|
-
styles: ['
|
|
59
|
+
styles: ['/pulse-ui.css', '/theme.css', '/docs.css', '/home-brut.css'],
|
|
60
|
+
// Loaded via JS after initial parse — non-render-blocking. The preconnect
|
|
61
|
+
// hints below still warm the connection up early, and display=swap in the
|
|
62
|
+
// URL means the browser paints with a fallback font immediately and swaps
|
|
63
|
+
// to Inter once it arrives, so there's no invisible-text window either way.
|
|
64
|
+
deferredStyles: ['https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800;900&display=swap'],
|
|
60
65
|
schema: {
|
|
61
66
|
'@context': 'https://schema.org',
|
|
62
67
|
'@type': 'SoftwareApplication',
|
|
@@ -13,20 +13,26 @@ function esc(s) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function statPill(label, value) {
|
|
16
|
-
return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.
|
|
17
|
-
<span style="color:#
|
|
16
|
+
return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14);color:rgba(255,255,255,0.7)">
|
|
17
|
+
<span style="color:#a78bfa">●</span> ${esc(label)}: <strong style="color:#fff;font-weight:600">${esc(String(value))}</strong>
|
|
18
18
|
</span>`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function templateCard({ name, route, description, tags = [], stats = {}, preview = '' }) {
|
|
22
22
|
const tagHtml = tags.map(t =>
|
|
23
|
-
`<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.
|
|
23
|
+
`<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.16);border-radius:4px;font-size:0.72rem;font-weight:500;color:rgba(255,255,255,0.7)">${esc(t)}</span>`
|
|
24
24
|
).join('')
|
|
25
25
|
|
|
26
26
|
const statHtml = Object.entries(stats).map(([k, v]) => statPill(k, v)).join('')
|
|
27
27
|
|
|
28
|
+
// This card is a deliberately dark, self-contained surface (macOS-style
|
|
29
|
+
// preview window + dark body) regardless of the page's own theme — it
|
|
30
|
+
// mirrors the code-window pattern elsewhere. Uses a real dark background
|
|
31
|
+
// (not a translucent white tint over the page background), so it reads
|
|
32
|
+
// correctly on both the light and dark docs themes and keeps the
|
|
33
|
+
// white-tinted text/borders it was designed with at proper contrast.
|
|
28
34
|
return `
|
|
29
|
-
<div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background
|
|
35
|
+
<div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background:#171512">
|
|
30
36
|
|
|
31
37
|
<a href="${esc(route)}" aria-label="Open ${esc(name)} template" style="display:block;background:#0d0d12;border-bottom:1px solid rgba(255,255,255,0.08);text-decoration:none;overflow:hidden">
|
|
32
38
|
<div style="padding:0.5rem 0.75rem;background:#16161e;display:flex;align-items:center;gap:0.375rem;border-bottom:1px solid rgba(255,255,255,0.06)">
|
|
@@ -40,13 +46,13 @@ function templateCard({ name, route, description, tags = [], stats = {}, preview
|
|
|
40
46
|
</div>
|
|
41
47
|
</a>
|
|
42
48
|
|
|
43
|
-
<div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem">
|
|
49
|
+
<div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem;color:#fff">
|
|
44
50
|
<div>
|
|
45
51
|
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
|
|
46
|
-
<h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3">${esc(name)}</h2>
|
|
52
|
+
<h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3;color:#fff">${esc(name)}</h2>
|
|
47
53
|
${tagHtml}
|
|
48
54
|
</div>
|
|
49
|
-
<p style="margin:0;font-size:0.85rem;line-height:1.6;
|
|
55
|
+
<p style="margin:0;font-size:0.85rem;line-height:1.6;color:rgba(255,255,255,0.7)">${esc(description)}</p>
|
|
50
56
|
</div>
|
|
51
57
|
<div style="display:flex;flex-wrap:wrap;gap:0.3rem;padding-top:0.625rem;border-top:1px solid rgba(255,255,255,0.08)">${statHtml}</div>
|
|
52
58
|
</div>
|
|
@@ -1719,14 +1719,14 @@ hr.ui-divider {
|
|
|
1719
1719
|
.ui-code-window-filename {
|
|
1720
1720
|
font-size: .75rem;
|
|
1721
1721
|
font-family: var(--ui-mono);
|
|
1722
|
-
color:
|
|
1722
|
+
color: var(--ui-muted);
|
|
1723
1723
|
margin-left: .35rem;
|
|
1724
1724
|
}
|
|
1725
1725
|
|
|
1726
1726
|
.ui-code-window-lang {
|
|
1727
1727
|
font-size: .7rem;
|
|
1728
1728
|
font-family: var(--ui-mono);
|
|
1729
|
-
color:
|
|
1729
|
+
color: var(--ui-muted);
|
|
1730
1730
|
margin-left: auto;
|
|
1731
1731
|
}
|
|
1732
1732
|
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.11
|
package/public/pulse-ui.css
CHANGED
|
@@ -1719,14 +1719,14 @@ hr.ui-divider {
|
|
|
1719
1719
|
.ui-code-window-filename {
|
|
1720
1720
|
font-size: .75rem;
|
|
1721
1721
|
font-family: var(--ui-mono);
|
|
1722
|
-
color:
|
|
1722
|
+
color: var(--ui-muted);
|
|
1723
1723
|
margin-left: .35rem;
|
|
1724
1724
|
}
|
|
1725
1725
|
|
|
1726
1726
|
.ui-code-window-lang {
|
|
1727
1727
|
font-size: .7rem;
|
|
1728
1728
|
font-family: var(--ui-mono);
|
|
1729
|
-
color:
|
|
1729
|
+
color: var(--ui-muted);
|
|
1730
1730
|
margin-left: auto;
|
|
1731
1731
|
}
|
|
1732
1732
|
|
package/scripts/build.js
CHANGED
|
@@ -502,7 +502,7 @@ const result = await esbuild.build({
|
|
|
502
502
|
platform: 'browser',
|
|
503
503
|
outdir: OUT_DIR,
|
|
504
504
|
entryNames: '[name]-[hash]',
|
|
505
|
-
chunkNames: '
|
|
505
|
+
chunkNames: 'chunk-[hash]',
|
|
506
506
|
splitting: true,
|
|
507
507
|
minify: true,
|
|
508
508
|
metafile: true,
|
|
@@ -520,13 +520,25 @@ const result = await esbuild.build({
|
|
|
520
520
|
|
|
521
521
|
const manifest = {}
|
|
522
522
|
|
|
523
|
+
// Identify the real Pulse framework runtime chunk (the one containing mount())
|
|
524
|
+
// by which auto-split chunks actually import RUNTIME_PATH — not by filename
|
|
525
|
+
// prefix, since chunkNames now applies to every generic shared chunk esbuild
|
|
526
|
+
// splits out (app-level shared code included), not just the framework runtime.
|
|
527
|
+
const RUNTIME_ABS = path.resolve(RUNTIME_PATH)
|
|
528
|
+
const realRuntimeChunk = Object.entries(result.metafile.outputs).find(([outFile, meta]) => {
|
|
529
|
+
if (meta.entryPoint) return false
|
|
530
|
+
// meta.inputs keys are relative to process.cwd() — resolve to compare reliably
|
|
531
|
+
return Object.keys(meta.inputs).some(input => path.resolve(input) === RUNTIME_ABS)
|
|
532
|
+
})?.[0]
|
|
533
|
+
|
|
523
534
|
for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
|
|
524
535
|
// outFile is relative to process.cwd(), not ROOT — use path.resolve to get the
|
|
525
536
|
// true absolute path before computing the URL-relative bundle path.
|
|
526
537
|
const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.resolve(outFile))
|
|
527
538
|
|
|
528
|
-
// Shared runtime chunk — esbuild generates this via splitting, no entryPoint
|
|
529
|
-
|
|
539
|
+
// Shared runtime chunk — esbuild generates this via splitting, no entryPoint.
|
|
540
|
+
// Identified by containing the actual runtime/index.js source, not by name.
|
|
541
|
+
if (!meta.entryPoint && outFile === realRuntimeChunk) {
|
|
530
542
|
manifest['_runtime'] = bundlePath
|
|
531
543
|
continue
|
|
532
544
|
}
|
package/src/runtime/index.js
CHANGED
|
@@ -7,12 +7,28 @@
|
|
|
7
7
|
* No framework. No virtual DOM. No dependencies.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation, initLiveStore } from './store.js'
|
|
11
10
|
import { trustedHTML, trustedScriptURL } from './tt.js'
|
|
12
11
|
|
|
13
12
|
// Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
|
|
14
13
|
const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
|
|
15
14
|
|
|
15
|
+
// store.js is lazy-loaded only by pages that declare spec.store — pages that
|
|
16
|
+
// never subscribe to the global store pay zero bytes for it (same pattern as
|
|
17
|
+
// showToast above). updateStore is needed synchronously inside runAction()
|
|
18
|
+
// for _storeUpdate (any page can push to the store from an action, even one
|
|
19
|
+
// that doesn't itself subscribe), so it is loaded the same lazy way on first
|
|
20
|
+
// use and cached in _storeModule for subsequent calls.
|
|
21
|
+
let _storeModule = null
|
|
22
|
+
const loadStore = () => _storeModule
|
|
23
|
+
? Promise.resolve(_storeModule)
|
|
24
|
+
: import('./store.js').then(m => { _storeModule = m; return m })
|
|
25
|
+
|
|
26
|
+
// data-store-event is wired via event delegation at bindEvents() time, before
|
|
27
|
+
// spec.store is known to be involved (a page can carry a data-store-event
|
|
28
|
+
// button without declaring spec.store itself) — load store.js on first fire.
|
|
29
|
+
const dispatchStoreMutationLazy = (name, payload) =>
|
|
30
|
+
loadStore().then(({ dispatchStoreMutation }) => dispatchStoreMutation(name, payload))
|
|
31
|
+
|
|
16
32
|
// ---------------------------------------------------------------------------
|
|
17
33
|
// Mount
|
|
18
34
|
// ---------------------------------------------------------------------------
|
|
@@ -29,37 +45,85 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
29
45
|
throw new Error('[Pulse] mount: spec must have a view')
|
|
30
46
|
}
|
|
31
47
|
// Spec is validated server-side at startup — no need to re-validate in the browser
|
|
32
|
-
// Initialise the client store from SSR data (no-op after the first page mount).
|
|
33
|
-
// window.__PULSE_STORE__ is serialised by the server when a store is registered.
|
|
34
|
-
if (typeof window !== 'undefined') {
|
|
35
|
-
initClientStore(window.__PULSE_STORE__ || {})
|
|
36
|
-
// Register store mutations so data-store-event bindings can dispatch them.
|
|
37
|
-
// No-op on subsequent mounts — mutations persist across client navigations.
|
|
38
|
-
if (options.store?.mutations) {
|
|
39
|
-
registerStoreMutations(options.store.mutations)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
48
|
// Separate page-level server state from store keys so they can be tracked
|
|
44
49
|
// independently — store values come from the live singleton, not the snapshot.
|
|
45
50
|
const _storeKeys = new Set(spec.store || [])
|
|
46
51
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
// store.js (initClientStore, registerStoreMutations, subscribe, initLiveStore,
|
|
53
|
+
// getStoreState, updateStore, dispatchStoreMutation) is lazy-loaded — only pages
|
|
54
|
+
// that actually touch the store pay for it. Two independent triggers load it:
|
|
55
|
+
// 1. spec.store is non-empty (this page reads store data / needs live reactivity)
|
|
56
|
+
// 2. a data-store-event fires, or an action returns _storeUpdate (this page
|
|
57
|
+
// pushes to the store without necessarily subscribing to it)
|
|
58
|
+
// Both paths funnel through loadStore(), which caches the resolved module so a
|
|
59
|
+
// second trigger on the same page is a no-op re-await, not a second fetch.
|
|
60
|
+
//
|
|
61
|
+
// initClientStore is safe to defer: it is only ever read via getStoreState(),
|
|
62
|
+
// which is only called from getEffectiveServerState() below, itself gated on
|
|
63
|
+
// _storeKeys.size — so a page with no spec.store never needs the store singleton
|
|
64
|
+
// initialised at all. window.__PULSE_STORE__ is also re-serialised fresh by the
|
|
65
|
+
// server on every navigation (see ssr.js buildStoreScript / navigate.js's
|
|
66
|
+
// window.__updatePulseStore__), so there is no cross-navigation dependency on an
|
|
67
|
+
// eager initClientStore call from a page that didn't declare spec.store.
|
|
68
|
+
// Populated once store.js resolves and this page subscribes — destroy() reads
|
|
69
|
+
// it at call time, so a destroy() that races the import still unsubscribes
|
|
70
|
+
// correctly (it just runs the .then() below first, since both are microtasks
|
|
71
|
+
// queued off the same import; destroy() itself is only ever called later, from
|
|
72
|
+
// user code or client navigation, well after mount() has returned).
|
|
73
|
+
let _unsubStore = null
|
|
74
|
+
let _destroyed = false
|
|
75
|
+
|
|
76
|
+
// NOTE: subscribe() below is intentionally not gated on `typeof window` — this
|
|
77
|
+
// matches the pre-lazy behaviour, where store subscription worked in any
|
|
78
|
+
// environment (including Node test environments with no `window` global) as
|
|
79
|
+
// long as spec.store was non-empty. Only the window-derived inputs
|
|
80
|
+
// (window.__PULSE_STORE__, window.__PULSE_LIVE__) are individually guarded.
|
|
81
|
+
if (_storeKeys.size > 0) {
|
|
82
|
+
loadStore().then(({ initClientStore, registerStoreMutations, initLiveStore, subscribe }) => {
|
|
83
|
+
if (_destroyed) return
|
|
84
|
+
if (typeof window !== 'undefined') {
|
|
85
|
+
// window.__PULSE_STORE__ is serialised by the server when a store is registered.
|
|
86
|
+
initClientStore(window.__PULSE_STORE__ || {})
|
|
87
|
+
// Register store mutations so data-store-event bindings can dispatch them.
|
|
88
|
+
// No-op on subsequent mounts — mutations persist across client navigations.
|
|
89
|
+
if (options.store?.mutations) {
|
|
90
|
+
registerStoreMutations(options.store.mutations)
|
|
91
|
+
}
|
|
92
|
+
// Live store push — connect to the server's SSE channel, but only when this
|
|
93
|
+
// page actually subscribes to store keys. Singleton: survives navigations,
|
|
94
|
+
// repeat mounts are no-ops. Pages without store keys never open a connection.
|
|
95
|
+
if (window.__PULSE_LIVE__) {
|
|
96
|
+
initLiveStore(window.__PULSE_LIVE__)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Subscribe to global store — re-render whenever store keys this page uses
|
|
100
|
+
// change. Wired here (after the async import resolves) rather than
|
|
101
|
+
// synchronously in mount() — see the module-level comment on loadStore.
|
|
102
|
+
_unsubStore = subscribe(() => render())
|
|
103
|
+
})
|
|
52
104
|
}
|
|
53
105
|
const _pageServerState = {}
|
|
54
106
|
for (const [k, v] of Object.entries(serverState)) {
|
|
55
107
|
if (!_storeKeys.has(k)) _pageServerState[k] = v
|
|
56
108
|
}
|
|
57
109
|
|
|
110
|
+
// Snapshot of the store keys as SSR handed them to mount() — used as a
|
|
111
|
+
// fallback for renders that happen before store.js's lazy import resolves
|
|
112
|
+
// (initial render, and any dispatch that fires in that window). This keeps
|
|
113
|
+
// first paint identical to the pre-lazy behaviour: the SSR-seeded values are
|
|
114
|
+
// available immediately. Once store.js loads, getEffectiveServerState()
|
|
115
|
+
// switches over to the live singleton (getStoreState()), which is required
|
|
116
|
+
// for correctness after any store mutation.
|
|
117
|
+
const _storeSnapshot = {}
|
|
118
|
+
for (const k of _storeKeys) {
|
|
119
|
+
if (serverState[k] !== undefined) _storeSnapshot[k] = serverState[k]
|
|
120
|
+
}
|
|
121
|
+
|
|
58
122
|
// Build the server state the view sees: fresh store slice + page-level data.
|
|
59
123
|
// Page-level keys always win over store keys with the same name.
|
|
60
124
|
function getEffectiveServerState() {
|
|
61
125
|
if (!_storeKeys.size) return _pageServerState
|
|
62
|
-
const storeState = getStoreState()
|
|
126
|
+
const storeState = _storeModule ? _storeModule.getStoreState() : _storeSnapshot
|
|
63
127
|
const slice = {}
|
|
64
128
|
for (const key of _storeKeys) {
|
|
65
129
|
if (storeState[key] !== undefined) slice[key] = storeState[key]
|
|
@@ -109,7 +173,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
109
173
|
try {
|
|
110
174
|
html = resolveView(spec, state, getEffectiveServerState())
|
|
111
175
|
} catch (err) {
|
|
112
|
-
console.error('[Pulse]
|
|
176
|
+
console.error('[Pulse] view error:', err)
|
|
113
177
|
const serverState = getEffectiveServerState()
|
|
114
178
|
html = spec.onViewError
|
|
115
179
|
? spec.onViewError(err, state, serverState)
|
|
@@ -120,6 +184,24 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
120
184
|
morph(el, html)
|
|
121
185
|
}
|
|
122
186
|
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Result application — shared by every mutation/action lifecycle step.
|
|
189
|
+
// Handles the side-channel keys (_toast, _storeUpdate), merges the rest of
|
|
190
|
+
// `raw` into state via constraints, and re-renders only if state actually
|
|
191
|
+
// changed. Used by dispatch() and every stage of runAction().
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function applyResult(raw) {
|
|
195
|
+
const { _toast, _storeUpdate, ...partial } = raw ?? {}
|
|
196
|
+
// store.js may not be loaded yet on a page that doesn't itself declare
|
|
197
|
+
// spec.store but still pushes via _storeUpdate — load it on demand.
|
|
198
|
+
if (_storeUpdate) loadStore().then(({ updateStore }) => updateStore(_storeUpdate))
|
|
199
|
+
if (_toast) showToast(_toast)
|
|
200
|
+
const prev = state
|
|
201
|
+
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
202
|
+
if (shallowChanged(prev, state)) render()
|
|
203
|
+
}
|
|
204
|
+
|
|
123
205
|
// ---------------------------------------------------------------------------
|
|
124
206
|
// Dispatch — the single entry point for all state changes
|
|
125
207
|
// ---------------------------------------------------------------------------
|
|
@@ -128,12 +210,8 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
128
210
|
// Mutation
|
|
129
211
|
if (spec.mutations?.[type]) {
|
|
130
212
|
const raw = spec.mutations[type](state, payload)
|
|
131
|
-
|
|
132
|
-
const { _toast, ...partial } = raw ?? {}
|
|
133
|
-
const prev = state
|
|
134
|
-
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
213
|
+
applyResult(raw)
|
|
135
214
|
persist()
|
|
136
|
-
if (shallowChanged(prev, state)) render()
|
|
137
215
|
return
|
|
138
216
|
}
|
|
139
217
|
|
|
@@ -143,7 +221,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
143
221
|
return
|
|
144
222
|
}
|
|
145
223
|
|
|
146
|
-
console.warn(`[Pulse]
|
|
224
|
+
console.warn(`[Pulse] no mutation/action "${type}"`)
|
|
147
225
|
}
|
|
148
226
|
|
|
149
227
|
// ---------------------------------------------------------------------------
|
|
@@ -153,25 +231,15 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
153
231
|
async function runAction(name, action, currentState, payload) {
|
|
154
232
|
// Optimistic update before async work
|
|
155
233
|
if (action.onStart) {
|
|
156
|
-
|
|
157
|
-
if (raw?._toast) showToast(raw._toast)
|
|
158
|
-
const { _toast, ...partial } = raw ?? {}
|
|
159
|
-
const prev = state
|
|
160
|
-
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
161
|
-
if (shallowChanged(prev, state)) render()
|
|
234
|
+
applyResult(action.onStart(currentState, payload))
|
|
162
235
|
}
|
|
163
236
|
|
|
164
237
|
// Validate before running if requested
|
|
165
238
|
if (action.validate) {
|
|
166
239
|
const errors = validateFields(state, spec.validation)
|
|
167
240
|
if (errors.length > 0) {
|
|
168
|
-
console.warn(`[Pulse] Validation failed
|
|
169
|
-
|
|
170
|
-
if (raw._toast) showToast(raw._toast)
|
|
171
|
-
const { _toast, ...partial } = raw
|
|
172
|
-
const prev = state
|
|
173
|
-
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
174
|
-
if (shallowChanged(prev, state)) render()
|
|
241
|
+
console.warn(`[Pulse] Validation failed: "${name}"`, errors)
|
|
242
|
+
applyResult(action.onError?.(state, { validation: errors }))
|
|
175
243
|
return
|
|
176
244
|
}
|
|
177
245
|
}
|
|
@@ -179,23 +247,10 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
179
247
|
try {
|
|
180
248
|
// Pass fresh effective server state (includes live store values)
|
|
181
249
|
const result = await action.run(state, getEffectiveServerState(), payload)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// _storeUpdate — push changes to the global store and notify all subscribers
|
|
185
|
-
if (raw._storeUpdate) updateStore(raw._storeUpdate)
|
|
186
|
-
if (raw._toast) showToast(raw._toast)
|
|
187
|
-
const { _storeUpdate: _su, _toast: _t, ...partial } = raw
|
|
188
|
-
const prev = state
|
|
189
|
-
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
190
|
-
if (shallowChanged(prev, state)) render()
|
|
250
|
+
applyResult(action.onSuccess(state, result))
|
|
191
251
|
} catch (error) {
|
|
192
|
-
console.error(`[Pulse]
|
|
193
|
-
|
|
194
|
-
if (raw._toast) showToast(raw._toast)
|
|
195
|
-
const { _toast, ...partial } = raw
|
|
196
|
-
const prev = state
|
|
197
|
-
state = applyConstraints({ ...state, ...partial }, spec.constraints)
|
|
198
|
-
if (shallowChanged(prev, state)) render()
|
|
252
|
+
console.error(`[Pulse] action "${name}" failed:`, error)
|
|
253
|
+
applyResult(action.onError(state, error))
|
|
199
254
|
}
|
|
200
255
|
|
|
201
256
|
persist()
|
|
@@ -210,9 +265,6 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
210
265
|
const _eventAbort = new AbortController()
|
|
211
266
|
bindEvents(el, dispatch, _eventAbort.signal)
|
|
212
267
|
|
|
213
|
-
// Subscribe to global store — re-render whenever store keys this page uses change.
|
|
214
|
-
const _unsubStore = _storeKeys.size > 0 ? subscribe(() => render()) : null
|
|
215
|
-
|
|
216
268
|
// If the element was server-rendered, skip the initial render to avoid
|
|
217
269
|
// touching existing DOM — this preserves the early LCP paint from SSR.
|
|
218
270
|
// Morph on first mutation will diff from whatever SSR left in the DOM.
|
|
@@ -235,7 +287,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
235
287
|
refresh: render,
|
|
236
288
|
|
|
237
289
|
/** Tear down — remove event listeners, unsubscribe from store, clear element */
|
|
238
|
-
destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
|
|
290
|
+
destroy: () => { _destroyed = true; _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
|
|
239
291
|
}
|
|
240
292
|
}
|
|
241
293
|
|
|
@@ -291,7 +343,7 @@ function bindEvents(el, dispatch, signal) {
|
|
|
291
343
|
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
292
344
|
if (storeTarget) {
|
|
293
345
|
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
294
|
-
if (sType === 'click') { e.preventDefault();
|
|
346
|
+
if (sType === 'click') { e.preventDefault(); dispatchStoreMutationLazy(sName, e) }
|
|
295
347
|
return
|
|
296
348
|
}
|
|
297
349
|
|
|
@@ -334,7 +386,7 @@ function bindEvents(el, dispatch, signal) {
|
|
|
334
386
|
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
335
387
|
if (storeTarget) {
|
|
336
388
|
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
337
|
-
if (sType === 'change')
|
|
389
|
+
if (sType === 'change') dispatchStoreMutationLazy(sName, e)
|
|
338
390
|
return
|
|
339
391
|
}
|
|
340
392
|
const target = e.target?.closest?.('[data-event]')
|
|
@@ -348,7 +400,7 @@ function bindEvents(el, dispatch, signal) {
|
|
|
348
400
|
const storeTarget = e.target?.closest?.('[data-store-event]')
|
|
349
401
|
if (storeTarget) {
|
|
350
402
|
const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
|
|
351
|
-
if (sType === 'input')
|
|
403
|
+
if (sType === 'input') dispatchStoreMutationLazy(sName, e)
|
|
352
404
|
return
|
|
353
405
|
}
|
|
354
406
|
const target = e.target?.closest?.('[data-event]')
|
|
@@ -598,7 +598,7 @@ test('dispatchStoreMutation warns on unknown mutation', () => {
|
|
|
598
598
|
assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
|
|
599
599
|
})
|
|
600
600
|
|
|
601
|
-
|
|
601
|
+
await testAsync('dispatchStoreMutation notifies subscribed pages', async () => {
|
|
602
602
|
updateStore({ counter: 0 })
|
|
603
603
|
const el = new FakeElement()
|
|
604
604
|
const spec = {
|
|
@@ -608,6 +608,12 @@ test('dispatchStoreMutation notifies subscribed pages', () => {
|
|
|
608
608
|
view: (state, server) => `<span>${server.counter ?? 0}</span>`,
|
|
609
609
|
}
|
|
610
610
|
mount(spec, el, { counter: 0 })
|
|
611
|
+
// store.js is lazy-loaded by mount() when spec.store is non-empty — its
|
|
612
|
+
// subscription is wired up after the dynamic import() resolves. Resolving
|
|
613
|
+
// a dynamic import is scheduled as a macrotask even for an already-cached
|
|
614
|
+
// module (verified empirically — microtask-only yields are not enough),
|
|
615
|
+
// so a setTimeout(0) tick-yield is required here, not just Promise.resolve().
|
|
616
|
+
await new Promise(r => setTimeout(r, 0))
|
|
611
617
|
const before = el.innerHTML
|
|
612
618
|
dispatchStoreMutation('inc')
|
|
613
619
|
assert(el.innerHTML !== before, `Page should re-render after store mutation`)
|
|
@@ -623,7 +629,7 @@ test('registerStoreMutations is a no-op after first call', () => {
|
|
|
623
629
|
|
|
624
630
|
console.log('\nLive store push (client)\n')
|
|
625
631
|
|
|
626
|
-
|
|
632
|
+
await testAsync('initLiveStore connects once, merges SSE store events, survives bad frames', async () => {
|
|
627
633
|
const instances = []
|
|
628
634
|
globalThis.EventSource = class {
|
|
629
635
|
constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
|
|
@@ -638,6 +644,9 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
|
|
|
638
644
|
// A store event merges into the client store and notifies subscribers
|
|
639
645
|
const el = new FakeElement()
|
|
640
646
|
mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
|
|
647
|
+
// store.js is lazy-loaded when spec.store is non-empty — wait for the dynamic
|
|
648
|
+
// import to resolve before the subscription (wired inside it) is live.
|
|
649
|
+
await new Promise(r => setTimeout(r, 0))
|
|
641
650
|
instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
|
|
642
651
|
assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
|
|
643
652
|
assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
|
|
@@ -650,6 +659,41 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
|
|
|
650
659
|
delete globalThis.EventSource
|
|
651
660
|
})
|
|
652
661
|
|
|
662
|
+
console.log('\nLazy store loading (through mount())\n')
|
|
663
|
+
|
|
664
|
+
await testAsync('mount() with spec.store lazy-loads store.js and wires reactivity end to end', async () => {
|
|
665
|
+
updateStore({ counter: 100 })
|
|
666
|
+
const el = new FakeElement()
|
|
667
|
+
const spec = {
|
|
668
|
+
route: '/lazy-store',
|
|
669
|
+
store: ['counter'],
|
|
670
|
+
state: {},
|
|
671
|
+
view: (state, server) => `<span>${server.counter ?? 0}</span>`,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const instance = mount(spec, el, { counter: 100 })
|
|
675
|
+
|
|
676
|
+
// mount() returns synchronously, before store.js's dynamic import has resolved —
|
|
677
|
+
// the view renders with the page-level serverState snapshot passed to mount()
|
|
678
|
+
// (not yet the live store slice), and store-driven reactivity is not wired yet.
|
|
679
|
+
assert(el.innerHTML.includes('100'), `Expected initial render to use the snapshot: ${el.innerHTML}`)
|
|
680
|
+
|
|
681
|
+
// Yield a macrotask so the lazy import('./store.js') resolves. This is the
|
|
682
|
+
// "one microtask/tick later" cost documented for the lazy-loading change —
|
|
683
|
+
// after this point store subscription, registerStoreMutations, and (when
|
|
684
|
+
// window.__PULSE_LIVE__ is set) initLiveStore have all been wired up.
|
|
685
|
+
await new Promise(r => setTimeout(r, 0))
|
|
686
|
+
|
|
687
|
+
// Store-driven re-render now works correctly end to end through mount():
|
|
688
|
+
// a mutation dispatched on the singleton reaches this page's subscription.
|
|
689
|
+
const before = el.innerHTML
|
|
690
|
+
dispatchStoreMutation('inc')
|
|
691
|
+
assert(el.innerHTML !== before, 'Page should re-render once store.js has loaded and subscribed')
|
|
692
|
+
assert(el.innerHTML.includes('101'), `Expected counter 101 in HTML, got: ${el.innerHTML}`)
|
|
693
|
+
|
|
694
|
+
instance.destroy()
|
|
695
|
+
})
|
|
696
|
+
|
|
653
697
|
// ---------------------------------------------------------------------------
|
|
654
698
|
|
|
655
699
|
console.log('\nDebounce / throttle\n')
|
package/src/runtime/ssr.js
CHANGED
|
@@ -313,6 +313,32 @@ export function buildHydrateScript(spec, storeDef, nonce) {
|
|
|
313
313
|
* @param {Object} [options.timing] - Server-Timing values
|
|
314
314
|
* @returns {string}
|
|
315
315
|
*/
|
|
316
|
+
// Third-party stylesheets (e.g. Google Fonts) pay a DNS+TLS round trip before
|
|
317
|
+
// the request can even start — preconnect warms that connection up in
|
|
318
|
+
// parallel with the document request instead of after the browser parses the
|
|
319
|
+
// stylesheet link. Relative URLs (same-origin) don't need this — the
|
|
320
|
+
// connection to the page's own origin is already open. Shared by wrapDocument
|
|
321
|
+
// (non-streaming render) and the streaming response head in server/index.js,
|
|
322
|
+
// which builds its <head> independently rather than calling wrapDocument.
|
|
323
|
+
export function buildStylePreconnects(styles = []) {
|
|
324
|
+
const styleOrigins = [...new Set(
|
|
325
|
+
styles
|
|
326
|
+
.map(href => { try { return new URL(href).origin } catch { return null } })
|
|
327
|
+
.filter(Boolean)
|
|
328
|
+
)]
|
|
329
|
+
return styleOrigins
|
|
330
|
+
.flatMap(origin => {
|
|
331
|
+
const links = [`<link rel="preconnect" href="${esc(origin)}">`]
|
|
332
|
+
// Google Fonts serves the CSS from fonts.googleapis.com but the actual
|
|
333
|
+
// font files from fonts.gstatic.com — preconnect to both up front.
|
|
334
|
+
if (origin === 'https://fonts.googleapis.com') {
|
|
335
|
+
links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>')
|
|
336
|
+
}
|
|
337
|
+
return links
|
|
338
|
+
})
|
|
339
|
+
.join('\n ')
|
|
340
|
+
}
|
|
341
|
+
|
|
316
342
|
export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '', faviconHref = '', livePath = '' }) {
|
|
317
343
|
const meta = spec.meta || {}
|
|
318
344
|
const title = meta.title || 'Pulse'
|
|
@@ -328,6 +354,8 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
328
354
|
meta.ogImage ? `<meta property="og:image" content="${esc(meta.ogImage)}">` : '',
|
|
329
355
|
].filter(Boolean).join('\n ')
|
|
330
356
|
|
|
357
|
+
const stylePreconnects = buildStylePreconnects([...(meta.styles || []), ...(meta.deferredStyles || [])])
|
|
358
|
+
|
|
331
359
|
const stylePreloads = (meta.styles || [])
|
|
332
360
|
.map(href => `<link rel="preload" as="style" href="${esc(href)}">`)
|
|
333
361
|
.join('\n ')
|
|
@@ -381,6 +409,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
381
409
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
382
410
|
<link rel="icon" href="${faviconHref || 'data:,'}">
|
|
383
411
|
<title>${esc(title)}</title>
|
|
412
|
+
${stylePreconnects}
|
|
384
413
|
${stylePreloads}
|
|
385
414
|
${runtimePreload}
|
|
386
415
|
${extraHead}
|
package/src/server/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
|
|
|
21
21
|
|
|
22
22
|
const _require = createRequire(import.meta.url)
|
|
23
23
|
export const version = _require('../../package.json').version
|
|
24
|
-
import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript } from '../runtime/ssr.js'
|
|
24
|
+
import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects } from '../runtime/ssr.js'
|
|
25
25
|
import { validateSpec } from '../spec/schema.js'
|
|
26
26
|
import { validateStore, resolveStoreState } from '../store/index.js'
|
|
27
27
|
|
|
@@ -1322,6 +1322,8 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
1322
1322
|
try { res.writeEarlyHints({ link: earlyLinks }) } catch {}
|
|
1323
1323
|
}
|
|
1324
1324
|
|
|
1325
|
+
const stylePreconnects = buildStylePreconnects([...(meta.styles || []), ...(meta.deferredStyles || [])])
|
|
1326
|
+
|
|
1325
1327
|
const stylePreloads = (meta.styles || [])
|
|
1326
1328
|
.map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
|
|
1327
1329
|
.join('\n')
|
|
@@ -1358,7 +1360,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
1358
1360
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1359
1361
|
<link rel="icon" href="${faviconPath || 'data:,'}">
|
|
1360
1362
|
<title>${escHtml(title)}</title>
|
|
1361
|
-
${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
|
|
1363
|
+
${stylePreconnects ? stylePreconnects + '\n' : ''}${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
|
|
1362
1364
|
</head>
|
|
1363
1365
|
<body${bodyAttr}>
|
|
1364
1366
|
<div id="pulse-root">`
|