@pbwire/pbwire 0.0.0-stage → 0.1.7
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/README.md +225 -2
- package/build.sh +49 -0
- package/lib/pbwire-compat.js +525 -0
- package/lib/pbwire.js +194 -0
- package/package.json +19 -4
- package/runtime/addon.c +2566 -0
package/README.md
CHANGED
|
@@ -1,3 +1,226 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pbwire Node.js binding
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime is `runtime/addon.c`, an N-API addon in C over `libpbwire.a`;
|
|
4
|
+
the `Schema`/`Message` surface is `lib/pbwire.js`. No npm dependencies.
|
|
5
|
+
|
|
6
|
+
Most code never touches that surface: generate a protobufjs-static-shaped
|
|
7
|
+
facade once (`make -C codegen gen-node`) and use it like the
|
|
8
|
+
library it mirrors — same `create`/`encode`/`decode`/`verify` calls,
|
|
9
|
+
same observable behavior, but the wire format stays in the C core:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const { Person } = require('./tests/gen/addressbook.js');
|
|
13
|
+
|
|
14
|
+
const msg = Person.create({name: 'alice', id: 7});
|
|
15
|
+
Person.verify(msg); // null: valid
|
|
16
|
+
const wire = Person.encode(msg).finish(); // Buffer
|
|
17
|
+
const back = Person.decode(wire);
|
|
18
|
+
back.name; // 'alice'
|
|
19
|
+
back.toJSON(); // {name: 'alice', id: 7, ...}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The generated file embeds its own `FileDescriptorSet`, so there is no
|
|
23
|
+
`.desc` to ship: `require` it and go. int64 reads as `BigInt`; `create`/
|
|
24
|
+
`fromObject` also take `number|string|Long-like`. `toObject()` keys are
|
|
25
|
+
camelCase (protobufjs convention); pass `{keepCase: true}` for proto
|
|
26
|
+
names. `fromObject` rejects bad shapes with `TypeError`; `verify`
|
|
27
|
+
returns the same reason strings as protobufjs. The one deliberate
|
|
28
|
+
difference: direct post-hoc writes of an implicit zero (`m.i32 = 0`)
|
|
29
|
+
are not marked present — set values through `create`/`fromObject` to
|
|
30
|
+
write explicit zeros.
|
|
31
|
+
|
|
32
|
+
The dynamic API is still there for gateways and schemas known only at
|
|
33
|
+
runtime:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
const { Schema } = require('./lib/pbwire');
|
|
37
|
+
|
|
38
|
+
const schema = Schema.fromFile('tests/data/demo.desc');
|
|
39
|
+
const person = schema.message('demo.Person');
|
|
40
|
+
|
|
41
|
+
const wire2 = person.encode({name: 'alice', id: 7, inner: {x: 42}});
|
|
42
|
+
const back2 = person.decode(wire2);
|
|
43
|
+
back2.get('name'); // 'alice'
|
|
44
|
+
back2.get('inner').x; // 42
|
|
45
|
+
back2.encode().equals(wire2); // true: byte-exact re-encode
|
|
46
|
+
back2.free();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
A message is one of two things, and both live behind the same API:
|
|
52
|
+
|
|
53
|
+
- **A built message** (`newMessage()`/`set()`) is a `pbw_message` value tree in
|
|
54
|
+
C. Field writes go straight into it, and `encode()` runs the core encoder
|
|
55
|
+
into a per-type reusable buffer, so re-encoding a message never crosses the
|
|
56
|
+
boundary again.
|
|
57
|
+
- **A decoded message** (`decode()`) is the bytes it came from plus one 12-byte
|
|
58
|
+
slice per wire field: decode validates the payload once while recording those
|
|
59
|
+
slices, `get()` materializes a single field on demand, and `encode()` hands
|
|
60
|
+
the same bytes back. A round trip is therefore byte-exact — unknown fields,
|
|
61
|
+
field order and non-canonical encodings included — and nothing is built for
|
|
62
|
+
fields that are never read.
|
|
63
|
+
|
|
64
|
+
The wire form holds no native state: the bytes are a JS `Buffer` and the slices
|
|
65
|
+
a `Uint32Array`, both owned by the message object. That is deliberate. Attaching
|
|
66
|
+
a finalizer to native memory costs N-API ~250 ns per object, which is more than
|
|
67
|
+
the entire decode of a small message, so only the built form (which has no
|
|
68
|
+
JS-visible storage) pays for one.
|
|
69
|
+
|
|
70
|
+
Writing to a decoded message promotes it to a tree (one `pbw_decode` of the
|
|
71
|
+
retained bytes), so `set()` works on either form and only callers that actually
|
|
72
|
+
edit a parsed message pay for the tree.
|
|
73
|
+
|
|
74
|
+
Field facts (number, type, wire type, label, oneof, closed enum, sub-message)
|
|
75
|
+
are resolved once per descriptor into a cached table with a name hash and a
|
|
76
|
+
number index, so `get()`/`set()` and the wire scan never walk the descriptor.
|
|
77
|
+
|
|
78
|
+
## Build and run
|
|
79
|
+
|
|
80
|
+
Needs Node (headers ship with it) and a C compiler. No `npm install`, no
|
|
81
|
+
`node-gyp`.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
bindings/node/build.sh # build the addon, then run the tests
|
|
85
|
+
bindings/node/build.sh build # build only
|
|
86
|
+
node bindings/node/test/test_pbwire.js # the tests alone (node:test)
|
|
87
|
+
node bindings/node/examples/roundtrip.js # the example
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`build.sh` builds `build/libpbwire.a` first when it is missing (the root `make`
|
|
91
|
+
default goal is the shared library, so it asks for the archive by name), then
|
|
92
|
+
compiles the addon with clang against `$(node -p ...)/include/node` and links
|
|
93
|
+
the static core. Set `NODE_INCLUDE` if a packaging splits the Node headers away
|
|
94
|
+
from the binary. Output: `bindings/node/build/pbwire_node.node`.
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
`Schema`
|
|
99
|
+
| member | meaning |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `Schema.fromBytes(buffer)` | load a `FileDescriptorSet`; throws on malformed input |
|
|
102
|
+
| `Schema.fromFile(path)` | same, from a file |
|
|
103
|
+
| `schema.message(name)` | the message type `name` (`"pkg.Msg"`); throws when it is not a message |
|
|
104
|
+
|
|
105
|
+
`MessageType`
|
|
106
|
+
| member | meaning |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `type.name` / `type.schema` | dotted full name and owning schema |
|
|
109
|
+
| `type.newMessage()` | empty `Message` (the mutable tree form) |
|
|
110
|
+
| `type.encode(object \| Message)` | encode a plain object or an existing `Message` |
|
|
111
|
+
| `type.decode(buffer)` | decode wire bytes into a `Message`; throws on malformed input |
|
|
112
|
+
|
|
113
|
+
`Message`
|
|
114
|
+
| member | meaning |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `msg.get(name)` | field value, `undefined` when the field is absent; throws for an unknown name |
|
|
117
|
+
| `msg.set(name, value)` | store a value, replacing what was there; `undefined`/`null` clears the field |
|
|
118
|
+
| `msg.encode()` | wire bytes as a `Buffer` |
|
|
119
|
+
| `msg.free()` | release early; a second call is a no-op, any other call afterwards throws |
|
|
120
|
+
|
|
121
|
+
Values map per field type:
|
|
122
|
+
|
|
123
|
+
| field type | JS value |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `int32`, `uint32`, `sint32`, `fixed32`, `sfixed32`, `enum` | `number` |
|
|
126
|
+
| `int64`, `uint64`, `sint64`, `fixed64`, `sfixed64` | `BigInt` (a `number` is accepted on `set`) |
|
|
127
|
+
| `float`, `double` | `number` |
|
|
128
|
+
| `bool` | `boolean` |
|
|
129
|
+
| `string` | `string` |
|
|
130
|
+
| `bytes` | `Buffer` |
|
|
131
|
+
| message | plain `object` (nested `get`/`set` by field name) |
|
|
132
|
+
| repeated | `array` |
|
|
133
|
+
|
|
134
|
+
Presence is explicit, as in the C core: a field set to its zero value is
|
|
135
|
+
written to the wire instead of being skipped the way a generated proto3
|
|
136
|
+
serializer would skip it. A message field is replaced on `set`, not merged, and
|
|
137
|
+
a repeated write replaces the whole list. Reading follows the wire: a repeated
|
|
138
|
+
field that is not on the wire reads `undefined`, a oneof reads only its last
|
|
139
|
+
member, and a singular sub-message that appears twice merges (the payloads
|
|
140
|
+
concatenate, which is what parsing them as one message means).
|
|
141
|
+
|
|
142
|
+
Two proto2 behaviours are worth naming:
|
|
143
|
+
|
|
144
|
+
- **Closed enums.** A number the enum declaration does not list is not a value
|
|
145
|
+
of the field (the reference parsers move it to the unknown fields), so `get()`
|
|
146
|
+
skips it, and `encode()` passes the original bytes on untouched.
|
|
147
|
+
- **`required` fields.** Whether a required field is present is a property of
|
|
148
|
+
the *merged* message, which is what the tree decoder checks after parsing. A
|
|
149
|
+
type that reaches a `required` field therefore decodes through the tree
|
|
150
|
+
(`decodeEager`) instead of the wire view; everything else decodes lazily.
|
|
151
|
+
|
|
152
|
+
## Tests
|
|
153
|
+
|
|
154
|
+
`test/test_pbwire.js` covers descriptor-set loading, every field kind, oneof,
|
|
155
|
+
packed and unpacked repetition, groups, closed enums, required fields,
|
|
156
|
+
byte-exact re-encode (including unknown field pass-through), editing a decoded
|
|
157
|
+
message, absent fields, schema/wire errors, nesting caps, and double-free
|
|
158
|
+
safety. The last two tests are differential: one decodes a corpus of truncated
|
|
159
|
+
and bit-flipped payloads, asserts that a decoded message is always its own
|
|
160
|
+
bytes, and compares every field against the tree decoder's answer on the same
|
|
161
|
+
bytes (so the two materializers cannot drift apart); the other feeds the addon a
|
|
162
|
+
slice array it did not produce and expects an error rather than a read past the
|
|
163
|
+
buffer.
|
|
164
|
+
|
|
165
|
+
The fixtures are `tests/data/{demo,shapes,legacy}.desc`, generated from the
|
|
166
|
+
`.proto` files next to them:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
protoc -Ibindings/node/tests/data \
|
|
170
|
+
--descriptor_set_out=bindings/node/tests/data/demo.desc \
|
|
171
|
+
bindings/node/tests/data/demo.proto
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Benchmark
|
|
175
|
+
|
|
176
|
+
`benchmarks/bench.sh` generates `matrix3_pb.js` with `protoc-gen-js`, installs
|
|
177
|
+
`google-protobuf` (the jspb runtime), `protoc-gen-js` and [protobufjs](https://www.npmjs.com/package/protobufjs) (the
|
|
178
|
+
fastest widely used JS runtime) into `benchmarks/node_modules` on first run,
|
|
179
|
+
and prints the four standard tiers from [`BENCHMARKS.md`](../../BENCHMARKS.md)
|
|
180
|
+
for `pbwire-node`, jspb and protobufjs side by side:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
./benchmarks/bench.sh # or: make node-bench
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
| payload | op | pbwire-node (MB/s, x jspb) | jspb (MB/s) | protobufjs (MB/s) |
|
|
187
|
+
|---|---|---|---|---|
|
|
188
|
+
| 136 B | encode | 608.5 (10.9x) | 56.0 | 243.3 |
|
|
189
|
+
| 136 B | decode | 375.3 (3.1x) | 119.5 | 395.7 |
|
|
190
|
+
| 174 B | encode | 568.6 (9.0x) | 62.9 | 300.8 |
|
|
191
|
+
| 174 B | decode | 431.9 (3.8x) | 112.7 | 391.1 |
|
|
192
|
+
| 2469 B | encode | 1201.6 (20.8x) | 57.9 | 445.6 |
|
|
193
|
+
| 2469 B | decode | 1122.9 (14.0x) | 80.3 | 375.4 |
|
|
194
|
+
| 74913 B | encode | 1350.2 (22.3x) | 60.6 | 496.5 |
|
|
195
|
+
| 74913 B | decode | 1655.0 (20.0x) | 82.9 | 402.0 |
|
|
196
|
+
|
|
197
|
+
jspb's throughput does not move with payload size: its writer resolves a
|
|
198
|
+
code/type table per field and hands every repeated element to a closure, where
|
|
199
|
+
the C core writes one tag byte and one inline value, or memcpys an already
|
|
200
|
+
validated slice. Two boundary costs decide the small tiers: N-API's own
|
|
201
|
+
`Buffer` creation costs ~200 ns, so `encode()` encodes into the type's reusable
|
|
202
|
+
buffer and copies into a JS-allocated (pooled) `Buffer` of the exact size, and
|
|
203
|
+
decode copies the payload once with `Buffer.from` instead of paying for a
|
|
204
|
+
per-message finalizer.
|
|
205
|
+
|
|
206
|
+
## Deliberate skips
|
|
207
|
+
|
|
208
|
+
- **No protoc plugin / code generation.** The schema is always loaded at run
|
|
209
|
+
time, like the Python binding: a `FileDescriptorSet` is the only schema
|
|
210
|
+
source, and there is no generated layout to keep in sync.
|
|
211
|
+
- **No conformance testee.** The official suite is covered through the C core
|
|
212
|
+
and `conformance/`, not through this binding. The differential test above is
|
|
213
|
+
what keeps the addon's wire reader honest against the core's.
|
|
214
|
+
|
|
215
|
+
Also out of scope here: JSON/text format (`pbw_json_print`/`pbw_text_print`),
|
|
216
|
+
field presence introspection (`pbw_message_field_count`), and delimited framing.
|
|
217
|
+
Each is one addon export away when a consumer needs it.
|
|
218
|
+
|
|
219
|
+
## Generated models
|
|
220
|
+
|
|
221
|
+
`protoc-gen-pbwire` (in `../../codegen`) generates `matrix3.d.ts` interfaces
|
|
222
|
+
(for TypeScript) and a JSDoc/`@ts-check` `.js` with the same shapes plus the enum
|
|
223
|
+
constants, describing exactly the plain objects `MessageType.encode()` accepts
|
|
224
|
+
and `Message.get()` returns. The committed output is `tests/gen/` and
|
|
225
|
+
`examples/gen/`; the generated test type-checks with `tsc --strict --checkJs`.
|
|
226
|
+
See `../../codegen/README.md`.
|
package/build.sh
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Build and check the Node.js binding: compile the N-API addon against
|
|
3
|
+
# build/libpbwire.a (building it first when missing), then run the self-check.
|
|
4
|
+
# clang on purpose: no node-gyp, no npm, no dependencies.
|
|
5
|
+
#
|
|
6
|
+
# bindings/node/build.sh build the addon, then run the tests
|
|
7
|
+
# bindings/node/build.sh build build only
|
|
8
|
+
set -eu
|
|
9
|
+
|
|
10
|
+
here=$(cd "$(dirname "$0")" && pwd)
|
|
11
|
+
root=$(cd "$here/../.." && pwd)
|
|
12
|
+
cc=${CC:-clang}
|
|
13
|
+
|
|
14
|
+
if [ ! -f "$root/build/libpbwire.a" ]; then
|
|
15
|
+
# The default make goal is the shared library; the addon links the static
|
|
16
|
+
# archive, so name it explicitly.
|
|
17
|
+
make -C "$root" build/libpbwire.a
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Node ships its headers next to the binary: <prefix>/bin/node and
|
|
21
|
+
# <prefix>/include/node. Override with NODE_INCLUDE if a packaging split them.
|
|
22
|
+
: "${NODE_INCLUDE:=$(node -p 'require("path").join(require("path").dirname(require("path").dirname(process.execPath)), "include", "node")')}"
|
|
23
|
+
if [ ! -f "$NODE_INCLUDE/node_api.h" ]; then
|
|
24
|
+
echo "build.sh: node_api.h not found in $NODE_INCLUDE (set NODE_INCLUDE)" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# A .node module is a loadable module, not a library: resolve the node symbols
|
|
29
|
+
# at load time on macOS, at link time from libc elsewhere.
|
|
30
|
+
case $(uname -s) in
|
|
31
|
+
Darwin) link="-dynamiclib -undefined dynamic_lookup" ;;
|
|
32
|
+
*) link="-shared" ;;
|
|
33
|
+
esac
|
|
34
|
+
|
|
35
|
+
mkdir -p "$here/build"
|
|
36
|
+
# shellcheck disable=SC2086 # $link is a flag list
|
|
37
|
+
$cc -std=c99 -O2 -fPIC -Wall -Wextra \
|
|
38
|
+
-I"$NODE_INCLUDE" -I"$root/include" \
|
|
39
|
+
$link -o "$here/build/pbwire_node.node" \
|
|
40
|
+
"$here/runtime/addon.c" "$root/build/libpbwire.a"
|
|
41
|
+
|
|
42
|
+
echo "built $here/build/pbwire_node.node"
|
|
43
|
+
|
|
44
|
+
if [ "${1:-all}" = build ]; then
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# --expose-gc so the finalizer test can force the collections it checks.
|
|
49
|
+
exec node --expose-gc --test "$here/test/test_pbwire.js" "$here/tests/generated_test.js" "$here/tests/compat_test.js"
|