@invisibleloop/pulse 0.7.1 → 0.7.3

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.
@@ -1 +1 @@
1
- 0.7.0
1
+ 0.7.2
@@ -3,60 +3,63 @@
3
3
  * Loaded as an external script so it passes the CSP (no unsafe-inline needed).
4
4
  */
5
5
  (function () {
6
- // ── Sidebar ──────────────────────────────────────────────────────────────
7
- var btn = document.querySelector('.mobile-menu-btn')
8
- var sidebar = document.querySelector('.docs-sidebar')
9
- var overlay = document.querySelector('.sidebar-overlay')
6
+ // ── Scroll active nav item into view ─────────────────────────────────────
10
7
 
11
- if (btn && sidebar) {
12
- function open() {
13
- sidebar.classList.add('open')
14
- overlay && overlay.classList.add('visible')
15
- document.body.style.overflow = 'hidden'
16
- }
8
+ function centerNavLink(sidebar, link) {
9
+ if (!sidebar || !link) return
10
+ var navRect = sidebar.getBoundingClientRect()
11
+ var linkRect = link.getBoundingClientRect()
12
+ sidebar.scrollTop = sidebar.scrollTop + (linkRect.top - navRect.top) - sidebar.clientHeight / 2 + link.clientHeight / 2
13
+ }
17
14
 
18
- function close() {
19
- sidebar.classList.remove('open')
20
- overlay && overlay.classList.remove('visible')
21
- document.body.style.overflow = ''
22
- }
15
+ // ── Bind sidebar, mobile menu, and centre the active link ─────────────────
16
+ // Called on first load and after every client-side navigation, because
17
+ // #pulse-root is replaced on navigate — all element references go stale.
23
18
 
24
- btn.addEventListener('click', function () {
25
- sidebar.classList.contains('open') ? close() : open()
26
- })
19
+ function init() {
20
+ var sidebar = document.querySelector('.docs-sidebar')
21
+ var btn = document.querySelector('.mobile-menu-btn')
22
+ var overlay = document.querySelector('.sidebar-overlay')
27
23
 
28
- overlay && overlay.addEventListener('click', close)
24
+ // Mobile open/close
25
+ if (btn && sidebar) {
26
+ function open() {
27
+ sidebar.classList.add('open')
28
+ overlay && overlay.classList.add('visible')
29
+ document.body.style.overflow = 'hidden'
30
+ }
29
31
 
30
- sidebar.querySelectorAll('.nav-link').forEach(function (link) {
31
- link.addEventListener('click', close)
32
- })
33
- }
32
+ function close() {
33
+ sidebar.classList.remove('open')
34
+ overlay && overlay.classList.remove('visible')
35
+ document.body.style.overflow = ''
36
+ }
34
37
 
35
- // ── Scroll active nav item into view ─────────────────────────────────────
36
- var navSidebar = document.querySelector('.docs-sidebar')
38
+ btn.addEventListener('click', function () {
39
+ sidebar.classList.contains('open') ? close() : open()
40
+ })
37
41
 
38
- function centerNavLink(link) {
39
- if (!navSidebar || !link) return
40
- var navRect = navSidebar.getBoundingClientRect()
41
- var linkRect = link.getBoundingClientRect()
42
- navSidebar.scrollTop = navSidebar.scrollTop + (linkRect.top - navRect.top) - navSidebar.clientHeight / 2 + link.clientHeight / 2
43
- }
42
+ overlay && overlay.addEventListener('click', close)
44
43
 
45
- // On initial load — scroll to the currently active link
46
- var activeLink = navSidebar && navSidebar.querySelector('.nav-link.active')
47
- if (activeLink) {
48
- if (document.readyState === 'complete') {
49
- setTimeout(function () { centerNavLink(activeLink) }, 50)
50
- } else {
51
- window.addEventListener('load', function () { centerNavLink(activeLink) })
44
+ sidebar.querySelectorAll('.nav-link').forEach(function (link) {
45
+ link.addEventListener('click', close)
46
+ })
52
47
  }
48
+
49
+ // Centre the active link
50
+ var activeLink = sidebar && sidebar.querySelector('.nav-link.active')
51
+ if (activeLink) centerNavLink(sidebar, activeLink)
53
52
  }
54
53
 
55
- // After client-side navigation — sidebar DOM is replaced, so re-query and re-centre
56
- document.addEventListener('pulse:navigate', function () {
57
- var newActive = navSidebar && navSidebar.querySelector('.nav-link.active')
58
- centerNavLink(newActive)
59
- })
54
+ // On initial load — wait for layout paint before scrolling
55
+ if (document.readyState === 'complete') {
56
+ setTimeout(init, 50)
57
+ } else {
58
+ window.addEventListener('load', init)
59
+ }
60
+
61
+ // After client-side navigation — re-bind everything against the fresh DOM
62
+ document.addEventListener('pulse:navigate', init)
60
63
 
61
64
  // ── Slider fill ──────────────────────────────────────────────────────────
62
65
  function updateSliderFill(input) {
@@ -14,7 +14,7 @@ let _manifest = {}
14
14
  export function initLayoutManifest(m) { _manifest = m }
15
15
  const asset = href => _manifest[href] || href
16
16
 
17
- const { version } = pkg
17
+ const version = typeof pkg !== 'undefined' ? (pkg?.version ?? '') : ''
18
18
 
19
19
  function esc(s) {
20
20
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "scripts": {
58
58
  "dev": "node --watch src/cli/dev.js",
59
59
  "build": "node scripts/build.js",
60
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
60
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
61
61
  "typecheck": "tsc --noEmit",
62
62
  "docs": "node --watch src/cli/dev.js --root docs --port 4000",
63
63
  "docs:start": "node docs/server.js"
@@ -1 +1 @@
1
- 0.7.1
1
+ 0.7.3
package/scripts/build.js CHANGED
@@ -476,7 +476,9 @@ const result = await esbuild.build({
476
476
  const manifest = {}
477
477
 
478
478
  for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
479
- const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.join(ROOT, outFile))
479
+ // outFile is relative to process.cwd(), not ROOT — use path.resolve to get the
480
+ // true absolute path before computing the URL-relative bundle path.
481
+ const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.resolve(outFile))
480
482
 
481
483
  // Shared runtime chunk — esbuild generates this via splitting, no entryPoint
482
484
  if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
package/src/cli/dev.js CHANGED
@@ -187,11 +187,14 @@ const { updateSpecs } = createServer(specs, {
187
187
 
188
188
  const serveFile = (filePath) => {
189
189
  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
190
- // Strip node:* imports — they don't resolve in the browser. Server-only imports
191
- // are only used inside spec.server functions which the client runtime never calls.
190
+ // Strip server-only imports that can't resolve in the browser:
191
+ // - node:* built-ins (fs, path, etc.)
192
+ // - JSON files imported with `with { type: 'json' }` (e.g. package.json for version)
193
+ // These are only used inside spec.server functions or module-level server init;
194
+ // the client runtime never calls those code paths.
192
195
  let content = fs.readFileSync(filePath, 'utf-8')
193
196
  content = content.replace(
194
- /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"]node:[^'"]+['"]\s*;?[ \t]*\n?/gm,
197
+ /^[ \t]*import\s+(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+['"](?:node:[^'"]+|[^'"]*\.json)['"]\s*(?:with\s*\{[^}]*\})?\s*;?[ \t]*\n?/gm,
195
198
  ''
196
199
  )
197
200
  res.writeHead(200, {
@@ -104,10 +104,13 @@ export function initNavigation(root, mountFn) {
104
104
  document.dispatchEvent(new CustomEvent('pulse:navigate'))
105
105
 
106
106
  if (hydratePath && mountFn) {
107
+ const { default: spec } = await import(/* @vite-ignore */ hydratePath)
108
+ // A newer navigation may have started while the import was in flight —
109
+ // bail out rather than destroying the already-mounted next page.
110
+ if (controller.signal.aborted) return
107
111
  currentMount?.destroy()
108
112
  root.dataset.pulseMounted = '1'
109
113
  window.__PULSE_SERVER__ = finalServerState
110
- const { default: spec } = await import(/* @vite-ignore */ hydratePath)
111
114
  if (spec) currentMount = mountFn(spec, root, finalServerState)
112
115
  }
113
116
 
@@ -130,10 +133,11 @@ export function initNavigation(root, mountFn) {
130
133
  if (push) history.pushState({ pulse: true, path }, '', path)
131
134
 
132
135
  if (hydrate && mountFn) {
136
+ const { default: spec } = await import(/* @vite-ignore */ hydrate)
137
+ if (controller.signal.aborted) return
133
138
  currentMount?.destroy()
134
139
  root.dataset.pulseMounted = '1'
135
140
  window.__PULSE_SERVER__ = serverState || {}
136
- const { default: spec } = await import(/* @vite-ignore */ hydrate)
137
141
  if (spec) currentMount = mountFn(spec, root, serverState || {})
138
142
  }
139
143
 
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Pulse — Navigate tests
3
+ * run: node src/runtime/navigate.test.js
4
+ */
5
+
6
+ import { initNavigation } from './navigate.js'
7
+ import assert from 'node:assert'
8
+
9
+ // ── Test runner ───────────────────────────────────────────────────────────────
10
+
11
+ let passed = 0
12
+ let failed = 0
13
+
14
+ async function test(label, fn) {
15
+ try {
16
+ await fn()
17
+ console.log(` ✓ ${label}`)
18
+ passed++
19
+ } catch (e) {
20
+ console.log(` ✗ ${label}`)
21
+ console.log(` ${e.message}`)
22
+ failed++
23
+ }
24
+ }
25
+
26
+ // ── Minimal browser shims ─────────────────────────────────────────────────────
27
+
28
+ function makeRoot() {
29
+ const root = {
30
+ innerHTML: '',
31
+ dataset: {},
32
+ querySelector: () => null,
33
+ querySelectorAll: () => [],
34
+ hasAttribute: (k) => root._attrs.has(k),
35
+ setAttribute: (k) => root._attrs.add(k),
36
+ removeAttribute: (k) => root._attrs.delete(k),
37
+ addEventListener: () => {},
38
+ focus: () => {},
39
+ _attrs: new Set(),
40
+ }
41
+ return root
42
+ }
43
+
44
+ /**
45
+ * Set up fresh browser globals and call initNavigation().
46
+ * Returns helpers to fire the registered click and popstate handlers.
47
+ */
48
+ function setup({ mountFn = null } = {}) {
49
+ let clickHandler = null
50
+ let popHandler = null
51
+
52
+ global.history = { replaceState: () => {}, pushState: () => {} }
53
+ global.location = { pathname: '/start', search: '', origin: 'http://localhost', href: '' }
54
+ global.window = {
55
+ scrollTo: () => {},
56
+ addEventListener: (e, fn) => { if (e === 'popstate') popHandler = fn },
57
+ }
58
+ global.document = {
59
+ title: 'Test',
60
+ addEventListener: (e, fn) => { if (e === 'click') clickHandler = fn },
61
+ dispatchEvent: () => {},
62
+ querySelectorAll: () => [],
63
+ createElement: () => ({
64
+ rel: '', href: '', src: '', textContent: '', type: '',
65
+ setAttribute: () => {},
66
+ getAttribute: () => null,
67
+ onload: null, onerror: null,
68
+ }),
69
+ head: { appendChild: () => {} },
70
+ }
71
+ global.CustomEvent = class { constructor(n) { this.type = n } }
72
+
73
+ const root = makeRoot()
74
+ initNavigation(root, mountFn)
75
+
76
+ return {
77
+ root,
78
+ click: (ev) => clickHandler(ev),
79
+ pop: (ev) => popHandler(ev),
80
+ }
81
+ }
82
+
83
+ /** Build a synthetic click event targeting an anchor. */
84
+ function clickEvent({ href, ctrlKey = false, metaKey = false, shiftKey = false, altKey = false, linkTarget = '' }) {
85
+ const anchor = {
86
+ getAttribute: (k) => k === 'href' ? (href ?? null) : null,
87
+ target: linkTarget,
88
+ }
89
+ return {
90
+ target: { closest: (sel) => sel === 'a' ? anchor : null },
91
+ ctrlKey, metaKey, shiftKey, altKey,
92
+ preventDefault: () => {},
93
+ }
94
+ }
95
+
96
+ /** Event where target has no ancestor <a>. */
97
+ function nonAnchorEvent() {
98
+ return {
99
+ target: { closest: () => null },
100
+ ctrlKey: false, metaKey: false, shiftKey: false, altKey: false,
101
+ preventDefault: () => {},
102
+ }
103
+ }
104
+
105
+ // ── Click handler — link filtering ───────────────────────────────────────────
106
+
107
+ console.log('\nClick handler — link filtering\n')
108
+
109
+ await test('does not intercept clicks with no ancestor anchor', async () => {
110
+ const { click } = setup()
111
+ let called = false
112
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
113
+ click(nonAnchorEvent())
114
+ assert(!called)
115
+ })
116
+
117
+ await test('does not intercept anchor with no href', async () => {
118
+ const { click } = setup()
119
+ let called = false
120
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
121
+ click(clickEvent({ href: null }))
122
+ assert(!called)
123
+ })
124
+
125
+ await test('does not intercept cross-origin links', async () => {
126
+ const { click } = setup()
127
+ let called = false
128
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
129
+ click(clickEvent({ href: 'https://example.com/page' }))
130
+ assert(!called)
131
+ })
132
+
133
+ await test('does not intercept ctrl+click', async () => {
134
+ const { click } = setup()
135
+ let called = false
136
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
137
+ click(clickEvent({ href: '/page', ctrlKey: true }))
138
+ assert(!called)
139
+ })
140
+
141
+ await test('does not intercept meta+click', async () => {
142
+ const { click } = setup()
143
+ let called = false
144
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
145
+ click(clickEvent({ href: '/page', metaKey: true }))
146
+ assert(!called)
147
+ })
148
+
149
+ await test('does not intercept shift+click', async () => {
150
+ const { click } = setup()
151
+ let called = false
152
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
153
+ click(clickEvent({ href: '/page', shiftKey: true }))
154
+ assert(!called)
155
+ })
156
+
157
+ await test('does not intercept target=_blank links', async () => {
158
+ const { click } = setup()
159
+ let called = false
160
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
161
+ click(clickEvent({ href: '/page', linkTarget: '_blank' }))
162
+ assert(!called)
163
+ })
164
+
165
+ await test('intercepts same-origin links', async () => {
166
+ const { click } = setup()
167
+ let called = false
168
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
169
+ click(clickEvent({ href: '/same-origin' }))
170
+ assert(called, 'fetch should be called for same-origin link')
171
+ })
172
+
173
+ // ── Navigation — fetch abort and cancellation ────────────────────────────────
174
+
175
+ console.log('\nNavigation — fetch abort and cancellation\n')
176
+
177
+ await test('aborts in-flight fetch when a new navigation starts', async () => {
178
+ const { click } = setup()
179
+ const signals = []
180
+ global.fetch = async (_url, opts) => {
181
+ signals.push(opts.signal)
182
+ return new Promise(() => {}) // never resolves
183
+ }
184
+
185
+ click(clickEvent({ href: '/page-a' }))
186
+ click(clickEvent({ href: '/page-b' }))
187
+
188
+ assert.equal(signals.length, 2, 'two fetches made')
189
+ assert(signals[0].aborted, 'first navigation signal should be aborted')
190
+ assert(!signals[1].aborted, 'second navigation signal should not be aborted')
191
+ })
192
+
193
+ await test('swallows AbortError — does not fall back to location.href', async () => {
194
+ const { click } = setup()
195
+ global.fetch = async () => {
196
+ const err = new Error('aborted')
197
+ err.name = 'AbortError'
198
+ throw err
199
+ }
200
+ let fallbackSet = false
201
+ Object.defineProperty(global.location, 'href', {
202
+ set: () => { fallbackSet = true },
203
+ get: () => '',
204
+ configurable: true,
205
+ })
206
+ click(clickEvent({ href: '/page' }))
207
+ await new Promise(r => setTimeout(r, 10))
208
+ assert(!fallbackSet, 'AbortError must not trigger location.href fallback')
209
+ })
210
+
211
+ await test('falls back to location.href on network error', async () => {
212
+ const { click } = setup()
213
+ global.fetch = async () => { throw new Error('network failure') }
214
+ let fallback = null
215
+ Object.defineProperty(global.location, 'href', {
216
+ set: (v) => { fallback = v },
217
+ get: () => '',
218
+ configurable: true,
219
+ })
220
+ click(clickEvent({ href: '/broken' }))
221
+ await new Promise(r => setTimeout(r, 10))
222
+ assert.equal(fallback, '/broken')
223
+ })
224
+
225
+ await test('falls back to location.href on non-ok response', async () => {
226
+ const { click } = setup()
227
+ global.fetch = async () => ({ ok: false })
228
+ let fallback = null
229
+ Object.defineProperty(global.location, 'href', {
230
+ set: (v) => { fallback = v },
231
+ get: () => '',
232
+ configurable: true,
233
+ })
234
+ click(clickEvent({ href: '/missing' }))
235
+ await new Promise(r => setTimeout(r, 10))
236
+ assert.equal(fallback, '/missing')
237
+ })
238
+
239
+ // ── Navigation — JSON response ───────────────────────────────────────────────
240
+
241
+ console.log('\nNavigation — JSON response\n')
242
+
243
+ function jsonFetch(payload) {
244
+ return async () => ({
245
+ ok: true,
246
+ headers: { get: () => 'application/json' },
247
+ json: async () => payload,
248
+ })
249
+ }
250
+
251
+ await test('sets root.innerHTML from JSON html field', async () => {
252
+ const { click, root } = setup()
253
+ global.fetch = jsonFetch({ html: '<p>Hello</p>', title: 'T', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
254
+ click(clickEvent({ href: '/new' }))
255
+ await new Promise(r => setTimeout(r, 10))
256
+ assert.equal(root.innerHTML, '<p>Hello</p>')
257
+ })
258
+
259
+ await test('updates document.title from JSON title field', async () => {
260
+ const { click } = setup()
261
+ global.fetch = jsonFetch({ html: '', title: 'New Title', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
262
+ click(clickEvent({ href: '/titled' }))
263
+ await new Promise(r => setTimeout(r, 10))
264
+ assert.equal(global.document.title, 'New Title')
265
+ })
266
+
267
+ await test('updates store state when storeState is present', async () => {
268
+ const { click } = setup()
269
+ global.fetch = jsonFetch({ html: '', title: '', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: { theme: 'dark' } })
270
+ let stored = null
271
+ global.window.__updatePulseStore__ = (s) => { stored = s }
272
+ click(clickEvent({ href: '/store-page' }))
273
+ await new Promise(r => setTimeout(r, 10))
274
+ assert.deepEqual(stored, { theme: 'dark' })
275
+ })
276
+
277
+ // ── Navigation — popstate ────────────────────────────────────────────────────
278
+
279
+ console.log('\nNavigation — popstate\n')
280
+
281
+ await test('navigates on pulse popstate', async () => {
282
+ const { pop } = setup()
283
+ let fetchedPath = null
284
+ global.fetch = async (path) => { fetchedPath = path; return new Promise(() => {}) }
285
+ global.location.pathname = '/history-page'
286
+ global.location.search = ''
287
+ pop({ state: { pulse: true } })
288
+ await new Promise(r => setTimeout(r, 0))
289
+ assert.equal(fetchedPath, '/history-page')
290
+ })
291
+
292
+ await test('ignores popstate without pulse state', async () => {
293
+ const { pop } = setup()
294
+ let called = false
295
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
296
+ pop({ state: null })
297
+ pop({ state: { pulse: false } })
298
+ assert(!called, 'fetch should not be called for non-pulse popstate')
299
+ })
300
+
301
+ // ── Summary ───────────────────────────────────────────────────────────────────
302
+
303
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
304
+ if (failed > 0) process.exit(1)
@@ -258,9 +258,48 @@ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
258
258
  }
259
259
 
260
260
  // ---------------------------------------------------------------------------
261
- // Page HTML wrapper
261
+ // Page HTML wrapper — shared script builders
262
262
  // ---------------------------------------------------------------------------
263
263
 
264
+ /**
265
+ * Build the inline script that exposes window.__PULSE_NONCE__ and (optionally)
266
+ * window.__PULSE_STORE__ to the client runtime.
267
+ *
268
+ * Only emitted for hydrated pages. Purely server-rendered pages have no JS to
269
+ * read these globals, and omitting the inline script keeps cached HTML nonce-free
270
+ * so it can be served with a nonce-free CSP without a CSP violation.
271
+ */
272
+ export function buildStoreScript(spec, storeState, nonce) {
273
+ if (!spec.hydrate) return ''
274
+ if (storeState && Object.keys(storeState).length > 0) {
275
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
276
+ }
277
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
278
+ }
279
+
280
+ /**
281
+ * Build the hydration <script> tag that mounts the spec client-side.
282
+ *
283
+ * Bundle paths (/dist/) → a single external <script type="module" src="..."> tag.
284
+ * Dev source paths → an inline module block with explicit imports (nonce required).
285
+ */
286
+ export function buildHydrateScript(spec, storeDef, nonce) {
287
+ if (!spec.hydrate) return ''
288
+ if (isBundle(spec.hydrate)) {
289
+ return `<script type="module" src="${esc(spec.hydrate)}"></script>`
290
+ }
291
+ const storeImport = storeDef?.hydrate ? `\n import store from '${esc(storeDef.hydrate)}'` : ''
292
+ const storeArg = storeDef?.hydrate ? ', { ssr: true, store }' : ', { ssr: true }'
293
+ return `<script type="module" nonce="${nonce}">
294
+ import spec from '${esc(spec.hydrate)}'
295
+ import { mount } from '/@pulse/runtime/index.js'
296
+ import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
297
+ const root = document.getElementById('pulse-root')
298
+ mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
299
+ initNavigation(root, mount)
300
+ </script>`
301
+ }
302
+
264
303
  /**
265
304
  * Wrap rendered content in a full HTML document.
266
305
  *
@@ -320,40 +359,8 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
320
359
  ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
321
360
  : ''
322
361
 
323
- // Serialise store state so the client store singleton can be initialised.
324
- // Also exposes window.__updatePulseStore__ so navigate.js can refresh the
325
- // singleton with fresh server data on client-side navigations.
326
- // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag
327
- // to satisfy the style-src CSP directive.
328
- const storeStateScript = storeState && Object.keys(storeState).length > 0
329
- ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
330
- : spec.hydrate
331
- ? `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
332
- : ''
333
-
334
- // Hydration bootstrap — makes the server-rendered HTML interactive.
335
- // When hydrate points to a self-executing bundle (/dist/…) a single <script>
336
- // tag is enough; the bundle imports spec + runtime and calls mount() itself.
337
- // In dev mode (source file path) we emit the explicit inline import block.
338
- const storeImport = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
339
- ? `\n import store from '${esc(storeDef.hydrate)}'`
340
- : ''
341
- const storeArg = spec.hydrate && !isBundle(spec.hydrate) && storeDef?.hydrate
342
- ? ', { ssr: true, store }'
343
- : ', { ssr: true }'
344
-
345
- const hydrateScript = spec.hydrate
346
- ? isBundle(spec.hydrate)
347
- ? `<script type="module" src="${esc(spec.hydrate)}"></script>`
348
- : `<script type="module" nonce="${nonce}">
349
- import spec from '${esc(spec.hydrate)}'
350
- import { mount } from '/@pulse/runtime/index.js'
351
- import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
352
- const root = document.getElementById('pulse-root')
353
- mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
354
- initNavigation(root, mount)
355
- </script>`
356
- : ''
362
+ const storeStateScript = buildStoreScript(spec, storeState, nonce)
363
+ const hydrateScript = buildHydrateScript(spec, storeDef, nonce)
357
364
 
358
365
  // Server-Timing header value (caller is responsible for setting the header)
359
366
  const serverTimingValue = timing.total !== undefined
@@ -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 } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -92,8 +92,8 @@ function buildCsp(nonce, ext = {}) {
92
92
 
93
93
  /**
94
94
  * Build the nonce-free CSP for cached responses.
95
- * Safe because cached pages (no spec.server, no spec.store) are only cached
96
- * when they have no spec.hydrate — so no inline nonce scripts are present.
95
+ * Safe because isCacheablePage() only returns true when spec.server, spec.store,
96
+ * and spec.hydrate are all absent — guaranteeing no inline nonce scripts in the HTML.
97
97
  * @param {Record<string,string[]>} [ext] Extra sources to merge in per directive.
98
98
  */
99
99
  function buildCachedCsp(ext = {}) {
@@ -305,6 +305,26 @@ function pageCacheTtl(spec, dev, defaultCache) {
305
305
  return cfg?.maxAge || 0
306
306
  }
307
307
 
308
+ /** Build the in-process page cache key scoped to route + request parameters. */
309
+ function buildCacheKey(spec, ctx) {
310
+ return spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
311
+ }
312
+
313
+ /**
314
+ * True when a page's rendered HTML is safe to cache in-process.
315
+ *
316
+ * A page is NOT cacheable if it has any of:
317
+ * - spec.server — embeds a nonce'd __PULSE_SERVER__ inline script
318
+ * - spec.store — same
319
+ * - spec.hydrate — embeds a nonce'd __PULSE_NONCE__ inline script (for toasts)
320
+ *
321
+ * In all three cases the cached HTML would contain a nonce that doesn't match
322
+ * the nonce-free CSP used for cached responses, causing a CSP violation.
323
+ */
324
+ function isCacheablePage(spec) {
325
+ return !spec.server && !spec.store?.length && !spec.hydrate
326
+ }
327
+
308
328
  // ---------------------------------------------------------------------------
309
329
  // Dev import map — lets browser resolve @invisibleloop/pulse/* bare specifiers
310
330
  // ---------------------------------------------------------------------------
@@ -350,8 +370,7 @@ async function cachedRenderToString(spec, ctx, dev) {
350
370
  return renderToString(spec, ctx)
351
371
  }
352
372
 
353
- // Build a cache key scoped to this route + request parameters
354
- const key = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
373
+ const key = buildCacheKey(spec, ctx)
355
374
 
356
375
  const cached = serverDataCache.get(key)
357
376
  if (cached) return cached
@@ -841,9 +860,8 @@ async function handleNavStreamResponse(spec, ctx, req, res) {
841
860
  * Checks the in-process page cache before rendering; stores result after.
842
861
  */
843
862
  async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
844
- const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
845
- // Pages with server data or store data embed a nonce'd __PULSE_SERVER__ script — don't cache them
846
- const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
863
+ const cacheKey = buildCacheKey(spec, ctx)
864
+ const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
847
865
 
848
866
  let html, serverTimingValue, fromCache = false
849
867
 
@@ -892,9 +910,8 @@ async function handleStringResponse(spec, ctx, req, res, extraBody = '', dev = f
892
910
  */
893
911
  async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = false, canonicalBase = '', nonce = '', runtimeBundle = '', defaultCache = null, store = null, csp = {}, faviconPath = null) {
894
912
  // Serve from in-process page cache when available — skip streaming overhead.
895
- // Pages with spec.server or spec.store embed a nonce'd __PULSE_SERVER__ script so are never cached.
896
- const cacheKey = spec.route + '\0' + JSON.stringify(ctx.params) + '\0' + JSON.stringify(ctx.query)
897
- const ttl = (!spec.server && !spec.store?.length) ? pageCacheTtl(spec, dev, defaultCache) : 0
913
+ const cacheKey = buildCacheKey(spec, ctx)
914
+ const ttl = isCacheablePage(spec) ? pageCacheTtl(spec, dev, defaultCache) : 0
898
915
  const cached = ttl > 0 ? pageHtmlCache.get(cacheKey) : null
899
916
 
900
917
  if (cached) {
@@ -973,45 +990,19 @@ ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload +
973
990
  <body${bodyAttr}>
974
991
  <div id="pulse-root">`
975
992
 
976
- const storeImport = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
977
- ? `\n import store from '${escHtml(store.hydrate)}'`
978
- : ''
979
- const storeArg = !spec.hydrate?.startsWith('/dist/') && store?.hydrate
980
- ? ', { ssr: true, store }'
981
- : ', { ssr: true }'
982
-
983
- const hydrateScript = spec.hydrate
984
- ? spec.hydrate.startsWith('/dist/')
985
- ? `\n <script type="module" src="${escHtml(spec.hydrate)}"></script>`
986
- : `\n <script type="module" nonce="${nonce}">
987
- import spec from '${escHtml(spec.hydrate)}'
988
- import { mount } from '/@pulse/runtime/index.js'
989
- import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
990
- const root = document.getElementById('pulse-root')
991
- mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
992
- initNavigation(root, mount)
993
- </script>`
994
- : ''
995
-
996
- const scriptTags = (meta.scripts || [])
993
+ const scriptTags = (meta.scripts || [])
997
994
  .map(src => ` <script src="${escHtml(src)}" defer></script>`)
998
995
  .join('\n')
996
+ const storeScript = buildStoreScript(spec, ctx.store || null, nonce)
997
+ const hydrateScript = buildHydrateScript(spec, store, nonce)
999
998
 
1000
999
  const resolvedExtraBody = typeof extraBody === 'function' ? extraBody(nonce) : extraBody
1001
1000
 
1002
- // Emit window.__PULSE_STORE__ so the client store singleton can be initialised.
1003
- // Also exposes __updatePulseStore__ for navigate.js to refresh store on navigation.
1004
- // window.__PULSE_NONCE__ lets the toast runtime inject a nonce'd <style> tag to
1005
- // satisfy the style-src CSP directive (which disallows uninonce'd inline styles).
1006
- const storeScript = ctx.store && Object.keys(ctx.store).length > 0
1007
- ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';window.__PULSE_STORE__=${JSON.stringify(ctx.store)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
1008
- : spec.hydrate
1009
- ? `\n <script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';</script>`
1010
- : ''
1011
-
1012
1001
  const docClose = `
1013
1002
  </div>
1014
- ${scriptTags}${storeScript}${hydrateScript}
1003
+ ${scriptTags}
1004
+ ${storeScript}
1005
+ ${hydrateScript}
1015
1006
  ${resolvedExtraBody}
1016
1007
  </body>
1017
1008
  </html>`