@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 +5 -3
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +8 -0
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +6 -0
- package/benchmark/public/dist/manifest.json +5 -0
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +2 -0
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/benchmark/src/pages/counter.js +37 -0
- package/benchmark/src/pages/home.js +21 -0
- package/docs/src/lib/stats.js +3 -3
- package/docs/src/pages/faq.js +1 -1
- package/docs/src/pages/getting-started.js +1 -0
- package/docs/src/pages/home.js +4 -4
- package/docs/src/pages/hydration.js +2 -2
- package/docs/src/pages/performance.js +4 -4
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +217 -0
- package/scripts/strip-server.test.js +187 -0
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 +
|
|
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 |
|
|
399
|
-
| /contact | 0.00 | 3
|
|
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,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
|
+
}
|
package/docs/src/lib/stats.js
CHANGED
|
@@ -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.
|
|
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
|
-
: '
|
|
54
|
-
const pageNavKb = fmt(bundles?.pageBootKb, '0.
|
|
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' }),
|
package/docs/src/pages/faq.js
CHANGED
|
@@ -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 ~
|
|
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>
|
package/docs/src/pages/home.js
CHANGED
|
@@ -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">
|
|
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"
|
|
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
|
|
255
|
-
The shared runtime is brotli-compressed and cached across all navigations. Subsequent pages cost 0.
|
|
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 (~
|
|
58
|
-
counter.boot-def456.js # per-page spec bundle (~0.
|
|
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><script src></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', '~
|
|
97
|
-
['Multiple pages', 'Your spec only — runtime is in a separate <code>runtime-[hash].js</code> chunk', '~0.
|
|
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.
|
|
104
|
-
<li><strong>Navigate to another page</strong> — <code>contact.boot-[hash].js</code> (~0.
|
|
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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
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)
|