Zum Inhalt springen
Deutsch

HTTP im Überblick

Das HTTP-Modul ist getrennt von den I/O-Broker-Actors — HTTP-Server passen nicht in die Form “eine Verbindung, viele Nachrichten”, deshalb bekommen sie eine eigene DSL und Backend- Abstraktion.

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);
// ... behandeln ...
return completeJson(201, { id: 'o-1' });
}),
),
),
);
const binding = await system.http(8080).bind(routes);
console.log(`gebunden auf ${binding.host}:${binding.port}`);

Drei Dinge passieren hier:

  • Die Route-DSL — path, get, post, concat komponieren einen Routenbaum. Typsicher; wird beim Bind zu einer flachen Liste kompiliert.
  • Der Marshaller — entity<T>(req) dekodiert den Request- Body je nach Content-Type; completeJson / completeText encoden die Response.
  • Der Shortcut — system.http(port).bind(routes) startet einen Server mit dem Standard-Fastify-Backend. Für ein anderes Backend übergibst Du backend: (siehe unten).
BausteinLebt inSeite
Routingactor-ts/http exportiert path, get, post, complete* usw.Route-DSL
Marshallingentity<T>(req)-Dekodierung + completeJson-Kodierung.Marshalling
BackendPluggable HTTP-Server — Fastify per Default, Hono, Express.Backends

Die DSL baut einen In-Memory-Routenbaum; system.http(...).bind(routes) flacht ihn ab und registriert alles beim gewählten Backend.

import { HonoBackend } from 'actor-ts/http';
// Default — keine Einrichtung nötig; Fastify ist eine Hard-Dependency:
await system.http(8080).bind(routes);
// Explizite Wahl (Express / Hono sind Peer-Deps; Opt-in):
await system.http(8080, { backend: new HonoBackend() }).bind(routes);
// Volle Kontrolle — die Extension-Surface bleibt erhalten:
import { HttpExtensionId } from 'actor-ts/http';
await system.extension(HttpExtensionId)
.newServerAt('0.0.0.0', 8080)
.useBackend(new HonoBackend())
.bind(routes);
BackendRuntime-EignungWann
FastifyBackend (Default)Bun, NodeProduktions-Default — Fastify ist das am stärksten kampferprobte.
ExpressBackendNode, BunDas bestehende Express-Middleware-Ökosystem passt sauber rein.
HonoBackendBun, Node, DenoSchlank + edge-tauglich; die Bun- und Deno-nativeste Option.

Alle drei implementieren dasselbe HttpServerBackend-Interface — die Route-DSL kompiliert identisch; das Backend besitzt nur die Listen-/Dispatch-Schleife.

Siehe die Backend-Seiten für die Konfigurationsoptionen, die jedes akzeptiert.

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

Der gemeinsame Client umschließt das native fetch der Runtime. Funktioniert identisch auf Bun, Node und Deno (jede unterstützte Runtime hat fetch eingebaut).

Eine Antwort wird im Speicher gepuffert, bevor du sie siehst — deshalb sind die drei Dinge, die die Gegenstelle kontrolliert, standardmäßig begrenzt:

SchrankeDefaultOverride pro Request
Deadline30 stimeoutMs — 0 nimmt einen Aufruf ganz heraus
Response-Body8 MiBmaxResponseBytes
Redirect-Hops5, gefolgtredirect, maxRedirects

Jenseits der Obergrenze wird der Request abgebrochen, während der Body noch eintrifft, und der Aufruf wirft HttpResponseTooLargeError — eine übergroße oder endlose Antwort alloziert also nie zu Ende. Jenseits der Deadline bricht er genauso ab wie bisher schon bei explizitem timeoutMs; neu ist nur, dass ein Aufruf ohne genannte Deadline jetzt eine hat.

Jeder Override wird beim Aufruf geprüft, nicht nur beim Bau des Clients. Jede Schranke wird über einen Vergleich durchgesetzt, und ein NaN verliert jeden Vergleich lautlos: Ein timeoutMs von NaN scharft keinen Timer, ein maxResponseBytes von Infinity löst nie aus, und beides meldet nichts — die Schranke hört einfach auf zu existieren. Ein Wert außerhalb des Wertebereichs wirft deshalb OptionsError, bevor ein Socket geöffnet wird. timeoutMs: 0 bleibt der eine legitime Weg zu keiner Deadline und maxRedirects: 0 der Weg, den ersten Redirect abzulehnen.

Hebe sie pro Aufruf an, wenn ein einzelner Endpunkt die Ausnahme ist:

