van-turnstile
v0.2.1
Published
Cloudflare Turnstile wrapper for VanJS
Readme
van-turnstile
A small, browser-focused VanJS wrapper for Cloudflare Turnstile.
- Explicit rendering for SPAs and dynamic forms
- Shared, retryable loading of Cloudflare's official
api.js - Reactive token, lifecycle status, and error state
- Safe promise-based manual execution
- Typed access to the current documented widget configuration
- Recoverable removal and terminal disposal
The package has no backend, native, Tauri, or application-specific helpers.
Install
bun add van-turnstile vanjs-corenpm install van-turnstile vanjs-coreBasic rendering
import van from "vanjs-core";
import { Turnstile } from "van-turnstile";
const captcha = Turnstile({
sitekey: "your-site-key",
onToken: () => {
// Enable submission. Do not log or persist the token.
},
onError: (cloudflareCode) => {
// This remains Cloudflare's documented error-code callback.
showCaptchaError(cloudflareCode);
return true; // Suppress Cloudflare's extra console warning.
},
});
van.add(document.querySelector("#app")!, captcha.el);
await captcha.ready;ready represents the handle's initial automatic render. It waits for DOM
attachment, script loading, API availability, and turnstile.render().
Startup rejections are observed internally, so an application may instead
react to status and error without causing an unhandled rejection.
Promise-based execution
Use Cloudflare's execution: "execute" mode when a fresh token should be
requested only at submission time:
const captcha = Turnstile({
sitekey: "your-site-key",
execution: "execute",
appearance: "execute",
});
van.add(document.querySelector("#captcha")!, captcha.el);
async function submitForm() {
try {
const token = await captcha.executeAsync();
// Send it directly to your server for Siteverify validation.
// Do not log it, put it in persistent storage, or expose your secret key.
await fetch("/submit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ turnstileToken: token }),
});
} catch (error) {
showRecoverableCaptchaError(error);
}
}executeAsync() is safe immediately after construction:
- It waits for attachment, script loading, API availability, and rendering.
- Concurrent calls share the same active execution and resolve to the same token.
- A later execution-mode call resets the completed widget first, ensuring a fresh token.
- A new call after a rendered widget error resets the widget before retrying.
- It rejects on loading, rendering, Cloudflare error, expiry, interactive timeout, unsupported browser, execution timeout, removal, or disposal.
executionTimeoutdefaults to 120,000 ms and is configurable.
For the default execution: "render" mode, executeAsync() returns the
current non-expired token or waits for the automatically running challenge.
The legacy synchronous execute() method remains for compatibility. It only
delegates when the widget is already rendered, does not wait, and does not
return a token. Prefer executeAsync().
Lifecycle, failure, and recovery
status is a VanJS State<TurnstileStatus> with these values:
| Status | Meaning |
| --- | --- |
| waiting | Waiting for el to be connected |
| loading | Loading Cloudflare's script and API |
| rendering | Calling turnstile.render() |
| ready | Widget rendered and available |
| executing | Waiting for a token |
| completed | A token was generated |
| error | error.val contains the latest lifecycle error |
| removed | Widget removed; render() can recreate it |
| disposed | Terminal state |
van.add(
document.body,
captcha.el,
() => `Turnstile: ${captcha.status.val}`,
() => captcha.error.val?.message ?? "",
);Lifecycle failures use TurnstileError, with a stable code and optional
cause. Cloudflare widget failures use code: "widget-error" and retain the
provider value as cloudflareCode; the existing onError(errorCode) callback
contract is unchanged.
Retry a failed initial render or recreate a removed widget explicitly:
try {
await captcha.render();
} catch (error) {
// Offer another retry or a different verification path.
}If a rendered widget enters error, render() retries it through
turnstile.reset(). This is the documented manual recovery path when
retry: "never". Calling reset() directly performs the same retry without a
promise.
remove() removes Cloudflare's widget, clears pending execution, and leaves
the handle recoverable through render(). dispose() is idempotent,
terminal, cancels attachment/loading waits, removes the widget, and rejects
pending execution. Late callbacks are ignored.
Always call dispose() when a route or component permanently removes its
widget:
onRouteLeave(() => captcha.dispose());API
Turnstile(props): TurnstileHandle
TurnstileProps uses camelCase and maps to Cloudflare's documented render
keys:
| Prop | Type / behavior |
| --- | --- |
| sitekey | Required non-empty string |
| onToken | (token: string) => void |
| onTokenExpired | () => void |
| onError | (cloudflareCode?: string) => unknown; return truthy after handling to suppress Cloudflare's console warning |
| onTimeout | Interactive timeout callback |
| onUnsupported | Unsupported-browser callback |
| onBeforeInteractive | Before interactive mode |
| onAfterInteractive | After interactive mode |
| theme | "auto" \| "light" \| "dark" |
| size | "normal" \| "compact" \| "flexible" |
| appearance | "always" \| "execute" \| "interaction-only" |
| execution | "render" \| "execute" |
| language | "auto" or a supported language/region code |
| tabIndex | Maps to Cloudflare tabindex |
| responseField | Controls the hidden response input |
| responseFieldName | Hidden input name |
| retry | "auto" \| "never" |
| retryInterval | Positive integer below 900,000 ms |
| refreshExpired | "auto" \| "manual" \| "never" |
| refreshTimeout | "auto" \| "manual" \| "never" |
| feedbackEnabled | Cloudflare failure-feedback control |
| offLabelShowPrivacy | Unbranded-widget privacy link |
| offLabelShowHelp | Unbranded-widget help link |
| action | 0–32 alphanumeric, _, or - characters |
| cData | 0–255 alphanumeric, _, or - characters |
| class | Class for the wrapper HTMLDivElement |
| scriptNonce | CSP nonce applied when this package creates api.js |
| scriptLoadTimeout | Script/API load timeout; default 15,000 ms |
| executionTimeout | Token execution timeout; default 120,000 ms |
Cloudflare defaults are left unchanged when a prop is omitted.
autoRefresh is deprecated but retained for compatibility. When
refreshExpired is absent, autoRefresh: true maps to "auto" and false
maps to "never". An explicit refreshExpired always wins. The wrapper does
not issue a duplicate reset from the expired callback. With Cloudflare's
default or explicit automatic refresh, expiry clears the token and returns the
lifecycle to executing while Cloudflare obtains a replacement; manual and
disabled refresh remain widget-expired errors.
TurnstileHandle
| Member | Behavior |
| --- | --- |
| el | Wrapper HTMLDivElement to mount |
| token | Reactive State<string \| null> |
| status | Reactive lifecycle state |
| error | Reactive State<TurnstileError \| null> |
| ready | Promise for the initial automatic render |
| render() | Render, reset an errored widget, or recreate; resolves to widget ID |
| executeAsync() | Wait and resolve with a token |
| execute() | Ready-only synchronous compatibility method |
| reset() | Reset/retry a rendered widget and clear local error/token state |
| remove() | Recoverably remove the widget |
| getResponse() | Current API response or reactive token |
| isExpired() | Delegate to turnstile.isExpired() when ready |
| dispose() | Terminal, idempotent cleanup |
The exported TurnstileApi type mirrors Cloudflare's browser API, including
ready() and isExpired(). Applications should normally use the handle's
ready promise; the shared loader already waits for the script load event and
API availability before rendering.
Script loading and CSP
The loader deduplicates the script element across concurrent widgets while enforcing each caller's own timeout, detects an existing official Turnstile script even when its supported query parameters differ, and retries after failure or timeout. A failed host-provided script is left in place and skipped on retry. The loader always loads Cloudflare's script directly from:
https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicitNever proxy, bundle, or cache api.js.
For a nonce-based CSP, pass the same server-generated nonce used by the page:
const captcha = Turnstile({
sitekey: "your-site-key",
scriptNonce: cspNonce,
});The script is global and shared, so concurrent widgets should receive the same request nonce; the call that creates the script supplies it.
Cloudflare recommends nonce-based CSP3 with strict-dynamic. A host allowlist
otherwise needs https://challenges.cloudflare.com in both script-src and
frame-src. See Cloudflare's CSP reference.
Security and platform requirements
- Server-side Siteverify validation is mandatory. A client widget alone does not protect a form.
- Tokens expire after five minutes and are single-use. Validate promptly and request a fresh token after expiry or redemption.
- Never log or persist tokens. Never ship the Turnstile secret key to a browser, bundle, mobile app, or public repository.
- Widgets only work on pages with
http://orhttps://origins;file://is unsupported. - The package may be imported by strict Node ESM, but constructing a widget is
browser-only and fails with
browser-unavailableelsewhere. - Turnstile works directly in mobile browsers. Native applications need a correctly configured WebView with JavaScript, DOM storage, stable user agent/device characteristics, allowed Cloudflare network access, and an HTTP(S) page. This package intentionally provides no native bridge. See Cloudflare's mobile implementation guide.
Demo and development
The Vite demo uses Cloudflare's public testing sitekey and intentionally hides token values:
bun run demoVerification commands:
bun run lint
bun run test
bun run build
bun run demo:build
bun run check
bun run smoke:esm
npm pack --dry-runprepack runs the complete check pipeline. It verifies the package but does
not publish it.
License
MIT
