npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rules-cascade/core

v1.0.0-alpha.11

Published

Rule Cascade core for browsers and Node.js: compile YAML/JSON business rule contracts to bundles and evaluate them identically to every other runtime.

Readme

@rules-cascade/core

The Rule Cascade runtime for browsers and Node.js. It implements both conformance levels of the specification: it evaluates bundles and manifests (evaluator) and it loads source documents and produces bundles (compiler). The shared conformance suite checks on every build that it returns what every other runtime returns.

It is one of four packages released together:

| Package | What it holds | |---|---| | @rules-cascade/common | The rule, bundle and manifest types, the cron schedule, VERSION | | @rules-cascade/core (this one) | The evaluator and the compiler | | @rules-cascade/client | The manifest client and the React hook | | @rules-cascade/server | The rule server |

| Entry point | Use it for | Extra dependency | |---|---|---| | @rules-cascade/core | Evaluate manifests and bundles, compile rulesets, read results, the Engine class. Re-exports everything in @rules-cascade/common. Browser-safe | none beyond decimal.js | | @rules-cascade/core/loader | JSON Schema validation of source documents (Node) | ajv | | rule-cascade-node engine (dist/cli.js) | The engine protocol on standard input and output | ajv for the compiler level |

In a browser

import { createManifestClient } from '@rules-cascade/client';
import { evaluate, fieldStates, findingsFor } from '@rules-cascade/core';

const manifests = createManifestClient({ baseUrl: '/api/rules' });
const manifest = await manifests.get('acme.payments.transfer');   // cached; revalidated with the ETag

const request = {
  entity: 'Transfer',
  operation: 'create',
  data: { type: 'international', amount: 12000, currency: 'USD', beneficiary: { name: 'Ana', country: 'ES' } },
  actor: { id: 'u-1', roles: ['teller'] },
};
const result = evaluate(manifest, request);

result.decision;                               // 'deny'
findingsFor(result, '/beneficiary/swiftCode'); // [{ code: 'PAY-TRF-002', severity: 'error', location: { component: 'beneficiary-panel' }, ... }]
fieldStates(result)['/beneficiary/swiftCode']; // { visible: true, required: true }

A request can narrow the evaluation in two ways:

  • trigger: 'change' or 'blur' evaluates only the rules that listen to that moment. With no trigger every client rule runs, which is what you want just before submit.
  • view: { page, screen, section, component } evaluates only the rules of that place. A rule that names a different place is skipped; a rule that names no place always applies.
evaluate(manifest, { ...request, view: { component: 'amount-panel' } }).findings.map((f) => f.code);  // ['PAY-TRF-003']

finding.location holds the page, screen, section and component the rule's target names. It is absent when the target names none. (It replaces the finding.component member of earlier versions.)

Fetching, caching and refreshing manifests, and the React hook, are in @rules-cascade/client.

locale selects the message catalog. Catalogs are merged least specific first: the default locale, then every prefix of the requested tag, so fr-CA reads the default catalog, then fr, then fr-CA, and a regional catalog only holds the messages that differ. Tags are compared exactly, including case. localeChain('en', 'fr-CA') returns ['en', 'fr', 'fr-CA'].

evaluate checks the shape of the request before it evaluates anything (specification section 8): entity and operation are strings; data, original, actor, ctx and view are objects; resolutions is a list; trigger and locale are strings. Optional members may be null, and members that are not part of a request are ignored. A request with another shape is never half-evaluated: evaluate throws a RequestError that says what is wrong.

import { requestProblem, RequestError } from '@rules-cascade/core';

requestProblem({ entity: 'Transfer', operation: 'create', data: [] });   // "'data' must be an object"
requestProblem({ entity: 'Transfer', operation: 'create', data: null }); // undefined: the request is well formed
evaluate(manifest, { entity: 'Transfer', operation: 'create', actor: { roles: 'teller' } });
// throws RequestError: 'actor.roles' must be a list of strings

In a Node backend

import { readFileSync } from 'node:fs';
import { parse } from 'yaml';
import { loadRuleSet, LoadError } from '@rules-cascade/core';
import { createSchemaValidator } from '@rules-cascade/core/loader';

const read = (file: string) => parse(readFileSync(`contracts/${file}`, 'utf8'));
const registry = {
  'acme.org.base': read('acme-org-base.ruleset.yaml'),
  'acme.payments.transfer': read('payments-transfer.ruleset.yaml'),
};

