tinybuf
TypeScript icon, indicating that this package has built-in type declarations

1.6.6 • Public • Published

🔌 tinybuf  NPM version test test

tinybuf icon showing binary peeking out from behind a square.

Compressed, static-typed binary buffers in HTML5 / Node.js

  • 🚀 Designed for real-time HTML5 games (via geckos.io or socket.io)
  • 🗜️ Lossless and lossy compression, up to ~50% smaller than FlatBuffers or Protocol Buffers
  • ✨ Out-of-the-box boolean packing, 16-bit floats, 8-bit scalars, and more
  • 🚦 Compile-time safety & runtime validation

tinybuf is safe for use with property mangling & code minification like terser

Why?

FlatBuffers and Protocol Buffers are heavy, cross-platform libraries that have limited encodings, and depend on clumsy external tooling. tinybuf is optimized for speed, performance, and ✨ developer productivity. See comparison table for more.

Sample Usage

Easily encode to and from binary formats

import { encoder, Type } from 'tinybuf';

// Define format:
const PlayerMessage = encoder({
  id: Type.UInt,
  health: Type.UInt8,
  position: {
    x: Type.Float32,
    y: Type.Float32
  }
});

// Encode:
const bytes = PlayerMessage.encode(myPlayer);

Decoding many formats:

import { decoder } from 'tinybuf';

// Create a decoder:
const myDecoder = decoder()
  .on(PlayerMessage, data => handlePlayerMessage(data))
  .on(OtherMessage, data => handleOtherMessage(data));

// Trigger handler (or throw UnhandledBinaryDecodeError):
myDecoder.processBuffer(bytes);

Getting Started

tinybuf provides the ability to quickly encode and decode strongly-typed message formats.

The core concepts are:

  1. encoder: Flexible, static-typed binary encoding formats
  2. Types: 25+ built-in encoding formats
  3. decoder: A parser for processing multiple binary buffer formats

For more information on additional pre/post-processing rules, check out Validation and Transforms.

Installation

# npm
npm install tinybuf -D

# yarn
yarn add tinybuf --dev

Usage

Define formats

Create an encoding format like so:

import { encoder, Type } from 'tinybuf';

// Define your format:
const GameWorldData = encoder({
  time: Type.UInt,
  players: [{
    id: Type.UInt,
    isJumping: Type.Boolean,
    position: {
      x: Type.Float,
      y: Type.Float
    }
  }]
});

Then call encode() to turn it into binary (as ArrayBuffer).

// Encode:
const bytes = GameWorldData.encode({
  time: 123,
  players: [
    {
       id: 44,
       isJumping: true,  
       position: {
         x: 110.57345,
         y: -93.5366
       }
    }
  ]
});

bytes.byteLength
// 14

And you can also decode it directly from the encoding type.

// Decode:
const data = GameWorldData.decode(bytes);

Inferred types

The encoder will automatically infer the types for encode() and decode() from the schema provided (see the Types section below).

For example, the type T for GameWorldData.decode(...): T would be inferred as:

{
  timeRemaining: number,
  players: {
    id: string,
    health: number,
    isJumping: boolean,
    position?: {
      x: number,
      y: number
    }
  }[]
}

You can also use the Decoded<T> helper type to get inferred types in any custom method/handler:

import { Decoded } from 'tinybuf';

function updateGameWorld(data: Decoded<typeof GameWorldData>) {
  // e.g. Access `data.players[0].position?.x`
}

Types

Serialize data as a number of lossless (and lossy!) data types

