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

@codefusion-cc/workers-http

v0.1.3

Published

The HTTP plumbing of a Cloudflare Worker's API: errors thrown as answers, an API's routes, JSON responses, bounded JSON and body reading, the client's address and network, a same-origin check, rate limits over a RateLimit binding, cookies, SHA-256 and a s

Readme

@codefusion-cc/workers-http

The HTTP plumbing of a Cloudflare Worker's API: errors thrown as answers, an API's routes, JSON responses, JSON and body reading with limits, the client's address and network, a same-origin check for cookie calls, rate limits over a Workers RateLimit binding, JSON answers kept in the Worker's cache, cookies, SHA-256, and a single-page app's pages at the address the visitor wrote. It runs in Workers and in any runtime with Request, streams and WebCrypto (Node 20+); its one dependency is @codefusion-cc/workers-crypto.

npm install @codefusion-cc/workers-http

One place answers errors

Handlers throw HttpError for any answer that is not the happy one, and so do readJson, readBody, requireSameOrigin, rateLimit, a Router's route and serveSinglePageApp. The Worker turns whatever was thrown into a response in one place:

import { clientNetwork, errorResponse, HttpError, json, rateLimit, readJson, requireSameOrigin } from '@codefusion-cc/workers-http'

export default {
  async fetch(request, env) {
    try {
      return await route(request, env)
    } catch (error) {
      return errorResponse(error) // HttpError: its status and body; anything else: a 500 that reveals nothing, logged
    }
  },
} satisfies ExportedHandler<Env>

async function signIn(request: Request, env: Env) {
  requireSameOrigin(request, [env.DEV_ORIGIN])                     // 403 for a page on another site
  await rateLimit(env.AUTH_LIMITER, clientNetwork(request))        // 429 with retry-after
  const { email } = await readJson<{ email?: unknown }>(request)   // 415, 413, 400
  if (typeof email !== 'string') throw new HttpError(422, 'Enter your e-mail', { fieldErrors: { email: 'Required' } })
  return json({ ok: true })
}

An HttpError answers { error, code?, fieldErrors?, ...data } with its status and headers. Its status must be 400 to 599 (anything else throws a RangeError). errorResponse also answers one made by another installed copy of the package (which instanceof cannot see), so an error thrown inside another package still gets its status.

json(data, init) sends application/json; charset=utf-8 and cache-control: no-store unless init sets them. init.headers may be a Headers with several set-cookie. jsonError(status, message) is the same body as a thrown HttpError, for returning instead of throwing.

The app's language

