Custom serializers
Это содержимое пока не доступно на вашем языке.
For formats beyond JSON / CBOR there are two routes:
- Avro and Protobuf ship with the framework —
AvroSerializerandProtobufSerializertake a compiled schema you bring and handle the wiring. - Anything else — MessagePack, FlatBuffers, a bespoke
format — is an implementation of the
Serializer<T>interface, bound to specific message classes.
import { Serializer, SerializationExtensionId } from 'actor-ts';
class ProtoOrderSerializer implements Serializer<Order> { readonly id = 100; readonly name = 'order-protobuf'; readonly includesManifest = false;
manifest(_obj: Order): string { return ''; }
toBinary(obj: Order): Uint8Array { return OrderProto.encode(obj).finish(); }
fromBinary(bytes: Uint8Array, _manifest: string): Order { return OrderProto.decode(bytes); }}
// Wire it up:const ext = system.extension(SerializationExtensionId);const orderSerializer = new ProtoOrderSerializer();ext.register(orderSerializer); // add it to the id lookup tableext.bind(Order, orderSerializer.id); // route Order through it (id 100)Now the extension resolves every Order value to Protobuf; every
other value falls back to the default serializer (JSON).
The Serializer interface
Section titled “The Serializer interface”interface Serializer<T = unknown> { readonly id: number; readonly name: string; includesManifest: boolean; manifest(obj: T): string; toBinary(obj: T): Uint8Array; fromBinary(bytes: Uint8Array, manifest: string): T;}id— unique number ≥ 100 (1-99 reserved for built-ins). Embedded in every frame; tells the receiver which serializer to use.name— diagnostic-only.includesManifest— whethermanifest()returns useful info.manifest(obj)— returns a string identifying the concrete type. Use it when one serializer handles multiple types and the decoder needs to know which.toBinary/fromBinary— the actual encode / decode.
Binding by class
Section titled “Binding by class”ext.register(orderSerializer);ext.register(paymentSerializer);ext.register(cancelSerializer);
ext.bind(Order, orderSerializer.id);ext.bind(Payment, paymentSerializer.id);ext.bind(Cancellation, cancelSerializer.id);The extension matches values to serializers by constructor:
value instanceof Class → use that class’s bound serializer.
This means:
- Plain objects don’t match any binding → fall back to default (JSON or CBOR).
- Class instances match their class binding.
- Subclasses match the parent’s binding (since
instanceofis transitive).
When to use a custom serializer
Section titled “When to use a custom serializer”Three good fits:
- Cross-language interop — your actor system needs to talk to non-JS services using Protobuf / Avro / similar.
- Schema-enforced evolution — Protobuf’s schema files in source control are the canonical types; the framework reads from them.
- Specific perf / size requirements — FlatBuffers for zero-copy reads, MessagePack for size between JSON + CBOR.
For typical apps without these constraints, JSON or CBOR suffices.
Avro and Protobuf out of the box
Section titled “Avro and Protobuf out of the box”Both formats ship as ready-made serializers. What you bring is
the compiled schema; the schema library stays a dependency of
your project, exactly as zodCodec takes a schema rather than
importing zod. actor-ts never imports avsc or protobufjs —
it accepts anything with the right shape, so generated static
code (pbjs, ts-proto) fits too.
import avsc from 'avsc';import { AvroSerializer, AvroSerializerOptions } from 'actor-ts';
const avroType = avsc.Type.forSchema({ name: 'Deposited', type: 'record', fields: [ { name: 'amount', type: 'int' }, { name: 'currency', type: 'string' }, ],});
const avroOptions = AvroSerializerOptions.create<Deposited>() .withAvroType(avroType) .withId(100);const avroSerializer = new AvroSerializer(avroOptions);import protobuf from 'protobufjs';import { ProtobufSerializer, ProtobufSerializerOptions } from 'actor-ts';
const root = protobuf.parse(` syntax = "proto3"; package shop; message Order { string id = 1; int32 amount = 2; }`).root;
const protobufOptions = ProtobufSerializerOptions.create<Order>() .withMessageType(root.lookupType('shop.Order')) .withId(101);const protobufSerializer = new ProtobufSerializer(protobufOptions);A plain options object works identically —
new AvroSerializer({ avroType, id: 100 }).
What they do that hand-wiring misses
Section titled “What they do that hand-wiring misses”Each one is a thin wrapper, but the thin parts are the ones that bite:
avsccannot decode a plainUint8Array. It reaches forBuffer-private methods insidefromBuffer, and base64 framing hands it exactly a plainUint8Array— so a hand-rolled Avro serializer encodes fine and fails on replay, on Bun, Node and Deno alike.AvroSerializerbridges that.protobufjsreturns pooled bytes.Writer.finish()is a window into a shared write pool (offset ~28 KB into a 64 KB pool on Node in one measurement). Stored as-is, the row is a view onto other messages’ memory.ProtobufSerializerdetaches it.protobufjsdoes not validate on encode.verify()is a separate call; the serializer makes it, so a wrong-typed field is a write-time error naming the field instead of a puzzle later.- Wrong-schema bytes usually decode “successfully”. Avro carries no field tags and Protobuf carries no message name, so foreign bytes turn into plausible nonsense. Both serializers write a manifest (defaulted from the record name / fully-qualified name) and refuse a payload written under a different one.
- Protobuf decodes to a plain object.
toObjectwith defaults filled in and 64-bit fields as strings, because aMessageinstance and aLongare not things a journal can store. Opt out with.withPlainObjects(false).
Choosing an id
Section titled “Choosing an id”id is required and must be ≥ 100 — 1–99 belong to the
built-ins (JSON = 1, CBOR = 2), and the constructor rejects
anything lower. The id is a wire contract embedded in every
stored row: change it and old rows stop decoding.
Why a compiled type and not a .proto path
Section titled “Why a compiled type and not a .proto path”Loading .proto at runtime would mean filesystem access —
unavailable in a browser, permission-gated on Deno, and one more
thing to ship next to the bundle. Taking the compiled message
type leaves the choice with you, and none of the three routes
force a build step:
protobuf.parse(protoSource).root.lookupType('Order') // a source stringprotobuf.Root.fromJSON(descriptor).lookupType('Order') // a bundled descriptorimport { Order } from './generated/order.js'; // generated static codeHand-rolled Protobuf, when you need more control
Section titled “Hand-rolled Protobuf, when you need more control”ProtobufSerializer writes one fixed manifest. When the manifest
has to be computed per value — a version tag read off the
message, say — implement the interface directly:
import { Serializer } from 'actor-ts';import { Order } from './generated/order_pb.js';
class OrderProtoSerializer implements Serializer<Order> { readonly id = 100; readonly name = 'order-pb'; readonly includesManifest = true;
manifest(obj: Order): string { return `order.v${obj.getVersion()}`; }
toBinary(obj: Order): Uint8Array { return obj.serializeBinary(); }
fromBinary(bytes: Uint8Array, manifest: string): Order { const order = Order.deserializeBinary(bytes); if (manifest && manifest !== `order.v${order.getVersion()}`) { throw new Error(`version mismatch: ${manifest} vs v${order.getVersion()}`); } return order; }}
const orderSerializer = new OrderProtoSerializer();ext.register(orderSerializer);ext.bind(Order, orderSerializer.id);The manifest carries version info; the decoder verifies. Combined with Protobuf’s wire-level backward compatibility, you can evolve schemas while old messages decode correctly.
MessagePack example
Section titled “MessagePack example”import { encode, decode } from '@msgpack/msgpack';
class MessagePackSerializer implements Serializer<unknown> { readonly id = 101; readonly name = 'msgpack'; readonly includesManifest = false;
manifest(): string { return ''; } toBinary(obj: unknown): Uint8Array { return encode(obj); } fromBinary(bytes: Uint8Array): unknown { return decode(bytes); }}
// As a system-wide default:ext.setDefault(new MessagePackSerializer());MessagePack sits between JSON (size, parse speed) and Protobuf (typed schemas). Useful when you want a CBOR-like format with broader cross-language libraries.
Using a custom serializer for persistence
Section titled “Using a custom serializer for persistence”Every journal, snapshot store and durable-state store accepts a
serializer option — the way a custom serializer reaches stored
rows. The store frames each row self-describingly
({"__serialized__":{"id":…,"manifest":…,"data":"<base64>"}}), so
rows written before the serializer was configured keep decoding
through the default tagged-JSON codec, and both formats can coexist
in one stream:
const journalOptions = SqliteJournalOptions.create() .withPath('./journal.db') .withSerializer(new MessagePackSerializer());const journal = new SqliteJournal(journalOptions);The Register<X>Plugins bundles take a shared withSerializer(...)
that fans out to all of the backend’s stores (a leaf’s own
serializer wins). Two rules to plan around:
- Reading a framed row requires a serializer with the same
id— remove or swap the serializer and reads of old framed rows fail with aSerializationErrornaming the id they need. - The in-memory stores ignore the option: their round-trip always uses the default codec, which is stricter than a custom serializer can be — the safe direction for a dev/test default.
One format per store, or one per version
Section titled “One format per store, or one per version”withSerializer is a store-wide setting: one format for every
payload the store writes. When different versions of one event
need different formats — a v1 already on disk in Avro, a v2 you
want in Protobuf — wrap the serializer as a codec and register it
per version with the
SchemaRegistry:
import { serializerCodec } from 'actor-ts';
registry.register('BankAccount.Deposited', 1, { codec: serializerCodec(avroSerializer),});registry.register('BankAccount.Deposited', 2, { codec: serializerCodec(protobufSerializer), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }),});Each format still exists once, as a Serializer;
serializerCodec only adapts it to the registry’s per-version
slot.
Per-class vs system-wide
Section titled “Per-class vs system-wide”ext.register(s); ext.bind(Class, s.id) → route only that class through sext.setDefault(s) → the default the extension uses for unbound valuesPer-class is safer — keeps the default fallback for things you didn’t think to bind. System-wide is uniform — every unbound value goes through the same serializer.
For most apps: per-class for specific types (Protobuf’d events), JSON default for everything else.
Schema evolution with custom serializers
Section titled “Schema evolution with custom serializers”Custom serializers should handle forward + backward compatibility:
fromBinary(bytes: Uint8Array, manifest: string): Order { if (manifest === 'order.v1') { return migrateV1ToV2(Order.deserializeBinary(bytes)); } return Order.deserializeBinary(bytes);}For Protobuf, the wire format itself is forward-compatible (new fields ignored by old code). You only need explicit migration when the semantic changes (renames, splits) — handled by your custom serializer the same way the framework’s migration adapters handle JSON-format evolution.
Registering with the extension
Section titled “Registering with the extension”const ext = system.extension(SerializationExtensionId);
// 1. Register each serializer (adds it to the id lookup table):ext.register(orderSerializer);ext.register(paymentSerializer);
// 2. Bind classes to a registered serializer's id (adds routing):ext.bind(Order, orderSerializer.id);ext.bind(Payment, paymentSerializer.id);register adds a serializer to the id lookup table (so the
decoder can find it by id, and so bind can reference it). bind
then adds routing — mapping a class to an already-registered
serializer’s id. You must register before you bind; binding an
unregistered id throws.
Where to next
Section titled “Where to next”- Serialization overview — the bigger picture.
- JSON serializer — the default.
- CBOR serializer — the binary alternative.
- Schema registry — a different wire format per event version.
- Migration overview — schema evolution patterns.
