npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-core
npm install van-turnstile vanjs-core

Basic 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.
  • executionTimeout defaults 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=explicit

Never 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:// or https:// origins; file:// is unsupported.
  • The package may be imported by strict Node ESM, but constructing a widget is browser-only and fails with browser-unavailable elsewhere.
  • 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 demo

Verification commands:

bun run lint
bun run test
bun run build
bun run demo:build
bun run check
bun run smoke:esm
npm pack --dry-run

prepack runs the complete check pipeline. It verifies the package but does not publish it.

License

MIT