Skip to main content

OmniBuf
for TypeScript & JS.

Runtime and code generator for Node.js, browsers, and edge workers: serialize declarative TypeScript schemas, drop in on @bufbuild/protobuf descriptors, or generate single-pass codecs and lazy Opaque views (see the TypeScript & JS benchmarks below).

Core Runtime (Universal)
$ pnpm add @omnibuf/core
Browser & Edge (@omnibuf/web)
$ pnpm add @omnibuf/web
Node.js (@omnibuf/node)
$ pnpm add @omnibuf/node

Three ways to use OmniBuf in TypeScript.

Define schemas directly in TypeScript, pass existing @bufbuild/protobuf descriptors with no build changes, or generate single-pass codecs and lazy Opaque views with the compiler plugin.

Declarative TypeScript schemas

Define a schema in TypeScript with defineMessage (or decorate a class with @omnibufMessage and @omnibufField) and pass it to create, toBinary, and fromBinary:

  • fieldNum sets the wire field number (1..65535).
  • name sets the wire field name written to Field #0 (defaults to snake_case of the TypeScript property name).
  • type selects the wire encoding (uint32, uint64, sint32, sint64, fixed32, fixed64, float, double, bool, string, bytes, message, or map).
  • omitEmpty skips encoding when the field holds its default value.
defineMessage Schema
import {
  defineMessage,
  create,
  toBinary,
  fromBinary,
  type Message,
} from '@omnibuf/core';

interface Peer extends Message<'app.Peer'> {
  peerId: number;
  router: string;
  active: boolean;
  prefixes: number[];
}

const PeerSchema = defineMessage<Peer, 'app.Peer'>('app.Peer', {
  peerId: { fieldNum: 1, name: 'peer_id', type: 'uint32' },
  router: { fieldNum: 2, type: 'string', omitEmpty: true },
  active: { fieldNum: 3, type: 'bool' },
  prefixes: { fieldNum: 4, type: 'uint32', repeated: true },
});

const peer = create(PeerSchema, {
  peerId: 64512,
  router: 'rtr-01',
  active: true,
});
const pkt = toBinary(PeerSchema, peer);
const out = fromBinary(PeerSchema, pkt);

Existing @bufbuild/protobuf schemas

Already generating TypeScript descriptors with protoc-gen-es? Pass your existing DescMessage schemas directly to create, toBinary, fromBinary, and mergeFromBinary with no extra plugin step.

OmniBuf compiles each DescMessage into a cached field-dispatch table on first use, preserving unknown fields in $unknown and supporting both proto3 and Editions 2023/2024 descriptors.

Existing @bufbuild/protobuf Descriptors
import {
  create,
  toBinary,
  fromBinary,
} from '@omnibuf/core';
// Standard protoc-gen-es descriptor:
import { PeerUpdateSchema } from './gen/peer_pb.js';

const msg = create(PeerUpdateSchema, {
  asn: 64512,
  routerId: 'edge-rtr-fra01',
  active: true,
});
const pkt = toBinary(PeerUpdateSchema, msg);
const out = fromBinary(PeerUpdateSchema, pkt);

Generated code with protoc-gen-omnibuf-es

Add protoc-gen-omnibuf-es to your buf.gen.yaml. The plugin generates typed Message interfaces, single-pass encoders and switch-based decoders, and lazy Opaque wrapper classes.

Opaque views read individual fields directly from the Uint8Array packet via locateField and re-serialize modified packets using spliceFields without decoding untouched fields.

buf.gen.yaml
version: v2
plugins:
  - local: protoc-gen-omnibuf-es
    out: src/gen
    opt: target=ts
Generated Schema & Lazy Opaque View
import {
  create,
  toBinary,
  fromBinary,
} from '@omnibuf/core';
import {
  PeerUpdateSchema,
  OpaquePeerUpdate,
} from './gen/peer_omnibuf.pb.js';

const msg = create(PeerUpdateSchema, {
  asn: 64512,
  routerId: 'rtr-01',
  active: true,
});
const pkt = toBinary(PeerUpdateSchema, msg);

// Lazy Opaque view: O(1) hop + zero-decode splice
const view = new OpaquePeerUpdate(pkt);
if (view.getActive() && view.getAsn() === 64512) {
  view.setSequence(99n);
  const forwarded = view.marshal();
}

TypeScript & JS benchmarks.

Measured against protobufjs, @bufbuild/protobuf v2 (protobuf-es), google-protobuf, @msgpack/msgpack, and native JSON on the canonical 11-field PeerUpdate workload. Shorter bars = Faster.

Full Message Encode Speed

Measures serializing the canonical 11-field PeerUpdate message (nested Location submessage, 4 Neighbor submessages, packed uint32 prefixes, string tags, and 64-bit counters) into a Uint8Array. OmniBuf writes in a single forward pass with 2-byte length backpatching and an inline UTF-8 fast path.

OmniBuf
1.00x (base)734 ns · 1,362,684 ops/s (362 B)
protobufjs
1.75x slower1,283 ns · 779,696 ops/s (350 B)
JSON
2.45x slower1,798 ns · 556,170 ops/s (791 B)
@bufbuild/protobuf (protobuf-es)
2.88x slower2,116 ns · 472,495 ops/s (350 B)
@msgpack/msgpack
4.23x slower3,108 ns · 321,709 ops/s (622 B)
google-protobuf
6.20x slower4,551 ns · 219,719 ops/s (350 B)
Full Message Decode Speed

Measures decoding the full 11-field PeerUpdate packet back into a typed JavaScript message object. Generated OmniBuf schemas dispatch on Prefix-Varint tags in a single switch loop without reflection, reading strings and submessages in place without intermediate subarray slices.

