flowdoc-napi
v1.1.4
Published
Fast multilanguage serialization format - Node.js native (N-API) binding
Maintainers
Readme
flowdoc-napi
Node.js binding (via napi-rs, a native N-API addon — not
WASM) for FlowDoc — a
fast serialization format: indent-delimited key: value records, parsed by
a shared Rust core (flowdoc-core) linked directly into Node as a native
addon.
const native = require('flowdoc-napi'); // or require('.') from inside this package
const records = JSON.parse(native.parseFlowJson(`
Record
id: 1
name: Test
`));
// [{ id: '1', name: 'Test' }]This is a second Node.js binding, alongside bindings/nodejs (wasm-bindgen).
It exists to answer one question directly: is Node's WASM boundary the
reason FlowDoc trails native JSON.parse, or is it something else? A
diagnostic echo function (below) isolates the answer.
Entry points
parseFlowJson(data)— returns a JSON string. Use this one.parseFlowDirect(data)— builds napiArray/Objectvalues directly during the parse (no intermediate RustHashMap, no JSON string) — the same technique that madebindings/python'sparse_flow~2x faster. Real improvement overparseFlowbelow, but lands tied withparseFlowJson, not ahead of it (see below) — kept as a correct, documented alternative, not the recommendation.parseFlowBinary(data)— returns aBufferin the same length-prefixed binary wire format the FFI bindings use (flowdoc_parse_binary), for the caller to decode in JS withdecode_binary.js'sdecodeBinary(). Measured slower thanparseFlowJsonfor the full round trip, even though the Rust-side encode alone is faster than JSON encoding — see below for why.parseFlow(data)— returns parsed records as native JS objects by converting a RustHashMap<String, String>through napi's generic conversion. Measured slower than all of the above — kept as a documented negative result, not removed.echo(data)— diagnostic only: returns its input unchanged, doing no parsing. Isolates N-API call/marshaling overhead from the real work.
What was actually measured (1000-record fixture, this repo's shared benchmark)
| Path | Time | vs. native JSON.parse |
|---|---:|---|
| echo (N-API call overhead only) | ~0.03ms | — |
| parseFlow (HashMap + generic conversion) | ~0.69-0.74ms | ~5.3-5.7x slower |
| parseFlowBinary + decodeBinary() (JS-side decode loop) | ~0.64-0.70ms | ~4.9-5.4x slower |
| parseFlowDirect (build during parse, no HashMap) | ~0.56-0.59ms | ~2.7-3.0x slower |
| parseFlowJson + JSON.parse | ~0.57-0.62ms | ~2.7-3.0x slower |
| bindings/nodejs's WASM binding, for comparison | ~0.71ms | ~3.6x slower |
| native JSON.parse | ~0.20ms | — |
parseFlowDirect was the obvious next experiment after mem::take —
Python's parse_flow got a real ~2x win from building PyDict/PyList
objects directly during the parse instead of building a Rust HashMap
first and converting it afterward. The same change here (parseFlowDirect,
built with napi::Env::create_object/create_array, Object::set) does
show a real, reproducible win over the old parseFlow (~0.58ms vs.
~0.69-0.74ms — removing the intermediate HashMap and its generic napi
conversion pass genuinely helps). But it does not beat parseFlowJson
— the two are statistically tied across repeated runs. Python's win came
specifically from skipping PyO3's generic-conversion overhead; here, the
remaining cost is dominated by making ~3000 individual N-API
property-set calls (one native call per field — each one also allocates a
CString for the key internally, since N-API's named-property functions
require a NUL-terminated C string), and that per-call cost turns out to be
about the same as one Rust-side JSON encode plus one native JSON.parse()
call. parseFlowJson remains the recommended entry point.
Four real findings, all consistent with what's already documented for
bindings/nodejs's WASM binding and bindings/php's parseFlowBinary in
the main CLAUDE.md:
- N-API's own call overhead is negligible (~0.03ms, from
echo) — the WASM-vs-native-addon question was never really about FFI/binding overhead in the first place. - Returning a native JS object directly is slower than returning a JSON
string and calling
JSON.parse(), even after removing every avoidable Rust-side allocation.parseFlow(build aHashMap, convert it generically) andparseFlowDirect(skip theHashMap, build the napi object directly, the technique that won ~2x for Python) both lose toparseFlowJson;parseFlowDirectcloses most of that gap but not all of it. The common cost neither version can avoid is ~3000 individual N-API property-set calls (one native call per field) — that per-call cost is roughly equal to one Rust-side JSON encode plus one nativeJSON.parse()call. - Eliminating N-API calls entirely doesn't help either.
parseFlowBinary+decodeBinary()moves the per-field work to a pure JS loop over aBuffer/DataView— zero N-API boundary crossings per field, unlike every option above. It still measures slower thanparseFlowJson(~0.64-0.70ms vs. ~0.57-0.62ms), even though the Rust-side binary encode alone is faster than the JSON encode. The bottleneck there isBuffer#toString(), called ~2000 times in the decode loop — each call is native and JIT-adjacent, but ~2000 of them still cost more in total than one purpose-builtJSON.parse()call. Four attempts now (parse_flow_wasm,parseFlow,parseFlowDirect,parseFlowBinary+decodeBinary), across three different boundary technologies (WASM marshaling, N-API calls, a pure-JS decode loop), and the pattern holds every time: any shape that does real per-field work loses to batching into one call. This isn't a boundary-technology problem — it's that FlowDoc's per-field data (2-3 short strings per record) is exactly the shape JSON's parser was built to consume in bulk, and nothing tried here changes that shape. - The
mem::takecore fix and the WASM→N-API switch are both real, and they stack —parseFlowJsonaverages ~0.58ms versus the WASM binding's ~0.71ms on identical input, a reproducible ~18-20% improvement, closing the JSON gap from ~3.6x down to ~2.7-3.0x.
It is not enough to beat native JSON.parse, though. The diagnostic
breakdown says why: the ~0.55ms of real cost (total minus echo's ~0.03ms
overhead) is genuine FlowDoc-parse-plus-JSON-encode work in Rust, competing
against a JSON parser that is one of the most heavily optimized code paths
in V8 — and the one technique that reliably beats generic per-field
marshaling (batching into one string, one parse call) is already what
parseFlowJson does. Closing the remaining gap would need the Rust-side
parse-plus-encode step itself to get faster in absolute terms — e.g.
avoiding the per-field String allocation in flowdoc_core::Record itself
(would affect every binding, not just this one) — not attempted here, and
not guaranteed to be enough on its own even if it works: mem::take was a
~30-40% win at the Rust level and only translated to ~18-20% here once
JS-side costs were included, so a further Rust-only win would likely
translate to something smaller still once the JS side dilutes it further.
Licensing (soft gate)
parseFlowJson/parseFlow/parseFlowDirect/parseFlowBinary work
identically whether or not a license key is configured — there is no
Pro-exclusive capability gated by this yet. If FLOWDOC_LICENSE_KEY is
set, this package validates it once per process against
FLOWDOC_LICENSE_SERVER + /api/licenses/validate (no default server —
validation is skipped entirely if this isn't set too), asynchronously, and
logs a warning on an invalid key or an unreachable server.
This lives in a separate hand-written file, license.js, not the
napi-rs-generated index.js — it's pure HTTP client logic with no
native/Rust involvement, so requiring it doesn't need the compiled addon
at all. Import it via the subpath:
const { licenseStatus } = require('flowdoc-napi/license');
const status = await licenseStatus();
// { checked: true, valid: true | false | null, error: string | null }valid is null when there was nothing to check (no key configured) or
nothing could be checked (no server configured, or unreachable) — see
license.js for the full behavior.
In production, set FLOWDOC_LICENSE_SERVER=https://license-admin.sendwavehub.tech/api
(the trailing /api is required — see RELEASING.md's "Production
license server" section for why). There is no default; validation is
skipped entirely without it.
Activation
activateLicense(activatedBy, options) is a separate, explicit call —
unlike licenseStatus(), it never runs automatically, since it's a
mutating call (it flips the license to "Activated" server-side, unlike
/validate's read-only check). Call it once, e.g. on first run/install:
const { activateLicense } = require('flowdoc-napi/license');
const result = await activateLicense('install-script', { activationIp: '1.2.3.4', metadata: { os: 'linux' } });
// { success: true, error: null, message, tier, seats, expiresAt, customerId, signedLicenseArtifact }
// or, on failure: { success: false, error: string, ...other fields null }Posts to FLOWDOC_LICENSE_SERVER + /licenses/<FLOWDOC_LICENSE_KEY>/activate
(a single /api/ segment, since FLOWDOC_LICENSE_SERVER is expected to
already carry one — see /validate's doubled /api/api/ above).
options.activationIp/options.metadata are both optional. Cache
signedLicenseArtifact yourself if you need it later; this function
doesn't persist anything.
Build & test
cd bindings/nodejs-napi
npm install
npm run build # napi build --platform --release -- also (re)generates index.js/index.d.ts
npm test # runs tests/test.js then tests/test_license.js
npm run benchnpm install installs @napi-rs/cli (a devDependency) — the build
script uses its napi build command instead of a hand-rolled
cargo build + cp, so it produces a correctly platform-named addon
(flowdoc-napi.<platform>-<arch>[-<abi>].node) automatically, on whatever
OS/arch it runs on, rather than assuming macOS the way an earlier prototype
version of this binding did.
index.js/index.d.ts (checked into git, unlike the compiled *.node
addon which is gitignored) are generated by napi build and loaded via
require('flowdoc-napi') — they detect the running platform/arch and
require() either a same-directory .node file (a local build, or a
monorepo checkout) or one of three optional per-platform npm packages
(flowdoc-napi-darwin-arm64, flowdoc-napi-linux-x64-gnu,
flowdoc-napi-win32-x64-msvc, each just a compiled addon — see npm/)
that npm install picks based on the installing machine's platform. Those
three, plus Windows/Linux/macOS(arm64) native .node builds, are what
.github/workflows/build-nodejs-napi.yml builds per-platform and
publishes to npm on a vX.Y.Z tag — see that workflow for the exact
napi-rs (napi prepublish) publish sequence.
See the FlowDoc project for the format overview, benchmark numbers, and links to every other binding.
