CLI tool
ceifa/wasmoon avatar
ceifa/wasmoon

wasmoon: a real Lua 5.4 VM compiled to WebAssembly with a JavaScript bridge

A real lua 5.4 VM with JS bindings made with webassembly

702 stars56 forksTypeScriptMIT

At a glance

What is it?
wasmoon takes the official Lua source, compiles it to WebAssembly, and wraps it in a JavaScript API so that Node, Deno and browser applications can run Lua without a native addon. It is the right choice when you want a genuine Lua runtime in a web context, and the wrong choice when bundle size is the binding constraint.
Who is it for?
Adopt wasmoon when your application has to execute Lua in a browser, in Deno, or in Node without a native toolchain, and you are willing to accept a larger bundle than a pure JavaScript Lua.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 45 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A real Lua VM, not a JavaScript reimplementation

The project states its goal plainly: embed Lua in any Node.js, Deno or web application, run Lua code on any operating system, and interoperate between Lua and JavaScript without memory leaks, including the DOM.

The mechanism is the interesting part. wasmoon compiles the official Lua code to WebAssembly and builds an abstraction layer for interop, and the project contrasts this with fengari, which is an entire Lua VM rewritten in JavaScript. That is the whole difference in one line: the same upstream C implementation of Lua, the one the Lua project itself ships, running inside a WebAssembly sandbox rather than a JavaScript translation of it.

The package is published to npm as wasmoon, at version 1.16.0 in the manifest, with the main entry point at ./dist/index.js and the module type set to ESM. It is written in TypeScript, with a build pipeline built on rolldown for the JavaScript side and a tsc pass that emits declaration files. There is also a bin entry named wasmoon, so the runtime is available as a command line program.

The licence is MIT, and the author field lists ceifa. The repository is not archived, the last push was on 2026-09-07, and there are no GitHub releases, so the npm version is the reference point rather than a tagged release series.

The factory and engine lifecycle, and why close matters

The API is built around two objects. A LuaFactory is the reusable piece: it holds the WebAssembly module and creates engines from it. An engine is a standalone Lua environment, obtained with createEngine, and it owns a Lua state that you work with.

The lifecycle is explicit. You set JavaScript functions into the Lua global table with lua.global.set, run Lua source with doString, and pull Lua functions back out as JavaScript callables with lua.global.get. The example in the documentation sets a JavaScript function as a global Lua function, calls it from Lua code, defines a Lua function in the same string, and then retrieves that function on the JavaScript side and calls it.

Then there is the closing step, which is not optional bookkeeping:

js
    lua.global.close()

The comment in the example is that this closes the Lua environment so it can be freed. That is what the no memory leaks claim rests on, and it is also the practical rule for a server: every engine you create for a request or a job needs an explicit close, or you are holding a Lua state alive for the lifetime of the process. The pattern in the examples is a try block with the close call in a finally block, which is the shape to copy.

The factory constructor also accepts the wasm location as its first argument, which the documentation describes as useful when you are using wasmoon in a web environment and want to host the file yourself rather than let the package load it.

A first engine, a JS function called from Lua and a Lua function called from JS

This is the shortest useful program: create a factory, create an engine, cross the boundary in both directions, then free the engine.

js
const { LuaFactory } = require('wasmoon')
const factory = new LuaFactory()
const lua = await factory.createEngine()
    lua.global.set('sum', (x, y) => x + y)
    await lua.doString(`
    print(sum(10, 10))
    function multiply(x, y)
        return x * y
    end
    `)
    const multiply = lua.global.get('multiply')

What you should see when it runs is the Lua side printing the result of the JavaScript function that was injected as a global, and the JavaScript side receiving multiply as a function it can call. Two boundaries are exercised: JavaScript into Lua through a global, and Lua into JavaScript through a global lookup.

The documentation presents this with require, while the package itself is an ESM package with a type of module in the manifest and a main entry of ./dist/index.js. The mismatch is worth noting, because a consumer in an ESM project will normally use an import statement, and the examples in the documentation are written in the CommonJS style. There is no separate export map documented, so check how your bundler or runtime resolves the package before assuming either form works unchanged.

wasmoon against fengari, measured on size and on speed

The comparison with fengari is the one the project itself makes, and it is the basis for choosing between them. The architectural difference is that fengari is a Lua VM rewritten in JavaScript while wasmoon compiles the official Lua code to WebAssembly.

On size, the project reports fengari as smaller, which matters in a browser: 393kB plain and 130kB gzipped for wasmoon, against 214kB plain and 69kB gzipped for fengari.

On speed, the project reports the opposite. The numbers given come from a heap sort run over a list of 2k numbers, ten times, lower being better: 15.267ms for wasmoon and 389.923ms for fengari.

The caveat the documentation attaches is important and easy to skim past. Because of WebAssembly, wasmoon will run Lua code much faster than fengari, but if you are going to interop a lot between JavaScript and Lua, that may no longer be true, and the project advises testing on your specific use case to find out. In other words, the benchmark measures Lua calling Lua. If your workload is a boundary crossing per call, the difference narrows, and the size cost does not go away.

That gives a clean decision rule. Pure Lua workloads inside a web page, where bundle size still fits, favour wasmoon. Interop heavy workloads, or a hard size budget, favour the smaller JavaScript VM.

A command line runtime with an interactive flag

Wasmoon was designed to be embedded, but it can also run from a shell. The usage line is:

sh
$: wasmoon [options] [file] [args]

Two options are documented. The -l flag includes a file or directory, and the -i flag enters interactive mode after running the files. A worked example runs a script and then drops into the interactive prompt:

