@pbwire/pbwire 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,205 @@
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
+ A `FileDescriptorSet` (`protoc --descriptor_set_out`) becomes a schema at
4
+ runtime; no code generation, no `protoc` plugin, no npm dependencies. The
5
+ runtime is `runtime/addon.c`, an N-API addon in C over `libpbwire.a`; the
6
+ `Schema`/`Message` surface is `lib/pbwire.js`.
7
+
8
+ ```js
9
+ const { Schema } = require('./lib/pbwire');
10
+
11
+ const schema = Schema.fromFile('tests/data/demo.desc');
12
+ const person = schema.message('demo.Person');
13
+
14
+ const wire = person.encode({name: 'alice', id: 7, inner: {x: 42}});
15
+ // or, by hand:
16
+ const msg = person.newMessage();
17
+ msg.set('name', 'alice').set('inner', {x: 42});
18
+ msg.encode(); // Buffer
19
+ msg.free();
20
+
21
+ const back = person.decode(wire);
22
+ back.get('name'); // 'alice'
23
+ back.get('inner').x; // 42
24
+ back.encode().equals(wire); // true: byte-exact re-encode
25
+ back.free();
26
+ ```
27
+
28
+ ## How it works
29
+
30
+ A message is one of two things, and both live behind the same API:
31
+
32
+ - **A built message** (`newMessage()`/`set()`) is a `pbw_message` value tree in
33
+ C. Field writes go straight into it, and `encode()` runs the core encoder
34
+ into a per-type reusable buffer, so re-encoding a message never crosses the
35
+ boundary again.
36
+ - **A decoded message** (`decode()`) is the bytes it came from plus one 12-byte
37
+ slice per wire field: decode validates the payload once while recording those
38
+ slices, `get()` materializes a single field on demand, and `encode()` hands
39
+ the same bytes back. A round trip is therefore byte-exact — unknown fields,
40
+ field order and non-canonical encodings included — and nothing is built for
41
+ fields that are never read.
42
+
43
+ The wire form holds no native state: the bytes are a JS `Buffer` and the slices
44
+ a `Uint32Array`, both owned by the message object. That is deliberate. Attaching
45
+ a finalizer to native memory costs N-API ~250 ns per object, which is more than
46
+ the entire decode of a small message, so only the built form (which has no
47
+ JS-visible storage) pays for one.
48
+
49
+ Writing to a decoded message promotes it to a tree (one `pbw_decode` of the
50
+ retained bytes), so `set()` works on either form and only callers that actually
51
+ edit a parsed message pay for the tree.
52
+
53
+ Field facts (number, type, wire type, label, oneof, closed enum, sub-message)
54
+ are resolved once per descriptor into a cached table with a name hash and a
55
+ number index, so `get()`/`set()` and the wire scan never walk the descriptor.
56
+
57
+ ## Build and run
58
+
59
+ Needs Node (headers ship with it) and a C compiler. No `npm install`, no
60
+ `node-gyp`.
61
+
62
+ ```sh
63
+ bindings/node/build.sh # build the addon, then run the tests
64
+ bindings/node/build.sh build # build only
65
+ node bindings/node/test/test_pbwire.js # the tests alone (node:test)
66
+ node bindings/node/examples/roundtrip.js # the example
67
+ ```
68
+
69
+ `build.sh` builds `build/libpbwire.a` first when it is missing (the root `make`
70
+ default goal is the shared library, so it asks for the archive by name), then
71
+ compiles the addon with clang against `$(node -p ...)/include/node` and links
72
+ the static core. Set `NODE_INCLUDE` if a packaging splits the Node headers away
73
+ from the binary. Output: `bindings/node/build/pbwire_node.node`.
74
+
75
+ ## API
76
+
77
+ `Schema`
78
+ | member | meaning |
79
+ |---|---|
80
+ | `Schema.fromBytes(buffer)` | load a `FileDescriptorSet`; throws on malformed input |
81
+ | `Schema.fromFile(path)` | same, from a file |
82
+ | `schema.message(name)` | the message type `name` (`"pkg.Msg"`); throws when it is not a message |
83
+
84
+ `MessageType`
85
+ | member | meaning |
86
+ |---|---|
87
+ | `type.name` / `type.schema` | dotted full name and owning schema |
88
+ | `type.newMessage()` | empty `Message` (the mutable tree form) |
89
+ | `type.encode(object \| Message)` | encode a plain object or an existing `Message` |
90
+ | `type.decode(buffer)` | decode wire bytes into a `Message`; throws on malformed input |
91
+
92
+ `Message`
93
+ | member | meaning |
94
+ |---|---|
95
+ | `msg.get(name)` | field value, `undefined` when the field is absent; throws for an unknown name |
96
+ | `msg.set(name, value)` | store a value, replacing what was there; `undefined`/`null` clears the field |
97
+ | `msg.encode()` | wire bytes as a `Buffer` |
98
+ | `msg.free()` | release early; a second call is a no-op, any other call afterwards throws |
99
+
100
+ Values map per field type:
101
+
102
+ | field type | JS value |
103
+ |---|---|
104
+ | `int32`, `uint32`, `sint32`, `fixed32`, `sfixed32`, `enum` | `number` |
105
+ | `int64`, `uint64`, `sint64`, `fixed64`, `sfixed64` | `BigInt` (a `number` is accepted on `set`) |
106
+ | `float`, `double` | `number` |
107
+ | `bool` | `boolean` |
108
+ | `string` | `string` |
109
+ | `bytes` | `Buffer` |
110
+ | message | plain `object` (nested `get`/`set` by field name) |
111
+ | repeated | `array` |
112
+
113
+ Presence is explicit, as in the C core: a field set to its zero value is
114
+ written to the wire instead of being skipped the way a generated proto3
115
+ serializer would skip it. A message field is replaced on `set`, not merged, and
116
+ a repeated write replaces the whole list. Reading follows the wire: a repeated
117
+ field that is not on the wire reads `undefined`, a oneof reads only its last
118
+ member, and a singular sub-message that appears twice merges (the payloads
119
+ concatenate, which is what parsing them as one message means).
120
+
121
+ Two proto2 behaviours are worth naming:
122
+
123
+ - **Closed enums.** A number the enum declaration does not list is not a value
124
+ of the field (the reference parsers move it to the unknown fields), so `get()`
125
+ skips it, and `encode()` passes the original bytes on untouched.
126
+ - **`required` fields.** Whether a required field is present is a property of
127
+ the *merged* message, which is what the tree decoder checks after parsing. A
128
+ type that reaches a `required` field therefore decodes through the tree
129
+ (`decodeEager`) instead of the wire view; everything else decodes lazily.
130
+
131
+ ## Tests
132
+
133
+ `test/test_pbwire.js` covers descriptor-set loading, every field kind, oneof,
134
+ packed and unpacked repetition, groups, closed enums, required fields,
135
+ byte-exact re-encode (including unknown field pass-through), editing a decoded
136
+ message, absent fields, schema/wire errors, nesting caps, and double-free
137
+ safety. The last two tests are differential: one decodes a corpus of truncated
138
+ and bit-flipped payloads, asserts that a decoded message is always its own
139
+ bytes, and compares every field against the tree decoder's answer on the same
140
+ bytes (so the two materializers cannot drift apart); the other feeds the addon a
141
+ slice array it did not produce and expects an error rather than a read past the
142
+ buffer.
143
+
144
+ The fixtures are `tests/data/{demo,shapes,legacy}.desc`, generated from the
145
+ `.proto` files next to them:
146
+
147
+ ```sh
148
+ protoc -Ibindings/node/tests/data \
149
+ --descriptor_set_out=bindings/node/tests/data/demo.desc \
150
+ bindings/node/tests/data/demo.proto
151
+ ```
152
+
153
+ ## Benchmark
154
+
155
+ `benchmarks/bench.sh` generates `matrix3_pb.js` with `protoc-gen-js`, installs
156
+ `google-protobuf` (the jspb runtime), `protoc-gen-js` and [protobufjs](https://www.npmjs.com/package/protobufjs) (the
157
+ fastest widely used JS runtime) into `benchmarks/node_modules` on first run,
158
+ and prints the four standard tiers from [`BENCHMARKS.md`](../../BENCHMARKS.md)
159
+ for `pbwire-node`, jspb and protobufjs side by side:
160
+
161
+ ```sh
162
+ ./benchmarks/bench.sh # or: make node-bench
163
+ ```
164
+
165
+ | payload | op | pbwire-node (MB/s, x jspb) | jspb (MB/s) | protobufjs (MB/s) |
166
+ |---|---|---|---|---|
167
+ | 136 B | encode | 608.5 (10.9x) | 56.0 | 243.3 |
168
+ | 136 B | decode | 375.3 (3.1x) | 119.5 | 395.7 |
169
+ | 174 B | encode | 568.6 (9.0x) | 62.9 | 300.8 |
170
+ | 174 B | decode | 431.9 (3.8x) | 112.7 | 391.1 |
171
+ | 2469 B | encode | 1201.6 (20.8x) | 57.9 | 445.6 |
172
+ | 2469 B | decode | 1122.9 (14.0x) | 80.3 | 375.4 |
173
+ | 74913 B | encode | 1350.2 (22.3x) | 60.6 | 496.5 |
174
+ | 74913 B | decode | 1655.0 (20.0x) | 82.9 | 402.0 |
175
+
176
+ jspb's throughput does not move with payload size: its writer resolves a
177
+ code/type table per field and hands every repeated element to a closure, where
178
+ the C core writes one tag byte and one inline value, or memcpys an already
179
+ validated slice. Two boundary costs decide the small tiers: N-API's own
180
+ `Buffer` creation costs ~200 ns, so `encode()` encodes into the type's reusable
181
+ buffer and copies into a JS-allocated (pooled) `Buffer` of the exact size, and
182
+ decode copies the payload once with `Buffer.from` instead of paying for a
183
+ per-message finalizer.
184
+
185
+ ## Deliberate skips
186
+
187
+ - **No protoc plugin / code generation.** The schema is always loaded at run
188
+ time, like the Python binding: a `FileDescriptorSet` is the only schema
189
+ source, and there is no generated layout to keep in sync.
190
+ - **No conformance testee.** The official suite is covered through the C core
191
+ and `conformance/`, not through this binding. The differential test above is
192
+ what keeps the addon's wire reader honest against the core's.
193
+
194
+ Also out of scope here: JSON/text format (`pbw_json_print`/`pbw_text_print`),
195
+ field presence introspection (`pbw_message_field_count`), and delimited framing.
196
+ Each is one addon export away when a consumer needs it.
197
+
198
+ ## Typed models
199
+
200
+ `protoc-gen-pbwire-typed` (in `../typed`) generates `matrix3.d.ts` interfaces
201
+ (for TypeScript) and a JSDoc/`@ts-check` `.js` with the same shapes plus the enum
202
+ constants, describing exactly the plain objects `MessageType.encode()` accepts
203
+ and `Message.get()` returns. The committed output is `tests/gen/` and
204
+ `examples/gen/`; the typed test type-checks with `tsc --strict --checkJs`.
205
+ See `../typed/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/typed_test.js"
package/lib/pbwire.js ADDED
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ /* Node.js binding: a FileDescriptorSet becomes a schema at runtime (no code
4
+ * generation, no protoc plugin), and messages are read and written by name.
5
+ *
6
+ * const { Schema } = require('./lib/pbwire');
7
+ * const schema = Schema.fromFile('demo.desc');
8
+ * const person = schema.message('demo.Person');
9
+ *
10
+ * const msg = person.newMessage();
11
+ * msg.set('name', 'alice');
12
+ * msg.set('inner', { x: 1, tag: 'kid' });
13
+ * const wire = msg.encode(); // Buffer
14
+ * msg.free();
15
+ *
16
+ * const back = person.decode(wire); // Message
17
+ * back.get('inner').x; // 1
18
+ * back.encode().equals(wire); // true
19
+ *
20
+ * A message is one of two things:
21
+ *
22
+ * A built message (newMessage()/set()) is a value tree in C. Writing to it
23
+ * is cheap after the first field, and encode() runs the core encoder into a
24
+ * reusable buffer.
25
+ *
26
+ * A decoded message is the bytes plus one slice per wire field: decode()
27
+ * validates the payload once, a value is materialized only when it is read,
28
+ * and encode() hands the bytes back, so a round trip is byte-exact (unknown
29
+ * fields, field order and non-canonical encodings included).
30
+ *
31
+ * Writing to a decoded message turns it into a tree (one decode of the retained
32
+ * bytes), so the API is the same either way.
33
+ *
34
+ * Values map per field type:
35
+ *
36
+ * int32/uint32/sint32/fixed32/sfixed32/enum number
37
+ * int64/uint64/sint64/fixed64/sfixed64 BigInt
38
+ * float/double number
39
+ * bool boolean
40
+ * string string
41
+ * bytes Buffer
42
+ * message / repeated object / array
43
+ *
44
+ * There is no int64 precision loss: 64-bit fields are BigInt, and a JS number
45
+ * is accepted when setting (rounded the way Number->int64 rounds).
46
+ */
47
+
48
+ const fs = require('node:fs');
49
+ const path = require('node:path');
50
+
51
+ const addon = require(path.join(__dirname, '..', 'build', 'pbwire_node.node'));
52
+
53
+ class Schema {
54
+ constructor(handle) {
55
+ this._handle = handle;
56
+ }
57
+
58
+ /** Load a FileDescriptorSet (protoc --descriptor_set_out, buf, ...). */
59
+ static fromBytes(bytes) {
60
+ return new Schema(addon.loadDescriptorSet(bytes));
61
+ }
62
+
63
+ static fromFile(file) {
64
+ return Schema.fromBytes(fs.readFileSync(file));
65
+ }
66
+
67
+ /** A message type of this schema; throws when the name is not a message. */
68
+ message(name) {
69
+ const handle = addon.descriptor(this._handle, name);
70
+ if (handle === null) {
71
+ throw new Error(`pbwire: ${name} is not a message in this schema`);
72
+ }
73
+ return new MessageType(this, name, handle);
74
+ }
75
+ }
76
+
77
+ class MessageType {
78
+ constructor(schema, name, handle) {
79
+ this.schema = schema;
80
+ this.name = name;
81
+ this._handle = handle;
82
+ // A proto2 `required` field has to hold in the merged message, which is
83
+ // what the tree decoder checks; such a type decodes eagerly.
84
+ this._eager = addon.isEager(handle);
85
+ }
86
+
87
+ /** An empty message of this type. */
88
+ newMessage() {
89
+ const message = new Message(this);
90
+ message._handle = addon.messageNew(this._handle);
91
+ return message;
92
+ }
93
+
94
+ /** Encode a plain object or a Message built by this type. */
95
+ encode(value) {
96
+ if (value instanceof Message) {
97
+ if (value.type !== this) {
98
+ throw new TypeError(`pbwire: ${value.type.name} is not ${this.name}`);
99
+ }
100
+ return value.encode();
101
+ }
102
+ if (value === null || typeof value !== 'object') {
103
+ throw new TypeError('pbwire: encode expects an object or a Message');
104
+ }
105
+ // The addon builds the tree, encodes it into the type's scratch buffer and
106
+ // frees the tree; only the bytes cross back.
107
+ return this._copy(addon.encodeObject(this._handle, value));
108
+ }
109
+
110
+ /** Decode wire bytes into a Message; throws on malformed input. */
111
+ decode(bytes) {
112
+ if (!Buffer.isBuffer(bytes)) {
113
+ throw new TypeError('pbwire: decode expects a Buffer');
114
+ }
115
+ // A private copy: the caller may reuse its Buffer, and a decoded message
116
+ // keeps the bytes it came from.
117
+ const wire = Buffer.from(bytes);
118
+ const message = new Message(this);
119
+ if (this._eager) {
120
+ message._handle = addon.decodeEager(this._handle, wire);
121
+ } else {
122
+ message._wire = wire;
123
+ message._slices = addon.decode(this._handle, wire);
124
+ }
125
+ return message;
126
+ }
127
+
128
+ /** Copies the scratch an encode just filled into a pooled Buffer. */
129
+ _copy(len) {
130
+ const out = Buffer.allocUnsafe(len);
131
+ addon.copyEncoded(this._handle, out);
132
+ return out;
133
+ }
134
+ }
135
+
136
+ class Message {
137
+ constructor(type) {
138
+ this.type = type;
139
+ this._handle = null; // tree form (newMessage/set)
140
+ this._wire = null; // wire form (decode): the bytes
141
+ this._slices = null; // wire form: (field index, offset, length) triples
142
+ }
143
+
144
+ /** Field value by name, or undefined when the field is absent. */
145
+ get(name) {
146
+ this._live();
147
+ if (this._wire !== null) {
148
+ return addon.wireGet(this.type._handle, this._wire, this._slices, name);
149
+ }
150
+ return addon.get(this._handle, name);
151
+ }
152
+
153
+ /** Store a field value by name; `undefined`/`null` clears the field. */
154
+ set(name, value) {
155
+ this._live();
156
+ if (this._wire !== null) {
157
+ // Writing needs a mutable tree: decode the retained bytes once.
158
+ this._handle = addon.promote(this.type._handle, this._wire);
159
+ this._wire = null;
160
+ this._slices = null;
161
+ }
162
+ addon.set(this._handle, name, value);
163
+ return this;
164
+ }
165
+
166
+ /** Wire bytes of this message. */
167
+ encode() {
168
+ this._live();
169
+ if (this._wire !== null) {
170
+ // Untouched since decode: the bytes are the encoding.
171
+ return Buffer.from(this._wire);
172
+ }
173
+ return this.type._copy(addon.encode(this._handle));
174
+ }
175
+
176
+ /** Release the tree early; calling it twice is a no-op. A decoded message
177
+ * only drops its bytes, which the garbage collector would do anyway. */
178
+ free() {
179
+ if (this._handle !== null) {
180
+ addon.free(this._handle);
181
+ this._handle = null;
182
+ }
183
+ this._wire = null;
184
+ this._slices = null;
185
+ }
186
+
187
+ _live() {
188
+ if (this._handle === null && this._wire === null) {
189
+ throw new TypeError('pbwire: message was freed');
190
+ }
191
+ }
192
+ }
193
+
194
+ module.exports = { Schema, MessageType, Message, abiVersion: addon.abiVersion };
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@pbwire/pbwire",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Dynamic protobuf over the pbwire C ABI, schema at runtime, no generated code",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jinganix/pbwire.git",
9
+ "directory": "bindings/node"
10
+ },
11
+ "author": "jinganix",
12
+ "main": "lib/pbwire.js",
13
+ "files": ["lib/", "runtime/", "build.sh", "README.md"],
14
+ "scripts": {
15
+ "install": "./build.sh build",
16
+ "test": "./build.sh"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ }
21
+ }