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

@weatherboard/forms

v0.2.2

Published

A drop-in contact and waitlist form for client sites: a headless React hook, a web-standard forwarding handler with a Next adapter, and validation generated from a pinned form definition. Ships no styles.

Readme

@weatherboard/forms

The drop-in contact and waitlist form for a client site (A-1161; this package is slice S6, A-1168). Three parts:

  • a headless React hook, useWeatherboardForm, that holds the values, shows the fields the chosen audience reveals, validates with the same function intake validates with, and hands back ids, aria-* wiring and handlers for the site's own design-system components to render;
  • a web-standard forwarding handler, createFormsHandler, that a site mounts at /api/forms/<form_key> and that forwards a same-origin browser POST to intake, server to server, under intake's Wire contract v1 (apps/intake/README.md);
  • a thin Next adapter and an on-interaction Turnstile loader.

It ships no styles and no components. The client's design system renders the form, so the design-system gate and the accessibility audit apply to it unchanged. The single inline style in the package is on the honeypot input, which it moves off-screen: that is what the honeypot is for, not how it looks.

It has no runtime dependencies. React is an optional peer, needed only by ./react.

The design is docs/plans/a-1161-forms.md (§2 shape, §3.4 abuse, §3.7 logs, §7 definitions, §8 reaching hundreds of sites). The definition format itself is documented in src/definition/README.md.

Install

pnpm add @weatherboard/forms

| entry point | what | runs in | | --- | --- | --- | | @weatherboard/forms | the definition format and the starters, the browser contract, and the pure form core (visibleFields, drawnFields (the order to draw and focus fields in, consent last), submissionValues, validateForm, default messages) | anywhere | | @weatherboard/forms/react | useWeatherboardForm | the browser | | @weatherboard/forms/server | createFormsHandler, vercelClientIp | the site's server | | @weatherboard/forms/next | createFormsRoute | a Next route handler | | @weatherboard/forms/turnstile | loadTurnstile | the browser |

Environment

| name | where | what | | --- | --- | --- | | FORMS_INGEST_URL | server only | intake's base URL, https://… (plain http only for a loopback host). The handler appends v1/submissions, keeping any path: https://host/forms posts to https://host/forms/v1/submissions. | | FORMS_INGEST_TOKEN | server only, secret | the project's ingest token: 43 characters of base64url. Never sent to the browser, never logged. | | PUBLIC_TURNSTILE_SITE_KEY | browser | the project's Turnstile site key. Public by design. |

Spin-up writes all three (plan §8, the forms-provision step). An unset or malformed server variable is refused by name, at the first submission: the site's log carries {"event":"could_not_answer","setting":"FORMS_INGEST_TOKEN"} and the browser gets unavailable. Nothing fails at build or import time.

In Next, a client component sees a variable only if it is NEXT_PUBLIC_* or named in next.config.ts's env block. The scaffold uses the PUBLIC_ prefix and the env block, so either add PUBLIC_TURNSTILE_SITE_KEY there or read it in a server component and pass it down as a prop.

Next: the route

src/app/api/forms/[form_key]/route.ts:

import { createFormsRoute } from "@weatherboard/forms/next";

// Every form this site serves, each pinned to the definition VERSION it renders.
export const { POST } = createFormsRoute({ forms: { contact: 1, waitlist: 1 } });

createFormsRoute reads FORMS_INGEST_URL and FORMS_INGEST_TOKEN from process.env on every request. It accepts every createFormsHandler option except the two it reads (below), plus env to read them from somewhere else.

Astro: the same handler

No Astro dependency: the handler is (Request, context?) => Promise<Response>, which is an Astro endpoint with one line around it. src/pages/api/forms/[form_key].ts:

import type { APIRoute } from "astro";
import { createFormsHandler } from "@weatherboard/forms/server";

export const prerender = false;

const FORMS = { contact: 1, waitlist: 1 };

// Read per request from process.env, not import.meta.env: Vite replaces
// `import.meta.env.*` at build time, which would write the token into the
// build output. Astro knows the visitor's address better than a header does,
// so it is passed in.
export const POST: APIRoute = ({ request, clientAddress }) =>
  createFormsHandler({
    ingestUrl: process.env.FORMS_INGEST_URL,
    ingestToken: process.env.FORMS_INGEST_TOKEN,
    forms: FORMS,
  })(request, { clientIp: clientAddress });

The endpoint needs server output (an adapter), because it runs per request.

createFormsHandler(options)

