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

@zvenigora/ng-eval-core

v0.11.0

Published

An expression evaluator for Angular

Downloads

2,033

Readme

@zvenigora/ng-eval-core

An expression evaluator for Angular.

The sources for this package are in the main @zvenigora/ng-eval repo.

This library was generated with Nx.

Full documentation — parsing, evaluation, compilation, async evaluation, discovery, and the caseInsensitive / state / scope options — is in the repository README. This file names the exported entry points, then documents the options that need more than a snippet.

Security. SECURITY.md covers the threat model, the prototype-pollution and call-sandboxing protections, and their documented limits. It also records reviewed external advisories — advisories raised against related projects and whether this library shares the defect. GHSA-pj3p-xpg7-h7gw, the case-insensitive guard bypass reported against the separate @zvenigora/jse-eval package, does not apply to @zvenigora/ng-eval-core: this library does not depend on jse-eval, and the advisory's proof-of-concept was run against this evaluator and is blocked. Note that ng-eval does not attempt to be a complete sandbox — see the threat model before evaluating untrusted expressions.

Exported entry points

The repository README walks through each of these in full. They are named here because they are what installing this package gives you: a symbol documented only in the repository README is one the consumer who installed the package cannot read about.

EvalService (evaluation) and CompilerService (compile once, call repeatedly) are used throughout the sections below.

Parsing — ParserService

import { ParserService } from '@zvenigora/ng-eval-core';

const service = inject(ParserService);   // Angular's inject(), or constructor injection

const ast = service.parse('1 + foo');    // an ESTree AST
ast.type;                                // 'BinaryExpression'

Discovery — DiscoveryService

Finds every node of a given type in an expression.

import { DiscoveryService } from '@zvenigora/ng-eval-core';

const service = inject(DiscoveryService);

const expressions = service.extract('1 + 2 * a', 'BinaryExpression');
expressions.length;   // 2

Scopes — EvalContext, EvalScope, EvalScopeOptions

An evaluation context may carry prior scopes, each with its own namespace and case sensitivity. Bare identifiers are read from the context itself; a namespaced scope is reached through its namespace.

thisArg is the this a scope's methods are called with. A method called on the scope's own object, such as cat.action(...) below, receives thisArg; so does this.fn() when a global scope supplies fn. Without thisArg, cat.action(...) receives the scope's object. Only the call's receiver changes: cat still evaluates to the scope's object, so cat.num reads it and cat.x = 1 writes to it. Up to 0.6.x thisArg was accepted and never applied.

import { EvalContext, EvalScope, EvalScopeOptions,
  EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);

const cat = {
  name: 'Miss Kitty',
  num: 3,
  action: function(args: string[], n: number, t: string) {
    return this.name + ' ' + args.join(' ') + ' ' + n + ' ' + t;
  }
};

// `args` is read as a bare identifier, so it belongs to the context itself
// rather than to the scope.
const evalContext = new EvalContext({ args: ['says', 'meow'] }, {});

const catOptions: EvalScopeOptions = {
  global: false,
  caseInsensitive: false,
  namespace: 'cat',
  thisArg: { name: 'Mister Whiskers' }
};
evalContext.priorScopes.push(EvalScope.fromObject(cat, catOptions));

const result = service.simpleEval('cat.action(args, cat.num, "times")', evalContext);
// 'Mister Whiskers says meow 3 times' - `this` is thisArg; cat.num still reads cat

Note that an EvalContext may back any number of evaluations, and that caseInsensitive set on the context alone does not reach the walk — pass it in the evaluation options too.

Options

Per-node timing

Set trackTime to true to accumulate per-node-type timings for an evaluation. They are read back from the state as nodeTimings, so this option needs the state-first style — simpleEval builds its state internally and never hands it back.

import { EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);

const context = { a: 2, b: 3, c: 4 };
const state = service.createState(context, { trackTime: true });

const result = service.eval('a + b * c', state); // 14

