From 54b270bc107e1bf58c9d0ec027ca02bc7cdad529 Mon Sep 17 00:00:00 2001
From: Tiago Rangel <tr@tiagorangel.com>
Date: Sat, 18 Jan 2025 14:36:36 +0000
Subject: [PATCH] A bunch of fixes

---
 demo/index.js           |   1 +
 demo/package.json       |   1 -
 demo/public/index.html  |   3 +-
 docs/guide/invisible.md |   2 +-
 widget/cap.min.js       |   2 +-
 widget/package.json     |   2 +-
 widget/src/cap.js       | 390 ++++++++++++++++++----------------------
 7 files changed, 185 insertions(+), 216 deletions(-)

diff --git a/demo/index.js b/demo/index.js
index 97d0bcc..88e91b8 100644
--- a/demo/index.js
+++ b/demo/index.js
@@ -5,6 +5,7 @@ const app = express();
 
 app.use(express.json());
 app.use(express.static('public'));
+/* app.use("/capsource", express.static('../widget/src/cap.js')); */
 
 const cap = new Cap({
   tokens_store_path: '.data/tokensList.json'
diff --git a/demo/package.json b/demo/package.json
index 19f7cab..ab6ddc0 100644
--- a/demo/package.json
+++ b/demo/package.json
@@ -1,6 +1,5 @@
 {
   "name": "cap-demo",
-  "version": "0.0.1",
   "description": "Cap demo",
   "license": "AGPL-3.0",
   "author": "Tiago Rangel",
diff --git a/demo/public/index.html b/demo/public/index.html
index 897a0d3..64048ff 100644
--- a/demo/public/index.html
+++ b/demo/public/index.html
@@ -24,9 +24,10 @@
     </style>
   </head>
   <body>
-    <cap-widget id="cap" data-cap-api-endpoint="/api/"></cap-widget>
+    <cap-widget id="cap" data-cap-api-endpoint="/api/" data-cap-worker-count="30"></cap-widget>
 
     <a href="https://github.com/tiagorangel1/cap/tree/main/demo" target="_blank" class="source">Source code</a>
   </body>
   <script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
+  <!-- <script src="/capsource"></script> -->
 </html>
diff --git a/docs/guide/invisible.md b/docs/guide/invisible.md
index 4a20df4..a2422a3 100644
--- a/docs/guide/invisible.md
+++ b/docs/guide/invisible.md
@@ -17,7 +17,7 @@ console.log(result.token);
 The following methods are supported:
 
 #### `new Cap({ ... })`
-Creates a new Cap instance.
+Creates a new Cap instance. If a 2nd argument is provided, it will use that element instead of creating a new one in memory.
 
 **Arguments**
 ```json
diff --git a/widget/cap.min.js b/widget/cap.min.js
index 7204b53..31f1dbc 100644
--- a/widget/cap.min.js
+++ b/widget/cap.min.js
@@ -1 +1 @@
-"use strict";!function(){let e;const t=(e,t=1e4)=>new Promise(((r,s)=>{const a=setTimeout((()=>{s(new Error("Initialize timeout"))}),t),i=()=>{e()?(clearTimeout(a),r()):setTimeout(i,500)};i()}));class r{#e="";#t=null;#r=null;#s=navigator.hardwareConcurrency||8;#a=null;async initialize(){this.#e&&URL.revokeObjectURL(this.#e);try{await t((()=>!!e)),this.#e=URL.createObjectURL(new Blob([e],{type:"application/javascript"}))}catch(e){throw this.error("Failed to initialize worker"),e}}async solve(){await t((()=>!!this.#e)),this.dispatchEvent("progress",{progress:0});try{const e=this.#t.getAttribute("data-cap-api-endpoint");if(!e)throw new Error("Missing API endpoint");const{challenge:t,target:r,token:s}=await(await fetch(`${e}challenge`,{method:"POST"})).json(),a=await this.solveChallenges({challenge:t,target:r,token:s}),i=await(await fetch(`${e}redeem`,{method:"POST",body:JSON.stringify({token:s,solutions:a}),headers:{"Content-Type":"application/json"}})).json();if(!i.success)throw new Error("Invalid solution");this.dispatchEvent("progress",{progress:100}),this.dispatchEvent("solve",{token:i.token}),this.#a=i.token,this.#t.querySelector("input[name='cap-token']")&&(this.#t.querySelector("input[name='cap-token']").value=i.token),this.#r&&clearTimeout(this.#r);const n=new Date(i.expires).getTime()-Date.now();return n>0&&n<864e5?this.#r=setTimeout((()=>this.reset()),n):this.error("Invalid expiration time"),{success:!0,token:this.#a}}catch(e){throw this.error(e.message),e}}async solveChallenges({challenge:e,target:t}){const r=e.length;let s=0;const a=Array(this.#s).fill(null).map((()=>new Worker(this.#e))),i=([e,t],i)=>new Promise(((n,o)=>{const c=a[i],d=setTimeout((()=>{c.terminate(),a[i]=new Worker(this.#e),o(new Error("Worker timeout"))}),3e4);c.onmessage=({data:a})=>{a.found&&(clearTimeout(d),s++,this.dispatchEvent("progress",{progress:Math.round(s/r*100)}),n([e,t,a.nonce]))},c.onerror=e=>{clearTimeout(d),this.error(`Error in worker: ${e}`),o(e)},c.postMessage({salt:e,target:t})})),n=[];try{for(let t=0;t<e.length;t+=this.#s){const r=e.slice(t,Math.min(t+this.#s,e.length)),s=await Promise.all(r.map(((e,t)=>i(e,t))));n.push(...s)}}finally{a.forEach((e=>e.terminate()))}return n}reset(){this.#r&&(clearTimeout(this.#r),this.#r=null),this.dispatchEvent("reset"),this.#a=null,this.#t.querySelector("input[name='cap-token']")&&(this.#t.querySelector("input[name='cap-token']").value="")}error(e="Unknown error"){console.error("[Cap] Error:",e),this.dispatchEvent("error",{isCap:!0,message:e})}dispatchEvent(e,t={}){const r=new CustomEvent(e,{bubbles:!0,composed:!0,detail:t});this.#t.dispatchEvent(r)}setElement(e){this.#t=e}setWorkersCount(e){const t=parseInt(e,10),r=Math.min(navigator.hardwareConcurrency||8,16);this.#s=!isNaN(t)&&t>0&&t<=r?t:navigator.hardwareConcurrency||8}getToken(){return this.#a}cleanup(){this.#r&&(clearTimeout(this.#r),this.#r=null),this.#e&&(URL.revokeObjectURL(this.#e),this.#e="")}}class s extends HTMLElement{#i=new r;#n;#o;#c;#d=!1;eventHandlers;static get observedAttributes(){return["onsolve","onprogress","onreset","onerror","workers"]}constructor(){super(),this.eventHandlers&&this.eventHandlers.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.eventHandlers=new Map,this.boundHandleProgress=this.handleProgress.bind(this),this.boundHandleSolve=this.handleSolve.bind(this),this.boundHandleError=this.handleError.bind(this),this.boundHandleReset=this.handleReset.bind(this)}attributeChangedCallback(e,t,r){if(e.startsWith("on")){const t=e.slice(2),s=this.eventHandlers.get(e);if(s&&this.removeEventListener(t,s),r){const r=t=>{const r=this.getAttribute(e);"function"==typeof window[r]&&window[r].call(this,t)};this.eventHandlers.set(e,r),this.addEventListener(t,r)}}}async connectedCallback(){this.#c=this,this.#n=this.attachShadow({mode:"open"}),this.#i.setElement(this),this.#o=document.createElement("div"),this.createUI(),this.addEventListeners(),await this.#i.initialize(),this.#o.removeAttribute("disabled");const e=this.getAttribute("data-cap-worker-count");this.#i.setWorkersCount(parseInt(e)?parseInt(e,10):navigator.hardwareConcurrency||8),this.#c.innerHTML='<input type="hidden" name="cap-token">'}createUI(){this.#o.classList.add("captcha"),this.#o.setAttribute("role","button"),this.#o.setAttribute("tabindex","0"),this.#o.setAttribute("disabled","true"),this.#o.innerHTML='<div class="checkbox"></div><p>I\'m a human</p><a href="#" class="credits" target="_blank"><span>Secured by&nbsp;</span>Cap</a>',this.#n.innerHTML='<style>.captcha{background-color:var(--cap-background);border:1px solid var(--cap-border-color);border-radius:var(--cap-border-radius);width:var(--cap-widget-width);display:flex;align-items:center;padding:var(--cap-widget-padding);gap:var(--cap-gap);cursor:pointer;transition:filter var(--cap-transition-duration),transform var(--cap-transition-duration);position:relative;-webkit-tap-highlight-color:rgba(255,255,255,0);overflow:hidden;color:var(--cap-color)}.captcha:hover{filter:var(--cap-hover-filter)}.captcha:not([disabled]):active{transform:scale(var(--cap-active-scale))}.checkbox{width:var(--cap-checkbox-size);height:var(--cap-checkbox-size);border:var(--cap-checkbox-border);border-radius:var(--cap-checkbox-border-radius);background-color:var(--cap-checkbox-background);transition:opacity var(--cap-transition-duration);margin-top:var(--cap-checkbox-margin);margin-bottom:var(--cap-checkbox-margin)}.captcha *{font-family:var(--cap-font)}.captcha p{margin:0;font-weight:500;font-size:15px;user-select:none;transition:opacity var(--cap-transition-duration)}.captcha[data-state=verifying] .checkbox{background: none;display:flex;align-items:center;justify-content:center;transform: scale(1.1);border: none;border-radius: 50%;background: conic-gradient(var(--cap-spinner-color) 0%, var(--cap-spinner-color) var(--progress, 0%), var(--cap-spinner-background-color) var(--progress, 0%), var(--cap-spinner-background-color) 100%);position: relative;}.captcha[data-state=verifying] .checkbox::after {content: "";background-color: var(--cap-background);width: calc(100% - var(--cap-spinner-thickness));height: calc(100% - var(--cap-spinner-thickness));border-radius: 50%;margin:calc(var(--cap-spinner-thickness) / 2)}.captcha[data-state=done] .checkbox{border:1px solid transparent;background-image:var(--cap-checkmark);background-size:cover}.captcha[data-state=error] .checkbox{border:1px solid transparent;background-image:var(--cap-error-cross);background-size:cover}.captcha[disabled]{\ncursor:not-allowed}.captcha[disabled][data-state=verifying]{cursor:progress}.captcha[disabled][data-state=done]{cursor:default}.captcha .credits{position:absolute;bottom:10px;right:10px;font-size:var(--cap-credits-font-size);color:var(--cap-color);opacity:var(--cap-opacity-hover)}.captcha .credits span{display:none;text-decoration:underline}.captcha .credits:hover span{display:inline-block}</style>',this.#n.appendChild(this.#o)}addEventListeners(){this.#o.querySelector("a").addEventListener("click",(e=>{e.stopPropagation(),e.preventDefault(),window.open("#","_blank")})),this.#o.addEventListener("click",(()=>{this.#o.hasAttribute("disabled")||this.solve()})),this.addEventListener("progress",this.boundHandleProgress),this.addEventListener("solve",this.boundHandleSolve),this.addEventListener("error",this.boundHandleError),this.addEventListener("reset",this.boundHandleReset)}async solve(){if(!this.#d)try{this.#d=!0,this.updateUI("verifying","Verifying...",!0);return await this.#i.solve()}finally{this.#d=!1}}updateUI(e,t,r=!1){this.#o.setAttribute("data-state",e),this.#o.querySelector("p").innerText=t,r?this.#o.setAttribute("disabled","true"):this.#o.removeAttribute("disabled")}handleProgress(e){const t=this.#o.querySelector("p");t&&(this.#o.querySelector(".checkbox").style.setProperty("--progress",`${e.detail.progress}%`),t.innerText=`Verifying... ${e.detail.progress}%`),this.executeAttributeCode("onprogress",e)}handleSolve(e){this.updateUI("done","You're a human",!0),this.executeAttributeCode("onsolve",e)}handleError(e){this.updateUI("error","Error. Try again."),this.executeAttributeCode("onerror",e)}handleReset(e){this.updateUI("","I'm a human"),this.executeAttributeCode("onreset",e)}executeAttributeCode(e,t){const r=this.getAttribute(e);if(!r)return;new Function("event",r).call(this,t)}reset(){this.#i.reset()}get token(){return this.#i.getToken()}disconnectedCallback(){this.removeEventListener("progress",this.boundHandleProgress),this.removeEventListener("solve",this.boundHandleSolve),this.removeEventListener("error",this.boundHandleError),this.removeEventListener("reset",this.boundHandleReset),this.eventHandlers.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.eventHandlers.clear(),this.#n&&(this.#n.innerHTML=""),this.#i.reset(),this.#i.cleanup()}}class a{constructor(e,t={}){let s=new r,a=e||document.createElement("div");if(e||(a.style.display="none"),Object.entries(t).forEach((([e,t])=>{a.setAttribute(e,t)})),!t.apiEndpoint)throw a.remove(),new Error("Missing API endpoint");a.setAttribute("data-cap-api-endpoint",t.apiEndpoint),s.setElement(a),s.setWorkersCount(t.workers||navigator.hardwareConcurrency||8),s.initialize(),this.solve=async function(){return await s.solve()},this.reset=function(){s.reset()},this.addEventListener=function(e,t){a.addEventListener(e,t)},Object.defineProperty(this,"token",{get:()=>s.getToken(),configurable:!0,enumerable:!0})}}const i=new CSSStyleSheet;i.replaceSync('html{--cap-font:system,-apple-system,"BlinkMacSystemFont",".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande","Ubuntu","arial",sans-serif;--cap-color:#212121;--cap-background:#fdfdfd;--cap-border-color:#dddddd8f;--cap-border-radius:14px;--cap-checkbox-border:1px solid #aaaaaad1;--cap-checkbox-border-radius:6px;--cap-checkbox-background:#fafafa91;--cap-widget-width:240px;--cap-widget-padding:14px;--cap-checkbox-size:24px;--cap-checkbox-margin:2px;--cap-transition-duration:0.2s;--cap-gap:15px;--cap-opacity-hover:0.8;--cap-hover-filter:brightness(97%);--cap-active-scale:0.98;--cap-credits-font-size:12px;--cap-spinner-color:black;--cap-spinner-background-color:#eee;--cap-error-cross:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'96\' height=\'96\' viewBox=\'0 0 24 24\'%3E%3Cpath fill=\'%23f55b50\' d=\'M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8\'/%3E%3C/svg%3E");--cap-checkmark:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cstyle%3E%40keyframes%20anim%7B0%25%7Bstroke-dashoffset%3A23.21320343017578px%7Dto%7Bstroke-dashoffset%3A0%7D%7D%3C%2Fstyle%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%2300a67d%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m5%2012%205%205L20%207%22%20style%3D%22stroke-dashoffset%3A0%3Bstroke-dasharray%3A23.21320343017578px%3Banimation%3Aanim%20.5s%20ease%22%2F%3E%3C%2Fsvg%3E");--cap-spinner-thickness:5px;}'),document.adoptedStyleSheets.push(i);const n=function(){let e;self.onmessage=async({data:{salt:t,target:r}})=>{e||(e=await hashwasm.createSHA256());let s=0;let a=0;const i=new Uint8Array(128),n=new TextEncoder;for(;;)try{for(let a=0;a<5e4;a++){const a=t+s.toString(),o=n.encode(a);i.set(o),e.init(),e.update(i.subarray(0,o.length));if(e.digest("hex").startsWith(r))return void self.postMessage({nonce:s,found:!0});s++}a+=5e4,a>=5e5&&(self.postMessage({nonce:s,found:!1}),a=0)}catch(e){return void self.postMessage({found:!1,error:e.message})}}};setTimeout((async function(){e=await(await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()+n.toString().replace(/^function\s*\([^\)]*\)\s*{|\}$/g,"").trim()}),1),window.Cap=a,customElements.get("cap-widget")?console.warn("The cap-widget element has already been defined. Skipping re-defining it."):customElements.define("cap-widget",s),"object"==typeof exports&&"undefined"!=typeof module?module.exports=a:"function"==typeof define&&define.amd&&define([],(function(){return a})),"undefined"!=typeof exports&&(exports.default=a)}();
\ No newline at end of file
+"use strict";!function(){let e;const t=(e,t=1e4)=>new Promise(((r,s)=>{const a=setTimeout((()=>{s(new Error("Initialize timeout"))}),t),i=()=>{e()?(clearTimeout(a),r()):setTimeout(i,500)};i()}));class r extends HTMLElement{#e="";#t=null;#r=navigator.hardwareConcurrency||8;#s=null;#a;#i;#n;#o=!1;#c;static get observedAttributes(){return["onsolve","onprogress","onreset","onerror","workers"]}constructor(){super(),this.#c&&this.#c.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.#c=new Map,this.boundHandleProgress=this.handleProgress.bind(this),this.boundHandleSolve=this.handleSolve.bind(this),this.boundHandleError=this.handleError.bind(this),this.boundHandleReset=this.handleReset.bind(this)}async initialize(){this.#e&&URL.revokeObjectURL(this.#e);try{await t((()=>!!e)),this.#e=URL.createObjectURL(new Blob([e],{type:"application/javascript"}))}catch(e){throw this.error("Failed to initialize worker"),e}}attributeChangedCallback(e,t,r){if(e.startsWith("on")){const t=e.slice(2),s=this.#c.get(e);if(s&&this.removeEventListener(t,s),r){const r=t=>{const r=this.getAttribute(e);"function"==typeof window[r]&&window[r].call(this,t)};this.#c.set(e,r),this.addEventListener(t,r)}}}async connectedCallback(){this.#n=this,this.#a=this.attachShadow({mode:"open"}),this.#i=document.createElement("div"),this.createUI(),this.addEventListeners(),await this.initialize(),this.#i.removeAttribute("disabled");const e=this.getAttribute("data-cap-worker-count");this.setWorkersCount(parseInt(e)?parseInt(e,10):navigator.hardwareConcurrency||8),this.#n.innerHTML='<input type="hidden" name="cap-token">'}async solve(){if(!this.#o)try{this.#o=!0,this.updateUI("verifying","Verifying...",!0),await t((()=>!!this.#e)),this.dispatchEvent("progress",{progress:0});try{const e=this.getAttribute("data-cap-api-endpoint");if(!e)throw new Error("Missing API endpoint");const{challenge:t,target:r,token:s}=await(await fetch(`${e}challenge`,{method:"POST"})).json(),a=await this.solveChallenges({challenge:t,target:r,token:s}),i=await(await fetch(`${e}redeem`,{method:"POST",body:JSON.stringify({token:s,solutions:a}),headers:{"Content-Type":"application/json"}})).json();if(!i.success)throw new Error("Invalid solution");this.dispatchEvent("progress",{progress:100}),this.dispatchEvent("solve",{token:i.token}),this.#s=i.token,this.querySelector("input[name='cap-token']")&&(this.querySelector("input[name='cap-token']").value=i.token),this.#t&&clearTimeout(this.#t);const n=new Date(i.expires).getTime()-Date.now();return n>0&&n<864e5?this.#t=setTimeout((()=>this.reset()),n):this.error("Invalid expiration time"),{success:!0,token:this.#s}}catch(e){throw this.error(e.message),e}}finally{this.#o=!1}}async solveChallenges({challenge:e,target:t}){const r=e.length;let s=0;const a=Array(this.#r).fill(null).map((()=>new Worker(this.#e))),i=([e,t],i)=>new Promise(((n,o)=>{const c=a[i],d=setTimeout((()=>{c.terminate(),a[i]=new Worker(this.#e),o(new Error("Worker timeout"))}),3e4);c.onmessage=({data:a})=>{a.found&&(clearTimeout(d),s++,this.dispatchEvent("progress",{progress:Math.round(s/r*100)}),n([e,t,a.nonce]))},c.onerror=e=>{clearTimeout(d),this.error(`Error in worker: ${e}`),o(e)},c.postMessage({salt:e,target:t})})),n=[];try{for(let t=0;t<e.length;t+=this.#r){const r=e.slice(t,Math.min(t+this.#r,e.length)),s=await Promise.all(r.map(((e,t)=>i(e,t))));n.push(...s)}}finally{a.forEach((e=>e.terminate()))}return n}setWorkersCount(e){const t=parseInt(e,10),r=Math.min(navigator.hardwareConcurrency||8,16);this.#r=!isNaN(t)&&t>0&&t<=r?t:navigator.hardwareConcurrency||8}createUI(){this.#i.classList.add("captcha"),this.#i.setAttribute("role","button"),this.#i.setAttribute("tabindex","0"),this.#i.setAttribute("disabled","true"),this.#i.innerHTML='<div class="checkbox"></div><p>I\'m a human</p><a href="#" class="credits" target="_blank"><span>Secured by&nbsp;</span>Cap</a>',this.#a.innerHTML='<style>.captcha{background-color:var(--cap-background);border:1px solid var(--cap-border-color);border-radius:var(--cap-border-radius);width:var(--cap-widget-width);display:flex;align-items:center;padding:var(--cap-widget-padding);gap:var(--cap-gap);cursor:pointer;transition:filter var(--cap-transition-duration),transform var(--cap-transition-duration);position:relative;-webkit-tap-highlight-color:rgba(255,255,255,0);overflow:hidden;color:var(--cap-color)}.captcha:hover{filter:var(--cap-hover-filter)}.captcha:not([disabled]):active{transform:scale(var(--cap-active-scale))}.checkbox{width:var(--cap-checkbox-size);height:var(--cap-checkbox-size);border:var(--cap-checkbox-border);border-radius:var(--cap-checkbox-border-radius);background-color:var(--cap-checkbox-background);transition:opacity var(--cap-transition-duration);margin-top:var(--cap-checkbox-margin);margin-bottom:var(--cap-checkbox-margin)}.captcha *{font-family:var(--cap-font)}.captcha p{margin:0;font-weight:500;font-size:15px;user-select:none;transition:opacity var(--cap-transition-duration)}.captcha[data-state=verifying] .checkbox{background: none;display:flex;align-items:center;justify-content:center;transform: scale(1.1);border: none;border-radius: 50%;background: conic-gradient(var(--cap-spinner-color) 0%, var(--cap-spinner-color) var(--progress, 0%), var(--cap-spinner-background-color) var(--progress, 0%), var(--cap-spinner-background-color) 100%);position: relative;}.captcha[data-state=verifying] .checkbox::after {content: "";background-color: var(--cap-background);width: calc(100% - var(--cap-spinner-thickness));height: calc(100% - var(--cap-spinner-thickness));border-radius: 50%;margin:calc(var(--cap-spinner-thickness) / 2)}.captcha[data-state=done] .checkbox{border:1px solid transparent;background-image:var(--cap-checkmark);background-size:cover}.captcha[data-state=error] .checkbox{border:1px solid transparent;background-image:var(--cap-error-cross);background-size:cover}.captcha[disabled]{cursor:not-allowed}.captcha[disabled][data-state=verifying]{cursor:progress}.captcha[disabled][data-state=done]{cursor:default}.captcha .credits{position:absolute;bottom:10px;right:10px;font-size:var(--cap-credits-font-size);color:var(--cap-color);opacity:var(--cap-opacity-hover)}.captcha .credits span{display:none;text-decoration:underline}.captcha .credits:hover span{display:inline-block}</style>',this.#a.appendChild(this.#i)}addEventListeners(){this.#i.querySelector("a").addEventListener("click",(e=>{e.stopPropagation(),e.preventDefault(),window.open("#","_blank")})),this.#i.addEventListener("click",(()=>{this.#i.hasAttribute("disabled")||this.solve()})),this.addEventListener("progress",this.boundHandleProgress),this.addEventListener("solve",this.boundHandleSolve),this.addEventListener("error",this.boundHandleError),this.addEventListener("reset",this.boundHandleReset)}updateUI(e,t,r=!1){this.#i.setAttribute("data-state",e),this.#i.querySelector("p").innerText=t,r?this.#i.setAttribute("disabled","true"):this.#i.removeAttribute("disabled")}handleProgress(e){const t=this.#i.querySelector("p");t&&(this.#i.querySelector(".checkbox").style.setProperty("--progress",`${e.detail.progress}%`),t.innerText=`Verifying... ${e.detail.progress}%`),this.executeAttributeCode("onprogress",e)}handleSolve(e){this.updateUI("done","You're a human",!0),this.executeAttributeCode("onsolve",e)}handleError(e){this.updateUI("error","Error. Try again."),this.executeAttributeCode("onerror",e)}handleReset(e){this.updateUI("","I'm a human"),this.executeAttributeCode("onreset",e)}executeAttributeCode(e,t){const r=this.getAttribute(e);if(!r)return;new Function("event",r).call(this,t)}error(e="Unknown error"){console.error("[Cap] Error:",e),this.dispatchEvent("error",{isCap:!0,message:e})}dispatchEvent(e,t={}){const r=new CustomEvent(e,{bubbles:!0,composed:!0,detail:t});super.dispatchEvent(r)}reset(){this.#t&&(clearTimeout(this.#t),this.#t=null),this.dispatchEvent("reset"),this.#s=null,this.querySelector("input[name='cap-token']")&&(this.querySelector("input[name='cap-token']").value="")}get token(){return this.#s}disconnectedCallback(){this.removeEventListener("progress",this.boundHandleProgress),this.removeEventListener("solve",this.boundHandleSolve),this.removeEventListener("error",this.boundHandleError),this.removeEventListener("reset",this.boundHandleReset),this.#c.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.#c.clear(),this.#a&&(this.#a.innerHTML=""),this.reset(),this.cleanup()}cleanup(){this.#t&&(clearTimeout(this.#t),this.#t=null),this.#e&&(URL.revokeObjectURL(this.#e),this.#e="")}}class s{constructor(e={},t){let r=t||document.createElement("cap-widget");if(Object.entries(e).forEach((([e,t])=>{r.setAttribute(e,t)})),!e.apiEndpoint)throw r.remove(),new Error("Missing API endpoint");console.log(e),r.setAttribute("data-cap-api-endpoint",e.apiEndpoint),this.widget=r,this.solve=r.solve,this.reset=r.reset,this.addEventListener=r.addEventListener,Object.defineProperty(this,"token",{get:()=>r.getToken(),configurable:!0,enumerable:!0})}}const a=new CSSStyleSheet;a.replaceSync('html{--cap-font:system,-apple-system,"BlinkMacSystemFont",".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande","Ubuntu","arial",sans-serif;--cap-color:#212121;--cap-background:#fdfdfd;--cap-border-color:#dddddd8f;--cap-border-radius:14px;--cap-checkbox-border:1px solid #aaaaaad1;--cap-checkbox-border-radius:6px;--cap-checkbox-background:#fafafa91;--cap-widget-width:240px;--cap-widget-padding:14px;--cap-checkbox-size:24px;--cap-checkbox-margin:2px;--cap-transition-duration:0.2s;--cap-gap:15px;--cap-opacity-hover:0.8;--cap-hover-filter:brightness(97%);--cap-active-scale:0.98;--cap-credits-font-size:12px;--cap-spinner-color:black;--cap-spinner-background-color:#eee;--cap-error-cross:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'96\' height=\'96\' viewBox=\'0 0 24 24\'%3E%3Cpath fill=\'%23f55b50\' d=\'M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8\'/%3E%3C/svg%3E");--cap-checkmark:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cstyle%3E%40keyframes%20anim%7B0%25%7Bstroke-dashoffset%3A23.21320343017578px%7Dto%7Bstroke-dashoffset%3A0%7D%7D%3C%2Fstyle%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%2300a67d%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m5%2012%205%205L20%207%22%20style%3D%22stroke-dashoffset%3A0%3Bstroke-dasharray%3A23.21320343017578px%3Banimation%3Aanim%20.5s%20ease%22%2F%3E%3C%2Fsvg%3E");--cap-spinner-thickness:5px;}'),document.adoptedStyleSheets.push(a);const i=function(){let e;self.onmessage=async({data:{salt:t,target:r}})=>{e||(e=await hashwasm.createSHA256());let s=0;let a=0;const i=new Uint8Array(128),n=new TextEncoder;for(;;)try{for(let a=0;a<5e4;a++){const a=t+s.toString(),o=n.encode(a);i.set(o),e.init(),e.update(i.subarray(0,o.length));if(e.digest("hex").startsWith(r))return void self.postMessage({nonce:s,found:!0});s++}a+=5e4,a>=5e5&&(self.postMessage({nonce:s,found:!1}),a=0)}catch(e){return void self.postMessage({found:!1,error:e.message})}}};setTimeout((async function(){e=await(await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()+i.toString().replace(/^function\s*\([^\)]*\)\s*{|\}$/g,"").trim()}),1),window.Cap=s,customElements.get("cap-widget")?console.warn("The cap-widget element has already been defined. Skipping re-defining it."):customElements.define("cap-widget",r),"object"==typeof exports&&"undefined"!=typeof module?module.exports=s:"function"==typeof define&&define.amd&&define([],(function(){return s})),"undefined"!=typeof exports&&(exports.default=s)}();
\ No newline at end of file
diff --git a/widget/package.json b/widget/package.json
index 0898619..9074515 100644
--- a/widget/package.json
+++ b/widget/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@cap.js/widget",
-  "version": "0.0.9",
+  "version": "0.0.10",
   "description": "Cap widget",
   "keywords": [
     "captcha",
diff --git a/widget/src/cap.js b/widget/src/cap.js
index 8d3b0dc..3371726 100644
--- a/widget/src/cap.js
+++ b/widget/src/cap.js
@@ -19,12 +19,35 @@
     });
   };
 
-  class CapBase {
+  class CapWidget extends HTMLElement {
     #workerUrl = "";
-    #el = null;
     #resetTimer = null;
     #workersCount = navigator.hardwareConcurrency || 8;
     #token = null;
+    #shadow;
+    #div;
+    #host;
+    #solving = false;
+    #eventHandlers;
+
+    static get observedAttributes() {
+      return ["onsolve", "onprogress", "onreset", "onerror", "workers"];
+    }
+
+    constructor() {
+      super();
+      if (this.#eventHandlers) {
+        this.#eventHandlers.forEach((handler, eventName) => {
+          this.removeEventListener(eventName.slice(2), handler);
+        });
+      }
+
+      this.#eventHandlers = new Map();
+      this.boundHandleProgress = this.handleProgress.bind(this);
+      this.boundHandleSolve = this.handleSolve.bind(this);
+      this.boundHandleError = this.handleError.bind(this);
+      this.boundHandleReset = this.handleReset.bind(this);
+    }
 
     async initialize() {
       if (this.#workerUrl) {
@@ -44,60 +67,104 @@
       }
     }
 
+    attributeChangedCallback(name, oldValue, newValue) {
+      if (name.startsWith("on")) {
+        const eventName = name.slice(2);
+        const oldHandler = this.#eventHandlers.get(name);
+        if (oldHandler) {
+          this.removeEventListener(eventName, oldHandler);
+        }
+
+        if (newValue) {
+          const handler = (event) => {
+            const callback = this.getAttribute(name);
+            if (typeof window[callback] === "function") {
+              window[callback].call(this, event);
+            }
+          };
+          this.#eventHandlers.set(name, handler);
+          this.addEventListener(eventName, handler);
+        }
+      }
+    }
+
+    async connectedCallback() {
+      this.#host = this;
+      this.#shadow = this.attachShadow({ mode: "open" });
+      this.#div = document.createElement("div");
+      this.createUI();
+      this.addEventListeners();
+      await this.initialize();
+      this.#div.removeAttribute("disabled");
+
+      const workers = this.getAttribute("data-cap-worker-count");
+      this.setWorkersCount(
+        parseInt(workers)
+          ? parseInt(workers, 10)
+          : navigator.hardwareConcurrency || 8
+      );
+      this.#host.innerHTML = `<input type="hidden" name="cap-token">`;
+    }
+
     async solve() {
-      await until(() => !!this.#workerUrl);
-      this.dispatchEvent("progress", { progress: 0 });
+      if (this.#solving) {
+        return;
+      }
 
       try {
-        const apiEndpoint = this.#el.getAttribute("data-cap-api-endpoint");
-        if (!apiEndpoint) throw new Error("Missing API endpoint");
+        this.#solving = true;
+        this.updateUI("verifying", "Verifying...", true);
 
-        const { challenge, target, token } = await (
-          await fetch(
-            `${apiEndpoint}challenge`,
-            {
-              method: "POST",
-            }
-          )
-        ).json();
-        const solutions = await this.solveChallenges({
-          challenge,
-          target,
-          token,
-        });
+        await until(() => !!this.#workerUrl);
+        this.dispatchEvent("progress", { progress: 0 });
+
+        try {
+          const apiEndpoint = this.getAttribute("data-cap-api-endpoint");
+          if (!apiEndpoint) throw new Error("Missing API endpoint");
 
-        const resp = await (
-          await fetch(
-            `${apiEndpoint}redeem`,
-            {
+          const { challenge, target, token } = await (
+            await fetch(`${apiEndpoint}challenge`, {
+              method: "POST",
+            })
+          ).json();
+          const solutions = await this.solveChallenges({
+            challenge,
+            target,
+            token,
+          });
+
+          const resp = await (
+            await fetch(`${apiEndpoint}redeem`, {
               method: "POST",
               body: JSON.stringify({ token, solutions }),
               headers: { "Content-Type": "application/json" },
-            }
-          )
-        ).json();
+            })
+          ).json();
 
-        if (!resp.success) throw new Error("Invalid solution");
+          if (!resp.success) throw new Error("Invalid solution");
 
-        this.dispatchEvent("progress", { progress: 100 });
-        this.dispatchEvent("solve", { token: resp.token });
-        this.#token = resp.token;
-        if (this.#el.querySelector("input[name='cap-token']")) {
-          this.#el.querySelector("input[name='cap-token']").value = resp.token;
-        }
+          this.dispatchEvent("progress", { progress: 100 });
+          this.dispatchEvent("solve", { token: resp.token });
+          this.#token = resp.token;
+          if (this.querySelector("input[name='cap-token']")) {
+            this.querySelector("input[name='cap-token']").value = resp.token;
+          }
 
-        if (this.#resetTimer) clearTimeout(this.#resetTimer);
-        const expiresIn = new Date(resp.expires).getTime() - Date.now();
-        if (expiresIn > 0 && expiresIn < 24 * 60 * 60 * 1000) {
-          this.#resetTimer = setTimeout(() => this.reset(), expiresIn); // 24h
-        } else {
-          this.error("Invalid expiration time");
-        }
+          if (this.#resetTimer) clearTimeout(this.#resetTimer);
+          const expiresIn = new Date(resp.expires).getTime() - Date.now();
+          if (expiresIn > 0 && expiresIn < 24 * 60 * 60 * 1000) {
+            this.#resetTimer = setTimeout(() => this.reset(), expiresIn);
+          } else {
+            this.error("Invalid expiration time");
+          }
 
-        return { success: true, token: this.#token };
-      } catch (err) {
-        this.error(err.message);
-        throw err;
+          return { success: true, token: this.#token };
+        } catch (err) {
+          this.error(err.message);
+          throw err;
+        }
+      } finally {
+        this.#solving = false;
       }
     }
 
@@ -156,36 +223,6 @@
       return results;
     }
 
-    reset() {
-      if (this.#resetTimer) {
-        clearTimeout(this.#resetTimer);
-        this.#resetTimer = null;
-      }
-      this.dispatchEvent("reset");
-      this.#token = null;
-      if (this.#el.querySelector("input[name='cap-token']")) {
-        this.#el.querySelector("input[name='cap-token']").value = "";
-      }
-    }
-
-    error(message = "Unknown error") {
-      console.error("[Cap] Error:", message);
-      this.dispatchEvent("error", { isCap: true, message });
-    }
-
-    dispatchEvent(eventName, detail = {}) {
-      const event = new CustomEvent(eventName, {
-        bubbles: true,
-        composed: true,
-        detail,
-      });
-      this.#el.dispatchEvent(event);
-    }
-
-    setElement(el) {
-      this.#el = el;
-    }
-
     setWorkersCount(workers) {
       const parsedWorkers = parseInt(workers, 10);
       const maxWorkers = Math.min(navigator.hardwareConcurrency || 8, 16);
@@ -197,89 +234,6 @@
           : navigator.hardwareConcurrency || 8;
     }
 
-    getToken() {
-      return this.#token;
-    }
-
-    cleanup() {
-      if (this.#resetTimer) {
-        clearTimeout(this.#resetTimer);
-        this.#resetTimer = null;
-      }
-
-      if (this.#workerUrl) {
-        URL.revokeObjectURL(this.#workerUrl);
-        this.#workerUrl = "";
-      }
-    }
-  }
-
-  class CapWidget extends HTMLElement {
-    #capBase = new CapBase();
-    #shadow;
-    #div;
-    #host;
-    #solving = false;
-    eventHandlers;
-
-    static get observedAttributes() {
-      return ["onsolve", "onprogress", "onreset", "onerror", "workers"];
-    }
-
-    constructor() {
-      super();
-      if (this.eventHandlers) {
-        this.eventHandlers.forEach((handler, eventName) => {
-          this.removeEventListener(eventName.slice(2), handler);
-        });
-      }
-      this.eventHandlers = new Map();
-      this.boundHandleProgress = this.handleProgress.bind(this);
-      this.boundHandleSolve = this.handleSolve.bind(this);
-      this.boundHandleError = this.handleError.bind(this);
-      this.boundHandleReset = this.handleReset.bind(this);
-    }
-
-    attributeChangedCallback(name, oldValue, newValue) {
-      if (name.startsWith("on")) {
-        const eventName = name.slice(2);
-        const oldHandler = this.eventHandlers.get(name);
-        if (oldHandler) {
-          this.removeEventListener(eventName, oldHandler);
-        }
-
-        if (newValue) {
-          const handler = (event) => {
-            const callback = this.getAttribute(name);
-            if (typeof window[callback] === "function") {
-              window[callback].call(this, event);
-            }
-          };
-          this.eventHandlers.set(name, handler);
-          this.addEventListener(eventName, handler);
-        }
-      }
-    }
-
-    async connectedCallback() {
-      this.#host = this;
-      this.#shadow = this.attachShadow({ mode: "open" });
-      this.#capBase.setElement(this);
-      this.#div = document.createElement("div");
-      this.createUI();
-      this.addEventListeners();
-      await this.#capBase.initialize();
-      this.#div.removeAttribute("disabled");
-      const workers = this.getAttribute("data-cap-worker-count");
-      
-      this.#capBase.setWorkersCount(
-        parseInt(workers)
-          ? parseInt(workers, 10)
-          : navigator.hardwareConcurrency || 8
-      );
-      this.#host.innerHTML = `<input type="hidden" name="cap-token">`;
-    }
-
     createUI() {
       this.#div.classList.add("captcha");
       this.#div.setAttribute("role", "button");
@@ -309,29 +263,14 @@
       this.addEventListener("reset", this.boundHandleReset);
     }
 
-    async solve() {
-      if (this.#solving) {
-        return;
-      }
-
-      try {
-        this.#solving = true;
-        this.updateUI("verifying", "Verifying...", true);
-        const result = await this.#capBase.solve();
-        return result;
-      } finally {
-        this.#solving = false;
-      }
-    }
-
     updateUI(state, text, disabled = false) {
-        this.#div.setAttribute("data-state", state);
-        this.#div.querySelector("p").innerText = text;
-        if (disabled) {
-          this.#div.setAttribute("disabled", "true");
-        } else {
-          this.#div.removeAttribute("disabled");
-        }
+      this.#div.setAttribute("data-state", state);
+      this.#div.querySelector("p").innerText = text;
+      if (disabled) {
+        this.#div.setAttribute("disabled", "true");
+      } else {
+        this.#div.removeAttribute("disabled");
+      }
     }
 
     handleProgress(event) {
@@ -369,12 +308,34 @@
       func.call(this, event);
     }
 
+    error(message = "Unknown error") {
+      console.error("[Cap] Error:", message);
+      this.dispatchEvent("error", { isCap: true, message });
+    }
+
+    dispatchEvent(eventName, detail = {}) {
+      const event = new CustomEvent(eventName, {
+        bubbles: true,
+        composed: true,
+        detail,
+      });
+      super.dispatchEvent(event);
+    }
+
     reset() {
-      this.#capBase.reset();
+      if (this.#resetTimer) {
+        clearTimeout(this.#resetTimer);
+        this.#resetTimer = null;
+      }
+      this.dispatchEvent("reset");
+      this.#token = null;
+      if (this.querySelector("input[name='cap-token']")) {
+        this.querySelector("input[name='cap-token']").value = "";
+      }
     }
 
     get token() {
-      return this.#capBase.getToken();
+      return this.#token;
     }
 
     disconnectedCallback() {
@@ -383,60 +344,63 @@
       this.removeEventListener("error", this.boundHandleError);
       this.removeEventListener("reset", this.boundHandleReset);
 
-      this.eventHandlers.forEach((handler, eventName) => {
+      this.#eventHandlers.forEach((handler, eventName) => {
         this.removeEventListener(eventName.slice(2), handler);
       });
-      this.eventHandlers.clear();
+      this.#eventHandlers.clear();
 
       if (this.#shadow) {
         this.#shadow.innerHTML = "";
       }
 
-      this.#capBase.reset();
-      this.#capBase.cleanup();
+      this.reset();
+      this.cleanup();
+    }
+
+    cleanup() {
+      if (this.#resetTimer) {
+        clearTimeout(this.#resetTimer);
+        this.#resetTimer = null;
+      }
+
+      if (this.#workerUrl) {
+        URL.revokeObjectURL(this.#workerUrl);
+        this.#workerUrl = "";
+      }
     }
   }
 
   class Cap {
-    constructor(el, config = {}) {
-      let capBase = new CapBase();
-      let element = el || document.createElement("div");
+    constructor(config = {}, el) {
+      let widget = el || document.createElement("cap-widget");
 
-      if (!el) element.style.display = "none";
       Object.entries(config).forEach(([a, b]) => {
-        element.setAttribute(a, b);
+        widget.setAttribute(a, b);
       });
 
       if (config.apiEndpoint) {
-        element.setAttribute("data-cap-api-endpoint", config.apiEndpoint);
+        console.log(config);
+        widget.setAttribute("data-cap-api-endpoint", config.apiEndpoint);
       } else {
-        element.remove();
+        widget.remove();
         throw new Error("Missing API endpoint");
       }
 
-      capBase.setElement(element);
-      capBase.setWorkersCount(
-        config.workers || navigator.hardwareConcurrency || 8
-      );
-      capBase.initialize();
-
-      this.solve = async function () {
-        return await capBase.solve();
-      };
-
-      this.reset = function () {
-        capBase.reset();
-      };
-
-      this.addEventListener = function (event, callback) {
-        element.addEventListener(event, callback);
-      };
+      this.widget = widget;
+      this.solve = this.widget.solve.bind(this.widget);
+      this.reset = this.widget.reset.bind(this.widget);
+      this.addEventListener = this.widget.addEventListener.bind(this.widget);
 
       Object.defineProperty(this, "token", {
-        get: () => capBase.getToken(),
+        get: () => widget.getToken(),
         configurable: true,
         enumerable: true,
       });
+
+      if (!el) {
+        widget.style.display = "none";
+        document.documentElement.appendChild(widget);
+      }
     }
   }
 
@@ -496,7 +460,11 @@
 
   setTimeout(async function () {
     workerScript =
-      (await (await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()) +
+      (await (
+        await fetch(
+          "https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js"
+        )
+      ).text()) +
       workerFunct
         .toString()
         .replace(/^function\s*\([^\)]*\)\s*{|\}$/g, "")