Node.js (Native Binding)
@goplasmatic/datalogic-node is the native Node.js binding: the Rust core compiled per platform and loaded through napi-rs, with no WebAssembly in between. On Node servers it is the fast path.
Two npm packages, one engine. This package is for Node services that want maximum throughput.
@goplasmatic/datalogic-wasmis the WebAssembly build: it also runs under Node, but its home turf is browsers, edge runtimes, Deno, and Bun. Both run the same core and pass the same conformance battery.
Install
npm install @goplasmatic/datalogic-node
The prebuilt platform binaries ship as optionalDependencies, so npm pulls only the .node file matching your platform:
| Platform | Architectures |
|---|---|
| Linux (glibc) | x64, arm64 |
| Linux (musl) | x64, arm64 |
| macOS | x64, arm64 |
| Windows | x64, arm64 |
Requires Node 18 or newer. There is no build step and no WASM initialization: import and call.
If require('@goplasmatic/datalogic-node') or import fails with MODULE_NOT_FOUND, upgrade to 5.8.0 or newer (5.9.0 is current). Versions 5.1.1 through 5.7.1 were published without the umbrella package’s index.js loader and index.d.ts typings, although the per-platform packages were complete.
Quick start
Rules and data are plain JavaScript objects; results come back as JavaScript values:
import { apply } from '@goplasmatic/datalogic-node';
const result = apply(
{ if: [{ '>': [{ var: 'score' }, 50] }, 'pass', 'fail'] },
{ score: 75 }
);
// -> "pass"
Both arguments also accept JSON text: the binding parses a JS string passed as rule or data as JSON instead of 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 hand the JSON text to the string-in methods (evaluateStr) or a DataHandle.
An object that crosses the boundary as a JS value, in either direction, comes back with its keys sorted. 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).
The constructor takes an options bag and, separately, a map of custom operators:
const strict = new Engine({ config: { preset: 'strict' } });
const templated = new Engine({ templating: true });
const stringsOnly = new Engine({ families: ['ExtString'] });
const custom = new Engine({ strictOperatorNames: true }, {
double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
});
custom.compile({ double: [21] }).evaluate({}); // 42
preset and the other evaluation options nest under config, not at the top level. The options bag reads only templating, templateKeyEscape, config, strictOperatorNames and families; it ignores other top-level keys, so new Engine({ preset: 'strict' }) builds a default engine.
templateKeyEscapeis a single-character prefix, unset by default, that lets a template emit a key which would otherwise run as an operator: withnew Engine({ templating: true, templateKeyEscape: '$' }),{ $type: { var: 'x' } }yields{ type: 1 }rather than running thetypeoperator, and{ $$type: 1 }yields{ $type: 1 }. Anything other than a one-character string throwserrorType: 'InvalidArguments'. See Structured Objects.familieskeeps the engine to the JSONLogic core plus the operator families you name ('DateTime','ExtString','ExtArray','ExtObject','ExtControl','ErrorHandling','ExtMath','Tensor','Flagd'). Unset, the engine has every family. A family left out compiles its names as unknown operators, and a custom operator may take them.strictOperatorNames: truemakes a custom operator named like a built-in (length,var, an alias such as?:) throw at construction instead of registering one that never runs.
Unknown keys inside config, an unknown family name and a refused operator name throw errorType: 'ConfigurationError'. Configuration covers what each config option means, including missing_var: 'error', which makes a read of a missing variable throw VariableNotFound instead of returning null.
Templating mode per compile
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, so one engine can check a condition strictly and build an output template:
engine.compileTemplate({ name: { var: 'user' }, active: true }).evaluate({ user: 'Alice' });
// { active: true, name: 'Alice' }
Checking a rule before it runs
compile rejects malformed JSON and structurally invalid rules (a multi-key object when templating is off), but it does not verify operator names: an unknown operator compiles and throws errorType: 'InvalidOperator' when you evaluate the rule. engine.check(rule, mode?) reports every problem the engine can see before the rule runs, each with a 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' }
// ]
An 'error' will fail; a 'warning' runs but is probably a mistake. engine.compileChecked(rule) compiles only a rule with no error diagnostic and otherwise throws an Error named CompileError whose diagnostics holds the same objects. Rule Analysis covers the diagnostic codes and check modes.
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.
Data handles and typed results
A DataHandle is an immutable, pre-parsed JSON document. Parse a payload once and every evaluation against it skips JSON parsing; the typed session methods also skip the result round trip:
import { DataHandle } from '@goplasmatic/datalogic-node';
const handle = new DataHandle('{"age": 25, "status": "active"}'); // throws ParseError on bad JSON
const isAdult = engine.compile({ '>=': [{ var: 'age' }, 18] });
const nextAge = engine.compile({ '+': [{ var: 'age' }, 1] });
nextAge.evaluateData(handle); // 26 (JS value out, no parse per call)
nextAge.evaluateDataStr(handle); // "26" (JSON string out)
sess.evaluateData(isAdult, handle); // true (hot path: session arena + no parse)
sess.evaluateBool(isAdult, handle); // true (strict JSON boolean, else TypeMismatch)
sess.evaluateInt(nextAge, handle); // 26 (a whole number a JS number holds exactly, else TypeMismatch)
sess.evaluateFloat(nextAge, handle); // 26 (any JSON number, else TypeMismatch)
sess.evaluateTruthy(isAdult, handle); // true (JSONLogic truthiness, never mismatches)
evaluateBool, evaluateInt and evaluateFloat throw an EvaluateError with errorType: 'TypeMismatch' when the rule evaluated fine but the result has another type. evaluateNumber is the deprecated name of evaluateFloat and is removed in 6.0. Handles are per-thread like sessions: parse one per worker.
Batch evaluation
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:
const outcomes = sess.evaluateBatch(rule, [h0, h1, h2]); // one rule, many payloads
const flags = sess.evaluateMany([r0, r1], handle); // many rules, one payload
for (const [i, o] of flags.entries()) {
if (o.status === 'rejected') {
console.log(`rule ${i} failed: ${o.reason.message} (${o.reason.tag})`);
} else {
console.log(`rule ${i}: ${o.value}`); // result as a JSON string
}
}
Async evaluation
rule.evaluateStrAsync(dataJson) evaluates on the libuv thread pool and returns a Promise<string>. It is no faster per call than evaluateStr; it keeps large payloads’ parse + evaluate + serialize off the event loop:
const result = await rule.evaluateStrAsync('{"age": 25}');
String input only (a DataHandle is pinned to the JS thread). Rejections carry the same structured fields as synchronous throws.
Tracing
engine.evaluateWithTrace(logic, data, mode?) takes JSON strings and returns a JSON string with the same { result, expression_tree, steps, pointers, error?, structured_error? } envelope the WASM package produces, so the React debugger accepts output from either package:
const run = JSON.parse(engine.evaluateWithTrace('{"+": [1, 2, 3]}', 'null'));
run.result; // 6
run.steps.length; // 1 (one step per operator node; literals record no step)
run.pointers; // { "1": "/+/0", "2": "/+/1", "3": "/+/2", "4": "" }
pointers maps each node id to the JSON Pointer of the rule value it was compiled from, so a debugger places each step in the rule as written. mode is 'engine' (the default), 'strict' or 'template', so a rule you compile with compileTemplate can be traced as one. Failures do not throw: 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; use it for debugging, not hot paths.
Operator names
Tooling that validates or autocompletes rules 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
const engine = new Engine({}, { double: (a) => String(JSON.parse(a)[0] * 2) });
engine.customOperatorNames(); // ['double']
builtinOperatorNames() is derived from the compiler’s own lookup table, so it cannot drift from dispatch; engine.customOperatorNames() lists the operators registered on that engine. For an engine with every operator family, the union is the engine’s full vocabulary; engine.operators() follows an engine’s families.
Three more calls describe the engine and a rule without running anything:
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({ '+': [{ var: 'a.b' }, { var: 'c' }] }).facts();
// { reads: [['a', 'b'], ['c']], computed_reads: false, reads_complete: true,
// reads_data: true, operators: ['+', 'val'], custom_operators: [], deterministic: true }
engine.truthy({}); // false: an empty object is falsy, like an empty array
engine.truthy('[]'); // false: a string is JSON text
operators() is the operator catalogue: one row per built-in operator the engine evaluates, following its families. facts() lists the data paths a compiled rule reads (as segment lists) and the operators it calls; 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.
Error handling
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. "InvalidOperator", "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
}
}
e.name is 'ParseError', 'EvaluateError', 'CompileError' (from compileChecked, with a diagnostics array) or 'InternalError'. An InternalError is a panic inside the native engine, caught at the binding boundary and thrown (or, for evaluateStrAsync, rejected) instead of aborting the process; the engine, rule and session stay usable, and it always means a bug worth reporting. A BudgetExceeded error carries budget and spent.
Missing data is not an error by default: { var: 'missing' } evaluates to null. An engine built with config: { missing_var: 'error' } throws errorType: 'VariableNotFound' instead.
API surface
| Symbol | Description |
|---|---|
apply(rule, data) | One-shot compile + evaluate |
builtinOperatorNames() | Every built-in operator name this build accepts (includes aliases) |
new Engine({ templating?, templateKeyEscape?, config?, strictOperatorNames?, families? }, customOperators?) | Engine with optional templating, template-key escape, evaluation config, operator-name check, operator families, and custom operators |
engine.compile(rule) | Compile to a reusable Rule |
engine.compileTemplate(rule) / engine.compileStrict(rule) | Compile in or outside templating mode, whatever the engine’s default |
engine.compileChecked(rule) / engine.check(rule, mode?) | Compile only a rule with no error diagnostic / list every diagnostic |
engine.eval(rule, data) / engine.evalStr(rule, data) | One-shot, JS value / JSON string out |
engine.evalMetered(rule, data, budget?) | One-shot with an operation count: { result, ops } |
engine.evaluateWithTrace(logic, data, mode?) | One-shot with execution trace (JSON strings in and out) |
engine.session() | Arena-reusing Session for hot loops |
engine.operators() / engine.truthy(value) | Operator catalogue / the engine’s truthiness |
engine.customOperatorNames() | Names of the custom operators registered on this engine |
new DataHandle(json) / DataHandle.fromValue(value) / handle.allocatedBytes | Parse a payload once (from JSON text or a JS value); arena bytes held by the handle |
rule.evaluate(data) / rule.evaluateStr(data) | Evaluate against one payload, JS value / JSON string out |
rule.evaluateData(handle) / rule.evaluateDataStr(handle) | Evaluate a pre-parsed handle |
rule.evaluateMetered(data, budget?) | Evaluate with an operation count |
rule.evaluateStrAsync(dataJson) | Evaluate on the libuv pool, returns Promise<string> |
rule.facts() | What the rule reads and calls |
sess.evaluate(rule, data) / sess.evaluateStr(rule, data) | Evaluate with arena reuse |
sess.evaluateData(rule, handle) / sess.evaluateDataStr(rule, handle) | Handle in, arena reuse; the Str form is the fastest path |
sess.evaluateBool / evaluateInt / evaluateFloat / evaluateTruthy (rule, handle) | Typed results (TypeMismatch for the strict three); evaluateNumber is the deprecated name of evaluateFloat |
sess.evaluateBatch(rule, handles) / sess.evaluateMany(rules, handle) | Batch evaluation, allSettled-style items |
sess.reset() / sess.allocatedBytes() | Explicit arena reset (optional); bytes held by the arena’s chunks |
The package README carries the same surface with the configuration key table; Configuration covers what each option means.
When to choose WASM instead
Choose @goplasmatic/datalogic-wasm when the code must run in a browser or edge runtime, or when you want one artifact across Node + browser. Choose this native package for Node services where throughput matters: the WASM build measures roughly 88× slower than the native core on the same benchmark workload.
Next steps
- Integration: Express for the compile-once, cache-by-version service pattern
- Starter Boilerplates for a minimal Express authorization middleware
- Framework integration patterns for React, Vue, and worker recipes (applicable to both JS packages)
- Operation Budget and Security & Sandboxing for running untrusted rules safely
- Use cases & examples and Thread safety