@cap.js/widget 0.1.58 → 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
@@ -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, vp: [window.innerWidth, window.innerHeight] });
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;
@@ -326,6 +374,7 @@
326
374
  const SPECULATIVE_DELAY_MS = 2500;
327
375
  const SPECULATIVE_WORKERS = 1;
328
376
  const SPECULATIVE_YIELD_MS = 120;
377
+ const MAX_IDLE_REFRESHES = 6;
329
378
 
330
379
  let _sharedWorkerUrl = null;
331
380
 
@@ -510,6 +559,8 @@
510
559
  #interactionHandler = null;
511
560
  #i18n = null;
512
561
  #abort = null;
562
+ #idleRefreshes = 0;
563
+ #refreshOnVisible = false;
513
564
 
514
565
  get #hasHaptics() {
515
566
  return (
@@ -532,6 +583,9 @@
532
583
  pendingPromotion: null,
533
584
  token: null,
534
585
  tokenExpires: null,
586
+ fetchP: null,
587
+ refreshTimer: null,
588
+ redeemError: null,
535
589
 
536
590
  notify() {
537
591
  for (const fn of this._listeners) fn();
@@ -549,10 +603,136 @@
549
603
  }
550
604
 
551
605
  #resetSpeculativeState() {
606
+ this.#dropChallenge(this.#speculative);
552
607
  this.#speculative = this.#makeSpeculativeState();
553
608
  this.#attachInteractionListeners();
554
609
  }
555
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
+
556
736
  #detachInteractionListeners() {
