@cap.js/widget 0.1.36 → 0.1.38

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
@@ -54,6 +54,104 @@
54
54
  return result.substring(0, length);
55
55
  }
56
56
 
57
+ async function runInstrumentationChallenge(instrBytes) {
58
+ const b64ToUint8 = (b64) => {
59
+ const bin = atob(b64);
60
+ const arr = new Uint8Array(bin.length);
61
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
62
+ return arr;
63
+ };
64
+
65
+ var compressed = b64ToUint8(instrBytes);
66
+
67
+ const scriptText = await new Promise((resolve, reject) => {
68
+ try {
69
+ var ds = new DecompressionStream("deflate-raw");
70
+ var writer = ds.writable.getWriter();
71
+ var reader = ds.readable.getReader();
72
+ var chunks = [];
73
+ function pump(res) {
74
+ if (res.done) {
75
+ var len = 0,
76
+ off = 0;
77
+ for (var i = 0; i < chunks.length; i++) len += chunks[i].length;
78
+ var out = new Uint8Array(len);
79
+ for (var i = 0; i < chunks.length; i++) {
80
+ out.set(chunks[i], off);
81
+ off += chunks[i].length;
82
+ }
83
+ resolve(new TextDecoder().decode(out));
84
+ } else {
85
+ chunks.push(res.value);
86
+ reader.read().then(pump).catch(reject);
87
+ }
88
+ }
89
+ reader.read().then(pump).catch(reject);
90
+ writer
91
+ .write(compressed)
92
+ .then(() => {
93
+ writer.close();
94
+ })
95
+ .catch(reject);
96
+ } catch (e) {
97
+ reject(e);
98
+ }
99
+ });
100
+
101
+ return new Promise((resolve) => {
102
+ var timeout = setTimeout(() => {
103
+ cleanup();
104
+ resolve({ __timeout: true });
105
+ }, 20000);
106
+
107
+ var iframe = document.createElement("iframe");
108
+ iframe.setAttribute("sandbox", "allow-scripts");
109
+ iframe.setAttribute("aria-hidden", "true");
110
+ iframe.style.cssText =
111
+ "position:absolute;width:1px;height:1px;top:-9999px;left:-9999px;border:none;opacity:0;pointer-events:none;";
112
+
113
+ var resolved = false;
114
+ function cleanup() {
115
+ if (resolved) return;
116
+ resolved = true;
117
+ clearTimeout(timeout);
118
+ window.removeEventListener("message", handler);
119
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
120
+ }
121
+
122
+ function handler(ev) {
123
+ var d = ev.data;
124
+ if (!d || typeof d !== "object") return;
125
+ if (d.type === "cap:instr") {
126
+ cleanup();
127
+ if (d.blocked) {
128
+ resolve({
129
+ __blocked: true,
130
+ blockReason: d.blockReason || "automated_browser",
131
+ });
132
+ } else if (d.result) {
133
+ resolve(d.result);
134
+ } else {
135
+ resolve({ __timeout: true });
136
+ }
137
+ } else if (d.type === "cap:error") {
138
+ cleanup();
139
+ resolve({ __timeout: true });
140
+ }
141
+ }
142
+
143
+ window.addEventListener("message", handler);
144
+
145
+ iframe.srcdoc =
146
+ '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><script>' +
147
+ scriptText +
148
+ "\n</scr" +
149
+ "ipt></body></html>";
150
+
151
+ document.body.appendChild(iframe);
152
+ });
153
+ }
154
+
57
155
  let wasmModulePromise = null;
58
156
 
