Embed real Lua (5.1-5.5) and LuaJIT in Node.js with native N-API bindings. Create Lua VMs, execute code, share values between languages - no compiler required when using prebuilt binaries.
Features • Quick Start • Installation • Usage • API • Mapping • CLI • Downstream Packages • Performance
- ⚡ Multiple Lua versions - Supports Lua 5.1–5.5 and LuaJIT
- 🧰 Prebuilt Binaries - Lua 5.4.8 included for Linux/macOS/Windows
- 🔄 Bidirectional integration - Call Lua from JS and JS from Lua
- 📦 Rich data exchange - Objects, arrays, functions in both directions
- 🎯 TypeScript-ready - Full type definitions included
- 🚀 Native performance - Built with N-API (no WebAssembly)
npm install lua-state
const { LuaState } = require("lua-state"); const lua = new LuaState(); lua.setGlobal("x", 10); const result = lua.eval("return x * 2"); console.log(result); // 20 lua.close();
Lua runs synchronously in the same thread as Node.js and blocks the event loop during execution. This means long-running Lua code will block all JavaScript execution.
Prebuilt binaries are currently available for Lua 5.4.8 and downloaded automatically from GitHub Releases.
If a prebuilt binary is available for your platform, installation is instant - no compilation required. Otherwise, it automatically falls back to building the official Lua sources (the same as --mode=official).
Requires Node.js 18+, tar (system tool or npm package), and a valid C++ build environment (for node-gyp ) if binaries are built from source.
Tip: if you only use prebuilt binaries you can reduce install size with
npm install lua-state --no-optional.
const lua = new LuaState();
Get Current Lua Version
lua.getVersion(); // "Lua 5.4.8" or "LuaJIT 2.1.0-beta3"
Evaluate Lua Code
lua.eval("return 2 + 2"); // 4 lua.eval('return "a", "b", "c"'); // ["a", "b", "c"]
Share Variables
// JS → Lua lua.setGlobal("user", { name: "Alice", age: 30 }); // Lua → JS lua.eval("config = { debug = true, port = 8080 }"); lua.getGlobal("config"); // { debug: true, port: 8080 } lua.getGlobal("config.port"); // 8080 lua.getGlobal("config.missing"); // undefined (path exists but value is missing) lua.getGlobal("missing"); // null (global variable does not exist)
Call Functions Both Ways
// Call Lua from JS lua.eval("function add(a, b) return a + b end"); const add = lua.getGlobal("add"); add(5, 7); // 12 // Call JS from Lua lua.setGlobal("add", (a, b) => a + b); lua.eval("return add(3, 4)"); // 12 // JS function with multiple returns lua.setGlobal("getUser", () => ["Alice", 30]); lua.eval("name, age = getUser()"); lua.getGlobal("name"); // "Alice" lua.getGlobal("age"); // 30 // JS function that throws an error lua.setGlobal("throwError", () => { throw new Error("Something went wrong"); }); const [success, err] = lua.eval(` local success, err = pcall(throwError); return success, err `); success; // false err.message; // "Something went wrong"
Get Table Length
lua.eval("items = { 1, 2, 3 }"); lua.getLength("items"); // 3
File Execution
-- config.lua return { title = "My App", features = { "auth", "api", "db" } }
const config = lua.evalFile("config.lua"); config.title; // "My App"
Lua Errors
// All errors are instances of LuaError // Syntax error try { lua.eval("return 1+"); } catch (err) { err instanceof LuaError; // true err.message; // [string "return 1+"]:1: unexpected symbol near <eof> } // String error try { lua.eval('error("foo")'); } catch (err) { err.message; // [string "error(\"foo\")"]:1: foo err.stack; // Lua-style stack trace (not a JavaScript stack) } // Table error (non-string) try { lua.eval('error({ foo = "bar" })'); } catch (err) { err.message; // "" err.cause; // { foo: "bar" } }
All Lua operations in lua-state are synchronous by design. The Lua VM runs in the same thread as JavaScript, providing predictable and fast execution. For asynchronous I/O, consider isolating Lua VMs in worker threads.
awaitis not required and not part of API - calls likelua.eval()block until completion- Lua coroutines work normally within Lua, but are not integrated with the JavaScript event loop
- Asynchronous bridging between JS and Lua is intentionally avoided to keep the API simple, deterministic, and predictable.
⚠️ Note: Lua 5.1 and LuaJIT have a small internal C stack, which may cause stack overflows when calling JS functions in very deep loops. Lua 5.1.1+ uses a larger stack and does not have this limitation.
Represents an isolated, synchronous Lua VM instance.
new LuaState(options?: { libs?: string[] | null // Libraries to load, use null or empty array to load none (default: all) })
Available libraries: base, bit32, coroutine, debug, io, math, os, package, string, table, utf8
Methods
| Method | Returns | Description |
|---|---|---|
eval(code) |
LuaValue |
Execute Lua code |
evalFile(path) |
LuaValue |
Run Lua file |
setGlobal(name, value) |
this |
Set global variable |
getGlobal(path) |
LuaValue | null | undefined |
Get global value |
getLength(path) |
number | null | undefined |
Get length of table |
getVersion() |
string |
Get Lua version |
close() |
void |
Close Lua VM |
⚠️ Note onclose():
Lua VM memory is not managed by the JavaScript garbage collector.
It is recommended to callclose()when the instance is no longer needed to avoid holding native memory.
Callingclose()multiple times has no effect.
Any method call afterclose()will throw an error.
Errors thrown from Lua are represented as LuaError instances.
Properties
| Property | Type | Description |
|---|---|---|
name |
"LuaError" |
Error name |
message |
string |
Error message (empty if a non-string value was passed to error(...)) |
stack |
string | undefined |
Lua stack traceback (not a JavaScript stack trace) |
cause |
unknown | undefined |
Value passed to error(...) when it is not a string |
When values are passed between JavaScript and Lua, they’re automatically converted according to the tables below. Circular references are preserved during conversion.
| JavaScript Type | Becomes in Lua | Notes |
|---|---|---|
string |
string |
UTF-8 encoded |
number |
number |
64-bit double precision |
boolean |
boolean |
|
date |
number |
Milliseconds since Unix epoch (not converted back to Date) |
undefined |
nil |
|
null |
nil |
|
function |
function |
Callable from Lua |
object |
table |
Recursively copies enumerable fields. Non-enumerable properties are ignored |
array |
table |
Indexed from 1 in Lua |
bigint |
string |
| Lua Type | Becomes in JavaScript | Notes |
|---|---|---|
string |
string |
UTF-8 encoded |
number |
number |
64-bit double precision |
boolean |
boolean |
|
nil |
null |
|
table |
object |
Converts to POJO (array-like tables are NOT converted to JavaScript arrays) |
function |
function |
Callable from JS |
⚠️ Note: Conversion is not always symmetrical - for example,
a JSDatebecomes a number in Lua, but that number won’t automatically
convert back into aDatewhen returned to JS.
⚠️ When Lua returns multiple values, they are returned as an array in JavaScript.
This package provides full type definitions for all APIs.
You can optionally specify the expected Lua value type for stronger typing and auto-completion:
import { LuaState } from "lua-state"; const lua = new LuaState(); const anyValue = lua.eval("return { x = 1 }"); // LuaValue | undefined const numberValue = lua.eval<number>("return 42"); // number
build
If you need to rebuild with a different Lua version or use your system Lua installation, you can do it with the included CLI tool:
npx lua-state build [options]
Options:
The build system is based on node-gyp and supports flexible integration with existing Lua installations.
| Option | Description | Default |
|---|---|---|
-m, --mode |
download, official, source, system |
download |
--skip-if-exists |
Skip build if a binary already exists | false |
-v, --version |
Lua version for download build |
5.4.8 |
--source-dir, --include-dirs, --libraries |
Custom paths for source/system builds |
- |
--prebuild [path] |
Build into standard prebuilds/ layout |
- |
--out <path> |
Copy binary to an exact path (low-level) | - |
Examples:
# Rebuild Lua 5.2.4 from official sources npx lua-state build --mode=official --version=5.2.4 # Build into prebuilds/ structure for the current platform npx lua-state build --mode=source --source-dir=deps/lua-5.2.1/src --prebuild # Build into a custom base directory (instead of cwd) npx lua-state build --mode=source --source-dir=deps/lua-5.2.1/src --prebuild ./dist # Copy binary to an exact path npx lua-state build --out ./prebuilds/linux-x64/lua-state.glibc.node # Rebuild with system Lua npx lua-state build --mode=system --libraries=-llua5.4 --include-dirs=/usr/include/lua5.4 # Rebuild with system or prebuilt LuaJIT npx lua-state build --mode=system --libraries=-lluajit-5.1 --include-dirs=/usr/include/luajit-2.1 # Rebuild with custom lua sources npx lua-state build --mode=source --source-dir=deps/lua-5.1/src # Skip the build if a binary already exists (used by the npm install hook) npx lua-state build --skip-if-exists
💡
--prebuildgenerates the standardprebuilds/{platform}-{arch}/lua-state[.glibc|.musl].nodepath for the current platform (Linux binaries get aglibc/musltag). The result is compatible withnode-gyp-buildandprebuildify. Has priority over--out.
💡 By default
buildalways produces a fresh binary.--skip-if-existsonly skips the rebuild whenbuild/Release/lua-state.nodeis already present;--out/--prebuildstill copy the existing binary.
💡 In
downloadmode, if no prebuilt binary exists for your platform/architecture/Lua version, the build automatically falls back toofficialmode - it downloads the matching Lua sources and compiles them (requires a C++ toolchain).
⚠️ Note: LuaJIT builds are only supported insystemmode (cannot be built from source).
run
Run a Lua script file or code string with the CLI tool:
npx lua-state run [file]
Options:
| Option | Description | Default |
|---|---|---|
-c, --code <code> |
Lua code to run as string | - |
--json |
Output result as JSON | false |
-s, --sandbox [level] |
Run in sandbox mode (light, strict) |
- |
Examples:
# Run a Lua file npx lua-state run script.lua # Run Lua code from string npx lua-state run --code "print('Hello, World!')" # Run and output result as JSON npx lua-state run --code "return { name = 'Alice', age = 30 }" --json # Run in sandbox mode (light restrictions) npx lua-state run --sandbox light script.lua # Run in strict sandbox mode (heavy restrictions) npx lua-state run --sandbox strict script.lua
Most downstream packages use lua-state only during the build - to generate schemas, serialize data, or run code-generation against the Lua runtime - so install it as a devDependency. Nothing native ships to consumers: the published artifact is plain generated output, with no binary and no runtime dependency on lua-state.
If your deliverable also embeds the binary at runtime (e.g. a custom Lua version), follow the steps below: build with --prebuild, load through node-gyp-build, and forward the copied types.
Use the build command with --prebuild to compile a specific Lua version from official sources into the standard prebuilds/ layout for the current platform:
{
"scripts": {
"build:lua": "lua-state build --mode=official --version=5.2.1 --prebuild"
}
}For a vendored/patched Lua source tree use --mode=source --source-dir=deps/lua-5.2.1/src; LuaJIT requires --mode=system (see CLI notes).
This produces prebuilds/{platform}-{arch}/lua-state[.glibc|.musl].node, compatible with what node-gyp-build expects at load time.
Since the generated layout matches what node-gyp-build expects, loading is a one-liner (__dirname is the package root where package.json and prebuilds/ live):
// index.js module.exports = require("node-gyp-build")(__dirname);
node-gyp-build returns any, so forward the copied declarations to keep full typing. Wire everything through a src/lua-state/ module:
src/lua-state/
├── lua-state.d.ts # copied from lua-state types
└── index.ts # loads the binary + forwards types
1. Copy the self-contained declarations (the main case is lua-state in devDependencies):
cp node_modules/lua-state/types/lua-state.d.ts src/lua-state/
⚠️ tscdoes not emit.d.tsfiles, so copy the declaration into your build output as well. Otherwiseexport type * from './lua-state'breaks for consumers of the published package:
"scripts": { "build": "tsc && cp src/lua-state/lua-state.d.ts dist/lua-state/" }
2. Load the binary and cast it to the module's shape (path.resolve(__dirname, '..', '..') is the package root where node-gyp-build looks for prebuilds/):
// src/lua-state/index.ts const path = require("node:path"); import type * as LuaTypes from "./lua-state"; const binding = require("node-gyp-build")( path.resolve(__dirname, "..", ".."), ) as typeof LuaTypes; export const LuaState = binding.LuaState; export const LuaError = binding.LuaError; export type LuaState = LuaTypes.LuaState; export type LuaError = LuaTypes.LuaError; export type * from "./lua-state";
The explicit export type aliases merge the class types with the local const exports; export type * forwards the remaining type-only exports (LuaStateOptions, LuaPrimitive, LuaValue, LuaFunction, LuaTable, LuaLibName).
Consumers get a fully typed API: import { LuaState, LuaError, type LuaStateOptions } from '../lua-state'.
For distributing a single consolidated .d.ts, pipe the declarations through @microsoft/api-extractor.
These variables can be used for CI/CD or custom build scripts.
| Variable | Description | Default |
|---|---|---|
LUA_STATE_MODE |
Build mode (download, official, source, system) |
download |
LUA_STATE_SKIP_IF_EXISTS |
Skip build if binary already exists | false |
LUA_VERSION |
Lua version (for download mode) |
5.4.8 |
LUA_SOURCE_DIR |
Lua source path (for source mode) |
- |
LUA_INCLUDE_DIRS |
Include directories (for system mode) |
- |
LUA_LIBRARIES |
Library paths (for system mode) |
- |
LUA_STATE_PREBUILD |
Build into standard prebuilds/ layout |
- |
LUA_STATE_OUT |
Copy binary to an exact path (low-level) | - |
| Package | Lua versions | TypeScript | API Style | Notes |
|---|---|---|---|---|
| fengari | 5.2 (WASM) | ❌ | Pure JS | Browser-oriented, slower |
| lua-in-js | 5.3 (JS interpreter) | ✅ | Pure JS | No native performance |
| wasmoon | 5.4 (WASM) | ✅ | Async/Promise | Node/Browser compatible |
| node-lua | 5.1 | ❌ | Native (legacy NAN) | Outdated, Linux-only |
| lua-native | 5.4 (N-API) | ✅ | Native N-API | Active project, no multi-version support |
| lua-state | 5.1–5.5, LuaJIT | ✅ | Native N-API | Multi-version, prebuilt binaries, modern API |
lua-state uses native N-API bindings and provides low-overhead communication between JavaScript and Lua.
Performance depends heavily on the type of data being exchanged:
- Primitive values are extremely fast
- Flat objects are moderately fast
- Deep or large object graphs are significantly more expensive to serialize
To run the benchmark locally:
npm run bench
Each native binary is built and tested automatically before release.
The test suite runs JavaScript integration tests to ensure stable behavior across supported systems.
MIT License © quaternion