콘텐츠로 이동
한국어

Serialization overview

이 콘텐츠는 아직 번역되지 않았습니다.

Two scenarios require turning JS values into bytes:

  1. Cluster wire — a tell to a remote actor needs the message serialized for TCP transmission.
  2. Persistence — events / snapshots / durable state stored on disk or in a journal.

The framework’s Serializer interface is the abstraction. Two built-ins ship:

SerializerFormatWhen
JsonSerializerJSONDefault. Human-debuggable, widely understood.
CborSerializerCBOR (RFC 8949)Compact, binary-native, faster.

Two more take a schema you bring — the schema library (avsc, protobufjs, generated code) stays a dependency of your project:

SerializerFormatWhen
AvroSerializerAvroSmallest rows; schema-registry interop.
ProtobufSerializerProtobufCross-language contracts; forward-compatible wire.

Plus an extension point: implement Serializer<T> for MessagePack, FlatBuffers, or anything else.

For a cross-node tell, the cluster wraps the message in an envelope and sends it as a length-prefixed JSON.stringify frame — the pluggable Serializer is not consulted on the wire:

Sender side:
remoteRef.tell(message)
→ wrapped in an envelope { to, from, body, tag }
→ ActorRefs embedded in body → wire-safe markers
→ JSON.stringify(envelope) → length-prefixed frame
→ cluster transport sends over the wire
Receiver side:
cluster transport reassembles the frame
→ JSON.parse(frame) → envelope
→ ActorRef markers → live remote refs
→ body delivered to the actor's onReceive

The body must be JSON-safe. The only type hint on the wire is tag — the message’s constructor name — for routing; there is no serializerId / manifest framing on the cluster wire.

interface Serializer<T = unknown> {
readonly id: number; // unique per serializer
readonly name: string; // human-readable
includesManifest: boolean;
manifest(obj: T): string; // type tag
toBinary(obj: T): Uint8Array;
fromBinary(bytes: Uint8Array, manifest: string): T;
}

Small surface — encode, decode, identify. The framework’s SerializationExtension is a registry that maps classes to serializers (via bind); when you encode a value through the extension (ext.encode), it looks up the serializer bound to the value’s class.

const system = ActorSystem.create('my-app');
// → JsonSerializer registered as default
// → Every value serializes as JSON unless a specific serializer is bound

If you do nothing, every value’s bytes are JSON. Plain objects, arrays, strings, numbers — all work. Class instances serialize as plain objects (losing methods).

AspectJSONCBOR
Wire sizeLarger~20-40 % smaller
SpeedSlower than CBOR for binary dataFaster
DebuggingTrivial (text)Requires CBOR-aware tool
Binary fieldsbase64-encoded (wasteful)Native binary
Library supportUniversalSolid in JS land

For most apps, JSON is fine — the perf difference doesn’t matter and the debuggability is valuable.

For bandwidth-sensitive cases (large cluster, lots of events, IoT-style payloads), CBOR wins meaningfully.

See JsonSerializer + CborSerializer for details.

Avro and Protobuf ship ready-made — you supply the compiled schema, the framework supplies the serializer:

import { ProtobufSerializer, ProtobufSerializerOptions, SerializationExtensionId } from 'actor-ts';
const protobufOptions = ProtobufSerializerOptions.create<MyEvent>()
.withMessageType(root.lookupType('shop.MyEvent'))
.withId(100);
const serializer = new ProtobufSerializer(protobufOptions);
const ext = system.extension(SerializationExtensionId);
ext.register(serializer); // add it to the id lookup table
ext.bind(MyEvent, serializer.id); // route MyEvent through it

bind takes a numeric serializer id, so the serializer must be registered first. Now the extension resolves every MyEvent value to Protobuf; other types fall back to JSON.

For any other format — MessagePack, FlatBuffers, something bespoke — implement Serializer<T> yourself. Ids 1–99 are reserved for the built-ins; pick ≥ 100.

See Custom serializers for the full setup.

The JsonSerializer class handles:

  • Plain objects ({ a: 1, b: 'two' }).
  • Arrays.
  • Strings, numbers, booleans, null.
  • Nested combinations of the above.
  • Date, Uint8Array, Map, Set, and bigint — round-tripped as real instances via type tags.
  • NaN / Infinity / -0, RegExp, URL, Error (name + message + cause, no stack), and every typed array / DataView / ArrayBuffer (#889).

It throws on functions, symbols, undefined, circular references, Promise, and WeakMap / WeakSet; class instances decode to plain objects (methods + identity lost).

CborSerializer carries the same list — a shared test suite asserts the two agree on every type — just as compact binary instead of tagged JSON text. It differs in one place: undefined is carried natively rather than rejected, which makes it the more permissive of the two.

The cluster wire is different: cross-node messages go through raw JSON.stringify (not JsonSerializer), so the usual JSON limits apply on the wire — Date → ISO string, Map / Set{}, bigint throws, undefined / functions dropped. Convert to plain, JSON-safe shapes before a cross-node tell:

// ✗
ref.tell({ when: new Date() });
// ✓
ref.tell({ when: Date.now() });
1. cluster wire — every cross-node tell (raw JSON.stringify).
2. PersistentActor.persist(event) — events to the journal.
3. DurableStateActor.persist(state) — state to the store.
4. Snapshot writes — snapshots to the snapshot store.
5. DistributedData — replicated state across the cluster.

Persistence (2-4) writes the tagged JSON tree format — the same tree JsonSerializer uses — so Date / Map / Set / bigint / Uint8Array round-trip through every journal, snapshot store and durable-state store without configuration. Each store also accepts an explicit withSerializer(...) option that routes a custom Serializer into its rows (see custom serializers); the SerializationExtension registry bindings are still not consulted for persistence or the wire — that wiring is tracked in #450.

The cluster wire uses JSON.stringify on the envelope directly — not the pluggable Serializer (see How it works). In-process tells don’t serialize — the receiver gets the exact in-memory reference. Serialization happens only at process / disk / cluster boundaries.