A native Node.js module for embedding Lua in your applications. This module provides seamless integration between JavaScript and Lua, allowing you to execute Lua scripts, pass functions between environments, and handle complex data structures.
- Node.js
- Bun
- Deno
- Execute Lua scripts and files from Node.js, Bun or Deno
- Pass JavaScript functions to Lua as callbacks
- Bidirectional data exchange (numbers, strings, booleans, objects, arrays)
- Global variable management (get and set)
- Userdata support — pass JavaScript objects to Lua by reference with optional property access and method binding
- Metatable support — attach metatables to Lua tables from JavaScript for operator overloading, custom indexing, and more
- Reference-based tables — metatabled tables returned from Lua are wrapped in JS Proxy objects, preserving metamethods across the boundary
- Table reference API — create, read, write, and iterate Lua tables directly from JavaScript with
create_table()andget_global_ref() - Module / require integration — register JS modules and add search paths for Lua's
require() - Opt-in standard library loading with
'all','safe', and per-library presets - Bytecode precompilation — compile Lua to bytecode with
compile(), load withload_bytecode()for faster startup - Async execution via
execute_script_async/execute_file_async— runs Lua on worker threads, returns Promises - Memory limits — cap Lua memory usage with
maxMemoryoption, monitor withget_memory_usage() - Coroutine support with yield/resume semantics
- Comprehensive error handling
- Cross-platform support (Windows, macOS)
- TypeScript support with full type definitions
npm install lua-nativeNOTE: Prebuilt binaries are currently available for macOS (Apple Silicon/arm64). Intel Mac and Windows users will need to build from source. Linux has not been tested.
NOTE: The prebuilt binaries include Lua 5.5 (pre-release). If you need a different Lua version, you will need to build from source.
- Node.js (v18 or later recommended)
- Python (required by node-gyp)
- VCPKG with Lua installed (
vcpkg install lua) - vcpkg.io - Windows: Visual Studio 2022 or Build Tools with C++ workload
- macOS: Xcode Command Line Tools (
xcode-select --install)
NOTE: There are two ways to build this module. You can use traditional
bindings.gyp along with node-gyp or you can use cmake on
Windows and macOS. Linux has not been tested.
# Debug build
npm run build-debug
# Release build
npm run build-releaseHello World:
import lua_native from "lua-native";
// Create a new Lua context
const lua = new lua_native.init({
print: (msg) => {
console.log(msg);
},
});
// Execute a simple script
lua.execute_script('print("Hello, World!")');Return a value:
import lua_native from "lua-native";
// Create a new Lua context (no callbacks or options needed)
const lua = new lua_native.init();
// Execute a simple script
const result = lua.execute_script("return 42");
console.log(result); // 42Execute Lua files directly instead of passing script strings:
import lua_native from "lua-native";
const lua = new lua_native.init({
greet: (name) => `Hello, ${name}!`,
});
// Execute a Lua file
const result = lua.execute_file("./scripts/init.lua");
console.log(result);Return values, globals, and callbacks all work exactly as with execute_script:
// scripts/math.lua:
// return 6 * 7
const answer = lua.execute_file("./scripts/math.lua");
console.log(answer); // 42
// scripts/setup.lua:
// config = { debug = true, level = 3 }
lua.execute_file("./scripts/setup.lua");
console.log(lua.get_global("config")); // { debug: true, level: 3 }Errors (file not found, syntax errors, runtime errors) throw JavaScript exceptions:
try {
lua.execute_file("./nonexistent.lua");
} catch (error) {
console.error(error.message); // "cannot open ./nonexistent.lua: No such file or directory"
}import lua_native from "lua-native";
// Create context with JavaScript function
const lua = new lua_native.init({
add: (a, b) => a + b,
});
// Call the JavaScript function from Lua
const result = lua.execute_script("return add(2, 3)");
console.log(result); // 5import lua_native from "lua-native";
const lua = new lua_native.init();
// Set global variables
lua.set_global("x", 7);
lua.set_global("times2", (n) => n * 2);
// Use globals in Lua script
const [a, b] = lua.execute_script("return x, times2(x)");
console.log(a, b); // 7, 14
// Read globals back from Lua
lua.execute_script("y = x * 3");
console.log(lua.get_global("y")); // 21The module supports converting complex Lua tables to JavaScript objects:
import lua_native from "lua-native";
const lua = new lua_native.init({
greet: (name) => `Hello, ${name}!`,
});
const result = lua.execute_script(`
local t = {
numbers = {1, 2, 3},
flags = { on = true, off = false },
msg = greet('World')
}
return t
`);
console.log(result);
// {
// numbers: [1, 2, 3],
// flags: { on: true, off: false },
// msg: 'Hello, World!'
// }Lua functions can be returned to JavaScript and called directly:
import lua_native from "lua-native";
const lua = new lua_native.init();
// Return a Lua function
const add = lua.execute_script(`
return function(a, b)
return a + b
end
`);
console.log(add(5, 3)); // 8
// Closures work too
const makeCounter = lua.execute_script(`
return function(start)
local count = start or 0
return function()
count = count + 1
return count
end
end
`);
const counter = makeCounter(10);
console.log(counter()); // 11
console.log(counter()); // 12Lua errors are automatically converted to JavaScript exceptions:
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
try {
lua.execute_script("error('Something went wrong')");
} catch (error) {
console.error("Lua error:", error.message);
// Output: Lua error: [string "error('Something went wrong')"]:1: Something went wrong
}Errors from JavaScript callbacks include the function name and original error message:
const lua = new lua_native.init(
{
riskyOperation: () => {
throw new Error("Database connection failed");
},
},
{ libraries: "all" },
);
try {
lua.execute_script("riskyOperation()");
} catch (error) {
console.error(error.message);
// Output: Host function 'riskyOperation' threw an exception: Database connection failed
}By default, new lua_native.init() creates a bare Lua state with no
standard libraries loaded. You opt in to the libraries you need via the
libraries option.
The 'all' preset loads all 10 standard libraries — equivalent to the previous
default behavior:
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
lua.execute_script('print(string.upper("hello"))'); // "HELLO"
lua.execute_script("print(math.floor(3.7))"); // 3
lua.execute_script("print(os.clock())"); // worksThe 'safe' preset loads everything except io, os, and debug — ideal for
running untrusted scripts:
const sandbox = new lua_native.init({}, { libraries: "safe" });
sandbox.execute_script('print(string.upper("hello"))'); // "HELLO"
sandbox.execute_script("print(math.floor(3.7))"); // 3
sandbox.execute_script("print(type(io))"); // "nil" — io is not loaded
sandbox.execute_script("print(type(os))"); // "nil" — os is not loaded
sandbox.execute_script("print(type(debug))"); // "nil" — debug is not loadedYou can also pass an explicit array of library names:
// Only load base, string, and math
const lua = new lua_native.init(
{},
{
libraries: ["base", "string", "math"],
},
);
lua.execute_script('print(string.upper("hello"))'); // "HELLO"
lua.execute_script("print(math.floor(3.7))"); // 3
lua.execute_script("print(type(io))"); // "nil" — io is not loadedOmitting libraries (or omitting all arguments entirely) creates a bare Lua state
with no standard libraries at all:
const bare = new lua_native.init();
// Basic Lua still works (arithmetic, strings, return)
bare.execute_script("return 1 + 2"); // 3
// But no standard functions are available
// bare.execute_script('print("hi")') -- ERROR: 'print' is nilAvailable library names: base, package, coroutine, table, io, os,
string, math, utf8, debug.
Available presets: 'all' (all 10 libraries), 'safe' (all except io, os,
debug).
Cap the total memory a Lua state can allocate, preventing untrusted scripts from crashing the host process:
import lua_native from "lua-native";
// Limit Lua to 10 MB of memory
const lua = new lua_native.init({}, {
libraries: "safe",
maxMemory: 10 * 1024 * 1024,
});
// Normal scripts work fine within the limit
lua.execute_script("local t = {}; for i = 1, 1000 do t[i] = i end");
// Scripts that exceed the limit throw an out-of-memory error
try {
lua.execute_script("local s = string.rep('x', 20 * 1024 * 1024)");
} catch (error) {
console.error(error.message); // "not enough memory"
}
// The context remains usable after an OOM error
lua.execute_script("return 1 + 1"); // 2Monitor memory usage with get_memory_usage():
const lua = new lua_native.init({}, { libraries: "all" });
console.log(lua.get_memory_usage()); // bytes currently allocated by Lua
lua.execute_script("big = string.rep('x', 100000)");
console.log(lua.get_memory_usage()); // increased after allocationMemory tracking works even without maxMemory — every Lua context tracks its
memory usage automatically.
Register JavaScript objects as Lua modules available via require(), or add
filesystem search paths for Lua module loading. Requires the package library.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// Register a JS object as a Lua module
lua.register_module("utils", {
clamp: (x, min, max) => Math.min(Math.max(x, min), max),
lerp: (a, b, t) => a + (b - a) * t,
version: "1.0.0",
});
// Use it from Lua with require()
const result = lua.execute_script(`
local utils = require('utils')
return utils.clamp(15, 0, 10), utils.version
`);
console.log(result); // [10, '1.0.0']Modules are pre-loaded into package.loaded — no filesystem search occurs.
Functions in the module become callable from Lua, and plain values (strings,
numbers, booleans) are set directly.
// Add filesystem search paths for Lua's require()
lua.add_search_path("./lua_modules/?.lua");
lua.add_search_path("./libs/?/init.lua");
// Lua can now require modules from those directories
lua.execute_script(`
local mymod = require('mymod') -- searches ./lua_modules/mymod.lua
print(mymod.name)
`);The path must contain a ? placeholder that gets replaced by the module name.
// Mix filesystem modules with JS-registered modules
lua.add_search_path("./scripts/?.lua");
lua.register_module("config", {
debug: true,
maxRetries: 3,
});
lua.execute_script(`
local config = require('config') -- from JS
local helpers = require('helpers') -- from ./scripts/helpers.lua
if config.debug then
print(helpers.format_debug())
end
`);By default, all Lua execution blocks the Node.js event loop. The async methods run Lua on a worker thread and return Promises, keeping the event loop free for other work.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// Non-blocking execution
const result = await lua.execute_script_async("return 6 * 7");
console.log(result); // 42
// File execution
const fileResult = await lua.execute_file_async("./scripts/heavy.lua");Run multiple independent contexts concurrently with Promise.all():
const contexts = [1, 2, 3, 4].map(
() => new lua_native.init({}, { libraries: "all" }),
);
const results = await Promise.all(
contexts.map((lua, i) => lua.execute_script_async(`return ${i + 1} * 10`)),
);
console.log(results); // [10, 20, 30, 40]Error handling works with standard try/catch:
try {
await lua.execute_script_async("error('something failed')");
} catch (error) {
console.error(error.message); // includes "something failed"
}Important: JS callbacks registered on the context are not available during async execution. Calling a registered JS function from async Lua code will reject the promise with a clear error:
const lua = new lua_native.init(
{
greet: () => "hello",
},
{ libraries: "all" },
);
// This will reject — JS callbacks can't run on the worker thread
await lua.execute_script_async("return greet()"); // Error: "JS callbacks are not available in async mode"
// Workaround: set up data before async, compute in Lua
lua.set_global("name", "World");
const result = await lua.execute_script_async(
"return 'Hello, ' .. name .. '!'",
);Compile Lua source to bytecode and load it later for faster startup. Bytecode skips the parsing and compilation phases on subsequent loads.
import lua_native from "lua-native";
import fs from "fs";
const lua = new lua_native.init({}, { libraries: "all" });
// Compile source to bytecode (returns a Buffer)
const bytecode = lua.compile("return function(x) return x * 2 end");
// Save to disk for later
fs.writeFileSync("my-script.luac", bytecode);
// Load and execute bytecode (identical result to execute_script)
const fn = lua.load_bytecode(bytecode);
fn(21); // 42Compile files directly:
const bytecode = lua.compile_file("./scripts/init.lua");
lua.load_bytecode(bytecode);Strip debug information for smaller production bytecode:
const devBuild = lua.compile(source);
const prodBuild = lua.compile(source, { stripDebug: true });
console.log(`Dev: ${devBuild.length} bytes, Prod: ${prodBuild.length} bytes`);Bytecode is portable across Lua contexts (same Lua version and architecture):
// Compile once, run in multiple independent contexts
const compiler = new lua_native.init();
const bytecode = compiler.compile("return function(x) return x * x end");
const ctx1 = new lua_native.init();
const ctx2 = new lua_native.init();
const square1 = ctx1.load_bytecode(bytecode);
const square2 = ctx2.load_bytecode(bytecode);
square1(5); // 25
square2(7); // 49- Binary-only loading —
load_bytecode()uses Lua's binary-only mode ("b"), rejecting raw source text. Useexecute_script()for source strings. - Version-specific — Lua bytecode encodes the Lua version, endianness, and pointer size. Bytecode from a different Lua version or architecture will fail with a clear error.
- No integrity checks — Lua bytecode has no built-in tamper protection. Malformed bytecode can crash the Lua VM. If bytecode comes from an untrusted source, verify its integrity (e.g., via checksum or signature) before loading.
- Strip debug info for production — Use
{ stripDebug: true }to remove local variable names and line numbers, producing smaller bytecode that doesn't leak source structure.
Lua coroutines are supported, allowing you to create pausable/resumable functions:
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// Create a coroutine from a function
const coro = lua.create_coroutine(`
return function(x)
coroutine.yield(x * 2)
coroutine.yield(x * 3)
return x * 4
end
`);
// Resume with initial argument
let result = lua.resume(coro, 10);
console.log(result.status); // 'suspended'
console.log(result.values); // [20]
// Continue resuming
result = lua.resume(coro);
console.log(result.values); // [30]
result = lua.resume(coro);
console.log(result.status); // 'dead'
console.log(result.values); // [40]Coroutines can receive values on each resume:
const coro = lua.create_coroutine(`
return function()
local a = coroutine.yield("first")
local b = coroutine.yield("second")
return a + b
end
`);
let result = lua.resume(coro);
console.log(result.values); // ['first']
result = lua.resume(coro, 10);
console.log(result.values); // ['second']
result = lua.resume(coro, 20);
console.log(result.values); // [30]Generator pattern with coroutines:
const squares = lua.create_coroutine(`
return function(n)
for i = 1, n do
coroutine.yield(i * i)
end
end
`);
// Generate squares from 1 to 5
let result = lua.resume(squares, 5);
const values = [result.values[0]];
while (result.status === "suspended") {
result = lua.resume(squares);
values.push(result.values[0]);
}
console.log(values); // [1, 4, 9, 16, 25]JavaScript objects can be passed into Lua as userdata — Lua holds a reference to the original object, not a copy. When the userdata flows back to JavaScript (through callbacks or return values), the original object is returned.
By default, userdata is opaque — Lua can pass it around but cannot read or modify its properties:
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
const connection = { host: "localhost", port: 5432, connected: true };
lua.set_userdata("db", connection);
// Lua can pass the handle to JavaScript callbacks
lua.set_global("useConnection", (conn) => {
console.log(conn === connection); // true — same object
console.log(conn.host); // 'localhost'
});
lua.execute_script("useConnection(db)");You can grant Lua read and/or write access to the object's properties:
const player = { name: "Alice", health: 100, score: 0 };
// Read-only access
lua.set_userdata("player", player, { readable: true });
lua.execute_script(`
print(player.name) -- "Alice"
print(player.health) -- 100
`);
// Read-write access
lua.set_userdata(
"state",
{ lives: 3, level: 1 },
{ readable: true, writable: true },
);
lua.execute_script(`
state.level = state.level + 1
state.lives = state.lives - 1
`);
console.log(lua.get_global("state")); // Changes are visible in JSUserdata created by Lua libraries (e.g., io.open() file handles) can pass
through JavaScript callbacks and back to Lua without losing their identity:
const lua = new lua_native.init(
{
processFile: (fileHandle) => {
// fileHandle is opaque to JS, but can be returned to Lua
return fileHandle;
},
},
{ libraries: "all" },
);
lua.execute_script(`
local f = io.tmpfile()
local f2 = processFile(f)
f2:write("hello")
f2:seek("set")
print(f2:read("*a")) -- "hello"
f2:close()
`);You can register JavaScript functions as methods on userdata, callable from Lua
using the : method syntax (obj:method(args)). Methods receive the original
JavaScript object as the first argument (self), followed by any Lua-provided
arguments.
const player = { x: 0, y: 0, name: "Alice" };
lua.set_userdata("player", player, {
readable: true,
methods: {
move: (self, dx, dy) => {
self.x += dx;
self.y += dy;
},
get_pos: (self) => [self.x, self.y],
greet: (self) => `Hello, I'm ${self.name}!`,
},
});
lua.execute_script(`
player:move(10, 20)
local x, y = player:get_pos()
print(x, y) -- 10 20
print(player:greet()) -- Hello, I'm Alice!
print(player.name) -- Alice (property access still works)
`);Methods work independently of readable/writable — you can have methods on an
otherwise opaque handle:
const handle = { secret: 42 };
lua.set_userdata("handle", handle, {
methods: {
get_value: (self) => self.secret,
},
});
lua.execute_script(`
print(handle:get_value()) -- 42
print(handle.secret) -- nil (not readable)
`);When both methods and properties exist, methods take precedence over properties with the same name:
const obj = { info: "property" };
lua.set_userdata("obj", obj, {
readable: true,
methods: {
info: (self) => "method result",
},
});
lua.execute_script(`
print(obj:info()) -- "method result" (method wins)
`);You can attach Lua metatables to global tables from JavaScript, enabling operator
overloading, custom tostring, callable tables, custom indexing, and more.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// Create two vector tables in Lua
lua.execute_script("v1 = {x = 1, y = 2}; v2 = {x = 10, y = 20}");
// Attach a metatable with __tostring and __add
lua.set_metatable("v1", {
__tostring: (t) => `(${t.x}, ${t.y})`,
__add: (a, b) => ({ x: a.x + b.x, y: a.y + b.y }),
});
lua.execute_script("print(tostring(v1))"); // (1, 2)
const sum = lua.execute_script("return v1 + v2");
console.log(sum); // { x: 11, y: 22 }const lua = new lua_native.init();
lua.execute_script("multiplier = {factor = 10}");
lua.set_metatable("multiplier", {
__call: (self, x) => self.factor * x,
});
const result = lua.execute_script("return multiplier(5)");
console.log(result); // 50__index can be a function (for computed lookups) or a table (for fallback values):
const lua = new lua_native.init();
// __index as a function — compute missing keys dynamically
lua.execute_script("obj = {}");
lua.set_metatable("obj", {
__index: (table, key) => `default_${key}`,
});
console.log(lua.execute_script("return obj.color")); // 'default_color'
// __index as a table — static fallback values
lua.execute_script("config = {}");
lua.set_metatable("config", {
__index: { timeout: 30, retries: 3 },
});
console.log(lua.execute_script("return config.timeout")); // 30const lua = new lua_native.init();
const log = [];
lua.execute_script("protected = {x = 1}");
lua.set_metatable("protected", {
__newindex: (table, key, value) => {
log.push(`blocked write: ${key} = ${value}`);
// Not calling rawset, so the write is silently dropped
},
});
lua.execute_script("protected.y = 42");
console.log(log); // ['blocked write: y = 42']
console.log(lua.execute_script("return protected.y")); // null (write was intercepted)| Metamethod | Lua Trigger | Description |
|---|---|---|
__tostring |
tostring(t) |
Custom string representation |
__add |
a + b |
Addition |
__sub |
a - b |
Subtraction |
__mul |
a * b |
Multiplication |
__div |
a / b |
Division |
__mod |
a % b |
Modulo |
__unm |
-a |
Unary minus |
__concat |
a .. b |
String concatenation |
__len |
#a |
Length operator |
__eq |
a == b |
Equality (both need metatable) |
__lt |
a < b |
Less than (both need metatable) |
__le |
a <= b |
Less or equal (both need metatable) |
__call |
t(args) |
Calling table as function |
__index |
t.key (missing) |
Custom read (function or table) |
__newindex |
t.key = val (new) |
Custom write interception |
When a Lua table has a metatable, it is returned to JavaScript as a Proxy object
instead of being deep-copied. This preserves all metamethods — __index,
__newindex, __tostring, __add, __call, etc. — so they fire naturally when
you access the object from JavaScript. Plain tables (no metatable) are still
deep-copied as before.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
const obj = lua.execute_script(`
local t = {}
setmetatable(t, {
__index = function(_, key) return key:upper() end
})
return t
`);
console.log(obj.hello); // "HELLO" — __index fires through the Proxy
console.log(obj.world); // "WORLD"const vec = lua.execute_script(`
local v = {x = 10, y = 20}
setmetatable(v, {})
return v
`);
console.log(vec.x); // 10
vec.x = 99; // sets via Lua (triggers __newindex if defined)
console.log(vec.x); // 99console.log(Object.keys(vec)); // ['x', 'y']
console.log("x" in vec); // true
console.log("z" in vec); // falseProxy objects passed back to Lua restore the original metatabled table, so metamethods continue to work:
lua.set_global("inspect", (tbl) => {
console.log(tbl.x); // access via Proxy
return tbl; // return to Lua — original table restored
});
lua.execute_script(`
local t = {x = 42}
setmetatable(t, {
__tostring = function(self) return "x=" .. self.x end
})
local t2 = inspect(t)
print(tostring(t2)) -- "x=42" — metamethods preserved after round-trip
`);All metamethods work when the Proxy is passed back to Lua:
lua.execute_script(`
v1 = {x = 1, y = 2}
v2 = {x = 10, y = 20}
`);
lua.set_metatable("v1", {
__add: (a, b) => {
// Return a new metatabled table
return lua.execute_script(
`local r = {x = ${a.x + b.x}, y = ${a.y + b.y}}; setmetatable(r, getmetatable(v1)); return r`,
);
},
__tostring: (t) => `(${t.x}, ${t.y})`,
});
// v1 + v2 triggers __add, tostring() triggers __tostring
lua.execute_script("print(tostring(v1 + v2))"); // (11, 22)Tables without metatables continue to deep-copy as before — no behavior change:
const plain = lua.execute_script("return {a = 1, b = 2}");
// plain is a regular JS object: { a: 1, b: 2 }The table reference API lets you create, read, write, and iterate Lua tables
directly from JavaScript without round-tripping through execute_script. Table
handles hold a live reference to the Lua table — mutations from JS are visible in
Lua and vice versa.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// Create an empty table
const t = lua.create_table();
// Create with an object initializer (string keys)
const point = lua.create_table({ x: 10, y: 20 });
// Create with an array initializer (1-indexed in Lua)
const list = lua.create_table([100, 200, 300]);const t = lua.create_table();
t.set("name", "Alice");
t.set("score", 95);
t.set(1, "first");
console.log(t.get("name")); // 'Alice'
console.log(t.get("score")); // 95
console.log(t.get(1)); // 'first'
console.log(t.get("missing")); // null
console.log(t.has("name")); // true
console.log(t.has("missing")); // false
console.log(t.length()); // 1 (sequence length — the # operator)const t = lua.create_table({ a: 1, b: 2, c: 3 });
// pairs() — all key-value pairs (like Lua pairs())
for (const [key, value] of t.pairs()) {
console.log(key, value); // 'a' 1, 'b' 2, 'c' 3
}
// ipairs() — integer sequence from 1 (like Lua ipairs())
const list = lua.create_table([10, 20, 30]);
for (const [index, value] of list.ipairs()) {
console.log(index, value); // 1 10, 2 20, 3 30
}get_global_ref() returns a live handle to an existing global table. Changes
through the handle are immediately visible in Lua, and Lua-side changes are
visible through the handle:
lua.execute_script('config = { host = "localhost", port = 5432, debug = false }');
const config = lua.get_global_ref("config");
console.log(config.get("host")); // 'localhost'
// Modify from JS — visible in Lua
config.set("debug", true);
lua.execute_script("print(config.debug)"); // true
// Modify from Lua — visible in JS
lua.execute_script("config.port = 3306");
console.log(config.get("port")); // 3306
config.release(); // free the registry reference when doneTable handles can be passed to set_global() to make them accessible from Lua:
const player = lua.create_table({ name: "Alice", hp: 100 });
lua.set_global("player", player);
lua.execute_script('print(player.name)'); // Alice
player.release();Call release() when you're done with a handle to free the Lua registry slot.
After release, all methods throw:
const t = lua.create_table();
t.set("x", 1);
t.release();
// t.get('x'); // Error: table handle has been released
// t.release(); // safe — double release is a no-opThe module includes comprehensive TypeScript definitions:
import lua_native from "lua-native";
import type {
LuaCallbacks,
LuaContext,
CompileOptions,
LuaCoroutine,
LuaInitOptions,
LuaLibraryPreset,
CoroutineResult,
LuaFunction,
LuaTableHandle,
LuaTableRef,
MetatableDefinition,
UserdataMethod,
UserdataOptions,
} from "lua-native";
// Type-safe callback definition
const callbacks: LuaCallbacks = {
add: (a: number, b: number): number => a + b,
greet: (name: string): string => `Hello, ${name}!`,
};
const lua: LuaContext = new lua_native.init(callbacks, { libraries: "all" });
const result: number = lua.execute_script("return add(10, 20)");
// Type-safe global access
lua.set_global("x", 42);
const x = lua.get_global("x"); // LuaValue
// Type-safe coroutine usage
const coro: LuaCoroutine = lua.create_coroutine(`
return function(x)
coroutine.yield(x * 2)
return x * 3
end
`);
const res: CoroutineResult = lua.resume(coro, 10);
console.log(res.status); // 'suspended' | 'dead'
console.log(res.values); // LuaValue[]
// Type-safe Lua function return
const fn = lua.execute_script<LuaFunction>(
"return function(a, b) return a + b end",
);
console.log(fn(5, 3)); // 8
// Type-safe userdata
const opts: UserdataOptions = { readable: true, writable: true };
lua.set_userdata("player", { name: "Alice", score: 0 }, opts);
// Type-safe userdata with methods
const move: UserdataMethod = (self, dx, dy) => {
self.x += dx;
self.y += dy;
};
lua.set_userdata("entity", { x: 0, y: 0 }, { methods: { move } });
// Type-safe metatable
lua.execute_script("vec = {x = 1, y = 2}");
const mt: MetatableDefinition = {
__tostring: (t) => `(${t.x}, ${t.y})`,
__unm: (t) => ({ x: -t.x, y: -t.y }),
};
lua.set_metatable("vec", mt);
// Type-safe reference-based tables
const proxy = lua.execute_script<LuaTableRef>(`
local t = {x = 1}
setmetatable(t, { __index = function(_, k) return k end })
return t
`);
console.log(proxy.x); // 1
console.log(proxy.hello); // "hello" — __index fires
// Type-safe module registration
lua.register_module("math_utils", {
clamp: (x: number, min: number, max: number): number =>
Math.min(Math.max(x, min), max),
PI: Math.PI,
});
lua.add_search_path("./lua_modules/?.lua");
const mod = lua.execute_script("return require('math_utils').clamp(15, 0, 10)");
// Type-safe bytecode precompilation
const opts: CompileOptions = { stripDebug: true, chunkName: "@my-script" };
const bytecode: Buffer = lua.compile("return 6 * 7", opts);
const answer: number = lua.load_bytecode<number>(bytecode);
const fileBytecode: Buffer = lua.compile_file("./scripts/init.lua");
lua.load_bytecode(fileBytecode);
// Type-safe memory limits
const limited: LuaContext = new lua_native.init({}, {
libraries: "safe",
maxMemory: 10 * 1024 * 1024, // 10 MB
});
const usage: number = limited.get_memory_usage();
// Type-safe library loading with presets
const preset: LuaLibraryPreset = "safe";
const sandboxed: LuaContext = new lua_native.init({}, { libraries: preset });
sandboxed.execute_script('print(string.upper("safe"))'); // "SAFE"
// Or with an explicit array
const options: LuaInitOptions = { libraries: ["base", "string", "math"] };
const custom: LuaContext = new lua_native.init({}, options);
custom.execute_script('print(string.upper("custom"))'); // "CUSTOM"
// Type-safe table reference API
const handle: LuaTableHandle = lua.create_table({ x: 1, y: 2 });
handle.set("z", 3);
const val: LuaValue = handle.get("x"); // LuaValue
const exists: boolean = handle.has("z");
const len: number = handle.length();
const entries: Array<[string | number, LuaValue]> = handle.pairs();
const seq: Array<[number, LuaValue]> = handle.ipairs();
lua.set_global("point", handle);
handle.release();
// Type-safe get_global_ref
lua.execute_script("data = { items = {1, 2, 3} }");
const ref: LuaTableHandle = lua.get_global_ref("data");
ref.set("count", 3);
ref.release();Creates a new Lua execution context. Both arguments are optional — new lua_native.init()
creates a bare Lua state with no callbacks and no standard libraries.
Parameters:
callbacks(optional): Object containing JavaScript functions and values to make available in Luaoptions(optional): Configuration object-
libraries(optional): Which standard libraries to load. If omitted, no libraries are loaded (bare state). Accepts:'all'— loads all 10 standard libraries'safe'— loads all exceptio,os, anddebugLuaLibrary[]— array of specific library names[]— bare state (no libraries)
Valid library names:
'base','package','coroutine','table','io','os','string','math','utf8','debug' -
maxMemory(optional): Maximum memory in bytes that the Lua state can allocate. When exceeded, Lua raises an out-of-memory error.0or omitted means unlimited. Memory usage is tracked even without a limit.
-
Returns: LuaContext instance
Throws: Error if an unknown library name is provided
Executes a Lua script and returns the result.
Parameters:
script: String containing Lua code to execute
Returns: The result of the script execution (converted to the appropriate JavaScript type). Tables with metatables are returned as Proxy objects that preserve metamethods; plain tables are deep-copied into objects or arrays.
Executes a Lua file and returns the result.
Parameters:
filepath: Path to the Lua file to execute
Returns: The result of the file execution (converted to the appropriate
JavaScript type), identical to execute_script. Returns undefined if the file
has no return statement.
Throws: Error if the file is not found, contains syntax errors, or encounters a runtime error.
Executes a Lua script asynchronously on a worker thread.
Parameters:
script: String containing Lua code to execute
Returns: Promise that resolves with the script result or rejects on error.
JS callbacks are not available during async execution.
Throws: Error if the context is busy with another async operation.
Executes a Lua file asynchronously on a worker thread.
Parameters:
filepath: Path to the Lua file to execute
Returns: Promise that resolves with the file result or rejects on error.
JS callbacks are not available during async execution.
Throws: Error if the context is busy with another async operation.
Returns whether the context is currently busy with an async operation.
Returns: boolean — true while an async operation is in progress, false
otherwise.
Returns the current memory usage of the Lua state in bytes.
Returns: number — bytes currently allocated by the Lua state
Appends a search path to Lua's package.path for module resolution.
Parameters:
path: Search path template containing a?placeholder (e.g.,'./modules/?.lua')
Throws: Error if the package library is not loaded, or if the path does not
contain a ? placeholder.
Registers a JavaScript object as a Lua module, making it available via
require(name). The module is pre-loaded into package.loaded — no filesystem
search occurs.
Parameters:
name: The module name used inrequire(name)module: An object whose properties become the module's fields. Functions become callable from Lua; other values are set directly.
Throws: Error if the package library is not loaded.
Sets a global variable or function in the Lua environment.
Parameters:
name: Name of the global variablevalue: Value to set (function, number, string, boolean, or object)
Gets a global variable from the Lua environment.
Parameters:
name: Name of the global variable
Returns: The value of the global (converted to JavaScript), or null if not set
Sets a JavaScript object as userdata in the Lua environment. The object is passed by reference — Lua holds a handle to the original object, not a copy.
Parameters:
name: The global variable name in Luavalue: The JavaScript object to store as userdataoptions(optional): Access control and methods for the userdatareadable: Allow Lua to read properties via__index(default:false)writable: Allow Lua to write properties via__newindex(default:false)methods: Object mapping method names to functions callable from Lua viaobj:method()syntax. Each method receives the original JS object as its first argument (self). Methods work independently ofreadable/writable.
Sets a metatable on an existing global Lua table, enabling operator overloading,
custom indexing, __tostring, __call, and other metamethods.
Parameters:
name: The name of an existing global tablemetatable: Object whose keys are metamethod names (e.g.__add,__tostring) and values are either callback functions or static Lua values
Throws: Error if the global does not exist or is not a table
Creates a coroutine from a Lua script that returns a function.
Parameters:
script: String containing Lua code that returns a function to be used as the coroutine body
Returns: LuaCoroutine object with status property ('suspended',
'running', or 'dead')
Resumes a suspended coroutine with optional arguments.
Parameters:
coroutine: TheLuaCoroutineobject to resume...args: Arguments to pass to the coroutine (received byyieldon resume, or as function arguments on first resume)
Returns: CoroutineResult object containing:
status:'suspended'|'running'|'dead'values: Array of values yielded or returned by the coroutineerror: Error message if the coroutine failed (optional)
Compiles Lua source code to bytecode without executing it.
Parameters:
script: Lua source code stringoptions(optional): Compilation settingsstripDebug: Strip debug information for smaller bytecode (default:false)chunkName: Name used in error messages (default: derived from source)
Returns: Buffer containing the compiled bytecode
Throws: Error if the source has syntax errors
Compiles a Lua file to bytecode without executing it.
Parameters:
filepath: Path to the Lua source fileoptions(optional): Same ascompile()
Returns: Buffer containing the compiled bytecode
Throws: Error if the file cannot be read or has syntax errors
Loads and executes precompiled Lua bytecode. Only accepts binary bytecode — raw
source text is rejected (use execute_script() for source).
Parameters:
bytecode:Buffercontaining Lua bytecode (fromcompile(),compile_file(), or theluaccompiler)chunkName(optional): Name for error messages (default:"bytecode"). Note: for binary bytecode, the name embedded at compile time takes precedence.
Returns: The result of executing the bytecode (converted to the appropriate
JavaScript type), identical to execute_script.
Throws: Error if the bytecode is invalid, corrupted, or from an incompatible Lua version.
Creates a new Lua table, optionally pre-populated with values. Returns a live
handle for direct manipulation without execute_script.
Parameters:
initial(optional): Initial values — a JS object for string keys, or an array for 1-indexed integer keys
Returns: LuaTableHandle — a live reference to the table
Gets a live reference to an existing global table. Unlike get_global() which
deep-copies plain tables, this returns a handle that reads and writes the actual
Lua table in place.
Parameters:
name: The global variable name
Returns: LuaTableHandle — a live reference to the table
Throws: Error if the global does not exist or is not a table
A handle to a Lua table stored in the Lua registry. Provides direct get/set/iterate
access. The handle holds a live reference — mutations from JS are visible in Lua
and vice versa. Call release() when done to free the registry slot.
Methods:
get(key: string | number): LuaValue— Get a field by key. Triggers__indexif the table has a metatable.set(key: string | number, value: LuaValue): void— Set a field by key. Triggers__newindexif the table has a metatable.has(key: string | number): boolean— Check if a key exists in the table.length(): number— Get the table length (#operator). Triggers__lenmetamethod.pairs(): Array<[string | number, LuaValue]>— Get all key-value pairs (like Luapairs()).ipairs(): Array<[number, LuaValue]>— Get integer-keyed sequence entries (like Luaipairs()). Iterates from index 1 until the first nil.release(): void— Release the registry reference. After callingrelease(), all other methods throw. Safe to call multiple times.
| Lua Type | JavaScript Type | Notes |
|---|---|---|
nil |
null |
|
boolean |
boolean |
|
number |
number |
|
string |
string |
|
table (array-like) |
Array |
Sequential numeric indices starting from 1 (no metatable) |
table (object-like) |
Object |
String or mixed keys (no metatable) |
table (metatabled) |
Proxy |
Wrapped as JS Proxy — metamethods preserved |
function |
Function |
Bidirectional: JS→Lua and Lua→JS |
thread |
LuaCoroutine |
Created via create_coroutine() |
userdata |
Object |
JS-created via set_userdata(), returned by reference. Lua-created userdata passes through as opaque handles |
- Nesting depth limit — Nested data structures (tables, arrays, objects) are limited to 100 levels deep. Exceeding this limit throws an error.
set_metatable()only for globals —set_metatable()works on global tables only. To set metatables on non-global tables, usesetmetatable()in Lua code.- Plain tables are copied, not referenced — When Lua tables without metatables are returned to JavaScript, they are converted to plain objects/arrays (deep copy). Changes to the JavaScript object do not affect the Lua table. Tables with metatables are returned as live Proxy objects that maintain a reference to the original Lua table. Use
create_table()orget_global_ref()to get live handles to plain tables.
# JavaScript/TypeScript integration tests (vitest)
npm test
# C++ unit tests (Google Test)
npm run test-cppsrc/- C++ source codelua-native.cpp- N-API binding layerlua-native.h- N-API binding headercore/lua-runtime.cpp- Lua runtime wrappercore/lua-runtime.h- Lua runtime header
tests/- Test filests/lua-native.spec.ts- TypeScript/JavaScript integration testscpp/lua-native-test.cpp- C++ unit tests
index.js- Module loader (finds and loads the native binary)index.d.ts- TypeScript entry point (re-exports fromtypes.d.ts)types.d.ts- TypeScript type definitions
https://github.com/frankhale/lua-native.git
MIT
Frank Hale <frankhale@gmail.com>
19 February 2026