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

@sdxc/captcha

v2026.10.2

Published

Turnstile, hCaptcha and reCAPTCHA verification, with middleware and widgets

Readme

@sdxc/captcha

Provider-neutral CAPTCHA verification: one Captcha contract with Turnstile, hCaptcha and reCAPTCHA implementations, router middleware, widgets and an in-memory provider for tests. Every failure carries a neutral code that separates what the visitor fixes (rejected, expired, low-score) from what the provider broke (unavailable).

Installation

npm add @sdxc/captcha

The middleware runs on the remix router and the widgets render with remix/component; every verification returns an @sdxc/result value. Both install alongside this package.

Usage

Guard a form route

import { captcha } from "@sdxc/captcha/middleware";
import { Turnstile } from "@sdxc/captcha/turnstile";
import { createRouter } from "remix/router";

let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let router = createRouter();

router.post("/sign-up", {
	middleware: [captcha(turnstile, { action: "sign-up" })],
	handler() {
		// Reached only once the token verified; a failure was answered with a 403.
		return new Response("Welcome");
	},
});

Render the widget

import type { Handle } from "remix/component";

import { TurnstileWidget } from "@sdxc/captcha/turnstile/ui";

function SignUpForm(handle: Handle<{ siteKey: string }>) {
	return () => (
		<form method="post" action="/sign-up">
			<input name="email" type="email" />
			<TurnstileWidget siteKey={handle.props.siteKey} action="sign-up" theme="auto" />
			<button type="submit">Sign up</button>
		</form>
	);
}

Verify without the middleware

import { Turnstile } from "@sdxc/captcha/turnstile";
import { isFailure } from "@sdxc/result";

let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let result = await turnstile.verify(token, { remoteIp: "203.0.113.7" });
if (isFailure(result)) return result.error.code; // "rejected", "expired", "unavailable", …
result.data.hostname; // "example.com"

Switch providers

import { HCaptcha } from "@sdxc/captcha/hcaptcha";
import { captcha } from "@sdxc/captcha/middleware";
import { ReCaptcha } from "@sdxc/captcha/recaptcha";

captcha(new HCaptcha({ secretKey: HCAPTCHA_SECRET, siteKey: HCAPTCHA_SITE_KEY }));
captcha(new ReCaptcha({ secretKey: RECAPTCHA_SECRET, minScore: 0.7 }), { action: "sign_up" });

API

@sdxc/captcha

  • Captcha — the interface a provider implements: field, the form field its widget writes, and verify(token, { remoteIp? }), which resolves to Result<Captcha.Verification, CaptchaError>. Single-use providers consume the token, so call it once per submission.
  • Captcha.Verification — what the provider confirmed: hostname?, action?, score? (0 bot to 1 human) and challengedAt?. A field the provider does not report stays unset.
  • Captcha.ErrorCode — one of missing-token, rejected, expired, low-score, unavailable, action-mismatch or hostname-mismatch.
  • CaptchaError — the failure every provider returns: code to branch on, and providerCodes with the provider's own codes verbatim for logs.

@sdxc/captcha/middleware

captcha(provider, options?) verifies provider.field on every request of the route it is installed on, so install it on the action that accepts the submission. It reads the form parsed by remix's formData() middleware when present, and otherwise a clone of the request, leaving the body readable for the handler. A missing field or a non-form body is missing-token.

  • action / hostname — expected values; a verification reporting another one, or none, fails with action-mismatch / hostname-mismatch.
  • remoteIp(request) — resolves the visitor's address; defaults to the CF-Connecting-IP header.
  • onFailure(error, ctx) — return a Response to refuse, or null to continue with the failure published. Defaults to a plain-text 403.

The outcome is published as ctx.captcha (CaptchaOutcome, a Result<Captcha.Verification, CaptchaError>) and under the CaptchaKey context key.

@sdxc/captcha/turnstile

new Turnstile({ secretKey, field?, timeout? }) verifies against Cloudflare's siteverify. field defaults to cf-turnstile-response; timeout to 10 seconds. It reports hostname, action and solve time.

@sdxc/captcha/turnstile/ui

<TurnstileWidget siteKey /> renders the .cf-turnstile container and Cloudflare's loader. Optional props: action, cData, theme (auto | light | dark), size (normal | flexible | compact), appearance (always | execute | interaction-only), language, field (match Turnstile's) and nonce for the loader script.

@sdxc/captcha/hcaptcha

new HCaptcha({ secretKey, siteKey?, maxRiskScore?, field?, timeout? }) verifies against hCaptcha's siteverify. siteKey makes hCaptcha refuse a token solved for another site. hCaptcha Enterprise scores risk (1 is a threat), so the verification reports 1 - risk as score, and maxRiskScore, in hCaptcha's own terms, fails a riskier token with low-score. field defaults to h-captcha-response.

@sdxc/captcha/hcaptcha/ui

<HCaptchaWidget siteKey /> renders the .h-captcha container and hCaptcha's loader. Optional props: theme (light | dark), size (normal | compact), tabIndex, language and nonce.

@sdxc/captcha/recaptcha

new ReCaptcha({ secretKey, minScore?, field?, timeout? }) verifies v2 and v3 tokens against Google's siteverify. A v3 answer reports its score and action; a score under minScore (default 0.5) fails with low-score. A v2 answer carries no score and passes on success alone. Hold a v3 token to the form's action with the middleware's action option. field defaults to g-recaptcha-response.

@sdxc/captcha/recaptcha/ui

