QUICKJS.EVAL

QUICKJS.EVAL script numkeys [key ...] [arg ...]

Run a script on QuickJS (the quickjs-ng fork), a small embeddable JavaScript engine. This is a distinct interpreter from EVAL (PUC-Lua), LUAU.EVAL (Luau), WREN.EVAL (Wren), TCL.EVAL (Tcl), and UPYTHON.EVAL (MicroPython); it shares the key space with them but has its own VM and its own script cache.

numkeys splits keys from args; they are exposed as the arrays KEYS and ARGV, which are 0-based (KEYS[0] is the first key).

How returning a value works

The script body runs inside a function, so return <value> produces the reply — and any var / let / const / function you declare stays script-local instead of leaking into the shared context. If the script never returns, the reply is nil.

> QUICKJS.EVAL "return 1 + 2" 0
(integer) 3

> QUICKJS.EVAL "return [0, 1, 2, 3].map(x => x * x)" 0
1) (integer) 0
2) (integer) 1
3) (integer) 4
4) (integer) 9

> QUICKJS.EVAL "var x = 5;" 0            # never returns
(nil)

Return value: JavaScript → RESP

Returned value RESP reply
a whole number integer reply
a non-whole number bulk string (its text form, e.g. 3.5)
string bulk string
true integer 1; false nil
null / undefined nil
Array array (elements converted recursively)
object with a string err property error reply
object with a string ok property status reply
any other object nil

The redis object

> QUICKJS.EVAL "return redis.call('zadd', KEYS[0], 1, 'a', 2, 'b')" 1 board
(integer) 2

> QUICKJS.EVAL "return redis.call('zrange', KEYS[0], 0, -1, 'WITHSCORES')" 1 board
1) "a"
2) "1"
3) "b"
4) "2"
Function Purpose
redis.call(cmd, ...args) Run a command. A command error throws — catch it with try/catch.
redis.pcall(cmd, ...args) Like call, but returns { err: msg } instead of throwing.
redis.error(msg) Returns { err: msg } — an error reply.
redis.status(msg) Returns { ok: msg } — a status reply.
redis.sha1hex(s) 40-character SHA1 hex digest of s.
redis.log(level, msg) Write msg to the server log.

Because a command error throws, error handling is idiomatic try/catch:

> QUICKJS.EVAL "try { redis.call('zscore') } catch (e) { return 'caught' }" 0
"caught"

An exception that escapes the script (a command error, a ReferenceError, a throw, ...) becomes a RESP error reply:

> QUICKJS.EVAL "return undefinedThing()" 0
(error) ERR ReferenceError: undefinedThing is not defined

redis.call maps the reply back to a JavaScript value: an integer reply becomes a number, a bulk/simple string becomes a string, an array becomes an Array, and a null becomes null. Numbers are passed to commands as integers when whole and as their %.17g text otherwise. (A bulk reply comes back as a string, so a numeric reply like a ZSCORE of 5 is the string "5", not a number — there is no value-based promotion.)

Sandbox

Only the core QuickJS engine is compiled in — there is no quickjs-libc, so a script has no filesystem, no os/std modules, no sockets, and no host module loading; it is pure computation plus the redis binding above. The runtime is also capped with a memory limit and a maximum stack size. KEYS and ARGV are the only injected globals besides redis. The runtime is created lazily on the first script.

Compare-and-delete — the Redlock unlock idiom

The most-copied Redis script — safe lock release, deleting a key only if it still holds the token you wrote — in JavaScript: KEYS/ARGV are 0-based arrays, compared with strict ===:

if (redis.call("get", KEYS[0]) === ARGV[0]) {
  return redis.call("del", KEYS[0])
}
return 0
> SET lock:job my-token
OK
> QUICKJS.EVAL "if (redis.call('get', KEYS[0]) === ARGV[0]) { return redis.call('del', KEYS[0]) } return 0" 1 lock:job my-token
(integer) 1

Goblin Core also ships this as a native, single-op command — no interpreter: GOBLIN.CAD key expected.

Real-time leaderboard rescoring

A heavier example: a leaderboard whose stored score is each member's last-activity timestamp, rescored on read by recency — decay = 1 / (1 + age / half_life), no transcendentals — returning the top k most-recent members. The top-k is kept in a bounded insertion-sorted array (O(n·k), not a full sort). ARGV is now, half_life, k (0-based); the reply is [member, round(decay·1e6), …], most recent first.

// KEYS[0] = leaderboard (score = last-activity unix ts); ARGV = now, half_life, k
var now = parseFloat(ARGV[0]), hl = parseFloat(ARGV[1]), k = parseInt(ARGV[2]);
var flat = redis.call('zrange', KEYS[0], 0, -1, 'WITHSCORES');
var best = [];                                  // [decay, member], sorted desc
for (var i = 0; i < flat.length; i += 2) {
  var m = flat[i];
  var ts = parseFloat(flat[i + 1]);
  var d = 1.0 / (1.0 + (now - ts) / hl);
  if (best.length < k || d > best[best.length - 1][0]) {   // bounded top-k
    var pos = best.length;
    while (pos > 0 && best[pos - 1][0] < d) pos -= 1;
    best.splice(pos, 0, [d, m]);
    if (best.length > k) best.pop();
  }
}
var result = [];
for (var j = 0; j < best.length; j++) {
  result.push(best[j][1]);
  result.push(Math.floor(best[j][0] * 1000000 + 0.5));
}
return result;

See also