const report = await system.extension(HttpExtensionId).client.get(
'https://api.example.com/exports/daily',
{ maxResponseBytes: 64 * 1024 * 1024, timeoutMs: 120_000 },
);

Oder gib einer ganzen Integration ihren eigenen Client, statt den zu lockern, den sich alle Aktoren im System teilen:

import { HttpClientOptions } from 'actor-ts/http';
const exportClientOptions = HttpClientOptions.create()
.withMaxResponseBytes(64 * 1024 * 1024)
.withDefaultTimeoutMs(120_000);
const exportClient = system.extension(HttpExtensionId).newClient(exportClientOptions);

Oder verschiebe die Zahlen, die das ganze Deployment erbt, ohne Code anzufassen — unter actor-ts.http.client:

actor-ts.http.client {
maxResponseBytes = 32M
defaultTimeoutMs = 60s
}

Die Reihenfolge ist die übliche: Ein Request schlägt den Client, auf dem er gemacht wurde, der schlägt HOCON, das schlägt die eingebauten Defaults. Der gemeinsame HttpExtensionId-Client liest diesen Block ebenfalls — den Boden anzuheben heißt also nicht, ihn aufzugeben. Ein direkt gebautes new HttpClient() hat kein System, aus dem es Config lesen könnte, und behält die eingebauten Zahlen.

Der Client folgt Redirects selbst, statt sie der Plattform zu überlassen — so fallen die Entscheidungen zwischen den Hops:

redirectVerhalten bei einem 3xx mit Location
'follow' (Default)Folgen, bis zu maxRedirects Hops
'error'HttpRedirectError; das Ziel wird nie kontaktiert
'manual'Den 3xx unverändert zurückgeben, Location in headers lesbar

Gefolgt wird höchstens 5 Hops weit (die Plattform erlaubt 20), und ein gefolgter Hop ist kein blindes Wiederholen des ursprünglichen Requests:

  • Ein Hop, der die Origin wechselt, verliert authorization, cookie und proxy-authorization — ein Redirect kann ein Bearer-Token also nicht zu einem Host tragen, den du nie ausgewählt hast.
  • Ein 303 — und ein 301/302 nach einem POST — läuft als GET weiter, ohne Body und ohne dessen content-type, genau wie die Fetch-Spezifikation es vorschreibt; ein Schreibzugriff wird so nie gegen einen zweiten Host wiederholt.
  • Ein Ziel, das nicht http: oder https: ist, wird rundheraus abgelehnt.
  • Deadline und Byte-Obergrenze gelten kumulativ über die ganze Kette, nicht pro Hop; Bodies von Zwischen-3xx werden verworfen.

Abgelehnt wird, bevor der Hop überhaupt abgesetzt wird — darum geht es: Eine Prüfung, wo eine Kette gelandet ist, hat jeden Request darin bereits gestellt, und genau das Absetzen des Requests ist der Seiteneffekt, den es zu verhindern gilt.

const webhookClientOptions = HttpClientOptions.create()
.withRedirect('error')
.withMaxResponseBytes(256 * 1024);
const webhookClient = system.extension(HttpExtensionId).newClient(webhookClientOptions);

response.url nennt den Hop, der tatsächlich geantwortet hat — darauf prüfst du, wenn es darauf ankommt, welcher Host die Bytes geliefert hat.

Für aufwendigere Client-Muster (Retries, Caching) wickle den Client in einen Retry- + Circuit-Breaker-Aufruf.

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

Das Framework liefert drei:

MiddlewareWas sie tut
Response-Cache (cached)Cached die Antwort eines Handlers, key’d über eine key(req)-Funktion.
Rate-Limit (rateLimit)Per-Key-Request-Limiter mit Fixed-Window-Counter im Cache.
Idempotency-Key (idempotent)Dedupliziert Writes anhand eines Idempotency-Key-Headers.

Jede ist ein Handler-Wrapper — du rufst sie mit den Optionen auf und übergibst der zurückgegebenen Funktion den Handler, der geschützt werden soll. Kombiniere durch Verschachteln auf Handler-Ebene. Siehe die Middleware-Seiten.

Der Routen-Handler gibt einen Promise<HttpResponse> zurück — darin kannst du frei Actor-Calls awaiten:

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);
}),
),
);

Das ist das häufige Muster — HTTP-Handler sind ein dünner Adapter: Request parsen, einen Actor fragen, die Antwort marshallen. Halte Business-Logik in Actors; HTTP-Handler bleiben kurz.

Die HttpExtension-API-Referenz deckt die volle Oberfläche ab.