cap-widget 0.1.57 → 0.1.59

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/src/cap.js CHANGED
@@ -1,5 +1,5 @@
1
1
  (() => {
2
- const WASM_VERSION = "0.0.7";
2
+ const WASM_VERSION = "0.0.8";
3
3
  const _browserHasHaptics =
4
4
  "vibrate" in navigator &&
5
5
  !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -165,76 +165,124 @@
165
165
  return pako.inflateRaw(compressed);
166
166
  }
167
167
 
168
- async function runInstrumentationChallenge(instrBytes) {
169
- const b64ToUint8 = (b64) => {
170
- const bin = atob(b64);
171
- const arr = new Uint8Array(bin.length);
172
- for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
173
- return arr;
168
+ const INSTR_ERRORS = [
169
+ "csp_blocked",
170
+ "natives_tampered",
171
+ "env_failed",
172
+ "script_failed",
173
+ ];
174
+ const CSP_DOCS = "https://trycap.dev/guide/widget.html#content-security-policy";
175
+
176
+ function startInstrumentation(instrBytes, host) {
177
+ let finished = false;
178
+ let ready = false;
179
+ let wantFinal = false;
180
+ let nonce = null;
181
+ let timer = null;
182
+ let iframe = null;
183
+ let settle;
184
+ let markReady;
185
+ const out = new Promise((resolve) => {
186
+ settle = resolve;
187
+ });
188
+ const session = {
189
+ observing: false,
190
+ result: null,
191
+ ready: new Promise((resolve) => {
192
+ markReady = resolve;
193
+ }),
174
194
  };
175
-
176
- const compressed = b64ToUint8(instrBytes);
177
- const scriptText = new TextDecoder().decode(await _inflateRaw(compressed));
178
-
179
- return new Promise((resolve) => {
180
- var timeout = setTimeout(() => {
181
- cleanup();
182
- resolve({ __timeout: true });
183
- }, 20000);
184
-
185
- var iframe = document.createElement("iframe");
186
- iframe.setAttribute("sandbox", "allow-scripts");
195
+ const send = () =>
196
+ iframe?.contentWindow?.postMessage({ type: "cap:finalize", nonce }, "*");
197
+ const finish = (value) => {
198
+ if (finished) return;
199
+ finished = true;
200
+ session.result = value;
201
+ clearTimeout(timer);
202
+ window.removeEventListener("message", onMessage);
203
+ iframe?.remove();
204
+ markReady();
205
+ settle(value);
206
+ };
207
+ const onMessage = (ev) => {
208
+ if (!iframe?.contentWindow || ev.source !== iframe.contentWindow) return;
209
+ const d = ev.data;
210
+ if (!d || typeof d !== "object") return;
211
+ if (d.type === "cap:ready") {
212
+ ready = true;
213
+ nonce = d.nonce;
214
+ session.observing = d.observe === true;
215
+ markReady();
216
+ if (wantFinal) send();
217
+ } else if (d.type === "cap:instr") {
218
+ if (d.early === true) return;
219
+ finish(
220
+ d.blocked
221
+ ? { __blocked: true, blockReason: d.blockReason || "automated_browser" }
222
+ : d.result || { __timeout: true },
223
+ );
224
+ } else if (d.type === "cap:error") {
225
+ finish({
226
+ __error: INSTR_ERRORS.includes(d.reason) ? d.reason : "script_failed",
227
+ });
228
+ }
229
+ };
230
+ session.finalize = () => {
231
+ if (!finished) {
232
+ wantFinal = true;
233
+ timer ??= setTimeout(() => finish({ __timeout: true }), 20000);
234
+ if (ready) send();
235
+ }
236
+ return out;
237
+ };
238
+ session.stop = () => finish({ __timeout: true });
239
+ window.addEventListener("message", onMessage);
240
+
241
+ (async () => {
242
+ const bin = atob(instrBytes);
243
+ const bytes = new Uint8Array(bin.length);
244
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
245
+ const scriptText = new TextDecoder().decode(await _inflateRaw(bytes));
246
+ if (finished) return;
247
+ const isolated = window.CAP_ISOLATE === true;
248
+ iframe = document.createElement("iframe");
249
+ iframe.setAttribute(
250
+ "sandbox",
251
+ isolated ? "allow-scripts" : "allow-scripts allow-same-origin",
252
+ );
187
253
  iframe.setAttribute("aria-hidden", "true");
254
+ iframe.setAttribute("tabindex", "-1");
188
255
  iframe.style.cssText =
189
256
  "position:absolute;width:1px;height:1px;top:-9999px;left:-9999px;border:none;opacity:0;pointer-events:none;";
190
-
191
- var resolved = false;
192
- function cleanup() {
193
- if (resolved) return;
194
- resolved = true;
195
- clearTimeout(timeout);
196
- window.removeEventListener("message", handler);
197
- if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
198
- }
199
-
200
- function handler(ev) {
201
- if (!iframe.contentWindow || ev.source !== iframe.contentWindow) return;
202
- var d = ev.data;
203
- if (!d || typeof d !== "object") return;
204
- if (d.type === "cap:instr") {
205
- cleanup();
206
- if (d.blocked) {
207
- resolve({
208
- __blocked: true,
209
- blockReason: d.blockReason || "automated_browser",
210
- });
211
- } else if (d.result) {
212
- resolve(d.result);
213
- } else {
214
- resolve({ __timeout: true });
215
- }
216
- } else if (d.type === "cap:error") {
217
- cleanup();
218
- resolve({ __timeout: true });
219
- }
220
- }
221
-
222
- window.addEventListener("message", handler);
223
-
224
257
  const scriptNonce = window.CAP_SCRIPT_NONCE || window.CAP_CSS_NONCE;
225
258
  const nonceAttr = scriptNonce
226
259
  ? ` nonce="${String(scriptNonce).replace(/"/g, "&quot;")}"`
227
260
  : "";
228
- iframe.srcdoc =
229
- '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><script' +
230
- nonceAttr +
231
- ">" +
232
- scriptText +
233
- "\n</scr" +
234
- "ipt></body></html>";
235
-
236
- document.body.appendChild(iframe);
237
- });
261
+ iframe.srcdoc = `<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><script${nonceAttr}>${scriptText}\n</scr${"ipt"}></body></html>`;
262
+ iframe.addEventListener("load", () => {
263
+ if (isolated) {
264
+ setTimeout(() => {
265
+ if (!ready && !finished) finish({ __error: "csp_blocked" });
266
+ }, 1000);
267
+ return;
268
+ }
269
+ let ran = true;
270
+ try {
271
+ const doc = iframe.contentDocument;
272
+ ran = !doc?.querySelector("script") || iframe.contentWindow.onload !== null;
273
+ } catch {}
274
+ if (ran) return;
275
+ setTimeout(() => {
276
+ if (!ready && !finished) finish({ __error: "csp_blocked" });
277
+ }, 250);
278
+ });
279
+ (host.host?.getClientRects().length === 0
280
+ ? document.documentElement
281
+ : host
282
+ ).appendChild(iframe);
283
+ })().catch(() => finish({ __timeout: true }));
284
+
285
+ return session;
238
286
  }
239
287
 
240
288
  let wasmModulePromise = null;
@@ -267,6 +315,52 @@
267
315
  return wasmModulePromise;
268
316
  };
269
317
 
318
+ const firstFulfilled = (tasks) =>
319
+ new Promise((resolve, reject) => {
320
+ let pending = tasks.length;
321
+ let firstError = null;
322
+ for (const task of tasks) {
323
+ task.then(resolve, (err) => {
324
+ if (!firstError) firstError = err;
325
+ pending--;
326
+ if (pending === 0) reject(firstError);
327
+ });
328
+ }
329
+ });
330
+
331
+ const MAX_HASHWX_DIFFICULTY = 1_000_000_000;
332
+ const MAX_HASHWX_NONCES_PER_HASH = 1_048_576;
333
+
334
+ let hashwxModulePromise = null;
335
+
336
+ const getHashwxModule = () => {
337
+ if (hashwxModulePromise) return hashwxModulePromise;
338
+
339
+ const wasmUrl =
340
+ window.CAP_CUSTOM_HASHWX_URL ||
341
+ `https://cdn.jsdelivr.net/npm/@cap.js/wasm@${WASM_VERSION}/browser/hashwx.wasm`;
342
+
343
+ const t0 = performance.now();
344
+ log.debug(T("wasm"), "fetching", wasmUrl);
345
+ hashwxModulePromise = fetch(wasmUrl)
346
+ .then((r) => {
347
+ if (!r.ok) throw new Error(`Failed to fetch hashwx wasm: ${r.status}`);
348
+ return r.arrayBuffer();
349
+ })
350
+ .then((buf) => WebAssembly.compile(buf))
351
+ .then((mod) => {
352
+ log.debug(T("wasm"), `hashwx ready in ${since(t0)}`);
353
+ return mod;
354
+ })
355
+ .catch((e) => {
356
+ hashwxModulePromise = null;
357
+ log.warn(T("wasm"), `hashwx load failed (${since(t0)}):`, e.message || e);
358
+ throw e;
359
+ });
360
+
361
+ return hashwxModulePromise;
362
+ };
363
+
270
364
  if (
271
365
  typeof WebAssembly === "object" &&
272
366
  typeof WebAssembly.compile === "function"
@@ -280,6 +374,7 @@
280
374
  const SPECULATIVE_DELAY_MS = 2500;
281
375
  const SPECULATIVE_WORKERS = 1;
282
376
  const SPECULATIVE_YIELD_MS = 120;
377
+ const MAX_IDLE_REFRESHES = 6;
283
378
 
284
379
  let _sharedWorkerUrl = null;
285
380
 
@@ -292,6 +387,9 @@
292
387
  return _sharedWorkerUrl;
293
388
  }
294
389
 
390
+ const WORKER_STOP_GRACE_MS = 250;
391
+ const HASHWX_SETTLE_MS = 1000;
392
+
295
393
  class WorkerPool {
296
394
  constructor(size) {
297
395
  this._size = size;
@@ -412,15 +510,31 @@
412
510
  }
413
511
  }
414
512
 
415
- terminate() {
513
+ stopAll() {
416
514
  for (const w of this._workers) {
417
515
  try {
418
- w.terminate();
516
+ w.postMessage({ kind: "stop" });
419
517
  } catch {}
420
518
  }
519
+ }
520
+
521
+ terminate() {
522
+ const workers = this._workers;
421
523
  this._workers = [];
422
524
  this._idle = [];
423
525
  this._queue = [];
526
+ for (const w of workers) {
527
+ try {
528
+ w.postMessage({ kind: "stop" });
529
+ } catch {}
530
+ }
531
+ setTimeout(() => {
532
+ for (const w of workers) {
533
+ try {
534
+ w.terminate();
535
+ } catch {}
536
+ }
537
+ }, WORKER_STOP_GRACE_MS);
424
538
  }
425
539
  }
426
540
 
@@ -445,6 +559,8 @@
445
559
  #interactionHandler = null;
446
560
  #i18n = null;
447
561
  #abort = null;
562
+ #idleRefreshes = 0;
563
+ #refreshOnVisible = false;
448
564
 
449
565
  get #hasHaptics() {
450
566
  return (
@@ -467,6 +583,9 @@
467
583
  pendingPromotion: null,
468
584
  token: null,
469
585
  tokenExpires: null,
586
+ fetchP: null,
587
+ refreshTimer: null,
588
+ redeemError: null,
470
589
 
471
590
  notify() {
472
591
  for (const fn of this._listeners) fn();
@@ -484,10 +603,136 @@
484
603
  }
485
604
 
486
605
  #resetSpeculativeState() {
606
+ this.#dropChallenge(this.#speculative);
487
607
  this.#speculative = this.#makeSpeculativeState();
488
608
  this.#attachInteractionListeners();
489
609
  }
490
610
 
611
+ #dropChallenge(spec) {
612
+ if (!spec) return;
613
+ clearTimeout(spec.refreshTimer);
614
+ spec.challengeResp?._instr?.stop();
615
+ }
616
+
617
+ #endpoint() {
618
+ let apiEndpoint = this.getAttribute("data-cap-api-endpoint");
619
+ if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) apiEndpoint = "/";
620
+ if (!apiEndpoint) return null;
621
+ return apiEndpoint.endsWith("/") ? apiEndpoint : `${apiEndpoint}/`;
622
+ }
623
+
624
+ #instrBlob(resp) {
625
+ if (resp?.format === 2 && Array.isArray(resp.challenges)) {
626
+ return (
627
+ resp.challenges.find((ch) => ch?.protocol === "instrumentation")
628
+ ?.payload?.blob ?? null
629
+ );
630
+ }
631
+ return resp?.instrumentation ?? null;
632
+ }
633
+
634
+ #startInstr(resp) {
635
+ const blob = this.#instrBlob(resp);
636
+ if (!blob || !this.#shadow) return null;
637
+ if (!resp._instr || resp._instr.result?.__timeout) {
638
+ resp._instr = startInstrumentation(blob, this.#shadow);
639
+ }
640
+ return resp._instr;
641
+ }
642
+
643
+ #finalizeInstr(resp) {
644
+ const session = this.#startInstr(resp);
645
+ return session ? session.finalize() : Promise.resolve(null);
646
+ }
647
+
648
+ #fetchChallenge(signal) {
649
+ const spec = this.#speculative;
650
+ if (!spec) return Promise.reject(_err("aborted", "aborted"));
651
+ if (spec.fetchP) return spec.fetchP;
652
+ const apiEndpoint = this.#endpoint();
653
+ if (!apiEndpoint) {
654
+ return Promise.reject(
655
+ _err(
656
+ "missing_endpoint",
657
+ "Missing API endpoint. Either custom fetch or an API endpoint must be provided.",
658
+ ),
659
+ );
660
+ }
661
+ const pending = (async () => {
662
+ const raw = await capFetch(`${apiEndpoint}challenge`, {
663
+ method: "POST",
664
+ signal: signal ?? this.#abort?.signal,
665
+ });
666
+ let resp;
667
+ try {
668
+ resp = await raw.json();
669
+ } catch {
670
+ throw _err("challenge_parse_error", "Failed to parse challenge response from server");
671
+ }
672
+ if (resp.error) throw _err("network_error", resp.error);
673
+ resp._apiEndpoint = apiEndpoint;
674
+ if (this.#speculative !== spec) return resp;
675
+ spec.challengeResp = resp;
676
+ this.#startInstr(resp);
677
+ this.#scheduleRefresh(spec, resp);
678
+ return resp;
679
+ })();
680
+ spec.fetchP = pending;
681
+ pending.catch(() => {
682
+ if (spec.fetchP === pending) spec.fetchP = null;
683
+ });
684
+ return pending;
685
+ }
686
+
687
+ #scheduleRefresh(spec, resp) {
688
+ clearTimeout(spec.refreshTimer);
689
+ let ttl = Number.NaN;
690
+ try {
691
+ const claims = JSON.parse(
692
+ atob(resp.token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")),
693
+ );
694
+ ttl = Math.min(claims.exp, Number(resp.expires) || claims.exp) - claims.iat;
695
+ } catch {}
696
+ if (!(ttl > 0)) ttl = Number(resp.expires) - Date.now();
697
+ if (!(ttl > 60_000 && ttl < 86_400_000)) ttl = 300_000;
698
+ const hashwx =
699
+ resp.format === 2 &&
700
+ resp.challenges?.some?.((ch) => ch?.protocol === "hashwx");
701
+ const margin = Math.min(
702
+ ttl / 2,
703
+ Math.max(hashwx ? 120_000 : 30_000, ttl * (hashwx ? 0.3 : 0.15)),
704
+ );
705
+ spec.refreshTimer = setTimeout(() => {
706
+ if (this.#speculative !== spec || this.#solving || spec.token) return;
707
+ if (spec.challengeResp !== resp) return;
708
+ if (spec.state !== "idle" && spec.state !== "done") return;
709
+ this.#resetSpeculativeState();
710
+ if (this.#idleRefreshes >= MAX_IDLE_REFRESHES) return;
711
+ if (document.hidden) {
712
+ this.#refreshOnVisible = true;
713
+ return;
714
+ }
715
+ this.#idleRefreshes++;
716
+ this.#prefetch();
717
+ }, ttl - margin);
718
+ }
719
+
720
+ #takeChallenge() {
721
+ const spec = this.#speculative;
722
+ const resp = spec.challengeResp;
723
+ spec.challengeResp = null;
724
+ spec.fetchP = null;
725
+ clearTimeout(spec.refreshTimer);
726
+ return resp;
727
+ }
728
+
729
+ #prefetch() {
730
+ const spec = this.#speculative;
731
+ if (!spec || spec.challengeResp || spec.fetchP) return;
732
+ if (!this.#endpoint() || !this.#isVisible()) return;
733
+ this.#fetchChallenge().catch(() => {});
734
+ }
735
+
491
736
  #detachInteractionListeners() {
492
737
  if (this.#interactionHandler) {
493
738
  window.removeEventListener("mousemove", this.#interactionHandler);
@@ -501,6 +746,7 @@
501
746
  this.#detachInteractionListeners();
502
747
  const handler = () => {
503
748
  this.#detachInteractionListeners();
749
+ this.#idleRefreshes = 0;
504
750
  this.#onFirstInteraction();
505
751
  };
506
752
  this.#interactionHandler = handler;
@@ -524,6 +770,7 @@
524
770
  }
525
771
 
526
772
  #onFirstInteraction() {
773
+ this.#prefetch();
527
774
  if (this.#speculative.state !== "idle") return;
528
775
  if (!this.#isVisible()) return;
529
776
  this.#speculative.state = "waiting";
@@ -533,40 +780,26 @@
533
780
  }
534
781
 
535
782
  async #beginSpeculativeSolve() {
536
- if (this.#speculative.state !== "waiting") return;
537
- this.#speculative.state = "fetching";
538
- this.#speculative._t0 = performance.now();
783
+ const spec = this.#speculative;
784
+ if (spec?.state !== "waiting") return;
785
+ spec.state = "fetching";
786
+ spec._t0 = performance.now();
539
787
 
540
- let apiEndpoint = this.getAttribute("data-cap-api-endpoint");
541
- if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
542
- apiEndpoint = "/";
543
- }
544
- if (!apiEndpoint) {
545
- this.#speculative.state = "idle";
788
+ if (!this.#endpoint()) {
789
+ spec.state = "idle";
546
790
  return;
547
791
  }
548
- if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
549
792
 
550
793
  try {
551
- const raw = await capFetch(`${apiEndpoint}challenge`, {
552
- method: "POST",
553
- signal: this.#abort?.signal,
554
- });
555
- let resp;
556
- try {
557
- resp = await raw.json();
558
- } catch {
559
- throw new Error("Failed to parse speculative challenge response");
560
- }
561
- if (resp.error) throw new Error(resp.error);
562
- if (!this.#speculative) return;
563
-
564
- resp._apiEndpoint = apiEndpoint;
565
- this.#speculative.challengeResp = resp;
794
+ const resp = await this.#fetchChallenge();
795
+ if (this.#speculative !== spec) return;
566
796
 
567
797
  if (resp.format === 2 && Array.isArray(resp.challenges)) {
568
- this.#speculative.state = "idle";
569
- this.#speculative.notify();
798
+ if (resp.challenges.some((ch) => ch?.protocol === "hashwx")) {
799
+ getHashwxModule().catch(() => {});
800
+ }
801
+ spec.state = "idle";
802
+ spec.notify();
570
803
  return;
571
804
  }
572
805
 
@@ -582,24 +815,25 @@
582
815
  ];
583
816
  });
584
817
  }
585
- this.#speculative.challenges = challenges;
586
- this.#speculative.state = "solving";
818
+ spec.challenges = challenges;
819
+ spec.state = "solving";
587
820
 
588
- this.#speculative.solvePromise = this.#speculativeSolveAll(
821
+ spec.solvePromise = this.#speculativeSolveAll(
589
822
  challenges,
823
+ spec,
590
824
  ).catch(() => {
591
- if (!this.#speculative) return;
592
- this.#speculative.state = "error";
593
- this.#speculative.notify();
825
+ if (this.#speculative !== spec) return;
826
+ spec.state = "error";
827
+ spec.notify();
594
828
  });
595
829
  } catch {
596
- if (!this.#speculative) return;
597
- this.#speculative.state = "error";
598
- this.#speculative.notify();
830
+ if (this.#speculative !== spec) return;
831
+ spec.state = "error";
832
+ spec.notify();
599
833
  }
600
834
  }
601
835
 
602
- async #speculativeSolveAll(challenges) {
836
+ async #speculativeSolveAll(challenges, spec) {
603
837
  _getSharedWorkerUrl();
604
838
 
605
839
  let wasmModule = null;
@@ -607,7 +841,7 @@
607
841
  wasmModule = await getWasmModule();
608
842
  } catch {}
609
843
 
610
- if (!this.#speculative) return [];
844
+ if (this.#speculative !== spec) return [];
611
845
  if (!this.#speculativePool) {
612
846
  this.#speculativePool = new WorkerPool(1);
613
847
  this.#speculativePool._spawn();
@@ -620,7 +854,7 @@
620
854
  let concurrency = SPECULATIVE_WORKERS;
621
855
  let promoted = false;
622
856
 
623
- this.#speculative.promoteFn = (fullCount) => {
857
+ spec.promoteFn = (fullCount) => {
624
858
  if (promoted || !this.#speculativePool) return;
625
859
  promoted = true;
626
860
  concurrency = fullCount;
@@ -628,15 +862,15 @@
628
862
  this.#speculativePool._ensureSize(fullCount);
629
863
  };
630
864
 
631
- if (this.#speculative.pendingPromotion !== null) {
632
- this.#speculative.promoteFn(this.#speculative.pendingPromotion);
633
- this.#speculative.pendingPromotion = null;
865
+ if (spec.pendingPromotion !== null) {
866
+ spec.promoteFn(spec.pendingPromotion);
867
+ spec.pendingPromotion = null;
634
868
  }
635
869
 
636
870
  let nextIndex = 0;
637
871
 
638
872
  while (nextIndex < total) {
639
- if (!this.#speculative || !this.#speculativePool) return results;
873
+ if (this.#speculative !== spec || !this.#speculativePool) return results;
640
874
  const batchSize = concurrency;
641
875
  const batch = [];
642
876
  const batchIndices = [];
@@ -654,7 +888,7 @@
654
888
  this.#speculativePool
655
889
  .run(challenge[0], challenge[1])
656
890
  .then((nonce) => {
657
- if (this.#speculative) this.#speculative.completedCount++;
891
+ spec.completedCount++;
658
892
  return nonce;
659
893
  }),
660
894
  ),
@@ -671,30 +905,43 @@
671
905
  }
672
906
  }
673
907
 
674
- if (!this.#speculative) return results;
675
- this.#speculative.results = results;
676
- this.#speculative.state = "redeeming";
677
- this.#speculativeRedeem(results);
908
+ if (this.#speculative !== spec) return results;
909
+ spec.results = results;
910
+ spec.state = "redeeming";
911
+ this.#speculativeRedeem(results, spec);
678
912
  return results;
679
913
  }
680
914
 
681
- async #speculativeRedeem(solutions) {
915
+ async #speculativeRedeem(solutions, spec) {
916
+ let answered = false;
682
917
  try {
683
- if (!this.#speculative) return;
684
- const challengeResp = this.#speculative.challengeResp;
918
+ if (this.#speculative !== spec) return;
919
+ const challengeResp = spec.challengeResp;
685
920
  const apiEndpoint = challengeResp._apiEndpoint;
686
921
  if (!apiEndpoint)
687
922
  throw _err("missing_endpoint", "speculative redeem: missing apiEndpoint");
688
923
 
924
+ const session = this.#startInstr(challengeResp);
925
+ if (session) {
926
+ await Promise.race([
927
+ session.ready,
928
+ new Promise((resolve) => setTimeout(resolve, 5000)),
929
+ ]);
930
+ if (this.#speculative !== spec) return;
931
+ }
932
+ if (session?.observing) {
933
+ spec.state = "done";
934
+ spec.notify();
935
+ return;
936
+ }
937
+
689
938
  let instrOut = null;
690
- if (challengeResp.instrumentation) {
691
- instrOut = await runInstrumentationChallenge(
692
- challengeResp.instrumentation,
693
- );
694
- if (!this.#speculative) return;
695
- if (instrOut?.__timeout || instrOut?.__blocked) {
696
- this.#speculative.state = "done";
697
- this.#speculative.notify();
939
+ if (session) {
940
+ instrOut = await session.finalize();
941
+ if (this.#speculative !== spec) return;
942
+ if (instrOut?.__timeout || instrOut?.__blocked || instrOut?.__error) {
943
+ spec.state = "done";
944
+ spec.notify();
698
945
  return;
699
946
  }
700
947
  }
@@ -710,26 +957,28 @@
710
957
  signal: this.#abort?.signal,
711
958
  });
712
959
 
960
+ answered = true;
713
961
  let resp;
714
962
  try {
715
963
  resp = await redeemRaw.json();
716
964
  } catch {
717
- throw new Error("Failed to parse speculative redeem response");
965
+ throw new Error("Failed to parse server response");
718
966
  }
719
967
 
720
- if (!this.#speculative) return;
968
+ if (this.#speculative !== spec) return;
721
969
  if (!resp.success)
722
- throw new Error(resp.error || "Speculative redeem failed");
723
-
724
- this.#speculative.token = resp.token;
725
- this.#speculative.tokenExpires = new Date(resp.expires).getTime();
726
- this.#speculative.state = "done";
727
- this.#speculative._invisibleElapsed = this.#speculative._t0 ? since(this.#speculative._t0) : "?";
728
- this.#speculative.notify();
729
- } catch {
730
- if (!this.#speculative) return;
731
- this.#speculative.state = "done";
732
- this.#speculative.notify();
970
+ throw new Error(resp.error || "Invalid solution");
971
+
972
+ spec.token = resp.token;
973
+ spec.tokenExpires = new Date(resp.expires).getTime();
974
+ spec.state = "done";
975
+ spec._invisibleElapsed = spec._t0 ? since(spec._t0) : "?";
976
+ spec.notify();
977
+ } catch (err) {
978
+ if (this.#speculative !== spec) return;
979
+ if (answered) spec.redeemError = err?.message || "Invalid solution";
980
+ spec.state = "done";
981
+ spec.notify();
733
982
  }
734
983
  }
735
984
 
@@ -902,11 +1151,19 @@
902
1151
 
903
1152
  log.debug(T(), `widget ready (workers=${this.#workersCount}, haptics=${this.#hasHaptics})`);
904
1153
  this.#attachInteractionListeners();
1154
+ this.#prefetch();
905
1155
  this.#updateValidity();
906
1156
 
907
1157
  this.addEventListener("invalid", this.#handleInvalid);
1158
+ document.addEventListener("visibilitychange", this.#handleVisibility);
908
1159
  }
909
1160
 
1161
+ #handleVisibility = () => {
1162
+ if (document.hidden || !this.#refreshOnVisible) return;
1163
+ this.#refreshOnVisible = false;
1164
+ this.#prefetch();
1165
+ };
1166
+
910
1167
  #handleInvalid = () => {
911
1168
  if (!this.#div) return;
912
1169
  try {
@@ -927,6 +1184,8 @@
927
1184
 
928
1185
  this.#enforceCredits();
929
1186
  const _solveT0 = performance.now();
1187
+ let usedResp = null;
1188
+ let reported = null;
930
1189
  const signal = this.#abort?.signal;
931
1190
  log.debug(T("solve"), "starting");
932
1191
 
@@ -970,6 +1229,10 @@
970
1229
  return this.#commitSpeculativeToken();
971
1230
  }
972
1231
 
1232
+ if (this.#speculative.redeemError) {
1233
+ throw _err("invalid_solution", this.#speculative.redeemError);
1234
+ }
1235
+
973
1236
  if (this.#speculative.state === "done") {
974
1237
  solutions = this.#speculative.results;
975
1238
  challengeResp = this.#speculative.challengeResp;
@@ -1028,11 +1291,12 @@
1028
1291
  this.#speculative.challengeResp?.format === 2 &&
1029
1292
  Array.isArray(this.#speculative.challengeResp.challenges)
1030
1293
  ) {
1031
- challengeResp = this.#speculative.challengeResp;
1032
- this.#speculative.challengeResp = null;
1294
+ challengeResp = this.#takeChallenge();
1295
+ usedResp = challengeResp;
1033
1296
  solutions = await this.solveChallengesV2(
1034
1297
  challengeResp.challenges,
1035
1298
  signal,
1299
+ challengeResp,
1036
1300
  );
1037
1301
  } else {
1038
1302
  if (this.#speculative.state !== "done") {
@@ -1047,27 +1311,22 @@
1047
1311
  return this.#commitSpeculativeToken();
1048
1312
  }
1049
1313
 
1314
+ if (this.#speculative.redeemError) {
1315
+ throw _err("invalid_solution", this.#speculative.redeemError);
1316
+ }
1317
+
1050
1318
  solutions = this.#speculative.results;
1051
1319
  challengeResp = this.#speculative.challengeResp;
1052
1320
  this.dispatchEvent("progress", { progress: 100 });
1053
1321
  }
1054
1322
  } else {
1055
- const cached = this.#speculative.challengeResp;
1056
- if (cached?.format === 2 && Array.isArray(cached.challenges)) {
1057
- challengeResp = cached;
1058
- this.#speculative.challengeResp = null;
1059
- } else {
1060
- const challengeRaw = await capFetch(`${apiEndpoint}challenge`, {
1061
- method: "POST",
1062
- signal,
1063
- });
1064
- try {
1065
- challengeResp = await challengeRaw.json();
1066
- } catch {
1067
- throw _err("challenge_parse_error", "Failed to parse challenge response from server");
1068
- }
1069
- if (challengeResp.error) throw _err("network_error", challengeResp.error);
1323
+ if (!this.#speculative.challengeResp) {
1324
+ await this.#fetchChallenge(signal);
1070
1325
  }
1326
+ if (signal?.aborted || !this.#speculative) return;
1327
+ challengeResp = this.#takeChallenge();
1328
+ if (!challengeResp) throw _err("network_error", "Failed to fetch challenge");
1329
+ usedResp = challengeResp;
1071
1330
 
1072
1331
  if (
1073
1332
  challengeResp.format === 2 &&
@@ -1076,6 +1335,7 @@
1076
1335
  solutions = await this.solveChallengesV2(
1077
1336
  challengeResp.challenges,
1078
1337
  signal,
1338
+ challengeResp,
1079
1339
  );
1080
1340
  } else {
1081
1341
  const { challenge, token } = challengeResp;
@@ -1094,29 +1354,47 @@
1094
1354
  }
1095
1355
  }
1096
1356
 
1097
- const instrPromise = challengeResp.instrumentation
1098
- ? runInstrumentationChallenge(challengeResp.instrumentation)
1099
- : Promise.resolve(null);
1100
-
1101
- const instrOut = await instrPromise;
1357
+ usedResp = challengeResp;
1358
+ const v2 = challengeResp.format === 2;
1359
+ const instrOut = v2 ? null : await this.#finalizeInstr(challengeResp);
1102
1360
  if (signal?.aborted || !this.#speculative) return;
1103
1361
 
1104
- if (instrOut?.__timeout || instrOut?.__blocked) {
1362
+ const v2Fail = v2
1363
+ ? solutions?.find?.((x) => x?.error || x?.timeout || x?.blocked)
1364
+ : null;
1365
+ const instrFail = v2Fail
1366
+ ? {
1367
+ __error: v2Fail.error,
1368
+ __timeout: v2Fail.timeout,
1369
+ __blocked: v2Fail.blocked,
1370
+ }
1371
+ : instrOut?.__timeout || instrOut?.__blocked || instrOut?.__error
1372
+ ? instrOut
1373
+ : null;
1374
+
1375
+ if (instrFail) {
1105
1376
  capFetch(`${apiEndpoint}redeem`, {
1106
1377
  method: "POST",
1107
1378
  body: JSON.stringify({
1108
1379
  token: challengeResp.token,
1109
1380
  solutions,
1110
- ...(instrOut.__blocked && { instr_blocked: true }),
1111
- ...(instrOut.__timeout && { instr_timeout: true }),
1381
+ ...(!v2 && instrFail.__error && { instr_error: instrFail.__error }),
1382
+ ...(!v2 && instrFail.__blocked && { instr_blocked: true }),
1383
+ ...(!v2 && instrFail.__timeout && { instr_timeout: true }),
1112
1384
  }),
1113
1385
  headers: { "Content-Type": "application/json" },
1114
1386
  signal,
1115
1387
  }).catch(() => {});
1116
1388
 
1389
+ const csp = instrFail.__error === "csp_blocked";
1117
1390
  this.updateUIBlocked(
1118
- this.getI18nText("error-label", "Error"),
1119
- instrOut?.__blocked,
1391
+ csp
1392
+ ? this.getI18nText(
1393
+ "csp-error-label",
1394
+ this.#i18n?.["error-label"] || "Blocked by this page",
1395
+ )
1396
+ : this.getI18nText("error-label", "Error"),
1397
+ !!instrFail.__blocked,
1120
1398
  );
1121
1399
  this.#trigger.setAttribute(
1122
1400
  "aria-label",
@@ -1125,10 +1403,18 @@
1125
1403
  "An error occurred, please try again",
1126
1404
  ),
1127
1405
  );
1128
- const instrCode = instrOut?.__blocked ? "instr_blocked" : "instr_timeout";
1129
- const instrMsg = instrOut?.__blocked
1130
- ? `Instrumentation blocked (${instrOut.blockReason || "automated_browser"})`
1131
- : "Instrumentation timed out";
1406
+ const instrCode = instrFail.__error
1407
+ ? `instr_${instrFail.__error}`
1408
+ : instrFail.__blocked
1409
+ ? "instr_blocked"
1410
+ : "instr_timeout";
1411
+ const instrMsg = csp
1412
+ ? "The page's Content-Security-Policy blocked Cap's instrumentation frame"
1413
+ : instrFail.__error
1414
+ ? `Instrumentation failed (${instrFail.__error})`
1415
+ : instrFail.__blocked
1416
+ ? `Instrumentation blocked (${instrFail.blockReason || "automated_browser"})`
1417
+ : "Instrumentation timed out";
1132
1418
  this.removeEventListener("error", this.boundHandleError);
1133
1419
  const errEvent = new CustomEvent("error", {
1134
1420
  bubbles: true,
@@ -1138,9 +1424,15 @@
1138
1424
  super.dispatchEvent(errEvent);
1139
1425
  this.addEventListener("error", this.boundHandleError);
1140
1426
  this.executeAttributeCode("onerror", errEvent);
1141
- log.error(T("instr"), `[${instrCode}] ${instrMsg}`);
1142
- this.#solving = false;
1143
- return;
1427
+ log.error(
1428
+ T("instr"),
1429
+ csp
1430
+ ? `[${instrCode}] ${instrMsg}. Set window.CAP_SCRIPT_NONCE to the nonce in your script-src before the widget loads, see ${CSP_DOCS}`
1431
+ : `[${instrCode}] ${instrMsg}`,
1432
+ );
1433
+ this.#resetSpeculativeState();
1434
+ reported = _err(instrCode, instrMsg);
1435
+ throw reported;
1144
1436
  }
1145
1437
 
1146
1438
  const { token } = challengeResp;
@@ -1197,6 +1489,7 @@
1197
1489
  return { success: true, token: this.token };
1198
1490
  } catch (err) {
1199
1491
  if (signal?.aborted || !this.#speculative) return;
1492
+ if (err === reported) throw err;
1200
1493
  this.#trigger.setAttribute(
1201
1494
  "aria-label",
1202
1495
  this.getI18nText(
@@ -1205,14 +1498,17 @@
1205
1498
  ),
1206
1499
  );
1207
1500
  this.error(err.message, err.code || "solve_failed");
1501
+ this.#resetSpeculativeState();
1208
1502
  throw err;
1209
1503
  }
1210
1504
  } finally {
1505
+ usedResp?._instr?.stop();
1211
1506
  this.#solving = false;
1212
1507
  }
1213
1508
  }
1214
1509
 
1215
- async solveChallengesV2(challenges, signal) {
1510
+ async solveChallengesV2(challenges, signal, resp) {
1511
+ const owner = resp ?? { format: 2, challenges };
1216
1512
  const total = challenges.length;
1217
1513
  let completed = 0;
1218
1514
 
@@ -1225,37 +1521,96 @@
1225
1521
  !(
1226
1522
  ch.protocol === "sha256-pow" ||
1227
1523
  ch.protocol === "rsw" ||
1524
+ ch.protocol === "hashwx" ||
1228
1525
  ch.protocol === "instrumentation"
1229
1526
  )
1230
1527
  ) {
1231
- // Unknown protocol = older widget on a newer server. Fall back to
1232
- // erroring out -- the host can detect this and serve format-1.
1233
1528
  throw _err("challenge_unsupported", `unsupported format-2 protocol '${ch?.protocol}'`);
1234
1529
  }
1235
1530
  }
1236
1531
 
1237
- let wasmModule = null;
1532
+ let needsCapWasm = false;
1533
+ let hashwxCount = 0;
1534
+ for (const ch of challenges) {
1535
+ if (ch.protocol === "sha256-pow") needsCapWasm = true;
1536
+ else if (ch.protocol === "hashwx") {
1537
+ hashwxCount++;
1538
+ const { c, d, n } = ch.payload ?? {};
1539
+ if (
1540
+ typeof c !== "string" ||
1541
+ !/^[0-9a-f]{64}$/i.test(c) ||
1542
+ !Number.isInteger(d) ||
1543
+ d < 1 ||
1544
+ d > MAX_HASHWX_DIFFICULTY ||
1545
+ !Number.isInteger(n) ||
1546
+ n < 1 ||
1547
+ n > MAX_HASHWX_NONCES_PER_HASH
1548
+ ) {
1549
+ throw _err("challenge_unsupported", "malformed hashwx challenge payload");
1550
+ }
1551
+ }
1552
+ }
1553
+ const needsHashwx = hashwxCount > 0;
1554
+
1238
1555
  const wasmSupported =
1239
1556
  typeof WebAssembly === "object" &&
1240
1557
  typeof WebAssembly.instantiate === "function";
1241
- if (wasmSupported) {
1558
+
1559
+ let wasmModule = null;
1560
+ if (wasmSupported && needsCapWasm) {
1242
1561
  try { wasmModule = await getWasmModule(); }
1243
1562
  catch (e) { log.warn(T("wasm"), "unavailable, falling back to JS solver:", e.message || e); }
1244
1563
  }
1245
1564
 
1565
+ let hashwxModule = null;
1566
+ if (needsHashwx) {
1567
+ if (!wasmSupported) {
1568
+ throw _err("challenge_unsupported", "hashwx requires WebAssembly");
1569
+ }
1570
+ hashwxModule = await getHashwxModule();
1571
+ }
1572
+
1246
1573
  const poolSize = Math.max(1, Math.min(this.#workersCount, challenges.length));
1247
1574
  const pool = new WorkerPool(poolSize);
1248
1575
  pool.setWasm(wasmModule);
1249
1576
  pool._ensureSize(poolSize);
1250
1577
 
1578
+ const hashwxWorkers = Math.max(1, this.#workersCount);
1579
+ const hashwxQueued = hashwxCount > hashwxWorkers;
1580
+ const hashwxStripes = hashwxQueued ? 1 : hashwxWorkers;
1581
+ let hashwxPool = needsHashwx ? new WorkerPool(hashwxWorkers) : null;
1582
+ if (hashwxPool) hashwxPool._ensureSize(hashwxWorkers);
1583
+
1584
+ const reclaimStripes = async (stripes) => {
1585
+ hashwxPool.stopAll();
1586
+ let timer;
1587
+ const settled = await Promise.race([
1588
+ Promise.allSettled(stripes).then(() => true),
1589
+ new Promise((resolve) => {
1590
+ timer = setTimeout(() => resolve(false), HASHWX_SETTLE_MS);
1591
+ }),
1592
+ ]);
1593
+ clearTimeout(timer);
1594
+ if (settled) return;
1595
+ log.warn(T("solve"), "hashwx workers did not stop in time, replacing the pool");
1596
+ hashwxPool.terminate();
1597
+ hashwxPool = new WorkerPool(hashwxWorkers);
1598
+ hashwxPool._ensureSize(hashwxWorkers);
1599
+ };
1600
+
1251
1601
  const TASK_TIMEOUT_MS = 60_000;
1252
- const withTimeout = (promise, label) => Promise.race([
1253
- promise,
1254
- new Promise((_, rej) => setTimeout(
1255
- () => rej(new Error(`[cap] ${label} timed out after ${TASK_TIMEOUT_MS}ms`)),
1256
- TASK_TIMEOUT_MS,
1257
- )),
1258
- ]);
1602
+ const withTimeout = (promise, label) => {
1603
+ let timer;
1604
+ return Promise.race([
1605
+ promise,
1606
+ new Promise((_, rej) => {
1607
+ timer = setTimeout(
1608
+ () => rej(new Error(`[cap] ${label} timed out after ${TASK_TIMEOUT_MS}ms`)),
1609
+ TASK_TIMEOUT_MS,
1610
+ );
1611
+ }),
1612
+ ]).finally(() => clearTimeout(timer));
1613
+ };
1259
1614
 
1260
1615
  const inFlight = new Array(challenges.length).fill(0);
1261
1616
  const emit = () => {
@@ -1264,9 +1619,82 @@
1264
1619
  this.dispatchEvent("progress", { progress: visual });
1265
1620
  };
1266
1621
 
1622
+ const solveHashwx = async (ch, idx) => {
1623
+ const { c, d, n } = ch.payload;
1624
+ const expected = Math.max(1, d);
1625
+ const striped = new Array(hashwxStripes).fill(0);
1626
+ const stripes = [];
1627
+ try {
1628
+ for (let s = 0; s < hashwxStripes; s++) {
1629
+ const stripe = s;
1630
+ const task = hashwxPool.runMsg(
1631
+ {
1632
+ kind: "hashwx",
1633
+ hashwxModule,
1634
+ c,
1635
+ d,
1636
+ n,
1637
+ workerIndex: stripe,
1638
+ workerCount: hashwxStripes,
1639
+ },
1640
+ (hashes) => {
1641
+ striped[stripe] = hashes;
1642
+ let sum = 0;
1643
+ for (const v of striped) sum += v;
1644
+ inFlight[idx] = Math.min(0.99, sum / expected);
1645
+ emit();
1646
+ },
1647
+ );
1648
+ task.catch(() => {});
1649
+ stripes.push(task);
1650
+ }
1651
+ const data = await withTimeout(
1652
+ firstFulfilled(stripes).catch((err) => {
1653
+ throw _err(
1654
+ "solve_failed",
1655
+ err?.message || "every hashwx worker failed",
1656
+ );
1657
+ }),
1658
+ `hashwx worker #${idx}`,
1659
+ );
1660
+ solutions[idx] = { nonce: data.nonce };
1661
+ log.debug(
1662
+ T("solve"),
1663
+ `hashwx #${idx}: ${data.hashes} hashes in ${data.durationMs}ms`,
1664
+ `compiled=${data.compiled}`,
1665
+ );
1666
+ inFlight[idx] = 0;
1667
+ completed++;
1668
+ emit();
1669
+ } catch (err) {
1670
+ await reclaimStripes(stripes);
1671
+ throw err;
1672
+ }
1673
+ return stripes;
1674
+ };
1675
+
1676
+ const hashwxJobs = [];
1677
+ challenges.forEach((ch, idx) => {
1678
+ if (ch.protocol === "hashwx") hashwxJobs.push({ ch, idx });
1679
+ });
1680
+ const hashwxSequence = hashwxJobs.length
1681
+ ? hashwxQueued
1682
+ ? Promise.all(hashwxJobs.map((job) => solveHashwx(job.ch, job.idx)))
1683
+ : (async () => {
1684
+ for (let i = 0; i < hashwxJobs.length; i++) {
1685
+ const stripes = await solveHashwx(
1686
+ hashwxJobs[i].ch,
1687
+ hashwxJobs[i].idx,
1688
+ );
1689
+ if (i < hashwxJobs.length - 1) await reclaimStripes(stripes);
1690
+ }
1691
+ })()
1692
+ : null;
1693
+
1267
1694
  try {
1268
- await raceAbort(Promise.all(
1269
- challenges.map((ch, idx) => {
1695
+ await raceAbort(Promise.all([
1696
+ hashwxSequence,
1697
+ ...challenges.map((ch, idx) => {
1270
1698
  if (ch.protocol === "sha256-pow") {
1271
1699
  return withTimeout(
1272
1700
  pool.run(ch.payload.salt, ch.payload.target),
@@ -1293,9 +1721,12 @@
1293
1721
  emit();
1294
1722
  });
1295
1723
  }
1296
-
1297
- return runInstrumentationChallenge(ch.payload.blob).then((out) => {
1298
- if (out?.__timeout) solutions[idx] = { timeout: true };
1724
+
1725
+ if (ch.protocol === "hashwx") return null;
1726
+
1727
+ return this.#finalizeInstr(owner).then((out) => {
1728
+ if (out?.__error) solutions[idx] = { error: out.__error };
1729
+ else if (out?.__timeout) solutions[idx] = { timeout: true };
1299
1730
  else if (out?.__blocked) solutions[idx] = { blocked: true };
1300
1731
  else solutions[idx] = { instr: out };
1301
1732
  inFlight[idx] = 0;
@@ -1303,9 +1734,10 @@
1303
1734
  emit();
1304
1735
  });
1305
1736
  }),
1306
- ), signal);
1737
+ ]), signal);
1307
1738
  } finally {
1308
1739
  pool.terminate();
1740
+ if (hashwxPool) hashwxPool.terminate();
1309
1741
  }
1310
1742
 
1311
1743
  return solutions;
@@ -1452,8 +1884,6 @@
1452
1884
  e.preventDefault();
1453
1885
  window.open(
1454
1886
  `https://trycap.dev/?${new URLSearchParams(
1455
- // this attribution is only for our plausible analytics
1456
- // instance. no personal data is collected.
1457
1887
  {
1458
1888
  utm_source: "cap_widget",
1459
1889
  utm_medium: "referral",
@@ -1470,7 +1900,7 @@
1470
1900
  });
1471
1901
 
1472
1902
  this.#trigger.addEventListener("click", () => {
1473
- if (!this.#trigger.hasAttribute("disabled")) this.solve();
1903
+ if (!this.#trigger.hasAttribute("disabled")) this.solve().catch(() => {});
1474
1904
  });
1475
1905
  this.#trigger.addEventListener("mousedown", () => {
1476
1906
  if (!this.#trigger.hasAttribute("disabled") && this.#hasHaptics) {
@@ -1486,7 +1916,7 @@
1486
1916
  ) {
1487
1917
  e.preventDefault();
1488
1918
  e.stopPropagation();
1489
- this.solve();
1919
+ this.solve().catch(() => {});
1490
1920
  }
1491
1921
  });
1492
1922
 
@@ -1695,6 +2125,11 @@
1695
2125
  clearTimeout(this.#resetTimer);
1696
2126
  this.#resetTimer = null;
1697
2127
  }
2128
+ if (this.#speculativeTimer) {
2129
+ clearTimeout(this.#speculativeTimer);
2130
+ this.#speculativeTimer = null;
2131
+ }
2132
+ if (this.#speculative) this.#resetSpeculativeState();
1698
2133
  this.token = null;
1699
2134
  this.dispatchEvent("reset");
1700
2135
  this.#setToken("");
@@ -1706,6 +2141,7 @@
1706
2141
 
1707
2142
  disconnectedCallback() {
1708
2143
  this.#abort?.abort();
2144
+ document.removeEventListener("visibilitychange", this.#handleVisibility);
1709
2145
  this.removeEventListener("progress", this.boundHandleProgress);
1710
2146
  this.removeEventListener("solve", this.boundHandleSolve);
1711
2147
  this.removeEventListener("error", this.boundHandleError);
@@ -1740,6 +2176,7 @@
1740
2176
  this.#speculativePool = null;
1741
2177
  }
1742
2178
  if (this.#speculative) {
2179
+ this.#dropChallenge(this.#speculative);
1743
2180
  this.#speculative.state = "error";
1744
2181
  this.#speculative.notify();
1745
2182
  this.#speculative = null;