Ir al contenido
Español

Marshalling

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

Two halves to the marshalling story:

  • Request side — decode the raw byte body into a typed value based on Content-Type.
  • Response side — encode the value back to bytes based on the client’s Accept header.

For most apps both sides are JSON, and you barely think about this. But the framework supports JSON + CBOR built-in, and the extension point is open for custom serializers.

import { entity } from 'actor-ts/http';
type NewOrder = { sku: string; quantity: number; };
post(async (req) => {
const order = entity<NewOrder>(req);
// ↑ throws HTTP 400 if body is missing or malformed
// ↑ throws HTTP 415 if the Content-Type is one nothing decodes
// ↑ chooses serializer based on Content-Type:
// application/json → JsonSerializer
// application/cbor → CborSerializer
// application/x-www-form-urlencoded → FormUrlEncodedSerializer
// no Content-Type at all → JsonSerializer
// ...
});

The entity<T>(req) call:

  1. Reads req.headers['content-type'] and reduces it to the bare media type — application/json; charset=utf-8 becomes application/json.
  2. Picks the matching serializer, or throws HttpError(415, ...).
  3. Decodes req.body (a Uint8Array).
  4. Returns the decoded value cast to T.

If the body is missing or malformed, entity throws an HttpError(400, ...) — the framework catches it and produces a 400 response with the error message.

A Content-Type nothing decodes — text/xml, application/pdf, multipart/form-data — is a 415, not an attempt to parse the body as JSON:

{
"error": "Unsupported Content-Type: text/xml",
"accepted": [
"application/json",
"application/cbor",
"application/x-cbor",
"application/x-www-form-urlencoded"
]
}

The same list ships as an Accept response header, which is what RFC 9110 §12.5.1 defines it to mean in a response: what to send next time.

Two deliberate exceptions to the rejection:

  • A missing Content-Type still decodes as JSON. When the sender states nothing, RFC 9110 leaves the recipient free to guess — and HttpClient sets the header only for object bodies, so a string body ships bare.
  • Structured-syntax suffixes follow their base type. application/vnd.api+json, application/merge-patch+json and application/problem+json all decode as JSON, per RFC 6839.

Note that the match is on the parsed media type, so naming a known type inside a parameter — multipart/form-data; boundary=----application/cbor — does not select that serializer.

application/x-www-form-urlencoded decodes into a flat record of strings, so an HTML <form> posts straight into a handler:

post(async (req) => {
const form = entity<{ name: string; tags: string | string[] }>(req);
// 'name=Ada&tags=a&tags=b' → { name: 'Ada', tags: ['a', 'b'] }
});

Every value is a string — form encoding carries no types. A repeated field widens to an array, matching how req.query already treats a repeated query parameter. Nesting is not expressible in this format; use JSON when you need it.

The cast is trust-based. entity<NewOrder>(req) doesn’t validate that the decoded value matches NewOrder — it just casts the result. For runtime validation, layer a validator on top:

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; // typed + validated
// ...
});

This is the standard TS pattern — the framework doesn’t bundle a validator, but every common one (zod, valibot, io-ts, etc.) drops in cleanly.

The simplest path is completeJson:

return completeJson(200, { id: 'o-1', status: 'created' });

The body is JSON-serialized at write time; Content-Type is set to application/json; charset=utf-8. Use this for the 95 % case.

For content-negotiated responses, use 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) picks a serializer from req.headers['accept']:

  • application/cbor (or application/x-cbor) → CborSerializer
  • application/json (or */*, or unspecified) → JsonSerializer

Returns { body: Uint8Array; contentType: string } — pass through to your HttpResponse.

Use marshal when:

  • Different clients want different formats. IoT clients on CBOR, browsers on JSON — the same handler serves both.
  • Bandwidth matters and CBOR’s compactness is worth the trade.

Otherwise stick to completeJson.

Standard JSON via JSON.stringify / JSON.parse. Bytes are UTF-8.

import { JsonSerializer } from 'actor-ts/serialization';
const ser = new JsonSerializer();
const bytes = ser.toBinary({ hello: 'world' });
const back = ser.fromBinary(bytes, '');

CBOR (RFC 8949) — a binary format more compact than JSON for nested data, with first-class binary support (no base64 wrapping for Uint8Array fields).

import { CborSerializer } from 'actor-ts/serialization';
const ser = new CborSerializer();
const bytes = ser.toBinary({ image: new Uint8Array([0xff, 0xd8, ...]) });

Useful when:

  • Payloads contain binary data (images, audio chunks).
  • Bandwidth matters (CBOR is typically 20-40 % smaller than JSON for the same data).
  • Both sides agree on CBOR — JSON is still the better choice for human-debuggable APIs.

The serializer interface is small:

interface Serializer {
toBinary(value: unknown): Uint8Array;
fromBinary(bytes: Uint8Array, manifest: string): unknown;
}

You can plug in your own (Protobuf, MessagePack, etc.) by implementing this interface — but the HTTP module’s media-type-to-serializer map is currently a private constant. For custom serializers in HTTP handlers, do the picking manually. A type the built-in map does not know is a 415, so branch on it before calling entity:

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, else 415
}
// ...
});

The cluster wire (between nodes, not HTTP) has no equivalent hook: it always frames the tagged JSON tree and never consults the SerializationExtension — see Serialization overview.

import { reject, Status } from 'actor-ts/http';
post(async (req) => {
const order = entity<NewOrder>(req);
// ↑ throws HttpError(400, "Cannot decode body: ...")
// automatically caught and converted to a 400 response
});

The framework catches HttpError and produces a structured response:

{
"error": "Cannot decode body: Unexpected token } in JSON at position 42"
}

For more specific error responses, reject after a manual decode:

const raw = entity<unknown>(req);
const parsed = ValidationSchema.safeParse(raw);
if (!parsed.success) {
reject(Status.UnprocessableEntity, 'invalid shape', {
issues: parsed.error.issues,
});
}

The extra argument lands in the JSON response body as top-level fields, alongside error: 'invalid shape'.

del(async (req) => {
// No body to decode — just do the work and return 204.
await registry.ask({ kind: 'delete', id: req.path.split('/').pop(), replyTo: ... });
return complete(Status.NoContent);
});

entity throws if the body is empty — don’t call it when you don’t expect a body.

entity already answers 415 for anything outside the built-in set, so you no longer need a guard just to get that. A check is still worth writing when an endpoint accepts less than the framework does — a JSON-only API that should refuse CBOR and form bodies:

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);
// ...
});

Note the !== '': a request with no Content-Type at all is decoded as JSON rather than rejected, so a guard that forgets the empty case turns away clients that never set the header.

HttpResponse.body accepts a web ReadableStream<Uint8Array>, and all three backends stream it straight to the client without buffering it in memory — Fastify hands the stream to reply.send, Express pipes it through Readable.fromWeb, and Hono returns it as a Response body. Return one straight from a handler:

get(async (req) => {
const rows = streamReport(); // ReadableStream<Uint8Array>
return complete(200, rows, { 'content-type': 'text/csv' });
});

Without an explicit Content-Type a stream defaults to application/octet-stream. Streams are one-shot, so don’t wrap a streaming response in the caching middleware. For SSE, use the SseActor.

Set Content-Length yourself when you know it. A backend derives the length from a Uint8Array body but has nothing to measure on a stream, so without the header the response goes out chunked. Fastify and Express keep a length you set; Hono hands its Response to the runtime’s server, and Bun.serve re-frames a stream as chunked regardless (node:http and Deno.serve keep it). Static file serving does this for you.