@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
Maintainers
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-httpOne 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
:namesegment is a parameter: it takes any one segment that is not empty, and its value arrives percent-decoded (Bosch%20%2BisBosch +; 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ówieniameets the browser's/api/zam%C3%B3wienia. - No escape in a request is read before fixed text is compared:
/api/%61dmin/usersis 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 aspathname.startsWith('/api/admin/'), and the router always agree on which path a request has. A parameter's value is decoded, so/api/:area/usersdoes getadminfrom%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'sGETroute answers it when the path has noHEADroute of its own, and the runtime sends that answer without its body. route(method, pathname)throws 404not_foundfor a path no route has, and 405method_not_allowedwithallow(the methods the path does answer) for one that has routes of other methods only.matchreturns null for both, for an app that answers them itself.onthrows 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(defaultMAX_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 outreadBodylets go of the body's stream, so it is never left locked.messagewords 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/plainwithout a preflight, but notapplication/json.anyContentType: truereads anavigator.sendBeaconbody (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, soObject.assign(row, body)cannot change a prototype.Tis 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-forandx-real-ipare 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.dgives the IPv4 one.requireSameOrigin(request, allowedOrigins): for calls a cookie authenticates. It allows anOriginthat is the request's own or listed (http://localhost:5173in development), or noOriginwithSec-Fetch-Site: same-origin. Everything else gets 403cross_origin, includingOrigin: nulland 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%2Ejsto/sw.js,/index.htmlto/). Only a 404 becomes the app's page, so it works the same whenrun_worker_firstsends 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_allowedwithallow: 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-Matchstill gets a 304. _headersrules 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/jsonwith a weak ETag (the SHA-256 of the text) andcache-control: public, max-age=…, stale-while-revalidate=…, wheremax-ageis the seconds the kept answer still has. A request whoseIf-None-Matchnames the current ETag (weak comparison, a list, or*) gets 304 without a body. - While the answer is fresh (under
maxAgeold)builddoes not run. Past that and withinstaleForthe kept answer goes out at once andbuildruns once in the background throughwaitUntil; requests that find the same stale answer within 15 seconds start no second renewal. WithoutwaitUntilthe stale answer is renewed before it is sent. - Past
maxAge + staleFor, or with nothing kept, the request waits forbuild, and its error is thrown on, forerrorResponse. Ifbuildthrows while renewing an answer that is still withinstaleFor, that one goes out andonErrorhears of it (console.errorby default). A cache that refusesputor failsmatchnever fails the answer. keyis where the answer is kept. The default is the request's own address, so?a=1&b=2and?b=2&a=1are two answers. Withoutcachenothing 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
maxAgeof at least 1 and astaleForof 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 charactersserializeCookie 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 requeststestAssets 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.
