diff --git a/packages/bundle-size/README.md b/packages/bundle-size/README.md
index f4c4ff906..8429ce1b9 100644
--- a/packages/bundle-size/README.md
+++ b/packages/bundle-size/README.md
@@ -16,11 +16,11 @@ usually do. We repeat this for an increasing number of files.
| code generator | files | bundle size | minified | compressed |
| ------------------- | ----: | ----------: | --------: | ---------: |
-| Protobuf-ES | 1 | 133,010 b | 68,782 b | 15,803 b |
-| Protobuf-ES | 4 | 135,199 b | 70,289 b | 16,433 b |
-| Protobuf-ES | 8 | 137,961 b | 72,060 b | 16,996 b |
-| Protobuf-ES | 16 | 148,411 b | 80,041 b | 19,349 b |
-| Protobuf-ES | 32 | 176,202 b | 102,059 b | 24,783 b |
+| Protobuf-ES | 1 | 142,632 b | 72,245 b | 16,533 b |
+| Protobuf-ES | 4 | 144,821 b | 73,753 b | 17,219 b |
+| Protobuf-ES | 8 | 147,583 b | 75,524 b | 17,750 b |
+| Protobuf-ES | 16 | 158,033 b | 83,505 b | 20,074 b |
+| Protobuf-ES | 32 | 185,824 b | 105,523 b | 25,528 b |
| protobuf-javascript | 1 | 314,120 b | 244,024 b | 35,999 b |
| protobuf-javascript | 4 | 340,137 b | 258,996 b | 37,473 b |
| protobuf-javascript | 8 | 360,931 b | 270,573 b | 38,585 b |
diff --git a/packages/bundle-size/chart.svg b/packages/bundle-size/chart.svg
index 740161bb2..a4e151625 100644
--- a/packages/bundle-size/chart.svg
+++ b/packages/bundle-size/chart.svg
@@ -43,14 +43,14 @@
0 KiB
-
+
Protobuf-ES
-Protobuf-ES 15.43 KiB for 1 files
-Protobuf-ES 16.05 KiB for 4 files
-Protobuf-ES 16.6 KiB for 8 files
-Protobuf-ES 18.9 KiB for 16 files
-Protobuf-ES 24.2 KiB for 32 files
+Protobuf-ES 16.15 KiB for 1 files
+Protobuf-ES 16.82 KiB for 4 files
+Protobuf-ES 17.33 KiB for 8 files
+Protobuf-ES 19.6 KiB for 16 files
+Protobuf-ES 24.93 KiB for 32 files
diff --git a/packages/protobuf-test/src/wire/binary-encoding-l0.test.ts b/packages/protobuf-test/src/wire/binary-encoding-l0.test.ts
new file mode 100644
index 000000000..550189d19
--- /dev/null
+++ b/packages/protobuf-test/src/wire/binary-encoding-l0.test.ts
@@ -0,0 +1,253 @@
+// Copyright 2021-2026 Buf Technologies, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { suite, test } from "node:test";
+import * as assert from "node:assert";
+import { BinaryReader, BinaryWriter, WireType } from "@bufbuild/protobuf/wire";
+
+/**
+ * L0 contiguous-buffer BinaryWriter — targeted tests covering behaviours
+ * introduced by the rewrite (spec §8.2): buffer growth, placeholder-and-shift
+ * fork/join, ASCII fast-path, int64 tri-dispatch, and the additive API.
+ */
+void suite("BinaryWriter (L0 contiguous-buffer)", () => {
+ void suite("ensureCapacity growth", () => {
+ void test("grows past the initial 1,024-byte capacity", () => {
+ const writer = new BinaryWriter();
+ // Writing a large `raw` chunk forces at least one grow.
+ const payload = new Uint8Array(4096);
+ for (let i = 0; i < payload.length; i++) payload[i] = i & 0xff;
+ writer.raw(payload);
+ const bytes = writer.finish();
+ assert.strictEqual(bytes.byteLength, 4096);
+ assert.deepStrictEqual(bytes, payload);
+ });
+
+ void test("single grow satisfies a request larger than 2× current", () => {
+ const writer = new BinaryWriter();
+ // 100 KB in one shot — growth loop must keep doubling.
+ const big = new Uint8Array(100_000);
+ big[0] = 0x42;
+ big[big.length - 1] = 0x77;
+ writer.raw(big);
+ const bytes = writer.finish();
+ assert.strictEqual(bytes.byteLength, 100_000);
+ assert.strictEqual(bytes[0], 0x42);
+ assert.strictEqual(bytes[bytes.length - 1], 0x77);
+ });
+
+ void test("repeated small grows do not lose previously written bytes", () => {
+ // Tiny initial capacity exercises the growth path every few writes.
+ const writer = new BinaryWriter(undefined, 4);
+ const expected: number[] = [];
+ for (let i = 0; i < 500; i++) {
+ writer.uint32(i);
+ // mirror the encoding we expect to see back
+ let v = i;
+ while (v > 0x7f) {
+ expected.push((v & 0x7f) | 0x80);
+ v = v >>> 7;
+ }
+ expected.push(v);
+ }
+ assert.deepStrictEqual(Array.from(writer.finish()), expected);
+ });
+ });
+
+ void suite("fork/join placeholder shift", () => {
+ // Every boundary where the length varint size changes.
+ for (const len of [0, 1, 127, 128, 16_383, 16_384, 2_097_151, 2_097_152]) {
+ void test(`length ${len} produces byte-identical framing`, () => {
+ const payload = new Uint8Array(len);
+ for (let i = 0; i < len; i++) payload[i] = i & 0xff;
+ // Build via fork/join
+ const forked = new BinaryWriter()
+ .tag(1, WireType.LengthDelimited)
+ .fork()
+ .raw(payload)
+ .join()
+ .finish();
+ // Build via explicit length-prefix
+ const explicit = new BinaryWriter()
+ .tag(1, WireType.LengthDelimited)
+ .uint32(len)
+ .raw(payload)
+ .finish();
+ assert.deepStrictEqual(forked, explicit);
+ });
+ }
+ });
+
+ void test("nested fork/join at depth 10 round-trips", () => {
+ const writer = new BinaryWriter();
+ const depth = 10;
+ for (let i = 0; i < depth; i++) {
+ writer.tag(1, WireType.LengthDelimited).fork();
+ }
+ writer.tag(2, WireType.Varint).uint32(42);
+ for (let i = 0; i < depth; i++) {
+ writer.join();
+ }
+ const bytes = writer.finish();
+ // Decode back down the same chain
+ let reader: BinaryReader = new BinaryReader(bytes);
+ for (let i = 0; i < depth; i++) {
+ const [fieldNo, wire] = reader.tag();
+ assert.strictEqual(fieldNo, 1);
+ assert.strictEqual(wire, WireType.LengthDelimited);
+ reader = new BinaryReader(reader.bytes());
+ }
+ const [leafFieldNo, leafWire] = reader.tag();
+ assert.strictEqual(leafFieldNo, 2);
+ assert.strictEqual(leafWire, WireType.Varint);
+ assert.strictEqual(reader.uint32(), 42);
+ });
+
+ void test("join() without matching fork() throws", () => {
+ assert.throws(() => new BinaryWriter().join(), {
+ message: /invalid state, fork stack empty/,
+ });
+ });
+
+ void suite("string ASCII fast-path / UTF-8 fallback", () => {
+ for (const s of [
+ "",
+ "GET",
+ "trace_id",
+ "0123456789abcdef",
+ "a".repeat(2048), // forces multiple grows on a small-cap writer
+ "\u00e9", // é — non-ASCII, 2-byte UTF-8
+ "hello \ud83d\ude00!", // smiley emoji, 4-byte UTF-8
+ "mixed ASCII and café", // mixed
+ ]) {
+ void test(`round-trips "${s.length > 20 ? s.slice(0, 20) + "…" : s}"`, () => {
+ const bytes = new BinaryWriter()
+ .tag(1, WireType.LengthDelimited)
+ .string(s)
+ .finish();
+ const reader = new BinaryReader(bytes);
+ const [fieldNo, wire] = reader.tag();
+ assert.strictEqual(fieldNo, 1);
+ assert.strictEqual(wire, WireType.LengthDelimited);
+ assert.strictEqual(reader.string(), s);
+ });
+ }
+ });
+
+ void suite("int64 family — tri-dispatch parity", () => {
+ const cases: { name: string; val: number | bigint | string }[] = [
+ { name: "number 0", val: 0 },
+ { name: "number small", val: 123 },
+ { name: "number 2^31", val: 0x80000000 },
+ { name: "number 2^52", val: 0x10000000000000 },
+ { name: "bigint 0n", val: BigInt(0) },
+ { name: "bigint 1n", val: BigInt(1) },
+ { name: "bigint max positive", val: BigInt("9223372036854775807") },
+ { name: "string 0", val: "0" },
+ {
+ name: "string max positive",
+ val: "9223372036854775807",
+ },
+ ];
+ for (const kase of cases) {
+ void test(`uint64 ${kase.name}`, () => {
+ const n = new BinaryWriter().uint64(kase.val).finish();
+ // Re-encode the same value as a string (canonical path) and compare
+ const s = new BinaryWriter().uint64(String(kase.val)).finish();
+ assert.deepStrictEqual(n, s);
+ });
+ void test(`fixed64 ${kase.name}`, () => {
+ const n = new BinaryWriter().fixed64(kase.val).finish();
+ const s = new BinaryWriter().fixed64(String(kase.val)).finish();
+ assert.deepStrictEqual(n, s);
+ });
+ }
+ const signedCases: { name: string; val: number | bigint | string }[] = [
+ { name: "bigint -1n", val: BigInt(-1) },
+ { name: "bigint min negative", val: BigInt("-9223372036854775808") },
+ { name: "number -1", val: -1 },
+ { name: "string -1", val: "-1" },
+ ];
+ for (const kase of signedCases) {
+ void test(`int64 ${kase.name}`, () => {
+ const n = new BinaryWriter().int64(kase.val).finish();
+ const s = new BinaryWriter().int64(String(kase.val)).finish();
+ assert.deepStrictEqual(n, s);
+ });
+ void test(`sfixed64 ${kase.name}`, () => {
+ const n = new BinaryWriter().sfixed64(kase.val).finish();
+ const s = new BinaryWriter().sfixed64(String(kase.val)).finish();
+ assert.deepStrictEqual(n, s);
+ });
+ }
+ });
+
+ void suite("additive API", () => {
+ void test("currentOffset reports write position", () => {
+ const writer = new BinaryWriter();
+ assert.strictEqual(writer.currentOffset(), 0);
+ writer.tag(1, WireType.Varint);
+ assert.strictEqual(writer.currentOffset(), 1);
+ writer.uint32(200);
+ assert.strictEqual(writer.currentOffset(), 3);
+ });
+
+ void test("ensureCapacity makes the writer idempotent for re-use", () => {
+ const writer = new BinaryWriter();
+ writer.ensureCapacity(1_000_000);
+ writer.uint32(7);
+ assert.deepStrictEqual(Array.from(writer.finish()), [7]);
+ });
+
+ void test("patchVarint32At writes identical bytes to uint32()", () => {
+ // Reserve 1 byte (enough for small values), patch, compare to a direct
+ // `uint32` encoding.
+ const writer = new BinaryWriter();
+ const offset = writer.currentOffset();
+ writer.ensureCapacity(1);
+ writer.raw(new Uint8Array(1)); // reserve
+ writer.patchVarint32At(offset, 42);
+ const patched = writer.finish();
+ const direct = new BinaryWriter().uint32(42).finish();
+ assert.deepStrictEqual(patched, direct);
+ });
+
+ void test("patchVarint32At handles multi-byte varints when fully reserved", () => {
+ const writer = new BinaryWriter();
+ // 300 encodes as 2 bytes (`0xac 0x02`); reserve 2.
+ const offset = writer.currentOffset();
+ writer.ensureCapacity(2);
+ writer.raw(new Uint8Array(2));
+ writer.patchVarint32At(offset, 300);
+ const patched = writer.finish();
+ const direct = new BinaryWriter().uint32(300).finish();
+ assert.deepStrictEqual(patched, direct);
+ });
+ });
+
+ void test("finish returns a stable view even after writer re-use", () => {
+ const writer = new BinaryWriter();
+ writer.tag(1, WireType.Varint).uint32(42);
+ const first = writer.finish();
+ // Re-use writer: the legacy test expects identical output.
+ writer.tag(1, WireType.Varint).uint32(42);
+ const second = writer.finish();
+ assert.deepStrictEqual(first, second);
+ // The first slice should remain untouched after additional writes on the
+ // new backing buffer.
+ writer.tag(1, WireType.Varint).uint32(99);
+ writer.finish();
+ assert.deepStrictEqual(Array.from(first), Array.from(second));
+ });
+});
diff --git a/packages/protobuf/src/wire/binary-encoding.ts b/packages/protobuf/src/wire/binary-encoding.ts
index 34fd9c81a..3fa527f37 100644
--- a/packages/protobuf/src/wire/binary-encoding.ts
+++ b/packages/protobuf/src/wire/binary-encoding.ts
@@ -12,12 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {
- varint32read,
- varint32write,
- varint64read,
- varint64write,
-} from "./varint.js";
+import { varint32read, varint64read } from "./varint.js";
import { protoInt64 } from "../proto-int64.js";
import { getTextEncoding } from "./text-encoding.js";
@@ -93,90 +88,166 @@ export const INT32_MAX = 0x7fffffff;
*/
export const INT32_MIN = -0x80000000;
+/**
+ * L0 contiguous-buffer BinaryWriter.
+ *
+ * Replaces the legacy chunk-list + scratch-array state with a single growable
+ * Uint8Array plus an integer-offset stack for `fork()`/`join()` framing.
+ *
+ * Public wire surface is identical to the legacy writer (same 20 methods,
+ * same signatures, byte-identical output). Three additive helpers
+ * (`ensureCapacity`, `currentOffset`, `patchVarint32At`) are exposed for
+ * upcoming L1/L2 consumers.
+ *
+ * Implementation notes:
+ * - D1/D2/D3: single Uint8Array, initial capacity 1024, 2× growth.
+ * - D4/D5/D6: fork/join use a 1-byte varint placeholder + `copyWithin` shift.
+ * Fork stack stores integer offsets only — no per-fork object allocation.
+ * - D7: `string()` probes for ASCII and writes bytes inline, falling back to
+ * the injected UTF-8 encoder for non-ASCII input.
+ * - D8: `finish()` returns `buf.subarray(0, pos)` — no extra copy. The
+ * returned view shares the writer's backing buffer. The writer is
+ * single-shot; construct a fresh instance per encode (D9).
+ * - D10: removed the `protected buf: number[]` field from the legacy writer.
+ * - D11: `DataView` cached and rebuilt on grow.
+ * - D13: int64 family uses a `typeof` tri-dispatch (number/bigint/string).
+ */
export class BinaryWriter {
- /**
- * We cannot allocate a buffer for the entire output
- * because we don't know its size.
- *
- * So we collect smaller chunks of known size and
- * concat them later.
- *
- * Use `raw()` to push data to this array. It will flush
- * `buf` first.
- */
- private chunks: Uint8Array[];
+ /** Contiguous growable buffer. Bytes in [0, pos) are valid output. */
+ private buf: Uint8Array;
- /**
- * A growing buffer for byte values. If you don't know
- * the size of the data you are writing, push to this
- * array.
- */
- protected buf: number[];
+ /** Write cursor. */
+ private pos = 0;
+
+ /** Lazy DataView over `buf`. Rebuilt on grow. */
+ private view: DataView;
+
+ /** Stack of reserved length-placeholder offsets for active forks. */
+ private stack: number[] = [];
+
+ /** Initial capacity used when resetting after `finish()`. */
+ private readonly initialCapacity: number;
/**
- * Previous fork states.
+ * Set to `true` by `finish()` to indicate the next write must allocate a
+ * fresh backing buffer. Defers the reset allocation from `finish()` to the
+ * first reuse write, keeping the single-shot path (one encode per writer)
+ * allocation-free beyond the encoded bytes themselves.
*/
- private stack: Array<{ chunks: Uint8Array[]; buf: number[] }> = [];
+ private dirtyAfterFinish = false;
constructor(
private readonly encodeUtf8: (
text: string,
) => Uint8Array = getTextEncoding().encodeUtf8,
+ initialCapacity = 1024,
) {
- this.chunks = [];
- this.buf = [];
+ const cap = initialCapacity > 0 ? initialCapacity : 1024;
+ this.initialCapacity = cap;
+ this.buf = new Uint8Array(cap);
+ this.view = new DataView(this.buf.buffer, this.buf.byteOffset);
+ }
+
+ // ── Additive L0 API ─────────────────────────────────────────────────────
+
+ /**
+ * Ensure at least `n` additional bytes are writable at `pos`.
+ *
+ * Grows the backing buffer by doubling (at minimum) until it can hold
+ * `pos + n` bytes. Invalidates and rebuilds the cached `DataView`.
+ */
+ ensureCapacity(n: number): void {
+ // If the previous encode finished, swap in a fresh buffer before writing
+ // so the returned subarray view stays stable.
+ if (this.dirtyAfterFinish) {
+ this.buf = new Uint8Array(this.initialCapacity);
+ this.view = new DataView(this.buf.buffer, this.buf.byteOffset);
+ this.dirtyAfterFinish = false;
+ }
+ const need = this.pos + n;
+ const cur = this.buf.length;
+ if (need <= cur) return;
+ let cap = cur * 2;
+ if (cap === 0) cap = 1024;
+ while (cap < need) cap *= 2;
+ const next = new Uint8Array(cap);
+ next.set(this.buf);
+ this.buf = next;
+ this.view = new DataView(next.buffer, next.byteOffset);
+ }
+
+ /**
+ * Return the current write offset.
+ */
+ currentOffset(): number {
+ return this.pos;
+ }
+
+ /**
+ * Back-patch an unsigned 32-bit varint at a previously reserved offset.
+ *
+ * Contract: the caller is responsible for having reserved at least
+ * `computeVarint32Size(value)` bytes at `offset`. No bounds check — this is
+ * a performance primitive for L1/L2 consumers.
+ */
+ patchVarint32At(offset: number, value: number): void {
+ this.writeVarint32At(offset, value >>> 0);
}
+ // ── Preserved public API ────────────────────────────────────────────────
+
/**
- * Return all bytes written and reset this writer.
+ * Return all bytes written and reset this writer for reuse.
+ *
+ * The returned Uint8Array is a subarray view over the writer's previous
+ * internal buffer — no copy is made. The writer installs a fresh buffer for
+ * subsequent writes so the returned slice is not clobbered by reuse. As in
+ * the legacy writer, the `stack` is cleared and `pos` reset to 0.
*/
finish(): Uint8Array {
- if (this.buf.length) {
- this.chunks.push(new Uint8Array(this.buf)); // flush the buffer
- this.buf = [];
- }
- let len = 0;
- for (let i = 0; i < this.chunks.length; i++) len += this.chunks[i].length;
- let bytes = new Uint8Array(len);
- let offset = 0;
- for (let i = 0; i < this.chunks.length; i++) {
- bytes.set(this.chunks[i], offset);
- offset += this.chunks[i].length;
- }
- this.chunks = [];
- return bytes;
+ const out = this.buf.subarray(0, this.pos) as Uint8Array;
+ // Lazily swap buffers on the next write rather than here — keeps
+ // single-shot encoding allocation-free beyond `out` itself.
+ this.pos = 0;
+ this.stack.length = 0;
+ this.dirtyAfterFinish = true;
+ return out;
}
/**
- * Start a new fork for length-delimited data like a message
- * or a packed repeated field.
+ * Start a new fork for length-delimited data like a message or a packed
+ * repeated field. Reserves a single-byte placeholder for the payload length
+ * varint; the caller writes the payload and then calls `join()`.
*
* Must be joined later with `join()`.
*/
fork(): this {
- this.stack.push({ chunks: this.chunks, buf: this.buf });
- this.chunks = [];
- this.buf = [];
+ this.ensureCapacity(1);
+ this.stack.push(this.pos);
+ this.pos += 1;
return this;
}
/**
- * Join the last fork. Write its length and bytes, then
- * return to the previous state.
+ * Join the last fork. Computes the payload length, shifts the payload right
+ * if the length varint needs more than one byte, then patches the varint at
+ * the reserved placeholder offset.
*/
join(): this {
- // get chunk of fork
- let chunk = this.finish();
-
- // restore previous state
- let prev = this.stack.pop();
- if (!prev) throw new Error("invalid state, fork stack empty");
- this.chunks = prev.chunks;
- this.buf = prev.buf;
-
- // write length of chunk as varint
- this.uint32(chunk.byteLength);
- return this.raw(chunk);
+ const placeholder = this.stack.pop();
+ if (placeholder === undefined)
+ throw new Error("invalid state, fork stack empty");
+ const contentStart = placeholder + 1;
+ const contentLen = this.pos - contentStart;
+ const varintSize = computeVarint32Size(contentLen);
+ if (varintSize > 1) {
+ const shift = varintSize - 1;
+ this.ensureCapacity(shift);
+ this.buf.copyWithin(contentStart + shift, contentStart, this.pos);
+ this.pos += shift;
+ }
+ this.writeVarint32At(placeholder, contentLen);
+ return this;
}
/**
@@ -194,11 +265,10 @@ export class BinaryWriter {
* Write a chunk of raw bytes.
*/
raw(chunk: Uint8Array): this {
- if (this.buf.length) {
- this.chunks.push(new Uint8Array(this.buf));
- this.buf = [];
- }
- this.chunks.push(chunk);
+ const len = chunk.byteLength;
+ this.ensureCapacity(len);
+ this.buf.set(chunk, this.pos);
+ this.pos += len;
return this;
}
@@ -207,14 +277,31 @@ export class BinaryWriter {
*/
uint32(value: number): this {
assertUInt32(value);
-
- // write value as varint 32, inlined for speed
- while (value > 0x7f) {
- this.buf.push((value & 0x7f) | 0x80);
- value = value >>> 7;
+ this.ensureCapacity(5);
+ const buf = this.buf;
+ let p = this.pos;
+ if (value < 0x80) {
+ buf[p++] = value;
+ } else if (value < 0x4000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = value >>> 7;
+ } else if (value < 0x200000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = value >>> 14;
+ } else if (value < 0x10000000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 14) & 0x7f) | 0x80;
+ buf[p++] = value >>> 21;
+ } else {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 14) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 21) & 0x7f) | 0x80;
+ buf[p++] = value >>> 28;
}
- this.buf.push(value);
-
+ this.pos = p;
return this;
}
@@ -223,7 +310,47 @@ export class BinaryWriter {
*/
int32(value: number): this {
assertInt32(value);
- varint32write(value, this.buf);
+ if (value >= 0) {
+ // Same as uint32 varint encoding for non-negative values.
+ this.ensureCapacity(5);
+ const buf = this.buf;
+ let p = this.pos;
+ if (value < 0x80) {
+ buf[p++] = value;
+ } else if (value < 0x4000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = value >>> 7;
+ } else if (value < 0x200000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = value >>> 14;
+ } else if (value < 0x10000000) {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 14) & 0x7f) | 0x80;
+ buf[p++] = value >>> 21;
+ } else {
+ buf[p++] = (value & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 14) & 0x7f) | 0x80;
+ buf[p++] = ((value >>> 21) & 0x7f) | 0x80;
+ buf[p++] = value >>> 28;
+ }
+ this.pos = p;
+ } else {
+ // Negative int32 is sign-extended to 10-byte varint (matching the
+ // legacy `varint32write` negative path).
+ this.ensureCapacity(10);
+ const buf = this.buf;
+ let p = this.pos;
+ let v = value;
+ for (let i = 0; i < 9; i++) {
+ buf[p++] = (v & 0x7f) | 0x80;
+ v = v >> 7;
+ }
+ buf[p++] = 1;
+ this.pos = p;
+ }
return this;
}
@@ -231,7 +358,8 @@ export class BinaryWriter {
* Write a `bool` value, a varint.
*/
bool(value: boolean): this {
- this.buf.push(value ? 1 : 0);
+ this.ensureCapacity(1);
+ this.buf[this.pos++] = value ? 1 : 0;
return this;
}
@@ -239,17 +367,53 @@ export class BinaryWriter {
* Write a `bytes` value, length-delimited arbitrary data.
*/
bytes(value: Uint8Array): this {
- this.uint32(value.byteLength); // write length of chunk as varint
- return this.raw(value);
+ const len = value.byteLength;
+ this.uint32(len);
+ this.ensureCapacity(len);
+ this.buf.set(value, this.pos);
+ this.pos += len;
+ return this;
}
/**
* Write a `string` value, length-delimited data converted to UTF-8 text.
+ *
+ * Uses a single-pass ASCII fast path: if every code unit is ≤ 0x7f, bytes
+ * are written inline without invoking `TextEncoder`. Otherwise falls back
+ * to the injected UTF-8 encoder. Non-string inputs are routed through the
+ * encoder (which coerces via `String()`), matching legacy behaviour.
*/
string(value: string): this {
- let chunk = this.encodeUtf8(value);
- this.uint32(chunk.byteLength); // write length of chunk as varint
- return this.raw(chunk);
+ if (typeof value === "string") {
+ const len = value.length;
+ // Single-pass ASCII probe.
+ let isAscii = true;
+ for (let i = 0; i < len; i++) {
+ if (value.charCodeAt(i) > 0x7f) {
+ isAscii = false;
+ break;
+ }
+ }
+ if (isAscii) {
+ this.uint32(len);
+ this.ensureCapacity(len);
+ const buf = this.buf;
+ let p = this.pos;
+ for (let i = 0; i < len; i++) {
+ buf[p++] = value.charCodeAt(i);
+ }
+ this.pos = p;
+ return this;
+ }
+ }
+ // Fallback: non-string or non-ASCII — let the injected encoder handle it.
+ const bytes = this.encodeUtf8(value);
+ const blen = bytes.byteLength;
+ this.uint32(blen);
+ this.ensureCapacity(blen);
+ this.buf.set(bytes, this.pos);
+ this.pos += blen;
+ return this;
}
/**
@@ -257,18 +421,20 @@ export class BinaryWriter {
*/
float(value: number): this {
assertFloat32(value);
- let chunk = new Uint8Array(4);
- new DataView(chunk.buffer).setFloat32(0, value, true);
- return this.raw(chunk);
+ this.ensureCapacity(4);
+ this.view.setFloat32(this.pos, value, true);
+ this.pos += 4;
+ return this;
}
/**
* Write a `double` value, a 64-bit floating point number.
*/
double(value: number): this {
- let chunk = new Uint8Array(8);
- new DataView(chunk.buffer).setFloat64(0, value, true);
- return this.raw(chunk);
+ this.ensureCapacity(8);
+ this.view.setFloat64(this.pos, value, true);
+ this.pos += 8;
+ return this;
}
/**
@@ -276,9 +442,10 @@ export class BinaryWriter {
*/
fixed32(value: number): this {
assertUInt32(value);
- let chunk = new Uint8Array(4);
- new DataView(chunk.buffer).setUint32(0, value, true);
- return this.raw(chunk);
+ this.ensureCapacity(4);
+ this.view.setUint32(this.pos, value, true);
+ this.pos += 4;
+ return this;
}
/**
@@ -286,9 +453,10 @@ export class BinaryWriter {
*/
sfixed32(value: number): this {
assertInt32(value);
- let chunk = new Uint8Array(4);
- new DataView(chunk.buffer).setInt32(0, value, true);
- return this.raw(chunk);
+ this.ensureCapacity(4);
+ this.view.setInt32(this.pos, value, true);
+ this.pos += 4;
+ return this;
}
/**
@@ -297,8 +465,33 @@ export class BinaryWriter {
sint32(value: number): this {
assertInt32(value);
// zigzag encode
- value = ((value << 1) ^ (value >> 31)) >>> 0;
- varint32write(value, this.buf);
+ const zz = ((value << 1) ^ (value >> 31)) >>> 0;
+ // zz is unsigned 32-bit — reuse uint32 encoding path.
+ this.ensureCapacity(5);
+ const buf = this.buf;
+ let p = this.pos;
+ if (zz < 0x80) {
+ buf[p++] = zz;
+ } else if (zz < 0x4000) {
+ buf[p++] = (zz & 0x7f) | 0x80;
+ buf[p++] = zz >>> 7;
+ } else if (zz < 0x200000) {
+ buf[p++] = (zz & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 7) & 0x7f) | 0x80;
+ buf[p++] = zz >>> 14;
+ } else if (zz < 0x10000000) {
+ buf[p++] = (zz & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 14) & 0x7f) | 0x80;
+ buf[p++] = zz >>> 21;
+ } else {
+ buf[p++] = (zz & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 7) & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 14) & 0x7f) | 0x80;
+ buf[p++] = ((zz >>> 21) & 0x7f) | 0x80;
+ buf[p++] = zz >>> 28;
+ }
+ this.pos = p;
return this;
}
@@ -306,32 +499,26 @@ export class BinaryWriter {
* Write a `sfixed64` value, a signed, fixed-length 64-bit integer.
*/
sfixed64(value: string | number | bigint): this {
- let chunk = new Uint8Array(8),
- view = new DataView(chunk.buffer),
- tc = protoInt64.enc(value);
- view.setInt32(0, tc.lo, true);
- view.setInt32(4, tc.hi, true);
- return this.raw(chunk);
+ const lh = signedInt64LoHi(value);
+ this.writeFixed64LoHi(lh.lo, lh.hi);
+ return this;
}
/**
* Write a `fixed64` value, an unsigned, fixed-length 64 bit integer.
*/
fixed64(value: string | number | bigint): this {
- let chunk = new Uint8Array(8),
- view = new DataView(chunk.buffer),
- tc = protoInt64.uEnc(value);
- view.setInt32(0, tc.lo, true);
- view.setInt32(4, tc.hi, true);
- return this.raw(chunk);
+ const lh = unsignedInt64LoHi(value);
+ this.writeFixed64LoHi(lh.lo, lh.hi);
+ return this;
}
/**
* Write a `int64` value, a signed 64-bit varint.
*/
int64(value: string | number | bigint): this {
- let tc = protoInt64.enc(value);
- varint64write(tc.lo, tc.hi, this.buf);
+ const lh = signedInt64LoHi(value);
+ this.writeVarint64(lh.lo, lh.hi);
return this;
}
@@ -339,12 +526,12 @@ export class BinaryWriter {
* Write a `sint64` value, a signed, zig-zag-encoded 64-bit varint.
*/
sint64(value: string | number | bigint): this {
- const tc = protoInt64.enc(value),
- // zigzag encode
- sign = tc.hi >> 31,
- lo = (tc.lo << 1) ^ sign,
- hi = ((tc.hi << 1) | (tc.lo >>> 31)) ^ sign;
- varint64write(lo, hi, this.buf);
+ const lh = signedInt64LoHi(value);
+ // zigzag encode
+ const sign = lh.hi >> 31;
+ const zLo = ((lh.lo << 1) ^ sign) >>> 0;
+ const zHi = (((lh.hi << 1) | (lh.lo >>> 31)) ^ sign) >>> 0;
+ this.writeVarint64(zLo, zHi);
return this;
}
@@ -352,10 +539,218 @@ export class BinaryWriter {
* Write a `uint64` value, an unsigned 64-bit varint.
*/
uint64(value: string | number | bigint): this {
- const tc = protoInt64.uEnc(value);
- varint64write(tc.lo, tc.hi, this.buf);
+ const lh = unsignedInt64LoHi(value);
+ this.writeVarint64(lh.lo, lh.hi);
return this;
}
+
+ // ── Private helpers ─────────────────────────────────────────────────────
+
+ /**
+ * Write an unsigned 32-bit varint at a given offset.
+ *
+ * Caller is responsible for ensuring the buffer has enough room at that
+ * offset (via `ensureCapacity` or prior reservation).
+ */
+ private writeVarint32At(offset: number, v: number): void {
+ const buf = this.buf;
+ if (v < 0x80) {
+ buf[offset] = v;
+ } else if (v < 0x4000) {
+ buf[offset] = (v & 0x7f) | 0x80;
+ buf[offset + 1] = v >>> 7;
+ } else if (v < 0x200000) {
+ buf[offset] = (v & 0x7f) | 0x80;
+ buf[offset + 1] = ((v >>> 7) & 0x7f) | 0x80;
+ buf[offset + 2] = v >>> 14;
+ } else if (v < 0x10000000) {
+ buf[offset] = (v & 0x7f) | 0x80;
+ buf[offset + 1] = ((v >>> 7) & 0x7f) | 0x80;
+ buf[offset + 2] = ((v >>> 14) & 0x7f) | 0x80;
+ buf[offset + 3] = v >>> 21;
+ } else {
+ buf[offset] = (v & 0x7f) | 0x80;
+ buf[offset + 1] = ((v >>> 7) & 0x7f) | 0x80;
+ buf[offset + 2] = ((v >>> 14) & 0x7f) | 0x80;
+ buf[offset + 3] = ((v >>> 21) & 0x7f) | 0x80;
+ buf[offset + 4] = v >>> 28;
+ }
+ }
+
+ /**
+ * Write 8 little-endian bytes for `fixed64` / `sfixed64` from 32-bit halves.
+ */
+ private writeFixed64LoHi(lo: number, hi: number): void {
+ this.ensureCapacity(8);
+ const buf = this.buf;
+ const p = this.pos;
+ buf[p] = lo & 0xff;
+ buf[p + 1] = (lo >>> 8) & 0xff;
+ buf[p + 2] = (lo >>> 16) & 0xff;
+ buf[p + 3] = (lo >>> 24) & 0xff;
+ buf[p + 4] = hi & 0xff;
+ buf[p + 5] = (hi >>> 8) & 0xff;
+ buf[p + 6] = (hi >>> 16) & 0xff;
+ buf[p + 7] = (hi >>> 24) & 0xff;
+ this.pos = p + 8;
+ }
+
+ /**
+ * Write a 64-bit varint given as two 32-bit halves.
+ *
+ * Mirrors `varint64write` byte-for-byte but writes into the contiguous
+ * buffer instead of pushing into a `number[]`.
+ */
+ private writeVarint64(lo: number, hi: number): void {
+ this.ensureCapacity(10);
+ const buf = this.buf;
+ let p = this.pos;
+ // First 4 bytes from `lo` (7 bits × 4 = 28 bits).
+ for (let i = 0; i < 28; i += 7) {
+ const shift = lo >>> i;
+ const hasNext = !(shift >>> 7 === 0 && hi === 0);
+ buf[p++] = (hasNext ? shift | 0x80 : shift) & 0xff;
+ if (!hasNext) {
+ this.pos = p;
+ return;
+ }
+ }
+ // The 5th byte splits across lo/hi.
+ const splitBits = ((lo >>> 28) & 0x0f) | ((hi & 0x07) << 4);
+ const hasMoreBits = !(hi >> 3 === 0);
+ buf[p++] = (hasMoreBits ? splitBits | 0x80 : splitBits) & 0xff;
+ if (!hasMoreBits) {
+ this.pos = p;
+ return;
+ }
+ // Remaining bytes from `hi` (7 bits at a time, shift starts at 3).
+ for (let i = 3; i < 31; i += 7) {
+ const shift = hi >>> i;
+ const hasNext = !(shift >>> 7 === 0);
+ buf[p++] = (hasNext ? shift | 0x80 : shift) & 0xff;
+ if (!hasNext) {
+ this.pos = p;
+ return;
+ }
+ }
+ // Final byte: top bit of hi.
+ buf[p++] = (hi >>> 31) & 0x01;
+ this.pos = p;
+ }
+}
+
+/**
+ * Shift amount used for extracting the high 32 bits of a bigint 64-bit value.
+ *
+ * A module-level constant avoids bigint literals (`32n`), which require
+ * targeting ES2020 — this file targets ES2017 per tsconfig.base.json.
+ */
+const BIGINT_32: bigint = /*@__PURE__*/ BigInt(32);
+
+/** Inclusive lower bound of signed int64 expressed as bigint. */
+const INT64_MIN_BI: bigint = /*@__PURE__*/ BigInt("-9223372036854775808");
+/** Inclusive upper bound of signed int64 expressed as bigint. */
+const INT64_MAX_BI: bigint = /*@__PURE__*/ BigInt("9223372036854775807");
+/** Inclusive upper bound of unsigned uint64 expressed as bigint. */
+const UINT64_MAX_BI: bigint = /*@__PURE__*/ BigInt("18446744073709551615");
+/** Zero as bigint (avoids `0n` literal on ES2017). */
+const ZERO_BI: bigint = /*@__PURE__*/ BigInt(0);
+
+/**
+ * Inclusive upper bound of the fast number path in signed/unsigned lo-hi
+ * splitters. Equals `2^53`. Computed via `2 ** 53` to avoid a numeric
+ * literal whose absolute value reaches 2^53 (TypeScript warning 80008):
+ * such literals cannot be represented accurately as integers in source.
+ */
+const POW_2_53: number = /*@__PURE__*/ 2 ** 53;
+
+/** Inclusive lower bound of the fast number path for signed values. */
+const NEG_POW_2_53: number = /*@__PURE__*/ -(2 ** 53);
+
+/**
+ * Return the number of bytes required to encode `v` as an unsigned 32-bit
+ * varint. Pure helper used by `join()` and `patchVarint32At` callers.
+ */
+function computeVarint32Size(v: number): number {
+ if (v < 0x80) return 1;
+ if (v < 0x4000) return 2;
+ if (v < 0x200000) return 3;
+ if (v < 0x10000000) return 4;
+ return 5;
+}
+
+/**
+ * Split a signed 64-bit value into (lo, hi) 32-bit halves with two's
+ * complement representation for negatives. Handles `number` (safe integer
+ * fast path), `bigint` (range-checked), or string (delegated to protoInt64).
+ *
+ * Invalid inputs are delegated to `protoInt64.enc()` so error messages match
+ * the legacy writer byte-for-byte.
+ */
+function signedInt64LoHi(value: string | number | bigint): {
+ lo: number;
+ hi: number;
+} {
+ const t = typeof value;
+ if (t === "number") {
+ const n = value as number;
+ // Safe-integer fast path — must be a finite integer within the 53-bit
+ // safe range. Otherwise fall through to protoInt64 for error parity.
+ if (Number.isInteger(n) && n >= NEG_POW_2_53 && n <= POW_2_53) {
+ if (n >= 0) {
+ const lo = n >>> 0;
+ const hi = ((n - lo) / 0x100000000) >>> 0;
+ return { lo, hi };
+ }
+ const abs = -n;
+ const aLo = abs >>> 0;
+ const aHi = ((abs - aLo) / 0x100000000) >>> 0;
+ // two's complement: ~abs + 1
+ const lo = (~aLo + 1) >>> 0;
+ const hi = (~aHi + (lo === 0 ? 1 : 0)) >>> 0;
+ return { lo, hi };
+ }
+ } else if (t === "bigint") {
+ const b = value as bigint;
+ if (b >= INT64_MIN_BI && b <= INT64_MAX_BI) {
+ const lo = Number(BigInt.asUintN(32, b)) >>> 0;
+ const hi = Number(BigInt.asUintN(32, b >> BIGINT_32)) >>> 0;
+ return { lo, hi };
+ }
+ }
+ // Fallback: let protoInt64 validate and either encode the value or throw
+ // with the exact error message format expected by the legacy writer.
+ const tc = protoInt64.enc(value);
+ return { lo: tc.lo, hi: tc.hi };
+}
+
+/**
+ * Split an unsigned 64-bit value into (lo, hi) 32-bit halves. Mirrors
+ * `signedInt64LoHi` but validates against the unsigned range; invalid
+ * inputs are delegated to `protoInt64.uEnc()` for error parity.
+ */
+function unsignedInt64LoHi(value: string | number | bigint): {
+ lo: number;
+ hi: number;
+} {
+ const t = typeof value;
+ if (t === "number") {
+ const n = value as number;
+ if (Number.isInteger(n) && n >= 0 && n <= POW_2_53) {
+ const lo = n >>> 0;
+ const hi = ((n - lo) / 0x100000000) >>> 0;
+ return { lo, hi };
+ }
+ } else if (t === "bigint") {
+ const b = value as bigint;
+ if (b >= ZERO_BI && b <= UINT64_MAX_BI) {
+ const lo = Number(BigInt.asUintN(32, b)) >>> 0;
+ const hi = Number(BigInt.asUintN(32, b >> BIGINT_32)) >>> 0;
+ return { lo, hi };
+ }
+ }
+ const tc = protoInt64.uEnc(value);
+ return { lo: tc.lo, hi: tc.hi };
}
export class BinaryReader {