557
737
  if (this.#interactionHandler) {
558
738
  window.removeEventListener("mousemove", this.#interactionHandler);
@@ -566,6 +746,7 @@
566
746
  this.#detachInteractionListeners();
567
747
  const handler = () => {
568
748
  this.#detachInteractionListeners();
749
+ this.#idleRefreshes = 0;
569
750
  this.#onFirstInteraction();
570
751
  };
571
752
  this.#interactionHandler = handler;
@@ -589,6 +770,7 @@
589
770
  }
590
771
 
591
772
  #onFirstInteraction() {
773
+ this.#prefetch();
592
774
  if (this.#speculative.state !== "idle") return;
593
775
  if (!this.#isVisible()) return;
594
776
  this.#speculative.state = "waiting";
@@ -598,43 +780,26 @@
598
780
  }
599
781
 
600
782
  async #beginSpeculativeSolve() {
601
- if (this.#speculative.state !== "waiting") return;
602
- this.#speculative.state = "fetching";
603
- 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();
604
787
 
605
- let apiEndpoint = this.getAttribute("data-cap-api-endpoint");
606
- if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
607
- apiEndpoint = "/";
608
- }
609
- if (!apiEndpoint) {
610
- this.#speculative.state = "idle";
788
+ if (!this.#endpoint()) {
789
+ spec.state = "idle";
611
790
  return;
612
791
  }
613
- if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
614
792
 
615
793
  try {
616
- const raw = await capFetch(`${apiEndpoint}challenge`, {
617
- method: "POST",
618
- signal: this.#abort?.signal,
619
- });
620
- let resp;
621
- try {
622
- resp = await raw.json();
623
- } catch {
624
- throw new Error("Failed to parse speculative challenge response");
625
- }
626
- if (resp.error) throw new Error(resp.error);
627
- if (!this.#speculative) return;
628
-
629
- resp._apiEndpoint = apiEndpoint;
630
- this.#speculative.challengeResp = resp;
794
+ const resp = await this.#fetchChallenge();
795
+ if (this.#speculative !== spec) return;
631
796
 
632
797
  if (resp.format === 2 && Array.isArray(resp.challenges)) {
633
798
  if (resp.challenges.some((ch) => ch?.protocol === "hashwx")) {
634
799
  getHashwxModule().catch(() => {});
635
800
  }
636
- this.#speculative.state = "idle";
637
- this.#speculative.notify();
801
+ spec.state = "idle";
802
+ spec.notify();
638
803
  return;
639
804
  }
640
805
 
@@ -650,24 +815,25 @@
650
815
  ];
651
816
  });
652
817
  }
653
- this.#speculative.challenges = challenges;
654
- this.#speculative.state = "solving";
818
+ spec.challenges = challenges;
819
+ spec.state = "solving";
655
820
 
656
- this.#speculative.solvePromise = this.#speculativeSolveAll(
821
+ spec.solvePromise = this.#speculativeSolveAll(
657
822
  challenges,
823
+ spec,
658
824
  ).catch(() => {
659
- if (!this.#speculative) return;
660
- this.#speculative.state = "error";
661
- this.#speculative.notify();
825
+ if (this.#speculative !== spec) return;
826
+ spec.state = "error";
827
+ spec.notify();
662
828
  });
663
829
  } catch {
664
- if (!this.#speculative) return;
665
- this.#speculative.state = "error";
666
- this.#speculative.notify();
830
+ if (this.#speculative !== spec) return;
831
+ spec.state = "error";
832
+ spec.notify();
667
833
  }
668
834
  }
669
835
 
670
- async #speculativeSolveAll(challenges) {
836
+ async #speculativeSolveAll(challenges, spec) {
671
837
  _getSharedWorkerUrl();
672
838
 
673
839
  let wasmModule = null;
@@ -675,7 +841,7 @@
675
841
  wasmModule = await getWasmModule();
676
842
  } catch {}
677
843
 
678
- if (!this.#speculative) return [];
844
+ if (this.#speculative !== spec) return [];
679
845
  if (!this.#speculativePool) {
680
846
  this.#speculativePool = new WorkerPool(1);
681
847
  this.#speculativePool._spawn();
@@ -688,7 +854,7 @@
688
854
  let concurrency = SPECULATIVE_WORKERS;
689
855
  let promoted = false;
690
856
 
691
- this.#speculative.promoteFn = (fullCount) => {
857
+ spec.promoteFn = (fullCount) => {
692
858
  if (promoted || !this.#speculativePool) return;
693
859
  promoted = true;
694
860
  concurrency = fullCount;
@@ -696,15 +862,15 @@
696
862
  this.#speculativePool._ensureSize(fullCount);
697
863
  };
698
864
 
699
- if (this.#speculative.pendingPromotion !== null) {
700
- this.#speculative.promoteFn(this.#speculative.pendingPromotion);
701
- this.#speculative.pendingPromotion = null;
865
+ if (spec.pendingPromotion !== null) {
866
+ spec.promoteFn(spec.pendingPromotion);
867
+ spec.pendingPromotion = null;
702
868
  }
703
869
 
704
870
  let nextIndex = 0;
705
871
 
706
872
  while (nextIndex < total) {
707
- if (!this.#speculative || !this.#speculativePool) return results;
873
+ if (this.#speculative !== spec || !this.#speculativePool) return results;
708
874
  const batchSize = concurrency;
709
875
  const batch = [];
710
876
  const batchIndices = [];
@@ -722,7 +888,7 @@
722
888
  this.#speculativePool
723
889
  .run(challenge[0], challenge[1])
724
890
  .then((nonce) => {
725
- if (this.#speculative) this.#speculative.completedCount++;
891
+ spec.completedCount++;
726
892
  return nonce;
727
893
  }),
728
894
  ),
@@ -739,30 +905,43 @@
739
905
  }
740
906
  }
741
907
 
742
- if (!this.#speculative) return results;
743
- this.#speculative.results = results;
744
- this.#speculative.state = "redeeming";
745
- this.#speculativeRedeem(results);
908
+ if (this.#speculative !== spec) return results;
909
+ spec.results = results;
910
+ spec.state = "redeeming";
911
+ this.#speculativeRedeem(results, spec);
746
912
  return results;
747
913
  }
748
914
 
749
- async #speculativeRedeem(solutions) {
915
+ async #speculativeRedeem(solutions, spec) {
916
+ let answered = false;
750
917
  try {
751
- if (!this.#speculative) return;
752
- const challengeResp = this.#speculative.challengeResp;
918
+ if (this.#speculative !== spec) return;
919
+ const challengeResp = spec.challengeResp;
753
920
  const apiEndpoint = challengeResp._apiEndpoint;
754
921
  if (!apiEndpoint)
755
922
  throw _err("missing_endpoint", "speculative redeem: missing apiEndpoint");
756
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
+
757
938
  let instrOut = null;
758
- if (challengeResp.instrumentation) {
759
- instrOut = await runInstrumentationChallenge(
760
- challengeResp.instrumentation,
761
- );
762
- if (!this.#speculative) return;
763
- if (instrOut?.__timeout || instrOut?.__blocked) {
764
- this.#speculative.state = "done";
765
- 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();
766
945
  return;
767
946
  }
768
947
  }
@@ -778,26 +957,28 @@
778
957
  signal: this.#abort?.signal,
779
958
  });
780
959
 
960
+ answered = true;
781
961
  let resp;
782
962
  try {
783
963
  resp = await redeemRaw.json();
784
964
  } catch {
785
- throw new Error("Failed to parse speculative redeem response");
965
+ throw new Error("Failed to parse server response");
786
966
  }
787
967
 
788
- if (!this.#speculative) return;
968
+ if (this.#speculative !== spec) return;
789
969
  if (!resp.success)
790
- throw new Error(resp.error || "Speculative redeem failed");
791
-
792
- this.#speculative.token = resp.token;
793
- this.#speculative.tokenExpires = new Date(resp.expires).getTime();
794
- this.#speculative.state = "done";
795
- this.#speculative._invisibleElapsed = this.#speculative._t0 ? since(this.#speculative._t0) : "?";
796
- this.#speculative.notify();
797
- } catch {
798
- if (!this.#speculative) return;
799
- this.#speculative.state = "done";
800
- 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();
801
982
  }
802
983
  }
803
984
 
@@ -970,11 +1151,19 @@
970
1151
 
971
1152
  log.debug(T(), `widget ready (workers=${this.#workersCount}, haptics=${this.#hasHaptics})`);
972
1153
  this.#attachInteractionListeners();
1154
+ this.#prefetch();
973
1155
  this.#updateValidity();
974
1156
 
975
1157
  this.addEventListener("invalid", this.#handleInvalid);
1158
+ document.addEventListener("visibilitychange", this.#handleVisibility);
976
1159
  }
977
1160
 
1161
+ #handleVisibility = () => {
1162
+ if (document.hidden || !this.#refreshOnVisible) return;
1163
+ this.#refreshOnVisible = false;
1164
+ this.#prefetch();
1165
+ };
1166
+
978
1167
  #handleInvalid = () => {
979
1168
  if (!this.#div) return;
980
1169
  try {
@@ -995,6 +1184,8 @@
995
1184
 
996
1185
  this.#enforceCredits();
997
1186
  const _solveT0 = performance.now();
1187
+ let usedResp = null;
1188
+ let reported = null;
998
1189
  const signal = this.#abort?.signal;
999
1190
  log.debug(T("solve"), "starting");
1000
1191
 
@@ -1038,6 +1229,10 @@
1038
1229
  return this.#commitSpeculativeToken();
1039
1230
  }
1040
1231
 
1232
+ if (this.#speculative.redeemError) {
1233
+ throw _err("invalid_solution", this.#speculative.redeemError);
1234
+ }
1235
+
1041
1236
  if (this.#speculative.state === "done") {
1042
1237
  solutions = this.#speculative.results;
1043
1238
  challengeResp = this.#speculative.challengeResp;
@@ -1096,11 +1291,12 @@
1096
1291
  this.#speculative.challengeResp?.format === 2 &&
1097
1292
  Array.isArray(this.#speculative.challengeResp.challenges)
1098
1293
  ) {
1099
- challengeResp = this.#speculative.challengeResp;
1100
- this.#speculative.challengeResp = null;
1294
+ challengeResp = this.#takeChallenge();
1295
+ usedResp = challengeResp;
1101
1296
  solutions = await this.solveChallengesV2(
1102
1297
  challengeResp.challenges,
1103
1298
  signal,
1299
+ challengeResp,
1104
1300
  );
1105
1301
  } else {
1106
1302
  if (this.#speculative.state !== "done") {
@@ -1115,27 +1311,22 @@
1115
1311
  return this.#commitSpeculativeToken();
1116
1312
  }
1117
1313
 
1314
+ if (this.#speculative.redeemError) {
1315
+ throw _err("invalid_solution", this.#speculative.redeemError);
1316
+ }
1317
+
1118
1318
  solutions = this.#speculative.results;
1119
1319
  challengeResp = this.#speculative.challengeResp;
1120
1320
  this.dispatchEvent("progress", { progress: 100 });
1121
1321
  }
1122
1322
  } else {
1123
- const cached = this.#speculative.challengeResp;
1124
- if (cached?.format === 2 && Array.isArray(cached.challenges)) {
1125
- challengeResp = cached;
1126
- this.#speculative.challengeResp = null;
1127
- } else {
1128
- const challengeRaw = await capFetch(`${apiEndpoint}challenge`, {
1129
- method: "POST",
1130
- signal,
1131
- });
1132
- try {
1133
- challengeResp = await challengeRaw.json();
1134
- } catch {
1135
- throw _err("challenge_parse_error", "Failed to parse challenge response from server");
1136
- }
1137
- if (challengeResp.error) throw _err("network_error", challengeResp.error);
1323
+ if (!this.#speculative.challengeResp) {
1324
+ await this.#fetchChallenge(signal);
1138
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;
1139
1330
 
1140
1331
  if (
1141
1332
  challengeResp.format === 2 &&
@@ -1144,6 +1335,7 @@
1144
1335
  solutions = await this.solveChallengesV2(
1145
1336
  challengeResp.challenges,
1146
1337
  signal,
1338
+ challengeResp,
1147
1339
  );
1148
1340
  } else {
1149
1341
  const { challenge, token } = challengeResp;
@@ -1162,29 +1354,47 @@
1162
1354
  }
1163
1355
  }
1164
1356
 
1165
- const instrPromise = challengeResp.instrumentation
1166
- ? runInstrumentationChallenge(challengeResp.instrumentation)
1167
- : Promise.resolve(null);
1168
-
1169
- const instrOut = await instrPromise;
1357
+ usedResp = challengeResp;
1358
+ const v2 = challengeResp.format === 2;
1359
+ const instrOut = v2 ? null : await this.#finalizeInstr(challengeResp);
1170
1360
  if (signal?.aborted || !this.#speculative) return;
1171
1361
 
1172
- 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) {
1173
1376
  capFetch(`${apiEndpoint}redeem`, {
1174
1377
  method: "POST",
1175
1378
  body: JSON.stringify({
1176
1379
  token: challengeResp.token,
1177
1380
  solutions,
1178
- ...(instrOut.__blocked && { instr_blocked: true }),
1179
- ...(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 }),
1180
1384
  }),
1181
1385
  headers: { "Content-Type": "application/json" },
1182
1386
  signal,
1183
1387
  }).catch(() => {});
1184
1388
 
1389
+ const csp = instrFail.__error === "csp_blocked";
1185
1390
  this.updateUIBlocked(
1186
- this.getI18nText("error-label", "Error"),
1187
- 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,
1188
1398
  );
1189
1399
  this.#trigger.setAttribute(
1190
1400
  "aria-label",
@@ -1193,10 +1403,18 @@
1193
1403
  "An error occurred, please try again",
1194
1404
  ),
1195
1405
  );
1196
- const instrCode = instrOut?.__blocked ? "instr_blocked" : "instr_timeout";
1197
- const instrMsg = instrOut?.__blocked
1198
- ? `Instrumentation blocked (${instrOut.blockReason || "automated_browser"})`
1199
- : "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";
1200
1418
  this.removeEventListener("error", this.boundHandleError);
1201
1419
  const errEvent = new CustomEvent("error", {
1202
1420
  bubbles: true,
@@ -1206,9 +1424,15 @@
1206
1424
  super.dispatchEvent(errEvent);
1207
1425
  this.addEventListener("error", this.boundHandleError);
1208
1426
  this.executeAttributeCode("onerror", errEvent);
1209
- log.error(T("instr"), `[${instrCode}] ${instrMsg}`);
1210
- this.#solving = false;
1211
- 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;
1212
1436
  }
1213
1437
 
1214
1438
  const { token } = challengeResp;
@@ -1265,6 +1489,7 @@
1265
1489
  return { success: true, token: this.token };
1266
1490
  } catch (err) {
1267
1491
  if (signal?.aborted || !this.#speculative) return;
1492
+ if (err === reported) throw err;
1268
1493
  this.#trigger.setAttribute(
1269
1494
  "aria-label",
1270
1495
  this.getI18nText(
@@ -1273,14 +1498,17 @@
1273
1498
  ),
1274
1499
  );
1275
1500
  this.error(err.message, err.code || "solve_failed");
1501
+ this.#resetSpeculativeState();
1276
1502
  throw err;
1277
1503
  }
1278
1504
  } finally {
1505
+ usedResp?._instr?.stop();
1279
1506
  this.#solving = false;
1280
1507
  }
1281
1508
  }
1282
1509
 
1283
- async solveChallengesV2(challenges, signal) {
1510
+ async solveChallengesV2(challenges, signal, resp) {
1511
+ const owner = resp ?? { format: 2, challenges };
1284
1512
  const total = challenges.length;
1285
1513
  let completed = 0;
1286
1514
 
@@ -1496,8 +1724,9 @@
1496
1724
 
1497
1725
  if (ch.protocol === "hashwx") return null;
1498
1726
 
1499
- return runInstrumentationChallenge(ch.payload.blob).then((out) => {
1500
- if (out?.__timeout) solutions[idx] = { timeout: true };
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 };
1501
1730
  else if (out?.__blocked) solutions[idx] = { blocked: true };
1502
1731
  else solutions[idx] = { instr: out };
1503
1732
  inFlight[idx] = 0;
@@ -1655,8 +1884,6 @@
1655
1884
  e.preventDefault();
1656
1885
  window.open(
1657
1886
  `https://trycap.dev/?${new URLSearchParams(
1658
- // this attribution is only for our plausible analytics
1659
- // instance. no personal data is collected.
1660
1887
  {
1661
1888
  utm_source: "cap_widget",
1662
1889
  utm_medium: "referral",
@@ -1673,7 +1900,7 @@
1673
1900
  });
1674
1901
 
1675
1902
  this.#trigger.addEventListener("click", () => {
1676
- if (!this.#trigger.hasAttribute("disabled")) this.solve();
1903
+ if (!this.#trigger.hasAttribute("disabled")) this.solve().catch(() => {});
1677
1904
  });
1678
1905
  this.#trigger.addEventListener("mousedown", () => {
1679
1906
  if (!this.#trigger.hasAttribute("disabled") && this.#hasHaptics) {
@@ -1689,7 +1916,7 @@
1689
1916
  ) {
1690
1917
  e.preventDefault();
1691
1918
  e.stopPropagation();
1692
- this.solve();
1919
+ this.solve().catch(() => {});
1693
1920
  }
1694
1921
  });
1695
1922
 
@@ -1914,6 +2141,7 @@
1914
2141
 
1915
2142
  disconnectedCallback() {
1916
2143
  this.#abort?.abort();
2144
+ document.removeEventListener("visibilitychange", this.#handleVisibility);
1917
2145
  this.removeEventListener("progress", this.boundHandleProgress);
1918
2146
  this.removeEventListener("solve", this.boundHandleSolve);
1919
2147
  this.removeEventListener("error", this.boundHandleError);
@@ -1948,6 +2176,7 @@
1948
2176
  this.#speculativePool = null;
1949
2177
  }
1950
2178
  if (this.#speculative) {
2179
+ this.#dropChallenge(this.#speculative);
1951
2180
  this.#speculative.state = "error";
1952
2181
  this.#speculative.notify();
1953
2182
  this.#speculative = null;