// `count` is exact; `total` is wall-clock in milliseconds.
state.nodeTimings.get('BinaryExpression'); // { count: 2, total: <ms> }
state.nodeTimings.get('Identifier');       // { count: 3, total: <ms> }

Totals are in milliseconds and inclusive of child nodes, so nested node types overlap and summing them exceeds the walk's duration — the figures are for comparing node types against each other, not for a breakdown that adds up. They accumulate for the life of the state rather than per eval call, so under the createState + repeated eval style the counts are running totals; use a fresh state for per-run figures.

Turning this on makes the walk dispatch a hook on every node. That is inherent to the feature rather than an oversight: per-node-type totals cannot be produced without visiting each node. If a single walk-level total is all you need, state.result.duration already provides one on every evaluation, at no cost, whether or not trackTime is set.

trackTime configures the hook registry the state creates for itself. If you pass your own registry through options.hooks, that registry is yours and options never configure it — install the hook explicitly instead:

import { EvalHooks, createTimingHook } from '@zvenigora/ng-eval-core';

const hooks = new EvalHooks();   // yours, so `trackTime` never configures it
const state = service.createState(context, { hooks, trackTime: true });

service.eval('a + b * c', state);
state.nodeTimings.size;          // 0 — the option did not reach your registry

const off = createTimingHook().install(state.hooks);

service.eval('a + b * c', state);
state.nodeTimings.get('BinaryExpression');   // { count: 2, total: <ms> }

Iteration budget

for loops are bounded so that a runaway expression fails fast rather than hanging the caller. The budget is 100,000 iterations per evaluation, shared by every loop in the expression — a per-loop cap would multiply under nesting and so would not be a bound at all. Exceeding it throws; a runaway loop that silently returns a partial value is the failure mode this exists to prevent.

import { EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);

service.simpleEval('for (let i = 0; i < 3; i++) { i }'); // 2

// Raise it, lower it, or set Infinity and own the consequence.
const state = service.createState({}, { maxIterations: 10 });

try {
  service.eval('for (let i = 0; i < 100; i++) { i }', state);
} catch (e) {
  e.message; // 'Iteration budget exhausted after 10 iterations'
}

The budget is refilled per outermost eval call, not per state — so the createState + repeated eval style does not erode one budget across independent evaluations, and an arrow function that outlives the walk that created it does not carry that walk's spent budget into a later call.

It bounds time; maxTraceItems bounds the memory. state.result.trace gains an entry per pushed value, so before that bound a long-running loop's trace grew as iterations × nodes — 700,007 entries and ~36 MB for a 100,000-iteration loop evaluated through eval. That loop is not stopped by the default budget: it charges exactly 100,000 and completes. Where the budget does stop a loop, the whole allocation was paid before the throw.

Bounding the trace

state.result.trace records every value the evaluator pushes, and it spans every evaluation run on one state, not just the last. maxTraceItems bounds how many entries it keeps. It defaults to 10,000 — roughly 0.5 MB, and three orders of magnitude above anything a hand-written expression produces.

const state = service.createState({ a: 10 });
service.eval('2 + 3 * a', state);

state.result.trace.length;      // 7
state.result.traceTruncated;    // false
state.result.tracePushCount;    // 7

The head is kept, in order, so trace[0] is still the first thing evaluated. Once a push is actually dropped:

  • state.result.traceTruncated turns true and stays true — no entry is appended to the trace to say so, because every row in it describes a real node. A walk of exactly maxTraceItems pushes leaves it false; nothing was lost.
  • state.result.tracePushCount goes on counting every push, which is the number the trace no longer tells you.
const state = service.createState({}, { maxIterations: Infinity });
service.eval('for (let i = 0; i < 100000; i++) { i }', state);

state.result.trace.length;      // 10000
state.result.traceTruncated;    // true
state.result.tracePushCount;    // 700007

