Skip to content

Make Fengari embeddable for a JS game: browser bundle, synchronous host calls, callbacks, dialogue coroutines and Lua 5.4 (0.1.6 – 0.5.4) - #230

Closed
datamoc wants to merge 26 commits into
fengari-lua:masterfrom
datamoc:master
Closed

datamoc wants to merge 26 commits into
fengari-lua:masterfrom
datamoc:master

Conversation

@datamoc

@datamoc datamoc commented Oct 11, 2026

Copy link
Copy Markdown

Description

Why

The starting point was running a game campaign using Lua in the browser on top of Fengari. The first thing that failed was the bundle itself: Fengari called
require('os').platform() (src/luaconf.js), which a browser bundler cannot resolve, and consumers had to write a local shim.

Behind it was a list of things the game's Lua needs from its VM:

  • synchronous Lua calls into game APIs, returning results;
  • Lua callbacks and table references retaining their identity;
  • dialogue coroutines that pause for player input and resume;
  • loading the real Lua modules, with Lua 5.4 compatibility.

Upstream Fengari (v0.1.5) is Lua 5.3.4 with 32-bit integers, no <const> / <close>, no host-driven coroutines, and a luaconf.js that cannot be bundled without shims.

This PR closes those gaps. Securing the VM for untrusted content, and the migration tooling, came out of that work.

What each goal needed

Bundling for the browser (0.5.3)

  • require('os').platform() is gone (process.platform). typeof process was tested in 13 places, so a bundler's process shim also made ldblib.js crash at load on an empty
    readline-sync stub. One module, src/isnode.js (process.versions.node), replaces them.
  • package.json has a browser field mapping fs, os, path, tmp, child_process and readline-sync to empty modules. esbuild, webpack, Vite and Browserify bundle the
    package without shims or resolve.fallback. Verified with esbuild --platform=browser, with and without a process shim.

Synchronous calls into game APIs (0.4.0, 0.5.x)

  • defineFunction(name, fn) registers a host function under a dotted path; Lua calls it synchronously and gets its return values (Multi for several).
  • run, runMulti, call, callMulti and callRef call Lua and return results with value conversion in both directions (BigInt for 64-bit integers, Uint8Array
    strings, Map for exotic keys, 1..n tables as arrays, LuaFloat / preserveFloats).
  • vm.protect(fn) runs a host callback in a Lua protected call, so Lua errors raised through the C API become LuaError / LimitError and the stack is restored.
    vm.throwLuaError builds the same error from a stack value.

Callbacks retaining their identity (0.4.0)

  • Functions, threads and userdata come back as LuaRef, anchored in the Lua registry and released with release() or by garbage collection. The same Lua function is
    value when called back with callRef / LuaRef#call. A LuaRef from another VM is refused.
  • Userdata carries opaque host values through Lua.
  • Not covered: tables are converted to JavaScript values on read, not kept as references. Identity-preserving table references are not provided; a host that needs them must hold the table behind a Lua function or userdata.

Dialogue coroutines (0.4.0)

  • vm.coroutine(path | LuaRef) and vm.coroutineFromSource(source) return a Coroutine with status, resume(...args) and close(). A host function returning new Yield([...]) suspends the calling coroutine; the resume arguments become the host function's return values. That is the shape of "pause for player input, resume when the player answers".
  • Coroutine#close() runs pending <close> handlers and reports a failure.

Real Lua modules and Lua 5.4 (0.1.6 – 0.3.0)

  • The baseline is now honest Lua 5.4.6 (LUA_VERSION_NUM 504). A "full Lua 5.5.1 compliance" claim had been added to the README (953794a) while the VM reports 5.4.6; it was removed (a0853cb).
  • 64-bit integers (upstream: 32-bit) with fixes in the string, math and table libraries.
  • <const> and <close> (0.2.0 advertised them but no such program compiled): attribute grammar (the 5.5 prefix form too), compile-time const checks, to-be-closed variables closed on block exit, break, goto, return and error, a new OP_TBC opcode, the closing value of generic for, coroutine.close, lua_closethread, lua_toclose, real lua_resetthread / lua_closeslot. Known limitation: a __close cannot yield.
  • files (read / list) lets the host serve module files, and libs / remove choose the libraries a module can see.
  • Per-state allocation hook (lua_setallocf) so memory can be bounded.

