@invisibleloop/pulse 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -2
- package/docs/src/pages/store.js +4 -5
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +29 -9
- package/scripts/strip-server.test.js +20 -1
- package/src/agent/guide-server.md +3 -5
- package/src/cli/cli.test.js +53 -0
- package/src/cli/dev.js +20 -2
- package/src/cli/discover.js +26 -0
- package/src/cli/start.js +5 -1
- package/src/runtime/ssr.js +5 -2
- package/src/server/index.js +19 -7
- package/src/server/server.test.js +24 -0
- package/types/server.d.ts +3 -0
package/CLAUDE.md
CHANGED
|
@@ -75,8 +75,10 @@ export const mySpec = {
|
|
|
75
75
|
// async () => [...] → enumerate a dynamic :param route's URLs
|
|
76
76
|
sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
|
|
77
77
|
|
|
78
|
-
// Global store keys this page subscribes to — appears in view's server arg
|
|
79
|
-
//
|
|
78
|
+
// Global store keys this page subscribes to — appears in view's server arg.
|
|
79
|
+
// The store is defined in pulse.store.js at the project root — AUTO-DISCOVERED
|
|
80
|
+
// by pulse dev/start (no registration step). Mutations update all subscribed
|
|
81
|
+
// pages without a server round-trip.
|
|
80
82
|
store: ['user', 'cart'],
|
|
81
83
|
|
|
82
84
|
// Initial client state — deep cloned on mount, never mutated directly
|
package/docs/src/pages/store.js
CHANGED
|
@@ -50,16 +50,15 @@ export default {
|
|
|
50
50
|
}`, 'js'))}
|
|
51
51
|
|
|
52
52
|
${section('registering', 'Registering the store')}
|
|
53
|
-
<p>
|
|
53
|
+
<p><strong>There is no registration step.</strong> <code>pulse dev</code> and <code>pulse start</code> auto-discover <code>pulse.store.js</code> from the project root — the same way pages are discovered from <code>src/pages/</code>. The store is loaded server-side, served to the browser for mutations, validated at startup (bad fetchers fail the boot), and hot-reloaded in dev when you edit it.</p>
|
|
54
|
+
<p>Only when you run your own server entry do you pass it manually:</p>
|
|
54
55
|
${codeBlock(highlight(`import { createServer } from '@invisibleloop/pulse'
|
|
55
56
|
import store from './pulse.store.js'
|
|
56
|
-
import { dashboardSpec } from './src/pages/dashboard.js'
|
|
57
|
-
import { settingsSpec } from './src/pages/settings.js'
|
|
58
57
|
|
|
59
|
-
createServer(
|
|
58
|
+
createServer(pages, {
|
|
60
59
|
port: 3000,
|
|
61
60
|
staticDir: 'public',
|
|
62
|
-
store, // ←
|
|
61
|
+
store, // ← manual server entry only
|
|
63
62
|
})`, 'js'))}
|
|
64
63
|
|
|
65
64
|
${section('using', 'Using store data in a page')}
|
package/package.json
CHANGED
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.1
|
package/scripts/build.js
CHANGED
|
@@ -34,7 +34,10 @@ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
|
|
|
34
34
|
* from being shipped to and evaluated in the browser
|
|
35
35
|
* - avoids bundling errors when server imports use Node-only modules
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
// NOTE: spec.submit is also server-only but must NOT be listed here — the key
|
|
38
|
+
// scanner is line-anchored at any nesting depth, and `submit:` is the most
|
|
39
|
+
// common ACTION name (actions: { submit: {…} }), which would get stripped too.
|
|
40
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Pulse subpath exports that are server-only (use Node built-ins).
|
|
@@ -351,17 +354,22 @@ function scanTemplateLiteral(src, pos) {
|
|
|
351
354
|
|
|
352
355
|
/**
|
|
353
356
|
* esbuild plugin — strips server-only spec properties before bundling.
|
|
354
|
-
*
|
|
357
|
+
* Transforms files under src/pages/ plus the global store file (pulse.store.js
|
|
358
|
+
* carries server fetchers that must never reach the browser) — never touches
|
|
359
|
+
* runtime/server code.
|
|
355
360
|
*
|
|
356
|
-
* @param {string} pagesDir
|
|
361
|
+
* @param {string} pagesDir Absolute path to src/pages/
|
|
362
|
+
* @param {string|null} storePath Absolute path to pulse.store.js, if it exists
|
|
357
363
|
*/
|
|
358
|
-
function createStripServerPlugin(pagesDir) {
|
|
364
|
+
function createStripServerPlugin(pagesDir, storePath = null) {
|
|
359
365
|
return {
|
|
360
366
|
name: 'pulse-strip-server',
|
|
361
367
|
setup(build) {
|
|
362
368
|
build.onLoad({ filter: /\.js$/ }, (args) => {
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
const isPage = args.path.startsWith(pagesDir + path.sep) ||
|
|
370
|
+
args.path === pagesDir.replace(/\/$/, '') + '.js'
|
|
371
|
+
const isStore = storePath && args.path === storePath
|
|
372
|
+
if (!isPage && !isStore) return undefined
|
|
365
373
|
|
|
366
374
|
const source = fs.readFileSync(args.path, 'utf8')
|
|
367
375
|
const stripped = stripServerOnlyImports(stripServerOnlyKeys(source))
|
|
@@ -435,6 +443,11 @@ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pat
|
|
|
435
443
|
|
|
436
444
|
const PAGES_DIR = path.join(ROOT, 'src', 'pages')
|
|
437
445
|
|
|
446
|
+
// Global store — bundled into boot files (server fetchers stripped) when present
|
|
447
|
+
const STORE_PATH = fs.existsSync(path.join(ROOT, 'pulse.store.js'))
|
|
448
|
+
? path.join(ROOT, 'pulse.store.js')
|
|
449
|
+
: null
|
|
450
|
+
|
|
438
451
|
const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
439
452
|
// Strip server-only keys/imports to check what's left for the client.
|
|
440
453
|
// Skip specs with no view — these are API routes or raw content specs
|
|
@@ -453,15 +466,22 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
|
453
466
|
const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
|
|
454
467
|
const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
|
|
455
468
|
|
|
469
|
+
// Global store — when pulse.store.js exists, every boot bundle imports it and
|
|
470
|
+
// passes it to mount() so client store mutations register in production.
|
|
471
|
+
// The strip plugin removes its server fetchers from the browser bundle;
|
|
472
|
+
// esbuild's shared-chunk splitting keeps it from being duplicated per page.
|
|
473
|
+
const storeImport = STORE_PATH ? `\nimport store from '${path.relative(TMP_DIR, STORE_PATH)}'` : ''
|
|
474
|
+
const mountOpts = STORE_PATH ? `{ ssr: true, store }` : `{ ssr: true }`
|
|
475
|
+
|
|
456
476
|
fs.writeFileSync(bootstrapPath, `\
|
|
457
477
|
import spec from '${relSpec}'
|
|
458
478
|
import { mount } from '${relRuntime}'
|
|
459
|
-
import { initNavigation } from '${relNavigate}'
|
|
479
|
+
import { initNavigation } from '${relNavigate}'${storeImport}
|
|
460
480
|
|
|
461
481
|
const root = document.getElementById('pulse-root')
|
|
462
482
|
if (root && !root.dataset.pulseMounted) {
|
|
463
483
|
root.dataset.pulseMounted = '1'
|
|
464
|
-
mount(spec, root, window.__PULSE_SERVER__ || {}, {
|
|
484
|
+
mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
|
|
465
485
|
initNavigation(root, mount)
|
|
466
486
|
}
|
|
467
487
|
|
|
@@ -488,7 +508,7 @@ const result = await esbuild.build({
|
|
|
488
508
|
metafile: true,
|
|
489
509
|
sourcemap: false,
|
|
490
510
|
treeShaking: true,
|
|
491
|
-
plugins: [createStripServerPlugin(PAGES_DIR)],
|
|
511
|
+
plugins: [createStripServerPlugin(PAGES_DIR, STORE_PATH)],
|
|
492
512
|
define: {
|
|
493
513
|
'process.env.NODE_ENV': '"production"'
|
|
494
514
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Inline the scanner functions (duplicated here to keep test self-contained)
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
|
|
10
|
+
const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render', 'sitemap']
|
|
11
11
|
const SERVER_ONLY_IMPORTS = ['@invisibleloop/pulse/md']
|
|
12
12
|
|
|
13
13
|
function stripServerOnlyKeys(source) {
|
|
@@ -398,5 +398,24 @@ devTest('leaves module-level constants intact',
|
|
|
398
398
|
|
|
399
399
|
// ---------------------------------------------------------------------------
|
|
400
400
|
|
|
401
|
+
console.log('\nStore + new spec fields\n')
|
|
402
|
+
|
|
403
|
+
test('sitemap enumerator is stripped (server-only)',
|
|
404
|
+
`export default {\n route: '/blog/:slug',\n sitemap: async () => ['/blog/a'],\n view: () => '',\n}`,
|
|
405
|
+
`export default {\n route: '/blog/:slug',\n view: () => '',\n}`
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
test('submit is NOT stripped — it collides with the common submit action name',
|
|
409
|
+
`export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`,
|
|
410
|
+
`export default {\n route: '/x',\n actions: {\n submit: {\n run: async () => 1,\n },\n },\n view: () => '',\n}`
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
test('store definition: server fetchers stripped, state and mutations survive',
|
|
414
|
+
`export default {\n state: { basket: [] },\n server: {\n nav: async () => fetchNav(),\n },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`,
|
|
415
|
+
`export default {\n state: { basket: [] },\n mutations: {\n add: (store, item) => ({ basket: [...store.basket, item] }),\n },\n}`
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
401
420
|
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
|
|
402
421
|
if (fail > 0) process.exit(1)
|
|
@@ -18,12 +18,10 @@ Only declared keys are saved. Restored values that differ from the spec default
|
|
|
18
18
|
|
|
19
19
|
The global store is a shared data layer. Server fetchers run per request; mutations run on the client and broadcast to all subscribed pages without a server round-trip.
|
|
20
20
|
|
|
21
|
-
**Define the store** in `pulse.store.js
|
|
21
|
+
**Define the store** in `pulse.store.js` at the project root — that's the whole setup. The CLI auto-discovers it (like pages in `src/pages/`), loads it server-side, serves it to the browser, and hot-reloads it on edit:
|
|
22
22
|
```js
|
|
23
23
|
// pulse.store.js
|
|
24
24
|
export default {
|
|
25
|
-
hydrate: '/pulse.store.js', // browser-importable path — required for mutations
|
|
26
|
-
|
|
27
25
|
state: { // default/fallback values
|
|
28
26
|
user: null,
|
|
29
27
|
settings: { theme: 'dark', lang: 'en' },
|
|
@@ -45,10 +43,10 @@ export default {
|
|
|
45
43
|
}
|
|
46
44
|
```
|
|
47
45
|
|
|
48
|
-
**
|
|
46
|
+
**No registration step is needed** with `pulse dev` / `pulse start` — `pulse.store.js` is auto-discovered. Only when you call `createServer` yourself do you pass it manually:
|
|
49
47
|
```js
|
|
50
48
|
import store from './pulse.store.js'
|
|
51
|
-
createServer([...specs], { store })
|
|
49
|
+
createServer([...specs], { store }) // manual server entry only
|
|
52
50
|
```
|
|
53
51
|
|
|
54
52
|
**Use store data in a page** — declare `spec.store` with the keys needed. They appear in the view's `server` argument:
|
package/src/cli/cli.test.js
CHANGED
|
@@ -105,6 +105,59 @@ for (const skill of requiredSkills) {
|
|
|
105
105
|
})
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Store auto-discovery (loadStore)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Regression: pulse.store.js was documented but never loaded by the CLI —
|
|
112
|
+
// dev/start servers got no store, so spec.store keys were always empty.
|
|
113
|
+
|
|
114
|
+
console.log('\nStore auto-discovery\n')
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
|
|
118
|
+
const { tmpdir } = await import('node:os')
|
|
119
|
+
const { loadStore } = await import('./discover.js')
|
|
120
|
+
|
|
121
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'pulse-store-'))
|
|
122
|
+
try {
|
|
123
|
+
// No store file → null
|
|
124
|
+
const none = await loadStore(dir)
|
|
125
|
+
test('loadStore returns null when pulse.store.js is absent', () => {
|
|
126
|
+
if (none !== null) throw new Error(`Expected null, got ${JSON.stringify(none)}`)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Valid store file → definition with browser hydrate path set
|
|
130
|
+
writeFileSync(path.join(dir, 'pulse.store.js'),
|
|
131
|
+
`export default { state: { basket: [] }, server: { nav: async () => ['Home'] }, mutations: { add: (s, i) => ({ basket: [...s.basket, i] }) } }`)
|
|
132
|
+
const def = await loadStore(dir)
|
|
133
|
+
test('loadStore loads pulse.store.js and sets the browser hydrate path', () => {
|
|
134
|
+
if (!def) throw new Error('Expected a store definition')
|
|
135
|
+
if (def.hydrate !== '/pulse.store.js') throw new Error(`Expected hydrate /pulse.store.js, got ${def.hydrate}`)
|
|
136
|
+
if (typeof def.server?.nav !== 'function') throw new Error('server fetchers missing')
|
|
137
|
+
if (typeof def.mutations?.add !== 'function') throw new Error('mutations missing')
|
|
138
|
+
if (!Array.isArray(def.state?.basket)) throw new Error('state missing')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Cache-busted re-import returns the edited module (dev hot reload)
|
|
142
|
+
writeFileSync(path.join(dir, 'pulse.store.js'),
|
|
143
|
+
`export default { state: { basket: [], promo: true }, mutations: {} }`)
|
|
144
|
+
const fresh = await loadStore(dir, Date.now())
|
|
145
|
+
test('loadStore cache-busts for hot reload', () => {
|
|
146
|
+
if (fresh?.state?.promo !== true) throw new Error('Expected fresh store definition after edit')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Bad export → clear error
|
|
150
|
+
writeFileSync(path.join(dir, 'pulse.store.js'), `export default 42`)
|
|
151
|
+
let threw = false
|
|
152
|
+
try { await loadStore(dir, Date.now() + 1) } catch (e) { threw = /store object/.test(e.message) }
|
|
153
|
+
test('loadStore throws a clear error for a non-object export', () => {
|
|
154
|
+
if (!threw) throw new Error('Expected a descriptive throw for bad default export')
|
|
155
|
+
})
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(dir, { recursive: true, force: true })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
108
161
|
// ---------------------------------------------------------------------------
|
|
109
162
|
|
|
110
163
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/cli/dev.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import path from 'path'
|
|
12
12
|
import fs from 'fs'
|
|
13
13
|
import { createServer } from '../server/index.js'
|
|
14
|
-
import { loadPages }
|
|
14
|
+
import { loadPages, loadStore } from './discover.js'
|
|
15
15
|
import * as log from './logger.js'
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -109,6 +109,10 @@ const PUBLIC_DIR = path.join(ROOT, 'public')
|
|
|
109
109
|
|
|
110
110
|
const specs = await loadPages(ROOT)
|
|
111
111
|
|
|
112
|
+
// Auto-discover the global store — pulse.store.js in the project root.
|
|
113
|
+
// Mirrors page discovery; no custom server entry needed.
|
|
114
|
+
const store = await loadStore(ROOT)
|
|
115
|
+
|
|
112
116
|
if (specs.length === 0) {
|
|
113
117
|
log.error('No pages found in src/pages/. Create a page to get started.')
|
|
114
118
|
process.exit(1)
|
|
@@ -192,6 +196,9 @@ async function triggerReload(label = 'File changed') {
|
|
|
192
196
|
try {
|
|
193
197
|
const fresh = await loadPages(ROOT, now)
|
|
194
198
|
updateSpecs(fresh)
|
|
199
|
+
// Hot-swap the store too — pulse.store.js edits take effect like spec edits
|
|
200
|
+
const freshStore = await loadStore(ROOT, now)
|
|
201
|
+
updateStore(freshStore)
|
|
195
202
|
log.info(`${label} — specs reloaded`)
|
|
196
203
|
checkUndefinedClasses(fresh)
|
|
197
204
|
} catch (err) {
|
|
@@ -204,6 +211,11 @@ async function triggerReload(label = 'File changed') {
|
|
|
204
211
|
// Watch src/ for changes to existing files (spec edits, component edits)
|
|
205
212
|
fs.watch(path.join(ROOT, 'src'), { recursive: true }, () => triggerReload('File changed'))
|
|
206
213
|
|
|
214
|
+
// Watch the store file — it lives in the project root, outside src/
|
|
215
|
+
if (fs.existsSync(path.join(ROOT, 'pulse.store.js'))) {
|
|
216
|
+
fs.watch(path.join(ROOT, 'pulse.store.js'), () => triggerReload('Store changed'))
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
// Also watch the pages directory explicitly so macOS reliably fires on new file creation.
|
|
208
220
|
// On macOS, fs.watch with recursive=true watches subdirectory contents but may not fire
|
|
209
221
|
// for the parent dir entry when a brand-new file is created. Watching the dir non-recursively
|
|
@@ -227,7 +239,7 @@ const reloadScript = (nonce) => `<script nonce="${nonce}">
|
|
|
227
239
|
})();
|
|
228
240
|
</script>`
|
|
229
241
|
|
|
230
|
-
const { updateSpecs } = await createServer(specs, {
|
|
242
|
+
const { updateSpecs, updateStore } = await createServer(specs, {
|
|
231
243
|
port: PORT,
|
|
232
244
|
stream: true,
|
|
233
245
|
staticDir: fs.existsSync(PUBLIC_DIR) ? PUBLIC_DIR : null,
|
|
@@ -235,6 +247,7 @@ const { updateSpecs } = await createServer(specs, {
|
|
|
235
247
|
extraBody: reloadScript,
|
|
236
248
|
dev: true,
|
|
237
249
|
agentMode: !!process.env.PULSE_AGENT_MODE,
|
|
250
|
+
...(store ? { store } : {}),
|
|
238
251
|
...(_config.csp ? { csp: _config.csp } : {}),
|
|
239
252
|
// Server options forwarded verbatim from pulse.config.js so dev behaves like
|
|
240
253
|
// production — keep in sync with start.js PASSTHROUGH_OPTIONS
|
|
@@ -285,6 +298,11 @@ const { updateSpecs } = await createServer(specs, {
|
|
|
285
298
|
return serveFile(filePath)
|
|
286
299
|
}
|
|
287
300
|
|
|
301
|
+
// Global store — pulse.store.js lives in the project root (not src/), and the
|
|
302
|
+
// dev hydrate bootstrap imports it so client store mutations register.
|
|
303
|
+
// serveFile strips node:/server-only imports, same treatment as page specs.
|
|
304
|
+
if (url === '/pulse.store.js' && serveFile(path.join(ROOT, 'pulse.store.js'))) return false
|
|
305
|
+
|
|
288
306
|
// Project source — /src/pages/, /src/components/, /src/lib/, etc.
|
|
289
307
|
// Falls back to framework source for imports like /src/ui/ that sub-projects
|
|
290
308
|
// resolve from relative paths (e.g. docs component pages → ../../../../src/ui/).
|
package/src/cli/discover.js
CHANGED
|
@@ -111,3 +111,29 @@ export async function loadPages(projectRoot, bust = 0) {
|
|
|
111
111
|
|
|
112
112
|
return specs
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Auto-discover the global store definition from pulse.store.js in the
|
|
117
|
+
* project root. Returns the store definition with `hydrate` set to its
|
|
118
|
+
* browser-importable path (used by the dev inline bootstrap), or null when
|
|
119
|
+
* the file doesn't exist.
|
|
120
|
+
*
|
|
121
|
+
* Mirrors page auto-discovery: pulse.store.js is the documented filename
|
|
122
|
+
* convention, so CLI-managed projects (pulse dev / pulse start) get the
|
|
123
|
+
* store without writing a custom server entry.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} projectRoot
|
|
126
|
+
* @returns {Promise<object|null>}
|
|
127
|
+
*/
|
|
128
|
+
export async function loadStore(projectRoot, cacheBust = null) {
|
|
129
|
+
const storePath = path.join(projectRoot, 'pulse.store.js')
|
|
130
|
+
if (!fs.existsSync(storePath)) return null
|
|
131
|
+
|
|
132
|
+
const url = 'file://' + storePath + (cacheBust ? `?t=${cacheBust}` : '')
|
|
133
|
+
const mod = await import(url)
|
|
134
|
+
const def = mod.default
|
|
135
|
+
if (!def || typeof def !== 'object') {
|
|
136
|
+
throw new Error('pulse.store.js must export a default store object ({ state?, server?, mutations? })')
|
|
137
|
+
}
|
|
138
|
+
return { ...def, hydrate: def.hydrate || '/pulse.store.js' }
|
|
139
|
+
}
|
package/src/cli/start.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import path from 'path'
|
|
12
12
|
import fs from 'fs'
|
|
13
13
|
import { createServer } from '../server/index.js'
|
|
14
|
-
import { loadPages }
|
|
14
|
+
import { loadPages, loadStore } from './discover.js'
|
|
15
15
|
|
|
16
16
|
const args = process.argv.slice(2)
|
|
17
17
|
const rootArg = args.indexOf('--root')
|
|
@@ -71,6 +71,9 @@ port = parseInt(process.env.PORT, 10) || port || 3000
|
|
|
71
71
|
|
|
72
72
|
const specs = await loadPages(ROOT)
|
|
73
73
|
|
|
74
|
+
// Auto-discover the global store — pulse.store.js in the project root
|
|
75
|
+
const store = await loadStore(ROOT)
|
|
76
|
+
|
|
74
77
|
if (specs.length === 0) {
|
|
75
78
|
console.error('No pages found in src/pages/.')
|
|
76
79
|
process.exit(1)
|
|
@@ -81,6 +84,7 @@ createServer(specs, {
|
|
|
81
84
|
stream: true,
|
|
82
85
|
staticDir: PUBLIC_DIR,
|
|
83
86
|
defaultCache,
|
|
87
|
+
...(store ? { store } : {}),
|
|
84
88
|
...(csp ? { csp } : {}),
|
|
85
89
|
...passthrough,
|
|
86
90
|
})
|
package/src/runtime/ssr.js
CHANGED
|
@@ -631,11 +631,14 @@ function encode(str) {
|
|
|
631
631
|
* bundle (i.e. resolved via the manifest) rather than a raw source file.
|
|
632
632
|
* Bundles live under /dist/ and carry a content hash in their filename.
|
|
633
633
|
*
|
|
634
|
-
*
|
|
634
|
+
* Null-safe: purely server-rendered pages (no mutations/actions/persist) have
|
|
635
|
+
* no hydrate path at all — they are never bundles.
|
|
636
|
+
*
|
|
637
|
+
* @param {string|undefined} hydratePath
|
|
635
638
|
* @returns {boolean}
|
|
636
639
|
*/
|
|
637
640
|
function isBundle(hydratePath) {
|
|
638
|
-
return hydratePath.startsWith('/dist/')
|
|
641
|
+
return typeof hydratePath === 'string' && hydratePath.startsWith('/dist/')
|
|
639
642
|
}
|
|
640
643
|
|
|
641
644
|
/**
|
package/src/server/index.js
CHANGED
|
@@ -593,9 +593,13 @@ export async function createServer(entries, options = {}) {
|
|
|
593
593
|
|
|
594
594
|
const healthPath = healthCheck === true ? '/healthz' : (healthCheck || null)
|
|
595
595
|
|
|
596
|
+
// Store definition — `let` so the dev server can hot-swap it via updateStore()
|
|
597
|
+
// when pulse.store.js changes, the same way updateSpecs() swaps page specs.
|
|
598
|
+
let storeDef = store
|
|
599
|
+
|
|
596
600
|
// Validate store at startup — fail fast before the server accepts connections
|
|
597
|
-
if (
|
|
598
|
-
const { valid, errors } = validateStore(
|
|
601
|
+
if (storeDef) {
|
|
602
|
+
const { valid, errors } = validateStore(storeDef)
|
|
599
603
|
if (!valid) {
|
|
600
604
|
throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
|
|
601
605
|
}
|
|
@@ -897,8 +901,8 @@ export async function createServer(entries, options = {}) {
|
|
|
897
901
|
// Global store — resolve once per request, attach to ctx before guard/server fetchers.
|
|
898
902
|
// Use global fetcherTimeout for store fetchers; spec-level override applied below.
|
|
899
903
|
ctx.fetcherTimeout = fetcherTimeout ?? null
|
|
900
|
-
if (
|
|
901
|
-
ctx.store = await resolveStoreState(
|
|
904
|
+
if (storeDef) {
|
|
905
|
+
ctx.store = await resolveStoreState(storeDef, ctx)
|
|
902
906
|
}
|
|
903
907
|
|
|
904
908
|
const spec = resolveSpec(match.spec, hydrateMap)
|
|
@@ -1020,11 +1024,11 @@ export async function createServer(entries, options = {}) {
|
|
|
1020
1024
|
// string path — they are small, must carry an explicit status, and POST
|
|
1021
1025
|
// bodies are already consumed.
|
|
1022
1026
|
if (responseStatus !== 200 || req.method === 'POST') {
|
|
1023
|
-
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache,
|
|
1027
|
+
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, responseStatus, livePath)
|
|
1024
1028
|
} else if (stream) {
|
|
1025
|
-
await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache,
|
|
1029
|
+
await handleStreamResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, livePath)
|
|
1026
1030
|
} else {
|
|
1027
|
-
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache,
|
|
1031
|
+
await handleStringResponse(spec, ctx, req, res, extraBody, dev, canonicalBase, nonce, runtimeBundle, defaultCache, storeDef, csp, faviconPath, 200, livePath)
|
|
1028
1032
|
}
|
|
1029
1033
|
|
|
1030
1034
|
} catch (err) {
|
|
@@ -1119,6 +1123,14 @@ export async function createServer(entries, options = {}) {
|
|
|
1119
1123
|
notFoundSpec = newSpecs.find(s => s.route === '*') || null
|
|
1120
1124
|
router = buildRouter(newSpecs.filter(s => s.route !== '*'))
|
|
1121
1125
|
allSpecs = newSpecs
|
|
1126
|
+
},
|
|
1127
|
+
/** Swap the store definition at runtime (dev hot reload of pulse.store.js). */
|
|
1128
|
+
updateStore(newStoreDef) {
|
|
1129
|
+
if (newStoreDef) {
|
|
1130
|
+
const { valid, errors } = validateStore(newStoreDef)
|
|
1131
|
+
if (!valid) throw new Error(`Invalid Pulse store:\n${errors.map(e => ` — ${e}`).join('\n')}`)
|
|
1132
|
+
}
|
|
1133
|
+
storeDef = newStoreDef || null
|
|
1122
1134
|
}
|
|
1123
1135
|
}
|
|
1124
1136
|
}
|
|
@@ -1294,6 +1294,30 @@ await test('without a "*" spec the default 404 still works', async () => {
|
|
|
1294
1294
|
})
|
|
1295
1295
|
})
|
|
1296
1296
|
|
|
1297
|
+
await test('custom 404 works in production mode (manifest with runtime bundle present)', async () => {
|
|
1298
|
+
// Regression: in production the manifest sets runtimeBundle, which made
|
|
1299
|
+
// wrapDocument call isBundle(spec.hydrate) — undefined for non-hydrated pages
|
|
1300
|
+
// like a typical 404 spec → TypeError → 500 instead of the custom 404.
|
|
1301
|
+
// Dev never hit it because runtimeBundle is '' there.
|
|
1302
|
+
const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
|
|
1303
|
+
await withServer([helloSpec, notFoundPageSpec], { stream: true, manifest: prodManifest }, async (port) => {
|
|
1304
|
+
const { status, body } = await get(port, '/nope')
|
|
1305
|
+
assert(status === 404, `Expected 404 in production mode, got ${status}`)
|
|
1306
|
+
assert(body.includes('Custom not found'), `Expected custom 404 content: ${body.slice(0, 200)}`)
|
|
1307
|
+
})
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
await test('non-hydrated pages render on the buffered path in production mode', async () => {
|
|
1311
|
+
// Same root cause, different surface: stream: false + production manifest
|
|
1312
|
+
// crashed EVERY zero-JS page, not just the 404.
|
|
1313
|
+
const prodManifest = { _runtime: '/dist/runtime-abcd1234.js' }
|
|
1314
|
+
await withServer([helloSpec], { stream: false, manifest: prodManifest }, async (port) => {
|
|
1315
|
+
const { status, body } = await get(port, '/hello')
|
|
1316
|
+
assert(status === 200, `Expected 200, got ${status}`)
|
|
1317
|
+
assert(body.includes('Hello world'), 'Expected page content')
|
|
1318
|
+
})
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1297
1321
|
// ---------------------------------------------------------------------------
|
|
1298
1322
|
|
|
1299
1323
|
console.log('\nServer-side forms (spec.submit) + CSRF\n')
|
package/types/server.d.ts
CHANGED
|
@@ -175,6 +175,9 @@ export interface ServerInstance {
|
|
|
175
175
|
/** Swap the spec list at runtime (used by the dev server for hot reload). */
|
|
176
176
|
updateSpecs(specs: PulseSpec[]): void
|
|
177
177
|
|
|
178
|
+
/** Swap the store definition at runtime (dev hot reload of pulse.store.js). Validates; throws on a bad definition. */
|
|
179
|
+
updateStore(storeDef: PulseStoreDefinition | null): void
|
|
180
|
+
|
|
178
181
|
/**
|
|
179
182
|
* Broadcast a store patch to every connected live client (requires the
|
|
180
183
|
* `live` option). Pages subscribed via spec.store merge the patch and
|