Imported from hoa-js/hoa (
SKILL.md). Install upstream withnpx skills add hoa-js/hoa. Copyright stays with the author.
Hoa Framework Skill
Hoa is a minimal Web framework built entirely on the Web Standards Fetch API (Request / Response / Headers / ReadableStream). An application is an async middleware pipeline around a single ctx object. The app exposes a standard fetch(request, env, executionCtx) handler that plugs into any modern JavaScript runtime with no adapter.
When to use
- Building a web server, HTTP API, edge function, or serverless handler in JavaScript/TypeScript.
- Targeting Cloudflare Workers, Deno, Bun, Vercel Edge, AWS Lambda/Lambda@Edge, Fastly Compute, or Node.js (>= 20) — especially multiple targets from the same source.
- Writing Koa-style middleware with
async (ctx, next) => {}semantics, but on the Fetch API rather than Node'shttpmodule. - Authoring reusable middleware/extensions for the Hoa ecosystem.
Prefer Hoa over Koa when the target runtime is Web Standards-based. Prefer Hoa over Hono when you want Koa-style ctx.req / ctx.res mutation and a smaller surface. Hoa itself ships no router — pair it with a router middleware or use ctx.req.pathname + ctx.req.method directly.
Installation
npm i hoa
Requires Node.js >= 20 when running on Node. ESM and CJS builds are both published.
Core mental model
An app is new Hoa(). You register middlewares with app.use(fn) and expose app.fetch as the runtime entry point. For each request, Hoa creates a ctx with:
ctx.request— the original Web StandardRequest(read-only passthrough).ctx.env— platform env (e.g. Cloudflareenv).ctx.executionCtx— platform execution context (e.g. Cloudflarectx).ctx.state— per-request, null-prototype object for middleware to share data.ctx.req— aHoaRequestwrapper (URL parts, headers, body readers, IP).ctx.res— aHoaResponsebuilder (status, headers, body, redirect).ctx.app— theHoainstance.ctx.throw(status, message?, options?)/ctx.assert(value, status, message?, options?)— throwHttpError.
You build the response by mutating ctx.res (ctx.res.status, ctx.res.body, ctx.res.type, headers, etc.). After the middleware stack resolves, Hoa synthesizes a Web Standard Response from ctx.res.
Quick start
Minimal app (any Web Standards runtime)
import { Hoa } from 'hoa'
const app = new Hoa()
app.use(async (ctx, next) => {
ctx.res.body = 'Hello, Hoa!'
})
export default app // { fetch } is available as app.fetch
Cloudflare Workers / Vercel Edge / Deno Deploy
Export the app (or app.fetch) as the default export. The runtime will call app.fetch(request, env, executionCtx).
export default app
// or: export default { fetch: app.fetch }
Bun
Bun.serve({ fetch: app.fetch, port: 3000 })
Deno
Deno.serve(app.fetch)
Node.js (>= 20)
Use a Web Standards server adapter, e.g. @hono/node-server, srvx, or Node's built-in node:http via a Request/Response adapter:
import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 3000 })
AWS Lambda / Lambda@Edge
Wrap app.fetch with a Lambda ↔ Fetch adapter (many exist; the Hoa fetch handler is standards-compliant so any adapter that accepts (request, env, ctx) => Response works).
Writing middleware
Middleware is async (ctx, next) => { ... }. Call await next() to run downstream middleware, then modify the response on the way out. Middlewares execute in registration order, onion-style.
// Logger
app.use(async (ctx, next) => {
const start = Date.now()
await next()
console.log(`${ctx.req.method} ${ctx.req.pathname} ${ctx.res.status} ${Date.now() - start}ms`)
})
// Response time header
app.use(async (ctx, next) => {
const start = Date.now()
await next()
ctx.res.set('X-Response-Time', `${Date.now() - start}ms`)
})
// Auth gate
app.use(async (ctx, next) => {
ctx.assert(ctx.req.get('authorization'), 401, 'Missing token')
ctx.state.user = await verify(ctx.req.get('authorization'))
await next()
})
// Route
app.use(async (ctx) => {
if (ctx.req.pathname === '/hello' && ctx.req.method === 'GET') {
ctx.res.body = { hello: ctx.state.user.name } // auto JSON
return
}
ctx.throw(404)
})
Rules:
- Always
await next(); never call it twice. app.use(fn)requires a function; it throwsTypeErrorotherwise.- Middlewares are composed lazily and cached; adding a middleware invalidates the cache.
- Throwing (including
ctx.throw) is caught by the framework and turned into an error response viactx.onerror→app.onerror.
ctx (HoaContext) API
Instance properties:
ctx.app: Hoa— the application instance.ctx.req: HoaRequest— request wrapper (see below).ctx.res: HoaResponse— response builder (see below).ctx.request?: Request— original Web StandardRequest.ctx.env?: any— platform env (e.g. Cloudflare bindings).ctx.executionCtx?: any— platform execution context.ctx.state: Record<string, any>— per-request, null-prototype bag for middleware.
Methods:
ctx.throw(status: number, message?: string, options?: HttpErrorOptions): never(alsoctx.throw(status, options)) — throw anHttpError.ctx.assert<T>(value: T, status: number, message?: string, options?: HttpErrorOptions): asserts value is NonNullable<T>(alsoctx.assert(value, status, options)) — throwsHttpErrorifvalueis falsy.ctx.onerror(err: unknown): Response— default error →Responsebuilder (override viaHoaContextprototype).ctx.toJSON(): HoaContextJson—{ app, req, res }snapshot.get ctx.response: Response— synthesize the final Web StandardResponsefromctx.res.
ctx.req (HoaRequest) API
All URL accessors lazy-parse ctx.request.url once. Types mirror types/index.d.ts.
Instance links: req.app: Hoa, req.ctx: HoaContext, req.res: HoaResponse.
URL (get/set):
req.url: URL— set withstring | URL.req.href: string— full URL (origin + path + search + hash).req.origin: string— scheme + host + port.req.protocol: string— e.g.'https:'.req.host: string— host with port.req.hostname: string— host without port (IPv6 brackets stripped).req.port: string— port as string,''for default.req.pathname: string— starts with/.req.search: string— includes leading?, or''.req.hash: string— includes leading#, or''.req.method: string— override-safe (setter stores locally).req.query: Record<string, string | string[]>— duplicate keys become arrays; setter replacessearch.
Headers (Web Standards Headers under the hood):
req.headers: Record<string, string>— plain-object snapshot (get) / replace (set) withHeaders | Record<string, string> | Iterable<[string, string]>.req.get(field: string): string | null— case-insensitive;'referer'/'referrer'are aliased.req.has(field: string): booleanreq.set(field: string, val: string): void/req.set(headers: Record<string, string>): voidreq.append(field: string, val: string): void/req.append(headers: Record<string, string>): voidreq.delete(field: string): voidreq.getSetCookie(): string[]— allSet-Cookievalues (use this to preserve multi-cookie semantics).
Client IP:
req.ip: string— first match acrossx-client-ip,x-forwarded-for,cf-connecting-ip,do-connecting-ip,fastly-client-ip,true-client-ip,x-real-ip,x-cluster-client-ip,x-forwarded,forwarded-for,forwarded,x-appengine-user-ip,cf-pseudo-ipv4. Returns''if none.req.ips: string[]— comma-splitx-forwarded-for(trimmed, empty values dropped); falls back to[req.ip]when that header is absent, or[]if no client IP can be resolved.
Body (each underlying stream can only be consumed once):
req.body: ReadableStream<Uint8Array> | null— raw stream (setter acceptsany).req.length: number | null— parsedContent-Length.req.type: string | null— media type without parameters (e.g.'application/json').req.text(): Promise<string>req.json<T = any>(): Promise<T>req.blob(): Promise<Blob>req.arrayBuffer(): Promise<ArrayBuffer>req.formData(): Promise<FormData>
Misc:
req.toJSON(): HoaRequestJson—{ method, url, headers }.
ctx.res (HoaResponse) API
Instance links: res.app: Hoa, res.ctx: HoaContext, res.req: HoaRequest.
Status:
res.status: number— defaults to404. Setting astatusEmptyMappingcode (204/205/304) clears body; non-integer values or codes outside100–1000throwTypeError.res.statusText: string— auto-synced withstatusunless you set it explicitly.
Body (polymorphic setter HoaResponseBody = string | Blob | ArrayBuffer | ArrayBufferView | ReadableStream | FormData | URLSearchParams | Response | Record<string, any> | null | undefined):
res.body: HoaResponseBody— autoContent-Typedetection:stringstarting with<→text/html, elsetext/plain.Blob→ usesblob.typeor falls back toapplication/octet-stream.ArrayBuffer/ TypedArray /ReadableStream→application/octet-stream.FormData→ left untyped (runtime adds multipart boundary).URLSearchParams→application/x-www-form-urlencoded.Response→ body, status, and headers copied through.- Any other object → JSON-serialized with
application/json. null/undefined→ when status is not already 204/205/304, ifContent-Typeisapplication/jsonthe body becomes the literal string'null'(headers/status untouched); otherwise status becomes204andContent-Type/Content-Length/Transfer-Encodingare removed. Settingnull(but notundefined) also marks the body as explicitly null so the final response serializesContent-Length: 0.- Setting a non-null body auto-sets
statusto200if not already explicit.
Content-Type / length:
res.type: string | null— get (parameters stripped) / set (accepts aliases:html,text,json,xml,md,form,pdf,zip,wasm,webmanifest,js,ts,png,jpg,jpeg,gif,svg,webp,avif,ico,mp3,wav,ogg,mp4,webm,avi,mov,woff,woff2,ttf,otf,bin).res.length: number | null— reads/setsContent-Length; computes from body when possible (setter ignored ifTransfer-Encodingpresent).
Headers (same semantics as req.*):
res.headers: Record<string, string>— snapshot (get) / replace (set).res.get(field: string): string | nullres.has(field: string): booleanres.set(field: string, val: string): void/res.set(headers: Record<string, string>): voidres.append(field: string, val: string): void/res.append(headers: Record<string, string>): voidres.delete(field: string): voidres.getSetCookie(): string[]
Redirects:
res.redirect(url: string): void— absolutehttp(s)://URLs normalized vianew URL(url),Locationpercent-safe-encoded, status becomes302unless already a redirect code (300/301/302/303/305/307/308),Content-Typeforced totext/plain, body set to"Redirecting to <url>.".res.back(alt?: string): void— same-originReferrerif safe, elsealtor/.
Misc:
res.toJSON(): HoaResponseJson—{ status, statusText, headers }.HEADrequests always return an empty body;Content-Lengthcomputed when missing.
Error handling
- Throw an
HttpErrorviactx.throw(status, message?, options?)orctx.assert(cond, status, message?). HttpError(import { HttpError } from 'hoa') fields:status/statusCode,expose(defaults tostatus < 500),headers(normalized viaHeaders), standardmessage/cause. Non-integer status throwsTypeError; statuses outside400–599are coerced to500. Defaultmessagefalls back tostatusTextMapping[status].- On a thrown error Hoa:
- Calls
app.onerror(err, ctx)(logs toconsole.errorunlesserr.status === 404,err.expose, orapp.silent === true). - Resets response headers, applies
err.headersif provided, forcesContent-Type: text/plain. - Sets status to
err.status || err.statusCode, or500if invalid. - Sets body to
err.expose ? err.message : statusTextMapping[status].
- Calls
- Override error behavior by subclassing or assigning:
app.onerror = (err, ctx) => { ... }, orapp.silent = trueto suppress logging.
Extensions
app.use(fn) |
app.extend(fn) |
|
|---|---|---|
| When it runs | Per request | Once at startup |
| Receives | (ctx, next) |
(app) |
| Purpose | Handle requests/responses | Extend app, patch prototypes, install middleware bundles |
| Async | Native | Synchronous only |
Use app.extend to add properties/methods to app, subclass HoaContext/HoaRequest/HoaResponse, or batch-install middlewares. Runs fn(app) immediately and returns app for chaining.
// Extend: add a helper method to the context prototype
app.extend((app) => {
app.HoaContext.prototype.json = function (data, status = 200) {
this.res.status = status
this.res.body = data
}
})
// Use: leverage the helper in request middleware
app.use(async (ctx) => {
ctx.json({ hello: 'world' })
})
You can also swap request/response classes wholesale before handling requests:
class MyContext extends HoaContext { ... }
app.HoaContext = MyContext
Named exports
import {
Hoa, // default export, also named
HoaContext,
HoaRequest,
HoaResponse,
HttpError,
compose, // compose(middlewares[]) → single middleware
statusTextMapping,
statusRedirectMapping,
statusEmptyMapping,
} from 'hoa'
compose validates input is an array, flattens nested arrays, requires every entry to be a function, and returns a single (ctx, next) dispatcher — use it to bundle middleware groups.
Ecosystem (official @hoajs/* middleware)
Prefer these over ad-hoc implementations or Koa/Hono packages. Install as npm i @hoajs/<name>.
- Adapter:
@hoajs/adapter(runtime/server adapters for Node, Lambda, etc.). - Routing:
@hoajs/router,@hoajs/tiny-router. - Body / parsing:
@hoajs/bodyparser(JSON / urlencoded / text),@hoajs/formidable(multipart),@hoajs/json(JSON formatting). - Auth:
@hoajs/basic-auth,@hoajs/jwt,@hoajs/csrf. - Security headers:
@hoajs/secure-headers(CSP, COEP/COOP/CORP, HSTS, Referrer-Policy, X-Frame-Options, Permission-Policy, …),@hoajs/cors,@hoajs/ip(IP allow/deny). - Validation:
@hoajs/zod,@hoajs/valibot,@hoajs/nana. - Caching / perf:
@hoajs/cache,@hoajs/compress,@hoajs/etag,@hoajs/timeout. - Rate limit:
@hoajs/cloudflare-rate-limit. - Cookies / headers / negotiation:
@hoajs/cookie,@hoajs/vary,@hoajs/language,@hoajs/method-override,@hoajs/powered-by. - Observability:
@hoajs/logger,@hoajs/request-id,@hoajs/response-time,@hoajs/sentry. - Views:
@hoajs/mustache. - Misc:
@hoajs/favicon,@hoajs/combine(compose sub-apps),@hoajs/context-storage(per-request async context).
Full docs: https://hoa-js.com/what-is-hoa.html
Conventions and gotchas
- No built-in router. Branch on
ctx.req.method+ctx.req.pathname, or install a router middleware (any(ctx, next) => {}function works). - Don't return a value from middleware expecting it to become the body. Always assign
ctx.res.body = .... Returning aResponsedoes nothing unless you set it asctx.res.body. - Body can be read only once. Choose one of
req.text()/req.json()/req.blob()/req.arrayBuffer()/req.formData()per request. ctx.res.statusdefaults to 404. Setting a non-null body promotes it to200automatically.ctx.res.body = null(vsundefined) is remembered as explicit-null so the final response emitsContent-Length: 0. IfContent-Typeis already JSON, the body becomes the literal string'null'; otherwise status becomes204and content headers are stripped.- HEAD responses always have an empty body; the framework fills
Content-Lengthwhen it can. compose(middlewares)requires an array; it flattens one level and every entry must be a function.- Errors must be real
Errorinstances (including cross-realm). Throwing non-errors is coerced toError("non-error thrown: …").
Recipes
JSON API with validation
app.use(async (ctx) => {
if (ctx.req.method !== 'POST') ctx.throw(405)
const input = await ctx.req.json().catch(() => ctx.throw(400, 'Invalid JSON'))
ctx.assert(input?.email, 422, 'email required', { expose: true })
ctx.res.status = 201
ctx.res.body = { id: crypto.randomUUID(), ...input } // JSON auto
})
Streaming response
app.use(async (ctx) => {
const stream = new ReadableStream({
async start (controller) {
controller.enqueue(new TextEncoder().encode('hello\n'))
controller.close()
}
})
ctx.res.type = 'text'
ctx.res.body = stream
})
Proxy pass-through
app.use(async (ctx) => {
const upstream = await fetch(`https://api.example.com${ctx.req.pathname}${ctx.req.search}`, {
method: ctx.req.method,
headers: ctx.req.headers,
body: ['GET', 'HEAD'].includes(ctx.req.method) ? undefined : ctx.req.body,
})
ctx.res.body = upstream // status + headers copied through
})
Redirect
app.use(async (ctx) => {
if (ctx.req.pathname === '/old') return ctx.res.redirect('/new')
})
References
- Source entry:
src/hoa.js - Context / Request / Response:
src/context.js,src/request.js,src/response.js - Middleware composition:
src/lib/compose.js - HttpError:
src/lib/http-error.js - Status / MIME / URL helpers:
src/lib/utils.js - Official site & docs: https://hoa-js.com
- LLM-oriented docs: https://hoa-js.com/llms-full.txt (full) · https://hoa-js.com/llms.txt (index)
- Repository: https://github.com/hoa-js/hoa