@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 +24 -0
- package/README.md +362 -20
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +661 -0
- package/dist/msgpack-core.d.ts +70 -0
- package/dist/msgpack-core.d.ts.map +1 -0
- package/dist/msgpack.d.ts +28 -0
- package/dist/msgpack.d.ts.map +1 -0
- package/dist/port-codec.d.ts +22 -0
- package/dist/port-codec.d.ts.map +1 -0
- package/package.json +16 -8
- package/src/index.ts +9 -0
- package/src/msgpack-core.ts +617 -0
- package/src/msgpack.ts +18 -12
- package/src/port-codec.ts +66 -0
- package/dist/index.d.mts +0 -23
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -76
- package/dist/index.mjs.map +0 -1
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
|
-
|
|
3
|
+
MessagePack on the wire, in **two distinct shapes**:
|
|
4
4
|
|
|
5
|
-
|
|
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)
|
|
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
|
-
##
|
|
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
|
-
|
|
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 →
|
|
24
|
-
| `decodeMsgpack<T>(src: AsyncIterable<Uint8Array>)` |
|
|
25
|
-
| `encodeFloat32Arrays(src: AsyncIterable<Float32Array>)` | arrays →
|
|
26
|
-
| `decodeFloat32Arrays(src: AsyncIterable<Uint8Array>)` |
|
|
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.
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
- [`@
|
|
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,
|
|
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
|
|
131
|
-
pnpm run build
|
|
132
|
-
pnpm lint
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|