Initial commit
This commit is contained in:
559
references/error-codes.md
Normal file
559
references/error-codes.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# Turnstile Error Codes Reference
|
||||
|
||||
**Complete error code reference with troubleshooting**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||
|
||||
---
|
||||
|
||||
## Error Code Families
|
||||
|
||||
Error codes use the format `XXXYYY` where:
|
||||
- `XXX` = Error family (indicates general category)
|
||||
- `YYY` = Specific error (internal use, often marked `***` in docs)
|
||||
|
||||
**Note**: When `***` appears, the last 3 digits can be ignored.
|
||||
|
||||
---
|
||||
|
||||
## 100*** - Initialization Problems
|
||||
|
||||
**Error**: Problem initializing Turnstile before challenge could start
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Usually caused by:
|
||||
- Old instance of solved challenge still present
|
||||
- Page state corruption
|
||||
- Cache issues
|
||||
|
||||
**Solution**:
|
||||
1. Reload the page
|
||||
2. Clear browser cache
|
||||
3. Reset Turnstile widget programmatically
|
||||
4. On continuous failures → likely automated device
|
||||
|
||||
---
|
||||
|
||||
## 102***, 103***, 104***, 106*** - Invalid Parameters
|
||||
|
||||
**Error**: Visitor sent invalid parameter as part of challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Malformed request data
|
||||
- Corrupted challenge parameters
|
||||
- Browser/extension interference
|
||||
|
||||
**Solution**:
|
||||
1. Retry the challenge automatically
|
||||
2. On continuous failures → likely bot
|
||||
3. Implement `error-callback` with retry logic
|
||||
4. Verify visitor authenticity by other means
|
||||
|
||||
---
|
||||
|
||||
## 105*** - API Compatibility
|
||||
|
||||
**Error**: Turnstile invoked in deprecated or invalid way
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Using outdated API methods
|
||||
- Invalid widget configuration
|
||||
- Script version mismatch
|
||||
|
||||
**Solution**:
|
||||
1. Check official Turnstile documentation
|
||||
2. Refresh page to get latest script version
|
||||
3. Review widget initialization code
|
||||
4. Ensure `api.js` loads from Cloudflare CDN
|
||||
|
||||
---
|
||||
|
||||
## 110100, 110110 - Invalid Sitekey
|
||||
|
||||
**Error**: Turnstile invoked with invalid or inactive sitekey
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Sitekey doesn't exist
|
||||
- Sitekey was deleted
|
||||
- Typo in sitekey
|
||||
- Using wrong sitekey for environment
|
||||
|
||||
**Solution**:
|
||||
1. Verify sitekey in Cloudflare Dashboard
|
||||
2. Check sitekey is still active
|
||||
3. Ensure no typos in configuration
|
||||
4. Use correct sitekey for environment (dev/prod)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
const SITE_KEY = '1x00000000000000000000AA' // Test key in production
|
||||
|
||||
// ✅ Correct
|
||||
const SITE_KEY = process.env.TURNSTILE_SITE_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110200 - Unknown Domain
|
||||
|
||||
**Error**: Domain not allowed for this widget
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Current hostname not in widget's allowed domains list
|
||||
- Using production widget on localhost
|
||||
- Subdomain not added to allowlist
|
||||
|
||||
**Solution**:
|
||||
1. Add domain to allowed list in Cloudflare Dashboard
|
||||
2. For localhost: add `localhost` or use test sitekey `1x00000000000000000000AA`
|
||||
3. Check subdomain matches exactly (www.example.com ≠ example.com)
|
||||
|
||||
**Example Allowed Domains**:
|
||||
```
|
||||
example.com
|
||||
www.example.com
|
||||
localhost # For development
|
||||
127.0.0.1 # For development
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110420 - Invalid Action
|
||||
|
||||
**Error**: Unsupported or incorrectly formatted action submitted
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Action contains invalid characters
|
||||
- Action exceeds 32 character limit
|
||||
- Non-alphanumeric characters (except `-` and `_`)
|
||||
|
||||
**Solution**:
|
||||
1. Use only `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||
2. Keep action ≤ 32 characters
|
||||
3. Example valid actions: `login`, `signup`, `contact-form`
|
||||
|
||||
**Reference**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations
|
||||
|
||||
---
|
||||
|
||||
## 110430 - Invalid cData
|
||||
|
||||
**Error**: Custom data (cData) format invalid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- cData contains invalid characters
|
||||
- cData exceeds 255 character limit
|
||||
|
||||
**Solution**:
|
||||
1. Keep cData ≤ 255 characters
|
||||
2. Use JSON.stringify() for objects
|
||||
3. Validate data before passing to Turnstile
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
// ❌ Wrong - too long
|
||||
const cdata = JSON.stringify({ /* 300+ chars */ })
|
||||
|
||||
// ✅ Correct
|
||||
const cdata = JSON.stringify({ userId: '123', sessionId: 'abc' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 110500 - Unsupported Browser
|
||||
|
||||
**Error**: Visitor using unsupported browser
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Internet Explorer (not supported)
|
||||
- Very outdated browser version
|
||||
- Browser without required APIs
|
||||
|
||||
**Solution**:
|
||||
1. Encourage visitor to upgrade browser
|
||||
2. Provide alternative verification method
|
||||
3. Display browser upgrade message
|
||||
|
||||
**Supported Browsers**: https://developers.cloudflare.com/cloudflare-challenges/reference/supported-browsers/
|
||||
|
||||
---
|
||||
|
||||
## 110510 - Inconsistent User-Agent
|
||||
|
||||
**Error**: Visitor provided inconsistent user-agent during challenge
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Browser extensions spoofing user-agent
|
||||
- Privacy tools modifying headers
|
||||
- Browser settings
|
||||
|
||||
**Solution**:
|
||||
1. Ask visitor to disable user-agent spoofing extensions
|
||||
2. Disable privacy tools temporarily
|
||||
3. Try different browser
|
||||
|
||||
---
|
||||
|
||||
## 11060* - Challenge Timed Out
|
||||
|
||||
**Error**: Visitor took too long to solve challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Slow network connection
|
||||
- System clock set incorrectly
|
||||
- Visitor distracted/inactive
|
||||
|
||||
**Solution**:
|
||||
1. Retry the challenge
|
||||
2. Check system clock is correct
|
||||
3. Improve network connection
|
||||
|
||||
---
|
||||
|
||||
## 11062* - Interactive Challenge Timeout
|
||||
|
||||
**Error**: Visitor didn't interact with checkbox (visible mode only)
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Challenge became outdated while waiting for interaction
|
||||
- User abandoned form
|
||||
- Long delays between rendering and submission
|
||||
|
||||
**Solution**:
|
||||
1. Reset widget programmatically
|
||||
2. Re-initialize widget
|
||||
3. Prompt user to interact
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'timeout-callback': () => {
|
||||
turnstile.reset(widgetId)
|
||||
alert('Please complete the verification')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 120*** - Internal Cloudflare Errors
|
||||
|
||||
**Error**: Internal debugging errors (Cloudflare employees only)
|
||||
|
||||
**Retry**: N/A
|
||||
|
||||
**Solution**: Only encountered by Cloudflare Support during debugging.
|
||||
|
||||
---
|
||||
|
||||
## 200010 - Invalid Caching
|
||||
|
||||
**Error**: Some portion of Turnstile was accidentally cached
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- Browser cached Turnstile resources incorrectly
|
||||
- CDN/proxy caching `api.js` script
|
||||
|
||||
**Solution**:
|
||||
1. Clear browser cache
|
||||
2. Hard refresh (Ctrl+Shift+R or Cmd+Shift+R)
|
||||
3. Ensure `api.js` is not proxied or cached
|
||||
|
||||
**CRITICAL**: Never proxy or cache `https://challenges.cloudflare.com/turnstile/v0/api.js`
|
||||
|
||||
---
|
||||
|
||||
## 200100 - Time Problem
|
||||
|
||||
**Error**: Visitor's system clock is incorrect
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- System time is wrong
|
||||
- Timezone misconfigured
|
||||
- Date/time not synchronized
|
||||
|
||||
**Solution**:
|
||||
1. Set system clock to correct time
|
||||
2. Enable automatic time synchronization
|
||||
3. Check timezone settings
|
||||
|
||||
---
|
||||
|
||||
## 200500 - Loading Error
|
||||
|
||||
**Error**: iframe under challenges.cloudflare.com could not be loaded
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**:
|
||||
- **Content Security Policy (CSP) blocking iframe**
|
||||
- Browser security settings blocking 3rd-party iframes
|
||||
- Network firewall blocking challenges.cloudflare.com
|
||||
|
||||
**Solution**:
|
||||
1. **Add CSP directives**:
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src 'self' https://challenges.cloudflare.com;
|
||||
connect-src 'self' https://challenges.cloudflare.com;
|
||||
">
|
||||
```
|
||||
2. Reduce browser security preferences
|
||||
3. Check firewall/network settings
|
||||
|
||||
**Most Common Cause**: CSP blocking. See check-csp.sh script.
|
||||
|
||||
---
|
||||
|
||||
## 300*** - Generic Client Execution Error
|
||||
|
||||
**Error**: Unspecified error occurred while visitor solved challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Browser extension interference
|
||||
- JavaScript errors on page
|
||||
- Memory issues
|
||||
- Network interruption
|
||||
|
||||
**Solution**:
|
||||
1. Retry automatically
|
||||
2. On continuous failures → potentially automated visitor
|
||||
3. Disable browser extensions
|
||||
4. Try incognito/private mode
|
||||
|
||||
**Known Issue (2025)**: Safari 18 + macOS 15 with "Hide IP" enabled causes Error 300010.
|
||||
|
||||
**Safari Fix**: Settings → Privacy → Hide IP address → Off
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
|
||||
---
|
||||
|
||||
## 300030 - Widget Crash
|
||||
|
||||
**Error**: Widget crashed for legitimate users
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**: Unknown - Cloudflare-side issue (2025)
|
||||
|
||||
**Solution**:
|
||||
1. Implement robust error handling
|
||||
2. Retry with exponential backoff
|
||||
3. Provide fallback verification method
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
let retryCount = 0
|
||||
const maxRetries = 3
|
||||
|
||||
turnstile.render('#container', {
|
||||
sitekey: SITE_KEY,
|
||||
'error-callback': (error) => {
|
||||
if (error.includes('300030') && retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(() => {
|
||||
turnstile.reset(widgetId)
|
||||
}, 2000 * retryCount) // Exponential backoff
|
||||
} else {
|
||||
showFallbackVerification()
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/turnstile-is-frequently-generating-300x-errors/700903
|
||||
|
||||
---
|
||||
|
||||
## 400020 - Invalid Sitekey (Server)
|
||||
|
||||
**Error**: Sitekey is invalid or does not exist
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Same as 110100/110110 but caught server-side
|
||||
|
||||
**Solution**: Verify sitekey exists and is active
|
||||
|
||||
---
|
||||
|
||||
## 400030 - Invalid Size
|
||||
|
||||
**Error**: Provided size option is not valid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Using invalid `size` parameter
|
||||
|
||||
**Valid Options**: `normal`, `compact`, `flexible`
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
{ size: 'large' }
|
||||
|
||||
// ✅ Correct
|
||||
{ size: 'compact' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 400040 - Invalid Theme
|
||||
|
||||
**Error**: Provided theme is not valid
|
||||
|
||||
**Retry**: No
|
||||
|
||||
**Cause**: Using invalid `theme` parameter
|
||||
|
||||
**Valid Options**: `light`, `dark`, `auto`
|
||||
|
||||
**Solution**:
|
||||
```typescript
|
||||
// ❌ Wrong
|
||||
{ theme: 'custom' }
|
||||
|
||||
// ✅ Correct
|
||||
{ theme: 'dark' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 401 - Unauthorized (Expected)
|
||||
|
||||
**Error**: 401 error in browser console during challenge
|
||||
|
||||
**Retry**: N/A
|
||||
|
||||
**Cause**: Turnstile requesting Private Access Token (not supported by all devices/browsers)
|
||||
|
||||
**Solution**: **Ignore this error** - it's expected behavior
|
||||
|
||||
**Note**: If widget is successfully resolving and generating tokens, no action required.
|
||||
|
||||
**Source**: https://developers.cloudflare.com/turnstile/troubleshooting/client-side-errors/error-codes/
|
||||
|
||||
---
|
||||
|
||||
## 600*** - Challenge Execution Failure
|
||||
|
||||
**Error**: Visitor failed to solve Turnstile challenge
|
||||
|
||||
**Retry**: Yes
|
||||
|
||||
**Cause**:
|
||||
- Suspected bot behavior
|
||||
- Challenge signals indicate automation
|
||||
- Failing test sitekey (intentional)
|
||||
|
||||
**Solution**:
|
||||
1. Retry automatically
|
||||
2. On continuous failures → likely bot
|
||||
3. Verify visitor by other means
|
||||
4. Consider alternative verification
|
||||
|
||||
**Testing**: Test sitekey `2x00000000000000000000AB` always fails with this error.
|
||||
|
||||
---
|
||||
|
||||
## 600010 - Configuration Error
|
||||
|
||||
**Error**: Widget configuration error
|
||||
|
||||
**Retry**: Depends
|
||||
|
||||
**Cause**:
|
||||
- **Missing hostname in allowlist** (most common)
|
||||
- Hostname was deleted from configuration
|
||||
- Widget misconfigured
|
||||
|
||||
**Solution**:
|
||||
1. Check Cloudflare Dashboard → Turnstile → Widget Settings
|
||||
2. Verify hostname in allowed domains
|
||||
3. Re-add hostname if missing
|
||||
|
||||
**Known Issue**: Hostnames sometimes disappear from dashboard configuration
|
||||
|
||||
**Source**: https://community.cloudflare.com/t/repeated-cloudflare-turnstile-error-600010/644578
|
||||
|
||||
---
|
||||
|
||||
## Browser-Specific Issues
|
||||
|
||||
### Brave Browser - Confetti Animation Failure (2025)
|
||||
|
||||
**Error**: Verification fails during success animation
|
||||
|
||||
**Cause**: Brave shields block animation scripts
|
||||
|
||||
**Solution**: Handle success callback before animation completes
|
||||
|
||||
**Source**: https://github.com/brave/brave-browser/issues/45608
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
When encountering errors:
|
||||
|
||||
1. **Check Error Code Family**
|
||||
- 100*: Initialization → Reload page
|
||||
- 110*: Configuration → Check sitekey, domain allowlist
|
||||
- 200*: Client issues → Check cache, CSP, system clock
|
||||
- 300*: Execution → Retry, check browser compatibility
|
||||
- 400*: Invalid input → Fix configuration
|
||||
- 600*: Challenge failure → Check for bot-like behavior
|
||||
|
||||
2. **Common Fixes**
|
||||
- Clear browser cache
|
||||
- Disable browser extensions
|
||||
- Try incognito/private mode
|
||||
- Check CSP headers
|
||||
- Verify system clock
|
||||
- Use test sitekey for development
|
||||
|
||||
3. **Network/Firewall**
|
||||
- Ensure `challenges.cloudflare.com` is accessible
|
||||
- Check for VPN/proxy interference
|
||||
- Verify no firewall blocking
|
||||
|
||||
4. **Code Review**
|
||||
- Server-side validation implemented?
|
||||
- Token expiration handled?
|
||||
- Error callbacks configured?
|
||||
- Using latest `api.js` from CDN?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Most Common Errors**: 110200 (domain), 200500 (CSP), 300030 (crash), 600010 (config)
|
||||
26
references/example-reference.md
Normal file
26
references/example-reference.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# [TODO: Reference Document Name]
|
||||
|
||||
[TODO: This file contains reference documentation that Claude can load when needed.]
|
||||
|
||||
[TODO: Delete this file if you don't have reference documentation to provide.]
|
||||
|
||||
## Purpose
|
||||
|
||||
[TODO: Explain what information this document contains]
|
||||
|
||||
## When Claude Should Use This
|
||||
|
||||
[TODO: Describe specific scenarios where Claude should load this reference]
|
||||
|
||||
## Content
|
||||
|
||||
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
|
||||
|
||||
---
|
||||
|
||||
**Note**: This file is NOT loaded into context by default. Claude will only load it when:
|
||||
- It determines the information is needed
|
||||
- You explicitly ask Claude to reference it
|
||||
- The SKILL.md instructions direct Claude to read it
|
||||
|
||||
Keep this file under 10k words for best performance.
|
||||
550
references/react-integration.md
Normal file
550
references/react-integration.md
Normal file
@@ -0,0 +1,550 @@
|
||||
# React Integration Guide
|
||||
|
||||
**Best practices for integrating Turnstile with React, Next.js, and modern React patterns**
|
||||
|
||||
**Recommended Package**: @marsidev/react-turnstile (Cloudflare-verified)
|
||||
|
||||
---
|
||||
|
||||
## Package Installation
|
||||
|
||||
```bash
|
||||
npm install @marsidev/react-turnstile
|
||||
# or
|
||||
pnpm add @marsidev/react-turnstile
|
||||
# or
|
||||
yarn add @marsidev/react-turnstile
|
||||
```
|
||||
|
||||
**Current Version**: 1.3.1 (September 2025)
|
||||
**React Compatibility**: React 18+, Next.js 13+, 14+, 15+
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ContactForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
<input name="email" type="email" required />
|
||||
<textarea name="message" required />
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
/>
|
||||
|
||||
<button disabled={!token}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props Reference
|
||||
|
||||
### Required Props
|
||||
|
||||
#### `siteKey`
|
||||
**Type**: `string`
|
||||
**Description**: Your Turnstile sitekey
|
||||
|
||||
```tsx
|
||||
<Turnstile siteKey="YOUR_SITE_KEY" />
|
||||
```
|
||||
|
||||
### Optional Props
|
||||
|
||||
#### `onSuccess`
|
||||
**Type**: `(token: string) => void`
|
||||
**Description**: Called when challenge succeeds
|
||||
|
||||
```tsx
|
||||
<Turnstile onSuccess={(token) => console.log(token)} />
|
||||
```
|
||||
|
||||
#### `onError`
|
||||
**Type**: `(error: string) => void`
|
||||
**Description**: Called when challenge fails
|
||||
|
||||
```tsx
|
||||
<Turnstile onError={(error) => console.error(error)} />
|
||||
```
|
||||
|
||||
#### `onExpire`
|
||||
**Type**: `() => void`
|
||||
**Description**: Called when token expires (5 min)
|
||||
|
||||
```tsx
|
||||
<Turnstile onExpire={() => setToken(undefined)} />
|
||||
```
|
||||
|
||||
#### `onAbort`
|
||||
**Type**: `() => void`
|
||||
**Description**: Called when challenge is aborted
|
||||
|
||||
#### `options`
|
||||
**Type**: `TurnstileOptions`
|
||||
**Description**: Widget configuration
|
||||
|
||||
```tsx
|
||||
<Turnstile
|
||||
siteKey="..."
|
||||
options={{
|
||||
theme: 'dark',
|
||||
size: 'compact',
|
||||
action: 'login',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Refs
|
||||
|
||||
Access widget instance for manual control:
|
||||
|
||||
```tsx
|
||||
import { useRef } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
|
||||
export function AdvancedForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
function handleReset() {
|
||||
turnstileRef.current?.reset()
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
turnstileRef.current?.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Turnstile ref={turnstileRef} siteKey="..." />
|
||||
<button onClick={handleReset}>Reset</button>
|
||||
<button onClick={handleRemove}>Remove</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js App Router
|
||||
|
||||
### Client Component
|
||||
|
||||
```tsx
|
||||
// app/contact/page.tsx
|
||||
'use client'
|
||||
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ContactPage() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.get('email'),
|
||||
message: formData.get('message'),
|
||||
'cf-turnstile-response': token,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('Success!')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="email" type="email" required />
|
||||
<textarea name="message" required />
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onExpire={() => setToken(undefined)}
|
||||
/>
|
||||
|
||||
<button disabled={!token}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Server Action (Next.js 14+)
|
||||
|
||||
```tsx
|
||||
'use server'
|
||||
|
||||
import { validateTurnstile } from '@/lib/turnstile'
|
||||
|
||||
export async function submitContact(formData: FormData) {
|
||||
const token = formData.get('cf-turnstile-response')?.toString()
|
||||
|
||||
if (!token) {
|
||||
return { error: 'Missing verification' }
|
||||
}
|
||||
|
||||
const result = await validateTurnstile(token, process.env.TURNSTILE_SECRET_KEY!)
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed' }
|
||||
}
|
||||
|
||||
// Process form
|
||||
return { success: true }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next.js Pages Router
|
||||
|
||||
### Page Component
|
||||
|
||||
```tsx
|
||||
// pages/contact.tsx
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function ContactPage() {
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### API Route
|
||||
|
||||
```typescript
|
||||
// pages/api/contact.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { validateTurnstile } from '@/lib/turnstile'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const token = req.body['cf-turnstile-response']
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Missing token' })
|
||||
}
|
||||
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
process.env.TURNSTILE_SECRET_KEY!,
|
||||
{
|
||||
remoteip: req.headers['x-forwarded-for']?.toString() || req.socket.remoteAddress,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(401).json({ error: 'Invalid token' })
|
||||
}
|
||||
|
||||
// Process form
|
||||
res.status(200).json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Hook Pattern
|
||||
|
||||
```tsx
|
||||
// hooks/useTurnstile.ts
|
||||
import { useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
|
||||
export function useTurnstile(siteKey: string) {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const reset = () => {
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const TurnstileWidget = () => (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={siteKey}
|
||||
onSuccess={(token) => {
|
||||
setToken(token)
|
||||
setIsReady(true)
|
||||
setError(undefined)
|
||||
}}
|
||||
onError={(err) => {
|
||||
setError(err)
|
||||
setIsReady(false)
|
||||
}}
|
||||
onExpire={() => {
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
token,
|
||||
isReady,
|
||||
error,
|
||||
reset,
|
||||
TurnstileWidget,
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export function MyForm() {
|
||||
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile(
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
)
|
||||
|
||||
return (
|
||||
<form>
|
||||
<TurnstileWidget />
|
||||
{error && <div>Error: {error}</div>}
|
||||
<button disabled={!isReady}>Submit</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jest Testing
|
||||
|
||||
### Mock Setup
|
||||
|
||||
```typescript
|
||||
// jest.setup.ts
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Test
|
||||
|
||||
```tsx
|
||||
// ContactForm.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
|
||||
// Turnstile auto-solves (mocked)
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment-Aware Sitekey
|
||||
|
||||
```tsx
|
||||
// lib/turnstile.ts
|
||||
export function useTurnstileSiteKey() {
|
||||
// Test/Development: Use dummy key
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return '1x00000000000000000000AA'
|
||||
}
|
||||
|
||||
// Production: Use real key
|
||||
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
}
|
||||
|
||||
// Usage
|
||||
import { useTurnstileSiteKey } from '@/lib/turnstile'
|
||||
|
||||
export function MyForm() {
|
||||
const siteKey = useTurnstileSiteKey()
|
||||
|
||||
return <Turnstile siteKey={siteKey} />
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Workarounds
|
||||
|
||||
### Issue #112: Next.js + Jest Compatibility (Oct 2025)
|
||||
|
||||
**Problem**: @marsidev/react-turnstile breaks Jest tests
|
||||
|
||||
**Source**: https://github.com/marsidev/react-turnstile/issues/112
|
||||
|
||||
**Workaround**: Mock the component (see Jest Testing section above)
|
||||
|
||||
### Issue #113: Blocked Script Execution (Oct 2025)
|
||||
|
||||
**Problem**: Script execution blocked in some environments
|
||||
|
||||
**Source**: https://github.com/marsidev/react-turnstile/issues/113
|
||||
|
||||
**Workaround**:
|
||||
1. Check CSP headers allow `challenges.cloudflare.com`
|
||||
2. Ensure `api.js` loads from CDN (not proxied)
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
TurnstileInstance,
|
||||
TurnstileProps,
|
||||
TurnstileOptions,
|
||||
} from '@marsidev/react-turnstile'
|
||||
|
||||
// Widget instance methods
|
||||
interface TurnstileInstance {
|
||||
reset(): void
|
||||
remove(): void
|
||||
execute(): void
|
||||
getResponse(): string | undefined
|
||||
isExpired(): boolean
|
||||
}
|
||||
|
||||
// Component props
|
||||
interface TurnstileProps {
|
||||
siteKey: string
|
||||
onSuccess?: (token: string) => void
|
||||
onError?: (error: string) => void
|
||||
onExpire?: () => void
|
||||
onAbort?: () => void
|
||||
options?: TurnstileOptions
|
||||
scriptOptions?: {
|
||||
nonce?: string
|
||||
defer?: boolean
|
||||
async?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Widget options
|
||||
interface TurnstileOptions {
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
action?: string
|
||||
cdata?: string
|
||||
execution?: 'render' | 'execute'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
retry?: 'auto' | 'never'
|
||||
'retry-interval'?: number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
```tsx
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const Turnstile = lazy(() =>
|
||||
import('@marsidev/react-turnstile').then(mod => ({ default: mod.Turnstile }))
|
||||
)
|
||||
|
||||
export function LazyTurnstileForm() {
|
||||
return (
|
||||
<form>
|
||||
<Suspense fallback={<div>Loading verification...</div>}>
|
||||
<Turnstile siteKey="..." />
|
||||
</Suspense>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
Only render Turnstile when needed:
|
||||
|
||||
```tsx
|
||||
export function ConditionalForm() {
|
||||
const [showTurnstile, setShowTurnstile] = useState(false)
|
||||
|
||||
return (
|
||||
<form>
|
||||
<input onChange={() => setShowTurnstile(true)} />
|
||||
|
||||
{showTurnstile && <Turnstile siteKey="..." />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Use environment variables** for sitekeys
|
||||
✅ **Mock in tests** using Jest setup file
|
||||
✅ **Handle expiration** with `onExpire` callback
|
||||
✅ **Disable submit until ready** based on token state
|
||||
✅ **Reset after submission** for multi-use forms
|
||||
✅ **Use TypeScript** for type safety
|
||||
✅ **Lazy load** if not immediately needed
|
||||
|
||||
❌ **Don't hardcode sitekeys** in components
|
||||
❌ **Don't skip error handling** (`onError`)
|
||||
❌ **Don't forget server validation** (critical!)
|
||||
❌ **Don't use production keys in tests**
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Package Version**: @marsidev/react-turnstile@1.3.1
|
||||
**Cloudflare Status**: ✅ Officially Recommended
|
||||
430
references/testing-guide.md
Normal file
430
references/testing-guide.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Turnstile Testing Guide
|
||||
|
||||
**Complete testing strategies for E2E, unit, and integration tests**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Dummy Credentials
|
||||
|
||||
### Sitekeys (Client-Side)
|
||||
```typescript
|
||||
const TEST_SITEKEYS = {
|
||||
ALWAYS_PASS: '1x00000000000000000000AA', // Visible, always passes
|
||||
ALWAYS_BLOCK: '2x00000000000000000000AB', // Visible, always blocks
|
||||
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB', // Invisible, always passes
|
||||
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',// Invisible, always blocks
|
||||
FORCE_INTERACTIVE: '3x00000000000000000000FF', // Visible, forces checkbox
|
||||
}
|
||||
```
|
||||
|
||||
### Secret Keys (Server-Side)
|
||||
```typescript
|
||||
const TEST_SECRET_KEYS = {
|
||||
ALWAYS_PASS: '1x0000000000000000000000000000000AA', // success: true
|
||||
ALWAYS_FAIL: '2x0000000000000000000000000000000AA', // success: false
|
||||
TOKEN_SPENT: '3x0000000000000000000000000000000AA', // "already spent" error
|
||||
}
|
||||
```
|
||||
|
||||
### Dummy Token
|
||||
```typescript
|
||||
const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||
```
|
||||
|
||||
**CRITICAL**:
|
||||
- Dummy sitekeys generate `XXXX.DUMMY.TOKEN.XXXX`
|
||||
- Dummy secret keys ONLY accept dummy token
|
||||
- Production secret keys REJECT dummy token
|
||||
- Real tokens FAIL with dummy secret keys
|
||||
|
||||
---
|
||||
|
||||
## Environment Detection Patterns
|
||||
|
||||
### Pattern 1: Request Headers
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
return request.headers.get('x-test-environment') === 'true'
|
||||
}
|
||||
|
||||
// Usage in Cloudflare Worker
|
||||
if (isTestEnvironment(request)) {
|
||||
secretKey = TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: IP Address
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||
return testIPs.includes(ip)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Query Parameter
|
||||
```typescript
|
||||
function isTestEnvironment(request: Request): boolean {
|
||||
const url = new URL(request.url)
|
||||
return url.searchParams.get('test') === 'true'
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Environment Variable
|
||||
```typescript
|
||||
const sitekey = process.env.NODE_ENV === 'test'
|
||||
? TEST_SITEKEYS.ALWAYS_PASS
|
||||
: process.env.TURNSTILE_SITE_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playwright Testing
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
extraHTTPHeaders: {
|
||||
'x-test-environment': 'true', // Auto-use test credentials
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Basic Test
|
||||
```typescript
|
||||
// tests/contact-form.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('submits contact form with Turnstile', async ({ page }) => {
|
||||
await page.goto('/contact')
|
||||
|
||||
// Fill form
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.fill('textarea[name="message"]', 'Test message')
|
||||
|
||||
// Turnstile auto-solves with dummy token in test mode
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Verify success
|
||||
await expect(page.locator('.success-message')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced: Multiple Scenarios
|
||||
```typescript
|
||||
test('handles Turnstile failure gracefully', async ({ page, context }) => {
|
||||
// Override to use "always fail" sitekey
|
||||
await context.route('**/api.js', route => {
|
||||
const FAIL_SITEKEY = '2x00000000000000000000AB'
|
||||
// Inject failing sitekey
|
||||
route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/contact')
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
await expect(page.locator('.error-message')).toContainText('verification failed')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cypress Testing
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// cypress.config.ts
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5173',
|
||||
setupNodeEvents(on, config) {
|
||||
// Set test header on all requests
|
||||
on('before:browser:launch', (browser, launchOptions) => {
|
||||
launchOptions.args.push('--disable-web-security')
|
||||
return launchOptions
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Test Example
|
||||
```typescript
|
||||
// cypress/e2e/turnstile.cy.ts
|
||||
describe('Turnstile Form', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('**/*', (req) => {
|
||||
req.headers['x-test-environment'] = 'true'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form successfully', () => {
|
||||
cy.visit('/contact')
|
||||
|
||||
cy.get('input[name="email"]').type('test@example.com')
|
||||
cy.get('textarea[name="message"]').type('Test message')
|
||||
|
||||
// Turnstile auto-solves in test mode
|
||||
cy.get('button[type="submit"]').click()
|
||||
|
||||
cy.contains('Success').should('be.visible')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jest / Vitest (React)
|
||||
|
||||
### Mock @marsidev/react-turnstile
|
||||
```typescript
|
||||
// jest.setup.ts or vitest.setup.ts
|
||||
import React from 'react'
|
||||
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### Component Test
|
||||
```typescript
|
||||
// ContactForm.test.tsx
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), {
|
||||
target: { value: 'test@example.com' },
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Message'), {
|
||||
target: { value: 'Test message' },
|
||||
})
|
||||
|
||||
// Turnstile auto-solves (mocked)
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Testing (Vitest)
|
||||
|
||||
### Validate Dummy Token
|
||||
```typescript
|
||||
// server.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateTurnstile } from './turnstile-server-validation'
|
||||
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Turnstile Validation', () => {
|
||||
it('validates dummy token with test secret', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects real token with test secret', async () => {
|
||||
const realToken = 'real-production-token'
|
||||
|
||||
const result = await validateTurnstile(
|
||||
realToken,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('handles always-fail secret key', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_FAIL
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare Workers Testing (Miniflare)
|
||||
|
||||
### Setup
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'miniflare',
|
||||
environmentOptions: {
|
||||
bindings: {
|
||||
TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA',
|
||||
TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Worker Test
|
||||
```typescript
|
||||
// worker.test.ts
|
||||
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import worker from '../src/index'
|
||||
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Worker with Turnstile', () => {
|
||||
it('validates test token successfully', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('email', 'test@example.com')
|
||||
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||
|
||||
const request = new Request('http://localhost/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Configuration
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||
NODE_ENV: test
|
||||
run: npm test
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
test:
|
||||
image: node:20
|
||||
script:
|
||||
- npm ci
|
||||
- npm test
|
||||
variables:
|
||||
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||
NODE_ENV: "test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
✅ **Always use dummy keys** - Never use production credentials in tests
|
||||
✅ **Test both success and failure** - Use both pass and fail test keys
|
||||
✅ **Mock in unit tests** - Mock Turnstile component for fast unit tests
|
||||
✅ **E2E with real widget** - Use test sitekeys in E2E tests
|
||||
✅ **Separate environments** - Different config for test/dev/staging/prod
|
||||
✅ **Test expiration** - Verify token expiration handling
|
||||
✅ **Test error states** - Validate error callback behavior
|
||||
|
||||
❌ **Never commit production keys** - Use environment variables
|
||||
❌ **Don't skip server validation tests** - Critical security component
|
||||
❌ **Don't test with production sitekeys** - Can trigger rate limits
|
||||
❌ **Don't hardcode test keys** - Use constants/config files
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
```typescript
|
||||
turnstile.render('#container', {
|
||||
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||
callback: (token) => console.log('[TEST] Token:', token),
|
||||
'error-callback': (error) => console.error('[TEST] Error:', error),
|
||||
'expired-callback': () => console.warn('[TEST] Token expired'),
|
||||
})
|
||||
```
|
||||
|
||||
### Check Network Requests
|
||||
```typescript
|
||||
// Playwright
|
||||
await page.route('**/siteverify', route => {
|
||||
console.log('Siteverify called with:', route.request().postData())
|
||||
route.continue()
|
||||
})
|
||||
```
|
||||
|
||||
### Verify Environment Detection
|
||||
```typescript
|
||||
test('uses test credentials in test env', async ({ page }) => {
|
||||
const response = await page.evaluate(() => {
|
||||
return document.querySelector('.cf-turnstile')?.getAttribute('data-sitekey')
|
||||
})
|
||||
|
||||
expect(response).toBe('1x00000000000000000000AA')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Test Framework Support**: Playwright, Cypress, Jest, Vitest, Miniflare
|
||||
412
references/widget-configs.md
Normal file
412
references/widget-configs.md
Normal file
@@ -0,0 +1,412 @@
|
||||
# Turnstile Widget Configuration Reference
|
||||
|
||||
**Complete reference for all widget configuration options**
|
||||
|
||||
**Official Docs**: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations
|
||||
|
||||
---
|
||||
|
||||
## Widget Modes
|
||||
|
||||
### Managed (Recommended)
|
||||
Functions like a Managed Challenge Page. Selects a challenge based on visitor signals.
|
||||
- Shows interactive checkbox only if bot is suspected
|
||||
- Best balance of security and UX
|
||||
- Use for most production deployments
|
||||
|
||||
### Non-Interactive
|
||||
Widget is displayed but visitor never needs to interact.
|
||||
- No checkbox required
|
||||
- Runs challenge in background
|
||||
- Use for minimal user friction
|
||||
|
||||
### Invisible
|
||||
Widget is completely hidden from visitor.
|
||||
- No visual presence
|
||||
- Challenge runs invisibly
|
||||
- Use for seamless UX, API protection
|
||||
|
||||
---
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
### Core Parameters
|
||||
|
||||
#### `sitekey` (Required)
|
||||
- **Type**: `string`
|
||||
- **Description**: Your widget's public sitekey from Cloudflare Dashboard
|
||||
- **Example**: `data-sitekey="YOUR_SITE_KEY"` or `{ sitekey: 'YOUR_SITE_KEY' }`
|
||||
|
||||
#### `action`
|
||||
- **Type**: `string`
|
||||
- **Max Length**: 32 characters
|
||||
- **Valid Characters**: `a-z`, `A-Z`, `0-9`, `-`, `_`
|
||||
- **Description**: Custom action name tracked in analytics
|
||||
- **Example**: `action: 'login'`, `action: 'signup'`
|
||||
|
||||
#### `cdata`
|
||||
- **Type**: `string`
|
||||
- **Max Length**: 255 characters
|
||||
- **Description**: Custom data passed through to server validation
|
||||
- **Example**: `cdata: JSON.stringify({ userId: '123' })`
|
||||
|
||||
---
|
||||
|
||||
## Appearance
|
||||
|
||||
### `theme`
|
||||
Controls widget color scheme.
|
||||
|
||||
- **`auto`** (default) - Matches system preference
|
||||
- **`light`** - Light mode
|
||||
- **`dark`** - Dark mode
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-theme="dark"></div>
|
||||
```
|
||||
```typescript
|
||||
{ theme: 'dark' }
|
||||
```
|
||||
|
||||
### `appearance`
|
||||
Controls when widget becomes visible.
|
||||
|
||||
- **`always`** (default) - Visible from page load
|
||||
- **`execute`** - Visible only after challenge begins
|
||||
- **`interaction-only`** - Visible only when user interaction required
|
||||
|
||||
**Note**: Only affects managed/non-interactive modes. Invisible widgets never show.
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-appearance="interaction-only"></div>
|
||||
```
|
||||
```typescript
|
||||
{ appearance: 'interaction-only' }
|
||||
```
|
||||
|
||||
### `size`
|
||||
Controls widget dimensions.
|
||||
|
||||
- **`normal`** (default) - 300px × 65px
|
||||
- **`compact`** - 150px × 140px
|
||||
- **`flexible`** - 100% width, adapts to container
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-size="compact"></div>
|
||||
```
|
||||
```typescript
|
||||
{ size: 'flexible' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
### `execution`
|
||||
Controls when challenge runs and token is generated.
|
||||
|
||||
- **`render`** (default) - Runs automatically after rendering
|
||||
- **`execute`** - Runs only when `turnstile.execute()` is called
|
||||
|
||||
**Use Cases**:
|
||||
- `render`: Standard forms, immediate protection
|
||||
- `execute`: Multi-step forms, conditional verification, performance optimization
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#container', {
|
||||
sitekey: SITE_KEY,
|
||||
execution: 'execute', // Manual trigger
|
||||
})
|
||||
|
||||
// Later, when needed:
|
||||
turnstile.execute(widgetId)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callbacks
|
||||
|
||||
### `callback`
|
||||
Called when challenge succeeds.
|
||||
|
||||
**Signature**: `(token: string) => void`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<div class="cf-turnstile" data-callback="onSuccess"></div>
|
||||
<script>
|
||||
function onSuccess(token) {
|
||||
console.log('Token:', token)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
```typescript
|
||||
{
|
||||
callback: (token) => {
|
||||
console.log('Success:', token)
|
||||
document.getElementById('submit-btn').disabled = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `error-callback`
|
||||
Called when challenge fails or errors occur.
|
||||
|
||||
**Signature**: `(errorCode: string) => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'error-callback': (error) => {
|
||||
console.error('Turnstile error:', error)
|
||||
showErrorMessage('Verification failed')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `expired-callback`
|
||||
Called when token expires (after 5 minutes).
|
||||
|
||||
**Signature**: `() => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'expired-callback': () => {
|
||||
console.warn('Token expired')
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `timeout-callback`
|
||||
Called when interactive challenge times out (user didn't interact).
|
||||
|
||||
**Signature**: `() => void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
'timeout-callback': () => {
|
||||
console.warn('Challenge timed out')
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Retry Behavior
|
||||
|
||||
### `retry`
|
||||
Controls automatic retry on errors.
|
||||
|
||||
- **`auto`** (default) - Automatically retries on transient errors
|
||||
- **`never`** - No automatic retry, manual control via `turnstile.reset()`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
retry: 'never', // Manual control
|
||||
'error-callback': (error) => {
|
||||
if (shouldRetry(error)) {
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `retry-interval`
|
||||
Milliseconds between automatic retries.
|
||||
|
||||
- **Default**: 8000ms (8 seconds)
|
||||
- **Min**: 0
|
||||
- **Max**: No limit
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
retry: 'auto',
|
||||
'retry-interval': 5000, // 5 seconds
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Configuration Example
|
||||
|
||||
### Implicit Rendering (HTML)
|
||||
|
||||
```html
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="YOUR_SITE_KEY"
|
||||
data-callback="onSuccess"
|
||||
data-error-callback="onError"
|
||||
data-expired-callback="onExpired"
|
||||
data-timeout-callback="onTimeout"
|
||||
data-theme="auto"
|
||||
data-size="normal"
|
||||
data-appearance="always"
|
||||
data-retry="auto"
|
||||
data-retry-interval="8000"
|
||||
data-action="login"
|
||||
data-cdata='{"userId":"123"}'>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Explicit Rendering (TypeScript)
|
||||
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#container', {
|
||||
sitekey: 'YOUR_SITE_KEY',
|
||||
callback: (token) => console.log('Success:', token),
|
||||
'error-callback': (error) => console.error('Error:', error),
|
||||
'expired-callback': () => turnstile.reset(widgetId),
|
||||
'timeout-callback': () => console.warn('Timeout'),
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
execution: 'render',
|
||||
appearance: 'always',
|
||||
retry: 'auto',
|
||||
'retry-interval': 8000,
|
||||
action: 'login',
|
||||
cdata: JSON.stringify({ userId: '123' }),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Methods
|
||||
|
||||
### `turnstile.render()`
|
||||
Renders a widget programmatically.
|
||||
|
||||
**Signature**:
|
||||
```typescript
|
||||
render(
|
||||
container: string | HTMLElement,
|
||||
options: TurnstileOptions
|
||||
): string // Returns widgetId
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const widgetId = turnstile.render('#my-container', {
|
||||
sitekey: SITE_KEY,
|
||||
callback: handleSuccess,
|
||||
})
|
||||
```
|
||||
|
||||
### `turnstile.reset()`
|
||||
Resets widget to initial state, clears current token.
|
||||
|
||||
**Signature**: `reset(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
turnstile.reset(widgetId)
|
||||
```
|
||||
|
||||
### `turnstile.remove()`
|
||||
Completely removes widget from DOM.
|
||||
|
||||
**Signature**: `remove(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
turnstile.remove(widgetId)
|
||||
```
|
||||
|
||||
### `turnstile.execute()`
|
||||
Manually triggers challenge (execution: 'execute' mode only).
|
||||
|
||||
**Signature**: `execute(widgetId: string): void`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
document.querySelector('#submit').addEventListener('click', () => {
|
||||
turnstile.execute(widgetId)
|
||||
})
|
||||
```
|
||||
|
||||
### `turnstile.getResponse()`
|
||||
Gets current token value.
|
||||
|
||||
**Signature**: `getResponse(widgetId: string): string | undefined`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const token = turnstile.getResponse(widgetId)
|
||||
if (token) {
|
||||
submitForm(token)
|
||||
}
|
||||
```
|
||||
|
||||
### `turnstile.isExpired()`
|
||||
Checks if token has expired.
|
||||
|
||||
**Signature**: `isExpired(widgetId: string): boolean`
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
if (turnstile.isExpired(widgetId)) {
|
||||
turnstile.reset(widgetId)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration from reCAPTCHA
|
||||
|
||||
Turnstile can be a drop-in replacement for reCAPTCHA v2.
|
||||
|
||||
### Compatibility Mode
|
||||
|
||||
Add `?compat=recaptcha` to script URL:
|
||||
|
||||
```html
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Implicit rendering for reCAPTCHA
|
||||
- `g-recaptcha-response` input name
|
||||
- Registers API as `grecaptcha`
|
||||
|
||||
**Example**:
|
||||
```html
|
||||
<!-- Old reCAPTCHA code -->
|
||||
<div class="g-recaptcha" data-sitekey="OLD_RECAPTCHA_KEY"></div>
|
||||
|
||||
<!-- New Turnstile code (compatibility mode) -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?compat=recaptcha"></script>
|
||||
<div class="g-recaptcha" data-sitekey="NEW_TURNSTILE_KEY"></div>
|
||||
```
|
||||
|
||||
**Note**: Change script URL and sitekey, everything else stays the same.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
✅ **Always validate server-side** - Client widget is not sufficient
|
||||
✅ **Handle expiration** - Implement `expired-callback` to reset widget
|
||||
✅ **Handle errors** - Use `error-callback` for user-friendly messages
|
||||
✅ **Use actions** - Track different form types in analytics
|
||||
✅ **Test with dummy keys** - Use `1x00000000000000000000AA` for development
|
||||
✅ **Separate environments** - Different widgets for dev/staging/production
|
||||
|
||||
❌ **Never proxy api.js** - Must load from Cloudflare CDN
|
||||
❌ **Never reuse tokens** - Each token is single-use
|
||||
❌ **Never expose secret key** - Keep in backend only
|
||||
❌ **Never skip expiration handling** - Tokens expire after 5 minutes
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**API Version**: v0 (stable)
|
||||
Reference in New Issue
Block a user