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

@profullstack/form-guard

v0.1.1

Published

Stops contact-form spam at the door with a signed proof-of-render token, a honeypot, a fill-time floor and per-IP rate limiting, then scores whatever survives so a human can filter it. Renders no markup, has no dependencies and runs on Node, Bun, Deno, Cl

Readme

@profullstack/form-guard

Contact-form spam control that keys on what bots actually do, not on what they write. No dependencies, no third-party service, no captcha, no markup of its own. Runs on Node, Bun, Deno, Cloudflare Workers and the Next.js edge runtime.

npm i @profullstack/form-guard

Why the honeypot you already have is not catching anything

A honeypot is a hidden field that humans cannot see and bots fill in. It works only against a bot that renders your page.

Most contact-form spam does not. It POSTs at your handler directly, which means the hidden field is never in the request body at all, and a check of the form "is this field empty?" answers yes. The submission sails through a defence that is working exactly as designed.

The message that prompted this package arrived at a form with a functioning honeypot. Every header passed — SPF, DKIM, DMARC, ARC — because the site was mailing itself through its own provider. The cryptography was never the question. Nothing in the request had touched the page.

So the first check here is a proof-of-render token: a signed, timestamped value minted when the form renders and required when it submits. Skip the page, have no token, go nowhere. It also carries the moment it was issued, which gives you the fill time for free.

Layers

| Layer | Catches | On failure | |---|---|---| | Proof-of-render token | Direct-to-endpoint bots | drop — silently discarded | | Fill-time floor | Instant submits | retry — a human is asked to resend | | Honeypot | DOM-filling bots | drop | | Per-IP rate limit | Floods | limited — 429 | | Content scoring | Low-effort lead bait | flagdelivered, tagged |

The split matters. The first four key on things no human visitor does, so they can block. Content scoring only ever tags, because every signal it reads has an innocent explanation — real people write short messages, use mail.ru, and paste links. Nothing in the scoring layer can stop a message from reaching you.

drop reports success to the caller. Telling a bot which check caught it is free tuning information for whoever runs it.

Use

import { createFormGuard, tagSubject, provenanceBlock } from '@profullstack/form-guard';

export const guard = createFormGuard({
  secret: process.env.FORM_GUARD_SECRET,
  binding: 'contact',              // ties tokens to this one form
  brandTerms: ['acme corp'],       // words a scraper echoes back
  rateLimit: { max: 5, windowMs: 60 * 60 * 1000 },
});

Render the form with a token:

const token = await guard.issue();
const { token: t, honeypot } = guard.fields(token);

<form action="/api/contact" method="post">
  {/* your real fields */}
  <input type="hidden" name={t.name} value={t.value} />
  <div aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }}>
    <label>Website<input type="text" name={honeypot.name} tabIndex={-1} autoComplete="off" /></label>
  </div>
  <button type="submit">Send</button>
</form>

Not using JSX? guard.hiddenHTML(token) returns both inputs as an escaped HTML fragment.

Check on submit:

const verdict = await guard.check({ fields: body, headers: request.headers });

switch (verdict.action) {
  case 'drop':
    return ok();                       // tell the bot it worked
  case 'retry':
    return error('Please try again.'); // stale or too fast
  case 'limited':
    return error('Too many messages. Try later.', 429);
  default:
    await send({
      subject: tagSubject(`[contact] ${subject}`, verdict),
      text: `${message}\n\n${provenanceBlock({ ...verdict, verdict })}`,
    });
    return ok();
}

A flagged message arrives with [spam? 6] in the subject — filter on it — and a provenance block naming the IP, user-agent, fill time and the signals that fired.

Rolling it out without breaking anything

Ship with requireToken: false first. Every submission is scored and annotated, nothing is ever blocked. Watch the flagged mail for a few days, confirm no real enquiry is being tagged, then flip it to true.

createFormGuard({ secret, requireToken: process.env.FORM_GUARD_ENFORCE === '1' });

The secret

Any stable server-side string. It never reaches the client — only the signature does — so it does not need to be a managed secret, but it does need to be the same across every instance that serves the form, or a token minted by one box will be rejected by the next.

Rotating it invalidates tokens on pages currently open. Those users get retry, not a lost message.

Options

| Option | Default | | |---|---|---| | secret | — | required | | binding | '' | ties a token to one form | | tokenField | 'fg_token' | rename to be less guessable | | honeypotField | 'website' | rename to be less guessable | | minAgeMs | 3000 | fill-time floor | | maxAgeMs | 7200000 | token lifetime | | flagAt | 3 | score at which a message is tagged | | brandTerms | [] | words a scraper echoes back | | requireToken | true | false to score without blocking | | rateLimit | {max:5, windowMs:3600000} | false to disable |

Rate limiting across instances

The default store is per-process memory: not shared between containers, reset on deploy. Fine for one box. Refused attempts count toward the window, so a flood keeps its own window full and recovering takes a full window of silence.

Pass a store with take(key, windowMs, now) and reset(key) to back it with Redis or a Durable Object when you run more than one instance.

What this does not do

It does not stop a human being paid to fill in your form, and it does not stop a headless browser that renders the page — both get a valid token. Those land in the scoring layer, tagged rather than blocked, which is the correct place for a judgement call a machine should not be making alone.

MIT © Profullstack, Inc.