Advanced — New in 1.2.0

Scripting Support

rulii 1.2.0 introduces a scripting layer that lets you embed dynamic expressions — written in JavaScript, Groovy, or any JSR-223 language — directly inside rules, conditions, actions, and functions. Scripts are first-class citizens: they are compiled once, cached, and evaluated against the current RuleContext.

The Script API

A Script<T> is a typed, named script. Build one with Script.builder():

// A boolean script using JavaScript via JSR-223
Script<Boolean> script = Script.builder()
    .with("js", "ctx.age >= 18")
    .build();

// Reference bindings by name directly in the script body
Script<Boolean> script = Script.builder()
    .with("js", "ctx.age >= ctx.minAge")
    .build();

The ctx Binding Object

Rather than injecting each binding as a top-level variable in the script scope, rulii exposes all bindings as a single object named ctx. This was a deliberate design decision for two reasons:

  • Namespace separation. Injecting every binding as its own top-level variable creates collisions with local script variables and language built-ins. Accessing bindings through ctx keeps the binding namespace isolated from anything the script declares locally.
  • Performance. Building a flat variable scope for a scripting engine requires copying every binding into the engine's bindings map on each evaluation. With ctx, only a single object reference is injected — the engine receives one binding regardless of how many values the RuleContext holds, which is significantly cheaper at runtime.
// All rule context bindings are accessed through ctx
Script<Boolean> check = Script.builder()
    .with("js", "ctx.age >= 18 && ctx.country === 'US'")
    .build();

// Local script variables do not pollute the binding namespace
Script<Integer> calc = Script.builder()
    .with("js",
        "var tax = ctx.price * 0.2; ctx.price + tax")
    .build();
// 'tax' is a local variable — it never leaks into the RuleContext bindings

Using Scripts in Conditions, Actions and Functions

The builder classes for Condition, Action, and Function all accept a Script directly:

Script<Boolean> ageCheck = Script.builder()
    .with("js", "ctx.age >= 18")
    .build();

// Wrap the script as a Condition
Condition condition = Condition.builder().build(ageCheck);

// Use it in a Rule
Rule rule = Rule.builder()
    .with("AgeCheck")
    .given(condition)
    .then(Action.builder().build(Script.builder()
        .with("js", "ctx.status = 'approved'")
        .build()))
    .build();

Script Processors & the Registry

A ScriptProcessor evaluates scripts for a specific language. The ScriptProcessorRegistry maps language names to their processors and is held by the RuleContext.

// Build a registry with JSR-223 auto-discovery enabled
ScriptProcessorRegistry registry = ScriptProcessorRegistry.builder()
    .autoIncludeJSR223(true)
    .build();

// Register a custom processor
registry.register(myCustomProcessor);

// Wire into RuleContext
RuleContext ctx = RuleContext.builder()
    .with("MyContext")
    .scriptProcessorRegistry(registry)
    .build();

JSR-223 Bridge

When autoIncludeJSR223 is enabled, rulii automatically discovers a javax.script.ScriptEngine by language name on first use. If the engine implements Compilable, the script is compiled and cached in a WeakHashMap for reuse. The RuleContext bindings are injected into the script scope under the configured bindings name ("ctx" by default).

// Access bindings in the script via "ctx"
// The bindings map is exposed as a plain Map, so dot-access
// syntax depends on the scripting language.

// JavaScript — language name "js" (Nashorn / GraalJS)
"ctx.age >= 18 && ctx.country === 'US'"

// Groovy
"ctx.age >= 18 && ctx.country == 'US'"

Built-in Script Engines

rulii ships with two optional script engines — GraalJS and Janino. Both provide a ScriptProcessorFactory that is auto-discovered via Java's ServiceLoader. Add the dependency to your build and the engine registers itself automatically — no additional wiring required.

GraalJS — language "js"

GraalJS is Oracle's high-performance JavaScript engine built on the Truffle framework. rulii's GraalJsScriptProcessorFactory creates an engine pre-configured with ECMAScript 2022, full host access (HostAccess.ALL), and unrestricted host class lookup — so scripts can call Java methods directly via ctx. If the GraalJS classes are not on the classpath isAvailable() returns false and the engine is silently skipped.

Maven

<!-- GraalJS script engine -->
<dependency>
    <groupId>org.graalvm.js</groupId>
    <artifactId>js-scriptengine</artifactId>
    <version>24.1.0</version>
</dependency>
<dependency>
    <groupId>org.graalvm.polyglot</groupId>
    <artifactId>js-community</artifactId>
    <version>24.1.0</version>
    <type>pom</type>
</dependency>

Gradle

implementation "org.graalvm.js:js-scriptengine:24.1.0"
implementation "org.graalvm.polyglot:js-community:24.1.0"

Once the dependencies are present, scripts using language "js" are compiled and cached automatically. Bindings are exposed through ctx and full Java interop is available.

// Boolean condition — age check
Script<Boolean> ageCheck = Script.builder()
    .with("js", "ctx.age >= 18 && ctx.country === 'US'")
    .build();

// Action — write a computed value back to a binding
Script<Void> applyDiscount = Script.builder()
    .with("js", "ctx.total = ctx.price * (1 - ctx.discountRate)")
    .build();

// Local variables are isolated from the RuleContext
Script<Void> withLocal = Script.builder()
    .with("js", "var tax = ctx.price * 0.2; ctx.total = ctx.price + tax;")
    .build();
// 'tax' stays local — it is never written back to the RuleContext

Janino — language "java"

Janino is a lightweight Java compiler that compiles script fragments to bytecode at runtime. Unlike the JSR-223 bridge, rulii's Janino integration uses a custom compiler that reads the live bindings at compile time to generate a fully typed method signature — each binding becomes a typed parameter, producing a JIT-compiled evaluator with no reflection overhead per call. The compiled evaluator is cached in the Script instance and reused on subsequent runs.

Maven

<!-- Janino JIT Java compiler -->
<dependency>
    <groupId>org.codehaus.janino</groupId>
    <artifactId>janino</artifactId>
    <version>3.1.12</version>
</dependency>

Gradle

implementation "org.codehaus.janino:janino:3.1.12"

Scripts are standard Java statement blocks. All bindings are accessed through ctx using the same dot-access syntax as the JavaScript engine. The Java standard library is available without any imports.

// Read a binding and write a result back
Script.<Void>builder()
    .with("java", "ctx.total = ctx.price * ctx.quantity;")
    .build();

// Local typed variable — stays local, never leaks into RuleContext
Script.<Void>builder()
    .with("java", "int square = ctx.base * ctx.base;\nctx.result = square + ctx.base;")
    .build();

// Ternary and standard library methods work as expected
Script.<Void>builder()
    .with("java", "ctx.label = ctx.score >= 90 ? \"A\" : ctx.score >= 80 ? \"B\" : \"C\";")
    .build();

Script.<Void>builder()
    .with("java", "ctx.output = ctx.input.trim().toUpperCase();")
    .build();

Constraints to be aware of

  • Bindings must exist at compile time. Because the compiler generates a typed method signature from the live bindings, referencing a binding that has not been added to the RuleContext before the script first runs will throw a BuildScriptException.
  • ctx is reserved. Declaring a local variable named ctx is rejected at compile time.
  • No increment/decrement operators on bindings. ctx.counter++ and ctx.counter-- are not supported — use ctx.counter = ctx.counter + 1 instead.

What's New in 2.1.0 (GraalJS)

rulii 2.1.0 reworks the GraalJS integration: scripts can read Java bean properties directly, a polyglot context leak is fixed, every name GraalJS answers to resolves to the configured factory, and factories can be unregistered.

Nashorn Compatibility Mode (on by default)

The shared GraalJS engine now sets js.nashorn-compat=true, so scripts read Java bean properties directly: ctx.person.name instead of ctx.person.getName(), ctx.person.active for isActive(), and assignment calls the setter. Previously such reads silently evaluated to null. The explicit ES2022 setting still applies.

// 2.1.0 — bean properties work directly (previously evaluated to null)
Script<Boolean> vipCheck = Script.builder()
    .with("js", "ctx.person.age >= 18 && ctx.person.active")
    .build();
// ctx.person.age calls getAge(); ctx.person.active calls isActive()

// Assignment calls the setter
Script<Void> promote = Script.builder()
    .with("js", "ctx.person.status = 'gold'")
    .build();
// calls setStatus("gold")

The mode also enables the Nashorn Java.* helpers, JavaImporter, and the exit() / quit() globals — which end the script with an exception, not the JVM.

Context Lifecycle & Value Detaching

GraalJS backs every evaluation with a polyglot context that is reclaimed only by an explicit close. In earlier releases that context was never closed, which exhausted the heap under sustained evaluation. 2.1.0 fixes the leak: the processor releases the context after each evaluation whenever the result does not depend on it. To make that possible almost always, GraalJS values are now detached into plain Java objects as they cross into Java — this applies to evaluation results and to values a script writes into the bindings:

  • JavaScript arrays, Sets and iterables become ArrayLists
  • Object literals and Maps become LinkedHashMaps
  • Dates become Instants

Behavior change: a value written into the bindings is a Java copy. Reading it back within the same script gives a Java List/Map — so it's ctx.out.add(4), not ctx.out.push(4). Functions cannot be detached, so a script that returns a function keeps its context open.

// A JS array crossing into Java arrives as a java.util.ArrayList
Script<Void> collect = Script.builder()
    .with("js", "ctx.out = [1, 2, 3]; ctx.out.add(4);")
    .build();
// ctx.out is already a Java List — use add(), not push()

Factory Aliases & Unregistration

A ScriptProcessorFactory can now declare alternate names via getAliases(). The GraalJS factory reports every name GraalJS itself registers with JSR-223 (JS, JavaScript, ECMAScript, graal.js, ...), so all of them resolve to the configured factory. Previously anything but js fell through to the generic JSR-223 wrapper, which had none of the factory's configuration.

Factories can also be taken back out: ScriptProcessorManager.unregister(factory) removes every name that maps to that exact instance and reports whether any did. Names the factory had displaced are refilled from service-loader discovery, so removing an override restores the stock factory for that language. This lets an integration that registers factories tied to its own lifecycle (rulii-spring registers ScriptProcessorFactory beans) take them out when that lifecycle ends.

Source Text vs Resolved Text

Script.getSourceText() returns the script text as written, before ${...} placeholder resolution; getScript() is still the resolved text the compiler saw. Both surface in a script-backed runnable's ExpressionInfo — see Introspection & Metadata for the full picture.

Error Handling

Two exceptions cover the scripting error surface:

Exception When Thrown
BuildScriptException Script compilation fails (syntax error, unknown language)
EvaluationException Script evaluation fails at runtime (type error, missing binding)

Spring users

If you're using rulii-spring, the SpEL Scripting page covers the Spring Expression Language integration, which is set up automatically and requires no extra configuration.