Type Inferred JavaScript Type Bytes About
Type.Int number 1-8* Integer between -Number.MAX_SAFE_INTEGER and Number.MAX_SAFE_INTEGER.
Type.Int8 number 1 Integer between -127 to 128.
Type.Int16 number 2 Integer between -32,767 to 32,767.
Type.Int32 number 4 Integer between -2,147,483,647 to 2,147,483,647.
Type.UInt number 1-8# Unsigned integer between 0 and Number.MAX_SAFE_INTEGER.
Type.UInt8 number 1 Unsigned integer between 0 and 255.
Type.UInt16 number 2 Unsigned integer between 0 and 65,535.
Type.UInt32 number 4 Unsigned integer between 0 and 4,294,967,295.
Type.Scalar number 1 Signed scalar between -1.0 and 1.0.
Type.UScalar number 1 Unsigned scalar between 0.0 and 1.0.
Type.Float64 / Type.Double number 8 Default JavaScript number type. A 64-bit "double" precision floating point number.
Type.Float32 / Type.Float number 4 A 32-bit "single" precision floating point number.
Type.Float16 / Type.Half number 2 A 16-bit "half" precision floating point number.
Important Note: Low decimal precision. Max. large values ±65,500.
Type.String string 1 + n A UTF-8 string.
Type.Boolean boolean 1 A single boolean.
Type.BooleanTuple boolean[] 1 Variable-length array/tuple of boolean values packed into 1 byte.
Type.Bitmask8 boolean[] 1 8 booleans.
Type.Bitmask16 boolean[] 2 16 booleans.
Type.Bitmask32 boolean[] 4 32 booleans.
Type.JSON any 1 + n Arbitrary JSON data, encoded as a UTF-8 string.
Type.Binary ArrayBuffer 1 + n JavaScript ArrayBuffer data.
Type.RegExp RegExp 1 + n + 1 JavaScript RegExp object.
Type.Date Date 8 JavaScript Date object.
Optional(T) T | undefined 1 Any optional field. Use the Optional(...) helper. Array elements cannot be optional.
[T] Array<T> 1 + n Use array syntax. Any array.
{} object none Use object syntax. No overhead to using object types. Buffers are ordered, flattened structures.

*Int is a variable-length integer ("varint") which encodes <±64 = 1 byte, <±8,192 = 2 bytes, <±268,435,456 = 4 bytes, otherwise = 8 bytes.

#UInt is a variable-length unsigned integer ("varuint") which encodes <128 = 1 byte, <16,384 = 2 bytes, <536,870,912 = 4 bytes, otherwise = 8 bytes.

Length of payload bytes as a UInt. Typically 1 byte, but could be 2-8 bytes for very large payloads.

2-bit overhead: 6 booleans per byte (i.e. 9 booleans would require 2 bytes).

✨ Parsing formats

By default, each encoder encodes a 2-byte identifier based on the shape of the data.

You can explicitly set Id in the encoder(Id, definition) to any 2-byte string or unsigned integer (or disable entirely by passing null).

Use Decoder

Handle multiple binary formats at once using a decoder:

import { decoder } from 'tinybuf';

const myDecoder = decoder()
  .on(MyFormatA, data => onMessageA(data))
  .on(MyFormatB, data => onMessageB(data));

// Trigger handler (or throw UnhandledBinaryDecodeError)
myDecoder.processBuffer(binary);

Note: Cannot be used with formats where Id was disabled.

Manual handling

You can manually read message identifers from incoming buffers with the static function BinaryCoder.peekIntId(...) (or BinaryCoder.peekStrId(...)):

import { BinaryCoder } from 'tinybuf';

if (BinaryCoder.peekStrId(incomingBinary) === MyMessageFormat.Id) {
  // Do something special.
}

💥 Id Collisions

By default Id is based on a hash code of the encoding format. So the following two messages would have identical Ids:

const Person = encoder({
  firstName: Type.String,
  lastName: Type.String
});

const FavoriteColor = encoder({
  fullName: Type.String,
  color: Type.String
});

NameCoder.Id === ColorCoder.Id
  // true

If two identical formats with different handlers is a requirement, you can explicitly set unique identifiers.

const Person = encoder(1, {
  firstName: Type.String,
  lastName: Type.String
});

const FavoriteColor = encoder(2, {
  fullName: Type.String,
  color: Type.String
});

Identifiers can either be a 2-byte string (e.g. 'AB'), an unsigned integer (0 -> 65,535).

✨ Validation / Transforms

Validation

The great thing about binary encoders is that data is implicitly type-validated, however, you can also add custom validation rules using setValidation():

const UserMessage = encoder({
  uuid: Type.String,
  name: Optional(Type.String),
  // ...
})
.setValidation({
  uuid: (x) => {
    if (!isValidUUIDv4(x)) {
      throw new Error('Invalid UUIDv4: ' + x);
    }
  }
});

