@brlebtag/binser
v0.1.0
Published
Compilador de schemas binários: gera serializadores bit a bit rápidos para JavaScript e TypeScript.
Maintainers
Readme
binser
Compilador de schemas binários para JavaScript e TypeScript. Você descreve o formato num arquivo de
texto, o binser gera um módulo sem dependências com serialize, deserialize e size, e os dados
são gravados bit a bit, sem nenhum metadado — o menor tamanho possível, com código especializado
e rápido.
x: i8
y: i8
alive: bool
life: ui7
speed: f32
name: utf8binser -s Player.txt -o Player.jsimport { serialize, deserialize, size } from './Player.js'
const player = { x: 10, y: 20, alive: true, life: 100, speed: 30, name: 'Bruno' }
const dv = new DataView(new ArrayBuffer(Math.ceil(size(player) / 8))) // 16 bytes
serialize({ dataview: dv, begin_in_bits: 0, obj: player })
const { obj } = deserialize({ dataview: dv, begin_in_bits: 0, obj: null })O mesmo objeto em JSON ocupa 65 bytes.
- Instalação
- Linguagem de schema
- API gerada
- CLI
- Entendendo os pacotes
- Segurança
- Desempenho
- API programática
- Especificação do formato: docs/FORMAT.md · Plano do projeto: docs/PLANO.md
Instalação
npm install -g @brlebtag/binser # comando global "binser"O compilador (comando binser) roda em Node.js 22+ ou Bun. O código gerado não depende de
nada: usa só DataView, BigInt e TextDecoder, então roda em qualquer ambiente JavaScript moderno
(browsers inclusive). A suíte de testes inteira roda em Node.js e em Bun (npm test e npm run test:bun).
Com Bun:
bun add -g @brlebtag/binser # instala o comando "binser"
bunx --bun binser -s Player.txt -o Player.ts # executa a CLI no próprio BunO arquivo .ts gerado pode ser importado direto no Bun, sem etapa de build.
Em projetos com mais de uma ponta (cliente e servidor, por exemplo), instale também como dependência de desenvolvimento com versão fixa, para que todos gerem código com a mesma versão:
npm install -D @brlebtag/binser
npx binser -s schemas/Player.txt -o src/generated/Player.tsLinguagem de schema
Cada linha declara um campo (nome: tipo). Os campos são gravados na ordem em que aparecem.
Comentários começam com #.
Tipos
| Tipo | Bits | Valor em JS / TS |
| --------------------------------- | -------------------------------------- | ------------------------------------ |
| iN, uiN (1 ≤ N ≤ 64) | N | number (N ≤ 53) ou bigint |
| i8, ui16, i32, ui64, ... | casos particulares de iN/uiN | |
| bool | 1 | boolean |
| f16, f32, f64 | 16, 32, 64 (IEEE-754) | number |
| q(min, max, passo) | ⌈log₂((max−min)/passo + 1)⌉ | number (quantizado) |
| enum(a, b, c) | ⌈log₂K⌉ | 'a' \| 'b' \| 'c' |
| utf8 (str, str8) | 32 + 8 × bytes | string |
| utf16 (str16) | 32 + 16 × unidades | string |
| utf8!, utf16! | tamanho em Exp-Golomb (poucos bits) | string |
| utf8<=N, utf16<=N | tamanho em ⌈log₂(N+1)⌉ bits | string (no máximo N unidades) |
| [T] | 32 + elementos | T[] |
| [T]! | Exp-Golomb + elementos | T[] |
| [T; N] | exatamente N elementos, sem contador | T[] |
| [T; <=N] | ⌈log₂(N+1)⌉ + elementos | T[] |
| (A, B, C) | A + B + C | [A, B, C] |
| { a: A, b: B } | A + B | objeto |
| ?T | 1 bit de presença (+ T) | T \| null |
| union(A, B) | ⌈log₂K⌉ bits de tag + valor | { type: 'A', value: A } \| ... |
Inteiros com sinal usam complemento de dois (i2 vai de −2 a 1). Os detalhes de cada tipo estão
em docs/FORMAT.md.
Números quantizados: q(min, max, passo)
Muitos números não precisam de toda a precisão de um f32. Uma posição num mapa de 0 a 4096 com
precisão de 0,1 tem 40.960 passos: cabe em 16 bits em vez de 32.
x: q(0, 4096, 0.1) # 16 bits; 1234.56 é gravado como 12346 e lido como 1234.6
angle: q(0, 360, 1.5) # 8 bits
ratio: q(0, 1, 0.01) # 7 bitsO erro máximo é de meio passo. Valores fora da faixa são saturados em min/max.
Enums
type State = enum(idle, moving, stopped) # 2 bits
state: State
mode: enum(on, off) # 1 bitEm JavaScript o valor é a string ('moving'); em TypeScript, uma união de literais. Enums
nomeados também exportam a lista de valores (State → ['idle', 'moving', 'stopped']).
Opcionais e uniões
owner: ?ui32 # null → 1 bit; 42 → 1 + 32 bits
event: union(Login, Chat) # 1 bit de tag + o valor escolhidoserialize({ dataview, begin_in_bits: 0, obj: { owner: null, event: { type: 'Chat', value: { text: 'oi' } } } })Tipos nomeados e imports
# Entity.txt
import Vec2 from "./Vec2.txt" # a raiz de outro schema
import { State } from "./Common.txt" # types declarados em outro schema
type Stats = { hp: ui7, mana: ui7 }
pos: Vec2
state: State
stats: Stats
path: [Vec2]!Um arquivo pode ser uma união de tipos em vez de uma lista de campos — útil para mensagens:
# Message.txt
import Login from "./Login.txt"
import Chat from "./Chat.txt"
type Ping = {}
union Login, Chat, PingEndianness
O padrão é big-endian. Uma linha little faz os campos seguintes serem gravados em little-endian;
big volta ao padrão. Só afeta larguras múltiplas de 8 com 16 bits ou mais (ui16, f32, i64...).
id: ui16 # 0x1234 → 12 34
little
checksum: ui32 # 0x11223344 → 44 33 22 11
bigAPI gerada
export const SCHEMA_HASH: string // muda sempre que o formato muda
export const FIXED_SIZE: number | null // bits, se o schema tiver tamanho fixo
export function size(obj?: Player): number // bits exatos de obj (obj é opcional em schemas fixos)
export function serialize(ctx: Context<Player>): Context<Player>
export function deserialize(ctx: Context<Player>): Context<Player>
export function inspect(ctx: Context<Player>): InspectResult<Player> // só com --debug
interface Context<T> {
dataview: DataView
begin_in_bits: number // bit onde começa; ao final, aponta para o bit seguinte ao valor
obj: T | null
}serializeedeserializealteram e retornam o mesmoctx, sem alocar memória extra.deserializecomctx.objdiferente denullpreenche o objeto existente (inclusive objetos e arrays aninhados) — útil em loops de alta frequência para reduzir o trabalho do garbage collector.- Os bits do buffer fora do valor não são alterados, então vários valores (de schemas diferentes) podem ser gravados em sequência:
const ctx = { dataview, begin_in_bits: 0, obj: header }
Header.serialize(ctx)
ctx.obj = player
Player.serialize(ctx) // começa no bit seguinte ao Header, sem paddingCom saída .ts, o módulo exporta também as interfaces (Player, Context, tipos nomeados).
CLI
binser -s <schema.txt> -o <saída.js|saída.ts> [opções]
binser explain <schema.txt>
binser dump -s <schema.txt> (<pacote.bin> | --hex "0A 14 ...") [--offset <bits>] [--json]| Opção | Descrição |
| ------------------ | --------------------------------------------------------------------------- |
| -s, --schema | schema de entrada |
| -o, --out | arquivo gerado; a extensão escolhe a linguagem (.js, .mjs, .ts, .mts)|
| --dts | com saída JavaScript, gera também o .d.ts |
| --checked | valida os valores no serialize (use em desenvolvimento e testes) |
| --debug | exporta também inspect(ctx) |
| -w, --watch | gera de novo sempre que o schema ou um import mudar |
Sem --checked, valores fora da faixa são cortados para o número de bits do campo. Com --checked,
o serialize lança um erro com o caminho do campo:
RangeError: Player.path[3].x: 5000 não cabe em ui12 (0..4095)Erros de schema indicam arquivo, linha e coluna:
erro: Player.txt:4:8: tipo 'ui0' inválido (N deve estar entre 1 e 64)Entendendo os pacotes
O pacote não carrega metadados; toda a informação vem do schema e destas ferramentas.
binser explain mostra o layout:
Player FIXED_SIZE = null (tamanho variável; parte fixa inicial: 56 bits)
offset bits campo tipo
0 8 x i8
8 8 y i8
16 1 alive bool
17 7 life ui7
24 32 speed f32
56 32 + 8n name utf8
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| x | y |a| life | speed |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| speed (cont.) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
seguido de: name (utf8)binser dump decodifica um pacote campo a campo:
$ binser dump -s Player.txt --hex "0A 14 E4 41 F0 00 00 00 00 00 05 42 72 75 6E 6F"
bit len campo tipo bits valor
0 8 x i8 00001010 10
8 8 y i8 00010100 20
16 1 alive bool 1 true
17 7 life ui7 1100100 100
24 32 speed f32 01000001 11110000 00000000 00000000 30
56 32 name.len ui32 00000000 00000000 00000000 00000101 5
88 40 name utf8 42 72 75 6E 6F "Bruno"
total: 128 bits (16 bytes)Se o pacote estiver corrompido, o dump mostra os campos lidos até o erro. Com --debug, o módulo
gerado exporta inspect(ctx), que devolve essa mesma lista em runtime. O topo de todo arquivo gerado
traz a tabela de layout como comentário.
SCHEMA_HASH: as duas pontas de uma comunicação podem trocar o hash ao conectar e recusar a
conexão se forem diferentes.
Segurança
deserialize foi feito para receber dados não confiáveis:
- contadores de strings e arrays são comparados com os bits restantes antes de alocar memória — um contador de 4 bilhões gera erro imediato;
- limites (
<=N), índices de enum, tags de união e passos deqfora da faixa geram erro; - a leitura nunca passa do fim do
DataView.
A memória alocada na leitura é proporcional ao tamanho do pacote, mas pode ser bem maior que ele: um
[[ui8]!]! com milhares de arrays vazios gasta 1 bit por array no pacote e dezenas de bytes por array
na memória. Em dados não confiáveis, prefira limites ([T; <=N], utf8<=N).
Chame deserialize dentro de try/catch; qualquer erro significa dado inválido.
Desempenho
npm run bench / bun bench/run.ts (Node 24 e Bun 1.4, Windows; operações por segundo, maior é melhor):
| Cenário | biblioteca | bytes | Node encode | Node decode | Bun encode | Bun decode |
| ----------------------------------- | -------------- | ----- | ----------- | ----------- | ---------- | ---------- |
| Player (6 campos, com string) | binser | 16 | 15,3 mi | 11,2 mi | 14,5 mi | 12,4 mi |
| | protobufjs | 20 | 6,2 mi | 9,2 mi | 4,7 mi | 9,5 mi |
| | msgpackr | 40 | 5,1 mi | 5,1 mi | 5,4 mi | 6,7 mi |
| | JSON | 65 | 3,9 mi | 2,8 mi | 8,9 mi | 4,6 mi |
| 64 entidades (ids, q, enum, bool) | binser | 486 | 1,41 mi | 778 mil | 1,36 mi | 931 mil |
| | protobufjs | 1.596 | 86 mil | 223 mil | 203 mil | 299 mil |
| | msgpackr | 4.298 | 69 mil | 47 mil | 81 mil | 34 mil |
| | JSON | 5.432 | 55 mil | 50 mil | 153 mil | 102 mil |
| 6 números alinhados em bytes | binser | 23 | 42,1 mi | 21,1 mi | 18,6 mi | 15,8 mi |
| | DataView à mão | 23 | 41,3 mi | 33,1 mi | 21,9 mi | 22,3 mi |
Detalhes e histórico das otimizações em bench/README.md.
O código gerado é linear e especializado para o schema; campos alinhados em byte usam uma única
chamada do DataView.
API programática
import { compile, compileSource, explain, dump } from '@brlebtag/binser'
const { code, dts, hash, fixedSize } = compile('schemas/Player.txt', { language: 'ts', checked: false, debug: false })
const result = compileSource('x: i8\ny: i8', { language: 'js' })Desenvolvimento
npm install
npm test # testes (inclui property-based e a saída TypeScript)
npm run test:bun # a mesma suíte executada no Bun
npm run typecheck
npm run bench
npm link # comando "binser" global apontando para este repositórioLicença
MIT © 2026 Bruno G. A. Lebtag
