@zaptcha/widget 1.0.0 → 1.0.1

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/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
- "name": "@zaptcha/widget",
3
- "version": "1.0.0",
2
+ "name": "@zaptcha/widget",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
- "main": "./src/zaptcha-widget.js",
5
+ "main": "./dist/zaptcha-widget.js",
6
6
  "types": "./src/zaptcha.d.ts",
7
7
  "files": [
8
- "src"
8
+ "dist",
9
+ "src/zaptcha.d.ts"
9
10
  ],
10
11
  "exports": {
11
- ".": "./src/zaptcha-widget.js"
12
+ ".": "./dist/zaptcha-widget.js"
13
+ },
14
+ "scripts": {
15
+ "build": "node scripts/build.js",
16
+ "prepare": "node scripts/build.js"
12
17
  }
13
18
  }
@@ -1,485 +0,0 @@
1
- /**
2
- * <zero-captcha> — CAPTCHA Web Component with WASM Proof-of-Work
3
- *
4
- * The worker is bundled inline — only this one file needs to be distributed.
5
- * WASM paths are resolved automatically relative to this module's URL.
6
- *
7
- * Attributes:
8
- * base-url="https://api.example.com" required — backend base URL
9
- * config-id="AAAA" required — base64url-encoded u32
10
- * wasm-glue="./pkg/zaptcha.js" optional — override WASM glue path
11
- * wasm-bin="./pkg/zaptcha_bg.wasm" optional — override WASM binary path
12
- *
13
- * CSS custom properties (set on a parent element to theme the widget):
14
- * --zaptcha-bg background color
15
- * --zaptcha-border border color
16
- * --zaptcha-border-focus focus ring color
17
- * --zaptcha-text primary text color
18
- * --zaptcha-text-muted muted / branding text color
19
- * --zaptcha-accent spinner / interactive accent color
20
- * --zaptcha-success checkmark fill color
21
- * --zaptcha-shadow box shadow
22
- * --zaptcha-radius border radius
23
- *
24
- * Events dispatched on host element:
25
- * zaptcha-success -> detail: { token: string }
26
- * zaptcha-fail -> detail: { reason: string }
27
- * zaptcha-reset -> detail: {}
28
- *
29
- * Public methods:
30
- * el.reset()
31
- *
32
- * Usage:
33
- * <zero-captcha base-url="https://api.example.com" config-id="AAAA"></zero-captcha>
34
- */
35
-
36
- // ── Default WASM paths (relative to this module) ──────────────────────────
37
-
38
- const _wasmGlue = new URL('./zaptcha.js', import.meta.url).href;
39
- const _wasmBin = new URL('./zaptcha_bg.wasm', import.meta.url).href;
40
-
41
- const WORKER_SRC = `
42
- let wasmModule = null;
43
- let wasmReady = false;
44
- let wasmGlueUrl = null;
45
- let wasmBinUrl = null;
46
-
47
- async function initWasm() {
48
- if (wasmReady) return;
49
- if (!wasmGlueUrl) throw new Error("WASM paths not provided");
50
- const mod = await import(wasmGlueUrl);
51
- if (typeof mod.default === "function") await mod.default(wasmBinUrl);
52
- wasmModule = mod;
53
- wasmReady = true;
54
- }
55
-
56
- async function runSolve(baseUrl, configId) {
57
- await initWasm();
58
-
59
- const challengeRes = await fetch(baseUrl + "/" + configId + "/challenge");
60
- if (!challengeRes.ok) throw new Error("Challenge request failed: HTTP " + challengeRes.status);
61
-
62
- const { token, challenge_count, salt_length, difficulty } = await challengeRes.json();
63
-
64
- self.__zaptchaProgressCb = (index, nonce) => {
65
- self.postMessage({ type: "progress", index, nonce, total: challenge_count });
66
- };
67
-
68
- let solutions;
69
- try {
70
- solutions = wasmModule.solve_challenge(
71
- token, difficulty, challenge_count, salt_length, self.__zaptchaProgressCb,
72
- );
73
- } finally {
74
- delete self.__zaptchaProgressCb;
75
- }
76
-
77
- const redeemRes = await fetch(baseUrl + "/" + configId + "/redeem", {
78
- method: "POST",
79
- headers: { "Content-Type": "application/json" },
80
- body: JSON.stringify({ token, solutions: Array.from(solutions) }),
81
- });
82
- if (!redeemRes.ok) throw new Error("Redeem request failed: HTTP " + redeemRes.status);
83
-
84
- const { message } = await redeemRes.json();
85
- if (!message) throw new Error("Server did not return a token");
86
- return message;
87
- }
88
-
89
- self.onmessage = async function ({ data }) {
90
- if (data.type === "init") {
91
- wasmGlueUrl = data.wasmGlue;
92
- wasmBinUrl = data.wasmBin;
93
- try {
94
- await initWasm();
95
- self.postMessage({ type: "ready" });
96
- } catch (err) {
97
- self.postMessage({ type: "fail", reason: err.message });
98
- }
99
- return;
100
- }
101
-
102
- if (data.type === "solve") {
103
- try {
104
- const token = await runSolve(data.baseUrl, data.configId);
105
- self.postMessage({ type: "success", token });
106
- } catch (err) {
107
- self.postMessage({ type: "fail", reason: err.message });
108
- }
109
- return;
110
- }
111
- };
112
- `;
113
-
114
- const STYLE = `
115
- :host {
116
- display: inline-block;
117
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
118
- -webkit-font-smoothing: antialiased;
119
- -moz-osx-font-smoothing: grayscale;
120
-
121
- /*
122
- * Map public --zaptcha-* custom properties to internal vars with
123
- * sensible light-mode fallbacks.
124
- */
125
- --zap-bg: var(--zaptcha-bg, #ffffff);
126
- --zap-border: var(--zaptcha-border, #e5e7eb);
127
- --zap-border-hover: var(--zaptcha-border-hover, #d1d5db);
128
- --zap-border-focus: var(--zaptcha-border-focus, #4f46e5);
129
- --zap-text: var(--zaptcha-text, #374151);
130
- --zap-text-muted: var(--zaptcha-text-muted, #9ca3af);
131
- --zap-accent: var(--zaptcha-accent, #4f46e5);
132
- --zap-success: var(--zaptcha-success, #10b981);
133
- --zap-shadow: var(--zaptcha-shadow, 0 2px 6px rgba(0,0,0,0.04));
134
- --zap-radius: var(--zaptcha-radius, 8px);
135
- }
136
-
137
- .widget {
138
- background: var(--zap-bg);
139
- border: 1px solid var(--zap-border);
140
- border-radius: var(--zap-radius);
141
- box-shadow: var(--zap-shadow);
142
- width: 210px; /* Слегка шире для баланса, но все еще очень компактно */
143
- box-sizing: border-box;
144
- transition: box-shadow 0.2s ease, border-color 0.2s ease;
145
- }
146
-
147
- .widget:hover {
148
- box-shadow: 0 4px 12px rgba(0,0,0,0.06);
149
- }
150
-
151
- .row {
152
- display: flex;
153
- align-items: center;
154
- gap: 10px;
155
- padding: 0 12px;
156
- height: 48px;
157
- cursor: pointer;
158
- user-select: none;
159
- box-sizing: border-box;
160
- border-radius: var(--zap-radius);
161
- outline: none;
162
- }
163
-
164
- .row:focus-visible {
165
- box-shadow: 0 0 0 2px var(--zap-bg), 0 0 0 4px var(--zap-border-focus);
166
- }
167
-
168
- .row[aria-checked="true"] {
169
- cursor: default;
170
- }
171
-
172
- /* Checkbox */
173
- .box {
174
- width: 22px;
175
- height: 22px;
176
- flex-shrink: 0;
177
- position: relative;
178
- display: flex;
179
- align-items: center;
180
- justify-content: center;
181
- }
182
-
183
- .box-border {
184
- position: absolute;
185
- inset: 0;
186
- border: 1.5px solid var(--zap-border);
187
- border-radius: 4px;
188
- background: var(--zap-bg);
189
- transition: border-color 0.2s ease, background-color 0.2s ease;
190
- }
191
-
192
- .row:hover:not([aria-checked="true"]) .box-border {
193
- border-color: var(--zap-border-hover);
194
- }
195
-
196
- .box.done .box-border {
197
- background: var(--zap-success);
198
- border-color: var(--zap-success);
199
- }
200
-
201
- .box-tick { display: none; position: relative; z-index: 1; }
202
- .box.done .box-tick { display: block; animation: zap-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
203
-
204
- @keyframes zap-pop {
205
- 0% { transform: scale(0.5); opacity: 0; }
206
- 100% { transform: scale(1); opacity: 1; }
207
- }
208
-
209
- /* Spinner */
210
- .box-spinner { display: none; position: absolute; inset: 0; }
211
- .box.spinning .box-border { border-color: transparent; background: transparent; }
212
- .box.spinning .box-spinner {
213
- display: flex;
214
- align-items: center;
215
- justify-content: center;
216
- }
217
- .box-spinner::after {
218
- content: '';
219
- display: block;
220
- width: 18px;
221
- height: 18px;
222
- border: 2px solid var(--zap-border);
223
- border-top-color: var(--zap-accent);
224
- border-radius: 50%;
225
- animation: zap-spin 0.6s linear infinite;
226
- }
227
- @keyframes zap-spin { to { transform: rotate(360deg); } }
228
-
229
- /* Label */
230
- .label {
231
- color: var(--zap-text);
232
- font-size: 13.5px;
233
- font-weight: 500;
234
- flex: 1;
235
- white-space: nowrap;
236
- overflow: hidden;
237
- text-overflow: ellipsis;
238
- }
239
-
240
- /* Branding */
241
- .branding {
242
- display: flex;
243
- flex-direction: column;
244
- align-items: flex-end;
245
- justify-content: center;
246
- flex-shrink: 0;
247
- gap: 1px;
248
- }
249
- .branding strong {
250
- font-size: 9px;
251
- font-weight: 700;
252
- letter-spacing: 0.05em;
253
- text-transform: uppercase;
254
- color: var(--zap-text-muted);
255
- }
256
- .branding a {
257
- font-size: 9px;
258
- color: var(--zap-text-muted);
259
- text-decoration: none;
260
- transition: color 0.2s;
261
- }
262
- .branding a:hover {
263
- color: var(--zap-text);
264
- }
265
- `;
266
-
267
- class ZeroCaptcha extends HTMLElement {
268
-
269
- static get observedAttributes() { return []; }
270
-
271
- constructor() {
272
- super();
273
- this._shadow = this.attachShadow({ mode: "open" });
274
- this._worker = null;
275
- this._blobUrl = null;
276
- /** @type {"idle"|"solving"|"done"|"error"} */
277
- this._state = "idle";
278
- this._progress = { done: 0, total: 1 };
279
- this._render();
280
- }
281
-
282
- connectedCallback() {
283
- this._bindEvents();
284
- this._startWorker();
285
- }
286
-
287
- disconnectedCallback() {
288
- this._destroyWorker();
289
- }
290
-
291
- reset() {
292
- this._destroyWorker();
293
- this._state = "idle";
294
- this._progress = { done: 0, total: 1 };
295
- this._render();
296
- this._bindEvents();
297
- this._startWorker();
298
- this.dispatchEvent(new CustomEvent("zaptcha-reset", { bubbles: true, detail: {} }));
299
- }
300
-
301
- _startWorker() {
302
- try {
303
- const externalUrl = this.getAttribute("worker-url");
304
- if (externalUrl) {
305
- this._worker = new Worker(externalUrl, { type: "module" });
306
- } else {
307
- const blob = new Blob([WORKER_SRC], { type: "text/javascript" });
308
- this._blobUrl = URL.createObjectURL(blob);
309
- this._worker = new Worker(this._blobUrl, { type: "module" });
310
- }
311
-
312
- this._worker.onmessage = (e) => this._onWorkerMsg(e.data);
313
- this._worker.onerror = (e) => this._onFail(`Worker error: ${e.message}`);
314
-
315
- const wasmGlue = this.getAttribute("wasm-glue") ?? _wasmGlue;
316
- const wasmBin = this.getAttribute("wasm-bin") ?? _wasmBin;
317
-
318
- this._worker.postMessage({ type: "init", wasmGlue, wasmBin });
319
- } catch (err) {
320
- this._worker = null;
321
- }
322
- }
323
-
324
- _destroyWorker() {
325
- this._worker?.terminate();
326
- this._worker = null;
327
- if (this._blobUrl) {
328
- URL.revokeObjectURL(this._blobUrl);
329
- this._blobUrl = null;
330
- }
331
- }
332
-
333
- _onWorkerMsg(msg) {
334
- switch (msg.type) {
335
- case "ready":
336
- break;
337
-
338
- case "progress":
339
- this._progress.done = (msg.index ?? 0) + 1;
340
- this._progress.total = msg.total ?? 1;
341
- this.dispatchEvent(new CustomEvent("zaptcha-progress", {
342
- bubbles: true,
343
- composed: true,
344
- detail: {
345
- index: msg.index,
346
- nonce: msg.nonce,
347
- total: msg.total,
348
- pct: Math.round(((msg.index ?? 0) + 1) / (msg.total ?? 1) * 100),
349
- },
350
- }));
351
- break;
352
-
353
- case "success":
354
- this._state = "done";
355
- this._render();
356
- this.dispatchEvent(new CustomEvent("zaptcha-success", {
357
- bubbles: true,
358
- composed: true,
359
- detail: { token: msg.token },
360
- }));
361
- break;
362
-
363
- case "fail":
364
- this._onFail(msg.reason || "Unknown error");
365
- break;
366
- }
367
- }
368
-
369
-
370
- _render() {
371
- const solving = this._state === "solving";
372
- const done = this._state === "done";
373
-
374
- const label = done ? "Verified" :
375
- solving ? "Verifying\u2026" :
376
- "I'm not a robot";
377
-
378
- this._shadow.innerHTML = `
379
- <style>${STYLE}</style>
380
- <div class="widget" part="widget">
381
- <div class="row"
382
- id="row"
383
- tabindex="${done ? -1 : 0}"
384
- role="checkbox"
385
- aria-checked="${done}"
386
- aria-label="${label}">
387
-
388
- <div class="box ${solving ? "spinning" : done ? "done" : ""}" id="box" part="checkbox">
389
- <div class="box-border"></div>
390
- <div class="box-spinner"></div>
391
- <svg class="box-tick" width="12" height="9" viewBox="0 0 12 9" fill="none" aria-hidden="true">
392
- <polyline points="1.5,4.5 4.5,7.5 10.5,1.5"
393
- stroke="white" stroke-width="2"
394
- stroke-linecap="round" stroke-linejoin="round"/>
395
- </svg>
396
- </div>
397
-
398
- <span class="label" part="label">${label}</span>
399
-
400
- <div class="branding" part="branding">
401
- <strong>Zaptcha</strong>
402
- <a href="#" tabindex="-1">Privacy</a>
403
- </div>
404
- </div>
405
- </div>
406
- `;
407
- }
408
-
409
- // ── Event binding ───────────────────────────────────────────────────────
410
-
411
- _bindEvents() {
412
- const row = this._shadow.getElementById("row");
413
- if (!row) return;
414
- row.addEventListener("click", () => this._onRowClick());
415
- row.addEventListener("keydown", (e) => {
416
- if (e.key === " " || e.key === "Enter") {
417
- e.preventDefault();
418
- this._onRowClick();
419
- }
420
- });
421
- }
422
-
423
- _onRowClick() {
424
- if (this._state !== "idle") return;
425
- this._startSolve();
426
- }
427
-
428
- // ── Solving flow ────────────────────────────────────────────────────────
429
-
430
- _startSolve() {
431
- this._state = "solving";
432
- this._progress = { done: 0, total: 1 };
433
- this._render();
434
-
435
- if (!this._worker) {
436
- this._onFail("Worker unavailable");
437
- return;
438
- }
439
-
440
- const baseUrl = this.getAttribute("base-url") ?? "";
441
- const configId = this.getAttribute("config-id") ?? "";
442
-
443
- if (!baseUrl || !configId) {
444
- this._onFail("Attributes base-url and config-id are required");
445
- return;
446
- }
447
-
448
- this._worker.postMessage({ type: "solve", baseUrl, configId });
449
- }
450
-
451
- _onFail(reason) {
452
- this._state = "error";
453
- this._render();
454
-
455
- this.dispatchEvent(new CustomEvent("zaptcha-fail", {
456
- bubbles: true,
457
- composed: true,
458
- detail: { reason },
459
- }));
460
-
461
- setTimeout(() => this.reset(), 3000);
462
- }
463
- }
464
-
465
- customElements.define("zero-captcha", ZeroCaptcha);
466
- export default ZeroCaptcha;
467
- export { ZeroCaptcha };
468
-
469
- // ── Standalone worker factory ─────────────────────────────────────────────
470
-
471
- export function createWorker({ wasmGlue = _wasmGlue, wasmBin = _wasmBin } = {}) {
472
- const blob = new Blob([WORKER_SRC], { type: "text/javascript" });
473
- const blobUrl = URL.createObjectURL(blob);
474
- const worker = new Worker(blobUrl, { type: "module" });
475
-
476
- worker.addEventListener("message", function onReady(e) {
477
- if (e.data?.type === "ready" || e.data?.type === "fail") {
478
- URL.revokeObjectURL(blobUrl);
479
- worker.removeEventListener("message", onReady);
480
- }
481
- });
482
-
483
- worker.postMessage({ type: "init", wasmGlue, wasmBin });
484
- return worker;
485
- }