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

@vibedout/web-crypto

v0.1.1

Published

WebCrypto-only primitives for Workers, browsers and Node: byte encodings (hex, base64, strict and lenient base64url), HMAC-SHA256, padded constant-time compare, SHA-256 digests, random tokens, peppered secret minting, webhook signature-header parsing with

Readme

@vibedout/web-crypto

The crypto every package and Worker needs, written once: byte encodings, HMAC-SHA256, constant-time compare, SHA-256 digests, random tokens, minting API keys, the steps every signed-webhook scheme repeats, and an at-most-once receipt store.

  • No dependencies, and no Node. Everything is crypto.subtle, crypto.getRandomValues, btoa and TextEncoder, so the same code runs in a Worker, a browser and Node.
  • ESM only, with types. Each module is also a subpath (@vibedout/web-crypto/hmac) for a narrower import.
import { hmacSha256Hex, timingSafeEqual, sha256Hex, randomToken } from "@vibedout/web-crypto";

Modules

bytes

UTF-8, hex, base64 and base64url.

import { utf8Encode, toHex, fromHex, toBase64, fromBase64, base64UrlEncode, base64UrlDecode, fromBase64Url, toBytes, concatBytes } from "@vibedout/web-crypto/bytes";

toHex(new Uint8Array([255, 0]));           // "ff00"
toBase64(bytes);                            // padded, built in chunks: safe on a multi-megabyte asset
base64UrlEncode(bytes);                     // unpadded, URL and cookie safe

There are two base64url decoders, and the names say which:

| Function | Accepts | Use it for | |---|---|---| | base64UrlDecode | Only the URL-safe alphabet, and only the one canonical spelling of each byte string. Throws on +, /, =, whitespace and non-zero trailing bits. | A token or signature you compare, dedupe or log | | fromBase64Url | Padded or unpadded, and the standard alphabet too. | Reading a value someone else produced |

toBase64Url is base64UrlEncode under the name the Workers have always used.

hmac

import { hmacSha256, hmacSha256Hex, hmacSha256Base64, hmacSha256Base64Url, verifyHmacSha256 } from "@vibedout/web-crypto/hmac";

const mac = await hmacSha256Hex("whsec_...", `${timestamp}.${body}`); // text or bytes, for both
const ok = await verifyHmacSha256(secret, message, signatureBytes);   // constant time

An empty secret throws: an unset webhook secret must fail loudly, not sign with nothing.

compare

import { timingSafeEqual } from "@vibedout/web-crypto/compare";

if (!timingSafeEqual(presentedToken, storedToken)) return unauthorized();

Always padded: both sides are walked to the length of the longer one, and a length difference is folded into the result. There is no early return on length. The walk length is the longer input's, so when even the length of the stored secret must stay private, hash both sides first and compare the digests.

digest

import { sha256Hex, sha256B64u, hashParts } from "@vibedout/web-crypto/digest";

await sha256Hex("text or bytes");                  // 64 hex characters: a hashed lookup key
await sha256B64u(verifier);                        // 43 characters: a PKCE challenge, a hashed token
await hashParts([email, ip]);                      // sha256B64u(JSON.stringify(parts)): no part can be smuggled across a boundary

random

import { randomBytes, randomToken, randomHex, randomString, prefixedId } from "@vibedout/web-crypto/random";

randomToken();                                      // 32 random bytes as 43 URL-safe characters
prefixedId("mi");                                   // "mi_" + 22 URL-safe characters (16 random bytes): an id that says what it is
randomString("23456789ABCDEFGHJKMNPQRSTUVWXYZ", 8); // a pairing code, no modulo bias

Every function that draws randomness takes an optional source as its last argument, so a test can feed it fixed bytes.

mint

API keys and bearer secrets: shown once, stored as a hash, found again by a short unhashed prefix.

import { mintPrefixedSecret, parsePrefixedSecret, hashSecret } from "@vibedout/web-crypto/mint";

const { plaintext, lookupPrefix, hash } = await mintPrefixedSecret({ prefix: "ve_sec_", bytes: 32, pepper: env.KEY_PEPPER });
// show `plaintext` once; store `lookupPrefix` and `hash`

const parsed = parsePrefixedSecret(presented, { prefix: "ve_sec_" });
if (!parsed) return unauthorized();
const row = await findByPrefix(parsed.lookupPrefix);
if (!timingSafeEqual(await hashSecret(parsed.plaintext, env.KEY_PEPPER), row.hash)) return unauthorized();
  • pepper mixes a server secret into the hash as sha256(pepper + ":" + value), so a stolen database cannot be brute-forced without it.
  • hashPart: "body" hashes only the random part, for a format that carries its own id after the prefix (vkn_<id>_<secret>).
  • At least 24 bytes (192 bits), and a lookup prefix of at most half the body, so the default 12-character prefix is valid for every size accepted. hashSecret(value, pepper?) is the same hash, for re-hashing a presented secret.

webhook

A signed webhook is three steps. Each adapter keeps only what is its own: what is signed, which headers, how long a delivery stays valid.

import { parseSignatureHeader, verifyHmacCandidates, checkFreshness } from "@vibedout/web-crypto/webhook";

