HTTP overview
Это содержимое пока не доступно на вашем языке.
The HTTP module is separate from the I/O broker actors — HTTP servers don’t fit the “one connection many messages” shape, so they get their own DSL and backend abstraction.
import { ActorSystem } from 'actor-ts';import { path, get, post, completeJson, entity, concat } from 'actor-ts/http';
const system = ActorSystem.create('my-app');
const routes = path('api', path('orders', concat( get(async () => completeJson(200, { orders: [] })), post(async (req) => { const order = entity<NewOrder>(req); // ... handle ... return completeJson(201, { id: 'o-1' }); }), ), ),);
const binding = await system.http(8080).bind(routes);console.log(`bound on ${binding.host}:${binding.port}`);Three things going on:
- The route DSL —
path,get,post,concatcompose a tree of routes. Type-safe; compiles to a flat list at bind time. - The marshaller —
entity<T>(req)decodes the request body by Content-Type;completeJson/completeTextencode the response. - The shortcut —
system.http(port).bind(routes)starts a server with the default Fastify backend. For a different backend, passbackend:(see below).
The three pieces
Section titled “The three pieces”| Piece | Lives in | Page |
|---|---|---|
| Routing | actor-ts/http exports path, get, post, complete*, etc. | Route DSL |
| Marshalling | entity<T>(req) decode + completeJson encode. | Marshalling |
| Backend | Pluggable HTTP server — Fastify by default, Hono, Express. | Backends |
The DSL builds an in-memory route tree; system.http(...).bind(routes)
flattens it and registers everything with the chosen backend.
The backends
Section titled “The backends”import { HonoBackend } from 'actor-ts/http';
// Default — no setup needed; Fastify is a hard dependency:await system.http(8080).bind(routes);
// Explicit override (Express / Hono are peer-deps; opt-in):await system.http(8080, { backend: new HonoBackend() }).bind(routes);
// For full control, the underlying extension surface is still there:import { HttpExtensionId } from 'actor-ts/http';await system.extension(HttpExtensionId) .newServerAt('0.0.0.0', 8080) .useBackend(new HonoBackend()) .bind(routes);| Backend | Runtime fit | When |
|---|---|---|
FastifyBackend (default) | Bun, Node | Production default — Fastify is the most-battle-tested. |
ExpressBackend | Node, Bun | Existing Express middleware ecosystem fits cleanly. |
HonoBackend | Bun, Node, Deno | Tiny + edge-friendly; the most Bun-and-Deno-native option. |
All three implement the same HttpServerBackend interface — the
route DSL compiles identically; the backend just owns the
listen/dispatch loop.
See the backend pages for the configuration options each accepts.
The HTTP client
Section titled “The HTTP client”const response = await http.singleRequest({ method: 'POST', url: 'https://api.example.com/orders', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sku: 'book-1' }),});
console.log(response.status, response.body);The shared client wraps the runtime’s native fetch. Works
identically on Bun, Node, and Deno (every supported runtime
has fetch built in).
Bounds every call carries
Section titled “Bounds every call carries”A response is buffered in memory before you see it, so the three things the remote peer controls are bounded by default:
| Bound | Default | Per-request override |
|---|---|---|
| Deadline | 30 s | timeoutMs — 0 opts one call out entirely |
| Response body | 8 MiB | maxResponseBytes |
| Redirect hops | 5, followed | redirect, maxRedirects |
Past the ceiling the request is aborted while the body is still
arriving and the call throws HttpResponseTooLargeError, so an
oversized or endless response never finishes allocating. Past the
deadline it aborts exactly as an explicit timeoutMs always did —
what changed is that a call naming no deadline now has one.
Each override is checked when the call is made, not only when the
client is built. Every bound is enforced by a comparison, and a
NaN loses a comparison silently: a timeoutMs of NaN arms no
timer, a maxResponseBytes of Infinity never trips, and neither
raises anything — the bound just stops existing. So an
out-of-domain value throws OptionsError before a socket is
opened. timeoutMs: 0 remains the one legitimate way to have no
deadline, and maxRedirects: 0 the way to refuse the first
redirect.
Raise them per call when a single endpoint is the exception:
const report = await system.extension(HttpExtensionId).client.get( 'https://api.example.com/exports/daily', { maxResponseBytes: 64 * 1024 * 1024, timeoutMs: 120_000 },);Or give a whole integration its own client, instead of loosening the one every actor in the system shares:
import { HttpClientOptions } from 'actor-ts/http';
const exportClientOptions = HttpClientOptions.create() .withMaxResponseBytes(64 * 1024 * 1024) .withDefaultTimeoutMs(120_000);
const exportClient = system.extension(HttpExtensionId).newClient(exportClientOptions);Or move the numbers the whole deployment inherits, without touching
code, under actor-ts.http.client:
actor-ts.http.client { maxResponseBytes = 32M defaultTimeoutMs = 60s}Precedence is the usual one — a request beats the client it was made
on, which beats HOCON, which beats the built-in defaults. The shared
HttpExtensionId client reads this block too, so raising the floor
does not mean abandoning it. A new HttpClient() built directly, with
no system to read config from, keeps the built-in numbers.
Where a redirect may take you
Section titled “Where a redirect may take you”The client follows redirects itself rather than handing them to the platform, so the decisions happen between hops:
redirect | What happens on a 3xx with a Location |
|---|---|
'follow' (default) | Chase it, up to maxRedirects hops |
'error' | Throw HttpRedirectError; the target is never contacted |
'manual' | Return the 3xx as-is, Location readable in headers |
Following is bounded at 5 hops (the platform allows 20), and a followed hop is not a blind replay of the original request:
- A hop that crosses origins loses
authorization,cookieandproxy-authorization, so a redirect cannot walk a bearer token to a host you never chose. - A
303— and a301/302after aPOST— continues as aGETwith the body and itscontent-typedropped, matching the Fetch spec, so a write is never replayed against a second host. - A target that is not
http:orhttps:is refused outright. - The deadline and the byte ceiling are cumulative over the whole chain, not per hop; intermediate 3xx bodies are discarded.
Refusals happen before the hop is issued — that is the point. A check on where a chain ended up has already made every request in it, and making the request is the side effect worth preventing.
const webhookClientOptions = HttpClientOptions.create() .withRedirect('error') .withMaxResponseBytes(256 * 1024);
const webhookClient = system.extension(HttpExtensionId).newClient(webhookClientOptions);response.url names the hop that actually answered, which is what
to assert on when it matters which host served the bytes.
For more elaborate client patterns (retries, caching), wrap the client in a retry + circuit-breaker call.
Middleware
Section titled “Middleware”import { cached, get, path, rateLimit } from 'actor-ts/http';import { InMemoryCache } from 'actor-ts/cache';
// One cache per middleware — never one shared instance (see below).const limiterCache = new InMemoryCache();const responseCache = new InMemoryCache();
const limited = rateLimit({ cache: limiterCache, windowMs: 1_000, max: 100, key: (request) => request.remoteAddress ?? 'unknown',});
const readThrough = cached({ cache: responseCache, ttlMs: 30_000, key: (request) => request.path,});
const routes = path('api', get(limited(readThrough(apiHandler))));The framework ships three:
| Middleware | What it does |
|---|---|
| Response cache | Caches a handler’s response keyed by a user-supplied key(request) function. |
| Rate limit | Per-IP / per-key fixed-window rate limiter. |
| Idempotency key | De-duplicates writes based on an Idempotency-Key header. |
Each is a handler wrapper — you call it with the options and hand the returned function the handler you want to protect. Compose by nesting at the handler level. See middleware pages.
Connecting HTTP to actors
Section titled “Connecting HTTP to actors”The route handler returns a Promise<HttpResponse> — you can
freely await actor calls inside:
const routes = path('orders', path(':id', get(async (req) => { const id = req.path.split('/').pop(); const order = await orderRegistry.ask({ kind: 'get', id }, 5_000,); return completeJson(200, order); }), ),);This is the common pattern — HTTP handlers act as a thin adapter: parse the request, ask an actor, marshal the reply. Keep business logic in actors; HTTP handlers stay short.
Where to next
Section titled “Where to next”- Route DSL — the full DSL:
path,get/post/put/etc.,concat,complete*,redirect,reject. - Marshalling —
entity<T>, Content-Type-driven decoding, response encoding. - Fastify backend — default backend’s configuration.
- Hono backend / Express backend — alternatives.
- Response cache middleware — caching for GET responses.
- Rate limit middleware — per-key rate limiting.
- Idempotency-key middleware — write-deduplication for mutations.
The HttpExtension API
reference covers the full surface.