Two values are special, and both are honoured rather than treated as falsy:

  • 0 disables tracing — trace.length stays 0 and traceTruncated stays false, while tracePushCount stays accurate, so a caller who turns tracing off for the allocation keeps the walk-size figure.
  • Infinity restores the unbounded behaviour exactly.

state.result.clearTrace() is the only reset. It empties the trace and both counters, in place — the array state.result.trace returns is the same instance for the life of the result, so a reference you are holding stays live across the call. Under the createState + repeated eval style, call it before an evaluation to make the trace describe only that walk.

Evaluation hooks

Hooks let you observe an evaluation as it happens: a callback per AST node, or per resolved context read. They are registered on the state's hooks registry, so they apply to that state — every evaluation you run through it, which under the createState + repeated eval style is more than one. EvalService is a root singleton, but hooks are never held on the service, so one consumer's hooks never reach another's.

import { EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);
const state = service.createState({ a: 2, b: 3 });

const off = state.hooks.on('after', 'BinaryExpression', (event) => {
  event.node.type; // 'BinaryExpression'
  event.value;     // the value the visitor pushed
});

service.eval('a + b', state); // 5
off();                        // every `on` returns its unsubscribe

on(phase, type, hook) takes 'before' or 'after', and either a concrete node type or '*' for every node. onRead(hook) registers a read hook instead, fired once per resolved context read with the key as the context actually resolved it — case-corrected when caseInsensitive is set, which is not recoverable from the AST node alone:

state.hooks.onRead((event) => {
  event.kind;   // 'identifier' | 'member'
  event.key;    // the resolved key
  event.path;   // 'a.b' when statically reconstructible, else undefined
  event.scoped; // true for arrow-function parameters - not dependencies
});

If you want a dependency set rather than raw events, createDependencyTracker() builds one and applies the scoped filtering for you.

Statements and the empty-completion sentinel

Since 0.4.0 an expression may be a statement list, and a statement that produces no value — a declaration, an if that takes no branch, a for that runs zero iterations — pushes a sentinel rather than undefined. JavaScript's completion-value semantics keep the last non-empty value, and empty is not the same as undefined: in a; noop(), where noop returns undefined, the result is undefined and not 'A', because the call genuinely produced a value.

EMPTY_COMPLETION never leaves an evaluation's return value — it is converted to undefined at the walk boundary — but an after hook fires before that conversion, so a hook on a statement node will see it. It is exported so you can recognise it by identity rather than by guessing:

import { EMPTY_COMPLETION, EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);
const state = service.createState({ a: 1 });

const produced = [];
state.hooks.on('after', 'VariableDeclaration', (event) => {
  produced.push(event.value !== EMPTY_COMPLETION);
});

service.eval('let x = 1; x + a', state); // 2
produced;                                // [false] - the declaration produced nothing

Hooks are synchronous

The walk is synchronous even under evalAsync — evaluateAsync runs the same synchronous traversal and only awaits the result at the end. There is no point at which a hook could be awaited.

A hook that returns a promise is therefore not awaited, and whatever it meant to do lands after the evaluation has finished. The dispatcher reports this as an error carrying the exported ASYNC_HOOK_MESSAGE, routed through the configured policy like any other hook error — collected by default, and thrown under 'throw'. Matching on the constant tells it apart from an error your own hook threw:

import { ASYNC_HOOK_MESSAGE } from '@zvenigora/ng-eval-core';

state.hookErrors.some(
  (e) => e.error instanceof Error && e.error.message === ASYNC_HOOK_MESSAGE
);

Hook errors

By default an error thrown by a hook is collected rather than propagated, so a faulty observer cannot break the evaluation it is observing. Collected errors are read back from the state:

const service = inject(EvalService);
const context = { a: 2, b: 3 };

const state = service.createState(context, { onHookError: 'collect' }); // the default
state.hooks.on('after', 'Identifier', () => { throw new Error('faulty observer'); });

service.eval('a + b', state);
state.hookErrors; // [{ phase, nodeType, error }, ...]

onHookError also accepts 'throw' (rethrow into the visitor, failing the evaluation) and 'ignore'.