| option | default | | | --- | --- | --- | | ingestUrl, ingestToken | — | FORMS_INGEST_URL, FORMS_INGEST_TOKEN; may be undefined, refused at use | | forms | — | { [form_key]: version }. A key not here is 404 not_found; Object.hasOwn, so constructor is not a form | | allowedOrigins | an Origin whose host equals the request's Host header | an exact list of origins allowed to post; set it when a proxy in front of the app rewrites Host | | clientIp | vercelClientIp | (request) => string \| null; see "The visitor's address" | | timeoutMs | 10,000 | how long to wait for intake | | fetch, log | global fetch; one JSON line per event on stdout | injected in tests |

The order, each step running only if every earlier one passed:

| # | check | answer | | --- | --- | --- | | 1 | method is POST | 405 method_not_allowed, allow: POST | | 2 | the last path segment is a form in forms | 404 not_found | | 3 | same origin: Origin's host equals the Host header (not request.url, which a framework may build with a different host — measured on next start), or with no Origin, Sec-Fetch-Site: same-origin | 403 forbidden | | 4 | content-type is JSON | 415 unsupported_media_type | | 5 | body at most 61,440 bytes, counted as it is read | 413 too_large | | 6 | body is the browser contract below, and nothing else | 400 bad_request | | 7 | honeypot empty | a filled one gets the success answer, byte for byte, and nothing is forwarded | | 8 | a Turnstile token is present | 403 verification_failed | | 9 | FORMS_INGEST_URL, FORMS_INGEST_TOKEN and the visitor's address resolve | 503 unavailable, the variable named in the log | | 10 | forward to intake, map its answer | see below |

What is forwarded is built, never copied. The handler writes exactly four headers: authorization: Bearer <FORMS_INGEST_TOKEN>, content-type, accept and x-forms-client-ip. No cookie, origin, referer or any other incoming header crosses. The runtime's own fetch adds its transport headers on top: MEASURED 2026-09-27 at a fake intake behind a next start of a scaffold-generated app on Node 22, those were accept-encoding, accept-language, connection, content-length, host, sec-fetch-mode and user-agent, and no origin or cookie. The body is a fresh Wire contract v1 envelope: form_key from the path, definition_version from forms (never from the browser), and the browser's values, turnstile_token and source_path. Redirects are not followed (redirect: "manual"), so the bearer token cannot be carried to a host nobody configured.

Intake's answer, mapped. Only what a visitor can act on keeps its identity:

| intake | browser | | --- | --- | | 202 { ok: true } | 200 { ok: true } | | 422 invalid_values | 422 invalid with fields: each { field, code } relayed only if the code is known and the field is a key the browser itself sent (else field: null) | | 429 rate_limited | 429 rate_limited, with retry_after and retry-after when intake gave a usable one | | 403 turnstile_failed | 403 verification_failed | | 413 payload_too_large | 413 too_large | | anything else — unauthorized, project_not_served, unknown_definition, definition_invalid, project_not_provisioned, turnstile_unavailable, could_not_answer, unavailable, internal, client_ip_invalid, origin_forbidden, an unknown code, an unreadable body, a redirect, a timeout, a network failure | 503 unavailable, with intake's status and code in the site's log |

Every response is JSON, cache-control: no-store, with no CORS header.

The browser contract

POST /api/forms/<form_key>
content-type: application/json

{ "values": { "<field>": "…" | true | false, … },
  "turnstile_token": "…",
  "honeypot": "",                 // optional
  "source_path": "/contact" }     // optional: a path, no ? or #, or null

Answers: { "ok": true }, or { "ok": false, "error": <code> } plus fields (with invalid) or retry_after (with rate_limited). The codes are BROWSER_ERROR_CODES: invalid, verification_failed, rate_limited, too_large, bad_request, forbidden, not_found, method_not_allowed, unsupported_media_type, unavailable. They are stable; a new one is a major version.

useWeatherboardForm(options)

"use client";
import type { FormDefinition } from "@weatherboard/forms";
import { useWeatherboardForm } from "@weatherboard/forms/react";

// `definition: FormDefinition` is the project's PINNED definition, a copy of the
// published row passed down from a server component: the version the route's
// `forms` names.
const form = useWeatherboardForm({
  formKey: "contact",
  definition,
  turnstileSiteKey,                       // PUBLIC_TURNSTILE_SITE_KEY
});

