Marshalling
Zwei Hälften der Marshalling-Geschichte:
- Request-Seite — den rohen Byte-Body in einen typisierten
Wert dekodieren basierend auf
Content-Type. - Response-Seite — den Wert zurück in Bytes encoden basierend
auf dem
Accept-Header des Clients.
Bei den meisten Apps sind beide Seiten JSON, und du denkst kaum darüber nach. Aber das Framework unterstützt JSON + CBOR eingebaut, und der Extension-Point ist offen für eigene Serializer.
Den Request-Body dekodieren
Abschnitt betitelt „Den Request-Body dekodieren“import { entity } from 'actor-ts/http';
type NewOrder = { sku: string; quantity: number; };
post(async (req) => { const order = entity<NewOrder>(req); // ↑ wirft HTTP 400, wenn der Body fehlt oder malformed ist // ↑ wirft HTTP 415, wenn nichts diesen Content-Type dekodiert // ↑ wählt den Serializer basierend auf Content-Type: // application/json → JsonSerializer // application/cbor → CborSerializer // application/x-www-form-urlencoded → FormUrlEncodedSerializer // gar kein Content-Type → JsonSerializer // ...});Der entity<T>(req)-Aufruf:
- Liest
req.headers['content-type']und reduziert ihn auf den reinen Media-Type — ausapplication/json; charset=utf-8wirdapplication/json. - Wählt den passenden Serializer, oder wirft
HttpError(415, ...). - Dekodiert
req.body(eineUint8Array). - Gibt den dekodierten Wert gecastet auf
Tzurück.
Wenn der Body fehlt oder malformed ist, wirft entity einen
HttpError(400, ...) — das Framework fängt ihn und produziert
eine 400-Response mit der Fehlermeldung.
Nicht unterstützte Media-Types
Abschnitt betitelt „Nicht unterstützte Media-Types“Ein Content-Type, den nichts dekodiert — text/xml,
application/pdf, multipart/form-data — ergibt eine 415,
nicht den Versuch, den Body als JSON zu parsen:
{ "error": "Unsupported Content-Type: text/xml", "accepted": [ "application/json", "application/cbor", "application/x-cbor", "application/x-www-form-urlencoded" ]}Dieselbe Liste geht als Accept-Response-Header raus — genau das,
was RFC 9110 §12.5.1
in einer Response bedeutet: was beim nächsten Mal zu senden ist.
Zwei bewusste Ausnahmen von der Ablehnung:
- Ein fehlender Content-Type wird weiterhin als JSON dekodiert.
Wenn der Sender nichts angibt, lässt RFC 9110 dem Empfänger die
Freiheit zu raten — und
HttpClientsetzt den Header nur für Objekt-Bodies, ein String-Body geht also ohne ihn raus. - Structured-Syntax-Suffixe folgen ihrem Basistyp.
application/vnd.api+json,application/merge-patch+jsonundapplication/problem+jsonwerden alle als JSON dekodiert, gemäß RFC 6839.
Beachte, dass gegen den geparsten Media-Type gematcht wird: einen
bekannten Typ in einem Parameter zu nennen — multipart/form-data; boundary=----application/cbor — wählt diesen Serializer nicht aus.
Form-Bodies
Abschnitt betitelt „Form-Bodies“application/x-www-form-urlencoded wird zu einem flachen Record
aus Strings dekodiert, ein HTML-<form> postet also direkt in
einen Handler:
post(async (req) => { const form = entity<{ name: string; tags: string | string[] }>(req); // 'name=Ada&tags=a&tags=b' → { name: 'Ada', tags: ['a', 'b'] }});Jeder Wert ist ein String — Form-Encoding transportiert keine
Typen. Ein wiederholtes Feld weitet sich zu einem Array, genau wie
req.query einen wiederholten Query-Parameter schon behandelt.
Verschachtelung ist in diesem Format nicht ausdrückbar; nimm JSON,
wenn du sie brauchst.
Type-Checking
Abschnitt betitelt „Type-Checking“Der Cast ist vertrauensbasiert. entity<NewOrder>(req)
validiert nicht, dass der dekodierte Wert zu NewOrder passt
— er castet das Ergebnis einfach. Für Laufzeit-Validierung leg
einen Validator obendrauf:
import { z } from 'zod';
const NewOrderSchema = z.object({ sku: z.string(), quantity: z.number().int().positive(),});
post(async (req) => { const raw = entity<unknown>(req); const parsed = NewOrderSchema.safeParse(raw); if (!parsed.success) reject(Status.BadRequest, 'invalid order shape'); const order = parsed.data; // typisiert + validiert // ...});Das ist das Standard-TS-Muster — das Framework bundelt keinen Validator, aber jeder gängige (zod, valibot, io-ts usw.) lässt sich sauber einbauen.
Den Response-Body encoden
Abschnitt betitelt „Den Response-Body encoden“Der einfachste Weg ist completeJson:
return completeJson(200, { id: 'o-1', status: 'created' });Der Body wird beim Schreiben JSON-serialisiert; Content-Type
ist auf application/json; charset=utf-8 gesetzt. Nimm das für
den 95-%-Fall.
Für content-negotiated Responses nimm marshal:
import { marshal } from 'actor-ts/http';
get(async (req) => { const data = await registry.ask({ kind: 'list' }); const { body, contentType } = marshal(req, data); return { status: 200, body, contentType, headers: {} };});marshal(req, value) wählt einen Serializer aus
req.headers['accept']:
application/cbor(oderapplication/x-cbor) →CborSerializerapplication/json(oder*/*, oder ungesetzt) →JsonSerializer
Gibt { body: Uint8Array; contentType: string } zurück — reich
das an deine HttpResponse durch.
Nimm marshal, wenn:
- Verschiedene Clients verschiedene Formate wollen. IoT-Clients auf CBOR, Browser auf JSON — derselbe Handler bedient beide.
- Bandbreite zählt und CBORs Kompaktheit den Kompromiss wert ist.
Sonst bleib bei completeJson.
Eingebaute Serializer
Abschnitt betitelt „Eingebaute Serializer“JsonSerializer
Abschnitt betitelt „JsonSerializer“Standard-JSON über JSON.stringify / JSON.parse. Bytes sind
UTF-8.
import { JsonSerializer } from 'actor-ts/serialization';
const ser = new JsonSerializer();const bytes = ser.toBinary({ hello: 'world' });const back = ser.fromBinary(bytes, '');CborSerializer
Abschnitt betitelt „CborSerializer“CBOR (RFC 8949) — ein Binärformat, das für verschachtelte Daten
kompakter ist als JSON, mit erstklassigem Binär-Support (kein
Base64-Wrapping für Uint8Array-Felder).
import { CborSerializer } from 'actor-ts/serialization';
const ser = new CborSerializer();const bytes = ser.toBinary({ image: new Uint8Array([0xff, 0xd8, ...]) });Nützlich, wenn:
- Payloads Binärdaten enthalten (Bilder, Audio-Chunks).
- Bandbreite zählt (CBOR ist für dieselben Daten typischerweise 20–40 % kleiner als JSON).
- Beide Seiten sich auf CBOR einigen — JSON ist für menschenlesbar zu debuggende APIs trotzdem die bessere Wahl.
Eigene Serializer
Abschnitt betitelt „Eigene Serializer“Das Serializer-Interface ist klein:
interface Serializer { toBinary(value: unknown): Uint8Array; fromBinary(bytes: Uint8Array, manifest: string): unknown;}Du kannst deinen eigenen einklinken (Protobuf, MessagePack usw.),
indem du dieses Interface implementierst — aber das
Media-Type-zu-Serializer-Mapping des HTTP-Moduls ist derzeit eine
private Konstante. Für eigene Serializer in HTTP-Handlern mach die
Auswahl manuell. Ein Typ, den das eingebaute Mapping nicht kennt,
ergibt eine 415 — verzweige also vor dem entity-Aufruf:
import { ProtobufSerializer } from './my-protobuf';
post(async (req) => { const mediaType = (req.headers['content-type'] ?? '').split(';')[0]!.trim(); let value: MyMessage; if (mediaType === 'application/x-protobuf') { value = new ProtobufSerializer().fromBinary(req.body!, '') as MyMessage; } else { value = entity<MyMessage>(req); // JSON / CBOR / Form, sonst 415 } // ...});Für den Cluster-Wire (zwischen Nodes, nicht HTTP) gibt es keinen
entsprechenden Hook: er rahmt immer den getaggten JSON-Tree und
konsultiert die SerializationExtension nie — siehe
Serialization-Übersicht.
Fehler beim Encoden
Abschnitt betitelt „Fehler beim Encoden“import { reject, Status } from 'actor-ts/http';
post(async (req) => { const order = entity<NewOrder>(req); // ↑ wirft HttpError(400, "Cannot decode body: ...") // automatisch gefangen und in eine 400-Response konvertiert});Das Framework fängt HttpError und produziert eine strukturierte
Response:
{ "error": "Cannot decode body: Unexpected token } in JSON at position 42"}Für spezifischere Fehler-Responses reject nach manuellem
Decode:
const raw = entity<unknown>(req);const parsed = ValidationSchema.safeParse(raw);if (!parsed.success) { reject(Status.UnprocessableEntity, 'invalid shape', { issues: parsed.error.issues, });}Das extra-Argument landet als Top-Level-Felder im JSON-
Response-Body, neben error: 'invalid shape'.
Häufige Muster
Abschnitt betitelt „Häufige Muster“Leere Bodies
Abschnitt betitelt „Leere Bodies“del(async (req) => { // Kein Body zu dekodieren — einfach die Arbeit machen und 204 zurückgeben. await registry.ask({ kind: 'delete', id: req.path.split('/').pop(), replyTo: ... }); return complete(Status.NoContent);});entity wirft, wenn der Body leer ist — ruf es nicht auf, wenn
du keinen Body erwartest.
Die akzeptierten Content-Types einschränken
Abschnitt betitelt „Die akzeptierten Content-Types einschränken“entity antwortet bereits mit 415 auf alles außerhalb des
eingebauten Sets — dafür brauchst du keinen Guard mehr. Eine
Prüfung lohnt sich weiterhin, wenn ein Endpunkt weniger
akzeptiert als das Framework — eine reine JSON-API, die CBOR- und
Form-Bodies ablehnen soll:
post(async (req) => { const mediaType = (req.headers['content-type'] ?? '').split(';')[0]!.trim(); if (mediaType !== '' && mediaType !== 'application/json') { reject(Status.UnsupportedMediaType, 'expected JSON'); } const order = entity<NewOrder>(req); // ...});Beachte das !== '': ein Request ganz ohne Content-Type wird als
JSON dekodiert statt abgelehnt — ein Guard, der den leeren Fall
vergisst, weist also Clients ab, die den Header nie setzen.
Streaming-Responses
Abschnitt betitelt „Streaming-Responses“HttpResponse.body akzeptiert einen Web-ReadableStream<Uint8Array>,
und alle drei Backends streamen ihn direkt zum Client, ohne ihn im
Speicher zu puffern — Fastify reicht den Stream an reply.send,
Express leitet ihn per Readable.fromWeb weiter, und Hono gibt ihn
als Response-Body zurück. Gib einen direkt aus einem Handler
zurück:
get(async (req) => { const rows = streamReport(); // ReadableStream<Uint8Array> return complete(200, rows, { 'content-type': 'text/csv' });});Ohne expliziten Content-Type fällt ein Stream auf
application/octet-stream zurück. Streams sind einmalig; leite
eine streamende Response daher nicht durch die Caching-Middleware.
Für SSE nimm den SseActor.
Setze Content-Length selbst, wenn du sie kennst. Ein Backend
leitet die Länge aus einem Uint8Array-Body ab, hat bei einem Stream
aber nichts zu messen — ohne den Header geht die Response also
chunked hinaus. Fastify und Express übernehmen eine gesetzte Länge;
Hono gibt seine Response an den Server der Runtime, und
Bun.serve framet einen Stream ohnehin chunked (node:http und
Deno.serve behalten sie).
Statische Dateien machen das für dich.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- HTTP-Übersicht — das große Bild: Backends, Routing, Middleware.
- Route-DSL —
complete*,reject, volle DSL-Oberfläche. - Serialization-Übersicht — die framework-interne Serialisierungsschicht für das Cluster- Wire-Format.
