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

@broberg/forms-turnstile

v0.3.0

Published

Spam-protected public form primitives for the broberg.ai fleet: honeypot detection, an in-process IP rate limiter, and Cloudflare Turnstile server-side verification. Headless core + a widget hook for React and Preact + a Hono middleware.

Readme

@broberg/forms-turnstile

Spam-protected public form primitives for the broberg.ai fleet: honeypot detection, an in-process IP rate limiter, and Cloudflare Turnstile server-side verification — plus a Preact widget hook and a Hono middleware. Extracted from webhouse/cms's form pipeline (headless core) cross-checked against xrt81's lead form (Preact/Hono e2e).

npm i @broberg/forms-turnstile      # exact-pin for prod-auth deps

Core (@broberg/forms-turnstile / @broberg/forms-turnstile/server)

Framework-agnostic, node:crypto only.

import { applySpamGauntlet, hashIp, getSitekeyResponse } from "@broberg/forms-turnstile/server";

const ipHash = hashIp(clientIp); // GDPR-friendly — never store the raw IP

const result = await applySpamGauntlet({
  honeypot: { body },                                          // omit to skip this layer
  rateLimit: { ipHash, formName: "contact", maxPerHour: 5 },    // omit to skip this layer
  turnstile: { token: body.token, secret: env.TURNSTILE_SECRET_KEY, remoteip: clientIp },
});
if (result.blocked) {
  // result.reason: "honeypot" | "rate-limit" | "turnstile"
}

Each layer is opt-in — pass only the options key for the checks you want; they run fail-fast in the order honeypot → rate-limit → Turnstile.

The individual checks are exported too (isHoneypotTriggered, isRateLimited, validateTurnstile, HONEYPOT_FIELD) if you'd rather call them yourself.

"You are a bot" and "we could not ask" are different answers (v0.3.0)

verifyTurnstile() returns three outcomes, because three things can happen:

import { verifyTurnstile } from "@broberg/forms-turnstile/server";

const r = await verifyTurnstile(token, secret, { remoteip, timeoutMs: 10_000 });
// { ok: true }
// { ok: false, reason: "rejected",    errorCodes: [...] }   ← Cloudflare said no
// { ok: false, reason: "unavailable", detail: "…" }         ← we never got an answer

It never throws — a 5xx, an HTML error page, a dropped connection and a timeout all come back as unavailable with a readable detail.

Why this exists. Before v0.3.0, unavailable reached callers on two different channels depending on the shape of Cloudflare's failure: a non-JSON body threw, while a JSON body without a success field returned false. No caller could handle it consistently — and the false branch was the harmful one. It became reason: "turnstile", which renders as "you failed the bot check": a real person told she is not human, with "try again" as her only option and nothing wrong with her token. The log said turnstile too, naming the wrong cause.

Measured in production. It is the same defect v0.2.0 fixed on the browser side — when a client/server pair has this bug in one half, check the other half first.

Absence of a verdict is not a verdict.

Choosing the policy

applySpamGauntlet defaults to fail-closed:

await applySpamGauntlet({ turnstile: { token, secret } });
// Cloudflare unreachable → THROWS. An unguarded route 500s. Nothing gets through.

That is deliberate: it preserves what this package already did for the dominant outage shape, so upgrading never silently converts somebody's 500 into a 400 that accuses a human. To decide for yourself instead:

const r = await applySpamGauntlet({
  turnstile: { token, secret, onUnavailable: "block" },
});
if (r.blocked && r.reason === "turnstile-unavailable") {
  // Say what is true: "We can't check that right now — try shortly, or call us."
  // NOT "you failed the bot check".
}

turnstile-unavailable is a distinct member of SpamBlockReason, so a switch over it will tell you at compile time that you have a new case to handle. Both options are also on the Hono middleware (timeoutMs, onUnavailable).

There is now a timeout (default 10s). Without one, a hung Cloudflare connection holds the request until the platform kills the invocation — worse on Fly/serverless than locally, and the user just watches a spinner.

validateTurnstile() is deprecated but unchanged. It is lossy — it cannot tell the two failures apart, and still splits them across a return value and a throw. Its exact behaviour is pinned by test so existing callers see no change on upgrade. Migrate to verifyTurnstile when you touch the call-site.

Rate limiter caveat: in-process only (a Map, swept lazily) — protects a single-instance deployment (Fly single machine, one Bun worker) but each instance has its own counters, so it does not protect multi-instance/serverless. For a shared, pluggable-store limiter (Turso/Redis-backed), reach for @broberg/apikey's SlidingWindowRateLimiter instead.