Two things to know about it:

  • Options-first calls cannot read collected hook errors, by design. simpleEval(expr, context, { hooks }) builds its state internally and never hands it back, so errors collected on it are out of reach. To read them, use the state-first style — createState, then eval — and read state.hookErrors. Or have a faulty hook fail the evaluation instead, with onHookError: 'throw' set on the EvalHooks you pass (see the next point).

  • Passing both hooks and onHookError silently ignores onHookError. A registry you pass through options.hooks is yours, and it keeps the policy it was constructed with; options never reconfigure an adopted registry. Pass the policy where the registry is built instead:

    import { EvalHooks } from '@zvenigora/ng-eval-core';
    
    const service = inject(EvalService);
    const context = { a: 2, b: 3 };
    
    const hooks = new EvalHooks({ onHookError: 'throw' });
    const state = service.createState(context, { hooks });

    The library never clears a registry you own. EvalService.ngOnDestroy() does not touch it, whichever method you passed it to, and up to 0.5.0 it cleared the registries of the states createState built. So a hook that keeps a state, by capturing it or by storing event.state, keeps that state and its context reachable for as long as the registry is. Release them in the teardown that owns the registry, such as your component's ngOnDestroy or a DestroyRef.onDestroy callback: call the unsubscribe that on / onRead returned, or hooks.clear().

completed: false events

An 'after' event normally means a visitor finished and pushed a value. When it carries completed: false it was synthesised — the visitor never closed the node itself — and there is no value.

These come from two different places, and the presence of error is what tells them apart. A consumer that reads completed: false as "this evaluation failed" will be wrong on the second kind.

1. Evaluation actually failed. The unwinder closes every node still open and supplies the error — including the statement nodes enclosing the expression, since a program and its statements are walked like anything else:

const service = inject(EvalService);

const boom = () => { throw new Error('kaboom'); };
const state = service.createState({ boom });
const seen = [];

state.hooks.on('after', '*', (e) => {
  if (!e.completed) seen.push([e.node.type, 'error' in e]);
});

try { service.eval('1 + boom()', state); } catch { /* rethrown */ }

// seen === [['CallExpression', true], ['BinaryExpression', true],
//           ['ExpressionStatement', true], ['Program', true]]
// innermost first, each carrying the error that aborted the walk

2. An enclosing visitor moved on without its child. The node is flushed when the enclosing one closes, and there is no error — nothing was reported as thrown, and the evaluation may still produce a value.

Up to 0.6.x an await whose operand threw synchronously did this: it turned the throw into a rejected promise and closed without its operand. Since 0.7.0 no built-in visitor does — the throw propagates, so the same expression is now the first kind:

import { CompilerService, EvalService } from '@zvenigora/ng-eval-core';

const service = inject(EvalService);
const compiler = inject(CompilerService);

const state = service.createState({ obj: {} });
const seen = [];

state.hooks.on('after', '*', (e) => {
  if (!e.completed) seen.push([e.node.type, 'error' in e]);
});

const fn = compiler.compile('async () => await obj.__proto__');
const arrow = compiler.call(fn, state); // returns the closure; seen === []
try { arrow(); } catch { /* the body runs here, and throws synchronously */ }

// seen === [['MemberExpression', true], ['AwaitExpression', true]]
// up to 0.6.x: [['MemberExpression', false]] - completed: false, but no error

The second kind stays in the hook contract.

So: test for the error property, not for completed === false.

Cost

The no-hooks path is a single boolean check per node, so an evaluation with nothing registered pays essentially nothing. Registering any hook turns per-node dispatch on for the whole walk; registering a read hook additionally turns on key resolution and path reconstruction at each read site, which node hooks alone do not pay for.

Note that trackTime: true registers a hook, so it makes the walk dispatch per node exactly as an explicit registration would.

Hooks are observers: a hook's return value is discarded and cannot replace or suppress the value a visitor produces.

License: MIT