@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 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
- // Store mutations update all subscribed pages without a server round-trip
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
@@ -50,16 +50,15 @@ export default {
50
50
  }`, 'js'))}
51
51
 
52
52
  ${section('registering', 'Registering the store')}
53
- <p>Pass your store to <code>createServer</code> via the <code>store</code> option. The store is validated at startup — bad fetchers throw before the server accepts connections:</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([dashboardSpec, settingsSpec], {
58
+ createServer(pages, {
60
59
  port: 3000,
61
60
  staticDir: 'public',
62
- store, // ← register the global 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.20.0
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
- const SERVER_ONLY_KEYS = ['server', 'meta', 'guard', 'serverTimeout', 'contentType', 'render']
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
- * Only transforms files under src/pages/ to avoid touching runtime/server code.
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 Absolute path to src/pages/
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
- if (!args.path.startsWith(pagesDir + path.sep) &&
364
- args.path !== pagesDir.replace(/\/$/, '') + '.js') return undefined
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__ || {}, { ssr: true })
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
- **Register the store** in your server file:
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:
@@ -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 } from './discover.js'
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/).
@@ -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 } from './discover.js'
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
  })
@@ -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
- * @param {string} hydratePath
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
  /**
@@ -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 (store) {
598
- const { valid, errors } = validateStore(store)
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 (store) {
901
- ctx.store = await resolveStoreState(store, ctx)
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, store, csp, faviconPath, responseStatus, livePath)
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, store, csp, faviconPath, livePath)
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, store, csp, faviconPath, 200, livePath)
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