Security hardening

Campaign content is third-party Lua, so the VM had to be safe to run it.

  • sandbox: true (base, coroutine, table, string, utf8, math only; no os, io, debug, package, require; dofile, loadfile, string.dump removed; text-only load; no host helper left as a global: __fs_read, __fs_list, __random).
  • limits (instructions, wall-clock time, per-allocation and total allocation) per top-level call, with a LimitError the script cannot catch.
  • A print option ('buffer', 'native', false) so a host that wants Lua's own print no longer re-opens libraries, which restored string.dump after the sandbox removed it.
  • Hardening of the conversion layer: __proto__ keys cannot modify prototypes, conversion errors in host functions become Lua errors.

Bypasses of the limits found and fixed along the way (do not use 0.4.x to 0.5.3 for untrusted scripts with limits):

  • the message handler of xpcall ran during the counting hook and could loop forever;
  • native pattern matching (find, match, gmatch, gsub), table.move and plain-text find were not interruptible;
  • huge allocations: new limits.allocation and limits.totalAllocation;
  • sandbox exposed the host files through __fs_read / __fs_list;
  • a limit overrun could be hidden by a script that caught the failure of a coroutine it created: every host entry point now fails with a LimitError whenever a limit was hit during the call;
  • Coroutine#close() ran its handlers without a budget;
  • a global path lookup (call, compile, coroutine) ran before the budget, so an __index on _G could loop forever or raise outside a protected call.

Limitations: other native functions whose cost depends only on argument size (default-comparator table.sort, table.concat, string.format) are not interrupted; bound limits.allocation / totalAllocation. limits only acts on the VM created here, so combine it with sandbox. compile is deliberately disabled while limits are active (documented in the README).

Tooling added on the way

  • TypeScript declarations (src/fengari.d.ts, "types" in package.json) so a TypeScript host needs no @ts-expect-error or hand-written types; test/types.test.js checks them against the runtime exports.
  • lua2js / FengariVm.toJs: translates Lua to readable JavaScript for code that eventually leaves Lua, with TODO(lua2js) markers for what it cannot translate faithfully, and an online demo (docs/lua2js). a or b keeps Lua's meaning (nullishOr opts in to ??). A Lua comment can no longer break out of the generated // comment (0.5.0 only).
  • Optional numeric compilation (vm.compile) and a benchmark page comparing the interpreter, compiled Lua and JavaScript in the browser.

Testing

test/browser-compat.test.js (fails if the library requires a Node-only module on the browser path), test/lattribs.test.js (const/close), test/lalloc.test.js, test/fengari-vm.test.js, test/fengari-vm-limits.test.js (every bypass above), test/types.test.js, test/ltranspile.test.js, test/numeric-compiler.test.js. 837 passing, 21 skipped; lint clean. The browser bundle was also built with esbuild and run with and without a process shim. All 34 files of the official Lua 5.5 test suite translate with lua2js to syntactically valid JavaScript.

datamoc and others added 26 commits October 10, 2026 10:20
- Add missing Lua 5.5.1 symbols to lauxlib.js
- Update inventaire-api.md to reflect new symbols
- Add comprehensive tests for all new symbols
- Update package.json for @datamoc/fengari
- Add README.md for the scoped package
…ial math order test

- lstrlib: add formatint() so %d/%i/%o/%u/%x/%X and string.pack/string.unpack
  handle 64-bit Lua integers correctly (signed for d/i, unsigned for o/u/x/X);
  rewrite packint/unpackint on BigInt with full-width sign handling and
  unread-byte validation; align string.packsize('j'/'J') and the dumper
  integer header on integer.SIZE; make Kint/Kuint overflow checks BigInt-safe.
- lmathlib: make integer overflow checks BigInt-safe.
- ltablib: guard table.unpack against overflow using LUAI_MAXSTACK instead of
  Number.MAX_SAFE_INTEGER.
- test/math: restore the official lua-5.5.1-tests 'if floatbits < intbits then
  ... else ... end' conditional in 'testing order'; the port had dropped it and
  hardcoded only the 'floats can represent all integers' branch, which cannot
  hold for 53-bit doubles vs 64-bit integers.
