Native Node.js bindings for
datalogic-rs, a fast
Rust implementation of JSONLogic. Same rules,
same semantics as the Rust crate, with the compile-once /
evaluate-many pattern exposed natively — compile a rule once and
evaluate it against thousands of data inputs without re-parsing. Every
binding runs the same core and passes the same 1,658-case conformance
battery (58 suites).
For the cross-runtime overview and the API-tier model every binding implements, see the repo README.
New in v5. This native Node binding is new — there is no v4 Node package. If you were running JSONLogic under Node via v4's
@goplasmatic/datalogic(WASM), the v5 upgrade path for production Node services is to install this package. See MIGRATION.md for the full cookbook.
Two npm packages, one engine.
@goplasmatic/datalogic-wasmis the WebAssembly build — runs in browsers, Node, Deno, Bun. This package (@goplasmatic/datalogic-node) is the native Node build via napi-rs, pulling in the same Rust engine through a per-platform prebuilt.nodeartifact. Pick this one when you're on Node and want maximum throughput; pick the WASM package when you need to run in the browser or want a single artifact across runtimes.
npm install @goplasmatic/datalogic-nodePrebuilt platform binaries are published as optionalDependencies, so
npm pulls only the .node file matching the consumer's platform:
| Platform | Architectures |
|---|---|
| Linux (glibc) | x64, arm64 |
| Linux (musl) | x64, arm64 |
| macOS | x64, arm64 |
| Windows | x64, arm64 |
Node 18 and newer are supported.
import { apply } from '@goplasmatic/datalogic-node';
const result = apply(
{ if: [{ '>': [{ var: 'score' }, 50] }, 'pass', 'fail'] },
{ score: 75 }
);
// -> "pass"Rules and data are plain JS values, and both arguments also accept JSON
text: a JS string passed as rule or data is parsed as JSON rather
than treated as a string value. To evaluate against a data document
that is a JSON string, pass it encoded
(rule.evaluate(JSON.stringify('hello'))), or use the string-in
methods (evaluateStr) or a DataHandle.
For repeated evaluations of the same rule, compile once and hold the
Rule instance:
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine();
const rule = engine.compile({ '+': [{ var: 'x' }, 1] });
for (const payload of inputs) {
console.log(rule.evaluate(payload));
}Rule has no thread affinity on the Rust side, but napi class instances
cannot be posted or transferred between worker_threads: a Rule sent
through postMessage arrives as an empty plain object with no
evaluate. Each worker must load the module and compile its own Rule
(compiling is cheap). For parallelism from a single thread,
rule.evaluateStrAsync(json) evaluates on the libuv pool (see
Async evaluation).
A Session reuses one bump arena across evaluations and resets between
calls to bound peak memory. Open one per worker thread:
const sess = engine.session();
for (const payload of inputs) {
sess.evaluate(rule, payload);
}Sessions hold non-Sync state and must not be shared between worker
threads — open one per worker.
New in 5.0.1, mirroring the C ABI v2 tiers. A DataHandle is an
immutable, pre-parsed JSON document: parse a payload once with
new DataHandle(json) and every evaluation against it skips JSON
parsing entirely. Handles are engine-independent (one handle can feed
rules compiled by different engines) and are never consumed or mutated
by evaluation. They are per-JS-thread — the underlying parsed tree is
Send but not Sync, which matches JS single-threaded semantics: a
handle cannot be shared across worker threads, so parse one per worker.
import { Engine, DataHandle } from '@goplasmatic/datalogic-node';
const handle = new DataHandle('{"age": 25, "status": "active"}'); // throws ParseError on bad JSON
handle.allocatedBytes; // arena bytes (input copy + tree)
rule.evaluateData(handle); // JS value out, no parse per call
rule.evaluateDataStr(handle); // JSON string out
sess.evaluateData(rule, handle); // hot path: session arena + no parse
sess.evaluateDataStr(rule, handle); // fastest: no parse, no JS materialisationFor predicates and scalar results, the typed session evaluations skip the JSON result round trip too:
sess.evaluateBool(rule, handle); // strict JSON boolean
sess.evaluateNumber(rule, handle); // any JSON number (JS has one number type)
sess.evaluateTruthy(rule, handle); // JSONLogic truthiness, never mismatchesevaluateBool and evaluateNumber throw an EvaluateError with
errorType: 'TypeMismatch' when the rule evaluates fine but the result
is not of the requested type (the message names the actual type).
evaluateTruthy coerces any result through the engine's configured
truthiness rules (the same coercion if/and/or apply).
The batch entry points evaluate a whole set in one native call and
report outcomes per item in the Promise.allSettled shape, so one bad
input never poisons its neighbours:
// One rule, many payloads:
const outcomes = sess.evaluateBatch(rule, [h0, h1, h2]);
// Many rules, one payload (the rule-set / feature-flag shape):
const flags = sess.evaluateMany([r0, r1], handle);
for (const [i, o] of outcomes.entries()) {
if (o.status === 'rejected') {
console.log(`item ${i} failed: ${o.reason.message} (${o.reason.tag})`);
continue;
}
console.log(`item ${i}: ${o.value}`); // result as a JSON string
}Item failures land in { status: 'rejected', reason: { tag, message, operator? } } and never throw; argument errors (a non-handle in the
array, a null rule, ...) do throw. The session arena is reset between
items.
One Node-specific note on engines: a Rule carries a reference to the
engine that compiled it, but every Session method evaluates the rule's
compiled logic with the session's engine — its configuration and
custom operators apply, and (unlike the C ABI) no engine-identity check
is performed. Compile rules and open sessions on the same engine unless
you specifically want that substitution.
Rule.evaluateStrAsync(dataJson) evaluates on the libuv thread pool and
returns a Promise<string>:
const result = await rule.evaluateStrAsync('{"age": 25}');It is not faster per operation than evaluateStr — the win is
event-loop hygiene: a large payload's parse + evaluate + serialize runs
off the JS thread, so reach for it when payloads are big enough to
cause noticeable event-loop stalls or to overlap evaluation with other
work. String input only (a DataHandle is pinned to the JS thread and
cannot cross to the pool). Rejections carry the same structured fields
as synchronous throws (name, errorType, operator, nodeIds,
path). Rules from engines with custom operators reject if evaluation
reaches a JS-backed operator — the callback is pinned to the JS thread.
Failures throw plain JS Error instances with structured fields
attached:
try {
rule.evaluate(data);
} catch (e) {
if (e.name === 'ParseError') {
// Malformed rule or data JSON
} else if (e.name === 'EvaluateError') {
console.log(e.errorType); // stable tag (e.g. "TypeError", "Thrown")
console.log(e.operator); // outermost failing operator
console.log(e.nodeIds); // leaf-to-root breadcrumb
console.log(e.path); // resolved root-to-leaf step list
}
}| Symbol | Description |
|---|---|
apply(rule, data) |
One-shot compile + evaluate; convenience |
builtinOperatorNames() |
Every built-in operator name this build accepts (includes the aliases var, ?:, match) |
Engine |
Construct once; holds compile state, opens sessions |
Engine.compile(rule) → Rule |
Parse a rule into a reusable handle |
Engine.eval(rule, data) |
One-shot, returns JS value |
Engine.evalStr(rule, data) |
One-shot, returns JSON string |
Engine.evaluateWithTrace(logic, data) |
One-shot with execution trace, returns JSON string |
Engine.session() → Session |
Open a hot-loop arena |
Engine.customOperatorNames() |
Names of the custom operators registered on this engine |
new DataHandle(json) |
Parse a payload once into a reusable handle |
DataHandle.allocatedBytes |
Arena bytes held by the handle |
Rule.evaluate(data) |
Evaluate, returns JS value |
Rule.evaluateStr(data) |
Evaluate, returns JSON string |
Rule.evaluateData(handle) |
Evaluate a pre-parsed handle, returns JS value |
Rule.evaluateDataStr(handle) |
Same, returns JSON string |
Rule.evaluateStrAsync(dataJson) |
Evaluate on the libuv pool, returns Promise<string> |
Session.evaluate(rule, data) |
Evaluate with arena reuse |
Session.evaluateStr(rule, data) |
Same, returns JSON string |
Session.evaluateData(rule, handle) |
Handle in, JS value out, arena reuse |
Session.evaluateDataStr(rule, handle) |
Handle in, JSON string out — fastest path |
Session.evaluateBool(rule, handle) |
Strict boolean result (TypeMismatch otherwise) |
Session.evaluateNumber(rule, handle) |
Any JSON number result (TypeMismatch otherwise) |
Session.evaluateTruthy(rule, handle) |
Engine-truthiness boolean; never mismatches |
Session.evaluateBatch(rule, handles) |
One rule × many handles, allSettled-style items |
Session.evaluateMany(rules, handle) |
Many rules × one handle, allSettled-style items |
Session.reset() |
Explicit arena reset (optional) |
Session.allocatedBytes() |
Bytes currently held by the arena's chunks |
Constructor options:
new Engine({ templating: true, config: { preset: 'strict' } })templating: true enables the engine's output-shaping templating mode —
multi-key objects in a rule compile to templates with embedded JSONLogic.
config sets the engine's evaluation configuration; see
Engine configuration.
The config constructor option changes evaluation semantics. It accepts
a plain object or a JSON-encoded string; both use the wire format every
binding shares, parsed by the core crate's
EvaluationConfig::from_json_str.
All keys are optional:
| Key | Values |
|---|---|
preset |
'default', 'safe_arithmetic', 'strict' |
arithmetic_nan_handling |
'throw_error', 'ignore_value', 'coerce_to_zero', 'return_null' |
division_by_zero |
'return_saturated', 'throw_error', 'return_null', 'return_infinity' |
loose_equality_errors |
boolean |
truthy_evaluator |
'javascript', 'python', 'strict_boolean' |
numeric_coercion |
object of booleans: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth |
integer >= 1 |
preset selects the starting point and the remaining keys override
individual fields on top of it:
const engine = new Engine({ config: { preset: 'strict' } });
// The default engine coerces booleans to numbers; strict rejects them:
engine.evalStr('{"+": [1, true]}', 'null'); // throws EvaluateErrorUnknown keys or values throw at construction with
errorType: 'ConfigurationError', so typos fail loudly instead of being
silently ignored.
Register host-language operators by passing a { name: fn } map as the
second constructor argument. Each callback receives the operator's
pre-evaluated arguments as a JSON-array string and returns a JSON-value
string:
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine({}, {
double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
});
const rule = engine.compile({ double: [21] });
rule.evaluate({}); // 42
engine.customOperatorNames(); // ['double']Callbacks run synchronously on the thread that created the engine.
Built-ins win: registering a name that collides with a built-in
operator (+, if, var, ...) has no effect. An engine carrying custom
operators is not safe to share across worker threads (the JS callback
is pinned to its originating thread); create one per worker. If a custom
operator is ever invoked from a different thread than the one that
registered it, evaluation fails with an EvaluateError naming the
operator rather than risking undefined behavior. A plain engine or a
compiled Rule with no custom operators is thread-safe.
Tooling that validates or autocompletes rules (editors, linters, palettes) can ask the binding for its vocabulary instead of keeping a hand-maintained list:
import { builtinOperatorNames, Engine } from '@goplasmatic/datalogic-node';
const names = builtinOperatorNames();
names.length; // 67: the 64 built-in operators plus the aliases var, ?:, match
names.includes('group_by'); // true
names.includes('preserve'); // false (removed in v5)
const engine = new Engine({}, { double: (a) => String(JSON.parse(a)[0] * 2) });
engine.customOperatorNames(); // ['double']builtinOperatorNames() mirrors Engine::builtin_operator_names() in
the Rust crate and is derived from the compiler's own lookup table, so
it cannot drift from dispatch; this binding enables every operator
feature, so the list is the full set. engine.customOperatorNames()
lists the operators passed as the second constructor argument (order
not guaranteed). The union of the two is that engine's full vocabulary,
which matters under templating mode, where an unknown key is not an
error but echoes back as data.
Engine.evaluateWithTrace(logic, data) evaluates with a step-by-step
execution trace. Both arguments are JSON strings. The return value is a
JSON string with the same envelope the WASM package
(@goplasmatic/datalogic-wasm) produces, so trace consumers such as the
React debugger component accept output from either package:
const engine = new Engine();
const run = JSON.parse(
engine.evaluateWithTrace('{"+": [1, 2, 3]}', 'null')
);
run.result; // 6
run.steps; // per-node log: { step_id, node_id, context, result, ... }
run.expression_tree; // compile-time tree: { id, expression, children }Failures do not throw. Instead result is null, error carries the
message, and structured_error the structured form. The rule is
compiled with optimization disabled so every operator surfaces a step;
expect it to be slower than evalStr. Use it for debugging, not hot
paths.
Geomean across 51 operator benchmark suites (Apple M2 Pro, median of 3 runs; pairwise shared-suite ratios per the methodology): the native Rust core evaluates at 10.3 ns/op, 7.0× faster than json-logic-engine (compiled, the fastest JS engine), 28.1× faster than jsonlogic-rs (the closest Rust alternative), and 83.6× faster than the json-logic-js reference implementation. The WASM build under Node measures 900.5 ns geomean (88× native); on Node servers, prefer @goplasmatic/datalogic-node.
The napi-rs boundary adds a small per-call marshalling cost on top of the core numbers; the measured per-call boundary overhead for this binding, per API tier, lives in BINDINGS-OVERHEAD.md.
Pick the path by your data's shape. Three rules of thumb: compile
once and reuse the Rule; when your data is already a JSON string, call
evaluateStr — the string path parses directly into the engine and is
the fastest way across the boundary at every payload size; and when the
same payload feeds multiple evaluations, parse it once into a
DataHandle — the handle paths skip the per-call parse entirely and are
the fastest tier of all. If your data
lives as plain JS objects and your rules are small, be aware that a
well-optimized pure-JS engine (e.g. json-logic-engine's compiled mode)
runs with zero boundary cost and can beat any native binding on raw
ns/op for that shape. Reach for this package when you need string
payloads straight from the wire, full conformance including the
extension operators, deterministic latency and bounded memory, parallel
evaluation across worker threads, or the same engine behaving
identically across languages.
cd bindings/node
npm install
npx napi build --platform --release
npm testThis produces a local datalogic-node.<platform-triple>.node, plus
index.js and index.d.ts loaders. The .node, index.js, and
index.d.ts files are gitignored — napi build regenerates them.