@broberg/forms-turnstile

中文文档
0.4.0发布于 2026/10/23MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 6584

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.

原始使用文档
文档目录9 节
  1. Core ( @broberg/forms-turnstile / @broberg/forms-turnstile/…
  2. "You are a bot" and "we could not ask" are different answer…
  3. Local dev / CI — no real keys needed
  4. Runtime site-key delivery
  5. Widget hook — React ( /react ) or Preact ( /preact )
  6. A low-key box — render options (v0.4.0)
  7. Gate the submit button on status , not on token (v0.2.0)
  8. React notes
  9. Hono middleware ( @broberg/forms-turnstile/hono )

@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.

A low-key box — render options (v0.4.0)

useTurnstile(siteKey, { appearance: "interaction-only", theme: "light", size: "flexible" });

Passed unchanged to turnstile.render: appearance ("always" | "execute" | "interaction-only"), theme ("light" | "dark" | "auto"), size ("normal" | "flexible" | "compact"), language. With "interaction-only" the box only appears when Cloudflare actually wants a click; otherwise status goes straight to solved with nothing visible. Options cannot replace the site key or the hook's callbacks. Without options the widget renders exactly as in 0.3.0.

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

statusmeaning
loadingno site key yet, or the script is still loading
readythe widget is up and waiting for the user
solvedtoken is valid — this is the only state you should submit in
failedit 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 });
  },
);

版本与发布记录

@broberg/forms-turnstile 共发布 5 个版本,首次发布于 2026/07/02;过去 12 个月发布了 5 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.0latest2026/10/2316 天前
0.3.02026/08/232 个月前
0.2.12026/08/122 个月前
0.2.02026/08/122 个月前
0.1.02026/07/024 个月前

关于 @broberg/forms-turnstile 的常见问题

@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. Headless core + a widget hook for React and Preact + a Hono middleware.

如何安装 @broberg/forms-turnstile?

在项目目录运行 npm install @broberg/forms-turnstile,或使用 pnpm add / yarn add / bun add。

@broberg/forms-turnstile 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/forms-turnstile 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/forms-turnstile 可以商用吗?

@broberg/forms-turnstile 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/forms-turnstile 的下载量有多少?

根据 npm 官方下载统计,@broberg/forms-turnstile 在 2026/10/09 – 2026/11/07 这 30 天内被下载 6,584 次,日均约 219 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/forms-turnstile 还在维护吗?

最新版本 0.4.0 发布于 2026/10/23(16 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/07/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。