@statewalker/webrun-msgpack 0.1.1 → 0.3.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/LICENSE CHANGED
@@ -19,3 +19,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---------------------------------------------------------------------------------------------------
24
+
25
+ This package contains a TypeScript port of msgpack.js (src/msgpack-core.ts), distributed under the
26
+ following license:
27
+
28
+ msgpack.js — https://github.com/ygoe/msgpack.js
29
+
30
+ Copyright © 2019, Yves Goergen, https://unclassified.software/source/msgpack-js
31
+
32
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
33
+ associated documentation files (the “Software”), to deal in the Software without restriction,
34
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
35
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
36
+ furnished to do so, subject to the following conditions:
37
+
38
+ The above copyright notice and this permission notice shall be included in all copies or
39
+ substantial portions of the Software.
40
+
41
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
42
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
43
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
44
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,32 +1,105 @@
1
1
  # @statewalker/webrun-msgpack
2
2
 
3
- Length-prefixed MessagePack frame codec for async iterables. Streams-safe `encode`/`decode` for arbitrary values, plus zero-copy specialisations for `Float32Array`.
3
+ MessagePack on the wire, in **two distinct shapes**:
4
4
 
5
- ## Why it exists
5
+ - a **stream codec** — `encodeMsgpack` / `decodeMsgpack`, length-prefixed, for a transport with no
6
+ message boundaries (plus specialisations for `Float32Array`);
7
+ - a **message codec** — `msgpackCodec`, a `PortCodec` for `@statewalker/webrun-rpc`'s
8
+ `multiplexPort`, with no length prefix, for a transport that already frames.
9
+
10
+ Both sit on the package's own MessagePack implementation, `serialize` / `deserialize`, which is
11
+ exported too. It is a TypeScript port of Yves Goergen's
12
+ [msgpack.js](https://github.com/ygoe/msgpack.js) with a handful of fixes — see
13
+ [Provenance and credits](#provenance-and-credits). The package has no runtime dependencies.
14
+
15
+ The two codecs are not interchangeable, and reaching for the wrong one is easy. The table below is
16
+ the whole decision.
17
+
18
+ ## Which codec
19
+
20
+ | | `encodeMsgpack` / `decodeMsgpack` | `msgpackCodec` |
21
+ | --- | --- | --- |
22
+ | Kind | stream codec | message codec (`PortCodec`) |
23
+ | Shape | `AsyncIterable<T>` ⇄ `AsyncIterable<Uint8Array>` | one `PortEnvelope` ⇄ one `postMessage` |
24
+ | Framing | **4-byte big-endian length prefix**, added by this package | **none** — the transport's own message boundaries are the framing |
25
+ | Use it when | the transport is a byte *stream*: a TCP-like socket, a `ReadableStream`, a file, anything where chunk boundaries are arbitrary | the transport preserves *message* boundaries: a WebSocket, an `RTCDataChannel`, a LiveKit data packet |
26
+ | Malformed input | a truncated trailing frame is never emitted; the consumer ends without yielding a partial value | dropped, never thrown — a bad frame from a peer cannot take the multiplexer down |
27
+ | Depends on | nothing | **type-only** `@statewalker/webrun-rpc` |
28
+
29
+ Adding a length prefix on a transport that already frames is redundant framing; relying on message
30
+ boundaries where there are none is the truncation bug the stream codec exists to prevent. Pick by
31
+ the transport, not by taste.
32
+
33
+ ## Why the stream codec exists
6
34
 
7
35
  Consumers that pipe values across transports (scanners writing chunks to a store, chat pipelines streaming embeddings, etc.) need a way to serialise a stream of objects into a byte stream and reassemble it on the other side without truncation surprises.
8
36
 
9
37
  A raw MessagePack stream has no frame boundaries: a decoder can only succeed if the chunk boundaries happen to line up with the payload boundaries. Length-prefix framing fixes this — the decoder buffers incoming bytes and only yields when a complete `[length][payload]` pair is available. Partial trailing frames are NEVER emitted, so callers can detect truncation by comparing observed count to expected.
10
38
 
11
- Previously the codec lived inside `@repo/streams` (private, unpublished). It's been extracted here so (a) consumers that only need framing don't pull in the broader `webrun-streams` surface, and (b) the `@ygoe/msgpack` dependency lives in exactly one place.
39
+ Previously the codec lived inside `@repo/streams` (private, unpublished). It's been extracted here so (a) consumers that only need framing don't pull in the broader `webrun-streams` surface, and (b) MessagePack lives in exactly one place.
12
40
 
13
- ## How to use
41
+ ## Why the port codec exists
42
+
43
+ `@statewalker/webrun-rpc`'s `multiplexPort` runs many virtual ports over one transport, and it needs
44
+ a codec to put its envelopes on the wire. `structuredCodec` (in `webrun-rpc`) passes them through
45
+ unencoded, which works only where messages are *structured values* — a `MessagePort`, a worker, an
46
+ iframe. `msgpackCodec` is the byte-transport sibling: one envelope becomes one msgpack frame and one
47
+ `postMessage`.
48
+
49
+ ## Install
14
50
 
15
51
  ```sh
16
52
  npm install @statewalker/webrun-msgpack
17
53
  ```
18
54
 
19
- Four exports — one encode/decode pair for generic values, one for `Float32Array`:
55
+ No runtime dependencies **in the emitted bundle** — `dist/index.js` imports nothing — and no peer
56
+ dependencies. ESM only (`"type": "module"`). That is not the same as the install cost:
57
+ `@statewalker/webrun-rpc` is a declared `dependency`, so `npm install` also pulls it and,
58
+ transitively, `@statewalker/webrun-streams` into `node_modules` — even for a consumer who uses only
59
+ the stream codec or `serialize` / `deserialize`.
60
+
61
+ `@statewalker/webrun-rpc` is declared as a dependency but is **type-only**: `msgpackCodec` imports
62
+ the `PortCodec` interface from it and no runtime code, so nothing of `webrun-rpc` is in the built
63
+ bundle and `webrun-rpc` gains no msgpack dependency in either direction.
64
+
65
+ The runtime needs `TextDecoder`, which every current browser, Node, Deno and Bun provide.
66
+
67
+ ## How to use
68
+
69
+ ### Exports
20
70
 
21
71
  | Export | Direction | Use case |
22
72
  | --- | --- | --- |
23
- | `encodeMsgpack<T>(src: AsyncIterable<T>)` | values → bytes | generic JSON-ish values |
24
- | `decodeMsgpack<T>(src: AsyncIterable<Uint8Array>)` | bytes → values | inverse of `encodeMsgpack` |
25
- | `encodeFloat32Arrays(src: AsyncIterable<Float32Array>)` | arrays → bytes | zero-copy float streaming |
26
- | `decodeFloat32Arrays(src: AsyncIterable<Uint8Array>)` | bytes → arrays | inverse of `encodeFloat32Arrays` |
73
+ | `encodeMsgpack<T>(src: Iterable<T> \| AsyncIterable<T>)` | values → frames | generic JSON-ish values |
74
+ | `decodeMsgpack<T>(src: Iterable<Uint8Array> \| AsyncIterable<Uint8Array>)` | frames → values | inverse of `encodeMsgpack` |
75
+ | `encodeFloat32Arrays(src: Iterable<Float32Array> \| AsyncIterable<Float32Array>)` | arrays → frames | float streaming, no per-element conversion |
76
+ | `decodeFloat32Arrays(src: Iterable<Uint8Array> \| AsyncIterable<Uint8Array>)` | frames → arrays | inverse of `encodeFloat32Arrays` |
77
+ | `msgpackCodec: PortCodec` | envelope ⇄ one framed message | `multiplexPort` over a byte transport |
78
+ | `serialize(value, options?)` | one value → one MessagePack document | the format itself, no framing |
79
+ | `deserialize(bytes, options?)` | one document → one value | inverse of `serialize` |
80
+
81
+ Types: `SerializeOptions`, `DeserializeOptions`, `MsgpackInput` (what `deserialize` accepts:
82
+ `Uint8Array`, `ArrayBuffer` or an array of byte values) and `MsgpackExtension` (an extension value
83
+ other than a timestamp: `{ type, data }`).
84
+
85
+ All four stream functions take synchronous iterables too — an array, a generator — so a fixed
86
+ list needs no async wrapper.
27
87
 
28
88
  ## Examples
29
89
 
90
+ ### One value, no framing
91
+
92
+ ```ts
93
+ import { deserialize, serialize } from "@statewalker/webrun-msgpack";
94
+
95
+ const bytes = serialize({ id: 7, tags: ["a", "b"], at: new Date(0), raw: new Uint8Array([1, 2]) });
96
+ const value = deserialize(bytes); // same shape; `at` is a Date, `raw` a Uint8Array
97
+
98
+ // Several documents back to back:
99
+ const three = serialize([1, "two", { three: 3 }], { multiple: true });
100
+ deserialize(three, { multiple: true }); // [1, "two", { three: 3 }]
101
+ ```
102
+
30
103
  ### Stream of values
31
104
 
32
105
  ```ts
@@ -70,8 +143,8 @@ for await (const arr of pipe) console.log(arr.length); // 4, 4
70
143
  ```ts
71
144
  import { decodeMsgpack, encodeMsgpack } from "@statewalker/webrun-msgpack";
72
145
 
73
- // Produce one frame, then split the bytes any way you like:
74
- const bytes = [];
146
+ // Produce one frame (a plain array is a valid input), then split the bytes any way you like:
147
+ const bytes: Uint8Array[] = [];
75
148
  for await (const f of encodeMsgpack([{ a: 1, b: "hi" }])) bytes.push(f);
76
149
  // Hand the decoder arbitrarily small slices — it buffers until complete:
77
150
  async function* byOne() {
@@ -82,6 +155,179 @@ for await (const v of decodeMsgpack<{ a: number; b: string }>(byOne())) {
82
155
  }
83
156
  ```
84
157
 
158
+ ### An RPC stream over a byte transport
159
+
160
+ `msgpackCodec` on both ends of a transport that carries `Uint8Array`s, one virtual port, one
161
+ `duplexOverPort` round trip. The pipe below stands in for the real thing — replace it with a
162
+ WebSocket pair, an `RTCDataChannel`, or a LiveKit packet stream and nothing else changes.
163
+
164
+ ```js
165
+ import { duplexOverPort, multiplexPort, serveDuplexOverPort } from "@statewalker/webrun-rpc";
166
+ import { msgpackCodec } from "@statewalker/webrun-msgpack";
167
+
168
+ // A byte transport: two ends that carry `Uint8Array`s and nothing else.
169
+ function bytePipePair() {
170
+ const listeners = [new Set(), new Set()];
171
+ const make = (self) => ({
172
+ postMessage(bytes) {
173
+ const copy = bytes.slice(); // a real transport does not share the sender's buffer
174
+ setTimeout(() => {
175
+ for (const listener of [...listeners[1 - self]]) listener({ data: copy });
176
+ }, 0);
177
+ },
178
+ addEventListener: (_type, listener) => listeners[self].add(listener),
179
+ removeEventListener: (_type, listener) => listeners[self].delete(listener),
180
+ });
181
+ return { a: make(0), b: make(1) };
182
+ }
183
+
184
+ const pipe = bytePipePair();
185
+
186
+ // The responder: every virtual port the peer opens gets an echo handler.
187
+ const server = multiplexPort(pipe.b, {
188
+ codec: msgpackCodec,
189
+ side: "responder",
190
+ onPort: (port) => {
191
+ serveDuplexOverPort(port, async function* (input) {
192
+ for await (const chunk of input) yield chunk;
193
+ });
194
+ },
195
+ });
196
+
197
+ // The initiator: one virtual port, one duplex round trip over it.
198
+ const client = multiplexPort(pipe.a, { codec: msgpackCodec, side: "initiator" });
199
+ const port = await client.openPort({ kind: "stream" });
200
+ const call = duplexOverPort(port, { maxMessageSize: client.maxMessageSize });
201
+
202
+ async function* body() {
203
+ yield new TextEncoder().encode("hello ");
204
+ yield new TextEncoder().encode("bytes");
205
+ }
206
+
207
+ const decoder = new TextDecoder();
208
+ let echoed = "";
209
+ for await (const chunk of call(body())) echoed += decoder.decode(chunk);
210
+ console.log(echoed); // "hello bytes"
211
+
212
+ await client.close();
213
+ await server.close();
214
+ ```
215
+
216
+ The same stack passes the unmodified `webrun-streams-conformance` L0–L6 suite over a byte pipe, in
217
+ both framing regimes — unlimited, and with frames capped at 64 KiB
218
+ (`tests/conformance-bytes.test.ts`).
219
+
220
+ **Read that green narrowly: an in-process pipe is not a transport.** The pipe hands `Uint8Array`s
221
+ straight from one object to another inside one process, so what the suite covers is this codec's own
222
+ contract end to end, including under chunking. It covers *none* of what a real byte transport brings:
223
+ framing, message-size ceilings and what a transport does when you exceed one, backpressure,
224
+ reconnection, close codes and error semantics. A WebSocket, an `RTCDataChannel` and a LiveKit data
225
+ track each need their own run before anything here is claimed of them.
226
+
227
+ ## `maxMessageSize` bounds the payload, not the frame
228
+
229
+ **Leave a margin of at least 256 bytes below your transport's hard limit.** This is the one thing
230
+ that will bite you when wiring `msgpackCodec` to a capped transport, and the frame sizes below are
231
+ measured rather than cautious:
232
+
233
+ `duplexOverPort` applies `toChunks(maxMessageSize)` to the *payload*. The envelope framing —
234
+ `WireChunk`, `callPort`'s `{type, channelName, callId, params}`, the mux's `{type, id, payload}`,
235
+ then this codec — is added **on top, afterwards**. Over `msgpackCodec` that overhead is
236
+ `87 + len(callId)` bytes and it is **not constant**: `callId` is
237
+ `` `call-${Date.now()}-${String(Math.random()).substring(2)}` ``, whose length varies **31–40**
238
+ characters *per chunk* because `Math.random()` drops trailing zeros; the port id's integer width
239
+ adds 0–4; the channel name adds 1 for `"out"` over `"in"`; and a chunk at or above 64 KiB adds 2 as
240
+ the payload's `bin` header widens.
241
+
242
+ Two numbers, and the difference between them matters: adding those terms up gives a **modelled
243
+ ceiling of 134 bytes**, while the overheads *actually observed* span **123–128 bytes**. The 134 is
244
+ arithmetic; the 123–128 is measurement.
245
+
246
+ The largest, 128, comes from a **64 KiB** cap — a 65,664-byte frame in the capped conformance run
247
+ over a 10 MiB body — which is the regime where the `bin`-header term applies. A separate sweep, eight
248
+ runs at a **16 KiB** cap with a 1 MiB body (several thousand chunks, so several thousand `callId`s),
249
+ never exceeded **126** at that cap; the table below is one run per cap and is not that sweep. A
250
+ 256-byte margin covers all of it, which is why the advice is a round number rather than a tight one.
251
+
252
+ Measured, with a 512 KiB body through the stack above:
253
+
254
+ | `maxMessageSize` | intent | largest frame actually posted | overhead |
255
+ | --- | --- | --- | --- |
256
+ | `16 * 1024` | an `RTCDataChannel`'s conservative ceiling | **16,508 bytes** | 124 |
257
+ | `12 * 1024` | LiveKit's safe packet size | **12,413 bytes** | 125 |
258
+ | `64 * 1024` | | **65,662 bytes** | 126 |
259
+
260
+ So setting `maxMessageSize` to the transport's hard limit **overruns it on the first full-size
261
+ chunk** — and a transport that silently drops an oversized message (LiveKit does; the body arrives
262
+ as zero bytes with no error on either side) gives you no signal at all. Set it to
263
+ `limit - 256` and the arithmetic stops mattering.
264
+
265
+ This is spec D10's correction, recorded in
266
+ `docs/superpowers/specs/2026-09-05-port-multiplexer-design.md`.
267
+
268
+ ## Two things `msgpackCodec` does that `structuredCodec` does not
269
+
270
+ **The transfer list is ignored.** `PortCodec.post` receives an optional `Transferable[]`;
271
+ `msgpackCodec` drops it. After encoding, the payload is *inside* the bytes — there is no live
272
+ `ArrayBuffer` left on the far side of the call to hand over, and passing the caller's original
273
+ buffers as transferables would detach buffers the caller still owns. `structuredCodec` forwards the
274
+ list, because there the objects themselves cross.
275
+
276
+ **msgpack drops object keys whose value is explicitly `undefined`**; structured clone keeps them. So
277
+ `{ result: undefined }` arrives as `{}` over this codec and as `{ result: undefined }` over
278
+ `structuredCodec`. Nothing `webrun-rpc`'s layer 2 sends depends on the difference — every wire shape
279
+ it produces is pinned against both codecs in `tests/codec-equivalence.test.ts` — and per **spec
280
+ D16** it must not come to. If you build a payload where `"key" in obj` means something different
281
+ from `obj.key === undefined`, it will not survive this codec.
282
+
283
+ D16's reach has one open edge, worth knowing before you put arbitrary application errors on a byte
284
+ transport: `serializeError` copies **every own enumerable property** off a thrown `Error` onto the
285
+ wire. Whether the resulting `error` payload is msgpack-expressible therefore depends on what your
286
+ code throws — a `cause` holding a `Map` or a class instance collapses to `{}`, and a circular
287
+ reference makes `serialize` throw.
288
+
289
+ ## What `deserialize` refuses, and how
290
+
291
+ `deserialize` throws on input that is not MessagePack; it never logs. Specifically:
292
+
293
+ - **Truncated input** — any read that would run past the end — throws a `RangeError`
294
+ (`Insufficient data: …`). Every proper prefix of a valid encoding is refused this way, so a cut
295
+ integer can no longer come back as `NaN`, nor a cut `bin` as a shorter array.
296
+ - **The never-used type byte `0xc1`**, an empty input, a timestamp extension of an unknown size,
297
+ and a non-byte argument throw an `Error`.
298
+ - **Malformed UTF-8 inside a string is not an error**: each malformed sequence decodes to U+FFFD,
299
+ exactly as `TextDecoder` does, and the values after the string are read normally. Overlong
300
+ forms are malformed — `C0 AF` does not decode to `/`.
301
+ - **Bytes after the first document are ignored** unless `{ multiple: true }` asks for all of them.
302
+
303
+ `msgpackCodec` turns every one of these throws into a dropped message. (Before this package
304
+ carried its own implementation, `@ygoe/msgpack` printed the whole buffer with `console.debug` on
305
+ some truncated input before throwing. That is gone.)
306
+
307
+ ## The value model
308
+
309
+ What each JavaScript value becomes on the wire, and what comes back:
310
+
311
+ | Written | As | Read back as |
312
+ | --- | --- | --- |
313
+ | `null`, `undefined` | nil | `null` — but an object key whose value is `undefined` is **dropped** |
314
+ | `boolean` | bool | `boolean` |
315
+ | safe integer | the narrowest int / uint | `number`; `-0` is written as `0` and loses its sign |
316
+ | any other number (fraction, beyond ±2⁵³, `NaN`, `±Infinity`) | float 64 | `number` |
317
+ | `string` | str, UTF-8; a lone surrogate is written as U+FFFD, as `TextEncoder` does | `string` |
318
+ | `Uint8Array`, `Uint8ClampedArray` | bin | `Uint8Array` — a **view into the input**, not a copy |
319
+ | other typed arrays (`Float32Array`, `Int16Array`, …) | array of numbers | `number[]` |
320
+ | `Array` | array | `unknown[]` |
321
+ | `Date` | timestamp extension (type -1), 32/64/96-bit as the instant needs | `Date`, floored to the millisecond; an invalid `Date` throws |
322
+ | any other object | map of its **own** enumerable string keys | plain object; every key, `__proto__` included, is an own property |
323
+ | `bigint`, `function`, `symbol` | — throws, unless `invalidTypeReplacement` supplies a stand-in | |
324
+
325
+ Reading also accepts what other encoders write: float 32, int 64 / uint 64 (as the nearest
326
+ `number` — precision beyond 2⁵³ is lost), maps with non-string keys (the key is converted with
327
+ `String()`), and extension types other than timestamps, which come back as
328
+ `{ type, data }` with `type` as the unsigned byte (so type `-2` reads as `254`). A `Map` or `Set`
329
+ has no own enumerable keys and is written as an empty map.
330
+
85
331
  ## Internals
86
332
 
87
333
  ### Frame layout
@@ -95,6 +341,8 @@ for await (const v of decodeMsgpack<{ a: number; b: string }>(byOne())) {
95
341
 
96
342
  - Big-endian 32-bit length prefix — same convention as Java `DataOutputStream` and most wire protocols.
97
343
  - Max payload per frame: 2³²−1 bytes. No fragmentation within a frame (a single call to `serialize` produces the whole payload up-front); very large values will allocate proportionally.
344
+ - **This layout is the stream codec's only.** `msgpackCodec` writes a bare msgpack document per
345
+ message and prefixes nothing.
98
346
 
99
347
  ### Decoder state machine
100
348
 
@@ -103,35 +351,129 @@ The decoder keeps a rolling `Uint8Array` buffer. Each incoming chunk is appended
103
351
  1. If buffer is shorter than 4 bytes — wait for more.
104
352
  2. Read the 32-bit BE length.
105
353
  3. If buffer doesn't hold `4 + length` bytes — wait for more.
106
- 4. Slice the payload, deserialise with `@ygoe/msgpack`, yield.
354
+ 4. Copy the payload out, `deserialize` it, yield.
107
355
  5. Advance the buffer past this frame; repeat step 1.
108
356
 
109
357
  Zero-length chunks are tolerated and simply no-op through the loop. Truncated trailing frames are silently dropped — the buffer retains them but the consuming `for await` ends without yielding a partial value.
110
358
 
111
- ### Float32Array zero-copy
359
+ ### What `msgpackCodec.read` accepts, and what it refuses
360
+
361
+ It accepts whatever byte shape a transport pump hands over — a `Uint8Array`, a bare `ArrayBuffer`,
362
+ or any `ArrayBufferView` (a `DataView` included, offset and length honoured). Everything else is
363
+ refused by returning `undefined`: a non-byte value, empty bytes, malformed msgpack, and well-formed
364
+ msgpack that decodes to something that is not a `PortEnvelope` (the `id` must be a non-negative
365
+ integer and the `type` one of `open` / `message` / `close`). A shared transport carries traffic that
366
+ is not ours, and layer 1 must not mistake it for an envelope.
367
+
368
+ Refusal is always a dropped message, never a throw: a throw here would escape inside the raw port's
369
+ own listener, outside any consumer's reach, and would let one hostile frame take the multiplexer
370
+ down.
371
+
372
+ There are three inputs for which `read` *can* still throw, all inside the byte-shape check that runs
373
+ before the `try`: a detached `ArrayBuffer`, a `DataView` over a detached buffer, and a `Proxy` with
374
+ a throwing `getPrototypeOf`. None is producible by a remote peer sending bytes — each needs
375
+ same-process JavaScript already holding the backing memory — so the guarantee is "cannot throw for
376
+ anything that arrives over a wire", not "cannot throw for any JavaScript value".
112
377
 
113
- `encodeFloat32Arrays` constructs a `Uint8Array` view over the `Float32Array`'s underlying buffer and serialises it as a msgpack `bin` payload — no float-by-float conversion. `decodeFloat32Arrays` reinterprets the decoded `Uint8Array` as a `Float32Array`. When the decoded buffer's `byteOffset` is not 4-byte aligned (can happen if `@ygoe/msgpack` returns a view into a larger buffer), we copy into a fresh aligned `Uint8Array` before constructing the `Float32Array`; otherwise the operation is view-only.
378
+ ### Float32Array: no per-element conversion
379
+
380
+ `encodeFloat32Arrays` constructs a `Uint8Array` view over the `Float32Array`'s underlying buffer and serialises it as a msgpack `bin` payload — no float-by-float conversion. `decodeFloat32Arrays` reinterprets the decoded `Uint8Array` as a `Float32Array`, copying it first when its `byteOffset` is not 4-byte aligned.
381
+
382
+ **In practice that copy always happens**, and the bytes are copied on the way out as well:
383
+ `serialize` copies the view into its output, and the frame is assembled by another copy. The
384
+ decoded `bin` is a view into the payload, starting just after its 2-, 3- or 5-byte header, and
385
+ none of those offsets is a multiple of 4. So "no per-element conversion" is the whole claim, not
386
+ "zero-copy".
387
+
388
+ ### A trap when writing a transport pump
389
+
390
+ `serialize` returns a **view** over an internal buffer that is not trimmed: a 10 MiB envelope
391
+ measures ~10,485,800 bytes over a 16,777,216-byte `ArrayBuffer` (~1.6× slack, `byteOffset` 0). Send
392
+ the view. A pump that reaches for `frame.buffer` instead would put ~6 MiB of trailing zeros on the
393
+ wire per message.
114
394
 
115
395
  ### Dependencies
116
396
 
117
- - [`@ygoe/msgpack`](https://github.com/ygoe/msgpack.js) — single-file msgpack implementation (≈7 kB gzipped), no transitive deps.
397
+ - [`@statewalker/webrun-rpc`](../webrun-rpc) — **types only** (`PortCodec`, `PortEnvelope`); no runtime import is emitted.
398
+ - MessagePack itself is `src/msgpack-core.ts`, in this package — see below.
118
399
 
119
- Dev: TypeScript, vitest, tsdown, rimraf (catalog versions from the monorepo root).
400
+ Dev: TypeScript, vitest, rolldown, rimraf (catalog versions from the monorepo root).
401
+ `@statewalker/webrun-streams` and `@statewalker/webrun-streams-conformance` are dev-only, for the
402
+ conformance run.
120
403
 
121
404
  ### Constraints
122
405
 
123
406
  - Big-endian length prefix only — no little-endian variant.
124
407
  - `decodeMsgpack` allocates one `Uint8Array` per incoming chunk for the `concat`; long streams with many tiny chunks may benefit from a batched source upstream.
125
408
  - `Float32Array` codec is strictly `Float32` — no element-size negotiation.
409
+ - `msgpackCodec` carries no length prefix, so it is **unusable** on a transport without message
410
+ boundaries. Use the stream codec there.
126
411
 
127
412
  ## Scripts
128
413
 
129
414
  ```sh
130
- pnpm test # vitest run (25 tests)
131
- pnpm run build # tsdown
132
- pnpm lint # biome check
415
+ pnpm test # vitest run (406 tests / 7 files)
416
+ pnpm run build # rolldown + tsc --emitDeclarationOnly
417
+ pnpm lint # biome check src tests
418
+ pnpm typecheck # tsc --noEmit (src)
419
+ pnpm typecheck:tests # tsc -p tsconfig.tests.json — needs the sibling packages built
133
420
  ```
134
421
 
422
+ ## Provenance and credits
423
+
424
+ `src/msgpack-core.ts` is a TypeScript port of **[msgpack.js](https://github.com/ygoe/msgpack.js)**
425
+ by **Yves Goergen**, © 2019, MIT license — the library this package depended on, as
426
+ [`@ygoe/msgpack`](https://www.npmjs.com/package/@ygoe/msgpack), until 0.3.0. Thank you.
427
+
428
+ It is ported from commit
429
+ [`05733cf`](https://github.com/ygoe/msgpack.js/tree/05733cfb43a2974cf669f0eb8693f43b548bdcd4)
430
+ (2024-04-16) on `master`, not from the npm release 1.0.3. `master` carries three fixes that 1.0.3
431
+ lacks, and they change what goes on the wire:
432
+
433
+ - [#34](https://github.com/ygoe/msgpack.js/pull/34): an integer beyond the safe range is written
434
+ as a float 64. 1.0.3 wrote `2 ** 100` as the maximal uint 64, which reads back as ~1.8e19.
435
+ - [#32](https://github.com/ygoe/msgpack.js/issues/32): a positive integer above uint 32 is written
436
+ with the uint 64 prefix `0xcf`, not int 64's `0xd3`.
437
+ - [#33](https://github.com/ygoe/msgpack.js/issues/33): a 16–255-byte `bin` gets the one-byte bin 8
438
+ header, not bin 16's two bytes.
439
+
440
+ The port keeps upstream's structure, comments and error messages. Before any change it was checked
441
+ against upstream `msgpack.js` itself: byte-identical output for 3,000 randomised values (226 MB
442
+ encoded), and the same value or the same error message for 20,000 random garbage inputs.
443
+
444
+ **Changes from upstream**, each marked `Modified from upstream` in the source and made where a
445
+ test adopted from another implementation failed:
446
+
447
+ 1. **Truncated input throws a `RangeError`** instead of decoding to `NaN` or a short `bin`, and
448
+ nothing is logged — upstream called `console.debug` with the whole input.
449
+ 2. **Timestamps are floored to the millisecond**, both ways. Upstream rounded
450
+ `…:07.999999999Z` up to the next second, read pre-1970 instants toward zero, and wrote
451
+ `new Date(-1002)` as second -1. An invalid `Date` throws instead of being written as second -1.
452
+ 3. **Strings follow the WHATWG Encoding standard.** Decoding goes through `TextDecoder`: malformed
453
+ sequences, overlong forms included, become U+FFFD, where upstream decoded `C0 AF` to `/` and
454
+ threw on a sequence cut by the end of the string. Encoding writes a lone surrogate as U+FFFD,
455
+ where upstream threw on a high one and wrote a low one as invalid UTF-8.
456
+ 4. **Object keys**: a `__proto__` map key is decoded as an own property — upstream's assignment
457
+ replaced the decoded object's prototype — and only own enumerable keys are written, where
458
+ upstream's `for…in` also wrote inherited ones.
459
+ 5. TypeScript types, `unknown` in place of `any`, and the bounds `0xffffffffffffffff` /
460
+ `0x7fffffffffffffff` spelled `2 ** 64` / `2 ** 63` (the same doubles).
461
+
462
+ **Tests.** Besides this package's own, the suite runs:
463
+
464
+ - upstream's test page, ported to vitest — `tests/msgpack-core.upstream.test.ts`;
465
+ - [kawanet/msgpack-test-suite](https://github.com/kawanet/msgpack-test-suite) (MIT, © Yusuke
466
+ Kawasaki), vendored — `tests/msgpack-core.test-suite.test.ts`;
467
+ - cases adopted from [msgpack/msgpack-javascript](https://github.com/msgpack/msgpack-javascript)
468
+ (ISC, © The MessagePack Community) and [kriszyp/msgpackr](https://github.com/kriszyp/msgpackr)
469
+ (MIT, © Kris Zyp), with msgpackr's sample documents vendored —
470
+ `tests/msgpack-core.adopted.test.ts`.
471
+
472
+ Sources, commits and licenses of everything vendored are in
473
+ [`tests/fixtures/README.md`](./tests/fixtures/README.md). Outside the suite, the final
474
+ implementation was checked for interoperability with `@msgpack/msgpack` 3.1.3 and `msgpackr` 2.1.0:
475
+ 2,000 random values encoded by each side decode identically on the other, in both directions.
476
+
135
477
  ## License
136
478
 
137
- MIT © statewalker
479
+ MIT © statewalker — see [LICENSE](./LICENSE), which also carries msgpack.js's MIT notice.
@@ -0,0 +1,4 @@
1
+ export { decodeFloat32Arrays, decodeMsgpack, encodeFloat32Arrays, encodeMsgpack, } from "./msgpack.js";
2
+ export { type DeserializeOptions, deserialize, type MsgpackExtension, type MsgpackInput, type SerializeOptions, serialize, } from "./msgpack-core.js";
3
+ export { msgpackCodec } from "./port-codec.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,aAAa,EACb,mBAAmB,EACnB,aAAa,GACd,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,KAAK,kBAAkB,EACvB,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,gBAAgB,EACrB,SAAS,GACV,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC"}