<form {...form.formProps}>
  {form.fields.map((field) => /* your component, given field.inputProps / value / setValue / onBlur / errorMessage */)}
  <div aria-hidden="true"><input {...form.honeypotProps} /></div>
  <div ref={form.turnstileRef} />
  <p id={form.formErrorId} role="status">{form.formError?.message}</p>
  <YourButton type="submit" disabled={form.status === "submitting"}>Send</YourButton>
</form>

| option | | | --- | --- | | formKey, definition, turnstileSiteKey | required | | endpoint | default /api/forms/<formKey> | | fieldMessages, formMessages | override any default message; {label} in a field message is the field's label | | verificationTimeoutMs | how long a submit waits for a Turnstile token; default 15,000 | | onSuccess | called once on a stored (or honeypotted) submission |

What it returns:

  • fields: the visible fields, in definition order (the common group, then the group the audience discriminator's current value reveals). Each has name, type, label, required, maxLength, options, value, setValue, onBlur, id, errorId, error (a ValueErrorCode), errorMessage, and inputProps: { id, name, required, aria-required, aria-invalid, aria-describedby }, where aria-describedby names errorId whenever there is an error. Give the element that shows errorMessage the id errorId. field(name) looks one up.
  • Switching the audience hides the other group's fields; what was typed in them is kept (switching back restores it) but never sent, because intake refuses a hidden group's non-empty field.
  • Errors appear once a field has been left (onBlur) or a submit was tried, never while the visitor is still typing a first time. A server field error is shown on its field and cleared when that field is edited. On a refused submit, focus moves to the first field in error.
  • status: idle, submitting, succeeded, failed. formError: { code, message } from the route's code, or network when no answer came back. formProps: noValidate, onSubmit, onFocus, onInput, and aria-describedby pointing at formErrorId when there is a form error.
  • submit() and reset() for a component that submits by itself.

Client validation is the server's validator. validateForm runs S4's validateSubmission — the function intake runs — on exactly the values submissionValues will send. The shared fixtures in src/testing/value-fixtures.ts are run through both the client and, via the real forwarding handler, a stand-in intake running the server validator, and must agree.

Turnstile loads on interaction

formProps.onFocus and onInput load Cloudflare's script on the first focus or keystroke inside the form, never at render, so a visitor who does not touch the form downloads nothing from Cloudflare. The script is added once per document from exactly https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit — Cloudflare's docs say it must not be proxied or cached — and the widget is rendered into turnstileRef with appearance: "interaction-only", action set to the form key, and response-field: false (the token travels in the JSON body, not a hidden input). A token is single-use: the widget is reset after every answer. The widget runs in a container the page keeps hidden until Turnstile says it needs the visitor: challengeVisible is true from its before-interactive-callback until its after-interactive-callback (or an answer, an error, an expiry or a timeout). Hidden with display: none, the widget still renders and still produces a token. A site with a Content-Security-Policy must allow https://challenges.cloudflare.com in script-src and frame-src.

A browser Turnstile cannot run in

When Cloudflare reports the visitor's browser unsupported (its unsupported-callback), the hook returns unsupportedMessage, a sentence to show by the submit button so the visitor learns before trying to submit. It is null otherwise. The shared Form in @weatherboard/ui renders it as an alert after Send. The default is "This form can't run in your browser. Please email us instead."; pass the unsupportedMessage option to give a site's own contact line:

const form = useWeatherboardForm({
  formKey: "contact",
  definition,
  turnstileSiteKey,
  unsupportedMessage: "This form can't run in your browser. Please email [email protected] instead.",
});

The same report releases a submit that was waiting for a token, as verification_failed, and clears any token and hides a visible challenge. A later submit then fails the same way at once, unless the widget has since issued a token, which is then used.

The visitor's address

Intake keys its rate limit on the address the site sends in x-forms-client-ip, and passes it to Turnstile. The default resolver, vercelClientIp, reads:

  1. x-vercel-forwarded-for, when present;
  2. else the first entry of x-forwarded-for.

That is right on Vercel only. Vercel's request-headers page (read 2026-09-27) says Vercel overwrites x-forwarded-for with the client's public address and does not forward external values, "to prevent IP spoofing", and that x-vercel-forwarded-for carries the same value even when a proxy sits in front of Vercel. Anywhere else, pass clientIp: (request) => … naming the header your own proxy sets, or pass the per-request context.clientIp (Astro's clientAddress, above). A request with no usable address is refused unavailable and logged client_ip_unresolved; it is not forwarded without one.

What this does not protect against

Scoped to what the code here does, not to the system (for that, see the plan §3.8 and intake's README):

  • A visitor who bypasses the browser. The same-origin check (Origin, or Sec-Fetch-Site) stops another site's page from posting through a visitor's browser. A script calling the route directly can send any Origin it likes; what it still has to pass is Turnstile and intake's rate limit.
  • The address header off Vercel. Behind any other host, with the default resolver, a client can choose its own x-forwarded-for and spread its submissions across rate-limit keys. Configure clientIp.
  • A proxy in front of Vercel that rewrites x-forwarded-for is handled by preferring x-vercel-forwarded-for; a proxy that also rewrites that header is not.
  • Bots that skip the honeypot. The honeypot catches form-fillers that complete every input; it is one cheap filter, not the control. Turnstile and the rate limit are.
  • The honeypot's name. It is chosen so autofill does not recognise it; a browser or extension that fills unknown fields anyway will have its submissions silently dropped.
  • What intake returns. Field errors are relayed only in the contract's shape and only for field names the browser itself sent; everything else from intake becomes unavailable. That narrows what an upstream fault can put on a page; it does not make intake's answers trusted.
  • The site's own logs and error reporting. The handler logs a closed set of codes and never a value, the token, the address or an error message. The site's framework, host or error SDK may still record the incoming request (its headers include the visitor's address), and that is outside this package.
  • Stale pages. A page rendered against version 1 of a definition, posted after the route was re-pinned to version 2, is validated by intake against version 2; the visitor sees the resulting field errors.

For S9: what the scaffold should emit

S9 owns packages/project-scaffold. Measured against a throwaway project generated from the unedited scaffold (the pull request that added this package records the commands and numbers), the scaffold needs:

  1. The route, at src/app/api/forms/[form_key]/route.ts in the generated project's web workspace, exactly the three lines under "Next: the route", with the project's pinned versions.
  2. ContactSection and WaitlistSection beside the scaffold's other sections (src/components/sections/ in the web workspace), each a "use client" component calling useWeatherboardForm with the project's own pinned definition object (since A-1668 the scaffold pins copies of the published rows, in src/forms/definitions/, rather than importing a starter) and rendering every field with the design system: Select for a choice, Checkbox for consent, Button (type="submit") to send, Text for messages. Each renders the honeypot inside an aria-hidden wrapper, the turnstileRef container, and a role="status" element with formErrorId.
  3. PUBLIC_TURNSTILE_SITE_KEY in next.config.ts's env block, beside PUBLIC_SENTRY_DSN, so a client component can read it; and FORMS_INGEST_URL / FORMS_INGEST_TOKEN in the secrets manifest and .env.example as server-only.
  4. @weatherboard/forms in the web workspace's package.json once it is published.
  5. The pages that place the sections (/contact, /waitlist) in perf-budget.json's pages, so the budget and the a11y audit measure them.

Gaps the measurement found in the scaffold's design system, for S9 (or Gyde) to decide rather than this package: there is no text-input or textarea component, so text, email, url, postcode_uk and textarea fields render native elements; and Checkbox and Select take no aria-describedby or aria-invalid, so a consent or choice error cannot be tied to its control through the design system as it stands.

Tests

pnpm --filter @weatherboard/forms test, under pnpm verify, no network:

  • handler.test.ts — every refusal the handler makes itself, every Wire contract v1 refusal code mapped, the size cap (claimed and streamed), the honeypot, the four forwarded headers, the Next adapter;
  • canary.test.ts — a planted value, the ingest token and the visitor's address, through every path with the production log sink: none reaches a log line, a response or a Sentry-shaped event, and on the forwarding paths each does reach intake;
  • form-state.test.ts — the shared fixtures through the client and the server validator; audience switching;
  • react.test.ts — the hook in React DOM under jsdom, driving both starters; Turnstile not loaded before interaction, with the real loader;
  • index.test.ts — every entry point's surface, and which files may import React or read the environment.

pnpm --filter @weatherboard/forms smoke packs the real tarball, installs it into a bare project and consumes every entry point (ESM, CJS, TypeScript under node16, a browser bundle that must not contain the handler). It needs the network and is not in pnpm verify.

Publishing

As @weatherboard/annotation (docs/RUNBOOK-npm-publishing.md): pnpm packs, npm publishes, because publishConfig.exports (the src/ → dist/ swap) is applied by pnpm and not by the npm CLI. pnpm build enforces the per-entry gzip budget in package.json#sizeBudget. Semver: 0.x while the browser contract may still move; a new browser error code, a renamed hook field or a changed default resolver is a breaking change.