serio

Fluent binary serialization / deserialization in TypeScript.
If you need to work with binary protocols and file formats, or manipulate C/C++
struct
s and arrays from TypeScript, this library is for you. It provides an
ergonomic API for defining TypeScript classes that can serialize and deserialize
to binary formats.
Quickstart
Installation
npm install --save serio
Requirements:
- TypeScript 5.0 or higher;
- The
experimentalDecorators
setting should NOT be enabled in tsconfig.json
.
Basic usage
import {SObject, SUInt32LE, field} from serio;
class Position extends SObject {
@field(SUInt32LE)
x = 0;
@field(SUInt32LE)
y = 0;
foo = 100;
}
const pos1 = new Position();
const pos2 = Position.with({x: 5, y: 0});
const pos3 = Position.from(buffer.subarray(...));
pos1.x = 5;
pos1.y = pos1.x + 10;
const buf = pos1.serialize();
const size = pos1.getSerializedLength();
const bytesRead = pos1.deserialize(buffer.subarray(...));
Serializable
Serializable
is
the base class that all serializable values (such as SUInt8
and SObject
)
derive from. It provides a common interface for basic operations such as
creating, serializing and deserializing values.
Example usage of a Serializable
class X
:
const obj1 = new X();
const obj2 = X.from(buffer.subarray(...));
const buffer = obj1.serialize();
obj2.deserialize(buffer);
const size = obj2.getSerializedLength();
Integers
serio provides a set of Serializable
wrappers for common integer types.
Example usage:
const v1 = new SUInt32LE();
const v2 = SUInt32LE.of(100);
const v3 = SUInt32LE.from(buffer.subarray(...));
v1.value = 100;
v2.value = v1.value * 10;
const buffer = v1.serialize();
v2.deserialize(buffer);
const size = v2.getSerializedLength();
The full list of provided integer types:
Enums
All of the integer wrappers above also support looking up an enum label for
conversion to JSON. For example:
enum MyType {
FOO = 0,
BAR = 1,
}
JSON.stringify(SUInt8.of(0));
JSON.stringify(SUInt8.enum(MyType).of(0));
class MyObject extends SObject {
@field(SUInt8.enum(MyType))
type = MyType.FOO;
}
JSON.stringify(new MyObject());
JSON.stringify(MyObject.withJSON({type: MyType.FOO}));
JSON.stringify(MyObject.withJSON({type: 'FOO'}));
Strings
serio provides the
SStringNT
and
SString
classes for
working with string values. Both classes wrap a string value and can have
variable or fixed length. The difference is that SStringNT
reads and writes
C-style null-terminated strings, whereas SString
reads and writes string
values without a trailing null type.
These classes uses the iconv-lite library under
the hood for encoding / decoding. See
here for
the list of supported encodings.
Variable-length strings
Example usage:
const str1 = new SStringNT();
const str2 = SStringNT.of('hello world!');
const str3 = SStringNT.from(buffer.subarray(...));
const str4 = SStringNT.from(buffer.subarray(...), {encoding: 'gb2312'});
str1.value = 'foo bar';
const buf1 = str1.serialize();
const buf2 = str1.serialize({encoding: 'win1251'});
str1.deserialize(buffer.subarray(...));
str1.deserialize(buffer.subarray(...), {encoding: 'win1251'});
const size = SStringNT.of('hi').getSerializedLength();
const size = SString.of('hi').getSerializedLength();
If your application uses a non-UTF-8 encoding by default, you can also change the
default encoding used by serio to avoid having to pass {encoding: 'XX'}
every time:
const buf1 = str1.serialize();
setDefaultEncoding('cp437');
const buf1 = str1.serialize();
Fixed sized strings
SStringNT.ofLength(N)
can be used to represent fixed size strings (equivalent to C character arrays
char[N]
). An instance of SStringNT.ofLength(N)
will zero pad / truncate the
raw data to size N during serialization and deserialization.
Example usage:
const str1 = new (SStringNT().ofLength(5))();
const str2 = SStringNT.ofLength(5).of('hello world!');
const str3 = SStringNT.ofLength(5).from(buffer.subarray(...));
str1.value = 'foo bar';
const str1 = new (SStringNT.ofLength(3))();
console.log(str1.value);
const buf1 = str1.serialize();
const size1 = str1.getSerializedLength();
str1.value = 'A';
const buf2 = str1.serialize();
const size2 = str1.getSerializedLength();
const str2 = SStringNT.ofLength(3).of('hello');
console.log(str2.value);
str2.serialize();
str2.getSerializedLength();
str2.deserialize(Buffer.from('hello', 'utf-8'));
console.log(str2.value);
const str3 = SString.ofLength(3).of('hello');
console.log(str3.value);
str3.serialize();
str3.getSerializedLength();
str3.deserialize(Buffer.from('hello', 'utf-8'));
console.log(str3.value);
Arrays
serio provides the
SArray
class for
working with array values. An SArray
instance can wrap an array of other
Serializables
, including SObject
s and other SArray
s:
const arr1 = new SArray<SUInt32LE>();
const arr2 = SArray.of([obj1, obj2, obj3]);
const arr3 = SArray.of(_.times(5, () => SUInt32LE.of(0)));
arr1.value.forEach(...);
arr1.value = [obj1, obj2];
const buf1 = arr1.serialize();
arr1.deserialize(buffer);
const size = arr1.getSerializedLength();
To wrap arrays of numbers, strings, and other raw values, SArray
can be
combined with wrapper classes such as SUInt32LE
and SStringNT
using
SArray.of(wrapperClass)
. To wrap multi-dimensional arrays, multiple levels of
SArray
s can be created using SArray.of(SArray.of(...))
. For example:
const arr1 = SArray.of(SUInt8).of([0, 0, 0]);
console.log(arr1.value);
console.log(arr1.serialize());
const arr3 = SArray.of(SStringNT.ofLength(10)).of(['hello', 'foo', 'bar']);
console.log(arr3.value);
const arr4 = SArray.of(SArray.of(SUInt8)).of([
[0, 0, 0],
[1, 1, 1],
[2, 2, 2],
]);
console.log(arr4.value[2][0]);
const arr5 = SArray.of(SStringNT).of(['你好', '世界']);
console.log([
arr5.getSerializedLength(),
arr5.getSerializedLength({encoding: 'gb2312'}),
]);
arr5.serialize({encoding: 'gb2312'});
arr5.deserialize(buffer, {encoding: 'gb2312'});
Fixed sized arrays
SArray.ofLength(N, elementType)
and
SArray.of(wrapperType).ofLength(N)
can be used to represent fixed size arrays, equivalent to C arrays
(elementType[N]
). An instance of SArray.ofLength(N, elementType)
or
SArray.of(wrapperType).ofLength(N)
will pad / truncate the array to size N
during serialization and deserialization.
Example usage:
const arr1 = new (SArray.of(SUInt8).ofLength(3))();
console.log(arr1.value);
arr1.value = [1, 2, 3, 4, 5];
console.log(arr1.getSerializedLength());
console.log(arr1.toJSON());
console.log(arr1.serialize());
arr1.deserialize(Buffer.of(6, 7, 8, 9, 10));
console.log(arr1.value);
console.log(arr1.serialize());
arr1.value = [];
console.log(arr1.getSerializedLength());
console.log(arr1.serialize());
arr1.deserialize(Buffer.of(101, 102, 103));
console.log(arr1.value);
To create / update nested SObject
s and SArray
s with JSON / POJO values, use
ofJSON()
and assignJSON()
:
const arr = SArray.ofLength(3, MyObject).ofJSON([{...}, {...}, {...}]);
arr.assignJSON([{prop1: '...'}, {...}, {...}]);
Objects
serio provides the
SObject
class for
defining serializable objects that are conceptually equivalent to C/C++
struct
s.
To define a serializable object:
- Define a class that extends
SObject
.
- Use the
@field()
decorator to annotate
class properties that should be serialized / deserialized:
@field() prop = X;
if the property is itself a Serializable
, such as
another object;
@field(WrapperClass) prop = X;
if the property should be wrapped with a
Serializable
wrapper, such as an integer or a string.
Basic example:
class Position extends SObject {
@field(SUInt32LE)
x = 0;
@field(SUInt32LE)
y = 0;
@json(false)
foo = 100;
@json(true)
get distFromOrigin() {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
}
const pos1 = new Position();
const pos2 = Position.with({x: 5, y: 0});
const pos3 = Position.from(buffer.subarray(...));
pos1.x = 5;
pos1.y = pos1.x + 10;
const buf = pos1.serialize();
const size = pos1.getSerializedLength();
const bytesRead = pos1.deserialize(buffer.subarray(...));
A more advanced example showing @field()
with getter / setters:
class Color extends SObject {
red = 0;
green = 0;
blue = 0;
@field(SUInt8)
@json(true)
get value() {
return (
((this.red & 0x07) << 5) | (this.green & (0x07 << 2)) | (this.blue & 0x03)
);
}
set value(v: number) {
this.red = (v >> 5) & 0x07;
this.green = (v >> 2) & 0x07;
this.blue = v & 0x03;
}
}
Note: Avoid using @field()
with both regular
properties and getters / setters in the same SObject
class. This is due to a
quirk in the ES6 decorator spec: decorator initializers for getters / setters
always run before regular properties, so if a class contains a mixure of
decorated properties and decorated getters / setters, the resulting
serialization order may be different from the declaration order in the
code.
Example combining objects and arrays:
class ExampleObject extends SObject {
@field(SArray)
prop1 = Array(10)
.fill()
.map(() => new Point());
@field(SArray.of(SUInt8))
prop2 = Array(10).fill(0);
@field(SArray.of(SArray.of(SStringNT.ofLength(10))))
prop3 = [
['hello', 'world'],
['foo', 'bar'],
];
}
const arr1 = SArray.of(_.times(5, () => new ExampleObject()));
console.log(arr1.value[0].prop3[0][0]);
To create / update nested SObject
s and SArray
s with JSON / POJO values, use
withJSON()
and assignJSON()
:
class Segment extends SObject {
@field()
p1 = new Point();
@field()
p2 = new Point();
}
const s2 = Segment.withJSON({
p1: {x: 1, y: 1},
p2: {x: 2, y: 2},
});
s2.assignJSON({p2: {x: 10}});
console.log(s2.toJSON());
Bitmasks
serio provides the
SBitmask
class for
working with bitmask values that represent the binary OR of several fields. The
interface is similar to SObject
. Example usage:
class Color8Bit extends SBitmask.of(SUInt8) {
@bitfield(3)
r = 0;
@bitfield(3)
g = 0;
@bitfield(2)
b = 0;
}
const c1 = new Color8Bit();
c1.serialize();
c1.r = 0b111;
c1.g = 0b001;
c1.serialize();
const c2 = Color8Bit.with({r: 0b000, g: 0b111, b: 0b01});
c2.serialize();
console.log(c2.value);
c2.value = 0b11100010;
console.log(c2.toJSON());
c2.deserialize(Buffer.of(0b11111111));
console.log(c2.toJSON());
const c3 = Color8Bit.of(0b11100010);
console.log(c3.toJSON());
Boolean flags are also supported:
class MyBitmask extends SBitmask.of(SUInt8) {
@bitfield(1)
flag1 = false;
@bitfield(2)
flag2 = false;
@bitfield(6)
@json(false)
unused = 0;
}
const bm1 = MyBitmask.of(0b11000000);
console.log(bm1.toJSON());
bm1.flag1 = false;
bm1.serialize();
Similar to @field()
, you can also use @bitfield()
with getters / setters, but you should avoid using @bitfield()
with both getters / setters and regular properties in the same class.
Creating new Serializable
classes
To define your own Serializable
classes that can be used with SArray
, SObject
etc, you can extend the
Serializable
abstract class and provide the required method implementations:
class MyType extends Serializable {
x = 0;
name = SStringNT.ofLength(32);
serialize(opts?: SerializeOptions): Buffer {
const buffer = Buffer.alloc(this.getSerializedLength(opts));
buffer.writeUInt8(this.x, 0);
this.name.serialize(opts).copy(buffer, 1);
return buffer;
}
deserialize(buffer: Buffer, opts?: DeserializeOptions): number {
this.x = buffer.readUInt8(0);
this.name.deserialize(buffer.subarray(1), opts);
return this.getSerializedLength(opts);
}
getSerializedLength(opts?: SerializeOptions): number {
return 1 + this.name.getSerializedLength(opts);
}
toJSON() {
return {x: this.x, name: this.name};
}
assignJSON(jsonValue: {x: string; name: string}) {
this.x = jsonValue.x;
this.name = jsonValue.name;
}
}
const obj1 = new MyType();
const obj2 = MyType.from(buffer);
const arr1 = SArray.of([new MyType(), new MyType()]);
class SomeObject extends SObject {
@field()
myType = new MyType();
}
To define a class that wraps a raw value, to be used with @field()
and
SArray.of()
, you can instead extend the
SerializableWrapper
class:
class MyWrapperType extends SerializableWrapper<number> {
value = 0;
serialize(opts?: SerializeOptions): Buffer {
}
deserialize(buffer: Buffer, opts?: DeserializeOptions): number {
}
getSerializedLength(opts?: SerializeOptions): number {
}
toJSON() {
}
assignJSON(jsonValue: unknown) {
}
}
const obj1 = new MyWrapperType();
const obj2 = MyWrapperType.from(buffer);
const obj3 = MyWrapperType.of(42);
const arr1 = SArray.of(MyWrapperType).of([1, 2, 3]);
class SomeObject extends SObject {
@field(MyWrapperType)
foo: number = 0;
}
About
serio is distributed under the Apache License v2.
Changelog
3.0
- Enable ESLint and improve type signatures.
2.0
- New APIs to simplify the construction of nested
SObject
s and SArray
s from
JSON / POJO values:
- Introduce the
assignJSON()
method to most Serializable
classes as a
canonical method for hydrating a Serializable
from a JSON / POJO value.
- Introduce
SObject.withJSON()
and SArrayWithWrapper.ofJSON()
, allowing inline
construction of nested SObject
s and SArray
s from JSON / POJO values.
- New API for converting
SObject
s and SBitmask
s to JSON / POJO values:
- Introduce the
@json(boolean)
decorator to control whether a field should
appear in the output of toJSON()
without having to override the latter.
- Breaking changes:
SObject.assignFromSerializable()
has been renamed to
SObject.assignSerializableMap()
for consistency with assignJSON()
, and
passing in unknown properties in the argument will now throw an error instead
of being silently ignored.
SObject.mapValuesToSerializable()
has been renamed to
SObject.toSerializableMap()
for consistency with toJSON()
.
SBitmask.toJSON()
previously only returned fields decorated with
@bitfield()
. Its behavior has been updated to be consistent with
SObject.toJSON()
: it now returns all properties on the object, with
support for field-level control with @json(boolean)
.