Measure your instance count, then write it next to the constant. With N instances the effective ceiling is maxPerHour × N, not maxPerHour — so the number in your code is a lie for whoever reads it next unless the comment says so. flyctl scale show -a <app> answers it in one line. fd-sundhed measured 2 machines against maxPerHour: 5 and documented the real limit as 10 at the constant, not in a commit message, which is the right place for it.

This is a brake on repetition, not a door. Honeypot and Turnstile carry the protection; if the rate limit is the layer you are relying on, you need a shared store.

Local dev / CI — no real keys needed

import { TURNSTILE_TEST_SITE_KEY, TURNSTILE_TEST_SECRET_KEY } from "@broberg/forms-turnstile/server";

Cloudflare's official always-pass test keys — safe to commit, safe default so the flow works end-to-end without a real Turnstile widget.

⚠️ Do not E2E-assert the unsolved state against the test keys

They solve almost instantly. So a check like "the submit button is disabled before the user solves the challenge" is racing the widget: the state you are trying to prove exists for under a second.

fd-sundhed hit this on adoption — the same assertion passed on one page and failed on the other, not because the app behaved differently, but because the assert raced. They deleted the check rather than adding a wait, which is the right call: a test that passes or fails on timing proves nothing in either direction. It is not a flaky test, it is a test of a state the test keys do not hold still for.

Assert the states that persist instead — solved after solving, failed with its error when you block the script. And note this trap is one we built, by shipping always-pass keys as the default: the convenience and the race are the same feature.

Runtime site-key delivery

// GET /config route — serves the (public) site key at runtime so rotating it
// is a secret change, never a rebuild.
app.get("/config", (c) => c.json(getSitekeyResponse(env.TURNSTILE_SITE_KEY)));

Widget hook — React (/react) or Preact (/preact)

Lazy-loads the Turnstile script (cached + deduped) and renders the widget once a site key is available. Both adapters are the same implementation — they differ only in which package the hooks come from, so a fix reaches both.

import { useTurnstile } from "@broberg/forms-turnstile/react";   // or /preact

function ContactForm() {
  const { widgetRef, token, status, error, reset } = useTurnstile(siteKey);

  return (
    <form onSubmit={onSubmit}>
      {/* ...fields... */}
      <div ref={widgetRef} data-testid="contact-form-captcha" />
      <button type="submit" disabled={status !== "solved"}>Send</button>
      {status === "failed" && <p role="alert">Spam-tjekket kunne ikke indlæses. {error}</p>}
    </form>
  );
}

siteKey may be null/undefined while a runtime /config fetch is in flight — that reads as loading.

Gate the submit button on status, not on token (v0.2.0)

| status | meaning | | --- | --- | | loading | no site key yet, or the script is still loading | | ready | the widget is up and waiting for the user | | solved | token is valid — this is the only state you should submit in | | failed | it will not work without intervention; error says why |

Why this matters. Before v0.2.0 the hook exposed only token, and an empty token had two causes: the user has not solved it yet, and this will never work. A form gating on !token therefore showed a submit button that never enabled, with nothing anywhere saying why. Turnstile is blocked by ordinary privacy extensions often enough that this is a normal user's experience, not an edge case.

Three distinct paths used to end in that same silence — a script that failed to load, a script that loaded while window.turnstile never appeared, and a widgetRef that was never attached. All three now end in failed with a distinct error. Raised by fd-sundhed, who found the first of the three by reading the tarball.

reset() returns a solved widget to ready. It will not move a failed widget out of failed — resetting a widget that never loaded cannot repair it, and laundering that into a hopeful state would erase the only evidence of the real problem.

React notes

react is an optional peer (>=18). The bundle carries "use client", so a Next.js App Router project can import it from a client component without marking anything extra — and only the React bundles carry it; /server and /hono stay server-safe.

Hono middleware (@broberg/forms-turnstile/hono)

Reads the JSON body itself (to inspect the honeypot field + Turnstile token), runs the gauntlet, and short-circuits with a 400 on block. On pass, the parsed body is stashed on the context as spamCheckedBody so your handler doesn't re-read the (already consumed) request stream.

import { honoTurnstileMiddleware } from "@broberg/forms-turnstile/hono";

app.post(
  "/api/contact",
  honoTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, formName: "contact", maxPerHour: 5 }),
  (c) => {
    const body = c.get("spamCheckedBody");
    // ...persist + notify...
    return c.json({ ok: true });
  },
);