// Stripe: `Stripe-Signature: t=1700000000,v1=abc,v1=def`
const parts = parseSignatureHeader(header);                 // Map { t: ["1700000000"], v1: ["abc", "def"] }
const t = parts.get("t")?.[0];
if (!t) throw badSignature();
const signed = await verifyHmacCandidates({ secret, message: `${t}.${rawBody}`, candidates: parts.get("v1") ?? [] });
if (!signed.ok) throw badSignature();
const fresh = checkFreshness(t, { unit: "s", toleranceSeconds: 300 });
if (!fresh.ok) throw stale();                                // "malformed" or "expired"
  • Signature first, clock second is the safer order when the two outcomes are told apart (a genuine but late delivery is expired, a forgery is never reported as late). A scheme that treats both as one failure may check either first.
  • parseSignatureHeader takes { separator, lowercaseKeys }; a key may repeat (a rotation window signs with both secrets).
  • verifyHmacCandidates takes one message or several (when the provider's wording of what it signed is uncertain, try the raw body and the re-serialised one), and encodings ("hex" by default, "base64", "base64url"). Every comparison is constant time.
  • checkFreshness takes unit: "s" | "ms" | "auto". In seconds the clock is read to the whole second, the way Stripe documents the check, so a window of N seconds is N whole seconds either side of the signing time; in milliseconds it is exact.
  • WebhookInput, createHeaderReader and webhookInputFromRequest give every adapter the same view of a delivery, body bytes untouched.

receipts

Webhooks arrive at least once. handleOnce claims every key a delivery is known by, then runs the handler, and releases the claims if it throws so the provider's retry is processed.

import { D1ReceiptStore, handleOnce, receiptsMigrationSql } from "@vibedout/web-crypto/receipts";

const store = new D1ReceiptStore(env.DB);          // table `webhook_receipts`; see receiptsMigrationSql()

const outcome = await handleOnce({
  store,
  namespace: "envia",
  keys: [deliveryId, `content:${contentHash}`],    // a replay under a fresh delivery id is still recognised by its content
  handle: () => applyEvent(event),
});
// { status: "processed", result } or { status: "duplicate" }: answer 2xx either way
  • Verify the signature before calling it, so an unsigned request can never poison the receipts.
  • claim is INSERT ... ON CONFLICT DO NOTHING and a check of the changed-row count: exactly one of any number of concurrent claims wins.
  • Copy receiptsMigrationSql() into the consuming Worker's migrations/. Keep receipts at least as long as the provider's replay window, and run purgeClaimedBefore(ms) from a cron trigger.
  • A claim or the handler that throws releases every claim this call made, so the provider's retry is processed. If a release fails too the claim is stuck and the retry would be answered duplicate, so that is reported, not swallowed: an AggregateError with the original error first.
  • If the process dies between the claim and the end of the handler, the claim stays. Where that matters, use BatchedD1ReceiptStore (below): the claim is written in the same D1 batch as the effects.
  • createMemoryReceiptStore() backs tests and single-isolate use. Any object with claim and release is a store.

A claim that cannot outlive its effects. D1ReceiptStore writes the claim first, so a Worker that dies before the handler finishes leaves a claim with no effect, and the retry is answered duplicate. BatchedD1ReceiptStore only looks at claim and remembers the pair; the INSERTs come out of statements() for the one db.batch that writes the effects (D1 runs a batch as one transaction), so a Worker that dies before it leaves nothing and the retry is processed.

import { BatchedD1ReceiptStore, handleOnce } from "@vibedout/web-crypto/receipts";

const store = new BatchedD1ReceiptStore(env.DB, { table: "webhook_receipts" }); // one per delivery
await handleOnce({
  store, namespace: "envia", keys: [deliveryId],
  handle: () => env.DB.batch([...effectsOf(event), ...store.statements()]),     // the claim and the effects, together
});
  • statementsIfChanged() writes each claim only if the statement before it in the batch changed a row (changes() > 0): put it after a compare-and-swap and its event, so a swap that lost claims nothing. A claim it did not write is still returned by statements().
  • Two deliveries of one event at one moment can both pass claim's look: the effects must be idempotent (a unique index, a compare-and-swap, an event with a dedupe key).
  • A delivery found to be a duplicate writes nothing, so a key that was new in a call that found another key seen is not recorded.
  • release (the handler threw) forgets the claim, and deletes the row when a statement for it was handed out.

testing

dyingD1(db, { dieAfter }) (@vibedout/web-crypto/testing, not on the barrel) wraps a D1 binding that answers dieAfter calls and then dies: the next call and every call after it throw WorkerDied, before they reach the database, and nothing the handler would clean up afterwards runs. That is what a Worker that is killed does, and no test that throws an ordinary error can show it: the error is caught and the handler releases its claim. A test finds the number of places to kill at with a large dieAfter and calls(), then runs the delivery once for each place and sends it again to a healthy database.

Testing

pnpm test (vitest). Digests, HMACs and encodings are checked against Node's own crypto and Buffer, the published test vectors (SHA-256, RFC 4231, RFC 7636) and fixed expected values, and the D1 receipt store runs its real SQL on SQLite. Webhook fixtures are signed with Node's own HMAC, so a bug here cannot hide on both sides.