Run Redis Lua scripts (EVAL / EVALSHA) in Node.js or the browser, without a Redis server.
lua-redis-wasm is the Lua 5.1 scripting engine of Redis, compiled to
WebAssembly. You give it a script plus KEYS and ARGV; whenever the script
calls redis.call(...), the engine calls a JavaScript function you provide, so
the script can work on your own data. Replies, errors and Lua libraries behave
the way they do in a real Redis or Valkey server.
Primary purpose: this engine powers the Lua scripting (
EVAL/EVALSHA) support in js-redis-server, an in-memory Redis-compatible server (browser demo). It is published as a standalone package so it can be reused, but its API and error semantics are driven by what js-redis-server needs to match real Redis. If you embed it directly, expect it to behave the way Redis behaves inside that server.
- Redis-compatible Lua 5.1: the Lua that Redis and Valkey embed, with their sandbox, globals protection and value conversions.
- Your data, your commands:
redis.call,redis.pcallandredis.logcall JavaScript functions you write. - Binary-safe: scripts,
KEYS,ARGVand replies are bytes (Buffer), null bytes included. - Resource limits: a deterministic instruction budget, reply and argument size caps, and a fixed-size memory heap per engine, so a runaway script cannot hang your process.
- Redis standard libraries:
cjson,cmsgpack,structandbit. - Version profiles: emulate Redis 6.2 to 8.0 or Valkey 8.0 to 9.0.
- Node.js and browsers, with TypeScript types included.
npm install lua-redis-wasmRequires Node.js 22 or later. Browsers are supported through bundlers (see Use in the browser).
import { LuaEngine } from "lua-redis-wasm";
const data = new Map<string, Buffer>();
const engine = await LuaEngine.create({
host: {
// Called for redis.call(...). args[0] is the command name.
redisCall(args) {
const [cmd, key, value] = args;
switch (cmd.toString().toUpperCase()) {
case "GET":
return data.get(key.toString()) ?? null;
case "SET":
data.set(key.toString(), value);
return { ok: Buffer.from("OK") };
default:
throw new Error(`ERR unknown command '${cmd}'`);
}
},
// Called for redis.pcall(...): return errors instead of throwing.
redisPcall(args, ctx) {
try {
return this.redisCall(args, ctx);
} catch (err) {
return { err: Buffer.from((err as Error).message) };
}
},
// Called for redis.log(level, ...).
log(level, message) {
console.log(`[redis.log ${level}] ${message.toString()}`);
},
},
});
engine.eval("return 1 + 1"); // 2
const reply = engine.evalWithArgs(
"redis.call('SET', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])",
[Buffer.from("greeting")], // KEYS
[Buffer.from("hello")], // ARGV
);
console.log(reply?.toString()); // "hello"
engine.dispose(); // free the engine's memory when you are doneengine.eval(script) runs a Lua script and returns its result as a
reply value. Lua strings come back as Buffers, numbers as
integers, tables as arrays:
engine.eval("return 'hello'"); // Buffer.from("hello")
engine.eval("return {1, 2, 3}"); // [1, 2, 3]
engine.eval("return 3.7"); // 3 (Redis truncates numbers to integers)
engine.eval("return nil"); // nullThe script can be a string, Buffer or Uint8Array. Evaluation is
synchronous: the call returns when the script has finished.
engine.evalWithArgs(script, keys, args) sets the script's KEYS and ARGV
tables. Pass them as Buffers; they may contain any bytes:
engine.evalWithArgs(
"return {KEYS[1], ARGV[1], ARGV[2]}",
[Buffer.from("key:1")],
[Buffer.from("arg1"), Buffer.from("arg2\x00with-null")],
);The host object you pass to LuaEngine.create is how scripts reach your
data. It has three required callbacks and one optional one:
| Callback | Called for | What to do |
|---|---|---|
redisCall(args, ctx) |
redis.call(...) |
Run the command and return a reply. Throw (or return { err }) to fail it. |
redisPcall(args, ctx) |
redis.pcall(...) |
Same, but return { err: Buffer } instead of throwing, as Redis does. |
log(level, message) |
redis.log(level, ...) |
Log the message. level is 0 (debug) to 3 (warning). |
onSetResp(version) |
redis.setresp(2 | 3) |
Optional. Switch the reply shapes you return to RESP2 or RESP3. |
Things to know:
argsareBuffers;args[0]is the command name. Lua numbers arrive formatted the way Redis 7.4+ formats them (1e15→"1000000000000000").- Return a reply value:
null, a number, aBuffer,{ ok }for a status reply,{ err }for an error, an array, or a RESP3 type. - A thrown exception becomes an error reply. If its message does not start with
an uppercase error code,
ERRis added (throw new Error("oops")→ERR oops). To choose the code, thrownew Error("WRONGTYPE ...")or return{ err: Buffer.from("WRONGTYPE ...") }. - A callback that throws never breaks the engine. A
logoronSetRespthat throws raises a Lua error in the script. ctxdescribes the Lua line that made the call (ctx.source,ctx.line), for hosts that need Redis 6.2's@user_script: <line>:error prefix. Readctx.sourceinside the callback, and passctxalong when one handler calls another.- Don't run another script on the same engine from inside a callback: it is
refused with
ERR nested eval is not supported: a script is already running.
Details: docs/host-interface.md.
A script that fails does not make eval throw. eval returns an error reply
instead, the same way Redis sends an error to its client:
engine.eval("return redis.call('NOPE')");
// {
// err: Buffer.from("unknown command 'NOPE'"),
// code: Buffer.from("ERR"),
// meta: { line: 1, sha: "19965f96bed2e953a3424cfa591695dc2a3e81db" },
// }erris the message andcodethe error code (ERR,WRONGTYPE, ...).codecan be missing, as Redis 7 sends some errors without one (for exampleerror({err='boom'})). Send-<code> <err>, or-<err>without a code.metatells you where the script failed:lineand the script'ssha. Redis adds them to the message as<message> script: <sha>, on @user_script:<line>.meta.kindiscompilewhen the script is not valid Lua, so none of it ran.erris Lua's message (user_script:1: unexpected symbol near '+'); Redis sends-ERR Error compiling script (new function): <err>, without the line and sha.- Otherwise
meta.kindis set for errors the engine raises itself, such as reading an undefined global (global-read, with the variable inmeta.name). For theseerris just the kind; replace it with Redis's wording (listed in docs/errors.md). - An error the script returns (for example
return redis.pcall(...)) comes back as{ err, code }withoutmeta, unchanged.
To render a script error the way Redis does:
const reply = engine.eval("return redis.call('NOPE')");
if (reply && typeof reply === "object" && "err" in reply && reply.meta) {
const message = reply.code ? `${reply.code} ${reply.err}` : `${reply.err}`;
if (reply.meta.kind === "compile") {
console.log(`-ERR Error compiling script (new function): ${reply.err}`);
} else {
console.log(`-${message} script: ${reply.meta.sha}, on @user_script:${reply.meta.line}.`);
}
}eval throws only when the engine itself cannot run the script: a script or
KEYS/ARGV too large for the engine's memory (RangeError; the engine keeps
working), or a failure inside the WebAssembly module, after which the engine is
unusable and you should create a new one. It also throws once the engine has
been disposed. Inside scripts, redis.call errors
are Redis 7 {err=...} tables by default; the redis-6.2
profile uses plain strings.
Details: docs/errors.md.
engine.compile(script) only compiles the script, as Redis does for
SCRIPT LOAD. It returns null when the script is valid Lua, or the same
error reply eval would give. Nothing runs: no redis.call, no globals, no
fuel spent.
engine.compile("return 1"); // null
engine.compile("return +");
// {
// err: Buffer.from("user_script:1: unexpected symbol near '+'"),
// code: Buffer.from("ERR"),
// meta: { kind: "compile", line: 1, sha: "..." },
// }Redis replies -ERR Error compiling script (new function): <err> for a script
that does not compile, both to SCRIPT LOAD and EVAL (see above).
Details: docs/host-interface.md.
Scripts run with an instruction budget, so an endless loop cannot hang your
process. Set limits to change it, or to cap reply and argument sizes:
const engine = await LuaEngine.create({
host,
limits: {
maxFuel: 50_000_000, // Lua instructions per script
maxReplyBytes: 2 * 1024 * 1024, // largest reply a script may return
maxArgBytes: 1024 * 1024, // largest KEYS + ARGV
},
});
engine.eval("while true do end");
// {
// err: Buffer.from("Script killed by fuel limit"),
// code: Buffer.from("ERR"),
// meta: { line: 1, sha: "..." },
// }| Limit | Default | When exceeded, the script replies |
|---|---|---|
maxFuel |
10,000,000 instructions | ERR Script killed by fuel limit |
maxReplyBytes |
no limit | ERR reply exceeds configured limit |
maxArgBytes |
no limit | ERR KEYS/ARGV exceeds configured limit |
- Limits must be non-negative integers; anything else throws a
RangeError. 0means "no limit" for the byte limits and "the default" formaxFuel: the instruction budget can be raised but not switched off.- The budget counts Lua instructions, not time, so results are deterministic.
Time spent in your callbacks is not counted, and a script cannot escape the
budget with
pcall. - Each engine has a fixed 64 MB memory heap. A script that runs out of memory
gets a
not enough memoryerror, and the engine keeps working.
Details: docs/limits.md.
Redis versions differ slightly in what scripts can see. Set profile to match
the server you are emulating:
const engine = await LuaEngine.create({ host, profile: "redis-7.2" });profile |
print |
os library |
server alias |
Errors inside scripts | math.random |
|---|---|---|---|---|---|
redis-6.2 |
yes | no | no | strings | reseeded before every script |
redis-7.0, redis-7.2 |
no | no | no | {err=...} tables |
one sequence across scripts |
redis-7.4, redis-8.0 |
no | yes | no | {err=...} tables |
one sequence across scripts |
valkey-8.0, valkey-9.0 |
no | yes | yes | {err=...} tables |
one sequence across scripts |
| none (default) | no | yes | yes | {err=...} tables |
one sequence across scripts |
Profiles also pick each version's wording for a few error messages. To change
a single behavior, add compat on top of the profile:
compat key |
Effect |
|---|---|
print |
Keep Lua's print global. |
os |
Expose the sandboxed os library (os.clock only). |
serverAlias |
Expose server as an alias of redis. |
reseedRandom |
Reseed math.random with 0 before every script. |
tableErrors |
Use Redis 7 error tables (false gives Redis 6.2 string errors). |
// Redis 8.0, but with Redis 6.2 string errors
const engine = await LuaEngine.create({
host,
profile: "redis-8.0",
compat: { tableErrors: false },
});Details: docs/compat.md.
The engine does not define version-specific members such as
redis.REDIS_VERSION or redis.replicate_commands(). Add the ones your
scripts need with redisProps:
const engine = await LuaEngine.create({
host,
redisProps: {
REDIS_VERSION: { value: "7.4.0" },
REPL_ALL: { value: 3 },
replicate_commands: { returns: true }, // function(...) return true end
set_repl: { returns: null }, // function(...) end (does nothing)
},
});{ value } sets a constant; { returns } sets a function that ignores its
arguments and returns the given value (null returns nothing). When the
server alias is enabled, it sees the same members.
LuaEngine.createStandalone() creates an engine with no host callbacks, for
scripts that only compute. redis.call raises
ERR redis.call is not available in standalone mode, and redis.pcall
returns ERR redis.pcall is not available in standalone mode as an error
table:
const calc = await LuaEngine.createStandalone({ limits: { maxFuel: 1_000_000 } });
calc.eval("return math.sqrt(16)"); // 4
calc.eval("return cjson.encode({a = 1})"); // Buffer.from('{"a":1}')
calc.dispose();It takes the same options as LuaEngine.create, without host.
engine.reset()replaces the Lua VM with a fresh one, discarding whatever earlier scripts changed (for examplecjsonsettings). Limits, profile,redisPropsand host callbacks are kept, and so is themath.randomsequence, as on a real server.engine.dispose()releases the engine and its 64 MB of WebAssembly memory. Afterwardseval,evalWithArgs,compileandresetthrow. Callingdispose()twice is fine.
const engine = await LuaEngine.createStandalone();
try {
engine.eval("return 1");
} finally {
engine.dispose();
}Both throw when called from inside one of the engine's host callbacks; call
them after eval returns. If reset() cannot build the new VM (out of memory),
it throws, and every eval replies ERR Lua VM not initialized until a later
reset() succeeds.
LuaEngine.create(options) is load(options) followed by
module.create(options.host). Split the two steps to load asynchronously once
and create the engine synchronously later:
import { load } from "lua-redis-wasm";
const module = await load({ limits: { maxFuel: 10_000_000 } });
const engine = module.create(host); // or module.createStandalone()- A module creates exactly one engine; call
load()again for another one. Engines never share state. - The compiled WebAssembly code is cached for the process, so only the first
load()reads and compiles the binary. wasmPathpoints at anotherredis_lua.wasm(a file path orfile://URL in Node, a URL in the browser);wasmBytespasses the binary directly (Uint8ArrayorArrayBuffer);modulePathpoints at the matchingredis_lua.mjsglue. Use binaries built from the same release as the package.
Bundlers such as Vite, webpack and Rollup pick the package's browser build
automatically (through the browser export condition). The build itself has
no node:* imports and fetches redis_lua.wasm from next to the module; if
your bundler does not copy that file, serve it yourself and pass its URL as
wasmPath, or fetch it and pass wasmBytes.
The Emscripten glue it loads (redis_lua.mjs) is shared with Node, so it
still mentions node:module, node:fs, node:path, node:url and
node:crypto, behind a check that only runs them in Node. Bundlers only need
to leave them alone:
-
Vite builds as is. It prints a "Module "node:module" has been externalized for browser compatibility" warning that you can ignore.
-
webpack 5 fails with
UnhandledSchemeError: Reading from "node:module"unless you ignore those imports:// webpack.config.js plugins: [new webpack.IgnorePlugin({ resourceRegExp: /^node:/ })],
The API uses Buffer, which browsers don't have. Install one as a global,
for example from the buffer
package, before you create an engine:
import { Buffer } from "buffer";
import { LuaEngine } from "lua-redis-wasm";
Object.assign(globalThis, { Buffer });
const engine = await LuaEngine.createStandalone();
engine.eval("return 1 + 1"); // 2A bundler plugin that provides Node's Buffer globally works too.
Script results and host replies use one type, ReplyValue:
type ReplyValue =
| null // Lua nil / Redis null
| number // integer (safe range)
| bigint // integer outside the safe range
| boolean // RESP3 boolean
| Buffer // bulk string
| { ok: Buffer } // status reply, e.g. +OK
| { err: Buffer; code?: Buffer; meta?: ReplyErrorMeta } // error reply
| { double: number } // RESP3 double
| { big_number: Buffer } // RESP3 big number
| { verbatim_string: { format: Buffer; string: Buffer } } // RESP3 verbatim string
| { map: [ReplyValue, ReplyValue][] } // RESP3 map
| { set: ReplyValue[] } // RESP3 set
| ReplyValue[]; // arrayHow Lua values come back, as in Redis:
| Lua value | Result |
|---|---|
nil, or no return |
null |
| number | integer, truncated (3.7 → 3); a bigint outside JavaScript's safe integer range (2^62 → 4611686018427387904n) |
| string | Buffer |
true / false |
1 / null (RESP3 after redis.setresp(3): true / false) |
| array table | array, up to the first nil |
{ok='...'} / redis.status_reply(...) |
{ ok } |
{err='...'} / redis.error_reply(...) |
{ err, code? } |
{double=}, {map=}, {set=}, {big_number=}, {verbatim_string=} |
the matching RESP3 variant |
function, coroutine, userdata (e.g. cjson.null) |
null, at any depth |
Status replies from your host stay tables inside the script:
redis.call('SET', 'k', 'v') gives {ok='OK'}, and
redis.call('SET', 'k', 'v').ok is 'OK'.
Typed tables such as {double=1.5} and {map={...}} come back typed even if
the script never called redis.setresp(3), as in Redis. If you serve RESP2
clients, convert them yourself: Redis sends a RESP2 client a bulk string for
double, big_number and verbatim_string, a flat key/value array for map,
and a plain array for set.
To tell reply types apart:
function describe(reply: ReplyValue): string {
if (reply === null) return "nil";
if (typeof reply === "number" || typeof reply === "bigint") return `integer ${reply}`;
if (typeof reply === "boolean") return `boolean ${reply}`;
if (Buffer.isBuffer(reply)) return `bulk string ${reply.toString()}`;
if (Array.isArray(reply)) return `array of ${reply.length}`;
if ("ok" in reply) return `status ${reply.ok.toString()}`;
if ("err" in reply) return `error ${reply.err.toString()}`;
return `RESP3 ${Object.keys(reply)[0]}`;
}| Export | Description |
|---|---|
LuaEngine.create(options) |
Load the module and create an engine with host callbacks. Options: host (required), limits, profile, compat, redisProps, wasmPath, wasmBytes, modulePath. |
LuaEngine.createStandalone(options?) |
Same, without host callbacks. |
engine.eval(script) |
Run a script; returns a ReplyValue. |
engine.evalWithArgs(script, keys, args) |
Run a script with KEYS and ARGV. |
engine.compile(script) |
Compile a script without running it; returns null or the compile error reply. |
engine.reset() |
Replace the Lua VM with a fresh one. |
engine.dispose() |
Release the engine. |
engine.getLimits() |
The limits the engine was created with. |
LuaEngine.defaultWasmPath(), LuaEngine.defaultModulePath() |
Location of the bundled redis_lua.wasm / redis_lua.mjs. |
load(options?) |
Load the module; returns a LuaWasmModule with create(host) and createStandalone(). |
LuaWasmModule |
What load() returns: create(host) / createStandalone() make its one engine; static defaultWasmPath() / defaultModulePath(). |
WasmFault |
Error class for a fault inside the WebAssembly module. |
encodeReply(value), decodeReplyBuffer(buffer), encodeArgs(args) |
Low-level helpers for the binary ABI encoding of replies and argument arrays. Most applications don't need them. |
LuaWasmEngine |
Deprecated alias of LuaEngine; use LuaEngine instead. |
Types: EngineOptions, StandaloneOptions, LoadOptions, EngineLimits,
RedisHost, RedisCallHandler, RedisCallContext, RedisLogHandler,
ReplyValue, ReplyError, ReplyErrorMeta, CompatProfile, CompatOverrides,
RedisProp, RedisProps.
- cjson: JSON encoding and decoding
- cmsgpack: MessagePack serialization
- struct: binary data packing and unpacking
- bit: bitwise operations
- Lua 5.1's
base,table,stringandmathlibraries, pluscoroutineand, depending on the profile, a sandboxedos(os.clockonly)
As in Redis, there is no file or network access, os (where the profile
enables it) has only os.clock, and scripts cannot create or change globals.
| Feature | Status |
|---|---|
| Redis version target | 7.x by default; Redis 6.2–8.0 and Valkey 8.0–9.0 via profile |
| Lua version | 5.1 |
| Binary-safe strings | Yes |
redis.call / redis.pcall |
Yes |
redis.log, redis.sha1hex, redis.error_reply, redis.status_reply |
Yes |
redis.setresp / RESP3 replies |
Yes (no RESP3 push) |
| Standard Lua libraries | Yes |
| Redis Lua modules (cjson, etc.) | Yes |
| Debug / REPL helpers | No |
| Redis Modules API | Not yet |
Version 2.0 changes some behavior your host can see. Most applications only need to:
- Replace
LuaWasmEnginewithLuaEngine(the old name still works but is deprecated). - Remove
maxMemoryBytesfromlimits, and pass whole, non-negative numbers for the other limits. - Check your error handling:
codecan be missing, a string error's code is alwaysERR, and scripts now see Redis 7 error tables. To keep 1.x-style string errors, useprofile: "redis-6.2"orcompat: { tableErrors: false }.
The full list, with what to do for each item, is in the CHANGELOG's breaking changes.
- Host interface: the callbacks in detail, call context, failures
- Errors: error replies, codes,
meta, errors inside scripts, exceptions - Resource limits: fuel, memory, stack and nesting limits
- Compatibility: profiles, sandbox rules, per-version wording
- Binary ABI: the WebAssembly interface, for contributors
- Limits and compatibility summary
Clone with submodules (the Lua sources come from the vendor/valkey
submodule): git clone --recursive, or git submodule update --init --recursive.
Building the WebAssembly module needs Docker (it runs Emscripten in a
container).
npm ci
npm run build # WASM + TypeScript + copy the .wasm into dist/
npm run build:wasm # WASM only (Docker)
npm run build:ts # TypeScript only
npm test # rebuild the WASM, then run all tests
npm run test:skip-wasm # run all tests against the current WASM build
npm run smoke # native C smoke tests (Docker)
# a single test file
node --test --test-timeout=60000 --import tsx test/engine.test.tsWe welcome contributions! Please see our Contributing Guide for details on:
- How to report bugs
- How to suggest enhancements
- Development setup
- Pull request process
- Coding standards
Please read our Code of Conduct before contributing.
Security is important to us. If you discover a security vulnerability, please follow our Security Policy for responsible disclosure.
For general security considerations when using lua-redis-wasm, see the Security Guide.
- Issues and questions: GitHub Issues
- Documentation: docs/
See CHANGELOG.md for a list of changes in each release.
This package is licensed under the MIT License. See LICENSE for details.
The WASM module is built from C sources vendored from
Valkey 8.0.11 (the vendor/valkey
submodule, pinned to a release tag), and parts of this project's C code are
derived from Valkey / Redis 7.2.4 and Redis 6.2. It includes third-party code
under the BSD 3-Clause License:
- Valkey / Redis 7.2.4 (derived scripting code, Lua core modifications,
rand.c) - Copyright (C) 2006-2020 Redis Ltd., (C) 2024-present Valkey contributors - Redis 6.2 (derived
redis-6.2profile error behavior) - Copyright (C) 2009-2012 Salvatore Sanfilippo, Redis Ltd.
under the MIT License:
- Lua 5.1 - Copyright (C) 1994-2012 Lua.org, PUC-Rio
- lua_cjson, strbuf, fpconv - Copyright (C) 2010-2012 Mark Pulford
- lua_cmsgpack - Copyright (C) 2012 Redis Ltd.
- lua_struct - Copyright (C) 2010-2018 Lua.org, PUC-Rio
- lua_bit - Copyright (C) 2008-2012 Mike Pall
and under the Boost Software License 1.0:
- fpconv_dtoa - Copyright (C) 2013-2019 night-shift, (C) 2009 Florian Loitsch, (C) 2021 Redis Ltd.
See THIRD_PARTY_NOTICES.md for full license texts.
- Redis and Valkey teams for the Lua integration design
- Emscripten project for WebAssembly tooling
- Contributors and maintainers of the included Lua libraries