@invisibleloop/pulse 0.2.0 → 0.2.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.
package/CLAUDE.md CHANGED
@@ -186,7 +186,7 @@ The bundle is self-executing (imports spec, calls `mount` + `initNavigation` int
186
186
 
187
187
  `npm run build` generates three things per app:
188
188
 
189
- - `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + schema, ~2.1 kB brotli)
189
+ - `public/dist/runtime-[hash].js` — shared runtime (mount + navigate + store, ~3.8 kB brotli)
190
190
  - `public/dist/[name].boot-[hash].js` — per-page spec bundle (~0.5–0.9 kB brotli)
191
191
  - `public/dist/manifest.json` — maps `/examples/foo.js` → `/dist/foo.boot-HASH.js`
192
192
 
@@ -395,8 +395,10 @@ Before writing any UI HTML by hand, check `src/ui/index.js` — there are 50+ co
395
395
 
396
396
  | Page | CLS | JS (brotli) |
397
397
  |---|---|---|
398
- | /counter | 0.00 | 3.5 kB (first visit) / 0.35 kB (cached runtime) |
399
- | /contact | 0.00 | 3.6 kB (first visit) / 0.47 kB (cached runtime) |
398
+ | /counter | 0.00 | 4.2 kB (first visit) / 0.4 kB (cached runtime) |
399
+ | /contact | 0.00 | 4.3 kB (first visit) / 0.5 kB (cached runtime) |
400
+
401
+ First-visit JS = runtime chunk (~3.8 kB) + per-page boot file (~0.4–0.9 kB). On repeat visits the runtime is served from cache — only the boot file is fetched. If multiple pages share UI components, esbuild's code splitting extracts those into the runtime chunk, so the runtime chunk grows with shared code.
400
402
 
401
403
  Lighthouse: 100/100/100 (Accessibility / Best Practices / SEO) on both pages.
402
404
 
@@ -0,0 +1,8 @@
1
+ import{a as n,b as u}from"./runtime-5Y7ESAIB.js";var o={route:"/counter",hydrate:"/src/pages/counter.js",meta:{title:"Counter \u2014 Pulse Benchmark",styles:["/pulse.css"]},state:{count:0},constraints:{count:{min:0,max:10}},mutations:{increment:t=>({count:t.count+1}),decrement:t=>({count:t.count-1})},view:(t,r)=>`
2
+ <main id="main-content">
3
+ <p>${r.greeting}</p>
4
+ <p>Count: ${t.count}</p>
5
+ <button data-event="decrement">\u2212</button>
6
+ <button data-event="increment">+</button>
7
+ </main>
8
+ `};var e=document.getElementById("pulse-root");e&&!e.dataset.pulseMounted&&(e.dataset.pulseMounted="1",n(o,e,window.__PULSE_SERVER__||{},{ssr:!0}),u(e,n));var m=o;export{m as default};
@@ -0,0 +1,6 @@
1
+ import{a as e,b as i}from"./runtime-5Y7ESAIB.js";var o={route:"/home",meta:{title:"Home \u2014 Pulse Benchmark",styles:["/pulse.css"]},state:{},view:()=>`
2
+ <main id="main-content">
3
+ <h1>Pulse Benchmark</h1>
4
+ <p>Static page \u2014 no client-side interactivity.</p>
5
+ </main>
6
+ `};var t=document.getElementById("pulse-root");t&&!t.dataset.pulseMounted&&(t.dataset.pulseMounted="1",e(o,t,window.__PULSE_SERVER__||{},{ssr:!0}),i(t,e));var r=o;export{r as default};
@@ -0,0 +1,5 @@
1
+ {
2
+ "/src/pages/counter.js": "/../benchmark/public/dist/counter.boot-PO6SNSJZ.js",
3
+ "/src/pages/home.js": "/../benchmark/public/dist/home.boot-YK6VAEQI.js",
4
+ "_runtime": "/../benchmark/public/dist/runtime-5Y7ESAIB.js"
5
+ }
@@ -0,0 +1,2 @@
1
+ var E={},V=!1,R={},D=!1,k=new Set;function U(t){V||(E={...t||{}},V=!0)}function q(){return E}function K(t){return k.add(t),()=>k.delete(t)}function P(t){E={...E,...t};for(let o of k)o(E)}function H(t){D||(R=t||{},D=!0)}function N(t,o){let r=R[t];if(!r){console.warn(`[Pulse] No store mutation found for "${t}"`);return}P(r(E,o))}var T=t=>import("./runtime-KO4BHUQ3.js").then(o=>o.showToast(t));function ft(t,o,r={},s={}){if(!t||t.state===void 0||!t.view)throw new Error("[Pulse] mount: spec must have state and view");typeof window<"u"&&(U(window.__PULSE_STORE__||{}),s.store?.mutations&&H(s.store.mutations));let e=new Set(t.store||[]),i={};for(let[u,c]of Object.entries(r))e.has(u)||(i[u]=c);function a(){if(!e.size)return i;let u=q(),c={};for(let w of e)u[w]!==void 0&&(c[w]=u[w]);return{...c,...i}}let n=z(t.state),h=t.persist?.length?`pulse:${t.route||location.pathname}`:null,y=!1;if(h)try{let u=JSON.parse(localStorage.getItem(h)||"{}");t.persist.forEach(c=>{u[c]!==void 0&&u[c]!==t.state[c]&&(n[c]=u[c],y=!0)})}catch{}function d(){if(h)try{let u={};t.persist.forEach(c=>{u[c]=n[c]}),localStorage.setItem(h,JSON.stringify(u))}catch{}}let m=null;function f(){let u;try{u=Z(t,n,a())}catch(c){console.error("[Pulse] View error:",c);let w=a();u=t.onViewError?t.onViewError(c,n,w):ot(c)}u!==m&&(m=u,et(o,u))}function S(u,c){if(t.mutations?.[u]){let w=t.mutations[u](n,c);w?._toast&&T(w._toast);let{_toast:C,...g}=w??{},p=n;n=x({...n,...g},t.constraints),d(),$(p,n)&&f();return}if(t.actions?.[u]){b(u,t.actions[u],n,c);return}console.warn(`[Pulse] No mutation or action found for "${u}"`)}async function b(u,c,w,C){if(c.onStart){let g=c.onStart(w,C);g?._toast&&T(g._toast);let{_toast:p,...M}=g??{},_=n;n=x({...n,...M},t.constraints),$(_,n)&&f()}if(c.validate){let g=rt(n,t.validation);if(g.length>0){console.warn(`[Pulse] Validation failed for action "${u}":`,g);let p=c.onError?.(n,{validation:g})??{};p._toast&&T(p._toast);let{_toast:M,..._}=p,L=n;n=x({...n,..._},t.constraints),$(L,n)&&f();return}}try{let g=await c.run(n,a(),C),p=c.onSuccess(n,g)??{};p._storeUpdate&&P(p._storeUpdate),p._toast&&T(p._toast);let{_storeUpdate:M,_toast:_,...L}=p,Y=n;n=x({...n,...L},t.constraints),$(Y,n)&&f()}catch(g){console.error(`[Pulse] Action "${u}" failed:`,g);let p=c.onError(n,g)??{};p._toast&&T(p._toast);let{_toast:M,..._}=p,L=n;n=x({...n,..._},t.constraints),$(L,n)&&f()}d()}let v=new AbortController;tt(o,S,v.signal);let l=e.size>0?K(()=>f()):null;return s.ssr&&!y||f(),{getState:()=>z(n),dispatch:S,refresh:f,destroy:()=>{l?.(),v.abort(),o.innerHTML=""}}}function Z(t,o,r){return typeof t.view=="function"?t.view(o,r):Object.values(t.view).map(s=>s(o,r)).join("")}function tt(t,o,r){let s=r?{signal:r}:{};t.addEventListener("click",e=>{let i=e.target?.closest?.("[data-store-event]");if(i){let[m,f]=A(i.dataset.storeEvent);m==="click"&&(e.preventDefault(),N(f,e));return}let a=e.target?.closest?.("[data-dialog-open]");if(a){let m=document.getElementById(a.dataset.dialogOpen);m?.showModal&&(e.preventDefault(),m.showModal());return}let n=e.target?.closest?.("[data-dialog-close]");if(n){let m=n.closest("dialog");m&&(e.preventDefault(),m.close());return}if(e.target?.tagName==="DIALOG"){e.target.close();return}let h=e.target?.closest?.("[data-event]");if(!h)return;let[y,d]=A(h.dataset.event);y==="click"&&(e.preventDefault(),o(d,e))},s),t.addEventListener("change",e=>{let i=e.target?.closest?.("[data-store-event]");if(i){let[y,d]=A(i.dataset.storeEvent);y==="change"&&N(d,e);return}let a=e.target?.closest?.("[data-event]");if(!a)return;let[n,h]=A(a.dataset.event);n==="change"&&I(a,h,e,o)},s),t.addEventListener("input",e=>{let i=e.target?.closest?.("[data-store-event]");if(i){let[y,d]=A(i.dataset.storeEvent);y==="input"&&N(d,e);return}let a=e.target?.closest?.("[data-event]");if(!a)return;let[n,h]=A(a.dataset.event);n==="input"&&I(a,h,e,o)},s),t.addEventListener("submit",e=>{let i=e.target?.closest?.("[data-action]");i&&(e.preventDefault(),o(i.dataset.action,new FormData(i)),i.hasAttribute("data-reset")&&i.reset())},s)}function et(t,o){if(typeof document>"u"){t.innerHTML=o;return}let r=document.createElement("div");r.innerHTML=o,O(t,r)}function O(t,o){let r=Array.from(o.childNodes),s=r.filter(i=>i.nodeType===1);if(s.length>0&&s.every(i=>i.getAttribute("data-key")!==null)){nt(t,s);return}let e=Array.from(t.childNodes);for(r.forEach((i,a)=>{let n=e[a];if(!n){t.appendChild(i.cloneNode(!0));return}if(n.nodeType!==i.nodeType||n.nodeName!==i.nodeName){t.replaceChild(i.cloneNode(!0),n);return}if(i.nodeType===3){n.nodeValue!==i.nodeValue&&(n.nodeValue=i.nodeValue);return}i.nodeType===1&&(B(n,i),O(n,i))});t.childNodes.length>r.length;)t.removeChild(t.lastChild)}function nt(t,o){let r=new Map;for(let e of t.childNodes)if(e.nodeType===1){let i=e.getAttribute("data-key");i!==null&&r.set(i,e)}let s=null;for(let e=o.length-1;e>=0;e--){let i=o[e],a=i.getAttribute("data-key"),n=r.get(a);n?(r.delete(a),B(n,i),O(n,i)):n=i.cloneNode(!0),(n.nextSibling!==s||n.parentNode!==t)&&t.insertBefore(n,s),s=n}for(let e of r.values())t.removeChild(e)}function B(t,o){for(let{name:r,value:s}of Array.from(o.attributes))t.getAttribute(r)!==s&&t.setAttribute(r,s);for(let{name:r}of Array.from(t.attributes))o.hasAttribute(r)||t.removeAttribute(r)}function A(t){let o=t.split(":");return o.length===1?["click",o[0]]:[o[0],o[1]]}function I(t,o,r,s){let e=parseInt(t.dataset.debounce,10);if(e>0){J(t,"d",o,e,a=>s(o,a))(r);return}let i=parseInt(t.dataset.throttle,10);if(i>0){J(t,"t",o,i,a=>s(o,a))(r);return}s(o,r)}function x(t,o){if(!o)return t;let s=Object.keys(o).some(e=>e.includes("."))?structuredClone(t):t;for(let[e,i]of Object.entries(o)){let{obj:a,key:n}=F(s,e);a===null||a[n]===void 0||(i.min!==void 0&&typeof a[n]=="number"&&(a[n]=Math.max(a[n],i.min)),i.max!==void 0&&typeof a[n]=="number"&&(a[n]=Math.min(a[n],i.max)))}return s}function rt(t,o){if(!o)return[];let r=[];for(let[s,e]of Object.entries(o)){let{obj:i,key:a}=F(t,s),n=i?.[a];if(e.required&&!n){r.push({path:s,rule:"required",message:`${s} is required`});continue}n==null||n===""||(e.minLength!==void 0&&String(n).length<e.minLength&&r.push({path:s,rule:"minLength",message:`${s} must be at least ${e.minLength} characters`}),e.maxLength!==void 0&&String(n).length>e.maxLength&&r.push({path:s,rule:"maxLength",message:`${s} must be no more than ${e.maxLength} characters`}),e.min!==void 0&&Number(n)<e.min&&r.push({path:s,rule:"min",message:`${s} must be at least ${e.min}`}),e.max!==void 0&&Number(n)>e.max&&r.push({path:s,rule:"max",message:`${s} must be no more than ${e.max}`}),e.format==="email"&&!st(String(n))&&r.push({path:s,rule:"format",message:`${s} must be a valid email address`}),e.format==="url"&&!ut(String(n))&&r.push({path:s,rule:"format",message:`${s} must be a valid URL`}),e.format==="numeric"&&isNaN(Number(n))&&r.push({path:s,rule:"format",message:`${s} must be numeric`}),e.pattern&&!e.pattern.test(String(n))&&r.push({path:s,rule:"pattern",message:`${s} does not match the required format`}))}return r}function F(t,o){let r=o.split("."),s=r.pop(),e=t;for(let i of r){if(e==null||typeof e!="object")return{obj:null,key:s};e=e[i]}return{obj:e,key:s}}function z(t){return structuredClone(t)}function $(t,o){if(t===o)return!1;let r=Object.keys(t);return r.length!==Object.keys(o).length?!0:r.some(s=>t[s]!==o[s])}function ot(t){return`<div style="padding:1rem;color:#b91c1c;background:#fef2f2;border:1px solid #fca5a5;border-radius:.375rem;font-family:monospace;font-size:.875rem"><strong>View error</strong>${t?.message?`: ${t.message}`:""}</div>`}function st(t){return/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)}var j=new WeakMap;function J(t,o,r,s,e){j.has(t)||j.set(t,{});let i=j.get(t),a=`${o}:${r}:${s}`;return i[a]||(i[a]=o==="d"?it(e,s):at(e,s)),i[a]}function it(t,o){let r;return function(...s){clearTimeout(r),r=setTimeout(()=>t(...s),o)}}function at(t,o){let r=0;return function(...s){let e=Date.now();e-r>=o&&(r=e,t(...s))}}function ut(t){try{return new URL(t),!0}catch{return!1}}function mt(t,o){history.replaceState({pulse:!0,path:location.pathname},"",location.pathname);let r=null;async function s(e,i){try{let a=await fetch(e,{headers:{"X-Pulse-Navigate":"true"}});if(!a.ok){location.href=e;return}if((a.headers.get("content-type")||"").includes("application/x-ndjson")){let h=a.body.getReader(),y=new TextDecoder,d="",m=null,f={},S=new Map,b=async v=>{if(!v.trim())return;let l=JSON.parse(v);if(l.type==="meta")m=l.hydrate,document.title=l.title||document.title,G(l.styles),await X(l.scripts);else if(l.type==="html"){t.innerHTML=l.html;for(let u of l.deferred||[]){let c=t.querySelector(`[id="pd-${u}"]`);c&&S.set(u,c)}i&&history.pushState({pulse:!0,path:e},"",e),Q(t)}else if(l.type==="deferred"){let u=S.get(l.id);if(u){let c=document.createElement("div");c.innerHTML=l.html,u.replaceWith(...c.childNodes),S.delete(l.id)}}else l.type==="done"&&(l.storeState&&window.__updatePulseStore__&&window.__updatePulseStore__(l.storeState),f=l.serverState||{})};for(;;){let{done:v,value:l}=await h.read();if(v)break;d+=y.decode(l,{stream:!0});let u=d.split(`
2
+ `);d=u.pop();for(let c of u)await b(c)}if(d&&await b(d),W(t),document.dispatchEvent(new CustomEvent("pulse:navigate")),m&&o){r?.destroy(),t.dataset.pulseMounted="1",window.__PULSE_SERVER__=f;let{default:v}=await import(m);v&&(r=o(v,t,f))}}else{let{html:h,title:y,styles:d,scripts:m,hydrate:f,serverState:S,storeState:b}=await a.json();if(b&&window.__updatePulseStore__&&window.__updatePulseStore__(b),t.innerHTML=h,document.title=y||document.title,G(d),await X(m),W(t),i&&history.pushState({pulse:!0,path:e},"",e),f&&o){r?.destroy(),t.dataset.pulseMounted="1",window.__PULSE_SERVER__=S||{};let{default:v}=await import(f);v&&(r=o(v,t,S||{}))}document.dispatchEvent(new CustomEvent("pulse:navigate")),Q(t)}}catch{location.href=e}}return document.addEventListener("click",e=>{let i=e.target.closest("a");if(!i)return;let a=i.getAttribute("href");if(!a)return;let n;try{n=new URL(a,location.origin)}catch{return}n.origin===location.origin&&(e.ctrlKey||e.metaKey||e.shiftKey||e.altKey||i.target&&i.target!=="_self"||(e.preventDefault(),s(n.pathname+n.search,!0)))}),window.addEventListener("popstate",e=>{e.state?.pulse&&s(location.pathname+location.search,!1)}),{setMount:e=>{r=e}}}function W(t){t.querySelectorAll("script:not([src])").forEach(o=>{let r=document.createElement("script");r.textContent=o.textContent,document.head.appendChild(r),r.remove()})}function G(t){if(!Array.isArray(t))return;let o=new Set([...document.querySelectorAll('link[rel="stylesheet"]')].map(r=>r.getAttribute("href")));for(let r of t)if(!o.has(r)){let s=document.createElement("link");s.rel="stylesheet",s.href=r,document.head.appendChild(s)}}function X(t){if(!Array.isArray(t))return Promise.resolve();let o=new Set([...document.querySelectorAll("script[src]")].map(r=>r.getAttribute("src")));return Promise.all(t.filter(r=>!o.has(r)).map(r=>new Promise(s=>{let e=document.createElement("script");e.src=r,e.onload=s,e.onerror=s,document.head.appendChild(e)})))}function Q(t){window.scrollTo({top:0,behavior:"instant"});let o=t.querySelector("#main-content, main, h1")||t;o.hasAttribute("tabindex")||(o.setAttribute("tabindex","-1"),o.addEventListener("blur",()=>o.removeAttribute("tabindex"),{once:!0})),o.focus({preventScroll:!0})}export{ft as a,mt as b};
@@ -0,0 +1,49 @@
1
+ var l="pulse-toasts";var m=new Set(["success","error","warning","info"]),p=`
2
+ #pulse-toasts {
3
+ position: fixed;
4
+ top: 1rem;
5
+ right: 1rem;
6
+ z-index: 9999;
7
+ display: flex;
8
+ flex-direction: column;
9
+ gap: .5rem;
10
+ pointer-events: none;
11
+ max-width: min(24rem, calc(100vw - 2rem));
12
+ }
13
+ .pulse-toast {
14
+ display: flex;
15
+ align-items: flex-start;
16
+ gap: .75rem;
17
+ padding: .75rem 1rem;
18
+ border-radius: .5rem;
19
+ box-shadow: 0 4px 16px rgba(0,0,0,.2);
20
+ font-size: .875rem;
21
+ line-height: 1.4;
22
+ pointer-events: all;
23
+ opacity: 0;
24
+ transform: translateX(calc(100% + 1.5rem));
25
+ transition: opacity .2s ease, transform .2s ease;
26
+ background: #1e293b;
27
+ color: #f8fafc;
28
+ }
29
+ .pulse-toast--visible {
30
+ opacity: 1;
31
+ transform: translateX(0);
32
+ }
33
+ .pulse-toast--success { background: #166534; }
34
+ .pulse-toast--error { background: #991b1b; }
35
+ .pulse-toast--warning { background: #92400e; }
36
+ .pulse-toast-message { flex: 1; }
37
+ .pulse-toast-close {
38
+ background: none;
39
+ border: none;
40
+ color: inherit;
41
+ cursor: pointer;
42
+ padding: 0 0 0 .25rem;
43
+ font-size: 1.125rem;
44
+ line-height: 1;
45
+ opacity: .7;
46
+ flex-shrink: 0;
47
+ }
48
+ .pulse-toast-close:hover { opacity: 1; }
49
+ `,c=!1;function f(){if(c)return;c=!0;let e=document.createElement("style");e.textContent=p,document.head.appendChild(e)}function g(){let e=document.getElementById(l);return e||(f(),e=document.createElement("div"),e.id=l,e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","false"),document.body.appendChild(e)),e}function b(e){if(typeof document>"u")return;let{message:a,variant:i="info",duration:r=4e3}=typeof e=="string"?{message:e}:e;if(!a)return;let u=m.has(i)?i:"info",n=g();for(;n.children.length>=5;)n.firstElementChild?.remove();let t=document.createElement("div");t.className=`pulse-toast pulse-toast--${u}`,t.setAttribute("role","status");let o=document.createElement("span");o.className="pulse-toast-message",o.textContent=a;let s=document.createElement("button");s.className="pulse-toast-close",s.setAttribute("aria-label","Dismiss notification"),s.textContent="\xD7",s.addEventListener("click",()=>d(t)),t.appendChild(o),t.appendChild(s),n.appendChild(t),requestAnimationFrame(()=>t.classList.add("pulse-toast--visible")),r>0&&setTimeout(()=>d(t),r)}function d(e){e.classList.remove("pulse-toast--visible"),e.addEventListener("transitionend",()=>e.remove(),{once:!0})}export{b as showToast};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Benchmark page — interactive counter with server data.
3
+ * Represents a typical SPA page: runtime + mutations + server fetcher.
4
+ */
5
+ export default {
6
+ route: '/counter',
7
+ hydrate: '/src/pages/counter.js',
8
+
9
+ meta: {
10
+ title: 'Counter — Pulse Benchmark',
11
+ styles: ['/pulse.css'],
12
+ },
13
+
14
+ state: { count: 0 },
15
+
16
+ constraints: {
17
+ count: { min: 0, max: 10 },
18
+ },
19
+
20
+ server: {
21
+ greeting: async () => 'Hello from the server',
22
+ },
23
+
24
+ mutations: {
25
+ increment: (state) => ({ count: state.count + 1 }),
26
+ decrement: (state) => ({ count: state.count - 1 }),
27
+ },
28
+
29
+ view: (state, server) => `
30
+ <main id="main-content">
31
+ <p>${server.greeting}</p>
32
+ <p>Count: ${state.count}</p>
33
+ <button data-event="decrement">−</button>
34
+ <button data-event="increment">+</button>
35
+ </main>
36
+ `,
37
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Benchmark page — static page, no client JS.
3
+ * Represents a content page with no hydration.
4
+ */
5
+ export default {
6
+ route: '/home',
7
+
8
+ meta: {
9
+ title: 'Home — Pulse Benchmark',
10
+ styles: ['/pulse.css'],
11
+ },
12
+
13
+ state: {},
14
+
15
+ view: () => `
16
+ <main id="main-content">
17
+ <h1>Pulse Benchmark</h1>
18
+ <p>Static page — no client-side interactivity.</p>
19
+ </main>
20
+ `,
21
+ }
@@ -47,11 +47,11 @@ function fmt(kb, fallback) {
47
47
  return (kb != null) ? kb.toFixed(kb < 1 ? 2 : 1) : fallback
48
48
  }
49
49
 
50
- const runtimeKb = fmt(bundles?.runtimeKb, '3.1')
50
+ const runtimeKb = fmt(bundles?.runtimeKb, '3.8')
51
51
  const firstVisit = bundles?.runtimeKb && bundles?.pageBootKb
52
52
  ? (bundles.runtimeKb + bundles.pageBootKb).toFixed(1)
53
- : '3.5'
54
- const pageNavKb = fmt(bundles?.pageBootKb, '0.35')
53
+ : '4.2'
54
+ const pageNavKb = fmt(bundles?.pageBootKb, '0.4')
55
55
 
56
56
  export const metrics = {
57
57
  generatedAt: new Date().toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }),
@@ -45,7 +45,7 @@ export default {
45
45
  ${q(
46
46
  'Why no virtual DOM?',
47
47
  `<p>A virtual DOM solves the problem of efficient incremental updates to a large, complex component tree. Pulse pages are server-rendered HTML strings — the client runtime re-renders a bounded section of the page when state changes, which is fast enough for the kinds of interactions Pulse is designed for.</p>
48
- <p>Eliminating the virtual DOM means eliminating the ~40–100 kB runtime that comes with it. Pulse ships ~3.5 kB brotli to the browser on first visit (shared runtime + page bundle). That is not a compression trick — there is genuinely no framework runtime on the client.</p>`
48
+ <p>Eliminating the virtual DOM means eliminating the ~40–100 kB runtime that comes with it. Pulse ships ~4 kB brotli to the browser on first visit (shared runtime + page bundle). That is not a compression trick — there is genuinely no framework runtime on the client.</p>`
49
49
  )}
50
50
 
51
51
  ${q(
@@ -23,6 +23,7 @@ export default {
23
23
  ${section('requirements', 'Requirements')}
24
24
  <ul>
25
25
  <li><strong>Node.js 22 or later</strong> — <a href="https://nodejs.org" target="_blank" rel="noopener" aria-label="nodejs.org (opens in new tab)">nodejs.org</a></li>
26
+ <li><strong>Google Chrome</strong> — used by the agent for screenshots and Lighthouse audits — <a href="https://www.google.com/chrome" target="_blank" rel="noopener" aria-label="Download Google Chrome (opens in new tab)">google.com/chrome</a></li>
26
27
  <li><strong>Claude Code</strong> — the CLI for Claude, installed and authenticated — <a href="https://docs.anthropic.com/en/docs/claude-code/getting-started" target="_blank" rel="noopener" aria-label="Claude Code installation guide (opens in new tab)">installation guide</a></li>
27
28
  </ul>
28
29
  <p>Claude Code provides the <code>claude</code> command. Pulse launches it automatically with the Pulse MCP server wired in — so the agent has instant access to the framework reference, your project structure, and all Pulse tools without any manual configuration.</p>
@@ -83,7 +83,7 @@ export default {
83
83
  </div>
84
84
  <div class="home-stat-divider"></div>
85
85
  <div class="home-stat">
86
- <span class="home-stat-value">3.5 kB</span>
86
+ <span class="home-stat-value">4 kB</span>
87
87
  <span class="home-stat-label">Runtime JS, first visit (brotli)</span>
88
88
  </div>
89
89
  <div class="home-stat-divider"></div>
@@ -200,7 +200,7 @@ export default {
200
200
  </tr>
201
201
  <tr>
202
202
  <th scope="row">Client JS shipped</th>
203
- <td class="v-yes">3.5 kB brotli (shared runtime, first visit)</td>
203
+ <td class="v-yes">~4 kB brotli (shared runtime, first visit)</td>
204
204
  <td class="v-no">50-200 kB+ depending on features</td>
205
205
  <td class="v-partial">~15 kB brotli</td>
206
206
  </tr>
@@ -251,8 +251,8 @@ export default {
251
251
  The shell renders and streams instantly. Deferred segments arrive as data resolves — no blocking, no flash.
252
252
  </li>
253
253
  <li>
254
- <strong>3.5 kB of JS on first visit.</strong>
255
- The shared runtime is brotli-compressed and cached across all navigations. Subsequent pages cost 0.35–0.5 kB.
254
+ <strong>~4 kB of JS on first visit.</strong>
255
+ The shared runtime is brotli-compressed and cached across all navigations. Subsequent pages cost 0.4–0.9 kB.
256
256
  </li>
257
257
  <li>
258
258
  <strong>Zero CLS.</strong>
@@ -54,8 +54,8 @@ export default {
54
54
  <p>Run <code>npm run build</code> to generate production bundles. This creates content-hashed files in <code>public/dist/</code> and a <code>manifest.json</code> mapping spec hydrate paths to bundle paths.</p>
55
55
  ${codeBlock(highlight(`# Generated by npm run build
56
56
  public/dist/
57
- runtime-abc123.js # shared runtime (~2.1 kB brotli)
58
- counter.boot-def456.js # per-page spec bundle (~0.5 kB brotli)
57
+ runtime-abc123.js # shared runtime (~3.8 kB brotli)
58
+ counter.boot-def456.js # per-page spec bundle (~0.4–0.9 kB brotli)
59
59
  manifest.json # { '/src/pages/counter.js': '/dist/counter.boot-def456.js' }`, 'bash'))}
60
60
  <p>When Pulse detects a manifest (via <code>staticDir</code> auto-detection or explicit <code>manifest</code> option), it resolves the <code>hydrate</code> path to the bundle path and emits a single <code>&lt;script src&gt;</code> tag instead of the inline bootstrap:</p>
61
61
  ${codeBlock(highlight(`<script type="module" src="/dist/counter.boot-def456.js"></script>`, 'html'))}
@@ -93,15 +93,15 @@ export default {
93
93
  ${table(
94
94
  ['App size', 'What the boot file contains', 'Size (brotli)'],
95
95
  [
96
- ['Single page', 'Your spec + the full Pulse runtime bundled together', '~3.5 kB'],
97
- ['Multiple pages', 'Your spec only — runtime is in a separate <code>runtime-[hash].js</code> chunk', '~0.35–0.5 kB'],
96
+ ['Single page', 'Your spec + the full Pulse runtime bundled together', '~4 kB'],
97
+ ['Multiple pages', 'Your spec only — runtime is in a separate <code>runtime-[hash].js</code> chunk', '~0.4–0.9 kB'],
98
98
  ]
99
99
  )}
100
100
  <p>With multiple pages, esbuild's code splitting extracts the Pulse runtime into a shared chunk because every page imports it. The browser downloads it once and caches it — subsequent page navigations only fetch the small per-page boot file.</p>
101
101
  <p><strong>What you see in the network tab across navigations:</strong></p>
102
102
  <ul>
103
- <li><strong>First page visit</strong> — <code>runtime-[hash].js</code> (~3.1 kB) + <code>home.boot-[hash].js</code> (~0.35 kB)</li>
104
- <li><strong>Navigate to another page</strong> — <code>contact.boot-[hash].js</code> (~0.47 kB) only. Runtime already cached.</li>
103
+ <li><strong>First page visit</strong> — <code>runtime-[hash].js</code> (~3.8 kB) + <code>home.boot-[hash].js</code> (~0.4 kB)</li>
104
+ <li><strong>Navigate to another page</strong> — <code>contact.boot-[hash].js</code> (~0.5 kB) only. Runtime already cached.</li>
105
105
  <li><strong>Return visit</strong> — nothing. Both files served from cache with <code>immutable</code> headers.</li>
106
106
  </ul>
107
107
  ${callout('tip', 'The runtime hash only changes when the Pulse runtime itself is updated — not when your app changes. Deploying new pages or mutations does not bust the runtime cache for returning visitors.')}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.2.0",
3
+ "version": "0.2.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.2.0
1
+ 0.2.2
package/scripts/build.js CHANGED
@@ -20,6 +20,222 @@ import { createHash } from 'crypto'
20
20
  import { discoverPages } from '../src/cli/discover.js'
21
21
  import { renderToString } from '../src/runtime/ssr.js'
22
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Server-only property stripping
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * These spec properties are resolved and used exclusively on the server.
29
+ * Stripping them from the client bundle:
30
+ * - reduces bundle size
31
+ * - prevents server-only imports (DB clients, Node built-ins, internal APIs)
32
+ * from being shipped to and evaluated in the browser
33
+ * - avoids bundling errors when server imports use Node-only modules
34
+ */
35
+ const SERVER_ONLY_KEYS = ['server', 'guard', 'serverTimeout', 'contentType', 'render']
36
+
37
+ /**
38
+ * Strip all server-only property declarations from a spec source string.
39
+ * Matches properties at the start of a line (after optional whitespace) —
40
+ * the standard formatting for Pulse spec files.
41
+ */
42
+ function stripServerOnlyKeys(source) {
43
+ for (const key of SERVER_ONLY_KEYS) {
44
+ source = removeObjectKey(source, key)
45
+ }
46
+ return source
47
+ }
48
+
49
+ /**
50
+ * Remove a single named property (key + value + optional trailing comma/newline)
51
+ * from a JS source string. Uses a character-level scanner to correctly handle
52
+ * nested structures, string literals, template literals, and function expressions.
53
+ */
54
+ function removeObjectKey(source, key) {
55
+ const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
56
+ let match
57
+
58
+ while ((match = keyRe.exec(source)) !== null) {
59
+ const removeStart = match.index // start of indentation
60
+ const afterColon = match.index + match[0].length // character after ':'
61
+
62
+ // Skip whitespace between ':' and value
63
+ let pos = afterColon
64
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
65
+
66
+ // Scan past the full value
67
+ pos = scanJsValue(source, pos)
68
+
69
+ // Consume optional trailing comma and newline
70
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
71
+ if (pos < source.length && source[pos] === ',') pos++
72
+ if (pos < source.length && source[pos] === '\n') pos++
73
+
74
+ source = source.slice(0, removeStart) + source.slice(pos)
75
+ keyRe.lastIndex = removeStart
76
+ }
77
+
78
+ return source
79
+ }
80
+
81
+ /**
82
+ * Scan a JS value starting at `pos`, returning the index just past it.
83
+ *
84
+ * Correctly handles:
85
+ * - Object / array / group literals: { } [ ] ( )
86
+ * - Arrow functions: (params) => expr or (params) => { body }
87
+ * - Function expressions: function(params) { body } / async function ...
88
+ * - String literals: '...' "..."
89
+ * - Template literals: `...${expr}...`
90
+ * - Line and block comments
91
+ * - Simple values (numbers, booleans, identifiers, null/undefined)
92
+ *
93
+ * Stops when it finds a comma or closing delimiter at depth 0 that is NOT
94
+ * followed by a continuation (arrow `=>`, method call `.`, etc.).
95
+ */
96
+ function scanJsValue(src, pos) {
97
+ let i = pos
98
+ let depth = 0
99
+
100
+ while (i < src.length) {
101
+ const c = src[i]
102
+
103
+ // ---- Line comment -------------------------------------------------------
104
+ if (c === '/' && src[i + 1] === '/') {
105
+ while (i < src.length && src[i] !== '\n') i++
106
+ continue
107
+ }
108
+
109
+ // ---- Block comment ------------------------------------------------------
110
+ if (c === '/' && src[i + 1] === '*') {
111
+ const end = src.indexOf('*/', i + 2)
112
+ i = end < 0 ? src.length : end + 2
113
+ continue
114
+ }
115
+
116
+ // ---- String literals ----------------------------------------------------
117
+ if (c === '"' || c === "'") {
118
+ i++
119
+ while (i < src.length) {
120
+ if (src[i] === '\\') { i += 2; continue }
121
+ if (src[i] === c) { i++; break }
122
+ i++
123
+ }
124
+ if (depth === 0) return i
125
+ continue
126
+ }
127
+
128
+ // ---- Template literals --------------------------------------------------
129
+ if (c === '`') {
130
+ i = scanTemplateLiteral(src, i + 1)
131
+ if (depth === 0) return i
132
+ continue
133
+ }
134
+
135
+ // ---- Open delimiters ----------------------------------------------------
136
+ if (c === '{' || c === '[' || c === '(') {
137
+ depth++
138
+ i++
139
+ continue
140
+ }
141
+
142
+ // ---- Close delimiters ---------------------------------------------------
143
+ if (c === '}' || c === ']' || c === ')') {
144
+ if (depth === 0) {
145
+ // Hit the parent structure's closing delimiter — stop, don't consume
146
+ return i
147
+ }
148
+ depth--
149
+ i++
150
+ if (depth === 0) {
151
+ // Just closed a top-level nested block — peek ahead to see if this is
152
+ // the end of the value or if the expression continues.
153
+ let j = i
154
+ while (j < src.length && (src[j] === ' ' || src[j] === '\t')) j++
155
+ const next = src[j]
156
+ // Terminator characters — value is complete
157
+ if (!next || next === ',' || next === '}' || next === ']' || next === ')' || next === '\n') {
158
+ return i
159
+ }
160
+ // Arrow function body follows: (params) => ...
161
+ if (next === '=' && src[j + 1] === '>') continue
162
+ // Anything else (method chain, ternary, etc.) — keep scanning
163
+ continue
164
+ }
165
+ continue
166
+ }
167
+
168
+ // ---- Terminators at top level -------------------------------------------
169
+ if (depth === 0) {
170
+ if (c === ',') return i
171
+ if (c === '\n') return i
172
+ }
173
+
174
+ i++
175
+ }
176
+
177
+ return i
178
+ }
179
+
180
+ /**
181
+ * Scan a template literal body (starting just after the opening backtick).
182
+ * Returns the index just past the closing backtick.
183
+ * Handles `${...}` expression blocks including nested strings and templates.
184
+ */
185
+ function scanTemplateLiteral(src, pos) {
186
+ let i = pos
187
+ while (i < src.length) {
188
+ if (src[i] === '\\') { i += 2; continue }
189
+ if (src[i] === '`') { return i + 1 }
190
+ if (src[i] === '$' && src[i + 1] === '{') {
191
+ i += 2
192
+ let depth = 1
193
+ while (i < src.length && depth > 0) {
194
+ const c = src[i]
195
+ if (c === '{') { depth++; i++; continue }
196
+ if (c === '}') { depth--; i++; continue }
197
+ if (c === '"' || c === "'") {
198
+ const q = c; i++
199
+ while (i < src.length) {
200
+ if (src[i] === '\\') { i += 2; continue }
201
+ if (src[i] === q) { i++; break }
202
+ i++
203
+ }
204
+ continue
205
+ }
206
+ if (c === '`') { i = scanTemplateLiteral(src, i + 1); continue }
207
+ i++
208
+ }
209
+ continue
210
+ }
211
+ i++
212
+ }
213
+ return i
214
+ }
215
+
216
+ /**
217
+ * esbuild plugin — strips server-only spec properties before bundling.
218
+ * Only transforms files under src/pages/ to avoid touching runtime/server code.
219
+ *
220
+ * @param {string} pagesDir Absolute path to src/pages/
221
+ */
222
+ function createStripServerPlugin(pagesDir) {
223
+ return {
224
+ name: 'pulse-strip-server',
225
+ setup(build) {
226
+ build.onLoad({ filter: /\.js$/ }, (args) => {
227
+ if (!args.path.startsWith(pagesDir + path.sep) &&
228
+ args.path !== pagesDir.replace(/\/$/, '') + '.js') return undefined
229
+
230
+ const source = fs.readFileSync(args.path, 'utf8')
231
+ const stripped = stripServerOnlyKeys(source)
232
+ return { contents: stripped, loader: 'js' }
233
+ })
234
+ }
235
+ }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
23
239
  // Project root — can be overridden via --root flag for CLI usage
24
240
  const rootArg = process.argv.indexOf('--root')
25
241
  const ROOT = rootArg !== -1
@@ -105,6 +321,7 @@ const result = await esbuild.build({
105
321
  metafile: true,
106
322
  sourcemap: false,
107
323
  treeShaking: true,
324
+ plugins: [createStripServerPlugin(PAGES_DIR)],
108
325
  define: {
109
326
  'process.env.NODE_ENV': '"production"'
110
327
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for the server-only property stripper in build.js
3
+ * Run: node scripts/strip-server.test.js
4
+ */
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Inline the scanner functions (duplicated here to keep test self-contained)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const SERVER_ONLY_KEYS = ['server', 'guard', 'serverTimeout', 'contentType', 'render']
11
+
12
+ function stripServerOnlyKeys(source) {
13
+ for (const key of SERVER_ONLY_KEYS) source = removeObjectKey(source, key)
14
+ return source
15
+ }
16
+
17
+ function removeObjectKey(source, key) {
18
+ const keyRe = new RegExp(`^([ \\t]*)(${key})([ \\t]*:)`, 'gm')
19
+ let match
20
+ while ((match = keyRe.exec(source)) !== null) {
21
+ const removeStart = match.index
22
+ const afterColon = match.index + match[0].length
23
+ let pos = afterColon
24
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
25
+ pos = scanJsValue(source, pos)
26
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++
27
+ if (pos < source.length && source[pos] === ',') pos++
28
+ if (pos < source.length && source[pos] === '\n') pos++
29
+ source = source.slice(0, removeStart) + source.slice(pos)
30
+ keyRe.lastIndex = removeStart
31
+ }
32
+ return source
33
+ }
34
+
35
+ function scanJsValue(src, pos) {
36
+ let i = pos, depth = 0
37
+ while (i < src.length) {
38
+ const c = src[i]
39
+ if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; continue }
40
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue }
41
+ if (c === '"' || c === "'") {
42
+ i++
43
+ while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === c) { i++; break } i++ }
44
+ if (depth === 0) return i
45
+ continue
46
+ }
47
+ if (c === '`') { i = scanTemplateLiteral(src, i + 1); if (depth === 0) return i; continue }
48
+ if (c === '{' || c === '[' || c === '(') { depth++; i++; continue }
49
+ if (c === '}' || c === ']' || c === ')') {
50
+ if (depth === 0) return i
51
+ depth--; i++
52
+ if (depth === 0) {
53
+ let j = i
54
+ while (j < src.length && (src[j] === ' ' || src[j] === '\t')) j++
55
+ const next = src[j]
56
+ if (!next || next === ',' || next === '}' || next === ']' || next === ')' || next === '\n') return i
57
+ if (next === '=' && src[j + 1] === '>') continue
58
+ continue
59
+ }
60
+ continue
61
+ }
62
+ if (depth === 0 && (c === ',' || c === '\n')) return i
63
+ i++
64
+ }
65
+ return i
66
+ }
67
+
68
+ function scanTemplateLiteral(src, pos) {
69
+ let i = pos
70
+ while (i < src.length) {
71
+ if (src[i] === '\\') { i += 2; continue }
72
+ if (src[i] === '`') { return i + 1 }
73
+ if (src[i] === '$' && src[i + 1] === '{') {
74
+ i += 2; let depth = 1
75
+ while (i < src.length && depth > 0) {
76
+ const c = src[i]
77
+ if (c === '{') { depth++; i++; continue }
78
+ if (c === '}') { depth--; i++; continue }
79
+ if (c === '"' || c === "'") { const q = c; i++; while (i < src.length) { if (src[i] === '\\') { i += 2; continue } if (src[i] === q) { i++; break } i++ } continue }
80
+ if (c === '`') { i = scanTemplateLiteral(src, i + 1); continue }
81
+ i++
82
+ }
83
+ continue
84
+ }
85
+ i++
86
+ }
87
+ return i
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Test runner
92
+ // ---------------------------------------------------------------------------
93
+
94
+ let pass = 0, fail = 0
95
+
96
+ function test(label, input, expected) {
97
+ const got = stripServerOnlyKeys(input).trim()
98
+ const exp = expected.trim()
99
+ if (got === exp) {
100
+ console.log(' \u2713 ' + label)
101
+ pass++
102
+ } else {
103
+ console.log(' \u2717 ' + label)
104
+ console.log(' expected: ' + JSON.stringify(exp))
105
+ console.log(' got: ' + JSON.stringify(got))
106
+ fail++
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+
112
+ console.log('\nServer-only key stripping\n')
113
+
114
+ test('object value with trailing comma',
115
+ `export default {\n route: '/',\n server: { data: async (ctx) => 'hello' },\n view: () => ''\n}`,
116
+ `export default {\n route: '/',\n view: () => ''\n}`
117
+ )
118
+
119
+ test('async arrow with block body',
120
+ `export default {\n server: {\n data: async (ctx) => {\n return 'x'\n }\n },\n view: () => ''\n}`,
121
+ `export default {\n view: () => ''\n}`
122
+ )
123
+
124
+ test('serverTimeout simple number',
125
+ `export default {\n serverTimeout: 5000,\n view: () => ''\n}`,
126
+ `export default {\n view: () => ''\n}`
127
+ )
128
+
129
+ test('guard async arrow with block body containing nested object',
130
+ `export default {\n guard: async (ctx) => {\n if (!ctx.session) return { redirect: '/login' }\n },\n view: () => ''\n}`,
131
+ `export default {\n view: () => ''\n}`
132
+ )
133
+
134
+ test('guard inline arrow expression',
135
+ `export default {\n guard: async (ctx) => { if (!ctx.session) return { redirect: '/login' } },\n view: () => ''\n}`,
136
+ `export default {\n view: () => ''\n}`
137
+ )
138
+
139
+ test('contentType and render are both stripped (raw content specs are never hydrated)',
140
+ `export default {\n route: '/feed.xml',\n contentType: 'application/rss+xml',\n render: (ctx) => '<rss/>'\n}`,
141
+ `export default {\n route: '/feed.xml',\n}`
142
+ )
143
+
144
+ test('render with template literal containing ${...}',
145
+ 'export default {\n render: (ctx, srv) => `<rss>${srv.items.map(i => i.title).join(\',\')}</rss>`,\n view: () => \'\'\n}',
146
+ "export default {\n view: () => ''\n}"
147
+ )
148
+
149
+ test('last property — no trailing comma',
150
+ `export default {\n view: () => '',\n server: { data: async () => [] }\n}`,
151
+ `export default {\n view: () => '',\n}`
152
+ )
153
+
154
+ test('multiple fetchers in server object',
155
+ `export default {\n server: {\n user: async (ctx) => ctx.user,\n posts: async (ctx) => []\n },\n view: () => ''\n}`,
156
+ `export default {\n view: () => ''\n}`
157
+ )
158
+
159
+ test('does not strip non-server keys',
160
+ `export default {\n route: '/',\n state: {},\n view: () => ''\n}`,
161
+ `export default {\n route: '/',\n state: {},\n view: () => ''\n}`
162
+ )
163
+
164
+ test('strips all server-only keys in one pass',
165
+ `export default {\n route: '/admin',\n guard: async (ctx) => { if (!ctx.user) return { redirect: '/login' } },\n serverTimeout: 3000,\n server: { profile: async (ctx) => ctx.user },\n view: () => ''\n}`,
166
+ `export default {\n route: '/admin',\n view: () => ''\n}`
167
+ )
168
+
169
+ test('does not touch "server" inside a view string',
170
+ `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`,
171
+ `export default {\n view: (state, server) => \`<p>\${server.name}</p>\`\n}`
172
+ )
173
+
174
+ test('async function expression (not arrow)',
175
+ `export default {\n server: {\n data: async function(ctx) { return ctx.db.query() }\n },\n view: () => ''\n}`,
176
+ `export default {\n view: () => ''\n}`
177
+ )
178
+
179
+ test('server with deeply nested template literal in view is not affected',
180
+ 'export default {\n server: { items: async () => [] },\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}',
181
+ 'export default {\n view: (s, srv) => `<ul>${srv.items.map(i => `<li>${i}</li>`).join(\'\')}</ul>`\n}'
182
+ )
183
+
184
+ // ---------------------------------------------------------------------------
185
+
186
+ console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`)
187
+ if (fail > 0) process.exit(1)