<ReCaptchaWidget siteKey /> renders the v2 checkbox: the .g-recaptcha container and Google's loader. Optional props: theme (light | dark), size (normal | compact), tabIndex, language and nonce. A v3 key has no widget; the page calls grecaptcha.execute() and writes the token into a g-recaptcha-response field itself.

@sdxc/captcha/memory

new MemoryCaptcha({ field?, verification?, unknownTokens? }) is a scriptable provider for tests. A call is answered by the next queued outcome, then by the token's own rule, then by the default: every non-empty token passes with verification, or is rejected under unknownTokens: "reject".

  • passNext(verification?) / failNext(code, providerCodes?) — queue the next answer.
  • accept(token, verification?) / reject(token, code, providerCodes?) — answer one token the same way every time.
  • calls / last — every recorded { token, remoteIp? }.
  • reset() — forget calls, queued answers and rules.

An empty token is missing-token and never consumes a queued answer. field defaults to captcha-response.

Pattern: Fail open when the provider is down

A sign-in keeps working through a provider outage while still refusing a bad token.

import { captcha } from "@sdxc/captcha/middleware";
import { Turnstile } from "@sdxc/captcha/turnstile";
import { createRouter } from "remix/router";

let turnstile = new Turnstile({ secretKey: TURNSTILE_SECRET_KEY });
let router = createRouter();

router.post("/sign-in", {
	middleware: [
		captcha(turnstile, {
			onFailure: (error) =>
				error.code === "unavailable"
					? null
					: new Response("Solve the challenge again", { status: 400 }),
		}),
	],
	handler(ctx) {
		// ctx.captcha is a failure with code "unavailable" when the provider was down.
		return signIn(ctx);
	},
});

Pattern: Escalate a low reCAPTCHA v3 score

v3 runs invisibly; a low-score answer is the moment to show a v2 checkbox instead of refusing outright.

import { captcha } from "@sdxc/captcha/middleware";
import { ReCaptcha } from "@sdxc/captcha/recaptcha";
import { createRouter } from "remix/router";

let recaptcha = new ReCaptcha({ secretKey: RECAPTCHA_V3_SECRET, minScore: 0.5 });
let router = createRouter();

router.post("/comment", {
	middleware: [
		captcha(recaptcha, {
			action: "comment",
			onFailure: (error) =>
				error.code === "low-score" ? null : new Response(null, { status: 403 }),
		}),
	],
	handler(ctx) {
		if (ctx.captcha.status === "failure") return renderWithCheckbox(ctx);
		return saveComment(ctx);
	},
});

Pattern: Testing a guarded route

MemoryCaptcha stands in for the real provider, so a test chooses the outcome and asserts on what was verified.

import { MemoryCaptcha } from "@sdxc/captcha/memory";
import { captcha } from "@sdxc/captcha/middleware";
import { createRouter } from "remix/router";
import { expect, test } from "vitest";

test("refuses a sign-up the provider rejected", async () => {
	let provider = new MemoryCaptcha().failNext("rejected");
	let router = createRouter();
	router.post("/sign-up", { middleware: [captcha(provider)], handler: () => new Response("ok") });

	let response = await router.fetch("https://example.com/sign-up", {
		method: "POST",
		headers: {
			"Content-Type": "application/x-www-form-urlencoded",
			"CF-Connecting-IP": "203.0.113.7",
		},
		body: "captcha-response=token",
	});

	expect(response.status).toBe(403);
	expect(provider.last).toEqual({ token: "token", remoteIp: "203.0.113.7" });
});

Pattern: Implementing Captcha for another provider

An implementation owns three things: the widget's field name, the call (or local check) that verifies a token, and the mapping of the provider's answer onto Captcha.Verification and Captcha.ErrorCode. Report only what the provider confirms, map token problems to rejected or expired, and map everything the visitor cannot fix — a bad key, a provider error, a network failure — to unavailable.

import type { Captcha } from "@sdxc/captcha";
import type { Result } from "@sdxc/result";

import { CaptchaError } from "@sdxc/captcha";
import { failure, success } from "@sdxc/result";

/** Friendly Captcha v2, verified with an API key header. */
class FriendlyCaptcha implements Captcha {
	readonly field = "frc-captcha-response";

	constructor(private apiKey: string) {}

	async verify(token: string): Promise<Result<Captcha.Verification, CaptchaError>> {
		if (token === "") return failure(new CaptchaError("missing-token"));
		let response: Response;
		try {
			response = await fetch("https://global.frcapi.com/api/v2/captcha/siteverify", {
				method: "POST",
				headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json" },
				body: JSON.stringify({ response: token }),
			});
		} catch {
			return failure(new CaptchaError("unavailable"));
		}
		if (!response.ok) return failure(new CaptchaError("unavailable", [`http-${response.status}`]));
		let answer = await response.json(); // validate the shape before trusting it
		if (answer.success) return success({ challengedAt: new Date(answer.data.challenge.timestamp) });
		let code = answer.error.error_code;
		if (code === "response_timeout" || code === "response_duplicate") {
			return failure(new CaptchaError("expired", [code]));
		}
		return failure(new CaptchaError("rejected", [code]));
	}
}

A provider verified locally fits the same contract: an ALTCHA proof of work is checked with an HMAC key and no network call, so its implementation reads the altcha field, ignores remoteIp, and reports no hostname, action or score — which is why the middleware treats an expected action it cannot confirm as action-mismatch.

Versioning

Releases are dated rather than semantic. A version is the UTC date it was published, written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one release goes out per day.

Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.

Depend on one exact date, and move it when you are ready to take the change:

{
	"dependencies": {
		"@sdxc/captcha": "2026.9.4"
	}
}

A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.

License

MIT

Author

Sergio Xalambrí