sh
$: wasmoon -i sum.lua 10 30

On Unix the same binary doubles as a script interpreter through a shebang line, which is how a Lua file can be executed directly:

lua
#!/usr/bin/env wasmoon
return arg[1] + arg[2]

and then:

sh
$: ./sum.lua 10 30

The documentation is candid that the command line story is not the strong point here. For anything more demanding than running a file, it points to demoon, a separate project by the same author. The bin/wasmoon entry in the manifest is what the shell resolves, and it is a thin wrapper around the same library.

Awaiting promises from Lua, and the coroutine walls you cannot cross

JavaScript promises are reachable from Lua with one specific mechanism. You pass a promise into Lua and call :await() on it, which yields the Lua execution until the promise completes. The documented example injects a sleep function that returns a promise and then awaits it inside Lua:

js
lua.global.set('sleep', (length) => new Promise((resolve) => setTimeout(resolve, length)))
await lua.doString(`
    sleep(1000):await()
`)

Two limitations follow from the Lua semantics, and the documentation treats them as inherent rather than as bugs.

The first is that it is not possible to await in a callback from JavaScript into Lua, and the same problem shows up when yielding at the top level of a file. The sample that triggers it creates a coroutine with a next callback, awaits inside that callback, and then awaits the result. The reported errors are either a Lua Error with the message cannot resume dead coroutine, or attempt to yield across a C-call boundary.

The second edge case is null. If injectObjects is set to true, null is injected as a userdata type, and while that works as expected, it evaluates to true in Lua. That is a trap for anyone porting code that checks for nil, since the check silently takes the wrong branch.

Browser bundlers need the Node built-ins ignored, not polyfilled

Bundle and require errors in a web environment have a specific cause. wasmoon tries to safely import some Node modules even in a browser environment, and the bundler is not prepared for that because it tries to statically resolve everything at build time.

The fix is not polyfilling. The documentation says polyfilling these modules is the wrong solution, because they are not actually being used, and they should simply be ignored. Three configurations are given.

For webpack, the resolve.fallback block sets path, fs, child_process, crypto, url and module to false, and the shipped snippet includes the entry file:

js
module.exports = {
    entry: './src/index.js', // Here is your entry file
    resolve: {
        fallback: {
            path: false,
            fs: false,
            child_process: false,
            crypto: false,
            url: false,
            module: false,
        },
    },
}

For Rollup, the recommendation is the rollup-plugin-ignore package with the same list of module names passed to it. For Angular, the browser section of package.json maps the same six names to false next to the main entry.

Building the project from source follows the same shape of choice. The Lua source arrives as a git submodule, so the checkout starts with the submodule update and the dependency install, and then either emscripten is installed locally or Docker is used to run the emscripten image. The three commands are the wasm build, the JavaScript bridge build and the test run:

sh
npm run build:wasm:docker:dev # build lua
npm run build # build the js code/bridge
npm test # ensure everything it's working fine

The test script is mocha running the files in test/ with a boot file, and there is a separate luatests script for the Lua side. The wasm build itself goes through build.sh, which the docker variants invoke inside the emscripten/emsdk image.

Editorial conclusion

Adopt wasmoon when your application has to execute Lua in a browser, in Deno, or in Node without a native toolchain, and you are willing to accept a larger bundle than a pure JavaScript Lua. Do not adopt it when the shipped size is the constraint, since the project itself reports the plain bundle at 393kB against 214kB for fengari, or when your design leans on heavy JS to Lua interop, where the WebAssembly speed advantage is documented as something you have to measure yourself. Verify first that your bundler ignores the Node built-ins the bridge imports, using the resolve.fallback or browser field configuration for webpack, Rollup or Angular.

Frequently asked questions

What is wasmoon and how does it differ from fengari?

wasmoon compiles the official Lua code to WebAssembly and adds an abstraction layer for interop between Lua and JavaScript, while fengari is an entire Lua VM rewritten in JavaScript. The project reports wasmoon as faster but larger: 15.267ms against 389.923ms on its heap sort benchmark, and 393kB plain against 214kB.

How do I create and run Lua code from JavaScript with wasmoon?

Create a LuaFactory, call createEngine to get an engine, set JavaScript functions as Lua globals with lua.global.set, run Lua with doString, and read Lua functions back with lua.global.get. You must call lua.global.close() when you are done so the Lua environment can be freed.

How do I await a JavaScript promise from inside Lua with wasmoon?

Pass the promise into Lua and call :await() on it, which yields the Lua execution until the promise completes. The documentation warns that awaiting inside a callback from JavaScript into Lua is not possible and can raise cannot resume dead coroutine or attempt to yield across a C-call boundary.

Why do I get bundle or require errors when using wasmoon in a browser?

Because wasmoon safely imports some Node modules even in a browser environment and the bundler tries to resolve them statically. The fix is to ignore them rather than polyfill them: set resolve.fallback to false in webpack, use rollup-plugin-ignore in Rollup, or add a browser section to package.json for Angular.

How do I build wasmoon from source?

Fetch the Lua submodule with git submodule update --init, install the dependencies with npm i, then run npm run build:wasm:docker:dev to compile Lua in the emscripten image, npm run build for the JavaScript bridge, and npm test. On Ubuntu, Debian or macOS you can install emscripten locally and use npm run build:wasm:dev instead.

Official sources

  1. ceifa/wasmoon on GitHub
  2. Issues
  3. License: MIT
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/ceifa-wasmoon.svg)](https://hysenlabs.com/projects/ceifa-wasmoon)