OmniBuf
1.00x (base)826 ns · 1,210,766 ops/s
protobufjs
1.45x slower1,195 ns · 836,811 ops/s
JSON
2.41x slower1,989 ns · 502,791 ops/s
google-protobuf
3.23x slower2,665 ns · 375,248 ops/s
@msgpack/msgpack
3.42x slower2,824 ns · 354,064 ops/s
@bufbuild/protobuf (protobuf-es)
3.63x slower2,996 ns · 333,786 ops/s
Read a Single Nested Field (peer.location.city)

Measures extracting a nested string field (peer.location.city) from a serialized packet. OmniBuf hops across preceding field headers in O(1) time per field using TZCNT length prefixes without decoding untouched strings, arrays, or submessages.

OmniBuf
1.00x (base)363 ns · 2,751,375 ops/s
protobufjs
3.28x slower1,190 ns · 839,984 ops/s
JSON
5.60x slower2,031 ns · 492,286 ops/s
google-protobuf
7.32x slower2,656 ns · 376,477 ops/s
@msgpack/msgpack
7.71x slower2,797 ns · 357,494 ops/s
@bufbuild/protobuf (protobuf-es)
8.41x slower3,052 ns · 327,632 ops/s
Selective Single-Field Mutation (Update peer.sequence & Re-Serialize)

Measures updating a 64-bit sequence counter on an in-flight packet and producing the updated wire bytes. OmniBuf splices the modified field directly onto the raw Uint8Array packet without decoding or re-encoding untouched fields.

OmniBuf
1.00x (base)254 ns · 3,929,775 ops/s
protobufjs
11.09x slower2,818 ns · 354,818 ops/s
JSON
14.24x slower3,616 ns · 276,567 ops/s
@bufbuild/protobuf (protobuf-es)
20.41x slower5,185 ns · 192,877 ops/s
@msgpack/msgpack
23.20x slower5,894 ns · 169,651 ops/s
google-protobuf
28.25x slower7,175 ns · 139,378 ops/s

Benchmark Environment

Hardware & Runtime
CPU: Apple Silicon (darwin/arm64)
Runtime: Node.js v24.22.1 (V8)
Command: pnpm run bench

All benchmarks serialize, deserialize, inspect, and mutate the same 11-field PeerUpdate message used in the Go benchmark suite: unsigned and ZigZag-signed integers, booleans, IEEE-754 floats, string lists, packed uint32 prefix arrays, a 6-field nested Location sub-message, and a 4-element repeated Neighbor sub-message list.

The benchmark harness is located in benchmarks/bench.ts in the omnibuf-es repository and can be reproduced locally with pnpm run bench or just bench.

TypeScript & JS questions.

How does omnibuf-es fit into a protobuf-es (@bufbuild/protobuf) codebase?

Messages in @omnibuf/core follow the @bufbuild/protobuf v2 plain-object design: every message has a $typeName property, optional $unknown field storage, and camelCase field properties that map to snake_case names in Field #0.

The standard lifecycle functions (create, toBinary, fromBinary, mergeFromBinary, clone, equals, and isMessage) accept existing protoc-gen-es descriptors, generated protoc-gen-omnibuf-es schemas, and declarative defineMessage schemas interchangeably.

When should I use Opaque views instead of fromBinary?

Use fromBinary when your code reads most fields of a message. Use generated Opaque views in edge workers, WebSocket gateways, and telemetry pipelines that only inspect a routing field or update a counter before forwarding the packet.

Getters hop across field headers in one step without decoding untouched strings or sub-messages, and view.marshal() splices modified fields directly onto the underlying Uint8Array.

Lazy Read & Zero-Decode Splice
const view = new OpaquePeerUpdate(pkt);
const router = view.getRouterId(); // O(1) header hop

// Update field #3 (active) and field #11 (sequence) in place:
view.setActive(false);
view.setSequence(42n);
const updated = view.marshal();

How do I enable Field #0 inline field names or decode without a schema?

Pass { selfDescribing: true } to toBinary(schema, msg, options) on any schema to emit Field #0 (omnibuf.v1.SchemaMeta) at byte 0 with the root and nested field-name tables.

To inspect a packet without a schema or generated code, call decodeBlind(pkt). It returns a plain object keyed by field numbers, or by field names automatically when Field #0 is present.

Field #0 & Schema-Less Decode
import { toBinary, decodeBlind } from '@omnibuf/core';

// Prepend Field #0 (SchemaMeta) inline with the packet:
const pkt = toBinary(PeerSchema, peer, {
  selfDescribing: true,
});

// Decode any OmniBuf packet without a schema:
const fields = decodeBlind(pkt);

What is the difference between @omnibuf/core, @omnibuf/web, and @omnibuf/node?

@omnibuf/core is a zero-dependency, environment-agnostic runtime that works in browsers, Node.js, Deno, Bun, and Cloudflare Workers.

Importing @omnibuf/web registers a browser codec backed by TextEncoder.encodeInto and adds Web Streams (TransformStream) and DevTools packet inspection helpers. Importing @omnibuf/node registers a Buffer-backed UTF-8 codec, Node stream framing (writeFrame, readFrames), and .fds.json / .fds.pb.gz descriptor loaders.

Browser & Node.js Adapters
// Browser / Edge: TextEncoder.encodeInto + Web Streams
import {
  createOmniBufDecodeStream,
  inspectPacket,
} from '@omnibuf/web';

// Node.js: Buffer UTF-8 codec + stream framing
import { writeFrame, readFrames } from '@omnibuf/node';
Explore the wire layout, Go implementation & specification