- test/loadfile: regenerate the stale loadfile-test.bc artifact, which no
  longer loaded with the current bytecode header.
- chore: drop the broken 'repository' block from package.json, remove unused
  imports flagged by eslint, refresh the docs/lua-5.5.1 baseline.
- Restrict the 'files' whitelist to src/; the previous whitelist shipped the
  whole test/ tree and the Lua 5.5.1 reference archives (170 files, 1.0 MB).
  The published tarball is now 66 files / 147 kB.
- Remove 'module' and 'types': the build script is still a placeholder, so
  dist/fengari.esm.js and dist/fengari.d.ts do not exist and would break
  bundlers and TypeScript resolution.
- Point the README's compliance-docs link at the GitHub repo, since docs/ is
  no longer part of the tarball.
The README and npm keywords advertised "full Lua 5.5.1 compliance" while
defs.js reports Lua 5.4.6 (LUA_VERSION_NUM 504) and none of the 5.5.1
alignment lots in docs/lua-5.5.1 is complete. The claims now describe
5.4.6 and point to the alignment work as in progress. CHANGES.md gains
the 0.1.6 section (extended lauxlib symbols, packaging fix, 64-bit
integer fixes already on master).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
lua_setallocf(L, allocf, ud) installs a hook called synchronously as
allocf(ud, nsize) before each internal Uint8Array allocation, charged at
the full new size. Throwing refuses the allocation: inside a protected
call the operation aborts with LUA_ERRMEM and the standard "not enough
memory" object, exactly like a failing C allocator, and the state stays
usable; a script-level pcall cannot swallow it. Outside any protected
call the embedder's own exception propagates unchanged.

The single choke point lives in src/lmem.js, a leaf module (the JS
analogue of lmem.c) so both the root libraries and the runtime factories
can reach it without breaking the composition order. Sites routed: the
luaL_Buffer growth (string.rep/gsub/format), ZIO buffer growth, the VM
concat, undump reads, and the small per-call temporaries that carry a
state. Not charged, by design and documented: the stateless defs.js
conversions an embedder drives itself, zero-size allocations, and the
few sites with no lua_State at hand. lua_getallocf(L) returns
[allocf, ud]; the console.warn stubs are gone.

Verified by test/lalloc.test.js (round-trip, observation, refusal,
pcall transparency, per-state isolation) and the full suite: 45 suites,
658 passing. Version 0.2.0.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
publish-npm.yml mirrors @datamoc/mw_games' proven path: triggered on a
published release (or a manual re-run), gated by the same lint and test
suite CI runs on every push, publishing with `npm stage publish` over the
OIDC exchange - no NPM_TOKEN secret, and the staged tarball still needs the
interactive 2FA approval a CI run cannot answer. The npmjs.org trusted
publisher for @datamoc/fengari must list this repo and workflow filename,
configured the way the mw_games one already is.

ci.yaml: installs from the frozen yarn.lock instead of a floating `yarn`,
runs the extended lauxlib suite as its own gate, and adds Node 26.x to the
matrix the publish job runs on. CHANGES.md's 0.2.0 section drops its
"En développement" marker now that the tag is cut.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
For a VM whose whole job is parsing untrusted input, the JavaScript
security queries are the one automated check worth adding beyond lint
and the suite. The scheduled variant is left out on purpose: scheduled
workflows are disabled on forks by default, so push and pull_request are
the live gates.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
setup-node's registry-url writes a ${NODE_AUTH_TOKEN} placeholder into
.npmrc; npm leaves the unset variable alone, but yarn 1 refuses to start
at all ("Failed to replace env in config"), which is what killed the
v0.2.0 publish run eleven seconds in. An empty NODE_AUTH_TOKEN on the
install step satisfies yarn's eager env replacement and changes nothing
for trusted publishing, which never uses NODE_AUTH_TOKEN.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
The per-step empty NODE_AUTH_TOKEN was not enough: every yarn invocation
re-reads npm config at startup and dies under the unexpanded placeholder,
so `yarn lint` failed the same way `yarn install` had. registry-url is
what makes setup-node write that placeholder, and it buys nothing here -
npm's default registry is already registry.npmjs.org - so the job now
writes no .npmrc at all and OIDC proceeds exactly as before.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
…, lua_closethread

