├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .gitmodules ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── bench ├── comparisons.js ├── heapsort.lua └── steps.js ├── bin └── wasmoon ├── build.sh ├── eslint.config.js ├── package-lock.json ├── package.json ├── rolldown.config.ts ├── src ├── declarations.d.ts ├── decoration.ts ├── engine.ts ├── factory.ts ├── global.ts ├── index.ts ├── luawasm.ts ├── multireturn.ts ├── pointer.ts ├── raw-result.ts ├── thread.ts ├── type-extension.ts ├── type-extensions │ ├── error.ts │ ├── function.ts │ ├── null.ts │ ├── promise.ts │ ├── proxy.ts │ ├── table.ts │ └── userdata.ts ├── types.ts └── utils.ts ├── test ├── boot.js ├── debug.js ├── engine.test.js ├── filesystem.test.js ├── initialization.test.js ├── luatests.js ├── promises.test.js └── utils.js ├── tsconfig.json └── utils ├── create-bindings.js └── create-bindings.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ceifa] 2 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | submodules: recursive 15 | - uses: mymindstorm/setup-emsdk@v14 16 | - name: Use Node.js 22.x 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 22.x 20 | - run: npm ci 21 | - run: npm run lint:nofix 22 | - run: npm run build:wasm 23 | - run: npm run build 24 | - run: npm test 25 | - run: npm run luatests 26 | - uses: JS-DevTools/npm-publish@v3 27 | with: 28 | token: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [18, 20, 22] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | submodules: recursive 19 | - uses: mymindstorm/setup-emsdk@v12 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm run lint:nofix 26 | - run: npm run build:wasm 27 | - run: npm run build 28 | - run: npm test 29 | - run: npm run luatests 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | .rollup.cache/ 4 | tsconfig.tsbuildinfo 5 | build/ 6 | .eslintcache -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lua"] 2 | path = lua 3 | url = https://github.com/lua/lua 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .rollup.cache/ 4 | package-lock.json 5 | build/ 6 | .eslintrc.json 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "lf", 5 | "quoteProps": "consistent", 6 | "semi": false, 7 | "printWidth": 140, 8 | "tabWidth": 4 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "outputCapture": "std", 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Launch Program", 12 | "skipFiles": ["/**"], 13 | "program": "${workspaceFolder}/test/debug.js", 14 | "runtimeArgs": ["--inspect", "--expose-gc"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.insertFinalNewline": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gabriel Francisco 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/ceifa/wasmoon/actions/workflows/publish.yml/badge.svg)](https://github.com/ceifa/wasmoon/actions/workflows/publish.yml) 2 | [![npm](https://img.shields.io/npm/v/wasmoon.svg)](https://npmjs.com/package/wasmoon) 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | # Wasmoon 6 | 7 | This package aims to provide a way to: 8 | 9 | - Embed Lua to any Node.js, Deno or Web Application. 10 | - Run lua code in any operational system 11 | - Interop Lua and JS without memory leaks (including the DOM) 12 | 13 | ## API Usage 14 | 15 | To initialize, create a new Lua state, register the standard library, set a global variable, execute a code and get a global variable: 16 | 17 | ```js 18 | const { LuaFactory } = require('wasmoon') 19 | 20 | // Initialize a new lua environment factory 21 | // You can pass the wasm location as the first argument, useful if you are using wasmoon on a web environment and want to host the file by yourself 22 | const factory = new LuaFactory() 23 | // Create a standalone lua environment from the factory 24 | const lua = await factory.createEngine() 25 | 26 | try { 27 | // Set a JS function to be a global lua function 28 | lua.global.set('sum', (x, y) => x + y) 29 | // Run a lua string 30 | await lua.doString(` 31 | print(sum(10, 10)) 32 | function multiply(x, y) 33 | return x * y 34 | end 35 | `) 36 | // Get a global lua function as a JS function 37 | const multiply = lua.global.get('multiply') 38 | console.log(multiply(10, 10)) 39 | } finally { 40 | // Close the lua environment, so it can be freed 41 | lua.global.close() 42 | } 43 | ``` 44 | 45 | ## CLI Usage 46 | 47 | Although Wasmoon has been designed to be embedded, you can run it on command line as well, but, if you want something more robust on this, we recommend to take a look at [demoon](https://github.com/ceifa/demoon). 48 | 49 | ```sh 50 | $: wasmoon [options] [file] [args] 51 | ``` 52 | 53 | Available options are: 54 | 55 | - `-l`: Include a file or directory 56 | - `-i`: Enter interactive mode after running the files 57 | 58 | ### Example: 59 | 60 | ```sh 61 | $: wasmoon -i sum.lua 10 30 62 | ``` 63 | 64 | And if you are in Unix, you can also use it as a script interpreter with [Shebang](): 65 | 66 | ```lua 67 | #!/usr/bin/env wasmoon 68 | return arg[1] + arg[2] 69 | ``` 70 | 71 | ```sh 72 | $: ./sum.lua 10 30 73 | ``` 74 | 75 | ## When to use wasmoon and fengari 76 | 77 | Wasmoon compiles the [official Lua code](https://github.com/lua/lua) to webassembly and creates an abstraction layer to interop between Lua and JS, instead of [fengari](https://github.com/fengari-lua/fengari), that is an entire Lua VM rewritten in JS. 78 | 79 | ### Performance 80 | 81 | Because of wasm, wasmoon will run Lua code much faster than fengari, but if you are going to interop a lot between JS and Lua, this may be not be true anymore, you probably should test on you specific use case to take the prove. 82 | 83 | This is the results running a [heap sort code](https://github.com/ceifa/wasmoon/blob/main/bench/heapsort.lua) in a list of 2k numbers 10x(less is better): 84 | 85 | | wasmoon | fengari | 86 | | -------- | --------- | 87 | | 15.267ms | 389.923ms | 88 | 89 | ### Size 90 | 91 | Fengari is smaller than wasmoon, which can improve the user experience if in web environments: 92 | 93 | | | wasmoon | fengari | 94 | | ----------- | ------- | ------- | 95 | | **plain** | 393kB | 214kB | 96 | | **gzipped** | 130kB | 69kB | 97 | 98 | ## Fixing common errors on web environment 99 | 100 | Bundle/require errors can happen because wasmoon tries to safely import some node modules even in a browser environment, the bundler is not prepared to that since it tries to statically resolve everything on build time. 101 | Polyfilling these modules is not the right solution because they are not actually being used, you just have to ignore them: 102 | 103 | ### Webpack 104 | 105 | Add the `resolve.fallback` snippet to your config: 106 | 107 | ```js 108 | module.exports = { 109 | entry: './src/index.js', // Here is your entry file 110 | resolve: { 111 | fallback: { 112 | path: false, 113 | fs: false, 114 | child_process: false, 115 | crypto: false, 116 | url: false, 117 | module: false, 118 | }, 119 | }, 120 | } 121 | ``` 122 | 123 | ### Rollup 124 | 125 | With the package [rollup-plugin-ignore](https://www.npmjs.com/package/rollup-plugin-ignore), add this snippet to your config: 126 | 127 | ```js 128 | export default { 129 | input: 'src/index.js', // Here is your entry file, 130 | plugins: [ignore(['path', 'fs', 'child_process', 'crypto', 'url', 'module'])], 131 | } 132 | ``` 133 | 134 | ### Angular 135 | 136 | Add the section browser on `package.json`: 137 | 138 | ```json 139 | { 140 | "main": "src/index.js", 141 | "browser": { 142 | "child_process": false, 143 | "fs": false, 144 | "path": false, 145 | "crypto": false, 146 | "url": false, 147 | "module": false 148 | } 149 | } 150 | ``` 151 | 152 | ## How to build 153 | 154 | Firstly download the lua submodule and install the other Node.JS dependencies: 155 | 156 | ```sh 157 | git submodule update --init # download lua submodule 158 | npm i # install dependencies 159 | ``` 160 | 161 | ### Windows / Linux / MacOS (Docker way) 162 | 163 | You need to install [docker](https://www.docker.com/) and ensure it is on your `PATH`. 164 | 165 | After cloned the repo, to build you just have to run these: 166 | 167 | ```sh 168 | npm run build:wasm:docker:dev # build lua 169 | npm run build # build the js code/bridge 170 | npm test # ensure everything it's working fine 171 | ``` 172 | 173 | ### Ubuntu / Debian / MacOS 174 | 175 | You need to install [emscripten](https://emscripten.org/) and ensure it is on your `PATH`. 176 | 177 | After cloned the repo, to build you just have to run these: 178 | 179 | ```sh 180 | npm run build:wasm:dev # build lua 181 | npm run build # build the js code/bridge 182 | npm test # ensure everything it's working fine 183 | ``` 184 | 185 | ## Edge Cases 186 | 187 | ### Null 188 | 189 | `null` is injected as userdata type if `injectObjects` is set to `true`. This works as expected except that it will evaluate to `true` in Lua. 190 | 191 | ### Promises 192 | 193 | Promises can be await'd from Lua with some caveats detailed in the below section. To await a Promise call `:await()` on it which will yield the Lua execution until the promise completes. 194 | 195 | ```js 196 | const { LuaFactory } = require('wasmoon') 197 | const factory = new LuaFactory() 198 | const lua = await factory.createEngine() 199 | 200 | try { 201 | lua.global.set('sleep', (length) => new Promise((resolve) => setTimeout(resolve, length))) 202 | await lua.doString(` 203 | sleep(1000):await() 204 | `) 205 | } finally { 206 | lua.global.close() 207 | } 208 | ``` 209 | 210 | ### Async/Await 211 | 212 | It's not possible to await in a callback from JS into Lua. This is a limitation of Lua but there are some workarounds. It can also be encountered when yielding at the top-level of a file. An example where you might encounter this is a snippet like this: 213 | 214 | ```js 215 | local res = sleep(1):next(function () 216 | sleep(10):await() 217 | return 15 218 | end) 219 | print("res", res:await()) 220 | ``` 221 | 222 | Which will throw an error like this: 223 | 224 | ``` 225 | Error: Lua Error(ErrorRun/2): cannot resume dead coroutine 226 | at Thread.assertOk (/home/tstableford/projects/wasmoon/dist/index.js:409:23) 227 | at Thread. (/home/tstableford/projects/wasmoon/dist/index.js:142:22) 228 | at Generator.throw () 229 | at rejected (/home/tstableford/projects/wasmoon/dist/index.js:26:69) 230 | ``` 231 | 232 | Or like this: 233 | 234 | ``` 235 | attempt to yield across a C-call boundary 236 | ``` 237 | 238 | You can workaround this by doing something like below: 239 | 240 | ```lua 241 | function async(callback) 242 | return function(...) 243 | local co = coroutine.create(callback) 244 | local safe, result = coroutine.resume(co, ...) 245 | 246 | return Promise.create(function(resolve, reject) 247 | local function step() 248 | if coroutine.status(co) == "dead" then 249 | local send = safe and resolve or reject 250 | return send(result) 251 | end 252 | 253 | safe, result = coroutine.resume(co) 254 | 255 | if safe and result == Promise.resolve(result) then 256 | result:finally(step) 257 | else 258 | step() 259 | end 260 | end 261 | 262 | result:finally(step) 263 | end) 264 | end 265 | end 266 | ``` 267 | -------------------------------------------------------------------------------- /bench/comparisons.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs') 2 | const path = require('path') 3 | 4 | const fengari = require('fengari') 5 | const wasmoon = require('../dist/index') 6 | 7 | const heapsort = readFileSync(path.resolve(__dirname, 'heapsort.lua'), 'utf-8') 8 | 9 | const startFengari = () => { 10 | const state = fengari.lauxlib.luaL_newstate() 11 | fengari.lualib.luaL_openlibs(state) 12 | 13 | console.time('Fengari') 14 | fengari.lauxlib.luaL_loadstring(state, fengari.to_luastring(heapsort)) 15 | fengari.lua.lua_callk(state, 0, 1, 0, null) 16 | fengari.lua.lua_callk(state, 0, 0, 0, null) 17 | console.timeEnd('Fengari') 18 | } 19 | 20 | const startWasmoon = async () => { 21 | const state = await new wasmoon.LuaFactory().createEngine() 22 | 23 | console.time('Wasmoon') 24 | state.global.lua.luaL_loadstring(state.global.address, heapsort) 25 | state.global.lua.lua_callk(state.global.address, 0, 1, 0, null) 26 | state.global.lua.lua_callk(state.global.address, 0, 0, 0, null) 27 | console.timeEnd('Wasmoon') 28 | } 29 | 30 | Promise.resolve().then(startFengari).then(startWasmoon).catch(console.error) 31 | -------------------------------------------------------------------------------- /bench/heapsort.lua: -------------------------------------------------------------------------------- 1 | function heapsort(n, ra) 2 | local j, i, rra 3 | local l = math.floor(n / 2) + 1 4 | local ir = n 5 | 6 | while 1 do 7 | if l > 1 then 8 | l = l - 1 9 | rra = ra[l] 10 | else 11 | rra = ra[ir] 12 | ra[ir] = ra[1] 13 | ir = ir - 1 14 | 15 | if (ir == 1) then 16 | ra[1] = rra 17 | 18 | return 19 | end 20 | end 21 | 22 | i = l 23 | j = l * 2 24 | 25 | while j <= ir do 26 | if j < ir and ra[j] < ra[j + 1] then 27 | j = j + 1 28 | end 29 | 30 | if rra < ra[j] then 31 | ra[i] = ra[j] 32 | i = j 33 | j = j + i 34 | else 35 | j = ir + 1 36 | end 37 | end 38 | 39 | ra[i] = rra 40 | end 41 | end 42 | 43 | return function() 44 | local Num = 10 45 | 46 | for i = 1, Num do 47 | local N = 2000 48 | local a = {} 49 | 50 | for i = 1, N do 51 | a[i] = math.random() 52 | end 53 | 54 | heapsort(N, a) 55 | 56 | for i = 1, N - 1 do 57 | assert(a[i] <= a[i + 1]) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /bench/steps.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs') 2 | const path = require('path') 3 | 4 | const wasmoon = require('../dist/index') 5 | 6 | const heapsort = readFileSync(path.resolve(__dirname, 'heapsort.lua'), 'utf-8') 7 | 8 | const createFactory = () => { 9 | console.time('Create factory') 10 | _ = new wasmoon.LuaFactory() 11 | console.timeEnd('Create factory') 12 | } 13 | 14 | const loadWasm = async () => { 15 | console.time('Load wasm') 16 | await new wasmoon.LuaFactory().getLuaModule() 17 | console.timeEnd('Load wasm') 18 | } 19 | 20 | const createEngine = async () => { 21 | const factory = new wasmoon.LuaFactory() 22 | 23 | console.time('Create engine') 24 | await factory.createEngine() 25 | console.timeEnd('Create engine') 26 | } 27 | 28 | const createEngineWithoutSuperpowers = async () => { 29 | const factory = new wasmoon.LuaFactory() 30 | 31 | console.time('Create engine without superpowers') 32 | await factory.createEngine({ 33 | injectObjects: false, 34 | enableProxy: false, 35 | openStandardLibs: false, 36 | }) 37 | console.timeEnd('Create engine without superpowers') 38 | } 39 | 40 | const runHeapsort = async () => { 41 | const state = await new wasmoon.LuaFactory().createEngine() 42 | 43 | console.time('Run plain heapsort') 44 | state.global.lua.luaL_loadstring(state.global.address, heapsort) 45 | state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null) 46 | state.global.lua.lua_pcallk(state.global.address, 0, 0, 0, 0, null) 47 | console.timeEnd('Run plain heapsort') 48 | } 49 | 50 | const runInteropedHeapsort = async () => { 51 | const state = await new wasmoon.LuaFactory().createEngine() 52 | 53 | console.time('Run interoped heapsort') 54 | const runHeapsort = await state.doString(heapsort) 55 | runHeapsort() 56 | console.timeEnd('Run interoped heapsort') 57 | } 58 | 59 | const insertComplexObjects = async () => { 60 | const state = await new wasmoon.LuaFactory().createEngine() 61 | const obj1 = { 62 | hello: 'world', 63 | } 64 | obj1.self = obj1 65 | const obj2 = { 66 | hello: 'everybody', 67 | array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 68 | fn: () => { 69 | return 'hello' 70 | }, 71 | } 72 | obj2.self = obj2 73 | 74 | console.time('Insert complex objects') 75 | state.global.set('obj', { obj1, obj2 }) 76 | console.timeEnd('Insert complex objects') 77 | } 78 | 79 | const insertComplexObjectsWithoutProxy = async () => { 80 | const state = await new wasmoon.LuaFactory().createEngine({ 81 | enableProxy: false, 82 | }) 83 | const obj1 = { 84 | hello: 'world', 85 | } 86 | obj1.self = obj1 87 | const obj2 = { 88 | hello: 'everybody', 89 | array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 90 | fn: () => { 91 | return 'hello' 92 | }, 93 | } 94 | obj2.self = obj2 95 | 96 | console.time('Insert complex objects without proxy') 97 | state.global.set('obj', { obj1, obj2 }) 98 | console.timeEnd('Insert complex objects without proxy') 99 | } 100 | 101 | const getComplexObjects = async () => { 102 | const state = await new wasmoon.LuaFactory().createEngine() 103 | await state.doString(` 104 | local obj1 = { 105 | hello = 'world', 106 | } 107 | obj1.self = obj1 108 | local obj2 = { 109 | 5, 110 | hello = 'everybody', 111 | array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 112 | fn = function() 113 | return 'hello' 114 | end 115 | } 116 | obj2.self = obj2 117 | obj = { obj1, obj2 } 118 | `) 119 | 120 | console.time('Get complex objects') 121 | state.global.get('obj') 122 | console.timeEnd('Get complex objects') 123 | } 124 | 125 | Promise.resolve() 126 | .then(createFactory) 127 | .then(loadWasm) 128 | .then(createEngine) 129 | .then(createEngineWithoutSuperpowers) 130 | .then(runHeapsort) 131 | .then(runInteropedHeapsort) 132 | .then(insertComplexObjects) 133 | .then(insertComplexObjectsWithoutProxy) 134 | .then(getComplexObjects) 135 | .catch(console.error) 136 | -------------------------------------------------------------------------------- /bin/wasmoon: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } = require('../dist') 3 | const fs = require('fs') 4 | const path = require('path') 5 | const readline = require('readline') 6 | 7 | async function* walk(dir) { 8 | const dirents = await fs.promises.readdir(dir, { withFileTypes: true }) 9 | for (const dirent of dirents) { 10 | const res = path.resolve(dir, dirent.name) 11 | if (dirent.isDirectory()) { 12 | yield* walk(res) 13 | } else { 14 | yield res 15 | } 16 | } 17 | } 18 | 19 | async function main() { 20 | const factory = new LuaFactory() 21 | const luamodule = await factory.getLuaModule() 22 | const lua = await factory.createEngine() 23 | 24 | let snippets = process.argv.splice(2) 25 | 26 | const consumeOption = (option, single) => { 27 | let i = -1 28 | const values = [] 29 | while ((i = snippets.indexOf(option)) >= 0) { 30 | values.push(snippets.splice(i, single ? 1 : 2).reverse()[0]) 31 | } 32 | return values 33 | } 34 | 35 | const includes = consumeOption('-l') 36 | const forceInteractive = consumeOption('-i', true).length > 0 37 | const runFile = process.stdin.isTTY && consumeOption(snippets[0], true)[0] 38 | const args = snippets 39 | 40 | for (const include of includes) { 41 | const relativeInclude = path.resolve(process.cwd(), include) 42 | const stat = await fs.promises.lstat(relativeInclude) 43 | if (stat.isFile()) { 44 | await factory.mountFile(relativeInclude, await fs.promises.readFile(relativeInclude)) 45 | } else { 46 | for await (const file of walk(relativeInclude)) { 47 | await factory.mountFile(file, await fs.promises.readFile(file)) 48 | } 49 | } 50 | } 51 | 52 | lua.global.set('arg', decorate(args, { disableProxy: true })) 53 | 54 | const interactive = process.stdin.isTTY && (forceInteractive || !runFile) 55 | 56 | if (runFile) { 57 | const relativeRunFile = path.resolve(process.cwd(), runFile) 58 | await factory.mountFile(relativeRunFile, await fs.promises.readFile(relativeRunFile)) 59 | 60 | await lua.doFile(relativeRunFile) 61 | console.log(lua.global.indexToString(-1)) 62 | } 63 | 64 | if (!interactive && runFile) { 65 | return 66 | } 67 | 68 | if (interactive) { 69 | // Call directly from module to bypass the result verification 70 | const loadcode = (code) => !lua.global.setTop(0) && luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok 71 | 72 | const version = require('../package.json').version 73 | console.log('Welcome to Wasmoon v' + version) 74 | 75 | const rl = readline.createInterface({ 76 | input: process.stdin, 77 | output: process.stdout, 78 | terminal: true, 79 | removeHistoryDuplicates: true, 80 | }) 81 | 82 | rl.prompt() 83 | 84 | for await (const line of rl) { 85 | const loaded = loadcode(line) || loadcode(`return ${line}`) 86 | if (!loaded) { 87 | console.log(lua.global.getValue(-1, LuaType.String)) 88 | rl.prompt() 89 | continue 90 | } 91 | 92 | const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) 93 | if (result === LuaReturn.Ok) { 94 | const returnValues = Array.from({ length: lua.global.getTop() }).map((_, i) => lua.global.indexToString(i + 1)) 95 | 96 | if (returnValues.length) { 97 | console.log(...returnValues) 98 | } 99 | } else { 100 | console.log(lua.global.getValue(-1, LuaType.String)) 101 | } 102 | 103 | rl.prompt() 104 | } 105 | } else { 106 | await lua.doString(fs.readFileSync(0, 'utf-8')) 107 | console.log(lua.global.indexToString(-1)) 108 | } 109 | } 110 | 111 | main().catch((err) => { 112 | console.error(err) 113 | process.exit(1) 114 | }) 115 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | cd $(dirname $0) 3 | mkdir -p build 4 | 5 | LUA_SRC=$(ls ./lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") 6 | 7 | extension="" 8 | if [ "$1" == "dev" ]; 9 | then 10 | extension="-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2" 11 | else 12 | extension="-O3" 13 | fi 14 | 15 | emcc \ 16 | -s WASM=1 $extension -o ./build/glue.js \ 17 | -s EXPORTED_RUNTIME_METHODS="[ 18 | 'ccall', \ 19 | 'addFunction', \ 20 | 'removeFunction', \ 21 | 'FS', \ 22 | 'ENV', \ 23 | 'getValue', \ 24 | 'setValue', \ 25 | 'lengthBytesUTF8', \ 26 | 'stringToUTF8', \ 27 | 'stringToNewUTF8' 28 | ]" \ 29 | -s INCOMING_MODULE_JS_API="[ 30 | 'locateFile', \ 31 | 'preRun' 32 | ]" \ 33 | -s ENVIRONMENT="web,worker,node" \ 34 | -s MODULARIZE=1 \ 35 | -s ALLOW_TABLE_GROWTH=1 \ 36 | -s EXPORT_NAME="initWasmModule" \ 37 | -s ALLOW_MEMORY_GROWTH=1 \ 38 | -s STRICT=1 \ 39 | -s EXPORT_ES6=1 \ 40 | -s NODEJS_CATCH_EXIT=0 \ 41 | -s NODEJS_CATCH_REJECTION=0 \ 42 | -s MALLOC=emmalloc \ 43 | -s STACK_SIZE=1MB \ 44 | -s WASM_BIGINT \ 45 | -s EXPORTED_FUNCTIONS="[ 46 | '_malloc', \ 47 | '_free', \ 48 | '_realloc', \ 49 | '_luaL_checkversion_', \ 50 | '_luaL_getmetafield', \ 51 | '_luaL_callmeta', \ 52 | '_luaL_tolstring', \ 53 | '_luaL_argerror', \ 54 | '_luaL_typeerror', \ 55 | '_luaL_checklstring', \ 56 | '_luaL_optlstring', \ 57 | '_luaL_checknumber', \ 58 | '_luaL_optnumber', \ 59 | '_luaL_checkinteger', \ 60 | '_luaL_optinteger', \ 61 | '_luaL_checkstack', \ 62 | '_luaL_checktype', \ 63 | '_luaL_checkany', \ 64 | '_luaL_newmetatable', \ 65 | '_luaL_setmetatable', \ 66 | '_luaL_testudata', \ 67 | '_luaL_checkudata', \ 68 | '_luaL_where', \ 69 | '_luaL_fileresult', \ 70 | '_luaL_execresult', \ 71 | '_luaL_ref', \ 72 | '_luaL_unref', \ 73 | '_luaL_loadfilex', \ 74 | '_luaL_loadbufferx', \ 75 | '_luaL_loadstring', \ 76 | '_luaL_newstate', \ 77 | '_luaL_len', \ 78 | '_luaL_addgsub', \ 79 | '_luaL_gsub', \ 80 | '_luaL_setfuncs', \ 81 | '_luaL_getsubtable', \ 82 | '_luaL_traceback', \ 83 | '_luaL_requiref', \ 84 | '_luaL_buffinit', \ 85 | '_luaL_prepbuffsize', \ 86 | '_luaL_addlstring', \ 87 | '_luaL_addstring', \ 88 | '_luaL_addvalue', \ 89 | '_luaL_pushresult', \ 90 | '_luaL_pushresultsize', \ 91 | '_luaL_buffinitsize', \ 92 | '_lua_newstate', \ 93 | '_lua_close', \ 94 | '_lua_newthread', \ 95 | '_lua_resetthread', \ 96 | '_lua_atpanic', \ 97 | '_lua_version', \ 98 | '_lua_absindex', \ 99 | '_lua_gettop', \ 100 | '_lua_settop', \ 101 | '_lua_pushvalue', \ 102 | '_lua_rotate', \ 103 | '_lua_copy', \ 104 | '_lua_checkstack', \ 105 | '_lua_xmove', \ 106 | '_lua_isnumber', \ 107 | '_lua_isstring', \ 108 | '_lua_iscfunction', \ 109 | '_lua_isinteger', \ 110 | '_lua_isuserdata', \ 111 | '_lua_type', \ 112 | '_lua_typename', \ 113 | '_lua_tonumberx', \ 114 | '_lua_tointegerx', \ 115 | '_lua_toboolean', \ 116 | '_lua_tolstring', \ 117 | '_lua_rawlen', \ 118 | '_lua_tocfunction', \ 119 | '_lua_touserdata', \ 120 | '_lua_tothread', \ 121 | '_lua_topointer', \ 122 | '_lua_arith', \ 123 | '_lua_rawequal', \ 124 | '_lua_compare', \ 125 | '_lua_pushnil', \ 126 | '_lua_pushnumber', \ 127 | '_lua_pushinteger', \ 128 | '_lua_pushlstring', \ 129 | '_lua_pushstring', \ 130 | '_lua_pushcclosure', \ 131 | '_lua_pushboolean', \ 132 | '_lua_pushlightuserdata', \ 133 | '_lua_pushthread', \ 134 | '_lua_getglobal', \ 135 | '_lua_gettable', \ 136 | '_lua_getfield', \ 137 | '_lua_geti', \ 138 | '_lua_rawget', \ 139 | '_lua_rawgeti', \ 140 | '_lua_rawgetp', \ 141 | '_lua_createtable', \ 142 | '_lua_newuserdatauv', \ 143 | '_lua_getmetatable', \ 144 | '_lua_getiuservalue', \ 145 | '_lua_setglobal', \ 146 | '_lua_settable', \ 147 | '_lua_setfield', \ 148 | '_lua_seti', \ 149 | '_lua_rawset', \ 150 | '_lua_rawseti', \ 151 | '_lua_rawsetp', \ 152 | '_lua_setmetatable', \ 153 | '_lua_setiuservalue', \ 154 | '_lua_callk', \ 155 | '_lua_pcallk', \ 156 | '_lua_load', \ 157 | '_lua_dump', \ 158 | '_lua_yieldk', \ 159 | '_lua_resume', \ 160 | '_lua_status', \ 161 | '_lua_isyieldable', \ 162 | '_lua_setwarnf', \ 163 | '_lua_warning', \ 164 | '_lua_error', \ 165 | '_lua_next', \ 166 | '_lua_concat', \ 167 | '_lua_len', \ 168 | '_lua_stringtonumber', \ 169 | '_lua_getallocf', \ 170 | '_lua_setallocf', \ 171 | '_lua_toclose', \ 172 | '_lua_closeslot', \ 173 | '_lua_getstack', \ 174 | '_lua_getinfo', \ 175 | '_lua_getlocal', \ 176 | '_lua_setlocal', \ 177 | '_lua_getupvalue', \ 178 | '_lua_setupvalue', \ 179 | '_lua_upvalueid', \ 180 | '_lua_upvaluejoin', \ 181 | '_lua_sethook', \ 182 | '_lua_gethook', \ 183 | '_lua_gethookmask', \ 184 | '_lua_gethookcount', \ 185 | '_lua_setcstacklimit', \ 186 | '_luaopen_base', \ 187 | '_luaopen_coroutine', \ 188 | '_luaopen_table', \ 189 | '_luaopen_io', \ 190 | '_luaopen_os', \ 191 | '_luaopen_string', \ 192 | '_luaopen_utf8', \ 193 | '_luaopen_math', \ 194 | '_luaopen_debug', \ 195 | '_luaopen_package', \ 196 | '_luaL_openlibs' \ 197 | ]" \ 198 | ${LUA_SRC} 199 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tsParser from '@typescript-eslint/parser' 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' 4 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default [ 8 | { 9 | ignores: ['**/dist/*', '**/build/*', '**/rolldown.config.ts', '**/utils/*', 'eslint.config.js'], 10 | }, 11 | eslint.configs.recommended, 12 | ...tseslint.configs.recommended, 13 | eslintPluginPrettierRecommended, 14 | { 15 | files: ['test/**/*.js', 'bench/**/*.js'], 16 | rules: { 17 | '@typescript-eslint/no-var-requires': 'off', 18 | '@typescript-eslint/no-require-imports': 'off', 19 | '@typescript-eslint/explicit-function-return-type': 'off', 20 | '@typescript-eslint/no-unsafe-argument': 'off', 21 | 'no-undef': 'off', 22 | }, 23 | }, 24 | { 25 | files: ['**/*.js', '**/*.mjs', '**/*.ts'], 26 | ignores: ['**/test/*', '**/bench/*'], 27 | plugins: { 28 | 'simple-import-sort': simpleImportSort, 29 | }, 30 | languageOptions: { 31 | parser: tsParser, 32 | ecmaVersion: 'latest', 33 | sourceType: 'script', 34 | parserOptions: { 35 | project: './tsconfig.json', 36 | }, 37 | }, 38 | rules: { 39 | 'no-console': 'off', 40 | '@typescript-eslint/no-unsafe-call': 'off', 41 | '@typescript-eslint/no-unsafe-member-access': 'off', 42 | '@typescript-eslint/no-unsafe-assignment': 'off', 43 | '@typescript-eslint/no-unsafe-return': 'off', 44 | '@typescript-eslint/restrict-plus-operands': 'off', 45 | '@typescript-eslint/restrict-template-expressions': 'off', 46 | '@typescript-eslint/no-non-null-assertion': 'off', 47 | '@typescript-eslint/camelcase': 'off', 48 | '@typescript-eslint/member-naming': 'off', 49 | 50 | '@typescript-eslint/no-unused-vars': [ 51 | 'error', 52 | { 53 | argsIgnorePattern: '^_', 54 | }, 55 | ], 56 | 57 | '@typescript-eslint/member-ordering': [ 58 | 'error', 59 | { 60 | classes: [ 61 | 'public-static-field', 62 | 'protected-static-field', 63 | 'private-static-field', 64 | 'public-static-method', 65 | 'protected-static-method', 66 | 'private-static-method', 67 | 'public-instance-field', 68 | 'protected-instance-field', 69 | 'private-instance-field', 70 | 'public-constructor', 71 | 'protected-constructor', 72 | 'private-constructor', 73 | 'public-instance-method', 74 | 'protected-instance-method', 75 | 'private-instance-method', 76 | ], 77 | }, 78 | ], 79 | 80 | 'curly': ['error', 'all'], 81 | 'eqeqeq': 'error', 82 | 'max-classes-per-file': 'error', 83 | 'no-alert': 'error', 84 | 'no-caller': 'error', 85 | 'no-eval': 'error', 86 | 'no-extend-native': 'error', 87 | 'no-extra-bind': 'error', 88 | 'no-implicit-coercion': 'error', 89 | 'no-labels': 'error', 90 | 'no-new': 'error', 91 | 'no-new-func': 'error', 92 | 'no-new-wrappers': 'error', 93 | 'no-octal-escape': 'error', 94 | 'no-return-assign': 'error', 95 | 'no-self-compare': 'error', 96 | 'no-sequences': 'error', 97 | 'no-throw-literal': 'error', 98 | 'no-unmodified-loop-condition': 'error', 99 | 'no-useless-call': 'error', 100 | 'no-useless-concat': 'error', 101 | 'no-void': 'error', 102 | 'prefer-promise-reject-errors': 'error', 103 | 'radix': ['error', 'always'], 104 | 'no-shadow': 'off', 105 | 106 | 'no-duplicate-imports': 'error', 107 | 'prefer-numeric-literals': 'error', 108 | 'prefer-template': 'error', 109 | 'symbol-description': 'error', 110 | 111 | '@typescript-eslint/array-type': [ 112 | 'error', 113 | { 114 | default: 'array-simple', 115 | }, 116 | ], 117 | 118 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], 119 | 120 | '@typescript-eslint/explicit-function-return-type': [ 121 | 'error', 122 | { 123 | allowExpressions: true, 124 | allowTypedFunctionExpressions: true, 125 | allowHigherOrderFunctions: true, 126 | }, 127 | ], 128 | 129 | '@typescript-eslint/interface-name-prefix': 'off', 130 | '@typescript-eslint/no-redundant-type-constituents': 'off', 131 | '@typescript-eslint/no-explicit-any': 'off', 132 | '@typescript-eslint/no-floating-promises': 'error', 133 | '@typescript-eslint/no-parameter-properties': 'off', 134 | '@typescript-eslint/no-require-imports': 'error', 135 | '@typescript-eslint/no-useless-constructor': 'error', 136 | '@typescript-eslint/prefer-for-of': 'error', 137 | 'prettier/prettier': 'error', 138 | }, 139 | }, 140 | ] 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wasmoon", 3 | "version": "1.16.0", 4 | "description": "A real lua VM with JS bindings made with webassembly", 5 | "main": "./dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build:wasm:dev": "./build.sh dev", 9 | "build:wasm": "./build.sh", 10 | "build:wasm:docker:dev": "docker run --rm -v $(pwd):/wasmoon emscripten/emsdk /wasmoon/build.sh dev", 11 | "build:wasm:docker": "docker run --rm -v $(pwd):/wasmoon emscripten/emsdk /wasmoon/build.sh", 12 | "start": "rolldown -w -c", 13 | "test": "mocha --parallel --require ./test/boot.js test/*.test.js", 14 | "luatests": "node --experimental-import-meta-resolve test/luatests.js", 15 | "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", 16 | "clean": "rm -rf dist build", 17 | "lint": "prettier --write . && eslint . --fix --cache", 18 | "lint:nofix": "eslint ." 19 | }, 20 | "files": [ 21 | "bin/*", 22 | "dist/*" 23 | ], 24 | "bin": { 25 | "wasmoon": "bin/wasmoon" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/ceifa/wasmoon.git" 30 | }, 31 | "author": "ceifa", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/ceifa/wasmoon/issues" 35 | }, 36 | "homepage": "https://github.com/ceifa/wasmoon#readme", 37 | "keywords": [ 38 | "lua", 39 | "moon", 40 | "wasm", 41 | "webassembly" 42 | ], 43 | "devDependencies": { 44 | "@eslint/js": "9.17.0", 45 | "@types/node": "22.10.2", 46 | "@typescript-eslint/parser": "8.18.2", 47 | "chai": "5.1.2", 48 | "chai-as-promised": "8.0.1", 49 | "eslint": "9.17.0", 50 | "eslint-config-prettier": "9.1.0", 51 | "eslint-plugin-prettier": "5.2.1", 52 | "eslint-plugin-simple-import-sort": "12.1.1", 53 | "fengari": "0.1.4", 54 | "mocha": "11.0.1", 55 | "prettier": "3.4.2", 56 | "rolldown": "1.0.0-beta.1-commit.7c52c94", 57 | "rollup-plugin-copy": "3.5.0", 58 | "tslib": "2.8.1", 59 | "typescript": "5.7.2", 60 | "typescript-eslint": "8.18.2" 61 | }, 62 | "dependencies": { 63 | "@types/emscripten": "1.39.10" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /rolldown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'rolldown' 2 | import copy from 'rollup-plugin-copy' 3 | import pkg from './package.json' with { type: 'json' } 4 | 5 | const production = !process.env.ROLLUP_WATCH 6 | 7 | export default defineConfig({ 8 | input: './src/index.ts', 9 | output: { 10 | file: 'dist/index.js', 11 | format: 'esm', 12 | sourcemap: true, 13 | minify: production, 14 | }, 15 | external: ['module'], 16 | define: { 17 | // Webpack workaround: https://github.com/webpack/webpack/issues/16878 18 | 'import.meta': 'Object(import.meta)', 19 | }, 20 | plugins: [ 21 | { 22 | name: 'package-version', 23 | resolveId(source) { 24 | if (source === 'package-version') { 25 | return 'package-version' 26 | } 27 | }, 28 | load(id) { 29 | if (id === 'package-version') { 30 | return `export default '${pkg.version}'` 31 | } 32 | }, 33 | }, 34 | copy({ 35 | targets: [{ src: 'build/glue.wasm', dest: 'dist' }], 36 | }), 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.wasm' { 2 | const value: string 3 | export default value 4 | } 5 | 6 | declare module '*.js' 7 | 8 | declare module 'package-version' { 9 | const value: string 10 | export default value 11 | } 12 | -------------------------------------------------------------------------------- /src/decoration.ts: -------------------------------------------------------------------------------- 1 | export interface BaseDecorationOptions { 2 | metatable?: Record 3 | } 4 | 5 | export class Decoration { 6 | public constructor( 7 | public target: T, 8 | public options: K, 9 | ) {} 10 | } 11 | 12 | export function decorate(target: unknown, options: T): Decoration { 13 | return new Decoration(target, options) 14 | } 15 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import { CreateEngineOptions } from './types' 2 | import Global from './global' 3 | import type LuaWasm from './luawasm' 4 | import Thread from './thread' 5 | import createErrorType from './type-extensions/error' 6 | import createFunctionType from './type-extensions/function' 7 | import createNullType from './type-extensions/null' 8 | import createPromiseType from './type-extensions/promise' 9 | import createProxyType from './type-extensions/proxy' 10 | import createTableType from './type-extensions/table' 11 | import createUserdataType from './type-extensions/userdata' 12 | 13 | export default class LuaEngine { 14 | public global: Global 15 | 16 | public constructor( 17 | private cmodule: LuaWasm, 18 | { 19 | openStandardLibs = true, 20 | injectObjects = false, 21 | enableProxy = true, 22 | traceAllocations = false, 23 | functionTimeout = undefined as number | undefined, 24 | }: CreateEngineOptions = {}, 25 | ) { 26 | this.global = new Global(this.cmodule, traceAllocations) 27 | 28 | // Generic handlers - These may be required to be registered for additional types. 29 | this.global.registerTypeExtension(0, createTableType(this.global)) 30 | this.global.registerTypeExtension(0, createFunctionType(this.global, { functionTimeout })) 31 | 32 | // Contains the :await functionality. 33 | this.global.registerTypeExtension(1, createPromiseType(this.global, injectObjects)) 34 | 35 | if (injectObjects) { 36 | // Should be higher priority than table since that catches generic objects along 37 | // with userdata so it doesn't end up a userdata type. 38 | this.global.registerTypeExtension(5, createNullType(this.global)) 39 | } 40 | 41 | if (enableProxy) { 42 | // This extension only really overrides tables and arrays. 43 | // When a function is looked up in one of it's tables it's bound and then 44 | // handled by the function type extension. 45 | this.global.registerTypeExtension(3, createProxyType(this.global)) 46 | } else { 47 | // No need to register this when the proxy is enabled. 48 | this.global.registerTypeExtension(1, createErrorType(this.global, injectObjects)) 49 | } 50 | 51 | // Higher priority than proxied objects to allow custom user data without exposing methods. 52 | this.global.registerTypeExtension(4, createUserdataType(this.global)) 53 | 54 | if (openStandardLibs) { 55 | this.cmodule.luaL_openlibs(this.global.address) 56 | } 57 | } 58 | 59 | /** 60 | * Executes Lua code from a string asynchronously. 61 | * @param script - Lua script to execute. 62 | * @returns A Promise that resolves to the result returned by the Lua script execution. 63 | */ 64 | public doString(script: string): Promise { 65 | return this.callByteCode((thread) => thread.loadString(script)) 66 | } 67 | 68 | /** 69 | * Executes Lua code from a file asynchronously. 70 | * @param filename - Path to the Lua script file. 71 | * @returns - A Promise that resolves to the result returned by the Lua script execution. 72 | */ 73 | public doFile(filename: string): Promise { 74 | return this.callByteCode((thread) => thread.loadFile(filename)) 75 | } 76 | 77 | /** 78 | * Executes Lua code from a string synchronously. 79 | * @param script - Lua script to execute. 80 | * @returns - The result returned by the Lua script. 81 | */ 82 | public doStringSync(script: string): any { 83 | this.global.loadString(script) 84 | const result = this.global.runSync() 85 | return result[0] 86 | } 87 | 88 | /** 89 | * Executes Lua code from a file synchronously. 90 | * @param filename - Path to the Lua script file. 91 | * @returns - The result returned by the Lua script. 92 | */ 93 | public doFileSync(filename: string): any { 94 | this.global.loadFile(filename) 95 | const result = this.global.runSync() 96 | return result[0] 97 | } 98 | 99 | // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed 100 | private async callByteCode(loader: (thread: Thread) => void): Promise { 101 | const thread = this.global.newThread() 102 | const threadIndex = this.global.getTop() 103 | try { 104 | loader(thread) 105 | const result = await thread.run(0) 106 | if (result.length > 0) { 107 | // The shenanigans here are to return the first result value on the stack. 108 | // Say there's 2 values at stack indexes 1 and 2. Then top is 2, result.length is 2. 109 | // That's why there's a + 1 sitting at the end. 110 | return thread.getValue(thread.getTop() - result.length + 1) 111 | } 112 | return undefined 113 | } finally { 114 | // Pop the read on success or failure 115 | this.global.remove(threadIndex) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/factory.ts: -------------------------------------------------------------------------------- 1 | // A rollup plugin will resolve this to the current version on package.json 2 | import version from 'package-version' 3 | import LuaEngine from './engine' 4 | import LuaWasm from './luawasm' 5 | import { CreateEngineOptions, EnvironmentVariables } from './types' 6 | 7 | /** 8 | * Represents a factory for creating and configuring Lua engines. 9 | */ 10 | export default class LuaFactory { 11 | private luaWasmPromise: Promise 12 | 13 | /** 14 | * Constructs a new LuaFactory instance. 15 | * @param [customWasmUri] - Custom URI for the Lua WebAssembly module. 16 | * @param [environmentVariables] - Environment variables for the Lua engine. 17 | */ 18 | public constructor(customWasmUri?: string, environmentVariables?: EnvironmentVariables) { 19 | if (customWasmUri === undefined) { 20 | const isBrowser = 21 | (typeof window === 'object' && typeof window.document !== 'undefined') || 22 | (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') 23 | 24 | if (isBrowser) { 25 | customWasmUri = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` 26 | } 27 | } 28 | 29 | this.luaWasmPromise = LuaWasm.initialize(customWasmUri, environmentVariables) 30 | } 31 | 32 | /** 33 | * Mounts a file in the Lua environment asynchronously. 34 | * @param path - Path to the file in the Lua environment. 35 | * @param content - Content of the file to be mounted. 36 | * @returns - A Promise that resolves once the file is mounted. 37 | */ 38 | public async mountFile(path: string, content: string | ArrayBufferView): Promise { 39 | this.mountFileSync(await this.getLuaModule(), path, content) 40 | } 41 | 42 | /** 43 | * Mounts a file in the Lua environment synchronously. 44 | * @param luaWasm - Lua WebAssembly module. 45 | * @param path - Path to the file in the Lua environment. 46 | * @param content - Content of the file to be mounted. 47 | */ 48 | public mountFileSync(luaWasm: LuaWasm, path: string, content: string | ArrayBufferView): void { 49 | const fileSep = path.lastIndexOf('/') 50 | const file = path.substring(fileSep + 1) 51 | const body = path.substring(0, path.length - file.length - 1) 52 | 53 | if (body.length > 0) { 54 | const parts = body.split('/').reverse() 55 | let parent = '' 56 | 57 | while (parts.length) { 58 | const part = parts.pop() 59 | if (!part) { 60 | continue 61 | } 62 | 63 | const current = `${parent}/${part}` 64 | try { 65 | luaWasm.module.FS.mkdir(current) 66 | } catch { 67 | // ignore EEXIST 68 | } 69 | 70 | parent = current 71 | } 72 | } 73 | 74 | luaWasm.module.FS.writeFile(path, content) 75 | } 76 | 77 | /** 78 | * Creates a Lua engine with the specified options. 79 | * @param [options] - Configuration options for the Lua engine. 80 | * @returns - A Promise that resolves to a new LuaEngine instance. 81 | */ 82 | public async createEngine(options: CreateEngineOptions = {}): Promise { 83 | return new LuaEngine(await this.getLuaModule(), options) 84 | } 85 | 86 | /** 87 | * Gets the Lua WebAssembly module. 88 | * @returns - A Promise that resolves to the Lua WebAssembly module. 89 | */ 90 | public async getLuaModule(): Promise { 91 | return this.luaWasmPromise 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import type LuaWasm from './luawasm' 2 | import Thread from './thread' 3 | import LuaTypeExtension from './type-extension' 4 | import { LuaLibraries, LuaType } from './types' 5 | 6 | interface LuaMemoryStats { 7 | memoryUsed: number 8 | memoryMax?: number 9 | } 10 | 11 | /** 12 | * Represents the global state of the Lua engine. 13 | */ 14 | export default class Global extends Thread { 15 | private memoryStats: LuaMemoryStats | undefined 16 | private allocatorFunctionPointer: number | undefined 17 | 18 | /** 19 | * Constructs a new Global instance. 20 | * @param cmodule - The Lua WebAssembly module. 21 | * @param shouldTraceAllocations - Whether to trace memory allocations. 22 | */ 23 | public constructor(cmodule: LuaWasm, shouldTraceAllocations: boolean) { 24 | if (shouldTraceAllocations) { 25 | const memoryStats: LuaMemoryStats = { memoryUsed: 0 } 26 | const allocatorFunctionPointer = cmodule.module.addFunction( 27 | (_userData: number, pointer: number, oldSize: number, newSize: number): number => { 28 | if (newSize === 0) { 29 | if (pointer) { 30 | memoryStats.memoryUsed -= oldSize 31 | cmodule.module._free(pointer) 32 | } 33 | return 0 34 | } 35 | 36 | const endMemoryDelta = pointer ? newSize - oldSize : newSize 37 | const endMemory = memoryStats.memoryUsed + endMemoryDelta 38 | 39 | if (newSize > oldSize && memoryStats.memoryMax && endMemory > memoryStats.memoryMax) { 40 | return 0 41 | } 42 | 43 | const reallocated = cmodule.module._realloc(pointer, newSize) 44 | if (reallocated) { 45 | memoryStats.memoryUsed = endMemory 46 | } 47 | return reallocated 48 | }, 49 | 'iiiii', 50 | ) 51 | 52 | const address = cmodule.lua_newstate(allocatorFunctionPointer, null) 53 | if (!address) { 54 | cmodule.module.removeFunction(allocatorFunctionPointer) 55 | throw new Error('lua_newstate returned a null pointer') 56 | } 57 | super(cmodule, [], address) 58 | 59 | this.memoryStats = memoryStats 60 | this.allocatorFunctionPointer = allocatorFunctionPointer 61 | } else { 62 | super(cmodule, [], cmodule.luaL_newstate()) 63 | } 64 | 65 | if (this.isClosed()) { 66 | throw new Error('Global state could not be created (probably due to lack of memory)') 67 | } 68 | } 69 | 70 | /** 71 | * Closes the global state of the Lua engine. 72 | */ 73 | public close(): void { 74 | if (this.isClosed()) { 75 | return 76 | } 77 | 78 | super.close() 79 | 80 | // Do this before removing the gc to force. 81 | // Here rather than in the threads because you don't 82 | // actually close threads, just pop them. Only the top-level 83 | // lua state needs closing. 84 | this.lua.lua_close(this.address) 85 | 86 | if (this.allocatorFunctionPointer) { 87 | this.lua.module.removeFunction(this.allocatorFunctionPointer) 88 | } 89 | 90 | for (const wrapper of this.typeExtensions) { 91 | wrapper.extension.close() 92 | } 93 | } 94 | 95 | /** 96 | * Registers a type extension for Lua objects. 97 | * Higher priority is more important and will be evaluated first. 98 | * Allows library users to specify custom types 99 | * @param priority - Priority of the type extension. 100 | * @param extension - The type extension to register. 101 | */ 102 | public registerTypeExtension(priority: number, extension: LuaTypeExtension): void { 103 | this.typeExtensions.push({ extension, priority }) 104 | this.typeExtensions.sort((a, b) => b.priority - a.priority) 105 | } 106 | 107 | /** 108 | * Loads a default Lua library. 109 | * @param library - The Lua library to load. 110 | */ 111 | public loadLibrary(library: LuaLibraries): void { 112 | switch (library) { 113 | case LuaLibraries.Base: 114 | this.lua.luaopen_base(this.address) 115 | break 116 | case LuaLibraries.Coroutine: 117 | this.lua.luaopen_coroutine(this.address) 118 | break 119 | case LuaLibraries.Table: 120 | this.lua.luaopen_table(this.address) 121 | break 122 | case LuaLibraries.IO: 123 | this.lua.luaopen_io(this.address) 124 | break 125 | case LuaLibraries.OS: 126 | this.lua.luaopen_os(this.address) 127 | break 128 | case LuaLibraries.String: 129 | this.lua.luaopen_string(this.address) 130 | break 131 | case LuaLibraries.UTF8: 132 | this.lua.luaopen_utf8(this.address) 133 | break 134 | case LuaLibraries.Math: 135 | this.lua.luaopen_math(this.address) 136 | break 137 | case LuaLibraries.Debug: 138 | this.lua.luaopen_debug(this.address) 139 | break 140 | case LuaLibraries.Package: 141 | this.lua.luaopen_package(this.address) 142 | break 143 | } 144 | this.lua.lua_setglobal(this.address, library) 145 | } 146 | 147 | /** 148 | * Retrieves the value of a global variable. 149 | * @param name - The name of the global variable. 150 | * @returns - The value of the global variable. 151 | */ 152 | public get(name: string): any { 153 | const type = this.lua.lua_getglobal(this.address, name) 154 | const value = this.getValue(-1, type) 155 | this.pop() 156 | return value 157 | } 158 | 159 | /** 160 | * Sets the value of a global variable. 161 | * @param name - The name of the global variable. 162 | * @param value - The value to set for the global variable. 163 | */ 164 | public set(name: string, value: unknown): void { 165 | this.pushValue(value) 166 | this.lua.lua_setglobal(this.address, name) 167 | } 168 | 169 | public getTable(name: string, callback: (index: number) => void): void { 170 | const startStackTop = this.getTop() 171 | const type = this.lua.lua_getglobal(this.address, name) 172 | try { 173 | if (type !== LuaType.Table) { 174 | throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) 175 | } 176 | callback(startStackTop + 1) 177 | } finally { 178 | // +1 for the table 179 | if (this.getTop() !== startStackTop + 1) { 180 | console.warn(`getTable: expected stack size ${startStackTop} got ${this.getTop()}`) 181 | } 182 | this.setTop(startStackTop) 183 | } 184 | } 185 | 186 | /** 187 | * Gets the amount of memory used by the Lua engine. Can only be used if the state was created with the `traceAllocations` option set to true. 188 | * @returns - The amount of memory used in bytes. 189 | */ 190 | public getMemoryUsed(): number { 191 | return this.getMemoryStatsRef().memoryUsed 192 | } 193 | 194 | /** 195 | * Gets the maximum memory allowed for the Lua engine. Can only be used if the state was created with the `traceAllocations` option set to true. 196 | * @returns - The maximum memory allowed in bytes, or undefined if not set. 197 | */ 198 | public getMemoryMax(): number | undefined { 199 | return this.getMemoryStatsRef().memoryMax 200 | } 201 | 202 | /** 203 | * Sets the maximum memory allowed for the Lua engine. Can only be used if the state was created with the `traceAllocations` option set to true. 204 | * @param max - The maximum memory allowed in bytes, or undefined for unlimited. 205 | */ 206 | public setMemoryMax(max: number | undefined): void { 207 | this.getMemoryStatsRef().memoryMax = max 208 | } 209 | 210 | private getMemoryStatsRef(): LuaMemoryStats { 211 | if (!this.memoryStats) { 212 | throw new Error('Memory allocations is not being traced, please build engine with { traceAllocations: true }') 213 | } 214 | 215 | return this.memoryStats 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LuaEngine } from './engine' 2 | export { default as LuaFactory } from './factory' 3 | export { default as LuaGlobal } from './global' 4 | export { default as LuaMultiReturn } from './multireturn' 5 | export { default as LuaRawResult } from './raw-result' 6 | export { default as LuaThread } from './thread' 7 | // Export the underlying bindings to allow users to just 8 | // use the bindings rather than the wrappers. 9 | export { decorate, Decoration } from './decoration' 10 | export { default as LuaWasm } from './luawasm' 11 | export { default as LuaTypeExtension } from './type-extension' 12 | export { decorateFunction } from './type-extensions/function' 13 | export { decorateProxy } from './type-extensions/proxy' 14 | export { decorateUserdata } from './type-extensions/userdata' 15 | export * from './types' 16 | -------------------------------------------------------------------------------- /src/luawasm.ts: -------------------------------------------------------------------------------- 1 | import initWasmModule from '../build/glue.js' 2 | import { EnvironmentVariables, LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' 3 | 4 | interface LuaEmscriptenModule extends EmscriptenModule { 5 | ccall: typeof ccall 6 | addFunction: typeof addFunction 7 | removeFunction: typeof removeFunction 8 | setValue: typeof setValue 9 | getValue: typeof getValue 10 | FS: typeof FS 11 | stringToNewUTF8: typeof allocateUTF8 12 | lengthBytesUTF8: typeof lengthBytesUTF8 13 | stringToUTF8: typeof stringToUTF8 14 | ENV: EnvironmentVariables 15 | _realloc: (pointer: number, size: number) => number 16 | } 17 | 18 | interface ReferenceMetadata { 19 | index: number 20 | refCount: number 21 | } 22 | 23 | export default class LuaWasm { 24 | public static async initialize(customWasmFileLocation?: string, environmentVariables?: EnvironmentVariables): Promise { 25 | const module: LuaEmscriptenModule = await initWasmModule({ 26 | locateFile: (path: string, scriptDirectory: string) => { 27 | return customWasmFileLocation || scriptDirectory + path 28 | }, 29 | preRun: (initializedModule: LuaEmscriptenModule) => { 30 | if (typeof environmentVariables === 'object') { 31 | Object.entries(environmentVariables).forEach(([k, v]) => (initializedModule.ENV[k] = v)) 32 | } 33 | }, 34 | }) 35 | return new LuaWasm(module) 36 | } 37 | 38 | public module: LuaEmscriptenModule 39 | 40 | public luaL_checkversion_: (L: LuaState, ver: number, sz: number) => void 41 | public luaL_getmetafield: (L: LuaState, obj: number, e: string | null) => LuaType 42 | public luaL_callmeta: (L: LuaState, obj: number, e: string | null) => number 43 | public luaL_tolstring: (L: LuaState, idx: number, len: number | null) => string 44 | public luaL_argerror: (L: LuaState, arg: number, extramsg: string | null) => number 45 | public luaL_typeerror: (L: LuaState, arg: number, tname: string | null) => number 46 | public luaL_checklstring: (L: LuaState, arg: number, l: number | null) => string 47 | public luaL_optlstring: (L: LuaState, arg: number, def: string | null, l: number | null) => string 48 | public luaL_checknumber: (L: LuaState, arg: number) => number 49 | public luaL_optnumber: (L: LuaState, arg: number, def: number) => number 50 | public luaL_checkinteger: (L: LuaState, arg: number) => number 51 | public luaL_optinteger: (L: LuaState, arg: number, def: number) => number 52 | public luaL_checkstack: (L: LuaState, sz: number, msg: string | null) => void 53 | public luaL_checktype: (L: LuaState, arg: number, t: number) => void 54 | public luaL_checkany: (L: LuaState, arg: number) => void 55 | public luaL_newmetatable: (L: LuaState, tname: string | null) => number 56 | public luaL_setmetatable: (L: LuaState, tname: string | null) => void 57 | public luaL_testudata: (L: LuaState, ud: number, tname: string | null) => number 58 | public luaL_checkudata: (L: LuaState, ud: number, tname: string | null) => number 59 | public luaL_where: (L: LuaState, lvl: number) => void 60 | public luaL_fileresult: (L: LuaState, stat: number, fname: string | null) => number 61 | public luaL_execresult: (L: LuaState, stat: number) => number 62 | public luaL_ref: (L: LuaState, t: number) => number 63 | public luaL_unref: (L: LuaState, t: number, ref: number) => void 64 | public luaL_loadfilex: (L: LuaState, filename: string | null, mode: string | null) => LuaReturn 65 | public luaL_loadbufferx: ( 66 | L: LuaState, 67 | buff: string | number | null, 68 | sz: number, 69 | name: string | number | null, 70 | mode: string | null, 71 | ) => LuaReturn 72 | public luaL_loadstring: (L: LuaState, s: string | null) => LuaReturn 73 | public luaL_newstate: () => LuaState 74 | public luaL_len: (L: LuaState, idx: number) => number 75 | public luaL_addgsub: (b: number | null, s: string | null, p: string | null, r: string | null) => void 76 | public luaL_gsub: (L: LuaState, s: string | null, p: string | null, r: string | null) => string 77 | public luaL_setfuncs: (L: LuaState, l: number | null, nup: number) => void 78 | public luaL_getsubtable: (L: LuaState, idx: number, fname: string | null) => number 79 | public luaL_traceback: (L: LuaState, L1: LuaState, msg: string | null, level: number) => void 80 | public luaL_requiref: (L: LuaState, modname: string | null, openf: number, glb: number) => void 81 | public luaL_buffinit: (L: LuaState, B: number | null) => void 82 | public luaL_prepbuffsize: (B: number | null, sz: number) => string 83 | public luaL_addlstring: (B: number | null, s: string | null, l: number) => void 84 | public luaL_addstring: (B: number | null, s: string | null) => void 85 | public luaL_addvalue: (B: number | null) => void 86 | public luaL_pushresult: (B: number | null) => void 87 | public luaL_pushresultsize: (B: number | null, sz: number) => void 88 | public luaL_buffinitsize: (L: LuaState, B: number | null, sz: number) => string 89 | public lua_newstate: (f: number | null, ud: number | null) => LuaState 90 | public lua_close: (L: LuaState) => void 91 | public lua_newthread: (L: LuaState) => LuaState 92 | public lua_resetthread: (L: LuaState) => LuaReturn 93 | public lua_atpanic: (L: LuaState, panicf: number) => number 94 | public lua_version: (L: LuaState) => number 95 | public lua_absindex: (L: LuaState, idx: number) => number 96 | public lua_gettop: (L: LuaState) => number 97 | public lua_settop: (L: LuaState, idx: number) => void 98 | public lua_pushvalue: (L: LuaState, idx: number) => void 99 | public lua_rotate: (L: LuaState, idx: number, n: number) => void 100 | public lua_copy: (L: LuaState, fromidx: number, toidx: number) => void 101 | public lua_checkstack: (L: LuaState, n: number) => number 102 | public lua_xmove: (from: LuaState, to: LuaState, n: number) => void 103 | public lua_isnumber: (L: LuaState, idx: number) => number 104 | public lua_isstring: (L: LuaState, idx: number) => number 105 | public lua_iscfunction: (L: LuaState, idx: number) => number 106 | public lua_isinteger: (L: LuaState, idx: number) => number 107 | public lua_isuserdata: (L: LuaState, idx: number) => number 108 | public lua_type: (L: LuaState, idx: number) => LuaType 109 | public lua_typename: (L: LuaState, tp: number) => string 110 | public lua_tonumberx: (L: LuaState, idx: number, isnum: number | null) => number 111 | public lua_tointegerx: (L: LuaState, idx: number, isnum: number | null) => bigint 112 | public lua_toboolean: (L: LuaState, idx: number) => number 113 | public lua_tolstring: (L: LuaState, idx: number, len: number | null) => string 114 | public lua_rawlen: (L: LuaState, idx: number) => number 115 | public lua_tocfunction: (L: LuaState, idx: number) => number 116 | public lua_touserdata: (L: LuaState, idx: number) => number 117 | public lua_tothread: (L: LuaState, idx: number) => LuaState 118 | public lua_topointer: (L: LuaState, idx: number) => number 119 | public lua_arith: (L: LuaState, op: number) => void 120 | public lua_rawequal: (L: LuaState, idx1: number, idx2: number) => number 121 | public lua_compare: (L: LuaState, idx1: number, idx2: number, op: number) => number 122 | public lua_pushnil: (L: LuaState) => void 123 | public lua_pushnumber: (L: LuaState, n: number) => void 124 | public lua_pushinteger: (L: LuaState, n: bigint) => void 125 | public lua_pushlstring: (L: LuaState, s: string | number | null, len: number) => string 126 | public lua_pushstring: (L: LuaState, s: string | number | null) => string 127 | public lua_pushcclosure: (L: LuaState, fn: number, n: number) => void 128 | public lua_pushboolean: (L: LuaState, b: number) => void 129 | public lua_pushlightuserdata: (L: LuaState, p: number | null) => void 130 | public lua_pushthread: (L: LuaState) => number 131 | public lua_getglobal: (L: LuaState, name: string | null) => LuaType 132 | public lua_gettable: (L: LuaState, idx: number) => LuaType 133 | public lua_getfield: (L: LuaState, idx: number, k: string | null) => LuaType 134 | public lua_geti: (L: LuaState, idx: number, n: bigint) => LuaType 135 | public lua_rawget: (L: LuaState, idx: number) => number 136 | public lua_rawgeti: (L: LuaState, idx: number, n: bigint) => LuaType 137 | public lua_rawgetp: (L: LuaState, idx: number, p: number | null) => LuaType 138 | public lua_createtable: (L: LuaState, narr: number, nrec: number) => void 139 | public lua_newuserdatauv: (L: LuaState, sz: number, nuvalue: number) => number 140 | public lua_getmetatable: (L: LuaState, objindex: number) => number 141 | public lua_getiuservalue: (L: LuaState, idx: number, n: number) => LuaType 142 | public lua_setglobal: (L: LuaState, name: string | null) => void 143 | public lua_settable: (L: LuaState, idx: number) => void 144 | public lua_setfield: (L: LuaState, idx: number, k: string | null) => void 145 | public lua_seti: (L: LuaState, idx: number, n: bigint) => void 146 | public lua_rawset: (L: LuaState, idx: number) => void 147 | public lua_rawseti: (L: LuaState, idx: number, n: bigint) => void 148 | public lua_rawsetp: (L: LuaState, idx: number, p: number | null) => void 149 | public lua_setmetatable: (L: LuaState, objindex: number) => number 150 | public lua_setiuservalue: (L: LuaState, idx: number, n: number) => number 151 | public lua_callk: (L: LuaState, nargs: number, nresults: number, ctx: number, k: number | null) => void 152 | public lua_pcallk: (L: LuaState, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number 153 | public lua_load: (L: LuaState, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn 154 | public lua_dump: (L: LuaState, writer: number | null, data: number | null, strip: number) => number 155 | public lua_yieldk: (L: LuaState, nresults: number, ctx: number, k: number | null) => number 156 | public lua_resume: (L: LuaState, from: LuaState | null, narg: number, nres: number | null) => LuaReturn 157 | public lua_status: (L: LuaState) => LuaReturn 158 | public lua_isyieldable: (L: LuaState) => number 159 | public lua_setwarnf: (L: LuaState, f: number | null, ud: number | null) => void 160 | public lua_warning: (L: LuaState, msg: string | null, tocont: number) => void 161 | public lua_error: (L: LuaState) => number 162 | public lua_next: (L: LuaState, idx: number) => number 163 | public lua_concat: (L: LuaState, n: number) => void 164 | public lua_len: (L: LuaState, idx: number) => void 165 | public lua_stringtonumber: (L: LuaState, s: string | null) => number 166 | public lua_getallocf: (L: LuaState, ud: number | null) => number 167 | public lua_setallocf: (L: LuaState, f: number | null, ud: number | null) => void 168 | public lua_toclose: (L: LuaState, idx: number) => void 169 | public lua_closeslot: (L: LuaState, idx: number) => void 170 | public lua_getstack: (L: LuaState, level: number, ar: number | null) => number 171 | public lua_getinfo: (L: LuaState, what: string | null, ar: number | null) => number 172 | public lua_getlocal: (L: LuaState, ar: number | null, n: number) => string 173 | public lua_setlocal: (L: LuaState, ar: number | null, n: number) => string 174 | public lua_getupvalue: (L: LuaState, funcindex: number, n: number) => string 175 | public lua_setupvalue: (L: LuaState, funcindex: number, n: number) => string 176 | public lua_upvalueid: (L: LuaState, fidx: number, n: number) => number 177 | public lua_upvaluejoin: (L: LuaState, fidx1: number, n1: number, fidx2: number, n2: number) => void 178 | public lua_sethook: (L: LuaState, func: number | null, mask: number, count: number) => void 179 | public lua_gethook: (L: LuaState) => number 180 | public lua_gethookmask: (L: LuaState) => number 181 | public lua_gethookcount: (L: LuaState) => number 182 | public lua_setcstacklimit: (L: LuaState, limit: number) => number 183 | public luaopen_base: (L: LuaState) => number 184 | public luaopen_coroutine: (L: LuaState) => number 185 | public luaopen_table: (L: LuaState) => number 186 | public luaopen_io: (L: LuaState) => number 187 | public luaopen_os: (L: LuaState) => number 188 | public luaopen_string: (L: LuaState) => number 189 | public luaopen_utf8: (L: LuaState) => number 190 | public luaopen_math: (L: LuaState) => number 191 | public luaopen_debug: (L: LuaState) => number 192 | public luaopen_package: (L: LuaState) => number 193 | public luaL_openlibs: (L: LuaState) => void 194 | 195 | private referenceTracker = new WeakMap() 196 | private referenceMap = new Map() 197 | private availableReferences: number[] = [] 198 | private lastRefIndex?: number 199 | 200 | public constructor(module: LuaEmscriptenModule) { 201 | this.module = module 202 | 203 | this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) 204 | this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) 205 | this.luaL_callmeta = this.cwrap('luaL_callmeta', 'number', ['number', 'number', 'string']) 206 | this.luaL_tolstring = this.cwrap('luaL_tolstring', 'string', ['number', 'number', 'number']) 207 | this.luaL_argerror = this.cwrap('luaL_argerror', 'number', ['number', 'number', 'string']) 208 | this.luaL_typeerror = this.cwrap('luaL_typeerror', 'number', ['number', 'number', 'string']) 209 | this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', ['number', 'number', 'number']) 210 | this.luaL_optlstring = this.cwrap('luaL_optlstring', 'string', ['number', 'number', 'string', 'number']) 211 | this.luaL_checknumber = this.cwrap('luaL_checknumber', 'number', ['number', 'number']) 212 | this.luaL_optnumber = this.cwrap('luaL_optnumber', 'number', ['number', 'number', 'number']) 213 | this.luaL_checkinteger = this.cwrap('luaL_checkinteger', 'number', ['number', 'number']) 214 | this.luaL_optinteger = this.cwrap('luaL_optinteger', 'number', ['number', 'number', 'number']) 215 | this.luaL_checkstack = this.cwrap('luaL_checkstack', null, ['number', 'number', 'string']) 216 | this.luaL_checktype = this.cwrap('luaL_checktype', null, ['number', 'number', 'number']) 217 | this.luaL_checkany = this.cwrap('luaL_checkany', null, ['number', 'number']) 218 | this.luaL_newmetatable = this.cwrap('luaL_newmetatable', 'number', ['number', 'string']) 219 | this.luaL_setmetatable = this.cwrap('luaL_setmetatable', null, ['number', 'string']) 220 | this.luaL_testudata = this.cwrap('luaL_testudata', 'number', ['number', 'number', 'string']) 221 | this.luaL_checkudata = this.cwrap('luaL_checkudata', 'number', ['number', 'number', 'string']) 222 | this.luaL_where = this.cwrap('luaL_where', null, ['number', 'number']) 223 | this.luaL_fileresult = this.cwrap('luaL_fileresult', 'number', ['number', 'number', 'string']) 224 | this.luaL_execresult = this.cwrap('luaL_execresult', 'number', ['number', 'number']) 225 | this.luaL_ref = this.cwrap('luaL_ref', 'number', ['number', 'number']) 226 | this.luaL_unref = this.cwrap('luaL_unref', null, ['number', 'number', 'number']) 227 | this.luaL_loadfilex = this.cwrap('luaL_loadfilex', 'number', ['number', 'string', 'string']) 228 | this.luaL_loadbufferx = this.cwrap('luaL_loadbufferx', 'number', ['number', 'string|number', 'number', 'string|number', 'string']) 229 | this.luaL_loadstring = this.cwrap('luaL_loadstring', 'number', ['number', 'string']) 230 | this.luaL_newstate = this.cwrap('luaL_newstate', 'number', []) 231 | this.luaL_len = this.cwrap('luaL_len', 'number', ['number', 'number']) 232 | this.luaL_addgsub = this.cwrap('luaL_addgsub', null, ['number', 'string', 'string', 'string']) 233 | this.luaL_gsub = this.cwrap('luaL_gsub', 'string', ['number', 'string', 'string', 'string']) 234 | this.luaL_setfuncs = this.cwrap('luaL_setfuncs', null, ['number', 'number', 'number']) 235 | this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', ['number', 'number', 'string']) 236 | this.luaL_traceback = this.cwrap('luaL_traceback', null, ['number', 'number', 'string', 'number']) 237 | this.luaL_requiref = this.cwrap('luaL_requiref', null, ['number', 'string', 'number', 'number']) 238 | this.luaL_buffinit = this.cwrap('luaL_buffinit', null, ['number', 'number']) 239 | this.luaL_prepbuffsize = this.cwrap('luaL_prepbuffsize', 'string', ['number', 'number']) 240 | this.luaL_addlstring = this.cwrap('luaL_addlstring', null, ['number', 'string', 'number']) 241 | this.luaL_addstring = this.cwrap('luaL_addstring', null, ['number', 'string']) 242 | this.luaL_addvalue = this.cwrap('luaL_addvalue', null, ['number']) 243 | this.luaL_pushresult = this.cwrap('luaL_pushresult', null, ['number']) 244 | this.luaL_pushresultsize = this.cwrap('luaL_pushresultsize', null, ['number', 'number']) 245 | this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', ['number', 'number', 'number']) 246 | this.lua_newstate = this.cwrap('lua_newstate', 'number', ['number', 'number']) 247 | this.lua_close = this.cwrap('lua_close', null, ['number']) 248 | this.lua_newthread = this.cwrap('lua_newthread', 'number', ['number']) 249 | this.lua_resetthread = this.cwrap('lua_resetthread', 'number', ['number']) 250 | this.lua_atpanic = this.cwrap('lua_atpanic', 'number', ['number', 'number']) 251 | this.lua_version = this.cwrap('lua_version', 'number', ['number']) 252 | this.lua_absindex = this.cwrap('lua_absindex', 'number', ['number', 'number']) 253 | this.lua_gettop = this.cwrap('lua_gettop', 'number', ['number']) 254 | this.lua_settop = this.cwrap('lua_settop', null, ['number', 'number']) 255 | this.lua_pushvalue = this.cwrap('lua_pushvalue', null, ['number', 'number']) 256 | this.lua_rotate = this.cwrap('lua_rotate', null, ['number', 'number', 'number']) 257 | this.lua_copy = this.cwrap('lua_copy', null, ['number', 'number', 'number']) 258 | this.lua_checkstack = this.cwrap('lua_checkstack', 'number', ['number', 'number']) 259 | this.lua_xmove = this.cwrap('lua_xmove', null, ['number', 'number', 'number']) 260 | this.lua_isnumber = this.cwrap('lua_isnumber', 'number', ['number', 'number']) 261 | this.lua_isstring = this.cwrap('lua_isstring', 'number', ['number', 'number']) 262 | this.lua_iscfunction = this.cwrap('lua_iscfunction', 'number', ['number', 'number']) 263 | this.lua_isinteger = this.cwrap('lua_isinteger', 'number', ['number', 'number']) 264 | this.lua_isuserdata = this.cwrap('lua_isuserdata', 'number', ['number', 'number']) 265 | this.lua_type = this.cwrap('lua_type', 'number', ['number', 'number']) 266 | this.lua_typename = this.cwrap('lua_typename', 'string', ['number', 'number']) 267 | this.lua_tonumberx = this.cwrap('lua_tonumberx', 'number', ['number', 'number', 'number']) 268 | this.lua_tointegerx = this.cwrap('lua_tointegerx', 'number', ['number', 'number', 'number']) 269 | this.lua_toboolean = this.cwrap('lua_toboolean', 'number', ['number', 'number']) 270 | this.lua_tolstring = this.cwrap('lua_tolstring', 'string', ['number', 'number', 'number']) 271 | this.lua_rawlen = this.cwrap('lua_rawlen', 'number', ['number', 'number']) 272 | this.lua_tocfunction = this.cwrap('lua_tocfunction', 'number', ['number', 'number']) 273 | this.lua_touserdata = this.cwrap('lua_touserdata', 'number', ['number', 'number']) 274 | this.lua_tothread = this.cwrap('lua_tothread', 'number', ['number', 'number']) 275 | this.lua_topointer = this.cwrap('lua_topointer', 'number', ['number', 'number']) 276 | this.lua_arith = this.cwrap('lua_arith', null, ['number', 'number']) 277 | this.lua_rawequal = this.cwrap('lua_rawequal', 'number', ['number', 'number', 'number']) 278 | this.lua_compare = this.cwrap('lua_compare', 'number', ['number', 'number', 'number', 'number']) 279 | this.lua_pushnil = this.cwrap('lua_pushnil', null, ['number']) 280 | this.lua_pushnumber = this.cwrap('lua_pushnumber', null, ['number', 'number']) 281 | this.lua_pushinteger = this.cwrap('lua_pushinteger', null, ['number', 'number']) 282 | this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', ['number', 'string|number', 'number']) 283 | this.lua_pushstring = this.cwrap('lua_pushstring', 'string', ['number', 'string|number']) 284 | this.lua_pushcclosure = this.cwrap('lua_pushcclosure', null, ['number', 'number', 'number']) 285 | this.lua_pushboolean = this.cwrap('lua_pushboolean', null, ['number', 'number']) 286 | this.lua_pushlightuserdata = this.cwrap('lua_pushlightuserdata', null, ['number', 'number']) 287 | this.lua_pushthread = this.cwrap('lua_pushthread', 'number', ['number']) 288 | this.lua_getglobal = this.cwrap('lua_getglobal', 'number', ['number', 'string']) 289 | this.lua_gettable = this.cwrap('lua_gettable', 'number', ['number', 'number']) 290 | this.lua_getfield = this.cwrap('lua_getfield', 'number', ['number', 'number', 'string']) 291 | this.lua_geti = this.cwrap('lua_geti', 'number', ['number', 'number', 'number']) 292 | this.lua_rawget = this.cwrap('lua_rawget', 'number', ['number', 'number']) 293 | this.lua_rawgeti = this.cwrap('lua_rawgeti', 'number', ['number', 'number', 'number']) 294 | this.lua_rawgetp = this.cwrap('lua_rawgetp', 'number', ['number', 'number', 'number']) 295 | this.lua_createtable = this.cwrap('lua_createtable', null, ['number', 'number', 'number']) 296 | this.lua_newuserdatauv = this.cwrap('lua_newuserdatauv', 'number', ['number', 'number', 'number']) 297 | this.lua_getmetatable = this.cwrap('lua_getmetatable', 'number', ['number', 'number']) 298 | this.lua_getiuservalue = this.cwrap('lua_getiuservalue', 'number', ['number', 'number', 'number']) 299 | this.lua_setglobal = this.cwrap('lua_setglobal', null, ['number', 'string']) 300 | this.lua_settable = this.cwrap('lua_settable', null, ['number', 'number']) 301 | this.lua_setfield = this.cwrap('lua_setfield', null, ['number', 'number', 'string']) 302 | this.lua_seti = this.cwrap('lua_seti', null, ['number', 'number', 'number']) 303 | this.lua_rawset = this.cwrap('lua_rawset', null, ['number', 'number']) 304 | this.lua_rawseti = this.cwrap('lua_rawseti', null, ['number', 'number', 'number']) 305 | this.lua_rawsetp = this.cwrap('lua_rawsetp', null, ['number', 'number', 'number']) 306 | this.lua_setmetatable = this.cwrap('lua_setmetatable', 'number', ['number', 'number']) 307 | this.lua_setiuservalue = this.cwrap('lua_setiuservalue', 'number', ['number', 'number', 'number']) 308 | this.lua_callk = this.cwrap('lua_callk', null, ['number', 'number', 'number', 'number', 'number']) 309 | this.lua_pcallk = this.cwrap('lua_pcallk', 'number', ['number', 'number', 'number', 'number', 'number', 'number']) 310 | this.lua_load = this.cwrap('lua_load', 'number', ['number', 'number', 'number', 'string', 'string']) 311 | this.lua_dump = this.cwrap('lua_dump', 'number', ['number', 'number', 'number', 'number']) 312 | this.lua_yieldk = this.cwrap('lua_yieldk', 'number', ['number', 'number', 'number', 'number']) 313 | this.lua_resume = this.cwrap('lua_resume', 'number', ['number', 'number', 'number', 'number']) 314 | this.lua_status = this.cwrap('lua_status', 'number', ['number']) 315 | this.lua_isyieldable = this.cwrap('lua_isyieldable', 'number', ['number']) 316 | this.lua_setwarnf = this.cwrap('lua_setwarnf', null, ['number', 'number', 'number']) 317 | this.lua_warning = this.cwrap('lua_warning', null, ['number', 'string', 'number']) 318 | this.lua_error = this.cwrap('lua_error', 'number', ['number']) 319 | this.lua_next = this.cwrap('lua_next', 'number', ['number', 'number']) 320 | this.lua_concat = this.cwrap('lua_concat', null, ['number', 'number']) 321 | this.lua_len = this.cwrap('lua_len', null, ['number', 'number']) 322 | this.lua_stringtonumber = this.cwrap('lua_stringtonumber', 'number', ['number', 'string']) 323 | this.lua_getallocf = this.cwrap('lua_getallocf', 'number', ['number', 'number']) 324 | this.lua_setallocf = this.cwrap('lua_setallocf', null, ['number', 'number', 'number']) 325 | this.lua_toclose = this.cwrap('lua_toclose', null, ['number', 'number']) 326 | this.lua_closeslot = this.cwrap('lua_closeslot', null, ['number', 'number']) 327 | this.lua_getstack = this.cwrap('lua_getstack', 'number', ['number', 'number', 'number']) 328 | this.lua_getinfo = this.cwrap('lua_getinfo', 'number', ['number', 'string', 'number']) 329 | this.lua_getlocal = this.cwrap('lua_getlocal', 'string', ['number', 'number', 'number']) 330 | this.lua_setlocal = this.cwrap('lua_setlocal', 'string', ['number', 'number', 'number']) 331 | this.lua_getupvalue = this.cwrap('lua_getupvalue', 'string', ['number', 'number', 'number']) 332 | this.lua_setupvalue = this.cwrap('lua_setupvalue', 'string', ['number', 'number', 'number']) 333 | this.lua_upvalueid = this.cwrap('lua_upvalueid', 'number', ['number', 'number', 'number']) 334 | this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, ['number', 'number', 'number', 'number', 'number']) 335 | this.lua_sethook = this.cwrap('lua_sethook', null, ['number', 'number', 'number', 'number']) 336 | this.lua_gethook = this.cwrap('lua_gethook', 'number', ['number']) 337 | this.lua_gethookmask = this.cwrap('lua_gethookmask', 'number', ['number']) 338 | this.lua_gethookcount = this.cwrap('lua_gethookcount', 'number', ['number']) 339 | this.lua_setcstacklimit = this.cwrap('lua_setcstacklimit', 'number', ['number', 'number']) 340 | this.luaopen_base = this.cwrap('luaopen_base', 'number', ['number']) 341 | this.luaopen_coroutine = this.cwrap('luaopen_coroutine', 'number', ['number']) 342 | this.luaopen_table = this.cwrap('luaopen_table', 'number', ['number']) 343 | this.luaopen_io = this.cwrap('luaopen_io', 'number', ['number']) 344 | this.luaopen_os = this.cwrap('luaopen_os', 'number', ['number']) 345 | this.luaopen_string = this.cwrap('luaopen_string', 'number', ['number']) 346 | this.luaopen_utf8 = this.cwrap('luaopen_utf8', 'number', ['number']) 347 | this.luaopen_math = this.cwrap('luaopen_math', 'number', ['number']) 348 | this.luaopen_debug = this.cwrap('luaopen_debug', 'number', ['number']) 349 | this.luaopen_package = this.cwrap('luaopen_package', 'number', ['number']) 350 | this.luaL_openlibs = this.cwrap('luaL_openlibs', null, ['number']) 351 | } 352 | 353 | public lua_remove(luaState: LuaState, index: number): void { 354 | this.lua_rotate(luaState, index, -1) 355 | this.lua_pop(luaState, 1) 356 | } 357 | 358 | public lua_pop(luaState: LuaState, count: number): void { 359 | this.lua_settop(luaState, -count - 1) 360 | } 361 | 362 | public luaL_getmetatable(luaState: LuaState, name: string): LuaType { 363 | return this.lua_getfield(luaState, LUA_REGISTRYINDEX, name) 364 | } 365 | 366 | public lua_yield(luaState: LuaState, count: number): number { 367 | return this.lua_yieldk(luaState, count, 0, null) 368 | } 369 | 370 | public lua_upvalueindex(index: number): number { 371 | return LUA_REGISTRYINDEX - index 372 | } 373 | 374 | public ref(data: unknown): number { 375 | const existing = this.referenceTracker.get(data) 376 | if (existing) { 377 | existing.refCount++ 378 | return existing.index 379 | } 380 | 381 | const availableIndex = this.availableReferences.pop() 382 | // +1 so the index is always truthy and not a "nullptr". 383 | const index = availableIndex === undefined ? this.referenceMap.size + 1 : availableIndex 384 | this.referenceMap.set(index, data) 385 | this.referenceTracker.set(data, { 386 | refCount: 1, 387 | index, 388 | }) 389 | 390 | this.lastRefIndex = index 391 | 392 | return index 393 | } 394 | 395 | public unref(index: number): void { 396 | const ref = this.referenceMap.get(index) 397 | if (ref === undefined) { 398 | return 399 | } 400 | const metadata = this.referenceTracker.get(ref) 401 | if (metadata === undefined) { 402 | this.referenceTracker.delete(ref) 403 | this.availableReferences.push(index) 404 | return 405 | } 406 | 407 | metadata.refCount-- 408 | if (metadata.refCount <= 0) { 409 | this.referenceTracker.delete(ref) 410 | this.referenceMap.delete(index) 411 | this.availableReferences.push(index) 412 | } 413 | } 414 | 415 | public getRef(index: number): any | undefined { 416 | return this.referenceMap.get(index) 417 | } 418 | 419 | // This is needed for some tests 420 | public getLastRefIndex(): number | undefined { 421 | return this.lastRefIndex 422 | } 423 | 424 | public printRefs(): void { 425 | for (const [key, value] of this.referenceMap.entries()) { 426 | console.log(key, value) 427 | } 428 | } 429 | 430 | private cwrap( 431 | name: string, 432 | returnType: Emscripten.JSType | null, 433 | argTypes: Array, 434 | ): (...args: any[]) => any { 435 | // optimization for common case 436 | const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') 437 | if (!hasStringOrNumber) { 438 | return (...args: any[]) => 439 | this.module.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) 440 | } 441 | 442 | return (...args: any[]) => { 443 | const pointersToBeFreed: number[] = [] 444 | const resolvedArgTypes: Emscripten.JSType[] = argTypes.map((argType, i) => { 445 | if (argType === 'string|number') { 446 | if (typeof args[i] === 'number') { 447 | return 'number' 448 | } else { 449 | // because it will be freed later, this can only be used on functions that lua internally copies the string 450 | if (args[i]?.length > 1024) { 451 | const bufferPointer = this.module.stringToNewUTF8(args[i] as string) 452 | args[i] = bufferPointer 453 | pointersToBeFreed.push(bufferPointer) 454 | return 'number' 455 | } else { 456 | return 'string' 457 | } 458 | } 459 | } 460 | return argType 461 | }) 462 | 463 | try { 464 | return this.module.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) 465 | } finally { 466 | for (const pointer of pointersToBeFreed) { 467 | this.module._free(pointer) 468 | } 469 | } 470 | } 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /src/multireturn.ts: -------------------------------------------------------------------------------- 1 | export default class MultiReturn extends Array {} 2 | -------------------------------------------------------------------------------- /src/pointer.ts: -------------------------------------------------------------------------------- 1 | export class Pointer extends Number {} 2 | -------------------------------------------------------------------------------- /src/raw-result.ts: -------------------------------------------------------------------------------- 1 | export default class RawResult { 2 | public constructor(public readonly count: number) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/thread.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from './decoration' 2 | import type LuaWasm from './luawasm' 3 | import MultiReturn from './multireturn' 4 | import { Pointer } from './pointer' 5 | import LuaTypeExtension from './type-extension' 6 | import { 7 | LUA_MULTRET, 8 | LuaEventMasks, 9 | LuaResumeResult, 10 | LuaReturn, 11 | LuaState, 12 | LuaThreadRunOptions, 13 | LuaTimeoutError, 14 | LuaType, 15 | PointerSize, 16 | } from './types' 17 | 18 | export interface OrderedExtension { 19 | // Bigger is more important 20 | priority: number 21 | extension: LuaTypeExtension 22 | } 23 | 24 | // When the debug count hook is set, call it every X instructions. 25 | const INSTRUCTION_HOOK_COUNT = 1000 26 | 27 | export default class Thread { 28 | public readonly address: LuaState 29 | public readonly lua: LuaWasm 30 | protected readonly typeExtensions: OrderedExtension[] 31 | private closed = false 32 | private hookFunctionPointer: number | undefined 33 | private timeout?: number 34 | private readonly parent?: Thread 35 | 36 | public constructor(lua: LuaWasm, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { 37 | this.lua = lua 38 | this.typeExtensions = typeExtensions 39 | this.address = address 40 | this.parent = parent 41 | } 42 | 43 | public newThread(): Thread { 44 | const address = this.lua.lua_newthread(this.address) 45 | if (!address) { 46 | throw new Error('lua_newthread returned a null pointer') 47 | } 48 | return new Thread(this.lua, this.typeExtensions, address, this.parent || this) 49 | } 50 | 51 | public resetThread(): void { 52 | this.assertOk(this.lua.lua_resetthread(this.address)) 53 | } 54 | 55 | public loadString(luaCode: string, name?: string): void { 56 | const size = this.lua.module.lengthBytesUTF8(luaCode) 57 | const pointerSize = size + 1 58 | const bufferPointer = this.lua.module._malloc(pointerSize) 59 | try { 60 | this.lua.module.stringToUTF8(luaCode, bufferPointer, pointerSize) 61 | this.assertOk(this.lua.luaL_loadbufferx(this.address, bufferPointer, size, name ?? bufferPointer, null)) 62 | } finally { 63 | this.lua.module._free(bufferPointer) 64 | } 65 | } 66 | 67 | public loadFile(filename: string): void { 68 | this.assertOk(this.lua.luaL_loadfilex(this.address, filename, null)) 69 | } 70 | 71 | public resume(argCount = 0): LuaResumeResult { 72 | const dataPointer = this.lua.module._malloc(PointerSize) 73 | try { 74 | this.lua.module.setValue(dataPointer, 0, 'i32') 75 | const luaResult = this.lua.lua_resume(this.address, null, argCount, dataPointer) 76 | return { 77 | result: luaResult, 78 | resultCount: this.lua.module.getValue(dataPointer, 'i32'), 79 | } 80 | } finally { 81 | this.lua.module._free(dataPointer) 82 | } 83 | } 84 | 85 | public getTop(): number { 86 | return this.lua.lua_gettop(this.address) 87 | } 88 | 89 | public setTop(index: number): void { 90 | this.lua.lua_settop(this.address, index) 91 | } 92 | 93 | public remove(index: number): void { 94 | return this.lua.lua_remove(this.address, index) 95 | } 96 | 97 | public setField(index: number, name: string, value: unknown): void { 98 | index = this.lua.lua_absindex(this.address, index) 99 | this.pushValue(value) 100 | this.lua.lua_setfield(this.address, index, name) 101 | } 102 | 103 | public async run(argCount = 0, options?: Partial): Promise { 104 | const originalTimeout = this.timeout 105 | try { 106 | if (options?.timeout !== undefined) { 107 | this.setTimeout(Date.now() + options.timeout) 108 | } 109 | let resumeResult: LuaResumeResult = this.resume(argCount) 110 | while (resumeResult.result === LuaReturn.Yield) { 111 | // If it's yielded check the timeout. If it's completed no need to 112 | // needlessly discard the output. 113 | if (this.timeout && Date.now() > this.timeout) { 114 | if (resumeResult.resultCount > 0) { 115 | this.pop(resumeResult.resultCount) 116 | } 117 | throw new LuaTimeoutError(`thread timeout exceeded`) 118 | } 119 | if (resumeResult.resultCount > 0) { 120 | const lastValue = this.getValue(-1) 121 | this.pop(resumeResult.resultCount) 122 | 123 | // If there's a result and it's a promise, then wait for it. 124 | if (lastValue === Promise.resolve(lastValue)) { 125 | await lastValue 126 | } else { 127 | // If it's a non-promise, then skip a tick to yield for promises, timers, etc. 128 | await new Promise((resolve) => setImmediate(resolve)) 129 | } 130 | } else { 131 | // If there's nothing to yield, then skip a tick to yield for promises, timers, etc. 132 | await new Promise((resolve) => setImmediate(resolve)) 133 | } 134 | 135 | resumeResult = this.resume(0) 136 | } 137 | 138 | this.assertOk(resumeResult.result) 139 | return this.getStackValues() 140 | } finally { 141 | if (options?.timeout !== undefined) { 142 | this.setTimeout(originalTimeout) 143 | } 144 | } 145 | } 146 | 147 | public runSync(argCount = 0): MultiReturn { 148 | const base = this.getTop() - argCount - 1 // The 1 is for the function to run 149 | this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null) as LuaReturn) 150 | return this.getStackValues(base) 151 | } 152 | 153 | public pop(count = 1): void { 154 | this.lua.lua_pop(this.address, count) 155 | } 156 | 157 | public call(name: string, ...args: any[]): MultiReturn { 158 | const type = this.lua.lua_getglobal(this.address, name) 159 | if (type !== LuaType.Function) { 160 | throw new Error(`A function of type '${type}' was pushed, expected is ${LuaType.Function}`) 161 | } 162 | 163 | for (const arg of args) { 164 | this.pushValue(arg) 165 | } 166 | 167 | const base = this.getTop() - args.length - 1 // The 1 is for the function to run 168 | this.lua.lua_callk(this.address, args.length, LUA_MULTRET, 0, null) 169 | return this.getStackValues(base) 170 | } 171 | 172 | public getStackValues(start = 0): MultiReturn { 173 | const returns = this.getTop() - start 174 | const returnValues = new MultiReturn(returns) 175 | 176 | for (let i = 0; i < returns; i++) { 177 | returnValues[i] = this.getValue(start + i + 1) 178 | } 179 | 180 | return returnValues 181 | } 182 | 183 | public stateToThread(L: LuaState): Thread { 184 | return L === this.parent?.address ? this.parent : new Thread(this.lua, this.typeExtensions, L, this.parent || this) 185 | } 186 | 187 | public pushValue(rawValue: unknown, userdata?: unknown): void { 188 | const decoratedValue = this.getValueDecorations(rawValue) 189 | const target = decoratedValue.target 190 | 191 | if (target instanceof Thread) { 192 | const isMain = this.lua.lua_pushthread(target.address) === 1 193 | if (!isMain) { 194 | this.lua.lua_xmove(target.address, this.address, 1) 195 | } 196 | return 197 | } 198 | 199 | const startTop = this.getTop() 200 | 201 | // Handle primitive types 202 | switch (typeof target) { 203 | case 'undefined': 204 | this.lua.lua_pushnil(this.address) 205 | break 206 | case 'number': 207 | if (Number.isInteger(target)) { 208 | this.lua.lua_pushinteger(this.address, BigInt(target)) 209 | } else { 210 | this.lua.lua_pushnumber(this.address, target) 211 | } 212 | break 213 | case 'string': 214 | this.lua.lua_pushstring(this.address, target) 215 | break 216 | case 'boolean': 217 | this.lua.lua_pushboolean(this.address, target ? 1 : 0) 218 | break 219 | default: 220 | if (this.typeExtensions.find((wrapper) => wrapper.extension.pushValue(this, decoratedValue, userdata))) { 221 | break 222 | } 223 | if (target === null) { 224 | this.lua.lua_pushnil(this.address) 225 | break 226 | } 227 | throw new Error(`The type '${typeof target}' is not supported by Lua`) 228 | } 229 | 230 | if (decoratedValue.options.metatable) { 231 | this.setMetatable(-1, decoratedValue.options.metatable) 232 | } 233 | 234 | if (this.getTop() !== startTop + 1) { 235 | throw new Error(`pushValue expected stack size ${startTop + 1}, got ${this.getTop()}`) 236 | } 237 | } 238 | 239 | public setMetatable(index: number, metatable: Record): void { 240 | index = this.lua.lua_absindex(this.address, index) 241 | 242 | if (this.lua.lua_getmetatable(this.address, index)) { 243 | this.pop(1) 244 | const name = this.getMetatableName(index) 245 | throw new Error(`data already has associated metatable: ${name || 'unknown name'}`) 246 | } 247 | 248 | this.pushValue(metatable) 249 | this.lua.lua_setmetatable(this.address, index) 250 | } 251 | 252 | public getMetatableName(index: number): string | undefined { 253 | const metatableNameType = this.lua.luaL_getmetafield(this.address, index, '__name') 254 | if (metatableNameType === LuaType.Nil) { 255 | return undefined 256 | } 257 | 258 | if (metatableNameType !== LuaType.String) { 259 | // Pop the metafield if it's not a string 260 | this.pop(1) 261 | return undefined 262 | } 263 | 264 | const name = this.lua.lua_tolstring(this.address, -1, null) 265 | // This is popping the luaL_getmetafield result which only pushes with type is not nil. 266 | this.pop(1) 267 | 268 | return name 269 | } 270 | 271 | public getValue(index: number, inputType?: LuaType, userdata?: unknown): any { 272 | index = this.lua.lua_absindex(this.address, index) 273 | 274 | const type: LuaType = inputType ?? this.lua.lua_type(this.address, index) 275 | 276 | switch (type) { 277 | case LuaType.None: 278 | return undefined 279 | case LuaType.Nil: 280 | return null 281 | case LuaType.Number: 282 | return this.lua.lua_tonumberx(this.address, index, null) 283 | case LuaType.String: 284 | return this.lua.lua_tolstring(this.address, index, null) 285 | case LuaType.Boolean: 286 | return Boolean(this.lua.lua_toboolean(this.address, index)) 287 | case LuaType.Thread: 288 | return this.stateToThread(this.lua.lua_tothread(this.address, index)) 289 | default: { 290 | let metatableName: string | undefined 291 | if (type === LuaType.Table || type === LuaType.Userdata) { 292 | metatableName = this.getMetatableName(index) 293 | } 294 | 295 | const typeExtensionWrapper = this.typeExtensions.find((wrapper) => 296 | wrapper.extension.isType(this, index, type, metatableName), 297 | ) 298 | if (typeExtensionWrapper) { 299 | return typeExtensionWrapper.extension.getValue(this, index, userdata) 300 | } 301 | 302 | // Fallthrough if unrecognised user data 303 | console.warn(`The type '${this.lua.lua_typename(this.address, type)}' returned is not supported on JS`) 304 | return new Pointer(this.lua.lua_topointer(this.address, index)) 305 | } 306 | } 307 | } 308 | 309 | public close(): void { 310 | if (this.isClosed()) { 311 | return 312 | } 313 | 314 | if (this.hookFunctionPointer) { 315 | this.lua.module.removeFunction(this.hookFunctionPointer) 316 | } 317 | 318 | this.closed = true 319 | } 320 | 321 | // Set to > 0 to enable, otherwise disable. 322 | public setTimeout(timeout: number | undefined): void { 323 | if (timeout && timeout > 0) { 324 | if (!this.hookFunctionPointer) { 325 | this.hookFunctionPointer = this.lua.module.addFunction((): void => { 326 | if (Date.now() > timeout) { 327 | this.pushValue(new LuaTimeoutError(`thread timeout exceeded`)) 328 | this.lua.lua_error(this.address) 329 | } 330 | }, 'vii') 331 | } 332 | 333 | this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) 334 | this.timeout = timeout 335 | } else if (this.hookFunctionPointer) { 336 | this.hookFunctionPointer = undefined 337 | this.timeout = undefined 338 | this.lua.lua_sethook(this.address, null, 0, 0) 339 | } 340 | } 341 | 342 | public getTimeout(): number | undefined { 343 | return this.timeout 344 | } 345 | 346 | public getPointer(index: number): Pointer { 347 | return new Pointer(this.lua.lua_topointer(this.address, index)) 348 | } 349 | 350 | public isClosed(): boolean { 351 | return !this.address || this.closed || Boolean(this.parent?.isClosed()) 352 | } 353 | 354 | public indexToString(index: number): string { 355 | const str = this.lua.luaL_tolstring(this.address, index, null) 356 | // Pops the string pushed by luaL_tolstring 357 | this.pop() 358 | return str 359 | } 360 | 361 | public dumpStack(log = console.log): void { 362 | const top = this.getTop() 363 | 364 | for (let i = 1; i <= top; i++) { 365 | const type = this.lua.lua_type(this.address, i) 366 | const typename = this.lua.lua_typename(this.address, type) 367 | const pointer = this.getPointer(i) 368 | const name = this.indexToString(i) 369 | const value = this.getValue(i, type) 370 | 371 | log(i, typename, pointer, name, value) 372 | } 373 | } 374 | 375 | public assertOk(result: LuaReturn): void { 376 | if (result !== LuaReturn.Ok && result !== LuaReturn.Yield) { 377 | const resultString = LuaReturn[result] 378 | // This is the default message if there's nothing on the stack. 379 | const error = new Error(`Lua Error(${resultString}/${result})`) 380 | if (this.getTop() > 0) { 381 | if (result === LuaReturn.ErrorMem) { 382 | // If there's no memory just do a normal to string. 383 | error.message = this.lua.lua_tolstring(this.address, -1, null) 384 | } else { 385 | const luaError = this.getValue(-1) 386 | if (luaError instanceof Error) { 387 | error.stack = luaError.stack 388 | } 389 | 390 | // Calls __tostring if it exists and pushes onto the stack. 391 | error.message = this.indexToString(-1) 392 | } 393 | } 394 | 395 | // Also attempt to get a traceback 396 | if (result !== LuaReturn.ErrorMem) { 397 | try { 398 | this.lua.luaL_traceback(this.address, this.address, null, 1) 399 | const traceback = this.lua.lua_tolstring(this.address, -1, null) 400 | if (traceback.trim() !== 'stack traceback:') { 401 | error.message = `${error.message}\n${traceback}` 402 | } 403 | this.pop(1) // pop stack trace. 404 | } catch (err) { 405 | console.warn('Failed to generate stack trace', err) 406 | } 407 | } 408 | 409 | throw error 410 | } 411 | } 412 | 413 | private getValueDecorations(value: any): Decoration { 414 | return value instanceof Decoration ? value : new Decoration(value, {}) 415 | } 416 | } 417 | -------------------------------------------------------------------------------- /src/type-extension.ts: -------------------------------------------------------------------------------- 1 | import { BaseDecorationOptions, Decoration } from './decoration' 2 | import Global from './global' 3 | import Thread from './thread' 4 | import { LuaType, PointerSize } from './types' 5 | 6 | export default abstract class LuaTypeExtension { 7 | // Type name, for metatables and lookups. 8 | public readonly name: string 9 | protected thread: Global 10 | 11 | public constructor(thread: Global, name: string) { 12 | this.thread = thread 13 | this.name = name 14 | } 15 | 16 | public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { 17 | return type === LuaType.Userdata && name === this.name 18 | } 19 | 20 | public abstract close(): void 21 | 22 | // A base implementation that assumes user data serialisation 23 | public getValue(thread: Thread, index: number, _userdata?: unknown): T { 24 | const refUserdata = thread.lua.luaL_testudata(thread.address, index, this.name) 25 | if (!refUserdata) { 26 | throw new Error(`data does not have the expected metatable: ${this.name}`) 27 | } 28 | const referencePointer = thread.lua.module.getValue(refUserdata, '*') 29 | return thread.lua.getRef(referencePointer) 30 | } 31 | 32 | // Return false if type not matched, otherwise true. This base method does not 33 | // check the type. That must be done by the class extending this. 34 | public pushValue(thread: Thread, decoratedValue: Decoration, _userdata?: unknown): boolean { 35 | const { target } = decoratedValue 36 | 37 | const pointer = thread.lua.ref(target) 38 | // 4 = size of pointer in wasm. 39 | const userDataPointer = thread.lua.lua_newuserdatauv(thread.address, PointerSize, 0) 40 | thread.lua.module.setValue(userDataPointer, pointer, '*') 41 | 42 | if (LuaType.Nil === thread.lua.luaL_getmetatable(thread.address, this.name)) { 43 | // Pop the pushed nil value and the user data. Don't need to unref because it's 44 | // already associated with the user data pointer. 45 | thread.pop(2) 46 | throw new Error(`metatable not found: ${this.name}`) 47 | } 48 | 49 | // Set as the metatable for the userdata. 50 | // -1 is the metatable, -2 is the user data. 51 | thread.lua.lua_setmetatable(thread.address, -2) 52 | 53 | return true 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/type-extensions/error.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../decoration' 2 | import Global from '../global' 3 | import Thread from '../thread' 4 | import TypeExtension from '../type-extension' 5 | import { LuaReturn, LuaState } from '../types' 6 | 7 | class ErrorTypeExtension extends TypeExtension { 8 | private gcPointer: number 9 | 10 | public constructor(thread: Global, injectObject: boolean) { 11 | super(thread, 'js_error') 12 | 13 | this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { 14 | // Throws a lua error which does a jump if it does not match. 15 | const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) 16 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 17 | thread.lua.unref(referencePointer) 18 | 19 | return LuaReturn.Ok 20 | }, 'ii') 21 | 22 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 23 | const metatableIndex = thread.lua.lua_gettop(thread.address) 24 | 25 | // Mark it as uneditable 26 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 27 | thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') 28 | 29 | // Add the gc function 30 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 31 | thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') 32 | 33 | // Add an __index method that returns the message field 34 | thread.pushValue((jsRefError: Error, key: unknown) => { 35 | if (key === 'message') { 36 | return jsRefError.message 37 | } 38 | return null 39 | }) 40 | thread.lua.lua_setfield(thread.address, metatableIndex, '__index') 41 | 42 | // Add a tostring method that returns the message. 43 | thread.pushValue((jsRefError: Error) => { 44 | // The message rather than toString to avoid the Error: prefix being 45 | // added. This fits better with Lua errors. 46 | return jsRefError.message 47 | }) 48 | thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') 49 | } 50 | // Pop the metatable from the stack. 51 | thread.lua.lua_pop(thread.address, 1) 52 | 53 | if (injectObject) { 54 | // Lastly create a static Promise constructor. 55 | thread.set('Error', { 56 | create: (message: string | undefined) => { 57 | if (message && typeof message !== 'string') { 58 | throw new Error('message must be a string') 59 | } 60 | 61 | return new Error(message) 62 | }, 63 | }) 64 | } 65 | } 66 | 67 | public pushValue(thread: Thread, decoration: Decoration): boolean { 68 | if (!(decoration.target instanceof Error)) { 69 | return false 70 | } 71 | return super.pushValue(thread, decoration) 72 | } 73 | 74 | public close(): void { 75 | this.thread.lua.module.removeFunction(this.gcPointer) 76 | } 77 | } 78 | 79 | export default function createTypeExtension(thread: Global, injectObject: boolean): TypeExtension { 80 | return new ErrorTypeExtension(thread, injectObject) 81 | } 82 | -------------------------------------------------------------------------------- /src/type-extensions/function.ts: -------------------------------------------------------------------------------- 1 | import { BaseDecorationOptions, Decoration } from '../decoration' 2 | import Global from '../global' 3 | import MultiReturn from '../multireturn' 4 | import RawResult from '../raw-result' 5 | import Thread from '../thread' 6 | import TypeExtension from '../type-extension' 7 | import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType, PointerSize } from '../types' 8 | 9 | export interface FunctionDecoration extends BaseDecorationOptions { 10 | receiveArgsQuantity?: boolean 11 | receiveThread?: boolean 12 | self?: any 13 | } 14 | 15 | export type FunctionType = (...args: any[]) => Promise | any 16 | 17 | export function decorateFunction(target: FunctionType, options: FunctionDecoration): Decoration { 18 | return new Decoration(target, options) 19 | } 20 | 21 | export interface FunctionTypeExtensionOptions { 22 | functionTimeout?: number 23 | } 24 | 25 | class FunctionTypeExtension extends TypeExtension { 26 | private readonly functionRegistry = 27 | typeof FinalizationRegistry !== 'undefined' 28 | ? new FinalizationRegistry((func: number) => { 29 | if (!this.thread.isClosed()) { 30 | this.thread.lua.luaL_unref(this.thread.address, LUA_REGISTRYINDEX, func) 31 | } 32 | }) 33 | : undefined 34 | 35 | private gcPointer: number 36 | private functionWrapper: number 37 | private callbackContext: Thread 38 | private callbackContextIndex: number 39 | private options?: FunctionTypeExtensionOptions 40 | 41 | public constructor(thread: Global, options?: FunctionTypeExtensionOptions) { 42 | super(thread, 'js_function') 43 | 44 | this.options = options 45 | // Create a thread off of the global thread to be used to create function call threads without 46 | // interfering with the global context. This creates a callback context that will always exist 47 | // even if the thread that called getValue() has been destroyed. 48 | this.callbackContext = thread.newThread() 49 | // Pops it from the global stack but keeps it alive 50 | this.callbackContextIndex = this.thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) 51 | 52 | if (!this.functionRegistry) { 53 | console.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') 54 | } 55 | 56 | this.gcPointer = thread.lua.module.addFunction((calledL: LuaState) => { 57 | // Throws a lua error which does a jump if it does not match. 58 | thread.lua.luaL_checkudata(calledL, 1, this.name) 59 | 60 | const userDataPointer = thread.lua.luaL_checkudata(calledL, 1, this.name) 61 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 62 | thread.lua.unref(referencePointer) 63 | 64 | return LuaReturn.Ok 65 | }, 'ii') 66 | 67 | // Creates metatable if it doesn't exist, always pushes it onto the stack. 68 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 69 | thread.lua.lua_pushstring(thread.address, '__gc') 70 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 71 | thread.lua.lua_settable(thread.address, -3) 72 | 73 | thread.lua.lua_pushstring(thread.address, '__metatable') 74 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 75 | thread.lua.lua_settable(thread.address, -3) 76 | } 77 | // Pop the metatable from the stack. 78 | thread.lua.lua_pop(thread.address, 1) 79 | 80 | this.functionWrapper = thread.lua.module.addFunction((calledL: LuaState) => { 81 | const calledThread = thread.stateToThread(calledL) 82 | 83 | const refUserdata = thread.lua.luaL_checkudata(calledL, thread.lua.lua_upvalueindex(1), this.name) 84 | const refPointer = thread.lua.module.getValue(refUserdata, '*') 85 | const { target, options } = thread.lua.getRef(refPointer) as Decoration 86 | 87 | const argsQuantity = calledThread.getTop() 88 | const args = [] 89 | 90 | if (options.receiveThread) { 91 | args.push(calledThread) 92 | } 93 | 94 | if (options.receiveArgsQuantity) { 95 | args.push(argsQuantity) 96 | } else { 97 | for (let i = 1; i <= argsQuantity; i++) { 98 | const value = calledThread.getValue(i) 99 | if (i !== 1 || !options?.self || value !== options.self) { 100 | args.push(value) 101 | } 102 | } 103 | } 104 | 105 | try { 106 | const result = target.apply(options?.self, args) 107 | 108 | if (result === undefined) { 109 | return 0 110 | } else if (result instanceof RawResult) { 111 | return result.count 112 | } else if (result instanceof MultiReturn) { 113 | for (const item of result) { 114 | calledThread.pushValue(item) 115 | } 116 | return result.length 117 | } else { 118 | calledThread.pushValue(result) 119 | return 1 120 | } 121 | } catch (err) { 122 | // Performs a longjmp 123 | if (err === Infinity) { 124 | throw err 125 | } 126 | calledThread.pushValue(err) 127 | return calledThread.lua.lua_error(calledThread.address) 128 | } 129 | }, 'ii') 130 | } 131 | 132 | public close(): void { 133 | this.thread.lua.module.removeFunction(this.gcPointer) 134 | this.thread.lua.module.removeFunction(this.functionWrapper) 135 | // Doesn't destroy the Lua thread, just function pointers. 136 | this.callbackContext.close() 137 | // Destroy the Lua thread 138 | this.callbackContext.lua.luaL_unref(this.callbackContext.address, LUA_REGISTRYINDEX, this.callbackContextIndex) 139 | } 140 | 141 | public isType(_thread: Thread, _index: number, type: LuaType): boolean { 142 | return type === LuaType.Function 143 | } 144 | 145 | public pushValue(thread: Thread, decoration: Decoration): boolean { 146 | if (typeof decoration.target !== 'function') { 147 | return false 148 | } 149 | 150 | // It's surprisingly inefficient to map JS functions to C functions so this creates a reference to the 151 | // function which stays solely in JS. The cfunction called from Lua is created at the top of the class 152 | // and it accesses the JS data through an upvalue. 153 | 154 | const pointer = thread.lua.ref(decoration) 155 | // 4 = size of pointer in wasm. 156 | const userDataPointer = thread.lua.lua_newuserdatauv(thread.address, PointerSize, 0) 157 | thread.lua.module.setValue(userDataPointer, pointer, '*') 158 | 159 | if (LuaType.Nil === thread.lua.luaL_getmetatable(thread.address, this.name)) { 160 | // Pop the pushed userdata. 161 | thread.pop(1) 162 | thread.lua.unref(pointer) 163 | throw new Error(`metatable not found: ${this.name}`) 164 | } 165 | 166 | // Set as the metatable for the function. 167 | // -1 is the metatable, -2 is the userdata 168 | thread.lua.lua_setmetatable(thread.address, -2) 169 | 170 | // Pass 1 to associate the closure with the userdata, pops the userdata. 171 | thread.lua.lua_pushcclosure(thread.address, this.functionWrapper, 1) 172 | 173 | return true 174 | } 175 | 176 | public getValue(thread: Thread, index: number): FunctionType { 177 | // Create a copy of the function 178 | thread.lua.lua_pushvalue(thread.address, index) 179 | // Create a reference to the function which pops it from the stack 180 | const func = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) 181 | 182 | const jsFunc = (...args: any[]): any => { 183 | // Calling a function would ideally be in the Lua context that's calling it. For example if the JS function 184 | // setInterval were exposed to Lua then the calling thread would be created in that Lua context for executing 185 | // the function call back to Lua through JS. However, if getValue were called in a thread, the thread then 186 | // destroyed, and then this JS func were called it would be calling from a dead context. That means the safest 187 | // thing to do is to have a thread you know will always exist. 188 | if (this.callbackContext.isClosed()) { 189 | console.warn('Tried to call a function after closing lua state') 190 | return 191 | } 192 | 193 | // Function calls back to value should always be within a new thread because 194 | // they can be left in inconsistent states. 195 | const callThread = this.callbackContext.newThread() 196 | try { 197 | const internalType = callThread.lua.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, BigInt(func)) 198 | if (internalType !== LuaType.Function) { 199 | const callMetafieldType = callThread.lua.luaL_getmetafield(callThread.address, -1, '__call') 200 | callThread.pop() 201 | if (callMetafieldType !== LuaType.Function) { 202 | throw new Error(`A value of type '${internalType}' was pushed but it is not callable`) 203 | } 204 | } 205 | 206 | for (const arg of args) { 207 | callThread.pushValue(arg) 208 | } 209 | 210 | if (this.options?.functionTimeout) { 211 | callThread.setTimeout(Date.now() + this.options.functionTimeout) 212 | } 213 | 214 | const status: LuaReturn = callThread.lua.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) 215 | if (status === LuaReturn.Yield) { 216 | throw new Error('cannot yield in callbacks from javascript') 217 | } 218 | callThread.assertOk(status) 219 | 220 | if (callThread.getTop() > 0) { 221 | return callThread.getValue(-1) 222 | } 223 | return undefined 224 | } finally { 225 | callThread.close() 226 | // Pop thread used for function call. 227 | this.callbackContext.pop() 228 | } 229 | } 230 | 231 | this.functionRegistry?.register(jsFunc, func) 232 | 233 | return jsFunc 234 | } 235 | } 236 | 237 | export default function createTypeExtension( 238 | thread: Global, 239 | options?: FunctionTypeExtensionOptions, 240 | ): TypeExtension { 241 | return new FunctionTypeExtension(thread, options) 242 | } 243 | -------------------------------------------------------------------------------- /src/type-extensions/null.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../decoration' 2 | import Global from '../global' 3 | import Thread from '../thread' 4 | import TypeExtension from '../type-extension' 5 | import { LuaReturn, LuaState } from '../types' 6 | 7 | class NullTypeExtension extends TypeExtension { 8 | private gcPointer: number 9 | 10 | public constructor(thread: Global) { 11 | super(thread, 'js_null') 12 | 13 | this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { 14 | // Throws a lua error which does a jump if it does not match. 15 | const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) 16 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 17 | thread.lua.unref(referencePointer) 18 | 19 | return LuaReturn.Ok 20 | }, 'ii') 21 | 22 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 23 | const metatableIndex = thread.lua.lua_gettop(thread.address) 24 | 25 | // Mark it as uneditable 26 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 27 | thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') 28 | 29 | // Add the gc function 30 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 31 | thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') 32 | 33 | // Add an __index method that returns nothing. 34 | thread.pushValue(() => null) 35 | thread.lua.lua_setfield(thread.address, metatableIndex, '__index') 36 | 37 | thread.pushValue(() => 'null') 38 | thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') 39 | 40 | thread.pushValue((self: unknown, other: unknown) => self === other) 41 | thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') 42 | } 43 | // Pop the metatable from the stack. 44 | thread.lua.lua_pop(thread.address, 1) 45 | 46 | // Create a new table, this is unique and will be the "null" value by attaching the 47 | // metatable created above. The first argument is the target, the second options. 48 | super.pushValue(thread, new Decoration({}, {})) 49 | // Put it into the global field named null. 50 | thread.lua.lua_setglobal(thread.address, 'null') 51 | } 52 | 53 | public getValue(thread: Thread, index: number): null { 54 | const refUserData = thread.lua.luaL_testudata(thread.address, index, this.name) 55 | if (!refUserData) { 56 | throw new Error(`data does not have the expected metatable: ${this.name}`) 57 | } 58 | return null 59 | } 60 | 61 | // any because LuaDecoration is not exported from the Lua lib. 62 | public pushValue(thread: Thread, decoration: any): boolean { 63 | if (decoration?.target !== null) { 64 | return false 65 | } 66 | // Rather than pushing a new value, get the global "null" onto the stack. 67 | thread.lua.lua_getglobal(thread.address, 'null') 68 | return true 69 | } 70 | 71 | public close(): void { 72 | this.thread.lua.module.removeFunction(this.gcPointer) 73 | } 74 | } 75 | 76 | export default function createTypeExtension(thread: Global): TypeExtension { 77 | return new NullTypeExtension(thread) 78 | } 79 | -------------------------------------------------------------------------------- /src/type-extensions/promise.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../decoration' 2 | import Global from '../global' 3 | import MultiReturn from '../multireturn' 4 | import RawResult from '../raw-result' 5 | import Thread from '../thread' 6 | import TypeExtension from '../type-extension' 7 | import { LuaReturn, LuaState } from '../types' 8 | import { isPromise } from '../utils' 9 | import { decorateFunction } from './function' 10 | 11 | class PromiseTypeExtension extends TypeExtension> { 12 | private gcPointer: number 13 | 14 | public constructor(thread: Global, injectObject: boolean) { 15 | super(thread, 'js_promise') 16 | 17 | this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { 18 | // Throws a lua error which does a jump if it does not match. 19 | const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) 20 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 21 | thread.lua.unref(referencePointer) 22 | 23 | return LuaReturn.Ok 24 | }, 'ii') 25 | 26 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 27 | const metatableIndex = thread.lua.lua_gettop(thread.address) 28 | 29 | // Mark it as uneditable 30 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 31 | thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') 32 | 33 | // Add the gc function 34 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 35 | thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') 36 | 37 | const checkSelf = (self: Promise): true => { 38 | if (!isPromise(self)) { 39 | throw new Error('self instance is not a promise') 40 | } 41 | return true 42 | } 43 | 44 | thread.pushValue({ 45 | next: (self: Promise, ...args: Parameters) => checkSelf(self) && self.then(...args), 46 | catch: (self: Promise, ...args: Parameters) => checkSelf(self) && self.catch(...args), 47 | finally: (self: Promise, ...args: Parameters) => checkSelf(self) && self.finally(...args), 48 | await: decorateFunction( 49 | (functionThread: Thread, self: Promise) => { 50 | checkSelf(self) 51 | 52 | if (functionThread.address === thread.address) { 53 | throw new Error('cannot await in the main thread') 54 | } 55 | 56 | let promiseResult: { status: 'fulfilled' | 'rejected'; value: any } | undefined = undefined 57 | 58 | const awaitPromise = self 59 | .then((res) => { 60 | promiseResult = { status: 'fulfilled', value: res } 61 | }) 62 | .catch((err) => { 63 | promiseResult = { status: 'rejected', value: err } 64 | }) 65 | 66 | const continuance = this.thread.lua.module.addFunction((continuanceState: LuaState) => { 67 | // If this yield has been called from within a coroutine and so manually resumed 68 | // then there may not yet be any results. In that case yield again. 69 | if (!promiseResult) { 70 | // 1 is because the initial yield pushed a promise reference so this pops 71 | // it and re-returns it. 72 | // 0 because this is called between resumes so the first one should've 73 | // popped the promise before returning the result. This is true within 74 | // Lua's coroutine.resume too. 75 | return thread.lua.lua_yieldk(functionThread.address, 0, 0, continuance) 76 | } 77 | 78 | this.thread.lua.module.removeFunction(continuance) 79 | 80 | const continuanceThread = thread.stateToThread(continuanceState) 81 | 82 | if (promiseResult.status === 'rejected') { 83 | continuanceThread.pushValue(promiseResult.value || new Error('promise rejected with no error')) 84 | return this.thread.lua.lua_error(continuanceState) 85 | } 86 | 87 | if (promiseResult.value instanceof RawResult) { 88 | return promiseResult.value.count 89 | } else if (promiseResult.value instanceof MultiReturn) { 90 | for (const arg of promiseResult.value) { 91 | continuanceThread.pushValue(arg) 92 | } 93 | return promiseResult.value.length 94 | } else { 95 | continuanceThread.pushValue(promiseResult.value) 96 | return 1 97 | } 98 | }, 'iiii') 99 | 100 | functionThread.pushValue(awaitPromise) 101 | return new RawResult(thread.lua.lua_yieldk(functionThread.address, 1, 0, continuance)) 102 | }, 103 | { receiveThread: true }, 104 | ), 105 | }) 106 | thread.lua.lua_setfield(thread.address, metatableIndex, '__index') 107 | 108 | thread.pushValue((self: Promise, other: Promise) => self === other) 109 | thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') 110 | } 111 | // Pop the metatable from the stack. 112 | thread.lua.lua_pop(thread.address, 1) 113 | 114 | if (injectObject) { 115 | // Lastly create a static Promise constructor. 116 | thread.set('Promise', { 117 | create: (callback: ConstructorParameters[0]) => new Promise(callback), 118 | all: (promiseArray: any) => { 119 | if (!Array.isArray(promiseArray)) { 120 | throw new Error('argument must be an array of promises') 121 | } 122 | 123 | return Promise.all(promiseArray.map((potentialPromise) => Promise.resolve(potentialPromise))) 124 | }, 125 | resolve: (value: any) => Promise.resolve(value), 126 | }) 127 | } 128 | } 129 | 130 | public close(): void { 131 | this.thread.lua.module.removeFunction(this.gcPointer) 132 | } 133 | 134 | public pushValue(thread: Thread, decoration: Decoration>): boolean { 135 | if (!isPromise(decoration.target)) { 136 | return false 137 | } 138 | return super.pushValue(thread, decoration) 139 | } 140 | } 141 | 142 | export default function createTypeExtension(thread: Global, injectObject: boolean): TypeExtension> { 143 | return new PromiseTypeExtension(thread, injectObject) 144 | } 145 | -------------------------------------------------------------------------------- /src/type-extensions/proxy.ts: -------------------------------------------------------------------------------- 1 | import { BaseDecorationOptions, Decoration } from '../decoration' 2 | import Global from '../global' 3 | import MultiReturn from '../multireturn' 4 | import Thread from '../thread' 5 | import TypeExtension from '../type-extension' 6 | import { LuaReturn, LuaState, LuaType } from '../types' 7 | import { isPromise } from '../utils' 8 | import { decorateFunction } from './function' 9 | 10 | export interface ProxyDecorationOptions extends BaseDecorationOptions { 11 | // If undefined, will try to figure out if should proxy 12 | proxy?: boolean 13 | } 14 | 15 | export function decorateProxy(target: unknown, options?: ProxyDecorationOptions): Decoration { 16 | return new Decoration(target, options || {}) 17 | } 18 | 19 | class ProxyTypeExtension extends TypeExtension { 20 | private readonly gcPointer: number 21 | 22 | public constructor(thread: Global) { 23 | super(thread, 'js_proxy') 24 | 25 | this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { 26 | // Throws a lua error which does a jump if it does not match. 27 | const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) 28 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 29 | thread.lua.unref(referencePointer) 30 | 31 | return LuaReturn.Ok 32 | }, 'ii') 33 | 34 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 35 | const metatableIndex = thread.lua.lua_gettop(thread.address) 36 | 37 | // Mark it as uneditable 38 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 39 | thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') 40 | 41 | // Add the gc function 42 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 43 | thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') 44 | 45 | thread.pushValue((self: any, key: unknown) => { 46 | switch (typeof key) { 47 | case 'number': 48 | // Map from Lua's 1 based indexing to JS's 0. 49 | // This is especially important here because ipairs just calls 50 | // __index with 1, 2, 3, 4 etc until there's a null. 51 | key = key - 1 52 | // Fallthrough 53 | case 'string': 54 | break 55 | default: 56 | throw new Error('Only strings or numbers can index js objects') 57 | } 58 | 59 | const value = self[key as string | number] 60 | if (typeof value === 'function') { 61 | return decorateFunction(value as (...args: any[]) => any, { self }) 62 | } 63 | 64 | return value 65 | }) 66 | thread.lua.lua_setfield(thread.address, metatableIndex, '__index') 67 | 68 | thread.pushValue((self: any, key: unknown, value: any) => { 69 | switch (typeof key) { 70 | case 'number': 71 | // Map from Lua's 1 based indexing to JS's 0. 72 | key = key - 1 73 | // Fallthrough 74 | case 'string': 75 | break 76 | default: 77 | throw new Error('Only strings or numbers can index js objects') 78 | } 79 | self[key as string | number] = value 80 | }) 81 | thread.lua.lua_setfield(thread.address, metatableIndex, '__newindex') 82 | 83 | thread.pushValue((self: any) => { 84 | return self.toString?.() ?? typeof self 85 | }) 86 | thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') 87 | 88 | thread.pushValue((self: any) => { 89 | return self.length || 0 90 | }) 91 | thread.lua.lua_setfield(thread.address, metatableIndex, '__len') 92 | 93 | thread.pushValue((self: any) => { 94 | const keys = Object.getOwnPropertyNames(self) 95 | let i = 0 96 | // Stateful rather than stateless. First call is with nil. 97 | return MultiReturn.of( 98 | () => { 99 | const ret = MultiReturn.of(keys[i], self[keys[i]]) 100 | i++ 101 | return ret 102 | }, 103 | self, 104 | null, 105 | ) 106 | }) 107 | thread.lua.lua_setfield(thread.address, metatableIndex, '__pairs') 108 | 109 | thread.pushValue((self: any, other: any) => { 110 | return self === other 111 | }) 112 | thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') 113 | 114 | thread.pushValue((self: any, ...args: any[]) => { 115 | if (args[0] === self) { 116 | args.shift() 117 | } 118 | return self(...args) 119 | }) 120 | thread.lua.lua_setfield(thread.address, metatableIndex, '__call') 121 | } 122 | 123 | // Pop the metatable from the stack. 124 | thread.lua.lua_pop(thread.address, 1) 125 | } 126 | 127 | public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { 128 | // Capture proxied types and functions returned by lua. 129 | return type === LuaType.Userdata && name === this.name 130 | } 131 | 132 | public getValue(thread: Thread, index: number): any { 133 | const refUserdata = thread.lua.lua_touserdata(thread.address, index) 134 | const referencePointer = thread.lua.module.getValue(refUserdata, '*') 135 | return thread.lua.getRef(referencePointer) 136 | } 137 | 138 | public pushValue(thread: Thread, decoratedValue: Decoration): boolean { 139 | const { target, options } = decoratedValue 140 | if (options.proxy === undefined) { 141 | if (target === null || target === undefined) { 142 | return false 143 | } 144 | 145 | if (typeof target !== 'object') { 146 | const isClass = 147 | typeof target === 'function' && target.prototype?.constructor === target && target.toString().startsWith('class ') 148 | 149 | if (!isClass) { 150 | return false 151 | } 152 | } 153 | 154 | if (isPromise(target)) { 155 | return false 156 | } 157 | } else if (options.proxy === false) { 158 | return false 159 | } 160 | 161 | if (options.metatable && !(options.metatable instanceof Decoration)) { 162 | // Otherwise the metatable will get converted into a JS ref rather than being set as a standard 163 | // table. This forces it to use the standard table type. 164 | decoratedValue.options.metatable = decorateProxy(options.metatable, { proxy: false }) 165 | return false 166 | } 167 | 168 | return super.pushValue(thread, decoratedValue) 169 | } 170 | 171 | public close(): void { 172 | this.thread.lua.module.removeFunction(this.gcPointer) 173 | } 174 | } 175 | 176 | export default function createTypeExtension(thread: Global): TypeExtension { 177 | return new ProxyTypeExtension(thread) 178 | } 179 | -------------------------------------------------------------------------------- /src/type-extensions/table.ts: -------------------------------------------------------------------------------- 1 | import { Decoration } from '../decoration' 2 | import Global from '../global' 3 | import Thread from '../thread' 4 | import TypeExtension from '../type-extension' 5 | import { LUA_REGISTRYINDEX, LuaType } from '../types' 6 | 7 | export type TableType = Record | any[] 8 | 9 | class TableTypeExtension extends TypeExtension { 10 | public constructor(thread: Global) { 11 | super(thread, 'js_table') 12 | } 13 | 14 | public close(): void { 15 | // Nothing to do 16 | } 17 | 18 | public isType(_thread: Thread, _index: number, type: LuaType): boolean { 19 | return type === LuaType.Table 20 | } 21 | 22 | public getValue(thread: Thread, index: number, userdata?: any): TableType { 23 | // This is a map of Lua pointers to JS objects. 24 | const seenMap: Map = userdata || new Map() 25 | const pointer = thread.lua.lua_topointer(thread.address, index) 26 | 27 | let table = seenMap.get(pointer) 28 | if (!table) { 29 | const keys = this.readTableKeys(thread, index) 30 | 31 | const isSequential = keys.length > 0 && keys.every((key, index) => key === String(index + 1)) 32 | table = isSequential ? [] : {} 33 | 34 | seenMap.set(pointer, table) 35 | this.readTableValues(thread, index, seenMap, table) 36 | } 37 | 38 | return table 39 | } 40 | 41 | public pushValue(thread: Thread, { target }: Decoration, userdata?: Map): boolean { 42 | if (typeof target !== 'object' || target === null) { 43 | return false 44 | } 45 | 46 | // This is a map of JS objects to luaL references. 47 | const seenMap = userdata || new Map() 48 | const existingReference = seenMap.get(target) 49 | if (existingReference !== undefined) { 50 | thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(existingReference)) 51 | return true 52 | } 53 | 54 | try { 55 | const tableIndex = thread.getTop() + 1 56 | 57 | const createTable = (arrayCount: number, keyCount: number): void => { 58 | thread.lua.lua_createtable(thread.address, arrayCount, keyCount) 59 | const ref = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) 60 | seenMap.set(target, ref) 61 | thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(ref)) 62 | } 63 | 64 | if (Array.isArray(target)) { 65 | createTable(target.length, 0) 66 | 67 | for (let i = 0; i < target.length; i++) { 68 | thread.pushValue(i + 1, seenMap) 69 | thread.pushValue(target[i], seenMap) 70 | 71 | thread.lua.lua_settable(thread.address, tableIndex) 72 | } 73 | } else { 74 | createTable(0, Object.getOwnPropertyNames(target).length) 75 | 76 | for (const key in target) { 77 | thread.pushValue(key, seenMap) 78 | thread.pushValue((target as Record)[key], seenMap) 79 | 80 | thread.lua.lua_settable(thread.address, tableIndex) 81 | } 82 | } 83 | } finally { 84 | if (userdata === undefined) { 85 | for (const reference of seenMap.values()) { 86 | thread.lua.luaL_unref(thread.address, LUA_REGISTRYINDEX, reference) 87 | } 88 | } 89 | } 90 | 91 | return true 92 | } 93 | 94 | private readTableKeys(thread: Thread, index: number): string[] { 95 | const keys = [] 96 | 97 | thread.lua.lua_pushnil(thread.address) 98 | while (thread.lua.lua_next(thread.address, index)) { 99 | // JS only supports string keys in objects. 100 | const key = thread.indexToString(-2) 101 | keys.push(key) 102 | // Pop the value. 103 | thread.pop() 104 | } 105 | 106 | return keys 107 | } 108 | 109 | private readTableValues(thread: Thread, index: number, seenMap: Map, table: TableType): void { 110 | const isArray = Array.isArray(table) 111 | 112 | thread.lua.lua_pushnil(thread.address) 113 | while (thread.lua.lua_next(thread.address, index)) { 114 | const key = thread.indexToString(-2) 115 | const value = thread.getValue(-1, undefined, seenMap) 116 | 117 | if (isArray) { 118 | table.push(value) 119 | } else { 120 | table[key] = value 121 | } 122 | 123 | thread.pop() 124 | } 125 | } 126 | } 127 | 128 | export default function createTypeExtension(thread: Global): TypeExtension { 129 | return new TableTypeExtension(thread) 130 | } 131 | -------------------------------------------------------------------------------- /src/type-extensions/userdata.ts: -------------------------------------------------------------------------------- 1 | import { BaseDecorationOptions, Decoration } from '../decoration' 2 | import Global from '../global' 3 | import Thread from '../thread' 4 | import TypeExtension from '../type-extension' 5 | import { LuaReturn, LuaState, LuaType } from '../types' 6 | 7 | export interface UserdataDecorationOptions extends BaseDecorationOptions { 8 | reference?: boolean 9 | } 10 | 11 | export function decorateUserdata(target: unknown): Decoration { 12 | return new Decoration(target, { reference: true }) 13 | } 14 | 15 | class UserdataTypeExtension extends TypeExtension { 16 | private readonly gcPointer: number 17 | 18 | public constructor(thread: Global) { 19 | super(thread, 'js_userdata') 20 | 21 | this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { 22 | // Throws a lua error which does a jump if it does not match. 23 | const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) 24 | const referencePointer = thread.lua.module.getValue(userDataPointer, '*') 25 | thread.lua.unref(referencePointer) 26 | 27 | return LuaReturn.Ok 28 | }, 'ii') 29 | 30 | if (thread.lua.luaL_newmetatable(thread.address, this.name)) { 31 | const metatableIndex = thread.lua.lua_gettop(thread.address) 32 | 33 | // Mark it as uneditable 34 | thread.lua.lua_pushstring(thread.address, 'protected metatable') 35 | thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') 36 | 37 | // Add the gc function 38 | thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) 39 | thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') 40 | } 41 | 42 | // Pop the metatable from the stack. 43 | thread.lua.lua_pop(thread.address, 1) 44 | } 45 | 46 | public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { 47 | return type === LuaType.Userdata && name === this.name 48 | } 49 | 50 | public getValue(thread: Thread, index: number): any { 51 | const refUserdata = thread.lua.lua_touserdata(thread.address, index) 52 | const referencePointer = thread.lua.module.getValue(refUserdata, '*') 53 | return thread.lua.getRef(referencePointer) 54 | } 55 | 56 | public pushValue(thread: Thread, decoratedValue: Decoration): boolean { 57 | if (!decoratedValue.options.reference) { 58 | return false 59 | } 60 | 61 | return super.pushValue(thread, decoratedValue) 62 | } 63 | 64 | public close(): void { 65 | this.thread.lua.module.removeFunction(this.gcPointer) 66 | } 67 | } 68 | 69 | export default function createTypeExtension(thread: Global): TypeExtension { 70 | return new UserdataTypeExtension(thread) 71 | } 72 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type LuaState = number 2 | 3 | export type EnvironmentVariables = Record 4 | 5 | export interface CreateEngineOptions { 6 | /** Injects all the lua standard libraries (math, coroutine, debug) */ 7 | openStandardLibs?: boolean 8 | /** Injects some JS objects to the Lua environment: Error, Promise, null, Objects */ 9 | injectObjects?: boolean 10 | /** Enables the proxy for JS objects, useful for classes, etc... */ 11 | enableProxy?: boolean 12 | /** Whether to trace memory allocations. */ 13 | traceAllocations?: boolean 14 | /** Maximum time in milliseconds a Lua function can run before being interrupted. */ 15 | functionTimeout?: number 16 | } 17 | 18 | export enum LuaReturn { 19 | Ok = 0, 20 | Yield = 1, 21 | ErrorRun = 2, 22 | ErrorSyntax = 3, 23 | ErrorMem = 4, 24 | ErrorErr = 5, 25 | ErrorFile = 6, 26 | } 27 | 28 | export interface LuaResumeResult { 29 | result: LuaReturn 30 | resultCount: number 31 | } 32 | 33 | export interface LuaThreadRunOptions { 34 | timeout?: number 35 | } 36 | 37 | export const PointerSize = 4 38 | 39 | export const LUA_MULTRET = -1 40 | export const LUAI_MAXSTACK = 1000000 41 | export const LUA_REGISTRYINDEX = -LUAI_MAXSTACK - 1000 42 | 43 | export enum LuaType { 44 | None = -1, 45 | Nil = 0, 46 | Boolean = 1, 47 | LightUserdata = 2, 48 | Number = 3, 49 | String = 4, 50 | Table = 5, 51 | Function = 6, 52 | Userdata = 7, 53 | Thread = 8, 54 | } 55 | 56 | export enum LuaEventCodes { 57 | Call = 0, 58 | Ret = 1, 59 | Line = 2, 60 | Count = 3, 61 | TailCall = 4, 62 | } 63 | 64 | export enum LuaEventMasks { 65 | Call = 1 << LuaEventCodes.Call, 66 | Ret = 1 << LuaEventCodes.Ret, 67 | Line = 1 << LuaEventCodes.Line, 68 | Count = 1 << LuaEventCodes.Count, 69 | } 70 | 71 | export enum LuaLibraries { 72 | Base = '_G', 73 | Coroutine = 'coroutine', 74 | Table = 'table', 75 | IO = 'io', 76 | OS = 'os', 77 | String = 'string', 78 | UTF8 = 'utf8', 79 | Math = 'math', 80 | Debug = 'debug', 81 | Package = 'package', 82 | } 83 | 84 | export class LuaTimeoutError extends Error {} 85 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const isPromise = (target: any): target is Promise => { 2 | return target && (Promise.resolve(target) === target || typeof target.then === 'function') 3 | } 4 | -------------------------------------------------------------------------------- /test/boot.js: -------------------------------------------------------------------------------- 1 | import { use } from 'chai' 2 | import chaiAsPromised from 'chai-as-promised' 3 | 4 | use(chaiAsPromised) 5 | -------------------------------------------------------------------------------- /test/debug.js: -------------------------------------------------------------------------------- 1 | import { getEngine } from './utils.js' 2 | 3 | // This file was created as a sandbox to test and debug on vscode 4 | const engine = await getEngine() 5 | engine.global.set('potato', { 6 | test: true, 7 | hello: ['world'], 8 | }) 9 | engine.global.get('potato') 10 | engine.doStringSync('print(potato.hello[1])') 11 | -------------------------------------------------------------------------------- /test/engine.test.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { LuaLibraries, LuaReturn, LuaThread, LuaType, decorate, decorateProxy, decorateUserdata } from '../dist/index.js' 3 | import { expect } from 'chai' 4 | import { getEngine, getFactory } from './utils.js' 5 | import { setTimeout } from 'node:timers/promises' 6 | import { mock } from 'node:test' 7 | 8 | class TestClass { 9 | static hello() { 10 | return 'world' 11 | } 12 | 13 | constructor(name) { 14 | this.name = name 15 | } 16 | 17 | getName() { 18 | return this.name 19 | } 20 | } 21 | 22 | describe('Engine', () => { 23 | let intervals = [] 24 | const setIntervalSafe = (callback, interval) => { 25 | intervals.push(setInterval(() => callback(), interval)) 26 | } 27 | 28 | afterEach(() => { 29 | for (const interval of intervals) { 30 | clearInterval(interval) 31 | } 32 | intervals = [] 33 | }) 34 | 35 | it('receive lua table on JS function should succeed', async () => { 36 | const engine = await getEngine() 37 | engine.global.set('stringify', (table) => { 38 | return JSON.stringify(table) 39 | }) 40 | 41 | await engine.doString('value = stringify({ test = 1 })') 42 | 43 | expect(engine.global.get('value')).to.be.equal(JSON.stringify({ test: 1 })) 44 | }) 45 | 46 | it('get a global table inside a JS function called by lua should succeed', async () => { 47 | const engine = await getEngine() 48 | engine.global.set('t', { test: 1 }) 49 | engine.global.set('test', () => { 50 | return engine.global.get('t') 51 | }) 52 | 53 | const value = await engine.doString('return test(2)') 54 | 55 | expect(value).to.be.eql({ test: 1 }) 56 | }) 57 | 58 | it('receive JS object on lua should succeed', async () => { 59 | const engine = await getEngine() 60 | 61 | engine.global.set('test', () => { 62 | return { 63 | aaaa: 1, 64 | bbb: 'hey', 65 | test() { 66 | return 22 67 | }, 68 | } 69 | }) 70 | const value = await engine.doString('return test().test()') 71 | 72 | expect(value).to.be.equal(22) 73 | }) 74 | 75 | it('receive JS object with circular references on lua should succeed', async () => { 76 | const engine = await getEngine() 77 | const obj = { 78 | hello: 'world', 79 | } 80 | obj.self = obj 81 | engine.global.set('obj', obj) 82 | 83 | const value = await engine.doString('return obj.self.self.self.hello') 84 | 85 | expect(value).to.be.equal('world') 86 | }) 87 | 88 | it('receive Lua object with circular references on JS should succeed', async () => { 89 | const engine = await getEngine() 90 | const value = await engine.doString(` 91 | local obj1 = { 92 | hello = 'world', 93 | } 94 | obj1.self = obj1 95 | local obj2 = { 96 | 5, 97 | hello = 'everybody', 98 | array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 99 | fn = function() 100 | return 'hello' 101 | end 102 | } 103 | obj2.self = obj2 104 | return { obj1 = obj1, obj2 } 105 | `) 106 | 107 | const obj = { 108 | obj1: { 109 | hello: 'world', 110 | }, 111 | 1: { 112 | 1: 5, 113 | hello: 'everybody', 114 | array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 115 | // Emulate the lua function 116 | fn: value[1].fn, 117 | }, 118 | } 119 | obj.obj1.self = obj.obj1 120 | obj[1].self = obj[1] 121 | expect(value).to.deep.eql(obj) 122 | }) 123 | 124 | it('receive lua array with circular references on JS should succeed', async () => { 125 | const engine = await getEngine() 126 | const value = await engine.doString(` 127 | obj = { 128 | "hello", 129 | "world" 130 | } 131 | table.insert(obj, obj) 132 | return obj 133 | `) 134 | 135 | const arr = ['hello', 'world'] 136 | arr.push(arr) 137 | expect(value).to.be.eql(arr) 138 | }) 139 | 140 | it('receive JS object with multiple circular references on lua should succeed', async () => { 141 | const engine = await getEngine() 142 | const obj1 = { 143 | hello: 'world', 144 | } 145 | obj1.self = obj1 146 | const obj2 = { 147 | hello: 'everybody', 148 | } 149 | obj2.self = obj2 150 | engine.global.set('obj', { obj1, obj2 }) 151 | 152 | await engine.doString(` 153 | assert(obj.obj1.self.self.hello == "world") 154 | assert(obj.obj2.self.self.hello == "everybody") 155 | `) 156 | }) 157 | 158 | it('receive JS object with null prototype on lua should succeed', async () => { 159 | const engine = await getEngine() 160 | const obj = Object.create(null) 161 | obj.hello = 'world' 162 | engine.global.set('obj', obj) 163 | 164 | const value = await engine.doString(`return obj.hello`) 165 | 166 | expect(value).to.be.equal('world') 167 | }) 168 | 169 | it('a lua error should throw on JS', async () => { 170 | const engine = await getEngine() 171 | 172 | await expect(engine.doString(`x -`)).to.eventually.be.rejected 173 | }) 174 | 175 | it('call a lua function from JS should succeed', async () => { 176 | const engine = await getEngine() 177 | 178 | await engine.doString(`function sum(x, y) return x + y end`) 179 | const sum = engine.global.get('sum') 180 | 181 | expect(sum(10, 50)).to.be.equal(60) 182 | }) 183 | 184 | it('scheduled lua calls should succeed', async () => { 185 | const engine = await getEngine() 186 | engine.global.set('setInterval', setIntervalSafe) 187 | 188 | await engine.doString(` 189 | test = "" 190 | setInterval(function() 191 | test = test .. "i" 192 | end, 1) 193 | `) 194 | await setTimeout(20) 195 | 196 | const test = engine.global.get('test') 197 | expect(test).length.above(3) 198 | expect(test).length.below(21) 199 | expect(test).to.be.equal(''.padEnd(test.length, 'i')) 200 | }) 201 | 202 | it('scheduled lua calls should fail silently if invalid', async () => { 203 | const engine = await getEngine() 204 | engine.global.set('setInterval', setIntervalSafe) 205 | const originalConsoleWarn = console.warn 206 | console.warn = mock.fn() 207 | 208 | await engine.doString(` 209 | test = 0 210 | setInterval(function() 211 | test = test + 1 212 | end, 5) 213 | `) 214 | engine.global.close() 215 | await setTimeout(5 + 5) 216 | console.warn = originalConsoleWarn 217 | }) 218 | 219 | it('call lua function from JS passing an array argument should succeed', async () => { 220 | const engine = await getEngine() 221 | 222 | const sum = await engine.doString(` 223 | return function(arr) 224 | local sum = 0 225 | for k, v in ipairs(arr) do 226 | sum = sum + v 227 | end 228 | return sum 229 | end 230 | `) 231 | 232 | expect(sum([10, 50, 25])).to.be.equal(85) 233 | }) 234 | 235 | it('call a global function with multiple returns should succeed', async () => { 236 | const engine = await getEngine() 237 | 238 | await engine.doString(` 239 | function f(x,y) 240 | return 1,x,y,"Hello World",{},function() end 241 | end 242 | `) 243 | 244 | const returns = engine.global.call('f', 10, 25) 245 | expect(returns).to.have.length(6) 246 | expect(returns.slice(0, -1)).to.eql([1, 10, 25, 'Hello World', {}]) 247 | expect(returns.at(-1)).to.be.a('function') 248 | }) 249 | 250 | it('get a lua thread should succeed', async () => { 251 | const engine = await getEngine() 252 | 253 | const thread = await engine.doString(` 254 | return coroutine.create(function() 255 | print("hey") 256 | end) 257 | `) 258 | 259 | expect(thread).to.be.instanceOf(LuaThread) 260 | expect(thread).to.not.be.equal(0) 261 | }) 262 | 263 | it('a JS error should pause lua execution', async () => { 264 | const engine = await getEngine() 265 | const check = mock.fn() 266 | engine.global.set('check', check) 267 | engine.global.set('throw', () => { 268 | throw new Error('expected error') 269 | }) 270 | 271 | await expect( 272 | engine.doString(` 273 | throw() 274 | check() 275 | `), 276 | ).eventually.to.be.rejected 277 | expect(check.mock.calls).to.have.length(0) 278 | }) 279 | 280 | it('catch a JS error with pcall should succeed', async () => { 281 | const engine = await getEngine() 282 | const check = mock.fn() 283 | engine.global.set('check', check) 284 | engine.global.set('throw', () => { 285 | throw new Error('expected error') 286 | }) 287 | 288 | await engine.doString(` 289 | local success, err = pcall(throw) 290 | assert(success == false) 291 | assert(tostring(err) == "Error: expected error") 292 | check() 293 | `) 294 | 295 | expect(check.mock.calls).to.have.length(1) 296 | }) 297 | 298 | it('call a JS function in a different thread should succeed', async () => { 299 | const engine = await getEngine() 300 | const sum = mock.fn((x, y) => x + y) 301 | engine.global.set('sum', sum) 302 | 303 | await engine.doString(` 304 | coroutine.resume(coroutine.create(function() 305 | sum(10, 20) 306 | end)) 307 | `) 308 | 309 | expect(sum.mock.calls).to.have.length(1) 310 | expect(sum.mock.calls[0].arguments).to.be.eql([10, 20]) 311 | }) 312 | 313 | it('get callable table as function should succeed', async () => { 314 | const engine = await getEngine() 315 | await engine.doString(` 316 | _G['sum'] = setmetatable({}, { 317 | __call = function(self, x, y) 318 | return x + y 319 | end 320 | }) 321 | `) 322 | 323 | engine.global.lua.lua_getglobal(engine.global.address, 'sum') 324 | const sum = engine.global.getValue(-1, LuaType.Function) 325 | 326 | expect(sum(10, 30)).to.be.equal(40) 327 | }) 328 | 329 | it('lua_resume with yield succeeds', async () => { 330 | const engine = await getEngine() 331 | const thread = engine.global.newThread() 332 | thread.loadString(` 333 | local yieldRes = coroutine.yield(10) 334 | return yieldRes 335 | `) 336 | 337 | const resumeResult = thread.resume(0) 338 | expect(resumeResult.result).to.be.equal(LuaReturn.Yield) 339 | expect(resumeResult.resultCount).to.be.equal(1) 340 | 341 | const yieldValue = thread.getValue(-1) 342 | expect(yieldValue).to.be.equal(10) 343 | 344 | thread.pop(resumeResult.resultCount) 345 | thread.pushValue(yieldValue * 2) 346 | 347 | const finalResumeResult = thread.resume(1) 348 | expect(finalResumeResult.result).to.be.equal(LuaReturn.Ok) 349 | expect(finalResumeResult.resultCount).to.be.equal(1) 350 | 351 | const finalValue = thread.getValue(-1) 352 | expect(finalValue).to.be.equal(20) 353 | }) 354 | 355 | it('get memory with allocation tracing should succeeds', async () => { 356 | const engine = await getEngine({ traceAllocations: true }) 357 | expect(engine.global.getMemoryUsed()).to.be.greaterThan(0) 358 | }) 359 | 360 | it('get memory should return correct', async () => { 361 | const engine = await getEngine({ traceAllocations: true }) 362 | 363 | const totalMemory = await engine.doString(` 364 | collectgarbage() 365 | local x = 10 366 | local batata = { dawdwa = 1 } 367 | return collectgarbage('count') * 1024 368 | `) 369 | 370 | expect(engine.global.getMemoryUsed()).to.be.equal(totalMemory) 371 | }) 372 | 373 | it('get memory without tracing should throw', async () => { 374 | const engine = await getEngine({ traceAllocations: false }) 375 | 376 | expect(() => engine.global.getMemoryUsed()).to.throw() 377 | }) 378 | 379 | it('limit memory use causes program loading failure succeeds', async () => { 380 | const engine = await getEngine({ traceAllocations: true }) 381 | engine.global.setMemoryMax(engine.global.getMemoryUsed()) 382 | expect(() => { 383 | engine.global.loadString(` 384 | local a = 10 385 | local b = 20 386 | return a + b 387 | `) 388 | }).to.throw('not enough memory') 389 | 390 | // Remove the limit and retry 391 | engine.global.setMemoryMax(undefined) 392 | engine.global.loadString(` 393 | local a = 10 394 | local b = 20 395 | return a + b 396 | `) 397 | }) 398 | 399 | it('limit memory use causes program runtime failure succeeds', async () => { 400 | const engine = await getEngine({ traceAllocations: true }) 401 | engine.global.loadString(` 402 | local tab = {} 403 | for i = 1, 50, 1 do 404 | tab[i] = i 405 | end 406 | `) 407 | engine.global.setMemoryMax(engine.global.getMemoryUsed()) 408 | 409 | await expect(engine.global.run()).to.eventually.be.rejectedWith('not enough memory') 410 | }) 411 | 412 | it('table supported circular dependencies', async () => { 413 | const engine = await getEngine() 414 | 415 | const a = { name: 'a' } 416 | const b = { name: 'b' } 417 | b.a = a 418 | a.b = b 419 | 420 | engine.global.pushValue(a) 421 | const res = engine.global.getValue(-1) 422 | 423 | expect(res.b.a).to.be.eql(res) 424 | }) 425 | 426 | it('wrap a js object (with metatable)', async () => { 427 | const engine = await getEngine() 428 | engine.global.set('TestClass', { 429 | create: (name) => { 430 | return decorate( 431 | { 432 | instance: decorateUserdata(new TestClass(name)), 433 | }, 434 | { 435 | metatable: { 436 | __name: 'js_TestClass', 437 | __index: (self, key) => { 438 | if (key === 'name') { 439 | return self.instance.getName() 440 | } 441 | return null 442 | }, 443 | }, 444 | }, 445 | ) 446 | }, 447 | }) 448 | 449 | const res = await engine.doString(` 450 | local instance = TestClass.create("demo name") 451 | return instance.name 452 | `) 453 | expect(res).to.be.equal('demo name') 454 | }) 455 | 456 | it('wrap a js object using proxy', async () => { 457 | const engine = await getEngine() 458 | engine.global.set('TestClass', { 459 | create: (name) => new TestClass(name), 460 | }) 461 | const res = await engine.doString(` 462 | local instance = TestClass.create("demo name 2") 463 | return instance:getName() 464 | `) 465 | expect(res).to.be.equal('demo name 2') 466 | }) 467 | 468 | it('wrap a js object using proxy and apply metatable in lua', async () => { 469 | const engine = await getEngine() 470 | engine.global.set('TestClass', { 471 | create: (name) => new TestClass(name), 472 | }) 473 | const res = await engine.doString(` 474 | local instance = TestClass.create("demo name 2") 475 | 476 | -- Based in the simple lua classes tutotial 477 | local Wrapped = {} 478 | Wrapped.__index = Wrapped 479 | 480 | function Wrapped:create(name) 481 | local wrapped = {} 482 | wrapped.instance = TestClass.create(name) 483 | setmetatable(wrapped, Wrapped) 484 | return wrapped 485 | end 486 | 487 | function Wrapped:getName() 488 | return "wrapped: "..self.instance:getName() 489 | end 490 | 491 | local wr = Wrapped:create("demo") 492 | return wr:getName() 493 | `) 494 | expect(res).to.be.equal('wrapped: demo') 495 | }) 496 | 497 | it('classes should be a userdata when proxied', async () => { 498 | const engine = await getEngine() 499 | engine.global.set('obj', { TestClass }) 500 | 501 | const testClass = await engine.doString(` 502 | return obj.TestClass 503 | `) 504 | 505 | expect(testClass).to.be.equal(TestClass) 506 | }) 507 | 508 | it('timeout blocking lua program', async () => { 509 | const engine = await getEngine() 510 | engine.global.loadString(` 511 | local i = 0 512 | while true do i = i + 1 end 513 | `) 514 | 515 | await expect(engine.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') 516 | }) 517 | 518 | it('overwrite lib function', async () => { 519 | const engine = await getEngine() 520 | 521 | let output = '' 522 | engine.global.getTable(LuaLibraries.Base, (index) => { 523 | engine.global.setField(index, 'print', (val) => { 524 | // Not a proper print implementation. 525 | output += `${val}\n` 526 | }) 527 | }) 528 | 529 | await engine.doString(` 530 | print("hello") 531 | print("world") 532 | `) 533 | 534 | expect(output).to.be.equal('hello\nworld\n') 535 | }) 536 | 537 | it('inject a userdata with a metatable should succeed', async () => { 538 | const engine = await getEngine() 539 | const obj = decorate( 540 | {}, 541 | { 542 | metatable: { __index: (_, k) => `Hello ${k}!` }, 543 | }, 544 | ) 545 | engine.global.set('obj', obj) 546 | 547 | const res = await engine.doString('return obj.World') 548 | 549 | expect(res).to.be.equal('Hello World!') 550 | }) 551 | 552 | it('a userdata should be collected', async () => { 553 | const engine = await getEngine() 554 | const obj = {} 555 | engine.global.set('obj', obj) 556 | const refIndex = engine.global.lua.getLastRefIndex() 557 | const oldRef = engine.global.lua.getRef(refIndex) 558 | 559 | await engine.doString(` 560 | local weaktable = {} 561 | setmetatable(weaktable, { __mode = "v" }) 562 | table.insert(weaktable, obj) 563 | obj = nil 564 | collectgarbage() 565 | assert(next(weaktable) == nil) 566 | `) 567 | 568 | expect(oldRef).to.be.equal(obj) 569 | const newRef = engine.global.lua.getRef(refIndex) 570 | expect(newRef).to.be.equal(undefined) 571 | }) 572 | 573 | it('environment variables should be set', async () => { 574 | const factory = getFactory({ TEST: 'true' }) 575 | const engine = await factory.createEngine() 576 | 577 | const testEnvVar = await engine.doString(`return os.getenv('TEST')`) 578 | 579 | expect(testEnvVar).to.be.equal('true') 580 | }) 581 | 582 | it('static methods should be callable on classes', async () => { 583 | const engine = await getEngine() 584 | engine.global.set('TestClass', TestClass) 585 | 586 | const testHello = await engine.doString(`return TestClass.hello()`) 587 | 588 | expect(testHello).to.be.equal('world') 589 | }) 590 | 591 | it('should be possible to access function properties', async () => { 592 | const engine = await getEngine() 593 | const testFunction = () => undefined 594 | testFunction.hello = 'world' 595 | engine.global.set('TestFunction', decorateProxy(testFunction, { proxy: true })) 596 | 597 | const testHello = await engine.doString(`return TestFunction.hello`) 598 | 599 | expect(testHello).to.be.equal('world') 600 | }) 601 | 602 | it('throw error includes stack trace', async () => { 603 | const engine = await getEngine() 604 | try { 605 | await engine.doString(` 606 | local function a() 607 | error("function a threw error") 608 | end 609 | local function b() a() end 610 | local function c() b() end 611 | c() 612 | `) 613 | throw new Error('should not be reached') 614 | } catch (err) { 615 | expect(err.message).to.includes('[string "..."]:3: function a threw error') 616 | expect(err.message).to.includes('stack traceback:') 617 | expect(err.message).to.includes(`[string "..."]:3: in upvalue 'a'`) 618 | expect(err.message).to.includes(`[string "..."]:5: in upvalue 'b'`) 619 | expect(err.message).to.includes(`[string "..."]:6: in local 'c'`) 620 | expect(err.message).to.includes(`[string "..."]:7: in main chunk`) 621 | } 622 | }) 623 | 624 | it('should get only the last result on run', async () => { 625 | const engine = await getEngine() 626 | 627 | const a = await engine.doString(`return 1`) 628 | const b = await engine.doString(`return 3`) 629 | const c = engine.doStringSync(`return 2`) 630 | const d = engine.doStringSync(`return 5`) 631 | 632 | expect(a).to.be.equal(1) 633 | expect(b).to.be.equal(3) 634 | expect(c).to.be.equal(2) 635 | expect(d).to.be.equal(5) 636 | }) 637 | 638 | it('should get only the return values on call function', async () => { 639 | const engine = await getEngine() 640 | engine.global.set('hello', (name) => `Hello ${name}!`) 641 | 642 | const a = await engine.doString(`return 1`) 643 | const b = engine.doStringSync(`return 5`) 644 | const values = engine.global.call('hello', 'joao') 645 | 646 | expect(a).to.be.equal(1) 647 | expect(b).to.be.equal(5) 648 | expect(values).to.have.length(1) 649 | expect(values[0]).to.be.equal('Hello joao!') 650 | }) 651 | 652 | it('create a large string variable should succeed', async () => { 653 | const engine = await getEngine() 654 | const str = 'a'.repeat(1000000) 655 | 656 | engine.global.set('str', str) 657 | 658 | const res = await engine.doString('return str') 659 | 660 | expect(res).to.be.equal(str) 661 | }) 662 | 663 | it('execute a large string should succeed', async () => { 664 | const engine = await getEngine() 665 | const str = 'a'.repeat(1000000) 666 | 667 | const res = await engine.doString(`return [[${str}]]`) 668 | 669 | expect(res).to.be.equal(str) 670 | }) 671 | 672 | it('negative integers should be pushed and retrieved as string', async () => { 673 | const engine = await getEngine() 674 | engine.global.set('value', -1) 675 | 676 | const res = await engine.doString(`return tostring(value)`) 677 | 678 | expect(res).to.be.equal('-1') 679 | }) 680 | 681 | it('negative integers should be pushed and retrieved as number', async () => { 682 | const engine = await getEngine() 683 | engine.global.set('value', -1) 684 | 685 | const res = await engine.doString(`return value`) 686 | 687 | expect(res).to.be.equal(-1) 688 | }) 689 | 690 | it('number greater than 32 bit int should be pushed and retrieved as string', async () => { 691 | const engine = await getEngine() 692 | const value = 1689031554550 693 | engine.global.set('value', value) 694 | 695 | const res = await engine.doString(`return tostring(value)`) 696 | 697 | expect(res).to.be.equal(`${String(value)}`) 698 | }) 699 | 700 | it('number greater than 32 bit int should be pushed and retrieved as number', async () => { 701 | const engine = await getEngine() 702 | const value = 1689031554550 703 | engine.global.set('value', value) 704 | 705 | const res = await engine.doString(`return value`) 706 | 707 | expect(res).to.be.equal(value) 708 | }) 709 | 710 | it('number greater than 32 bit int should be usable as a format argument', async () => { 711 | const engine = await getEngine() 712 | const value = 1689031554550 713 | engine.global.set('value', value) 714 | 715 | const res = await engine.doString(`return ("%d"):format(value)`) 716 | 717 | expect(res).to.be.equal('1689031554550') 718 | }) 719 | 720 | it('yielding in a JS callback into Lua does not break lua state', async () => { 721 | // When yielding within a callback the error 'attempt to yield across a C-call boundary'. 722 | // This test just checks that throwing that error still allows the lua global to be 723 | // re-used and doesn't cause JS to abort or some nonsense. 724 | const engine = await getEngine() 725 | const testEmitter = new EventEmitter() 726 | engine.global.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) 727 | const resPromise = engine.doString(` 728 | local res = yield():next(function () 729 | coroutine.yield() 730 | return 15 731 | end) 732 | print("res", res:await()) 733 | `) 734 | 735 | testEmitter.emit('resolve') 736 | await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') 737 | 738 | expect(await engine.doString(`return 42`)).to.equal(42) 739 | }) 740 | 741 | it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { 742 | const engine = await getEngine({ functionTimeout: 10 }) 743 | engine.global.set('promise', Promise.resolve()) 744 | const thread = engine.global.newThread() 745 | thread.loadString(` 746 | promise:next(function () 747 | while true do 748 | -- nothing 749 | end 750 | end):await() 751 | `) 752 | await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') 753 | 754 | expect(await engine.doString(`return 42`)).to.equal(42) 755 | }) 756 | 757 | it('function callback timeout still allows timeout of caller thread', async () => { 758 | const engine = await getEngine() 759 | engine.global.set('promise', Promise.resolve()) 760 | const thread = engine.global.newThread() 761 | thread.loadString(` 762 | promise:next(function () 763 | -- nothing 764 | end):await() 765 | while true do end 766 | `) 767 | await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') 768 | }) 769 | 770 | it('null injected and valid', async () => { 771 | const engine = await getEngine() 772 | engine.global.loadString(` 773 | local args = { ... } 774 | assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) 775 | return null, args[1], tostring(null) 776 | `) 777 | engine.global.pushValue(null) 778 | const res = await engine.global.run(1) 779 | expect(res).to.deep.equal([null, null, 'null']) 780 | }) 781 | 782 | it('null injected as nil', async () => { 783 | const engine = await getEngine({ injectObjects: false }) 784 | engine.global.loadString(` 785 | local args = { ... } 786 | assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) 787 | return nil, args[1], tostring(nil) 788 | `) 789 | engine.global.pushValue(null) 790 | const res = await engine.global.run(1) 791 | expect(res).to.deep.equal([null, null, 'nil']) 792 | }) 793 | 794 | it('Nested callback from JS to Lua', async () => { 795 | const engine = await getEngine() 796 | engine.global.set('call', (fn) => fn()) 797 | const res = await engine.doString(` 798 | return call(function () 799 | return call(function () 800 | return 10 801 | end) 802 | end) 803 | `) 804 | expect(res).to.equal(10) 805 | }) 806 | 807 | it('lots of doString calls should succeed', async () => { 808 | const engine = await getEngine() 809 | const length = 10000; 810 | 811 | for (let i = 0; i < length; i++) { 812 | const a = Math.floor(Math.random() * 100); 813 | const b = Math.floor(Math.random() * 100); 814 | const result = await engine.doString(`return ${a} + ${b};`); 815 | expect(result).to.equal(a + b); 816 | } 817 | }) 818 | }) 819 | -------------------------------------------------------------------------------- /test/filesystem.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { getEngine, getFactory } from './utils.js' 3 | 4 | describe('Filesystem', () => { 5 | it('mount a file and require inside lua should succeed', async () => { 6 | const factory = getFactory() 7 | await factory.mountFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') 8 | const engine = await factory.createEngine() 9 | 10 | await engine.doString('require("test")') 11 | 12 | expect(engine.global.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) 13 | }) 14 | 15 | it('mount a file in a complex directory and require inside lua should succeed', async () => { 16 | const factory = getFactory() 17 | await factory.mountFile('yolo/sofancy/test.lua', 'return 42') 18 | const engine = await factory.createEngine() 19 | 20 | const value = await engine.doString('return require("yolo/sofancy/test")') 21 | 22 | expect(value).to.be.equal(42) 23 | }) 24 | 25 | it('mount a init file and require the module inside lua should succeed', async () => { 26 | const factory = getFactory() 27 | await factory.mountFile('hello/init.lua', 'return 42') 28 | const engine = await factory.createEngine() 29 | 30 | const value = await engine.doString('return require("hello")') 31 | 32 | expect(value).to.be.equal(42) 33 | }) 34 | 35 | it('require a file which is not mounted should throw', async () => { 36 | const engine = await getEngine() 37 | 38 | await expect(engine.doString('require("nothing")')).to.eventually.be.rejected 39 | }) 40 | 41 | it('mount a file and run it should succeed', async () => { 42 | const factory = getFactory() 43 | const engine = await factory.createEngine() 44 | 45 | await factory.mountFile('init.lua', `return 42`) 46 | const value = await engine.doFile('init.lua') 47 | 48 | expect(value).to.be.equal(42) 49 | }) 50 | 51 | it('run a file which is not mounted should throw', async () => { 52 | const engine = await getEngine() 53 | 54 | await expect(engine.doFile('init.lua')).to.eventually.be.rejected 55 | }) 56 | 57 | it('mount a file with a large content should succeed', async () => { 58 | const factory = getFactory() 59 | const engine = await factory.createEngine() 60 | 61 | const content = 'a'.repeat(1000000) 62 | await factory.mountFile('init.lua', `local a = "${content}" return a`) 63 | const value = await engine.doFile('init.lua') 64 | 65 | expect(value).to.be.equal(content) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/initialization.test.js: -------------------------------------------------------------------------------- 1 | import { LuaFactory } from '../dist/index.js' 2 | import { expect } from 'chai' 3 | 4 | describe('Initialization', () => { 5 | it('create engine should succeed', async () => { 6 | await new LuaFactory().createEngine() 7 | }) 8 | 9 | it('create engine with options should succeed', async () => { 10 | await new LuaFactory().createEngine({ 11 | enableProxy: true, 12 | injectObjects: true, 13 | openStandardLibs: true, 14 | traceAllocations: true, 15 | }) 16 | }) 17 | 18 | it('create with environment variables should succeed', async () => { 19 | const env = { 20 | ENV_TEST: 'test', 21 | } 22 | const engine = await new LuaFactory(undefined, env).createEngine() 23 | 24 | const value = await engine.doString('return os.getenv("ENV_TEST")') 25 | 26 | expect(value).to.be.equal(env.ENV_TEST) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/luatests.js: -------------------------------------------------------------------------------- 1 | import { LuaFactory } from '../dist/index.js' 2 | import { fileURLToPath } from 'node:url' 3 | import { readFile, readdir } from 'node:fs/promises' 4 | import { resolve } from 'node:path' 5 | 6 | async function* walk(dir) { 7 | const dirents = await readdir(dir, { withFileTypes: true }) 8 | for (const dirent of dirents) { 9 | const res = resolve(dir, dirent.name) 10 | if (dirent.isDirectory()) { 11 | yield* walk(res) 12 | } else { 13 | yield res 14 | } 15 | } 16 | } 17 | 18 | const factory = new LuaFactory() 19 | const testsPath = import.meta.resolve('../lua/testes') 20 | const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) 21 | 22 | for await (const file of walk(filePath)) { 23 | const relativeFile = file.replace(`${filePath}/`, '') 24 | await factory.mountFile(relativeFile, await readFile(file)) 25 | } 26 | 27 | const lua = await factory.createEngine() 28 | const luamodule = await factory.getLuaModule() 29 | luamodule.lua_warning(lua.global.address, '@on', 0) 30 | lua.global.set('arg', ['lua', 'all.lua']) 31 | lua.global.set('_port', true) 32 | lua.global.getTable('os', (i) => { 33 | lua.global.setField(i, 'setlocale', (locale) => { 34 | return locale && locale !== 'C' ? false : 'C' 35 | }) 36 | }) 37 | lua.doFileSync('all.lua') 38 | -------------------------------------------------------------------------------- /test/promises.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { getEngine, tick } from './utils.js' 3 | import { mock } from 'node:test' 4 | 5 | describe('Promises', () => { 6 | it('use promise next should succeed', async () => { 7 | const engine = await getEngine() 8 | const check = mock.fn() 9 | engine.global.set('check', check) 10 | const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) 11 | engine.global.set('promise', promise) 12 | 13 | const res = engine.doString(` 14 | promise:next(check) 15 | `) 16 | 17 | expect(check.mock.calls.length).to.be.equal(0) 18 | await promise 19 | await res 20 | expect(check.mock.calls[0].arguments).to.be.eql([60]) 21 | }) 22 | 23 | it('chain promises with next should succeed', async () => { 24 | const engine = await getEngine() 25 | const check = mock.fn() 26 | engine.global.set('check', check) 27 | const promise = new Promise((resolve) => resolve(60)) 28 | engine.global.set('promise', promise) 29 | 30 | const res = engine.doString(` 31 | promise:next(function(value) 32 | return value * 2 33 | end):next(check):next(check) 34 | `) 35 | 36 | await promise 37 | await tick() 38 | await res 39 | 40 | expect(check.mock.calls[0].arguments).to.be.eql([120]) 41 | expect(check.mock.calls.length).to.be.equal(2) 42 | }) 43 | 44 | it('call an async function should succeed', async () => { 45 | const engine = await getEngine() 46 | engine.global.set('asyncFunction', async () => Promise.resolve(60)) 47 | const check = mock.fn() 48 | engine.global.set('check', check) 49 | 50 | const res = engine.doString(` 51 | asyncFunction():next(check) 52 | `) 53 | 54 | await tick() 55 | await res 56 | expect(check.mock.calls[0].arguments).to.be.eql([60]) 57 | }) 58 | 59 | it('return an async function should succeed', async () => { 60 | const engine = await getEngine() 61 | engine.global.set('asyncFunction', async () => Promise.resolve(60)) 62 | 63 | const asyncFunction = await engine.doString(` 64 | return asyncFunction 65 | `) 66 | const value = await asyncFunction() 67 | 68 | expect(value).to.be.equal(60) 69 | }) 70 | 71 | it('return a chained promise should succeed', async () => { 72 | const engine = await getEngine() 73 | engine.global.set('asyncFunction', async () => Promise.resolve(60)) 74 | 75 | const asyncFunction = await engine.doString(` 76 | return asyncFunction():next(function(x) return x * 2 end) 77 | `) 78 | const value = await asyncFunction 79 | 80 | expect(value).to.be.equal(120) 81 | }) 82 | 83 | it('await an promise inside coroutine should succeed', async () => { 84 | const engine = await getEngine() 85 | const check = mock.fn() 86 | engine.global.set('check', check) 87 | const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) 88 | engine.global.set('promise', promise) 89 | 90 | const res = engine.doString(` 91 | local co = coroutine.create(function() 92 | local value = promise:await() 93 | check(value) 94 | end) 95 | 96 | while coroutine.status(co) == "suspended" do 97 | local success, res = coroutine.resume(co) 98 | -- yield to allow promises to resolve 99 | -- this yields on the promise returned by the above 100 | coroutine.yield(res) 101 | end 102 | `) 103 | 104 | expect(check.mock.calls.length).to.be.equal(0) 105 | await promise 106 | await res 107 | expect(check.mock.calls[0].arguments).to.be.eql([60]) 108 | }) 109 | 110 | it('awaited coroutines should ignore resume until it resolves the promise', async () => { 111 | const engine = await getEngine() 112 | const check = mock.fn() 113 | engine.global.set('check', check) 114 | const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) 115 | engine.global.set('promise', promise) 116 | 117 | const res = engine.doString(` 118 | local co = coroutine.create(function() 119 | local value = promise:await() 120 | check(value) 121 | end) 122 | while coroutine.status(co) == "suspended" do 123 | coroutine.resume(co) 124 | -- yields for a tick 125 | coroutine.yield() 126 | end 127 | `) 128 | 129 | expect(check.mock.calls.length).to.be.equal(0) 130 | await promise 131 | await res 132 | expect(check.mock.calls[0].arguments).to.be.eql([60]) 133 | }) 134 | 135 | it('await a thread run with async calls should succeed', async () => { 136 | const engine = await getEngine() 137 | engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) 138 | const asyncThread = engine.global.newThread() 139 | 140 | asyncThread.loadString(` 141 | sleep(1):await() 142 | return 50 143 | `) 144 | 145 | const asyncFunctionPromise = asyncThread.run() 146 | expect(await asyncFunctionPromise).to.be.eql([50]) 147 | }) 148 | 149 | it('run thread with async calls and yields should succeed', async () => { 150 | const engine = await getEngine() 151 | engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) 152 | const asyncThread = engine.global.newThread() 153 | 154 | asyncThread.loadString(` 155 | coroutine.yield() 156 | sleep(1):await() 157 | coroutine.yield() 158 | return 50 159 | `) 160 | 161 | const asyncFunctionPromise = asyncThread.run() 162 | // Wait 1 tick for the initial yield 163 | await tick() 164 | expect(await asyncFunctionPromise).to.be.eql([50]) 165 | }) 166 | 167 | it('reject a promise should succeed', async () => { 168 | const engine = await getEngine() 169 | engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) 170 | const asyncThread = engine.global.newThread() 171 | 172 | asyncThread.loadString(` 173 | throw():await() 174 | error("this should not be reached") 175 | `) 176 | 177 | await expect(asyncThread.run()).to.eventually.rejectedWith('expected test error') 178 | }) 179 | 180 | it('pcall a promise await should succeed', async () => { 181 | const engine = await getEngine() 182 | engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) 183 | const asyncThread = engine.global.newThread() 184 | 185 | asyncThread.loadString(` 186 | local succeed, err = pcall(function() throw():await() end) 187 | assert(tostring(err) == "Error: expected test error") 188 | return succeed 189 | `) 190 | 191 | expect(await asyncThread.run()).to.be.eql([false]) 192 | }) 193 | 194 | it('catch a promise rejection should succeed', async () => { 195 | const engine = await getEngine() 196 | const fulfilled = mock.fn() 197 | const rejected = mock.fn() 198 | engine.global.set('handlers', { fulfilled, rejected }) 199 | engine.global.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) 200 | 201 | const res = engine.doString(` 202 | throw:next(handlers.fulfilled, handlers.rejected):catch(function() end) 203 | `) 204 | 205 | await tick() 206 | await res 207 | expect(fulfilled.mock.calls.length).to.be.equal(0) 208 | expect(rejected.mock.calls.length).to.be.equal(1) 209 | }) 210 | 211 | it('run with async callback', async () => { 212 | const engine = await getEngine() 213 | const thread = engine.global.newThread() 214 | 215 | engine.global.set('asyncCallback', async (input) => { 216 | return Promise.resolve(input * 2) 217 | }) 218 | 219 | thread.loadString(` 220 | local input = ... 221 | assert(type(input) == "number") 222 | assert(type(asyncCallback) == "function") 223 | local result1 = asyncCallback(input):await() 224 | local result2 = asyncCallback(result1):await() 225 | return result2 226 | `) 227 | 228 | thread.pushValue(3) 229 | const [finalValue] = await thread.run(1) 230 | 231 | expect(finalValue).to.be.equal(12) 232 | }) 233 | 234 | it('promise creation from js', async () => { 235 | const engine = await getEngine() 236 | const res = await engine.doString(` 237 | local promise = Promise.create(function (resolve) 238 | resolve(10) 239 | end) 240 | local nested = promise:next(function (value) 241 | return Promise.create(function (resolve2) 242 | resolve2(value * 2) 243 | end) 244 | end) 245 | return nested:await() 246 | `) 247 | expect(res).to.be.equal(20) 248 | }) 249 | 250 | it('reject promise creation from js', async () => { 251 | const engine = await getEngine() 252 | const res = await engine.doString(` 253 | local rejection = Promise.create(function (resolve, reject) 254 | reject("expected rejection") 255 | end) 256 | return rejection:catch(function (err) 257 | return err 258 | end):await() 259 | `) 260 | expect(res).to.equal('expected rejection') 261 | }) 262 | 263 | it('resolve multiple promises with promise.all', async () => { 264 | const engine = await getEngine() 265 | engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) 266 | const resPromise = engine.doString(` 267 | local promises = {} 268 | for i = 1, 10 do 269 | table.insert(promises, sleep(5):next(function () 270 | return i 271 | end)) 272 | end 273 | return Promise.all(promises):await() 274 | `) 275 | const res = await resPromise 276 | 277 | expect(res).to.be.eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 278 | }) 279 | 280 | it('error in promise next catchable', async () => { 281 | const engine = await getEngine() 282 | engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) 283 | const resPromise = engine.doString(` 284 | return sleep(1):next(function () 285 | error("sleep done") 286 | end):await() 287 | `) 288 | await expect(resPromise).eventually.to.be.rejectedWith('[string "..."]:3: sleep done') 289 | }) 290 | 291 | it('should not be possible to await in synchronous run', async () => { 292 | const engine = await getEngine() 293 | engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) 294 | 295 | expect(() => { 296 | engine.doStringSync(`sleep(5):await()`) 297 | }).to.throw('cannot await in the main thread') 298 | }) 299 | }) 300 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import { LuaFactory } from '../dist/index.js' 2 | 3 | export const getFactory = (env) => { 4 | return new LuaFactory(undefined, env) 5 | } 6 | 7 | export const getEngine = (config = {}) => { 8 | return new LuaFactory().createEngine({ 9 | injectObjects: true, 10 | ...config, 11 | }) 12 | } 13 | 14 | // Used to make the event loop cycle 15 | export const tick = () => { 16 | return new Promise((resolve) => setImmediate(resolve)) 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": false, 4 | "moduleResolution": "node", 5 | "inlineSources": false, 6 | "removeComments": false, 7 | "sourceMap": false, 8 | "target": "ES2018", 9 | "skipLibCheck": true, 10 | "lib": ["ESNEXT", "DOM"], 11 | "forceConsistentCasingInFileNames": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noImplicitAny": true, 15 | "noUnusedParameters": true, 16 | "noUnusedLocals": true, 17 | "importHelpers": true, 18 | "strict": true, 19 | "resolveJsonModule": true, 20 | 21 | }, 22 | "include": ["src/**/*", "test/**/*", "bench/**/*", "eslint.config.js"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /utils/create-bindings.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const readline = require('readline') 4 | const fs = require('fs') 5 | 6 | const rl = readline.createInterface({ 7 | input: process.stdin, 8 | terminal: false, 9 | }) 10 | 11 | String.prototype.replaceAll = function (str, newStr) { 12 | return this.split(str).join(newStr) 13 | } 14 | 15 | const allFunctions = [] 16 | const types = [] 17 | 18 | const functionPointerTypes = ['lua_KFunction', 'lua_Reader', 'lua_Writer', 'lua_Alloc', 'lua_Hook', 'lua_WarnFunction'] 19 | 20 | const bindingTypes = { 21 | 'void': null, 22 | 'lua_State*': 'number', 23 | 'lua_Number': 'number', 24 | 'size_t': 'number', 25 | 'int': 'number', 26 | 'char*': 'string', 27 | 'size_t*': 'number', 28 | 'lua_Integer': 'number', 29 | 'void*': 'number', 30 | 'luaL_Buffer*': 'number', 31 | 'luaL_Reg*': 'number', 32 | 'lua_CFunction': 'number', 33 | 'int*': 'number', 34 | 'lua_Unsigned': 'number', 35 | 'lua_KContext': 'number', 36 | 'lua_Debug*': 'number', 37 | } 38 | 39 | const typescriptTypes = { 40 | 'void': 'void', 41 | 'lua_State*': 'LuaState', 42 | 'lua_Number': 'number', 43 | 'size_t': 'number', 44 | 'int': 'number', 45 | 'char*': 'string', 46 | 'size_t*': 'number', 47 | 'lua_Integer': 'number', 48 | 'void*': 'number', 49 | 'luaL_Buffer*': 'number', 50 | 'luaL_Reg*': 'number', 51 | 'lua_CFunction': 'number', 52 | 'int*': 'number', 53 | 'lua_Unsigned': 'number', 54 | 'lua_KContext': 'number', 55 | 'lua_Debug*': 'number', 56 | } 57 | 58 | const functionsThatReturnType = [ 59 | 'lua_getfield', 60 | 'lua_getglobal', 61 | 'lua_geti', 62 | 'lua_gettable', 63 | 'lua_getiuservalue', 64 | 'lua_rawgeti', 65 | 'lua_rawgetp', 66 | 'lua_type', 67 | 'luaL_getmetafield', 68 | 'luaL_getmetatable', 69 | ] 70 | 71 | const functionsThatReturnState = [ 72 | 'lua_load', 73 | 'lua_pcall', 74 | 'lua_resetthread', 75 | 'lua_resume', 76 | 'lua_status', 77 | 'luaL_dofile', 78 | 'luaL_dostring', 79 | 'luaL_loadbuffer', 80 | 'luaL_loadbufferx', 81 | 'luaL_loadfile', 82 | 'luaL_loadfilex', 83 | 'luaL_loadstring', 84 | ] 85 | 86 | const bannedFunctions = [ 87 | // Not a defined function in our builds. 88 | 'debug_realloc', 89 | // Accepts string array 90 | 'luaL_checkoption', 91 | 'luaB_opentests', 92 | ] 93 | 94 | function mapTsType(type, name) { 95 | if (name && functionsThatReturnType.includes(name)) { 96 | return 'LuaType' 97 | } 98 | if (name && functionsThatReturnState.includes(name)) { 99 | return 'LuaReturn' 100 | } 101 | const mapped = typescriptTypes[type] 102 | if (mapped === undefined) { 103 | throw new Error('missing ts mapping') 104 | } 105 | if (type.endsWith('*') && mapped !== 'LuaState' && !name) { 106 | // Pointers can be null, cast to nullptr. 107 | return `${mapped} | null` 108 | } 109 | return mapped 110 | } 111 | 112 | function parseNamedSymbol(symbol) { 113 | const isPointer = symbol.includes('*') 114 | 115 | const [type, name] = symbol 116 | .replaceAll('const', '') 117 | .replaceAll('unsigned', '') 118 | .replaceAll('*', '') 119 | .split(' ') 120 | .map((val) => val.trim()) 121 | .filter((val) => !!val) 122 | if (functionPointerTypes.includes(type)) { 123 | return { type: 'void*', name } 124 | } 125 | const saneType = isPointer ? `${type}*` : type 126 | types.push(saneType) 127 | 128 | return { type: saneType, name } 129 | } 130 | 131 | function parseLine(line) { 132 | const argStart = line.lastIndexOf('(') 133 | if (argStart < 0) { 134 | console.warn('Cannot find parameters on line', line) 135 | return undefined 136 | } 137 | const starter = line.substring(0, argStart) 138 | const retPlusSymbol = starter.substring(line.indexOf(' ')).replaceAll('(', ' ').replaceAll(')', '') 139 | const rawArgs = line.substring(argStart).substring(1).split(')')[0].split(',') 140 | return { 141 | definition: parseNamedSymbol(retPlusSymbol), 142 | args: rawArgs.map((arg) => parseNamedSymbol(arg)).filter((arg) => arg.type !== 'void'), 143 | } 144 | } 145 | 146 | rl.on('line', (file) => { 147 | const rawFile = fs.readFileSync(file).toString() 148 | 149 | const statements = rawFile 150 | .replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') 151 | .replaceAll('\\\n', '') 152 | .split('\n') 153 | .filter((line) => !line.trim().startsWith('#')) 154 | .join('\n') 155 | .split(';') 156 | .map((statement) => statement.trim()) 157 | const apiStatements = statements 158 | .filter((statement) => { 159 | const starter = statement.trim().split(' ')[0] 160 | return starter.includes('_API') 161 | }) 162 | .map((statement) => statement.replaceAll('\n', '')) 163 | allFunctions.push(...apiStatements.map((statement) => parseLine(statement)).filter((statement) => !!statement)) 164 | }) 165 | 166 | rl.on('close', () => { 167 | const bindings = allFunctions 168 | .map((fn) => { 169 | try { 170 | if (bannedFunctions.includes(fn.definition.name)) { 171 | throw new Error('skipping banned function') 172 | } 173 | const argArray = fn.args.map((arg) => { 174 | const type = bindingTypes[arg.type] 175 | if (type === undefined) { 176 | throw new Error('missing binding type') 177 | } 178 | if (!arg.name) { 179 | throw new Error('missing argument name') 180 | } 181 | return type === null ? null : `'${type}'` 182 | }) 183 | const returnType = bindingTypes[fn.definition.type] 184 | if (returnType === undefined) { 185 | throw new Error('missing binding return type') 186 | } 187 | const quotedReturn = returnType === null ? null : `'${returnType}'` 188 | const binding = `this.${fn.definition.name} = this.module.cwrap('${fn.definition.name}', ${quotedReturn}, [${argArray.join( 189 | ', ', 190 | )}])` 191 | 192 | // public lua_newstate: (allocatorFunction: number, userData: number | null) => LuaState 193 | const tsParams = fn.args.map((arg) => { 194 | let mapped = mapTsType(arg.type) 195 | if (fn.definition.name === 'lua_resume' && arg.name === 'from') { 196 | mapped = `${mapped} | null` 197 | } 198 | return `${arg.name}: ${mapped}` 199 | }) 200 | const tsReturn = mapTsType(fn.definition.type, fn.definition.name) 201 | const header = `public ${fn.definition.name}: (${tsParams.join(', ')}) => ${tsReturn}` 202 | 203 | const sh = ` '_${fn.definition.name}', \\` 204 | 205 | return { binding, header, sh } 206 | } catch (err) { 207 | console.warn(err.message, fn) 208 | return undefined 209 | } 210 | }) 211 | .filter((val) => !!val) 212 | 213 | console.log(bindings.map((binding) => binding.binding).join('\n')) 214 | console.log('\n\n') 215 | console.log(bindings.map((binding) => binding.header).join('\n')) 216 | console.log('\n\n') 217 | console.log(bindings.map((binding) => binding.sh).join('\n')) 218 | 219 | console.log(Array.from(new Set(types))) 220 | }) 221 | -------------------------------------------------------------------------------- /utils/create-bindings.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | find ../lua/ -name "*.h" | ./create-bindings.js 4 | --------------------------------------------------------------------------------