@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.
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/menu.js +46 -43
- package/docs/src/lib/layout.js +1 -1
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +3 -1
- package/src/cli/dev.js +6 -3
- package/src/runtime/navigate.js +6 -2
- package/src/runtime/navigate.test.js +304 -0
- package/src/runtime/ssr.js +42 -35
- package/src/server/index.js +34 -43
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
1
|
+
0.7.2
|
package/docs/public/menu.js
CHANGED
|
@@ -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
|
-
// ──
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
38
|
+
btn.addEventListener('click', function () {
|
|
39
|
+
sidebar.classList.contains('open') ? close() : open()
|
|
40
|
+
})
|
|
37
41
|
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
56
|
-
document.
|
|
57
|
-
|
|
58
|
-
|
|
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) {
|
package/docs/src/lib/layout.js
CHANGED
|
@@ -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
|
|
17
|
+
const version = typeof pkg !== 'undefined' ? (pkg?.version ?? '') : ''
|
|
18
18
|
|
|
19
19
|
function esc(s) {
|
|
20
20
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.7.
|
|
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"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.7.
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
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:[^'"]
|
|
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, {
|
package/src/runtime/navigate.js
CHANGED
|
@@ -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)
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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
|
-
|
|
324
|
-
|
|
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
|
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 } 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
|
|
96
|
-
*
|
|
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
|
-
|
|
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
|
|
845
|
-
|
|
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
|
-
|
|
896
|
-
const
|
|
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
|
|
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}
|
|
1003
|
+
${scriptTags}
|
|
1004
|
+
${storeScript}
|
|
1005
|
+
${hydrateScript}
|
|
1015
1006
|
${resolvedExtraBody}
|
|
1016
1007
|
</body>
|
|
1017
1008
|
</html>`
|