{"_id":"@broberg/forms-turnstile","_rev":"4-01c4d77f7503d1bf3a5e0ff2a68765e7","name":"@broberg/forms-turnstile","dist-tags":{"latest":"0.3.0"},"versions":{"0.1.0":{"name":"@broberg/forms-turnstile","version":"0.1.0","keywords":["turnstile","cloudflare","captcha","spam-protection","honeypot","rate-limit","forms","hono","preact","broberg"],"license":"MIT","_id":"@broberg/forms-turnstile@0.1.0","maintainers":[{"name":"cbroberg","email":"cb@webhouse.dk"}],"homepage":"https://github.com/broberg-ai/components#readme","bugs":{"url":"https://github.com/broberg-ai/components/issues"},"dist":{"shasum":"980da4493dba20086af70e46ccdec9bba987bee8","tarball":"https://registry.npmjs.org/@broberg/forms-turnstile/-/forms-turnstile-0.1.0.tgz","fileCount":28,"integrity":"sha512-6E3kS6MzGE+Q0ry8LWr6ZYeCAUyiGJjrBv3xpHrP6abHldc0uZdVJf31t7W95aO51S3PP6d6DStRXonLmS5ZJg==","signatures":[{"sig":"MEUCICRlScdl8+0p/R7c/SOBvwigcuaoYCLHICU4I58rEaK4AiEAqLhVU35iQ7JA+pnjvEeWYC3kG/eHOUduypWMl3c/QcQ=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":89771},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./hono":{"types":"./dist/hono.d.ts","import":"./dist/hono.js","require":"./dist/hono.cjs"},"./preact":{"types":"./dist/preact.d.ts","import":"./dist/preact.js","require":"./dist/preact.cjs"},"./server":{"types":"./dist/server.d.ts","import":"./dist/server.js","require":"./dist/server.cjs"}},"gitHead":"65b841c3a66adfd3932260c116e87133207ae893","scripts":{"test":"vitest run","build":"tsup","typecheck":"tsc --noEmit"},"_npmUser":{"name":"cbroberg","email":"cb@webhouse.dk"},"repository":{"url":"git+https://github.com/broberg-ai/components.git","type":"git","directory":"packages/forms-turnstile"},"_npmVersion":"11.10.1","description":"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 Preact widget hook + a Hono middleware.","directories":{},"sideEffects":false,"_nodeVersion":"25.7.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"hono":"^4.6.0","tsup":"^8.3.0","preact":"^10.23.0","vitest":"^2.1.0","typescript":"^5.6.0","@types/node":"^22.7.0"},"peerDependencies":{"hono":">=4","preact":">=10.0.0"},"peerDependenciesMeta":{"hono":{"optional":true},"preact":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/forms-turnstile_0.1.0_1782933919073_0.5607493216854202","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@broberg/forms-turnstile","version":"0.2.0","keywords":["turnstile","cloudflare","captcha","spam-protection","honeypot","rate-limit","forms","hono","preact","react","broberg"],"license":"MIT","_id":"@broberg/forms-turnstile@0.2.0","maintainers":[{"name":"cbroberg","email":"cb@webhouse.dk"}],"homepage":"https://github.com/broberg-ai/components#readme","bugs":{"url":"https://github.com/broberg-ai/components/issues"},"dist":{"shasum":"78324f64125347194a56ef93a4d1ea84b3eed5c6","tarball":"https://registry.npmjs.org/@broberg/forms-turnstile/-/forms-turnstile-0.2.0.tgz","fileCount":38,"integrity":"sha512-1GeXbpZiibRSOMnctLkuzRmR8wzMJZT9r0XtNe/0ccA3EXLEAtiznyJ4ziTeDf1SNI7Vf+WZj+tUroJZp64nQQ==","signatures":[{"sig":"MEUCICuTHR2kEou98gvoq4BcX0unCiF8vqwh9CkhBETc84GZAiEAvxbTmR5BNfEeDNFrQ6dvoY7lBkP7yXa6wgR3J4kaghA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@broberg%2fforms-turnstile@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":127656},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./hono":{"types":"./dist/hono.d.ts","import":"./dist/hono.js","require":"./dist/hono.cjs"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.js","require":"./dist/react.cjs"},"./preact":{"types":"./dist/preact.d.ts","import":"./dist/preact.js","require":"./dist/preact.cjs"},"./server":{"types":"./dist/server.d.ts","import":"./dist/server.js","require":"./dist/server.cjs"}},"gitHead":"bd3d35fcd4db7c188099610a823a9e4729734c34","scripts":{"test":"vitest run","build":"tsup && node add-use-client.mjs","typecheck":"tsc --noEmit"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:92f6483e-6d42-4a0f-8b5d-1c92a9096aed"}},"repository":{"url":"git+https://github.com/broberg-ai/components.git","type":"git","directory":"packages/forms-turnstile"},"_npmVersion":"11.5.1","description":"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.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"hono":"^4.6.0","tsup":"^8.3.0","react":"^19.0.0","preact":"^10.23.0","vitest":"^2.1.0","typescript":"^5.6.0","@types/node":"^22.7.0","@types/react":"^19.0.0"},"peerDependencies":{"hono":">=4","react":">=18","preact":">=10.0.0"},"peerDependenciesMeta":{"hono":{"optional":true},"react":{"optional":true},"preact":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/forms-turnstile_0.2.0_1786525261581_0.8771148313427277","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@broberg/forms-turnstile","version":"0.2.1","keywords":["turnstile","cloudflare","captcha","spam-protection","honeypot","rate-limit","forms","hono","preact","react","broberg"],"license":"MIT","_id":"@broberg/forms-turnstile@0.2.1","maintainers":[{"name":"cbroberg","email":"cb@webhouse.dk"}],"homepage":"https://github.com/broberg-ai/components#readme","bugs":{"url":"https://github.com/broberg-ai/components/issues"},"dist":{"shasum":"fac597b25e5fb74fd96013c526824fa7ee546487","tarball":"https://registry.npmjs.org/@broberg/forms-turnstile/-/forms-turnstile-0.2.1.tgz","fileCount":38,"integrity":"sha512-GFCFMTZ/5lF0KqanBX1i/txpUu0kajAGkw7fEVEI0MBCpIO70csBsnM7pjtVYPoOQomVXqHNj0M2kPyLvbvJbQ==","signatures":[{"sig":"MEUCIDPWsaz3lYYpkF+D7Y88rpvu0bhAmcqnddCOp/+RDJg/AiEA6+paYRn4nlPUE2d8yEf/o07/P19+hYxk3lxHnMRcmdU=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@broberg%2fforms-turnstile@0.2.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":129278},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./hono":{"types":"./dist/hono.d.ts","import":"./dist/hono.js","require":"./dist/hono.cjs"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.js","require":"./dist/react.cjs"},"./preact":{"types":"./dist/preact.d.ts","import":"./dist/preact.js","require":"./dist/preact.cjs"},"./server":{"types":"./dist/server.d.ts","import":"./dist/server.js","require":"./dist/server.cjs"}},"gitHead":"ce8787ee5b1dd6f38a10e4e648605fed7f60d277","scripts":{"test":"vitest run","build":"tsup && node add-use-client.mjs","typecheck":"tsc --noEmit"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:92f6483e-6d42-4a0f-8b5d-1c92a9096aed"}},"repository":{"url":"git+https://github.com/broberg-ai/components.git","type":"git","directory":"packages/forms-turnstile"},"_npmVersion":"11.5.1","description":"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.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"hono":"^4.6.0","tsup":"^8.3.0","react":"^19.0.0","preact":"^10.23.0","vitest":"^2.1.0","typescript":"^5.6.0","@types/node":"^22.7.0","@types/react":"^19.0.0"},"peerDependencies":{"hono":">=4","react":">=18","preact":">=10.0.0"},"peerDependenciesMeta":{"hono":{"optional":true},"react":{"optional":true},"preact":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/forms-turnstile_0.2.1_1786526603571_0.619737671155477","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@broberg/forms-turnstile","version":"0.3.0","description":"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.","type":"module","license":"MIT","sideEffects":false,"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./server":{"types":"./dist/server.d.ts","import":"./dist/server.js","require":"./dist/server.cjs"},"./preact":{"types":"./dist/preact.d.ts","import":"./dist/preact.js","require":"./dist/preact.cjs"},"./react":{"types":"./dist/react.d.ts","import":"./dist/react.js","require":"./dist/react.cjs"},"./hono":{"types":"./dist/hono.d.ts","import":"./dist/hono.js","require":"./dist/hono.cjs"}},"scripts":{"build":"tsup && node add-use-client.mjs","test":"vitest run","typecheck":"tsc --noEmit"},"peerDependencies":{"hono":">=4","preact":">=10.0.0","react":">=18"},"peerDependenciesMeta":{"hono":{"optional":true},"preact":{"optional":true},"react":{"optional":true}},"devDependencies":{"@types/node":"^22.7.0","hono":"^4.6.0","preact":"^10.23.0","tsup":"^8.3.0","typescript":"^5.6.0","vitest":"^2.1.0","react":"^19.0.0","@types/react":"^19.0.0"},"keywords":["turnstile","cloudflare","captcha","spam-protection","honeypot","rate-limit","forms","hono","preact","react","broberg"],"repository":{"type":"git","url":"git+https://github.com/broberg-ai/components.git","directory":"packages/forms-turnstile"},"publishConfig":{"access":"public"},"_id":"@broberg/forms-turnstile@0.3.0","gitHead":"dafe5e7a3a91ca9881e3fc01b3bddefca45f439d","bugs":{"url":"https://github.com/broberg-ai/components/issues"},"homepage":"https://github.com/broberg-ai/components#readme","_nodeVersion":"22.23.2","_npmVersion":"11.5.1","dist":{"integrity":"sha512-06uccOX34ap3tCr2i9HrsXuiAD9ngYqqDFZkC0gzooH9aqNbnT4foRKzhoMuEiPmR5cM25HQXcxRxy69HBCxXQ==","shasum":"548c0b486227f19feb8d08e18727c10ef76af6e4","tarball":"https://registry.npmjs.org/@broberg/forms-turnstile/-/forms-turnstile-0.3.0.tgz","fileCount":38,"unpackedSize":182925,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@broberg%2fforms-turnstile@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQC09MachvgWyAKFNTDCvHw8NlqDaKfXM37sMgt1ENz5owIgXEeGLx/3Zdy0FmBOtR/UH0npiau49Hxiv4LIu2+Nu+o="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:92f6483e-6d42-4a0f-8b5d-1c92a9096aed"}},"directories":{},"maintainers":[{"name":"cbroberg","email":"cb@webhouse.dk"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/forms-turnstile_0.3.0_1787469350180_0.9778251128256585"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-01T19:25:18.926Z","modified":"2026-08-23T07:15:50.693Z","0.1.0":"2026-07-01T19:25:19.212Z","0.2.0":"2026-08-12T09:01:01.727Z","0.2.1":"2026-08-12T09:23:23.695Z","0.3.0":"2026-08-23T07:15:50.343Z"},"bugs":{"url":"https://github.com/broberg-ai/components/issues"},"license":"MIT","homepage":"https://github.com/broberg-ai/components#readme","keywords":["turnstile","cloudflare","captcha","spam-protection","honeypot","rate-limit","forms","hono","preact","react","broberg"],"repository":{"type":"git","url":"git+https://github.com/broberg-ai/components.git","directory":"packages/forms-turnstile"},"description":"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.","maintainers":[{"name":"cbroberg","email":"cb@webhouse.dk"}],"readme":"# @broberg/forms-turnstile\n\nSpam-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).\n\n```bash\nnpm i @broberg/forms-turnstile      # exact-pin for prod-auth deps\n```\n\n## Core (`@broberg/forms-turnstile` / `@broberg/forms-turnstile/server`)\n\nFramework-agnostic, `node:crypto` only.\n\n```ts\nimport { applySpamGauntlet, hashIp, getSitekeyResponse } from \"@broberg/forms-turnstile/server\";\n\nconst ipHash = hashIp(clientIp); // GDPR-friendly — never store the raw IP\n\nconst result = await applySpamGauntlet({\n  honeypot: { body },                                          // omit to skip this layer\n  rateLimit: { ipHash, formName: \"contact\", maxPerHour: 5 },    // omit to skip this layer\n  turnstile: { token: body.token, secret: env.TURNSTILE_SECRET_KEY, remoteip: clientIp },\n});\nif (result.blocked) {\n  // result.reason: \"honeypot\" | \"rate-limit\" | \"turnstile\"\n}\n```\n\nEach 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.\n\nThe individual checks are exported too (`isHoneypotTriggered`, `isRateLimited`, `validateTurnstile`, `HONEYPOT_FIELD`) if you'd rather call them yourself.\n\n### \"You are a bot\" and \"we could not ask\" are different answers (v0.3.0)\n\n`verifyTurnstile()` returns **three** outcomes, because three things can happen:\n\n```ts\nimport { verifyTurnstile } from \"@broberg/forms-turnstile/server\";\n\nconst r = await verifyTurnstile(token, secret, { remoteip, timeoutMs: 10_000 });\n// { ok: true }\n// { ok: false, reason: \"rejected\",    errorCodes: [...] }   ← Cloudflare said no\n// { ok: false, reason: \"unavailable\", detail: \"…\" }         ← we never got an answer\n```\n\nIt **never throws** — a 5xx, an HTML error page, a dropped connection and a\ntimeout all come back as `unavailable` with a readable detail.\n\n**Why this exists.** Before v0.3.0, `unavailable` reached callers on *two\ndifferent channels depending on the shape of Cloudflare's failure*: a non-JSON\nbody **threw**, while a JSON body without a `success` field returned **`false`**.\nNo caller could handle it consistently — and the `false` branch was the harmful\none. It became `reason: \"turnstile\"`, which renders as *\"you failed the bot\ncheck\"*: a real person told she is not human, with \"try again\" as her only\noption and nothing wrong with her token. The log said `turnstile` too, naming\nthe wrong cause.\n\nMeasured in production. It is the same defect [v0.2.0 fixed on the browser\nside](#gate-the-submit-button-on-status-not-on-token-v020) — when a client/server\npair has this bug in one half, check the other half first.\n\n**Absence of a verdict is not a verdict.**\n\n#### Choosing the policy\n\n`applySpamGauntlet` defaults to **fail-closed**:\n\n```ts\nawait applySpamGauntlet({ turnstile: { token, secret } });\n// Cloudflare unreachable → THROWS. An unguarded route 500s. Nothing gets through.\n```\n\nThat is deliberate: it preserves what this package already did for the dominant\noutage shape, so upgrading never silently converts somebody's 500 into a 400 that\naccuses a human. To decide for yourself instead:\n\n```ts\nconst r = await applySpamGauntlet({\n  turnstile: { token, secret, onUnavailable: \"block\" },\n});\nif (r.blocked && r.reason === \"turnstile-unavailable\") {\n  // Say what is true: \"We can't check that right now — try shortly, or call us.\"\n  // NOT \"you failed the bot check\".\n}\n```\n\n`turnstile-unavailable` is a distinct member of `SpamBlockReason`, so a\n`switch` over it will tell you at compile time that you have a new case to\nhandle. Both options are also on the Hono middleware (`timeoutMs`,\n`onUnavailable`).\n\n**There is now a timeout** (default 10s). Without one, a hung Cloudflare\nconnection holds the request until the platform kills the invocation — worse on\nFly/serverless than locally, and the user just watches a spinner.\n\n**`validateTurnstile()` is deprecated but unchanged.** It is lossy — it cannot\ntell the two failures apart, and still splits them across a return value and a\nthrow. Its exact behaviour is pinned by test so existing callers see no change on\nupgrade. Migrate to `verifyTurnstile` when you touch the call-site.\n\n**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.\n\n> **Measure your instance count, then write it next to the constant.** With N\n> instances the effective ceiling is `maxPerHour × N`, not `maxPerHour` — so the\n> number in your code is a lie for whoever reads it next unless the comment says\n> so. `flyctl scale show -a <app>` answers it in one line. fd-sundhed measured 2\n> machines against `maxPerHour: 5` and documented the real limit as 10 **at the\n> constant**, not in a commit message, which is the right place for it.\n>\n> This is a brake on repetition, not a door. Honeypot and Turnstile carry the\n> protection; if the rate limit is the layer you are relying on, you need a\n> shared store.\n\n### Local dev / CI — no real keys needed\n\n```ts\nimport { TURNSTILE_TEST_SITE_KEY, TURNSTILE_TEST_SECRET_KEY } from \"@broberg/forms-turnstile/server\";\n```\n\nCloudflare's official **always-pass** test keys — safe to commit, safe default so the flow works end-to-end without a real Turnstile widget.\n\n> #### ⚠️ Do not E2E-assert the *unsolved* state against the test keys\n>\n> They solve **almost instantly**. So a check like *\"the submit button is\n> disabled before the user solves the challenge\"* is racing the widget: the\n> state you are trying to prove exists for under a second.\n>\n> fd-sundhed hit this on adoption — the same assertion passed on one page and\n> failed on the other, **not because the app behaved differently, but because\n> the assert raced**. They deleted the check rather than adding a wait, which is\n> the right call: a test that passes or fails on timing proves nothing in either\n> direction. It is not a flaky test, it is a test of a state the test keys do not\n> hold still for.\n>\n> Assert the states that persist instead — `solved` after solving, `failed` with\n> its `error` when you block the script. And note this trap is one *we* built,\n> by shipping always-pass keys as the default: the convenience and the race are\n> the same feature.\n\n### Runtime site-key delivery\n\n```ts\n// GET /config route — serves the (public) site key at runtime so rotating it\n// is a secret change, never a rebuild.\napp.get(\"/config\", (c) => c.json(getSitekeyResponse(env.TURNSTILE_SITE_KEY)));\n```\n\n## Widget hook — React (`/react`) or Preact (`/preact`)\n\nLazy-loads the Turnstile script (cached + deduped) and renders the widget once a\nsite key is available. **Both adapters are the same implementation** — they\ndiffer only in which package the hooks come from, so a fix reaches both.\n\n```tsx\nimport { useTurnstile } from \"@broberg/forms-turnstile/react\";   // or /preact\n\nfunction ContactForm() {\n  const { widgetRef, token, status, error, reset } = useTurnstile(siteKey);\n\n  return (\n    <form onSubmit={onSubmit}>\n      {/* ...fields... */}\n      <div ref={widgetRef} data-testid=\"contact-form-captcha\" />\n      <button type=\"submit\" disabled={status !== \"solved\"}>Send</button>\n      {status === \"failed\" && <p role=\"alert\">Spam-tjekket kunne ikke indlæses. {error}</p>}\n    </form>\n  );\n}\n```\n\n`siteKey` may be `null`/`undefined` while a runtime `/config` fetch is in\nflight — that reads as `loading`.\n\n### Gate the submit button on `status`, not on `token` (v0.2.0)\n\n| `status` | meaning |\n| --- | --- |\n| `loading` | no site key yet, or the script is still loading |\n| `ready` | the widget is up and waiting for the user |\n| `solved` | `token` is valid — this is the only state you should submit in |\n| `failed` | it will not work without intervention; `error` says why |\n\n**Why this matters.** Before v0.2.0 the hook exposed only `token`, and an empty\ntoken had two causes: *the user has not solved it yet*, and *this will never\nwork*. A form gating on `!token` therefore showed a submit button that never\nenabled, with nothing anywhere saying why. Turnstile is blocked by ordinary\nprivacy extensions often enough that this is a normal user's experience, not an\nedge case.\n\nThree distinct paths used to end in that same silence — a script that failed to\nload, a script that **loaded** while `window.turnstile` never appeared, and a\n`widgetRef` that was never attached. All three now end in `failed` with a\ndistinct `error`. Raised by fd-sundhed, who found the first of the three by\nreading the tarball.\n\n`reset()` returns a solved widget to `ready`. It will **not** move a `failed`\nwidget out of `failed` — resetting a widget that never loaded cannot repair it,\nand laundering that into a hopeful state would erase the only evidence of the\nreal problem.\n\n### React notes\n\n`react` is an optional peer (`>=18`). The bundle carries `\"use client\"`, so a\nNext.js App Router project can import it from a client component without\nmarking anything extra — and only the React bundles carry it; `/server` and\n`/hono` stay server-safe.\n\n## Hono middleware (`@broberg/forms-turnstile/hono`)\n\nReads 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.\n\n```ts\nimport { honoTurnstileMiddleware } from \"@broberg/forms-turnstile/hono\";\n\napp.post(\n  \"/api/contact\",\n  honoTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, formName: \"contact\", maxPerHour: 5 }),\n  (c) => {\n    const body = c.get(\"spamCheckedBody\");\n    // ...persist + notify...\n    return c.json({ ok: true });\n  },\n);\n```\n","readmeFilename":"README.md"}