@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 +155 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/index.cjs +1963 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +825 -0
- package/dist/index.d.ts +825 -0
- package/dist/index.global.js +1968 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +1936 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
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
|
+
[](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
|
|
13
|
+
[](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
|
|
14
|
+
[](https://github.com/sofa-buffers/corelib-ts/actions/workflows/ci.yml)
|
|
15
|
+
[](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.
|