@zaptcha/widget 1.0.0
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/README.md +496 -0
- package/package.json +13 -0
- package/src/zaptcha-widget.js +485 -0
- package/src/zaptcha.d.ts +145 -0
- package/src/zaptcha.js +249 -0
- package/src/zaptcha_bg.wasm +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
# Zero-Captcha
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@your-org/zero-captcha)
|
|
4
|
+
[](https://deno.land/x/zero_captcha)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
A zero-knowledge CAPTCHA Web Component with WASM-based proof-of-work verification. Secure, accessible, and framework-agnostic.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
✨ **Zero-Knowledge Proof** - Server doesn't learn anything about solver
|
|
13
|
+
🔐 **WASM Proof-of-Work** - Client-side computational verification
|
|
14
|
+
🎨 **Fully Themeable** - CSS custom properties for complete styling control
|
|
15
|
+
♿ **Accessible** - ARIA attributes, keyboard navigation, semantic HTML
|
|
16
|
+
📦 **Framework Agnostic** - Works with React, Vue, Angular, Svelte, plain JS
|
|
17
|
+
⚡ **Lightweight** - ~8KB gzipped (with WASM)
|
|
18
|
+
🚀 **Deno & Node.js** - Published to both ecosystems
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
### npm (Node.js)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @your-org/zero-captcha
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
yarn add @your-org/zero-captcha
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm add @your-org/zero-captcha
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Deno
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
import ZeroCaptcha from "https://deno.land/x/zero_captcha@1.0.0/src/index.ts";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Browser (CDN)
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script type="module">
|
|
46
|
+
import ZeroCaptcha from "https://cdn.jsdelivr.net/npm/@your-org/zero-captcha@1.0.0/dist/index.js";
|
|
47
|
+
</script>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
### Basic HTML
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<!DOCTYPE html>
|
|
56
|
+
<html>
|
|
57
|
+
<head>
|
|
58
|
+
<style>
|
|
59
|
+
:root {
|
|
60
|
+
--zaptcha-accent: #4f46e5;
|
|
61
|
+
--zaptcha-success: #10b981;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<form id="myForm">
|
|
67
|
+
<input type="text" placeholder="Your name" required />
|
|
68
|
+
|
|
69
|
+
<zero-captcha
|
|
70
|
+
base-url="https://api.example.com"
|
|
71
|
+
config-id="AAAA"
|
|
72
|
+
></zero-captcha>
|
|
73
|
+
|
|
74
|
+
<button type="submit">Submit</button>
|
|
75
|
+
</form>
|
|
76
|
+
|
|
77
|
+
<script type="module">
|
|
78
|
+
import ZeroCaptcha from '@your-org/zero-captcha';
|
|
79
|
+
|
|
80
|
+
const form = document.getElementById('myForm');
|
|
81
|
+
const captcha = form.querySelector('zero-captcha');
|
|
82
|
+
let token = null;
|
|
83
|
+
|
|
84
|
+
captcha.addEventListener('zaptcha-success', (e) => {
|
|
85
|
+
token = e.detail.token;
|
|
86
|
+
console.log('✓ CAPTCHA verified:', token);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
captcha.addEventListener('zaptcha-fail', (e) => {
|
|
90
|
+
console.error('✗ CAPTCHA failed:', e.detail.reason);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
form.addEventListener('submit', async (e) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
|
|
96
|
+
if (!token) {
|
|
97
|
+
alert('Please solve the CAPTCHA');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Send form with token to server
|
|
102
|
+
const formData = new FormData(form);
|
|
103
|
+
formData.append('captcha_token', token);
|
|
104
|
+
|
|
105
|
+
const response = await fetch('/api/submit', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
body: formData
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (response.ok) {
|
|
111
|
+
console.log('✓ Form submitted');
|
|
112
|
+
captcha.reset();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### React
|
|
121
|
+
|
|
122
|
+
```jsx
|
|
123
|
+
import { useEffect, useRef, useState } from 'react';
|
|
124
|
+
import ZeroCaptcha from '@your-org/zero-captcha';
|
|
125
|
+
|
|
126
|
+
export function CaptchaForm() {
|
|
127
|
+
const captchaRef = useRef(null);
|
|
128
|
+
const [token, setToken] = useState(null);
|
|
129
|
+
const [loading, setLoading] = useState(false);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const el = captchaRef.current;
|
|
133
|
+
if (!el) return;
|
|
134
|
+
|
|
135
|
+
const handleSuccess = (e) => setToken(e.detail.token);
|
|
136
|
+
const handleFail = (e) => console.error(e.detail.reason);
|
|
137
|
+
|
|
138
|
+
el.addEventListener('zaptcha-success', handleSuccess);
|
|
139
|
+
el.addEventListener('zaptcha-fail', handleFail);
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
el.removeEventListener('zaptcha-success', handleSuccess);
|
|
143
|
+
el.removeEventListener('zaptcha-fail', handleFail);
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const handleSubmit = async (e) => {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
if (!token) return alert('Solve CAPTCHA first');
|
|
150
|
+
|
|
151
|
+
setLoading(true);
|
|
152
|
+
const res = await fetch('/api/submit', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ token })
|
|
156
|
+
});
|
|
157
|
+
setLoading(false);
|
|
158
|
+
|
|
159
|
+
if (res.ok) {
|
|
160
|
+
captchaRef.current?.reset();
|
|
161
|
+
setToken(null);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<form onSubmit={handleSubmit}>
|
|
167
|
+
<input type="email" placeholder="Email" required />
|
|
168
|
+
<zero-captcha
|
|
169
|
+
ref={captchaRef}
|
|
170
|
+
base-url="https://api.example.com"
|
|
171
|
+
config-id="AAAA"
|
|
172
|
+
/>
|
|
173
|
+
<button type="submit" disabled={!token || loading}>
|
|
174
|
+
{loading ? 'Submitting...' : 'Submit'}
|
|
175
|
+
</button>
|
|
176
|
+
</form>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Vue 3
|
|
182
|
+
|
|
183
|
+
```vue
|
|
184
|
+
<template>
|
|
185
|
+
<form @submit.prevent="handleSubmit">
|
|
186
|
+
<input v-model="email" type="email" placeholder="Email" required />
|
|
187
|
+
|
|
188
|
+
<zero-captcha
|
|
189
|
+
ref="captchaRef"
|
|
190
|
+
base-url="https://api.example.com"
|
|
191
|
+
config-id="AAAA"
|
|
192
|
+
@zaptcha-success="onSuccess"
|
|
193
|
+
@zaptcha-fail="onFail"
|
|
194
|
+
/>
|
|
195
|
+
|
|
196
|
+
<button type="submit" :disabled="!token || loading">
|
|
197
|
+
{{ loading ? 'Submitting...' : 'Submit' }}
|
|
198
|
+
</button>
|
|
199
|
+
</form>
|
|
200
|
+
</template>
|
|
201
|
+
|
|
202
|
+
<script setup>
|
|
203
|
+
import { ref } from 'vue';
|
|
204
|
+
import ZeroCaptcha from '@your-org/zero-captcha';
|
|
205
|
+
|
|
206
|
+
const captchaRef = ref(null);
|
|
207
|
+
const email = ref('');
|
|
208
|
+
const token = ref(null);
|
|
209
|
+
const loading = ref(false);
|
|
210
|
+
|
|
211
|
+
const onSuccess = (e) => {
|
|
212
|
+
token.value = e.detail.token;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onFail = (e) => {
|
|
216
|
+
console.error(e.detail.reason);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const handleSubmit = async () => {
|
|
220
|
+
if (!token.value) return;
|
|
221
|
+
|
|
222
|
+
loading.value = true;
|
|
223
|
+
const res = await fetch('/api/submit', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({ email: email.value, token: token.value })
|
|
227
|
+
});
|
|
228
|
+
loading.value = false;
|
|
229
|
+
|
|
230
|
+
if (res.ok) {
|
|
231
|
+
captchaRef.value?.reset();
|
|
232
|
+
token.value = null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
</script>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Angular
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import { Component, ViewChild, ElementRef } from '@angular/core';
|
|
242
|
+
import { FormsModule } from '@angular/forms';
|
|
243
|
+
import ZeroCaptcha from '@your-org/zero-captcha';
|
|
244
|
+
|
|
245
|
+
@Component({
|
|
246
|
+
selector: 'app-captcha-form',
|
|
247
|
+
template: `
|
|
248
|
+
<form (ngSubmit)="onSubmit()">
|
|
249
|
+
<input
|
|
250
|
+
[(ngModel)]="email"
|
|
251
|
+
name="email"
|
|
252
|
+
type="email"
|
|
253
|
+
placeholder="Email"
|
|
254
|
+
required
|
|
255
|
+
/>
|
|
256
|
+
|
|
257
|
+
<zero-captcha
|
|
258
|
+
#captchaEl
|
|
259
|
+
base-url="https://api.example.com"
|
|
260
|
+
config-id="AAAA"
|
|
261
|
+
(zaptcha-success)="onSuccess($event)"
|
|
262
|
+
(zaptcha-fail)="onFail($event)"
|
|
263
|
+
/>
|
|
264
|
+
|
|
265
|
+
<button
|
|
266
|
+
type="submit"
|
|
267
|
+
[disabled]="!token || loading"
|
|
268
|
+
>
|
|
269
|
+
{{ loading ? 'Submitting...' : 'Submit' }}
|
|
270
|
+
</button>
|
|
271
|
+
</form>
|
|
272
|
+
`,
|
|
273
|
+
standalone: true,
|
|
274
|
+
imports: [FormsModule]
|
|
275
|
+
})
|
|
276
|
+
export class CaptchaFormComponent {
|
|
277
|
+
@ViewChild('captchaEl') captchaEl!: ElementRef<any>;
|
|
278
|
+
|
|
279
|
+
email = '';
|
|
280
|
+
token: string | null = null;
|
|
281
|
+
loading = false;
|
|
282
|
+
|
|
283
|
+
onSuccess(event: CustomEvent<{ token: string }>) {
|
|
284
|
+
this.token = event.detail.token;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
onFail(event: CustomEvent<{ reason: string }>) {
|
|
288
|
+
console.error(event.detail.reason);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async onSubmit() {
|
|
292
|
+
if (!this.token) return;
|
|
293
|
+
|
|
294
|
+
this.loading = true;
|
|
295
|
+
const res = await fetch('/api/submit', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'Content-Type': 'application/json' },
|
|
298
|
+
body: JSON.stringify({ email: this.email, token: this.token })
|
|
299
|
+
});
|
|
300
|
+
this.loading = false;
|
|
301
|
+
|
|
302
|
+
if (res.ok) {
|
|
303
|
+
this.captchaEl.nativeElement.reset();
|
|
304
|
+
this.token = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Deno Fresh
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// routes/form.tsx
|
|
314
|
+
import { h, Fragment } from "preact";
|
|
315
|
+
import { useState, useRef } from "preact/hooks";
|
|
316
|
+
|
|
317
|
+
export default function FormPage() {
|
|
318
|
+
const [token, setToken] = useState<string | null>(null);
|
|
319
|
+
const captchaRef = useRef<any>(null);
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<form onSubmit={(e) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
if (!token) alert('Solve CAPTCHA');
|
|
325
|
+
}}>
|
|
326
|
+
<input type="email" placeholder="Email" required />
|
|
327
|
+
|
|
328
|
+
<zero-captcha
|
|
329
|
+
ref={captchaRef}
|
|
330
|
+
base-url="https://api.example.com"
|
|
331
|
+
config-id="AAAA"
|
|
332
|
+
onzaptcha-success={(e: any) => setToken(e.detail.token)}
|
|
333
|
+
/>
|
|
334
|
+
|
|
335
|
+
<button type="submit" disabled={!token}>Submit</button>
|
|
336
|
+
</form>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Styling
|
|
342
|
+
|
|
343
|
+
Customize appearance with CSS custom properties:
|
|
344
|
+
|
|
345
|
+
```css
|
|
346
|
+
:root {
|
|
347
|
+
/* Light mode (defaults) */
|
|
348
|
+
--zaptcha-bg: #ffffff;
|
|
349
|
+
--zaptcha-border: #e5e7eb;
|
|
350
|
+
--zaptcha-border-hover: #d1d5db;
|
|
351
|
+
--zaptcha-border-focus: #4f46e5;
|
|
352
|
+
--zaptcha-text: #374151;
|
|
353
|
+
--zaptcha-text-muted: #9ca3af;
|
|
354
|
+
--zaptcha-accent: #4f46e5;
|
|
355
|
+
--zaptcha-success: #10b981;
|
|
356
|
+
--zaptcha-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
|
357
|
+
--zaptcha-radius: 8px;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
@media (prefers-color-scheme: dark) {
|
|
361
|
+
:root {
|
|
362
|
+
--zaptcha-bg: #1f2937;
|
|
363
|
+
--zaptcha-border: #374151;
|
|
364
|
+
--zaptcha-border-hover: #4b5563;
|
|
365
|
+
--zaptcha-border-focus: #818cf8;
|
|
366
|
+
--zaptcha-text: #f3f4f6;
|
|
367
|
+
--zaptcha-text-muted: #6b7280;
|
|
368
|
+
--zaptcha-accent: #818cf8;
|
|
369
|
+
--zaptcha-success: #34d399;
|
|
370
|
+
--zaptcha-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
zero-captcha {
|
|
375
|
+
width: 100%;
|
|
376
|
+
max-width: 210px;
|
|
377
|
+
}
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## API Reference
|
|
381
|
+
|
|
382
|
+
### Attributes
|
|
383
|
+
|
|
384
|
+
| Name | Type | Required | Description |
|
|
385
|
+
|------|------|----------|-------------|
|
|
386
|
+
| `base-url` | string | ✓ | Backend API base URL |
|
|
387
|
+
| `config-id` | string | ✓ | Base64url-encoded config ID |
|
|
388
|
+
| `wasm-glue` | string | | Custom WASM glue module path |
|
|
389
|
+
| `wasm-bin` | string | | Custom WASM binary path |
|
|
390
|
+
|
|
391
|
+
### Events
|
|
392
|
+
|
|
393
|
+
| Event | Detail | Description |
|
|
394
|
+
|-------|--------|-------------|
|
|
395
|
+
| `zaptcha-success` | `{ token: string }` | CAPTCHA solved, token generated |
|
|
396
|
+
| `zaptcha-fail` | `{ reason: string }` | Solve failed |
|
|
397
|
+
| `zaptcha-reset` | `{}` | Widget reset to idle state |
|
|
398
|
+
| `zaptcha-progress` | `{ index, nonce, total, pct }` | Solving progress update |
|
|
399
|
+
|
|
400
|
+
### Methods
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
element.reset(): void
|
|
404
|
+
// Reset widget to idle state
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Backend Integration
|
|
408
|
+
|
|
409
|
+
Your backend needs two endpoints:
|
|
410
|
+
|
|
411
|
+
### GET /config-id/challenge
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
|
|
415
|
+
```json
|
|
416
|
+
{
|
|
417
|
+
"token": "eyJ...",
|
|
418
|
+
"challenge_count": 16,
|
|
419
|
+
"salt_length": 32,
|
|
420
|
+
"difficulty": 20
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### POST /config-id/redeem
|
|
425
|
+
|
|
426
|
+
Request body:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{
|
|
430
|
+
"token": "eyJ...",
|
|
431
|
+
"solutions": [12345, 67890, ...]
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Response:
|
|
436
|
+
|
|
437
|
+
```json
|
|
438
|
+
{
|
|
439
|
+
"message": "proof_token_xyz"
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
See [backend implementation guide](./docs/backend.md) for details.
|
|
444
|
+
|
|
445
|
+
## Browser Support
|
|
446
|
+
|
|
447
|
+
| Browser | Version |
|
|
448
|
+
|---------|---------|
|
|
449
|
+
| Chrome | 76+ |
|
|
450
|
+
| Firefox | 79+ |
|
|
451
|
+
| Safari | 14.1+ |
|
|
452
|
+
| Edge | 79+ |
|
|
453
|
+
|
|
454
|
+
WebAssembly and ES2020 modules required.
|
|
455
|
+
|
|
456
|
+
## Troubleshooting
|
|
457
|
+
|
|
458
|
+
**WASM not loading?**
|
|
459
|
+
- Check WASM paths in `wasm-glue` and `wasm-bin` attributes
|
|
460
|
+
- Ensure WASM served with `Content-Type: application/wasm`
|
|
461
|
+
|
|
462
|
+
**Events not firing?**
|
|
463
|
+
- Use `addEventListener()` (not `on*` attributes for Custom Events)
|
|
464
|
+
- Event names are `zaptcha-success`, not `zaptchaSuccess`
|
|
465
|
+
|
|
466
|
+
**Styling not applying?**
|
|
467
|
+
- CSS custom properties must be set on parent, not `:root`
|
|
468
|
+
- Use `!important` if conflicting with existing styles
|
|
469
|
+
|
|
470
|
+
## Performance
|
|
471
|
+
|
|
472
|
+
- Challenge solving: 100-500ms (varies by difficulty)
|
|
473
|
+
- WASM bundle: 2-3MB uncompressed, gzipped to ~400KB
|
|
474
|
+
- Component size: ~8KB (with gzip)
|
|
475
|
+
|
|
476
|
+
## Security
|
|
477
|
+
|
|
478
|
+
- No cookies stored
|
|
479
|
+
- No tracking pixels
|
|
480
|
+
- No external requests (except to your API)
|
|
481
|
+
- Proof-of-work verified on server
|
|
482
|
+
- Token valid for single use only
|
|
483
|
+
|
|
484
|
+
## License
|
|
485
|
+
|
|
486
|
+
MIT © 2024 Your Organization
|
|
487
|
+
|
|
488
|
+
## Contributing
|
|
489
|
+
|
|
490
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
491
|
+
|
|
492
|
+
## Support
|
|
493
|
+
|
|
494
|
+
- 📖 [Documentation](https://github.com/your-org/zero-captcha/wiki)
|
|
495
|
+
- 🐛 [Issue Tracker](https://github.com/your-org/zero-captcha/issues)
|
|
496
|
+
- 💬 [Discussions](https://github.com/your-org/zero-captcha/discussions)
|
package/package.json
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
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
|
+
}
|
package/src/zaptcha.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zaptcha.d.ts — TypeScript type declarations for <zero-captcha>
|
|
3
|
+
*
|
|
4
|
+
* Usage (module):
|
|
5
|
+
* import type { ZeroCaptcha, ZaptchaSuccessEvent, ZaptchaFailEvent } from "./zaptcha";
|
|
6
|
+
*
|
|
7
|
+
* Usage (global augmentation, add to your project's *.d.ts):
|
|
8
|
+
* /// <reference types="./zaptcha" />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── Event payload types ───────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** Fired when PoW solving succeeds and the server returns a signed token. */
|
|
14
|
+
export interface ZaptchaSuccessDetail {
|
|
15
|
+
/** Signed token returned by the backend after successful PoW redemption. */
|
|
16
|
+
token: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Fired when solving or network communication fails. */
|
|
20
|
+
export interface ZaptchaFailDetail {
|
|
21
|
+
/** Human-readable error description. */
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Fired on each solved PoW challenge. */
|
|
26
|
+
export interface ZaptchaProgressDetail {
|
|
27
|
+
index: number;
|
|
28
|
+
nonce: number;
|
|
29
|
+
total: number;
|
|
30
|
+
/** 0–100 */
|
|
31
|
+
pct: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fired when the widget is reset to its idle state. */
|
|
35
|
+
export type ZaptchaResetDetail = Record<string, never>;
|
|
36
|
+
|
|
37
|
+
export type ZaptchaSuccessEvent = CustomEvent<ZaptchaSuccessDetail>;
|
|
38
|
+
export type ZaptchaFailEvent = CustomEvent<ZaptchaFailDetail>;
|
|
39
|
+
export type ZaptchaProgressEvent = CustomEvent<ZaptchaProgressDetail>;
|
|
40
|
+
export type ZaptchaResetEvent = CustomEvent<ZaptchaResetDetail>;
|
|
41
|
+
|
|
42
|
+
// ── Element class ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export declare class ZeroCaptcha extends HTMLElement {
|
|
45
|
+
/**
|
|
46
|
+
* Backend base URL.
|
|
47
|
+
* Maps to the `base-url` attribute.
|
|
48
|
+
*/
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Base64url-encoded config identifier (u32).
|
|
53
|
+
* Maps to the `config-id` attribute.
|
|
54
|
+
*/
|
|
55
|
+
configId: string;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Color theme.
|
|
59
|
+
* Maps to the `theme` attribute.
|
|
60
|
+
* @default "light"
|
|
61
|
+
*/
|
|
62
|
+
theme: "light" | "dark";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* URL of the Web Worker script.
|
|
66
|
+
* Maps to the `worker-url` attribute.
|
|
67
|
+
* @default "./zaptcha-worker.js"
|
|
68
|
+
*/
|
|
69
|
+
workerUrl: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reset the widget to idle state, terminate any in-progress solving,
|
|
73
|
+
* and fire a `zaptcha-reset` event.
|
|
74
|
+
*/
|
|
75
|
+
reset(): void;
|
|
76
|
+
|
|
77
|
+
// ── Standard HTMLElement event overloads ────────────────────────────────
|
|
78
|
+
|
|
79
|
+
addEventListener(
|
|
80
|
+
type: "zaptcha-progress",
|
|
81
|
+
listener: (ev: ZaptchaProgressEvent) => void,
|
|
82
|
+
options?: boolean | AddEventListenerOptions,
|
|
83
|
+
): void;
|
|
84
|
+
addEventListener(
|
|
85
|
+
type: "zaptcha-success",
|
|
86
|
+
listener: (ev: ZaptchaSuccessEvent) => void,
|
|
87
|
+
options?: boolean | AddEventListenerOptions,
|
|
88
|
+
): void;
|
|
89
|
+
addEventListener(
|
|
90
|
+
type: "zaptcha-fail",
|
|
91
|
+
listener: (ev: ZaptchaFailEvent) => void,
|
|
92
|
+
options?: boolean | AddEventListenerOptions,
|
|
93
|
+
): void;
|
|
94
|
+
addEventListener(
|
|
95
|
+
type: "zaptcha-reset",
|
|
96
|
+
listener: (ev: ZaptchaResetEvent) => void,
|
|
97
|
+
options?: boolean | AddEventListenerOptions,
|
|
98
|
+
): void;
|
|
99
|
+
addEventListener(
|
|
100
|
+
type: string,
|
|
101
|
+
listener: EventListenerOrEventListenerObject,
|
|
102
|
+
options?: boolean | AddEventListenerOptions,
|
|
103
|
+
): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Global HTMLElementTagNameMap augmentation ─────────────────────────────
|
|
107
|
+
// Enables typed querySelector / createElement in TypeScript projects.
|
|
108
|
+
|
|
109
|
+
declare global {
|
|
110
|
+
interface HTMLElementTagNameMap {
|
|
111
|
+
"zero-captcha": ZeroCaptcha;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface HTMLElementEventMap {
|
|
115
|
+
"zaptcha-progress": ZaptchaProgressEvent;
|
|
116
|
+
"zaptcha-success": ZaptchaSuccessEvent;
|
|
117
|
+
"zaptcha-fail": ZaptchaFailEvent;
|
|
118
|
+
"zaptcha-reset": ZaptchaResetEvent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default ZeroCaptcha;
|
|
123
|
+
|
|
124
|
+
// ── Standalone worker factory ─────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export interface CreateWorkerOptions {
|
|
127
|
+
/** Absolute URL to the WASM JS glue file. Defaults to the bundled path. */
|
|
128
|
+
wasmGlue?: string;
|
|
129
|
+
/** Absolute URL to the WASM binary. Defaults to the bundled path. */
|
|
130
|
+
wasmBin?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a standalone Zaptcha worker without the widget UI.
|
|
135
|
+
* The worker is pre-warmed and ready to receive `"solve"` messages.
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* const worker = createWorker();
|
|
139
|
+
* worker.onmessage = ({ data }) => {
|
|
140
|
+
* if (data.type === 'success') console.log(data.token);
|
|
141
|
+
* };
|
|
142
|
+
* worker.postMessage({ type: 'solve', baseUrl: 'https://api.example.com', configId: 'AAAA' });
|
|
143
|
+
* worker.terminate();
|
|
144
|
+
*/
|
|
145
|
+
export function createWorker(options?: CreateWorkerOptions): Worker;
|
package/src/zaptcha.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/* @ts-self-types="./zaptcha.d.ts" */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} token
|
|
5
|
+
* @param {number} difficulty
|
|
6
|
+
* @param {number} challenge_count
|
|
7
|
+
* @param {number} salt_length
|
|
8
|
+
* @param {Function} progress_callback
|
|
9
|
+
* @returns {Uint32Array}
|
|
10
|
+
*/
|
|
11
|
+
export function solve_challenge(token, difficulty, challenge_count, salt_length, progress_callback) {
|
|
12
|
+
const ptr0 = passStringToWasm0(token, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
|
13
|
+
const len0 = WASM_VECTOR_LEN;
|
|
14
|
+
const ret = wasm.solve_challenge(ptr0, len0, difficulty, challenge_count, salt_length, progress_callback);
|
|
15
|
+
var v2 = getArrayU32FromWasm0(ret[0], ret[1]).slice();
|
|
16
|
+
wasm.__wbindgen_free(ret[0], ret[1] * 4, 4);
|
|
17
|
+
return v2;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function __wbg_get_imports() {
|
|
21
|
+
const import0 = {
|
|
22
|
+
__proto__: null,
|
|
23
|
+
__wbg___wbindgen_throw_6ddd609b62940d55: function(arg0, arg1) {
|
|
24
|
+
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
25
|
+
},
|
|
26
|
+
__wbg_call_dcc2662fa17a72cf: function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
|
27
|
+
const ret = arg0.call(arg1, arg2, arg3);
|
|
28
|
+
return ret;
|
|
29
|
+
}, arguments); },
|
|
30
|
+
__wbindgen_cast_0000000000000001: function(arg0) {
|
|
31
|
+
// Cast intrinsic for `F64 -> Externref`.
|
|
32
|
+
const ret = arg0;
|
|
33
|
+
return ret;
|
|
34
|
+
},
|
|
35
|
+
__wbindgen_init_externref_table: function() {
|
|
36
|
+
const table = wasm.__wbindgen_externrefs;
|
|
37
|
+
const offset = table.grow(4);
|
|
38
|
+
table.set(0, undefined);
|
|
39
|
+
table.set(offset + 0, undefined);
|
|
40
|
+
table.set(offset + 1, null);
|
|
41
|
+
table.set(offset + 2, true);
|
|
42
|
+
table.set(offset + 3, false);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
__proto__: null,
|
|
47
|
+
"./zaptcha_bg.js": import0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function addToExternrefTable0(obj) {
|
|
52
|
+
const idx = wasm.__externref_table_alloc();
|
|
53
|
+
wasm.__wbindgen_externrefs.set(idx, obj);
|
|
54
|
+
return idx;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getArrayU32FromWasm0(ptr, len) {
|
|
58
|
+
ptr = ptr >>> 0;
|
|
59
|
+
return getUint32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getStringFromWasm0(ptr, len) {
|
|
63
|
+
ptr = ptr >>> 0;
|
|
64
|
+
return decodeText(ptr, len);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let cachedUint32ArrayMemory0 = null;
|
|
68
|
+
function getUint32ArrayMemory0() {
|
|
69
|
+
if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) {
|
|
70
|
+
cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer);
|
|
71
|
+
}
|
|
72
|
+
return cachedUint32ArrayMemory0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let cachedUint8ArrayMemory0 = null;
|
|
76
|
+
function getUint8ArrayMemory0() {
|
|
77
|
+
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
|
78
|
+
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
|
79
|
+
}
|
|
80
|
+
return cachedUint8ArrayMemory0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleError(f, args) {
|
|
84
|
+
try {
|
|
85
|
+
return f.apply(this, args);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const idx = addToExternrefTable0(e);
|
|
88
|
+
wasm.__wbindgen_exn_store(idx);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function passStringToWasm0(arg, malloc, realloc) {
|
|
93
|
+
if (realloc === undefined) {
|
|
94
|
+
const buf = cachedTextEncoder.encode(arg);
|
|
95
|
+
const ptr = malloc(buf.length, 1) >>> 0;
|
|
96
|
+
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
|
97
|
+
WASM_VECTOR_LEN = buf.length;
|
|
98
|
+
return ptr;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let len = arg.length;
|
|
102
|
+
let ptr = malloc(len, 1) >>> 0;
|
|
103
|
+
|
|
104
|
+
const mem = getUint8ArrayMemory0();
|
|
105
|
+
|
|
106
|
+
let offset = 0;
|
|
107
|
+
|
|
108
|
+
for (; offset < len; offset++) {
|
|
109
|
+
const code = arg.charCodeAt(offset);
|
|
110
|
+
if (code > 0x7F) break;
|
|
111
|
+
mem[ptr + offset] = code;
|
|
112
|
+
}
|
|
113
|
+
if (offset !== len) {
|
|
114
|
+
if (offset !== 0) {
|
|
115
|
+
arg = arg.slice(offset);
|
|
116
|
+
}
|
|
117
|
+
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
|
118
|
+
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
|
119
|
+
const ret = cachedTextEncoder.encodeInto(arg, view);
|
|
120
|
+
|
|
121
|
+
offset += ret.written;
|
|
122
|
+
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
WASM_VECTOR_LEN = offset;
|
|
126
|
+
return ptr;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
130
|
+
cachedTextDecoder.decode();
|
|
131
|
+
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
|
132
|
+
let numBytesDecoded = 0;
|
|
133
|
+
function decodeText(ptr, len) {
|
|
134
|
+
numBytesDecoded += len;
|
|
135
|
+
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
|
136
|
+
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
137
|
+
cachedTextDecoder.decode();
|
|
138
|
+
numBytesDecoded = len;
|
|
139
|
+
}
|
|
140
|
+
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cachedTextEncoder = new TextEncoder();
|
|
144
|
+
|
|
145
|
+
if (!('encodeInto' in cachedTextEncoder)) {
|
|
146
|
+
cachedTextEncoder.encodeInto = function (arg, view) {
|
|
147
|
+
const buf = cachedTextEncoder.encode(arg);
|
|
148
|
+
view.set(buf);
|
|
149
|
+
return {
|
|
150
|
+
read: arg.length,
|
|
151
|
+
written: buf.length
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let WASM_VECTOR_LEN = 0;
|
|
157
|
+
|
|
158
|
+
let wasmModule, wasm;
|
|
159
|
+
function __wbg_finalize_init(instance, module) {
|
|
160
|
+
wasm = instance.exports;
|
|
161
|
+
wasmModule = module;
|
|
162
|
+
cachedUint32ArrayMemory0 = null;
|
|
163
|
+
cachedUint8ArrayMemory0 = null;
|
|
164
|
+
wasm.__wbindgen_start();
|
|
165
|
+
return wasm;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function __wbg_load(module, imports) {
|
|
169
|
+
if (typeof Response === 'function' && module instanceof Response) {
|
|
170
|
+
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
|
171
|
+
try {
|
|
172
|
+
return await WebAssembly.instantiateStreaming(module, imports);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
const validResponse = module.ok && expectedResponseType(module.type);
|
|
175
|
+
|
|
176
|
+
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
|
177
|
+
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
|
178
|
+
|
|
179
|
+
} else { throw e; }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bytes = await module.arrayBuffer();
|
|
184
|
+
return await WebAssembly.instantiate(bytes, imports);
|
|
185
|
+
} else {
|
|
186
|
+
const instance = await WebAssembly.instantiate(module, imports);
|
|
187
|
+
|
|
188
|
+
if (instance instanceof WebAssembly.Instance) {
|
|
189
|
+
return { instance, module };
|
|
190
|
+
} else {
|
|
191
|
+
return instance;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function expectedResponseType(type) {
|
|
196
|
+
switch (type) {
|
|
197
|
+
case 'basic': case 'cors': case 'default': return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function initSync(module) {
|
|
204
|
+
if (wasm !== undefined) return wasm;
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if (module !== undefined) {
|
|
208
|
+
if (Object.getPrototypeOf(module) === Object.prototype) {
|
|
209
|
+
({module} = module)
|
|
210
|
+
} else {
|
|
211
|
+
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const imports = __wbg_get_imports();
|
|
216
|
+
if (!(module instanceof WebAssembly.Module)) {
|
|
217
|
+
module = new WebAssembly.Module(module);
|
|
218
|
+
}
|
|
219
|
+
const instance = new WebAssembly.Instance(module, imports);
|
|
220
|
+
return __wbg_finalize_init(instance, module);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function __wbg_init(module_or_path) {
|
|
224
|
+
if (wasm !== undefined) return wasm;
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if (module_or_path !== undefined) {
|
|
228
|
+
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
|
229
|
+
({module_or_path} = module_or_path)
|
|
230
|
+
} else {
|
|
231
|
+
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (module_or_path === undefined) {
|
|
236
|
+
module_or_path = new URL('zaptcha_bg.wasm', import.meta.url);
|
|
237
|
+
}
|
|
238
|
+
const imports = __wbg_get_imports();
|
|
239
|
+
|
|
240
|
+
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
|
241
|
+
module_or_path = fetch(module_or_path);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
|
245
|
+
|
|
246
|
+
return __wbg_finalize_init(instance, module);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { initSync, __wbg_init as default };
|
|
Binary file
|