The 0.2.0 notes claimed this support but no such program compiled.
const/close attributes (suffix and 5.5 prefix form), read-only checks,
to-be-closed variables closed on block exit, break, goto, return and error,
generic-for closing value, coroutine.close, lua_closethread/lua_toclose and
real lua_resetthread/lua_closeslot. A __close cannot yield.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…version

FengariVm gains Coroutine (resume/close), Yield from host functions,
libs/remove options, LuaRef/Userdata/LuaFloat, BigInt for 64-bit
integers, Uint8Array for binary strings, Map for exotic table keys,
runMulti/callMulti, and is now exported from the fengari entry point.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- FengariVm option `sandbox`: safe libraries only, no dofile/loadfile/
  string.dump, text-only load.
- FengariVm option `limits` / setLimits: instruction and wall-clock budgets
  per top-level call through a count hook; LimitError; not escapable with
  pcall or coroutines.
- Hardening: __proto__ keys, cross-VM references, package.loaded removal.
- docs/benchmark: browser page comparing Lua in Fengari with plain
  JavaScript, plus tools/build-benchmark.js (npm run bench:build).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s, benchmark page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…g, large allocations), isolate sandbox from host files

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…r, demo page

- lua2js / FengariVm.toJs: readable JavaScript from Lua source, with
  TODO(lua2js) markers and warnings for what cannot be translated
- Literal Lua patterns become RegExp calls
- docs/lua2js: paste Lua, get JS, benchmark against the interpreter
- Optional numeric compilation (vm.compile) and its benchmark
- Lua 5.5.1 migration docs and related VM updates

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A Lua line comment containing a bare CR, U+2028 or U+2029 ended the
generated // comment early, so the rest of the comment ran as code.
Replace those characters with spaces and add a regression test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- src/fengari.d.ts + "types" in package.json; test/types.test.js keeps
  the declarations and the runtime exports in sync
- vm.throwLuaError(L, index = -1); Coroutine#close uses it
- CHANGES: lua2js comment fix (previous commit) and these additions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lua errors raised through the C API inside the callback (failing __index,
luaL_error, limit overrun) become LuaError / LimitError and the stack is
restored; a JavaScript error from the callback is rethrown unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Hosts use these FengariVm members next to read/push. 0.5.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ons.node

luaconf.js required 'os' whenever `process` existed, and a bundler's
process shim also made ldblib.js load the empty readline-sync stub and
crash. One src/isnode.js replaces `typeof process` in 13 places,
os.platform() becomes process.platform, and package.json gets a
"browser" field mapping the Node-only modules to false. 0.5.3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e budgeted, `or` keeps Lua truth

- every host entry point fails with a LimitError when a limit was hit
  during the call, even if Lua caught the failure of a coroutine/pcall
- Coroutine#close() runs its __close handlers with a fresh budget
- call/callMulti/compile/coroutine resolve their global path in a
  protected call under the limits (an __index on _G could loop forever)
- lua2js: `a or b` is no longer `a ?? b` (differs for false); the old
  form is available as the nullishOr option / --nullish-or
- 0.5.4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… union

- sandbox: true no longer defines the __random helper (nothing in
  Fengari calls it); like __fs_read/__fs_list it stays outside the sandbox
- print: 'buffer' | 'native' | false, so a host that wants Lua's print does
  not re-open libraries (which restored string.dump)
- LuaRef.type is 'function' | 'thread' | 'userdata' (LuaRefType)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@giann

giann commented Oct 11, 2026

Copy link
Copy Markdown
Member

I don't know about you @daurnimator , but I'm not going to even consider this vibe-coded PR.

@daurnimator

Copy link
Copy Markdown
Member

I don't know about you @daurnimator , but I'm not going to even consider this vibe-coded PR.

Agreed. This does way too much for one PR in any case.

@datamoc start piecemeal and a PR should only solve one small problem at a time. We don't have an AI policy yet but you should be prepared to answer questions on why things were done a particular way without referring back to AI. i.e. you need to understand what your AI is suggesting at least as well as what you expect us to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants