webhanger-front 1.0.12 → 1.0.15
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/browser.min.js +1 -1
- package/index.js +311 -129
- package/package.json +1 -1
package/browser.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
!function(e){const t="components",n="wh2_",r=[],o={};function a(e,t){o[e]||(o[e]=[]),o[e].push(t)}function s(e,t){(o[e]||[]).forEach(e=>e(t))}const c={loads:0,cacheHits:0,errors:0,totalTime:0};function i(e,t){e in c&&(c[e]+=t),s("metric",{name:e,value:t,metrics:{...c}})}const d=new Map;async function l(e,t,n){if(!e)return"";try{const r=e.split(":");if(3!==r.length)return console.warn("[WebHanger] decryptChunk: unexpected format, parts:",r.length),"";const[o,a,s]=r,c=Uint8Array.from(atob(o),e=>e.charCodeAt(0)),i=Uint8Array.from(atob(a),e=>e.charCodeAt(0)),l=Uint8Array.from(atob(s),e=>e.charCodeAt(0)),u=new Uint8Array(l.length+i.length);u.set(l),u.set(i,l.length);const h=await async function(e,t){const n=e+t;if(d.has(n))return d.get(n);const r=new TextEncoder,o=await crypto.subtle.digest("SHA-256",r.encode(e+t)),a=await crypto.subtle.importKey("raw",o,{name:"AES-GCM"},!1,["decrypt"]);return d.set(n,a),a}(t,n),f=await crypto.subtle.decrypt({name:"AES-GCM",iv:c},h,u);return(new TextDecoder).decode(f)}catch(e){return console.warn("[WebHanger] decryptChunk failed:",n,e.message),""}}function u(){return new Promise((e,n)=>{const r=indexedDB.open("webhanger_v2",1);r.onupgradeneeded=e=>e.target.result.createObjectStore(t),r.onsuccess=t=>e(t.target.result),r.onerror=()=>n(r.error)})}async function h(e,r){try{if(r.length<51200)return void localStorage.setItem(n+e,r)}catch(e){}await async function(e,n){const r=await u();return new Promise((o,a)=>{const s=r.transaction(t,"readwrite").objectStore(t).put(n,e);s.onsuccess=()=>o(),s.onerror=()=>a(s.error)})}(e,r)}async function f(e,r){const o=await async function(e){try{const t=localStorage.getItem(n+e);if(t)return t}catch(e){}return await async function(e){const n=await u();return new Promise((r,o)=>{const a=n.transaction(t,"readonly").objectStore(t).get(e);a.onsuccess=()=>r(a.result),a.onerror=()=>o(a.error)})}(e)}(e);if(o)return i("cacheHits",1),r().then(t=>{t&&t!==o&&h(e,t)}).catch(()=>{}),{data:o,source:"cache"};const a=await r();return a&&await h(e,a),{data:a,source:"cdn"}}async function m(e,t,n){const r=Array.isArray(e)?e:[e];let o;for(const e of r)try{const r=n?`${e}?token=${t}&expires=${n}`:`${e}?token=${t}`,o=await fetch(r);if(!o.ok)throw Error("HTTP "+o.status);return await o.text()}catch(t){console.warn(`[WebHanger] CDN failed (${e}): ${t.message} \u2014 trying next...`),o=t}throw Error("All CDN endpoints failed. Last error: "+o?.message)}function w(e){return new Promise(t=>{if("style"===e.type?document.querySelector(`link[href="${e.url}"]`):document.querySelector(`script[src="${e.url}"]`))return t();if("style"===e.type){const n=document.createElement("link");n.rel="stylesheet",n.href=e.url,n.onload=t,n.onerror=t,document.head.appendChild(n)}else{const n=document.createElement("script");n.src=e.url,e.defer&&(n.defer=!0),e.async&&(n.async=!0),n.onload=t,n.onerror=t,document.head.appendChild(n)}})}async function p(e=[]){for(const t of e)await w(t)}async function y(e,t){const n=`${e.cdnUrl}@${e.expires}`,{data:r}=await f(n,()=>m(e.cdnUrl,e.token,e.expires));if(!r)return;let o;try{o=JSON.parse(r)}catch(e){return}o.assets&&o.assets.length&&await p(o.assets);const a=await l(o.c,t,"::css");if(a&&!document.querySelector(`style[data-wh-dep="${e.name}@${e.version}"]`)){const t=document.createElement("style");t.setAttribute("data-wh-dep",`${e.name}@${e.version}`),t.textContent=a,document.head.appendChild(t)}const s=await l(o.h,t,"::html");if(s){const t=document.querySelector(`[data-wh-${e.name}]`);t&&(t.innerHTML=s)}const c=await l(o.j,t,"::js");if(c){const e=document.createElement("script");e.textContent=c,document.head.appendChild(e),document.head.removeChild(e)}}async function g(t,n,r,o,a="[data-wh]",c=null,d=[],u={}){if(!t||!n||!r||null==o)return void console.error("[WebHanger] Missing required params");if(0!==o&&Math.floor(Date.now()/1e3)>o)return void console.warn("[WebHanger] Token expired.");const h=function(e){return(t,n={})=>{"function"==typeof e&&e({stage:t,...n}),s(t,n)}}(c),w=function(e){const t=document.querySelector(e||"[data-wh]");if(!t)return{show:()=>{},hide:()=>{}};const n=document.createElement("div");return n.setAttribute("data-wh-loader",""),n.style.cssText="display:flex;align-items:center;justify-content:center;padding:24px;width:100%;box-sizing:border-box;",n.innerHTML='<div style="width:28px;height:28px;border:3px solid #e5e7eb;border-top-color:#6366f1;border-radius:50%;animation:wh-spin 0.7s linear infinite;"></div><style>@keyframes wh-spin{to{transform:rotate(360deg);}}</style>',{show(){t.appendChild(n)},hide(){n.parentNode&&n.parentNode.removeChild(n)}}}(a),g=performance.now();h("start",{cdnUrl:t,selector:a}),w.show(),i("loads",1);const b=`${t}@${o}`;try{h("fetching");const{data:c,source:v}=await f(b,()=>m(t,r,o));let x;try{x=JSON.parse(c)}catch(e){x={}}x.assets&&x.assets.length&&(h("assets",{count:x.assets.length}),await p(x.assets));const C=[...d,...x.dependencies||[]];if(C.length){h("deps",{count:C.length});for(const e of C)"object"==typeof e&&e.cdnUrl&&await y(e,n)}h("injecting"),w.hide(),await async function(t,n,r,o={}){const{sandbox:a=!1,allowedDomains:c,beforeMount:i,afterMount:d}=o,u=document.querySelector(r||"[data-wh]");if(!u)return void console.warn("[WebHanger] No mount target found.");let h;try{h=JSON.parse(t)}catch(e){return void console.error("[WebHanger] Invalid payload.")}if(!function(t){if(!t||!t.length)return!0;const n=e.location?.hostname||"";return t.some(e=>n===e||n.endsWith("."+e))}(c))return console.error("[WebHanger] Domain not allowed: "+e.location?.hostname),void s("error",{reason:"domain_restricted"});const f=await l(h.c,n,"::css"),m=await l(h.h,n,"::html"),w=await l(h.j,n,"::js");if(h.integrity&&(m||w)){const e=await async function(e,t,n,r){if(!r)return!0;const o=(new TextEncoder).encode(e+t+n),a=await crypto.subtle.digest("SHA-256",o);return Array.from(new Uint8Array(a)).map(e=>e.toString(16).padStart(2,"0")).join("")===r}(m,f,w,h.integrity);if(!e)return console.error("[WebHanger] Integrity check failed \u2014 bundle may be tampered or re-deploy needed."),void s("error",{reason:"integrity_failed"})}if("function"==typeof i&&i({target:u,html:m,css:f}),s("beforeMount",{target:u,selector:r}),a)!function(e,t,n){const r=e.attachShadow({mode:"closed"});if(n){const e=document.createElement("style");e.textContent=n,r.appendChild(e)}if(t){const e=document.createElement("div");e.innerHTML=t,r.appendChild(e)}}(u,m,f);else{if(f){const e=document.createElement("style");e.textContent=f,document.head.appendChild(e)}m&&(u.innerHTML=m)}if(w){const e=document.createElement("script");e.textContent=w,document.head.appendChild(e),document.head.removeChild(e)}"function"==typeof d&&d({target:u}),s("afterMount",{target:u,selector:r})}(c,n,a,u);const S=Math.round(performance.now()-g);i("totalTime",S),h("done",{time:S,source:v}),s("load",{cdnUrl:t,time:S,source:v,selector:a})}catch(e){w.hide(),i("errors",1),h("error",{message:e.message}),"function"==typeof u.onError&&u.onError(e),console.error("[WebHanger] Load failed:",e.message)}}let b=null,v="./wh-manifest.json";"undefined"!=typeof customElements&&customElements.define("wh-component",class extends HTMLElement{async connectedCallback(){b&&await this._load()}async _load(){const e=this.getAttribute("name"),t=this.hasAttribute("sandbox");if(!e)return void console.error("[WebHanger] <wh-component> missing 'name' attribute");const n=b||await fetch(this.getAttribute("src")||v).then(e=>e.json()),r=n.components[e];if(!r)return void console.error(`[WebHanger] <wh-component>: "${e}" not found in manifest`);this.setAttribute("data-wh-el",e);const o=`wh-component[data-wh-el="${e}"]`;await g(r.urls||r.url,n.pid,r.token,r.expires,o,null,[],{sandbox:t})}}),e.WebHangerFront={load:g,clearCache:async function(){Object.keys(localStorage).filter(e=>e.startsWith(n)).forEach(e=>localStorage.removeItem(e));try{(await u()).transaction(t,"readwrite").objectStore(t).clear()}catch(e){}if("caches"in window){const e=await caches.keys();await Promise.all(e.map(e=>caches.delete(e)))}if("serviceWorker"in navigator){const e=await navigator.serviceWorker.getRegistrations();await Promise.all(e.map(e=>e.unregister()))}Object.keys(sessionStorage).filter(e=>e.startsWith(n)).forEach(e=>sessionStorage.removeItem(e))},use:function(e){"function"==typeof e.install&&e.install({on:a,emit:s}),r.push(e)},on:a,metrics:c,initialize:async function(e="./wh-manifest.json"){v=e;const t=await fetch(e);b=await t.json(),document.querySelectorAll("wh-component[name]").forEach(e=>e._load())},version:"2.0.0"}}(window);
|
|
1
|
+
!function(e){const t="components",n="wh2_",r=[],o={};function a(e,t){o[e]||(o[e]=[]),o[e].push(t)}function s(e,t){(o[e]||[]).forEach(e=>e(t))}const c={loads:0,cacheHits:0,errors:0,totalTime:0};function i(e,t){e in c&&(c[e]+=t),s("metric",{name:e,value:t,metrics:{...c}})}const l=new Map;async function d(e,t,n){if(!e)return"";try{const r=e.split(":");if(3!==r.length)return console.warn("[WebHanger] decryptChunk: unexpected format, parts:",r.length),"";const[o,a,s]=r,c=Uint8Array.from(atob(o),e=>e.charCodeAt(0)),i=Uint8Array.from(atob(a),e=>e.charCodeAt(0)),d=Uint8Array.from(atob(s),e=>e.charCodeAt(0)),u=new Uint8Array(d.length+i.length);u.set(d),u.set(i,d.length);const h=await async function(e,t){const n=e+t;if(l.has(n))return l.get(n);const r=new TextEncoder,o=await crypto.subtle.digest("SHA-256",r.encode(e+t)),a=await crypto.subtle.importKey("raw",o,{name:"AES-GCM"},!1,["decrypt"]);return l.set(n,a),a}(t,n),f=await crypto.subtle.decrypt({name:"AES-GCM",iv:c},h,u);return(new TextDecoder).decode(f)}catch(e){return console.warn("[WebHanger] decryptChunk failed:",n,e.message),""}}function u(){return new Promise((e,n)=>{const r=indexedDB.open("webhanger_v2",1);r.onupgradeneeded=e=>e.target.result.createObjectStore(t),r.onsuccess=t=>e(t.target.result),r.onerror=()=>n(r.error)})}async function h(e,r){try{if(r.length<51200)return void localStorage.setItem(n+e,r)}catch(e){}await async function(e,n){const r=await u();return new Promise((o,a)=>{const s=r.transaction(t,"readwrite").objectStore(t).put(n,e);s.onsuccess=()=>o(),s.onerror=()=>a(s.error)})}(e,r)}async function f(e,r){const o=await async function(e){try{const t=localStorage.getItem(n+e);if(t)return t}catch(e){}return await async function(e){const n=await u();return new Promise((r,o)=>{const a=n.transaction(t,"readonly").objectStore(t).get(e);a.onsuccess=()=>r(a.result),a.onerror=()=>o(a.error)})}(e)}(e);if(o)return i("cacheHits",1),r().then(t=>{t&&t!==o&&h(e,t)}).catch(()=>{}),{data:o,source:"cache"};const a=await r();return a&&await h(e,a),{data:a,source:"cdn"}}async function p(e,t,n){const r=Array.isArray(e)?e:[e];let o;for(const e of r)try{const r=n?`${e}?token=${t}&expires=${n}`:`${e}?token=${t}`,o=await fetch(r);if(!o.ok)throw Error("HTTP "+o.status);return await o.text()}catch(t){console.warn(`[WebHanger] CDN failed (${e}): ${t.message} \u2014 trying next...`),o=t}throw Error("All CDN endpoints failed. Last error: "+o?.message)}function m(e){return new Promise(t=>{if("style"===e.type?document.querySelector(`link[href="${e.url}"]`):document.querySelector(`script[src="${e.url}"]`))return t();if("style"===e.type){const n=document.createElement("link");n.rel="stylesheet",n.href=e.url,n.onload=t,n.onerror=t,document.head.appendChild(n)}else{const n=document.createElement("script");n.src=e.url,e.defer&&(n.defer=!0),e.async&&(n.async=!0),n.onload=t,n.onerror=t,document.head.appendChild(n)}})}async function g(e=[]){for(const t of e)await m(t)}function w(e,t){return e&&t?e.replace(/\{\{wh\.([^}]+)\}\}/g,function(e,n){return void 0!==t[n]?t[n]+"":""}):e}async function y(e,t){const n=`${e.cdnUrl}@${e.expires}`,{data:r}=await f(n,()=>p(e.cdnUrl,e.token,e.expires));if(!r)return;let o;try{o=JSON.parse(r)}catch(e){return}o.assets&&o.assets.length&&await g(o.assets);const a=await d(o.c,t,"::css");if(a&&!document.querySelector(`style[data-wh-dep="${e.name}@${e.version}"]`)){const t=document.createElement("style");t.setAttribute("data-wh-dep",`${e.name}@${e.version}`),t.textContent=a,document.head.appendChild(t)}const s=await d(o.h,t,"::html");if(s){const t=document.querySelector(`[data-wh-${e.name}]`);t&&(t.innerHTML=s)}const c=await d(o.j,t,"::js");if(c){const e=document.createElement("script");e.textContent=c,document.head.appendChild(e),document.head.removeChild(e)}}async function b(t,n,r,o,a="[data-wh]",c=null,l=[],u={}){if(!t||!n||!r||null==o)return void console.error("[WebHanger] Missing required params");if(0!==o&&Math.floor(Date.now()/1e3)>o)return void console.warn("[WebHanger] Token expired.");const h=function(e){return(t,n={})=>{"function"==typeof e&&e({stage:t,...n}),s(t,n)}}(c),m=function(e){const t=document.querySelector(e||"[data-wh]");if(!t)return{show:()=>{},hide:()=>{}};const n=document.createElement("div");return n.setAttribute("data-wh-loader",""),n.style.cssText="display:flex;align-items:center;justify-content:center;padding:24px;width:100%;box-sizing:border-box;",n.innerHTML='<div style="width:28px;height:28px;border:3px solid #e5e7eb;border-top-color:#6366f1;border-radius:50%;animation:wh-spin 0.7s linear infinite;"></div><style>@keyframes wh-spin{to{transform:rotate(360deg);}}</style>',{show(){t.appendChild(n)},hide(){n.parentNode&&n.parentNode.removeChild(n)}}}(a),b=performance.now();h("start",{cdnUrl:t,selector:a}),m.show(),i("loads",1);const v=`${t}@${o}`;try{h("fetching");const{data:c,source:S}=await f(v,()=>p(t,r,o));let W;try{W=JSON.parse(c)}catch(e){W={}}W.assets&&W.assets.length&&(h("assets",{count:W.assets.length}),await g(W.assets));const C=[...l,...W.dependencies||[]];if(C.length){h("deps",{count:C.length});for(const e of C)"object"==typeof e&&e.cdnUrl&&await y(e,n)}h("injecting"),m.hide(),await async function(t,n,r,o={}){const{sandbox:a=!1,allowedDomains:c,beforeMount:i,afterMount:l,props:u={}}=o,h=document.querySelector(r||"[data-wh]");if(!h)return void console.warn("[WebHanger] No mount target found.");let f;try{f=JSON.parse(t)}catch(e){return void console.error("[WebHanger] Invalid payload.")}if(!function(t){if(!t||!t.length)return!0;const n=e.location?.hostname||"";return t.some(e=>n===e||n.endsWith("."+e))}(c))return console.error("[WebHanger] Domain not allowed: "+e.location?.hostname),void s("error",{reason:"domain_restricted"});const p=await d(f.c,n,"::css");let m=await d(f.h,n,"::html"),g=await d(f.j,n,"::js");if(f.integrity&&(m||g)){const e=await async function(e,t,n,r){if(!r)return!0;const o=(new TextEncoder).encode(e+t+n),a=await crypto.subtle.digest("SHA-256",o);return Array.from(new Uint8Array(a)).map(e=>e.toString(16).padStart(2,"0")).join("")===r}(m,p,g,f.integrity);if(!e)return console.error("[WebHanger] Integrity check failed \u2014 bundle may be tampered or re-deploy needed."),void s("error",{reason:"integrity_failed"})}const y=(v=u,S={},Object.keys((b=f.props)||{}).forEach(function(e){var t=b[e];S[e]=void 0!==t.default?t.default:""}),Object.keys(v||{}).forEach(function(e){var t=e.replace(/^wh-/,"").replace(/-([a-z])/g,function(e,t){return t.toUpperCase()}),n=v[e],r=b&&b[t];if(r&&"json"===r.type)try{n=JSON.parse(n)}catch(e){}S[t]=n}),S);var b,v,S;if(console.log("[WebHanger] props schema:",JSON.stringify(f.props),"| userProps:",JSON.stringify(u),"| resolved:",JSON.stringify(y)),m=w(m,y),g=w(g,y),"function"==typeof i&&i({target:h,html:m,css:p}),s("beforeMount",{target:h,selector:r}),a)!function(e,t,n){const r=e.attachShadow({mode:"closed"});if(n){const e=document.createElement("style");e.textContent=n,r.appendChild(e)}if(t){const e=document.createElement("div");e.innerHTML=t,r.appendChild(e)}}(h,m,p);else{if(p){const e=document.createElement("style");e.textContent=p,document.head.appendChild(e)}m&&(h.innerHTML=m)}if(g){const e=document.createElement("script");e.textContent=g,document.head.appendChild(e),document.head.removeChild(e)}"function"==typeof l&&l({target:h}),s("afterMount",{target:h,selector:r})}(c,n,a,u);const E=Math.round(performance.now()-b);i("totalTime",E),h("done",{time:E,source:S}),s("load",{cdnUrl:t,time:E,source:S,selector:a})}catch(e){m.hide(),i("errors",1),h("error",{message:e.message}),"function"==typeof u.onError&&u.onError(e),console.error("[WebHanger] Load failed:",e.message)}}let v=null,S="./wh-manifest.json";async function W(e="./wh-manifest.json"){S=e;const t=await fetch(e);v=await t.json(),document.querySelectorAll("wh-component[name]").forEach(e=>e._load())}"undefined"!=typeof customElements&&customElements.define("wh-component",class extends HTMLElement{async connectedCallback(){v&&await this._load()}async _load(){const e=this.getAttribute("name"),t=this.hasAttribute("sandbox");if(!e)return void console.error("[WebHanger] <wh-component> missing 'name' attribute");const n={};for(const e of this.attributes)e.name.startsWith("wh-")&&"wh-component"!==e.name&&(n[e.name]=e.value);const r=v||await fetch(this.getAttribute("src")||S).then(e=>e.json()),o=r.components[e];if(!o)return void console.error(`[WebHanger] <wh-component>: "${e}" not found in manifest`);this.setAttribute("data-wh-el",e);const a=`wh-component[data-wh-el="${e}"]`;await b(o.urls||o.url,r.pid,o.token,o.expires,a,null,[],{sandbox:t,props:n})}});const C={supported:!1,adapter:null,device:null},E="wh_last_updated";!async function(){if(!navigator.gpu)return!1;try{const e=await navigator.gpu.requestAdapter();if(!e)return!1;const t=await e.requestDevice();return C.supported=!0,C.adapter=e,C.device=t,s("gpu",{supported:!0,adapter:e}),!0}catch(e){return!1}}(),e.WebHangerFront={load:b,clearCache:async function(){Object.keys(localStorage).filter(e=>e.startsWith(n)).forEach(e=>localStorage.removeItem(e));try{(await u()).transaction(t,"readwrite").objectStore(t).clear()}catch(e){}if("caches"in window){const e=await caches.keys();await Promise.all(e.map(e=>caches.delete(e)))}if("serviceWorker"in navigator){const e=await navigator.serviceWorker.getRegistrations();await Promise.all(e.map(e=>e.unregister()))}Object.keys(sessionStorage).filter(e=>e.startsWith(n)).forEach(e=>sessionStorage.removeItem(e))},use:function(e){"function"==typeof e.install&&e.install({on:a,emit:s}),r.push(e)},on:a,metrics:c,initialize:W,smartInitialize:async function(e,r){e=e||"./wh-manifest.json",r=r||"http://localhost:5000";var o=!1;try{var a=await fetch(r+"/api/last-updated",{signal:AbortSignal.timeout(2e3)}),c=(await a.json()).lastUpdatedAt||0,i=parseInt(localStorage.getItem(E)||"0");c>i?(o=!0,localStorage.setItem(E,c+""),s("cache-invalidated",{reason:"components_updated",serverTs:c,cachedTs:i}),console.log("[WebHanger] Components updated \u2014 refreshing cache")):(s("cache-hit",{reason:"up_to_date",serverTs:c,cachedTs:i}),console.log("[WebHanger] Up to date \u2014 loading from cache"))}catch(e){console.log("[WebHanger] Admin server unreachable \u2014 using cache")}if(o)try{Object.keys(localStorage).filter(function(e){return e.startsWith(n)}).forEach(function(e){localStorage.removeItem(e)}),(await u()).transaction(t,"readwrite").objectStore(t).clear()}catch(e){}await W(e)},registerSW:async function(e="/webhanger.sw.js"){if("serviceWorker"in navigator)try{s("sw",{registered:!0,scope:(await navigator.serviceWorker.register(e)).scope})}catch(e){console.warn("[WebHanger] SW registration failed:",e.message)}},setOfflinePage:async function(e="",t=""){if(!("serviceWorker"in navigator))return;const n=await navigator.serviceWorker.ready;n.active?.postMessage({type:"SET_OFFLINE_PAGE",html:e,css:t})},gpu:C,version:"2.0.0"}}(window);
|
package/index.js
CHANGED
|
@@ -1,25 +1,75 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Frontend SDK — ESM (for bundlers: Next.js, Vite, Webpack, Rollup)
|
|
3
|
+
* Full feature parity with browser.js IIFE build.
|
|
4
|
+
* Tree-shakeable named exports.
|
|
5
|
+
*/
|
|
3
6
|
|
|
4
|
-
const VERSION = "
|
|
5
|
-
const IDB_NAME = "
|
|
7
|
+
const VERSION = "2.0.0";
|
|
8
|
+
const IDB_NAME = "webhanger_v2";
|
|
6
9
|
const IDB_STORE = "components";
|
|
7
|
-
const LS_PREFIX = "
|
|
10
|
+
const LS_PREFIX = "wh2_";
|
|
8
11
|
const SIZE_THRESHOLD = 50 * 1024;
|
|
9
12
|
|
|
10
|
-
// ───
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
// ─── Plugin system ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const _plugins = [];
|
|
16
|
+
const _listeners = {};
|
|
17
|
+
|
|
18
|
+
export function use(plugin) {
|
|
19
|
+
if (typeof plugin.install === "function") plugin.install({ on, emit });
|
|
20
|
+
_plugins.push(plugin);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function on(event, fn) {
|
|
24
|
+
if (!_listeners[event]) _listeners[event] = [];
|
|
25
|
+
_listeners[event].push(fn);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function emit(event, data) {
|
|
29
|
+
(_listeners[event] || []).forEach(fn => fn(data));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Metrics ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const metrics = { loads: 0, cacheHits: 0, errors: 0, totalTime: 0 };
|
|
35
|
+
|
|
36
|
+
function recordMetric(name, value) {
|
|
37
|
+
if (name in metrics) metrics[name] += value;
|
|
38
|
+
emit("metric", { name, value, metrics: { ...metrics } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── AES-256-GCM decrypt ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const _keyCache = new Map();
|
|
44
|
+
|
|
45
|
+
async function deriveKey(projectId, salt) {
|
|
46
|
+
const k = projectId + salt;
|
|
47
|
+
if (_keyCache.has(k)) return _keyCache.get(k);
|
|
48
|
+
const enc = new TextEncoder();
|
|
49
|
+
const hashBuf = await crypto.subtle.digest("SHA-256", enc.encode(k));
|
|
50
|
+
const key = await crypto.subtle.importKey("raw", hashBuf, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
51
|
+
_keyCache.set(k, key);
|
|
52
|
+
return key;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function decryptChunk(encoded, projectId, salt) {
|
|
56
|
+
if (!encoded) return "";
|
|
57
|
+
try {
|
|
58
|
+
const parts = encoded.split(":");
|
|
59
|
+
if (parts.length !== 3) return "";
|
|
60
|
+
const [ivB64, tagB64, dataB64] = parts;
|
|
61
|
+
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
|
|
62
|
+
const tag = Uint8Array.from(atob(tagB64), c => c.charCodeAt(0));
|
|
63
|
+
const data = Uint8Array.from(atob(dataB64), c => c.charCodeAt(0));
|
|
64
|
+
const combined = new Uint8Array(data.length + tag.length);
|
|
65
|
+
combined.set(data); combined.set(tag, data.length);
|
|
66
|
+
const key = await deriveKey(projectId, salt);
|
|
67
|
+
const dec = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, combined);
|
|
68
|
+
return new TextDecoder().decode(dec);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn("[WebHanger] decryptChunk failed:", salt, err.message);
|
|
71
|
+
return "";
|
|
21
72
|
}
|
|
22
|
-
return new TextDecoder().decode(out);
|
|
23
73
|
}
|
|
24
74
|
|
|
25
75
|
// ─── IndexedDB ────────────────────────────────────────────────────────────────
|
|
@@ -27,8 +77,8 @@ function decrypt(b64, projectId, salt) {
|
|
|
27
77
|
function openIDB() {
|
|
28
78
|
return new Promise((resolve, reject) => {
|
|
29
79
|
const req = indexedDB.open(IDB_NAME, 1);
|
|
30
|
-
req.onupgradeneeded =
|
|
31
|
-
req.onsuccess =
|
|
80
|
+
req.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
|
81
|
+
req.onsuccess = e => resolve(e.target.result);
|
|
32
82
|
req.onerror = () => reject(req.error);
|
|
33
83
|
});
|
|
34
84
|
}
|
|
@@ -36,8 +86,7 @@ function openIDB() {
|
|
|
36
86
|
async function idbGet(key) {
|
|
37
87
|
const db = await openIDB();
|
|
38
88
|
return new Promise((resolve, reject) => {
|
|
39
|
-
const
|
|
40
|
-
const req = tx.objectStore(IDB_STORE).get(key);
|
|
89
|
+
const req = db.transaction(IDB_STORE, "readonly").objectStore(IDB_STORE).get(key);
|
|
41
90
|
req.onsuccess = () => resolve(req.result);
|
|
42
91
|
req.onerror = () => reject(req.error);
|
|
43
92
|
});
|
|
@@ -46,179 +95,312 @@ async function idbGet(key) {
|
|
|
46
95
|
async function idbSet(key, value) {
|
|
47
96
|
const db = await openIDB();
|
|
48
97
|
return new Promise((resolve, reject) => {
|
|
49
|
-
const
|
|
50
|
-
const req = tx.objectStore(IDB_STORE).put(value, key);
|
|
98
|
+
const req = db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).put(value, key);
|
|
51
99
|
req.onsuccess = () => resolve();
|
|
52
100
|
req.onerror = () => reject(req.error);
|
|
53
101
|
});
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
// ─── Cache
|
|
104
|
+
// ─── Cache (stale-while-revalidate) ──────────────────────────────────────────
|
|
57
105
|
|
|
58
106
|
async function cacheGet(key) {
|
|
59
|
-
try {
|
|
60
|
-
const ls = localStorage.getItem(LS_PREFIX + key);
|
|
61
|
-
if (ls) return ls;
|
|
62
|
-
} catch (_) {}
|
|
107
|
+
try { const v = localStorage.getItem(LS_PREFIX + key); if (v) return v; } catch (_) {}
|
|
63
108
|
return await idbGet(key);
|
|
64
109
|
}
|
|
65
110
|
|
|
66
111
|
async function cacheSet(key, value) {
|
|
67
|
-
try {
|
|
68
|
-
if (value.length < SIZE_THRESHOLD) {
|
|
69
|
-
localStorage.setItem(LS_PREFIX + key, value);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
} catch (_) {}
|
|
112
|
+
try { if (value.length < SIZE_THRESHOLD) { localStorage.setItem(LS_PREFIX + key, value); return; } } catch (_) {}
|
|
73
113
|
await idbSet(key, value);
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
|
|
116
|
+
async function cacheGetSWR(key, fetchFn) {
|
|
117
|
+
const cached = await cacheGet(key);
|
|
118
|
+
if (cached) {
|
|
119
|
+
recordMetric("cacheHits", 1);
|
|
120
|
+
fetchFn().then(fresh => { if (fresh && fresh !== cached) cacheSet(key, fresh); }).catch(() => {});
|
|
121
|
+
return { data: cached, source: "cache" };
|
|
122
|
+
}
|
|
123
|
+
const fresh = await fetchFn();
|
|
124
|
+
if (fresh) await cacheSet(key, fresh);
|
|
125
|
+
return { data: fresh, source: "cdn" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Fetch with multi-CDN failover ────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async function fetchComponent(cdnUrls, token, expires) {
|
|
131
|
+
const urls = Array.isArray(cdnUrls) ? cdnUrls : [cdnUrls];
|
|
132
|
+
let lastErr;
|
|
133
|
+
for (const url of urls) {
|
|
134
|
+
try {
|
|
135
|
+
const u = expires ? `${url}?token=${token}&expires=${expires}` : `${url}?token=${token}`;
|
|
136
|
+
const res = await fetch(u);
|
|
137
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
138
|
+
return await res.text();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.warn(`[WebHanger] CDN failed (${url}): ${err.message}`);
|
|
141
|
+
lastErr = err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
throw new Error("All CDN endpoints failed: " + lastErr?.message);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── CDN asset loader ─────────────────────────────────────────────────────────
|
|
77
148
|
|
|
78
149
|
function loadAsset(asset) {
|
|
79
|
-
return new Promise(
|
|
150
|
+
return new Promise(resolve => {
|
|
80
151
|
const existing = asset.type === "style"
|
|
81
152
|
? document.querySelector(`link[href="${asset.url}"]`)
|
|
82
153
|
: document.querySelector(`script[src="${asset.url}"]`);
|
|
83
154
|
if (existing) return resolve();
|
|
84
|
-
|
|
85
155
|
if (asset.type === "style") {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
link.onerror = resolve;
|
|
91
|
-
document.head.appendChild(link);
|
|
156
|
+
const el = document.createElement("link");
|
|
157
|
+
el.rel = "stylesheet"; el.href = asset.url;
|
|
158
|
+
el.onload = resolve; el.onerror = resolve;
|
|
159
|
+
document.head.appendChild(el);
|
|
92
160
|
} else {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
if (asset.defer)
|
|
96
|
-
if (asset.async)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
document.head.appendChild(script);
|
|
161
|
+
const el = document.createElement("script");
|
|
162
|
+
el.src = asset.url;
|
|
163
|
+
if (asset.defer) el.defer = true;
|
|
164
|
+
if (asset.async) el.async = true;
|
|
165
|
+
el.onload = resolve; el.onerror = resolve;
|
|
166
|
+
document.head.appendChild(el);
|
|
100
167
|
}
|
|
101
168
|
});
|
|
102
169
|
}
|
|
103
170
|
|
|
104
171
|
async function loadAssets(assets = []) {
|
|
105
|
-
for (const
|
|
172
|
+
for (const a of assets) await loadAsset(a);
|
|
106
173
|
}
|
|
107
174
|
|
|
108
|
-
// ───
|
|
175
|
+
// ─── Props resolution ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export function resolveProps(schema, attrs) {
|
|
178
|
+
const props = {};
|
|
179
|
+
Object.keys(schema || {}).forEach(key => {
|
|
180
|
+
const def = schema[key];
|
|
181
|
+
props[key] = def.default !== undefined ? def.default : "";
|
|
182
|
+
});
|
|
183
|
+
Object.keys(attrs || {}).forEach(attrKey => {
|
|
184
|
+
const propKey = attrKey.replace(/^wh-/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
185
|
+
let val = attrs[attrKey];
|
|
186
|
+
const schemaEntry = schema && schema[propKey];
|
|
187
|
+
if (schemaEntry && schemaEntry.type === "json") {
|
|
188
|
+
try { val = JSON.parse(val); } catch (_) {}
|
|
189
|
+
}
|
|
190
|
+
props[propKey] = val;
|
|
191
|
+
});
|
|
192
|
+
return props;
|
|
193
|
+
}
|
|
109
194
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
114
|
-
|
|
115
|
-
if (!res.ok) throw new Error(`Failed to fetch component: ${res.status}`);
|
|
116
|
-
return await res.text();
|
|
195
|
+
export function applyProps(str, props) {
|
|
196
|
+
if (!str || !props) return str;
|
|
197
|
+
return str.replace(/\{\{wh\.([^}]+)\}\}/g, (_, key) =>
|
|
198
|
+
props[key] !== undefined ? String(props[key]) : ""
|
|
199
|
+
);
|
|
117
200
|
}
|
|
118
201
|
|
|
119
|
-
// ─── Inject
|
|
202
|
+
// ─── Inject component ─────────────────────────────────────────────────────────
|
|
120
203
|
|
|
121
|
-
function injectComponent(
|
|
122
|
-
const
|
|
204
|
+
async function injectComponent(payload, projectId, selector, options = {}) {
|
|
205
|
+
const { sandbox = false, allowedDomains, beforeMount, afterMount, props: userProps = {} } = options;
|
|
206
|
+
const target = document.querySelector(selector || "[data-wh]");
|
|
123
207
|
if (!target) { console.warn("[WebHanger] No mount target found."); return; }
|
|
124
208
|
|
|
125
|
-
let
|
|
126
|
-
try {
|
|
127
|
-
|
|
209
|
+
let parsed;
|
|
210
|
+
try { parsed = JSON.parse(payload); } catch (_) { console.error("[WebHanger] Invalid payload."); return; }
|
|
211
|
+
|
|
212
|
+
if (allowedDomains?.length) {
|
|
213
|
+
const host = window.location?.hostname || "";
|
|
214
|
+
const ok = allowedDomains.some(d => host === d || host.endsWith("." + d));
|
|
215
|
+
if (!ok) { emit("error", { reason: "domain_restricted" }); return; }
|
|
216
|
+
}
|
|
128
217
|
|
|
129
|
-
const css
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
218
|
+
const css = await decryptChunk(parsed.c, projectId, "::css");
|
|
219
|
+
let html = await decryptChunk(parsed.h, projectId, "::html");
|
|
220
|
+
let js = await decryptChunk(parsed.j, projectId, "::js");
|
|
221
|
+
|
|
222
|
+
if (parsed.integrity && (html || js)) {
|
|
223
|
+
const enc = new TextEncoder();
|
|
224
|
+
const buf = await crypto.subtle.digest("SHA-256", enc.encode(html + css + js));
|
|
225
|
+
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
226
|
+
if (hash !== parsed.integrity) {
|
|
227
|
+
console.error("[WebHanger] Integrity check failed.");
|
|
228
|
+
emit("error", { reason: "integrity_failed" });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
134
231
|
}
|
|
135
232
|
|
|
136
|
-
|
|
137
|
-
|
|
233
|
+
// Resolve props and apply {{wh.x}} placeholders
|
|
234
|
+
const resolvedProps = resolveProps(parsed.props, userProps);
|
|
235
|
+
html = applyProps(html, resolvedProps);
|
|
236
|
+
js = applyProps(js, resolvedProps);
|
|
237
|
+
|
|
238
|
+
if (typeof beforeMount === "function") beforeMount({ target, html, css });
|
|
239
|
+
emit("beforeMount", { target, selector });
|
|
240
|
+
|
|
241
|
+
if (sandbox) {
|
|
242
|
+
const shadow = target.attachShadow({ mode: "closed" });
|
|
243
|
+
if (css) { const s = document.createElement("style"); s.textContent = css; shadow.appendChild(s); }
|
|
244
|
+
if (html) { const d = document.createElement("div"); d.innerHTML = html; shadow.appendChild(d); }
|
|
245
|
+
} else {
|
|
246
|
+
if (css) { const s = document.createElement("style"); s.textContent = css; document.head.appendChild(s); }
|
|
247
|
+
if (html) target.innerHTML = html;
|
|
248
|
+
}
|
|
138
249
|
|
|
139
|
-
const js = decrypt(payload.j, projectId, "::js");
|
|
140
250
|
if (js) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
document.head.appendChild(
|
|
144
|
-
document.head.removeChild(
|
|
251
|
+
const s = document.createElement("script");
|
|
252
|
+
s.textContent = js;
|
|
253
|
+
document.head.appendChild(s);
|
|
254
|
+
document.head.removeChild(s);
|
|
145
255
|
}
|
|
256
|
+
|
|
257
|
+
if (typeof afterMount === "function") afterMount({ target });
|
|
258
|
+
emit("afterMount", { target, selector });
|
|
146
259
|
}
|
|
147
260
|
|
|
148
|
-
// ───
|
|
261
|
+
// ─── Main load ────────────────────────────────────────────────────────────────
|
|
149
262
|
|
|
150
|
-
|
|
151
|
-
* Load a WebHanger component into the DOM.
|
|
152
|
-
*
|
|
153
|
-
* @param {string} cdnUrl - Full CDN URL of the component
|
|
154
|
-
* @param {string} projectId - Your WebHanger project ID (decrypt key)
|
|
155
|
-
* @param {string} token - HMAC signed token
|
|
156
|
-
* @param {number} expires - Unix timestamp expiry (0 = never)
|
|
157
|
-
* @param {string} [selector] - CSS selector for mount target (default: [data-wh])
|
|
158
|
-
*/
|
|
159
|
-
export async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]") {
|
|
263
|
+
export async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]", onSignal = null, deps = [], options = {}) {
|
|
160
264
|
if (!cdnUrl || !projectId || !token || expires === undefined || expires === null) {
|
|
161
|
-
console.error("[WebHanger] Missing required params
|
|
162
|
-
return;
|
|
265
|
+
console.error("[WebHanger] Missing required params"); return;
|
|
163
266
|
}
|
|
164
|
-
|
|
165
267
|
if (expires !== 0 && Math.floor(Date.now() / 1000) > expires) {
|
|
166
|
-
console.warn("[WebHanger] Token expired.");
|
|
167
|
-
return;
|
|
268
|
+
console.warn("[WebHanger] Token expired."); return;
|
|
168
269
|
}
|
|
169
270
|
|
|
170
|
-
const
|
|
271
|
+
const signal = (stage, detail = {}) => {
|
|
272
|
+
if (typeof onSignal === "function") onSignal({ stage, ...detail });
|
|
273
|
+
emit(stage, detail);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const preloader = (() => {
|
|
277
|
+
const t = document.querySelector(selector || "[data-wh]");
|
|
278
|
+
if (!t) return { show: () => {}, hide: () => {} };
|
|
279
|
+
const el = document.createElement("div");
|
|
280
|
+
el.setAttribute("data-wh-loader", "");
|
|
281
|
+
el.style.cssText = "display:flex;align-items:center;justify-content:center;padding:24px;width:100%;box-sizing:border-box;";
|
|
282
|
+
el.innerHTML = '<div style="width:28px;height:28px;border:3px solid #e5e7eb;border-top-color:#6366f1;border-radius:50%;animation:wh-spin 0.7s linear infinite;"></div><style>@keyframes wh-spin{to{transform:rotate(360deg);}}</style>';
|
|
283
|
+
return { show() { t.appendChild(el); }, hide() { el.parentNode?.removeChild(el); } };
|
|
284
|
+
})();
|
|
285
|
+
|
|
286
|
+
const start = performance.now();
|
|
287
|
+
signal("start", { cdnUrl, selector });
|
|
288
|
+
preloader.show();
|
|
289
|
+
recordMetric("loads", 1);
|
|
171
290
|
|
|
291
|
+
const cacheKey = `${cdnUrl}@${expires}`;
|
|
172
292
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
payload = await fetchComponent(cdnUrl, token, expires);
|
|
176
|
-
await cacheSet(cacheKey, payload);
|
|
177
|
-
}
|
|
293
|
+
signal("fetching");
|
|
294
|
+
const { data: payload, source } = await cacheGetSWR(cacheKey, () => fetchComponent(cdnUrl, token, expires));
|
|
178
295
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
296
|
+
let parsed;
|
|
297
|
+
try { parsed = JSON.parse(payload); } catch (_) { parsed = {}; }
|
|
298
|
+
|
|
299
|
+
if (parsed.assets?.length) { signal("assets", { count: parsed.assets.length }); await loadAssets(parsed.assets); }
|
|
300
|
+
|
|
301
|
+
const allDeps = [...deps, ...(parsed.dependencies || [])];
|
|
302
|
+
if (allDeps.length) signal("deps", { count: allDeps.length });
|
|
303
|
+
|
|
304
|
+
signal("injecting");
|
|
305
|
+
preloader.hide();
|
|
306
|
+
await injectComponent(payload, projectId, selector, options);
|
|
183
307
|
|
|
184
|
-
|
|
308
|
+
const elapsed = Math.round(performance.now() - start);
|
|
309
|
+
recordMetric("totalTime", elapsed);
|
|
310
|
+
signal("done", { time: elapsed, source });
|
|
311
|
+
emit("load", { cdnUrl, time: elapsed, source, selector });
|
|
185
312
|
} catch (err) {
|
|
313
|
+
preloader.hide();
|
|
314
|
+
recordMetric("errors", 1);
|
|
315
|
+
signal("error", { message: err.message });
|
|
316
|
+
if (typeof options.onError === "function") options.onError(err);
|
|
186
317
|
console.error("[WebHanger] Load failed:", err.message);
|
|
187
318
|
}
|
|
188
319
|
}
|
|
189
320
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
321
|
+
// ─── Initialize (manifest-based) ─────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
let _manifest = null;
|
|
324
|
+
let _manifestUrl = "./wh-manifest.json";
|
|
325
|
+
|
|
326
|
+
export async function initialize(manifestUrl = "./wh-manifest.json") {
|
|
327
|
+
_manifestUrl = manifestUrl;
|
|
328
|
+
const res = await fetch(manifestUrl);
|
|
329
|
+
_manifest = await res.json();
|
|
330
|
+
document.querySelectorAll("wh-component[name]").forEach(el => el._load?.());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Service Worker ───────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export async function registerSW(swUrl = "/webhanger.sw.js") {
|
|
336
|
+
if (!("serviceWorker" in navigator)) return;
|
|
337
|
+
try {
|
|
338
|
+
const reg = await navigator.serviceWorker.register(swUrl);
|
|
339
|
+
emit("sw", { registered: true, scope: reg.scope });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.warn("[WebHanger] SW registration failed:", err.message);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function setOfflinePage(html = "", css = "") {
|
|
346
|
+
if (!("serviceWorker" in navigator)) return;
|
|
347
|
+
const reg = await navigator.serviceWorker.ready;
|
|
348
|
+
reg.active?.postMessage({ type: "SET_OFFLINE_PAGE", html, css });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Hard flush ───────────────────────────────────────────────────────────────
|
|
352
|
+
|
|
193
353
|
export async function clearCache() {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
354
|
+
Object.keys(localStorage).filter(k => k.startsWith(LS_PREFIX)).forEach(k => localStorage.removeItem(k));
|
|
355
|
+
try { (await openIDB()).transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).clear(); } catch (_) {}
|
|
356
|
+
if ("caches" in window) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); }
|
|
357
|
+
if ("serviceWorker" in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister())); }
|
|
358
|
+
Object.keys(sessionStorage).filter(k => k.startsWith(LS_PREFIX)).forEach(k => sessionStorage.removeItem(k));
|
|
359
|
+
}
|
|
198
360
|
|
|
199
|
-
|
|
361
|
+
// ─── WebGPU ───────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
export const gpu = { supported: false, adapter: null, device: null };
|
|
364
|
+
|
|
365
|
+
(async () => {
|
|
366
|
+
if (!("gpu" in navigator)) return;
|
|
200
367
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
368
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
369
|
+
if (!adapter) return;
|
|
370
|
+
const device = await adapter.requestDevice();
|
|
371
|
+
gpu.supported = true; gpu.adapter = adapter; gpu.device = device;
|
|
372
|
+
emit("gpu", { supported: true, adapter });
|
|
204
373
|
} catch (_) {}
|
|
374
|
+
})();
|
|
205
375
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
376
|
+
export async function smartInitialize(manifestUrl = "./wh-manifest.json", adminServerUrl = "http://localhost:5000") {
|
|
377
|
+
const SMART_CACHE_KEY = "wh_last_updated";
|
|
378
|
+
let shouldClearCache = false;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const res = await fetch(`${adminServerUrl}/api/last-updated`, { signal: AbortSignal.timeout(2000) });
|
|
382
|
+
const data = await res.json();
|
|
383
|
+
const serverTs = data.lastUpdatedAt || 0;
|
|
384
|
+
const cachedTs = parseInt(localStorage.getItem(SMART_CACHE_KEY) || "0");
|
|
385
|
+
|
|
386
|
+
if (serverTs > cachedTs) {
|
|
387
|
+
shouldClearCache = true;
|
|
388
|
+
localStorage.setItem(SMART_CACHE_KEY, String(serverTs));
|
|
389
|
+
emit("cache-invalidated", { reason: "components_updated", serverTs, cachedTs });
|
|
390
|
+
} else {
|
|
391
|
+
emit("cache-hit", { reason: "up_to_date", serverTs, cachedTs });
|
|
392
|
+
}
|
|
393
|
+
} catch (_) {}
|
|
211
394
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
395
|
+
if (shouldClearCache) {
|
|
396
|
+
try {
|
|
397
|
+
Object.keys(localStorage).filter(k => k.startsWith(LS_PREFIX)).forEach(k => localStorage.removeItem(k));
|
|
398
|
+
const db = await openIDB();
|
|
399
|
+
db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).clear();
|
|
400
|
+
} catch (_) {}
|
|
216
401
|
}
|
|
217
402
|
|
|
218
|
-
|
|
219
|
-
Object.keys(sessionStorage)
|
|
220
|
-
.filter(k => k.startsWith(LS_PREFIX))
|
|
221
|
-
.forEach(k => sessionStorage.removeItem(k));
|
|
403
|
+
await initialize(manifestUrl);
|
|
222
404
|
}
|
|
223
405
|
|
|
224
|
-
export
|
|
406
|
+
export { VERSION as version };
|