lua-native
v1.1.0
Published
A native Node.js module for embedding Lua in your apps
Maintainers
Readme
lua-native
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.
Supported Runtimes
- Node.js
- Bun
- Deno
Features
- 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)
- Type-system fidelity —
BigInt,Date,Map,Set,Buffer/TypedArray, andRegExpconvert to natural Lua representations, with 64-bit integer precision preserved in both directions; register app-specific converters in both directions withregister_type_converter()andregister_from_lua_converter() - Conversion controls —
binaryStringsreturns Lua strings as exact bytes for binary protocols,tableAs: 'map'preserves table keys a JS object cannot hold, andstrictConversionturns every silent conversion loss into an error - Global variable management (get and set), including dotted paths (
set_global('config.db.host', v)) that read and auto-create nested table fields - Call Lua functions by name with
call('greet', 'world')— dotted paths included — without aget_globalround-trip - Userdata support — pass JavaScript objects to Lua by reference with optional property access and method binding
- Class / usertype binding — register a JS class with
register_class()so Lua can construct instances (Obj.new(...)), call methods, access properties, use overloaded operators, and inherit from another registered class withextends; declare class-level members withstaticsand computed or validated fields withproperties - Metatable support — attach metatables to Lua tables from JavaScript for operator overloading, custom indexing, and more, on a global name or any live table reference
- 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(), descending into nested tables by reference withget_ref() - Environment tables — give each script its own global namespace with
create_environment()/execute_script_in(), so scripts in one context can run at different permission levels - Shared state between contexts — publish one JS object as a global in several contexts with
createSharedTable()and keep them in step withset()/sync() - Reference lifecycle — explicitly free the registry reference behind a returned Lua function, coroutine, or table reference with
release(), so long-lived contexts don't accumulate Lua-side memory - Context reset —
reset()swaps in a fresh Lua state with the same options and replays your callbacks, so a long-lived process can drop accumulated global state without rebuilding the context - Explicit teardown —
dispose()tears the Lua state down for good and makes every later call refuse loudly, rather than leaving release up to the garbage collector - Module / require integration — register JS modules, add search paths, or resolve modules dynamically with a JS searcher (
add_searcher) for Lua'srequire() - Output redirection — route Lua
print()/io.write()to a JS handler viaset_print_handleror theprintoption - Input redirection and virtual files — route
io.readto a JS handler withset_read_handler(), and resolvedofile/loadfilethrough a JS callback withset_file_reader(), so a sealed context can serve files that never touch the disk - Bytecode guard —
allowBytecode: falserefuses untrusted binary chunks (blocksload_bytecodeand forcesload()to text-only) - Opt-in standard library loading with the
'all','safe'and'sandbox'presets, or an explicit list of libraries —'sandbox'is the sealed one, droppingrequire,dofile/loadfileand bytecode loading rather than just theio/os/debuglibraries - Filesystem policy —
filesystem: 'deny'closes every door Lua has to the disk in one option (dofile,loadfile, thepackagepath/cpath searchers,loadlib,io.open,os.remove, …), whilerequirekeeps working for host-registered modules - 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 - Promise-aware async via
execute_async— runs Lua as a main-thread coroutine that transparentlyawaits JS Promises returned by host functions (with working callbacks andcancel()) - Awaiting through every door —
call_async()awaits inside a function you hold (by name or as aLuaFunctionreference, with no chunk compiled per call) andresume_async()does the same for a coroutine you drive yourself - Memory limits — cap Lua memory usage with
maxMemoryoption, monitor withget_memory_usage() - State introspection —
info()returns a diagnostics snapshot: Lua version, current memory, configured limits, and loaded libraries - Debug hooks — trace Lua execution from JavaScript with
set_hook()(line, call, return, and instruction-count events) for profilers and debugger integrations, and read the call stack from inside one withget_stack()/get_locals() - GC control — trigger, pause, step, and tune Lua's collector from JavaScript with
gc(), using Lua's owncollectgarbagecommand vocabulary - Execution limits — cap Lua VM instructions with
maxInstructions, or wall-clock time withtimeout, so infinite loops abort instead of hanging - Coroutine support with yield/resume semantics — created from a script or an existing Lua function, and iterable with
for..of/for await;close()runs a suspended coroutine's pending<close>handlers, which nothing else in the API will do - Error fidelity — Lua errors carry stack tracebacks, thrown JS
Errorobjects round-trip with full fidelity (type, message, stack, custom props), andpcall()runs a function protected, returning{ ok, value/error } - Cross-platform support (Windows, macOS)
- TypeScript support with full type definitions
Installation
npm install lua-nativeRequires Node.js 20 or later. The addon is built against N-API version 8, so the binary itself will load on older releases, but 20+ is the supported and tested floor (24 LTS recommended). Bun and Deno are supported through their N-API compatibility layers.
NOTE: The supported targets are macOS (Apple Silicon/arm64) and Windows x64, and the published package ships a prebuilt binary for both — no C++ toolchain, no vcpkg, and no build step on install. Linux has not been tested.
NOTE: The prebuilt binaries include Lua 5.5.0. If you need a different Lua version, you will need to build from source.
Building from Source
On a supported target you do not need any of this — the prebuilt binary is used
automatically. Build from source to work on the addon itself, to link a
different Lua version, or to run on a platform with no prebuild. Note that this
means building from a clone: the published tarball contains prebuilds only,
so npm install lua-native on an unshipped platform reports that fact rather
than attempting a compile.
Building compiles a native N-API addon that statically links Lua, so you need a C++17 toolchain and a Lua library in addition to Node.js. Linux has not been tested.
Prerequisites at a Glance
| Dependency | Version | Why it's needed |
| ------------------------ | -------------------------- | ------------------------------------------------------------------- |
| Node.js | 20+ (24 LTS recommended) | Runtime and host for the addon; supplies npm and node-gyp |
| Python | 3.8+ | Required by node-gyp to run gyp |
| vcpkg | any recent checkout | Provides Lua headers and the static Lua library |
| Lua (via vcpkg) | 5.5.x | The embedded VM this addon links against |
| C++ toolchain | MSVC v143 / Apple Clang | Compiles the addon (C++17, exceptions and RTTI enabled) |
| Git | any | Fetching the Google Test submodule for debug builds |
| CMake | 3.20+ (optional) | Only for the alternative CMake build path |
The addon targets N-API version 8, so any Node.js ≥ 16 can load the compiled
binary. The 20+ floor is for the dev tooling — Vitest 4 requires Node
^20 || ^22 || >=24.
1. npm Dependencies
Clone the repository and install:
git clone https://github.com/frankhale/lua-native.git
cd lua-native
npm installRuntime dependencies (installed into consumers of the package too):
node-addon-api(^8.9.1) — the C++ wrapper around N-API.binding.gypasks it for its header directory withnode -p "require('node-addon-api').include", so the build fails without it even though nothing imports it from JavaScript.node-gyp-build(^4.8.4) — runs as the package'sinstallscript. On a consumer machine it resolves the prebuilt binary inprebuilds/and does nothing further. Its usual source-build fallback cannot fire: the published tarball ships prebuilds only (nosrc/, nobinding.gyp), so an unshipped platform gets a clear error fromindex.jsinstead of a failed compile.
Dev dependencies:
vitest(^4.1.10) — the TypeScript/JavaScript test suite (npm test).prebuildify(^6.0.1) — produces the prebuilt binaries inprebuilds/(npm run prebuildify).@types/node(^25.9.5) — types for the test suite and build scripts. Also declared as an optional peer dependency, so a consumer using TypeScript gets the Node types the.d.tsfiles assume without it being forced on a plain-JS install.
node-gyp is not in package.json. It ships inside npm, and npm puts it on
the PATH for npm run scripts, which is how build-debug / build-release
find it. If you see node-gyp: command not found (common with alternate package
managers), install it yourself:
npm install -g node-gypnode-gyp also needs Python 3.8 or newer on the PATH. If you have several
Pythons installed, point it at the right one:
npm config set python /path/to/python3
# or, per-invocation:
PYTHON=/path/to/python3 npm run build-debug2. vcpkg and Lua
Lua is not vendored — it comes from vcpkg. Both build
paths (node-gyp and CMake) resolve the Lua include and library paths from the
VCPKG_ROOT environment variable, falling back to ~/vcpkg when it is unset.
Install vcpkg (skip if you already have one, e.g. the copy CLion manages):
# macOS / Linux
git clone https://github.com/microsoft/vcpkg.git ~/vcpkg
~/vcpkg/bootstrap-vcpkg.sh
export VCPKG_ROOT="$HOME/vcpkg" # add to ~/.zshrc or ~/.bashrc# Windows (PowerShell)
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
C:\vcpkg\bootstrap-vcpkg.bat
setx VCPKG_ROOT "C:\vcpkg" # reopen the shell afterwardThen install Lua. The triplet matters — get_vcpkg_path.js looks for a
static library at
$VCPKG_ROOT/installed/<triplet>/lib/{liblua.a,lua.lib}, and the triplet is
chosen from your platform and architecture:
| Platform | Triplet | Command |
| ------------------- | ------------------- | ----------------------------------- |
| macOS Apple Silicon | arm64-osx | npm run vcpkg-lua |
| Windows x64 | x64-windows-static| vcpkg install lua:x64-windows-static |
On macOS, use npm run vcpkg-lua rather than a bare vcpkg install lua.
The default triplets are already static, so a plain install links fine — but
vcpkg's stock arm64-osx triplet sets no deployment target, so it builds
liblua.a against whatever SDK the machine has. Building on macOS 26 that way
yields a minos 26.0 static library, one built for newer 'macOS' version
warning per Lua object at link time, and an addon whose recorded minimum macOS
is a claim it was never actually linked for.
triplets/arm64-osx.cmake in this repo is an overlay triplet that adds
VCPKG_OSX_DEPLOYMENT_TARGET 13.5, matching the two MACOSX_DEPLOYMENT_TARGET
settings in binding.gyp. It is named arm64-osx on purpose so it shadows the
built-in triplet and installs to the same installed/arm64-osx directory that
get_vcpkg_path.js and CMakeLists.txt already hardcode. The overlay applies
only when --overlay-triplets is passed, which is exactly what the script does —
along with removing the package first (vcpkg otherwise reports "already
installed" and leaves the previous library in place) and printing the resulting
minos so you can see the target it actually produced. It installs
lua[tools], since npm run oracle needs the port's interpreter.
On Windows you must ask for x64-windows-static explicitly — the default
x64-windows triplet builds a DLL and installs to the wrong directory, and the
addon is built against the static CRT (/MT), so it must link a static Lua.
Windows has no deployment-target equivalent, so no overlay is involved there.
vcpkg's lua port is currently 5.5.0, which is what this project targets.
The code uses Lua 5.5 APIs (luaL_openselectedlibs, the 5.5 lua_gc arities,
native 64-bit integers), so it will not compile against Lua 5.4 or earlier.
Verify the resolution before building — these print the exact paths the build will use:
npm run get-vcpkg-include # .../installed/arm64-osx/include
npm run get-vcpkg-lib # .../installed/arm64-osx/lib/liblua.aIf either path does not exist on disk, fix VCPKG_ROOT or the triplet before
going further; the compiler error you would otherwise get (lua.hpp not found,
or an unresolved-symbol link failure) is much less obvious.
3. Platform Build Tools
Windows
- Visual Studio 2022 with the "Desktop development with C++" workload, or the standalone Build Tools for Visual Studio 2022.
- That workload supplies the MSVC v143 toolset and the Windows 10/11 SDK, both required.
- The build uses the static runtime (
/MT,/MTdfor debug) and definesLUA_STATIC, which is why the static vcpkg triplet above is mandatory. - Only x64 is configured.
macOS
Xcode Command Line Tools:
xcode-select --installA full Xcode install works too, but the Command Line Tools alone are enough.
Apple Clang with
libc++, C++17, exceptions and RTTI enabled.binding.gypsetsMACOSX_DEPLOYMENT_TARGETto 13.5, in both places it appears (the addon target and the C++ test target), matching theminosof the official Node 24 macOS arm64 build. Lua must be built to the same target, which is whatnpm run vcpkg-luaand thetriplets/arm64-osx.cmakeoverlay are for — see "vcpkg and Lua" above. If you change the target, change it in all three (bothbinding.gypsettings and the overlay triplet), reinstall Lua withnpm run vcpkg-lua, and regenerate any prebuild.Both arm64 and x64 are supported; prebuilt binaries only cover arm64.
4. Google Test Submodule (Debug Builds)
The debug build also compiles the C++ test binary, whose sources include
vendor/googletest. That directory is a git submodule — a fresh clone
leaves it empty and the build fails on a missing gtest-all.cc:
git submodule update --init --recursivenpm run build-release sets -Dskip_test=1 and skips the test target, so it
does not need the submodule.
5. Build
# macOS only, and only once per vcpkg tree: install Lua at this project's
# deployment target (see "vcpkg and Lua" above for why a bare install differs).
npm run vcpkg-lua
# Debug build — includes the C++ test binary. Required before `npm test`.
npm run build-debug
# Release build — addon only.
npm run build-releaseOutput lands in build/Debug/ or build/Release/ as lua-native.node;
index.js searches local build output (debug → release → the CMake layouts)
first, then prebuilds/, then node-gyp-build. Local builds deliberately win,
so a freshly compiled binary is always what loads during development even when a
prebuild is present.
After any C++ change, re-run npm run build-debug before npm test — the
test suite loads the freshly built binary, not a prebuilt one.
Alternative: CMake
There are two independent build paths — binding.gyp with node-gyp (the
default and the one used for releases) and CMake. The CMake path needs CMake
3.20+ on the PATH plus the same VCPKG_ROOT; it wires up vcpkg's toolchain
file automatically:
npm run build-cmake-debug
npm run build-cmake-releaseClean
npm run clean # removes node-gyp's build/ and the cmake-build-* directoriesTroubleshooting
| Symptom | Cause and fix |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| node-gyp: command not found | Not using npm's bundled copy — npm install -g node-gyp |
| gyp ERR! find Python | No Python 3.8+ on the PATH — install it, or npm config set python <path> |
| fatal error: 'lua.hpp' file not found | Wrong or unset VCPKG_ROOT, or Lua not installed for the right triplet — check npm run get-vcpkg-include |
| cannot open input file 'lua.lib' / unresolved Lua symbols | On Windows, Lua installed for x64-windows instead of x64-windows-static |
| gtest-all.cc: No such file or directory | Submodule not fetched — git submodule update --init --recursive |
| Undefined luaL_openselectedlibs or lua_gc arity errors | Linking Lua 5.4 or older; this project requires Lua 5.5 |
| npm test behaves as though your C++ change never happened | Rebuild with npm run build-debug first |
Usage
Basic Script Execution
Hello 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); // 42File Execution
Execute 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"
}Passing JavaScript Functions to Lua
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); // 5JavaScript functions are callable from Lua even when nested inside an object or array — they cross the boundary as real Lua functions, not placeholders:
import lua_native from "lua-native";
const lua = new lua_native.init();
// Functions nested inside a table/array remain callable in Lua
lua.set_global("math_ops", {
double: (n) => n * 2,
ops: [(a, b) => a + b],
});
const [d, sum] = lua.execute_script(`
return math_ops.double(21), math_ops.ops[1](3, 4)
`);
console.log(d, sum); // 42 7Working with Global Variables
import 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")); // 21
// Dotted paths read and auto-create nested table fields
lua.set_global("config.db.host", "localhost"); // creates config and config.db
console.log(lua.get_global("config.db.host")); // 'localhost'
console.log(lua.get_global("config.db.port")); // null (missing leaf)
console.log(lua.get_global("missing.a.b")); // null (nil intermediate, no error)Calling Lua Functions by Name
call(name, ...args) invokes a Lua function directly, accepting a dotted path
just like get_global:
lua.execute_script(`
function greet(name) return "hello " .. name end
handlers = { on = { tick = function(n) return n * 2 end } }
`);
console.log(lua.call("greet", "world")); // 'hello world'
console.log(lua.call("handlers.on.tick", 21)); // 42This is more than shorthand for get_global(name)(...). get_global on a
function mints a JavaScript wrapper backed by its own Lua registry slot, freed
only when that wrapper is garbage-collected — one per call in a per-frame or
per-request loop. call() keeps the function on the Lua side, so the steady
state is flat.
The target must be a genuine Lua function; a callable table (one with __call)
is rejected with a clear message — reach it through get_global instead.
Complex Data Structures
The 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!'
// }JavaScript Type Conversion
Common JavaScript built-in types convert to their natural Lua representations
when passed into Lua (via set_global, callbacks, create_table, etc.):
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
// BigInt -> Lua integer (64-bit)
lua.set_global("big", 9007199254740993n);
lua.execute_script("return math.type(big)"); // "integer"
// Date -> epoch milliseconds
lua.set_global("when", new Date(1234));
lua.execute_script("return when"); // 1234
// Buffer / TypedArray / ArrayBuffer -> binary-safe Lua string
lua.set_global("buf", Buffer.from("hello"));
lua.execute_script("return #buf"); // 5
// Map -> table, Set -> array
lua.set_global("m", new Map([["a", 1], ["b", 2]]));
lua.execute_script("return m.a"); // 1
lua.set_global("s", new Set([10, 20, 30]));
lua.execute_script("return #s"); // 3
// RegExp -> its source pattern string
lua.set_global("re", /foo\d+/g);
lua.execute_script("return re"); // "foo\\d+"Full JavaScript → Lua mapping for built-in types:
| JavaScript type | Lua result | Notes |
| --------------------------------------- | ---------------- | ------------------------------------------------- |
| BigInt | integer | Throws if outside signed 64-bit range |
| Buffer / TypedArray / ArrayBuffer | string | Raw bytes, binary-safe (honors byteOffset) |
| Date | number | Epoch milliseconds |
| Map | table | Keys stringified; values convert recursively |
| Set | table (array) | Values convert recursively |
| RegExp | string | The .source pattern (flags are dropped) |
| Symbol | — | Rejected with an error (no Lua representation) |
64-bit integer precision is preserved in both directions: a Lua integer whose
magnitude exceeds 2^53 - 1 is returned to JavaScript as a BigInt rather than
a lossy number. Smaller integers remain a number.
const max = lua.execute_script("return math.maxinteger");
console.log(max); // 9223372036854775807n (BigInt — exact)
const small = lua.execute_script("return 123");
console.log(small); // 123 (number)Custom Type Converters
Register your own converters to control how application-specific types cross into
Lua with register_type_converter(match, convert). Converters are consulted in
registration order; the first whose match returns truthy has its convert
result passed into Lua (converted normally, so it may return any Lua-compatible
value):
class Money {
constructor(cents) {
this.cents = cents;
}
}
lua.register_type_converter(
(v) => v instanceof Money,
(v) => ({ cents: v.cents, dollars: v.cents / 100 }),
);
lua.set_global("price", new Money(1299));
lua.execute_script("return price.dollars"); // 12.99Converters run after internal round-trip markers (so reference-based tables
and userdata are never hijacked) but before the built-in handling above — so
you can also override how a built-in type like Date is converted:
lua.register_type_converter(
(v) => v instanceof Date,
(v) => v.toISOString(),
);
lua.set_global("now", new Date("2026-07-10T00:00:00Z"));
lua.execute_script("return now"); // "2026-07-10T00:00:00.000Z"Converters apply only to object values — plain primitives, functions, BigInt,
and Symbol bypass them.
The Other Direction — register_from_lua_converter
register_from_lua_converter(match, convert) is the mirror: it rebuilds
application types out of the Lua values that encode them. Together the two make a
round trip:
class Money {
constructor(cents) {
this.cents = cents;
}
}
// JS -> Lua
lua.register_type_converter(
(v) => v instanceof Money,
(v) => ({ __type: "Money", cents: v.cents }),
);
// Lua -> JS
lua.register_from_lua_converter(
(v) => v?.__type === "Money",
(v) => new Money(v.cents),
);
lua.set_global("price", new Money(1299));
const back = lua.get_global("price");
console.log(back instanceof Money, back.cents); // true 1299
// Values Lua builds itself convert too
const total = lua.execute_script(`return { __type = 'Money', cents = 250 }`);
console.log(total instanceof Money); // truematch sees the value the built-in conversion produced — a plain object for a
Lua table, a Proxy for a metatabled one — since that is the only shape a
JavaScript predicate can inspect. convert's return value is used verbatim:
it is already a JavaScript value, so unlike the JS→Lua direction it is not
converted again (which also means a converter matching its own output cannot
loop).
Converters are consulted at every level of the conversion, so they reach values nested inside tables and arrays, and values arriving as callback arguments — not just top-level results:
lua.set_global("charge", (m) => console.log(m instanceof Money)); // true
lua.execute_script(`
local order = { items = { { __type = 'Money', cents = 100 } } }
charge(order.items[1])
`);As with the JS→Lua direction, only object-valued results are offered — every number and string crossing out of Lua stays on the fast path.
Returning Lua Functions
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()); // 12Error Handling
Lua errors are converted to JavaScript exceptions, and the message includes a
stack traceback (available even when the debug library is not loaded):
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
try {
lua.execute_script('function foo() error("boom") end\nfoo()');
} catch (error) {
console.error(error.message);
// [string "..."]:1: boom
// stack traceback:
// [C]: in function 'error'
// [string "..."]:1: in function 'foo'
// [string "..."]:2: in main chunk
}JS Error fidelity
A JavaScript Error thrown by a host function is preserved end-to-end. If it
propagates uncaught back to JS, you get the same Error instance — type,
message, stack, and custom properties intact:
class DBError extends Error {
constructor(msg) { super(msg); this.name = "DBError"; this.code = "E_DB"; }
}
const lua = new lua_native.init(
{ query: () => { throw new DBError("connection failed"); } },
{ libraries: "all" },
);
try {
lua.execute_script("query()");
} catch (error) {
console.log(error instanceof DBError); // true
console.log(error.name, error.code); // "DBError" "E_DB"
}Inside Lua, the same error is a readable table, so scripts can inspect it:
const info = lua.execute_script(`
local ok, err = pcall(query)
return { message = err.message, name = err.name }
`);
// { message: "connection failed", name: "DBError" }(Non-object throws — throw "string", throw 42 — surface as a plain message.)
Protected calls with pcall
Call a function in protected mode and get a result object instead of an
exception. The preserved error is returned in error:
const fn = lua.execute_script(
'return function(x) if x < 0 then error("negative") end return x * 2 end'
);
lua.pcall(fn, 5); // { ok: true, value: 10 }
lua.pcall(fn, -1); // { ok: false, error: Error("...negative...\nstack traceback...") }Standard Library Loading (Opt-In)
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.
Load all libraries
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())"); // worksSafe preset
The 'safe' preset loads everything except io, os, and debug:
const safe = new lua_native.init({}, { libraries: "safe" });
safe.execute_script('print(string.upper("hello"))'); // "HELLO"
safe.execute_script("print(math.floor(3.7))"); // 3
safe.execute_script("print(type(io))"); // "nil" — io is not loaded
safe.execute_script("print(type(os))"); // "nil" — os is not loaded
safe.execute_script("print(type(debug))"); // "nil" — debug is not loaded'safe' is not a sandbox, and the name is about which libraries load rather
than about what a script can reach. base still carries dofile and loadfile,
and package still provides require with a writable package.path — so
untrusted Lua under 'safe' can execute any readable .lua file on the host.
Use 'sandbox' below, or add filesystem: 'deny', if that is what you need.
Sealed preset ('sandbox')
The 'sandbox' preset is the sealed one. It loads the computational libraries
and nothing that reaches outside the VM:
const lua = new lua_native.init(
{},
{
libraries: "sandbox",
maxMemory: 256 * 1024,
maxInstructions: 1_000_000,
},
);
lua.execute_script('return string.upper("ok")'); // "OK" — base/string/math/table/utf8 are loaded
lua.execute_script("return type(dofile)"); // "nil" — cleared from base
lua.execute_script("return type(require)"); // "nil" — no package library
lua.execute_script("return type(io)"); // "nil"| | 'all' | 'safe' | 'sandbox' |
|---|---|---|---|
| io, os, debug | ✅ | — | — |
| package / require | ✅ | ✅ | — |
| dofile, loadfile | ✅ | ✅ | — (cleared from base) |
| bytecode loading | ✅ | ✅ | off by default |
| base, coroutine, table, string, math, utf8 | ✅ | ✅ | ✅ |
dofile and loadfile are cleared after the libraries open, because they live
in base and cannot be dropped by omitting a library without also losing
pairs, type and tostring. allowBytecode defaults to false under this
preset, since string.dump plus load would otherwise reach the bytecode
loader; an explicit allowBytecode: true still wins. The seal survives
reset() — it is part of the runtime config, not a constructor-only step.
A sealed context is not a mute one: set_read_handler
and set_file_reader give it input and
files backed by JavaScript rather than by the disk.
Selective loading (array)
You 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 loadedBare state (default)
Omitting 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).
Memory Limits
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.
For deterministic cleanup, run a collection explicitly with
gc('collect'), and see that section for how
gc('count') relates to get_memory_usage().
State Introspection
info() returns a diagnostics snapshot of a context — which Lua it runs, how
much memory it holds right now, and the limits and libraries it was created
with:
import lua_native from "lua-native";
const lua = new lua_native.init({}, {
libraries: "safe",
maxMemory: 10 * 1024 * 1024,
maxInstructions: 1_000_000,
});
console.log(lua.info());
// {
// version: 'Lua 5.5',
// release: 'Lua 5.5.0',
// versionNumber: 505,
// memoryBytes: 15022,
// memoryKB: 14.669921875,
// memoryLimit: 10485760,
// maxInstructions: 1000000,
// timeout: 0,
// libraries: ['base', 'package', 'coroutine', 'table', 'string', 'math', 'utf8']
// }Everything reported comes from state the runtime already tracks, so info()
runs no Lua code and never triggers a collection — it's safe to poll on a timer:
setInterval(() => {
const { memoryBytes, memoryLimit } = lua.info();
if (memoryLimit > 0 && memoryBytes / memoryLimit > 0.9) {
console.warn("Lua context approaching its memory cap");
lua.reset();
}
}, 30_000);libraries reports the names a preset expanded to, which makes it easy to
confirm what a sandboxed context can actually reach:
new lua_native.init({}, { libraries: "safe" }).info().libraries;
// ['base', 'package', 'coroutine', 'table', 'string', 'math', 'utf8'] — no io/os/debug
new lua_native.init().info().libraries; // [] — bare statememoryBytes is the same counter get_memory_usage() returns, and memoryKB
is simply that divided by 1024 — one source of truth, so the two can never
disagree. (Lua's own gc('count') is an independent reading; see the
gc() section for how the two relate.)
Execution Time Limits
Cap the number of Lua VM instructions a single execution may run, so an infinite
loop aborts instead of hanging the host process. This is the second half of
sandboxing alongside maxMemory:
import lua_native from "lua-native";
const lua = new lua_native.init({}, {
libraries: "safe",
maxInstructions: 1_000_000,
});
// Normal scripts complete well within the budget
lua.execute_script("local s = 0; for i = 1, 100 do s = s + i end; return s"); // 5050
// A runaway loop is aborted instead of hanging
try {
lua.execute_script("while true do end");
} catch (error) {
console.error(error.message); // "instruction limit exceeded"
}
// The context remains usable afterward
lua.execute_script("return 1 + 1"); // 2The budget applies per execution call — each execute_script,
execute_file, load_bytecode, Lua-function call from JS, and each coroutine
resume gets a fresh budget, so the limit catches a single runaway execution
without accumulating across unrelated calls. So does any other operation that
runs Lua: a metamethod fired by a table handle, a Proxy read, or access to a
metatabled _G. Nested entries — a Lua loop calling a JS callback that
re-enters Lua — share the enclosing budget rather than restarting it, so the
limit bounds the whole call tree. Coroutines created inside a script (including
via coroutine.create) inherit the limit. Enforcement is approximate to within
~1000 instructions (the sampling granularity of the hook). Set to 0 or omit
for unlimited execution.
Wall-Clock Timeout
maxInstructions is deterministic but abstract — how many instructions is "two
seconds"? timeout caps real elapsed time instead, in milliseconds:
const lua = new lua_native.init({}, {
libraries: "safe",
timeout: 5000, // abort any execution running longer than 5 seconds
});
try {
lua.execute_script("while true do end");
} catch (error) {
console.error(error.message); // "execution timeout"
}
lua.execute_script("return 1 + 1"); // 2 — the context is still usableThe two limits are complements, not alternatives. Set both and whichever is reached first aborts the script:
const sandbox = new lua_native.init({}, {
libraries: "safe",
maxMemory: 256 * 1024,
maxInstructions: 1_000_000,
timeout: 1000,
});timeout follows the same per-execution-call rule as maxInstructions: each
execute_script, execute_file, load_bytecode, Lua-function call, coroutine
resume, and every other operation that runs Lua (a table-handle metamethod, a
Proxy read, metatabled _G access) starts a fresh deadline, and nested entries
share the enclosing one. Under execute_async, time spent suspended awaiting a
JS Promise doesn't count — the timeout bounds Lua compute per step, not the
whole round trip.
Both limits are enforced from the same instruction hook, which means the
deadline is checked between VM instructions. A single long-running C call — a
huge string.rep, or a host callback that blocks — is not interrupted. The
clock is monotonic, so changing the system time can't shorten or extend a
running script.
Debug Hooks
set_hook() exposes Lua's lua_sethook to JavaScript: a callback that fires as
the script runs, reporting lines, calls, returns, or instruction counts. It's
the building block for profilers, tracers, and debugger integrations.
import lua_native from "lua-native";
const lua = new lua_native.init({}, { libraries: "all" });
lua.set_hook((event, line, name) => {
console.log(`${event} at line ${line}${name ? ` in ${name}` : ""}`);
}, { call: true, line: true });
lua.execute_script(`
local function add(a, b)
return a + b
end
return add(1, 2)
`);
lua.remove_hook();The callback receives (event, line, name):
| Argument | Meaning |
| --- | --- |
| event | 'call', 'tail call', 'return', 'line', or 'count' |
| line | Current source line, or -1 where Lua has no line information |
| name | The function's name if Lua can infer one from the call site, else '' |
Request events with the options object — at least one is required:
lua.set_hook(fn, { line: true }); // every source line (most detailed, slowest)
lua.set_hook(fn, { call: true }); // function entry ('call' and 'tail call')
lua.set_hook(fn, { return: true }); // function exit
lua.set_hook(fn, { count: 10_000 }); // every N VM instructionsSampling Profiler
count is the option to reach for when tracing whole programs — it samples
instead of reporting everything, so the overhead stays bounded:
const samples = new Map();
lua.set_hook((_event, line) => {
samples.set(line, (samples.get(line) ?? 0) + 1);
}, { count: 10_000 });
lua.execute_file("./workload.lua");
lua.remove_hook();
const hottest = [...samples].sort((a, b) => b[1] - a[1]).slice(0, 10);
console.log("Hottest lines:", hottest);Tracing Until a Condition
Calling remove_hook() from inside the callback is safe and is the usual way
to trace only as far as you need:
lua.set_hook((event, line) => {
console.log(event, line);
if (line >= 100) lua.remove_hook();
}, { line: true });Inspecting the Stack — get_stack() / get_locals()
A hook tells you where execution is. These tell you what the stack looks like there and what its variables hold — the difference between building a profiler and building a debugger:
lua.set_hook((event, line) => {
if (line === breakpoint) {
for (const f of lua.get_stack()) {
console.log(`${f.shortSource}:${f.currentLine} ${f.name || '?'} (${f.what})`);
}
console.log(lua.get_locals(0)); // [{ name: 'n', value: 5 }, ...]
lua.remove_hook();
}
}, { line: true });get_stack() returns frames innermost-first, each with level, source /
shortSource, currentLine, lineDefined, name, nameWhat and what
('Lua', 'C' or 'main'). Pass { maxLevels } to cap the walk. Outside
execution it returns [].
get_locals(level) returns the named locals of that frame with their values.
Lua's compiler temporaries are skipped, and a level that does not exist is a
RangeError rather than an empty array. Both are read-only — there is no
stack manipulation and no lua_State handed to JavaScript, which keeps the
design decision in LIMITATIONS.md §7 intact. Both are
refused while execute_script_async holds the state on a worker thread.
Chunk names make this legible: pass { chunkName: '@file.lua' } when you run a
script and shortSource reports it.
What to Know Before Using It
lineis expensive. It crosses into JavaScript for every source line executed, which slows a script by orders of magnitude. Usecountwith a coarse interval for anything long-running.- A coarser
countstops paying off. Hook overhead isfixed + per-fire × fires, and the fixed part — the cost of the VM taking its hook-dispatch path at all — is there whether the callback fires hundreds of times or not once. Once the interval is coarse enough that the hook fires only a handful of times across your script, what is left is that fixed part, and widening it further buys nothing measurable: the remaining choice is between a hook and no hook, not between intervals. On a tight numeric loop (measured August 7, 2026) the fixed part was already most of the overhead atcount: 1000. - A throwing callback is swallowed. The hook is a diagnostic channel, not a
control one — an exception can't be allowed to unwind through Lua's C frames,
so it's contained and execution continues. To stop a running script, use
maxInstructionsorcancel(). - Coroutines inherit the hook at creation. It's installed on the main state
and copied into coroutine threads created afterwards, so set it before
creating the coroutines you want traced — the same rule as
maxInstructions. - Worker-thread async is not traced.
execute_script_async/execute_file_asyncrun Lua on a worker thread, where calling into JavaScript is not permitted, so the hook doesn't fire there.execute_async(main thread) traces normally. - It coexists with
maxInstructionsandcancel(). All three share one underlyinglua_sethookinstallation, and the masks are combined — so setting or removing a debug hook never disables the limit or cancellation, and acountinterval finer than the limit's own still works.
Re-entering Lua from the hook is allowed — Lua disables the hook while it runs,
so lua.execute_script(...) inside a callback won't recurse.
Module / Require Integration
Register JavaScript objects as Lua modules available via require(), or add
filesystem search paths for Lua module loading. Requires the package library.
Registering JS Modules
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.
Adding Search Paths
// 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.
Combined Usage
// 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
`);Dynamic Modules with a JS Searcher
register_module is a static preload and add_search_path hits the filesystem.
add_searcher resolves modules lazily through JavaScript — return the
module's Lua source (or null to let the next searcher try). Sources can come
from a bundle, database, or in-memory map:
const modules = {
greet: 'return function(name) return "Hello, " .. name end',
mathx: 'return { square = function(x) return x * x end }',
};
lua.add_searcher((name) => modules[name] ?? null);
lua.execute_script(`
local greet = require('greet')
local mathx = require('mathx')
return greet('Ada'), mathx.square(9)
`); // ['Hello, Ada', 81]Modules are cached like any require, so the searcher runs once per module.
Searchers must be synchronous and return Lua source (not a value). Requires
the package library.
Output Redirection
Route Lua print() and io.write() to a JavaScript handler instead of the
process stdout. The handler receives the fully-formatted text — exactly what
would have been printed (arguments joined with tabs, __tostring applied, and a
trailing newline for print):
import lua_native from "lua-native";
const lines = [];
const lua = new lua_native.init({}, {
libraries: "all",
print: (text) => lines.push(text),
});
lua.execute_script('print("hello", 42)\nprint("world")');
console.log(lines); // ["hello\t42\n", "world\n"]You can also set or change the handler at runtime, and pass null to send output
back to stdout:
const lua = new lua_native.init({}, { libraries: "all" });
lua.set_print_handler((text) => process.stdout.write(`[lua] ${text}`));
lua.execute_script('print("captured")'); // [lua] captured
lua.set_print_handler(null); // back to stdoutInput Redirection and Virtual Files
set_print_handler covers output. Two more handlers cover the ways Lua reads:
set_read_handler routes io.read, and set_file_reader resolves dofile and
loadfile. Both matter most in a sealed context, where the real ones are gone.
Reading input — set_read_handler()
Without it, a prompting script under a print handler has its output captured and then blocks on the process's real stdin:
const lua = new lua_native.init({}, { libraries: "all" });
const lines = ["Ada", "42"];
let i = 0;
lua.set_read_handler(() => (i < lines.length ? lines[i++] : null));
lua.execute_script("return io.read()"); // "Ada"
lua.execute_script('return io.read("n")'); // 42 — a number, like real io.read("n")The handler receives the format as Lua passes it — 'l', 'n', 'a', or a
number for a byte count, with the Lua 5.3 * prefix already stripped so only
one spelling is ever seen. Return null for end-of-input; an empty string is a
valid empty line. Returning a Uint8Array or Buffer sends those exact bytes to
Lua, which is the only way to feed it a sequence that is not valid UTF-8.
It works in a sealed context, and does not widen the seal:
const lua = new lua_native.init({}, { libraries: "sandbox" });
lua.set_read_handler(() => "Ada"); // true — io.read is now wired
lua.execute_script("return io.read()"); // "Ada"
lua.execute_script("return type(io.open)"); // "nil" — still sealedUnder 'sandbox' there is no io library, so an io table is synthesized to
hold read and nothing else — no open, lines, write or stdout. The
method returns whether io.read is now wired to your handler. The only
false case is a global io that exists and is not a table (io = 42): that
value belongs to the script, so it is left alone and the handler is not retained.
Unlike a print handler, a throwing read handler is not swallowed — it surfaces as a Lua error, because a read that failed has no sensible value to continue with.
Serving files — set_file_reader()
add_searcher covers require; this covers the other two ways Lua reaches a
file. Under 'sandbox', where dofile and loadfile are cleared, installing a
reader brings them back backed only by what you choose to serve:
const files = {
"/lib/util.lua": "return { add = function(a, b) return a + b end }",
};
const lua = new lua_native.init({}, { libraries: "sandbox" });
lua.set_file_reader((path) => files[path] ?? null);
lua.execute_script('return dofile("/lib/util.lua").add(2, 3)'); // 5Return the Lua source for a path, or null/undefined for "no such file" —
which loadfile reports as nil, message and dofile raises, the shapes the
real ones use:
lua.execute_script('local f, err = loadfile("/nope.lua"); return err');
// "cannot open /nope.lua"While a reader is installed the real filesystem is never consulted — this is
deliberately not a fallback chain, because "the reader, or the disk if the reader
declines" would make the meaning of a path depend on the reader's answer. A
reader that wants disk access can read the disk itself. Source is loaded in
text-only mode, so a reader cannot hand back bytecode and route around
allowBytecode.
Pass null to remove either handler. Both are re-installed automatically across
reset(), so a sandboxed context that resets does not silently lose its virtual
filesystem while still holding your callback.
Filesystem Policy
Closing Lua's access to the disk otherwise takes several calls and still leaves
package.path writable from inside the sandbox. filesystem: 'deny' closes
every door in one option:
const lua = new lua_native.init({}, { libraries: "safe", filesystem: "deny" });
// Modules from the host still work.
lua.register_module("config", { env: "prod" });
lua.execute_script('return require("config").env'); // "prod"
// The disk does not.
lua.execute_script('dofile("/etc/passwd")'); // throws
lua.execute_script('local f, err = loadfile("/etc/passwd"); return err');
// "loadfile is unavailable: this Lua context was created with filesystem access denied"The full door list, which is longer than it first appears:
| Library | Denied |
|---------|--------|
| base | dofile, loadfile |
| package | searchers[2] (path), searchers[3]/[4] (cpath → native code), loadlib, searchpath |
| io | open, lines, input, output |
| os | remove, rename, tmpname |
package.loadlib and the cpath searchers link an arbitrary shared library into
the process, which is a stronger capability than executing a readable .lua
file.
require keeps working for register_module modules and add_searcher
searchers — only the searchers that read the disk are closed. That configuration
has no other expression: 'safe' reaches the disk, and 'sandbox' has no
require at all.
Each door refuses in its own idiom — loadfile, io.open, os.remove,
os.rename, loadlib and searchpath return nil, message, while dofile,
io.lines, io.input, io.output and os.tmpname raise — so a script that
already handles a missing file keeps working. add_search_path() refuses rather
than accepting a path require could never consult:
lua.add_search_path("./modules/?.lua");
// throws: "Cannot add search path: this Lua context was created with filesystem
// access denied, so require() never consults package.path. Use register_module()
// or add_searcher() to serve modules from the host."It governs Lua, not the host. execute_file(), compile_file() and a
set_file_reader handler keep working — the host asking for a file by name is
your own decision. Process execution (os.execute, io.popen) is not filesystem
access and is untouched. The seal is re-applied across reset() and cannot be
lifted for the life of the context.
Async Execution
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 .. '!'",
);Awaiting JavaScript Promises (execute_async)
execute_script_async runs on a worker thread and cannot call back into
JavaScript. execute_async is different: it runs Lua as a coroutine on the
main thread, so JS callbacks work — and when a host function returns a
Promise, the Lua coroutine transparently suspends until it resolves, then
continues with the resolved value. No special Lua syntax is needed.
import lua_native from "lua-native";
const lua = new lua_native.init(
{
// An async JS function — returns a Promise.
fetchUser: async (id) => {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json(); // { id, name }
},
// A synchronous callback — also works during execute_async.
upper: (s) => s.toUpperCase(),
},
{ libraries: "all" },
);
const name = await lua.execute_async(`
local user = fetchUser(42) -- suspends here until the Promise resolves
return upper(user.name) -- sync callbacks work too
`);
console.log(name); // e.g. "ADA"Awaits compose naturally — sequential calls, loops, and multiple values all work:
const total = await lua.execute_async(`
local sum = 0
for i = 1, 3 do
sum = sum + getAmount(i) -- each getAmount(i) awaits a Promise
end
return sum
`);Promise-returning methods on userdata and class instances are awaited too:
lua.register_class("Client", {
construct: () => ({}),
methods: { get: async (self, id) => (await db.get(id)) },
});
const row = await lua.execute_async('return Client.new():get(7)');Rejections surface as Lua errors, so scripts can pcall them; an uncaught
rejection rejects the returned Promise:
lua.set_global("risky", () => Promise.reject(new Error("nope")));
// Caught inside Lua:
const [ok, err] = await lua.execute_async(`
local ok, err = pcall(function() return risky() end)
return ok, err
`); // [false, "...nope"]
// Uncaught -> the returned Promise rejects:
await lua.execute_async("return risky()").catch((e) => console.log(e.message)); // "nope"Cancellation — cancel() aborts an in-flight run (its Promise rejects). It
takes effect while the script is suspended awaiting a Promise:
const p = lua.execute_async("local x = slowCall(); return x");
setTimeout(() => lua.cancel(), 100);
await p.catch((e) => console.log(e.message)); // "execution cancelled"Notes:
- Only one async run per context at a time —
is_busy()istruemeanwhile, and concurrent calls throw. Use separate contexts for true concurrency. - Calling a Promise-returning host function from synchronous
execute_scriptthrows — such functions must be awaited viaexecute_async. - Only native
Promiseresults suspend; other values are converted as usual.
Awaiting in a function you hold — call_async()
execute_async needs a script to run. call_async is the awaiting counterpart
to call(): it takes a global name (dotted paths
included) or a LuaFunction reference this context produced.
const lua = new lua_native.init(
{ fetchName: async (id) => "Ada" }, // a host function returning a Promise
{ libraries: "all" },
);
lua.execute_script('function greet(id) return "hi " .. fetchName(id) end');
await lua.call_async("greet", 7); // "hi Ada"It closes two things execute_async cannot. A LuaFunction held only on the
JavaScript side — never stored as a global — has no name to route through, so it
could not await at all:
const fn = lua.execute_script("return function(id) return fetchName(id) end");
await lua.call_async(fn, 7); // "Ada" — not reachable by nameAnd execute_async('return f(1)') compiles a fresh chunk on every call, where
this keeps the function a reference. Everything else matches execute_async: the
same driver, the same one-run-per-context rule, and the same cancel()
behaviour.
Awaiting in a coroutine you drive — resume_async()
The awaiting counterpart to resume(), and a
drop-in for it — the resolved value is the same { status, values, error? }
object, including for a Lua error, which is reported in the result rather
than thrown.
const lua = new lua_native.init(
{ fetchUser: async (id) => ({ name: "Ada", id }) },
{ libraries: "all" },
);
const co = lua.create_coroutine(`
return function(id)
local user = fetchUser(id) -- suspends until the Promise settles
coroutine.yield(user.name)
return "done"
end
`);
const first = await lua.resume_async(co, 7);
console.log(first.status, first.values); // "suspended" ["Ada"]
const second = await lua.resume_async(co);
console.log(second.status, second.values); // "dead" ["done"]Under plain resume() the coroutine runs synchronously, so a host callback
returning a Promise anywhere inside it hard-errors. Under resume_async the
coroutine is the driven thread, so that call suspends it and then continues. A
coroutine created inside it still cannot await, and says so.
for await over a coroutine steps through resume_async, which is what makes an
awaiting loop work:
const co = lua.create_coroutine(`
return function()
for i = 1, 3 do coroutine.yield(delay(i)) end -- delay returns a Promise
end
`);
for await (const v of co) console.log(v); // 2, 4, 6Because the coroutine is yours rather than the binding's, cancel() leaves it
suspended and resumable at the point it reached — exactly as breaking out of
a for await loop does.
Bytecode Precompilation
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):
// Co