59
157
  const getWasmModule = () => {
@@ -84,8 +182,382 @@
84
182
  const prefersReducedMotion = () =>
85
183
  window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
86
184
 
185
+ const SPECULATIVE_DELAY_MS = 2500;
186
+ const SPECULATIVE_WORKERS = 1;
187
+ const SPECULATIVE_YIELD_MS = 120;
188
+
189
+ const speculative = {
190
+ state: "idle",
191
+ challengeResp: null,
192
+ challenges: null,
193
+ results: [],
194
+ completedCount: 0,
195
+ solvePromise: null,
196
+ promoteFn: null,
197
+ _listeners: [],
198
+ pendingPromotion: null,
199
+ token: null,
200
+ tokenExpires: null,
201
+
202
+ notify() {
203
+ for (const fn of this._listeners) fn();
204
+ this._listeners = [];
205
+ },
206
+
207
+ onSettled(fn) {
208
+ if (this.state === "done" || this.state === "error") {
209
+ fn();
210
+ } else {
211
+ this._listeners.push(fn);
212
+ }
213
+ },
214
+ };
215
+
216
+ function _resetSpeculativeState() {
217
+ speculative.state = "idle";
218
+ speculative.challengeResp = null;
219
+ speculative.challenges = null;
220
+ speculative.results = [];
221
+ speculative.completedCount = 0;
222
+ speculative.solvePromise = null;
223
+ speculative.promoteFn = null;
224
+ speculative.pendingPromotion = null;
225
+ speculative._listeners = [];
226
+ speculative.token = null;
227
+ speculative.tokenExpires = null;
228
+
229
+ _attachInteractionListeners();
230
+ }
231
+
232
+ let _speculativeTimer = null;
233
+
234
+ function _onFirstInteraction() {
235
+ if (speculative.state !== "idle") return;
236
+ speculative.state = "waiting";
237
+
238
+ _speculativeTimer = setTimeout(() => {
239
+ _beginSpeculativeSolve();
240
+ }, SPECULATIVE_DELAY_MS);
241
+ }
242
+
243
+ let _currentInteractionHandler = null;
244
+
245
+ function _detachInteractionListeners() {
246
+ if (_currentInteractionHandler) {
247
+ window.removeEventListener("mousemove", _currentInteractionHandler);
248
+ window.removeEventListener("touchstart", _currentInteractionHandler);
249
+ window.removeEventListener("keydown", _currentInteractionHandler);
250
+ _currentInteractionHandler = null;
251
+ }
252
+ }
253
+
254
+ function _attachInteractionListeners() {
255
+ _detachInteractionListeners();
256
+
257
+ const handler = () => {
258
+ _detachInteractionListeners();
259
+ _onFirstInteraction();
260
+ };
261
+ _currentInteractionHandler = handler;
262
+ window.addEventListener("mousemove", handler, { passive: true });
263
+ window.addEventListener("touchstart", handler, { passive: true });
264
+ window.addEventListener("keydown", handler, { passive: true });
265
+ }
266
+
267
+ _attachInteractionListeners();
268
+
269
+ async function _beginSpeculativeSolve() {
270
+ if (speculative.state !== "waiting") return;
271
+ speculative.state = "fetching";
272
+
273
+ const widget = document.querySelector("cap-widget");
274
+ if (!widget) {
275
+ speculative.state = "idle";
276
+ return;
277
+ }
278
+
279
+ let apiEndpoint = widget.getAttribute("data-cap-api-endpoint");
280
+ if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
281
+ apiEndpoint = "/";
282
+ }
283
+ if (!apiEndpoint) {
284
+ speculative.state = "idle";
285
+ return;
286
+ }
287
+ if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
288
+
289
+ try {
290
+ const raw = await capFetch(`${apiEndpoint}challenge`, { method: "POST" });
291
+ let resp;
292
+ try {
293
+ resp = await raw.json();
294
+ } catch {
295
+ throw new Error("Failed to parse speculative challenge response");
296
+ }
297
+ if (resp.error) throw new Error(resp.error);
298
+
299
+ resp._apiEndpoint = apiEndpoint;
300
+ speculative.challengeResp = resp;
301
+
302
+ const { challenge, token } = resp;
303
+ let challenges = challenge;
304
+ if (!Array.isArray(challenges)) {
305
+ let i = 0;
306
+ challenges = Array.from({ length: challenge.c }, () => {
307
+ i++;
308
+ return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
309
+ });
310
+ }
311
+ speculative.challenges = challenges;
312
+ speculative.state = "solving";
313
+
314
+ speculative.solvePromise = _speculativeSolveAll(challenges);
315
+ } catch (e) {
316
+ console.warn("[cap] speculative challenge fetch failed:", e);
317
+ speculative.state = "error";
318
+ speculative.notify();
319
+ }
320
+ }
321
+
322
+ async function _speculativeSolveAll(challenges) {
323
+ _getSharedWorkerUrl();
324
+
325
+ let wasmModule = null;
326
+ try {
327
+ wasmModule = await getWasmModule();
328
+ } catch {}
329
+
330
+ _speculativePool.setWasm(wasmModule);
331
+
332
+ const total = challenges.length;
333
+ const results = new Array(total);
334
+
335
+ let concurrency = SPECULATIVE_WORKERS;
336
+ let promoted = false;
337
+
338
+ speculative.promoteFn = (fullCount) => {
339
+ if (promoted) return;
340
+ promoted = true;
341
+ concurrency = fullCount;
342
+ _speculativePool._size = fullCount;
343
+ _speculativePool._ensureSize(fullCount);
344
+ };
345
+
346
+ if (speculative.pendingPromotion !== null) {
347
+ speculative.promoteFn(speculative.pendingPromotion);
348
+ speculative.pendingPromotion = null;
349
+ }
350
+
351
+ let nextIndex = 0;
352
+
353
+ while (nextIndex < total) {
354
+ const batchSize = concurrency;
355
+ const batch = [];
356
+ const batchIndices = [];
357
+
358
+ for (let i = 0; i < batchSize && nextIndex < total; i++) {
359
+ batchIndices.push(nextIndex);
360
+ batch.push(challenges[nextIndex]);
361
+ nextIndex++;
362
+ }
363
+
364
+ _speculativePool._ensureSize(Math.max(concurrency, batchSize));
365
+
366
+ const batchResults = await Promise.all(
367
+ batch.map((challenge) =>
368
+ _speculativePool.run(challenge[0], challenge[1]).then((nonce) => {
369
+ speculative.completedCount++;
370
+ return nonce;
371
+ }),
372
+ ),
373
+ );
374
+
375
+ for (let i = 0; i < batchIndices.length; i++) {
376
+ results[batchIndices[i]] = batchResults[i];
377
+ }
378
+
379
+ if (!promoted && nextIndex < total) {
380
+ await new Promise((resolve) => setTimeout(resolve, SPECULATIVE_YIELD_MS));
381
+ }
382
+ }
383
+
384
+ speculative.results = results;
385
+ speculative.state = "redeeming";
386
+ _speculativeRedeem(results);
387
+ return results;
388
+ }
389
+
390
+ async function _speculativeRedeem(solutions) {
391
+ try {
392
+ const challengeResp = speculative.challengeResp;
393
+ const apiEndpoint = challengeResp._apiEndpoint;
394
+ if (!apiEndpoint) throw new Error("[cap] speculative redeem: missing apiEndpoint");
395
+
396
+ let instrOut = null;
397
+ if (challengeResp.instrumentation) {
398
+ instrOut = await runInstrumentationChallenge(challengeResp.instrumentation);
399
+ if (instrOut?.__timeout || instrOut?.__blocked) {
400
+ speculative.state = "done";
401
+ speculative.notify();
402
+ return;
403
+ }
404
+ }
405
+
406
+ const redeemRaw = await capFetch(`${apiEndpoint}redeem`, {
407
+ method: "POST",
408
+ body: JSON.stringify({
409
+ token: challengeResp.token,
410
+ solutions,
411
+ ...(instrOut && { instr: instrOut }),
412
+ }),
413
+ headers: { "Content-Type": "application/json" },
414
+ });
415
+
416
+ let resp;
417
+ try {
418
+ resp = await redeemRaw.json();
419
+ } catch {
420
+ throw new Error("Failed to parse speculative redeem response");
421
+ }
422
+
423
+ if (!resp.success) throw new Error(resp.error || "Speculative redeem failed");
424
+
425
+ speculative.token = resp.token;
426
+ speculative.tokenExpires = new Date(resp.expires).getTime();
427
+ speculative.state = "done";
428
+ speculative.notify();
429
+ } catch (e) {
430
+ console.warn("[cap] speculative redeem failed (will redo on click):", e);
431
+ speculative.state = "done";
432
+ speculative.notify();
433
+ }
434
+ }
435
+
436
+ let _sharedWorkerUrl = null;
437
+
438
+ function _getSharedWorkerUrl() {
439
+ if (_sharedWorkerUrl) return _sharedWorkerUrl;
440
+
441
+ _sharedWorkerUrl = URL.createObjectURL(
442
+ new Blob([`%%workerScript%%`], { type: "application/javascript" }),
443
+ );
444
+ return _sharedWorkerUrl;
445
+ }
446
+
447
+ class WorkerPool {
448
+ constructor(size) {
449
+ this._size = size;
450
+ this._workers = [];
451
+ this._idle = [];
452
+ this._queue = [];
453
+ this._wasmModule = null;
454
+ this._spawnFailures = 0;
455
+ }
456
+
457
+ setWasm(wasmModule) {
458
+ this._wasmModule = wasmModule;
459
+ }
460
+
461
+ _spawn() {
462
+ const url = _getSharedWorkerUrl();
463
+ const w = new Worker(url);
464
+ w._busy = false;
465
+ this._workers.push(w);
466
+ this._idle.push(w);
467
+ return w;
468
+ }
469
+
470
+ _replaceWorker(deadWorker) {
471
+ const idx = this._workers.indexOf(deadWorker);
472
+ if (idx !== -1) this._workers.splice(idx, 1);
473
+ const idleIdx = this._idle.indexOf(deadWorker);
474
+ if (idleIdx !== -1) this._idle.splice(idleIdx, 1);
475
+
476
+ try {
477
+ deadWorker.terminate();
478
+ } catch {}
479
+
480
+ this._spawnFailures++;
481
+ if (this._spawnFailures > 3) {
482
+ console.error("[cap] worker spawn failed repeatedly, not retrying");
483
+ return null;
484
+ }
485
+
486
+ return this._spawn();
487
+ }
488
+
489
+ _ensureSize(n) {
490
+ while (this._workers.length < n) this._spawn();
491
+ }
492
+
493
+ run(salt, target) {
494
+ return new Promise((resolve, reject) => {
495
+ this._queue.push({ salt, target, resolve, reject });
496
+ this._dispatch();
497
+ });
498
+ }
499
+
500
+ _dispatch() {
501
+ while (this._idle.length > 0 && this._queue.length > 0) {
502
+ const worker = this._idle.shift();
503
+ const { salt, target, resolve, reject } = this._queue.shift();
504
+
505
+ let settled = false;
506
+
507
+ const onMessage = ({ data }) => {
508
+ if (settled) return;
509
+ settled = true;
510
+ worker.removeEventListener("message", onMessage);
511
+ worker.removeEventListener("error", onError);
512
+ this._spawnFailures = 0;
513
+ this._idle.push(worker);
514
+ if (!data.found) {
515
+ reject(new Error(data.error || "worker failed"));
516
+ } else {
517
+ resolve(data.nonce);
518
+ }
519
+ this._dispatch();
520
+ };
521
+
522
+ const onError = (err) => {
523
+ if (settled) return;
524
+ settled = true;
525
+ worker.removeEventListener("message", onMessage);
526
+ worker.removeEventListener("error", onError);
527
+ const replacement = this._replaceWorker(worker);
528
+ reject(err);
529
+ if (replacement) {
530
+ this._dispatch();
531
+ }
532
+ };
533
+
534
+ worker.addEventListener("message", onMessage);
535
+ worker.addEventListener("error", onError);
536
+
537
+ if (this._wasmModule) {
538
+ worker.postMessage({ salt, target, wasmModule: this._wasmModule }, []);
539
+ } else {
540
+ worker.postMessage({ salt, target });
541
+ }
542
+ }
543
+ }
544
+
545
+ terminate() {
546
+ for (const w of this._workers) {
547
+ try {
548
+ w.terminate();
549
+ } catch {}
550
+ }
551
+ this._workers = [];
552
+ this._idle = [];
553
+ this._queue = [];
554
+ }
555
+ }
556
+
557
+ const _speculativePool = new WorkerPool(1);
558
+ _speculativePool._spawn();
559
+
87
560
  class CapWidget extends HTMLElement {
88
- #workerUrl = "";
89
561
  #resetTimer = null;
90
562
  #workersCount = navigator.hardwareConcurrency || 8;
91
563
  token = null;
@@ -126,13 +598,7 @@
126
598
  }
127
599
 
128
600
  initialize() {
129
- this.#workerUrl = URL.createObjectURL(
130
- // this placeholder will be replaced with the actual worker by the build script
131
-
132
- new Blob([`%%workerScript%%`], {
133
- type: "application/javascript",
134
- }),
135
- );
601
+ _getSharedWorkerUrl();
136
602
  }
137
603
 
138
604
  attributeChangedCallback(name, _, value) {
@@ -182,6 +648,15 @@
182
648
  this.setWorkersCount(parsedWorkers || navigator.hardwareConcurrency || 8);
183
649
  const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
184
650
  this.#host.innerHTML = `<input type="hidden" name="${fieldName}">`;
651
+
652
+ if (speculative.state === "idle" || speculative.state === "waiting") {
653
+ if (_speculativeTimer) {
654
+ clearTimeout(_speculativeTimer);
655
+ _speculativeTimer = null;
656
+ }
657
+ speculative.state = "waiting";
658
+ _speculativeTimer = setTimeout(() => _beginSpeculativeSolve(), SPECULATIVE_DELAY_MS);
659
+ }
185
660
  }
186
661
 
187
662
  async solve() {
@@ -192,59 +667,209 @@
192
667
  try {
193
668
  this.#solving = true;
194
669
  this.updateUI("verifying", this.getI18nText("verifying-label", "Verifying..."), true);
195
-
196
670
  this.#div.setAttribute(
197
671
  "aria-label",
198
672
  this.getI18nText("verifying-aria-label", "Verifying you're a human, please wait"),
199
673
  );
200
-
201
674
  this.dispatchEvent("progress", { progress: 0 });
202
675
 
203
676
  try {
204
677
  let apiEndpoint = this.getAttribute("data-cap-api-endpoint");
205
-
206
678
  if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) {
207
679
  apiEndpoint = "/";
208
- } else if (!apiEndpoint)
680
+ } else if (!apiEndpoint) {
209
681
  throw new Error(
210
682
  "Missing API endpoint. Either custom fetch or an API endpoint must be provided.",
211
683
  );
684
+ }
685
+ if (!apiEndpoint.endsWith("/")) apiEndpoint += "/";
686
+
687
+ let solutions;
688
+ let challengeResp;
689
+
690
+ if (
691
+ speculative.state === "done" &&
692
+ speculative.token &&
693
+ speculative.tokenExpires &&
694
+ Date.now() < speculative.tokenExpires
695
+ ) {
696
+ this.dispatchEvent("progress", { progress: 100 });
697
+
698
+ const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
699
+ if (this.querySelector(`input[name='${fieldName}']`)) {
700
+ this.querySelector(`input[name='${fieldName}']`).value = speculative.token;
701
+ }
702
+ this.dispatchEvent("solve", { token: speculative.token });
703
+ this.token = speculative.token;
704
+
705
+ const expiresIn = speculative.tokenExpires - Date.now();
706
+ if (this.#resetTimer) clearTimeout(this.#resetTimer);
707
+ this.#resetTimer = setTimeout(() => this.reset(), expiresIn);
708
+
709
+ this.#div.setAttribute(
710
+ "aria-label",
711
+ this.getI18nText(
712
+ "verified-aria-label",
713
+ "We have verified you're a human, you may now continue",
714
+ ),
715
+ );
212
716
 
213
- if (!apiEndpoint.endsWith("/")) {
214
- apiEndpoint += "/";
717
+ _resetSpeculativeState();
718
+ this.#solving = false;
719
+ return;
215
720
  }
216
721
 
217
- const { challenge, token } = await (
218
- await capFetch(`${apiEndpoint}challenge`, {
722
+ if (speculative.state === "done") {
723
+ solutions = speculative.results;
724
+ challengeResp = speculative.challengeResp;
725
+ this.dispatchEvent("progress", { progress: 100 });
726
+ } else if (
727
+ speculative.state === "solving" ||
728
+ speculative.state === "redeeming" ||
729
+ speculative.state === "fetching" ||
730
+ speculative.state === "waiting"
731
+ ) {
732
+ if (speculative.state === "waiting") {
733
+ if (_speculativeTimer) {
734
+ clearTimeout(_speculativeTimer);
735
+ _speculativeTimer = null;
736
+ }
737
+ speculative.state = "waiting";
738
+ _beginSpeculativeSolve();
739
+ }
740
+
741
+ speculative.pendingPromotion = this.#workersCount;
742
+ if (speculative.promoteFn) {
743
+ speculative.promoteFn(this.#workersCount);
744
+ }
745
+
746
+ const progressInterval = setInterval(() => {
747
+ if (speculative.state !== "solving" && speculative.state !== "redeeming") {
748
+ clearInterval(progressInterval);
749
+ return;
750
+ }
751
+ const total = speculative.challenges ? speculative.challenges.length : 1;
752
+ const done = speculative.completedCount;
753
+ const visual =
754
+ speculative.state === "redeeming"
755
+ ? 99
756
+ : Math.min(98, Math.round((done / total) * 100));
757
+ this.dispatchEvent("progress", { progress: visual });
758
+ }, 150);
759
+
760
+ await new Promise((resolve) => speculative.onSettled(resolve));
761
+ clearInterval(progressInterval);
762
+
763
+ if (speculative.state !== "done") {
764
+ throw new Error("Speculative solve failed – please try again");
765
+ }
766
+
767
+ if (
768
+ speculative.token &&
769
+ speculative.tokenExpires &&
770
+ Date.now() < speculative.tokenExpires
771
+ ) {
772
+ this.dispatchEvent("progress", { progress: 100 });
773
+
774
+ const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
775
+ if (this.querySelector(`input[name='${fieldName}']`)) {
776
+ this.querySelector(`input[name='${fieldName}']`).value = speculative.token;
777
+ }
778
+ this.dispatchEvent("solve", { token: speculative.token });
779
+ this.token = speculative.token;
780
+
781
+ const expiresIn = speculative.tokenExpires - Date.now();
782
+ if (this.#resetTimer) clearTimeout(this.#resetTimer);
783
+ this.#resetTimer = setTimeout(() => this.reset(), expiresIn);
784
+
785
+ this.#div.setAttribute(
786
+ "aria-label",
787
+ this.getI18nText(
788
+ "verified-aria-label",
789
+ "We have verified you're a human, you may now continue",
790
+ ),
791
+ );
792
+
793
+ _resetSpeculativeState();
794
+ this.#solving = false;
795
+ return;
796
+ }
797
+
798
+ solutions = speculative.results;
799
+ challengeResp = speculative.challengeResp;
800
+ this.dispatchEvent("progress", { progress: 100 });
801
+ } else {
802
+ const challengeRaw = await capFetch(`${apiEndpoint}challenge`, {
219
803
  method: "POST",
220
- })
221
- ).json();
804
+ });
805
+ try {
806
+ challengeResp = await challengeRaw.json();
807
+ } catch {
808
+ throw new Error("Failed to parse challenge response from server");
809
+ }
810
+ if (challengeResp.error) throw new Error(challengeResp.error);
811
+
812
+ const { challenge, token } = challengeResp;
813
+ let challenges = challenge;
814
+ if (!Array.isArray(challenges)) {
815
+ let i = 0;
816
+ challenges = Array.from({ length: challenge.c }, () => {
817
+ i++;
818
+ return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
819
+ });
820
+ }
222
821
 
223
- let challenges = challenge;
822
+ solutions = await this.solveChallenges(challenges);
823
+ }
224
824
 
225
- if (!Array.isArray(challenges)) {
226
- let i = 0;
825
+ const instrPromise = challengeResp.instrumentation
826
+ ? runInstrumentationChallenge(challengeResp.instrumentation)
827
+ : Promise.resolve(null);
227
828
 
228
- challenges = Array.from({ length: challenge.c }, () => {
229
- i = i + 1;
829
+ const instrOut = await instrPromise;
230
830
 
231
- return [prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d)];
831
+ if (instrOut?.__timeout || instrOut?.__blocked) {
832
+ this.updateUIBlocked(this.getI18nText("error-label", "Error"), instrOut?.__blocked);
833
+ this.#div.setAttribute(
834
+ "aria-label",
835
+ this.getI18nText("error-aria-label", "An error occurred, please try again"),
836
+ );
837
+ this.removeEventListener("error", this.boundHandleError);
838
+ const errEvent = new CustomEvent("error", {
839
+ bubbles: true,
840
+ composed: true,
841
+ detail: { isCap: true, message: "Instrumentation failed" },
232
842
  });
843
+ super.dispatchEvent(errEvent);
844
+ this.addEventListener("error", this.boundHandleError);
845
+ this.executeAttributeCode("onerror", errEvent);
846
+ console.error("[cap]", "Instrumentation failed");
847
+ this.#solving = false;
848
+ return;
233
849
  }
234
850
 
235
- const solutions = await this.solveChallenges(challenges);
851
+ const { token } = challengeResp;
236
852
 
237
- const resp = await (
238
- await capFetch(`${apiEndpoint}redeem`, {
239
- method: "POST",
240
- body: JSON.stringify({ token, solutions }),
241
- headers: { "Content-Type": "application/json" },
242
- })
243
- ).json();
853
+ const redeemResponse = await capFetch(`${apiEndpoint}redeem`, {
854
+ method: "POST",
855
+ body: JSON.stringify({
856
+ token,
857
+ solutions,
858
+ ...(instrOut && { instr: instrOut }),
859
+ }),
860
+ headers: { "Content-Type": "application/json" },
861
+ });
862
+
863
+ let resp;
864
+ try {
865
+ resp = await redeemResponse.json();
866
+ } catch {
867
+ throw new Error("Failed to parse server response");
868
+ }
244
869
 
245
870
  this.dispatchEvent("progress", { progress: 100 });
871
+ if (!resp.success) throw new Error(resp.error || "Invalid solution");
246
872
 
247
- if (!resp.success) throw new Error("Invalid solution");
248
873
  const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token";
249
874
  if (this.querySelector(`input[name='${fieldName}']`)) {
250
875
  this.querySelector(`input[name='${fieldName}']`).value = resp.token;
@@ -253,6 +878,8 @@
253
878
  this.dispatchEvent("solve", { token: resp.token });
254
879
  this.token = resp.token;
255
880
 
881
+ _resetSpeculativeState();
882
+
256
883
  if (this.#resetTimer) clearTimeout(this.#resetTimer);
257
884
  const expiresIn = new Date(resp.expires).getTime() - Date.now();
258
885
  if (expiresIn > 0 && expiresIn < 24 * 60 * 60 * 1000) {
@@ -287,6 +914,9 @@
287
914
  const total = challenges.length;
288
915
  let completed = 0;
289
916
 
917
+ const speculativeHead = 0;
918
+ const remaining = total - speculativeHead;
919
+
290
920
  let wasmModule = null;
291
921
  const wasmSupported =
292
922
  typeof WebAssembly === "object" && typeof WebAssembly.instantiate === "function";
@@ -316,67 +946,31 @@
316
946
  }
317
947
  }
318
948
 
319
- const workers = Array(this.#workersCount)
320
- .fill(null)
321
- .map(() => {
322
- try {
323
- return new Worker(this.#workerUrl);
324
- } catch (error) {
325
- console.error("[cap] Failed to create worker:", error);
326
- throw new Error("Worker creation failed");
327
- }
328
- });
329
-
330
- const solveSingleChallenge = ([salt, target], workerId) =>
331
- new Promise((resolve, reject) => {
332
- const worker = workers[workerId];
333
- if (!worker) {
334
- reject(new Error("Worker not available"));
335
- return;
336
- }
337
-
338
- worker.onmessage = ({ data }) => {
339
- if (!data.found) return;
340
-
341
- completed++;
342
- this.dispatchEvent("progress", {
343
- progress: Math.round((completed / total) * 100),
344
- });
345
-
346
- resolve(data.nonce);
347
- };
348
-
349
- worker.onerror = (err) => {
350
- this.error(`Error in worker: ${err.message || err}`);
351
- reject(err);
352
- };
353
-
354
- if (wasmModule) {
355
- worker.postMessage({ salt, target, wasmModule }, []);
356
- } else {
357
- worker.postMessage({ salt, target });
358
- }
359
- });
949
+ const pool = new WorkerPool(this.#workersCount);
950
+ pool.setWasm(wasmModule);
951
+ pool._ensureSize(this.#workersCount);
360
952
 
361
953
  const results = [];
362
954
  try {
363
955
  for (let i = 0; i < challenges.length; i += this.#workersCount) {
364
956
  const chunk = challenges.slice(i, Math.min(i + this.#workersCount, challenges.length));
365
957
  const chunkResults = await Promise.all(
366
- chunk.map((c, idx) => solveSingleChallenge(c, idx)),
958
+ chunk.map(([salt, target]) =>
959
+ pool.run(salt, target).then((nonce) => {
960
+ completed++;
961
+ const visual = Math.min(
962
+ 99,
963
+ Math.round(((speculativeHead + completed) / total) * 100),
964
+ );
965
+ this.dispatchEvent("progress", { progress: visual });
966
+ return nonce;
967
+ }),
968
+ ),
367
969
  );
368
970
  results.push(...chunkResults);
369
971
  }
370
972
  } finally {
371
- workers.forEach((w) => {
372
- if (w) {
373
- try {
374
- w.terminate();
375
- } catch (error) {
376
- console.error("[cap] error terminating worker:", error);
377
- }
378
- }
379
- });
973
+ pool.terminate();
380
974
  }
381
975
 
382
976
  return results;
@@ -406,9 +1000,7 @@
406
1000
  "Verify you're human",
407
1001
  )}</span></p><a part="attribution" aria-label="Secured by Cap" href="https://capjs.js.org/" class="credits" target="_blank" rel="follow noopener" title="Secured by Cap: Self-hosted CAPTCHA for the modern web.">Cap</a>`;
408
1002
 
409
- const css = `%%capCSS%%`;
410
-
411
- this.#shadow.innerHTML = `<style${window.CAP_CSS_NONCE ? ` nonce=${window.CAP_CSS_NONCE}` : ""}>${css}</style>`;
1003
+ this.#shadow.innerHTML = `<style${window.CAP_CSS_NONCE ? ` nonce=${window.CAP_CSS_NONCE}` : ""}>%%capCSS%%</style>`;
412
1004
 
413
1005
  this.#shadow.appendChild(this.#div);
414
1006
  }
@@ -471,7 +1063,9 @@
471
1063
  if (current) {
472
1064
  current.classList.remove("active");
473
1065
  current.classList.add("exit");
474
- current.addEventListener("transitionend", () => current.remove(), { once: true });
1066
+ current.addEventListener("transitionend", () => current.remove(), {
1067
+ once: true,
1068
+ });
475
1069
  }
476
1070
  }
477
1071
 
@@ -489,6 +1083,46 @@
489
1083
  }
490
1084
  }
491
1085
 
1086
+ updateUIBlocked(label, showTroubleshooting = false) {
1087
+ if (!this.#div) return;
1088
+
1089
+ this.#div.setAttribute("data-state", "error");
1090
+ this.#div.removeAttribute("disabled");
1091
+
1092
+ const wrapper = this.#div.querySelector(".label-wrapper");
1093
+ if (!wrapper) return;
1094
+
1095
+ const troubleshootingUrl =
1096
+ this.getAttribute("data-cap-troubleshooting-url") ||
1097
+ "https://capjs.js.org/guide/troubleshooting/instrumentation.html";
1098
+
1099
+ const current = wrapper.querySelector(".label.active");
1100
+ const next = document.createElement("span");
1101
+ next.className = "label";
1102
+ next.innerHTML = showTroubleshooting
1103
+ ? `${label} · <a class="cap-troubleshoot-link" href="${troubleshootingUrl}" target="_blank" rel="noopener">${this.getI18nText("troubleshooting-label", "Troubleshoot")}</a>`
1104
+ : label;
1105
+ wrapper.appendChild(next);
1106
+
1107
+ void next.offsetWidth;
1108
+ next.classList.add("active");
1109
+ if (current) {
1110
+ current.classList.remove("active");
1111
+ current.classList.add("exit");
1112
+ current.addEventListener("transitionend", () => current.remove(), {
1113
+ once: true,
1114
+ });
1115
+ }
1116
+
1117
+ const link = next.querySelector(".cap-troubleshoot-link");
1118
+ if (link) {
1119
+ console.log("linkblud")
1120
+ link.addEventListener("click", (e) => {
1121
+ e.stopPropagation();
1122
+ });
1123
+ }
1124
+ }
1125
+
492
1126
  handleProgress(event) {
493
1127
  if (!this.#div) return;
494
1128
 
@@ -532,6 +1166,10 @@
532
1166
  return;
533
1167
  }
534
1168
 
1169
+ console.error(
1170
+ "[cap] using `onxxx='…'` is strongly discouraged and will be deprecated soon. please use `addEventListener` callbacks instead.",
1171
+ );
1172
+
535
1173
  new Function("event", code).call(this, event);
536
1174
  }
537
1175
 
@@ -590,11 +1228,6 @@
590
1228
  clearTimeout(this.#resetTimer);
591
1229
  this.#resetTimer = null;
592
1230
  }
593
-
594
- if (this.#workerUrl) {
595
- URL.revokeObjectURL(this.#workerUrl);
596
- this.#workerUrl = "";
597
- }
598
1231
  }
599
1232
  }
600
1233