@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 CHANGED
@@ -1,3 +1,226 @@
1
- # Temporary Holding Version
1
+ # pbwire Node.js binding
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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"