Skip to content

marvec/cel-vm

Repository files navigation

GitHub License NPM Version NPM Downloads

cel-vm

API Reference · Architecture · License · Notices

High-performance Common Expression Language (CEL) evaluator in JavaScript using a bytecode VM. No runtime dependencies.

~24× faster than marcbachmann/cel-js on repeated evaluation (12–47× depending on expression complexity).

Why

CEL is Google's expression language for policy evaluation, access control, and data validation. Existing JavaScript implementations use tree-walking interpreters — they re-traverse the AST every time an expression is evaluated. That's fine for one-off use, but expensive when the same expression runs millions of times against different inputs (policy engines, rule engines, analytics pipelines).

cel-vm compiles CEL to bytecode once and evaluates it in a tight dispatch loop. The bytecode can be serialised to Base64, stored in a database, and loaded without re-parsing.

The implementation was created by AI (specifically Claude, mostly Opus) as my personal research experiment on AI development. I write about software architecture and AI tooling on my Substack.

Prior Art

Install

npm install cel-vm

API

run(expr, activation?) — evaluate in one call

import { run } from 'cel-vm'

run('1 + 2 * 3')                              // → 7n
run('age >= 18', { age: 25n })                 // → true
run('name.startsWith("J")', { name: 'Jane' }) // → true

run() compiles the expression, caches the bytecode, and evaluates it. On repeated calls with the same expression, compilation is skipped. See DOCS.md for full parameter details.

program(expr, options?) — compile to a callable

import { program } from 'cel-vm'

const check = program('x > 0 && y < 100')
check({ x: 10n, y: 5n })   // → true
check({ x: -1n, y: 50n })  // → false

Compiles once, returns a function. This is the recommended API for repeated evaluation — same performance as compile() + evaluate(), less boilerplate. See DOCS.md for full parameter details.

compile(expr) — compile to bytecode

import { compile } from 'cel-vm'

const bytecode = compile('x > 0 && y < 100')
// bytecode is a Uint8Array — the compiled program

evaluate(bytecode, activation?) — run bytecode

import { compile, evaluate } from 'cel-vm'

const bytecode = compile('x + y')
evaluate(bytecode, { x: 10n, y: 5n })  // → 15n
evaluate(bytecode, { x: 1n, y: 2n })   // → 3n

This is the hot path. Pre-compile once, evaluate many times. Use program() for a more ergonomic wrapper around this pattern. See DOCS.md for compile/evaluate parameter details.

toB64(bytecode) / fromB64(base64) — serialise and deserialise

import { compile, evaluate, toB64, fromB64 } from 'cel-vm'

// Compile and serialise to Base64 for storage
const bytecode = compile('score > 90')
const b64 = toB64(bytecode)
// → "Q0UBAAABAgAAAA..." — store this in a database, config file, etc.

// Later: deserialise from Base64 and evaluate (no re-compilation)
const loaded = fromB64(b64)
evaluate(loaded, { score: 95n })  // → true

See DOCS.md for full parameter details.

Activation values

Pass variables as a plain object. Use the correct JavaScript types:

CEL type JavaScript type Example
int / uint BigInt 42n
double number 3.14
string string 'hello'
bool boolean true
null null null
list Array [1n, 2n, 3n]
map Map or plain object new Map([['a', 1n]])
bytes Uint8Array new Uint8Array([0x48])

Error types

import { LexError, ParseError, CheckError, CompileError, EvaluationError } from 'cel-vm'

All errors include descriptive messages with source location when available. See DOCS.md for the full list of error classes.

Environment — custom functions, constants, and variable declarations

import { Environment } from 'cel-vm'

const env = new Environment()
  .registerConstant('minAge', 'int', 18n)
  .registerFunction('hasRole', 2, (user, role) =>
    Array.isArray(user.roles) && user.roles.includes(role)
  )
  .registerMethod('titleCase', 0, (s) =>
    s.replace(/\b\w/g, c => c.toUpperCase())
  )

// Compile to a callable — recommended for repeated evaluation
const policy = env.program('user.age >= minAge && hasRole(user, "admin")')
policy({ user: { age: 25n, roles: ['admin'] } })  // → true
policy({ user: { age: 16n, roles: [] } })          // → false

// Or one-shot
env.run('"hello world".titleCase()')  // → "Hello World"

// Enable debug mode for source-mapped error locations
const debug = new Environment().enableDebug()
try {
  debug.run('a / b', { a: 1n, b: 0n })
} catch (e) {
  console.log(e.message)  // "division by zero at 1:3"
}

See DOCS.md for the full Environment API reference.

CLI

# Evaluate an expression
cel '1 + 2'
# → 3

# With variables (JSON — integers auto-convert to BigInt)
cel 'name.startsWith("J") && age >= 18' --vars '{"name": "Jane", "age": 25}'
# → true

# Compile to Base64 bytecode
cel compile 'x > 10'
# → Q0UBAAABAgAAAA...

# Evaluate Base64 bytecode
cel eval 'Q0UBAAABAgAAAA...' --vars '{"x": 42}'
# → true

Supported Features

Types

int (64-bit via BigInt), uint, double, bool, string, bytes (b"..."), null, list, map, optional, timestamp, duration

