Ir al contenido
Español

HTTP overview

Esta página aún no está disponible en tu idioma.

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, concat compose 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 / completeText encode the response.
  • The shortcut — system.http(port).bind(routes) starts a server with the default Fastify backend. For a different backend, pass backend: (see below).
PieceLives inPage
Routingactor-ts/http exports path, get, post, complete*, etc.Route DSL
Marshallingentity<T>(req) decode + completeJson encode.Marshalling
BackendPluggable 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.

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);
BackendRuntime fitWhen
FastifyBackend (default)Bun, NodeProduction default — Fastify is the most-battle-tested.
ExpressBackendNode, BunExisting Express middleware ecosystem fits cleanly.
HonoBackendBun, Node, DenoTiny + 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.

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).

A response is buffered in memory before you see it, so the three things the remote peer controls are bounded by default:

BoundDefaultPer-request override
Deadline30 stimeoutMs — 0 opts one call out entirely
Response body8 MiBmaxResponseBytes
Redirect hops5, followedredirect, 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.

The client follows redirects itself rather than handing them to the platform, so the decisions happen between hops:

redirectWhat 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, cookie and proxy-authorization, so a redirect cannot walk a bearer token to a host you never chose.
  • A 303 — and a 301/302 after a POST — continues as a GET with the body and its content-type dropped, matching the Fetch spec, so a write is never replayed against a second host.
  • A target that is not http: or https: 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.

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:

MiddlewareWhat it does
Response cacheCaches a handler’s response keyed by a user-supplied key(request) function.
Rate limitPer-IP / per-key fixed-window rate limiter.
Idempotency keyDe-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.

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.

The HttpExtension API reference covers the full surface.