The package's messages are English. errorResponse swaps them for the app's wording by code, and leaves any message the app wrote alone (readBody's message, its own HttpErrors):

errorResponse(error, {
  messages: {
    unsupported_media_type: 'Wyślij dane jako JSON.',
    body_too_large: 'Za duże zapytanie.',
    unreadable_body: 'Nie udało się odczytać zapytania.',
    invalid_json: 'Nieprawidłowe dane.',
    cross_origin: 'Odrzucono zapytanie z innej strony.',
    rate_limited: 'Zbyt wiele prób. Odczekaj minutę i spróbuj ponownie.',
    not_found: 'Nie znaleziono.',
    method_not_allowed: 'Ten adres nie przyjmuje takiego zapytania.',
    server_error: 'Wystąpił błąd serwera. Spróbuj ponownie.',
  },
  onError: error => console.error('unhandled', error), // console.error by default; a throwing logger cannot break the answer
})

Routes

Router keeps an API's routes, a method and a path to whatever the app calls a handler, and finds the one a request is for:

import { errorResponse, json, Router } from '@codefusion-cc/workers-http'

type Handler = (request: Request, env: Env, params: Record<string, string>) => Response | Promise<Response>

const api = new Router<Handler>()
  .on('GET', '/api/orders/new', () => json({ draft: true }))       // before the parameter that would take "new"
  .on('GET', '/api/orders/:id', (_request, _env, { id }) => json({ id }))
  .on('DELETE', '/api/orders/:id', removeOrder)

export default {
  async fetch(request, env) {
    try {
      const { handler, params } = api.route(request.method, new URL(request.url).pathname)  // 404, 405
      return await handler(request, env, params)
    } catch (error) {
      return errorResponse(error)
    }
  },
} satisfies ExportedHandler<Env>
  • A :name segment is a parameter: it takes any one segment that is not empty, and its value arrives percent-decoded (Bosch%20%2B is Bosch +; one whose encoding is broken stays as sent, and a decoded value can hold a /). Every other segment is met exactly, letter case and a trailing slash included, and text outside ASCII is written plainly: /api/zamówienia meets the browser's /api/zam%C3%B3wienia.
  • No escape in a request is read before fixed text is compared: /api/%61dmin/users is not /api/admin/users (it is a 404, or a value for a parameter there). So a check the app makes on the path before routing, such as pathname.startsWith('/api/admin/'), and the router always agree on which path a request has. A parameter's value is decoded, so /api/:area/users does get admin from %61dmin: a handler never takes a value for a path the earlier check has seen.
  • The first route added that fits answers. A method is met exactly, but for HEAD: a path's GET route answers it when the path has no HEAD route of its own, and the runtime sends that answer without its body.
  • route(method, pathname) throws 404 not_found for a path no route has, and 405 method_not_allowed with allow (the methods the path does answer) for one that has routes of other methods only. match returns null for both, for an app that answers them itself.
  • on throws a TypeError for a programming mistake: a method that is not uppercase letters, a path that is not one (no leading /, a ?, a #, a ..), a : segment that is not :name, a parameter twice in one path, and a second route for a method and path that has one, which could never answer.

Reading bodies

| Call | Refuses | | --- | --- | | readJson(request, options) | 415 unsupported_media_type: Content-Type not application/json or application/*+json. 413 body_too_large. 400 unreadable_body. 400 invalid_json: no body, not UTF-8, not JSON, or not an object. | | readBody(request, options) | 413 body_too_large, 400 unreadable_body (the upload broke off). Resolves the bytes; no body is empty bytes. Takes a Response too, e.g. one fetched from another service. |

  • maxBytes (default MAX_BODY_BYTES, 256 KiB) counts bytes, not characters. A larger Content-Length is refused before anything is read. A body that turns out longer (chunked, or a lying header) is cancelled as soon as it passes the limit, so an endless upload costs at most the limit. Every body it refuses is cancelled, and on every way out readBody lets go of the body's stream, so it is never left locked.
  • message words that 413 for this call: 'The photo is larger than 3 MB.'.
  • The Content-Type is checked because a cross-site form can post text/plain without a preflight, but not application/json. anyContentType: true reads a navigator.sendBeacon body (text/plain); only for calls no cookie authenticates.
  • Only an object passes, not an array or a bare value, so const { name } = await readJson(request) cannot throw a TypeError.
  • __proto__ keys are dropped at every depth, so Object.assign(row, body) cannot change a prototype.
  • T is a promise to the compiler, not a check: validate the fields before using them.

Who is calling

  • clientIp(request, fallback = 'unknown'): cf-connecting-ip, which Cloudflare sets and nobody can forge through it. x-forwarded-for and x-real-ip are never read.
  • clientNetwork(request, fallback): the key to rate-limit by address. IPv4 addresses stay whole. IPv6 addresses are cut to their /64, since one home or phone holds a whole /64 and could take a fresh address for every try. Every spelling of an address gives the same key, and ::ffff:a.b.c.d gives the IPv4 one.
  • requireSameOrigin(request, allowedOrigins): for calls a cookie authenticates. It allows an Origin that is the request's own or listed (http://localhost:5173 in development), or no Origin with Sec-Fetch-Site: same-origin. Everything else gets 403 cross_origin, including Origin: null and no header at all. It covers WebSocket upgrades, which carry cookies across sites. A listed entry that is not an origin throws a TypeError.

Rate limits

// wrangler.jsonc
"ratelimits": [{ "name": "AUTH_LIMITER", "namespace_id": "1001", "simple": { "limit": 10, "period": 60 } }]

rateLimit(env.AUTH_LIMITER, key, { message, retryAfter }) counts one request against the key and throws 429 rate_limited with retry-after (default 60, the binding's period) once the limit is reached. A binding that fails rejects with its error, so a broken limiter refuses requests instead of letting everyone through.

A single-page app's pages

"not_found_handling": "single-page-application" answers a page path the assets spell another way with a 307 to their own spelling. They decode each segment and encode it again with encodeURIComponent:

| The page wrote | The assets redirect to | The page reads | | --- | --- | --- | | /Mac/search/house+of+the+dragon | /Mac/search/house%2Bof%2Bthe%2Bdragon | pluses, not spaces | | /rooms/AC%2FDC | /rooms/AC/DC | two segments | | /a//b | /a/b | one segment fewer | | /s/a:b, /s/%c5%82, /s/100% | /s/a%3Ab, /s/%C5%82, /s/100%25 | the same text, after a needless round trip |

html_handling: "none" does not change this. So an app that keeps text in its paths (a search, a name) reads something else after a reload. Without that handling ("none", the default) the assets answer a path that is no file with a plain 404, never a redirect, and the router hands it to the Worker. serveSinglePageApp answers it with the app's page, at the address as written:

// wrangler.jsonc
"assets": {
  "directory": "./dist",
  "binding": "ASSETS"
  // No not_found_handling: the Worker answers pages (serveSinglePageApp). Files never invoke the Worker.
}
async function route(request: Request, env: Env) {
  const path = new URL(request.url).pathname
  if (path.startsWith('/api/')) return (await handleApi(request, env)) ?? jsonError(404, 'Not found')
  return serveSinglePageApp(request, env.ASSETS) // last, for every path the API does not own
}
  • It asks the assets for the path first: a file is theirs to answer, and so is their 307 for a file asked by another spelling (/sw%2Ejs to /sw.js, /index.html to /). Only a 404 becomes the app's page, so it works the same when run_worker_first sends files to the Worker (true, or ["/*", "!/assets/*"] for a Worker that must see every page load), at a Worker invocation per file.
  • With "single-page-application" left on, the assets answer every path themselves and this changes nothing: take it out.
  • Only GET and HEAD are pages. Any other method throws 405 method_not_allowed with allow: GET, HEAD, and the assets are not asked.
  • The page is asked with the request's method and headers, so a reload with If-None-Match still gets a 304.
  • _headers rules apply to pages as they do to /: /* rules do, rules for a page's own path do not. Keep page headers (a CSP, x-frame-options) on /*, or set them in the Worker.
  • { shell: '/admin/' } answers with another page, for an app whose page is not at /. A shell that is not a path of this site rejects with a TypeError.

Answers kept in the cache

cachedJson answers a GET or HEAD with JSON that is expensive to make and the same for everyone (a catalog, a product, a price list). It keeps the answer in the Worker's cache, so most requests never run build:

import { cachedJson } from '@codefusion-cc/workers-http'

async function catalog(request: Request, env: Env, ctx: ExecutionContext) {
  const key = new URL('/api/catalog', request.url)                // one spelling of the question, however a visitor writes it
  return cachedJson(request, { cache: caches.default, waitUntil: (work) => ctx.waitUntil(work), maxAge: 60, staleFor: 600, key }, () => readTheCatalog(env.DB))
}
  • It sends application/json with a weak ETag (the SHA-256 of the text) and cache-control: public, max-age=…, stale-while-revalidate=…, where max-age is the seconds the kept answer still has. A request whose If-None-Match names the current ETag (weak comparison, a list, or *) gets 304 without a body.
  • While the answer is fresh (under maxAge old) build does not run. Past that and within staleFor the kept answer goes out at once and build runs once in the background through waitUntil; requests that find the same stale answer within 15 seconds start no second renewal. Without waitUntil the stale answer is renewed before it is sent.
  • Past maxAge + staleFor, or with nothing kept, the request waits for build, and its error is thrown on, for errorResponse. If build throws while renewing an answer that is still within staleFor, that one goes out and onError hears of it (console.error by default). A cache that refuses put or fails match never fails the answer.
  • key is where the answer is kept. The default is the request's own address, so ?a=1&b=2 and ?b=2&a=1 are two answers. Without cache nothing is kept and every request builds.
  • Never use it for an answer that differs by visitor (a cart, a session, a price by account): it is public, and the first visitor's answer would reach the next.
  • A GET or HEAD only, a maxAge of at least 1 and a staleFor of at least 0 in whole seconds, and a value JSON can hold: anything else throws a TypeError, since it is a programming mistake.

Cookies and hashes

getCookie(request, '__Host-session')                                  // string, or null (missing or empty)
serializeCookie('__Host-session', token, { maxAge: 30 * 86_400 })     // Path=/; Max-Age=…; HttpOnly; Secure; SameSite=Lax
serializeCookie('__Host-session', '', { maxAge: 0 })                  // clears it
await sha256(token)                                                   // 64 hex characters
await sha256(token, 'base64url')                                      // 43 characters

serializeCookie throws for what a browser would refuse or misread: a value with a space, quote, comma, semicolon or backslash (encode it first), SameSite=None or a __Host-/__Secure- name without secure, a __Host- name with a Domain or another Path. Keep one sha256 encoding per stored column: the two never match.

In an app's tests

import { testRateLimiter } from '@codefusion-cc/workers-http/testing'

const limiter = testRateLimiter({ limit: 2 })       // or { fail: new Error('down') }
await rateLimit(limiter, 'k'); await rateLimit(limiter, 'k')
await expect(rateLimit(limiter, 'k')).rejects.toMatchObject({ status: 429 })
limiter.keys // ['k', 'k', 'k']
import { testAssets } from '@codefusion-cc/workers-http/testing'

const assets = testAssets({ files: { '/index.html': '<!doctype html>…', '/sw.js': '…' } }) // or { fail: new Error('down') }
await worker.fetch(new Request('https://app.test/Mac/search/a+b'), { ...env, ASSETS: assets })
assets.paths // ['/Mac/search/a+b', '/']: what was asked; assets.requests holds the requests

testAssets answers like static assets with not_found_handling: "none": a file by its path and a folder's index.html at the folder, a 307 from any other spelling of either, a 404 for a path that is no file, 405 for other methods on a file, and a 304 for its ETag. The package's workerd tests hold it to the real binding.

Tests that run the Worker in workerd use the real bindings from the Wrangler config instead.