@goplasmatic/datalogic-node
v5.8.1
Published
JSONLogic (json-logic) rules engine for Node.js — native Rust core via N-API, compile-once/evaluate-many. A fast alternative to json-logic-js with identical semantics across Node, browsers/WASM, Python, Go, Java, .NET, PHP, and Rust.
Maintainers
Readme
@goplasmatic/datalogic-node
Part of datalogic-rs: one engine, every runtime.
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 2,128-case conformance
battery (66 suites).
For the cross-runtime overview and the API-tier model every binding implements, see the repo README.
No v4 package. This native binding started with v5. If you ran JSONLogic under Node through v4's
@goplasmatic/datalogic(WASM), install this package for production Node services. See MIGRATION.md for the full cookbook.
Two npm packages, one engine.
@goplasmatic/datalogic-wasmis the WebAssembly build and runs in browsers, Node, Deno, and 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 on Node when throughput matters; pick the WASM package when you need to run in the browser or want a single artifact across runtimes.
Install
npm install @goplasmatic/datalogic-nodePrebuilt platform binaries ship 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 |
The package supports Node 18 and newer.
If require('@goplasmatic/datalogic-node') or import fails with
MODULE_NOT_FOUND, upgrade to 5.8.0: versions 5.1.1 through 5.7.1
were published without the package's index.js loader and
index.d.ts typings.
Quick start
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: the binding parses a JS string passed as rule or data as JSON
rather than treating it 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.
Object keys come back sorted when an object crosses the boundary as a
JS value, in either direction (a rule, data or DataHandle.fromValue
object, or a result from evaluate). To keep key order, pass JSON text
and call a string-out method (evalStr, evaluateStr,
evaluateDataStr).
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 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).
Templating mode per compile
new Engine({ templating: true }) makes every compile treat a
multi-key object as an output template. To choose per rule instead,
engine.compileTemplate(rule) compiles in templating mode and
engine.compileStrict(rule) outside it, whatever the engine was built
with. Both use the engine's custom operators and config:
const engine = new Engine();
engine.compileTemplate({ name: { var: 'user' }, active: true }).evaluate({ user: 'Alice' });
// { active: true, name: 'Alice' } (keys sorted: a JS-value result)Checking a rule before it runs
compile checks the JSON and the rule's structure only: an unknown
operator compiles and fails at evaluation with
errorType: 'InvalidOperator'. engine.check(rule, mode?) reports
every problem the engine can see before the rule runs, each with an
RFC 6901 JSON Pointer into
the rule. mode is 'engine' (the default), 'strict' or
'template':
engine.check({ if: [true, { vr: 'x' }, { map: [1] }] });
// [
// { code: 'UnknownOperator', severity: 'error', pointer: '/if/1', operator: 'vr',
// message: 'unknown operator `vr`; did you mean `var`?' },
// { code: 'ArgumentCount', severity: 'error', pointer: '/if/2', operator: 'map',
// message: '`map` takes exactly 2 arguments, not 1' }
// ]
engine.check({ a: { var: 'x' }, b: 1 }, 'template'); // []An 'error' will fail; a 'warning' runs but is probably a mistake
(for example an argument the operator never evaluates).
engine.compileChecked(rule) compiles only a rule with no error
diagnostic and otherwise throws an Error named CompileError whose
diagnostics holds the same objects:
try {
engine.compileChecked({ vr: 'x' });
} catch (e) {
e.name; // 'CompileError'
e.diagnostics[0].code; // 'UnknownOperator'
}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, so don't share them between worker
threads; open one per worker.
Data handles, typed results, and batch evaluation
These mirror 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. Handles are engine-independent (one handle can feed
rules compiled by different engines), and evaluation never consumes or
mutates them. They are per-JS-thread: the underlying parsed tree is
Send but not Sync, which matches JS single-threaded semantics. You
cannot share a handle 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
const fromObj = DataHandle.fromValue({ age: 25, status: 'active' }); // no JSON round trip
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.evaluateInt(rule, handle); // a whole number a JS number holds exactly
sess.evaluateFloat(rule, handle); // any JSON number
sess.evaluateTruthy(rule, handle); // JSONLogic truthiness, never mismatchesevaluateBool, evaluateInt and evaluateFloat 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).
evaluateNumber is the deprecated name of evaluateFloat and is
removed in 6.0.
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 resets its arena 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) the binding performs no
engine-identity check. Compile rules and open sessions on the same engine unless
you 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 gain 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, because the callback is pinned to the JS thread.
API surface
| 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.compileTemplate(rule) / Engine.compileStrict(rule) → Rule | Compile in or outside templating mode, whatever the engine's default |
| Engine.compileChecked(rule) → Rule | Compile only a rule check finds no error in; otherwise throw CompileError |
| Engine.check(rule, mode?) | Every problem the engine can see before the rule runs, as diagnostics |
| Engine.eval(rule, data) | One-shot, returns JS value |
| Engine.evalStr(rule, data) | One-shot, returns JSON string |
| Engine.evalMetered(rule, data, budget?) | One-shot with an operation count, returns { result, ops } |
| Engine.evaluateWithTrace(logic, data, mode?) | One-shot with execution trace, JSON strings in and out |
| Engine.session() → Session | Open a hot-loop arena |
| Engine.operators() | The operator catalogue: one row per built-in operator this engine evaluates |
| Engine.truthy(value) | The engine's truthiness applied to a value (a string is JSON text) |
| Engine.customOperatorNames() | Names of the custom operators registered on this engine |
| new DataHandle(json) | Parse a payload once into a reusable handle |
| DataHandle.fromValue(value) | Build a handle from a JS value, copied once (later changes to the value are not seen) |
| DataHandle.allocatedBytes | Arena bytes held by the handle |
| Rule.evaluate(data) | Evaluate, returns JS value |
| Rule.evaluateStr(data) | Evaluate, returns JSON string |
| Rule.evaluateMetered(data, budget?) | Evaluate with an operation count, returns { result, ops } |
| 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> |
| Rule.facts() | What the rule reads and calls |
| 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.evaluateInt(rule, handle) | Whole-number result a JS number holds exactly, \|n\| <= 2^53 - 1 (TypeMismatch otherwise) |
| Session.evaluateFloat(rule, handle) | Any JSON number result (TypeMismatch otherwise) |
| Session.evaluateNumber(rule, handle) | Deprecated name of evaluateFloat; removed in 6.0 |
| 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 held by the arena's chunks (exact past 4 GiB) |
Constructor options:
new Engine({
templating: true,
templateKeyEscape: '$',
config: { preset: 'strict' },
strictOperatorNames: true,
families: ['ExtString', 'DateTime'],
})templating: true enables the engine's output-shaping templating mode:
multi-key objects in a rule compile to templates with embedded JSONLogic.
templateKeyEscape is an optional single-character prefix (see below).
config sets the engine's evaluation configuration, and families
limits the operator families it evaluates; see
Engine configuration. strictOperatorNames
refuses a custom operator named like a built-in (see
Custom operators). The options bag reads only
these five keys: new Engine({ preset: 'strict' }) builds a default
engine, because preset belongs under config.
Emitting keys that are operator names
A single-key object is an operator invocation, so in templating mode a key
naming a built-in (type, map, if, length, …) or a registered custom
operator runs the operator instead of becoming an output field. There is no
error, only the wrong result. Set templateKeyEscape to a single-character
prefix to recover those keys: exactly one leading prefix is stripped from
every template key, and an escaped key is never resolved as an operator.
const engine = new Engine({ templating: true, templateKeyEscape: '$' });
engine.compile({ $type: { var: 'x' } }).evaluate({ x: 1 }); // { type: 1 }
engine.compile({ $$type: 1 }).evaluate({}); // { $type: 1 }
engine.compile({ type: { var: 'x' } }).evaluate({ x: 1 }); // 'number' (operator)Unset by default, so $-prefixed keys otherwise pass through verbatim. The
prefix is one character of your choosing, so payloads that already use $
keys (MongoDB documents, JSON Schema output) can pick ~ or # instead.
Anything other than a one-character string throws at construction with
errorType: 'InvalidArguments'.
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
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. With
strictOperatorNames: true, that registration throws at construction
with errorType: 'ConfigurationError' instead. 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 (as rule.evaluateStrAsync does, on the libuv pool),
evaluation fails with an EvaluateError naming the operator rather than
risking undefined behavior. No napi class instance (Engine, Rule,
Session, DataHandle) can be posted to a worker thread, so every
worker builds its own either way.
Introspection
Operator names
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; // 87: the 84 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). For an engine with every operator family, the union of
the two is its full vocabulary, which matters under templating mode,
where an unknown key is not an error but echoes back as data. On an
engine built with families, engine.operators() lists the built-ins
that engine evaluates.
Operator catalogue, rule facts and truthiness
Three more calls describe the engine and a rule without running anything:
const engine = new Engine();
engine.operators().find((op) => op.name === 'reduce');
// { name: 'reduce', aliases: [], family: 'Core', feature: null, min_args: 2, max_args: 3,
// reads_context: false, effect: 'pure', cost: 'per_item', scoped_arg: 1 }
engine.compile({ if: [{ '>': [{ var: 'user.age' }, 18] }, 'adult', { var: 'fallback' }] }).facts();
// { reads: [['fallback'], ['user', 'age']], computed_reads: false, reads_complete: true,
// reads_data: true, operators: ['>', 'if', 'val'], custom_operators: [],
// deterministic: true }
engine.truthy({}); // false: an empty object is falsy, like an empty array
engine.truthy('[]'); // false: a string is JSON textoperators() returns one row per built-in operator the engine
evaluates, in the schema of the
operator catalogue:
aliases, family and gating feature, the argument counts it reads,
whether it reads the data context, its effect (pure, clock,
throws, catches), its cost class, and which argument runs once per
element. facts() lists each data path the rule reads from the root as
its segments, after the optimizer, so a folded branch is not listed.
reads_complete is false when a path is computed at runtime or a
custom operator runs, and deterministic is false for now and any
custom operator. truthy applies the engine's configured truthiness,
the rules if, and, or and evaluateTruthy use.
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 | bool |
| missing_var | "null" (default: a missing variable reads as null), "error" (raises VariableNotFound) |
| truthy_evaluator | "javascript", "python", "strict_boolean" |
| numeric_coercion | object of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
| max_recursion_depth | integer >= 1 |
| ops_budget | integer >= 1, or null for unbounded (caps the work one evaluation may do; crossing it raises BudgetExceeded) |
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 EvaluateErrorWith missing_var: 'error', a var / val read that finds nothing
throws errorType: 'VariableNotFound', so a typo in a path fails
instead of flowing on as null. A default ({ var: ['x', 0] }), a
present null, missing, missing_some and exists are not misses,
and try catches the error:
const checked = new Engine({ config: { missing_var: 'error' } });
checked.eval({ var: 'user.nmae' }, { user: { name: 'Ana' } }); // throws VariableNotFound
checked.eval({ var: ['user.nmae', 'anonymous'] }, { user: { name: 'Ana' } }); // 'anonymous'Unknown keys or values throw at construction with
errorType: 'ConfigurationError', so typos fail at startup.
Operator families
families keeps the engine to the JSONLogic core plus the families you
name, using the family names of engine.operators(): 'DateTime',
'ExtString', 'ExtArray', 'ExtObject', 'ExtControl',
'ErrorHandling', 'ExtMath', 'Tensor', 'Flagd'. Unset, the engine
has every family. A family you leave out is not there for that engine:
its names compile as unknown operators, which fail at evaluation with
errorType: 'InvalidOperator' (and are errors in check and
compileChecked), and a custom operator may take them. An unknown
family name throws at construction with errorType: 'ConfigurationError'.
const stringsOnly = new Engine({ families: ['ExtString'] });
stringsOnly.eval({ upper: 'abc' }, null); // 'ABC'
stringsOnly.eval({ abs: -1 }, null); // throws, errorType 'InvalidOperator'Metering: what a rule costs
evalMetered returns { result, ops } (the result as a JSON string and
the operations the evaluation charged), so you can see what a rule costs
whether or not a budget is set. Rule.evaluateMetered(data, budget?) is
the same thing on an already-compiled rule.
const engine = new Engine();
const rule = { map: [{ var: 'xs' }, { '*': [{ var: '' }, 2] }] };
engine.evalMetered(rule, { xs: [1, 2, 3] });
// { result: '[2,4,6]', ops: 10 }
// An optional third argument caps the operations for that one call,
// overriding the engine's `config.ops_budget`.
engine.evalMetered(rule, { xs: [1, 2, 3] }, 100_000);One operation is one node the engine dispatches, one item an iterator
walks, or whatever an operator charges for the data it moves (the tensor
family prices itself in elements). Literals and constant-folded subtrees
cost nothing. A budget must be a whole number >= 1. Exceeding it throws
an EvaluateError with errorType: 'BudgetExceeded', carrying budget
and spent. The engine refuses the evaluation before doing the work,
and a try in the rule cannot recover from it.
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); // innermost failing operator
console.log(e.nodeIds); // leaf-to-root breadcrumb
console.log(e.path); // resolved root-to-leaf step list
}
}| Property | Contents |
|---|---|
| name | 'ParseError', 'EvaluateError', 'CompileError' (compileChecked) or 'InternalError' (a caught panic) |
| errorType | Stable tag: 'ParseError', 'InvalidOperator', 'InvalidArguments', 'VariableNotFound', 'Thrown', 'TypeError', 'ConfigurationError', 'BudgetExceeded', ... plus 'TypeMismatch' (typed session methods), 'CompileError' and 'InternalError' |
| operator | Innermost failing operator, custom operators included, or null |
| nodeIds | Breadcrumb of compiled-node ids from the failure site toward the root |
| path | Root-to-leaf { nodeId, operator, argIndex, jsonPointer } steps, or null when the error has no compiled rule to resolve against |
| budget / spent | BudgetExceeded only |
| diagnostics | CompileError only: the objects check returns |
A panic inside the native engine never takes the process down. It is
caught at the binding boundary and thrown (or, for evaluateStrAsync,
rejected) as an Error with name and errorType set to
"InternalError" and the panic message as message. The engine, rule
and session stay usable. An InternalError always means a bug; please
report it.
Threading
| Type | Pattern |
|---|---|
| Engine | Build once per thread (the main thread or each worker) and share it across calls on that thread |
| Rule | Compile once per thread; evaluateStrAsync evaluates it on the libuv pool |
| Session | One per thread |
| DataHandle | Parse once per thread; immutable, evaluation never mutates it |
napi class instances cannot be posted or transferred between
worker_threads, so each worker loads the module and builds its own.
Tracing
Engine.evaluateWithTrace(logic, data, mode?) 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 }
run.pointers; // { "1": "/+/0", "2": "/+/1", "3": "/+/2", "4": "" }pointers maps each node id to the RFC 6901 JSON Pointer of the rule
value it was compiled from, so a debugger can place every step in the
rule as written; it is absent when the rule does not compile. The
result keeps its object key order. mode is 'engine' (the default),
'strict' or 'template', as for check, so a rule you compile with
compileTemplate can be traced as one.
Failures do not throw. Instead result is null, error carries the
message, and structured_error the structured form. The binding
compiles the rule 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 JSON text into the engine's arena
with no intermediate JS value, 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 and are
the fastest tier of all). If your data lives as plain JS objects and
your rules are small, 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 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.
Learn more
- datalogic-rs repository
- Rust crate deep-dive
- Documentation: Node.js
- Online playground
- JSONLogic specification
License
Apache-2.0. See the main repository for source and contribution guidelines.