// Throws LoadError (with stable codes) for anything that must not be served.
const rules = loadRuleSet(registry['acme.payments.transfer'], registry, {
  validateSchema: createSchemaValidator(),
  schemaLoader: (file) => read(file.replace(/^\.\//, '')),
});

const result = rules.evaluate({ entity: 'Transfer', operation: 'create', data, actor });  // server channel
const forBrowsers = rules.manifest('client');   // serve this, with rules.checksum as the ETag

Loading runs every check of specification section 5, with two things to know:

  • Without validateSchema the document is not validated against the JSON Schema. Omit it only for documents that were validated elsewhere. The validator treats every pattern of the schema as a portable pattern: $ is the very end of the string and \d is an ASCII digit.
  • Without schemaLoader the entity schemas are not read, so PATH_UNKNOWN and SCHEMA_REF_UNRESOLVED are never reported.

Enforcing in one line

rules.enforce(request) returns an allowed result and throws RuleViolationError for a denied one; error.problem() is the HTTP answer (RFC 9457 problem details, status 422, the same in every Rule Cascade runtime). @rules-cascade/core/http wires it into Node.js frameworks, with no framework as a dependency:

import { rulesMiddleware, rulesErrorHandler, rulesPreHandler } from '@rules-cascade/core/http';

app.post('/orders', rulesMiddleware(rules, { entity: 'Order', operation: 'create' }), handler);   // Express, Connect
app.use(rulesErrorHandler());                                     // RuleViolationError -> 422, RequestError -> 400
fastify.post('/orders', { preHandler: rulesPreHandler(rules, { entity: 'Order', operation: 'create' }) }, handler);

The request body is the entity by default; data, original, actor (default req.user), resolutions and ctx (default { now }) take functions of the request. The allowed result is on res.locals.ruleEvaluation (Fastify: request.ruleEvaluation). Walkthrough: https://rulescascade.com/get-started/first-app/

Bundles

A bundle is the compiled form of a ruleset: plain JSON holding the server and the client manifest. Compile once, in CI, and evaluate the same bundle in every runtime.

import { RuleSet } from '@rules-cascade/core';

const bundle = rules.bundle();    // { ruleCascadeBundle: '1.0.0', id, version, checksum, manifests: { server, client } }
writeFileSync('acme.payments.transfer.bundle.json', JSON.stringify(bundle));

// In the evaluating process: no YAML, no schema validation, no inheritance.
const loaded = RuleSet.fromBundle(JSON.parse(readFileSync('acme.payments.transfer.bundle.json', 'utf8')));
loaded.evaluate({ entity: 'Transfer', operation: 'create', data, actor });

RuleSet.fromBundle throws a LoadError with code BUNDLE_UNSUPPORTED for a bundle whose format is not version 1.x, BUNDLE_INVALID for one without a usable server and client manifest (each with id, version, checksum, rules and its own name as channel), and then VERSION_UNSUPPORTED for a manifest whose ruleCascade this runtime does not implement (an absent one is 1.0.0; this runtime implements 1.0 and 1.1). It runs no other check: the compiler already did. loaded.resolved is undefined, because a bundle holds manifests, not the resolved ruleset. The bundle contains server-only rules, so it is never sent to a browser; browsers get manifest('client').

One manifest on its own

A browser or a mobile app receives one manifest, not the bundle. RuleSet.fromManifest reads it:

import { ChannelError, RuleSet } from '@rules-cascade/core';

const rules = RuleSet.fromManifest(clientManifest);   // throws LoadError MANIFEST_INVALID for anything else, VERSION_UNSUPPORTED for a later ruleCascade
rules.channels;                                       // ['client']
rules.evaluate(request).decision;                     // 'deny': no channel given, so the one it has
rules.evaluate(request, 'server');                    // throws ChannelError: this ruleset has no server manifest
RuleSet.fromBundle(bundle).channels;                  // ['client', 'server']

A usable manifest has a string id, version and checksum, a list rules and a channel of server or client. A ruleset read this way has that one channel: a client manifest cannot be evaluated as the server. manifest(channel), evaluate(request, channel) and bundle() throw a ChannelError for a channel the ruleset does not have. Without a channel argument manifest() and evaluate(request) use server when the ruleset has it, and otherwise the channel it has.

Custom operators

A ruleset declares the custom operators it uses under operators, and its rules call them as { op: 'x-<name>', args: [...] }. The host supplies the implementation as a plain function, by name:

import { missingOperators, RuleSet, type Operators } from '@rules-cascade/core';

const operators: Operators = {
  'x-luhn': (text) => {
    if (typeof text !== 'string' || !/^[0-9]{2,}$/.test(text)) return false;
    const digits = [...text].reverse().map(Number);
    const sum = digits.reduce((total, d, i) => total + (i % 2 === 0 ? d : d * 2 > 9 ? d * 2 - 9 : d * 2), 0);
    return sum % 10 === 0;
  },
};

const customer = RuleSet.fromBundle(customerBundle);
customer.manifest('server').operators;                          // ['x-luhn']: what this manifest needs
missingOperators(customer.manifest('server'), operators);       // []: check this when the host starts
customer.missingOperators();                                    // ['x-luhn']: for every channel the ruleset has, with nothing registered

const request = { entity: 'Customer', operation: 'update', data: { loyaltyNumber: '79927398710' }, original: {}, view: { section: 'membership' } };
customer.evaluate(request, 'server', operators).findings[0];    // { code: 'ONB-CUS-001', message: 'This loyalty number is not valid. Check the digits.', ... }
customer.evaluate(request, 'server').findings[0];               // { code: 'RULE-EVALUATION-ERROR', blocking: true, detail: 'custom operator x-luhn is not registered', ... }

The same object is the third argument of evaluate(manifest, request, operators) and of useRuleEvaluation (@rules-cascade/client/react). An operator receives plain JSON values, with computed numbers already rounded to 15 significant digits, and returns a JSON value. It must be synchronous and pure. A rule whose operator is missing, throws, or returns something JSON cannot carry (NaN, an infinity, a promise, a Date) fails closed with a RULE-EVALUATION-ERROR finding.

Expressions

import { evaluateExpression } from '@rules-cascade/core';

evaluateExpression({ op: 'add', args: [0.1, 0.2] });   // 0.3
evaluateExpression(
  { fn: 'vat', args: [{ var: 'data.net' }] },
  { data: { net: 19.99 } },
  { functions: { vat: { params: ['amount'], body: { op: 'round', args: [{ op: 'mul', args: [{ var: 'arg.amount' }, 0.21] }, 2] } } } },
);                                                     // 4.2
evaluateExpression({ op: 'matches', args: ['a b', '\\s'] });   // throws EvalError: pattern "\\s": escape \s is not portable
evaluateExpression({ var: 'data.rate' }, { data: { rate: 0.1234567890123456 } });   // 0.123456789012346

The third argument takes functions (the function table) and operators. patternProblem(pattern) returns why a pattern is outside the portable subset, or undefined when it is inside. A pattern has at most 1000 code points.

An expression is a literal or an object with exactly the members { var }, { op, args } or { fn, args }. Any other object, and a list, is an evaluation error when it is evaluated. A variable path starts at one of the nine roots of the specification; any other root is null.

Numbers:

  • Inside an expression arithmetic is decimal with 34 significant digits; nothing is rounded there.
  • Every number that leaves the engine is rounded half even to 15 significant digits, whether it was computed or only passed through: the result of an expression, computed values, message arguments, command payloads, the arguments of a custom operator.
  • A number whose magnitude is then larger than the largest double is an evaluation error, so the rule fails closed.
  • A JSON number is the double nearest to what was written, in every runtime: 9007199254740993 is 9007199254740992. The specification asks authors to stay within 15 significant digits and to send identifiers as strings. jsonFinite(value) tells whether a parsed value is free of the infinities that JSON.parse produces for a number too large for a double; the engine and the rule server refuse such input.

Engine protocol

dist/cli.js serves the JSON Lines protocol of specification section 13: one request per line on standard input, one response per line on standard output, in order. Installed, the command is rule-cascade-node.

printf '%s\n' '{"id":1,"command":"version"}' '{"id":2,"command":"expression","expr":{"op":"add","args":[0.1,0.2]}}' \
  | rule-cascade-node engine
# {"id":1,"ok":true,"result":{"engine":"rule-cascade-typescript","engineVersion":"1.0.0-alpha.11","ruleCascade":"1.1.0","bundle":"1.0.0","levels":["evaluator","compiler"],"operators":[]}}
# {"id":2,"ok":true,"result":0.3}

--conformance-operators registers the three operators of the conformance suite (x-test-reverse, x-test-sum, x-luhn); without the flag no custom operator is registered. compile needs ajv. When ajv is not installed the command still runs, reports the level evaluator only and answers compile with UNSUPPORTED.

To embed the protocol, or to give it your own operators, use the class the command is built on:

import { Engine, serve } from '@rules-cascade/core';
import { createSchemaValidator } from '@rules-cascade/core/loader';

const engine = new Engine({ operators, validateSchema: createSchemaValidator() });
engine.handle({ id: 1, command: 'load', bundle });   // { id: 1, ok: true, result: { ruleset, version, checksum, channels, missingOperators } }
engine.handleLine('nonsense');                        // { ok: false, error: { code: 'BAD_REQUEST', message: 'not JSON: ...' } }

await serve(process.stdin, (line) => void process.stdout.write(line), engine);   // answer until the input ends

An Engine without validateSchema is an evaluator.

The source of a manifest or evaluate request is a ruleset id that was loaded, an inline bundle or an inline manifest; a bundle wins over a manifest, and a manifest over a ruleset id. load takes a bundle or a manifest, replaces any ruleset held under the same id, and reports the channels the ruleset has and the missingOperators the engine does not provide. Without a channel a request uses server when the ruleset has it, and otherwise the channel it has; asking for a channel the ruleset lacks is answered with CHANNEL_UNAVAILABLE:

engine.handle({ command: 'load', manifest: clientManifest });   // { ok: true, result: { ..., channels: ['client'], missingOperators: [] } }
engine.handle({ command: 'evaluate', ruleset: 'acme.payments.transfer', channel: 'server', request });
// { ok: false, error: { code: 'CHANNEL_UNAVAILABLE', message: 'ruleset acme.payments.transfer was loaded without a server manifest' } }

The members of a request are checked before a ruleset is looked up or a bundle is read, so a malformed request is always BAD_REQUEST: a bundle, manifest, env, functions, registry or schemaDocuments member that is present must be an object (the last four may be null), a channel must be server, client or null, and the request of evaluate must have the shape requestProblem checks. A line that holds NaN, Infinity or a number too large for a double (1e999) is not JSON and is answered with BAD_REQUEST.

Caches

Evaluation keeps three caches. They never change a result.

  • Rule selections, per ruleset and channel: the rules of an entity and an operation, sorted. Only names the ruleset uses are keys; every entity or operation it does not know shares one entry, so requests cannot fill the cache. At most DEFAULT_SELECTION_CACHE_SIZE (256) entries. Each evaluation first checks the manifest's rules against what the selections were built from and starts again when a rule was added, removed or changed, so a manifest changed by the host evaluates as it is now. A manifest with a malformed rule (operations not a list of strings, an entity not a string, a priority not a number) is not cached at all.
  • Variable paths, split at their dots, for the whole process: at most DEFAULT_PATH_CACHE_SIZE (4096), each at most DEFAULT_MAX_CACHED_PATH_LENGTH (256) UTF-16 code units long; a longer path is split every time. When it is full, it starts again.
  • Compiled matches patterns, for the whole process: at most DEFAULT_PATTERN_CACHE_SIZE (2048); when it is full, it starts again.

A size is a whole number; 0 turns that cache off. An explicit setting wins over the environment variable, which wins over the default:

| Cache | Option | Environment variable | |---|---|---| | Selections | RuleSet.fromBundle(bundle, { selectionCacheSize }), also fromManifest and loadRuleSet options | RULE_CASCADE_SELECTION_CACHE_SIZE | | Paths | setPathCacheSize(n) | RULE_CASCADE_PATH_CACHE_SIZE | | Longest path kept | setMaxCachedPathLength(n) | RULE_CASCADE_MAX_CACHED_PATH_LENGTH | | Patterns | setPatternCacheSize(n) | RULE_CASCADE_PATTERN_CACHE_SIZE |

Any other value is a TypeError. For an option or a setter it shows the value (setPathCacheSize is "-1": expected a whole number, 0 or more (0 turns the cache off)); for an environment variable it never does (RULE_CASCADE_PATH_CACHE_SIZE is not valid: expected a whole number, 0 or more (0 turns the cache off)). A variable must be 1 to 19 ASCII digits: an empty value (VAR=) is refused too, as in the other runtimes. Environment variables are read once, and only where there is a process (not in a browser). Loading a ruleset (fromBundle, fromManifest, loadRuleSet) checks all four, so a misconfigured process fails at start-up, never during an evaluation; checkCacheSettings() runs the same check on its own, and the rule server runs it before it listens.

Scripts

npm run typecheck
npm test          # the shared conformance suite in process, and the tests of this package
npm run build     # dist/, including dist/cli.js

The same suite over the engine protocol, from the repository root:

python tools/rulecheck.py conformance --engine "node packages/typescript/dist/cli.js engine --conformance-operators"