Transforms

You can also apply additional encode/decode transforms.

Here is an example where we're stripping out all whitespace:

const PositionMessage = encoder({ name: Type.String })
  .setTransforms({ name: a => a.replace(/\s+/g, '') });

let binary = PositionMessage.encode({ name: 'Hello  There' })
let data = PositionMessage.decode(binary);

data.name
  // "HelloThere"

Unlike validation, transforms are applied asymmetrically.

The transform function is only applied on encode(), but you can provide two transform functions.

Here is an example which cuts the number of bytes required from 10 to 5:

const PercentMessage = encoder(null, { value: Type.String })
  .setTransforms({
    value: [
      (before) => before.replace(/\$|USD/g, '').trim(),
      (after) => '$' + after + ' USD'
    ]
  });

let binary = PercentMessage.encode({ value: ' $45.53 USD' })
let data = PercentMessage.decode(binary);

binary.byteLength
  // 5

data.value
  // "$45.53 USD"

🏓 Comparison Table

Choosing for real-time HTML5 / Node.js applications and games.

Here are some use cases stacked uup.

tinybuf FlatBuffers Protocol Buffers Raw JSON
Serialization format Binary Binary Binary String
Schema definition Native .fbs files .proto files Native
TypeScript Types Native Code generation Code generation Native
External tooling dependencies None cmake and flatc None* N/A
Reference data size 34 bytes 68 bytes 72 bytes 175 bytes (minified)
Fast & efficient 🟢 🟢 🟢 🔴
16-bit floats 🟢 🔴 🔴 🔴
Boolean-packing 🟢 🔴 🔴 🔴
Arbitrary JSON 🟢 🔴 🔴 🟢
Property mangling 🟢 🔴 🔴 🔴
Suitable for real-time data 🟢 🟢 🔴 🔴
Suitable for web APIs 🔴 🔴 🟢 🟢
Supports HTML5 / Node.js 🟢 🟢 🟢 🟢
Cross-language (Java, C++, Python, etc.) 🔴 🟢 🟢 🟢

Based on the Reference data formats and schemas

*When using protobufjs

See Reference data

Sample data (Minified JSON):

{
  "players": [
    {
      "id": 123,
      "position": {
        "x": 1.0,
        "y": 2.0,
        "z": 3.0
      },
      "velocity": {
        "x": 1.0,
        "y": 2.0,
        "z": 3.0
      },
      "health": 1.00
    },
    {
      "id": 456,
      "position": {
        "x": 1.0,
        "y": 2.0,
        "z": 3.0
      },
      "velocity": {
        "x": 1.0,
        "y": 2.0,
        "y": 3.0
      },
      "health": 0.50
    }
  ]
}

tinybuf

const ExampleMessage = encoder({
  players: [
    {
      id: Type.UInt,
      position: {
        x: Type.Float16,
        y: Type.Float16,
        z: Type.Float16
      },
      velocity: {
        x: Type.Float16,
        y: Type.Float16,
        y: Type.Float16
      },
      health: Type.UScalar
    },
  ],
});

FlatBuffers

// ExampleMessage.fbs

namespace ExampleNamespace;

table Vec3 {
  x: float;
  y: float;
  z: float;
}

table Player {
  id: uint;
  position: Vec3;
  velocity: Vec3;
  health: float;
}

table ExampleMessage {
  players: [Player];
}

root_type ExampleMessage;

Protocol Buffers (Proto3)

syntax = "proto3";

package example;

message Vec3 {
  float x = 1;
  float y = 2;
  float z = 3;
}

message Player {
  uint32 id = 1;
  Vec3 position = 2;
  Vec3 velocity = 3;
  float health = 4;
}

message ExampleMessage {
  repeated Player players = 1;
}

Encoding guide

See docs/ENCODING.md for an overview on how most formats are encoded (including the dynamically sized integer types).

Credits

Developed from a hard-fork of Guilherme Souza's js-binary.

Package Sidebar

Install

npm i tinybuf

Weekly Downloads

0

Version

1.6.6

License

MIT

Unpacked Size

154 kB

Total Files

55

Last publish

Collaborators

  • reececomo