@azerothjs/http
v2.1.0
Published
AzerothJS server kernel - web-standard Request/Response HTTP stack (no third-party dependencies) where every request is a reactive root
Maintainers
Readme
@azerothjs/http
AzerothJS server kernel - web-standard Request/Response HTTP stack (no third-party dependencies) where every request is a reactive root
Part of AzerothJS - the fine-grained fullstack framework. This is the server half: a web-standard HTTP stack for Node >= 22, written from scratch with no third-party dependencies (it builds only on sibling @azerothjs/* packages - the reactive request root, schema validation, and the logger).
Install
[!NOTE] ESM-only, Node >= 22.
azerothjsis a REQUIRED peer: the kernel wires per-request reactive-root isolation into theazerothjsruntime, and both halves must share one copy of it, so it is installed alongside rather than bundled.
npm install @azerothjs/http azerothjsOverview
Handlers are (context) => Response on WHATWG types - one context carries the request, params, URL, and middleware additions. Node's http/http2
appear only in edge adapters, which buys three things at once: the same app serves on any
fetch-shaped runtime through toFetchHandler,
app.handle(new Request(...)) is the entire integration-testing story
(no sockets, no inject shim), and "headers already sent" or double-send are unrepresentable -
a handler returns exactly one Response.
app.handleitself returns the kernel's fast internal response, not a host-constructedResponse. It is the right thing to call in a test; a non-Node runtime needstoFetchHandler(app), which is one line. See the runtimes section below.
import { App, json, readJson } from '@azerothjs/http';
import { serve } from '@azerothjs/http/node';
const app = new App();
// One `context` per handler: the request, the params (typed from the pattern), the
// URL, and whatever middleware added. Return a Response - never mutate an argument.
app.get('/users/:id', (context) => json({ id: context.params.id }));
app.post('/users', async ({ request }) =>
{
const body = await readJson<{ name: string }>(request); // limits ON; bad input -> 400/413/415
return json({ created: body.name }, { status: 201 });
});
const served = await serve(app, { port: 3000 });
// served.shutdown(): graceful - drain in-flight responses up to the grace period, destroy
// what remains (held WebSockets exit in milliseconds, not after the grace), then close.Scaffold a complete runnable server like this - a custom error envelope, a scoped with
guard, and graceful shutdown, in one file with no build step - with
npm create azeroth@latest (the backend template).
Development
There is no build step in the dev loop. Node >= 22 runs TypeScript directly, so the whole story is one command:
node --watch src/main.ts # runs the server, restarts on any change it importsWire it as your dev script and that is the entire setup - no bundler, no
tsc -w in a second terminal. The dev run pulls in nothing; the only dev
dependencies are the two the type-check gate needs (typescript for tsc, and
@types/node so tsc can resolve the node:http types this package's adapters
reference):
// package.json
{
"type": "module",
"scripts": {
"dev": "node --watch src/main.ts",
"start": "node src/main.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^24.0.0"
}
}Node's native TypeScript is strip-only: it erases types and runs, it does not
check them. So type errors never block node --watch (you keep moving), and
tsc --noEmit stays a separate gate you run in CI or on save. A minimal
tsconfig.json for that gate, matching the .ts import extensions Node wants -
types: ["node"] is what silences the Cannot find name 'node:http' errors from
the adapter typings:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"types": ["node"]
}
}Relative imports carry the .ts extension (import { x } from './x.ts') - that
is what Node resolves at runtime, and the tsconfig above lets tsc accept it too.
When you do need a build step: only if something in your stack relies on
emitDecoratorMetadata - a decorator-driven ORM (TypeORM, etc.) reads type
metadata that strip-only execution does not emit. Then compile with tsc and run
node --watch dist/main.js. A plain @azerothjs/http app needs none of that.
The typed API layer - @azerothjs/http/api
The typed API system lives in this package as the ./api subpath: declare each
feature once with feature() - routes, schemas, guards, handlers, docs, colocated,
with routes.form/routes.raw/routes.stream making uploads, webhooks and SSE first-class -
register it (boundary validation, the typed reply channel), export OpenAPI from
the same declaration, and consume it in the browser through the fully inferred
client at @azerothjs/http/api/shared (typeof the server's features plus a
two-fields-per-route manifest; client-safe: it never drags server code into a
bundle). The full guide: docs/api.md.
Calling your own api in process
A page rendered on the server often needs what its own api already serves. That call does not
have to go out to a socket and back: when the render is answering a request that carries the
api, the same typed client dispatches through app.handle in process, with the visitor's
identity and none of the round trip.
// the same module the browser imports
export const client = createClient<typeof api>(manifest, { baseUrl: '/api' });
// a page loader - in the browser this is a fetch, on the server it is an in-process dispatch
{ path: '/guestbook', component: GuestBook, render: 'server', loader: () => client.guestbook.list() }Nothing is switched on: the client decides per call, and the rules are the same on every host.
- It needs an ambient page request. The in-process route exists inside a guard walk, a loader, or a per-request render on the GET path, and inside a page action BODY, which its POST's authorizing walk already gave the request and the bridge. A background work unit (ISR regeneration, cron, a ws handler) and a build-time prerender answer no request of their own, and a call from one of those takes the named throw below.
- A relative
baseUrlonly./apimeans "this origin", which in a browser islocationand on a server is the request being answered. An absolute one - a scheme in any case, or a leading//- says "go over the wire" and is never dispatched in process, and the dispatcher refuses a url on another authority by name whoever hands it one. - One
registerper App. The record a request root reads is per App and the last call wins, so mount every group in oneregistercall rather than in two. - An explicit
ClientOptions.fetchwins. It is chosen before either self-selected transport and keeps its own inert base (http://localhost), so an app-supplied transport is never handed an authority derived from the request being answered. - GET and HEAD only. A write dispatched in process would carry a forwarded cookie through the App's own guards while the edge pipeline around it never ran, so any other method is refused by name - at the call site, and again in the dispatcher, so the rule holds for anyone holding the request. Call a write from the browser, or over the wire with an absolute baseUrl.
- An allowlist carries identity, and nothing else.
forwardIdentitycopiescookie,authorizationandaccept-language, and only where the call has not set them itself, soClientOptions.headers: { authorization }is never overwritten by the visitor's.host,content-length, the hop-by-hop set,origin,referer,sec-fetch-*,x-forwarded-*, conditional and range headers, the csrf header andaccept-encodingare all refused by construction. The correlation id is stamped from the trusted request id rather than the inbound header, so sub-call log lines correlate without trusting a client-forged value. - A sub-call enters at
app.handle. It runs the App's own middleware, guards and validation but NOT an outerpipeline(app, requestId(), securityHeaders(), csrfCookie(), rateLimit()), which composes outside. Authorization that lives in a pipeline layer rather than in a guard is not applied to it.csrfProtectis not one of those layers - it is a guard by type, so every csrf spelling this package offers runs insideapp.handleand therefore on the in-process leg too. - Keep
rateLimitin the pipeline. Installed withapp.useit DOES run in process, and its default key throws 500rate-limit-key-unavailablethere, because an in-process request has no peer. That is also whyclientIpis not forwarded: copying it would count one visitor twice against an ip-keyed limit. - Compress on the way out. The client's response reader does not decode a content-encoding
and no
accept-encodingis forwarded, so a host that composescompressResponseINSIDE its App rather than around it breaks the in-process leg. - A real nested root. The sub-call gets its own scope, its own
createStorestate, its own cleanups and its own data cache from the handler's first await on, so an api middleware cannot write the page's request-scoped stores. It inherits the page request'sAbortSignal, so a disconnect reaches the handler and itsonWorkUnitCleanupruns. Where it declares aresponseTimeoutMsof its own, the deadline it keeps is the tighter of that and the enclosing root's REMAINING time, so a page half way through its budget cannot open a sub-call with a fresh full one; an App that declares none is unbounded here as it always was, and the page's own deadline is what answers the client. - Redirects are never followed, and the caps still hold. A 3xx surfaces as an
ApiError, so a forwarded cookie is never replayed to aLocation, andmaxResponseBytesbounds the body exactly as it does over the wire.
When a relative baseUrl finds neither an ambient request carrying the api nor a browser
location, the call throws an error naming every cause - no api registered on this App or any
enclosing root; no request root around the call; a call from outside a guard walk, a loader or a
per-request render; a build-time prerender; or an SSR bundle that resolved its own second copy of
azerothjs (check ssr.external). That replaces the TypeError: fetch failed against
http://localhost such a call used to produce.
forwardIdentity(from, to) is exported for a host building a dispatcher of its own: it applies
the allowlist above and returns the request to hand to app.handle.
import { forwardIdentity } from '@azerothjs/http';
const answer = await app.handle(forwardIdentity(pageRequest, new Request('https://app.example/api/me')));What is in the box
- Radix router - no regex, O(segments), route conflicts FAIL BOOT with a printable
table; 405 +
Allowdistinguished from 404; params typed from the pattern string. - One error path - every throw (sync or async) becomes a stable wire shape
{ error: { code, message, details? } }; 4xx messages cross the wire, 5xx internals stay home;ValidationError.details.fieldsis the exact mapazerothjs's formsetErrorconsumes. Speak your own envelope withnew App({ serializeError })- one place to reshape the body (route-miss 404s included), the same guarantees. - Bodies with limits on by default - JSON, urlencoded, raw, and a from-scratch
multipart/form-data parser (byte-exact, capped on three axes). Uploads beyond memory
use the pull-based twin:
streamMultipart(request)iterates parts as they arrive, each file payload a ReadableStream piped straight to its sink (disk, object storage). - A request is a reactive root -
createStorestate is request-isolated acrossawait(the same isolation SSR renders have), andonWorkUnitCleanupteardown ALWAYS runs: success, throw, or client disconnect. The disconnectAbortSignalrides onrequest.signal. - Typed middleware - a middleware takes the same
contextand RETURNS its additions (an object merged flat onto the context downstream), aResponseto short-circuit (guards), or nothing; ordering is lexical; nonext().app.use()accumulates the additions in the type system;app.with(mw)scopes a middleware to just the routes registered through it -app.with(requireAuth).get(...)- so auth/throttle live at registration, not as a repeated guard call inside every handler. - Server-Sent Events -
sse()produces exactly what the frontendstreamkeyword (createStream({ parse: 'sse' })) consumes: framed events, comment heartbeats,[DONE]. - The rest of a real server - cookies (loud
__Host-/SameSite validation), static files (traversal-safe, etags, 304s, single-rangeRange/If-Range206s for media seeking and download resume; the containment decision is exported ascontainedFilefor any server that maps a path to a file under a directory), negotiated compression (br/gzip/deflate, event streams and partial responses exempt), typed env config that reports every problem in ONE boot error, structured logging as an interface, graceful shutdown, HTTP/1.1 + h2c adapters.
Other runtimes - Bun, Deno, Workers, Vercel Edge
The . entry is a pure fetch-standard kernel (one sanctioned node: import, the
AsyncLocalStorage request-root seam, which Bun, Deno and workerd all implement). To serve on a
runtime other than Node, hand its own primitive a toFetchHandler:
import { App, json, toFetchHandler } from '@azerothjs/http';
const app = new App();
app.get('/healthz', () => json({ ok: true }));
Bun.serve({ fetch: toFetchHandler(app) });
Deno.serve(toFetchHandler(app));
export default { fetch: toFetchHandler(app) }; // Cloudflare Workers, Vercel EdgetoFetchHandler is required, not decoration. app.handle returns the kernel's
PayloadResponse - a measured optimisation that satisfies instanceof Response but is not
built by the host realm's constructor, which Bun.serve and Deno.serve both reject. The
adapter materialises kernel responses at that one boundary and forwards an already-native
response (a stream, an sse() body) untouched. Node's serve() never calls it.
One caveat worth knowing before you deploy: rateLimit keys on the client IP, and only the
Node adapter can expose a socket address. On any other runtime it refuses loudly rather than
silently sharing one global bucket, so give it the key that runtime does provide:
Bun.serve({ fetch: (request, server) => toFetchHandler(
pipeline(app, rateLimit({ limit: 100, windowMs: 60_000, key: () => server.requestIP(request)?.address ?? 'unknown' }))
)(request) });Verified by runtime-compat.spec.ts, which boots real Bun and Deno servers over real sockets.
Production hardening
Cross-cutting response concerns wrap the whole app as composable EDGE middleware - a
(next) => next decorator that returns new Response values, never mutating a channel.
pipeline() composes them into a WebHandler you hand straight to serve():
import { App, pipeline, requestId, securityHeaders, cors, rateLimit } from '@azerothjs/http';
import { serve, handleShutdownSignals } from '@azerothjs/http/node';
const app = new App();
// ... routes ...
const handler = pipeline(
app,
requestId(), // mints X-Request-Id (trustInbound honors one); rides into the logger
securityHeaders(), // nosniff, frame-options, referrer-policy, ... (opt-in HSTS/CSP)
cors({ origin: ['https://app.example'], credentials: true }),
rateLimit({ limit: 100, windowMs: 60_000 }) // 429 + Retry-After + RateLimit-* headers
);
const served = await serve(handler, {
port: 3000,
timeouts: { headersMs: 15_000, keepAliveMs: 5_000 } // slowloris + idle bounds, all overridable
});
handleShutdownSignals(served); // SIGTERM/SIGINT -> bounded drain (held sockets destroyed), then exitEvery piece is opt-in and tested through app.handle(new Request(...)) - no socket required.
clientIp(request, { trustProxy }) resolves the real address through an explicit trusted-proxy
boundary (the X-Forwarded-For spoofing footgun, closed), and rateLimit's RateStore interface
is the seam for a Redis-backed limiter across a fleet. The same boundary covers the URL:
serve(app, { trustProxy: true }) (or { proto: true } / { host: true } granularly) believes
X-Forwarded-Proto/-Host from a declared terminating proxy, so context.url carries the
client's real scheme and host - redirects, absolute links, and secure-cookie decisions stop
seeing the internal hop. Off by default: without a proxy those headers are caller-forgeable.
There is no hop count to configure for those two, however many proxies you stack: every
mainstream one (nginx, HAProxy, Traefik, ALB, Cloudflare) SETS proto and host rather than
appending, so the header carries one value and the value read is the last one written - which
also drops a client's prepended X-Forwarded-Host on the floor. X-Forwarded-For is the
header that does grow per hop, and that is where trustedHops applies (clientIp,
rateLimit).
csrfProtect rides the same declaration: its origin check compares the browser's Origin
against context.url.origin, so behind TLS termination an undeclared proxy leaves the URL on
http while every browser says https, and each same-origin POST is rejected as cross-origin
(the 403 names trustProxy when the mismatch is scheme-only) until the proxy is declared.
One rule matters more than the rest when you write a policy check by hand: decide on
context.path, not context.url.pathname. The router matches decoded, slash-collapsed segments,
so /%61dmin, //admin and /admin// are all the route /admin, while url.pathname keeps the
client's spelling. context.path is the path the router actually matched, which is what an auth
prefix check, a CSRF exemption list, a rate-limit bucket, and an audit line all need in order to
agree with the route that ran. Behind a proxy also declare trustProxy/trustedHops on
rateLimit, or its default key is the proxy's address and every client on earth shares one
bucket; on a fetch-hosted runtime there is no socket at all, so give the limiter an explicit
key.
Those timeouts bound the PEER. Nothing bounds YOUR handler, and the difference matters: a
handler awaiting a wedged upstream holds its socket until the process dies. new App({
responseTimeoutMs }) bounds how long a handler may take to produce a Response and answers 503
when it does not. It is opt-in and has no default, because only your application knows how long
its own code should take - and because the clock includes reading the request body, so a value
below requestMs (default 300000) would refuse slow uploads the adapter explicitly permits.
Be clear about what it buys, because it is less than it looks. A JavaScript promise cannot be cancelled from outside, so the handler runs on and keeps its memory, its pool slot, and its upstream connection until it settles by itself. The deadline frees the SOCKET and answers the client; it bounds your users' wait, not your server's resources. Under a sustained upstream stall the connection pool still exhausts - but sockets are released, clients get a 503 instead of hanging, and a health check fails fast enough to take the instance out of rotation. It does not bound a streaming body: the clock stops at the Response, so SSE and file serving are unaffected by design. One drain caveat: the 503 closes the response, so a graceful shutdown stops counting that request as in-flight and may exit while its handler is still running.
For a full deployment: timeouts also takes requestMs (whole-request bound for slow bodies)
and checkIntervalMs (how promptly a slow connection is reclaimed); new App({ observe:
logRequests(createLogger()) }) - the logger is @azerothjs/logger's - emits one JSON log line
per request with method, path, status,
duration, and the request id; expose a cheap GET /healthz returning 200 for orchestrator
probes; and enable HSTS via securityHeaders({ hsts }) only when TLS terminates in front - it
is emitted only over a connection proven secure.
Server actions
A server action is a POST-only, param-free route kind whose typed client surface is a directly-callable function. The wire behavior is exactly a JSON route - input validated into the 422 field map, output checked against the contract, guards composing through the ordinary chain - but the call site reads like the function it is:
// server
export const api = {
posts: feature('/posts', (routes) => ({
create: routes.with(csrfProtect(csrf)).action('/create',
{ input: postInput, output: post },
({ input }) => db.posts.create(input))
}))
};
// client - typed end to end from `typeof api`, zero server code in the bundle
const created = await client.posts.create({ title: 'hello' });A declared Date arrives client-side as its ISO string, and the call's return type says
so (Wire<T>). Validation failures land on a form with one call:
catch (error)
{
if (!applyFieldErrors(form, error))
{
form.setError('title', 'Could not reach the server - try again.');
}
}CSRF
Browser-facing mutations pair csrfCookie (edge middleware minting a READABLE token
cookie - readability is the point of double submit) with csrfProtect (a guard checking
Sec-Fetch-Site/Origin plus the mirrored x-azeroth-csrf header). The typed client mirrors
the cookie automatically on action calls. Non-browser callers hold no ambient cookies, so
they mint their own pair or use token auth without the guard:
const csrf: CsrfOptions = {}; // { secure: false } for plain-http dev
const handler = pipeline(app, requestId(), securityHeaders(), csrfCookie(csrf));
// then guard any mutating route: feature('/x', [csrfProtect(csrf)], ...) or routes.with(...)The decision rules, spelled out because "checks the Origin" leaves the interesting cases open:
| Request | Outcome |
| --- | --- |
| Sec-Fetch-Site: same-origin or none, token matches | allowed |
| Sec-Fetch-Site: cross-site or same-site | refused - same-site is a SIBLING subdomain, not this origin |
| Origin present and not this origin | refused, however the token looks |
| Origin: null with Sec-Fetch-Site: same-origin, token matches | allowed - see below |
| Origin: null with anything else, or with no Sec-Fetch-Site | refused |
| Origin absent, Sec-Fetch-Site absent, token matches | allowed - the non-browser lane |
| token missing, empty, short, or off by one character | refused |
| cookie present, header absent (or the reverse) | refused |
Safe methods (GET/HEAD/OPTIONS) skip the guard entirely, so a mutation must never live behind
one. allowedOrigins re-admits a named cross-origin caller; nothing else does, and the literal
null can never be one of those names - any page can blank its own Origin, so an entry for it
would admit every cross-site caller at once (cors() refuses the same string).
Why Origin: null is judged by the other header. A browser blanks the Origin on a
same-origin navigation POST when the page was served with Referrer-Policy: no-referrer - which
securityHeaders() sets by default - so a plain <form method="post"> arrives carrying a value
that says nothing. Sec-Fetch-Site is what tells that apart from a genuinely opaque origin, and a
page cannot forge it: Fetch reserves every sec- header name, so fetch, XMLHttpRequest and a
service worker all fail to set it, and a sandboxed frame, a data: document or a cross-site
redirect chain all arrive as cross-site.
One limit worth knowing: browsers send Sec-Fetch-* only to a potentially trustworthy origin. On
plain http to a non-loopback host (a LAN name, a container) there is no such header, so a
no-referrer form submit is refused there. Serve over https, or set a referrer policy that keeps
the origin.
Streaming and response-wrapping middleware
Edge middleware can turn a streaming response into a buffered one, and nothing on the wire
says so. A wrapper that reads the body and rebuilds still answers transfer-encoding: chunked
with no content-length, and response.body is still a ReadableStream - so neither the
client nor the application can tell. Only time-to-first-byte moves, measured here on a route
whose last chunk is 400 ms late:
| middleware shape | TTFB | streaming? |
|---|---|---|
| no middleware | 22 ms | yes |
| headers only (requestId, securityHeaders, csrfCookie) | 2 ms | yes |
| response.clone(), clone unread | 4 ms | yes |
| response.clone(), clone fully read | 2 ms | yes |
| compressResponse | 2 ms to first DECODABLE content | yes, but see below |
| await response.text() then rebuild | 407 ms | no |
| await response.arrayBuffer() then rebuild | 404 ms | no |
The rule is simply: reading the body buffers it; everything else does not. text(),
arrayBuffer(), json() and blob() must have the whole body before they resolve, so every
byte then waits for the slowest chunk - a ~200x TTFB regression on the measurement above.
Compression is measured at the decoder, not the socket. compressResponse pipes rather than
collects, so raw bytes leave early - but the compressor holds them in its window, and what
matters is when the client can DECODE the shell. Measured against a route holding its last chunk
for 300 ms, with no per-chunk flush: brotli produced no decodable content before the hold at any
size from 3 KB to 256 KB, and gzip only when the shell was both large and incompressible. The
shell reached the socket in 2 ms and painted at 300 ms, and no header said so. compressResponse
therefore flushes per chunk on a streamed response (one with no content-length), which puts
first decodable content at 1-14 ms across every size and both encodings. The flush costs about
9-20% of encoded size on real page HTML - roughly 80-110 bytes a page - and buffered responses
keep the tighter encoding, since they have nothing to gain.
clone() is the useful surprise: it tees, so an audit or logging sink can read a complete copy
without costing the client its first byte. Prefer it whenever you need to observe a body.
To transform streamed HTML, transform it INSIDE the render rather than around the response, or
skip the routes that stream. @azerothjs/kit marks a render: 'stream' response with
x-accel-buffering: no (its purpose is telling nginx not to buffer, and it doubles as the
signal that reading this body would defeat the point):
const brand = (next) => ({
handle: async (request) =>
{
const response = await next.handle(request);
// A streamed page is exactly the one you must not read here.
if (response.headers.get('x-accel-buffering') === 'no')
{
return response;
}
return new Response((await response.text()).replace('%TITLE%', title), response);
}
});The behaviour is pinned in tests/streaming-middleware-contract.spec.ts, including the case
proving a buffered response is indistinguishable by inspection.
The QUERY method (RFC 10008)
[!IMPORTANT] Experimental. RFC 10008 is not yet deployed internet reality - proxies, caches, and tooling may not recognize QUERY. The API is stable within 1.x but flagged until the RFC lands broadly.
For a read whose parameters are too large or too structured for a URL - a complex filter, a search document - a query string does not fit and a POST wrongly signals a state change. The QUERY method is the answer: SAFE and IDEMPOTENT like GET, but with a request body like POST, so responses can be cached and requests retried.
import { readJson, json } from '@azerothjs/http';
app.query('/products/search', async ({ request }) =>
{
const filter = await readJson(request); // Content-Type is enforced; a missing one is a 415
const results = await search(filter); // MUST NOT mutate state - that is what makes QUERY safe
return json({ results }, { headers: { 'content-location': '/products/search/results/abc' } });
});The radix router treats QUERY like any method: it appears in the Allow header of a 405, and a
handler MUST NOT mutate state. In a typed contract it is a first-class method whose input is the
query body:
search: query('/products/search', { input: FilterSchema, output: ResultsSchema })QUERY is new, so some intermediaries (older proxies, CDNs, browsers) may not pass it yet - verify
the path end to end for your deployment. On Node's own fetch/Request it works today, both as a
client and a server.
Performance
The hot path is built to stay allocation-light: the request and response are lazy shims over the Node objects, so headers and body are read on demand rather than eagerly copied, and the per-request reactive root is a scope entry, not a fresh runtime. That isolation is ON in the serving path - it is a property of the architecture, not a mode you trade away for speed.
Related
The server half of the AzerothJS monorepo. Related packages:
azerothjs (the reactive runtime this kernel shares as a peer),
@azerothjs/ws (WebSockets), @azerothjs/cron (scheduling), and
@azerothjs/logger (logging).
