@goplasmatic/datalogic-node
July 17, 2026 · View on GitHub
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,565-case conformance
battery (54 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.
Install
npm install @goplasmatic/datalogic-node
Prebuilt 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.
Quick start
import { apply } from '@goplasmatic/datalogic-node';
const result = apply(
{ if: [{ '>': [{ var: 'score' }, 50] }, 'pass', 'fail'] },
{ score: 75 }
);
// -> "pass"
Compile-once / evaluate-many
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 is safe to share across worker threads — share one instance and
evaluate concurrently.
Sessions: hot-loop arena reuse
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.
Data handles, typed results, and batch evaluation
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 materialisation
For 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 mismatches
evaluateBool 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.
Async evaluation
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.
Errors
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
}
}
API surface
| Symbol | Description |
|---|---|
apply(rule, data) | One-shot compile + evaluate; convenience |
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 |
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() | High-water mark for the arena |
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.
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 EvaluateError
Unknown keys or values throw at construction with
errorType: 'ConfigurationError', so typos fail loudly instead of being
silently ignored.
Custom operators
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
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.
Tracing
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.
Performance
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.
Building from source
cd bindings/node
npm install
npx napi build --platform --release
npm test
This 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.