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

static-contact-form

v0.4.1

Published

Contact-form backend for static sites on Cloudflare Workers: validation, Turnstile, email, optional KV copy.

Readme

static-contact-form

A small contact-form backend for static sites on Cloudflare Workers. It validates the fields, checks Cloudflare Turnstile, can store a copy in KV, and emails the inquiry to the site owner. It does not depend on any framework.

Install

pnpm add static-contact-form
# or straight from git
pnpm add github:blazardsky/static-contact-form#v0.4.1

The compiled JavaScript and type declarations in dist/ are committed, so installing from git needs no build step. Vite, Astro, Wrangler and Node all import it as plain ESM.

Releasing

  1. Change src/, then bump version in package.json.
  2. pnpm build and commit dist/ (git installs use it).
  3. git tag vX.Y.Z && git push origin main --tags
  4. npm publish (prepublishOnly builds, typechecks and tests first).

Server: handleInquiry

import {handleInquiry, turboSmtp} from 'static-contact-form'

const result = await handleInquiry(formDataOrObject, {
  send: turboSmtp({key: env.TURBO_KEY, secret: env.TURBO_SECRET}),
  store: env.INQUIRIES,              // optional KV namespace
  turnstileSecret: env.TURNSTILE_SECRET,
  to: env.INQUIRY_TO,                // owner inbox
  fields: {                          // optional extra fields, see below
    phone: {label: 'Phone', maxLength: 40},
    weddingDate: {label: 'Wedding date', pattern: /^\d{4}-\d{2}-\d{2}$/},
    audience: {label: 'Audience'},
  },
  from: 'Studio <[email protected]>', // optional, defaults to `to`; must be a sender the email provider accepts
  confirmation: {                    // optional email to the visitor
    subject: 'Thanks, {name}',
    text: 'Hi {name}, we received your message and will reply soon.',
  },
})

The function does these steps in order:

  1. Honeypot. A non-empty website field returns {ok: false, reason: 'spam'}.

  2. Validation. name (100 characters max), email (254 max, must look like an address) and message (5000 max) are required. Fields declared in fields are validated too (see below), and any other field is ignored. A failure returns {ok: false, reason: 'invalid', errors: Record<string, FieldError>}, where FieldError is 'required' | 'invalid' | 'too-long'.

  3. Turnstile. The cf-turnstile-response token is verified with siteverify. A missing or rejected token returns {ok: false, reason: 'spam'}. If siteverify itself fails (network error or a non-2xx response), the result is {ok: false, reason: 'verify-failed'} and nothing is sent, so the site can ask the visitor to try again.

  4. Store. When store is set, the copy is saved as JSON with put(key, value, {expirationTtl}). The copy is kept for 30 days by default; set storeTtl in seconds to change it. The copy is stored before sending, so it survives a failed send.

  5. Send. The inquiry is emailed to to as plain text, with reply-to set to the visitor. If sending fails, the result is {ok: false, reason: 'send-failed', stored}.

  6. Confirmation (optional). The visitor receives the fixed subject and text from config. Only {name} is filled in, in both the subject and the text, and the visitor's message is never included. If the confirmation fails, the result is still ok.

    Name check. {name} is filled in only when the name looks like a person's name: it starts with a letter, contains only Unicode letters, spaces, apostrophes (' ’) and hyphens, and is at most 50 characters. That rules out digits, ., @ and /, so URLs and addresses cannot get through. Any other name is replaced by confirmation.fallbackName, which defaults to ''. When the replacement is empty, the space before {name} is removed too, so Ciao {name}, becomes Ciao,. Write templates that still read well that way. The owner email always shows the name exactly as submitted.

    Remaining risk: because of the name check, a visitor can no longer place a link or an address in the confirmation email. The fixed confirmation text can still be sent to an address the visitor does not own. Turnstile limits how many can be sent. To remove the risk entirely, leave out confirmation.

The code that calls handleInquiry turns the result into a response, for example 200, 400 for invalid, 403 for spam, 503 for verify-failed and 502 for send-failed.

Extra fields: fields

fields declares the fields the form may send besides name, email and message. Each declared field is validated, saved in the KV copy under its key, and listed in the owner email as label ?? key. Undeclared fields are dropped. name, email, message, website and cf-turnstile-response are reserved: if declared in fields, they are ignored there. Each field accepts these options:

| Option | Meaning | Error code | |---|---|---| | required?: boolean | the field must be non-empty | required | | maxLength?: number | defaults to 500 | too-long | | pattern?: RegExp | an empty optional field skips the check; anchor the pattern with ^…$, and do not use the g or y flag, because RegExp.test is stateful with them | invalid | | label?: string | heading in the owner email | — |

A wedding studio example:

fields: {
  phone: {label: 'Phone', maxLength: 40},
  weddingDate: {label: 'Wedding date', pattern: /^\d{4}-\d{2}-\d{2}$/}, // <input type="date"> sends YYYY-MM-DD
  audience: {label: 'Audience', maxLength: 40},                        // hidden input set by the site
}

