@invisibleloop/pulse 0.7.1 → 0.7.2

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.
@@ -52,8 +52,10 @@
52
52
  }
53
53
  }
54
54
 
55
- // After client-side navigation — sidebar DOM is replaced, so re-query and re-centre
55
+ // After client-side navigation — #pulse-root is replaced, so the original
56
+ // navSidebar reference is detached. Re-query for the fresh sidebar element.
56
57
  document.addEventListener('pulse:navigate', function () {
58
+ navSidebar = document.querySelector('.docs-sidebar')
57
59
  var newActive = navSidebar && navSidebar.querySelector('.nav-link.active')
58
60
  centerNavLink(newActive)
59
61
  })
@@ -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.2",
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.7.1
1
+ 0.7.2
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, {
@@ -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>`