@codefusion-cc/workers-http

中文文档
0.1.4发布于 2026/10/26MITTypeScriptES Module近 30 天下载 2141

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

原始使用文档
文档目录11 节
  1. One place answers errors
  2. The app's language
  3. Routes
  4. Reading bodies
  5. Who is calling
  6. Rate limits
  7. A single-page app's pages
  8. Answers kept in the cache
  9. Preview and production
  10. Cookies and hashes
  11. In an app's tests

@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, { request }) // 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.

Given the request, errorResponse also cancels a body nothing has read, so an upload refused before it was read (a 401 on a PUT) stops at once instead of running to its end.

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.',
    length_required: 'Wyślij zapytanie z nagłówkiem Content-Length.',
    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

CallRefuses
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.
streamBody(request, { maxBytes }, store)411 length_required: no Content-Length, or one that is not a whole number. 413 body_too_large. 400 unreadable_body: more or fewer bytes than declared. Calls store(body, size) with the body as a stream of exactly size bytes, for a store that takes a stream of known length, and resolves what it resolves.
  • 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.

streamBody hands an upload on to a store without holding it in memory, R2 for one:

const limit = { maxBytes: 100e6, message: 'An artifact is at most 100 MB.' }
await streamBody(request, limit, body => env.BUCKET.put(key, body))  // 411, 413, 400 when the upload broke
  • Both refusals come before a byte is read, and cancel the body. In workerd the stream is a FixedLengthStream, so R2 knows its length.
  • A body that breaks off, or brings more or fewer bytes than its Content-Length, errors the stream, so the store fails instead of keeping a cut copy, and streamBody throws 400 unreadable_body: the sender's fault. Any other failure of the store (it stops reading, R2 is down) is thrown as it is, a 500.

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 wroteThe assets redirect toThe page reads
/Mac/search/house+of+the+dragon/Mac/search/house%2Bof%2Bthe%2Bdragonpluses, not spaces
/rooms/AC%2FDC/rooms/AC/DCtwo segments
/a//b/a/bone segment fewer
/s/a:b, /s/%c5%82, /s/100%/s/a%3Ab, /s/%C5%82, /s/100%25the 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.

Preview and production

Each app deploys two copies of itself, a preview and production (tools/ci). answerCopy and markCopy let each say which one it is and keep it out of search results:

import { answerCopy, type CopyEnv, markCopy } from '@codefusion-cc/workers-http'

export default {
  fetch: async (request: Request, env: Env) => markCopy(answerCopy(request, env) ?? await answer(request, env), env),
}
  • answerCopy answers GET and HEAD of /version.json with { commit, environment } (COMMIT, which the deploy sets, and APP_ENV from the copy's Wrangler vars; null where unset), and of /robots.txt. Every other request is null, so it stays the app's. The deploy and the hourly production watch read both.
  • markCopy adds X-Robots-Tag: noindex, nofollow to every answer, and /robots.txt disallows everything, unless the app passes { indexed: true } and APP_ENV is exactly production. A preview, a missing APP_ENV or a typo in it stays out of search results. A WebSocket upgrade passes as it is. A site's static files never reach the Worker, so its _headers sends the same header.

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.

版本与发布记录

@codefusion-cc/workers-http 共发布 8 个版本,首次发布于 2026/10/02;过去 12 个月发布了 8 个版本。

版本发布日期距今
0.1.4latest2026/10/2614 天前
0.1.32026/10/091 个月前
0.1.22026/10/041 个月前
0.1.12026/10/031 个月前
0.1.02026/10/021 个月前
1.1.02026/10/021 个月前

关于 @codefusion-cc/workers-http 的常见问题

@codefusion-cc/workers-http 是什么?

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

如何安装 @codefusion-cc/workers-http?

在项目目录运行 npm install @codefusion-cc/workers-http,或使用 pnpm add / yarn add / bun add。

@codefusion-cc/workers-http 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@codefusion-cc/workers-http 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@codefusion-cc/workers-http 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@codefusion-cc/workers-http 可以商用吗?

@codefusion-cc/workers-http 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@codefusion-cc/workers-http 的下载量有多少?

根据 npm 官方下载统计,@codefusion-cc/workers-http 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,141 次,日均约 71 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@codefusion-cc/workers-http 还在维护吗?

最新版本 0.1.4 发布于 2026/10/26(14 天前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/10/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。