Operators

Category Operators
Arithmetic + - * / % ** (unary -)
Comparison == != < <= > >=
Logical && || !
Membership in
Ternary ? :
Field access . ?. []
Optional ?.field [?index]

String methods

contains(), startsWith(), endsWith(), matches(), size(), toLowerCase() / lowerAscii(), toUpperCase() / upperAscii(), trim(), split(), substring(), indexOf(), lastIndexOf(), charAt(), replace(), join(), format()

Built-in functions

size(), type(), int(), uint(), double(), string(), bool(), bytes(), timestamp(), duration(), dyn()

Macros

exists(), all(), exists_one(), filter(), map(), has(), cel.bind()

Optional types

optional.of(), optional.none(), .hasValue(), .orValue(), optional.ofNonZero() / optional.ofNonZeroValue()

Timestamp methods

getFullYear(), getMonth(), getDayOfMonth(), getDayOfWeek(), getDayOfYear(), getHours(), getMinutes(), getSeconds(), getMilliseconds()

Duration methods

getHours(), getMinutes(), getSeconds(), getMilliseconds()

Math extensions

math.greatest(), math.least(), math.max(), math.min(), math.abs(), math.ceil(), math.floor(), math.round(), math.trunc(), math.sign(), math.isNaN(), math.isInf(), math.isFinite(), math.bitAnd(), math.bitOr(), math.bitXor(), math.bitNot(), math.bitShiftLeft(), math.bitShiftRight()

String extensions

strings.quote()

String literals

Double-quoted, single-quoted, triple-quoted (multiline), raw strings (r"..."), byte strings (b"..."), full escape sequences (\n, \t, \xHH, \uHHHH, \UHHHHHHHH, octal)

Benchmarks

Measured on Apple M1 Pro, Bun 1.3.11, 100K iterations per case. All cel-vm timings are for bytecode evaluation only (pre-compiled).

Expression cel-vm cel-js Speedup
1 + 2 * 3 1,861K ops/s 96K ops/s 19×
(x + y) * z 1,600K ops/s 52K ops/s 31×
x > 100 && y < 50 1,335K ops/s 56K ops/s 24×
x > 0 ? "pos" : "neg" 1,266K ops/s 50K ops/s 25×
[1, 2, 3, 4, 5] 1,443K ops/s 31K ops/s 47×
list[2] 1,694K ops/s 74K ops/s 23×
m.x 1,738K ops/s 123K ops/s 14×
l.exists(v, v > 3) 598K ops/s 37K ops/s 16×
l.filter(v, v % 2 == 0) 395K ops/s 33K ops/s 12×
l.map(v, v * 2) 735K ops/s 38K ops/s 19×
x > 0 && y > 0 && x + y < 100 1,116K ops/s 34K ops/s 33×

Average: 24× faster. Pre-compiled bytecode is an additional 5.6× faster than compile-and-evaluate.

# Run the benchmark yourself
bun run bench/compare.js

cel-spec Conformance

1434 / 1583 tests passing (90.6%). 911 / 1060 cel-spec conformance tests (86.0%). All 315 marcbachmann/cel-js compatibility tests pass (100%).

149 cel-spec tests are skipped (proto messages, enums, and other features that require protobuf schema support). See docs/plans/2026-04-08-006-feat-skipped-test-gap-analysis-plan.md for the full breakdown and implementation roadmap.

Divergences

Area cel-vm cel-spec Reason
Proto messages / enums Plain JS objects only Proto schema types No protobuf schema in JS runtimes
uint negation/underflow Not detected Error uint and int are both BigInt at runtime — type distinction lost after compilation. -(42u) and 0u - 1u silently produce negative BigInts
Regex JS RegExp RE2 No native RE2 in JS; minor semantic differences possible
Timestamps Full support Full support Millisecond precision; nanosecond precision not yet implemented
Bytes literals b"..."Uint8Array Full support \u/\U escapes in bytes produce raw charCodeAt instead of UTF-8 encoding
Bytes comparison Not supported <, >, <=, >= Bytes ordering operators not implemented
Type identifiers type() returns string First-class type values bool, int, etc. are not resolvable as identifiers

Architecture

The pipeline is strictly sequential: source → Lexer → Parser → Checker → Compiler → Bytecode → VM.

src/lexer.js       — hand-written tokeniser
src/parser.js      — recursive-descent, plain-object AST
src/checker.js     — macro expansion + security validation
src/compiler.js    — AST → bytecode; constant folding, variable pre-resolution
src/bytecode.js    — binary encode/decode, Base64 serialisation
src/vm.js          — while/switch dispatch loop (plain function, not a class)
src/environment.js — Environment class (custom functions, constants, variable declarations)
src/index.js       — public API

See IMPLEMENTATION.md for detailed design decisions.

Contributing

# Run all tests
bun test

# Run a single test file
bun test test/marcbachmann/arithmetic.test.js

# Run tests matching a pattern
bun test --test-name-pattern "string literals"

# Benchmark
bun run bench/compare.js

Tests are written with Node's built-in node:test. No test framework dependencies.

License

GNU GPLv3

About

High-performance Common Expression Language (CEL) evaluator in JavaScript using a bytecode VM. No runtime dependencies.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors