Scripting

Rift supports multiple scripting engines for dynamic behavior.


Script API: unified ctx, respond(ctx), result constructors

_rift.script has a single contract that is identical across Rhai and JavaScript: a ctx object passed into the script, and result constructors instead of a hand-built #{ inject:, fault: } map — see ctx API below for the full reference.

// named entrypoint
fn respond(ctx) {
  let n = ctx.state.incr("attempts");
  if n <= 2 {
    http(503, #{ error: "unavailable", attempt: n }).header("Retry-After", "1")
  } else {
    http(200, #{ ok: true, succeededOnAttempt: n })
  }
}
// bare-expression form — no `fn respond(ctx) { ... }` wrapper at all. The whole script body
// IS the function, with `ctx` already in scope. Equivalent to the named form above.
let n = ctx.state.incr("attempts");
if n <= 2 {
  http(503, #{ error: "unavailable", attempt: n }).header("Retry-After", "1")
} else {
  http(200, #{ ok: true, succeededOnAttempt: n })
}

Both forms are legal for every hook placement; which named entrypoint applies depends on where the script is attached — see the table below.


Available Engines

Engine Format Use Case
JavaScript inject response, decorate, or _rift.script with "engine": "javascript" (alias js) Mountebank-compatible injection; the same ctx API as Rhai
Rhai _rift.script ("engine": "rhai", the default) Lightweight fault logic with flow state

Every script surface — inject responses and predicates, decorate, shellTransform, a function wait, and _rift.script — requires --allowInjection, exactly as Mountebank gates inject. Without it, creating the imposter returns 400 invalid injection. The Lua engine was removed; "engine": "lua" (or a .lua script file) is rejected with an error naming the replacement.


JavaScript (Mountebank Inject)

JavaScript uses the standard Mountebank inject response format for compatibility.

Injection Responses

{
  "responses": [{
    "inject": "function(config) { return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: config.request.path, timestamp: Date.now() }) }; }"
  }]
}

Request Object

config.request.method      // "GET", "POST", etc.
config.request.path        // "/api/users/123"
config.request.query       // { page: "1", limit: "10" }
config.request.headers     // { "content-type": "application/json" }
config.request.body        // Request body (string or parsed object)

Path parameters (request.pathParams, from a stub’s routePattern) are exposed to the _rift.script engines — Rhai and JavaScript — not to this Mountebank inject config.request object.

State Object

Persist data across requests within the same imposter:

function(config, state) {
  // Initialize or increment counter
  state.counter = (state.counter || 0) + 1;

  // Store user-specific data
  var userId = config.request.headers['X-User-Id'];
  state.users = state.users || {};
  state.users[userId] = { lastSeen: Date.now() };

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ requestNumber: state.counter })
  };
}

Rhai (_rift.script)

Rhai is a lightweight embedded scripting language optimized for Rust. Scripts define a respond(ctx) function, or the bare-expression form — see ctx API below.

Basic Script

{
  "port": 4545,
  "protocol": "http",
  "_rift": {
    "flowState": {"backend": "inmemory", "ttlSeconds": 600}
  },
  "stubs": [{
    "responses": [{
      "_rift": {
        "script": {
          "engine": "rhai",
          "code": "fn respond(ctx) { let count = ctx.state.incr(\"counter\"); http(200, #{ count: count }) }"
        }
      }
    }]
  }]
}

Available Variables

See ctx.request for the full reference:

// Request information
ctx.request.method        // String: "GET", "POST", etc.
ctx.request.path          // String: "/api/users"
ctx.request.headers       // Map with lowercased keys: access via ctx.request.headers["header-name"]
ctx.request.header(name)  // case-insensitive getter, e.g. ctx.request.header("X-Name")
ctx.request.query         // Map: access via ctx.request.query["param"]
ctx.request.pathParams    // Map: access via ctx.request.pathParams["name"] (populated from the stub's routePattern)
ctx.request.body          // Raw request body (string)
ctx.request.json          // Body lazily parsed as JSON (unit if not JSON)

// Helper functions
timestamp_header()      // RFC 1123 formatted timestamp for HTTP Date header

State

State persists across requests, automatically scoped to the request’s resolved flow id — no explicit id argument needed. See ctx.state and ctx.store below for the full reference.

// Get value (get_or supplies a default instead of the () / nil dance)
let count = ctx.state.get_or("counter", 0);

// Set value
ctx.state.set("counter", count + 1);

// Increment counter (returns new value)
let attempts = ctx.state.incr("attempts");

// Check existence
if ctx.state.exists("key") {
  // key exists
}

// Delete value
ctx.state.delete("key");

// Set TTL for every key in the flow (seconds)
ctx.state.ttl(300);

// Set one key's TTL — returns true if it existed, false if absent; <= 0 deletes it
ctx.state.ttl("counter", 60);

// Remove every key in the flow
ctx.state.clear();

Return Values

respond(ctx) returns a result constructor — see Result constructors below for the full reference:

// No injection
pass()

// Inject error response
http(503, #{ error: "Service unavailable" }).header("Retry-After", "30")

// Inject latency
delay(500)

Script Examples

Rate Limiting

{
  "_rift": {
    "flowState": {"backend": "inmemory", "ttlSeconds": 60}
  },
  "stubs": [{
    "responses": [{
      "_rift": {
        "script": {
          "engine": "rhai",
          "code": "fn respond(ctx) { let count = ctx.state.incr(\"requests\"); if count > 100 { http(429, #{ error: \"Rate limit exceeded\", count: count }).header(\"Retry-After\", \"60\") } else { pass() } }"
        }
      }
    }]
  }]
}

Retry Simulation (Fail First N Requests)

{
  "_rift": {
    "flowState": {"backend": "inmemory", "ttlSeconds": 300, "flowIdSource": "header:X-Flow-Id"}
  },
  "stubs": [{
    "responses": [{
      "_rift": {
        "script": {
          "engine": "rhai",
          "code": "fn respond(ctx) { let attempts = ctx.state.incr(\"attempts\"); if attempts <= 2 { http(503, #{ error: \"Temporary failure\", attempt: attempts }) } else { pass() } }"
        }
      }
    }]
  }]
}

Authoring Scripts: file: and ref: (YAML)

The retry script above works fine as a single JSON-escaped line, but it stops being readable once a script grows past a few statements. _rift.script also accepts file: (load the script from a separate file) and ref: (resolve from a named entry under _rift.scripts) instead of inline code: — exactly one of code, file, or ref must be set. This is most useful in a YAML configfile, where a block scalar (|) lets you write the same script as normal multi-line Rhai. rift --configfile config.yaml expects a YAML sequence of imposters at the document root (a single imposter is still a one-element sequence). rift-lint config.yaml checks one before you run it — the linter reads YAML as well as JSON, and reports any other root shape as E046:

- port: 4545
  protocol: http
  _rift:
    flowState: { backend: inmemory, ttlSeconds: 300, flowIdSource: "header:X-Flow-Id" }
  stubs:
    - responses:
        - _rift:
            script:
              engine: rhai
              # Block scalar: the exact retry logic from the JSON example above, just readable.
              code: |
                fn respond(ctx) {
                  let attempts = ctx.state.incr("attempts");
                  if attempts <= 2 {
                    http(503, #{ error: "Temporary failure", attempt: attempts })
                  } else {
                    pass()
                  }
                }

For a script reused across stubs — or one you’d rather keep in its own file for editor syntax-highlighting and diffs — use file: instead, resolved relative to the configfile’s own directory (--datadir files resolve the same way; admin-API-created imposters resolve under --scripts-dir instead, and reject any path that escapes it):

- port: 4545
  protocol: http
  _rift:
    flowState: { backend: inmemory, ttlSeconds: 300 }
    # Named registry: give a script a name once, `ref:` it from any response.
    scripts:
      failTwice:
        file: scripts/fail-twice.rhai   # engine inferred from the extension: .rhai -> rhai
  stubs:
    - responses:
        - _rift:
            script:
              ref: failTwice

engine is inferred from file’s extension (.rhai -> rhai, .js -> javascript) when omitted; a ref: may not itself point at another ref: (no chains), and an unknown ref: or a file: that can’t be read is a config-time validation error — surfaced at rift --configfile load, at POST /imposters as a 400, and by rift-lint.

Counter with Multiple Endpoints

{
  "_rift": {
    "flowState": {"backend": "inmemory", "ttlSeconds": 600}
  },
  "stubs": [
    {
      "predicates": [{"equals": {"method": "POST", "path": "/api/counter/increment"}}],
      "responses": [{
        "_rift": {
          "script": {
            "engine": "rhai",
            "code": "fn respond(ctx) { let counter = ctx.state.incr(\"counter\"); http(200, #{ counter: counter }) }"
          }
        }
      }]
    },
    {
      "predicates": [{"equals": {"method": "GET", "path": "/api/counter"}}],
      "responses": [{
        "_rift": {
          "script": {
            "engine": "rhai",
            "code": "fn respond(ctx) { let counter = ctx.state.get_or(\"counter\", 0); http(200, #{ counter: counter }) }"
          }
        }
      }]
    },
    {
      "predicates": [{"equals": {"method": "DELETE", "path": "/api/counter"}}],
      "responses": [{
        "_rift": {
          "script": {
            "engine": "rhai",
            "code": "fn respond(ctx) { ctx.state.delete(\"counter\"); http(200, #{ message: \"Counter reset\" }) }"
          }
        }
      }]
    }
  ]
}

ctx API

ctx is built the same way, with the same field names and semantics, in every engine. It has one entrypoint:

Placement Entrypoint Returns
Response script (_rift.script) respond(ctx) a result constructor, or nothing (pass through)

The script may either define respond explicitly, or omit the wrapper entirely and write the function body directly at the top level (bare-expression form) — both are shown above.

What runs where

respond(ctx) is the whole of the ctx API. Scripting at the other hook points is Mountebank-shaped rather than ctx-shaped, and each has its own documented mechanism:

To do this Use Documented at
Decide whether a stub matches a Mountebank inject predicate — a bare function over request, state, logger, config Predicates
Rewrite a response after it is built the decorate / shellTransform behaviors Behaviors
Wait before responding _behaviors.wait — a number, a {min, max} range, or a Mountebank-style JS function ("wait": "function() { ... }", or the object spelling {"inject": "function() { ... }"}) Behaviors

Each takes its own Mountebank-shaped inputs, not ctx: an inject predicate receives request/state/logger/config, a decorate function receives the request and the response, and a wait function takes no arguments and returns a millisecond count. Earlier versions of this page also listed matches(ctx), transform(ctx) and delay(ctx) rows here. Those entrypoints were never dispatched by either engine — a script defining one would pass rift script check and then silently have no effect — so they were removed in #1001 rather than left as a promise nothing kept. rift script check --hook and rift script run --hook now both accept only respond.

ctx.request

ctx.request.method        // "GET", "POST", etc.
ctx.request.path          // "/api/users/123"
ctx.request.pathParams    // map: populated from the stub's routePattern
ctx.request.query         // map of query-string parameters
ctx.request.headers       // map with LOWERCASED keys — always, regardless of wire casing
ctx.request.header(name)  // case-insensitive getter, e.g. ctx.request.header("X-Flow-Id")
ctx.request.body          // the raw request body, as a string
ctx.request.json          // the body lazily parsed as JSON; unit/nil/null if it isn't JSON

ctx.request.header(name) exists so X-Flow-Id, x-flow-id, and X-FLOW-ID all resolve the same value — a common source of bugs when reading request.headers[...] directly against on-the-wire casing.

ctx.state and ctx.store

ctx.state is a flow-scoped state handle, already bound to the request’s resolved flow id (per flowIdSource — the same resolution the scenario-state gate uses):

let n = ctx.state.get("key");     // unit if not set
ctx.state.set("key", n + 1);
let attempts = ctx.state.incr("attempts");
ctx.state.exists("key");          // bool
ctx.state.delete("key");

Atomic ops and ergonomic getters:

let n = ctx.state.get_or("attempts", 0);   // value, or the default if absent — kills
                                            // the `if v == () { v = 0; }` idiom
let n = ctx.state.incr_by("attempts", 5);  // atomic +5, starts at 0 when absent
ctx.state.ttl(60);                         // re-stamp every key in the flow (seconds)
ctx.state.ttl("attempts", 60);             // set one key's TTL (bool); <= 0 deletes it
ctx.state.clear();                         // remove every key in the flow

let outcome = ctx.state.cas("status", "pending", "paid");
if outcome.applied {
  // this call won the race
} else {
  // outcome.current is who won instead (unit/nil/null if the key was absent)
}

cas(key, expected, new) is Rift’s atomic compare-and-set. It always returns an object{ applied: true } on success, or { applied: false, current: <value> } on conflict — rather than a bare value, so a conflicting stored value that happens to equal true can never be mistaken for “applied”. This shape is identical in both engines; only the method spelling differs to match each engine’s naming convention — Rhai uses get_or/incr_by (snake_case), JS uses getOr/incrBy (camelCase); cas and ttl are spelled the same everywhere.

Every ctx.state call is fail-loud: a store failure (a Redis connection dropping mid-request, for example) raises a script error and is logged — it is never silently swallowed into a default value. See Flow State for how the underlying store is selected, including the in-memory auto-provisioning that lets ctx.state work with zero configuration.

ctx.store is the escape hatch for touching a different flow’s state — ctx.store.flow(id) returns a handle just like ctx.state, but scoped to id instead of the request’s own flow:

ctx.store.flow("other-flow-id").set("shared", 99);

ctx.flowId and ctx.stub

ctx.flowId              // the resolved flow id (string) — same value ctx.state is bound to
ctx.stub.scenarioName    // string, or unit/nil/null if the stub isn't part of a scenario
ctx.stub.scenarioState   // string, or unit/nil/null
ctx.stub.id              // the stub's own id, or unit/nil/null if it has none

ctx.logger

Real logging (not a no-op): debug/info/warn/error, routed to the process’s own tracing output at target rift::script, tagged with the imposter port and stub id where available.

ctx.logger.info("handling request " + ctx.request.path);

Result constructors

How respond(ctx) describes what should happen — no hand-built #{ inject:, fault: } map. Available in respond(ctx) (and as the return value of a bare-expression script):

Constructor Meaning
http(status) / http(status, body) respond with this status/body; chain .header(k, v) for extra headers
delay(ms) wait ms, then answer 200 with an empty body and an x-rift-latency-ms header
reset() reset the connection (transport-level)
pass() answer 200 with an empty body — no injection
(nothing) same as pass()

A _rift.script response is script-only: there is no is body behind it to fall through to, and pass() does not advance to the stub’s next response or to another stub. Use http(...) to shape the success response too. Every script response carries x-rift-script: <engine>.

http’s body is a value, not a hand-assembled JSON string: pass a map/array and it is JSON-serialized with Content-Type: application/json set automatically (unless you set your own Content-Type via .header(...), which always wins); pass a string and it’s used verbatim.

// Object body -> JSON + Content-Type: application/json
http(503, #{ error: "unavailable", attempt: 2 })

// String body -> passed through as-is, no Content-Type added
http(200, "OK")

// Chained headers
http(429, #{ error: "rate limited" }).header("Retry-After", "60")

Execution Limits

Script execution is bounded so a runaway script cannot wedge the engine. This applies to _rift.script and to the Mountebank-compatible JavaScript hooks — response inject, predicate inject, and the decorate behavior — all of which run off the async workers (on a dedicated script-worker pool) under the same deadline.

  • Wall-clock timeout. Each script runs under a deadline — _rift.scriptEngine.timeoutMs if configured, otherwise 5000 ms. Rhai is interrupted mid-run when the deadline passes.
  • JavaScript (Boa) bounds. The Boa interpreter cannot be interrupted per-instruction, so it is bounded structurally instead: a loop-iteration limit of 10,000,000 iterations per call frame and a recursion limit of 512. The client is still released at the wall-clock timeout with an error; a pathological nested loop may keep a background worker busy a little longer, but it cannot run unbounded.
  • On a genuine error — a compile error, a runtime error, or exceeding the loop/recursion bound — a _rift.script failure is a 500 Internal Server Error carrying an x-rift-script-error: true header; a failing Mountebank inject (response or predicate) is a 400 with a Mountebank-shaped {"errors": [...]} body (code invalid injection / invalid predicate injection), and a failing decorate serves the undecorated response with an x-rift-decorate-error: true header (or a 500 under strictBehaviors).
  • On a wall-clock timeout the response is instead a 504 Gateway Timeout carrying an x-rift-script-timeout: true marker on top of the hook’s usual signal header, so a transient, retry-worthy deadline miss is distinguishable from a permanent config error. Concretely: a response inject → 504 with code injection timeout; a predicate inject → 504 with code predicate injection timeout; a _rift.script504 with x-rift-script-error; a decorate → the undecorated response (200) with x-rift-decorate-error (or 504 under strictBehaviors). All four also carry x-rift-script-timeout: true. A debug-mode matching run (X-Rift-Debug) that misses the same deadline is the fifth case: 504 with x-rift-script-timeout: true. It answered 500 in earlier releases — a deadline miss there is now retry-worthy like every other script timeout (#695).
  • Amortized JavaScript startup. The Mountebank hooks reuse a per-worker-thread Boa context and a parsed-script cache keyed by source content, so steady-state execution skips both JS realm construction and re-parsing. Like Mountebank itself — which evaluates every injection in one shared Node.js process — scripts on the same worker thread share JS globals; per-imposter state isolation is unaffected.
{
  "_rift": {
    "scriptEngine": { "timeoutMs": 2000 }
  }
}

A _rift.script block’s engine is decided in this order, first match wins:

  1. its own engine;
  2. its file: extension (.rhai -> rhai, .js -> javascript);
  3. the imposter’s _rift.scriptEngine.defaultEngine;
  4. rhai.

So with "defaultEngine": "javascript", inline blocks without engine run as JavaScript, while a .rhai file stays Rhai. This applies to stubs added later through the stub endpoints too. The chosen engine is written into the script, so GET /imposters shows it. An unknown defaultEngine fails only a script that actually needs it; rift-lint flags it as W016.

Flow-Store Error Semantics

ctx.state is always fail-loud: every op — get/set/incr/exists/delete and the atomic get_or/incr_by/cas/ttlraises a script error on a backend failure (e.g. a Redis outage mid-request) and logs it, so a store outage is never silently returned as an empty/absent value. The raised error propagates to the standard script-error path (500 with x-rift-script-error).


Engine Comparison

Both engines share the same unified ctx API for _rift.scriptrespond(ctx), ctx.state, and the http()/delay()/reset()/pass() result constructors all work identically on either engine. The two real differentiators are Mountebank-native inject/decorate compat (JavaScript only) and raw throughput (Rhai, compiled and cached).

Feature JavaScript Rhai
Format _rift.script (unified ctx), or Mountebank inject/decorate _rift.script (unified ctx)
State access ctx.state.get(key) (Mountebank inject path: config.state) ctx.state.get(key)
Flow isolation Per flow_id Per flow_id
Function wrapper respond(ctx) or bare expression respond(ctx) or bare expression
Performance Good Excellent
Mountebank compatible Yes (via inject/decorate) No

Performance Tips

  1. Use Rhai for high-throughput - it is compiled and cached for efficient reuse
  2. Minimize ctx.state access - Each get/set has overhead; batch operations when possible
  3. Keep scripts simple - Complex logic is harder to debug and maintain
  4. Choose flowIdSource wisely - it determines what ctx.state isolates by (request header, imposter port); pick a source that keys state per request/user/session to avoid collisions
  5. Set appropriate TTLs - Prevent unbounded state growth with ttlSeconds config. It must be >= 1; a non-positive value is rejected at construction rather than accepted, because it would expire every write immediately (in-memory) or fail on the first write (Redis). This applies to both the per-imposter _rift.flowState block and the server-level flowState config