Secrets and bindings (names are up to the site, passed in config)

| Value | Where | |---|---| | TURBO_KEY, TURBO_SECRET | turboSMTP consumer key and secret (wrangler secret put) | | TURNSTILE_SECRET | Turnstile widget secret key | | INQUIRY_TO | owner inbox | | INQUIRIES | optional KV namespace binding |

Astro endpoint example

An on-demand endpoint runs in the Worker, so it works even when every page is prerendered. It answers JSON when the request asks for it (enhanceForm does), and otherwise 303-redirects, so the form also works without JavaScript.

// src/pages/api/inquiry.ts
import type {APIRoute} from 'astro'
import {env} from 'cloudflare:workers'
import {handleInquiry, turboSmtp} from 'static-contact-form'

export const prerender = false

const status = {invalid: 400, spam: 403, 'verify-failed': 503, 'send-failed': 502}

export const POST: APIRoute = async ({request, redirect}) => {
  const result = await handleInquiry(await request.formData(), {
    send: turboSmtp({key: env.TURBO_KEY, secret: env.TURBO_SECRET, region: 'eu'}),
    store: env.INQUIRIES,
    turnstileSecret: env.TURNSTILE_SECRET,
    to: env.INQUIRY_TO,
  })
  if (request.headers.get('accept')?.includes('application/json')) {
    return Response.json(result, {status: result.ok ? 200 : status[result.reason]})
  }
  return redirect(result.ok ? '/thanks/' : '/contact/?error=1', 303)
}
<form method="post" action="/api/inquiry">
  <input name="name" required> <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <!-- honeypot: hidden from people and assistive tech -->
  <input name="website" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button>Send</button>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

Client: enhanceForm

import {enhanceForm} from 'static-contact-form/client'

enhanceForm(form, {success: statusEl, error: errorEl})

This is progressive enhancement for the site's existing markup. When the form is submitted, enhanceForm:

  • Posts new FormData(form) to form.action with fetch and Accept: application/json, so the server can answer JSON instead of redirecting. The form data includes the cf-turnstile-response input that the Turnstile widget adds inside the form.
  • Disables the submit buttons and sets aria-busy on the form while the request runs. Further submits are ignored until it finishes.
  • On a 2xx response, resets the form, unhides success, gives it tabindex="-1" and focuses it. The buttons stay disabled so the same inquiry cannot be sent twice.
  • On any other response, or a network failure, unhides error, focuses it and re-enables the buttons.
  • Calls turnstile.reset() after each attempt, because a token can be used only once.
  • Calls umami.track('inquiry-sent') or umami.track('inquiry-error'), but only when window.umami exists.

Use the site's own elements for the messages, such as <p role="status" hidden> for success and <p role="alert" hidden> for errors, so the site keeps control of their design and copy. Without JavaScript the form posts normally.

<form method="post" action="/api/inquiry" id="inquiry">…</form>
<p role="status" id="inquiry-ok" hidden>Thanks, we'll be in touch.</p>
<p role="alert" id="inquiry-ko" hidden>Sorry, that didn't go through. Please try again or email us.</p>
<script>
  import {enhanceForm} from 'static-contact-form/client'
  enhanceForm(document.querySelector('#inquiry')!, {
    success: document.querySelector('#inquiry-ok')!,
    error: document.querySelector('#inquiry-ko')!,
  })
</script>

Email adapters

An adapter is a plain function:

type Send = (message: {from: string; to: string; replyTo: string; subject: string; text: string}) => Promise<void>

It should resolve when the message is accepted and throw when it is not. Any provider works, for example:

const send: Send = async (m) => {
  const res = await fetch('https://api.example-mail.com/send', {method: 'POST', body: JSON.stringify(m), headers: {authorization: `Bearer ${env.MAIL_KEY}`}})
  if (!res.ok) throw new Error(`mail ${res.status}`)
}

turboSmtp({key, secret, region?})

This adapter calls the turboSMTP HTTP API. Its behaviour was checked against turboSMTP's official OpenAPI spec (turboSMTP/developers-hub, api-reference/Domains/Mail.yaml) and their official JS SDK (turboSMTP/turboSMTP-js) in October 2026:

  • It sends POST https://api.turbo-smtp.com/api/v2/mail/send. European accounts use https://api.eu.turbo-smtp.com/api/v2/mail/send, selected with region: 'eu'.
  • It authenticates with the consumerKey and consumerSecret headers. The endpoint does not accept an Authorization header.
  • The JSON body contains from, to, subject and content (plain text). Reply-to is sent as custom_headers: {"reply-to": "..."}, which is how both the spec example and the official SDK do it.
  • A 200 response means the message was accepted. A 400 response lists errors, such as nocredit, and a 401 response means the credentials are wrong. Any response other than 2xx throws, and the error includes the status and the response body.

License

MIT