@sofa-buffers/corelib 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,155 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@sofa-buffers/corelib` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ While the version is below `1.0.0`, breaking changes bump the **minor** version.
8
+
9
+ ## [Unreleased]
10
+
11
+ ### Changed
12
+
13
+ - **Strict UTF-8 for `string` fields (corelib-ts#85, MESSAGE_SPEC §8,
14
+ CORELIB_PLAN §6.4).** JavaScript strings are a Unicode string type, so the
15
+ corelib transcodes `string` payloads at the boundary and is now **always
16
+ strict** — there is no lossy mode and the `SOFAB_STRICT_UTF8` option is a no-op
17
+ that is omitted. Silent `U+FFFD` substitution, previously produced by both the
18
+ decoder and the encoder, is removed in **both** directions:
19
+ - *Decode:* the corelib builds the string with a **fatal** `TextDecoder`
20
+ (`new TextDecoder("utf-8", { fatal: true })`). An invalid-UTF-8 payload that
21
+ is materialized (`Cursor.readString`) is now the `INVALID` outcome —
22
+ `SofabError` with `SofabErrorCode.InvalidMsg` (`"INVALID_MSG"`) — instead of
23
+ decoding to a string full of replacement characters. Skipped fields are never
24
+ validated; embedded `U+0000` round-trips.
25
+ - *Encode:* `writeString` (both the in-memory fast path and the streaming
26
+ `TextEncoder` path) now **rejects** an **unpaired surrogate** with
27
+ `SofabError` / `SofabErrorCode.Argument` (`"ARGUMENT"`) rather than emitting
28
+ `EF BF BD`. Every valid string — ASCII, multibyte BMP, correctly paired
29
+ astral code points, embedded `U+0000` — still encodes byte-for-byte as
30
+ before.
31
+
32
+ The shared `assets/test_vectors.json` gains the top-level `invalid_utf8`
33
+ negative-vector array (tracked by corelib-c-cpp#97); the conformance suite
34
+ exercises it under the strict decode and encode paths.
35
+
36
+ ### Added
37
+
38
+ - **`Cursor.fixSub` — the delivered fixlen subtype (corelib-ts#58).** A new
39
+ public accessor on `Cursor`, the companion to `wire`, that reports the fixlen
40
+ subtype of the header `readHeader` just accepted — one of `FixlenSubtype`
41
+ (`Fp32`/`Fp64`/`String`/`Blob`) when `wire` is `Fixlen` or `ArrayFixlen`, and
42
+ `-1` otherwise. The four fixlen subtypes all share one wire type, so `wire`
43
+ alone cannot separate them; `fixSub` lets a generated guard skip a fixlen
44
+ field whose subtype contradicts the schema (MESSAGE_SPEC §7.3) — exactly as it
45
+ already does on `wire` for the other kinds — instead of passing the wire-type
46
+ guard and then throwing from the wrong-typed reader. It is *peeked* (the
47
+ subtype word is not consumed), so the matching typed reader / `skip()` still
48
+ reads and validates the word and a malformed or truncated one still surfaces
49
+ `INVALID` / `INCOMPLETE`. Completes §7.3 for the TypeScript target, matching
50
+ corelib-py's `Field.subtype` and corelib-cpp's `fixType()`.
51
+
52
+ - **Opt-in decode limits (corelib-ts#38).** A new optional `DecodeLimits`
53
+ options object — `{ maxArrayCount?, maxStringLen?, maxBlobLen? }` — is accepted
54
+ by every decode entry point: `decode(bytes, visitor, limits?)`, the `IStream`
55
+ constructor, and the `Cursor` constructor. When set, an array count or string /
56
+ blob byte length that exceeds the cap is rejected at the field's header —
57
+ before the array is sized or any payload is decoded / streamed to the visitor —
58
+ with the new `SofabErrorCode.LimitExceeded` (`"LIMIT_EXCEEDED"`). The decoder
59
+ never clamps or truncates. `LimitExceeded` is deliberately distinct from
60
+ `InvalidMsg`: exceeding a receiver-configured limit is *policy*, not wire
61
+ malformation — the identical bytes decode fine under a looser limit. **Default:
62
+ no limits (today's behavior); the corelib invents no default cap** — the values
63
+ come from the sofabgen config, baked into generated code (generator#102). Also
64
+ hardens `Cursor` so a wire array `count` larger than the bytes remaining is
65
+ rejected as `Incomplete` before `new Array(count)` is sized, so a hostile count
66
+ can never drive an allocation larger than the input.
67
+ - **Finish-less three-valued decode outcome (MESSAGE_SPEC §7).** Truncation — a
68
+ decode that ends *inside* a field — is now a distinct outcome from a malformed
69
+ message. New `SofabErrorCode.Incomplete` (`"INCOMPLETE"`) and a `DecodeStatus`
70
+ enum (`Complete` / `Incomplete` / `Invalid`) are exported. Every one-shot
71
+ truncation site (`decode()`, `Cursor`) that used to throw `INVALID_MSG` — an
72
+ unterminated varint, a payload / array shorter than its declared length, or a
73
+ nested sequence left open at end-of-buffer — now throws `INCOMPLETE` instead;
74
+ genuinely malformed input (varint over 64 bits, bad subtype/length/count, id
75
+ over max, dangling sequence-end, over-`MAX_DEPTH` nesting) still throws
76
+ `INVALID_MSG`. Mirrors corelib-go#42.
77
+
78
+ ### Changed
79
+
80
+ - **BREAKING (decode API):** there is no finish/finalize step. `IStream.end()`
81
+ no longer throws to promote an incomplete stream to an error; it is now a pure
82
+ accessor returning `DecodeStatus.Complete` when the stream ended on a field
83
+ boundary or `DecodeStatus.Incomplete` when it ended inside one. A malformed
84
+ message still throws from `IStream.feed()`. Callers that relied on `end()`
85
+ throwing on truncation must check its return value instead.
86
+ - **BREAKING (wire format):** a fixlen array (`fp32`/`fp64`) now always carries
87
+ its `fixlen_word` — even when empty (`element_count == 0`). Previously an empty
88
+ fixlen array was `[header][count=0]` with no `fixlen_word`, making an empty
89
+ `fp32` array byte-identical to an empty `fp64` one (`05 00`); a decoder could
90
+ not tell them apart. An empty fixlen array is now
91
+ `[header][count=0][fixlen_word]` with no payload (`05 00 20` for `fp32`,
92
+ `05 00 41` for `fp64`), so the element subtype stays recoverable. Integer
93
+ arrays (`u8`…`u64`, `i8`…`i64`) are unchanged — they never carry a
94
+ `fixlen_word` — so an empty integer array stays `[header][count=0]`. Mirrors
95
+ CORELIB_PLAN §4.8 / MESSAGE_SPEC §3 and corelib-c-cpp#45.
96
+
97
+ ## [0.2.0] - 2026-06-29
98
+
99
+ A performance release: the encode and decode hot paths no longer churn
100
+ short-lived `BigInt` objects, which V8 profiling identified as the dominant
101
+ cost. The wire format is unchanged and all shared conformance vectors still
102
+ pass. One source-level breaking change to the decode `Visitor` enables the
103
+ decode-side win.
104
+
105
+ ### Changed
106
+
107
+ - **BREAKING:** `Visitor.unsigned`, `Visitor.signed`, `Visitor.arrayUnsigned`,
108
+ and `Visitor.arraySigned` now receive `value: number | bigint` instead of
109
+ `bigint`. Integer values are delivered **number-first** — a `number` when the
110
+ value fits exactly (`≤ 2^53 − 1`, covering field ids, `u8`…`u32` and small
111
+ `u64`/`i64` values) and a `bigint` only beyond that. This avoids a per-value
112
+ `bigint` allocation on the common path.
113
+
114
+ **Migration:** a handler that did `bigint`-only arithmetic on a decoded value
115
+ must coerce the argument, e.g. `const n = typeof v === "bigint" ? v : BigInt(v)`
116
+ (to keep working in `bigint`) or `Number(v)` (to work in `number`, safe for
117
+ values `≤ 2^53`). The encoder is unaffected — it already accepted
118
+ `number | bigint` — so re-encoding a decoded value is byte-identical.
119
+
120
+ ### Added
121
+
122
+ - `decode()` now runs a dedicated **contiguous fast-path decoder** that advances
123
+ a single cursor over the whole buffer (the technique Protocol Buffers uses),
124
+ instead of driving the resumable per-byte state machine. Same API and
125
+ validation; markedly faster when the whole message is in hand. The streaming
126
+ `IStream` remains for chunked input.
127
+ - Expanded the shared conformance suite to the 67-vector `test_vectors.json`,
128
+ including the new `skip-ids` decode scenario (auto-skipping fields by id at any
129
+ nesting depth, including whole nested sequences) and `requires`/`skip_ids`
130
+ metadata.
131
+
132
+ ### Performance
133
+
134
+ - **Decode:** number-first values + the contiguous fast path cut BigInt-builtin
135
+ time from ~35% to ~4% and GC from ~10% to ~1% on small-value workloads. A
136
+ `u32` array decodes ~2.2× faster streaming and ~2.6× faster contiguous
137
+ (≈165 / ≈270 MB/s) for a number-consuming visitor. (#6)
138
+ - **Decode (streaming):** the resumable varint reader accumulates into two 32-bit
139
+ number halves instead of doing a per-byte `bigint` shift, with no loss of
140
+ 64-bit fidelity.
141
+ - **Encode:** `encodeVarint` / `varintSize` split the 64-bit value into two
142
+ 32-bit number halves once and emit LEB128 with number-only arithmetic,
143
+ dropping per-value `bigint` allocations from ~20 to 2. A full-range `u64`
144
+ array encodes ~4.4× faster (≈14.5 → ≈64 MB/s, isolated); ids, lengths, counts
145
+ and small scalars/arrays take a number fast path. (#5)
146
+
147
+ ## [0.1.0]
148
+
149
+ - Initial release: streaming, dependency-free TypeScript implementation of the
150
+ SofaBuffers binary serialization format — `OStream` to encode and `IStream`
151
+ (driving a `Visitor`) to decode, both chunkable, with a swappable acceleration
152
+ `Kernel` seam.
153
+
154
+ [0.2.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.2.0
155
+ [0.1.0]: https://github.com/sofa-buffers/corelib-ts/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SofaBuffers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ <p align="center"><img src="assets/sofabuffers_logo.png" alt="SofaBuffers" height="140"></p>
2
+
3
+ # SofaBuffers
4
+
5
+ <b>Structured Objects For Anyone</b><br>
6
+ <i>... so optimized, feels amazing.</i>
7
+
8
+ [Would you like to know more?](https://github.com/sofa-buffers)
9
+
10
+ ## SofaBuffers TypeScript library
11
+
12
+ [![CI](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
13
+ [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsofa-buffers%2Fcorelib-ts%2Fbadges%2Fcoverage.json)](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
14
+ [![Branches](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsofa-buffers%2Fcorelib-ts%2Fbadges%2Fbranches.json)](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
15
+ [![Docs](https://img.shields.io/badge/docs-API-blue)](https://sofa-buffers.github.io/corelib-ts/)
16
+
17
+ [GitHub repository](https://github.com/sofa-buffers/corelib-ts)
18
+
19
+ A dependency-free, streaming TypeScript implementation of the SofaBuffers
20
+ (*Sofab*) serialization format — the runtime stream core that runs anywhere
21
+ JavaScript does (Node.js, browsers, Electron, Deno, Bun, a `<script>` tag).
22
+
23
+ Like protobuf's `CodedInputStream` / `CodedOutputStream`, it is meant to be
24
+ driven by generated code: the `sofabgen` generator emits one class per message
25
+ with marshal / unmarshal methods that call these primitives. Two decode models
26
+ are offered — a resumable push / visitor decoder for streaming, and a
27
+ monomorphic pull cursor (`Cursor`) driven by a single `switch` over the field id.
28
+
29
+ ### Requirements
30
+
31
+ Node.js 20+ (CI runs 20 / 24), or any modern browser / Electron / Deno /
32
+ Bun. Built with TypeScript 6.x; targets ES2020 (`bigint` required).
33
+
34
+ ### Dependencies
35
+
36
+ None. Zero runtime dependencies; uses only standard JS / Web APIs
37
+ (`Uint8Array`, `DataView`, `TextEncoder` / `TextDecoder`).
38
+
39
+ ### Packaging
40
+
41
+ Published as `@sofa-buffers/corelib`:
42
+
43
+ ```bash
44
+ npm install @sofa-buffers/corelib
45
+ ```
46
+
47
+ Ships ESM (`.js`), CommonJS (`.cjs`), a browser IIFE global (`SofaBuffers`) and
48
+ full type declarations.
49
+
50
+ ## Why this design
51
+
52
+ | Goal | How |
53
+ |------|-----|
54
+ | Runs everywhere | Pure TypeScript over `Uint8Array` / `DataView` / `TextEncoder`, no Node built-ins on the hot path. |
55
+ | Streaming **out** | `OStream` writes into a small caller buffer and calls a `FlushSink` when it fills, so a message can exceed the buffer. |
56
+ | Streaming **in** | `IStream` is a resumable state machine fed arbitrary chunks; large string / blob payloads arrive in pieces. |
57
+ | Fast whole-buffer decode | With the whole message in one buffer, `decode()` (push) and `Cursor` (pull) advance a single cursor. |
58
+ | Full 64-bit fidelity | Scalars round-trip the entire `uint64` / `int64` range: `number` when exact, `bigint` beyond `2^53-1` (`Long` offers a `bigint`-free array path). |
59
+ | Generated-code friendly | The pull `Cursor` gives a monomorphic `readHeader()` + typed `read*` loop; the push `Visitor` has all-optional methods. |
60
+ | Reserve-offset | `new OStream(buf, offset)` leaves room at the front for a lower-layer header, saving a copy. |
61
+ | Explicit endianness | IEEE-754 values are read / written little-endian via `DataView`, identical on every engine. |
62
+ | Pluggable acceleration | The encoder's bulk array paths run through a swappable `Kernel`; the default is pure TypeScript. |
63
+
64
+ ## Usage
65
+
66
+ The codec has four use cases — serialize a message that fits in one buffer,
67
+ serialize one too large for the buffer (streamed out in chunks), deserialize a
68
+ whole message, and deserialize one arriving in chunks — plus the generated-code
69
+ path that wraps them. Problems are reported by throwing `SofabError`; the cause is
70
+ on `SofabError.code` (`ARGUMENT`, `USAGE`, `BUFFER_FULL`, `INVALID_MSG`,
71
+ `INCOMPLETE`). The decoder splits its two failure kinds (MESSAGE_SPEC §7):
72
+ `INVALID_MSG` is a message malformed regardless of what follows, while
73
+ `INCOMPLETE` means the bytes merely ended *inside* a field — a truncation more
74
+ bytes could complete, which is not an error the caller must treat as one. There
75
+ is no finish/finalize step: a streaming decode reports `INCOMPLETE` from `end()`
76
+ (see below), never by promoting it to a throw.
77
+
78
+ ### Serialize
79
+
80
+ Write fields into an in-memory `OStream` and take a view of the finished bytes:
81
+
82
+ ```ts
83
+ import { OStream } from "@sofa-buffers/corelib";
84
+
85
+ const os = new OStream(); // in-memory, auto-growing buffer
86
+ os.writeUnsigned(1, 42);
87
+ os.writeSigned(2, -7);
88
+ os.writeString(3, "hi");
89
+ const bytes = os.bytes(); // Uint8Array view of the finished message
90
+ ```
91
+
92
+ ### Serialize stream
93
+
94
+ Constructed over a caller-owned buffer with a `FlushSink`, `OStream` drains that
95
+ small buffer whenever it fills, so the buffer never has to be message-sized:
96
+
97
+ ```ts
98
+ import { OStream, type FlushSink } from "@sofa-buffers/corelib";
99
+
100
+ const out: number[] = [];
101
+ const sink: FlushSink = (chunk) => out.push(...chunk); // or socket / file / stream
102
+ const os = new OStream(new Uint8Array(16), 0, sink); // tiny 16-byte buffer
103
+ for (let i = 0; i < 1000; i++) os.writeUnsigned(i, BigInt(i));
104
+ os.flush(); // push the tail
105
+ ```
106
+
107
+ ### Deserialize
108
+
109
+ `decode()` walks a whole buffer and calls one optional `Visitor` method per field;
110
+ unhandled fields are silently skipped:
111
+
112
+ ```ts
113
+ import { decode, type Visitor } from "@sofa-buffers/corelib";
114
+
115
+ class My implements Visitor {
116
+ a = 0;
117
+ b = 0;
118
+ unsigned(id: number, v: number | bigint) { if (id === 1) this.a = Number(v); }
119
+ signed(id: number, v: number | bigint) { if (id === 2) this.b = Number(v); }
120
+ // fp32(), fp64(), string(), blob(), arrayBegin(), sequenceBegin(), ... as needed
121
+ }
122
+
123
+ decode(bytes, new My());
124
+ ```
125
+
126
+ ### Deserialize stream
127
+
128
+ `IStream` resumes across chunk boundaries, so feed it whatever the transport hands
129
+ you — from any source — and read the outcome from `end()`. String / blob payloads
130
+ arrive as one or more chunks tagged with the field's `total` length and byte
131
+ `offset`:
132
+
133
+ ```ts
134
+ import { IStream, DecodeStatus, type Visitor } from "@sofa-buffers/corelib";
135
+
136
+ const visitor: Visitor = {
137
+ blob(id, total, offset, chunk) {
138
+ /* append `chunk` at `offset`; the field is `total` bytes */
139
+ },
140
+ };
141
+
142
+ const is = new IStream();
143
+ for await (const chunk of source) is.feed(chunk, visitor); // any async byte source
144
+ // end() is a pure accessor — it never throws and never promotes an incomplete
145
+ // decode to an error (MESSAGE_SPEC §7). The caller owns end-of-input.
146
+ if (is.end() !== DecodeStatus.Complete) {
147
+ // stream ended inside a field (INCOMPLETE) — wait for more bytes, or treat
148
+ // the truncation as an error if this really was the end of input.
149
+ }
150
+ ```
151
+
152
+ ### Code generator
153
+
154
+ `sofabgen` compiles a schema to one class per message with a `marshal` (chaining
155
+ `OStream` writes) and a `static decode` driven by a monomorphic pull `Cursor` —
156
+ one `switch` over `c.id`. A hand-written stand-in, encoded then decoded:
157
+
158
+ ```ts
159
+ import { OStream, Cursor } from "@sofa-buffers/corelib";
160
+
161
+ // generated by: sofabgen --lang typescript
162
+ class Point {
163
+ x = 0;
164
+ y = 0;
165
+
166
+ marshal(os: OStream): void {
167
+ os.writeSigned(1, this.x);
168
+ os.writeSigned(2, this.y);
169
+ }
170
+
171
+ static decode(bytes: Uint8Array): Point {
172
+ return Point.decodeFrom(new Cursor(bytes));
173
+ }
174
+
175
+ static decodeFrom(c: Cursor): Point {
176
+ const p = new Point();
177
+ while (c.readHeader()) {
178
+ switch (c.id) {
179
+ case 1: p.x = Number(c.readSigned()); break;
180
+ case 2: p.y = Number(c.readSigned()); break;
181
+ // case 3: p.child = Child.decodeFrom(c); break; // nested sequence
182
+ default: c.skip(c.wire); break; // forward-compatible
183
+ }
184
+ }
185
+ return p;
186
+ }
187
+ }
188
+
189
+ const p = new Point(); p.x = 3; p.y = 4;
190
+ const os = new OStream(); p.marshal(os);
191
+ const wire = os.bytes();
192
+ const got = Point.decode(wire); // got.x === 3, got.y === 4
193
+ ```
194
+
195
+ ## Memory handling
196
+
197
+ Who owns the bytes:
198
+
199
+ - **Encode (`OStream`).** In-memory `new OStream()` — the library allocates and
200
+ auto-grows an internal buffer (never throws `BUFFER_FULL`); `bytes()` returns a
201
+ **view** of the finished message, so `.slice()` it if it must outlive the next
202
+ write or grow. Streaming `new OStream(buf, offset?, flush?)` — writes into the
203
+ caller-owned buffer and never grows; when it fills it drains a view to the
204
+ `flush` sink (valid only during that callback) and, with no sink, throws
205
+ `BUFFER_FULL`.
206
+ - **Decode (`decode()` / `Cursor` / `IStream`).** Input payload bytes are
207
+ zero-copy: string / blob chunks and `Cursor.readBlob` are `subarray` **views**
208
+ aliasing the input (or, for `IStream`, the chunk you fed). A visitor chunk is
209
+ valid **only during that callback**; a `Cursor` view lasts as long as the source
210
+ buffer lives. Scalars are delivered by value. Copy (`.slice()`) or decode
211
+ (`Cursor.readString` decodes for you) to retain a payload.
212
+
213
+ ### Decode limits
214
+
215
+ For a schema whose `count` / `maxlen` bounds are omitted, the decoder otherwise
216
+ accepts whatever count / length the received message claims. Pass an optional
217
+ `DecodeLimits` object to cap that and protect a receiver from a hostile oversized
218
+ field:
219
+
220
+ ```ts
221
+ const limits = { maxArrayCount: 65536, maxStringLen: 1 << 20, maxBlobLen: 1 << 20 };
222
+ decode(bytes, visitor, limits); // one-shot push
223
+ new Cursor(bytes, limits); // pull
224
+ new IStream(limits); // streaming
225
+ ```
226
+
227
+ An over-limit array count or string / blob length is rejected at the field's
228
+ header — **before** the array is sized or any payload is decoded or streamed to
229
+ the visitor — by throwing `SofabError` with code
230
+ `SofabErrorCode.LimitExceeded`. The decoder never clamps or truncates. Each limit
231
+ is independent, and an omitted one means **no cap** (the default is today's
232
+ unlimited behavior — the corelib invents no default). `LimitExceeded` is distinct
233
+ from `INVALID_MSG`: exceeding a receiver-configured limit is policy, not a
234
+ malformed message. Generated code supplies these values from the sofabgen config.
235
+
236
+ ## Feature flags
237
+
238
+ None — the build always ships every wire type.
239
+
240
+ ## Build & test
241
+
242
+ ```bash
243
+ npm ci
244
+ npm run typecheck # tsc --noEmit (strict)
245
+ npm test # vitest run: vectors, chunked feeding, cursor, errors, round-trips
246
+ npm run coverage # vitest run --coverage (v8)
247
+ npm run build # tsup -> ESM + CJS + IIFE + .d.ts in dist/
248
+ npm run smoke # cross-runtime smoke test of the built bundle
249
+ ```
250
+
251
+ Tests live in `test/` as focused suites, including `vectors.test.ts` (encode +
252
+ decode every shared conformance vector), `istream.chunked.test.ts` (every vector
253
+ fed one byte at a time), `cursor.test.ts`, `errors.test.ts`, `ostream.test.ts`,
254
+ `roundtrip.test.ts` and more. CI type-checks, tests and builds on Node 20 /
255
+ 24, smoke-tests the bundle on Node, Deno and Bun, and publishes coverage badges;
256
+ a separate `docs.yml` deploys the TypeDoc API reference to GitHub Pages.
257
+
258
+ ## Benchmarks
259
+
260
+ Two standalone tools mirror the other-language ports so implementations can be
261
+ compared directly:
262
+
263
+ ```bash
264
+ npm run perf # per-op cost: code-cost figure plus throughput MB/s
265
+ npm run bench # throughput table (MB/s) for a u64 array and a mixed message
266
+ npm run bench:callgrind # machine-independent instructions/op under Valgrind
267
+ ```
268
+
269
+ `perf` and `bench` encode the identical message as their counterparts in the
270
+ other ports and print the same report layout. Since JS engines expose no portable
271
+ cycle counter, `perf` uses CPU time/op as the code-cost proxy; `bench:callgrind`
272
+ counts instructions/op under Valgrind for a fully machine-independent figure.
273
+ Running the same tools under Node (V8) and Bun (JavaScriptCore) gives directly
274
+ comparable numbers.